From f5b627256fc0d9743bf858ee2c500cd1e1a09ffc Mon Sep 17 00:00:00 2001 From: Talal Sharabi Date: Sat, 14 Mar 2026 16:46:46 +0400 Subject: [PATCH] EnCoach Odoo 19 custom modules Full backend implementation with custom Odoo modules: - encoach_api: Core API, user management, JWT auth - encoach_exam: Exam generation (reading, writing, listening, speaking) - encoach_evaluate: AI-powered evaluation (writing, speaking) - encoach_training: Training tips and walkthrough - encoach_storage: File storage management - encoach_payment: Stripe, PayPal, Paymob integration - encoach_mail: Email notifications Made-with: Cursor --- .gitignore | 16 + Archive.zip | Bin 0 -> 517729 bytes DEVELOPER_FEEDBACK.md | 249 +++ ENCOACH_AI_KEYS.md | 158 ++ ENCOACH_SUMMARY.md | 345 ++++ EnCoach_API.postman_collection.json | 1692 +++++++++++++++++ ODOO_MIGRATION_SRS_v2.md | 1292 +++++++++++++ README.md | 5 + encoach_ai/__init__.py | 2 + encoach_ai/__manifest__.py | 16 + encoach_ai/models/__init__.py | 1 + encoach_ai/models/constants.py | 59 + encoach_ai/security/ir.model.access.csv | 1 + encoach_ai/services/__init__.py | 1 + encoach_ai/services/openai_service.py | 103 + encoach_ai_generation/__init__.py | 1 + encoach_ai_generation/__manifest__.py | 13 + .../security/ir.model.access.csv | 1 + encoach_ai_generation/services/__init__.py | 1 + .../services/generation_service.py | 352 ++++ encoach_ai_grading/__init__.py | 1 + encoach_ai_grading/__manifest__.py | 13 + .../security/ir.model.access.csv | 1 + encoach_ai_grading/services/__init__.py | 1 + .../services/grading_service.py | 340 ++++ encoach_ai_media/__init__.py | 2 + encoach_ai_media/__manifest__.py | 17 + encoach_ai_media/models/__init__.py | 1 + encoach_ai_media/models/elai_avatar.py | 30 + encoach_ai_media/security/ir.model.access.csv | 4 + encoach_ai_media/services/__init__.py | 3 + encoach_ai_media/services/elai_service.py | 107 ++ encoach_ai_media/services/polly_service.py | 129 ++ encoach_ai_media/services/whisper_service.py | 167 ++ encoach_ai_media/views/avatar_views.xml | 49 + encoach_api/__init__.py | 1 + encoach_api/__manifest__.py | 29 + encoach_api/controllers/__init__.py | 20 + encoach_api/controllers/approvals.py | 89 + encoach_api/controllers/assignments.py | 235 +++ encoach_api/controllers/auth.py | 110 ++ encoach_api/controllers/base.py | 73 + encoach_api/controllers/classrooms.py | 122 ++ encoach_api/controllers/discounts.py | 88 + encoach_api/controllers/entities.py | 175 ++ encoach_api/controllers/evaluations.py | 293 +++ encoach_api/controllers/exams.py | 220 +++ encoach_api/controllers/generation.py | 347 ++++ encoach_api/controllers/grading.py | 65 + encoach_api/controllers/media.py | 213 +++ encoach_api/controllers/registration.py | 149 ++ encoach_api/controllers/sessions.py | 73 + encoach_api/controllers/stats.py | 137 ++ encoach_api/controllers/storage.py | 82 + encoach_api/controllers/subscriptions.py | 192 ++ encoach_api/controllers/tickets.py | 128 ++ encoach_api/controllers/training.py | 117 ++ encoach_api/controllers/users.py | 185 ++ encoach_api/security/ir.model.access.csv | 1 + encoach_api/tests/__init__.py | 1 + encoach_api/tests/test_endpoints.py | 483 +++++ encoach_assignment/__init__.py | 1 + encoach_assignment/__manifest__.py | 14 + encoach_assignment/models/__init__.py | 1 + encoach_assignment/models/assignment.py | 87 + .../security/ir.model.access.csv | 5 + encoach_assignment/security/rules.xml | 17 + encoach_assignment/views/assignment_views.xml | 91 + encoach_classroom/__init__.py | 1 + encoach_classroom/__manifest__.py | 13 + encoach_classroom/models/__init__.py | 1 + encoach_classroom/models/group.py | 36 + .../security/ir.model.access.csv | 5 + encoach_classroom/views/group_views.xml | 66 + encoach_core/__init__.py | 1 + encoach_core/__manifest__.py | 25 + encoach_core/data/assign_admin_group.xml | 8 + encoach_core/data/constants.xml | 56 + encoach_core/models/__init__.py | 7 + encoach_core/models/code.py | 57 + encoach_core/models/entity.py | 37 + encoach_core/models/invite.py | 46 + encoach_core/models/permission.py | 45 + encoach_core/models/res_users.py | 92 + encoach_core/models/role.py | 20 + encoach_core/models/user_entity_rel.py | 15 + encoach_core/scripts/import_avatars.py | 69 + encoach_core/scripts/migrate_firebase_auth.py | 98 + encoach_core/scripts/migrate_mongodb.py | 731 +++++++ encoach_core/scripts/rebuild_faiss.py | 90 + encoach_core/security/ir.model.access.csv | 21 + encoach_core/security/security.xml | 30 + encoach_core/views/code_views.xml | 65 + encoach_core/views/entity_views.xml | 82 + encoach_core/views/invite_views.xml | 59 + encoach_core/views/menu.xml | 13 + encoach_core/views/permission_views.xml | 49 + encoach_core/views/res_users_views.xml | 39 + encoach_core/views/role_views.xml | 50 + encoach_core/views/user_entity_rel_views.xml | 50 + encoach_evaluation/__init__.py | 1 + encoach_evaluation/__manifest__.py | 16 + encoach_evaluation/models/__init__.py | 2 + encoach_evaluation/models/ai_job.py | 34 + encoach_evaluation/models/evaluation.py | 45 + .../security/ir.model.access.csv | 6 + encoach_evaluation/security/rules.xml | 23 + encoach_evaluation/views/ai_job_views.xml | 55 + encoach_evaluation/views/evaluation_views.xml | 50 + encoach_exam/__init__.py | 1 + encoach_exam/__manifest__.py | 15 + encoach_exam/models/__init__.py | 2 + encoach_exam/models/approval_workflow.py | 58 + encoach_exam/models/exam.py | 77 + encoach_exam/security/ir.model.access.csv | 9 + encoach_exam/security/rules.xml | 17 + encoach_exam/views/approval_views.xml | 71 + encoach_exam/views/exam_views.xml | 87 + encoach_registration/__init__.py | 1 + encoach_registration/__manifest__.py | 13 + encoach_registration/models/__init__.py | 1 + encoach_registration/models/registration.py | 132 ++ .../security/ir.model.access.csv | 1 + encoach_stats/__init__.py | 1 + encoach_stats/__manifest__.py | 15 + encoach_stats/models/__init__.py | 2 + encoach_stats/models/session.py | 21 + encoach_stats/models/stat.py | 67 + encoach_stats/security/ir.model.access.csv | 9 + encoach_stats/security/rules.xml | 29 + encoach_stats/views/session_views.xml | 43 + encoach_stats/views/stat_views.xml | 67 + encoach_subscription/__init__.py | 2 + encoach_subscription/__manifest__.py | 16 + encoach_subscription/models/__init__.py | 3 + encoach_subscription/models/discount.py | 24 + encoach_subscription/models/package.py | 25 + .../models/subscription_payment.py | 35 + .../security/ir.model.access.csv | 10 + encoach_subscription/services/__init__.py | 4 + .../services/paymob_service.py | 170 ++ .../services/paypal_service.py | 128 ++ .../services/stripe_service.py | 133 ++ .../services/subscription_helper.py | 52 + encoach_subscription/views/discount_views.xml | 58 + encoach_subscription/views/package_views.xml | 61 + encoach_subscription/views/payment_views.xml | 65 + encoach_ticket/__init__.py | 1 + encoach_ticket/__manifest__.py | 15 + encoach_ticket/models/__init__.py | 1 + encoach_ticket/models/ticket.py | 53 + encoach_ticket/security/ir.model.access.csv | 5 + encoach_ticket/security/rules.xml | 17 + encoach_ticket/views/ticket_views.xml | 74 + encoach_training/__init__.py | 2 + encoach_training/__manifest__.py | 17 + encoach_training/models/__init__.py | 3 + encoach_training/models/training.py | 28 + encoach_training/models/training_tip.py | 22 + encoach_training/models/walkthrough.py | 19 + encoach_training/security/ir.model.access.csv | 10 + encoach_training/security/rules.xml | 17 + encoach_training/services/__init__.py | 1 + encoach_training/services/training_service.py | 177 ++ encoach_training/views/tip_views.xml | 54 + encoach_training/views/training_views.xml | 65 + encoach_training/views/walkthrough_views.xml | 53 + requirements.txt | 43 + 168 files changed, 13428 insertions(+) create mode 100644 .gitignore create mode 100644 Archive.zip create mode 100644 DEVELOPER_FEEDBACK.md create mode 100644 ENCOACH_AI_KEYS.md create mode 100644 ENCOACH_SUMMARY.md create mode 100644 EnCoach_API.postman_collection.json create mode 100644 ODOO_MIGRATION_SRS_v2.md create mode 100644 README.md create mode 100644 encoach_ai/__init__.py create mode 100644 encoach_ai/__manifest__.py create mode 100644 encoach_ai/models/__init__.py create mode 100644 encoach_ai/models/constants.py create mode 100644 encoach_ai/security/ir.model.access.csv create mode 100644 encoach_ai/services/__init__.py create mode 100644 encoach_ai/services/openai_service.py create mode 100644 encoach_ai_generation/__init__.py create mode 100644 encoach_ai_generation/__manifest__.py create mode 100644 encoach_ai_generation/security/ir.model.access.csv create mode 100644 encoach_ai_generation/services/__init__.py create mode 100644 encoach_ai_generation/services/generation_service.py create mode 100644 encoach_ai_grading/__init__.py create mode 100644 encoach_ai_grading/__manifest__.py create mode 100644 encoach_ai_grading/security/ir.model.access.csv create mode 100644 encoach_ai_grading/services/__init__.py create mode 100644 encoach_ai_grading/services/grading_service.py create mode 100644 encoach_ai_media/__init__.py create mode 100644 encoach_ai_media/__manifest__.py create mode 100644 encoach_ai_media/models/__init__.py create mode 100644 encoach_ai_media/models/elai_avatar.py create mode 100644 encoach_ai_media/security/ir.model.access.csv create mode 100644 encoach_ai_media/services/__init__.py create mode 100644 encoach_ai_media/services/elai_service.py create mode 100644 encoach_ai_media/services/polly_service.py create mode 100644 encoach_ai_media/services/whisper_service.py create mode 100644 encoach_ai_media/views/avatar_views.xml create mode 100644 encoach_api/__init__.py create mode 100644 encoach_api/__manifest__.py create mode 100644 encoach_api/controllers/__init__.py create mode 100644 encoach_api/controllers/approvals.py create mode 100644 encoach_api/controllers/assignments.py create mode 100644 encoach_api/controllers/auth.py create mode 100644 encoach_api/controllers/base.py create mode 100644 encoach_api/controllers/classrooms.py create mode 100644 encoach_api/controllers/discounts.py create mode 100644 encoach_api/controllers/entities.py create mode 100644 encoach_api/controllers/evaluations.py create mode 100644 encoach_api/controllers/exams.py create mode 100644 encoach_api/controllers/generation.py create mode 100644 encoach_api/controllers/grading.py create mode 100644 encoach_api/controllers/media.py create mode 100644 encoach_api/controllers/registration.py create mode 100644 encoach_api/controllers/sessions.py create mode 100644 encoach_api/controllers/stats.py create mode 100644 encoach_api/controllers/storage.py create mode 100644 encoach_api/controllers/subscriptions.py create mode 100644 encoach_api/controllers/tickets.py create mode 100644 encoach_api/controllers/training.py create mode 100644 encoach_api/controllers/users.py create mode 100644 encoach_api/security/ir.model.access.csv create mode 100644 encoach_api/tests/__init__.py create mode 100644 encoach_api/tests/test_endpoints.py create mode 100644 encoach_assignment/__init__.py create mode 100644 encoach_assignment/__manifest__.py create mode 100644 encoach_assignment/models/__init__.py create mode 100644 encoach_assignment/models/assignment.py create mode 100644 encoach_assignment/security/ir.model.access.csv create mode 100644 encoach_assignment/security/rules.xml create mode 100644 encoach_assignment/views/assignment_views.xml create mode 100644 encoach_classroom/__init__.py create mode 100644 encoach_classroom/__manifest__.py create mode 100644 encoach_classroom/models/__init__.py create mode 100644 encoach_classroom/models/group.py create mode 100644 encoach_classroom/security/ir.model.access.csv create mode 100644 encoach_classroom/views/group_views.xml create mode 100644 encoach_core/__init__.py create mode 100644 encoach_core/__manifest__.py create mode 100644 encoach_core/data/assign_admin_group.xml create mode 100644 encoach_core/data/constants.xml create mode 100644 encoach_core/models/__init__.py create mode 100644 encoach_core/models/code.py create mode 100644 encoach_core/models/entity.py create mode 100644 encoach_core/models/invite.py create mode 100644 encoach_core/models/permission.py create mode 100644 encoach_core/models/res_users.py create mode 100644 encoach_core/models/role.py create mode 100644 encoach_core/models/user_entity_rel.py create mode 100644 encoach_core/scripts/import_avatars.py create mode 100644 encoach_core/scripts/migrate_firebase_auth.py create mode 100644 encoach_core/scripts/migrate_mongodb.py create mode 100644 encoach_core/scripts/rebuild_faiss.py create mode 100644 encoach_core/security/ir.model.access.csv create mode 100644 encoach_core/security/security.xml create mode 100644 encoach_core/views/code_views.xml create mode 100644 encoach_core/views/entity_views.xml create mode 100644 encoach_core/views/invite_views.xml create mode 100644 encoach_core/views/menu.xml create mode 100644 encoach_core/views/permission_views.xml create mode 100644 encoach_core/views/res_users_views.xml create mode 100644 encoach_core/views/role_views.xml create mode 100644 encoach_core/views/user_entity_rel_views.xml create mode 100644 encoach_evaluation/__init__.py create mode 100644 encoach_evaluation/__manifest__.py create mode 100644 encoach_evaluation/models/__init__.py create mode 100644 encoach_evaluation/models/ai_job.py create mode 100644 encoach_evaluation/models/evaluation.py create mode 100644 encoach_evaluation/security/ir.model.access.csv create mode 100644 encoach_evaluation/security/rules.xml create mode 100644 encoach_evaluation/views/ai_job_views.xml create mode 100644 encoach_evaluation/views/evaluation_views.xml create mode 100644 encoach_exam/__init__.py create mode 100644 encoach_exam/__manifest__.py create mode 100644 encoach_exam/models/__init__.py create mode 100644 encoach_exam/models/approval_workflow.py create mode 100644 encoach_exam/models/exam.py create mode 100644 encoach_exam/security/ir.model.access.csv create mode 100644 encoach_exam/security/rules.xml create mode 100644 encoach_exam/views/approval_views.xml create mode 100644 encoach_exam/views/exam_views.xml create mode 100644 encoach_registration/__init__.py create mode 100644 encoach_registration/__manifest__.py create mode 100644 encoach_registration/models/__init__.py create mode 100644 encoach_registration/models/registration.py create mode 100644 encoach_registration/security/ir.model.access.csv create mode 100644 encoach_stats/__init__.py create mode 100644 encoach_stats/__manifest__.py create mode 100644 encoach_stats/models/__init__.py create mode 100644 encoach_stats/models/session.py create mode 100644 encoach_stats/models/stat.py create mode 100644 encoach_stats/security/ir.model.access.csv create mode 100644 encoach_stats/security/rules.xml create mode 100644 encoach_stats/views/session_views.xml create mode 100644 encoach_stats/views/stat_views.xml create mode 100644 encoach_subscription/__init__.py create mode 100644 encoach_subscription/__manifest__.py create mode 100644 encoach_subscription/models/__init__.py create mode 100644 encoach_subscription/models/discount.py create mode 100644 encoach_subscription/models/package.py create mode 100644 encoach_subscription/models/subscription_payment.py create mode 100644 encoach_subscription/security/ir.model.access.csv create mode 100644 encoach_subscription/services/__init__.py create mode 100644 encoach_subscription/services/paymob_service.py create mode 100644 encoach_subscription/services/paypal_service.py create mode 100644 encoach_subscription/services/stripe_service.py create mode 100644 encoach_subscription/services/subscription_helper.py create mode 100644 encoach_subscription/views/discount_views.xml create mode 100644 encoach_subscription/views/package_views.xml create mode 100644 encoach_subscription/views/payment_views.xml create mode 100644 encoach_ticket/__init__.py create mode 100644 encoach_ticket/__manifest__.py create mode 100644 encoach_ticket/models/__init__.py create mode 100644 encoach_ticket/models/ticket.py create mode 100644 encoach_ticket/security/ir.model.access.csv create mode 100644 encoach_ticket/security/rules.xml create mode 100644 encoach_ticket/views/ticket_views.xml create mode 100644 encoach_training/__init__.py create mode 100644 encoach_training/__manifest__.py create mode 100644 encoach_training/models/__init__.py create mode 100644 encoach_training/models/training.py create mode 100644 encoach_training/models/training_tip.py create mode 100644 encoach_training/models/walkthrough.py create mode 100644 encoach_training/security/ir.model.access.csv create mode 100644 encoach_training/security/rules.xml create mode 100644 encoach_training/services/__init__.py create mode 100644 encoach_training/services/training_service.py create mode 100644 encoach_training/views/tip_views.xml create mode 100644 encoach_training/views/training_views.xml create mode 100644 encoach_training/views/walkthrough_views.xml create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6370814 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg +.env +.venv/ +env/ +venv/ +*.log +.DS_Store +Archive/ diff --git a/Archive.zip b/Archive.zip new file mode 100644 index 0000000000000000000000000000000000000000..9c5d825567dba0ddd3a457dce0d9fc8c62954b8c GIT binary patch literal 517729 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

QS3!XVMx^>9U>&Vzu^Ig1F@rjzMV01pUN zya^p)ca}_4su};6W zT^!!Dh0w@IIlM{5i%t-AlLwz{?Fbqey2HZ{f4U41F`44{`ED^b=J`4@vJOoQBUVMT zXiJG|rk-mf4v)aE_PNgI&!73eX(7a1@U$RZ=gjp9@XV z%j#pYrg)_5!S-kQC;3M72q;JMn%DyUg&XRx($(bQR(}Sezl2oi4|G)BJT^tXpnWrlC@r z($awcNrf8XT!=R(DPt{Ym@Otp53VLHWG|5fn=F*UMd<5pj|u_;c_CwfZIo7IUhp|>H132-pX=i96T3XpWXM>ZFjyOC{eVvz}cIev~N8> z*u0}fh8Xtqc%&HJk6qr_uHH$csd0V=DR@W`Xf^OfQ_C0J+U+s_gybm;^%=2dy&$ag zDfD9Kea6KYZK>p_XUnA}Dft&2)F&SX5v^hWu*a5YdovHY5B#>BLG2;kHP238+rWtj z43MhpZjQN7Z6WCMkt053&yk2NiImfQ!~UAu3C`)2nn=U8d0K~WBuLNhVjE0@pMOIj zTtGr;y%sblMKB_1g4Fr>K_W2=f*iV+nw_0L>^=V~Y6c#!Ji%uQKkyDgszE!*$F;Ah zL+_qqa^R~Hz%(OU;{8<9R_91RQueSH=yEMp5NwzU`gMocfI=tnsBfNzms?=rEvO$3e+ zqk-9zc`~w76G+4Zb5vkwy@N&OC!CBd0~qc>mXV+h%c=13iUIo9Ef1hqvD*Qo>0*L2 z>bkQQ;{jI7c~C-FFq41Wkq2nnbAlkxWO`P4U3%>qyHRwRf5ARmsw0*e!>td0-6r9 zsyH%^`044J^~o(VI1tr|@`vXaUQG;)gh|OKG;N#u8PSZAgI$R+Z%o>Uk6J{CWSst} zLt&1y3Nyl!G|TEtifoL~>T-t^X8`11p8?jvEd%k%a`z)u`G*cR1Mx$zwb2!dab9x+ z7BN&VmwFxk)5D3awL(@T8Kl_xPlV;PjNxX&>E&89pdak&0xfjId!nhuzUjGSeIM6^ zk+6-m>G=7UiT zo&J=Yt7-p!H65oJrQYX{Wj}!LKgyZsA6ZfB`z%0;W%y0uCg~!qi*OmkKGmeWhn<1h zUfL+hW7!yeHz!CAsy`iSO5#qpR*86-a8u>9=j@)14==ipAI*skCEz|-q zQYb;PMwrD~KAgJ0g&W{tl&$$(Be?r@;4bhnhdrOCk1Sl9TG(Rf1k;P}y*Q2Fi}Ly{ z)|&?c$ICJ0dxd%7@qhL}zuN2fe=g$vGp75ma6gp4+$bUWH}1z!-$MW29!Z|QUMo3& z9dGlIs`)F*3;%qq$!mv4j21CjtxOLxRqn7C^12DB_Ve)J(Ssf8g|j}%I=>Qsv~q;F zIAXuIH_uDuVXf@vSj9QP9_#+3Lb92De-@36&pP~3X3 z(PtxE>1mT&jdDI(ptC)m%!tg`d>gZNYR3pE%@xy4J9lOv-o|jVj4y25>7&Jo8Qz&l zDY#l0D^l1X?1@wmPZ*)CBDc|vzCDw@={phBegIa}gG$11c<*jzFM)7w zn2F_d`*1Ssq!KIXQ041Nx^p31@jC6O-`-N#9W3N)ulfV1wSn`C#K6%U*%&%^0>bTd zcFVPtcE;Vku7Qn-2i;vWK0cDX=MzRn=u&_A){AM;h!NNBTs=jBz<0Aj?WHG_s8VF# zu#nC#_qUni7~WM-BzT-}1&31;RUfcg-;i$I`GJdQgo%_|?HvWiq`Gf-t9|1iT2dis zwb5rrRk{JX{Q4~^u-uQsVq{Y;8qz$s4rN{JL+a2prM4_2#BqUmPDsOOAMy<3z>7Wt zh}p}6hLr6-aB2J0WE|S2_xm`LXfgH~6)Tg(QH=EyYG5f6?Pa@=#|@S| z?daj`oiR0EO%0w7{fcuswERPYPV!-=h37NJ-dw_wF;0T2s~@?7rObx#5q1-VGN_gA zv)98BOkMqEh0S~kR7UbMDO#FcS1rCWtlWs6s4jZEk~FZv^6`chYvG|bi+F$<{S1E= z8$dO>E#~KojXfufa%BL9ICWH`dQ>bPkXWIgO>(&hUW%V_Z+HPSJg z+_lsnnnkIK91XjS6hEpA#~DyyqFO`l(Dsy2X8Nd8pk`H>8Y;zM@M<>+a+!`IUKSoa zS`f+tdfIBKC_*hPEqL0=7ge|L*Jzzf*D9EpRF$9wSRHvHY?uHV+S&lK&qXM6K&M@EhUjqAa~BKL(svc zKD$3ApKC^bk`$S1&RPOpe24Ey-SDZ4Q6WL>3(b3JX_{CSjFY`r+f~ zUD&|&M1x_n#EmLh0&=UvcHHD(Q)YcPLeE{(ik6rgoe3nmNgy` zkYWkHW**oQXrFj+f{nLGJsZ#WJz^7lur~_=)_$NJF)bb-Mzv6OqJfY{-;hHjP@(W=;>?#MRpsJ_9z2u28fPtxC`g z24$o}>5;~cyKxo@ChSYa2-EjXSNfS}5J*qhD;v*2X)OA>+R-lB z%jL8A&_oPt8?KeR0;CW>GOG6TF1)zuto9kkEp2+BGBCFP1*FG-k%?S<@fyqOFU%y5hr zd@xR@C#t}3bZnp78PUVJvAEX7&7(8v$k+bf95S{et-vBw-CFh3Kr3i9bO%*ZFb(y~ zqNX&#kqJ{#8aKy9HMrTHPf#G{`jr@EeVj@%?P4TZSp?XODQx0{f+;E*x?25SFD&=0 zF+=OioUx&S_?R~M&S|11CPY;#7R|IItc6mIL->;00CN}x`sS0M>hMDd8GjGZQ2bHm z)0%g0{AMIqJ4rHZ+R`aLN~Iqsm~E& zyLYYfYX$qf)BJM}`IS{soaxMnch9zTMLWrj94$EsIc}`ed?0^l09bFS4jH8z)dytR zv($kJmij$x0sB)^>={{^WqL3-+a1*`5JmlHP49i2CT=*O<~SeqHgXm?d+kz2fG&6s z*`VT0_~y?A?akYg?XJhj^*4QI6A#yC2eZCwlVlrviU$H%mR_RG6(d4}Mmx)L5B`QA zHPb6?H79Af%MBE!?5-AM+oEF6f4k~>cU6_B_DT;=$NF=0@~b<4D=GUEXCHssv;P$# z%C8aEzdHdjw9(cxwlMsI9Qo%HkQMp0H$=|k6_FeCohlFuWYi!PIx3vfJH(($A6_9SfJ_rt@Q-OP&q&`L<9)JY~p-UEHW|>M1!O4;@3_spPT( zsFFX)#42^7J49sRC)cScPb&yA!?lcXX6fanrU@csUqrv=#h*noy=Khy_2s-BFM6AV zR#bxS)EI3vEY5~G7`SyEralg)lDG%W(#Wk=-^2~SCKpZ9{vwulGuu}%5VzYiBR$)B zrXDQ14IP5_8uFlfr4u=iK~nC^K&Yy$%4lTF($ofrdwU0t-rq7gm04p3w%oi@zLHdU zP{rO!GIn9oC4fSk!T=iXDzWtCv1cl^fQ2TfayY7N9&GZFejNgjjcdS1%mwCX&=OU( z57AgVfjY(_^lf<@<3kqc_ z79hEaNwFij9<;;Hv6dR01sCQ@(Nf=n)jE&JS`AmJ^@NVm#ERL1&&d{*k&2ZWf*bPj z)R&Zg+~EVI@H@(5JgJ4yX)zz){i=+7RsN!n#a=Robo@vMIeAL97&V~9!DR=-bRc8wK~OSn+8um4Sy=+?y7DJ z5BJ$*e1wC!p2RubPp-|oAUg^ClI|boaWv^?4+~>1_9FB#pa#;E!CMmNB(jBeRWH4k ztk5-+DgzOvz{ng-lX(4~Q>(wi zIQ)8do%rA6FWOdCHkJ2FHmMxirx5GjN zCJwolR**?jQzt|>R+S0VOu=k~m_xmi(S9(zq)iV&Bzd)QH8 zAU2WuL~;_55TJMmV+v-Kajr&c*`rUX!M+WNJ+sP7<6JotcN*P)U7&?5R^!2+7uS`-YC)Tf#_}fd#CnD z&I6dnv0PW)Yp@i^3O7X`?iEOJ?kFSu5AGoOFeFws@*x)1b^R2lFrGi_N-2vs>1|Y@ zzlU|)cr2bz)^FDdOD@wqhR}lR_g^1Z4h$joQ|U*{TrTmM&J?nag|#oG60Q(1u|?RW zK<=Zj~e2IQa<;(6s|JUuQ0}JR(5{G z7;~iq*ow;8H8wP0-Tf&WOs{g|KVCC&W^Rn};4B=E%2w6y$3)h^=0sSoR5 z}nq0S{z=a$CL_x^gGi;fP)SS$`c8r9}0*JBU9&x)-G{E#p1*@Cunt=_k6QW)tC z$x@&w4AV~^cTTatq%S(0sBN|Vt+;!wUxS0?6}k6@`#CcCRZ(gGTx9ZRb@s2YJ-;4C z{5$C0{*@){kL=FRmE1G`fbMU~@GHSY{EPHH-yn*2g_Mc`SDSj1z7}STBOY0O5ed(U zHc)?l7$vZ;ApT?>k1TuzzqHlhwtKpqe`-z_8Jn(KLhZ z$*dakq)u;clItYMWLmo#B0hQe9r&fQ$1Fa}DOY%MFPLSxxz1EO39?b&7<0s+syIU> z#&#sugUDZ|lH5y2X?FR0aMQ}36Eo52<429>lR&*VS#fk(Z-e(oEJcC?gXMIrIV3#Y zC0dfh5(CiyjvHVl!-9yhGllCIkINry32alTd}8VhPdRRH_YW^O)6;#oQ}k3yVt9-) zG29YF$vQ&uD+_IEN!P=xd!w?lq^oqe$Y?dxG~{X~w1^7uvMn0%Zt>Y}Q82zE1A0TJ z$+Y8K?cFAU?^7K@wF}Y9QOH1Pi;Bci=891;CVJ9H7t255#rrChMR**F9n-&j`?lw_ zf|i6IyQQZ-{V_GAl^>~hAVvG@F9kt&>qV+gB)rV;&dA!~% z_s-|f8d$F$!P!U)pg2n&Uqf571Cu6g)WK7^Sk7&oe0L$S0RnYwJSr@v!?`xUBesal z0#$zNv-`!4ca?%BuL|@uBuG%Yt8&hbk`ZoAMee{%<-}<=XX%#8sK#&Cv3g*h%(Vbo zAkHVu9oM*GWVSR9W9^Fm>Uj;1 zLd{vflNVKX-PfSuc$;FYD%xqfn$n=x2}*JwV^MaSiy&1xilfoef_>J|?o5WM#VJ8# zG|HG9xQly6k)nsJLuo4*>KO3TDJ&%*xjkrDQj;2J)OQ6nP=3>ZlVbik5DPY1w71?4>RcT^vPFR_d-K9vO@2H~H{#(?Qp#JH2Urgf4uxQJ0UDUvA#_#g9*euRZG@KY-%gEfYeH zdc5bMHm6m!&em(t@I&wYVDb*!POEhRsM((Q1H?J770qL%Q3H`d;$2uWgTrz8=`BhD zVb3dmF)n6A!Oj|7<A43*HloQyM&RSe!ZP=f-H|igz)AO_F8kQNvK2(0I?y9MqzJHPbvDT)6$?Bj1 zQ*U>@74g_K-`6DNRBb-_4S16DcukMHbi{w%g5i0_?V2tkK*6fFV*S1SC8V9S@Ne#4!+)w8c){VqNp!$XGC|(Pq$r}_Q%1|~ z%2?M{!Y5*7wtAV(vTZYRGS5_vC{1BJYt}k=G ztP{^|6aII5zdrM+W?S@$aU z@GyVX{;PYIQot8vS;A8K%MaFCYKP*D2rV z6dpdi4a=We+kfBh{)gR$e-dr}6A=^tk8Z=C9!-Dk+B-PuI~drT+5VP5{kzDy`I)J2 zf3DyAh1$CH9t*tJvI_h`!{f?!Mzi%%2nlVuVg&2ulk9P=~ zTC!)@=G4%M2wd181}t2N11v=P3}qSgi-rV(-G}!0Nn(TT_T|?A;w#w|H?tCUEuUyZX2bpb)SbO*BJ0L1XogZ_*R<6sPcB zOWgxxhzLKZd+>#m&rrivd1&27<~f?2G|aoJ`1vH0a*d+{O*=#Tjy<#tEYy^~-xDX# z=$ayabR(A8z6;61PqO@lwSYK1DFKkD1OPf4b_Odfk{BMlCc3F~+IT{Dwg|PCj`QNq#^wX$->j z$66Oe3$f+Hhw|~rK|O3uTl08isX~EX`no}<&0&M5L?u4jC%B(;Q^GrH!HLq=-v0L6>ZBrnQos;;!q@m$n13aV=Fql!uBytO2sF`ls<6^31cj%a| zUv9{kWCbn&i>@ZZ%|MMR8Qxd0d(hGXPOubchTY;R5^Cybh~t22zj@iOH^mNNIa)i_#5@NL9Y&_`BAzA3`s1Rw3B59(wGmERq^j%!eRcPc^xb`ks1H)@1M_ zZF!^csst^Gf4zNGjLp_Y(M~!Lct9DIYKe-$Gk9=oNcCoim|SmTy@>VwI8TO}cYx;! zl`Vni)8kznV{~U#M!-&vjiZz@YI6-wJ55xD&QThuIEy9-zPbj9h6dY2(Z)MQWmhYd%CgsthIAPAgl%t$1*z-;l_<>U2(j!|Xr zteYzEgNcO<1}>#8FSk1uCrez1MR$c<6rgROQ*PfWc7KJ+fYxQ`v}U+ZElV;IhSy7Z zi5}~c(04ui!47wypD%XCxTJ!=@Td@sJHcNc5$={MTznJgv-Mn+;=j5VLWb_>gOasn zA$4R0Th|$?ydsLdENbB?#o|%ZEeM@`L=z~L&^SnwDzs)xg;iE)m=^E|1dl6n$CeJx z!4(u9@li{Cyf%9(hu9^-ZjtA7L{H|Ywt93omWJcJcQcgs`PUF;^)N8asZYk`*Z$ z-nwy23QASqGA`aj^!v2|Xu1?}i5Q`(2zQRY>(+O8XrC`k(2GC=aE+t4Fvb-1;7AGw z!RK=Hneo^DILUIJ1Kho=^VNKkng*k6)RYfd!gcXd+cFVmi8`&gr}?r5YD#&P5tIzp z1z4#5KT$c^^)^&S>m!@!jzA?H@je|HSJ= z`r~!}U0Kf1%)!9M>EHVRj(onyvCs8;zfj)(A7wcmy_&S2zlPD_T9ASi zaYn)tM9CQpDNr$%PZe#ch2wP@`H#s9ZLF(Oj!A}V(#w`y4NeWjXF32_ri!k0>_HhZ4urBA zSO++a%Vivcf{&FSmGu_~Rax<~u+|MS?#60On3qo6k-t8Rug5fk99+NISDjF2jA`PukA(q~mF#V{cY4z+! zFNM*qUMP1%T^ojcBXIGc@+eH70w20*rpQXOC z9jU}S`e@#M`oNW;p{;%-5~l+6(2+Y(qCXIs4Jijoto9t4IuU^|4x^uSjU04)b~nh- z+>cJC+SINOTWc`%KvGCVb=3iszk`0yO(@B@U= z{jba08#~VzBidi1^6kRBrD-E)@NvIZU5SRc3HMm7tLoOsRCi^9Z{N6~Xg+4@bs0S; zCV1WjFFFRs^mg{#8vsQY=yxFJ+>zO#qU?8o4m^7Is?`naU;FF|ki(wZ{ zqXKEv$yqaGlUNqnj7-4>4IN9AVIH*^G-Z(*JA5z7Rc7KcEGC9jh*<~bo=v&dBy7lt zuXSwFnY$ym6k80r^xl@%4eo}hX`WS$XVxzAvR-@EF8;;ow;tFr5&x{z+5ERk-Jjp? zKdjXK)6e+-G#q`REr93$1|0p>Pvn2)_4C_5_xFGJz5l-lwLkChk0JcOY;*qZUjL_` z*>A@mv_GENZ`b61CpLU4Z3>-u~ABU#CFitU^X7rO979UR*7QW|*cLCp}T6h^f(t&p+h`Oy@B)jjT zhRSGtd1v3SJ5-EO?$Bo%j5H;gmULiAJzmvKITAaI38Ytu?8H#xE|#)-8bT#LQ3X`YfoGFj=7xDpGC6z!sXL$IhOSQ)_s&`~uKbr@%1ICyS*e6EYkzohN zN^W%lo~`G(MK3`oe&v?AJn^}zZ^5Q+YWp{sl8jT1y(DLS17}3tirn8p5fxUV4iH47 zBM?WRdNX{{t2dJW4kLJq_i9`X<*ZewSV!e^6t{b>zgC0P{j0AsS6p5N0PfS}&Q#_Y z^IEtu8VvpEd#1QpvNtBU(VisX;#yjA|>6ba*mm+!2+H9;=lA zziarPu=TC_!KN~|0R#>bLlfg8V|)8E06R|!jgv4EWog`G_HtA2qaeAKly-u@0MQ#( zvQIEhT14I7Jf3gK5lQys8K=dZIH&33Y?v--^c!_#vW%qwCph{Bcdh9Yq(^_LpC0$C zN~O$0LMw``>7pm(TNXg}BZA}SvSh6|YSOy->QBdrm-8}=<@H{)SICH`vd1!DtctCY z{j91>$?;9k)EqAPTOP}rQswB^&~{0Xz|eNGik76z@LcL@Os#c@nBw$-{F=O$0dn)G z*kVAwk(40pdwXE9+%xmDLDtFI z!}T~bM^E1%-z_mT@7dWYZl2TmQ!Q!uy@pj&mslfTKoS^4tc4rBjEyhc`Uy+!nOf>t z#G5oZ564-VENlhv{qBj)qBm+AbD-m>nRY1+3Zp$G8f#Njq9sQHz@io3Ks!2A=V~PY zX#6cwsKC4&oM94XUZ@w?TSZ4w>*_@|KnK!oaZ=}Cz}sL%3SoLRPM!7r`$Ppj@!9)@ zGP<C(6L}06Ir~lv zk5Rp=xe5HABSP!Kdi!z;LcB^Z(sX4EamnKuDNx9v4nbP77VqRTjzbn1(c%<9U7<|G zXw{OB7rAtoXfYr?6?|>_FSjzh@cu6kSHw)?9_=n@rLZ(kJ|Eiw!sRS-(}z+(%DH}3+4W*QN4L~B&S0Fue!Yxqd<>6 zl|$oRmQM?0<5W=UhOYzQo^VgcBKLB`r^Q8O7p}X=jQ)P^iD^pueZ9E&%qX+va^6d- zVn4UfE>-4g@vPDR@zM>tqU7%8-O5%4%`qG-YJ3{0Lfu7xTjA|x6`_m_EhcNm;OK7U znai0c`N3!7S+WHiPgLf9<$=qY8&76=?-$6AI$-;e7@S#LoLDp&JTKP$GajDU9uLP1 z#UwZD9y|BBvIWwo{e8ClPwJU|#pFWt0LnaP8er6EHcI^v|jt`ajOtGngA(oJ8`nAKGELSu*<^_3rJ*QhVhh|W9 zdm_Ww=#u#Jd4G+kN|LViSqploua=p~PGb(Vv7Fw?T_-w42T|bWbctskRmn$4?M-|U04p?~J zQN$2NF2UQ(JWB=%jFjnZ|2bUxCx{G5@T_}xgK1;f2n>fxBMKw+<}au8tafiQ!9k7E zeK3e@tXbbf^Gshz-S8Wip_E)Fv-o+W>H2OpTio)fMZ7`F90&$O)syFOz^RTR`cTdx&LI2pwA*02o$WEK+)YTg@|3UA11cm|)Hok-fT@{2^>GNkvIl32iy2HBA%vS>UdYJcKD-DfPhZnp z*gqVN%iJ)q->I~?SpWW;RH{&5=r;xXkPOb?P$CP{f=YLUJl1D4ksJL%lo9=h^19N! zws;d&4Boy2$V&~2@nHi$BRq62qU)r52$~gexWMvXfNBM4*Hmd1??tD68A?k4^Bs8+ zQMaH)RILSkhV&Nj8Vq5GwlqYbKowN|p+MsoSl*|mw!rM8<64#!eu zgjZJuLYTD|)$SK8L;1a88}9yR(RFc>${+VJZ8uGG{6>fMMe2mrorg`a{5daAkkR&%k<>ENu5kbOxm6err6se! zzO8Q7G>Zdu1Bo35<%7%V26(_-hCh`k^g*1VM*6e8?9GJvmfM;hqNUaq!`%C6+C#Rj zhUqfq;#XI+_sZLch)0O{!KE`70ghtC+s^TB0Aa@3UDS&eY2^n7ZzFQBynfG`xhd!> z+K6O&0)zzz3)Qcolu2nKj$#Wv!WZyy*RDj7a+3^=xu|?ZckqyzdMsVUmo}nYDi4n~ zq@g4OC8mk&io#6YWGlBo(%1;Pvd|plYNb{QMgwU;X{il}Hbgyc!fmU@*MTaNG((~+ z?Nm{fZhg#5RQ`TJhGM_rAfFhXQiX!G5Mohf-l2`BpSfeT7?Lo5H(xQ&3>gUctc|bE z*P@C~A!x)zaKEJSqWZXBufZl&fhWb{ro}VYp*sTcFgG`3LM?D%vAh_gHPI!@DrQ7+ z#95NgrxT(;CZEBz5K+(7z(TF>%hyC*xK-re1aTL*k2`obHM9Eo-T~P8gA?7k>~pRs z#5H2%pnH+^Hiu0_hI6ZnbF*(Z1?NTmIWF%9L+=)w;Kc{^8U4cN)x6KE!Jk)Q1*a%? z6fSZ)m*N_jZ>#o-aoqs zVBoPobV9v9Td>9?e+^@QBK|)NWB(ld|8EFme@9(1a@Di?cgOnw9>8>dE53Z5O8!M% zDk=Q*lu;aLA3g&rl=3ZJ7t{rh6k69blp`u5)}FiieXOS%i_R+4>dN!372fLEzHxPF z97PL?O@AvB;@Np*M{2;BF$w5X3^q3UezH{Sc3>sQLGDRnnvPJ zc-HV9x$DChd60}Rtw)+peT0b@8oHo8GDp>QJVfWRbhCi$qr4r`0Bz+)SUk<%0#i??fL2h^{48q8sTZq zeiiy!fdUB~8QdVS-+l@SGBf}cFDG8pbYM%_7jMjw+@U1!(^!(~x3OgJ4IFzpO8m!^ z{Poe$+@0?1_tApA&q3J z*)L7js(0YPdLsN_mOvizP^C9AmMzv!gCsJPAY9AcNO0|Jt>IJ2at_JP zy|zx;axb>(_L^zA-AL@~(Kbr;A}imM1;@?R(osBNbHO7D*~eJyZuB%TsX$4yoAQkR69lHGKhR=Pwh1JQ>)uaRU!y#Ys%S4f)PPBd#anUl5E z(u4C2=P)boVaHY_P`{KIZ?0(dLmmqphD3CB*=ce@stG^BN#{AXC$Suy;jeUxZoW9j zTYPm0C952UK~@k9se0O*le=zH50BEXDOhD zyK|-KyypfF<7=-o9+YlQhwQh8Gb~eGkE_<1739Z3n0?{JaFP82ub+lgcuw-y;)N4y zZlysiFm}y*+{c6BwO?s(ph4CA7@E`5Awu)fD#Zwwk3^J0U$7~=ObwMe_svMx+bh7r zD4T9=PvAv-hTwk{?WOx-9UG-4M+iKV$tD1;7rRz7O(89Pg$;`t$s`bS%Mb9STS+V& znj1*EJxL@rDo)YptEwdwsBm$rFVUP|bKaLAVH#;qE=ZmYs9$?*AuxZq z9bQyI@d9=Vscy`ePfn^dOOX1Su@w1OOY1_?0T!f{Vc|OQz%{IOx~Braws}^t>2d1X zv07zgdW`}N5h$EO@uJsc%+)&7$&y>C^15UIz5s3CM9k$S8Nu!eF^5e6n*Vwo+_1i# zY2DMu(N?_5hWxtWgxfY&Q`iccG~L$(5cfr1mNpTO3%-nfJU^a1Q;2#Atpg8*)no>V z;Sgs2HH{{f-YAaeR_Jlik=@7X=B4EuaU|#)wFw76kL@(qPs?Rqm9&D5=3wj8bP?;2 z?P$}FCnEJ_pzueObnfUY$qL3f73woQNrJWQ@$C9YucUErE*>16NIbbU>K`^mUBK5+ zDb5AmlZ@FqubdxKKaayKeo92yh~(UMpRNdb`Z$bg19MY^R37j4JZz1puitbnesZwF zx_0s(VMys!bm+(;Bxe(g496Ko-gpBg;pj8f;QeR24>Z5sExX?gU8YL5>Qk^(EbVg- zce;znI|<9)xLkrKj*3r4Xgw^v{p$Fl!K$psZQEv{*MZc^pxf#gv90AlvWf4@&UUVt z0%Q%fr_2=k3s)a2&~*N+dsy#ylF9rP?*~a`&mpsZ28>Lm)|h!W?YhvUswLR0+Mul? zp@p=ntAQXIy!f5dB{NYG-0*77-B)IkZG$lw3jlx_ z(ci`6e@Kb?CsFMG_DJmDsP}K(-F`=6;m`GZUsT=x9f`e{b@&b8;u%!zA}&cvBlH9{ zKvvTA$Ff2AKkYx}2orF{4o3Xnt~!XwCGrhP%{X%B1Bh_kw!OHvZlcSUun3r+B_MDW zAG=U1l!jFJYZLXQgvdech*H#TIL!QWE@BORSS8I$5;bLq*7D#w;Tv1uy4tH;;rGF3 zh}9aM<5A{&Fw_!{GON|HNBH59nU%sb&B_;R{4K~&F^%u*jDuyqM-7kg^X)wQb-K?9 zB6k^oIWaNd4;N`5$0I)xZ?5Y`ASI_~v6uSr)OjAQUi4G0S?y3O3dmt5j6)Dd!H^dJ>oMsYn zxMn_fRaX?AaT0Mck?_`4E1-b?;=RvB$V8mJh@wxX_s0rlX->03tjQNslx2j|67EgQ z{nP1w^+c&D=Yfu!N%kV7pRsxVp?U=HbVx4WnHxCRp1LydEgcmsRszJ+z@Mqu)@Q76^aE=YhxANb`#R?^$SVX!0{LNMcW14`&y%Y^GvDZ#rxCM@b!;}H zauglG@$-NyKE>W0X_~@TKTSc+5`+;V6yFAQh>j}cOhlwe z&f|R%Wn0J{#9>)yb=niDachUI@tx9AQy4QF*rL(?VI;BSVAsmV>At5zih#VTcXOz* zW8$WoLMBFQ>xCaM*;3T#9n>pdC}N+1!#Et1DTN^{LJ9&H*?eH)2@j#URs^SciDg=i z6g<72Eh_g^Xyov46ZgHOvt z-%)r)MF~xMO)n(gRifv>_&(5F#TYMmKiaXmx&8^(craHM@C&7>gpR^>hKQBwkMQEU zk1kn^GMBI2^*)qCn4Uo4W-RLM+amergI8F37uJ`qC#YO`88CZB$X1l-=WjsXwTPT@ z=P6w4c`%bLTG=*R<9IEXpZ4Cb2N_!YhBj|bSi1}T>#H&AxQ(o070WI{lvr;1xg0yp z=aruJz+EN`=SW!4kt<2DFl}w|k7;@LMBpQp&3AYvhMcL`%&{{b#EnWh`*}m5Lv1U4 z{1GOMt}RBnK{ngXOw~{Fk>fuY>TMZYU6UkfUQBI^%LwlYu2KEa%fu%+6G_F8wMD2n zn%kWXQ!DP>lQclt$kLq*{q*gH%!gZaO0B3_72e~9T8e6djGlWnmd6L}Y=7XYgd7Jai7ZwY!^^=jR#~#K#;*7%rJ|%*< zl&{%0X?uHV4N7PIsxqh>W6K_K<=yrT>f~MF`9;hY`hoO?(VDJ0jTO+m8L>&*5+|Nr zVFda}FS(2Z4G}Y^A}3_x2-@llHm9{o_c{IQnZ@ZfM(JPWkd4?~qOsi!p2fRa*EAB2 zVjb|TFwyH+($Bvk_#I)my(fkw3R|~=%i0J+ScuaaaO?%8w}m5RA^K>HvJZj$+$riw zozK_YrwEv7xEt?bKYPg+!Gf#|+bickj zn?{Mofb{Ml@%&s<+o8kWmPelVQ%Os_k04E#U`xG&>S@L-w-px-&i?#_)Fz*qnL|0z z`h>r!Ztmkf4WJ8kcwrS0DRa_~qUV(SV@x+@oXs@W@pd8JbWiZpXFMle3Y4&lnWgDF ziU?=ytq@UV-Kh^bUsl&9n%F`Syfi83Y-t81ljdEtiSr64{g4XBbj7eMaSLs6d}HmQ zcv$+jbxh7qHgHQZNJD%?Hb>Rg6D+~QBKd{p7jA=UBnThvn!^X|KSDGh6o>v}I+hx<&*XttPAs@%; zs6CKbxiR95)Ym+P@+Oaq)Qdn{Y-MS!Dv&finy}|B+Q6*`0Dwy>m%&RFV0tn)n1@&x zS6F~O;cjOx*D7?yEsb4(lUNgIPNbjSjv}oyO!hA)4@waUia`cB9=^q2o4&3WL=rP` z`7{V(L*o<*cvh0KRQcb2O#o$c{aFxsIW1>(Ajz`ZT31NN{W-H?Y@q*PT1~_5y+4c3+PVv|UU<@9VZj}sZDA7DdS56YsNpRUMjV>R9J zWCFX^?+zXz6UsaH2;FeWtpd`NKb=lxe>`NlFPWcr!$2i0(h=zT!5}+vi<|o#S#4})qzBp`SEbA`!v+ydxmY}r823Y_ry(#f zVI!(@lzkpTqSsDIM?OGzQKrkyC<49uspiRqchB48vqphWc+fl2+pdi2@xQ8DKtMSa zg`Z@fOQ^p>+@F~64+X*h3?BXyAopjv?(YnZ3@ksZboMqje_P)AuNf=J8lQ#%$Rpc# z)n8YR-eSC_ zYlns6yhiNmfh58n?=@_@<2^FT`yzmnop8R0D6Xjg(i-8v z<`R*)e9H9~;8|O=KoYnXN0`|hCfU^P%}uoST@V%{R9V;arEH4asOO+D{W5?eCy_lt z=DaOsLgyUAGX)#Xs{YkY`VZ8@ymvG9(c92R+}rljFOK zP=kQwqjwpCqtqib3y4{k=EnPTW0*Dlb&2J&vu*hqPA`GcM7E@2vC`5csW0QwyS4nQ zF>(M{Zp)%!7x)mv73!hq`77%XlQ%)L4FN;EA!g0w%^(;hWd}nzT*SFb4SnV6SCU`U zVCbMS)@*Sysu$VX!jIjbjLL(lme(c`(334JF&KJTr-J)V4T0yZ!I{>-WTV;{xN7y& z{CWf?Mo~_R%sy$i%G|`%@(@wZ8rvSNE}vwrv#6?rs=G+B4eL1M+*qopNNawa)mzV4 z-Fn69ow~F_Fkya#Uk?3gqWf9%;z50b&SYV?UNDW6HM^cC>*CCZ-jcIpgvHF!9k?M&8Kzbz!<0vV*359EKuRXesuV9NEFd009x?z478CK zpkTHYBB~?Lq6aI$JY|os8DJVjOW}&3)&*|Ci(cymt$>CU^jV>FJqK6XWS+BjI{a;> zy?S_ingc6WAJm9+E9!&i&Gs4MtuG@i{H^ob_IU=@2esE7)bFytr)X`};wLboA^sH@ z|HP4h=%n(GA>}`TjnIEk`u>h&|Jk)Rb2KyhcZL79Paa>#=lZ?>PrK2l8u%ANb~PZR-%1GOr_aW<@F$%Qu`Eszk#*E(o1NH5OJT)sZ#VjLYd?3Vzxs6r4eVHA?vL`D+V^#1(zwm8_a8siYs z06*XvFlw|;*+GYxf=pZ_i!mH_^Q45{Ar!h$NGGILH@mFw=)o9rtXy$@lfN7og6cfn zn7Mo2?pL#7GF8KcufHXV16^s3_Hx^YtZ50LN{bgkO3-E#pXl#n<7{qy(?k&kC25=bNEW|K?^-)&)*Xd zBJa-oUSyU=iyB{wU>Ga+bF6?w4GaW$93R~kLGCMehKyj3BxWKNv}gaNF%3QG!P#>X z=aW&KtU>Ae7`;iT;$EA1d4sE1 z@Wmx=XO^|1aKrE1cE*D$T#c{;fy3ii98fgaz1$i$W1b65xkO-4FKF<=uP}mdesNn+ z=;_>Mro2ja2_dQbeAK@L*)0hB?I%eO8lNtKX4P;KMMgVpECsOyjwBKKbO!*vTzply z;Z$FoxNyB$hqbi4x1#Oq@WG`V*1&|@EV3(?5cmL@S_RE0L83m5A4ddE%s6(ww)vWq zm~P=QRIf1@UWAEDkx^K#?BN#Iuoz{rXY(MQ5KB8Ia)XE!SonjK#k0i6DWgA{&8G4p z)|V+lew%KU3FF#Vtn3HTw>-vp#t461xHV1UFV@op2TZHMY((<&%EUO9o%D0AzoUVsO&u3SoNoy7RxabAMS@MkyLikQP?sn@<~trHb6A>&b0#8brzrkeoVN>w7!UY_D!@$6_k|bR3=7DluJI+CJGm8Yq2% zh5IH5=OWK9!n5vVs^Y6vEp8b+pbWyW}AAPC91Ws6&+%)sr=(H z*&5v7H~_NfjBabul&N`n9XJ82ncERCNxc(~0!g&ZK$3sJf?dNcWMJY2Q{(Rx^L(Sp zJa>E@-X4z4erVc)@YVdO8ZZtk@N?!K-uaUS#sM=sWK|}H4YXp%W4FX`Tj=5Dm&1~E z<_V^KvbBD`fH&$$c`A}YN{3c;Rw_wNu$<&r%C}I~f1EVK=qQw*3yuCPf71MN>9#Zpa=4e6O zEDaYll~M40>%!8z>jnd$>s2Y6#7#`mUNQh%!jr0+*xXQdPaP?D=b#D;ICjrHdJap zAA)E}kN7<_ey~;t4|&e)E9lPuF^D*b;ME!UvxcDm-}G94V(vdAhW|tO{ZB-Z{XYf~ z|J_I^eLaVNS3}7B96_A*xqk0S%KxR^^@LC=^~bsg0=XV&hMA>a%U=Px2+$*oWJ+$0 z6HO{K@CbPG2tg~mFWOw-LriBpnhXjV2-Q{dkQ0s@mfNLGC zk5UGfi;_1oxe(2d3GQjuoz-M#GOf=v9Y;N<^^Q?W+6Put$~IElbTAmLszj;j&>7Mm z&4}Cyk#}FVgMr!ASodqpKU5|YYqg{c$4Fx0<2e2{8*G{Qweu?O2y8@f>(_hsg1CCs z3+@F;y-{|vA50mdi0+pcDO~;GR$hTBOnlxCH<@lQNq(e?Y~66n7Inlyq8?Z`CRH3a z*IwF#CEdmlFh@ZP1!Kkwuo%YU$BcMiT+Yefc@S&8QT#~$V*g%mt4BDEFNi9cAErF$8 z_Ic_ss`K4=6B{JUl(#o4O*;HA^^SX`O9h7Ub5sc_D{J6lQ@M((Bij$V0?eO zgP9N68zths4`*E=^qC(Yo``!MMx|c{THN}fw`NT@f%5C*@+HIO+uPA!5friKFn&jfwKaQ7ql+}(Bin!tzqOVW`;3DI1S zc%>krY}QT`Rs|!Zdh4$68g?9vLf3ZBip$xED?}EO52V%J(ju~XA7zVWFfci+Tbt5B zAw9FDcRp@^4yHU(Etyr(BAZN4?bD<$OC%|COZyktPdyK5v3sO7emi>qfO}GxDRp{e z{hc4Hd9cl-edfoq5P!{&|3ra*IA!S{0K$I))BY@j{9R`JIhOTv;OyU;4F1pB=%%uU z%$W$h_fw7P92h4Pvm%EVzm(DfF1)-4Ur;$5xLO3aSTkG4$z^Jw!=TUA`HOeJY$IYh z9-@cIVaDru=%Turp${ercU(`2c^$m{uA4)ZvLuOkoqxTM=5k5iFDfz2_wim`0^}_R zV3L!9NR*hAp75&JY&133SxePG#hihdx2!BeGor~&)3gF$2R z&e0(wESW>uTO>YtzSPs+*_{XcaeI3B<95Euht;TV?rs%B24=S7yYV633sMQKpPLb% zVmTyq;T<_Am*%ItuEfII(;Dt-lAHwtLtN@Nn(F#I^9VehOgZB3nqn$_n; zDbacq*wYfs1)elJwfgXZ(RLAZ!mrGIg+ru=k`#{TRsgT2y^<3^AdSi#-*uP7F(D7H zzV}m5^9Ty;KHI)s3|bIuRP2{A_>B|S8)2@_N5hEn6Vx{mAVP$x;%cb++04**e|3FD z3@@ZMjV&;{=|Ix&={iGKNG7yH8^ZPX?0g`o8L2G~@0c|VJ5^VeNa54to#^_Bf;$yB zn0v8rxMtv7JhNewv2s(GgpBr`f>g&nw_EfrVcZi^SEmM#9XEi(^x6s9vw9D39es%j zZ6imvUW;YO7d(#`%*jth&audm4#TBF!vbvqI9)wp^d$~*eU5x;8wXG35eH+T+8IC| zi?l0DPHhDj=i9b|h>R>TZzq=!#f^Jvrq6~^@_zhOnE$4pz#16oxJM?)&!t1CtEM%0 zCTGrWx_S!5707_Eshx8_%rW=fTv8p9#x~yIJe)RYF80w~Or==g-ED`U>uOo>Hyyqx zN}MOe)A^!X0`f>kM^|Zx+WGcmP**Rv)D>cow6^&|n@6+eMZdEVKQd?x2ov^!f62 zufzEh=;*9<+jRle-qHXqw3kX5DX8UB*I$Ex6!7icoUDrvRz)LTAW~JKmVt2Ez8Xoc z;X9%QOC7^%u(k$GsOjkND^rZQ^B{)+8*|NzRICO=s2LwxAI<5_*B<51lBM{#nZLpO zhG0kWas;7ID}aapR-*V5wEkgg`492yKLNvkP5|fnLv!e8L~H8R;25TUQ4ED@`Q;<7d9f)?oH~ztFU>#9DR2?G8bH_(~q0UwE_L zNG&&$v@K(1MT+HSG0{;L@5ph8qmW1*pG9C55f?|@E_}h2?zFyO-%-_M71z|jUn{&u z$S22SI+xEMkqF`G70;MM3lLfCi=d^8PGODwsa z#anF|=T9QrXgew}f4Gitetll%it@4?4YwS9j~3oCDVCNWz2OKl-73HI_(sltrO6VK zx%yf?AI5}-(&|W+ru)4!6*X<56TYJrTS5qf2G^u`I4wc|k&^^I;G-nIPAFaNhdlW? zet#axa@q-_$%;%CkK5zQ#tCD?d4$Q#hfHkP))Bs-6Qd5&%?8Ja|>+1IXK zMiW_vvCJ%X))8!v=lj|TCzb@=>cQ_&uUgRWtk}{&_5{)NA&klTd#`py8SLUPF#R2{ ze;{oHvCTje*pE}S`@)5u7@CK6q#g$4sS+j|2kZazBod>k@3k*WJ>$`QupFhDEBSu1 z03p3k;*lO9;zr!2Af^eCiP61dIRRf{B??;<9{OIbp$IVb^YG(KaBK;k<3eXzi)IOp zKDWcFEzzXm}OyYHd**js__J)0F^IGxca#6;@lVl`!>6GXZP! zMJdWU>_V^=srg7Vl#}jy6i3}dE{$bZDV7G_t?qh2=gLbQ+`9HcfEB=V-GyM+$`?6+ zb?=3c3jj!c&d`@SqgjCFj9L8V41|CS)JBN)DpSVh^;uNQmV&C4OTP8mO}DIe^K`hZ z0s&6q8SG#^k!~eMNU_0CF_#(BgKloz;YW*N4MlZM$ALxxuOa2);xP3*)T$=KZ^hb{ zXgE#Z7UCnM1LTbj-9|QYjV%V!HK4_JtAnvaMW$J+%vm+_RGU0_x0^LfLEGdklr+?- z&l(q-^P1P>z)ppDkcwhIq#PCk-6^Z31s$?jfU(RQ;FgWy z#nHIGboa4NwcpK|YaDsLusZSpo5ItZBCToKU94fyDCAK$tX`(_0qIhW7qj_k{IE-% za3=3FbG`YT8u=XBTS9^d5qkT@BTUDb_A4k0;sgh%HuiLACq55`>;R$N!K^%$U1HBT zbAgpUbGC2d^1XSA6&~zE zCTZcxgN|Qr!W@M)tiLa@KZh>lwI;4Rka^Twoi_c5Qgvrv7l51|#bR1zBNDnI@*ELB z&BOWVD;d?=*+ayM5&juC8X>cxj`I_mIHcDB98P~#TK=W7b7-=c-h;vc5N)D4i>PG0 z_t{qNJzK55__@MtY)t1MNEBMdeTHH+C7ulF2$Yt|{D=@i8<_+vn4Fr&OerH0a!gXZ zt%-E^iMzowxzRJkfNZ_`rMPvJWJ7()DHF*6;qZVhCDeQCt}5Z<#c)Dy%>I4j{uf}Y zWQBbiIiOCi&^HEuUklY_bvY5-8MQ&`h&<)7ug3D7_-#TwUOY~O(KW*L*I^U!N|U*T z`-2@u@?y@Dm^4!oG|!p7H1QfkftITW>$GZP)J!9>hD0h~*%K3nfh$Q6(MU)qL~==z zQ3!rA)No_$!i#?uLfz)|gE|X7q>IBDtS|3}4^f!tr_rS2{C}8x#~@h(ZC!A;ZQC|? z+qP}n#%|lTZQHi(-fi2QKJ#YYoOdSf%)RH&eHl@aQBe^YkyWcQzO_CqGv-F<6uKfT z&M@6!oa2!i`EQw)ZM%chUQef5p%$+t`+>Og)#Q>Ew9_qj4_7~2%6Ub1kMaW(hm2<* zGX_p=xC^D+Z<(3P?51w7Yq>gC>K#s%lPvNl+|g%Ta(6OG+~R{Bbx0fclSUi|?qVyy zU8oazs_lb&$NTp1GEeepflc>^BkDfMr;3@bor zho@~U&W;w3cHN$;b)1>kuxRa~5nG=3Tb`2LeetfQW54Avp?!`8wq2=@xudbR;G#To z*`hWc-fXiv-5nmKwi`-LFs3|3pt5iCO?|c-a5!dy za?7%Nt4^lOZ16LmqmaE5+;@!nek(uVOuxels@vL*?dYE24NBv(@Mmzh?y73w3Lb>3 zIqAz_ee>q>J?w#(tAbfjGZ!^6iXgZQH5!Nm=A$ZnE_ zX3D@#{Y8H*1d=qzwh#+}p$iwkX(ptd5|0MKj<@UW{)R(#x_7!m%xN`EIf;ZfO`yO9 z)3r;nB5z1UEJEExb0grsr^2A6ZPI&8pANMm4zn|gjmeY=v}&EWzm4c8&>~<*8w(E! zO0-a;PYVW*J0b!A8TvS0<4qACJ?({i`BPfcHsWyOsjv7scdomb=HKnB4P6l z$Jx~a_#4Rb!}a5J_g3IIEZ$YpW|`YI+D^O0pr2DSqLEkjU6p%iTdw=J zlj{dJ2lONLj@sA!agC;A<9kKbZ;hSM>f)V(mOUWb%uah6QLrHI)}c`KkT~Jj@74&y z2AYNN)8{f?`T%OLg~+`6X%n0gcbQ!f5jKBqyI+>v$>MaxL2AjqgxJHL*MC3zCG9qf z|F(;XDcUGeP3XI-08n+Ty}+J&>jYAIj8(nr*}ZF+(4?Q^(Sn57AD`QC=Tgh0cwq#; zz4nwwJ>#{S87)2HRqEM)+narkG|GH4U_9YzjCd<)}hsgPlUS* znvR$$6C~F}Q=NN%NanE8DB|I<5|}Q1rw`pEC4MEd0RWne4%#?4<$KD&)E;~`1&Rw!GD*`sE1m9DpzFZ>adWJU) ztIII{)(ZgEaP0M(l)f@1^S;bzJ^monL?*R`M{%jvb2z`x#zB3vRRBTThvLSU&l z4i-anD{c z%vq{p3X2VYApvCt==Ds2Q=x3xS`cB)35c|&!`Ls?Xs1U##JSkeKby0Z_Jtp_UV^?` zQ)z4|_pv!Sh~&YR5}s_I;ZxNx6sE~`a*XuWRVq4mm?B0imKPH~q!sA(WufXhCG6j+ zmlF~v(8GXBqH;nj^J&|{wvOS#Evw` zw27-${51(N9?j66XG48o2(`Js>Uaz^K85gcpi^+9>`qO0tlBe~l0&)Nt!6xeLB&S# zXtuf?yZq*paq*SiLi;UoGr*$J!#HXRgbXT+E|Kdi-_&zEfXo|YW_Fk`ne7Y}?qUp9r474pW52FQ?QQlXv^V9pr3z0G)iU;} z^h6&Z zQz;?0BODma@T4~X_z;J~P4MlC8_*=v0@SQ0d>a_zQI5`2A46q1+m9I`HN%-5ki(Lcrwt*{k*w6^@;v@dfMs=}G3^Tl10)OFTv53J$Z5U_$q7 zA|CTO>l2QWS|jLa#_Z?;541p;HodLe#_{KwylJF!!G^as*OCY%x+5~351V58TZ&l2 zhG2)v?&)ugL=7C{vZ7sQ47@~Pr44GIAPQdRMcF+Ar7j!R=%wfnHo4&vo8J{KUy`LL zq+#bSOx8mONt?}MS~o!g$BXyyqL`E4(w_$+bB&<)`4hR#77FN6^UEYbeS6_n}z-O_FXr*N2&SKT`+tR?- z_S99#Fk8?Slv&@f7C|7{&R|EqXMn{03J|l85Yh$I(D3sdSP$ac+OZ~a z=?C=s6UQ}xn(}l#V>2PX!ll^Ux7FGJ^AOzqg%1ML2_1^=c?FwlVRZ$KV!oHlGD=j| zI!#MYFeW^QA+y8skNLJ8a}O}6Ujtm%ZClVjJMO-;;jDkWR0$q#GCP0eJ$t)3!EEug zaHiV?W%G=>=e~Hlo=bFx8Z9V6?-c9*TplB^iyp_u-!BB_$`?l=m5cdloXKKQY4he{ zN@KO~#$W^}3ZH<@1j-p|2XIy;9h|H^u-n5RM|%fAmUysisH(8pISy!?CHGY)c=@A^ zw)@A=lc)nQ6Y%v7c*hr!&?_B!JaH(DRsdF%VF^@tW1D(d1pH z-unTyZ3ncvKDX#IIyD(mx_Le_o0Blx!>2oae4hb2z4HmTsFvYdW>v3$o>#F%%1hU< zIF*^jBn$xwMBq4^%e#CaEP}$xE+;WerXOE!w|MNpcB5!nPP7fo5XpV?Fp_>FHz6!i z>|;)Y>kdW}%O$Sl>iTObXfKCSvt$NexI?N}3a3ISpRyw8Q+j=eSP+L-tQB$#YD%)Q z+*&9gTLK$?!3i;_r}fLFIF{B_A^nN%s@|=VS z^2&M{4zufLIe2*5etVdbts%YJ&dYbDzQ&<)BMaXKa=!+MpBv{Bsb~?MYJY6fa^<$O zBur6<>fHKP`PT5MoTh>d?siKq@suyzV{#++K6 z6k%#kCn2*{kG0Q7k04EUlkz9SS@NvEyqf@C$E9J<2{js{c)#l`O)xxzu0DI!VegF( z0fZSbBf-%}oNy41OOma$;qccF!DN{lm$l_c(lmezyMW_A2Y#J z)1!Gw5`dnvc4E;ip7n;SF(uh1&6Cs%Q+6I7=ZgY0*BUD`d#z$oNtGQll&iaAEHKr& zYBka9M~P$J zK-+Gd!13I19VyInQtmhIT>s?BGeB&LfbV?UIuP7RvSL?i(;!-s`%foOlLWeY}P1?uOa zICiHoCJ*?Nor>p(&Ywpoo1@LmFs22jemsR6lPn{W4s9V*5)js4Vvn#6ND7VoczO}a zh43*!wyj#I+S)b+w0+$0)9YIj}uM%+eKy7)y89|UkvyudgJm&f6yrDm`9 zXoL4`ulH<$ce^@o#vLB@JLq0Yz89BP^wk8o3yN2uR42t74OdYpjP}E&O093RJnza^ zAyS%NuLnnQ?_Y0W+-K+1AtaSqw72H+;;Qn^^)w|fZa+;I*PeXyK668__7Mhb{TSWg zE6Lc;1kA9QeQ+{JIF+ptUndg04q1wln93_9KkPN2KXL9Zlh3A`R?(tU)sF&7DT{$D z7qqxLtEg`--e>pN+q|@Z`pu{_s3$jF1NE*9oxFmJQv2+{c#+MgD^CRcqtoOSmx9+| zsEC+lwSgFq+hFC- zH_AHN*g;%IMe2uZ%3VvsK+o6dn4R>y+l4aMGsn%WDVJ}z1`3HDkfiFY!^CP`m2P5? z`ebTJFGwhC)Nn(S^N3{Brk=~}_;dLmQ~a-G3S!d;$bS$rRzqTEC_iA7>5}-y88Rs&(i%Q*YlBmu8r5#uR zWWZAUR%;Lp%!eUeVgYD6^H|Sae+_{F3(3O5#TGca3y~1$AWkAPoqU1HA(cJ?53cE2tavKmg|D$Fgacp|rkW%?!PxUijUwDdV%L40dq&k^ z9mux_-=C@U%;qmqo{Fi*(fxXB?*rFWQe0GOQ6IfM`QC7!wlM%q zTMrGZcU)^yQO#WRS7F%3J)n&(Hzu*O0K`64D|C(R-vpds8oOv~sxB_0@1(dO<{RC3 zeoO#4S~{a7>}seaMAVWZ^kWFJA_cLXtKvL=BzWRLspurf0)K6y>aB@6h`c^XuEwDS zUdJiiL_w|+K3}5s#*&KB{RBK~aZn?RT`RtP^cpax;ig}bp6|1cAAF&eU-Fjyh12q0 zaYfelc30gwEAzM9u#66I8JE(v)wI^uq)ip!WzKOA#}_?qRL9e(w5z9-RMD0sovK{?Dx2|84*HPn3bi zk3}r(hi?1Fxk}Ym3Tq4{=W+doEjBa{Q zT@R;0(qskVMJhuj?o1*Mb6tX1qTEiw`EXpk=q%)jXo*F+@K4=L+{I*(Lix7$uB0%n z5=YT|5fdwGG`+Z{Gx0XhjOYE=)Z{yiPu&`|Z{(F=Ps*1(XzvdH+%9Xk>|Wp`5I8;( zC{Y_7Q-5p?ZDod$s9NxJo&IN`{ve+lH*;W{_{00%xJ0oW>|&^=Z7YXEcsk4WjP!rdaoI-#NE%@We_JIgxkrQm&GNb`-v zU=c8BcNp4JCd7d0L!r?^W5H5-6TZ-k{K-d5)dASde~dKNOCgwb0VJ_Bh=Q$GG@G?` zRT{H1>O8Dl4dfJK>1kc4>Vp&Q)P&?KWK-k4ENpP=gwPxn3m3rnTR<%ZsJ!-v6@V=;}w{h1Pa7&RhloElM0;DHE0 ztuS86O`RFCG{l`&=bbg?4`29mPw(#uY$|NZID*C{R*mk-jFO}3Hatk1wjQtz1)I@~ za_Pb=I|FH^g-K90G{MJ^Qk?}6P-y^ z)bnU`OVnwHw}5N8Ou(0HmxvKPPcv=&bumq%jPNieSAVB8|KzzU=BPqqW;EGZsp!Kq z16Z5tn4`nY*BC8FVVoRWB&&3;W{s zF_8ud$KVUXAvS@>bH^8ZIHXKFzAbKKN5t>4yUGYy`xnRIGEt1Lw>es`dT11{$nYyD z1y(o`BUhFr=SKsOm^wgGppl3Lu7uu{U9v;TccfaXTVti*I2OcH=cW;OR2b_k41$uI z`G-%5DFG!wA`gV(It$s_FN#Nkptwt6!bw4dnprlwYaxnmh=3?tMdZ?h%4ft#y@aNV zV87At6BRp;yH_UQo0Nrz1*bh0sfwe{g-6936`&y~%r4-38nMI&)rz9b2_qa9tFDNP zY*Si6%_72|E6B|XS-}|<0U+Kc5+;B=XzequH~z+CWtH2YG#L?wIgcZc6%%?Tyv6f_ zJn}aRZY>ZES`099C#POhLyT*nE`)#3Y+y6xbUq7GrfpFyOA@XtugSW}V{$4DyhdDWaKb zV91VskwL;Lw+&WOKqVywIU&--Lkvh?@9z1GmGujcCjr~Ofq;*f1KMj02HqJ)0H4jq zqxSVpzy7vXWNM{GQ^WVH-_-n7P$gzX#!zNxWJnvpQr&l9$|%EgX(hvvp*WrFHUmVK z^Y?uIW1G0g)qYpTHJmry4*N6GR_xw0{v(^Yhj;8+_8v#P+wfH?{5tZP+in>}g;?iO zLtoKLwtwz^RQ>n&@}?#)FXFZP$=zmkhkOgXSUYU7c1S|?0EMdQ`cjsYv#ABwDW+}x z@cZmis*8I9?e$)D;zWvl3xarY^Iv<6m5D@N0Ie$SKVDk^7MRC78J=`=4<7M8L{qLL zue6AI>AcwoF+1lHKf7U(eOR|s%C_9QT>e%auJg9~9P2diwACsk@VHRAUnuRJ==tM? zKXd0(T+48{nwDEKEoHxswT(gVq?YX@Rqv!!?mX8PUwO<(Gu>`UXsfQR{4#CuY14tl zuBP*iUcAU#N^5RUP;t%w+v{!V*&IkCZYr7oI2z7$c=0*7-eHm(F01^z>^UagV`?`* zM&N68aZ8HFb?V)dbFIUcF;FCH$n1*fcdFLHG5p#uR(SW{@87~k`G#(OIQ}&Rh7edF zz}Y5?kf2rt$|pMwZ`RDvu~d37{8YO>qY45mslqL2d2DD41?}i)^|niMkfp6*ltCwv z^~g-Q3&153xopgbj{=-J#91t95w+YZsUb)AJ49Qpo#cQ>jjiDy6VTKdEF+Vp`A!!M z_r)fWL~}MR4Z>nFF3ac+wo79xknEwIz#7Mg>V;m$71+sw9fMpULqV#MDjCPaPQWRr zo;e3B(ADGa%%3%J8@CDv)xJp^_$`jV<}QUEqT&Jo&P^JwLPiCTPX~bKv;`a0Di-I@ zRA;ie1rsB#Vsl0Lapy<5-ZM@pkTg8Y+%_=D0|+# z>X>VxcXHfK@c1VDQMrX6P3*`vz3!7SCN14pIaaN}(OyU?vyOE9Sn#=T_k9{x61sD$ zZNybtyas8r#hP$slzZ4mcuGsh7ZYM_f>z5)xr zfJKmu7D8uF>#xZIE+|Wtm!&}#}O#RD?C>o z8jGhvEQM_416L;50i=7(uW5VtlMy0b>^7BdJCio&!^y6{LNhH$rU7BI2i^f6YR3#O zX=K#9$R;!sHLq#?>463Ab3P?U*}M!7HhUSSNCxEg{2jKTT+1w0mF=SkZp@j_S50}W zm=$q603`0%=ciEAXgz85)@!C^Jg&y0P;mC@2yN50rg%|ia=Fiw0hsE7YvKWySFAuMH`9~c z$Jv}r&z#5G6+ZVKTcOxJ4L997H-V4P` zkCXE9$3*0?A{1+^1mTJYD}Fx=5<9;aFzHxZ32(+h+QA^4LPMF=yxC~k^9cY5_a6L% z!JyMz;uF98uD3`hX9VWEM*0F*E3uuAF zGSXobJX8;N>InB~&>@c7uqKpC3`4|pjQg3sRYJnWCB<|`3gT-X^*%x_&{;Sb=$P2i z6N}lU+Rb5(ikVi1#X}Hl3}azPTvVgYg}x)92X(Q9(6Ah%x-hSrp9yy?ZF0Cp6pepp zy|Y>lX!=zYMl9x4?~1k>OAqxKlZVUPEwd#yq%GDT;+SkFHtZa?(_K;*>l+$R=4b9u zvKT6K@?%eyuS>W|QL*(~6;-=@Z51ZAL%z~vG=}Bp9sW?2 zi5{Oi<$6e_(0nV|5;-56Z@ud~h)}4~=HZA{(i@WXdy36qQgT}|o~C(TVko&|u~dp) z3(h-4H@ZMfY0TtFDlKx7d+JQ|Klkk`MH}L2lQ!q8tR*2LN-0Q9*)cxegmP{bK9xV^SUEB-}Z;2F=fge-~I3Pgwp)^@>dIB0n6Nk<$E{aO=Yi*)~BqmM;l z&opL4@Ec&lR-xm4!^AF4>VmL?#FS(tBwHa#?0}erP&5*=tN)QX8K|yr7u9RCLtTEep|&W2B%+$8NJ-v!zS3A zt>nWR#7snvJq$@gH24&Ff8P8f)8-3V|)vQ=m85_S;#z@ zj21|rTpqh96Np-Av!H|YrwDb%s8}^svo&47C4F{;e`%BWSms^l@it;xP=>5WPbQCVTf$ZBJ}hT+=-K+3WGXTA@*x z2qV#b#uKDb-R}6vZY-!k=a7TNp$U#o7NAQK>>850Hqd&TbEgoZ?$Kr|V(#uZ+4(%p zC|d55-O(yUEp^z4GuVjpvE^)jF{yK|J;00S68!#DKi~E7)>*m18x_#{+T5r6QAB2& z{FuN=WK#?Zn@((#)^*e$Qa(O&+}~6$jAy^j>W!#LrT1>I_CSf+AIjcq^KP48AlZ z4M;eOwn&}LgA%vTx>MzqDrX&|rcq`t+&eISJ9{d3xzx%G+%sJFMBK<8GieveMCC;n zNZ`m0X_Ad8S}ll7jiU;4DZ8lRp!F3u84u6 zS>FS!7sJ}u@u3N{qGMfZ(2nswqM_ZNQLGyKsv`8$rjI>?>vfWXU8|kL+K$`d%i;bH zdjm=Yk1y~UU-KO?oNQ^0*QD)p`n0TIsQOo`=nzB;@|rGFXB)N6<*Yz)Wrx=KobqE= z67HG4^>{fiCT5FG1rnEV7-(bMui7Bjk{w>h#?8iE^W0&f`Y`z^2@{#CHvB}-nxK16 z@Dax1xOyh>gJBGbDM}wKnG5W9ggz(2U~M=0n=u+f4y*I;_?!-~4!F3S_qyq?%VnQ1 zIU$R`mf>j`28`!`Xt|;gTCdkYDSux!K&mCEnA6l&$N@yv4MsV$8Ftz3r_TV6laGs! z);cRBYVAK!`i9>Z^%N(Tl_6Mp1Z(@W@nL=0LK}1a;r!U3_ekJnfx{ZcTdF|9uv9=o zDZhSsw9&WuBu`Q*@dzhkjG7?)diJO9s7*LEb4rR z7~faK2VHiStqYP3U=s}dwKI}H{%lEOu2bq*`&44Lsm2bQyulp$`_ne$g^7k3oAQ@+CAFF|5zDjMy9txai{_5rZebc)79k(r(v5{0wL`l>qaWv zh?Q+1&F~Fv=azEv8@pKF8%jm?_!4sZ!oZlDRNHcoA0)ti-3ewmzGce+^Un321$~4g zb?JPCB~@8PY0|eXc(43tGgmbrQw)Qn3;BSl@6>CPKZB(T0GP@RK7xEi`Z#-1IdRa$ z()ocw@u2ynwQuqYvIegZ2MXm&d{=td&)|tUXbQ`W$N(R`3QVfY<`0hklL|?s_$5Q_ z2ZjQI;`b#yl0`eCr~6ZT1@gKg+vDtxQLfuTAqxe_%|T}1<|Eyd_7)3yX?dtN*V9>LFfx^5(cQM zLycYWqB*URg@guYNM7OZ8N^2Kq26w12#pIYQZuj=t`JszMqEGHSzERB%ho~d)ZN#J zqmNG^$?n1Nsz&4e7ly6bd>eJjf}J~L@AZRp%YByB#wubyl~w48RkBm}3<1K7yr`*p zs@+0&4`XdjK4Tnr5757SNyHHI}zE#fxeb)9@blO?~JTBWhz^S<$m5C)-gIR{bvLd zp^+DblpM=cpQUx0loi=%rP;61!}xa4>I5x%;(fKk5&`T1Q<^aIO_@qPWu ze0!~5JpugGFw9QhqFnEjy3y_9O6sHqML03a36QukLqSrdF34i{hi4JwVm09?)McH>lM}SD>~LIy5G3e zthk-rj$kR6o{*-Kf9K_)oxBX*quy1oI71||x*}|o0C}l@oPlt<-iCg(tRs|}6w%!c z@ZT>FQ41|};$%8hm)_nWc{y@h$;yG49@Ga$k}<-Vo-+q$t^>qw6nGC#kIQ)-A!e~7 z-a^|I=NAoX*NVt-0rD{yQpEL>#3^E!F)6tsAS5M)H23 znjBgjPeLiD6Q(G;v}Q8vcQ<|;9)7UV1mCl8gHIcknKIo;V{d+{Z9m5InM9IHF$GUT zUgPwK?2?O(I*PQgZZV$wGpR^Lu~iH?xAX3UiK^6?tAh8X6z%@z3;Sk> zVPCagS}W^%`T)={Xs}R78uHi`&$vON1q)3uge0n@ICH_tU+Nc~7~6r73u+elYo>8iz~z zF&VZXSaA!sx`68WIW9+DLCbYQAu-pwmSqS&yyNUD%lsIduQKQR)58z}>lX}|a& zncQ_}78RFZSDu({U1wnqKLP+E=$~|`tdK+|L^L*JNT@g{ty(8%qwaUUwj_n~{XDTq z!?asqgI1_KHAZ3~MEl~To(g8=Zt6K~fgf(q^<=w$jO{n)R1ipX)RePAd^DPXKzvI1 z;btF2^syk)rq{I{K})P&{DKuYR$hLe00Jp<^rOjvLl#RsYac0MJb*nCf)1Z!e6=1Z zFq2R@X1H!>u8>!F1QfUmQW@skAc*8qDMcow$SunBk`xg}h{?caB8;E*Pm>*JrDph8K>V-yuIb3(^t+s~u(up*sE7%g+thXFnFBI%`|q zP-7%}p3x~t@}<&xTJCh0V{DuV(7N?Uj~mavj3qy(1)7d?g&hUm_S4JNGr!Ewze54} z8yUBcCR^SSAwrT{hizENuybc)nwkyE%MXiZt20%yp{zdZt^><7y0#M<8}m>Vb2*w| zH;+>L1HY}{CVn@YR&A)_ecG=`pDXq>Be1}{p7Awv-1MBg^k2OBmjX6HCwKN`u%{WuTS}Bu&VgYj+uv^@TV9=MJC;A@CJ6AJr2})?xYvL4*&TUw8&v z3d8G1)lgbKqj#CRFBBhEP>!Fo(%5b{dN~Wbe-9AWf(p;ynN^W{TPRLi>PSMC*`L*VpakwZjAS6E43Dk&}X-hYh$Q^cdZJb>AW;(F7D zFnw!Oji@C-rxc2D%N{C|wob+*PKSEB?Kt60uyab#L9-toNeL ztVs{~r^p562rX5C(zw^I+~6E}xYC}+(q|@5FN4B3oYi(x9n%`o#Hu3PL}nRRKG!C( z_hm_?J><3mYF{-Z-?Bx*T@|B;)!k;ZvV_ZZW|tM2z5*w1a%T9ixJ>RS*T|pl!1Ml? z*0_-i|CI=$)h5RVY)DZz*b*2AEusK^^642pm03uFuaC~Oup4M0ZiK+|*3ff4`fCzx z#P>%c>h&<$Sz37dgZMw`5dl;p9e?Ks@Sl2}6~*9dIl*dA7q?xJE=TstW#$Ew7#)z6 z1D%w`McEko<5hg&+*;*;P|0d6h_m1YkTQMIXqo#AVEq*T--a?#%Y$r`ft@%$I;VLs{4bS31ag`p=rpJL+ma4%i~tK(e_P? zV|s<_Wr;}7s?+2cA*Ei>-|x+1N|_=CHLVB$gqb8LE^t`{Zqh4~7y?8CtT~Y#7DCe@ zM!h0Hw9>R?DQtje69UTu`J0wBK(-7I{p4T1Vzk|B(B~H>EionzOXrUeX+dK(U(nv{ zQkkp6t);HU*Mo9Zo=d%kXzUg@9mq>?aqJYI`FLEKP-iv3I=4gmmTn7@UM-YTK)4W(JHhyAkW0~J%1*n?Wd3}|P*--j72U$2@^qa3 zw8=WJ{lp2pwcDy~eO1;bnChK8RB-+nxZ!j9T69d8rOlDy>TIm79WjvecfQ-oJLUvR zD_i>t#qyjkM}u|0yhe&vwpdXOOe?JhG=&xED!8+P<=mp{@Dye1F{MDYIJrb+xZ3ka z<+93n?oriYV(&I3q{OMi`_yGv6Gd*KHCJ8RN;@(g>3*EjLfxbbkBl1zd9dNV@KMV} zf2l6&S-gLX$LSEAhjo<4g2}jjs1M>*OKlTt9kBIs6Z7;X*m#uFNSu6s z<#}3w9J@IHtCBwo`z`SOXN_NuRX@Ky%6+NUlD|P114TFZ#|(7ztsa%@=6x|%h2@y2 z@(@4F*i*Vx_@Xw6c*cHGf4oI3y7n7Ofum6R6d{Sx%0iUel6=G7UyZ(g7$gBV9drLl>$jGUI7YVeL5FJ-45-Qn{EXsS3k5d0DSQT|q>ucxIQ0%F^(%E*(U( zoOC@mzazg&wXk@>wMkKx(|kfp9%{1F9C^ZO*qb5l1{54?NZ%s zEze{|@iQj!cBS-^h^y74JDly+x?7I1nj(p<05i~XD!|>Q_P8?f40rf)OzGlcD~UU6 z-Bd=b>BI*Skp8NPL2*0-0b>*e=+j;?W1ii#w`eNUT~*LyHvP%lP{?{o;n& zLk@lSU5Stpd}D_<OH-pH8O{DwXc4PQ8TR=tR+7C#>!m5Y4Z>WNCPNxJWF zUTmtxwr{B@a>tkSD11du9<6uU-E;Q z$I0EOaKY+oQ@vpk%fhVB9Q9j9b*4H`LiWUKi#D_Oo;h-s&fM9Ire^&zYF*j!R(H#r zgEZ%0^Ox7gw$+BtB#JHcNx>l5}#9Jd=)XUhHY*4YaVR+gU)w*vh$CK5uV3p?LywBH{st2t}Uip zmj>R3#pa&Pe?m@dr{D3$-|#g4s>B$3f;oHv8SK;6b%P*zlI`}4_-V2tu~vFGF0WHJ zyL5VJWi6u z8PVDiV2PWk*(^1`xw&lgO)ktBiRg~=LTrXq~vyfNyT_j1Cz zod$@-Oa1v@4v4M?uzJ0$8uP9zzT3Y8(D1w-<6<5(f01c2^IDSDW!iTB<|=b@Wqflk zRJeWTPA^ZC!U#cV^B-b5Ih840?f z$ByG28vB)AK#oqE0ZcfM`89>fPOw8AX8m@ZNe%ez@Q3+@_>c($$%(IFkY?TC8S>E~ za?RmQ@)17}0PXRvep9vTmc0NK@FIqK%3-?UP5J)X7Hz;k;+qWk^+40mJ^4ZovKJ%u zYz8oxuL_{p%WV zD5IzNWhZRUNB?Pi9rEBi7TFdcyynxI{Le_vuOZVX+?iIHk8b*}UFrK>RXr|^z#z+! zF1BtwUi|m*>u=oijhX#XZl1cDJ?>q@`{{Qb24tS;d2CD$cEoBYfM0Wrojrow@ST>5 z>tG*3eh1d*4@x|WdMJ&W`1a(r>eY<6@)U^P%CmfiY_wo7kWUqV_!VB7Yc1|^%lljW zSX6+4!ZmSMYSE>scvy1BT}jDN1)n^ZAi-xTJQi>lYm*_Jg6REJJa4Bp4sAs`Znckx zJ4*TGy5*EM+Lh^)5zI4Q!Sg^LOOg~;G1 z^4v|=JQ;3*edQRZMY?7Cadp@jSa9}%qb~~&TCU?^?Y$UD$vLA?cDS}-FhhbkozYygIAu3G>2k**9bLsE})1b$1?3$Gz;67EioC1uCb01JM zYz`xtS;2goUsySBe77+@6)udbBWYr)lny^Q>zJH?YZn5Seo#N!s(0NP(oGWzk2UlC zg(Le+TI8dYz3CZ`ZTvItv$maOWdK!i4@Y+06|%KoKex(H$*p1gh zCwg*>Ao_yb7y*s!=w1{U;V4(g-M$6#gkc01!l~FhAh#*_-7!PTd$3%wchLNoBy$i* zC*QShNxj93>bg9yTXnWE#+8>;`zz00T-od>lU$DmCWOg<={5^ZZ4|j5=-ovHPfW&G z*t>#{CEAom#)Hm7c)MfSKMhQX6Vo!s!I^%au5B#Z*Q;5Kt4UChVYaJoRL_L7 z&KTPcuXoP3W9pQ!aBNu%=4B$3tUzOh8(R&e4VW@5>zQCzJWsLsvkBo8+~@~#z=Y_Z zv9keFpu!%Pu{FuSw*xn4TLpo~9Go>rOD^NxEH-_h>hKVp*)kWVJk<-x8 zyap6*60$xm(z*Til<6!n}un&2RGN#-u7m@vp)82{`M`IfSsYAek06s z0KZU^be$glKEM&C{XnVV0R+8QMj|@s%ZTmOaTiA>@gk;)B}OXQaEl=GfN&zV`G<$m zZQ(+c7K;If{fl?NtNjbvbXw{azEL!nT4s=S4}^5F9*`N?$;Bj{*aUBOk;o-s>!e<0 z-x1*mu`+u5=IJ4g?bQ1m(g-%i=NlhenWUW0wy;WG*x_SiLN}RHK2c zJ@9s@ErH9<*E3kJsZ8FTmApmk+N{^MrmvxA>szeXxTde88(8)=I?R*ewa$qgBqSsh z!$vsS#+^fwz1mdE!n}(q|JJ-{M#W8j>it#NbbNfDw?`7B0W&zxOH7n9Wu2HXO6C5mHMW-N&> zLq394w&)7Hge8ID^10+ni|0s(7~`?PP{yHA;r9tF zJ*-KnA>`p@ap4Q>OuZp2VFlr2q$CkxX@Z1{(FkP1l_To_p}v|Ic`jFS3s&g!0gB_o z3_WOvcqoj2(J_)D8$qESj~);C*BTU&ASGdOJ&|k;DJ+a%zbzmO9v%5NkJVo=IBuHc zPc}$OswXiXsvwgtu#_a5t##Hy^jF75r?gH!>;NPX8m?ZZNr)$!Sl^2lp^l>+92y6A zJ$6hS9RU16Yk(7&5b_}+UZnen=VWck;LY?{>N*#8tV6_u3D2x6Ng><`QSzE|acPaV zJ}^JL2zbXZRkR7xkr@fby$Tw{nOO;_qMYg3!AZ@`=v)@{jC!_3P2tVKlY95!5c@o2 z2SB@c3x(iuHdOZPx7yi%cB5s#>&#}Hy`3FfZ<4LMnXQp8*_tEAG23t7N&{ls-bS00 zIS2R(xI^skU`KZkM!gVHN4L4$-a`qO1iTr zI?&rSovOXrOb1)8C2#M+x%d2T$Hs1+?VWy^^G#W!%$AVBLf@kI-b&;5>*7{Mk9bpU zuB^KM>Pnt7EDgw`>r9rb?nY-VE0_9O&;Hz68_ihSVO7AEue(l00zUny^UFcV0l0@) zj;8rpk8(h^_xsw?mAAT27A@~aYVMZ!-*qP+U#=^>S@xDwZ0&d-lR~(4JmAXNUQmjv zpS(uz_b+m@eTz@-`GWgx^X#nYT$(2GrOl=R9Oc556jwJ>xa`iOY0e*igKupVH6JQ3 zMT@%Q#% zcsSJx<;SABL=A^b>d){@w+3;_`b!RqR61;M%@XnI-C~5duzS-YjCa{&ts6^{lE&>NgO>%7O1VF^kGpFvikg&a=`X zg<D&(g+7KqoK}j0Q7?>Xj>PSW)VL=aX zDNj(}o6^7?kafmWA0X;DqeS7+#2PL{f8<$;%mo8?lDU_QsE$*c0#CiPewL} z1+{NzO%IJUZ>vq5;6jBZ#CSA9oR*@4Hbr)3KlKOF>+f~*bg)E7Z0A<-H*5OSX#3Ns z`}-Ka=QeeiWP5&0=NNZ{9z+{^M1j%bezeBQoAc#rHtmLXw5mVdw#t9;-_EgnBcNZ| zK=;yuaa`KX`Y<#Z?bf>=b9b&zZ^Rl$9UC!R-5=k~TFLq^nY@=?xvaYW%i3pA1vu2f z`>1T)V9mO~*2bK5f9;7ed2-m9r?d9<+St_;-q*pFy0AkWy~I#jgXZSW6TUC^t-t1P zYJbA@%l?KE#AjF0|Ao5X8z3)MC(Y%hRIbaz#O15TJ?6lXEMU||iRjFhNTKv^+}Jd8^; z>M78$SGbHR@iK1MEnm)(dMP#Pkvw3BwVu~u@#vZn3$fH%ULmu+FgExq0(fZFv!#D* z9#*f3i@AwbPANl6tr%3c#*|0h1@l{Oci{}n2Q%yr(}hb{VjWxEN3)B3Z?}W% zOOu^wV};avcehc>?B?q+2Yd~zDF?XxRcm!2p^d%z+3d#lqvuPVoZnisgz17WL$ z^Uxy=`#!ctcSLYCa&6o!r>{>?mnF0kJ17$`XGob=^ehf58|C@R02G!z+8ehlqWSwbD26HuU4aPYNz4}GAzAh*yN;mMHb@JD%XA^76Q8XCt0H7o`2flrh6 z_#U#lGmW0!glL24qee2Y6*fGI9`xjdbvCLfYk(xJ&me-|kn6p`wfsT6djYH9W^Y1k zr5ib{UFx%^(_i)&x$~5P5D3)MRA0~s%m?@ao+>(j|HG7IJU;Kv_D>&&g!wOWCo`JY%NLt6%09qZ4W$MwT|sIASI4wmrbh`^%2 zgsG$y-7?#;5F{mnZGOdx2ia(B!w$1>dv!&lYH^8D30MVEZ1ZO0*L;br8+wIqM3`=b z4pqr!n&S)<(^kqqk$V7pqFD)y6GZ}cQd`1vE= zch7ZAn0|i20bx`BzP`v|76Slb5kdZ6RnZ7S)xOAnOC~9}imw%l8Z#Ef8H5#iL;+9@ z1WF)mywqOF@e02jC>MaxPB08w>4wU1-=bGpSG6?;vE?Odjty1$@Jqp5`Fwp^lRZo` zPCw8tkvlPc8Vq<4@I8pQP~!n1ahFs-sPu21ilVC7xyifxbn20ZAB}SB@ z9?`w&SkR&_FgvrBPgeT+HR^S2sOt*Zo0!BN$${STe!VXs@20?>lKx!0rd9MPH+hzR zM+Q9nL!Ji=_@f01=rP!s0Fib^kfR|;xI_utrb1`z5>;GysQa$KX20gj=FO9F6uLd-Cocn>=!7!M{s<%5a&0FrP7gP|nr(_pHK z!^i}<3Z8j(Ri{qy;C_${5+W>6UZ%EdvSmqD7t)DvH9=@uj=HHH(iP4u1V>7H`9HE& z`H!p?iJ7w2^R-oiPeoSa_BoJLk^?ze%lXF7psdH*s^@uYXOqNYpzxteI!3!1iCGEZ zuEv^)Cedv&9UbZOg&&QS7M4Bx!d&H00r{6eRC)Ocxx#|APXcUF-*h>0p(;%8h!UjRGx5#fGSB zP?*>82*y;=Bgy)08|X+g^fwi5(5HM7PLUom#-dIjOhu^>Bp4V+ON1``j!mOVvbl+> zI1kC_6mzfv1O*u-@DGj{Tc@J&SJLBz7Z%)0!y>u%E)Znx4?U!pzla|dAV4dEJA1xx z9mmJ$AyrHf17ZmIV2HtqL`J}a4l;(T0}dyFKXgYVZ;K~DIC`+EpXQpQV|~SAu5pBp zlA&SXKrzHXV#L)8=33iX@vUQ+A392QdzCZxGn7~Oxh01Mj9#yQGOh)B&O62o@ch1# zjfx1Pz}9c$+jT2J#&|=sI0PTT6(saRsjYCk+zdC)beL30Zdoz0y%oO&%yeAS=sVrY zal6mC&3H4-9tEDf3x_#3`gT-59))7VGNUt@ev!%ZGo;aKyUApg$_R-r>Ad9GJOO72 zlZX5>q+LB1mOK^o*nBQ8S1e16-sV2@GHf+XR^-ii^{unBe}5cw@CIJ36Fur4eros& z^E-1WH@ZI_13OP-mYzXxZGX(^241}vmfUEMNiP8_12dyqj#Wv=23d{`L>HM(IMUp3 zv0-W$!ejp_oEh5n>`er4}Sol_>a0q7q@;(5q6|QLd$(5j8)C>d-w(8P|0* z7H2NBNPR@|Jrl;0x8d}#gfFS2E}7vtVpR5jX{jg@@@bRuDVM_m*9Ogvp_Bj<7$+A- z34u(AqnETN#EmGKC`Mus9IA}3oL*xr)fBcU&WRyNMG}03C&Un#e!SkPSoD2_APx^| zutyM#=pwko9TwG=kGkOREd&TEQ6}(Bs~#fTVl>LUb5)vx7PaevYS+W!v4v-mJuPS; zSaO615e(Ppen3fx8%7=he>1S_T?fz{_Tn%ej{{KGJCUw359xCJFu7?Qizf((eUET3 zgH<_{Qcd?`RR?MVSvNQw`A|`Ggm^p+g3-7cH-JZ}*PCPAj$DY*WESrll@)$U5m2?M zkswo?TAD;3y5<4%Y}32S*9weYztf4rp;zvQI@p^C>NM&ioSmmOtjKyLjJQmxNLTtU zsHLc*y?USrVxCbBI9a6tm2fMc*h>0cR4d0{VQ3V25h|aZzWlt_xPIi znz!DdvA5lwo|}uK%@$*O)m4Ft+io8IuIlmmdR;z8bK2T#HR4Nb!JAz3Yc}G`ZpF<$ zVRpGMrhz}h^c~yDc4?n|G1<-e#{J=5`6r;y<68~f@g0VjR{)k~@9*$jXt$^4II0kK zpaacgA$II)o{9FmUEb(%d&ya4BlqesUo+6$JZ4VR$ATakn}Vu^i>W!6aMX}0VxCo0 zT-sT^;zU$Iy-xdf-GAEx<^Z~T1(>aJ{y>xxR3EexX=@;XCmI@UBvJH82v28g+a>#L z(nPCa-~S+rfiEcT6p1Tpt_+7aK$^Z)Xa*A6cGxU;*er48c8=9h03LOu(QNp@ zwMqy=V|(y1N!T?wuTFwaoVuIQlv;yHSX8ogTGEw|IJEJ;Di2NJQI0*le#0ZTS4{8b-@jeL*c8gf9)I5`?YA8+wwsvv#QX|aHhcA8p3;W<7Zug>go@!9&FgcQUG&qbkh)^5pY^teq!XHd!=y22B zM_KCx!LvFXqs|UwkiNwCGJNh{1kxU-Zb4`R`L$wasvJYP${o7CR?hSq<$<%BaRam503S|+XBBoqHPSl#p^|eDE?_Z1={8B7D7qQmA#x)AY=N@sNNt`$syc`RJyMIhDFL#|AH2)PrUR2{`Uwc;w!`;{~r}q z{~xJH^1mM8{NG{2{|jKm`JV(MQxC&`?(P376KQEXAZvQvrfSuEjf^MWwceu!HNYC% zv9V)s^czfH8`9)=W}Bq`_oB{ODKxOP2u$i;3YiUQPsu6#Z%(xX_@bhD$Ni)-G-OJfl8t_M?o0 zV?hRb$uk!>pd7Uxbwo49Ke92{Rl$A??d^LO_euXHJcE^eYx-S)D2V^;%!57nFQz>{ za}*ehY1oD4D28lAwwX`Uu9v$E(NAbc*8pnskpQJt26qwJjU&WFEgJP@C^WJV`|7y0 zE5*nB85`b-UX3r!?-6Wfr&`Z)y1oc}Jq*i0fhZcwg^-;0xG>5RN>oi*5iq!9AOU-c(HG$2a2$cyzSDFu`!-V=B{X zFBN;|$5%O0EG91$`9t#Pr}RUns)*VK=duh%8;t0to&twYE*387nhaxs^1Qr=lT}=Z zQW>q(%~BKr34|)X0@#y?3cNYgNGpjlu1kDY8)2oO8nA5?H|om#BoQfWVk) zi1PIt#S;gkX!MV8LG^VTYk*ipqL>Q$3iIh4Es~R5X2Gs0RHhmGL&ZoL7NJiid_G+g z+I*G5e3OK!TvaVM#XOj-@-je)hP|TFreiqyNVBvE?tr?~>I=xG1d>n7GoD7CK9I5Y z%9xaKU$`cDjBnm0OtM=%vnWGxP_83CF~-lfw@0G$nWh-S2j^V+^J;Y{XYA;J$5ej4v2b@@pVK<+`4VAV zo~NbJl3pn)vDk3xL<-=6k;W`I=KR5dVeH93!@Ky;Jp^&6=uC3{09tnb!B96lg@W-e zA{whu5mBq(y*wS`$_NpP7(H&7Fi(`wZhn;U0!Y;2g1o-KQ)U!M6F57j`ULn0lv-T+ zr63JE8k}QwK3>4$40+cVd>5~HT!;Mg8y>pF-8u%g5l9RFFfENNl^~S21DTaAG3!u1e+l;%b0lfk8E=aj&1f464Xc8In;a8`GNA+$=YqlB)s*xXF zp9bq;DAlq%V&&IFQ*YtH-S}p9*$(^-X(dwBDbg$?(kvrjXz)bRucOuDRGY0l4r+U_QebCP$+Odn zptx!|NpdXi->1%x1bVH*m9JESy~vj~ZgcK4u_RPn(2iLX%d6!B@l_?sr#T~VKf65sCsEgKufK^kUy`;*PB*b4H?g2x znqBmUl^K^^3*1W&>w11o)}7X;R)f2pHvZ&>{^XW>Y$tN4X>{o9B$3#sp-CfIWnKqA zrwJFt&Mr1a2O{4)P3*BGQT*wbzJcBqjE46A{L}oJt3PpDr#;LsQztbMkE`n={?vPo z^|XqsIaF10DQbZ}rE50zJmf4RtEfW8t-pE{SHGtr0re?Au?m7|{A?R(vZ|2Es@A7bp zM$YTWCp*RU+~S6uvJ@!kyG8{Z{ce@$5|(D}FEWsLBigcij$#zLbvqn6~hqD}cs1Bu_oLJ9Jq1e!DhNh&81U&t8 zF{&geDj$`L>oCg|Edpf7lCzysn*kAtfn1@mMDhg*Q&X=ow$4O17Ql-06&EMs17R1v z4V3-(_V4#Zpsr;c0hRVK1lorXZd21*hYBF?<)f*2xw&=a?F}LYLi(v8M4GUAqEsE* zh_-_Q!M+LSAi+lYd9L4Lz(1(_!MllZ21&?nlB^&n7*~QkKB0wld=_!;PR2SU7620v z!gqOYS`h6BtOl&)nlZz)8!W~~GV^pItSBaJKBh_^cy{8XfzXnPM zmZWvf?H<~Qn-Vm9Z4R4KOEPgCBEEz{~( z%BoG7gCMUyImuQ(T#sJIUSt`m*pk=lnKk>MRd(gqSO1Dl6wqzGkzRk@D(;V@-uys% z|2;HV{MB`6Qd*mSn3C0vD6Iu`Z>;ccpo`_UCa}YLKW%7Xnd7$8WU}?maUV_s?lvZO zEJGUhZSm&j!E8)x+t>)&Yc9Ie)e znTP7MLS#ne$&St#{cbL&a{s7m#!0S*UQGxjb*Y7KrAih8i9|6n6`7^$1W;X*yuegR z@l9ELj4#*=uhbG#2T*RdW7D%tTh}`zVqnKSP=`Fv#T*M))ohD;zpIg9N zGMsZu6#wSF`o~9M)33k$cTpe^h2_o_9*?7#=Wn*Paj8Ghle``E4LMm03~q@io;Y*! z_z{8Fv7;8FA3FV<>im49jZ$vX#zuSO-XycK<&^d$Y1qG=n|zZRjYhfJ1Uh8;2R*Ih zwhq?Cy4nTpWuZKEXx~zn2wCYC29v{>t`TyoK>MQRlw#cwJ}4j7%4wC9ggMd@^Y}V7 zyEq_vQyF^^K4xReKR&DK3Gklv?|f)P-q&M?6llO2<1&m&E=JLm-91$awKuM}T=?_?bWm5<4FbUF+BIP2&8r|@LL)t5~D=79l)!u-SX-OW4 zTYp!ymAdVrc|8%sSY0I7k0~({Yyv%p_;qe`rPZ)HweFX@`wnRx@QUFLCZ1u93PYX z0ce+c5VN173jjDUp@Wk0q4Y7LQ93pVv>}3^oEvGtAfG)b&f#?>hNi881>cuh;v6wC z8^(d=6O!JIZGPrNu(+oG%Fi1%`g$EF5wFEfUXf_nF*+|4_Ym7%z5}m{@uCtvRsN|^ zgMJhg{Q`{olxBuxYV5t=9Ovgk6Z6@s{1N)tzt6plGwoyWjxKirsA`5UXTf!bL#VK) z6z2j5f)Y+>70{-E>LjXC$~@OJ!zf9CyDv0JR^n<;x+YkuFenjZ1IS%l50Th+!!iM) z1@&fB{pk?oddyu$<9J-E4gBX#V+2-wEXY9uH;%Rsra(5P15ivIUmZFmaQ76p#K00e zQ2b`%Lxb_gOm&3)3aYjqs$ekMya!aO?_dRAfirYW@AuZeHbwjR#+icjqQk)%GndjS z)`^upJ37;OHwwpZhz<`2o^kOSu}DCDaLiHHNb?;FJxt9PonoITG)tL#h7 zR@lxWd`y=3y4qgx;(wxa-~LlR+mq0)40+hw*#(FPpXm+Z@qIP=x=ZykB!y&BOmU^z zjm%&Gtt#+RFTRd2R5;G{03~DwadMDR!XUpbC^5Bft>F_s zBCbIJsA~3?E=qAzCIaXJl>(;S(`1nZuiSi@C?#_2CSzGYknc@jfcbe6YiLql5flJIUo%+wGM!+xJaj@#A)Rz?`hG`Lsh(`P$3u&wR`awO3*FHyf{X znUzi(xjDJ=3Ts))xG7 zMPD_kARJ2;NX}T$PWTux22U;vcu4)kQ3uB@Y^m~M+dc~g&umjUx<6Gnf20P@w?=S% zd*q1zpZ92w*s!wqmUvAh4<#pbLl1wM>Uj{`F1Gie0{AdHj593bv^!Rs)RC^Zu`r$o z!@r4S_cE$=7sPp0ke@&uM4{?n5*uVCRZ*<7`f{i#)5#;Yt_`7{d(bhNrFj>X^(lCP z)J6`b{MqN3Eaht)92EP*hnPsBNsn zUg*o1#L}w};ObV+Hf%HObQCS+Jk~)$)H#i?t3i4iHsSu}6n^hN?;Qyq=ZA@4yviF) zzGDd&44rho!0D~zBa)%?hx=KzD809dzujIqHmHyhda_xJCq8{{b zX0nau|5OGL`LCb7{>w+9{}(P^|EsC^f48Lh|H+sCi$|{>&HpF@@Ev75z&baB~r#u>I=*tQ*_#kX^X7q`sd`bN;=V&VKU!i}RbBRRw;& zw(=_e)+Pq<3#sIHIqd3>8KK$_5Sk8F^gzuhm0xA0#VQ<|2y#nYR)Oi2EM^;7P`1LH zsR#?G@`scMl;CLW5)a{FwOkMM6QJl9fdNZxC^7d?<_26vXsupiy+z*9d0sAjnPhIR zunMg4Zn^>IFG`olTPR-L)R~K{o-=)WPpyi$mZumP&|EmE2U-zTGU>%aU^Kok16Ano zQ-%VgmDrQQ7r}!J#!lG2ppbR)tW{mwwKK{!EamHE^=pUb(OJp5b>rrld5nI%QJpSL zGq&gvgG*3Nk!(N$0WW`WJ+}Kv4m&AS<&L+Fz1N@`yzpmy-+wBKzKkV zKc`%GRcWf(a2JV?j6(Jo0aSZl#$p>j`;%5p6xqE zit|NM50by(*UQpY@`k4z_KDik$=&X$=V6y7Ca{^VilsY=VwQ5fON?HgV00^46r(&E1bZM)!MtOCllWGrgvjSM z%{0e9x@Rrf$C%hWIX87I3L5ox*~SjKc4q?$2(^Q1?nWJoEIf9i*7D&ye`uauLi(@V zVvP?3+9aI2lg@I{>mB1Ql6N*#3&E;5wqb@bf^Svr#7Gu;_7bN_FtbDE!Sm2z^HB&U zr$cG(>KS%_o-v^;=FpvSIUth@w?b@2e8N~R|In~sJvmjUpQRgkhJnk@FrFHti6ZR? zbqf%H@g$h^xQj$)8TD|eLnA|umS z9-H)aqkWu^i;KFf@FdR`*g~GuDlUe3^jHpYlJZ3;R1*VJq!vHB&uk#|o7=PU!o6^o zjhe+=y~@sZWRh)~KQq+jS=-#+Ts=1S;gf9sE}ocRpil?a83%Glb?Z&C3(n}8P@5K>NrSi)k$)jFuF|&s&3 z(n|m4V_Ko%>wcnJa4Ivuh}Pf&LW@f{6>{=hUczK#38|&!R%2=%L})GC+)g6~Y<uRFkCZ6=-DY2A|4{T!h>ps_!oW|a50#FX-;2ZWq=H1Z4Aof-65Dy5 zz{8ngEFBEP1d&fEZ|8PZ%}Ep}OAHU{ahen#ZG?epgdiSMsOM}(UoG=gneV7VLQkcu zWRNWc0*^;x1}>ezZ(UkbCNrRacv&ONHiptjqk7A~xOTA3LfN7Em?DYaDuYj<)e?Ai zTur{nYwaI@b|miUR_49>LonAOel~s1&SEK|MQ=?S{%o@NmpUUiJsTaVB4A=hsMs?I zVGRzUjy|0HoIYV;8B9|pg^{f;t}W*4v^)CCJ)P=>Zk))#6h}_4cmwndh6r+(cPir3 zbCvVNp-^$QHX!|rv{TA~n)nhaG>3;L2z={49}e5q2)nN#?gU6tS;j1C)Kt`5pwXBIQW9ds!5gh>m;uk0Yow9(itZ zzyPU~!7%c-GCf#p($4ZE)5e@&WRk z5DCA{-JL)>f6k3j5qHVL{WMlcbRsG)(&AX{ zq3YZ{+dP%?PY5c+-KV$@-rS(x^@jLpKN>K9Kr%m|y)^Ma6B;?98$(f&M~dArtX_`R zR|9MdVBe%PydkhwkXTCdiO6WzNyz&TL@l`iB;K)RD|G;K^A6@IT9>2jGX3gD^0D8gSP zE2CCoe}}qNkj?LUdCPGLOCByD-ApKl3*fM3Dyb~JFH6%ME`GLXelA7z9%n=;ImQ-J z=ctyVRaWG_fFovP!@fjbu05f&SEVY{hzxXn0xalI0sZWhoH4eN%IhGlr_#*urex@0 z3_&;0rcF!*+I~=m6z!4Zr4?5T_XP}**Xf~KJ3G;F6E2pgi%5t*+2PsV>Y0$AGSHj_ z-o876%skLg5w9@wnb~EEQY6w490r{uali_b4dW=vmGU7AV+v62%(lF4T%S~qSPZlIX0wyrdKwh1XFUsnT)HB zSqn{mH?kU9u9yvZ1u1MH((vL_-oT`~g-Ug$`eV`7Mxv~NM_si=$I(HJyc8T#Px*R_ z8Psggt)<&~dzQaAt~BWw&}v(6@bvh7wnZ%%O;I%sUZ<`Dg#`50WECWy?o4j>b8FYx zU<;?5xo6GUA#a)EyN6|gBdO|~{q%bK9waJrJG3=ZDWGC(1Qsl={^-am>iru$I|Tn~ovEYVcdC>mMFRjA#S&SVnL(Mts#40CO zBkuHE6foY{-qscNQI47s2piszp@#MuoC-;JM{q<_K=0=NUO2B4mTJD#-V$I$xM)&Qz&-YfemnV>R9s zW)m8CsA;vmk;`Im(e}8p4RC~R(n&vkQz;e6=R|>MGcHq##=={%qlaL(hqRA{hc9|((h$B7>d^L!959hwpQ&P#tUAZ0xj*$cF? zgs6F_T6`exCbef&^sfg$oO__9v74GxTh5K^%a58TEkN_?KT8Nt01@;7bm|50)DJ53 z|MOTUIFk0+c+wf(OH)p-eFc9Z-(BZf>V_;r-{3~mzvq9q>%9kbq4xOuALJDd8&wYh z0|0P|^?xF-|E;*}e}^Xj^kq(lmUfnQ=KqyM{{Ij7`d_yb|FiIAYUg6^uByqW^}&p7J9k0!V^O2th~^{Asu_NsPY^E+&u1YdfOqA6=JIP55)< z@@A&3Mbwf^(1O-zMQOsiB+Ew3T9gC+Y3M#aC#88aq}HODQ9B_;9CJ!&&tOt=%X8Cn z(#Y5jQ{3fl=0A#5Ry)^ow%T*z-Rpf@@VjEM4E+9)_?>bm1q#pyY>6+`Q5!AH2x%c; z3^Rv28d)^E-6AoyB)`P_%wOt2{iH}vp$`X6ZoL@kx^##2{e3CKGi60;?Ri)>I z?A;UTYnREJva(CCg{^umYx%ktM=7h{j`^Ame&-~v-w?P`TAM?+FJcI zC*L%;GpuL(3u^fqTK)Q3{X{SGFC9c(L6hFn5!@a7!l-3NIo?np=&wgK?jgUfGEas= z3#%ce;&CF~f0BCNs>PG^e$|bSU6kB$+%@W<1`qa*^877I^|VChJs^;}Cog_R;?G}qiqyU3$`Gj!wAP^I+h)>^AVN(w{7D4! z;{^2jvN=qGph;dg(-2N~j%h~4lih}vHvY$ceN0-Vyf4q#BLb#@WoQ)%>EbBEP>$#W z;SN$lWSzOc-eMc^$`XG}okZOebf}T=)%ACRV6I+JK0nZS-5MtA-;bT3cY4;z(RM6E zCWkNg(I#$FEq18~g9QX*5@_t#yXe8L_5EI?}yfq)A(IA%Rst=^ydh8I@VDo}QFrte2*P-SjBQ&9?#;0JDOVhKZeb*K|_SG}Lm^O05fL31qUm^iQVFNSQ# z=G{E*`3C~ZzTt2oCJlcjdcsN%1a_t==DaSGh(+h`ODP6R0F==2lA;0M>vtq&i;0rB zT&=az_vbm+g;Nvc2Jwt;<)=-H_>lM9l!JzdfiLCYyUZY(P?g? zxZTFwanRgxa*K?(H${eCA8Na5D@M^@A1APp*@G*C3VRss_=z^-hX2JWZebSg)A$~K zg6B^Vd~)^NMs9xJ!_e9j#}yIELZU$yTlj6P5v`@^HJoBi?^|Joc{3HI{odRQQCaV`-8+Vk|-T*`AR4V@~Z$3SaH@ z6#JE)!Se%>ybS9{VNDAQWGMLC4J?wsYS3y{gk-n6SR#saj~`?=36#`J_Idm|G?`>VW#FV6UJ~p_BH0NU4q(>?1v)bJ9Sc zwmLOgRM2c`kOC~|0@lznF8OMKK*#ye2LIA^14oI$Q{Ht9f{@_Ch+;@c#ftt&d6}_X z8;@l(B@g8?LNN3?2b&URjFJQua*PROX+`pe+j(F_n3-f{)w?c@uNOAEdMB-p7L!F! z6R*mp2EZb3jcORV8%Qgfn<^}4t~;sh#&WTBfMf6hzETr<$qa(ew+=s4)6>{P+mIr0 zD=1G=zfIl>W$s9*5Gw|OIQei~@|Jg_N3F5h+GAI8dxh7F) z61vMmvsl!s3D&w+jms7?nAKpq585ZqAkQf4F2PwKx7{Mwd>O5Nwu}yHkDWcF?@(%F z(?3d+f02C#PyQJF26+4$GX8a-`76QGr#o%0PeY?iL)E44QRmRuF}?VEE1t3^OUQk*U$yJA!^3N0!^RC61pq?u^TuL*AXq;x5RNA~|Cpvm9TqP0T7^ z{GG03nFS%vZqT4r{%_}lSg8thRPw6YIQT4?wQB+_x>xekKtonp@e#|wRdL*(<&zFzV{Hzu$vd*vw zD<$IZfl6w_*XE(rJsrj&^{3&uRNprUp(m}>2C+mw`5>NXA!>xN1P(aGZZASENnKNKOngO30%Xx~aZ}A=k}J_dL}i3^F2Gp9O^XpMxA<{` z-S8|cZDu~AyQX28ZPfL4xZjg#!?*I0Zq#bw#A?K<{O0m`M{duDLi$sgP1{~xfq51l zSn2ykt@nbc^T6`#7XCC+)7|$XhpbHBQ-8Y!UFId?OiI)9wqiqtb*G~ky&A3#OPb6> zVTV<%<1PUnH}k)Txcv4?w?%EbTjXix2i}3WZ{gMBv|K^2@b-}gOPcJK$Wtw+U$3?e zH~-9BHfoi2FPkVm!ah$lzx7~#GDX={ zbJbW0+;ZR0FFsJpIz%}Wj!J*Xq8OhItY0hX70P$96QEyLWRIQlcQNSr)qPSI9{)8Y ziJTCjrI0W$VpS3(gQn!;e0&PYbzD402v?z4AfUbj#Xy|vIRWy*DrP*M)5QG#yq44% z2yL7Rv0Sy~Ld@qodHlY}Eq)YosIB|#Hs903Ml$y-0`s#Ai%M!mfTw<8_m=w z6c=p1lj;Q(sA_zD-N~$M#)>b9Gf5Gq|7U~3+?KPKjZV8urltO{0_Hk z-0Y>eM*g$xLfe_r|OJUx58Ld%cTgCUaQm&>w73*z<jU-CZ)IL^g#VwwM2AlLn`{;%h#wy3p6uWLn}i_Pg6MD+ zNwt(08m9G)EqL=;QjKOUq9oHbw!kpuaq8r9mf|y@?QC$$0$uhof@QKUOY92K^<7x% z5X$oQpf21{={j<$z4bBIj0e2|H$qEG*mC^0+{78WE>Fkgj4m`ccG|u#&(mQVJ#qwG zv^NVg>Fq<%Z$g-t2hCJbxj)y znOAi(XJijb(NHN7d>jiu>-zr4W5bg;!#juOhJg*3zRg&HcheMk2k74|B|!%>Ch+7G z1W&{A4?njkGcMz};kegAbr@_3gmgq<+0@w~Ia5aUSi0gH%ji3H2Zo7rd!7R(g{#CS z=j~-;35oO13!Epy6uv=x%O`z5>^u7u8l=hS#)p+9Z^L2#jBn_4VHatzn2PR7#Gb5O zsC+$T2gba4jLsXB!`qj(WwNZZebCx(A3fqnXX@N#;7&3MeZ_2T8w1% zZ|_TccV8h}(U9+*)E?sTKpb=l&A%9%6%3!OowR9*!#Jk}yxV560yO3-D=-icSq_P* zJGTgE9Ab#9<>Sk(!s8l6`M}Jp%ht;+Np-)?k*!1bnhWwT%_f!*Tbv`Br`*LRo?J8| zgp5Aw&IrhZpShXTdUo*c9J6)dyf|(?&U!XqQnBhXc>Ql97ljxs`RPHrKg&JD7g_$G zLWi=gt<3;;{G5!SIZXn$Zp%8M+Rp=my?01l;YQ{{B^bUIeYqFaEE1e8SY7a#X zp6tT0Ao8m_F1k3kAc!pCYMc_R%zaE&z#d<;#8ZU!fDjLF4C}E$c;Ntu)W~qXkea4< zHGO9ZKsX<5+r7?j2ro3|U$w_Ip5OJ))$Z-S&F~)OFQI57JM}}tF8WmL zxq>SRzjew1l8mDzz8!OR-okf^gY$NPFf^A|kQ3>d1rCc^6iEY$e42f`HCkV)cW{wu zUxIGJ;6Hazxc@krrLeq4=!cp}(9$g$V}2>SO_Tk;fl{mKJ=yoJ6bg*OnOVt2=gMWE zq)V=bU7+7&uK2_E(5Y(o0PcMa0sIB(V5VWHAPr=fTiV&^*d;N4PZY2pV7E#H4NDW} zuadYcjnrFj+N@_I{}P!4-lemP?S-ws>tW>IS!aQ=`S&bgMhxm0oxn~7y{nF2RL#^0 zX*?}nZieJJ%1vG)b~-dKsdKWil*nY5|fto}OhW^9+6-6{f3P@nC=FQ$fTU*S4xowG9`v#s84|;1HC$5-Irt8( zRk>5f_eUTY-UnG+&sf(t+CS#=9fkP)b?^WHlJx)V68)=n@?W?_|BZ|C|AI^O?{4dx z8rv8<=sTI)+Wb2{*#FNb($ccRQuD~({bWoqyC;=Q5!Py^kZdL&Z6-W7k*fDn@hZb6 zQ)QEq&y1H&M{_qhfs&$|V_qruUa|HY90GzA_l7WH|9hs7_YoI5APERe6S1r4*V;f4 zCsNPFmj1PgMpA*NRxXi4me<0HCrnZNB{*&FiE5bK*Zc}@~{l+r4 zP(?ggJDn@(BpWr{A1?ng^e4U}-c4O6zHEj_5Q- z9*JVzlJf1!@@>NTmd*RO=ermt<;;8`D`EsZsR0d6glI*lauWEASdh|=JaxZfK_g;b zEcB>>)vs6h{veYweM}kRD7Fc*l7e{p8XhK}WHz04ug8s|$ix!&&dr)*=Zh})h~?mf z3z#kRf)y-?gk5L-kZb`)0Ve(E2?P4+h%a64LH&S?I%d{THJ3`gH*agr@e5JoM#wC& z#Ak)>D+%i8#I<$yDpgb-LJf_{Or5-+5TnMkb7h^KN>ODzGs;!#G=ZU+`hTC=6Cp^LbvI2bZKe_@)!nGv$R1KdPj$yH=k(BKKglFr6ZNP zDb2}V+88?GAa5wkvI3TCZO7XA3eJXZB4>(k8@cVsAK%!{SGAG#CGU}S$B{p|U1C3= zZQz!v-`yTBX8DO^TM_-9iMd1+5nfLO^*ebfA-6uiV0eJ36k&or3?xDxWtFxJoTtA4 z&2Kzd8Dh6&#N@;MHwu6G|A+lgiIe&9T;+<24GFNbrA z4A~jFtm8dukzpYPI-%JX3eC<7vX=~eC%=e>c>1}c^MJl+d>MxOaCV{FHI0bFCNt65 z+M!!bN}oT=3=)jJ%g5IF0k5QiucWVQJJ^X~Xy^2?+b~M^dfc|qqDT!R4qMVF)vq$8 zf|Q&>Yy!X`e_RD0s5AT|jLhl+J3*c85YurXlsW+yGo$LEJFB-ow#Qw8Z3$pV?dXsT z_VTdm;4#ulkQ|lMj6K>tJb)n>r7z!;x^H9Bl}7i=%DGCS=1H{;&8qVp?R&N5-YOaz6utVZH^kJqi!tdY+K{aTb?i8m0%NK>w3=FBJKBcn7!Af6 zop$~LC4F43&E6%U7xM=5-W&C~b9Y(Ul}N<-{XX9Q_C3Hnaz+R7{%bagAfxqY*Lg2! z210k}YJW}E%Vc_O*jFgZ_AGBl$^`E0;=QUMYh_rLm>1_%^iINbc}3?k15J#vsROaf zee+N%QLWzrJ{W@7uTr*wCT_8iWgSFA8Up%IkH@sK6OPI#FXo*ynek0SnzBHR!Vq93 zEBQjeuB?E?ysP}uWKOZs8_KTP24ZTcQ2`+nfw=@G2Og3APf~CCLWvB*2Ma*EiIiV8 z$+q&+Th<0#q@jggTFDWd3$ z{ZDSyrQka|l}3I7D3p3WEx$G!^#mBjAaWo+mpQhNE~XG+Nx$&KBvMH>fdyL;X{8u#)(G zK{wnxZl(?{ij%{mXNq)W@!?SNsg?J0Ut4C0d6`e0&+AONVX`(!eRf{(eNRgV+V!a4 z-DHXM63^%=X+^%C@6c+FqlW`bl{+$ascxM!6Fo#Faxvs8HmkZ4>9ONJzsP zGyaKuAtCc4^osU7&05^8*a&l!U%al%x^FZquGap=hn6jCvZ=d-jaeRvyu+$?I;9`))t^t~?a{#gCgKzeZAaGCcYUI0|rqyxO7cb3`L@$w^ zY5w(7nWEvfzAz!2(IPWRT_Z7Bxy{8w)C|&uS+5GZaHiM=DXt(ebvNUWUCoYEOmtrf zJt`dF079it(S`rUFMbfo8zXF?yLePCPuq^C1X#)gxgwuzJ{y?E=*SBxLTgYmX z%ejvi&5u!^&8Cyg3uBTQNb+EQqH;2S!pYDK^JXOSU=Eic*BHjDQO9Dryi)684gT}k zm4K_bo`6%uld%qLd@wmGUajWPI-aF6lzi+z^(8({5qYJXc3JmHu*GHKs*F9zD-}x9 zT?T{!^yn4CS0-54D-5NSMPOt~B-zs}S}-J|P84}^NSj)eYN3h$+)7mDqhVMg%}N-q&) ztH6hV2LqJ&I+nwhP?k(9MEsSEsQCM#+a8L!-YQ(QxKRQpdM>09K_RCT=6RAb%*Y2u z*n?-yQ7}Y2__-K4;Yf)2Tm=Sq6KZ>1YtdT=mFBd3nY7kbi$3pyge$4NprBQll^~RPcjV=hBlhPOik|k;( z8O?YlghwvFy8ZO_PeydCZ|K>4sLV@2j3^ej)HSsA)a^-DY>n19%Z-kOo|PW#X;xgV zv=zXCN0ZDh_ptT-ah6xWlghO7XtXo|0M7L%KO4I&^feDtpNSxBzMr$PiLlM&3=?an z`f*>p*otvDwp~UxBVU+qR=b5LzS!K1I_msSbYBgn7hT~{XNe~q z`P%7b63%Nv*NWT*9u{^)*mz}X#FUMxiu`#`vc9bwQG>Xv_{>;R!9v$x3}Of+T`fe= z2lOL5Z_Hs#>OPjy%ag+J(h%U!zYxnj*q@sr9$%&Cv7kF%6myJBVV{d81pWVdd8r;` zar*Dy?j7zpT`O#1M-G(Y7pjHtbhq?b)5`8a!dj&6cEouY1Y3=Hk&@JaVPP0~1jIm$ zOk))bL4!u;rw|kc7P{epP`U$4K$wCMyXK|{2S!*58wMjm_hTTp=e0^NkTb5i^8XHn zq}c;U7IT(#NrtW?c*9h!&-e@dIQAVxBGOyCXIIqTd_0ZD(jAmieROJf@4B<-yKlB( z_reIf9kOpXWbdZ`A>|p^%4xO2#rOo<$myPu${m?~xK&w#Q$;u06T=Ddv zWWB`GyGq(k${ogQ>-K>W>s$1x<{#>B_0GS<)Vhl??l#2BmG~%~+kWAC#I3; zb%4!6kWbgiIl9H-+Z`{akg@D*)m6H@f{vt66+oiFLrPNCQzF4k|4;4Oo<>4vf@PMrt+WP+w*=d?6j5qCyrBtN-B{jCuM{4qn;;igcbGIFc!Ql0 z(PTVP#_4dB)Wa-BKX-~SY*8>p%Ks(ApiC8IBLNMlBx_WUkfC~$RGEPN?W8XhEya>4 z=5NTp*^oW+`N^gov@y02hnCjV(e{F++=2}zed-^}t2JhWDg6PVCf*`v# zpk*D|>ff&=9a!B+7~A2Mw#1frhhYjLne6Sd*cGULplAMO*R=jmSAvbMbO~}U&e5lh zmYUe33E)35B{GEVPbQm@Z=adg-DJD{stTgSQ2*SZgL?(p0daU_(gE~`*`M?J@G;)( zfHa@>W?@io5IgJ-Iy@8eZQfkOs@!m0-^KIa3pzUGUw}?PAko6BMos+`Ax@HO<-u{B7ai+dmv=$fvt6Hx<4NX%O8O24|Z=sA4fa7x5F1LPn& zOeK{RsUnd}uOpJ(8tMHDe)aBGGM*y4HcFFhl?KG7v5pu(4(f1HN*(bD$geynP~VW7 z5%kE~V;gblQ<7Ljyhp`ly~T2lDvA%v-!R47R_0I1?xRCUck|vJ zhpZtR;nb;!?EF+XX2r3=rxn#*Kc+oc>CTR}V;5`W7Fh>96BPS70%X~&V}ZcX71YZQC93xmS*ZlnkO2$N?h*?b)F<^ZUsaa`b+$aT9H05 zU|WQ{p7y#}F&c5+(<{vN8g0wL_|B8fYX}wQdi#=Iy=Vh6rJr-^svNoyb{^<>uTK3hlJ{8ND(9<%spXMLI7&tbNp?`Q1 zU`^M1=H6Ex8dLv#Ah>>M$3)rPPqb%ySKs=@pfVBP%Ivx|$SuhapY;yAx~|OhCR@g? zXY1^5y)m#M9GKWRI5}whcC5_c6or5k;jknzy{ekvK2w}9BRlWdHRzgbbfYYvMVHWC zjUl@}(Y2jw(E3fwqH9=g+Rauo6nK&WI%&2`TPkUmZ7`PnHL{9cQ*awLkGM)HQ*0 zJA5uI&V0rV_tAsi!O4q!7Xeo_+D?WjbK3pqL;neTdQiedcLy8y-&AsecEjezoZ?*bKcziH@ z>09?+RL1*LzWaid@}B-bJTE3l*Hs-70DzF-|IH`;FY0^#J0J0X!zumO2G@TVz+md2 zZ}cC=1^-cQI;r+Q0EX zleTes#8V(1X8>=eO!w=_AN;}(397SW9nS6#=W8l+sMt3MO|;{imeVT5Nry3;7M3k%31H{^_g#0T=szU^BZb`AwEw!a}nD5hLz?_6iDO(%{;{Eh1# z7O10=nliHH<^GLUsQC``q{s7hMuxm8R7TNGxYP6MqkDJ3hgO{W3{dxxsWd)5yLUye zZG#w*oS4QmL*$2fgCn3s#AkimHQ&-nTd)60=_(R=1lXTd& zf{s}_hbhVDYW!6w*VK3gs#%dYp{UO&U@#2p%WDLQ`v`@@8)2R6a`!E1r)YoUdg@@c z0I4ZE__8F?3LWmsbnPf!5ri`jitY@I zPBOd%bp+jnSGrRn$E3K(%M3I?EtepAY{g?eG5ReV%VoK=qH}skPkbng2;T74E8tKw20ICzS{+4 zbMCMtdsTVn*r+6RY$LQZ`kUi$HW$V`vP{G73lxKfLTxM}uQ~B>=7LlZ7?O;^g0?L7 zbZTX`=wF-m@jY5xobNUechfiWsdwkv$;-z{c`ZRRC3a;b*u~)Zv*4Pb8CCL549u!v z(dFYSnMEPGbZt{Y5D) z;CGuMn_`>PCAbB!q4I`r_>dpmfhJ3iHob0}A{8AMXjy2iwigUp$bYN&Z6Au(bD_ z9=x>LdwNAA(FvlS5A{;0d0mj=77fA|b-u8~r=^<(h@0Lv`XM=PG4Pn|ef#Ira6>fM zd0#GJed^GQ4A4l2DPCgqd8oF`D9KByDebfGf`i5+$l^Yy4JfrcR7G3WkEot#YSToI zXQWPjDl4wJ8hTf^R2%zUpvy4bJgN;?!E4uLY(ZN@FVY3k?RQ;>&5{=`cn>sEr&7V7 zbjc)(4<;H0sWIhKFD8N71Kc*X-UcM zw*x*#w)0-5`zH8$Z(U`p?{>I;Fr_;B8U24~F{QISRy?mRUUTcagm?%8SBqP(?_Z9h z7M*BcZB-ogCA^kfP0{@^U!+F9ZYa9u{)|ygw}_g>aP|GYf}!X}lf9tswSp=X@tX() z4UMz{`H;yU$nCS7q9pK4fR3^WBnW8pFZsWX8E9jzN7KK zGvNhbYB2Xyo}%tW#+W|qoa)-8&=z^+ z0*xGx>k#h63eA}~2UEJ^Rrjl(hJzn0=Xmc>-x~3Xvl^;^ElsLoX zYJu5!`_%l`l~Y32aWMzvta#CpVCj`&7aYa0`I=aPLLt3tTpw@Y`1J=>(#U1puzh@e zr+QaiDW*xMCa^Yx$qK8e$iNR0(mZps~`tQb496NF^qe$C!Mu21{G6 zfl%6P9X?rNU)zF4K0mv+yL8w&1=9H_@r|K<`Is5(7rMa%O9r}idlU!eqPC+|pzGF~ zD`e43^2laB<3eVSKeSSKi$GN^hU6}CGty>$1vNM``{Ak(yUb%|XtI)5ppuP^Ed$0- zScd*HR{XEI?g-{!Rdqhb_1~vvT}~lW^bvdlJdoT*W7~R6R{f z?Je1+-N%|kD&5CE%ZaxWm+#ka7t(H|@^Fk=+T)yOhj924@@A0vN+ip#JqIZ>j7-5P zGJ2CZoKk^n?tNg}i6R2!8ATRl*|!f7z&|Ekpl zpxY&@s90AX7!sBqR|ET6AnyzE4AHkwDZsPCmw5N`+oDXhgyT2YgGZ5?^%r!?MaH2N z4#hp!-7|O)WNjUEO?e3f{n{yhisEZVG2e!CJ(NA3s=U>>T9-e#8eEKgjUft_zc?>G zS{&W}{n}2^&Y5g`O~v}yb3@4YJq-XU)*^5-cUI(eJkT&heBvhH_!V<$wk+zv)5hWS zJ(D<)SY|pqdvOv85aDF=Q1yJ+XfVE8`OJX_dTY(=@jSmM=ZJ^;vW2*#WjH=0zVk&_0VZOE46wfm25>UZuDTj#D+9F53opE-QoR=fkOz@SVV*l(@h zY@6~^?q_Ws3u+s{7kEH(&(s0FL8vm z?2Ovb9aHqHMJcgSe2OZnI}w?`si&F055@xS$rW8hp$$T{tPY~Im59kYBCA5&12TVB zOg=y-UkcXapQ01b$*xHxH-Uq%{1u-^1)oRh!0tE6A~%mCQ@ij^IRC1$P zwE^yZPmHzzjy$p;k1b3Y8wE>xpP&XQsi%iqckY?LQYWwl75J2L)uXUPS!6(%f z03c{Yrl+Rbqm7b6zllv235-27IETx5K<@9?u&E%)u+-tpRDzo1(U?ZvZf;Ify8HO*wC$O9U0ih@ zo=MhT1#P{EQN|9jQjR^oQvq~FiUW=_1K4DN+?yfr23T7a#1X@yEZu3V=o~IB)!J-*Rt(6e3_?tdHOvT?X?%I;1Ao<3XD(IcHtN5 zmalK)U$`Uh!0#_AKC?LN?{XnM5sTw#mw_ghhz~s&Og2ous8(4Fkd(95nv;+B^Pi<6hz|Yge(^@9#h}9gw64$ zZp`A=SRHrutAbR5lt{!mp^;B0FUH_CWyX!Gq(J!(|lh*j4=H zAxMxeAA>+uuXJo%XX`ZLfu`}hEC+0yA*Ha76ySYuc)w9e1>L+IwUaDc`2#$&>uq4| z)h)%`3iy+;EV^H?TTfr(Owur2!u(dw-~Ur}-97~d`wk8O5crcTl$QbqK?VT-x&Bpv z3H|4tKmVx~``^%+{~Dd-`8Rc@zMY+et&6_Zf4p{jnD#$n)9?D@jm!mm-w%d>fB?|I zC!uy6bw$u83?~c{f<|A1kaGDJIy`!x!F7nZ89P~o$~sOpKRynTvXGdYA3mA2Qgy8n z&YE0Yz3Wu5V5@6`$U}A(ff+Sbc)CEn$7hnRPJ;aL}rb zP>DYP@lLorg1YlfF<8*r8FS(e`_76Spy>__H_BS$+8bb2Q_}!M%Pomba~nkZReT{i zClQ#y6r~YJ1#HZYcue2^ zDJe$^)5q8gz82Ed1BF{$)0xrI4cqw(8uhK|{hJoDZ$*snE~0Ng<`*l@cRtD2Q0r!w zfD5xofqd&8T;!ynaDX8DM1N4j5*|!`&RRo_oL?$!NH8yg;)sGtL0z>SJ-*U#7fyZN zr<0ha=XyG7`pTt=k!FOLd-~IGA@1tTxD*c%*_ac55L!HPE;@b+v4ubnke9BexR=WH_VvbwJ=-PaZbOkP#M$5b$SrM4L3Zv;ZHt zJ2mmn$Yp_fhap+lA6kJ-^8x-s;(m4jAM|KQS6h`Ym^{U_mhI3;hjw5be}4DW;#fuY z>(oWWx2VrQNHrkL7y?3ry3d~oL&QTtDk2NaSFQ%7oTiC!z!e1SUR}YAcS9H8A%v^B zkyq*Q3ZAn=8&$=VjgA%X_0!!m{v&t#D4-JsiE!XYRxN!SPsKvlWM*9#f zOi*tYY8z|ySK?j%>BwrS6y|m#q;;znWIe6(6Ofk@G&s!aCOG)Y26v29XMPuj>TWlZ z(X(tCu47g)I(!A6jXeDX2?MzV8_$+@AzNhAz_OT!*8Bt0>(b^ zt0qwQ*YL-4z<#giF4yj9%xe3mu2y$r)Q8(cUlmkAx9(eUg-J_Nlg^ZS@lCfBh z##wTqOA$_o;v&p^OjQ3nS)^-BZ-}ihTX}EO*pc^+4KQM?3p0*+H7jqm-fYhBB6Q08 z8Ta8r;y=)T-tC{#!;Hv=(v;-gZ+M~%_4-?gp^^z6VX}%IZbNX!G>pR=qIRQ(!xjMk zE)=16#DjHi_GaD^8BEx{OGCuYp!~!f`z9*3$`8evTS<$5YIW+(5FT^K(vw3YdA`$x zV6$$I{&M`yjj&#*({nl>IPu5i0dactKu8J&D1dqjSYIHV+Q!&RVIiSEHw&!Is-s>JhNsIk}?}dW0ebTeVXW(U0@p3kye5*WNGtP*Y ziOxl@k7=9M)F#SpuJu}IYH{EHR99a2;&xeSJuklam?Jlrc#Nw#d{XDQzjW-fY&R+| zQU37LGwU{_lheFxdJ)a^v~T`?TjDh2V{xNSTeK=ut?v_-L+ESz73~haB^5<>1+#(S zCOK`h%6Zm(C>pMUMhj5B!U{%SarsD9fGcvIqk*|4s=%#Zt>Q7UW5bjpH{qOJu#iXu zk`Nxj?YXx`WJc??f_89beB`7gb{`JVW|Uy~MDe>y7f-E>__1ewk2D-R_-0APG_u0| zcoLzDnZ~e(#y1L(6#SQZj0_{BF-G!~M4`fo<;W?FMwh6ijP1l3iYA$cnHa~p9or}^ zQ9P~pau39jbjzr*@{cSqJ`n zN17K;1||X`6C6qjIW5}um^afL)~8Z=9$iv< z)})Vw4+^bg4;v&P76zU{FBPCKChqzLqK5A6C#2F%1hNfDLTShsp4Vvqax=60hi{^- zQ%_NhiEI;KCUZhxjC6fqjuGuhZ-M#( z5kX>KQH*eUDBS&dUmyt`t{yOIG&?C)WKdwd6eY51uAf4S;PBtM&$xhqLA9xiyW^z7 zCG{_=DA_&!9?-Kpx))m-U#Z>brSqzsZ}JvZvJ(g!>U4W-aShl`9~;N6`>}z+ZdLb6 zkQ!-XJe;+dgKMd?45+eURiIJU775ljFN6ohtNT%t9JxAd z@VTbe1w4bEfwR z>cuO$$A{v=Zd2)RZgXSJFi^-^djr{@CKkYEv*2a1ve?hEr)p*TgosBz!@YYW`z0#> zPCHMVYs8x85*OL$63)*ues*VEZE{{Pexy?Z-Mki6y0()Gl6x-KqSmbicc(582VPMR zlw;Hbt5Hr%>y=`d4eMm{&c{@kmkJ1v$2jv3W1*^i2sX=`P_xHKn9V~p_Yq3%lh2*P zyDZRVi+gQWI$5Qr#*$jI`7s)-*wW`pT%W>9OJ&E-vh)J*fEQF`FDS?!V61N-$sS;1 zx(SlN4wDHIbH~w<@F@3Tm42!xWPwI_BF~=+GOK56U$8M}3b ziuTWP`(Jga_ z?$&#jBkdy{X(BG5n(uLmzcOgR2N_{U*v0253x5g%BEc*^7WQ=@k#B=tV$a3O|X$H>Sc5tc^$YzGWfY0N2kb61yVWWBsP?b z9&yqd3UXUoa@+V(OQfFvMy>FNFPM@jS6Xc0wj0lsrK7tfjus~#- z#ikcR{}c>)DMLRQd_pF)K)@b97?>#VjCoVh5V;&qHu7AV=+<15W5T4|r^(vH0Fw*@ zvVLNs(obj}ShF;fk5Vgj_7{x5*QYJuzd<>PL!?L78&a9O-BqLqlK^m~^+WW6Hx!J= zj&pInxZp|`$rs8Caqu^%37WwFG#KCrvgLtSq-K{$Lv|S^P)8u62c!)phC_2lZ)``0JZ1G5JWt_n3`ok*5R6Y!qNMteQoh#8&Gf_KzZcO3TEv7Y4G|zbar&zFi z)EsXWF|S`KDaUwCbDdMG&xoBm0T@inix@(5r>MHVu_kzICkU;Y$=7C=VK2ilgUB(! zIKZGw!!isw+M~I0?x+eI{vr(>e>qO3Nt7+DxN0iNS+1`=v}K9zfv=HsUe8K zC0F7QtwQM!$lw@Lr^EEH3K%X5_z%0$$5$CJ^v>s+4p5sm=!kcI(=)zH4;Co1X6Cx8 zUOob1C!N_AQ^|BR_)Gd$e77O<2G+DSdaKtUjHBqIp;cNYF16o81rq?!Zs0;fdu+Hn zcLyeFwFZ9Wls_WDebiAMK=I+!lmNm!)HdWtZ6zjXMi)+zmth2G+9_zxhhVoNH!8f

sg&_cXsEk5s6bCeZz=DviuCXy0H>0paQag$~>`mbi7l3I^)ayuv&0JHCQZ`%GdP zTejo5gh%^y?o&=WV=g~ zgKx=IV69Ek7-pQU1;Uj2V*(okg#hC1%0#omU7Td2knJtD|`bM0h z(3dwKfX+;NeuN@8;%IBGTxQ_yz4s<>>yb-X{;O2HroeSm>#9%B+Uaw7{sI#WbueJ) zcWml@b%CHm!ibz(b6-KwdVWyon3P31{uvFs*Z+s-b#1(LP}c2J;saK^>k2LnV$z{N8qM?Ha&2Kx)siV4At8D44Mhk z@E56!=r6)7SzhfSu^;g4CH?w`Y~94QdM7_Ae1IFy?JVz(LoLJ#T>qSZ82<|)0;Mpd zI)ERSSC11KF^>FPTDs@bFz!!5rMAK>s+w?)9|&v`FVR{&wA{O2S1zhRYD*%mDIy?R z7qmB~gI^S`ku`p>kSTiW9nZ8j23k?5+sUNcZhaLl<@OWijmgVub9j8-OAibIjc_px~}BvI?|KN)>a+;%Z!zr7WQ~C z$$SK#d0G6JnyTqCcy&FM`DseI$$kb4nA&PqBQ}W7b@5k-Pg9~R3k@_Mb4NY*ZiZ_8Ex$7*47-S$StOG>bZa9CS%kBFbDDAIsG6Wb6)-rKzML@9IDI<+k3+_i~l zR>HYx*k+ZU3TEsBJ76KL5*H8SYhZ5ssX#?pf{ z8Q!Li(KK2oamFaxz!O$bYy_@U0c@NZTPOb-AZ$!z%cng?0Ur?H`GQQ7MC`N$8N^vn z1a6{&6m%ef^DL+U{F2tc=o7gB+O;zw2*>|raD>4Md;-+_W(JTB{acCgW46ytCn{E3 z(iEuH82g5yM}Tp~Flc3;Qm!onv6^(N3D3&NvJL3T%bHHt$MUpF#T6)DN~>>cXkAx! z6^`tJeyj1B4!XFiXuO8H>u%PP!`#Mqx;@7g_mEl)&l*Y*?DdUzYi%0i9VKw#sIboX z8UL{qs%=vX`sDM9lHd2(;*v=(=Tw2ez-2XM0L_9( zWibuAP`~3&-Vf|%R}!G_XoskH6%v!adz0d;@Hx;W_$85f2ppc}tU;jwL$th5VoAvc zb)OQR1~qavpVw~;z_g{#hHDETzFNPe4z*DU!8~Pk^rr8=?j!e=T*!(+BjK16EViW5 z4rP|_Tep%xYl=|RJ{TQpYU67Ia&>ZV$QqSxP`aZMcK&<*%#Fd4a(WFx=^N&*h4OWy zfg{{UweBG>b~3YrQx;7_^RIo1NNDV@*zQBnBMQeh$cKaA=V?feALg z13q*7Bs#6)1+_>pH`AQ+AY7W1(87Cr}iaj}FufGKP9k_Da&ENrs4q0G0VP z`;0z(Bi7LA%>%p@4k;)O))Z<1i^eZF^ky=(%iCGyYN%f51Z377IAJdWRCdE724=|x z2Ez883A_6|z)R5R*g+*Gm6y3N{+R`*7f2E_(s~|`10GrO2ehV4IBm#aQz4B4oIBym zrjhZBLE&;xC8P6P_iH5z+P5C~{&5HE7W9jo7c;G|}1HS92H^cX94 zUjjL{NVOR9dC8grxr7Y6>C4*W1&onAho-nyYc4m>-2K#X249LKLu>>7->bVhT~6*l zRg}gFEz5z(W15ta<}n);hgpG{#`2Pu6A)UKV@vV1&Nt1~!Tw?j>kB$zaM;CMMk}o4eLZ`~L_&|XuT&4Op`^y|kf#;8!U1RvuRA(l7 z^Bk0#2{HR*bk}&N7{{jhhKj?Y`{3K_=|N4eSEySv{eeZLRl8aFX`z|`8sx6v#aul*YlEr(YZUWJfQP{#$OZEdiTmBY6CL zs~aKPAG4EQ40m_Iqd*pwHfx1HiaKBudaB((XSdXFfF1iyu($JeSAoxZh^*qqUDPxAakv)0zN2R`5cGJIplIZZ=wOeCS{-(;e?(`v}w5`*xJ-g{2H3{AC zLzxv_<|IVs^-}Zju-^4jo4v|-TpJ62sk?fbYKW?f>OM|*2w(+Hcz036*`k<9)Ooti z4|w?xA(F(2b6tds9#`e6IBlKL4U-)Q`Ij$(8m-HN3;4$syqUGaw4`Rz=xeSj`wq0f zet#JtG;7iEh&;Okb}qBw5}5?lSv|W`&Aj`irA}s%+4+4_3as)-WB4E7NJvN&#CL#? z(8rYCZiF}IG{gIrCvm`TzkvZBAPm}BAJC2Xu(g=0Zld+NTg?iSE_p_yJ=*X2k#ao? z77hZ>lwb(y@-q!R7UPj6thVJv>#p=J)y;DDF$B2hRRw)$=Y4C6-4=S3B88+S$6cE^i%?1!CG0hRkxyHW~%h{vy!WfgSrS4|AvdfBa*d?ka~Tc$fF|3TPp3#Lz9SjpRb>8VK~aHl&F7=2Cnz z8~Vl-pb^pq-$}kfa4~8p<>|g$tlilU~(hGZ|sQ9+~4{0dg z%W}2s_X#eA{|U0tf3^VF$;8R&x7X#rtPExUKeoTa-(VzT zOd`9(T}IG9{V2$2jc+I#W5~6AGY#;hDv+I&`8~`8hbzq6Y=>`@rYop&fr=(!P0Bo8 zgbK^m|2V-y$2KKvs`}KgrX0YPCsk~N?+E5A4lCW_dPOz5DlWx)pX_+~{@!74cy{mg zaNxN3<<9PIhfd9(;N_p^j$bQ1gwX)u-H8ATirElJ=Qfl6kd9MG?6EKkkWMQwVJUTS z2$0h%Km$ti6CB9oogi9I3R|EwXZB(CEw%#KA<_V0x~)pmE177RhCrz6?HQ2pn=iFh zl+0}c1w2C zN2R-Gv~oAlr8*64+WtLmP+saP@6lPcD=VB;K3`;t;V)caB%+9*keaa2P|VK~hp=Bk zJW@22r(_}sFJj?L?nyx~!g6tNH&$+h(Zk_k!@6ew(K;;hj~2;ii9 zj1V10amQ7_q+%$T;9>e;*|W|nwP&@~hZ};Ff5hgwf2ovO8Ze95xXLY-~CLOZ%1j(ldNYqNn)b1{+t$EtnihH2GuCr3z$ zfl5IX>QSf-G~X3AcWWvOR?D0|tN9-j*ikgB=uG3bdofJc3s#D^^DK^=@@A!CEe`Bt zCd2f`q3&kR5lH`AVqY;@znX_;7tt3WmsG+=G2C~G$=_ZRBHh%<57_0XzGR`-NbjyXkn$Wd1+!$3&p}DW_qh4iu^WVk)$|$MVMtf z-KcV`;ALi}T-g$o=pL|RN@Ij$No8dbCOH9x;TM<9phqW{SmQ4LB(5f*~LI<5f@)CCTkUU^B$wKGdYm-1vuu1)5g+y&2 z4{*>Rx~_mr2eo&PM~ge;qACzG_9Qo3?(zG6l%zvJX_c4TO+R*u$A+rJ*H|!)9I@}l zfhMSS{dey=j88+Yq=To7@$5>#6#=2&1>s=wEomV4@f=@nflom&9I0fYM&94 zsZQ??bU)r_wKXq>oo>b5^s76m;oKbAc6{kJob4eduE*M)4>jA5`7CmyUpB8TwC}#n zhhDSw(r2w^_&Gk@9XRQHy%#s?S?XzHFF0fE@HK6*CtG5VH}EI3RqY!mLL#M?mFJs_ zZq=Y>ua>cP;j=g%1jk-*+xa)z8uqGAk=s4R_u$!K4j%dQ`fCp}IaQ5*E_a1y`B%kf zZiB(OPQm?Fz3cDSIpAK5g|Wc!>w>f4SMEfaSyRz+HccDy_BS*iK6NPC*I!5%FQpj^gimI z5;HpauBX4^vacgAOtOQXf=7+^rZ1=d{+s2uH*UPc_ar@j zK>sJfMD)AyV(W!oUoh7$*`fLmE=LLRW_xcR&K%1ZM=D?E=U{0~7xh0LWL-9+el|(+ zk0-sC9LNB>5Od+5r$hmu?r)qex0X=v6&V7~&7S;8C_J&%`!u2lU@TlHJ&4#4qmqga zHH!>inJ%Tppp0xcs|@7|#kh0MhN7LRHP&6q9r?*j;vH5grzO?uxf0%jlh%s*!h;!2 z%)t;i)ahpLt&fNyE&i)&OXCUsCCjP9YnP|$ALJQCP$?!AgW%fcpFO6UX|q z1mot*j;s{-dn*`uG7m%N78VkHm~{vs1Y+hP04)7H{8$F}3l|*2yC5%y_1JMh|4186 z$*=_w?jW@oeMgi;qcwj_Uw;vC=RSfFKX)KG+L80YQ7QV|J7!(GBNsQS;F?a^{7s5q zT5t(BsAqX2*G~P?SEDVYmP77DyBrTsoLg=>pOSNTD|_7^dHZM8r$(=P8!LFT%mJ&Y z%gV89@xSZ3zu_~gLHg-q9q`7f{tmWR{l6Sd%hSU)>#5zPU3;1Rrow)HMd0&%?yuRl zBtK^a2+<%k8ObK3LG(10>l!Ufta%KB4Y|eb!r@9Eo5mrISb!c7rT>V9@dDJ|n$9dj zR0IF8dpQW({&m5clVKC{L;NE09Bp_0x{ohi%?)3_JEoa5BQFZj@U_^mz$4O)_m@Sy z{BY+HRCbP7xq2^J>=^;gnJ@@FqOo{o2QN<95t*jVNVHNlDm9MfpZe?- zJTNlqluZJ0z+vtVhvf%g;|Gk(=Rd>J>9*5r_>rG?twhH8aS9rXTvMYBOkOmp2Kc3K# z>W8dU+`Uq;0@E^rh#>}Qp6P;;CCWk;cVWuBw2362$eq!gZ~{;FCu}OV1cu5mYLD3t zDJy05L3vJB_A88T4(m1%Z(E5w>n;w7hc=?|ZiVRknSwq{u3r3&RTtuusLJl6xKEql ziW;>@$@K~UA;ul()=&~uQ&9j{;l-Y$^njuZzc*shek0Z;7K(NM$qp$NN?GZm;vZOg zZwXLueUP3~@~*PW*9MaBwNK+HQ98MZDoO~k0iuW?A>K50$ND;dtABkM5T^tab~h^2 z8_CCUW#THy^>S)$v|e@^PV$%#UbArItC>f*Y2iUrQTG1uIRv?QlN^65plIA4u@s8Z zpdM_?Awu+Z8M&cw9LWTp$RMbSH0c#ERz*WJe)+?p1dUjCC=Qh znV<-@GIW!Alk1%PUqzi*GifjL0yqTd45~}>Bp3N?bJaSAh(-PK32m4eC;kz&qRr62%T!kHO-&c_fMPePN-zu8}69+kpjvc~^}|nbvY&f@Yf%8~jbR?Do}t0t1!* zQVr^)SGWtePf4WU^;Rngra+G`<5y3D0VG5S2_EDPo6+tgmt;0gT+((nRgGhAl|RCR z2(keKQHWc5mFFLNFj*gdp3+%z4QfL z3%dEF!N|9l{-&6@Teej+b~Q%=e0xFbdK~*nqr87E#aANGE0kj8$%w6cVf0> zhbzSmO+>aD#2jr;I^IGn;Yc%uGfkU$6Q9|sH)-BD*c(DP_9;ihMel zZYmZ@Up1MYZa;Y$dJ;mP>S|HWWZG2Q*!d8c*-mkP&WE(GrAv<(!gO+)_@|a>Hj3ZM zlk?d`&Gp=UrEv?slFQ0%Egn|+QbK#(%d+Y8ECY|VYxPf`@%(LkTKel=L?)(O4^JD* z`_+18qt4spTyh|z8E=XOZ_WHllFgNLIQnMa`StF@yyVldZ(`0h_q3)|BJm$N3UV8NI@HZ^>JxO^u>9@wr)k~yo@0=-W zI=Gg^%37=~);MCsXeO3%F7A?}W5zfYJR6PPvYd8@-48nYUAR&|I22k)<=^|1MZW)B zIVeWoQ8p?=(&uq1TfD>rAnVp)Up_0ov=(f|t0I9wt;&0wNU!|PKm1^r94n4qSXlx@ zOR7flaX}4gHF0WqBs+{;_*!9BaRJeM>a?8Ro&{Ftf?8*it};V8>{%G6R5jr?r(obR zojXOTS(Zd~it+_w2oQM(1p%E~J6zw78xwZ38E>8j-6dssgh4;?mj}O30h?^}ec4Eu z9k2in=59Yv2xD(4G{(RlC(zDdE>Zv=jGTczxPAGI885d#4<9(IgD~zuDug~B2W1{X z^unI55BYIsdtNG?1JHS37LvmiGm0Lg(^rj>%;pcUg{l6viak=8bQG{s7nk~xEH0+{ z<8L_;<>Z`dI#-M_=Zsleev(630lBM5^tP7=QB8*L66X;KFSj1}ot`Wnz+3H1;ULRW zFQ&NvV@d(BAYxc4x2f1U<=jnp>Z{(rCZ70 z09|d4u{eHWhb3V5kFf~6*4zzz8yYlw6R~nz5ybtb%hisGP?|gZpXrk!aH~ONvaocXy9c$Ip=%i~e?7n5ffntgfR1QP`*~5cw=*cQ19;ZvpzLC8Xcx>nN)n5fJRmq z>GyYk+*Aw`XU1e_9}nVi68S-JM|v+peAWp=&SQo{-OqstuJ=a}B`nQJ%|JND6PF{> zWl*hfu6S1~(KyW!fSDbmwvEBylzJ<^8!e#D3h_emu-3e?@{4f5#G~G!I;gon1gq+dyF5;?X4%nQqPzX72(pCL2~zaa zeR@l*s#b6|aOR#+w8z>bJDB)hKs+hKJ=U6x)mw5czBE-YJQ2m(z#5Z#Xibu}wB0R? z+4U|%V!3HHP}|xG5<9Z(o{cHUzkgQ8?Zxduyoc;4y>CNT6mvyzPt^>)chzp+-ekue z3C&$i&AVTF2VUsNyOUQxs{e|~Prf4R@T(Mm{Sxpkyr`Z&92ZYQ%2Zr#9UMnJd;c4-`~CS_ljDmb zPgfrOV)#mQj8ngE-;n3u%qLtQB;nA$o`od zBKwCawQA|Ku>gFFW2AHARZ6dxC*fLB&4azNXmNSoZ1l7BDzR%x9ZYu|(ddYZ`qirQu^Rw0A(*2zHE&a7d{#AJ;xhX27){#&B z{$Ym|Q~g|eIoh*S$z_#`Xj$ozDW5sz_pc(kF8AY5Ei^17@(ABoEuY|a5=-tzB@Q0B zjiC34khklXrFh%zk_tE1yR>3x&b13xrk9OZu>X9Hd0O1EF?+>{;pf`tpMQFG{Iyhz zE#Z%hmtFMeId}zg`z#4o+q##pJAMX=Zz%Mh{*RB)9s|)MC{@%50|d}pn0t^z@I|pm z$sf^c2N=LE;1m&S5_|RA^deL#a%3o6W&0z1v{X^3w#c@j{G3a}s$HlxeCjtoKs0%g5c4JxzhZKA>Wo`=MaA$$e>sF5WndGd-c!&{X13VJCyeW z4O&8)SWcj;o9O2%_kAxrSEAsLN<@E{&DBMtcflV|UKCT7zI*!I2>e>j@Cz7iuP?33_ zo@p3TxyAM16!Bs;t_gF7iVAu{_C^(J;f=>$v~|M*g`AgNvW+x_P*bO7HL2`&hs#X& zX_nV)`!3ITE)`W2#=qz0FC~)ep}O1pgk07i?{0u`Zczgiyg|`7-NTnjc1t_>iLcH2b(skBQye|GdY!XaY zU>T4-nZV)o#^%-smd2b37}U6WdVjW}hzBjKQA+C+P?dFYmyNi5QDn<0Sc>z*>LL~t zJ4!#uJysP$=B@csqHAK?P%jyXO-Vy!k769 z1`$I*30V`QpU9AD#77L7#ns+~%ER!*@ht!#Cliq zzle6Oh=pOf5(`9R%oDN^TpTj8#TBE#dW}i^;-QjLK>8S302`! z7E)i~W)`D7L-uJ&BwJE1GDTI;&iB*LdXkSnTL-GeyD1d!D94jxU9-IDFo{KetGPK= z8PslH+_v>y0`#4eqR>7{@r1bu(c9{Zr)syi^cfG7QqG~6_UiA!+cOu(6C``JG0DG>G{8Sj{{8=?nmlC7Hf|na*h@kxkVkgpADJ}6G z1)qeF)neM})E83{M6_x9$=`+7>y>*+2 z7kJbI_GGbr$IEsS(_tS#BywI!@m^AY0grV{pgI3BSt~7CoQ*oMnyt8owc;XKhnjsl zx~~4~+v*#LO}&M1)mynaz4q<*^=6H8N_bp!TAUn7#_K=N#q~FQG&`N4d!m1ZXep_> z#<1feTl;4ZqIR9&tI(_6eSoG-ZU+8i=;TxWqX~T6I78g^2;t@&^!Zk@hjUz2$hS8B zKIxR2D%164zT%tJijRIpKCj*UnBG3+=pL*AYae~BEi}4uABx$JnTG3b>yQG&GNsc? z$ZqGjlF1kMEQK41I|=Xp;GAN8I_~=SYAT_dd%bhzk!I-Y4NdF?>WI8iO}E3+>E^3_ zBP44z^^y7Lg^ivRJ+U45d2*Zjq?RU@R(nxn#w+HrskhL?%`_%oFqklWz1^(ft-z*gL^-joU<|M~1*Gdnfb`!4q4oCB2{a{wL z!(Gm6#p&;>HUjsy#w@@sru)hWtl!l38>JiN5bVx=fg8uH|6Vu`j{D6M4ZJb-Mvs;G zx0h10a#j0>F=h}NO?u3jFi$0pzLcni&Xex4p+Nk$|jbpZoS=qQlcet#%K} zSVzr$HCcNxeplLuBl!_`_Jh-1Pi$p%__IS7@q)1H1h&LAG1GHOfz8k5=7PS)FISIiCim(v1Nidg8y~0nvGv5(0er)KG`*@Y(ajkurl!is-wnn-kPi{3 z)$CJmZTnH}q~=I`b|HP3iNkGMaoTViy439Z)QJy0E3{QpZuaYD=p&wPXZ?ACe@V?2 zi^1x-;W0Ygo_Q9Gm71YbYG=kAUlWa%8mr$0riM-V-Tt@)&1uE@;*Zy&m9z~x8-okz zdn=pg)WtWvG^o*j@Olc%>ir3)?N7MNqXZ>$hPxKB+uJ~fOdV(s6F4j6&|Av=0Q*PR zsB<&mXGsG#PfvBbv41QdTN50|@{eM)P=@QrabK~yANfR6xILHWInpvZFtJfZ0uwH& z_W6Yuc4}>p^}SM4QY`?WJ!H|f4+enpdgD?AcZfr16phy@#92`Ep+-`XWG63kG3rYleg1i`i- z*`6kkKZMzqx=pm1BF8+0=?EM# z<152p2pwU^&HF;teckU6iP#7X=E_57pzJIENO$B*Q|-F`E2vM?Gf?DZyO_gE3AFLx zyEUKYlzog@1cK@SxP_=v2snJO_Or00I$22muBGGYY2s{FrNrqT@nI%nuXS3v93C`y z|DJeD&4p@Vo!NS6MMqnWap!2a} zMu3jxgfz9KhR)Za8y>;>?lB=Ok7^LJ5Z}n|ObuO%xYNQNk(?09XmGNSBsPeZN_Cx0 zfQ=_is_Dg4&QolYPew^iibUA3nOE!DI4!JI?~d+3ZQ923k!-m|^C@`u1VRNNoRb;E z9>Iwjkk@=^Qu6G>hJg4rjpG*B!veC{=Q+y&>e>bJIVi8os6FRxA0{8~!Y)G96NQ-y z6ZpaaDpRs6YzxLLUklJ=9A{&MmnQfp|EV=3xIE%dF~lGgxFaU9PEv*#0Duh-D$2y! zGbca3&qFyZC^zmcy__8Kgq*cup5P=SW|IPJXL)E5h3GT=%-L-5pP!h!^#nZy&w36_ z-JHkeR@I5DIW9>?4(K#_S#Z+QflPy{VhDI-H0#a^HOXpCY|WkB@N)Nx)xQmFn^H)0 zVZM(kK|QqGwWIg-QLuL|a8{f`45$ufYNa>{J-j?Y?mQ0VG+#Rf_sXc(%MCetZQe$` zyO(jYvdjM1)WlhEE>0pj$nR`AinXBx3QkHQ(p>uCQyf}xD09|EJ7zIC3V%%^pBS@h zJu(1VtDvD?@9b{SmX=j$1gh26P!$*2q?%NLN+@fnqSVq*U9i$JNpDb8pStUdn`czP z6k5vf(^COq+hAN42U3ZibhpMgp`KFxZ35E zCP@x^Ak9wrh)YTXWR&O`FZ`22B?LeT(-lS#TZ!nhL|xx9Ot(aXHH4+J3$WgD1JfluQxMB zga+L;trs`QK9Y!}|5_P@S&!o+EB8+{3uY4GyQygZvXcw$>Zh zvV7Wq@BMv%SF77*1Yf3pt&qGuHaE6`v3|UB8+K|x{UJh}M@(TK`Us$!e<-j5``r^V zqViifFuK?D6Z^hG*7 ziPcoP_4lftsbiM+*@84HeTr9!DS@qRC8uS)6u|CPYGt`APk6D8!E7De2DA@$I01Fb ztKQQw*pV&mSd$<;@_$boH+-4#qH{j+2Y-bxKoo`gU76GdUf+=s4itF5VdQH zEDAM56@^MP{GjRzffZH_pD<Af2qFDjv9>Hm?FbxR`u`yV(zjsW#KLwEpycEbOit|9ebH+=qg zSO5PLCqnD=;OaR|ZDA4Jihub&w30Q_(`&HVhFPW|>j?)sU&T~<&b zgT?0ezy~f?W4w@89T7a9icQLE4`M|152mrXjdZhfTMcAz3`p&j#Xwk{I_u)nSYhg< zWi5I=vzPR%a|;`xf?IV3pGxW`f?*G1Jm^HtH+rT(a8NbdSv+AxiJ8U8_{a50X%4O__LGGuT@*M-F)MP$@R1rKDx z3gLfcDl`xm_1m zZ?09v^^*zjR#@ICv9zPFXDi=Wt=Qm_yqd0fkyZKZK=Qh%sBF}&!rg1zlQxctzT47g z9tbpNp9)~#KFo!Gd5g61_h%*I!0*I>0~0oMc2``=Cp8ilViWSqYC|xWz^Vs98>o*E!n;Z!%ip)mgUh$? zv~S;DIGR9SgJ}^axr>t(h>Z)hlgOM47y&_Cpf>L}nZ-V?3{q`Lr&B*ni|}jM(@UKz z|5#0ZU*!q*6YA$nC-|!~-1~Q)bWF#cntwilHy>PvYDYS%-MGnf-4twB1YS8GTxj49 zI8+k;&lED)h-0xgSY2GZBHQ0dtlB}BI%91{+H?;|TUZU0E-jt1;*N&mx`r|k)CS5Y z6>VA-`9f6*Vguf)vpHnd@=W4N0;(WCh$?!)-lNi<1CQ*VGZHece&B(`PrS z9}78wE(Zd#gUnwfYWnyepA;g-poU|zBQ5ap&5B~gLx{S^zx4A4A~IXDqkeP1^}`I^U_22eNITEqM*$?|g?U=Y76Rm{fMdm(3l@Mv zGOOg_mZwJ)0d0_Q^$9}x%b~nFQP4ZuapCo@Gl6m)de(g8$NRa?%sx#8nuo#OZRPI> z`S2)^VI>ds8}ayt{B=7wLigCa9p`h$FR0}I+uNpi!THBerD*UDGGz)B-A;+52YBffv>vvaQuRFhz&hgW+IG3+zt{*3JUq z@TfMqcipYKC{{gu2(7%YYmsfy%pHeG$nor@vpDNZ%)(HwmIH;zb$cUnq6sF9WO)%x z$*0wfeqIbvCfYv)DsIg^>SbMhQ>BH-2K0w;-KWkwL;rOV-BJ=S0t}$|oGl>@XpN14 zhL~IngOrl1?l?67;HIKD6TwO4ZUPU*rn)2z^#`;ST$-~05tnuL^92V%eCCJ#Oyg*T zU=e|$j_>x2kJSpXK|C}j)(VD03; z;edQZLUrg^y#L@~uB%AN9ZvI-WRr{mIae(eRC5acK3T7}W&W zEIIr^3Y_O6hXgF_p#^+bQVTbzRG}+aD!Yfh z^JtuVoAxER#6UcSZTXVxo0FU1t#M?z#6UfrBZsu3Z?S8zS8&ENoONEmLP}J8ZLU7F z&O?A-UzuwyR_V6tN?xB#FJA?H>$FlrTPUJhe%$XvBP&m$T=ZI+nbwmnuf60xcP=SA zNUfSBWJaQzNiQwruiM?6BkaEe{K-bB<#&3wTUS*6tj@zM z_aZyxb6?`l)?t6{A)w%QYQ}jRAS`>I+~C$D=C1C^k6gCi?tAI_OPo0P#d#`g}I8EceMbJ76sG@LlGL8NCD!xZ9x@GdzVRWd!{cm93s$!YX#x%l|TDGmAVMmG!34_3A8st#q7CuBLGEFQ;O`ibO5@oBhp5G)h#8`WC~0QJL~O&k zN>9?a7YAJL$ms*lfVS!c(1JeU;IalEXb3OR`0dH5=A@Jfkp5&udIOZirSTggLBW}@ z-Z0{YNfd5_!Yfns{Kfg!iUF6t(RuL4(vKItab7ml$ zGukXKLW8opuJ{VIGs)z!B3?yC$egW}xBqOIC?d$S8~x~5&QDtI#y!=kOr*TjC5@WZ z%&rtuTsirqt+tYG_z@-9e!#Krs54CVIj)l3ek*M{J!37r+F5B{$CjykX-1vIuWq5y z>6PfrAIRVlYUPnX3T(2`&KvomBjfkFb>kM=UIPbSmiOKAd-%icv$4&!CGEWuBt)4Y zG-wt=9-18gefstzRJ3{%^n^4)QFM65U`)T&jdjR z!+bp?M$vo5sk*Do#>hpOcjeD&+pbsi0ti8;ByAA8o`xX@0KiOh?9Ry^PC>i;A;?^W z!??)i2Fiy56p9o;KqhSg{yu=u*{+dSi!2)pld$7$k*EL)lB^B$59cJ!*bGa_3mzw5 zxpwCYTHU~>!5}UE$j99kQOJc@s_bkeE4?>eT#m(p-6G(}*qHmW_-2a3?XW@^@%GOa z*<9UHy!0Y^N*~7JAM48JT*b{50vCwEC%JJ&1wGtUz;%XXs&qnF`R~A#jWPzR+#py!P}`#`XqSh#fN$B=+nL7Jk&9ewWJ}3H zMTasj?QerThFShmyTQOac7xGDI&~A6;R89|uG#4)J4h78nRx5LJ>W$1KwFgY zBc%_*HN@5)Q7%~2RTnG1Z`V8K$q%^Uxyp>4qs+gz58-@RLiFSo;^`F%0)i9z8K_2r z<;DN_clH=gmTN~hF*Hyn{qCJ=^le{5`kE?%8#dyOmL=Tig9nL^M1zzX7e)ja7bf6< z>=lI9{zTv}fOb~N_zK--yffS)9#yoP^H1#-z6)qO!MSSISAjaKlOW0i^rz_$TkgEh zx8hG#?PO z=d&Nx{;{YsksreeT3BK#yx)R?nXg$XFGQ2!q9x|R2eTqS^S{*UIdURO3^$He#4$s! z$InDqx?{9M=>2?Bb3o{@9}SHnd+gs{Q~$iC9?+{^;MEWH>Po;%LXKZ#l!PXh>Nrza zn|rE%BAyW6AbLE>+HX^N*u1`x6X{5Af1v*(&ww+K$ISC9T+q}1?}Q7<|GIGT-(2ed z|IjTY(EfXf3Ii8s^ZyK@f?BB2!vFX6AMeu|Hqu(Fn7pr(yBLGbMqG4D8ob#c(n^Aa zMWjXjab$5-aYYWB8tA*&b%*etH>Ccj(mE&>S89PJutX|Q$yCu4@exgY>$UMsGV?E| zoTiN3QmxWDen_I0=oDjBBXuQ^_`pE<8tuUU^>vmF1*D<%NuYHyx* zgTGaP`&RSLO2>Kd{i24L_WLG*L@*H) z>H$eG(uf#JNvYrNH(F8UDUpx^MnXs-UbUd%vbZ+|N?@CW2on{k(LF;Lap@mo)u!5E?9)RBm9C|eV|QLWWZ;1|5RTfoZD>GSk|S6&QqCmdGEvk)jT*zTp*_;4kK^)kjJPeWu zQ*`E_)c!q5aB3wUi9lPM?{-6nINA6g70p@nlr?t34G4LGNScL#G0EU`#p(e13PEaS zx$QBV+(_qy0OJHSD}~->6X*GmnadYoN_g8m8W8jBwvr0R*nwq}D(5a_wgEftv4p!G9Bl<->vU1uXg;3`#6KDQ4l^Mp}(4v@DA9Zz`FMaAb%SX*kfFWw< z!FI?5v-o2SjMA9^2B79UK?Uee1(eIj+kILS&5fj2-T71>S~R)O2lfvX-~m+KFbP6B zp!)_T{{rlNVFwzx_n}A~43}-~D7Vwzb`FX*hZHe_alWnqZ@zirHtQh*DnV`osC%j* zWIx<}cM=p>3CPp=HBdUFgKes?&cnJnO>-!gz5G4bqFSs$u&n?Flthp*(Be7J{?{Pb ze$k-RQ$eA>h;T$pA}(^4PUah701R=%H$x}BfVv2IT$S8V9F!=r?Vu2ccl%VX!h%zY zD54R$=!TGo4pGkQALK2CYbH^VZNt{Y7iMzNhHx;DiUc$xMt}R+`SRt@R1Ab6LqwA< z{3}rT0Wk#F>G^SdlX4Bw53Ns~5;@_&Y;--k&o2S?S7&s+Ns(TXr&J=^cUr=E_9c5<^`h)SRlr7DYagnx< zM{}ncQcFO1&~!cXqwmA}5Aw33sgMkK%fC&0FQ`$l^O@H$z1eIHm(o=%W1GbT#4NY9 zWG5rM$j0kt-P>t_{BU*D&SG&#=*(u{eqo~ zCf51P=y#4V*(JWc)j${#5>)l2f|Mf!Rzl%v`wYf!Rzk+)S~;VbU@a zHJN-tjj(3ptFzHG@PF~Tt$uEFd5pTU)hHgz&`e@vTbAkNd2Z<{ zCN=RQHR4KZz?I(Aom}%&Y{7d!>$KREBlFQ~m?0YFiPTzr;ay*N?YkILZ!NCcGzCA= zTv+j;fO$cl$$pN-uuOet$g5hW_OGb;wrvJe-`)iRdiDAWyU9mX zXgm2-0k3241trNy`6OdCs*p;*FfEWO>8{AkrgSassxzACHQ5B zJSGvwq^Yh4{b1i!16%>fxr_v`(AP61`RpnO|Qo#(gi1o~(3fx_tPOJKlR z^TOA8Y(TvOL;HU*_ZCo@F1flePUG(G?(Qy)ySqcIZDwDv9qT1mczeoF+$79AxC+!Kb z524YW5d-r{&R$M}buig{=W2{pzQd%N1upGQ?@gC*V)#%6Lj4nIKWm_B@U)i?>p-*e zDO~z&aSIV=GK;z3g5r|6 z%O1J3SQ=FBy*!T4=!QR_bzRmdB6$ghp6cPE;$PeEq(=A-b!`by{7%NAZh(der3A~m zEkQSYQ%y>JDG@}c?-!E}T#bSDVCbh#%jt;)8DhgApKaISz4u)z912efk&h>$766A9=I8-8ixAO{=Rk*P&h`R`>1`uKn=FOkWvS zlT0?VxUWgRsA8>T)?Q?BF?Ly<>#b~1YHGaeSam%2dKJHoMMM7%m|sC z?87*?`g+s6HddG!pvp`|tn3intlM>`v1?&u{r)pLC^x81@{X+1u?k=GK%tE(&VXE5XxpG9K_psc)Fw-!O=hz$OVkKj z*{jQfg2d3?>n7g(y)K^31SFZ$<$0z1Y&{d~wz;kgV?@@4ivc4ap6mhbv)2<-)#Z42nhn$?%%4N0$>5PrrV8e?xd@}XD7Gv?daygoTdLT;RLvOi-E z`S+M*u5J~F?(IEc3!k0vwZ-!}*lE_7lcaZ7V16JMK114CkO$A{P%C{`nqknt5Yu3T?T>44vO{gF)t!YF_#w9tIYZ{sdr@EBx?NP63cIE4h30 zu8KNUBf9~@*1Kc=vlC~Jl{8J#WKkdKadaGi?UWFAzrZCrvNk^aPM#pXFYK_NSq5Iz zRxJg*tIgL4qc8I}j`u(bexXiK%aP8|5sGRV0^6Lv)>4vK}iFwPnQ%=I5N1rUH!tiahm9OS1CYg~A38s08)up&t*heYus%l)x1v{fl=jysh00b^9)kfgPR`WsDKvEV#(iXLI%mT`tuDcknZ>4Sm{6>o!^jXdkyl(CZle^ej zh8fA#5Z{O-vJ* zWjoXA&(-J{&Nqm8)rFz1KlPO%3=izCh6Vu0!}?nv@wZ;n|DdnTzv@8!C!E9IUhV&* z_uZ|Gjm-7`h%GeOnAtb`XipqMoYxOq)ol$t~0hBWrhEpN?D#A&}zyMVM!s z7t)~}A9G{x@klQAAZvRxH}J@88qL5H>Ef~KIe^zVG5D7KU3A;d9t3x_1njvilNp`^ zFUg3d95u#Lt8cdaE$fH1MUmQ0asRqfsl2w%tAG1*5TqApRVPK2cbuSZwrUr9m3Nrn zW~}Na^-=P-!(@uvD7QNrx4SWvXHd7h3bz*<#Y42o@9QWABV5=grrfzXzJh)LA%xM+ zCM%nh+QvmIz}`Ar{%>2ZFUYik^*N-ob3bZ)H^~%Cr=Vj(pP5X>WHLB-xQXLE);Qx3 zO^P_;40?M5OR~2E!{EmvzQ%~52?WDm5TU&GYtsdG9ekN>WJIj%C;q(aIg8U-l)7+f zEGJsVX!PCQg~i?-eJOu65BIFxS)ocHgw@Jqsf3%PL<(Y^dLVh~`1)RKXmsWB zwH!s6%T`7njl0y;YywHkQ{8+yEGpZ=Buj@}+Q>|uD1E*leNf&M^F#QY96BlHr(9}n z3pBs`*uYqU$pY;{7m_1}1mUcjwB=bxJkKmi)2fJyjC*;Hhul|^gWTQuo5 z_T>|MmW{N!$_YO{eCw<`*wfH!Pbg?t5T*wBYq#D;%!|7i)ogqBCw(1E?8$qH#e(cD zbW~B~(ov>lY(awCS$7~uxmbv1SJ)ot;i~LTU%ysqgE+J){Q08?0Z@Z5)zQ1Wc?^71 zk0Fj8u8|P`jT(3^B8wkXz3HO!_B~^S10lj zdcVdvh%?(CxmW|w_kz-f-xg9qV}er$=H?RyrH@Uvn#H>^LknHLu+IS09T{Wz{iuOx zQOHg{SpDep)#(Mc&pRw7#bEiu3@L5T>vwT_cxnw-lN(FC!8tuyO&vKsri}tWm!)A# zCz?Um$E~xkxq2IEriej!OHk{9M7laZmYSRV(?`HlidcGKkbqIYrb-jjFln&4(A@e` zGJ;3nrE>LQ@YEw&rpk5uzVG8A3R&~`d5W>i+AKF)itQrp?f3(ARy0@Jz6=TFH&p1G zU=h>yJg4)1KI#US$W+Wcn!cHb$)Pi}kGo*HTqkI-6L{gQIH4SHm0T}|lW)xq0Jmo_ zC-)mE9c=PvGD@t(PQoIa4uYEU+%7*w0OfTO=~anO6JwtW%D8GCOs5<*#FnA*bKBWJ zqlbrgdB}^V;A|pdqK_CR7_TegFe!_DabJ@1n(afHUJ z2gGa6UK`WQlLP*gW5o|16Y|2zExod^PCP6gwL$?*g}m!E%r|JoPIkE;KN+@*#Y+Z4 zDa6a!RK7C9sa~RBdcWqIKw>jyi%lg%ewNf;jLbu&QW}vY6FIwk$H^T35Py;~sU^dNWhEf--eAVxqgAJ2Fj{*N&NbesXCL$75fHcT$Y=%X(`Am&ut7I~GzjSPR5frWN1i zqzX6NSC9?m;T-LD?527VX{${h+S1|N)gG3)+=|0swns7;To}7HwElo#KTOwh3r=pn znaXn_0Z}ar+Z%8XRU_emobGV&t1HIDL<_E^hW>G3^t!J_A zE!p<0u%f z#><9(tA^ZYk6V$-X1k@|k)-rBQ2?kwfP$uju!3~vV494r&NrRwtzn>L1_5h1m1wccgtMLJTl73alMm3gzybhhOo$Usmi^hc zAP|hw;XKGmg?)wn+NyIuo@WL^iH=AadC?}&K{J&u!$=IBlOpT!jd@e$jwbb2Fuaom zdX;LLL4YJTdKKg&Y*cjOW-yuNl(DTxe0-F#AT0`}URCqR%KYLSex|-D5HD-c4x=#~ z!s=v#vV6lwX;f{@(oi+>19z{FCWcC;L&PuF|iBbjkr8I{lF%pi>0O@O!Hw9rh_u3 zdZ(VxWF78~_}g*K*!)d6t>R|d(HV}`r?vtpeY8$^g-h4(3qO`Cw5^B*MAL5kN;=R0 zU^iiVZ{1!`SM%<_&md%r*3>XLa<(&ZS~*F21icWHC7S82<&oHC&s#^S{p^L6 zPW#l zRI_=`V^ZaQ5v9(#A|pbhZF{k&B{$N`#@rZuQ3|}NmeG^abC$juyj%+Ex+g1|XkjHp z`9mQUhP#c>Z@6Ogyhzm+qE5hJd|BQ|nm;wmkQ3Db3)__AonSvb(0x>FG#y>=YobIz zas4vs<&n6{@<_%xOf!~ErB>G1ECPGbpEEN^us=S!+yQWKGwB+_2)t(qgpP^(l5qFI&to!tz}^Bg zsJeonNvGfv1uT=WKrQ%`AyA&bz)wgHAhU0xJ!ZgdE$IeXtGTxQ^VzYL5EJC~H1`nx zCP=IA=9b9}IV#_f+p`@kI&lsSdcmiTL&b^+Vj=~FHFu_f@<;cS0m*Nk1$In9N)fMs z7F%D$!-PP&qMN>dv(ag_qtNK$GdWRjx*M?cveSHTwIVmcYK_@$OmG;=N<=_un39Cq zt28wc*RGOGf{z{rbM8p2g&p@yXvP2$>7UXS_au6m5O)RuglSK&oPuH&;S|9bUeI3)?axJ_(L zsxE1_%P4K83XjKoH|5duz4btA+`)SN zMqvD=r}dyO<)Dw83HL#&-P^2%$>eMeTjSh)z(Zju?Wp4H;&YbD^z|?yBZ@VN0W9COOmTN`GCk;mF$$ zE*v!8gvv}R%Eqv8654ejB2G|X-IYm#WoxSTt^TxY47DmPO)e!OLL zog-N6J|j|T#G>)9A~5EU`dPjNGo^7EXC-@A7%@ummB6g4CH1grhfe& zO+FIK63xumL03+`DV=aklxS+iOz9EJy4V!+EVda0W}H2ywgjDDn{dGpde+GXnZlzO zzI=?2D!5=M@|&BcjtP-W<7YnR&x!ar1nuh;_bE`;aUgeMY!4z%Xn{g_m?+MT#{ zrXOqGK)tjiVdIv7BLaN5Z(f+*NeikJfZu_C9nKmQN2)==0sttH{jKBuTaWaAP)7J) zc255jp7(D%Y5mcTu14k#hBi)rFqu3Otls=u1FPr+m|mH=8sVq1?Qp;JVms zJ!+Og!{&;pLR+;>R<*2-V9VqAIo6?#cD4AE#_{l2;p(8rY6JIcC=Qtq#dHZ?t+ah-OKH0KM=!P=MKLQ`y?Y zCmmHpWHFGp1@`c}McRU<=de@6yIM+$YuAb^e~V~HdUD~f{A`p^Lh=Z(FF@hGs3v-c zMbtS#%Q3s+Jt*=j+$|#K2>4&OR(l&m3A@GdPvbz@@w@Av7rxAwv*}LU>?p}uejFRG z{M4wxgS1x)#|Li*P?iXVE~#IelBA@90z^NRuXu~47Fj&I-&yXj1H0-kNpei`#sy~y zt92Cf_W~4Q$%}ktS62(1keUz@Aov!lAk6aEr{K~Apqvo9@WYB6>IWC)Di^x%`jItZ zfEJvxe=-L0P(W1^U#X}e;NI~M{%m-N+gs4bWAd+pf^u=P3mKf+G*|HjVF?$i;I6oZ zXK2C5=p~bp9e6~9DTcxA(Gf^9`U{w%0Vh+XqS1$X`~4gmOhqVvl{w>aBP@~EQC3a8+z&_6o3_Qc#UC3~-4pCkqK>8iZe zP&!rq7#rkckpU}uDSA#?cY<(PkAE45;#GFEeLkIEUT%{G+Xq{Ou3u{P&oAG0{o+eY zt`6v^Gt$)weE2l+g{d0v5jM62jgDi8Q8i;LeCK?Hq*7+Ai2Db5jH;HJBQef|&t94; zy5*zFecmJ=xj835XR~krT&*OEH0YqI8@0QuO1GX{&ft*z6b(o`rB}s{=mBZ9LA|+N z)X$KfaLW|!Z8-)?qv7{c_&^qwko^-K);l6koeKXhkn# zJL1@kH8*;CGeB2i?~?95aX*lQ&&h+`E#xLZ2CH!Jg`s=dqBeiibf1FJ;#?nRmcT%2L1sC)ew%W-$xwY1tG3UQ*QRQgXXG_G6+{$ zR3!&}!3>5+ZL5nW5yrw!A7atc8tGRXSI~_D9aNR=YX<6X)pSt-#V=Ol2rzhp%-`Mh zSSo7@v4#n33WQ_AH6v|fb`=Pt2K-(64WivXiw&f}@kr0dEXg*GYl-rXMejfiT@~cJ z*EzHw|0GOweBO56lq~kx-gxYA{jA;V=DzGk`-3~#k(HLq3!<2Ak2A#{FZDe2I5liS zSGIMk05(wyP8w#yeniNrG$89}v2-z2^G5i3t5g|jPEc(LV5c^!q0%;|J8_btVUuTu zYlBPky}+)LVUM@ z97S<-FyynDyF19(Odis}tl;eDcOLqsuLn}`%r(Hd zXy=(R-}`8hh-wsnKpf0Db|0#3YNG&3993Q!<&khO=1`|yg(yG{rz4xJ}WC-P!AD?>cv1_&t zBJx$E*2C8>LLTS(>j|UY+C$~RfcA8wE76f9))#4xa8lYxT{Zi!)_OYw1MvlQB-S>| z5{n-x9dn*?c@`SxP1vMFtd_GX?S0iPT?d`w>m6#vLbBNJb}j>l-4pw%3#k zc(TaLWIt+IcLq){ZMJN^cK8%Inh@LV&M&cwjh?4fM{ANLl6aU`E-Yt}bvSfH-08CL zT1kD?mF47(binI$Gk5TkRbX8pP}nDWs((`eZsjQ}yHb{$8iOEKUR?4lY^hVn3+*C! zgYy%=028)4Iq`+;+6eC_ZQj*+VzM34m$*>QVmpUGL*k4$?i;8@p{+)UxDhWYm|x{k zI7I60wa(dc2Kh)+9T33|KZ^+*5VygE)6D5EfRjw_8AM6|3%dJA<_34AN#&Dpg_*ogn&GCu#^>4`n7@U9#IRea+)fuP!gZKOCi>J#c^vy+KYfxxbs%TWL%S-=I7ib{&XCqpec1erQE`XTa+U#%#sHYi;W9SNTl4!y-{IR!y`j zO;?P0*@VO}TQU3iBI;f*)$9u7qaowdpKAkd1X!zq;%?lW=Vj%`H(qJE(B6qZC-gm9 zM9f)D*-*&cda^I3J}P1m_2YCt96z)R@T4i{@*?L^bL(wTi&>@9l2#%mf>bkMzxE|~ z=~UU@jDWPvVTyI!k~7Y$FjUM>!KZXdev zdF$d6btW)lJaAjV&#!P>;LdM&-2iYe0hhsF*2v&`gm{U;w|v2O;jyn{vHgf};|y)b z4efzsTm^Cc#5{#%dMIQ^3@=k+MvyK8Wrr0nzsimRK)G<&awL;D9alN}P%uG)}){CJqwrMw!9e$wPgKT4a{!{$_!-%!wVW(yP|w7~EH5rSi5nVdy=g3q=AD^@q}5 z4lzIsej*w`H_eewu-Jlo6AbRS6N=}!sC99-)Q zcYpu@yug3!^ZwQs{2vrp|DSN;e?roklgsuA?jasNgMkBlj*Umz zFw#stKet#Vwgt$z$%L{FVUuOWoX4sgq-}Mpt(m#Jj2zy?659amn?&xwV+iKtDsi!2 z`yL2Hgvg%p?9T`{ic_6>fhdpzV4re=2(FXMup=%75q^k`*Kw9jxH%x$nM#1`j(bNc zo)oF^`0n$Hw_^St9?Eu&aVI>@eKXmv>&-zzcqm_WD}MN>h`r)Vq)2dFUm7 zG{BwFopgc6WX<**U;^~IFdneF5fYA9AJfxL_kch*M!=?r06*D=R3qg(JIOWOve#33 z_hpVtWHfCJ7Nx=Q6k9(L_tOmyhJ1V2Gq$2$v!$T3t{h@c@gcB7?wJrKJGIiSN9R;( zK?pCCH-qysN9;!22_ew zx44Y62Zohis$i5Si?@%9PQ7Qv1iLa(JCG z28lsLEy2wR1qB~RLNw9z%4rQlq}AJW5dZ`PkXSohoac>!fLlIXIW9Q$Q(`I^zfxZN z8T<_O=)O;2&PTje;z5d+!4imFpk(pZB}`dRZ{yopH&{e_Ai39W;8_rHZ#*OO@T9xF z)ZMD_q8tS8~AyHrmugb2^7Q6$;%v z{~4;el5L@u!e|5>T-r;(AWuesb+W(tNy3i=6xu=vK}GxRt;upg7}Z zsNtB7KqgTv#fs2dImWU@;lh;nTU8(~fJfHn17kC1mf43ojB9iCT2?bpHN|9NL1IklCM#4g2o-e&lS)sV7zN zU%Ma}NB57g$2XHr$p=M8MS$4eOQ6KfN4}?2(4wM@U0`9&S`eR zDffJgJ8vByc6Ffp;e|{cwz%5c=A$qh)}rjEL85~c%sGB6$vg`v+<3~Zl+Rb>r7FIn zg89{ZjnL!vhOeUTw*j#ZBM<|lp5&B<5mbzZ5O}Cm!(u+jsTz*G%PFmrnQ?`L9dA7s zG0I}pNX3I}fBtGz$o~@$CZ}#QjK$l|xstoQsh1hY;Qs zn>f;yufjrF{v74!o%%B%nsiMUh8WnL<)~m3z~3PkQYiC?<+{uyL;!A*(wyouvRcX! zL1RnSi(;hbUv%d3mOg(!8V303?Bn4(`%c!f1V7I9;2G+}8Y7u&ZD0jwLb;5G=nO z{e#8dy9yOy)k)mFEyXLa#}9C)TCGoh>S-g=nkVm5`JjH$7i^E7?~w4mszXFLb=KMG zA%>GCHtbNZhI`LkS8MC;-z%)wGE$bDKCR}dKrX&~AIM6WBS`|kr(c&!L=Y>01Q)Vs zW0IvW;PzE^N3rw^y>IBZJB;VyzzFmj$E@I$oBDFoInD1n)%$2qhR* zyIx_#u6#7W`!>_48`rF15sM#!@8aO!$!g(`Q)rKqcd>rJ!h~wGDeIz1lBO~(BT%Zd z*cL@{_7fIKQj&Lvg+TY1O#t6>F&9oLQg2~HWMIeL7{SdT^iRGxp@TN(NO}c|)b=iE&%`vYchM0YxO_MX6Q${npl{%m(gV%p!XC z5L5OQ;d9!3rb{n0ESLm{{uj%u`~g1&%R7De8I>R%+4visbLR6bQt)w@L@ON?CwUMi zG%F=UV&!(r1u7Y7WN)g@@!5pdFx8gnyx^F*3MF)B;nK@BA`Zh;51>4gOe&)Z;Tazo z26(3uI^f!lV)RD%`z%p}dmYdu^J_OU0u{Q^pu2AeWm&RHp|6R|OMH*Ay#^io9=%~z zaPav$r#?i%f&#L^4=VLmM&*X{5_Y@9g}5jH8r&(AgkwY4(i1j>C2_`$h_wo(ND zg5l!VB!tpE2>{uu8KGrr&s-`Hk;ABzll{PhFd1*`QQn%sO96s`PuT=1A zfdb0|W#QKGU_9qiN5;zyP2gHQlJL(&Ei+SrTFpyWh?p6CGCxZqIp;hO5}wW=AjoK8 z7m(2oomSV;G|vt+Gtay|OqZ|pN6DZgr@AT;wWSX=GHg??F6qE-dxK4Yg z)#<7-q3M#gv`bv|bMr`RRIVK;T)#RViWF+jmhpuXWczDTrS&rISW)mVa2!h)2xwd) zw@PlMZ35H#o)mpC5fERL~;bYtRYPs&AtO`lLGY|&M# ztiNj5%3v2FPOTo7@E|?g9_r3#Gf|cb?P<@8#;w>P23CrjZ@1GfRSGpWkLpP-(6z2Y zY`3P*=Ult|cwbEhzSuOgU}3 zcp~F+;#Rx@O)cK6BfpD~xP21o%o)oKHushEkg2$r^S!2%U)_u~KWf|10Wio@G6z2M zcE$!)**%>=e#k9@V*D()-&l1*s@;ILwm-<9q74#WR1a%mfL9j(xTm)u16#goS?bd# zY^{<>WljKnkfl}$s7jD+LWN5Y;fm0PFb2nCBu{xnVU{e&=_10QMk|kGylCrnCrW%E zSrZW!-7^s5(qlwR4hR57i3V(a*J&B$m>$mw!RR6>#%yi3i40*o_B<)Y_7_VLrK+EH zp`V5Yz<5yNuoI|2_YBP&*|F8Sej+X2^|flkEP8FXnu)^W*2;;ea~TU^jC_tH+V(PD zib39F_ESQYN6GCBwMUrGrXQIiww9~0L(egtCcS1Ds>44ZSyTY`Cj@iSfK+IxF36wy zgaVa*u^q6|#2m@5IZT3ds#L-wm3g^2gO^R$L&rl)La)hq&^r7^yVlyI-92(byP6L%L zT`Jdns%+LbQ;Hu29&RCe37y5qz3ihpr(>aq4p?vNM5k0MZa- z_t*pu+4?D!y=O~eblG;=_VG4Ktyg#QYSW$pEcmsRB95-Mz* zk2}6J>LU@yK8~fFTg_P}LmO5mGg{HbFok&-mT@;hzZCow&*6&RiauMtW}e`ojbyig z>DJP{qjAavPidKMl@M83l9IQB5m-INE1v?LBw??-v#nNdt9Xj|eci;~8`NAz;9E;N zDk>0{;!CWQb~I>oEpdN@b!3)pEemuhRiw&hU&9)BaN}tE%thwLgRhX^Ni!fCoW=|l zqpd?+-C;G}Gp8v}T8XjT=IfXmG>MJw;N&enG)TJg*U_EpQpRotbwVlDU?3T1rsXx& zI%1ikeyDljBTuHhM#l-ELXGJ<;za6PRJK!cj0H$6X?qCEEK6!l80F0;@_P6*se#Q* zZe5?qkuCHjwrx#Bm3>8&P0bb-Rz-*V&9IYNX=Y>9;T~MhBt_??koAJ4ROVs|#us(Q zA-niJbX-ZGZMt35^#1U4iGhc zgN3yawh8P8Vn>3|nWOH6V(}I64h8H6@#X5_(DTp0a70=`bN3BmC-U; z(;1>PZCl)E>UW=dY%U#V9b25lM}tw)olBqt4o6^-_o6r!SC8#oxb z4Svm_eM`NkIv1*XSqq7%8YuQz@}h&d$d7qIfxO6&_%}dOn-xeXp1tG$ni; zFzr2SL!e7rEzQ}e)K{5dWNA!4!xVIhi`Q5GWR56;)?`3l{@5ur-y@-liztfHahJom z4@}<`2H#d9d$VuhKHltd>x-)EAdaW~u05$|yd`0J2_x(855<{2GFyd~rNy@pN8c-` zfrNKnF19x#Cn{;nBhH)9CrU&}?-Wqvv?HTUgZX{e+fW9*dr517H5sy8i`oa`6a6^B z^*TDjBRn;Be!A_cEY;u=!*`I1ua3im>4eJ2_SrQa1VtG^2}h7b0j|xepB-4DR*KZV zvl;V|r4NZ6Sxy#hLk3XH2}Xrk5yn3yl<;U-7$#nSd4@^yC8j#HN~kzYPmd7OoCdrkOLZF2dQ_IgN`e zaX$znvm|gDC^k%C7S(Uuoy15T1Pcm3)4A{~C&FCWo^e!=E-R=N;8miu;7c{Ia&(_P zQ`sfv8mX>~^ z5~ZS)FO9!i<$|i2DDRapoNKEnv(zL}Yw%TrXv2wee?7bRtfcT_SoUNKb}b#S>2N^G#Fsj| z@5kaeV8GcviD!F;TMyMx=qe!A8C2P%X8*FLvU2Puh0eg*g)fR*H_=3pn+m)cRH>@7 zP&+~k@AJ7%SIW}3?vQu)GMU|f&bH)2?i-W3>`a)B^~43NOtk}nP=NBJF(b{_)L~&_ z+=1|!(pYG+Lp_NMDAYFqMZms|NL7f1gW&?pdW=-a9~X@pv<}r!*Q;I|)=ip4O0{PY zo9s?qil5OWX2(b=N1y0ULKtUKOqU2pZFWPp{EqwRc@9VrnVc3&XNmLg$@ zE*jr}+UO?YrOHT|@a1~hyh-~g3?(l*cjQ={6EY-jQ{0nIU#qlI(r*^-02*bE`V$D4 zWfR~%j%`mPlB=9o(vveg@F@$Uxt3fwx!wS(3=JZSK9em(BDt-8*04KXLC5lD;fSiZ z08%u>&d|dzz~73sIiBl|mL!=2%ZP-q+)WTr(&ti^f&>|YA!U!2O#ir$13Oa$k4LHm zUTy+#Es$Z+(pFbz_(1IR6(3r(6gFiC1o&H^<$n7-X2M;^ro_W{N9gHR3E&n_dO)#I z=nBMO8|m9pQXAHvX+ZU0@w4rZtcKVw$NaqB*4>~`Spr~M>Sj0{9AUY}T4o?$=JG}v zh1iK75rLKQ!5WDBs+n*s;W%ds8zgM!idg+uPmZ9uJaSHQmF@t47Sc!=Yg+fwZ)!ne z%8qymG*RxBoG2dnBd1gqjaC`7QaS>&WQ8yxJD9?j-{v-eb4e6~*1hGb!wde|9FQGYHp%V7=-h*6|JFb&}okFTX9B6j4$v@pMM=AIymLiRaIJP5fHSZ%sKQ z$bE8ZyZs*WqhK3wtG-o9zy?q}1$03$RxNo?=@@4Br*ZF@atsHwuo!1K8YkX(q@4i{ zYL!6!!a0KQC3(xehHx*5s?LRZilo+jsUEkCf9slv*R6K2*dd$7IsJ0S{&Q;G-GGo$ z1YO}SqCB}@do8uMR&bCCUx=yecT9@Td3@+l7UqiHwCmt>%fihiWO9jVu7o~>yW59~ zMcupzhwsO$3#5khvlszqfyq}5&*RD+X02B-GOP)M2NbBGV3f5f`o{#|W9lxfSYldT z=@oDX&RjJRXAWa9QV<34tlpF@y^?ud>M!`O^#(aii8M&b*#Yb>I*2bE|IL&HUn zqoY1qjDRxE_*#mz2vU^{1rDXU8d`{)CV~Al|3u;G?TbYQXtxjWr2=XFX_6&;*C;0m{1ImV^zrrE2jcuaMF01*|GP*3ClDbB`g=sM ze;*MCV?!rkm1+(S7?6wWYWKd=KlufgQ5OB$?swG=Q;k}82wu0 zMaaL$2>16f`pXfMt=sQ?fHA+&APAN|-4v*6e(^1L~Pso0rpx6fj_$7*5 z%)Y)K=ga^oAM{r+fBOm){}jwW_%{D;JbzCLF@GOVD;pzYONZa(xIZS0#Qzke|C-|W zR_o6*{BOVq@AvW1)wOjq{5Tgh*46!oAi;9{GmZ5}IO#9*{3E1(P4n+Y>X&4a|3-Te z{v)J5j!ex>j2(WZBK*E|o_^@Avk&?;D$8}q05d>uydbOW$2S`I!w~e(`uV}+jH8YsfM3!vCu0PTPc6IEHd>!x z$6B<4NnPO0_{2i{BoOuY!%^;}j%m>T$~^hS%JWt7==ksgAc(ze9K!~v z6~6nrteBv#$CWV&(hFHO7RUpTJG;yFTaL8hWqbv)s}M<~s?b=)p1ijrI@Dz9&RQb* zCy9;H2cBczaOSnm#Izj5n&S0)PSCp!QOj2$y6XLF?~9>+&8*iCYzBBil0=bDXgq3b z8|wk_hE?o$*Guij4ALM|;s_zTNWXUYn0lmA96$g7#lOpO`R#hp{}Zr9?Z~wHhK9xtAMF4foPUqoQaS6)(?96fm>F9G+lLP={NN20 zp4Pw@pjE9v)gzoza=e+0ngJ`#D2&Vg#J-*|f)(sbDjbtQ;zMK;jGWwZgrQgsky~n- z7pdG@tK4xZ%&pVSfm}08>+hikpX&!~-x!akt20-AZt7&A?EhYFQXT=(Hd;hAasS~p z0{cc9`vF`@t7d={ss zos^mylb)dZ@n0tNcjHy^k76YN7$5Xkz5Uy_`DX(5e_o4L_Yc0C?5BGn$pTB&p z-`Rrxg79Aqx&H@)@n690FVp>=6#ufY|5JA3kKb@6*?)`MUn2wmz<6x>;LDZ|`ZZRl zNXzcf!*@KWs&N!5Bmxgf@Kno9SAtLpJOs)a#eY6gHgK^5mnV38iS9xqgORIG7h3&p zvk_@JXru9Y23{UgK&=irT50((#h)B9gce3N1r@0 zX-o~FTKtiyr!2ak7NGjCqp!W0L(7&b^GX!8`$0Ja=UNnvdoZ?{oH5H$Pib^CpC`?M z56J;TCe6TQv2#F_#T%ETYy&iJpaO^v5GH|m;<%)pYw{TrFN=*4M;AmJ>6^&K;22Gf z;W~4F+LE#tpc-Te`;TgXD*QP5niOfHI?~cKP>4vmvgocnw_jQyETBzuW(xL`*H1QQfyrNl=ue2=Z`v7HfREB=9VyFFC37#N{As~boEc%<#Jd7-eioD zx9Un8n#c>7s=)~Qkn}b3{Cr=xyr{WK9$e8Wj{HKy+tUJQLwQeoVihVjxPcL5yK?#S zc!n#1fMp{Mk*#ur#+$k&1D$v;az!;yn{N*l*fkwVoOBd$8rDcU%@_HP701S605R!9 z8l~R9qxc=3NY~gU^+C$1pMGZ&e%MKeXOYVauI$Qd@Ry@qlPB}HT9oOZRf$-`%m5iu zWb{huT=06=fX~X-Z_y=GF432a$ZbH0MK|?3OqE;3plzIAbE7lfFAePfihS5@F4*+L zUfIYC68Z$8RQ4d*JfBhO`Z>)zmF+m_^G4D;xQXUq7Ul_VTD#GJe0ay`Hx)taZ(JA7 zk?kpEx0mdn`lv(LYw=zCe9MhRA3WOYk52T)xFK}X3PFtpmcduqmozfGakn=}^)ex8 zMrcHNh1>5AVskV{M(APD0(kn^gZm>ptIfS*8vDkfV$Hw^(exK)7<(9CkOZ8?3*H>6)|rp_xYgV80=GB7Z9S#)gDmaF>i$p7lj1Cj4ro41q0NsB`@d@I z-%2RsKiL`oqOJe1u>QOC_3woXxqe^o{Hn~u^IAN`XcUBZSA;txs}V)uAy@^dPh*@+5#i47d(U;~AH*!FL8!j? z1WS(^QuXlR7u4Cq&9hfC)t$Dp*=%a&%af31tuJ*qe0suv&%0Jsjs6S=@<~n3m+-5e zp@#27DX4LE?zNewk^I@h>={B=C`FsYWFd4Bw_xS$@#S}%W+4Z#R!09od6a(cvzT-9 z-RUllBPoL)8^@`%NW*78>b2*Okj0m*vGblD9LL>H7hYgA6c2% z>UYOVw!0JL#c{>2Uhm2Krby-sLma;zYxs=byoO%u`71nia%p~48xofiY{(m3e;~6&C#`%=@?N{mb6?8@KvjSn>Z249)gO82XRj3Himu-pv2S%v=*=V*>yn zl6S18;Xp@2(Bi%j5xM z>jvZoVBr-;kI=LCl3WZVBz#ruybXB;y$N~R4ME{A^@BK}L~~v%`YHF|a_ml=xC0yCfizDf?D5|_sk#gh`7na_V}%!adCw}H`CgiA)JN}g zqJ$gvm&&Ln`Ge3uqf|Z3?M#hb{B(BsVHnulqIY}65?De+n$NN+=?at4{3(GSoyaiJ zuhwq?Aj244pmkrN3bMsMuf0}IWBIcT!}4%XyBI)(=zuKF)Lm4#YSMv zULCG|`K)-&o;*{1-7EDn_VF1*xuS|AF3QRCo&4!$FL7?E(YGx9L+08v1>J^5`BV?| zFPu%U6|0KUmrk&F2VR8MNxm8do^myv&@a&^J@#bCKN;;cf2yJEZM8+o4&q|;$Q3#( zCjiSN=UtWO8sevmXS|K3j|RP`JsBM2!LKp4VB5wHMs#3lVWU|uu))XVO!G!T#p9HN z%psJEWe=CPuyD`lzBfH(fgtaXpE@om`f z*&GfJI|nF4EWdWyt~DO#${DFT-ZWuz2Sh5|Q*8S6b|7SOkU5iu#ZJzsS=%adP zZxW3!ce{ZpbQ)Z>!jG#BaxL&re(<#^Beu-iXJWaZO(+qhgpp9ivA@TNDHZ=a7`E?DJXYDhYg^z$|9vjrFS z%&~=klBpHxtzBG_FMuSQR`dTDF8dhTXrBjNaa1yQfF-QOJt-#`D$ALn9OZ??)uG>8 zEh%h8%#8A|(L}KvIo`G~n^{@9Q${kHQc(27ova*;RQOQNJ{k9|<2f}lmyo&U(sC&J z;G!K3vAh5#jIe?cQnWhCG}%uECH6^3i4WauChOlI(CA!(H_xV1=V`f8;Ct%+(*Fqm&FcR^RNo{%{tIFKFQ^0eFRR0!%Kj@SRKJZz ziWSApaC*>E)oEV}1c^q*2yOYB&QytsBEdCC%Qo023Y7zHZUzzs25F})TMp5qT$M>6 zffJS2$=K%skx_a8Ed>su)H98|#D^em6S&tDPz^*|?T@*U&%$Oy%V}>=n`LBMjBS3p z?{G52vgdZRKGZnV;>PI4dDgQJpq z9|{J66bN+}#7JN+1GJK3Hts#Uwt98f_HEV>=9}4awty!E7DUI^+0$=QqzJKAosHmbibKM60-#D%AikfdeQ z)1*MVPMD=6L?*>9ASu|o9yIeUjB&_P5DJlfZ8~AmRv0$;GU`-kNVT!bBUsSHi$blk zSWcx?Rr?D3?x6@beV%@rc^M(T4~kOK1{&F<*)OkU{OEJ@gzebF*J|51oqIVoX5Sp} z4egJX@1BVZ4!c9I1ye=FLBdX5HgSv=u%gjJ2Tp5P zk2`*b;^@qe>O=E+<5i(sUOePHe)jA_dg1EWxxP+cq5^*XwFrpP78E`f6g{{FEAW|@ z_Z(%sS!=TiO6wD+-nZ|r%2D34hA;6w8WVr5N-vVn_?#H(GOwK`_i!^gO0PDyc{1B< zmrFc;>}}l9-c3oBG|gxY^F?kU9W_2;yWH*KQP(vi#k5~9zOn$~SjQGx!45bYXc=Ks zWRYh&&RTkcg=5USl#7-TH=&JwR(l4wPvrIBA0PpZ8q3!>;P$!2WA}CB90+|XvXOly zsjZ@m#ohu>EN8cghHRpC^2z+IMr$#qQ*6nmEh{ZgF`bFyX+l|CV%b&2IssGDJ<1j% zhKWQqzKzdorlQw!A6pgoAY73AfR zBPap|X*wEDax`?bpT=skO}Yd$fwM_==X2t6b};&1raI0ye1tUB=}DA_@#!CXnW}*^ zHrM<@_LHoNVOFD*3V!xZ+tM{p@pBh#48k2Q(6*KN8V{{oclLe9SO^hNyCpqinc%d- z?!}kbFJH>$`ANw0<`%qe%YEHOW~)+LgZ)e$Dy_uNpK4jVxUiv7MaAts+HAWDeTFbu z!Kjb`w(}#d)3f1Bb{yUr)kB3YC4rEgKyS(J6LYPz?G>DGi-<>(eX?i}lpk7CF<%gJ zAbcL@gp=pxQ_1`233zbv2HJ06N^~ml2x8_cWSPH9n0jLAptY}=(SZf?6B zZ;!F6m&jLPI4kgI+w^IHpFf4^MXg&r$#012Q*nu|W>2_k#5W9iMCFZ0!fyhd8*en! z3ZhyzlJum6Bvw~NA?|I|Rfq2JSl_b>{w3@BR=g)?Xt5!Bn`_A=zDe?U^DJx(?2bDt zBX!{=BxK!&-Q91Np$sB-eO#Xg@9Uc^L!&7g`(Jmcg~_?CRiSps%84<2!m7m2HS*G~ zU$YK@c6r`vn0FQs;9vEAU=+4tt6eynj3??N?YlV{YqcW;)ZEk>`;ySPhEjKJ(P`65 z0c){Otku@#xO81i{x6S2)&^HaVfJd!-bC5MCQoDLUxX`O^$0IoSn&hSmHV~S?T?y3 zO$W(d!sCmW))Xulb>U3lrZl{EH1*I#@-U*(AO*72Grw6d{FDZ`icDwHlu81zVtG2J zI1aL*?C32ohJ$bVdNlLXff1SGgf}ft0!Jss^YxB~*TW2N z3^;%AJs@TzzZ&>VSMx5_F#vY|r1@fgflrD2Q~zYoiyiMSs38oJ;R_MBMWs{Mj?NU! z`O*>Uh1g+Ns_noQ8VM5}7U`-Ox&B&`U5Y$5^euK&IU{0kJy`!gW_J5m1PWd3Ps{>c8mr8a#vk}n|h zBW=6e%KW_<{*c=A=lHamzr)yHec*pVI^Xhj|D64|N%x=qgTJRw{(=_$&sM>^?xk-D zeqxi~lp}s@m!p7`?Kq&_^_&rpEkOxjeNV5TJLjVKQ{FQb)az}$D-I%@`ba9|A<0Y} zT5xj;;)p>^Ja~Ea=*&@ns~n1O218eKEDZRq0S34Ht4z079x#C}5}`BC8bW6$o0R!| z#K_`Q9Wg~{aGnOM==caXG$ck_VNJMJymBS2=nMn4X==!YXOzLNJaJbMnSMo79>GZs zq-SLECWp11@Y&*|hT5v97bw4seO~;G!$m}HVaX+X*!8x%&MVKZd9ooy5LaL3WCRLE zm^LDk9hBLYraingla}=`}i({cKufKK#zRyf%pJmy3)mkZ=Y_2DWT-pMd-FfgZ(% z!yEGL3QBOw6p0SNkXj=1ith1)vAvHKDbdc^ulJ~WRnewJG@ZNbXp;}mCmkw*lj2i; zYDpljtIJz*>NQwde`8@rU27o5p7}0XFky2CK4gi*Hzj;V7$8{y_C`gwiW^KN#% z$Hh%v%~Z|5)wt&6o+aM-o$ttN_oc%2*K7M#JhYnsrGWm8CBGjn+?^%(&n5pF#{W^T z`!PJ;g)31%3|oZg`srwverK&IajQ-Nj$i@Z-QV@60o|X+9fk5Q4Cs#ueUrQW>nZ)y zqP

w8G^EiLd*_xx3~U)|>q_Y+(I$a%+AVSLyBDYg5b_;sm${o0Q(=*!3cX{p^| zP}x1H{n%OmAvHrAOM9!Ikkv{609^`bcYUZFXZRdG;?6Zy1l5~Ln@AfHj6M@v5o_t7 z9ibzR%0XN}3L{6`Lmjw{Z7*C{*SY>7Pp$O`e#GAA=3Y zQtWggfa1o$2i7T^^s#}qJ3tE}iDN#4SrKK?@`iu2CzkU_f;79JQhwi*0S45O;B;mH z3fFm7BRiP6=!%ZH^rLY5!fBC@wuT#f5ST(}lJ||74w7(G;EoBN;;% z4p+U+7O?3l#@F(zl1IO>s7MNLc)Edsfbt)Fiyrrt=5KPI0B3OYb0-mL1ELwgFxGWS#Z#X@&CdaIuKm{BHIvX{a%vskJE3Cb2S%_UWnJ`ah z_^_>+Pzf@%M34R>p_C}{wn`DX9BI5!JiVL&VJBE$LsYt4CnZidp%^PEqYoq!1t0Mg zg0s293oiB+%<1}mEnYh}aUVv|Ds%8?FpPwf0Y+ghEUj`vSW)#ZDIPaehJ5_&{YdPG zp-9?Mb4|d@ai{T;XHW#)$$bGF6u|zMO%A!RndF|-sVMmwHiuE%#va;G* zaxN~#dll<YapXa|@{{LP;{|0{D-wu7tJO4BE z`F)f18wUS1RqAf9^`$i_h}$Lsc;_D#jqEH4>OKPvzh>B}@;@2uHG*RoP@xQD-F@?h zSjOx8iLt?!cj!oS?!@ja#LP;C$utUPaF*>kR6NgP<;e;&T4`ON)8xPLJ5YHvEv^0{)a8N_m*035ht1S3SKE z3-bNzhV8%@qpmVf5aOq4yF+IRnEoWyybT#y670jn!G^k*&t9w@UD{9M5@Tn4O8XYP&JMB>TW)NB~g4acL3ONUR$*+-HsrbpP9 z3N02Lmeztan|{8Z5hBb{o@fQvs46nf0Im2-T_M!$l=th_HK-5t3o0eqQWDnX zL{%PXNGbV-;^llG7EyY)YY5s7+#qE*4Hb&vx;GsiRylUPF6LH;nIK|x1!u;Q2g_v3 zbS#Wb*mV_|(9$hJ1qsY63jUUNCNzb`cL{KhVtNK@ds-kOcwBQjoh3wvdRp;YqG>~f z!|@2U5!kvGFBgGga?617{lX(r_c8xV`bg#XEq>R(LA2iI_qZZ}-Ch;cw-9|_eSb*G zzOTo$n*W6+{~Jbmf3xu8pyCJnk>8`FUm(Za?He=!?XGu|2M|XDP*blzUyxPLc1n!a z=X(V ztjSs~NdY)+AT|`#T3p2?TMh^(D=6+8W)F}L%z9QEvq*`$64b#3$7D$b9FZ{0{5gE9 z@}LRt04xT`%7??&g9IRK@LsqGb;&^I$-SV&=KS#NUhPEI{-$g&XL(KtF%R^EVC~wW zFM2&wg}}hIA^q?K98`!`+n>4QLDcU_SFSYe3GF@JVsGxKu^PFp6K&L`S^5|21sXC>_$?J6!ogqzlS``?x1Pyxop0;Xr zq08Dr%IBydCGoH!O9qHUh8e5)+lZU$O7zrm&w9H2s{E7-dh_GthiVGY?6F7=`EpFwmpg(5ETVb^U@sr7HLfzUQT^V zNK*3T%(V^w1O0THfdcLaD9Xf>52Jw}+H6}za3DR@D?#=YT&I`aJmyN%d-K?(Lchs_ z+N~+Q!whK_hkWm;Y_u$cGS2&Ix&K7xLQg+ELf-r^Pati?dXY=2;^QaE9@JYLq$mWbECEF{RE;Q6#w{Vu zgSrS=Oq;AF>KUtCq|e7kL)4ao6qGTvW*D1I6zV?{`O4IEr`=O_l9FsD@5$wT!wkarai>W4c(2EYeT;eS#+Ssfj0o z8s;Hl6VG!Rrz#fTzGQEKGgX){A!hijx9+To|L8rar2ygU_`^@Q4Cq zn2w2pyorLr4SMCy{9`5Yb`@1b=ZFP9y?wfw8`#yp$|OkE+Uh+t3$&jUoP(^-u~osZ z^*w_~!#z*UX8G%VdoagVo^|q@x-@Tdsfoq~Pa<{;R}Z*A8|FDdUa`8Vl+B?JRvdW* z&9(+77g4)h8&cstgCVOi*d1O-XrkNo>!HMGobH`8bhMnWv)?=Fv3yonk8_!R*c^|$ zs9{dr^Qq5Ta`R>4EBD3&{?9tO8ba;qYIWtg9&Kei^4*KxrVV|zu~ZsY$JJ`V^16~^ z(Of|d2S>3abna0F?GyXgBlAvvCH~!eeT)1pK{D(bS49{*FOd-a*K&0}yn&=kSyc3r zi8kXp)|V$Fd|^ayx=!jps(+Hj_{K3Ta5y&b$<$ykM=Eep@n+LZnziH->#n2Kg>UjY z?<_-A-PYWqhs1UCEy!I;@aQ(NsROXrG===uLEMMZzlq_0fT@3Yi(jGReL>ZK!)M%& zR=<6n?oS8!ga7xnsQ3$R<1U21M?n28ln45y(+@cqYw_q`xTny+9e(&cK>s(9{9jX0 zL3*$Lq@XT;rJ!aVWiGt}1oIVt8PE&;bwK|ES$&hm{4b>SPpbpqPyYuW0`A)_ou!Tb z4^x+S%lr2=Sd4GQ_6-#RFzSy|lwSt@H>99^uf~slUkYDK06&^}15Jgo2wjEoUH@lR z;QpBS--_dF>gP_xe|>+{m(l-ex!k308ScsDyVBa-)W3qlkCXMb0EyTE+Fk!&(;n`B z{Kqx@-v#)K^Z2J__#+ELy{}t;;Kz?N>x)g?^?z#CeF(o}3H)ci>36~WKKNga{l6fS z?^qze>($qC#!nIG?@>8sPCF7E!B5JkzgamzEHQ|nuZkAgFA5!AO$vW2jq6dXD~%~te})T&NH8|MrOKNClB*U% zx7N2ouu#nHTmGr$0Yzk9%(%uUn+@O9Ey8J`+ssPn7UMR!?8~gZhOLmxAPzqY_k_1G zln53$XU`;sj>3=ZR+W^NWs-?bgA+}%jHqj4i(D}rsYx#CavB%=bsWR z1GT;=_Iua_d==|ha0%u>_ol)BdDt2I`NxSvVqkI{c-n*SDElKP%$en%g@Gfh2h zJMAALmL0$lBLL;zyZ%oozdzo0^wE1k!rjn6lE;^k{|zn4zgOG+_FB^4pz7zen>0WQ z_)Gg!AomCS?po4&0{JQ9=F71Ev_$Sgrs8{yo4Z>Iznk~hQ1TbKBz^#rjX$8>^|3J{ z7JxgW1Qt(eq$?YD4KrYi;Ft)|BE+vRf&G|R{Yba?B40miuCa>UKCa}hDMDVC?n5AH zpsTPrt!(yI76g@5xyD`tif>muav7@_t*H@)EOmH5Gb!wPX_-5xTWJyPK9g+|zV<9W zQtgH`G$QzX<6V=#q%T9CfU!v12Pp7Ixchui1}V$wM5t7h?UPJ!ky4Za*LCL!yF;5D znps8ih0lzfEaYQ25Y)yhbmIyf_$5?hr{u_8!@aAeit?aUr;ho(KwBQjBHJ905a98{ zalAgJJo%`mQ_pfOebLi0>&>TB;^dPg9}hm9-TC?%3H%=Ffb+8ahZqYI=#Ll!Mo+7i z>DiP*_V|loF4=Ui+`ITj*WSlw62+Ko0wr%VH|KL1o_3U%U1%Ccf3_`uM>L3MS6XQs z`-EP}nRl>x?E19tImPA1igE+>Q}IsX4C)ZVROH3O&6{%)Szh;k4x;(nv6zmX+}e)M zAM1`mCLzv#Lwm+)OGZ0jAfSNnat8O8n(?Q2_I1sFnL+er)&C6@Ccg)A-`-96n^AxL zJ70eM>_ogd0PHLP?XEA5kv0Vo#d;PmDJ+^ReY0n@N;!!s04KXn#&?ZF@DCCprM(kN z-7m4SiQY+f7a6Z*ns*5irF(BJq8>OXeNLPu8h@|;1V67Bn+&3w$H`|Q9sI@iJMJhkF(w5O zh9zpUg;LzdMiVjJC$QNh_^2JsWY|NLLXsRW0zrG?JhR%hLm4PERvpGY)Zg{Wywv}| z1ZQ#Eu01l8m&tm}rF5k=bE6g; zCw;xb`c4o&tc4hEPNS)(iScD#{dNyqLTBp}4uB3Sf_`iu?h8^$Es_YxRbfH-#D6HQSed%OWUFD{fI0S)D06wd-UXJeR}+ zWLkDwpjNGJXszCdhCw_gXit?H;62ZAJ1N^!WXZ%Gd)<;H3rkzkK^2yop~ZKY_qjOaG!6u}K@cywX zn6#-TrA_Udm?_=i)$_hI4X^Qw*EzMU&u9V`;2Bc+xxG*n;2v3mC~gs26*Yf=JT{O5 z2|Np~fm0y%lD&BAIGo@cFRdI=^MajMaF@XH(_7FhI)1023CM@ZwX8KFv`9f@7P8qS zjpAx%fg;&@J9!fWm^KoYvY0zYMbAi>i&m`^-PmF%>efWFm80ke4<8+nJd>hpKMohM zb!l|BOXUO!rl^TNl8g2sx~LjMRmt~}d}!BWET-+df?kP3Qz^)oID`)4UYU5ENS8-Q z5}mY>rFXNykET&nR@?$aeWR0hss3s)iR&)k8>N}+a|W=RuJ8Co_tosj{GxC4jaKuQ zHSXUq8<~4%^X+|lznTA+EQ77S4d7Ig9pFZEO&fi)pV$r!z-H(G?XC}vWS8uuNA$V8 z3W_b{ho&Hplu4I`4#M{oa(i-;s?(?O@rt{@r6;^~1m8**V{vdejBXgabW}ykpn}$; z47qA=IYUZxkumh$+>2L2O0<(*nMve%r7am2RP2N2TaU1XK8nrZhqRVL`@KR1MekdZ zst^%AZqKJ77sX25h|%snah$+2VBFidDqx;Om3DYam2j45{)%nqG_F)1-VJM283F!{ zG+WF{Qt-5QHYz-5n3xq^E&n814fZBR%bR_)iX;+s>WA zStXB8o#ouhSG`w)cvY8Nrw^}=%f)s^Bwwl)>t2VtP=k3b#rS=CsBq)C>8sTo>_-FK zqxo>OeJ{L_fwkL94%wEwRbnpRYJCCVVzJ=C6F0xMVlk9VrYGQB6YRg<4%eNt;l$s$e~Iwkgq+ z?){W!-mR9=Kj@5!oEdl%%9q~(Q##QLgqp?4Ax~b*VnSTYRl$EZbdJVI@44m(y(&E@ z^xK@L^)GT3o|%F%F=;g*R#%dMH@`LFK3*Jzr5Wj;0)=JD;F&$<%Tk^vIAUEYxb`hZX zJmk;z^DooG^r^J;D(u{xU%-+eA*W(3KDD*g$q}TxV8VWv#e7NaBjC(eP+E=OqyH-7 z;mza=BSTQW{i+$ka#aB>@Fle3quBF}sI@hU8j?zvO>sl+QC&k%EcXJsA$37g*3rwf zSNUQx{yK&_kAjC1uIyf{P9q1R)ZfTT`w>aMU%Nm^x|$MN27h(?@@5(rma35LidU$i zCpe}B|M zHzhds8Pu1}Hw%@Ud}6(Ljf=yPZ|QO9U}&7s+{{7E@|wNOoy z(WaxQ`Zj4vZkvc^;<(COA&VIM`>263xPbntYz=U z1%&8MUoKQIaUw z;&cfNd11~4M5q*#q`9vlRnQ3rv*5t>k3r=b+eqcjrLIA27R)Tr+dcrP6)Yj#Il;&| ziLeHM155>+D*rQubKgepLXjV*`Mx^AUoe+{!`d0|dA@I-v-mcs`kU(0T?Nbm(C&Km zAs%QCdRYDuH_1p8r)*kAU+(A_VbsQ^0ICW%hJ6S%W1^8YA&}=>-bm(G$uuN;Ui*E% z=zJu+bN!{4(k(jidR;6FEh-RiPky^Y304@{SrkxZN&~b%SK$7{!dxmAN`kP^_QiiFW>p6Wp$T*N>3}Z+fC#-_*p2!B|i=A?6*(N*qVL|2mUBy}5)QBx) zO@S1iyvbhR*T9~Eg{&TgRg6!&7+uOqkqvEw9;g}W2*wQ<>)F?f4r2jc&K@7akyIK) z#g@DoiCTCU%ovVgO0NkxkQ-BqLf)2GO6_9i6D1)WN*EZ{3(AJ!0Movk&f(KT+Vq-d z!I{+7wDXh8271>cOPQUi)#|IN;{6}tFD(KY6|Ugo<~pYZle*q( zAEV%$X&~r8jb=#13&me544%z|Ec%~Bsb1sO@n35iUg_5Qot8aSAy2P>C5jMMx%A;; zu1nkg?IerugM1T8K=|YK-Jan7lKcW3{=(|~FDTc~HUhtIFMksLg#DpfcfGxwxMebc z`F%u1oyN^eKChd_nT?b0Pb@Q`Do+4`u$|Yv5EdF^9($|tl7`ys15C(blJok|tJJ~X z5Jtacrzfk^M#SW*6_0p?RZ#_UeX6$8*@@~`c-ZYeK8Z}s-Ot;FSywFVf;4-}N^Afc z*$>bc2OlzjL5N`(OX{=?DK+vZRbS>K^)rB%3SYR~$ zg+$Fp)1ka+12f01%l1aR^ph2CS`Wc6&K$#)Q%%It^GKx9#)Fef*V_pQYAO$$(~LRV z&Y<<2A5!U=Y9p#InVWIbEYq-%AvcW;CNPUuBeN^2NV%n!mhx_dYm<3RZUiqXI=F-y zA`H$>=O!n`LDEbKZFgi;mYiEw;ic5ceK^IV8n2;{))@#-GCjP+MXC)v7EFCHV2c#< zN$e5Xl~;~6OH789ZuK_DXItJdmP*DW?Ox8Z#`BW#m^BSXBXLWkV_UT$xY}lxn?>MH z%)avGauqxpPg-csUL5>(6x|Ls^86#fHfq1i!rZrwU)UP_l5+hU)}i~0ZpudA_P0&H z?N1Cu6yTmD0PU_%Q4qJBWkqcIq=@)*#)ANr6TFuoiM177LUmliQveY%gsnVKB#V30 zxRK}P5}raji@YI80m6TEFo|xMBS)s3AWB%+X-q_4hzM)kg@+QJsiUQGP1&JE!<~xd z)>XZvkHonb(!D}02^3WO5Ug+N+NOC!AAG|Bp}(K`+9tw0n^$t&BUS?;iAO@Y!#7sF zuFGn83Cm>0LBY~bymjZosKg;t3A+F;FLH|Akza5auVUN4`GtMU`j5a*x3bnK4)tIcnTyT(%GJgBldCuzFW^Mp*7u`3p1i$k2UE21%w7gp ze@&*2Tcnf9h80lU*&n+;4btCmNj@pnF=<6%AkpW(Y=h2^;|r{|E^2Mw*6BiuQ|Y11 zkhk%gIez&=a$wzM@#;yschVuA%;u=9pN zw&*BK(}A5tP2p0SpK)v!e<-GT4n;@PQ`o8By0A2Xy}+Fo==E?xGgt0OOv<}si>M>s z-n5iRGFnhGP`VtJ1xn=u?EW~*A)dBA867V)#rH8)bJo_+dP1KbQ|Lg5S4J1PrWx^x zyZVwnT?6)i5S}^@t7cz#4)WY5*y^T6lRfa6mLd%JVCZ>i0v9cKq4C>db$n+jOzgMp zffw+X5lOoF>9lOY8MlN>x_n)aAEccI))P=XRr6%UdBw@=`Ut@zHk#ci)hg?xae}CZ zX?x=&AYwI@)!=k=s>O0W2vhljkfCut!LNP`Vj3*&qp6vy%F#<;`@k8{^U9mUgbmKF zk*Zk{Vb9JsNCygyAX-F}E&p|Tyij#nk>xk`^k$2;UHc)hu$(@LH$Mj8fPv7!yjL?le2Ai-dE`#PnUV|TO zZHjb7SAGe@@20@1y?qp7$`fayxp)&`(WB<{IKQY$UtLM}aipJ8F7x#%Xc*?J3F;93 z(Wm)7z)GA1q4vw=Z06=dC%~WadJx2P-qlrd&9`KfnM1gTgWleRn2RqwfatyXt>$=1 zNXv5uV3)+eejD1~M;WTW5AA;yaQ+R@yia9gx*ywr8`OWtnE6Fs?bindq}wdU=@IQ6 zIt-GwDGSlbGw^MARmKe(KKO8}6`=8|5D@pX+3ajiM(yxdJ5H=VUuq4+F?{9?)6+}# zfJ$3Cirrkdauzy=>0Kkp19T zPAjHVQ5*J_o8(xKwX_aFG7k)ol5(|jajv{|65QHgO3=MT&vtLIg8n>>(zYK{DT$%M zxSGx0t{ZVdddDo@dJZ;@PJ<5Nk)$r3NkQv#9C~#q_W+uX0>-NFZ54_jK`)ZWLx#hRBT^PB?58D7i#MAEr?E5-&cZu$g^9|oB z)BoEiZ|@2V-|d0F{cB(M^1o;!`rp4(&{dzrAmDkd3r^hnAe##YC~y;&0+u3#4Yn>s zIwS-(RxELl4$HGWF{EbN=15k?Ht3o$!QwIMW6&;zC$8Yem)VK0qN36iann?!Wn%Ku z662H8UL_-?C2PhM;Nhhu#497?y-}2M4(b;On;W(sD_u$ z?PXXDxoDWD_L28s!=)1!SBl0}DTh;y>pO$Mb;xU3#^f|OPTlaV9=Y1d<$;MP4;)3&`*^8tC_V-!)*0@I+G!eten zaY%h}&?$QdMf>5&&F-NgxFsS=3wFkET=F=MY98?28wDfXvdF45fnK`dwJexhMQA!p3^X=F~2lhc$w#Z zYxVLug)*h6U&@(D)pEYHvaBfv=YD~SU9h}7tp`+F_s59yRV7ZL&v4=?a*W+)Tn`zk z$laxNL65p^GKL84%Yq`O94D9PKa$JFWx^GC8-qb*;m|XO1g>PBv>iD;YV2I32lE_O z-ntC&IB?KK$N|S%>X%*EJan4UW@VKnKBs$F5r7+nS@5n5nRw&^m6D<9t!iZ)=2(ot zu>1>i*9buZya<@QD(E#51e%E`?GNO$pclQ(K60I19!#&@Mapk;8Z08By?S{{Hm>{O zC6Qu^BRvi=`+Aj8^S7zt&2TG2VskZMhr)8AYmATaruIz?(iNW=*|zG8=RhI(QvhRM z8#*k{8*2J+jmO2eXWE(caB&Toh=)7TwovDZzj!$L#=v-;{FZ$vbHe0(7P0KRy_fN} zV%fT5W7b}c#erBy&Scm0=B<+}b1diDA4`lqAKIr(_BeYudT(<1Z*~VqCtQ8Pcd0+v zt0*a}C7L#Yjp)>-4Q8`>+APnj29B%7ZjG63*ts1W8E;5iUFn`zGWMCrI!tyHH#=Qi@VNT-y%yectwAqbUD!%qex$0U-j~+KSPC;Zds@;r;XAj02kVn^ z^6n+Eah+2EiiD;S;lyu@g4sRgcs@X6hWs5H?7rFly!QLWegSo^|HgvM`7hu4pC2V(?-;>KKSqo3phqyfM!188@>ui_T~Z!3eO)DD}opz{U>gW-UH zc}mQectQgCK=7&R9I*@u1)0Xkt=n^%D1FuH&xoR{*Jryc2fK&6xyG3j$G*W70ZjQU zIT0VGJY=xvtib5RK_Wlk7j+K>Vl2;+7LT<-^o+zRG9rF(aAGA z3T>texrB{q%DD^d{HiB6fIc!>445H3S3G5Q5kDF_Wn5-et>`3$vUNu-S}|-AU58d# z0nv#8pOJ7RU^&=;3jg)k7+8!xBMjuT1Gx{QB!XMA81-Hk@%ZemIQ1XmretkMmKl?UduVM@ypxrL&Kh9ej?tdBZgR%!!Zdg!D2>R` zS_5A5ImAvTeo*E^@?;~ano$Sh@T`}7 zvCx~W0A6Ua$lvg`ODJnt5LRu5>{xZS9v3%rS;dDn%!Xg0@qQnnN>(o=Lb;c(PXOM4 zI;y?^S!29H{??G}Okf*+mbS=eP8gU6(N%-PwJC*e^was(&A`0w#p&mjv|7=}G*cYj zpQxil>C?1OZZjjb#(TAe;Sbxgtpgm*c~ypDoF^&`a)i3)DzOIR`Fq`-T~9hA7a8EN zteWSYNNZt}fSj7=cSnGS^w>)I(K-7)_R)1}dVSN209ASZs=OY)N$-W1rt(bd643MG z+9h<5TZH6#Ff=gs+@UTRzhiJQg=vuyMeOI+xIK(p9lU4EPSIH}O-Smu8Xj z=5t%3s?&+p1-nV6aySL?#!TBptz^8lvdW&hT<$`kCn^>*#>a)7aU zkAwXj&&Sn36|4)6r58qr$uBTW>cM?afho9vTJ2k$;d{8p+nnWwluzvmLd9_8AGvgg zRdu3@nWir5KO1OIJ%cA`ir2L&3S5DA5i*}_qzv=z*uo_hJhhv$X7o=Pkx2{KTt#Co zaiMx0)8W>r5daALbYL|okZEYc)M zwkv=%FFA-Q`HNVc_J!`=u&G?ujY5g`;mr!`?HeB8lknJr?6 zBi#J@;D%_{5L>ecM0Gf1?0OKo?^TggJ&}Sff$tnL6(yhd5@}7ka5oKE_OywGZxmD+yLNzZRUY*S zy{8Rm0zo5r_ocA;zNHqoN(6^y$A4QeK>J& zzV)4_ovJ<98pHD>qv{VQr&@)mVZR7RezN5wX5_EksYDB{MJ*vDWbAaYD>U^vbga(8 zw&j_z|LMo1k6rQpJ!>DJIvUTs+Q-Y)va6t?1!-&NCY^ZmCbq?4KBX|3hLw>&slX5z zp@8%2jAM7OX)rW9&avx;CO9P$=&Fi^pCfjH^TPK|4!Q8Dn#Wnb5)LRZmsj-@8hm(V zei@W3G$E5aQX3AgYKrAlxA?Jj2P|2EY#N7;PqZ0AjX~NnUh55vZo>PwY4&i|^cgV` zbwTa1hpoxE@D%dkz11*9(Z?~ykZV))Zy4;t{HozD>DR_wJDexd3^nW0;%#wN{NrW$ z9JA7EF`H#%4Jm{2flJ2JkCA;-B%jBzx!c=ZVW(YHv$CJMm8M1FE3L!QG!aOYHRTof zg^}fE&a>)8(l9+}lk(Q8CDfocC(*38XJT)8(_xa(t1>Hi7Q0!;@sRswiMD(_<8WwP zKQRL2Fn5EW;V>T2rTl-$0xvujEMLmWL(k+02VYJZ7Jlan0#3>shB6A66jNQbyDAjMBffVYu>$EHWWH z22`$Zq8!4x4}o7?Ql&*UNXECk*r9`Pm{adLb+SyU3rQM$WXk3O_Q9U|3H~(|9Kr4H zkKHCu^d|y>ASmu@%hL(Cd zKY@5I0JQS}+FifHd1<*)JJNFbMoRuc)d8DC_1i*E+(ZN0DWHa$+1YJH+)-{V;`iD_ zq6Lo@Fi+=iZ-(|sWRUgOmW#Y6TXiy*VVM|khhgyuUtSkx7W9T&DB18wSH+w6&oZ9R z_nuylTrw)>Sl(_ubpPZQFgkwu3VrQKZraVwaIlbYBbw@ZmY(qkH0|MX8)j@b+TJLa z6@k=}2jbSED^5@FocEI+W&=I-QG_aW#B_m7@HH;qhbIar)fQ==#UB_8kafM>sd?0t zyUaRd^yFs9%z;0HTs+8QA)bv*=1ecx}WWKjH_my2gQ)xw7o(lXf~B`f&gJ4I5ZZ3sZ10G z{v6itN~GdzOC6hzxeo5!jrKU>lwe3?&nrIpqjMt6Q;Pl&LZj_Z*wAzs^`YD~)JIAn z4nWN0NK5lVaP^5JFBHNSf#?kJ7Mj?(8XC{``Sx#)iO4@1hOH=q4Z*zJ?Mp-0VL~a* z=LWh_>dR{%!Cu`DZSNm@l{i68?L(CuvQDLdnr;`CZ*5ZB4m*}t!R$kY8&{;PtZR%5 zJTtk`G`qP8@)1aq)4KjNkzDyb{MgwPBXgp zgMmp)r5}~pK0YRTB{(GXDNNvAzp7OGA-XYR3GkG-XgfRoW!xaL zMy7csI88z!&hi0wMgqSKBNA16FS%P3# zaG^_e&wz-6BbE?o6P73{hDms!emKr-F|ro17{lw~2oUx|G42B|@qA-4m3b!0ECe`F z)27$b?%9EFvNd>}2!vd^7MZ zRF0MnfHYuVF`zR!GpEO;{(sE9V|b?7)-4>ff{N{=VxwYJRIydDZQHhO+qPY?ZCe$c zRQK+4-mdrUeRiMkM}I%o_56L-eT^~aTyw2ChF}gYSj1ski1~fGkXEQYRve2lfP3Kw zZ!6yIJ~>@-z#Yn?iGzMqg=hTiVN!|FO18OPL`-!0IK-HfukvnY+gh+g@+Lk_`+@3AY<`6~7B7~1}D^--!k{tfq>m8Z|o2y#W%e;>XD$q`GmY`_b#os)M zLu^kxfQ00|JHW)#FDb_)v|s$+ed8eH8F5?SL-9^~H@nu9hikwM$ly-LWzc8Rb~#en zMFsF2P%!aVf^xhYFaa^w$271j(>}Awwa_U=AnD85H)Mwna6+WG3mjA~_YlK1n%jj) zEt5}JL!j_Vmnjgf^BAq(stPV00BK?9xR%Mv2<^k0gSrgx(xr@bZsY5Xf!}dMOf3NC zaX0e$9xr+m(^eO26ejJJQAd{RMs(c0?)SRrWc5=5-N`xARt`l+O&H+>^uziCTJK6Y;4v-16eWXE%-x8mv__-eel!ZXoZ> zh~THNO!+ZJ*OW!qstEiU$nem{Iw4o1^Hw~6l?ifKL0-ZZWh!@r)l57~Q}%p8K*%PUTMVvZ#O*<$?v}h5p^a_iy-iwV$cqVIencUMIWs0N@;LcJ5a}CR^JnVKP z7u1*pVtWoxI?;k%O{$~*vRGzdU7l%pH#6*3$z#t?d1 zU*L=Rknpl<(#Pq|%F(EElY8ZU!*?5~_>uRE|L^C%!;CsvhY%o|B&T893Pq)|v1KRbJD;jWN*15G>?4+Q!&fs29-Tf& z1qcHsiPf!`rEIJQ3w#D^X*Q43X3)D(G0bOEqr;!Yh{>#mxOv&4g`IMP$7 z&n|6#YRi{m0Qv#$5)E<+rKUbs9~?j|Uxqatw_GR)y4CE(yXoq6Z8{B7bxHM|z>{f<-$L=n*@a6iuJ)$)HHtlk%R++FbiFRT- z_98{OkI@Xa_+#du3#ZYKg_{Qse}LPy7=(N=xe#~mDtU|%r$;T@yx@m*7!uJJKwC5l+yCOvCKxPEBXXI)I;Lpe;|&D(gK7s6Otx>*9yXfw_f zcDh;a*$6?PvVo!dog|j{{Kuj4$5Nwllu*fNV{L`!3U3JkO)GiJ9+?<;+%%ZT0%(*T z!!SR+IMt(A0bISH68}`{F4w+iOsp1n?w?w9?v|BMZ#b72@U%mQ*i(R^5g z2xuWi{EE`a4Ofk6B!oMYDA!2%p}3 z8hSVmETM@EbqBNKS=tYS3l55lx^jG=3@TE(wj#>ct{9;BD#jkL<7zP64mZ?vn%k69 zku0Igt|cktHW2!iXP>gd$lV*OEL5InHOQ(M(IgsTrj6dc^SFu+og#^j%$?7 zdfEYbSaD)9LqAaVoJ!MdL*Wz+Lt+zyV6tBcfqIs)cg;LvuBa=tfmajV!Md3LHKGb7Y{DX?y1`b{O(M~EV zN(L*aW`t8S2}%<=hrHgh(!S%JYoote)|jxmiVQTsc{@$ba)^gq4S3Zn7TO|jZo*Rt zc9y!k;LVaw+ryF3x({atlhYu)kVMUmIYk%ME&d2ZMxw{yS#y)<_`&Z{_Z-2_3 zXrF|Xb`!Kk9hQ;}jWbT$VL?xYlo45yEetj$mg)uK@ko>^@0DI!-9WTvAol00Y#Zh3 zzFBT#>no?Uwr4hLxYn2Z!6Dd{w#T->p8XUJzjpfp3)i9M`?ZXkuei0*S%yEb<9oBo z0c`Jb%WzUnZ`T)&dZ7r8#^}9k3zxDAhbW3n>Vh-tL{czf>HpWtT!uoi$K(6*EJ#`O^InBKMW zsXerj+L+UI!+l{#g>e#jOU9R5ZCgwH^5W}1hM_{ADIuVhsx{_S;cYhLZ;c^K>oqu6 z?Q52`yHqK{YIw}kMn27>nT!Q|GYx;mDZDWnLve=}!ajR)f)_WPam#k~*-d}IL~?5e zpYSQYYe=A?YY`I&jEZPDSVOxhlHKEVwe2&sCKcC7MmH`!u_kff#B5Xp`#5&zK)Wdr zHNrD4h=~XN^YNkr^nJj(vz_$oEb59Bnyv7W#XN02A>$VU>P+aGjb~e=2Sn|L1=bNs za^A3&Xz$oa{l&t}NU&-e2hFYaepIW_ADQW#7r_|^rJ?fwAOyA^NY)hG9CGv(xK|-&aH~jd63{h}b9Zy17shX(<)fmPQ zu1s&;8KCUME#$Q+YD*+mGis38Lm|Zg6kcQXA=y($8CM+Zey&17g5DT4)1WZL!*bYQ zA#4fxLge;Z-i1qX{lW0C%-bB(Dp=q|Q-FT?0pyv}+ok@4E-SN#@M(gF+;=J(k3F%V z4EaVlBZQgukTSJ?D~WO#_5P1x6`UIM;c?FsBgv_l{%o;Vo$wrtwNTj#wr!U0329hg zSOzq~Evv{P!!WVDIx(ITu~r+WhO_38PgQh`LF&E}IL*Y~ek0(Ba2rd#kn8of?g`IL z^VEYgQk6L3f?M+8SvWOwMXAH4pv7LHCqr#egR}`60zxMW?yb^v&T^OB9< zY27+CDRN-a>GM~Joe{yykEwsj7TYPA#8Y6*S28-`=CTh5SK zV3=`^*Owp{0303R2|C~#yV5hN69ol*wVf*&(zpQil5_1xRLdx+eNlh?)eE4v9D;(RSi$tK@OJZf4d05m|IZI z(|WE2+|X5I_@vuMLrwRT?Rhe|$`hQ3h6t$?njXcB{t-mt{sPk+NFte|9!=`zkB(LxKw3d3b1^?!KXVES)aB z*abJ00dfl`pR?vNU;;AGCFjl!gTBDg0ITC{JTU;3c7PeUX_3{rcOG$aH$H~1{R08 z8=pU}<~*)Q0OBwmc7fX1eddR=315s*+MTNcdsr|h1-xBVR(NuW*Lzc>#21;n+luu4 z9ML1JyzotEpY|g(OsBxp#ynVGul1dM)x8|{o0!DSgp0@JOGJU=Nc#QZ_0xR;_v_BAUw;(OiX^BE+(tq;CUNz& zL#(AcbP2!<=~v*P=)1gcg16G1W!xc8@&2w8?4Mn)1Nhp{cQ)>$IL2ZGw|K8ea6s4} z90rEAqRCF~NA7k#!pGBDc)ns+phNrM@YlxrK$(pn8V&|ln3w!T;~YC9&72zuiI*bGdF;Z=!y zccS_a&@A>#`!rj+*eBCaq+&b{iuMbRI}y}?6VQ;Q_0mUP`m-r+wAHXkk4-HG+}4C9oZx_+w!vxCz*#t?UMV@tpM($>Qtx(cbw z_Lb3MEU>j63vsgb>qWiUMQwlFg}dUcBbYeK^ka{C@OkOcRXmB!_1QycY=e;Ak_#ut zjIR}6ZGD`}MqU0|MH|rzOOngF+x0RFDYLI7%J!@LjY6GavLSJWG3YEawFUNl?XH}L z_C|<_s_O07MX@Ykyw)CgrSdkct=0ztx*xyYQms?uB-kPX0IYia*SEC4Hv0Z&QTYGb z@q71;{MXI>f7bK+C)$^qzl?zWx1q(~B+7Iw%x!G7%x!J{N&}|%eLm-p)fPV?xlD`X zdH#%?E_o7Z&%e}3Ab7c)80CK>Dbku)wz_on@dGU!P#2A&F36F9`%<2IHn}o4dwhB}CyNEaHPu@_9r zRbEy$qN&xy&}b~)x9T?Rex@mZ&B}ifJY*7btR5~~L_!@GVp#QSU%X!)f<{<1hP{^h z(jW&UjJ6!3Q_|!~W{kc!I-p1%g7QJpG{*MBI?8}qm}|{x3sk>ix)pxsli{I5l-SS< zCgU+)F>lC9@X0)oZK0zd1FiIj`o@=h&Z=2lOSIDo?rT{3d$-+KzeE@707qIc4H3 zD=gT6@o4h37Lz)5MJ15^gj1#^(7YT}6!m$71MQV6d%?9+(Uq2ngU*66C3kZMzEB+IRDEUQ+@V~uO z`(26f=cM_+x2DN?*CqU8wJCI2|FougqF`ieh`gHt{v0rR15AIaoD*m#B_wQ1to?M} zf)Hea2|uo@f@^wl=0Z`nrdoIgm!?JIsm`u?P1A>Mpc!XU>sJ0afxFJ_v3U> z{W*rZiE`z`jtB+@j_!r>tViZ8O}u{W0jmRYXy~sMP&$2j$m1g84FFm{Jc2oMFn>HQ zP+bmf#pI$xR$0wq6&2N}eDgKvYyZYb@w}@&g3!_#`<5*dlCJVaFRd|fxQSko9D1-` zmrf~;;VThMWaheseo^)w&57&T4&BLi#e&IlBeJaIfYVwognH}pq7i`IP@a{yn+h%t zRpP4|O05>#2j~3IoAx~kBJ1PW8n16et_3h*5LGe8=A;~7MgZ8G0T(3*ITtQkfsN|b z(quiYor^=3 z1h@YV*i4k+XhW8cIJEGXwEskwlOU`bIDG^YA@z+m|K$}l)HoIOlx(Qy+>1c&=~#_X zLq42F*5kLC{Lk8w7j*9v3NC+&zrW)1uLY-m2kOrN{GUSgpWs{mFMRuNx1_&swo8PJ-P^LEw3w9qbyt9{mdd`kpCoYWqR21W!u4FByha6fv!ye6 zAr;hl^Qx8^$B3)Rr%7wXM;+wjVvxtq#`wI#o zlr&sge9mpIPBtjUO04y^NEKEtpI-I}h*}aL6H~aoTz{BH41aBo_F3dzV4lpfc$<8~ zT9F$`j(wJ#_nFJVQAd~#(9;EjPj=B@FlJ~X=4H-=DP#FMfLDi&+8CW_0qex$m_5l` z@XeKA6t>e(v`256cvXxua)B(w^2Ev00F}oQ+C_|WDqR{#xsw!o2LJ`Sy&~#Z(}3YI z5JU*$rQbFMkls)T`TGt;9=8b~o3!}VL`HZ)o>vR0<(T@g4kG0%K`4&xk!5+J+yc}Y zT7%?7!xvUjg;k)%YI-`McQqCumam3r&7X zlK-ht`F%6L3m*M@iln|%aZe11D9*E zU{=iw!OBkPSso4EkVv{ZmmSmvrMsyxaf*G{lNFdp#0TLhnmopi99oMpi?BzKmnvuv zCYI54N`4}WIGoZF$>~Fim%uBXIeK?kA*ofs+}oW%1`!g!i5_;j@!+(WqJyHi zqfh?h{QLdy8|P-_zG1iQcB!W*?LM@X)v6Qn!M1z1(>yITNNqUmOvckwEJS$*?`Jj< zRBCA(leX4Wcf&B_Mph|=;5zbQ>adtriXk^>wuLMAuRt|JBJg!n7tyN;+djurEu3)# z8ONGh6aXwS&vTmd5*2;7s^6F)RqU6EbSLd%0*&rrvL40a1h^(KXzVK%mN}OtU+YXH zVN!rhAM{=Fh}AF5Jus+KCOq{lE=)Nzfx^qXA#Y!=x} zg@#OXT5lRmd-`IpI|xP4RjmOM!t^J@8(2RbJbiIg+fE0mQr?Q>$eAK~pU@o}m*S32 z@NWa1*M%WUBgccpm`YKi-l-)G2eaL8N+FG~WI!3Zsy`mDAmt3@(1lH6UU{@Hl zx6RJdbT2_r*r_SYvva?Mtljl0sKU*6#@p8Yq0#GBW+z8-=qrf+ukxn{Zni>@a^E18 z_1J&BEblwu9`v}!kY>%e^IycFZ45&Y^o4VM{hY^@dWlsb(M)0o<{2#mNop^bTo@k( zmqW!yi$#p&Em!J`eS2=}u@|TkK4?Q^1(D(j`pRXF)BRzB%qpyZ;^Dg@q~CWw6`@jw z%`R>yE=ZIR{9K&?AUg2fRC1Tf%l#l!Zz%iD6Vx1{fMIO}QLl^KJR_N|m8-dACSnDO z-8wp@Hhm@@OtAvowBB}8MbM5j9)t#&MGFPU{X((Fg6{V}M}^g~Z?nm$Lr}%)iRq3z zN1tQN*

j*2+v6mIfjQb%lWac8aw>!wIP(^`m3NeymCvBmyUE)@g!9VYrL82$x|e zSyk6j;{+KO#hv5*&r7+IM^VYh&k!8%6qt689mK; zG2}6=WBWzfIRKn;#QMn}Lmr#k!kpEnUS0x5)j)#)!|eD}ti_Fj%A6$7)Sb%Zw5;MR z<&8rDFkucW8`aENv)O!BqOX(I5fmU1^l!!LYrOd|i}QTwg@@N~K<%=OTC5+jT_4pM zZn#s^uwfVRo%?qrUyw6rcVj(iv{2mV8vR|?)*tyZdQUaN*MHKT4f*wJ={w!C{`6S; zS91K{>Hc?k@=wq`@0XimS z&OP-v{FLM*5MqSvQ5W=*xvSKn-5XEU!E9#{>l^_GTg8BS=2R_Sl%4^i-+YzoJ z$CQV-={lhA^|?{G9b?$B&G}m46Y~jyHV8{*#8@xs-6X6yv=@`C{Ttbu@v4{m+7w00 z36N2g25I4-qMYWTa>r9!j%}ba^=b{{ILGOB%~}?dZ4ZBuMfq~4SH=0AM786WA@aMy zfaSZBOx(BTGU$DKOuw_Z`@CGXDAg%lU<Y*8FO{@N+-^L#g_U zVC}yr&>v0=KY#n;xMSY4W#5x4RG6^@mp&<}sRk&voRkTYi_Ki>4pX!%{(5?xEePV< zHkiKfX7Ajz>fx!R#N)*(#@&Z;lM{+pg?FQ0H=nXIT#DD*k%5-`=Pa(+1%f$n!Hg+h zpK`tCvWFV~no#RmTkD&R1Cj&FDA>>su$~MdREfbRp9HD0(+yH|?WQzU<|v?%BNWv; z5`zp$s*~^-K8_6&94hV=@Rfiq+r*i$jd$y1uE!4O28kHg!i|C_FyL>uzzrq30lDNJ zuyHyDm(B8G!zg3Xfz+~4*JP8?tX^pg-6I_0l44^heUB4Z>C`=*rW_`?uiUEV`kftrtgz`Jb01oGunl%et;g@Gm{V=!@bZBQoGh$D zvX)a(TY2im9PVZiL;=F+=5L<+3^=4HE=@Wf-E1?(JJ%_x3|T6xSxfP*b*>%mdm-c$ zwgq-^RgTCqg`Vod{%S+AN(G?bUrWp8(iuQyVq2x{immo~Edt?sr#L1aONXiLAgRyv zO>4e-sd+J0luF}p20Ko8A`fTGN;c)Hb@t-y_%iY1*>&XES~zTc`$i^|#f2g~j4c3& zwcO!`6aGFfn2aagR6x20RNTI=NmU?5k3;GYyD+8yLl&46YX zIUNdN%GlZRJo8ZO8JJA8wIHBKLZ9d0ceN(w4S&(LbRP*&iRHEraOvd4(!sTdE0|MiqN4wR+zadWt z2<;f99+wQWKp&9PKo*TCo!+O0@yF3QWGD=UO(|F0eJgkHsWoXLlnp?c2{cP|Tt{(Q zROLFTw5jNYvK!bT*dj&KnK`E%q)Ys{TP>%PKJ3O4B*4d z!Ym)bm#GD;1YMUl2h)cn`WlaHWM(4qt!|eaxad~RZi>EPN&r=TN}g4N`|>OKKKmid zPu!c2b%-~=bBhR~F`|b`ibinWs1eSHt&A9_^r)bcYcof@lH;b)Dq^;p>#g^DF5K5+%kx!7_I1D4De#x6p;BlI{urGN<=M>+ zvFysZg26{R@n5btm1Mprobd|<^Xw>818I7)(BHsO2P_|df-+(odl4{FzZN} zmb*PMa1q}{nNx~p?e}>=?E=#T3P#hUbUlL9kHU%anZd`jdzR=lHx7}+V!`$Mi{<=v z(5lS?Q^V>>3*(g2L@9o&RU7sF$ahvK%!3E3$JUy4ZT#-kwiyf+3)9OwNUt9& zY+WY2^b8?uIuXk6fh!@-yID`y+h%>ifeCu3`9N@k5|Y4xkC{_yT?KQ!!wL@)1;lol zi&Ts-#p&g%w#N`n8(~S@DDi_BcnPU0yRx5NH=NVe_ zu@t|%AS9kdM6C$|`=Zf7d{>l0$GdzH6)bXwd|B3Er`3;A7>NQTgFKq37o|p4!N4f# zp5m|Rx~QnuLKmrPOeFE1xT~^hR}Lp7dtAmvE&~aYYut}YrpAP`HtyQz!QUe0w~l*+ z(s5nlj7ZAm3SM>whiAifgb7)zxtzE94)w3Jna)3fq05vQMZhs59v$H>Z_>;i8FHh7v#H7K%7vse=ptQo_|^@-Xz;;z!)cy7umaA;P5#(i z+QjuN;@z?M8J_eLxC@2fQ?kXM6#jo@GWTCrcmD&W-v)|4#{os{zv z-;>{V4XaiGhnc;z`2k(8o(j`oz#R?qjY3z>lfv#I#G$2ZF9GA=Lo+tBYH`l=p4rb} z4b&bcIP-sSwydw8Ie_g7xmI+4uH;#l*+J0n3UP5UGLYZeZx59Y^Ii1J=I>4;_-K)O zr(UV7L}KzI)jYv5HOs9v0A`pd_(Mr}lr~17aD3U4T?oWrqX4^b-;y0dop>G#RRupw zxG;ub@z^lEZq^cG)TG&JwNaIF=z#WRh7P%Jz82X*eb{VphlT(HU9sc5U>_o{le-s@ z+}NBr|7EbPm1BVMFng-0)iER!%*NnlKoNM6xKl<9#TwiTybZmJ>Zg=XEP`BS2L0?w zvZteDpQ2Pz;w#F@DZYNvFxscUyrla?SL4L~i4*ZZzQ_^hYjm)f-~)dEEUMSq^ZOR4?52b^u5FPcL0p&T#Y_9yd6;ksv-% zyw=+|L5gTy#2@#KGJNx^ScM#yiJ}XL5>BIc=+61JK1?Bk;{di7f5YPfvL_)Hu$!3% zoMCdI>tLWbZ{*h8MC}@{<-i@S6HLjGurG|^oEpbD)QzaG5yECuF;Zaj30U?CNLzW0 zpJZffnzT2MwKHe5XblxPG(;7SDoptWg5u7!SFMuyCkQH1yuf$$X%5rQxb{|OX z3(Kc#q?BPG@1wjG=qk;GBJrI9*kdrf+07cEiO)mK+^LBu@_W)TIY|^ovNV%DvX+8b zVj7Icm$8Y?)ksnJcxQhqEjP(A&W&Cnj2MYMC7mOuy^SeHlWf+@hwFkxgQ&4lmM}7d z%*I#~xg=xw3IOb_Z%>A@4udfYhU)s|KJGS_v z3|XMS?m8rVV`R2XSzy2f4TVe5U0fX*IF~-Zks~3>R@_~0c~L2&aE`BpO)lZ#BPNsR zBU}{1(^MaiE*KmdhDNQ%V)V`&KN={XI5=@$I#@u+8FAJ%bI&T97$z`YujX{>#gnm(?THAVw zAI@1uvh35u6b_WP;|-}}m6YXBpZ6yVzT2&#BqFt7v;B+xiH!Rtfq&!xlM~HipU-sVkhd&YM zDJ_tjU!j-tSoO^f(lr7q9p!x0QZDN)I9j+8D``3*lo{~C_Pvin49jLzIsR@5Lwt#m zg}Q(2n65_pfjf$X6lRA@u-9Vgw2&tlY{r^M#Az=zM9#uv%3pXo~nCWH|P$ zDaH(fBmBNoQQ0M=$TnROs)1@P&1nJukEk#{;Z57L9lB^G*zb*?UJyPX_(zngWv^2$ z0x71d6tE(%Br@|obA)wRpV3`u-z3%$}On^opM_gq0tP2rYc=V$qp z)bV$_yJ;^#KMT#2dZ{1EG^}B^ueG%(vfjJy=G}KMJ65~xPmh}CO{yKhgtbM_fos6I z%ad@gJ(Qloavo6M{orZEK2= zq&*c8ox%x*Az_NMOHKmeOs;icP8-B3nj##yNzd9-4Bu`%*F0%p>=ePAwlG*9eFdBm zr+@n`DiqRIALhQ_W8wTsO#WBGeEoY-;r|17{)zDLYYhBP3etaR=l{)u)K6Yq4D-Dp zRX$%j<~DW5E@5WMjZMtufrkk$j4p(Xgb>bU{-mLRIRPL#-=b@ettH$E4l5W3rI6Pg zM-Z3(6(m5o?PkG+#t+Y_E`i`S7W3m_UcvauQY_f?xR(81ip5%r)4EGdhkp+s&$1%! zOYZY1@CEqiS>L7m2e4Ce06-A=NFVgXZF(#atVDo7Rp3FN%~sS@BX*qoluc+?6~{4u zqw%{vTVMH}yKzz_0m1q#enk2JG+jI(bl9#?a4U2iCw(o!4S>6GmNdHn$KG=&_HScF zjyM(tcViFOf~vrxYijGTrB&Xr$K4(akZ%8?U1mTL72)ZJ!p}Gc9#0d;Kq-vGBk-qXT2V8`v zDvmMUvwex0XIcn}RqkVq=d&C5qF8n6uqp~w?KeTHNt>^QU~ECAV6X=xX1ss$NvY0l z`tv;mVm~qx%w``Y2QpxM6{Xlf2HVgb(rAx^&rO7Hr2ZR&Fzbpk>|xgDb$|$#Jr;e! zhg2I5#zLatV1r2kLtngi-KhG&S7St%fn54a>`L z>Qz|ty%v-1+nvtGQj*TYp5Z#yMK@S%wkD=|xS4&J@e(-eM|7EB8|Y*G(5t9vTkLlTgv`7E&wZbi_>D{GCS??Qs9hS(6Qj#ZFg!sTtmJ6m`)bHwJbhSZymZUB?O z2P!>GiJ2Y?GW@|_?VK#yAG;wruiNf`N@dTk%ik%FXef}#2r(&lRgko0Yc-AT2+1t_ z#pF(TPmIVQYWWAtA>GGQlzNqRd)K}h>2_4UM{&7(^VkDZK2UMF6IQyqGr!9C@|*K` zrbpRxb4Zinj9 zh^Z$3QU|xK_aOiz--4Q69I`7;a}LMg#IeL$*AKtNJ?w>V?}sN~s2PiX^s{|@B%CR$ zeqWVpN=?~k2T{e9c;S%i%rB+83LHsVUj%F2%uP-h__@+_f4xswlFOrK#Wkvb> z1Ml?HiX)I`pz=73_g>yAM&7haJAI|<+-HqO8_g)6Bp(;0+Q<&iK7~Z1F2_tG;IS-D zSLH#CPDRoHGxkyLW|lh!{q)|R*0!T8Ww0=g`e6^e^n`_iP?IR|9(Xq{F0)tGB zU^-_8QB)>7M_7m+k|3~>L9$}JX-wE;l-$@ZeP2c|z z@+ol%RjD*XAte_o!djq;Fdf43B3WCeW`o9^=Ke3mM40L#Z6IA;C47u*v$G{f*#vq_ zBI9DKqhF=^vRHyFz$CIpq`FgxI)ew5q?Y{r6&pn45#+;AG$(3vivbk2vHcVDk<}PFXSPmUHPjL(I*O85~2y36ppD9`A{JTBAAK zLX1O*Nu;s5S=oDKq%|`#v0H!~v2Gl*Ue1cd59pFYrcMF%n=@8VPNu@7bBPCRM#(ve zC`@_l*D@RUq0&)#`7b&{pR#Z zOQE@%&kdU# zY+D?jmrLZ~g4=r6>zI@eC1P6;XHjAw0z2CD4cR#?9cqt+v^*brK&x!q9NpNju5c^l zd`Nc!J6ue%rWj<-&==2~UTAD)6_6T|(kV`XckjH6A&5_V*A+giW6*@Zz0OkQVF)Vw zKHQt#Dzd@FvBSx;OCb=yJ`jqDYqQ6C%Gdi-tVGc}vfI&2!Ys8U)HBx&z!cEMiV_-aq=<54MX10L7^1xty6qiVcfHva0io@S)wROH=tthGK5&FR15U$wVKccn`*WxbC8N@erddVf2|081_NWuQP zj&`th(1h1{&4ia(1G*mBzCl5cYqyk1p#q}P4#jlQ#=hj}6);7|W)w!yiZTW(Mn?k> z9gk`ap|CGt3I^;M*Em$+8AW*HQT#m2DBkr4H8hIXZcv= z>@JFu_c;4)*+1uT7;Kq&{cXpst>t`t^fc+~{er~3l9tT*Nt?~VT~@d6+N6_1<_cXd z+y^t(dK3$n+wk(KfIhf$wdcFtr=ipE80T8>zBt2Htg!XtH~oj#i~C7g?%K%o7j5=F=ZrQ6hD)RaRmgnK9MjK0~w# zs)Gq;#&BgSKDAWgB5|%{c`okRdUfZ0bcTGf? z^>tUNj^V?#axRm*?IVjn@5u!Gp^%Ss$Swnf7sYc*@`5{Q~vDR zZzG&ep?ry+@4e5PkNUu?(X9L zzJC6ODgIvRuI^4)i$oy_$i|l{i5z1iGl&Ci{#g20h-DN+|mgKJIF$vBH^+3&IEF{maslmcRm`W7NM9e zK7dM2a=RXLu~>WB8@sCD+ z(d7EJQtWO4!07`b%>mfrb5VE!PZW5s7H|+t*mLXmI4R?$x57$~AjLcdy?Mt^#*JPfjhVB0o+M0uaKUWM z6t8M{XfYIF5-UwCF7HFXu=ZarP&Itxeqq%NVrj8ae$6Dr{!~p3{)x;4oOjOUp%@hJ z`Z_{@En2Ntp0YTyQQ0LKV&nh=8x;y-eLrx7r%Awv72)lkZ)*UoTQEC!Cn}$EA32d! zlQV=qpN@|CQjjm8*_>&cTbLC7C^<@S{=gjZR5O=>O%O8W1rjB3k}oJQhL!jYx{zpO z(hU&nRM>5%`Vzs6e1qbeW|uqet#v8Yt%B=G{q$4OQkiT|x{i!4XmlKMDC0ExllfX#st} zQ=&w=uw0C@yq^HEP;S%N1DB+$tt@C-KGMf^IfKBbMS57=tW_Npj#1TiX>s))NY2GN&#DggiBUZon1n^)OVGpF^<&lcatBg z%HfsG-nQ;c7Mh0&RojictrtF&4VG>09HEcU&~MSSjT6|Si}Yp~>9wFD&BG@I3&HzX zaQI#BIuIiHRg_>a&cvv=D-%4b4qsN%A^d|JLq6q3WL;=eTK=sM1e)FlK`%#+aOFi3 z&iEWTvu0VV^o+c7F*+Ia4!{$hQ!qkIierifgZhFq>@w_>LYVef&F8t{oQ)>c?sdMX zIcS<$b#}#b(K(=@co)M`wNPsOOLwpl9GXuxa(S#K&&?CSKPqLSQPBullyRIM_}|aT$$%m9^mS z&rS$u%FdC##M^}&F1~f$NVu~ypLZ_F48ABI$^M5h-&M~5V?bcx1wolu(b=tOV+jjS9 z+qP}nwr%^gd-|O(lW*=XcV_N;|GAS?rFJTnN-DeZ?6udko@XhW$)~wpI*4~-MxrQ= z2-RawsYz*>l=+3FD5UDZf$;qz)5r)SfYRV78= zQ7va$oJMd?Q;||tC*Dm0`6N{}HNL01XpS1_>B=lq^w!K3ds*@Q{k|Y)pc#hkUEM(f z#?E_^`;D_2HP|dtPE>U|SJif|b+I&PI<`Z?wwJ83lwKDXS{DayQt`dIJas;0(xY{g z;yNP3HnQ!-{(M|qff>CraA)#j#N$@c`r7<%Iho3)$p1z_lr zJN4-PN{+Z-9<^P3#vC_q?!koSXoVwI!k613={amIP>MiE6u1QR?pAvdW@(|`=fXcE z8LpD9C%`)ZWS`8!Xn7e+J6l0xNW(p{-Q}46C)rH?LIjnJMhlLH0y7zHk`{Bnnbwkf z`OlTNp1TeB#)dh@;!6mtWiBd4rIy)Kl*;zn(}+!1oxRKFW%I?h)2CGNUTD&piksU) z18RAQ#a$h^Db`-vxK;&QH|7RbHJt6Q_pY_mtjf-<@XprP&nh97JM#Ok@feN0m*NpP z$BDlV)`y`icU3BGr)ip}PfEwnLI}J*7w5xKPuLq0M2`|bb?|)rSEMTm8+5E6WT|7< zZ@%}mIgMog+kK`yI+o5Dk8tv#AIjg)e?;608Cx#Ue7%oURkLq>S{{`EvS+hduwdG+ zGS%Ocy433XXS$u~D`ZtT*9kg|f+wNi-XZU}^&S;RrtOe)fhx7;O6z9UdHfGrYy7`& zc7<>=-Hth?#GSrN%1gdLPiHm0fBj3z!_BY3cl#qUtp5+SK>u8E|4k*&zq|PUPk@Sl z5{3WrruhHLtNwQ#^`v8@C5Gq`{K!7VH$r6v@W=ze$xFNZ+(1_1rOB;(`7^lVx$DU6 z&(@npZoz`v?T=*KxKyWw(5Lq^=#Wi9+%;=d{Saw&pO}u}U(m;Nw|5A#b3?OX=^6Vl z+yk=1h&{ry!h|-1^lPNvH#Ar{7?LH z|HSK@|Ak-fe;2p^yS%Nl(v0nf5{|En)yR|9(Al~LF5srQMoK`s^E?6sbJ=)5${!PB z^Wfx&qt~wDX(XTc-)s5J=xIh|3ZbK8rw=?^g~H z!3UWHq6Z|0H0K>uL=$98xX8@>cuDG^CciGh`ikz@K&XtxOZpT`SwUNNKwkk&76;pb zSOTRtCA`s4Q7F(E23ZvgVs8O@nVSpHD$gZ@&JGvuz;dfOwc0wPCpUjd?^J>Jjw(x? z`Et^I&L3`13IDvQs(n+29mlSH$WBh0;gXTV)x799-G$vYjf^yOW*i-uZ^4$~|Gii} zau(ce>azzft8(#`QkhF%Ul40y48Rr?DEKUcvRZxOpN|Gsm+j^#f9CK3Y3KpI#5U@iJf~39G9$VW1-rY03RHz=sX?P?JytM_z1N1iCZWJU zaM`;Kw}{80)&q>{?R_MZ>_Kt^kTP)nwgYfLUy3$-Le$H+MA^%$#Gc6^oA!x!LYBVp z73tXUL&;6L))d3G)5oykO5h#fmRWGzYicUZjJ9nYhba4st&43t%l1u;oHRzYV@nLs z!jL|$+L@qATdryU-#?66lM1W?!sv==WYQ!$Q9>;H!Bu{0-QcPS{ekB6PKS+p{t^Ub zH6hf}4?Ch9E~*1RRLE5G3Kr{hALmLKa&U|7)))SX;m(Q4S3zb}-AypsnxF9WQ;9x@ z55GfKPuS*}(Q&$UdbrmGJ5+}ZiYz>=^R?C}%ppoQxCyee(&xq^TN$wPxtW!NsflMX z6GM~YTD8+~&1GVd{hV({3B}3!08?RU+jJdDZY}EMi6L+j%hx^y2Hk6xIra%Y4a&s8i^$}AOBa68 z^Fj+Q>xm&iP4M!gJk^h3QK!j$rv1+r4@s0-1z(XqY1Vw~Nu%f~(GkyTTA&Ks#J8dat6hqTa(ldzg+^Vv$o8Xluj ziRu^;U6Z(nrX>uPy@}VXxdy_&hW~z+@=8Jg*(34z+r-RCaGm#f;d*G#e-LG_KATO& zce-=-jc@lV{=c*msL2UNP(RtSlmAdq@Xv@O{O<+c|6XwVp9sDGTw!?s8G8ROx2FG_ ztsvchbSU{RkO}{%Zuh4K=U>+>bZaT>lUJUdHQpR+EBfPt!oB$*KS)+sX|sML=I(fQ zzs2Bb-+;Y2pO7 zWsBu&#h-@aMb%q5Jg#4U5Vl(`$v2;F-8p}~d9P!1=t6hf93I*3fhN28JMdWfct2T! zXBp$=V8j!RB;^>!v8)*~SmqhlvC>kvLg-nJ1w8TobW0zZFq8xz>_h-}ppuZMaXoJ| zZ)OTsXM$-(2F}IV$+{WfL5W*UlC>H5SYdnW(}mWBAk|A4vL^RdQk&7*-#SX-jd^aw zwUDrf7uM^_)lrsd-G8QrD|aAN=OL)6MOi{Qp`_R03axrVm@A|7PuHDDs-122C0j9E zvp5-gK}j#vz87U#KYd{@ErZn= zGT^pPne3z^MVwu9Lgt+6z^MXLIcSgJ7Y;X1S_`%sKB&2V>V;&v*J`#aubkno!Zy2$ zs*N+`wID36?EZ-*LoA4k120>s99(s)tUhM_u*YtDEwo?G*dI!QCzMVKDK4Ev9X>rR zgC~37mXJOup=d}vPbm>!ehQQgHl+dmVy`?<#c}YcLUY2PFO>MxK0VVMMwppeLOKrt zeEXxXuu{J(uqAiT&7nS+&Yb!$+k+ICVqa%um@xifS_u|bx)6`eC^CED1FSTp7cLnJ zLr;2d`)>dPGZ8YxMf{|p*gBzsH9B` zkhvQ`bfYi+<_=!>Ht9^Cu*4c6;yxXFrQLB5!wqDeX1t(G|1hk+P?YR>AtM20L4qD= zC5yg90xa^Dr3Vse052^WQqYn=qE-Or{*>V%U^RtuLQqg;B=BG+sqmB>e_N~d$`+d< z3hZ~uMPyojBq4u^iClVmu@iW3B;gRX@oGfh^(wZ6xtNu!71!CYUc~A(lD{bSkWx#; zuC#Sq z6O;6fnP&mn)MLJ|=O%0Te)GzNUnr0DuZnd^*9Dh>nOt@7UP<}I4^60(%1=#ZgM~(U zRkb2qP)nNyP@08i1#zaUSS>ppEkN63=?23m&pViI@-Cryy0>VS@L!9%a3`w;7uh7!m)=31FcC97xQJEfc~uK%ka=|)qK|!p&I*oh$ji-;LiiI zW~&reE{R)*;t3b0xXK-0+?{UIYZAp9t8DnY0tWRJ#46z=7C%b#9uAklg4JtaoK9-(;E7k046@Qb1Ub@_IQJs5DNX zCCqup?n9(=r}A|0K{y@xs(x5wD=isPKqlo3;8YxHby$UJmN21degVprnleR`VBBEN zQfO7-sF%Dioso0`jPhJ58Di70iF0_V^F$(`U|iV}HG$;Z%xJPO>vM~8TCxEk{WAOj zc-QptUt=sFuNq?`OMi@ju-W@XAFd*3b-)RKgC5TMOgZt{t1}DQI)$w&Wz{0@vnLx# zlgSX|MMoP%(bqHPBiZa4*y{Gsud>XRq%HZ`@n{cto6^AvztbX$>NICG!63vtcyasx(FRI6 z9mHj^=1D+^`eNO`!alvexQc?y28{xTfYpZ7kI%P{qBktBL}(cjtlj*L(ctbc0c|D7 zD`H13x$eFpb@lDGn?%8i^gdu*MnAGqqs$Zm;1 zjjGl=JynXhRcZRsFvDj3?TIWLFmnmWl4-=?M2%wyx>4jr;k|L47unqqlF>W{;Ck_SU1a z5l|kB?n6k8^VCZE6Ykb+mfAyB>x#YZ_o-I#LDJ-yYD4$L!z7We-UPDRrS9p>V)GVH z%QZ*WS=mSDofzxveRZ9OUYRAwn~Kv?swtCyvM6kxmGDEyK*i$x+!3;EmKtuhs|jU# zi`q`UXrB85jc%K}anl*zbSIW(dpxX`XVyOn1^9*HsVa(^r|N z)AMNAkagDz~VD=f{9`P#Ql&W(F^1TCM}G#dw$Nej^{iTc_EM;b2;o#?ye|y>5*QmKm;f1Jh;blPb zo1%AIl|=t;eAn*aSvwWM!AMiicvnd2r77t~2g1a-5k#PJgn=@6UpwX6{vTbd@Z1Q2{@9WIZgCC2a=IpKhywiIwhx8=2r4{b?$n;Q{AJS5L|oO z18(Dc^NS!ikxu|+sarkl$nY1_i1fpH&m6peQIB!!wZ~&_`~@#;N?~zFppRWV<4#G1 zNBQXvD23ZM^uPEpS<+ONhM!DZE5iR9K_UMCh?D;_9QjYLyMF^o{{I0h^Z!?{@;@75 z{~O=S1zVmTQlQF93ufdrL~39q$&FB6OEbao!B4Im~}b3i^lm)Fu{* zB~jG@jMTQFfsQUO z#>ynKtGwJ_B+v|?uu|T{HN=BbNmW_()BE%L@AuP+?haepBgV_d)+5bMOizHmZ?O2+ z?deU4Z^>Y73Ih^Xa2%8ZM;?0-f6ut0#A5|#5l~M#OgM_) zV8Ia41yikJZ&5ye)97BK+{VT>=32pDD302EntFcG7Mm*%ywqALr)XPZ%kL>^mP>b> zV(fAnDh1>jZ8@D@HE``5%2WuI2R)stjqezhDc{8|CkG8y+G~qmMCo^&U>KM4pIb63 zq+ujNyo>9FD0h5iw062o7yXP>jTwFSMW!nY$^!Z+F z?Xj(CsNQh*dwkCV|CG|pERM#(vX9U98{s&^F5v;p@f{y?Gv9H_)%h_)b{nQJox-@v zL7CQ{RzOCscIiU?SYmq-kl(`GNtC;+eBjgPh3Glbu6A z*7KqZnb(4Hw^ASIFz6;y@>}tRTT~k=8(<~?>V>pSa&4~rdMdAsgUy*)Q9N4(mxlIuWZR#(o~?4>A56+ts7A z{{|$UabutCUc~SL6O1rZ&P8wVVga?RpN<#cuOY8&R3fCL$vgI|9Dt!=H3sfO*aZ}x zGeZnJ$jK9qx^g#y7|+kbM{2iGOK-)7Za*iOJin2$h-`6j%b#1r=HHXd4{a1G;nD7a z;F0vaERdrKFt~JwkRjnnCeceGl5RMg5q?HEZllNA5d9ysxE2f>|io#WjUJB#cO zZ6qqVRQpSSzj7H|z1?}pkE77Jkxzh40oM*p(`KsbSmPD4Q=i>t0!^t2ST%quj)N+w zQO>eBh`faTD&NeUGfC%1+&-Cdg*v+cbN+IphQ&xpyLmljtM_7?rG&2x$>_ON%_12E zX$)5+-jXRlewTRClUtnBIffgNvetw#)J=@LAE6H6pvnDl9#`8!oGiist!c2xgu109 z1vrz7^P#KZl%2Yxv|GS*Lwh;r+j*t03{vg*=RcImX}ay-E~ z>E|eHa479^@bx@G-0=k-Xl?A=9>Hg2JSs9{r+*m)dc-i&cyw_qz4xDi`;~DUSThD9 z3|$FyMOLB>g(aa226t4bDQC!rnd=&;g9cs#ewVXkA*^#aG#1}bAC@Yto8eA&Ua3Vc_C+7cX-=P~ z0!La%hODCnf*m!|M01Ii>kG<YW`it6> z*!KVCxK2w!$LAFHwg_>CzGs>qXCg1Qj&Pks5NiG;v$= z%Qo+~4h*jda+p$F3?&YLS(P}6*m7}HF?$7A1=v4;LRbRn!s*$e_`J}CX!DWyouGnljM#-LjNpZBg{Xz@MzmtqMtEXZg|LNi^KkPt zj&Lhz+naQ?Q=eh-Z{{@`4v?JGZv-EJ&CwHoYWBI0lkJKe74i_Mlx+~##dU=2h<)tr zWtKy`N}nmcQ&rtH=sFh*3EyPh6+c;ySPHNf{T(K0e8e1ch5RMx`0<1X1hg#h;9mes z_nLG7uz^v=EYlvk&&*@{DAJ!7XklU9scR7FF>8otX5aM$RBrs^;=Z;E2EawO-aX;w zgKN2PI)B047s@aCTu zET$D*<=rM>hpRf<&-2X}rhyzwyK2~V>%H)I2L~uAY-rpvb)hlTZfF_F4XWAN)=)NU zzZ$PhV}5v=x{C9ix$lq9P6nazmMI<)=YUZpyQ{Bv0Dth-lR8{+9 zV|P`-F52H+1i!l|es^*F_I3&Junc+Ygn4>~d1AEC74#MedCP}+BEdYDWLiuyEv=c9 z)6L4;PRjGq40&sJdIHEK7o|(W0W4((@M~)DEk`f2`-4?IF>Sep{B>s^e!SFO#=w_z z6`ePbYB6vUE{yaPJcA?|S4_l#rd064FmLV_;fAAZN=y@r2Yje{sV1jS3@n+54jIf4 zB|MKjuS85Hk%+=8N{z~byp2K!3iB^G!I#F9fG3Y$L&3*?gvB@!p0gZ=48kk4E!aiTW&G|z~~mq#L$ zkmuzSI}8mIOr!%YB@j{~ej=KZoJpE*&Yp%3BLs93OXD7V3ma>oG`jP=o`GGCW<{dpbRVHP%8*QA_x_%KBO-Ye4Lx{*VbadwUbS2^ws+)D$x>xAwhBO)o-zNtyQc=JAF=E_{v- z%STcuI_IfB51l}Szt<#x47Pj0345KvUD64^#VwseB|rwflnW+a zve?H+00eyw?rT9*AZ-W|!M8t*|m4kC=G02XNT5kM-pUv%IV(C+|oT2uwt z1A_FkR&B+1PLe<~b+!fV54O(LldRM%m!1mC9?@CEZ4jbcX)cBvde$_1d`S-o1&uV& z-9_C1jM;E!^r7Nl=TW%^ih{D_If6WT-MX%gciGe1bV_^w@_23&qg7S+IyQrb&gS3# z?qL2|HMdW8(@L#zIINa{9dM3j&|^#5*?ueUyM1VMbx~M3^?~EJ+_r2cwveeR0mqB+ zX0AyKx{eF{4VN$;_i&b$kV#o#>NdIy-Fe*|%tm(f7Q19?$r&)3s9iP#`$~$b;t(;P zIjSYLa&&FU(6*1^zYm(pe`(E#nmmk* z>@ND}xmCBul3ax?uymLEp@%mmlY?TlN74!nG`hc>e5yVMqg2gE|)=< zG~kxo-m8+?G`np~Br}p-Yp0iUi=@BGd26lSTahNJUe`>O)okX-Qs)CeK_KGi8x4|YIwU~|wV@%+K?cb#ODDPA zEb?ou#m_*50htH(k7Vw{eZ-WWA_ik3@w6WDB+P+{LnH9KfCcg#ynlkRB4} zlbI%lJZwFJrVb9ldg0XXgTcR)cVTtCvr(sv23`i{ zFDKz6PhJ=SebVX0D-d(X2p*wl=jeqmR z)`us4=;o7mL&*A)QUXCSMfkz9Q2<#49=F%Jd(L)dG2Osqa0MJkCp}bftpE zFg!mpRkjfsxs;>;df)>aT>6O4HZxgmpf+CZTjA<&Z0B0nj4q`Nx8|GlX6rWX)YGjc zTWgtP8!c-%|1w&(7_59+#S^aCuUca>CFroxF;O!Wot>wU<%g)f02-`@Ua7Ne#;|)s z6&{`M*7wogXy~Z=>hK9SYOnQ>Cub{?Bwpz@%}iEJy8uZ1a>CJchFJD3CI~n zg;~1@v#*{ww}-eEs5_Ab$Xz@;AckTBm)?V)CR^PTJl0R+RSvz7_K{pie4YjJ5mws#ux+u=H>LlBwEBFcp6{JQq^q;A!i>A!6BBF^Rf{E!UG3ggr-Xij>;cx#adw2x0-wrs!8;H;@gDeQ8Q273r!r)?a5UaAdbv>Y0Ja!ea$ZH}mzKqv& zgibraSJ*;Yp`#9&?}|`ZS>=KJIibwd@F@T4QHmd}2>|`OmI%sZ* z$My@Xb7Vkcfoch0&hSa3UpOEE8DI`e0rRCD?#rRQ#r#3WZb2CG8`o{PJ6_Ih%zH5i zceou9P!WVadgBxNhK)1}Xh-LeJ0C9j<{}ti$bzThe%g+=uHh zyVAc5vI>p1lcI6Jo#&A=@VAs#zaFIJz`eJ7VWjWS(y{(B0P3URU_L$+>EwUTI?*k+ zNj*x(2^p*p(4&7akBxljg9c6!TG>N4kA@>1L?fBrGF-2bb1;KMcXCKXd2&j~G$-Qd z5BJg*jY$6ifoy@VWV2Wb)L z{j^uJjSqfenxJF=QBpzlm3TBg^$U zElvh|A}R^ty8R$sA`<;j3bxWP7YFqz~TZ)5Q6$Lo#ewiN<{ynRM-OL7h zC7tOly1I?m*dj{39DH{%X~37!;(S(?bdMTRb)~IH!PVdsZ>l-KAUaZ#I=|P-`B(=Q zh?^92RsHv>TFho|;$3(By9sztOO<}5X|XQfpv5J`p@WcS%Qn;ZliTkAR8nWTjmK;) zPUnc6iRGiPlsfw3UDtE}8JUU_?i4>XpsAy+p9k?IT1@zc7p_vS&hVV}8SA7IH2$r2qvEjk_GQN>=|;l*LLf!53d zW*wDj-J$oQS+;A{fvyyntZf_F#Y@`tl@sR3>B;BRObf$fccq5+R?T*}?s*_|n$}Qx zvxeYE@Hjc7ye~TZH}bMBAumrcM}Jin#WTqgW4Xdh@RfoaOP&^no7~c%<)(|NcKvTb z-rtbcdP8X0+vj>Q^(E)4z<}|gh8>kqsB~3}?Q-vi=V_}_??(aUhx^5>hKhzhf3h`X z*-%+83b%bpt)!DTy+QdGDEmM?$&JA1=vqVB*}722OIWUuz-WE92ou{W5iKsFrKcO! z7Ft!GN@c#h(Qk06t|Gmu$fV{fj@rvk6wYK#|HMi-g;w^aATD=oK&*H-VEU6p`-CxA-b*5jAu z2(E+ivgO>-qXd}Sz;i$%+!?RJ>}hq`hCfcdjbffw^PIIo^kNQFm2s2cB z^CU%m(IeKlpXLMz>N|j-o$SPdUIQE!Br_EA?XnW zdYp$f1<{VWkUOyc6rpW52n~uDm?L64aD9rpo%4OfZFyEmOCXRw<(dd1>Z*YYki`o0pYwqpm-RN$q%K8MvDZCXf^Q`L-EjJ?{ZgK9UJ9#gJs`jfXu}^2)fe^s`a&k$^I_RafO)6CD^lJa zi5-kRPqCn6ryuBTe!SG^fmf3BfK_~}e~pX~5F!fY3&8S_j-qNzN`oYyk~%1ypY0ayXZL$lT*!NyJS&hS!s8{N5bepL)h9s} z2oNRZ39A)DKV+}T?rWZn<+|UU$m%#cUEj^S_a-^r==3JS5F$Hwz^o`p31dg+2C|(( zpPI|JJOR0{6HOi}Y!KKduDg{v1ifq8Pf#E}i(<&$X-SmUtt<6L6tw;zWr+wy;?Qel zzS;OV3c5yDE$wqzRb`2faGR+0<2Up=c=S2Rz8%g0{AieYP0FX5aFb3AF<``|vduZ#Q8K762m z_6qZ8K2l=q{lZQ$%IlP}w%aq`sk@JQPYa7z9mB)pFw5OQ==^IZLQi>88)(Ss69$N(4E8 zI8?@R3}@KO7rpRz2y>`MXY=U~$b~UA#Z1Hin>P=t_ z!I(rSdRd2I4KnIs9-PK4(c_QL#zoCf3G47SdSykI_0^fq$V^Geb)dTv_ktpZfA-P& zm>?cO;^fTVLQ-$V(fz$>0q*^$hHqZkcNBccx9;F>SCyg9?!`cz*^g0CuboWD{v2;4 zFjFU4S(wivwc)jfoQqkX)44hSotzO)0ytu6|O$APVl z!9Dvt45!*RN%*a0P6bnH|FL{mWU3NvpVSo>U$08L*;>m!lEf@LPN`eft=y$Fk<=tI zPKCxJskvH@Yjrj)CVaHtAgQ@_Z_->T%x%~fPh}oaARIuojp}N|H-O3_tkqPrX1lRg zyv;SjzJh%BB=MQq;H|9)z!Wh#n`|gTzR*UYIh{)%#oXwFWv;l4)0+F`>C6+d$9crn z;SsreqAjh<@#0=1(_+i@r6ak}&G{jFVHcaya`mx%kR#FlP!Y)LOM$cf)MKXf201@h6EygU1c#428t4Q-T)cyiv=I;X=PYLdm^cKQ0}^TCSJ9qF+mjef zcV}6Z6ni?^eiXvENTkgfA zpCs~=si3!@WQ_VejiA`^DTR#$yC^--&|Qo@!tj|G0RJ-G{skQ%|KBw zUwC8;)$p$xwfhgmPHFL8Qyd);afw}`K>~Ly!e89sUY9XAuc?8@>9uXNX4MCiu=hTtIdRXK=i@>e3odEH2jEJ4;>fz4T& zRU}pyQ0wi>Rls)3oi{kz=bqN5&$E_9EuyR1@2%@({Y?dZ>vLh27YZ};s;lW+R!`Zj zmNQx0)gE%0tw8L9g3%=i$F9ewO1Gk1m zvj@VP6*!^#Yvaq(_~HZ*-F1k%3OW^X!1%!YF(UH~Dr)Nhy?;#bz^$F;SSh?LkxCaN z{&h^m%av@QID-I41QnWAL1ZyMtdl#}N<-nt^JmEjX3-9HZRDQON@U^jLw5Z<06>f2 z+)nQN7F0(~K|Zy;t9gH$;N(Eh6s#LNIGTjlJ;vYP`YJoUDtsK*><;_(r57y*e0Yi3 z8IMyxL4BwZF3HX6CGz#ggwXWHyoeSXjQNof&e%*oy_zpHPk4N)3(;HkP~6IVQg6Ra zZfW8a-em=|nOw}w64`|5XTL_uT{X_+rcqXnD|6pij#0E% z`QV`k<0%ds2}!_@BmB^A7Ht3`X^K}4Cr9r<*@Z{F;0vAL%&#t~I5`Otxd>M9cb-WH z01~{60?B2%*UrPN%?~XR!rxDCP-TZ&%bb695#;EH(LrJX8Jq<9AP32rpw6}a9VrLV z8pWW2xK5gz2CGi=4cI&x9dnI`9Jfu5R0DkLV558AJoyd!Q#W?{9VINl_30}o4j^)$0`R6}GTETEo{JKX zAD|l&>m%5-my42-73?DuJ*;9W|walh@)98p>aM@R|7f2QWE^9YLh7Mzi zRoRMyd6um~3ysR+8(xvg;j6yrrG1wMHBq7FwZE}^^n0hi>ahFBZQ)MfdRt8X0V~=V zy-GiKYXn4PV>roa>sss4tK` z%+vC8Di7Y_{l{;lc7`T*MsU?snn-ol>tf&F%-8CzD|;vc8bosz=jLJ2L8jDdFc1f*jJZnN;3_{>V9<8>(H3!gV>_j_{9z#U%S?w z$kYXk?P9mi#5M1zH6O~h4|D6MSz);cg{OH?)#i{&qv-fJIy?=cl{xaoX0~23>QrfF z#f^aaQ9gQ=7W<5HlcO;%eCAO^ms#Hcz3N;)hbB>yvSfG>TyuUBjjg7sqcnGTw7ov& ziG+s1@Q(Eala_jl(l5%o+*2a7LWCr1(l?03KhXjkmot2IeyaN?IS8o@?O+>Ki9l;i22NMk&Wz))uL(z3ZNCPhu^{Iy9ACE!rKrMQtn9A?B{Wu;g!j%LcJuv9nR> zx4%r-qL5y45ZOm3G=u)~_(a@J=~fuUBeGS;8*BW@4v)U^i(3dH3^`wpz^Z`~UIjbh zsS1)YP4G0EXKmLALZ`#g2B;tT$jiXa`OzDv6tYc1^U#A64J z)hHV6MCnVMKBlS$Qi-*b*{Y|#4QG0dWbfR>UzJ7-vlOtuLPfkR&&7)Dt!N$Rpn!;w zdNF1+MHW!2#0;$ckXArQ-jv5`7p(@bxdkk_!wus7!Ljh{suha4Q+S&Vjd;hC0B`bh z#(8!Y8LGc0PZSm=FrY?(2#8?Lq%u$-M`6awMj02Sw=LBq#o19S#2l%VdqC~(;Vw*6c0le|?v;Xb!vDYWn7I;{$Z6h$V z;miXa9PoRUR11XqcQT~Q6dl}k60q%f`D#A>eu?!Wf%-W<1Mr{-LvH&_TaExW_mP)IW#!d# z%MRcbpp5x_P-(Z3(AP}%n=2+TK_n48Nf0CjFPJz;as_Wl6WA7x|FO=yEycMRl%_+W zAjSfMm$yk@c!u2PSzQLHyHr<&<`+~I(^+vZI{od#-*b<|K;dy$tSbDj3P!l~KOJZ| z#qORR0(0ISV8z1id5j-E$_sLc-5NnZOfUBf)0AP$>eo}vp1;xH2mn0ZQMqtg7E6NK zbYtW$q6VennvvsvURzJEQ6o(2y#Sjx%Vw#(mf{~)E2$pYb6@<19Eh)#kOG@#H}(j?=i78fs#C?nM!r zJ9L&h!z)g;{4qzUm4R=TU~2k<_u^q-v?bM;z!?qoPfIRMt971A>yBv(KR< zmTR@t;L(bBO^Un|Nmm*T{rj74_E+HnRF=9EfkUijA3UEv#v_bPCZV8ka(1dc>o}Yr zMrJd2Lxwfkoz4#CHbu-zYh&3yCf3ka2{A`W-mW`YX}@Cgrimh^YOdRxEYDu*$&|K- zv$>m805VXDZ;BzBDZsXFdFev~nW@cG$9|DoKzMww9S)|`3Z+|8KfU}xy?(dfs z&YQ0$4ET1rOIeb~3s=UQ?74ox|&jdwZE)k7Nt z_>9g-&sFZ6#?UD1#1J(n9`eA#LU3bXJ4Gx&71)6=r#WsYSRhs8fzw|J3&<#$5X%tA z8?;e=lQ4C|5R`ww{a4GJyz|8wI&?QBrJ)Q?fB7Fav$D*F1%?-D8%-OUlUb2uw0MK* z58Y7utdUtY%mYR=GR2c9iv>jZPq*H)+G9d&B8X@W>hFegA@>&&_=+)qqdSIe9)9R@ z#0#M6dj0ip*U|RvxZiTw>b3dEE;rA`?OBNq^a{8|Y?0OW_~<{GtqG1X8uaDeYiJwQ zFhGEB8&&KfMUG%YEW$Ha^g5C%`*+@mM>3<)4JqX2^@RRmCLW zJpCnD?pS^V%B*peN?byUk84bEl-Qb83hC868C9A%?)#`W<%)f>`Iml@+5RGMrFxM|in`6?Bx(%e>o+r=1LPEdfw z$UWk68&#{!CNV_00&(ehJc#l@J!+}%g@INfx)La`$PrJ04Lx>G$_lgD;0?BYY|0Ob zD%x6=b{X<)V0G%GDfm~vYblgHQGk ze2+k+HjR79p;qf2n#noHaJI*x)oUE0p(yndP8W&0c>sGs{ z9!cRALHRkb1nqE)gOy)TB($^>8a>b^%j~FjBaj=naAx+v6topB_|_!$Hy$2H@k)sr zV16BPkZa%FHwclS{{s;_6#^3rCRA$aJEOyJ!wa%`y?eD)WXNn8?y%DOBH!zuR z4ku~!9aU#HokP`Vbd6WHL|3m1E*DDF9aTp^q&z5cnOJ(k1|>l{iU1QsX{H1unM}=L zHQNJ|ZE_95y2COZMZLt`F;(Y3>QuT!;~p@Jr<<$Kjts#Etf!jw+B{MD-l=`N>@5w- zTsgQJX%n3|eYTbl&MV}$t7e}%ZabW%KT%&gP=D~5t!49*z3uPUnqMkavqdl21+y9^ zJHT->W4$)LLki?*NS2XW(1QKoN9RMq?1etK=u+N8At>cz6Y{jF$d>S}dhfGOV?OOEB1`g^MVfDIz%r$Wtm>-}vU`f&h8@<5sa4@!RO+7jep`HKg4 zum=Zo>o8^V7MUDlw?!N0PhOC9#4iEE9P=nREsP|h@z;7i&<@424E{{0-gD9z$Z4Y9 zv-1eeeztC8rsj~E?Ez?G;WJ%Y`o9B88V4N!TSu=arHHZpyNyP2LmsJvq>Tt{Po5i} z(vxmdoxM3;xT1%7k$qNv(8Pzxov!aD^NrjfCp64C%u z8ZH}^sU9W;kdn@OH%!I(=xBK*wu$mtfFckY=x#d0AN8ZCdApd*r%9-{c9E3tUyp7x zp2HMxRJ9ODf_&f;$|KQ_cOV=@FY#X}8t85Yk@vk0n~)dnDB_djF-1>@nP_kNAp`}K+pRwooQQEEeR-9@M00Hs zqJHj`{sir5a%wAr5RBWwUO3hY%}^MYp={M5zhfl|)}LZ^7S+vyU0@@N~Rl^0xHxwmIv4SAk@&ALl zuK?BSLWoFOT zf!WV~e$VQ?)?Rh=-Iy?WrZMayWK+*m2l_@jC>Qay^VCH)7fD!qU6iEbf!oyZHd8fydXC_p z{Tj}}G@t_gKI~RxpTnLF%_QhoTWhqV+vt+1!;g{*x>07+Pk>=Z{!5iasmqXf$@gUr6YwO$1nMu;8>9{q&qru!acoSDb@)u^H#_ z6sD2D!I8dzw(Ww?gZt@sS&gz%xx50=p*95(wz?1+atGgBj8GBD)&6#cMYsONj@NuX zO*0CQ1>o2-ZeMCp?}MRY=lekOLnJpGLl;ONO%wU{%=snGR_0TfnypfEbtF2oqsJBX z^!Uj=hiqGy5gfCGJ*g5HhrUdPII1a|C}KxEX40~^eIeI7)(B|iJvx`|StvBsfcv4; zde5Tguzt;Jw*_grFzT(w{+ce(ZJjPYU4SxhFX^uAMC`|fAZ6d)2M%ZWxH4{q9{dp! z+>H_=GjtxrWt5rVJz}X@u`rL=kmU_cLles?U!c`kq{tH*O9@RMd{f}a%M3k4Om1ZN zXWa3nI`=PXebAbtC2D=dV=?5}C8rGf1Wj;*`hrJZn3~VZL6U2#@8P~A6NnMJ^DEwf z2%1zJNaGN53*4b!87xlVi7J$1x_yFYDdB_9ou%tRmqZs z4GyT!BsAC*Q|B6~dX}HG6Y0opSjo4sl+XrQ)vW7gtw!JRec&D6YD(Y^IVYDu)%1D= z$#LVh~Up@t9eZ00M zPMwVxK04c`!WmFp93F$$IKDYA$fovWfrWItDi{551=a3TRv4^rkU;=MGC#b%8v{a; zH%&tzr$9TZBOH_VnTg~y;EP<#<-QUIvy)ilvOliDPd!?cz!br8KO`S@l+U@4y(eGrA@xTt z8UbP8fCcr>@}dvq*wdtj&VY&&Ex~pNAwtdO8L-Gf-eT4no+t}mIo9CQ9xD>z>m-rO zg41O8v4=DW8lujAZhQ%*kLQ~&!g-oA_z z+u-4V-kNLyUB|&}XO3Wp130cZzoGgBnr9|-yM>+iGg;ow@SiAWVJ{>yRnI61@i+!~ z*P!`?z%#R_t0WGRi$@%Z91UOK7+QHIZ9R{auq z;TuI%$H69gPds5N^tKZ}&}~fIWbJISr6%|#aV16nSFXgi^K9x3^F-_Euy?bmK{ZDg z)kk_3Vc%Sj;#{%&eR$C9bVMoXs6Kj=UcQ@Uhlx>F^~gQ0m`FN?%Pm=^jm^W+LA&hE z{gAT#4&(l{5NfW44zdL($Ba>T3*#gPJ{7Rqn+*R*m8t75$rYL+C|PQx>n~0z<2!Uz zjv1KJ=oG}Z=#yTbDMqwO-8vi)5^&EuI9`4%I_<5x1Ef`FI)0*x89h==9oNv9niybD zH=S`++mIdG5!Rwn%Cr5Lb|E9zgW&Uis}TDw<}{sbEM7B~he zx*JYR_ePld=Hpb&a#MxZ&bQVWuQFryO(^}Y8cAEhgIn6yvL*7(N0E88s&=wlZwY9l7gEHLVRe!w|8|BAQ*7*(w;0gbZ|6?8Ne#mSx+DpmHcd<<-NeeXonBp=JQJpn4a`Qpm|G&ibT7eq(UzjNXm*+ zv%Qk)v#8w)TC?>!4!=pKePi-H-!G4lDW?U0M_SAvd*YY`;>(*r+V(zm`W*{^mU75?C z9=#)Ibf%t>=!oO@c9irWlI>Hl*g!VyYkHoGI{eW#X=;d32zbkN;|``In*KO&a)LE- ze7CdU#<>Nw*C1zhm%StAn1(R|4k$O>Y!)aus@3k>$Jp%19!QFyBTKbM7H83UbExHO z--^%(m|AcOK$K>Kp>IAyzXA24Xt74~mjdcK~pc-Gj}Zi36w5*NTu?*Zhl8p$`m>-$v9jfube# zt_tgTag7IVz?41`L@d8-m&s%rak;RHD0m0kyi2|JuKU3G%UQGX;UxJh&G3su<<@2) z;IW@djgqY`IqKOv z{FMvRzPq7E^`+(9BzjvOd2G&Wni+e%LWc3Ksz7%8xJwf=92?0wb_%#=sd?tn5wlux z4rN0{VzU|K?IB{EeaNe?)RP%pkz zsp{6`M;5e($>fdphm@J3HmMZ&O+Dijxv>eY9ETvm1V(Z%TrU|S`uOsMJ$jzZXY$R6 z##+zUy^mF;Ua&jEJ{tWr^g>|TJ2VGhl@-6h-7mz9sOX482W9PeN-GtXq`%TuL)TG- zOGt_@q7=4)%nu-f?C%tayy3@uLE7dat*oN+frb>tmyLsfr(Fj1sw_xxQyO>y&H{5KznB;P0=>%8f-dM20Z6UH6wmW29mXPChr(k$hCqVT!M(W1 zDDXbNV368o27Z&$foC>xwx*m8q>u1fy4wAtd#M8KePgREIg)q-#(<0O%6#3kNzv=} zv~Js}y$NW8nnYQLc&MXhi!Vi_oi^mTO|un|7FMDbgD~heT&5m%nuisyl529JSc_fw z6EC;DNDQQ3Eb5qeD}y$vr|H(&`0n zjWo*hQwp^j8z!q+otqDqK7+J}`m4^ZCiYG(Q-=0VRuhWa$IR;88mEPKVihabnHG%} z7VP(Qx|7dJArg6BXmy)Z!#k$n^tM6{xA_*l3;WRS>R>f*&DoVGHb7gIGWnT9tES6; z7i~H&xMx@WBvmy|r%u^q3B0ErI}4SGq`(xUwf8e274rgT3U75(1p1d{gRiyEPyE_l zzDAEQx6p};s3`cI!we^IE_1!1-!JW4h;GCpLTc_)ss=8nP>Ga`rchB6=}p8z)(99< z-1U-?>C4FzP;(>F*jIYXlx?Ib#a~fPZ#^Ct0zXQRj~*YN1&Ia!aLFt!fivX2k3?(h zW2&RQOfC&6L<;JeoPAZqnIJ75boPQ!B;HzlwHr`~H+9(CXIPAXBq+8mYRc`n@4nu; zYmghQ>?BSKzQ&rDuc5)_*ei_kb)BR`FM(M`#Ps+bxZ-qpv}BPV6o*C<`T`PKt%qcM zE<|i@akMOSsF_4?q6Bp4nVCXhEiE+8bFcSskc!nPN}#f)G4W`%da1}Qjm}`1;K=^c z8zV$Y?TE=RuHrBh@JlX4S|F5EaVaL5bdJ&kw~Jd}nh1~C0xIsFmlGy;lVv}^cXxA> z&!#@;@g4UvJPai+G_g0*H?r5#$8%Nb9Zu^ngb#t6EG}Ir&%dHW)0k>tH4zrZGrqG_M;HreE3>`{5eCkH;*zAmEFOguno87>e0o&2hmi^N$EssgB zLN+gxDK8V)wlq7WyIj|(X}i4LpWF9vGAw5!P(PDgWbK|RLd#OIh9wSse8D)d$`@k z4<{S>{Y}0JZGsLiRjs+Mm%VFoh;kLR+|M3_y#@Ig_&%s^-)pSc7H#v@;%S9#SYzCh zUDP2ScjxjfHP6({OMRT(qigxN*_WYVdmHIy2hn&(cjNQL&U<}Pyp7^)V|n(7$^150 z59G=_do3aI5JQ*aPR+C*$EtNzZ>*<6hN{I*Yx2t8C(9x&GB$C^L5)}xREAvYc@9p5 zF!MkUpcvJk7=?+bHv5v0Vk6oa?P4F*k5+6+)P>y*MNv(RE-GOyJ*>1E1Y2QJGT@`= za|VuQ&r*Z!#Mdgv-6hXOM~|A>vFntI9-yb&;flT`#I68%CrwsdNVBLbj9mJ`CkzwA zkEpHC)Nj@fxZ@b!)(g~fPTQlvBF;j0YxY!Z2)(h*tpLjNbl}BNIjC;MuHA5FhUZ~~ z$#SO^nl%$FQ~0KzM&9;K=S9$A)B+TDCUGL$c^s~fg`pHHv4?b8jLV>#tL<|LBC4UBv7KChn67L#aZ%57l%!F&9EH` z=F_yC4P2toF*K`)r6jLi9X@#9KXBcL_iq4Qx|iMv-Mh#F-GiIc=G?x1j|gImYWoVt zHkyfa8I2hjYzwM#ZFRuql^Kf>tE(lK+mV_WzQiiqI=+$_yl<;vAEQybjZ{Z$t?W$f zIRzJ3n$_pfwDIT~Z$#OqJs=i-ADSk4p>9U7{(?Io4g{b+SYXECtFP)c)(#E@6#YiF zJnx|r2a?*KcU47K^hANri; z8AoDb49U2}Yf*hP9W>MAgv)po2X@gU#hkoe$51^@l(@5`x&RET^Z()Qo%75s7Fdz0;`JH%IjY z14NvLy?r(0F;Iw{rk>sJg>Na`(NkG@#}yJMKXj=pye-JZivUxd!jO}AZX%Ay_ZqKM z)KXmf*)y~^-S4p?J4IKgGrAmAE~|u!O5;JAO5@ScGHHYHO>C#DChY0)I`!eXavgTR7$%9 zp_l@bf)(6Sg&Oj(>2X+&iTmei6o^ptkzaylbCoDGV&S2nWk~cU-_y+%y7Alv?anW& zT_zu|O41xr2;^nRS1~z zTBM&Itm6P%9Hm1K81>I?-g50wotM3=H50d@FA zxe@Dl>amnkZRaq2g71!U5%?xInQIjk$r5i%v_-y3*i7+A#n;F|h0i4*Z9Wuo3h!>$ zPYutb#2=px&2GJS9%BbwS20vJw~oW^atAjx3<0=6i>nb?3GvJ5;#m!jVP8r=L(0_h zHwUnfTn&d`kiXt&G3pInz1zv#EaQ+e>?_+q(5R|~I|uF7SoyeJ2RV6eC6vA`T}K{N z&O7soFTYS!$+r87CC&)5!v8d~38yX}k401kp^SaWNq^s)rW`{LorWhac`^wipNKMH zCZtiiA3CqHhBHy6YUE?JI`xYk1h0~t%qleogkfX5AWUXgYPjPRxsx!_ZK2y7(PMj0 zV2NAiDE7e!U-J~XEY~oTgxUb+%b>}QA;u0k$w?r2>=^bzN-8t2dJ7`UguUs4JTlFi zd@K!}uB;Lcs*nQe!szuej=?(T_qn6xQ5=r#!o@Y+_XuI)Dg(cZmfEFSQ z$&y6i@QKJ7T$6qHc~a!Mn*B?gO2sEQJ7GpGaFDe@R%a8Z#Tsku#7yDJL_|82r5AWX3CCy(MkgJf;&fdC{5gfVy@=BmnFu9}kZW1_T@+|GlF zVLHTBDB2PZDO=1p+bH3R?upDv2TeY5Xvgu9gS3Nh0>B5Af=1YtI^?Ch2v9jWEhg{^f4O`WzsJOt#rxyZlC zlk}hdtmE0=b8M{({*ZOfUEk!reBDO1wZBmPt++l&j@-s~HmUG@iMjIBrI-87DaPLHvN=sGXYu|a35r&|F1fTc{ zrTS(Fz9J&TYIHfd4-8IkBHpt1x7nbT+W-Mg^`)|?7aIg-8L4N@xt@h>>kXo_ciFo@ z{g5v2UnO`X&g*KJuKvNX-%LIiw{3f2M)09H;$F$T3{q*a?}iwHqILy#;Tz`r9=7*A zUow0Jhi%(TT+@5&x=-#a#M*qm7V_CH*xyaOJQ6#--==G#dDt!SKHNXh8q%61`x4mG zAiKc>36o*Fh`M+t43m-3QYg0XDwFRI{+>LxlEVA4)_obC zWx|TxlbVpbK-3*3Cikj$G@>{4LdJZnU03_+Dq(oOUgwbM)!aVS0Z6mf)Rs+}2G>>Q zD#P-U8aJ>&ZsGDlZ!;I`!p%Emxt?cP^TR_Q?R}N!M80h5oR&k{Ts)*Q$0gEbR3Wjq zW`e5Inwcz4x}{Q0KNn%TKUHaYS(;K6173%I_P8i)D`3Tsi?g<*(CV*0gS+9 z_;Nk8Cib;`oFh_<<2}$tyZcNc(T^vG!2t;_qOfQcU{oDDGZid`v zc|{D2FOJ12j(2-%bbSjs&%{ARKM=@-X>O$Grp|1*h+W zJc4T&#)vlzFSuBC{v|ERs|Vta>kv~7?>htiL(Fd27q2WnIr-913bgVaFA7^v71ONN zzV{e*A+zIn)gQhpGB6fE1$*O!k;~)pAbPgLd(M}1Vc>Ge5$w6_JI*ixNpN{9#Ip&x zAn$$dwq!}~z3z8;rTciNpC)a41}@-EzZS$l3a}~s9|LUvXUpxsZKDDKep->Nv>)f6 ze^7s8qZ&EuSvu)Cn%P*>Il00D`fYy$1_A;@1N^CqGCUCEC&0zlgMjfk|Ju6Y>4Nyb zMUhtb=Z+e`uKh<#{}YJd27ZqS-j5M+Ffst7CpxObS7@HD{NlIJ z{3kGUw7Ne(UdKmxGeV?^*{jDG3EVe9szH?W|qF|!1W$61Q%^RUW@ zQttpwFCC>q@1UH-AjKvKpd|<-6c@9P4<>-<&;jFDFrRLL;!nZ+i@W)62N;j@uOK~Lm*h`D`sopW^o0KfdTKo{?TLp+ekff=8GrN>-p~?Wu<3rW^Cl(sH;o+WA5Ar$gZz|@i<#4$Vh=Q zptW461*BQjcgHOXhe7c`xua?<8fRKCr&1r1&S4yXNWHTju}xuRjv4ZyYTE{QN(OPb8-+4h-!vxs|fbDz*vKqo$RSr;MwQO z)j(=t&0@zpu*gtnNp=aY7bKrZ;v_=L`k3|J9N}?43kOY(2E%59YDL)s_jicFqdKwE z=y+zN5^m*Rv2J)pIl5zQkYS ge+*{M7+Yv`xkyYQ)TgIbH-`sYuCK{7ai~DZhoG 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 literal 0 HcmV?d00001 diff --git a/DEVELOPER_FEEDBACK.md b/DEVELOPER_FEEDBACK.md new file mode 100644 index 0000000..010aeab --- /dev/null +++ b/DEVELOPER_FEEDBACK.md @@ -0,0 +1,249 @@ +# EnCoach Odoo 19 -- Developer Feedback + +**Date:** March 11, 2026 +**Status:** ALL ITEMS RESOLVED (March 13, 2026) +**Reference:** ODOO_MIGRATION_SRS_v2.md + +--- + +## Overall Assessment + +The delivery covers approximately **85% of the SRS v2 requirements**. All 15 custom modules are present, the core AI/ML services are well-implemented, data models match the specification, and 67-72 API endpoints are wired up with proper JWT authentication and security groups. The code quality is solid -- clean structure, proper error handling, and good separation of concerns. + +The items below still need to be implemented to reach full SRS compliance. + +--- + +## 1. Payment Integration (Critical -- Not Implemented) + +**SRS Reference:** Section 7 + +All three payment services are currently stubs that return `"not yet implemented"`. The SRS requires full integration with: + +### 1.1 Stripe + +**Files:** `encoach_subscription/services/stripe_service.py` + +Required functionality: +- Install and use the `stripe` Python SDK +- `create_checkout_session()` -- Create a Stripe Checkout Session using `stripe.checkout.Session.create()` with: + - `line_items` from the selected `encoach.package` + - `success_url` and `cancel_url` from the request + - `client_reference_id` = user ID + - `customer_email` = user email + - Apply discount if `discount_code` is provided (validate against `encoach.discount`) +- Return `{ "sessionId": "cs_...", "url": "https://checkout.stripe.com/..." }` +- **Webhook endpoint** (`POST /api/stripe/webhook`): + - Verify signature using `stripe.Webhook.construct_event()` with `STRIPE_WEBHOOK_SECRET` + - Handle `checkout.session.completed` event: + - Find user by `client_reference_id` + - Extend `subscription_expiration` by the package's `duration_days` + - Create `encoach.subscription.payment` record + - For corporate users, propagate subscription to entity members + +### 1.2 PayPal + +**Files:** `encoach_subscription/services/paypal_service.py` + +Required functionality: +- Use PayPal REST API (`httpx` or `requests`) +- Obtain access token from `PAYPAL_ACCESS_TOKEN_URL` using `client_id` + `client_secret` +- `create_order()` -- Create PayPal order via `POST /v2/checkout/orders`: + - `intent: "CAPTURE"` + - `purchase_units` with amount from package (minus discount) + - Return `{ "orderId": "...", "approvalUrl": "https://paypal.com/..." }` +- `capture_order()` -- Capture approved order via `POST /v2/checkout/orders/{id}/capture`: + - On success, extend subscription and create payment record + - Return `{ "ok": true }` + +### 1.3 Paymob + +**Files:** `encoach_subscription/services/paymob_service.py` + +Required functionality: +- Use Paymob API (`httpx` or `requests`) +- `create_intention()` -- Create payment intention: + - Authenticate with `PAYMOB_API_KEY` + - Create order, then payment key + - Return `{ "intentionId": "...", "clientSecret": "..." }` +- `verify_transaction()` -- Verify webhook callback: + - Validate HMAC using `PAYMOB_SECRET` + - On success, extend subscription and create payment record +- **Webhook endpoint** (`POST /api/paymob/webhook`): + - Verify transaction authenticity + - Update subscription on success + +### 1.4 Subscription Extension Logic + +After any successful payment (all providers), the system must: +1. Calculate new expiration: `max(current_expiration, now()) + duration_days` +2. Update `user.subscription_expiration` +3. Create `encoach.subscription.payment` record with provider, amount, transaction ID +4. For corporate users: propagate the new expiration to all entity members + +--- + +## 2. Missing API Endpoints + +**SRS Reference:** Section 4 + +The following endpoints are specified in the SRS but not present in the delivery: + +### 2.1 Exam Variants (add to `encoach_api/controllers/generation.py` or `exams.py`) + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `POST /api/exam/writing//attachment` | POST | Academic writing Task 1 with image upload (multipart form with `file` + `difficulty`). Must send image to GPT-4o vision API for prompt generation. | +| `GET /api/exam/level/` | GET | Return a pre-built level exam (no AI generation) | +| `GET /api/exam/level/utas` | GET | Return a UTAS-format level exam | +| `POST /api/exam/level/import/` | POST | Import level exam from uploaded file | +| `POST /api/exam/level/custom/` | POST | Generate custom level exam from JSON specification | +| `POST /api/exam/reading/import` | POST | Import reading exam from Word/Excel file | +| `POST /api/exam/listening/import` | POST | Import listening exam from file | + +### 2.2 Entity/Role/Permission Management (new controller files needed) + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `GET /api/entities` | GET | List entities (with pagination) | +| `POST /api/entities` | POST | Create entity | +| `PATCH /api/entities/` | PATCH | Update entity | +| `DELETE /api/entities/` | DELETE | Delete entity | +| `GET /api/roles` | GET | List roles (filter by entityID) | +| `POST /api/roles` | POST | Create role | +| `PATCH /api/roles/` | PATCH | Update role | +| `DELETE /api/roles/` | DELETE | Delete role | +| `GET /api/permissions` | GET | List permissions | + +### 2.3 Other Missing Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `GET /api/discounts` | GET | List discount codes | +| `POST /api/discounts` | POST | Create discount code | +| `PATCH /api/discounts/` | PATCH | Update discount code | +| `DELETE /api/discounts/` | DELETE | Delete discount code | +| `GET /api/approval-workflows` | GET | List approval workflows | +| `POST /api/approval-workflows` | POST | Create approval workflow | +| `PATCH /api/approval-workflows/` | PATCH | Update approval workflow | +| `DELETE /api/approval-workflows/` | DELETE | Delete approval workflow | + +--- + +## 3. Evaluation Status Endpoint Path + +**SRS Reference:** Section 4.2 + +**Current:** `GET /api/evaluate/status?sessionId=...&exerciseId=...` (query params) +**SRS specifies:** `GET /api/evaluate//` (path params) + +The frontend SRS document (`ODOO_MIGRATION_FRONTEND_SRS.md`) references the path-param version. Please either: +- (A) Change the endpoint to use path params to match the SRS, OR +- (B) Confirm that query params are intentional so we can update the frontend SRS + +--- + +## 4. Record-Level Security Rules (`ir.rule`) + +**SRS Reference:** Section 5 + +The delivery has model-level ACL rules (`ir.model.access.csv`) for each module, which is good. However, record-level security rules (`ir.rule`) are also needed to ensure: + +- **Students** can only read/write their own records (sessions, stats, evaluations, training, tickets) +- **Teachers** can see records for students in their groups/assignments +- **Corporate** users can see records within their entity +- **Admin/Developer** users have unrestricted access + +Example rules needed (add to each module's `security/` directory): + +```xml + + + Students see own stats + + + [('user_id', '=', user.id)] + +``` + +Models that need record rules: +- `encoach.session` -- students see own sessions only +- `encoach.stat` -- students see own stats only +- `encoach.evaluation` -- students see own evaluations only +- `encoach.training` -- students see own training only +- `encoach.ticket` -- students see own tickets, admins see all +- `encoach.assignment` -- students see assignments they're assigned to +- `encoach.exam` -- filtered by access field (public/private/entity) + +--- + +## 5. Whisper Scaling + +**SRS Reference:** Section 6.5 + +**Current:** Single lazy-loaded Whisper model instance in `whisper_service.py` +**SRS specifies:** 4 model instances with `ThreadPoolExecutor(max_workers=4)` for parallel transcription + +Update `encoach_ai_media/services/whisper_service.py` to: +1. Load the configurable number of model instances (from `encoach.whisper_workers` system parameter, default 4) +2. Use `concurrent.futures.ThreadPoolExecutor` to process transcription requests in parallel +3. Round-robin or queue-based assignment of requests to model instances + +This is important for production performance when multiple students submit speaking exams simultaneously. + +--- + +## 6. FAISS Index Type + +**SRS Reference:** Section 6.8 + +**Current:** `faiss.IndexFlatIP` (inner product) +**SRS specifies:** `faiss.IndexFlatL2` (L2/Euclidean distance) + +The current implementation normalizes vectors and uses inner product, which effectively gives cosine similarity. This works correctly, but it's a deviation from the SRS. If the original `ielts-be` training data was built with L2 distance, the results may differ slightly. Please verify which index type the original system used and align accordingly. + +--- + +## 7. Payment Webhook Endpoints + +**SRS Reference:** Section 7 + +In addition to the payment service implementations (Item 1 above), the following webhook controller routes need to be added to `encoach_api/controllers/subscriptions.py`: + +```python +@http.route('/api/stripe/webhook', type='http', auth='public', methods=['POST'], csrf=False, cors='*') +def stripe_webhook(self, **kwargs): + """Handle Stripe webhook events (checkout.session.completed, etc.).""" + ... + +@http.route('/api/paymob/webhook', type='http', auth='public', methods=['POST'], csrf=False, cors='*') +def paymob_webhook(self, **kwargs): + """Handle Paymob transaction callbacks.""" + ... +``` + +These must be `auth='public'` (no JWT) since they're called by external services. + +--- + +## Summary of Action Items + +| # | Item | Priority | Status | +|---|------|----------|--------| +| 1 | Payment integration (Stripe/PayPal/Paymob) | **Critical** | RESOLVED | +| 2 | Missing API endpoints (~20 endpoints) | **High** | RESOLVED | +| 3 | Evaluation status endpoint path alignment | **Medium** | RESOLVED | +| 4 | `ir.rule` record-level security rules | **High** | RESOLVED | +| 5 | Whisper multi-instance scaling | **Medium** | RESOLVED | +| 6 | FAISS index type verification | **Low** | RESOLVED | +| 7 | Payment webhook endpoints | **Critical** | RESOLVED | + +--- + +## Changes Already Applied + +The following small fixes have been applied directly to the codebase: + +1. **`encoach_ai_media/__manifest__.py`** -- Added missing `external_dependencies`: `numpy`, `openai-whisper`, `librosa`, `soundfile`, `httpx` +2. **`encoach_training/__manifest__.py`** -- Added missing `external_dependencies`: `numpy`, `faiss-cpu`, `sentence-transformers` +3. **`requirements.txt`** -- Created at project root with all Python dependencies and versions from SRS Section 6.10 diff --git a/ENCOACH_AI_KEYS.md b/ENCOACH_AI_KEYS.md new file mode 100644 index 0000000..a7dc348 --- /dev/null +++ b/ENCOACH_AI_KEYS.md @@ -0,0 +1,158 @@ +# EnCoach AI & Service Keys Configuration + +All API keys are stored as Odoo **System Parameters** (`ir.config_parameter`). + +--- + +## How to Set Keys + +### Via Odoo UI + +1. Login as `admin` / `admin` at http://127.0.0.1:8069/web +2. Navigate to **Settings > Technical > Parameters > System Parameters** +3. Search for `encoach.` to filter all EnCoach keys +4. Click a parameter and paste your key into the **Value** field +5. Save — changes take effect immediately (no restart needed) + +### Via Terminal + +```bash +cd /Users/yamenahmad/projects2026/odoo/odoo19/odoo + +../.conda-envs/odoo19/bin/python odoo-bin shell -c ../odoo.conf -d encoach <<'EOF' +params = env['ir.config_parameter'].sudo() +params.set_param('encoach.openai_api_key', 'sk-your-key-here') +params.set_param('encoach.aws_access_key_id', 'AKIA...') +params.set_param('encoach.aws_secret_access_key', 'your-aws-secret') +params.set_param('encoach.elai_token', 'your-elai-token') +params.set_param('encoach.gptzero_api_key', 'your-gptzero-key') +params.set_param('encoach.jwt_secret', 'a-strong-random-secret-256-bits') +env.cr.commit() +print("All keys set.") +EOF +``` + +--- + +## Key Reference + +### Core + +| Parameter | Description | Required | Where to Get | +|-----------|-------------|----------|-------------| +| `encoach.jwt_secret` | JWT signing secret for API authentication | Yes | Generate a random 256-bit string | +| `encoach.jwt_expiry_hours` | JWT token expiry in hours (default: 72) | No | Set any integer | + +### OpenAI (GPT-4o, Whisper) + +Used by: Exam generation, writing/speaking evaluation, grading, training tips. + +| Parameter | Description | Required | Where to Get | +|-----------|-------------|----------|-------------| +| `encoach.openai_api_key` | OpenAI API key | Yes | https://platform.openai.com/api-keys | + +**Models used:** +- `gpt-4o` — Exam generation, evaluation, grading, training +- `whisper-1` — Audio transcription (speaking evaluation) + +### AWS (Polly TTS) + +Used by: Listening exam audio generation, instruction audio. + +| Parameter | Description | Required | Where to Get | +|-----------|-------------|----------|-------------| +| `encoach.aws_access_key_id` | AWS IAM Access Key ID | Yes | https://console.aws.amazon.com/iam/ | +| `encoach.aws_secret_access_key` | AWS IAM Secret Access Key | Yes | Same as above | + +**AWS services used:** +- **Amazon Polly** — Text-to-speech for listening exam audio +- **Region:** Configured in `polly_service.py` (default: `us-east-1`) + +**Required IAM permissions:** +```json +{ + "Effect": "Allow", + "Action": ["polly:SynthesizeSpeech"], + "Resource": "*" +} +``` + +### ELAI.io (Avatar Videos) + +Used by: Speaking exam video generation with AI avatars. + +| Parameter | Description | Required | Where to Get | +|-----------|-------------|----------|-------------| +| `encoach.elai_token` | ELAI API token | For speaking videos | https://elai.io/api | + +### GPTZero (Plagiarism Detection) + +Used by: AI-generated content detection in writing submissions. + +| Parameter | Description | Required | Where to Get | +|-----------|-------------|----------|-------------| +| `encoach.gptzero_api_key` | GPTZero API key | Optional | https://gptzero.me/api | + +### Stripe (Payments) + +Used by: Subscription checkout. + +| Parameter | Description | Required | Where to Get | +|-----------|-------------|----------|-------------| +| `encoach.stripe_secret_key` | Stripe secret key (`sk_test_...` or `sk_live_...`) | For Stripe payments | https://dashboard.stripe.com/apikeys | +| `encoach.stripe_webhook_secret` | Stripe webhook signing secret (`whsec_...`) | For webhooks | https://dashboard.stripe.com/webhooks | + +### PayPal (Payments) + +Used by: Subscription checkout. + +| Parameter | Description | Required | Where to Get | +|-----------|-------------|----------|-------------| +| `encoach.paypal_client_id` | PayPal REST API Client ID | For PayPal payments | https://developer.paypal.com/dashboard/applications | +| `encoach.paypal_client_secret` | PayPal REST API Client Secret | For PayPal payments | Same as above | + +### Paymob (Payments) + +Used by: Subscription checkout (Middle East region). + +| Parameter | Description | Required | Where to Get | +|-----------|-------------|----------|-------------| +| `encoach.paymob_api_key` | Paymob API key | For Paymob payments | https://accept.paymob.com/portal2/en/settings | + +--- + +## Which Keys Are Needed for What + +| Feature | Keys Required | +|---------|--------------| +| User registration & login | `jwt_secret` | +| Generate reading/listening/writing/speaking exams | `openai_api_key` | +| Grade writing answers | `openai_api_key` | +| Grade speaking answers | `openai_api_key` | +| Generate listening audio (MP3) | `aws_access_key_id` + `aws_secret_access_key` | +| Transcribe audio (Whisper) | `openai_api_key` | +| Generate speaking avatar video | `elai_token` | +| Detect AI-generated content | `gptzero_api_key` | +| Personalised training tips | `openai_api_key` | +| Stripe payments | `stripe_secret_key` + `stripe_webhook_secret` | +| PayPal payments | `paypal_client_id` + `paypal_client_secret` | +| Paymob payments | `paymob_api_key` | + +--- + +## Minimum Setup (to test AI features) + +For basic AI testing, you only need **one key**: + +```bash +cd /Users/yamenahmad/projects2026/odoo/odoo19/odoo +../.conda-envs/odoo19/bin/python odoo-bin shell -c ../odoo.conf -d encoach <<'EOF' +env['ir.config_parameter'].sudo().set_param('encoach.openai_api_key', 'sk-your-openai-key') +env.cr.commit() +print("OpenAI key set.") +EOF +``` + +This enables: exam generation, writing evaluation, speaking evaluation (text grading), short-answer grading, and training tips. + +Add AWS keys later for listening audio, ELAI for avatar videos, and payment keys when ready for subscriptions. diff --git a/ENCOACH_SUMMARY.md b/ENCOACH_SUMMARY.md new file mode 100644 index 0000000..d1003ed --- /dev/null +++ b/ENCOACH_SUMMARY.md @@ -0,0 +1,345 @@ +# EnCoach Odoo 19 – Project Summary + +## Overview + +The **EnCoach IELTS platform** (originally Node.js / MongoDB / Firebase) has been migrated into **15 custom Odoo 19 modules** that replicate all business logic inside Odoo Community Edition. + +--- + +## Module Map + +| Module | Purpose | +|--------|---------| +| `encoach_core` | Users, entities (orgs), roles, permissions, registration codes, invites | +| `encoach_exam` | Exam builder with parts/exercises/questions (JSON), approval workflows | +| `encoach_classroom` | Student groups / classrooms | +| `encoach_assignment` | Assignments with start / release / archive lifecycle | +| `encoach_stats` | Exam sessions and score statistics | +| `encoach_evaluation` | AI-powered writing & speaking evaluation jobs | +| `encoach_ai` | Core AI services (OpenAI, prompt management) | +| `encoach_ai_grading` | Multi-answer grading, GPTZero plagiarism checks | +| `encoach_ai_generation` | AI exam content generation (reading / writing / speaking / listening) | +| `encoach_ai_media` | AWS Polly audio, ELAI video avatars, Whisper transcription | +| `encoach_training` | Personalised training plans, tips (FAISS semantic search), walkthroughs | +| `encoach_subscription` | Packages, payments (Stripe / PayPal / Paymob), discount codes | +| `encoach_registration` | Registration code management service | +| `encoach_ticket` | Support ticket system | +| `encoach_api` | REST API layer (67 endpoints) with JWT authentication | + +--- + +## How to Run + +### Prerequisites + +- Python 3.12 (via Miniconda) +- PostgreSQL (local, via Conda) + +### Start PostgreSQL + +```bash +cd /Users/yamenahmad/projects2026/odoo/odoo19 +.conda-envs/odoo19/bin/pg_ctl -D pgdata -l pg.log start +``` + +### Start Odoo + +```bash +cd /Users/yamenahmad/projects2026/odoo/odoo19/odoo +../.conda-envs/odoo19/bin/python odoo-bin -c ../odoo.conf -d encoach +``` + +### Fresh Install (drop + recreate) + +```bash +.conda-envs/odoo19/bin/dropdb -h localhost -U yamenahmad encoach +.conda-envs/odoo19/bin/createdb -h localhost -U yamenahmad encoach + +cd odoo +../.conda-envs/odoo19/bin/python odoo-bin -c ../odoo.conf -d encoach -i encoach_api --stop-after-init +``` + +After fresh install, set the admin password: + +```bash +cd odoo +../.conda-envs/odoo19/bin/python odoo-bin shell -c ../odoo.conf -d encoach <<'EOF' +user = env['res.users'].browse(2) +user.password = 'admin' +env.cr.commit() +EOF +``` + +--- + +## Access the System + +### Odoo Backend (Admin UI) + +| | | +|--|--| +| **URL** | http://127.0.0.1:8069/web | +| **Login** | `admin` | +| **Password** | `admin` | + +The **EnCoach** top-level menu contains: Users & Entities, Exams, Classroom, AI & Grading, Training, Subscriptions, Support, Configuration. + +### REST API (for the frontend) + +| | | +|--|--| +| **Base URL** | http://127.0.0.1:8069 | +| **Auth** | JWT Bearer token (obtained via `/api/login`) | +| **Format** | JSON request / response | + +--- + +## API Collection (67 Endpoints) + +### Auth (4) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/register` | Register new user | +| POST | `/api/login` | Login – returns JWT token | +| POST | `/api/logout` | Logout | +| POST | `/api/reset` | Reset password | +| POST | `/api/reset/sendVerification` | Send reset verification | + +### Users (6) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/user` | Get current user (from JWT) | +| POST / PATCH | `/api/users/update` | Update profile | +| GET | `/api/users/list` | List / search users | +| GET | `/api/users/` | Get user by ID | +| POST | `/api/users/controller` | User admin actions | +| GET | `/api/users/balance` | Get user balance | + +### Registration Codes (3) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/code/` | Validate registration code | +| POST | `/api/batch_users` | Batch-create users | +| POST | `/api/make_user` | Create single user | + +### Exams (7) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/exam` | List exams (filter by `?module=`) | +| POST | `/api/exam/` | Create exam | +| GET | `/api/exam//` | Get exam | +| PATCH | `/api/exam//` | Update exam | +| DELETE | `/api/exam//` | Delete exam | +| POST | `/api/exam//import/` | Import exam from file | +| GET | `/api/exam/avatars` | List exam avatars | + +### Classrooms (4) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/groups` | List groups | +| POST | `/api/groups` | Create group | +| PATCH | `/api/groups/` | Update group | +| DELETE | `/api/groups/` | Delete group | + +### Assignments (8) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/assignments` | List assignments | +| POST | `/api/assignments` | Create assignment | +| PATCH | `/api/assignments/` | Update assignment | +| DELETE | `/api/assignments/` | Delete assignment | +| POST | `/api/assignments//start` | Start assignment | +| POST | `/api/assignments//release` | Release results | +| POST | `/api/assignments//archive` | Archive | +| POST | `/api/assignments//unarchive` | Unarchive | + +### Sessions & Stats (4) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/sessions` | Save exam session | +| DELETE | `/api/sessions/` | Delete session | +| POST | `/api/stats` | Save statistics | +| GET | `/api/stats/` | Get stat by ID | +| POST | `/api/statistical` | Query / aggregate stats | + +### AI Evaluation (4) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/evaluate/writing` | Evaluate writing answer | +| POST | `/api/evaluate/speaking` | Evaluate speaking (audio) | +| POST | `/api/evaluate/interactiveSpeaking` | Evaluate interactive speaking | +| GET | `/api/evaluate/status` | Poll evaluation job status | + +### AI Generation (7) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/exam/reading/` | Generate reading passage | +| POST | `/api/exam/reading/` | Generate reading exercises | +| GET | `/api/exam/listening/

` | Generate listening dialog | +| POST | `/api/exam/listening/` | Generate listening exercises | +| GET | `/api/exam/writing/` | Generate writing task | +| GET | `/api/exam/speaking/` | Generate speaking task | +| POST | `/api/exam/level/` | Generate level-test exercises | + +### AI Media (7) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/exam/listening/media` | Generate listening MP3 (AWS Polly) | +| POST | `/api/exam/listening/transcribe` | Transcribe audio to dialog | +| POST | `/api/exam/listening/instructions` | Generate instructions MP3 | +| POST | `/api/exam/speaking/media` | Start AI avatar video generation | +| GET | `/api/exam/speaking/media/` | Poll video generation status | +| GET | `/api/exam/speaking/avatars` | List speaking avatars | +| POST | `/api/transcribe` | Transcribe audio file (Whisper) | + +### Training (4) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/training` | Generate personalised training plan | +| GET | `/api/training/` | Get training by ID | +| POST | `/api/training/tips` | Get contextual tips | +| GET | `/api/training/walkthrough` | Get walkthrough | + +### Subscriptions & Payments (5) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/packages` | List subscription packages | +| POST | `/api/stripe` | Create Stripe checkout session | +| POST | `/api/paypal` | Create PayPal order | +| POST | `/api/paypal/approve` | Capture / approve PayPal order | +| POST | `/api/paymob` | Create PayMob payment intention | + +### Tickets (4) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/tickets` | List support tickets | +| POST | `/api/tickets` | Create ticket | +| PATCH | `/api/tickets/` | Update ticket | +| DELETE | `/api/tickets/` | Delete ticket | + +### Storage (2) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/storage` | Upload file | +| POST | `/api/storage/delete` | Delete file | + +### Grading (2) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/grading/multiple` | Grade multiple short-answer exercises | +| POST | `/api/exam/grade/summary` | Generate grading summary | + +--- + +## Quick Test + +```bash +# 1. Register a user +curl -s http://127.0.0.1:8069/api/register \ + -H "Content-Type: application/json" \ + -d '{"email":"test@test.com","password":"Pass1234","name":"Test User","type":"student"}' + +# 2. Login – get JWT token +TOKEN=$(curl -s http://127.0.0.1:8069/api/login \ + -H "Content-Type: application/json" \ + -d '{"credential":"test@test.com","password":"Pass1234"}' \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") + +# 3. Get current user (authenticated) +curl -s http://127.0.0.1:8069/api/user \ + -H "Authorization: Bearer $TOKEN" | python3 -m json.tool + +# 4. List exams +curl -s "http://127.0.0.1:8069/api/exam?module=reading" \ + -H "Authorization: Bearer $TOKEN" | python3 -m json.tool + +# 5. Create a ticket +curl -s http://127.0.0.1:8069/api/tickets \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"subject":"Test ticket","description":"Just testing","type":"feedback"}' + +# 6. List packages +curl -s http://127.0.0.1:8069/api/packages \ + -H "Authorization: Bearer $TOKEN" | python3 -m json.tool +``` + +--- + +## File Structure + +``` +addons_extra/ +├── encoach_core/ +│ ├── __manifest__.py +│ ├── models/ # entity, role, permission, code, invite, res_users, user_entity_rel +│ ├── security/ # security.xml (groups), ir.model.access.csv +│ ├── data/ # constants.xml, assign_admin_group.xml +│ ├── views/ # menu.xml + views for every model +│ └── scripts/ # migrate_mongodb.py, migrate_firebase_auth.py, rebuild_faiss.py, import_avatars.py +├── encoach_exam/ +│ ├── models/ # exam.py, approval_workflow.py +│ └── views/ +├── encoach_classroom/ +│ ├── models/ # group.py +│ └── views/ +├── encoach_assignment/ +│ ├── models/ # assignment.py +│ └── views/ +├── encoach_stats/ +│ ├── models/ # session.py, stat.py +│ └── views/ +├── encoach_evaluation/ +│ ├── models/ # evaluation.py, ai_job.py +│ └── views/ +├── encoach_ai/ +│ └── services/ # openai_service.py, prompt_service.py +├── encoach_ai_grading/ +│ └── services/ # grading_service.py, gptzero_service.py +├── encoach_ai_generation/ +│ └── services/ # reading, listening, writing, speaking, level generators +├── encoach_ai_media/ +│ ├── models/ # elai_avatar.py +│ ├── services/ # polly_service.py, elai_service.py, whisper_service.py +│ └── views/ +├── encoach_training/ +│ ├── models/ # training.py, tip.py, walkthrough.py +│ ├── services/ # training_service.py, faiss_service.py +│ └── views/ +├── encoach_subscription/ +│ ├── models/ # package.py, payment.py, discount.py +│ ├── services/ # stripe_service.py, paypal_service.py, paymob_service.py +│ └── views/ +├── encoach_registration/ +│ └── services/ # code_service.py +├── encoach_ticket/ +│ ├── models/ # ticket.py +│ └── views/ +└── encoach_api/ + └── controllers/ # 16 controller files (auth, users, exams, classrooms, etc.) +``` + +--- + +## Key Technical Decisions + +- **JSON fields** (`fields.Json`) used extensively for complex nested data (exam parts, scores, solutions) to match existing MongoDB document shapes. +- **JWT authentication** via `PyJWT` for stateless API auth, stored secret in `ir.config_parameter`. +- **Odoo 19 changes**: `group_ids` (not `groups_id`), no `category_id` on `res.groups`, `models.Constraint` instead of `_sql_constraints`. +- **Security**: Each model has ACL rules for Student (read-only), Teacher (read/write), Admin (full CRUD), and `base.group_system` (Odoo admin full CRUD). +- **Admin user** is automatically added to the EnCoach Developer group (which implies Admin and Teacher) on install. diff --git a/EnCoach_API.postman_collection.json b/EnCoach_API.postman_collection.json new file mode 100644 index 0000000..7ef4f0e --- /dev/null +++ b/EnCoach_API.postman_collection.json @@ -0,0 +1,1692 @@ +{ + "info": { + "name": "EnCoach Odoo 19 API", + "description": "Complete API collection for the EnCoach IELTS platform running on Odoo 19 — 90+ endpoints.\n\n**Setup:**\n1. Run the \"Register\" then \"Login\" request first.\n2. The login response token is auto-saved to the `token` variable.\n3. All authenticated requests use `Bearer {{token}}`.\n\n**Folders:** Auth, Registration Codes, Users, Exams, Classrooms, Assignments, Sessions, Stats, AI Evaluation, AI Generation, AI Media, Training, Entities & Roles, Discounts, Approval Workflows, Subscriptions & Payments, Tickets, Storage, Grading.\n\n**Base URL:** `http://127.0.0.1:8069`", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "baseUrl", + "value": "http://127.0.0.1:8069" + }, + { + "key": "token", + "value": "" + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{token}}", + "type": "string" + } + ] + }, + "item": [ + { + "name": "Auth", + "item": [ + { + "name": "Register", + "request": { + "auth": { "type": "noauth" }, + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"testuser@example.com\",\n \"password\": \"Test1234\",\n \"name\": \"Test User\",\n \"type\": \"student\",\n \"code\": \"\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/register", + "host": ["{{baseUrl}}"], + "path": ["api", "register"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var json = pm.response.json();", + "if (json.token) {", + " pm.collectionVariables.set('token', json.token);", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "Login", + "request": { + "auth": { "type": "noauth" }, + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"credential\": \"testuser@example.com\",\n \"password\": \"Test1234\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/login", + "host": ["{{baseUrl}}"], + "path": ["api", "login"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var json = pm.response.json();", + "if (json.token) {", + " pm.collectionVariables.set('token', json.token);", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "Logout", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "url": { + "raw": "{{baseUrl}}/api/logout", + "host": ["{{baseUrl}}"], + "path": ["api", "logout"] + } + } + }, + { + "name": "Reset Password", + "request": { + "auth": { "type": "noauth" }, + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"testuser@example.com\",\n \"password\": \"NewPass1234\",\n \"token\": \"\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/reset", + "host": ["{{baseUrl}}"], + "path": ["api", "reset"] + } + } + }, + { + "name": "Send Reset Verification", + "request": { + "auth": { "type": "noauth" }, + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"testuser@example.com\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/reset/sendVerification", + "host": ["{{baseUrl}}"], + "path": ["api", "reset", "sendVerification"] + } + } + } + ] + }, + { + "name": "Registration Codes", + "item": [ + { + "name": "Validate Code", + "request": { + "auth": { "type": "noauth" }, + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/code/SAMPLE-CODE", + "host": ["{{baseUrl}}"], + "path": ["api", "code", "SAMPLE-CODE"] + } + } + }, + { + "name": "Batch Create Users", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"users\": [\n { \"email\": \"user1@example.com\", \"name\": \"User One\", \"type\": \"student\" },\n { \"email\": \"user2@example.com\", \"name\": \"User Two\", \"type\": \"student\" }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/batch_users", + "host": ["{{baseUrl}}"], + "path": ["api", "batch_users"] + } + } + }, + { + "name": "Make User (Admin)", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"newuser@example.com\",\n \"name\": \"New User\",\n \"password\": \"TempPass123\",\n \"type\": \"student\",\n \"isVerified\": true\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/make_user", + "host": ["{{baseUrl}}"], + "path": ["api", "make_user"] + } + } + } + ] + }, + { + "name": "Users", + "item": [ + { + "name": "Get Current User", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/user", + "host": ["{{baseUrl}}"], + "path": ["api", "user"] + } + } + }, + { + "name": "Update User Profile", + "request": { + "method": "PATCH", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Updated Name\",\n \"phone\": \"+1234567890\",\n \"nativeLanguage\": \"Arabic\",\n \"country\": \"Egypt\",\n \"learningGoal\": \"IELTS Band 7\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/users/update", + "host": ["{{baseUrl}}"], + "path": ["api", "users", "update"] + } + } + }, + { + "name": "List Users", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/users/list?search=&type=student&page=1&limit=20", + "host": ["{{baseUrl}}"], + "path": ["api", "users", "list"], + "query": [ + { "key": "search", "value": "" }, + { "key": "type", "value": "student" }, + { "key": "entityID", "value": "", "disabled": true }, + { "key": "page", "value": "1" }, + { "key": "limit", "value": "20" } + ] + } + } + }, + { + "name": "Get User by ID", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/users/5", + "host": ["{{baseUrl}}"], + "path": ["api", "users", "5"] + } + } + }, + { + "name": "User Controller Action", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"action\": \"transfer\",\n \"userIds\": [5],\n \"fromGroupId\": 1,\n \"toGroupId\": 2\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/users/controller", + "host": ["{{baseUrl}}"], + "path": ["api", "users", "controller"] + } + } + }, + { + "name": "Get User Balance", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/users/balance", + "host": ["{{baseUrl}}"], + "path": ["api", "users", "balance"] + } + } + } + ] + }, + { + "name": "Exams", + "item": [ + { + "name": "List Exams", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/exam?module=reading&page=1&limit=20", + "host": ["{{baseUrl}}"], + "path": ["api", "exam"], + "query": [ + { "key": "module", "value": "reading" }, + { "key": "createdBy", "value": "", "disabled": true }, + { "key": "isPublished", "value": "", "disabled": true }, + { "key": "entityID", "value": "", "disabled": true }, + { "key": "page", "value": "1" }, + { "key": "limit", "value": "20" } + ] + } + } + }, + { + "name": "Create Exam", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"label\": \"Sample Reading Exam\",\n \"access\": \"public\",\n \"variant\": \"full\",\n \"min_timer\": 60,\n \"is_diagnostic\": false,\n \"shuffle\": false,\n \"difficulty\": [\"B1\", \"B2\"],\n \"parts\": [\n {\n \"name\": \"Part 1\",\n \"exercises\": [\n {\n \"type\": \"multiple_choice\",\n \"questions\": [\n { \"text\": \"What is the main idea?\", \"options\": [\"A\", \"B\", \"C\", \"D\"], \"answer\": \"A\" }\n ]\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/exam/reading", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "reading"] + } + } + }, + { + "name": "Get Exam", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/exam/reading/1", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "reading", "1"] + } + } + }, + { + "name": "Update Exam", + "request": { + "method": "PATCH", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"label\": \"Updated Exam Title\",\n \"access\": \"private\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/exam/reading/1", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "reading", "1"] + } + } + }, + { + "name": "Delete Exam", + "request": { + "method": "DELETE", + "url": { + "raw": "{{baseUrl}}/api/exam/reading/1", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "reading", "1"] + } + } + }, + { + "name": "Import Exam", + "request": { + "method": "POST", + "body": { + "mode": "formdata", + "formdata": [ + { "key": "exercises", "type": "file", "src": "" }, + { "key": "solutions", "type": "file", "src": "", "disabled": true } + ] + }, + "url": { + "raw": "{{baseUrl}}/api/exam/reading/import/", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "reading", "import", ""] + } + } + }, + { + "name": "List Avatars", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/exam/avatars", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "avatars"] + } + } + } + ] + }, + { + "name": "Classrooms", + "item": [ + { + "name": "List Groups", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/groups?participant=&admin=&entityID=", + "host": ["{{baseUrl}}"], + "path": ["api", "groups"], + "query": [ + { "key": "participant", "value": "", "disabled": true }, + { "key": "admin", "value": "", "disabled": true }, + { "key": "entityID", "value": "", "disabled": true } + ] + } + } + }, + { + "name": "Create Group", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"IELTS Prep Class A\",\n \"entityID\": null,\n \"participants\": []\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/groups", + "host": ["{{baseUrl}}"], + "path": ["api", "groups"] + } + } + }, + { + "name": "Update Group", + "request": { + "method": "PATCH", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Updated Group Name\",\n \"participants\": [5]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/groups/1", + "host": ["{{baseUrl}}"], + "path": ["api", "groups", "1"] + } + } + }, + { + "name": "Delete Group", + "request": { + "method": "DELETE", + "url": { + "raw": "{{baseUrl}}/api/groups/1", + "host": ["{{baseUrl}}"], + "path": ["api", "groups", "1"] + } + } + } + ] + }, + { + "name": "Assignments", + "item": [ + { + "name": "List Assignments", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/assignments?page=1&limit=20", + "host": ["{{baseUrl}}"], + "path": ["api", "assignments"], + "query": [ + { "key": "groupId", "value": "", "disabled": true }, + { "key": "createdBy", "value": "", "disabled": true }, + { "key": "status", "value": "", "disabled": true }, + { "key": "participant", "value": "", "disabled": true }, + { "key": "isArchived", "value": "", "disabled": true }, + { "key": "page", "value": "1" }, + { "key": "limit", "value": "20" } + ] + } + } + }, + { + "name": "Create Assignment", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Weekly Reading Test\",\n \"examId\": 1,\n \"groupId\": 1,\n \"startDate\": \"2026-03-15T09:00:00\",\n \"endDate\": \"2026-03-15T11:00:00\",\n \"duration\": 120\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/assignments", + "host": ["{{baseUrl}}"], + "path": ["api", "assignments"] + } + } + }, + { + "name": "Update Assignment", + "request": { + "method": "PATCH", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Updated Assignment\",\n \"startDate\": \"2026-03-16T09:00:00\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/assignments/1", + "host": ["{{baseUrl}}"], + "path": ["api", "assignments", "1"] + } + } + }, + { + "name": "Delete Assignment", + "request": { + "method": "DELETE", + "url": { + "raw": "{{baseUrl}}/api/assignments/1", + "host": ["{{baseUrl}}"], + "path": ["api", "assignments", "1"] + } + } + }, + { + "name": "Start Assignment", + "request": { + "method": "POST", + "url": { + "raw": "{{baseUrl}}/api/assignments/1/start", + "host": ["{{baseUrl}}"], + "path": ["api", "assignments", "1", "start"] + } + } + }, + { + "name": "Release Assignment", + "request": { + "method": "POST", + "url": { + "raw": "{{baseUrl}}/api/assignments/1/release", + "host": ["{{baseUrl}}"], + "path": ["api", "assignments", "1", "release"] + } + } + }, + { + "name": "Archive Assignment", + "request": { + "method": "POST", + "url": { + "raw": "{{baseUrl}}/api/assignments/1/archive", + "host": ["{{baseUrl}}"], + "path": ["api", "assignments", "1", "archive"] + } + } + }, + { + "name": "Unarchive Assignment", + "request": { + "method": "POST", + "url": { + "raw": "{{baseUrl}}/api/assignments/1/unarchive", + "host": ["{{baseUrl}}"], + "path": ["api", "assignments", "1", "unarchive"] + } + } + } + ] + }, + { + "name": "Sessions", + "item": [ + { + "name": "Save Session", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"examId\": 1,\n \"module\": \"reading\",\n \"answers\": {},\n \"startedAt\": \"2026-03-13T09:00:00\",\n \"completedAt\": \"2026-03-13T10:00:00\",\n \"assignmentId\": null\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/sessions", + "host": ["{{baseUrl}}"], + "path": ["api", "sessions"] + } + } + }, + { + "name": "Delete Session", + "request": { + "method": "DELETE", + "url": { + "raw": "{{baseUrl}}/api/sessions/1", + "host": ["{{baseUrl}}"], + "path": ["api", "sessions", "1"] + } + } + } + ] + }, + { + "name": "Stats", + "item": [ + { + "name": "Save Stats", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sessionId\": 1,\n \"module\": \"reading\",\n \"score\": { \"correct\": 30, \"total\": 40, \"missing\": 0 },\n \"result\": {},\n \"examId\": 1,\n \"assignmentId\": null\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/stats", + "host": ["{{baseUrl}}"], + "path": ["api", "stats"] + } + } + }, + { + "name": "Get Stat by ID", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/stats/1", + "host": ["{{baseUrl}}"], + "path": ["api", "stats", "1"] + } + } + }, + { + "name": "Statistical Query", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"userId\": 5,\n \"module\": \"reading\",\n \"dateFrom\": \"2026-01-01\",\n \"dateTo\": \"2026-12-31\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/statistical", + "host": ["{{baseUrl}}"], + "path": ["api", "statistical"] + } + } + } + ] + }, + { + "name": "AI Evaluation", + "item": [ + { + "name": "Evaluate Writing", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sessionId\": 1,\n \"exerciseId\": \"ex1\",\n \"question\": \"Describe the chart below.\",\n \"answer\": \"The chart shows a significant increase in...\",\n \"task\": 1\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/evaluate/writing", + "host": ["{{baseUrl}}"], + "path": ["api", "evaluate", "writing"] + } + } + }, + { + "name": "Evaluate Speaking", + "request": { + "method": "POST", + "body": { + "mode": "formdata", + "formdata": [ + { "key": "userId", "value": "5", "type": "text" }, + { "key": "sessionId", "value": "1", "type": "text" }, + { "key": "exerciseId", "value": "ex1", "type": "text" }, + { "key": "task", "value": "1", "type": "text" }, + { "key": "question_0", "value": "Describe your hometown.", "type": "text" }, + { "key": "audio_0", "type": "file", "src": "" } + ] + }, + "url": { + "raw": "{{baseUrl}}/api/evaluate/speaking", + "host": ["{{baseUrl}}"], + "path": ["api", "evaluate", "speaking"] + } + } + }, + { + "name": "Evaluate Interactive Speaking", + "request": { + "method": "POST", + "body": { + "mode": "formdata", + "formdata": [ + { "key": "userId", "value": "5", "type": "text" }, + { "key": "sessionId", "value": "1", "type": "text" }, + { "key": "exerciseId", "value": "ex1", "type": "text" }, + { "key": "task", "value": "2", "type": "text" }, + { "key": "question_0", "value": "Tell me about a hobby you enjoy.", "type": "text" }, + { "key": "audio_0", "type": "file", "src": "" } + ] + }, + "url": { + "raw": "{{baseUrl}}/api/evaluate/interactiveSpeaking", + "host": ["{{baseUrl}}"], + "path": ["api", "evaluate", "interactiveSpeaking"] + } + } + }, + { + "name": "Poll Evaluation Status (path params)", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/evaluate/1/ex1", + "host": ["{{baseUrl}}"], + "path": ["api", "evaluate", "1", "ex1"] + } + } + }, + { + "name": "Poll Evaluation Status (query params - legacy)", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/evaluate/status?sessionId=1&exerciseId=ex1", + "host": ["{{baseUrl}}"], + "path": ["api", "evaluate", "status"], + "query": [ + { "key": "sessionId", "value": "1" }, + { "key": "exerciseId", "value": "ex1" } + ] + } + } + } + ] + }, + { + "name": "AI Generation", + "item": [ + { + "name": "Generate Reading Passage", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/exam/reading/1?topic=climate+change&word_count=800", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "reading", "1"], + "query": [ + { "key": "topic", "value": "climate+change" }, + { "key": "word_count", "value": "800" } + ] + } + } + }, + { + "name": "Generate Reading Exercises", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"text\": \"The passage text goes here...\",\n \"exercises\": [\"multiple_choice\", \"true_false\"],\n \"difficulty\": [\"B1\", \"B2\"]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/exam/reading/", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "reading", ""] + } + } + }, + { + "name": "Generate Listening Dialog", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/exam/listening/1?topic=university+enrollment&difficulty=B1", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "listening", "1"], + "query": [ + { "key": "topic", "value": "university+enrollment" }, + { "key": "difficulty", "value": "B1" } + ] + } + } + }, + { + "name": "Generate Listening Exercises", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"text\": \"Dialog transcript here...\",\n \"exercises\": [\"form_completion\", \"multiple_choice\"],\n \"difficulty\": [\"B1\"]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/exam/listening/", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "listening", ""] + } + } + }, + { + "name": "Generate Writing Task", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/exam/writing/1?topic=urbanization&difficulty=B2", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "writing", "1"], + "query": [ + { "key": "topic", "value": "urbanization" }, + { "key": "difficulty", "value": "B2" } + ] + } + } + }, + { + "name": "Generate Speaking Task", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/exam/speaking/1?topic=hobbies&difficulty=B1", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "speaking", "1"], + "query": [ + { "key": "topic", "value": "hobbies" }, + { "key": "first_topic", "value": "", "disabled": true }, + { "key": "second_topic", "value": "", "disabled": true }, + { "key": "difficulty", "value": "B1" } + ] + } + } + }, + { + "name": "Generate Level Test", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"exercises\": [\"grammar\", \"vocabulary\"],\n \"difficulty\": [\"A2\", \"B1\", \"B2\"]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/exam/level/", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "level", ""] + } + } + }, + { + "name": "Writing Task with Image (Vision)", + "request": { + "method": "POST", + "body": { + "mode": "formdata", + "formdata": [ + { "key": "file", "type": "file", "src": "" }, + { "key": "difficulty", "value": "B2", "type": "text" } + ] + }, + "url": { + "raw": "{{baseUrl}}/api/exam/writing/1/attachment", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "writing", "1", "attachment"] + } + } + }, + { + "name": "Get Pre-built Level Exam", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/exam/level/", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "level", ""] + } + } + }, + { + "name": "Get UTAS Level Exam", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/exam/level/utas", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "level", "utas"] + } + } + }, + { + "name": "Import Level Exam", + "request": { + "method": "POST", + "body": { + "mode": "formdata", + "formdata": [ + { "key": "exercises", "type": "file", "src": "" } + ] + }, + "url": { + "raw": "{{baseUrl}}/api/exam/level/import/", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "level", "import", ""] + } + } + }, + { + "name": "Generate Custom Level Exam", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"spec\": {\n \"exercises\": [\"grammar\", \"vocabulary\", \"reading_comprehension\"],\n \"difficulty\": [\"B1\", \"B2\", \"C1\"]\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/exam/level/custom/", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "level", "custom", ""] + } + } + } + ] + }, + { + "name": "AI Media", + "item": [ + { + "name": "Generate Listening MP3", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"conversation\": [\n { \"speaker\": \"A\", \"text\": \"Hello, how can I help you?\" },\n { \"speaker\": \"B\", \"text\": \"I'd like to enroll in a course.\" }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/exam/listening/media", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "listening", "media"] + } + } + }, + { + "name": "Transcribe Audio to Dialog", + "request": { + "method": "POST", + "body": { + "mode": "formdata", + "formdata": [ + { "key": "audio", "type": "file", "src": "" } + ] + }, + "url": { + "raw": "{{baseUrl}}/api/exam/listening/transcribe", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "listening", "transcribe"] + } + } + }, + { + "name": "Generate Instructions MP3", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"text\": \"You will hear a conversation between two students. Listen and answer questions 1 to 5.\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/exam/listening/instructions", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "listening", "instructions"] + } + } + }, + { + "name": "Start Avatar Video", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"text\": \"Tell me about your favorite hobby and why you enjoy it.\",\n \"avatar\": \"avatar_code_here\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/exam/speaking/media", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "speaking", "media"] + } + } + }, + { + "name": "Poll Video Status", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/exam/speaking/media/vid_12345", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "speaking", "media", "vid_12345"] + } + } + }, + { + "name": "List Speaking Avatars", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/exam/speaking/avatars", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "speaking", "avatars"] + } + } + }, + { + "name": "Transcribe Audio (General)", + "request": { + "method": "POST", + "body": { + "mode": "formdata", + "formdata": [ + { "key": "audio", "type": "file", "src": "" } + ] + }, + "url": { + "raw": "{{baseUrl}}/api/transcribe", + "host": ["{{baseUrl}}"], + "path": ["api", "transcribe"] + } + } + } + ] + }, + { + "name": "Training", + "item": [ + { + "name": "Generate Training Plan", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"userID\": 5,\n \"stats\": [\n { \"module\": \"reading\", \"score\": 6.5 },\n { \"module\": \"writing\", \"score\": 5.0 }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/training", + "host": ["{{baseUrl}}"], + "path": ["api", "training"] + } + } + }, + { + "name": "Get Training by ID", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/training/1", + "host": ["{{baseUrl}}"], + "path": ["api", "training", "1"] + } + } + }, + { + "name": "Get Contextual Tips", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"context\": \"reading\",\n \"question\": \"How to improve skimming?\",\n \"answer\": \"Student attempted skimming but missed key details.\",\n \"correct_answer\": \"Focus on first and last sentences of each paragraph.\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/training/tips", + "host": ["{{baseUrl}}"], + "path": ["api", "training", "tips"] + } + } + }, + { + "name": "Get Walkthroughs", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/training/walkthrough", + "host": ["{{baseUrl}}"], + "path": ["api", "training", "walkthrough"] + } + } + } + ] + }, + { + "name": "Entities & Roles", + "item": [ + { + "name": "List Entities", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/entities?page=1&limit=20", + "host": ["{{baseUrl}}"], + "path": ["api", "entities"], + "query": [ + { "key": "page", "value": "1" }, + { "key": "limit", "value": "20" } + ] + } + } + }, + { + "name": "Create Entity", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Test University\",\n \"label\": \"TU\",\n \"licenses\": 50,\n \"expiryDate\": \"2027-01-01T00:00:00\",\n \"paymentCurrency\": \"USD\",\n \"paymentPrice\": 999.99\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/entities", + "host": ["{{baseUrl}}"], + "path": ["api", "entities"] + } + } + }, + { + "name": "Update Entity", + "request": { + "method": "PATCH", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Updated University\",\n \"licenses\": 100\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/entities/1", + "host": ["{{baseUrl}}"], + "path": ["api", "entities", "1"] + } + } + }, + { + "name": "Delete Entity", + "request": { + "method": "DELETE", + "url": { + "raw": "{{baseUrl}}/api/entities/1", + "host": ["{{baseUrl}}"], + "path": ["api", "entities", "1"] + } + } + }, + { + "name": "List Roles", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/roles", + "host": ["{{baseUrl}}"], + "path": ["api", "roles"], + "query": [ + { "key": "entityID", "value": "", "disabled": true } + ] + } + } + }, + { + "name": "Create Role", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Department Head\",\n \"entityId\": 1\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/roles", + "host": ["{{baseUrl}}"], + "path": ["api", "roles"] + } + } + }, + { + "name": "Update Role", + "request": { + "method": "PATCH", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Updated Role Name\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/roles/1", + "host": ["{{baseUrl}}"], + "path": ["api", "roles", "1"] + } + } + }, + { + "name": "Delete Role", + "request": { + "method": "DELETE", + "url": { + "raw": "{{baseUrl}}/api/roles/1", + "host": ["{{baseUrl}}"], + "path": ["api", "roles", "1"] + } + } + }, + { + "name": "List Permissions", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/permissions", + "host": ["{{baseUrl}}"], + "path": ["api", "permissions"] + } + } + } + ] + }, + { + "name": "Discounts", + "item": [ + { + "name": "List Discounts", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/discounts", + "host": ["{{baseUrl}}"], + "path": ["api", "discounts"] + } + } + }, + { + "name": "Create Discount", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"code\": \"SAVE20\",\n \"percentage\": 20,\n \"expiryDate\": \"2026-12-31\",\n \"maxUses\": 100\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/discounts", + "host": ["{{baseUrl}}"], + "path": ["api", "discounts"] + } + } + }, + { + "name": "Update Discount", + "request": { + "method": "PATCH", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"percentage\": 30,\n \"maxUses\": 200\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/discounts/1", + "host": ["{{baseUrl}}"], + "path": ["api", "discounts", "1"] + } + } + }, + { + "name": "Delete Discount", + "request": { + "method": "DELETE", + "url": { + "raw": "{{baseUrl}}/api/discounts/1", + "host": ["{{baseUrl}}"], + "path": ["api", "discounts", "1"] + } + } + } + ] + }, + { + "name": "Approval Workflows", + "item": [ + { + "name": "List Workflows", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/approval-workflows", + "host": ["{{baseUrl}}"], + "path": ["api", "approval-workflows"] + } + } + }, + { + "name": "Create Workflow", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"examId\": 1,\n \"status\": \"pending\",\n \"modules\": [\"reading\", \"writing\"],\n \"steps\": [{\"step\": 1, \"reviewer\": \"admin\"}]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/approval-workflows", + "host": ["{{baseUrl}}"], + "path": ["api", "approval-workflows"] + } + } + }, + { + "name": "Update Workflow", + "request": { + "method": "PATCH", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"status\": \"approved\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/approval-workflows/1", + "host": ["{{baseUrl}}"], + "path": ["api", "approval-workflows", "1"] + } + } + }, + { + "name": "Delete Workflow", + "request": { + "method": "DELETE", + "url": { + "raw": "{{baseUrl}}/api/approval-workflows/1", + "host": ["{{baseUrl}}"], + "path": ["api", "approval-workflows", "1"] + } + } + } + ] + }, + { + "name": "Subscriptions & Payments", + "item": [ + { + "name": "List Packages", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/packages", + "host": ["{{baseUrl}}"], + "path": ["api", "packages"], + "query": [ + { "key": "entityID", "value": "", "disabled": true } + ] + } + } + }, + { + "name": "Stripe Checkout", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"packageId\": 1,\n \"successUrl\": \"http://localhost:3000/success\",\n \"cancelUrl\": \"http://localhost:3000/cancel\",\n \"discountCode\": \"\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/stripe", + "host": ["{{baseUrl}}"], + "path": ["api", "stripe"] + } + } + }, + { + "name": "PayPal Create Order", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"packageId\": 1,\n \"discountCode\": \"\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/paypal", + "host": ["{{baseUrl}}"], + "path": ["api", "paypal"] + } + } + }, + { + "name": "PayPal Approve", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"orderId\": \"PAYPAL-ORDER-ID\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/paypal/approve", + "host": ["{{baseUrl}}"], + "path": ["api", "paypal", "approve"] + } + } + }, + { + "name": "Paymob Payment", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"packageId\": 1,\n \"discountCode\": \"\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/paymob", + "host": ["{{baseUrl}}"], + "path": ["api", "paymob"] + } + } + }, + { + "name": "Stripe Webhook", + "request": { + "auth": { "type": "noauth" }, + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Stripe-Signature", "value": "t=1234567890,v1=signature_here" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"type\": \"checkout.session.completed\",\n \"data\": {\n \"object\": {\n \"id\": \"cs_test_123\",\n \"client_reference_id\": \"5\",\n \"metadata\": { \"package_id\": \"1\", \"user_id\": \"5\" },\n \"amount_total\": 2999,\n \"currency\": \"usd\",\n \"payment_intent\": \"pi_test_123\"\n }\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/stripe/webhook", + "host": ["{{baseUrl}}"], + "path": ["api", "stripe", "webhook"] + } + } + }, + { + "name": "Paymob Webhook", + "request": { + "auth": { "type": "noauth" }, + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"obj\": {\n \"id\": 12345,\n \"success\": true,\n \"amount_cents\": 2999,\n \"currency\": \"EGP\",\n \"order\": { \"id\": 1, \"merchant_order_id\": \"encoach_5_1\" }\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/paymob/webhook?hmac=computed_hmac_here", + "host": ["{{baseUrl}}"], + "path": ["api", "paymob", "webhook"], + "query": [ + { "key": "hmac", "value": "computed_hmac_here" } + ] + } + } + } + ] + }, + { + "name": "Tickets", + "item": [ + { + "name": "List Tickets", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/tickets?page=1&limit=20", + "host": ["{{baseUrl}}"], + "path": ["api", "tickets"], + "query": [ + { "key": "status", "value": "", "disabled": true }, + { "key": "page", "value": "1" }, + { "key": "limit", "value": "20" } + ] + } + } + }, + { + "name": "Create Ticket", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"subject\": \"Cannot submit writing exam\",\n \"description\": \"When I click submit the page freezes and nothing happens.\",\n \"type\": \"bug\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/tickets", + "host": ["{{baseUrl}}"], + "path": ["api", "tickets"] + } + } + }, + { + "name": "Update Ticket", + "request": { + "method": "PATCH", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"status\": \"in_progress\",\n \"description\": \"Updated description with more details.\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/tickets/1", + "host": ["{{baseUrl}}"], + "path": ["api", "tickets", "1"] + } + } + }, + { + "name": "Delete Ticket", + "request": { + "method": "DELETE", + "url": { + "raw": "{{baseUrl}}/api/tickets/1", + "host": ["{{baseUrl}}"], + "path": ["api", "tickets", "1"] + } + } + } + ] + }, + { + "name": "Storage", + "item": [ + { + "name": "Upload File", + "request": { + "method": "POST", + "body": { + "mode": "formdata", + "formdata": [ + { "key": "file", "type": "file", "src": "" } + ] + }, + "url": { + "raw": "{{baseUrl}}/api/storage", + "host": ["{{baseUrl}}"], + "path": ["api", "storage"] + } + } + }, + { + "name": "Delete File", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"id\": 1\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/storage/delete", + "host": ["{{baseUrl}}"], + "path": ["api", "storage", "delete"] + } + } + } + ] + }, + { + "name": "Grading", + "item": [ + { + "name": "Grade Multiple Answers", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"text\": \"The passage about climate change...\",\n \"questions\": [\n { \"id\": \"q1\", \"text\": \"What is the main cause?\" },\n { \"id\": \"q2\", \"text\": \"Name two effects.\" }\n ],\n \"answers\": [\n { \"id\": \"q1\", \"value\": \"greenhouse gases\" },\n { \"id\": \"q2\", \"value\": \"flooding and droughts\" }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/grading/multiple", + "host": ["{{baseUrl}}"], + "path": ["api", "grading", "multiple"] + } + } + }, + { + "name": "Grade Summary", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sections\": [\n {\n \"module\": \"reading\",\n \"score\": { \"correct\": 30, \"total\": 40 }\n },\n {\n \"module\": \"listening\",\n \"score\": { \"correct\": 28, \"total\": 40 }\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/exam/grade/summary", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "grade", "summary"] + } + } + } + ] + } + ] +} diff --git a/ODOO_MIGRATION_SRS_v2.md b/ODOO_MIGRATION_SRS_v2.md new file mode 100644 index 0000000..ade8095 --- /dev/null +++ b/ODOO_MIGRATION_SRS_v2.md @@ -0,0 +1,1292 @@ +# EnCoach Platform -- Odoo 19 Full Migration SRS (v2) + +## Software Requirements Specification + +**Version:** 2.0 +**Date:** March 11, 2026 +**Status:** Draft +**Supersedes:** ODOO_MIGRATION_SRS.md (v1) +**Key change from v1:** The ielts-be (FastAPI) microservice is fully eliminated. All AI/ML functionality is absorbed into custom Odoo modules. + +--- + +## Table of Contents + +1. [Executive Summary](#1-executive-summary) +2. [Odoo Module Plan](#2-odoo-module-plan) +3. [Data Models](#3-data-models-odoo-models) +4. [REST API Specification](#4-rest-api-specification) +5. [Authentication & Authorization](#5-authentication--authorization) +6. [AI/ML Services Integration](#6-aiml-services-integration) +7. [Payment Integration](#7-payment-integration) +8. [Business Rules & Workflows](#8-business-rules--workflows) +9. [Data Migration Plan](#9-data-migration-plan) +10. [Non-Functional Requirements](#10-non-functional-requirements) + +--- + +## 1. Executive Summary + +### 1.1 Platform Overview + +EnCoach is an IELTS preparation and English proficiency testing platform serving students, teachers, corporate clients, and administrators. The platform provides: + +- Full IELTS exam simulation (Reading, Listening, Writing, Speaking, Level) +- AI-powered grading of writing and speaking responses (OpenAI GPT-4o) +- AI-powered exam content generation (OpenAI GPT-4o) +- AI-generated audio for listening exams (AWS Polly) +- AI-generated video for speaking exams (ELAI) +- Speech-to-text transcription (OpenAI Whisper) +- AI plagiarism/detection for writing (GPTZero) +- Personalized training recommendations (FAISS + sentence-transformers + GPT) +- Multi-tenant entity (organization) management +- Classroom and assignment management +- Subscription-based access with multiple payment providers +- Support ticket system + +### 1.2 Migration Scope + +**Everything moves to Odoo 19.** Both backend systems are replaced: + +1. **ielts-ui API routes** (Node.js, MongoDB, iron-session, Firebase Auth) -- 108 API route files, 22 MongoDB collections +2. **ielts-be** (Python, FastAPI, OpenAI, Whisper, AWS Polly, ELAI, GPTZero, FAISS) -- all AI/ML workloads + +**What stays unchanged:** + +- ielts-ui browser/frontend code (React components, Zustand stores, pages) +- Strapi CMS (encoachcms) +- Landing page (encoach-landing-page) + +### 1.3 Target Architecture + +``` +ielts-ui (Next.js 14) + └── Browser UI (unchanged) + │ + ▼ +Odoo 19 (Python + PostgreSQL) + ├── REST API Controllers (JSON) + ├── Business Logic (custom modules) + ├── AI/ML Modules + │ ├── OpenAI GPT-4o (grading + content generation) + │ ├── OpenAI Whisper (speech-to-text, local model) + │ ├── AWS Polly (text-to-speech for listening) + │ ├── ELAI (AI video for speaking) + │ ├── GPTZero (AI detection for writing) + │ └── FAISS + sentence-transformers (training search) + ├── PostgreSQL (all data) + ├── Odoo Attachments (file storage) + └── Payment Providers (Stripe, PayPal, Paymob) +``` + +There is no separate microservice. Odoo is the sole backend. + +--- + +## 2. Odoo Module Plan + +### 2.1 Standard Odoo Modules to Leverage + +| Odoo Module | Usage | +|-------------|-------| +| `base` / `res.users` / `res.partner` | User accounts, extended with EnCoach-specific fields | +| `payment` | Payment provider framework for Stripe, PayPal, Paymob | +| `product` | Subscription packages as products | +| `mail` | Email notifications (password reset, verification, invites) | +| `queue_job` (OCA) or `ir.cron` | Background task processing for async AI grading | + +### 2.2 Custom Modules to Develop + +| Module | Depends On | Complexity | Description | +|--------|------------|------------|-------------| +| `encoach_core` | `base`, `mail` | Medium | User type extensions, entity management, roles, permissions, codes, invites | +| `encoach_exam` | `encoach_core` | High | Exam models for 5 modules with complex nested exercise structures | +| `encoach_classroom` | `encoach_core` | Low | Group/classroom management with participant tracking | +| `encoach_assignment` | `encoach_core`, `encoach_exam` | Medium | Assignment lifecycle (create, start, release, archive) | +| `encoach_stats` | `encoach_core`, `encoach_exam` | Medium | Exam sessions, per-exercise statistics, score tracking | +| `encoach_evaluation` | `encoach_core`, `encoach_exam`, `encoach_ai` | Medium | Async grading records for writing/speaking | +| `encoach_training` | `encoach_core`, `encoach_ai` | Medium | Training content, walkthrough, FAISS semantic search | +| `encoach_subscription` | `encoach_core`, `product`, `payment` | Medium | Packages, discounts, subscription management, Stripe/PayPal/Paymob | +| `encoach_registration` | `encoach_core` | Medium | Multi-path registration (individual, corporate), codes, invites | +| `encoach_ticket` | `encoach_core` | Low | Support tickets | +| `encoach_ai` | `base` | **Very High** | Core AI services: OpenAI client, Whisper, AWS Polly, ELAI, GPTZero, FAISS | +| `encoach_ai_grading` | `encoach_ai`, `encoach_exam` | High | Writing/speaking grading logic, rubrics, prompt templates | +| `encoach_ai_generation` | `encoach_ai`, `encoach_exam` | High | Exam content generation for all 5 modules | +| `encoach_ai_media` | `encoach_ai` | High | Audio (Polly TTS), video (ELAI), transcription (Whisper) | +| `encoach_api` | All above | High | All REST JSON controllers for frontend consumption (~60 endpoints) | + +### 2.3 Module Dependency Graph + +``` +encoach_api + ├── encoach_core + │ ├── base / res.users / res.partner + │ └── mail + ├── encoach_exam + │ └── encoach_core + ├── encoach_classroom + │ └── encoach_core + ├── encoach_assignment + │ ├── encoach_core + │ └── encoach_exam + ├── encoach_stats + │ ├── encoach_core + │ └── encoach_exam + ├── encoach_evaluation + │ ├── encoach_core + │ ├── encoach_exam + │ └── encoach_ai + ├── encoach_training + │ ├── encoach_core + │ └── encoach_ai + ├── encoach_subscription + │ ├── encoach_core + │ ├── product + │ └── payment + ├── encoach_registration + │ └── encoach_core + ├── encoach_ticket + │ └── encoach_core + ├── encoach_ai (core AI services) + │ └── base + ├── encoach_ai_grading + │ ├── encoach_ai + │ └── encoach_exam + ├── encoach_ai_generation + │ ├── encoach_ai + │ └── encoach_exam + └── encoach_ai_media + └── encoach_ai +``` + +--- + +## 3. Data Models (Odoo Models) + +All models from v1 remain unchanged. This section adds the AI/ML-related models that were previously managed by ielts-be. + +> **Note:** For the full specification of the following models, refer to Section 3 of the v1 document (`ODOO_MIGRATION_SRS.md`). They are identical: +> `encoach.user`, `encoach.user.entity.rel`, `encoach.entity`, `encoach.role`, `encoach.group`, `encoach.exam`, `encoach.assignment`, `encoach.session`, `encoach.stat`, `encoach.evaluation`, `encoach.package`, `encoach.payment`, `encoach.subscription.payment`, `encoach.ticket`, `encoach.code`, `encoach.invite`, `encoach.permission`, `encoach.discount`, `encoach.walkthrough`, `encoach.approval.workflow` + +The following models are new or significantly expanded in v2: + +### 3.1 `encoach.training` (expanded) + +**Source:** MongoDB `training` collection (previously managed by ielts-be) + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `user_id` | Many2one(`res.users`) | Yes | Student | +| `created_at` | Datetime | Yes | Training generation date | +| `exams` | Text (JSON) | No | JSON array of exam performance summaries: `[{ "id", "date", "performance_comment", "detailed_summary" }]` | +| `tips` | Text (JSON) | No | JSON object with categorized tips from FAISS search | +| `weak_areas` | Text (JSON) | No | JSON array: `[{ "area": "...", "comment": "..." }]` | +| `legacy_id` | Char | No | Original MongoDB ID | + +### 3.2 `encoach.training.tip` (new) + +**Source:** ielts-be `pathways_2_rw_with_ids.json` tips data + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `tip_id` | Char | Yes | Original tip identifier | +| `category` | Selection | Yes | `ct_focus`, `language_for_writing`, `reading_skill`, `strategy`, `writing_skill`, `word_link`, `word_partners` | +| `content` | Text | Yes | Tip text content | +| `embedding` | Binary | No | Pre-computed embedding vector (float32 array) | + +### 3.3 `encoach.ai.job` (new) + +Tracks async AI jobs (grading, video generation). + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `job_type` | Selection | Yes | `writing_grading`, `speaking_grading`, `video_generation` | +| `status` | Selection | Yes | `pending`, `in_progress`, `completed`, `error`. Default: `pending` | +| `user_id` | Many2one(`res.users`) | No | Requesting user | +| `evaluation_id` | Many2one(`encoach.evaluation`) | No | Related evaluation record | +| `input_data` | Text (JSON) | No | Serialized input for the AI task | +| `result_data` | Text (JSON) | No | Serialized result from the AI task | +| `error_message` | Text | No | Error details if failed | +| `created_at` | Datetime | Yes | Job creation time | +| `completed_at` | Datetime | No | Job completion time | +| `retry_count` | Integer | No | Number of retries. Default: 0 | + +### 3.4 `encoach.elai.avatar` (new) + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | Char | Yes | Avatar display name | +| `avatar_code` | Char | Yes | ELAI avatar code | +| `avatar_url` | Char | No | Avatar preview image URL | +| `gender` | Selection | No | `male`, `female` | +| `canvas` | Char | No | ELAI canvas ID | +| `voice_id` | Char | No | ELAI voice ID | +| `voice_provider` | Char | No | Voice provider name | + +### 3.5 Exam `parts` JSON Structure + +Identical to v1. See v1 document Section 3.6.1 for the full JSON schema for Reading, Listening, Writing, Speaking, and Level exam parts. + +### 3.6 Evaluation `result` JSON Structure + +Identical to v1. See v1 document Section 3.10.1 for the writing and speaking grading result schemas. + +--- + +## 4. REST API Specification + +All endpoints from v1 remain unchanged in path and request/response format. The key difference is that endpoints previously marked as "proxy to ielts-be" are now handled directly by Odoo. + +> **Note:** For the full specification of all non-AI endpoints, refer to Section 4 of the v1 document. They are identical: Auth (4.1), Users (4.2), Registration/Codes (4.3), Entities (4.12), Groups (4.11), Roles (4.13), Permissions (4.14), Invites (4.7), Codes (4.8), Assignments (4.10), Sessions (4.11), Stats (4.12), Payments (4.15), Packages (4.16), Discounts (4.17), Tickets (4.18), Storage (4.19), Approval Workflows (4.21). + +The following endpoints were previously "proxy to ielts-be" and are now handled directly by Odoo: + +### 4.1 Exam Generation Endpoints + +#### `GET /api/exam/reading/{passage}` + +Generate a reading passage using AI. + +**Path params:** `passage` = `1` | `2` | `3` + +**Query params:** `topic` (optional), `word_count` (optional, default ~500) + +**Response (200):** +```json +{ + "title": "The Impact of Urbanization", + "text": "Full passage text..." +} +``` + +**Implementation:** Call OpenAI GPT-4o with passage-specific prompt (see Section 6.3.1). + +#### `POST /api/exam/reading/` + +Generate reading exercises from a passage. + +**Request:** +```json +{ + "text": "passage text", + "exercises": [ + { "type": "multipleChoice", "quantity": 5 }, + { "type": "trueFalse", "quantity": 4 }, + { "type": "fillBlanks", "quantity": 3, "num_random_words": 1, "max_words": 3 } + ], + "difficulty": ["B1", "B2"] +} +``` + +**Response (200):** `{ "exercises": [...] }` + +#### `GET /api/exam/listening/{section}` + +Generate a listening dialog/monologue. + +**Path params:** `section` = `1` | `2` | `3` | `4` + +**Query params:** `topic` (optional), `difficulty` (optional) + +**Response (200):** +```json +{ + "dialog": { + "conversation": [ + { "name": "Sarah", "gender": "female", "text": "Hello..." }, + { "name": "James", "gender": "male", "text": "Hi..." } + ] + } +} +``` + +Sections 1 and 3 return `conversation` (dialog). Sections 2 and 4 return `monologue`. + +#### `POST /api/exam/listening/media` + +Generate MP3 audio from a dialog/monologue using AWS Polly TTS. + +**Request:** +```json +{ + "conversation": [ + { "name": "Sarah", "gender": "female", "text": "Hello...", "voice": "Ruth" } + ] +} +``` + +Or for monologue: +```json +{ + "monologue": "Full monologue text..." +} +``` + +**Response:** MP3 binary data (`Content-Type: audio/mpeg`) + +**Implementation:** Call AWS Polly with neural engine (see Section 6.4). + +#### `POST /api/exam/listening/transcribe` + +Transcribe audio using Whisper. + +**Request:** Multipart form with `audio` file + +**Response (200):** Dialog object (conversation or monologue text) + +**Implementation:** Run Whisper model locally, then use GPT-4o to clean overlapping segments (see Section 6.5). + +#### `POST /api/exam/listening/instructions` + +Generate MP3 for listening instructions text. + +**Request:** `{ "text": "instructions text" }` + +**Response:** MP3 binary data + +#### `POST /api/exam/listening/` + +Generate listening exercises from a dialog. + +**Request:** +```json +{ + "text": "transcript text", + "exercises": [ + { "type": "multipleChoice", "quantity": 3 }, + { "type": "writeBlanksFill", "quantity": 5 } + ], + "difficulty": ["B1"] +} +``` + +**Response (200):** `{ "exercises": [...] }` + +#### `GET /api/exam/speaking/{task}` + +Generate a speaking task prompt. + +**Path params:** `task` = `1` | `2` | `3` + +**Query params:** `topic`, `first_topic`, `second_topic`, `difficulty` + +**Response (200):** Speaking part content (prompts array for Part 1/3, text block for Part 2) + +#### `POST /api/exam/speaking/media` + +Generate AI avatar video using ELAI. + +**Request:** +```json +{ + "text": "Question text to speak", + "avatar": "avatar_code" +} +``` + +**Response (200):** `{ "status": "STARTED", "result": null }` (async -- poll for completion) + +#### `GET /api/exam/speaking/media/{vid_id}` + +Poll for video generation status. + +**Response (200):** +```json +{ + "status": "COMPLETED", + "result": "https://elai-video-url..." +} +``` + +Possible statuses: `STARTED`, `IN_PROGRESS`, `COMPLETED`, `ERROR` + +#### `GET /api/exam/speaking/avatars` + +List available ELAI avatars. + +**Response (200):** `[ { "name": "...", "avatar_code": "...", "avatar_url": "...", "gender": "..." } ]` + +#### `GET /api/exam/writing/{task}` + +Generate a writing task prompt. + +**Path params:** `task` = `1` | `2` + +**Query params:** `topic`, `difficulty` + +**Response (200):** Writing task prompt text + +#### `POST /api/exam/writing/{task}/attachment` + +Generate an academic writing Task 1 with image attachment. + +**Request:** Multipart form with `file` (chart/image) and optional `difficulty` + +**Response (200):** Writing task prompt with image analysis + +#### `POST /api/exam/level/` + +Generate level test exercises. + +**Request:** +```json +{ + "exercises": [ + { "type": "multipleChoice", "quantity": 10, "difficulty": "B1" }, + { "type": "fillBlanks", "quantity": 5, "text_size": 200, "topic": "education" } + ], + "difficulty": ["A2", "B1", "B2"] +} +``` + +**Response (200):** Generated exercises + +#### `GET /api/exam/level/` + +Get a pre-built level exam. + +#### `GET /api/exam/level/utas` + +Get a UTAS-format level exam. + +#### `POST /api/exam/level/import/` + +Import a level exam from file. + +**Request:** Multipart form with `exercises` and optional `solutions` files + +**Response (200):** Parsed exam object + +#### `POST /api/exam/level/custom/` + +Generate a custom level exam from JSON specification. + +#### `POST /api/exam/reading/import` + +Import a reading exam from Word/Excel file. + +**Request:** Multipart form with `exercises` and optional `solutions` files + +**Response (200):** Parsed exam object + +#### `POST /api/exam/listening/import` + +Import a listening exam from file. + +### 4.2 Grading Endpoints + +#### `POST /api/evaluate/writing` + +Submit a writing answer for AI grading. + +**Request:** +```json +{ + "userId": 1, + "sessionId": 20, + "exerciseId": "uuid", + "question": "Write about...", + "answer": "Student's essay text...", + "task": 1, + "attachment": "optional-image-url" +} +``` + +**Response (200):** `{ "ok": true }` (grading happens asynchronously) + +**Implementation:** Creates `encoach.evaluation` and `encoach.ai.job`, then runs grading in a background thread (see Section 6.2). + +#### `POST /api/evaluate/speaking` + +Submit speaking audio for AI grading. + +**Request:** Multipart form with `userId`, `sessionId`, `exerciseId`, `task`, and audio files (`audio_1`, `audio_2`, etc.) with corresponding `question_N` fields. + +**Response (200):** `{ "ok": true }` (grading happens asynchronously) + +**Implementation:** Whisper transcribes each audio, then GPT-4o grades the transcript (see Section 6.2). + +#### `POST /api/evaluate/interactiveSpeaking` + +Submit interactive speaking (multiple Q&A pairs) for grading. + +**Request:** Same as speaking but with `question_N` and `audio_N` pairs. + +#### `GET /api/evaluate/{sessionId}/{exerciseId}` + +Poll for evaluation result. + +**Response (200):** +```json +{ + "status": "completed", + "result": { "...grading result..." } +} +``` + +#### `POST /api/grading/multiple` + +Grade multiple short-answer exercises. + +**Request:** +```json +{ + "text": "passage text", + "questions": ["Q1", "Q2"], + "answers": ["A1", "A2"] +} +``` + +**Response (200):** `{ "exercises": [{ "id": "...", "correct": true, "correct_answer": "..." }] }` + +**Implementation:** GPT-4o evaluates each answer against the passage. + +#### `POST /api/exam/grade/summary` + +Generate a grading summary for a full exam session. + +**Request:** +```json +{ + "sections": [ + { "code": "reading", "name": "Reading", "grade": 6.5 }, + { "code": "writing", "name": "Writing", "grade": 7.0 } + ] +} +``` + +**Response (200):** +```json +{ + "sections": [ + { + "code": "reading", + "name": "Reading", + "grade": 6.5, + "evaluation": "Detailed evaluation text...", + "suggestions": "Improvement suggestions...", + "bullet_points": ["Focus on skimming", "Practice time management"] + } + ] +} +``` + +### 4.3 Training Endpoints + +#### `POST /api/training` + +Generate personalized training content. + +**Request:** +```json +{ + "userID": 1, + "stats": [{ "exam_id": "...", "date": 1710000000, "performance_comment": "...", "detailed_summary": "..." }] +} +``` + +**Response (200):** `{ "id": "training-record-id" }` + +**Implementation:** Uses FAISS to find relevant tips, GPT to generate personalized recommendations (see Section 6.6). + +#### `POST /api/training/tips` + +Fetch contextual tips. + +**Request:** +```json +{ + "context": "reading passage about climate change", + "question": "What does the author suggest?", + "answer": "student's wrong answer", + "correct_answer": "the correct answer" +} +``` + +**Response (200):** `{ "tips": "Personalized tip text..." }` + +#### `POST /api/transcribe` + +Transcribe audio file. + +**Request:** Multipart form with audio file + +**Response (200):** Transcript text or dialog object + +#### `POST /api/batch_users` + +Bulk import users. + +**Request:** `{ "makerID": "admin-id", "users": [{ ...userDTO }] }` + +**Response (200):** `{ "ok": true }` + +**Implementation:** In v1 this was handled by ielts-be (which imported into Firebase Auth + MongoDB). In v2, Odoo creates users directly in `res.users`. No Firebase import needed. + +--- + +## 5. Authentication & Authorization + +Identical to v1 document Section 5. Summary: + +- **Recommended:** Odoo native auth with JWT tokens for API access +- **7 user roles** mapped to Odoo security groups with `ir.rule` record rules: student, teacher, corporate, admin, developer, agent, mastercorporate + +--- + +## 6. AI/ML Services Integration + +This is the new Section 6, replacing v1's "ielts-be Integration Specification." Odoo calls all external AI services directly. + +### 6.1 External Service Overview + +| Service | Purpose | API Type | Auth | Odoo Module | +|---------|---------|----------|------|-------------| +| **OpenAI GPT-4o** | Grading, content generation, text correction | REST API | API Key (`Authorization: Bearer`) | `encoach_ai` | +| **OpenAI GPT-3.5-turbo** | Secondary tasks (fixed text, perfect answers, summaries) | REST API | Same key | `encoach_ai` | +| **OpenAI Whisper** | Speech-to-text transcription | **Local model** (Python library) | N/A | `encoach_ai_media` | +| **AWS Polly** | Text-to-speech for listening exams | AWS SDK (boto3) | AWS Access Key + Secret | `encoach_ai_media` | +| **ELAI** | AI avatar video generation for speaking | REST API | Bearer token | `encoach_ai_media` | +| **GPTZero** | AI-generated text detection for writing | REST API | API Key (`x-api-key`) | `encoach_ai_grading` | +| **FAISS** | Semantic search over training tips | **Local library** (Python) | N/A | `encoach_training` | +| **sentence-transformers** | Embedding generation for FAISS | **Local model** (Python) | N/A | `encoach_training` | + +### 6.2 Background Task Architecture + +AI grading operations (writing and speaking) take 10-60 seconds. They must run asynchronously. + +**Recommended approach:** Use Python threading within Odoo or the OCA `queue_job` module. + +**Flow:** + +1. Frontend calls `POST /api/evaluate/writing` (or speaking) +2. Odoo controller creates `encoach.evaluation` record with `status = 'pending'` +3. Odoo controller creates `encoach.ai.job` record and launches a background thread +4. Controller returns `200 OK` immediately to the frontend +5. Background thread: + a. Calls OpenAI GPT-4o for grading (and GPTZero for AI detection in parallel) + b. Updates `encoach.evaluation` with `status = 'completed'` and `result` JSON + c. Updates `encoach.ai.job` with `status = 'completed'` +6. Frontend polls `GET /api/evaluate/{sessionId}/{exerciseId}` until status is `completed` + +**Threading pattern (Odoo-compatible):** + +```python +import threading +from odoo import api, SUPERUSER_ID + +def _run_grading_in_background(dbname, evaluation_id, input_data): + with api.Environment.manage(): + registry = odoo.registry(dbname) + with registry.cursor() as cr: + env = api.Environment(cr, SUPERUSER_ID, {}) + service = env['encoach.ai.grading'] + service.execute_grading(evaluation_id, input_data) + +# In the controller: +thread = threading.Thread( + target=_run_grading_in_background, + args=(request.env.cr.dbname, evaluation.id, input_data) +) +thread.start() +``` + +**Alternative:** Use OCA `queue_job` for production-grade async processing with retry, monitoring, and worker management. + +### 6.3 OpenAI Integration + +#### 6.3.1 Client Configuration + +Create a service class `EncoachOpenAIService` in `encoach_ai`: + +| Parameter | Value | +|-----------|-------| +| **Library** | `openai` (Python package) | +| **Client** | `AsyncOpenAI(api_key=api_key)` or synchronous `OpenAI` | +| **API Key** | Odoo system parameter `encoach.openai_api_key` | +| **Response format** | `response_format={"type": "json_object"}` | + +#### 6.3.2 Models Used + +| Task | Model | Temperature | +|------|-------|-------------| +| Writing grading | `gpt-4o` | 0.1 | +| Speaking grading | `gpt-4o` | 0.1 | +| Short answer grading | `gpt-4o` | 0.1 | +| Fixed text / perfect answer | `gpt-3.5-turbo` | 0.1 | +| Grading summary | `gpt-3.5-turbo` | 0.1 | +| Reading passage generation | `gpt-4o` | 0.7 | +| Listening dialog generation | `gpt-4o` | 0.7 | +| Writing task generation | `gpt-4o` | 0.7 | +| Speaking task generation | `gpt-4o` | 0.7 | +| Level exercise generation | `gpt-4o` | 0.7 | +| Training tips | `gpt-4o` | 0.2 | +| Whisper overlap cleanup | `gpt-4o` | 0.1 | + +#### 6.3.3 Writing Grading Prompts + +**System message:** + +`You are a helpful assistant designed to output JSON on this format: {template}` + +Where `{template}` is: + +```json +{ + "comment": "comment about student's response quality", + "overall": 0.0, + "task_response": { + "Task Achievement": { "grade": 0.0, "comment": "..." }, + "Coherence and Cohesion": { "grade": 0.0, "comment": "..." }, + "Lexical Resource": { "grade": 0.0, "comment": "..." }, + "Grammatical Range and Accuracy": { "grade": 0.0, "comment": "..." } + } +} +``` + +**User message (Task 2):** + +`Evaluate the given Writing Task {task} response based on the IELTS grading system, ensuring a strict assessment that penalizes errors. Deduct points for deviations from the task, and assign a score of 0 if the response fails to address the question. Additionally, provide a detailed commentary highlighting both strengths and weaknesses in the response.\nQuestion: "{question}"\nAnswer: "{answer}"` + +**User message (Task 1 General):** + +Same as Task 2 plus: `Refer to the parts of the letter as: "Greeting Opener", "bullet 1", "bullet 2", "bullet 3", "closer (restate the purpose of the letter)", "closing greeting"` + +**User message (Task 1 Academic with image):** + +The image is sent as base64 in the message content using the vision API. + +**Additional parallel tasks for writing grading:** + +| Task | Model | Prompt | +|------|-------|--------| +| Perfect answer | `gpt-3.5-turbo` | `Write a perfect answer for this IELTS writing task: "{question}"` | +| Fixed text | `gpt-3.5-turbo` | `Fix the grammatical and spelling errors in this text, keeping the original meaning: "{answer}"` | +| AI detection | GPTZero API | See Section 6.7 | + +#### 6.3.4 Speaking Grading Prompts + +**System message:** + +`You are a helpful assistant designed to output JSON on this format: {template}` + +Where `{template}` is: + +```json +{ + "comment": "extensive comment about answer quality", + "overall": 0.0, + "task_response": { + "Fluency and Coherence": { "grade": 0.0, "comment": "extensive comment..." }, + "Lexical Resource": { "grade": 0.0, "comment": "..." }, + "Grammatical Range and Accuracy": { "grade": 0.0, "comment": "..." }, + "Pronunciation": { "grade": 0.0, "comment": "..." } + } +} +``` + +**User message:** + +`Evaluate the given Speaking Part {task} response based on the IELTS grading system, ensuring a strict assessment that penalizes errors. Deduct points for deviations from the task, and assign a score of 0 if the response fails to address the question. Additionally, provide detailed commentary highlighting both strengths and weaknesses in the response.` + +Followed by question/answer pairs from transcription. + +**Task-specific instructions:** + +- Task 1: `Address the student as "you". If the answers are not 2 or 3 sentences long, warn the student that they should be.` +- Task 2: `Address the student as "you"` +- Task 3: `Address the student as "you" and pay special attention to coherence between the answers.` + +**Speaking grading flow:** + +1. Whisper transcribes each audio file +2. GPT-4o grades the full transcript against the rubric +3. GPT-3.5-turbo generates fixed text and perfect answer (for Task 2) +4. Results combined into the evaluation record + +#### 6.3.5 Reading Passage Generation Prompts + +**System message:** + +`You are a helpful assistant designed to output JSON on this format: {"title": "title of the text", "text": "generated text"}` + +**User message (Passage 1):** + +`Generate an extensive text for IELTS Reading Passage 1, of at least {word_count} words, on the topic of "{topic}". The passage should offer a substantial amount of information relevant to the chosen subject matter. It should be fairly easy and consist of multiple paragraphs. Make sure that the generated text does not contain forbidden subjects in muslim countries.` + +**User message (Passage 2):** + +Same structure but: `fairly hard and consist of multiple paragraphs` + +**User message (Passage 3):** + +Same structure but: `very hard, present different points or theories, cite different sources, and consist of multiple paragraphs` + +#### 6.3.6 Listening Dialog Generation Prompts + +**Section 1 (conversation, 2 people):** + +System: `{"conversation": [{"name": "name", "gender": "gender", "text": "text"}]}` + +User: `Compose an authentic conversation between two individuals on the topic of "{topic}". Please include random names and genders. Include misleading discourse (dates, colors, etc.) and spelling of names. Make sure that the generated conversation does not contain forbidden subjects in muslim countries.` + +**Section 2 (monologue, social):** + +System: `{"monologue": "monologue"}` + +User: `Generate a comprehensive monologue set in the social context of "{topic}". Make sure that the generated monologue does not contain forbidden subjects in muslim countries.` + +**Section 3 (conversation, up to 4 people):** + +User: `Compose an authentic and elaborate conversation between up to four individuals on the topic of "{topic}". Please include random names and genders. Make sure that the generated conversation does not contain forbidden subjects in muslim countries.` + +**Section 4 (monologue, academic):** + +User: `Generate a comprehensive and complex monologue on the academic subject of "{topic}". Make sure that the generated monologue does not contain forbidden subjects in muslim countries.` + +#### 6.3.7 Writing Task Generation Prompts + +**General Task 1:** + +`Craft a prompt for an IELTS Writing Task 1 General Training exercise that instructs the student to compose a letter based on the topic of "{topic}" of {difficulty} CEFR level difficulty. The prompt should end with "In the letter you should" followed by 3 bullet points. Make sure it does not contain forbidden subjects in muslim countries.` + +**General Task 2:** + +`Craft a comprehensive question of {difficulty} CEFR level difficulty like the ones for IELTS Writing Task 2 General Training that directs the candidate to delve into an in-depth analysis of contrasting perspectives on the topic of "{topic}". The question should lead to an answer with either "theories", "complicated information" or be "very descriptive" on the topic.` + +**Academic Task 1 (with image):** + +`Analyze the uploaded image and create a detailed IELTS Writing Task 1 Academic prompt. Describe the visual type, context, and create a prompt at {difficulty} CEFR level.` + +#### 6.3.8 Speaking Task Generation Prompts + +**Part 1:** + +`Craft 5 simple and single questions of easy difficulty for IELTS Speaking Part 1 that encourages candidates to delve deeply into personal experiences, preferences, or insights on the topic of "{first_topic}" and the topic of "{second_topic}". The questions should lead to the usage of 4 verb tenses (present perfect, present, past and future). Make sure that the generated question does not contain forbidden subjects in muslim countries.` + +**Part 2:** + +`Create a question of medium difficulty for IELTS Speaking Part 2 that encourages candidates to narrate a personal experience or story related to the topic of "{topic}". Include 3 prompts that guide the candidate. The prompts must not be questions. Also include a suffix like the ones in the IELTS exams that start with "And explain why". Make sure that the generated question does not contain forbidden subjects in muslim countries.` + +**Part 3:** + +`Formulate a set of 5 single questions of hard difficulty for IELTS Speaking Part 3 that encourage candidates to engage in a meaningful discussion on the topic of "{topic}". They must be 1 single question each and not be double-barreled questions. Make sure that the generated question does not contain forbidden subjects in muslim countries.` + +#### 6.3.9 Level Exercise Generation Prompts + +**Multiple Choice:** + +`Generate {quantity} multiple choice questions of 4 options for an english level exam of {difficulty} CEFR level. Ensure that the questions cover a range of topics such as verb tense, subject-verb agreement, pronoun usage, sentence structure, and punctuation. Make sure every question only has 1 correct answer.` + +**Fill Blanks:** + +`Generate a text of at least {size} words about the topic {topic}. From the generated text choose exactly {quantity} words (cannot be sequential words), replace each with {{id}}, and generate a JSON object containing: the modified text, solutions array, words array with four options per blank.` + +#### 6.3.10 Retry and Validation Logic + +- Retry up to 2 times if response contains blacklisted words or missing required fields +- Blacklisted words include topics related to religion, politics, explicit content, and culturally sensitive subjects (stored in constants) +- Token limit: `4097 - input_token_count - 300` for max_tokens +- All responses expected in JSON format via `response_format={"type": "json_object"}` + +### 6.4 AWS Polly Integration (TTS) + +Implemented in `encoach_ai_media` module. + +| Parameter | Value | +|-----------|-------| +| **Library** | `boto3` / `aioboto3` | +| **Service** | `polly` | +| **Engine** | `neural` | +| **Output format** | `mp3` | +| **Auth** | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` (Odoo system parameters) | + +**Available voices:** + +| Voice | Gender | Accent | +|-------|--------|--------| +| Danielle | Female | US | +| Gregory | Male | US | +| Kevin | Male | US | +| Ruth | Female | US | +| Stephen | Male | US | +| Arthur | Male | GB | +| Olivia | Female | GB | +| Ayanda | Female | ZA | +| Aria | Female | NZ | +| Kajal | Female | IN | +| Niamh | Female | IE | + +**Conversation audio generation:** + +1. For each dialog line, call Polly `synthesize_speech()` with the assigned voice +2. Chunk text at sentence boundaries if > 3000 characters +3. Concatenate all MP3 segments +4. Append final message: `"This audio recording, for the listening exercise, has finished."` (voice: Stephen) + +**Monologue audio generation:** + +1. Select a random voice +2. Chunk text at sentence boundaries if > 3000 characters +3. Synthesize each chunk and concatenate + +### 6.5 Whisper Integration (STT) + +Implemented in `encoach_ai_media` module. + +| Parameter | Value | +|-----------|-------| +| **Library** | `openai-whisper` (local Python package) | +| **Model size** | `base` (~1 GB, or configurable) | +| **Model instances** | 4 (for parallel transcription) | +| **Thread pool** | `ThreadPoolExecutor(max_workers=4)` | +| **Audio resampling** | 16 kHz, normalized | +| **Chunk size** | 30 seconds (480,000 samples) with 1/4 overlap | +| **Options** | `fp16=False`, `language='English'`, `verbose=False` | +| **Retries** | 3 attempts via tenacity | + +**Long audio handling:** + +1. Split audio into 30-second chunks with 25% overlap +2. Transcribe each chunk independently +3. Use GPT-4o to remove duplicated words at chunk boundaries +4. Join cleaned segments into final transcript + +**Audio-to-dialog conversion:** + +After transcription, use GPT-4o to determine whether the transcript is a conversation or monologue and output structured JSON: + +``` +You are a helpful assistant designed to output JSON on either one of these formats: +1 - {"dialog": [{"name": "name", "gender": "gender", "text": "text"}]} +2 - {"dialog": "text"} + +A transcription of an audio file will be provided to you. Based on that transcription you will need to determine whether the transcription is a conversation or a monologue. If it is a conversation, output format 1. If it is a monologue, output format 2. +``` + +### 6.6 ELAI Integration (AI Video) + +Implemented in `encoach_ai_media` module. + +| Parameter | Value | +|-----------|-------| +| **Base URL** | `https://apis.elai.io/api/v1/videos` | +| **Auth** | Bearer token (Odoo system parameter `encoach.elai_token`) | + +**Video generation flow:** + +1. `POST /api/v1/videos` -- Create video with avatar, text, voice +2. `POST /api/v1/videos/render/{video_id}` -- Start rendering +3. Poll `GET /api/v1/videos/{video_id}` -- Check status (`ready`, `failed`, or in progress) +4. Return video URL when `ready` + +**Avatar configuration:** Stored in `encoach.elai.avatar` model (avatar codes, voice IDs, voice providers). + +### 6.7 GPTZero Integration (AI Detection) + +Implemented in `encoach_ai_grading` module. + +| Parameter | Value | +|-----------|-------| +| **Endpoint** | `https://api.gptzero.me/v2/predict/text` | +| **Auth** | `x-api-key` header (Odoo system parameter `encoach.gptzero_api_key`) | + +**Request:** +```json +{ + "document": "student's writing text", + "version": "", + "multilingual": false +} +``` + +**Response fields used:** +- `class_probabilities` -- probability scores for human/AI/mixed +- `predicted_class` -- `human`, `ai`, or `mixed` +- `sentences[].highlight_sentence_for_ai` -- boolean per sentence + +**Result stored in evaluation:** `"ai_detection": { "probability": 0.12, "predicted_class": "human" }` + +### 6.8 FAISS + Sentence-Transformers (Training) + +Implemented in `encoach_training` module. + +| Parameter | Value | +|-----------|-------| +| **Embeddings model** | `sentence-transformers/all-MiniLM-L6-v2` | +| **Index type** | `faiss.IndexFlatL2` (one per category) | +| **Top-K results** | 5 | + +**Categories:** + +| Category | Description | +|----------|-------------| +| `ct_focus` | Critical thinking focus tips | +| `language_for_writing` | Language for writing tips | +| `reading_skill` | Reading skills tips | +| `strategy` | Test strategy tips | +| `writing_skill` | Writing skills tips | +| `word_link` | Word linking tips | +| `word_partners` | Word partner/collocation tips | + +**Index files:** Store as Odoo attachments or on disk: +- `{category}_tips_index.faiss` -- FAISS index file per category +- `tips_metadata.pkl` -- Metadata mapping index positions to tip content + +**Query flow:** + +1. Encode user query using sentence-transformers: `embedding = model.encode([query])` +2. Search FAISS index: `distances, indices = index.search(embedding, top_k=5)` +3. Retrieve tip content from metadata +4. Pass tips + user performance data to GPT for personalized recommendations + +**Training content generation flow:** + +1. Receive user's exam stats (performance comments, detailed summaries) +2. Use GPT to identify weak areas and generate training queries +3. For each query, search FAISS index to find relevant tips +4. Use GPT to generate personalized recommendations based on tips + performance +5. Store result in `encoach.training` + +### 6.9 Topic and Content Constants + +Maintain in `encoach_ai` module as constants or configuration records: + +**Topics for content generation:** + +- `TOPICS` -- General IELTS topics (education, technology, environment, health, etc.) +- `TWO_PEOPLE_SCENARIOS` -- Listening Section 1 scenarios +- `SOCIAL_MONOLOGUE_CONTEXTS` -- Listening Section 2 contexts +- `FOUR_PEOPLE_SCENARIOS` -- Listening Section 3 scenarios +- `ACADEMIC_SUBJECTS` -- Listening Section 4 subjects + +**Difficulty levels:** `["A1", "A2", "B1", "B2", "C1", "C2"]` + +**Blacklisted words:** List of culturally sensitive terms that trigger retry if found in AI-generated content (religion, politics, explicit content, etc.) + +### 6.10 Python Dependencies + +Add to the Odoo server's Python environment: + +| Package | Version | Purpose | +|---------|---------|---------| +| `openai` | >= 1.50 | OpenAI API client (GPT-4o, GPT-3.5) | +| `openai-whisper` | latest | Local Whisper model for STT | +| `boto3` | >= 1.34 | AWS SDK for Polly TTS | +| `faiss-cpu` | >= 1.7 | FAISS vector search | +| `sentence-transformers` | >= 3.0 | Embeddings for FAISS | +| `httpx` | >= 0.27 | HTTP client for ELAI, GPTZero | +| `tiktoken` | >= 0.7 | Token counting for OpenAI | +| `librosa` | >= 0.10 | Audio processing for Whisper | +| `soundfile` | >= 0.12 | Audio file I/O | +| `numpy` | >= 1.26 | Numerical operations | +| `tenacity` | >= 8.2 | Retry logic for API calls | +| `torch` | >= 2.0 | Required by Whisper and sentence-transformers | + +**Server requirements for Whisper:** The Odoo server needs at least 4 GB RAM for the Whisper base model and sentence-transformers model. Consider running Whisper on a GPU for better performance, or use the OpenAI Whisper API instead of local inference. + +### 6.11 Configuration (Odoo System Parameters) + +| Parameter Key | Description | Example | +|--------------|-------------|---------| +| `encoach.openai_api_key` | OpenAI API key | `sk-...` | +| `encoach.aws_access_key_id` | AWS access key | `AKIA...` | +| `encoach.aws_secret_access_key` | AWS secret key | `wJal...` | +| `encoach.elai_token` | ELAI API token | `Bearer ...` | +| `encoach.gptzero_api_key` | GPTZero API key | `...` | +| `encoach.whisper_model_size` | Whisper model size | `base` (or `small`, `medium`, `large`) | +| `encoach.whisper_workers` | Number of Whisper worker threads | `4` | +| `encoach.grading_temperature` | Temperature for grading prompts | `0.1` | +| `encoach.generation_temperature` | Temperature for content generation | `0.7` | +| `encoach.tips_temperature` | Temperature for training tips | `0.2` | + +--- + +## 7. Payment Integration + +Identical to v1 document Section 7. Summary: + +- **Stripe:** Checkout session creation, webhook handling, subscription extension +- **PayPal:** Order creation, capture, subscription update +- **Paymob:** Intention creation, transaction webhook verification +- Subscription logic: extend `subscriptionExpirationDate` on successful payment; propagate to entity members for corporate + +--- + +## 8. Business Rules & Workflows + +Identical to v1 document Section 8, with one addition: + +### 8.1-8.7 (Unchanged from v1) + +See v1 for: Registration flow, Subscription management, Entity licensing, Assignment lifecycle, Grading polling, Corporate payment activation, User change propagation. + +### 8.8 AI Content Moderation (New) + +All AI-generated content must be validated before being returned to the user: + +1. **Blacklist check:** Scan generated text against the blacklisted words list +2. **Retry logic:** If blacklisted words are found, regenerate with the same prompt (up to 2 retries) +3. **JSON validation:** Verify the AI response is valid JSON with all required fields +4. **Score validation:** For grading, verify all scores are between 0.0 and 9.0 +5. **Content length:** For passages, verify minimum word count is met + +### 8.9 Batch User Import (Updated) + +In v1, batch import was proxied to ielts-be (which imported into Firebase Auth). In v2: + +1. Admin uploads CSV/Excel with user data +2. Odoo parses the file and creates `res.users` records directly +3. Generates temporary passwords and sends welcome emails +4. Creates registration codes and group assignments +5. No Firebase Auth involvement + +--- + +## 9. Data Migration Plan + +### 9.1 Migration Strategy + +Perform a one-time data migration from both data sources: + +1. **MongoDB** (used by ielts-ui) -> PostgreSQL +2. **MongoDB** (used by ielts-be) -> PostgreSQL (evaluation records, training data) +3. **Firebase Auth** users -> Odoo `res.users` + +### 9.2 Collection-to-Model Mapping + +Identical to v1 Section 9.2, with additional ielts-be collections: + +| ielts-be MongoDB Collection | Odoo Model | Notes | +|----------------------------|------------|-------| +| `evaluation` | `encoach.evaluation` | Grading records with result JSON | +| `training` | `encoach.training` | Training content | + +### 9.3 Additional Migration: AI Assets + +| Asset | Source | Target | +|-------|--------|--------| +| FAISS index files | ielts-be `./faiss/` directory | Odoo server filesystem or `ir.attachment` | +| Tips metadata | `tips_metadata.pkl` | `encoach.training.tip` model records | +| Tips source data | `pathways_2_rw_with_ids.json` | `encoach.training.tip` model records | +| Avatar config | `conf.json`, `avatars.json` | `encoach.elai.avatar` model records | +| Whisper model | Downloaded at runtime | Odoo server filesystem | + +### 9.4 Migration Script Requirements & Import Order + +Identical to v1 Section 9.3 and 9.4, with the addition of: + +22. `encoach.training.tip` (from tips JSON) +23. `encoach.elai.avatar` (from avatar config JSON) +24. FAISS index rebuild (re-embed tips into FAISS after import) + +--- + +## 10. Non-Functional Requirements + +### 10.1-10.6 (Unchanged from v1) + +See v1 for: API response format, CORS, file storage, performance targets, scalability, security. + +### 10.7 AI/ML Performance Requirements (New) + +| Operation | Target Response Time | Notes | +|-----------|---------------------|-------| +| Reading passage generation | < 15s | Single GPT-4o call | +| Listening dialog generation | < 15s | Single GPT-4o call | +| Listening MP3 generation | < 30s | Multiple Polly calls + concatenation | +| Writing task generation | < 10s | Single GPT-4o call | +| Speaking task generation | < 10s | Single GPT-4o call | +| Level exercise generation | < 20s | Multiple GPT-4o calls | +| Writing grading (async) | < 30s total | Parallel: GPT-4o grading + GPTZero + perfect answer + fixed text | +| Speaking grading (async) | < 60s total | Sequential: Whisper transcription + GPT-4o grading | +| Short answer grading | < 10s | Single GPT-4o call | +| Transcription (1 min audio) | < 15s | Local Whisper | +| FAISS query | < 1s | Local computation | +| Video generation (async) | 1-5 min | ELAI rendering; poll for completion | + +### 10.8 AI/ML Scalability (New) + +- Whisper model requires ~1 GB RAM per instance (4 instances = 4 GB) +- sentence-transformers model requires ~500 MB RAM +- FAISS indices are small (< 100 MB total) +- OpenAI API has rate limits -- implement request queuing if needed +- AWS Polly has a 3000-character limit per request -- chunking is already handled +- Consider GPU acceleration for Whisper if transcription volume is high + +### 10.9 AI/ML Monitoring (New) + +- Log all OpenAI API calls with model, token usage, response time, and cost +- Log all Polly synthesis calls with character count +- Log all ELAI video generation requests with status and duration +- Log all Whisper transcription jobs with audio duration and processing time +- Alert on repeated grading failures (> 3 consecutive errors) +- Track OpenAI API spend against budget thresholds + +### 10.10 Logging, Testing (Unchanged from v1) + +See v1 Sections 10.7 and 10.8. + +--- + +## Appendix A: Exercise Type Reference + +Identical to v1 Appendix A. + +## Appendix B: Grading Rubric Details + +Identical to v1 Appendix B. + +## Appendix C: Environment Variables + +Updated to include all AI/ML service credentials: + +| Variable | Description | Example | +|----------|-------------|---------| +| `ENCOACH_JWT_SECRET` | JWT signing secret | Random 256-bit string | +| `OPENAI_API_KEY` | OpenAI API key | `sk-...` | +| `AWS_ACCESS_KEY_ID` | AWS access key for Polly | `AKIA...` | +| `AWS_SECRET_ACCESS_KEY` | AWS secret key for Polly | `wJal...` | +| `ELAI_TOKEN` | ELAI API bearer token | `...` | +| `GPT_ZERO_API_KEY` | GPTZero API key | `...` | +| `WHISPER_MODEL_SIZE` | Whisper model size | `base` | +| `STRIPE_SECRET_KEY` | Stripe secret key | `sk_live_...` | +| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret | `whsec_...` | +| `PAYPAL_CLIENT_ID` | PayPal client ID | `AX...` | +| `PAYPAL_CLIENT_SECRET` | PayPal client secret | `EL...` | +| `PAYPAL_ACCESS_TOKEN_URL` | PayPal OAuth URL | `https://api.paypal.com/v1/oauth2/token` | +| `PAYMOB_API_KEY` | Paymob API key | `ZXlK...` | +| `PAYMOB_SECRET` | Paymob webhook secret | `...` | + +## Appendix D: Glossary + +Identical to v1 Appendix D, with additions: + +| Term | Definition | +|------|-----------| +| **Whisper** | OpenAI's speech-to-text model, run locally on the Odoo server | +| **Polly** | AWS text-to-speech service for generating listening exam audio | +| **ELAI** | AI video generation service for creating speaking exam avatar videos | +| **GPTZero** | AI text detection service for identifying AI-generated writing submissions | +| **FAISS** | Facebook AI Similarity Search -- vector search library for finding relevant training tips | +| **sentence-transformers** | Python library for generating text embeddings used with FAISS | + +## Appendix E: Custom Odoo Module Summary + +| Module | Complexity | Key Models | Key Services | External APIs | +|--------|------------|------------|-------------- |---------------| +| `encoach_core` | Medium | User extensions, Entity, Role, Permission, Code, Invite | Registration, entity management | -- | +| `encoach_exam` | High | Exam (5 modules), Exercise structures | Exam CRUD, import/export | -- | +| `encoach_classroom` | Low | Group | Group management | -- | +| `encoach_assignment` | Medium | Assignment | Assignment lifecycle | -- | +| `encoach_stats` | Medium | Session, Stat | Stats tracking, PDF export | -- | +| `encoach_evaluation` | Medium | Evaluation, AI Job | Async grading coordination | -- | +| `encoach_training` | Medium | Training, Training Tip | FAISS search, tip generation | -- | +| `encoach_subscription` | Medium | Package, Payment, Subscription Payment, Discount | Subscription management | Stripe, PayPal, Paymob | +| `encoach_registration` | Medium | Code, Invite | Multi-path registration | -- | +| `encoach_ticket` | Low | Ticket | Ticket CRUD | -- | +| `encoach_ai` | **Very High** | -- | OpenAI client, prompt management, retry logic | OpenAI API | +| `encoach_ai_grading` | High | -- | Writing grading, speaking grading, short answer grading | OpenAI, GPTZero | +| `encoach_ai_generation` | High | -- | Reading/listening/writing/speaking/level generation | OpenAI | +| `encoach_ai_media` | High | ELAI Avatar | Polly TTS, Whisper STT, ELAI video | AWS Polly, ELAI, Whisper (local) | +| `encoach_api` | High | -- | REST JSON controllers (~60 endpoints) | -- | diff --git a/README.md b/README.md new file mode 100644 index 0000000..ae2299f --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Extra / custom addons + +Put your custom or third-party Odoo modules here (each as a subfolder with `__manifest__.py`). + +This path is loaded first, so your modules override Community modules with the same name. diff --git a/encoach_ai/__init__.py b/encoach_ai/__init__.py new file mode 100644 index 0000000..71a0242 --- /dev/null +++ b/encoach_ai/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import services diff --git a/encoach_ai/__manifest__.py b/encoach_ai/__manifest__.py new file mode 100644 index 0000000..d4a0d67 --- /dev/null +++ b/encoach_ai/__manifest__.py @@ -0,0 +1,16 @@ +{ + "name": "EnCoach AI", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "Core AI services for the EnCoach IELTS platform", + "description": "OpenAI integration, constants, blacklist validation, token counting.", + "depends": ["encoach_core"], + "data": [ + "security/ir.model.access.csv", + ], + "installable": True, + "license": "LGPL-3", + "external_dependencies": { + "python": ["openai", "tiktoken", "httpx", "tenacity", "numpy"], + }, +} diff --git a/encoach_ai/models/__init__.py b/encoach_ai/models/__init__.py new file mode 100644 index 0000000..87030a7 --- /dev/null +++ b/encoach_ai/models/__init__.py @@ -0,0 +1 @@ +from . import constants diff --git a/encoach_ai/models/constants.py b/encoach_ai/models/constants.py new file mode 100644 index 0000000..3a5ccb9 --- /dev/null +++ b/encoach_ai/models/constants.py @@ -0,0 +1,59 @@ +BLACKLISTED_WORDS = [ + "alcohol", "atheism", "beer", "bible", "blasphemy", + "buddha", "buddhism", "cannabis", "casino", "christ", + "christianity", "church", "cocaine", "communist", "condom", + "corruption", "crucifixion", "cult", "drug", "drugs", + "erotic", "extremism", "gambling", "gay", "god", + "gods", "gospel", "haram", "heresy", "heroin", + "hindu", "hinduism", "homosexual", "idol", "infidel", + "islam", "islamic", "israeli", "jesus", "jew", + "jewish", "jihad", "judaism", "lesbian", "liquor", + "marijuana", "mosque", "muhammad", "muslim", "naked", + "nazi", "nude", "pagan", "pig", "pork", + "porn", "pornography", "prayer", "priest", "prophet", + "prostitution", "quran", "rabbi", "rape", "religion", + "religious", "satan", "sex", "sexual", "sharia", + "shinto", "sikh", "strip", "suicide", "synagogue", + "temple", "terrorism", "terrorist", "torah", "vodka", + "whiskey", "wine", "worship", "zionism", +] + +TOPICS = [ + "Technology", "Education", "Health", "Environment", "Travel", + "Food and Nutrition", "Sports", "Music", "Art", "Science", + "History", "Geography", "Business", "Economy", "Media", + "Communication", "Fashion", "Architecture", "Transportation", "Energy", + "Water Resources", "Agriculture", "Space Exploration", "Wildlife", + "Climate Change", "Urbanization", "Rural Life", "Population Growth", + "Immigration", "Cultural Diversity", "Globalization", "Tourism", + "Employment", "Workplace Culture", "Leadership", "Innovation", + "Artificial Intelligence", "Social Media", "Advertising", "Cinema", + "Literature", "Languages", "Volunteering", "Charity", + "Mental Health", "Fitness", "Aging Population", "Childhood Development", + "Higher Education", "Online Learning", "Public Libraries", + "Museums", "Public Transport", "Cycling", "Renewable Energy", + "Recycling", "Waste Management", "Deforestation", "Ocean Conservation", + "Animal Rights", "Genetic Engineering", "Robotics", "Cybersecurity", + "Privacy", "Freedom of Speech", "Democracy", "Law Enforcement", + "Crime Prevention", "Housing", "Homelessness", "Poverty", + "Income Inequality", "Consumer Culture", "Minimalism", "Traditions", + "Festivals", "Cooking", "Gardening", "Photography", + "Podcasts", "Board Games", "Pet Ownership", "Astronomy", + "Archaeology", "Philosophy", "Psychology", "Sociology", + "Ecology", "Marine Biology", "Meteorology", "Geology", + "Time Management", "Stress Management", "Work-Life Balance", +] + +CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"] + +GPT_MODELS = { + "grading": "gpt-4o", + "generation": "gpt-4o", + "secondary": "gpt-3.5-turbo", +} + +TEMPERATURE = { + "grading": 0.1, + "generation": 0.7, + "tips": 0.2, +} diff --git a/encoach_ai/security/ir.model.access.csv b/encoach_ai/security/ir.model.access.csv new file mode 100644 index 0000000..97dd8b9 --- /dev/null +++ b/encoach_ai/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/encoach_ai/services/__init__.py b/encoach_ai/services/__init__.py new file mode 100644 index 0000000..84c1031 --- /dev/null +++ b/encoach_ai/services/__init__.py @@ -0,0 +1 @@ +from . import openai_service diff --git a/encoach_ai/services/openai_service.py b/encoach_ai/services/openai_service.py new file mode 100644 index 0000000..9c021c2 --- /dev/null +++ b/encoach_ai/services/openai_service.py @@ -0,0 +1,103 @@ +import json +import logging + +import tiktoken +from openai import OpenAI +from tenacity import retry, stop_after_attempt, wait_exponential + +from ..models.constants import BLACKLISTED_WORDS + +_logger = logging.getLogger(__name__) + +TOKEN_RESERVE = 300 +MODEL_TOKEN_LIMIT = 4097 + + +class EncoachOpenAIService: + + def __init__(self, env): + self.env = env + api_key = ( + env["ir.config_parameter"] + .sudo() + .get_param("encoach.openai_api_key", "") + ) + if not api_key: + _logger.warning("OpenAI API key not configured (encoach.openai_api_key)") + self.client = OpenAI(api_key=api_key) + + def prediction( + self, + model, + messages, + temperature=0.7, + response_format=None, + fields_to_check=None, + check_blacklisted=True, + max_retries=2, + ): + """Call OpenAI chat completion with validation and blacklist filtering. + + Returns parsed JSON dict on success or None on failure. + """ + if response_format is None: + response_format = {"type": "json_object"} + + input_tokens = self._count_tokens( + " ".join(m.get("content", "") for m in messages if isinstance(m.get("content"), str)), + model, + ) + max_tokens = max(MODEL_TOKEN_LIMIT - input_tokens - TOKEN_RESERVE, 256) + + attempt = 0 + while attempt <= max_retries: + try: + resp = self.client.chat.completions.create( + model=model, + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + response_format=response_format, + ) + content = resp.choices[0].message.content + data = json.loads(content) + + if check_blacklisted: + text_to_check = content + if fields_to_check and isinstance(data, dict): + text_to_check = " ".join( + str(data.get(f, "")) for f in fields_to_check + ) + if self._check_blacklisted(text_to_check): + _logger.info( + "Blacklisted content detected (attempt %d/%d)", + attempt + 1, + max_retries + 1, + ) + attempt += 1 + continue + + return data + + except json.JSONDecodeError: + _logger.warning("Invalid JSON from OpenAI (attempt %d)", attempt + 1) + attempt += 1 + except Exception: + _logger.exception("OpenAI API error (attempt %d)", attempt + 1) + attempt += 1 + + _logger.error("OpenAI prediction failed after %d attempts", max_retries + 1) + return None + + @staticmethod + def _check_blacklisted(text): + lower = text.lower() + return any(word in lower for word in BLACKLISTED_WORDS) + + @staticmethod + def _count_tokens(text, model="gpt-4o"): + try: + encoding = tiktoken.encoding_for_model(model) + except KeyError: + encoding = tiktoken.get_encoding("cl100k_base") + return len(encoding.encode(text)) diff --git a/encoach_ai_generation/__init__.py b/encoach_ai_generation/__init__.py new file mode 100644 index 0000000..99464a7 --- /dev/null +++ b/encoach_ai_generation/__init__.py @@ -0,0 +1 @@ +from . import services diff --git a/encoach_ai_generation/__manifest__.py b/encoach_ai_generation/__manifest__.py new file mode 100644 index 0000000..3431c10 --- /dev/null +++ b/encoach_ai_generation/__manifest__.py @@ -0,0 +1,13 @@ +{ + "name": "EnCoach AI Generation", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "AI-powered IELTS exam content generation", + "description": "Reading passages, listening dialogs, writing/speaking tasks, level exercises.", + "depends": ["encoach_ai", "encoach_exam"], + "data": [ + "security/ir.model.access.csv", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/encoach_ai_generation/security/ir.model.access.csv b/encoach_ai_generation/security/ir.model.access.csv new file mode 100644 index 0000000..97dd8b9 --- /dev/null +++ b/encoach_ai_generation/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/encoach_ai_generation/services/__init__.py b/encoach_ai_generation/services/__init__.py new file mode 100644 index 0000000..8f3285a --- /dev/null +++ b/encoach_ai_generation/services/__init__.py @@ -0,0 +1 @@ +from . import generation_service diff --git a/encoach_ai_generation/services/generation_service.py b/encoach_ai_generation/services/generation_service.py new file mode 100644 index 0000000..5273e5f --- /dev/null +++ b/encoach_ai_generation/services/generation_service.py @@ -0,0 +1,352 @@ +import json +import logging +import random + +from odoo.addons.encoach_ai.models.constants import ( + CEFR_LEVELS, + GPT_MODELS, + TEMPERATURE, + TOPICS, +) +from odoo.addons.encoach_ai.services.openai_service import EncoachOpenAIService + +_logger = logging.getLogger(__name__) + +PASSAGE_DIFFICULTY = { + 1: "fairly easy and consist of multiple paragraphs", + 2: "fairly hard and consist of multiple paragraphs", + 3: ( + "very hard, present different points or theories, " + "cite different sources, and consist of multiple paragraphs" + ), +} + +LISTENING_SECTION_CONFIG = { + 1: { + "type": "conversation", + "format": '{"conversation": [{"name": "name", "gender": "gender", "text": "text"}]}', + "prompt": ( + "Compose an authentic conversation between two individuals " + 'on the topic of "{topic}". Please include random names and genders. ' + "Include misleading discourse (dates, colors, etc.) and spelling of names. " + "Make sure that the generated conversation does not contain " + "forbidden subjects in muslim countries." + ), + }, + 2: { + "type": "monologue", + "format": '{"monologue": "monologue"}', + "prompt": ( + "Generate a comprehensive monologue set in the social context " + 'of "{topic}". Make sure that the generated monologue does not ' + "contain forbidden subjects in muslim countries." + ), + }, + 3: { + "type": "conversation", + "format": '{"conversation": [{"name": "name", "gender": "gender", "text": "text"}]}', + "prompt": ( + "Compose an authentic and elaborate conversation between " + 'up to four individuals on the topic of "{topic}". ' + "Please include random names and genders. Make sure that the " + "generated conversation does not contain forbidden subjects " + "in muslim countries." + ), + }, + 4: { + "type": "monologue", + "format": '{"monologue": "monologue"}', + "prompt": ( + "Generate a comprehensive and complex monologue on the academic " + 'subject of "{topic}". Make sure that the generated monologue ' + "does not contain forbidden subjects in muslim countries." + ), + }, +} + + +class EncoachGenerationService: + + def __init__(self, env): + self.env = env + self.ai = EncoachOpenAIService(env) + + # ------------------------------------------------------------------ + # Reading + # ------------------------------------------------------------------ + + def generate_reading_passage(self, passage_num, topic=None, word_count=500): + """Generate an IELTS reading passage (passage 1, 2, or 3).""" + topic = topic or random.choice(TOPICS) + difficulty = PASSAGE_DIFFICULTY.get(passage_num, PASSAGE_DIFFICULTY[1]) + + system_msg = ( + "You are a helpful assistant designed to output JSON on this format: " + '{"title": "title of the text", "text": "generated text"}' + ) + user_msg = ( + f"Generate an extensive text for IELTS Reading Passage {passage_num}, " + f'of at least {word_count} words, on the topic of "{topic}". ' + "The passage should offer a substantial amount of information " + f"relevant to the chosen subject matter. It should be {difficulty}. " + "Make sure that the generated text does not contain forbidden " + "subjects in muslim countries." + ) + + return self.ai.prediction( + model=GPT_MODELS["generation"], + messages=[ + {"role": "system", "content": system_msg}, + {"role": "user", "content": user_msg}, + ], + temperature=TEMPERATURE["generation"], + fields_to_check=["title", "text"], + ) + + def generate_reading_exercises(self, text, exercises_config, difficulty): + """Generate exercises for a reading passage. + + exercises_config: list of dicts with 'type' and 'quantity' keys. + difficulty: CEFR level string. + """ + system_msg = ( + "You are a helpful assistant designed to output JSON. " + "Generate reading comprehension exercises based on the given text." + ) + config_str = json.dumps(exercises_config) + user_msg = ( + f"Based on the following text, generate exercises according to this " + f"configuration: {config_str}. Target CEFR level: {difficulty}.\n\n" + f'Text: "{text}"' + ) + + return self.ai.prediction( + model=GPT_MODELS["generation"], + messages=[ + {"role": "system", "content": system_msg}, + {"role": "user", "content": user_msg}, + ], + temperature=TEMPERATURE["generation"], + ) + + # ------------------------------------------------------------------ + # Listening + # ------------------------------------------------------------------ + + def generate_listening_dialog(self, section, topic=None, difficulty=None): + """Generate a listening dialog/monologue for the given section (1-4).""" + topic = topic or random.choice(TOPICS) + config = LISTENING_SECTION_CONFIG.get(section, LISTENING_SECTION_CONFIG[1]) + + system_msg = ( + "You are a helpful assistant designed to output JSON on this format: " + + config["format"] + ) + user_msg = config["prompt"].format(topic=topic) + if difficulty: + user_msg += f" Target CEFR level: {difficulty}." + + return self.ai.prediction( + model=GPT_MODELS["generation"], + messages=[ + {"role": "system", "content": system_msg}, + {"role": "user", "content": user_msg}, + ], + temperature=TEMPERATURE["generation"], + ) + + def generate_listening_exercises(self, text, exercises_config, difficulty): + """Generate exercises for a listening transcript.""" + system_msg = ( + "You are a helpful assistant designed to output JSON. " + "Generate listening comprehension exercises based on the given transcript." + ) + config_str = json.dumps(exercises_config) + user_msg = ( + f"Based on the following transcript, generate exercises according to " + f"this configuration: {config_str}. Target CEFR level: {difficulty}.\n\n" + f'Transcript: "{text}"' + ) + + return self.ai.prediction( + model=GPT_MODELS["generation"], + messages=[ + {"role": "system", "content": system_msg}, + {"role": "user", "content": user_msg}, + ], + temperature=TEMPERATURE["generation"], + ) + + # ------------------------------------------------------------------ + # Writing + # ------------------------------------------------------------------ + + def generate_writing_task( + self, task, topic=None, difficulty=None, task_type="general" + ): + """Generate a writing task prompt. + + task: 1 or 2 + task_type: 'general' or 'academic' + """ + topic = topic or random.choice(TOPICS) + difficulty = difficulty or random.choice(CEFR_LEVELS) + + system_msg = ( + "You are a helpful assistant designed to output JSON on this format: " + '{"question": "the generated writing task prompt"}' + ) + + if task == 1 and task_type == "general": + user_msg = ( + "Craft a prompt for an IELTS Writing Task 1 General Training " + "exercise that instructs the student to compose a letter based " + f'on the topic of "{topic}" of {difficulty} CEFR level difficulty. ' + 'The prompt should end with "In the letter you should" followed ' + "by 3 bullet points. Make sure it does not contain forbidden " + "subjects in muslim countries." + ) + elif task == 2: + user_msg = ( + f"Craft a comprehensive question of {difficulty} CEFR level " + "difficulty like the ones for IELTS Writing Task 2 General " + "Training that directs the candidate to delve into an in-depth " + "analysis of contrasting perspectives on the topic of " + f'"{topic}". The question should lead to an answer with either ' + '"theories", "complicated information" or be "very descriptive" ' + "on the topic." + ) + else: + user_msg = ( + "Analyze the uploaded image and create a detailed IELTS Writing " + "Task 1 Academic prompt. Describe the visual type, context, and " + f"create a prompt at {difficulty} CEFR level." + ) + + return self.ai.prediction( + model=GPT_MODELS["generation"], + messages=[ + {"role": "system", "content": system_msg}, + {"role": "user", "content": user_msg}, + ], + temperature=TEMPERATURE["generation"], + fields_to_check=["question"], + ) + + # ------------------------------------------------------------------ + # Speaking + # ------------------------------------------------------------------ + + def generate_speaking_task( + self, + part, + topic=None, + first_topic=None, + second_topic=None, + difficulty=None, + ): + """Generate a speaking task for the given part (1, 2, or 3).""" + system_msg = ( + "You are a helpful assistant designed to output JSON. " + "Generate IELTS speaking task questions." + ) + + if part == 1: + first_topic = first_topic or random.choice(TOPICS) + second_topic = second_topic or random.choice(TOPICS) + user_msg = ( + "Craft 5 simple and single questions of easy difficulty for " + "IELTS Speaking Part 1 that encourages candidates to delve " + "deeply into personal experiences, preferences, or insights " + f'on the topic of "{first_topic}" and the topic of ' + f'"{second_topic}". The questions should lead to the usage ' + "of 4 verb tenses (present perfect, present, past and future). " + "Make sure that the generated question does not contain " + "forbidden subjects in muslim countries." + ) + elif part == 2: + topic = topic or random.choice(TOPICS) + user_msg = ( + "Create a question of medium difficulty for IELTS Speaking " + "Part 2 that encourages candidates to narrate a personal " + f'experience or story related to the topic of "{topic}". ' + "Include 3 prompts that guide the candidate. The prompts " + "must not be questions. Also include a suffix like the ones " + 'in the IELTS exams that start with "And explain why". ' + "Make sure that the generated question does not contain " + "forbidden subjects in muslim countries." + ) + else: + topic = topic or random.choice(TOPICS) + user_msg = ( + "Formulate a set of 5 single questions of hard difficulty " + "for IELTS Speaking Part 3 that encourage candidates to " + "engage in a meaningful discussion on the topic of " + f'"{topic}". They must be 1 single question each and not ' + "be double-barreled questions. Make sure that the generated " + "question does not contain forbidden subjects in muslim countries." + ) + + if difficulty: + user_msg += f" Target CEFR level: {difficulty}." + + return self.ai.prediction( + model=GPT_MODELS["generation"], + messages=[ + {"role": "system", "content": system_msg}, + {"role": "user", "content": user_msg}, + ], + temperature=TEMPERATURE["generation"], + ) + + # ------------------------------------------------------------------ + # Level exercises + # ------------------------------------------------------------------ + + def generate_level_exercises(self, exercises_config, difficulty): + """Generate level-exam exercises (multiple choice, fill-blanks, etc.). + + exercises_config: list of dicts with 'type', 'quantity', and optional + 'topic', 'size' keys. + difficulty: CEFR level string. + """ + system_msg = ( + "You are a helpful assistant designed to output JSON. " + "Generate English level exam exercises." + ) + + prompts = [] + for cfg in exercises_config: + ex_type = cfg.get("type", "multiple_choice") + quantity = cfg.get("quantity", 5) + topic = cfg.get("topic") or random.choice(TOPICS) + size = cfg.get("size", 200) + + if ex_type == "multiple_choice": + prompts.append( + f"Generate {quantity} multiple choice questions of 4 options " + f"for an english level exam of {difficulty} CEFR level. " + "Ensure that the questions cover a range of topics such as " + "verb tense, subject-verb agreement, pronoun usage, sentence " + "structure, and punctuation. Make sure every question only has " + "1 correct answer." + ) + elif ex_type == "fill_blanks": + prompts.append( + f"Generate a text of at least {size} words about the topic " + f"{topic}. From the generated text choose exactly {quantity} " + "words (cannot be sequential words), replace each with " + "{id}, and generate a JSON object containing: the modified " + "text, solutions array, words array with four options per blank." + ) + + user_msg = "\n\n".join(prompts) + + return self.ai.prediction( + model=GPT_MODELS["generation"], + messages=[ + {"role": "system", "content": system_msg}, + {"role": "user", "content": user_msg}, + ], + temperature=TEMPERATURE["generation"], + ) diff --git a/encoach_ai_grading/__init__.py b/encoach_ai_grading/__init__.py new file mode 100644 index 0000000..99464a7 --- /dev/null +++ b/encoach_ai_grading/__init__.py @@ -0,0 +1 @@ +from . import services diff --git a/encoach_ai_grading/__manifest__.py b/encoach_ai_grading/__manifest__.py new file mode 100644 index 0000000..c38ce30 --- /dev/null +++ b/encoach_ai_grading/__manifest__.py @@ -0,0 +1,13 @@ +{ + "name": "EnCoach AI Grading", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "AI-powered IELTS grading for writing and speaking", + "description": "GPT-4o grading with IELTS rubrics, GPTZero AI detection, short-answer evaluation.", + "depends": ["encoach_ai", "encoach_exam"], + "data": [ + "security/ir.model.access.csv", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/encoach_ai_grading/security/ir.model.access.csv b/encoach_ai_grading/security/ir.model.access.csv new file mode 100644 index 0000000..97dd8b9 --- /dev/null +++ b/encoach_ai_grading/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/encoach_ai_grading/services/__init__.py b/encoach_ai_grading/services/__init__.py new file mode 100644 index 0000000..dc335a3 --- /dev/null +++ b/encoach_ai_grading/services/__init__.py @@ -0,0 +1 @@ +from . import grading_service diff --git a/encoach_ai_grading/services/grading_service.py b/encoach_ai_grading/services/grading_service.py new file mode 100644 index 0000000..81d2e19 --- /dev/null +++ b/encoach_ai_grading/services/grading_service.py @@ -0,0 +1,340 @@ +import json +import logging + +import httpx + +from odoo.addons.encoach_ai.models.constants import GPT_MODELS, TEMPERATURE +from odoo.addons.encoach_ai.services.openai_service import EncoachOpenAIService + +_logger = logging.getLogger(__name__) + +WRITING_RESPONSE_TEMPLATE = json.dumps({ + "comment": "comment about student's response quality", + "overall": 0.0, + "task_response": { + "Task Achievement": {"grade": 0.0, "comment": "..."}, + "Coherence and Cohesion": {"grade": 0.0, "comment": "..."}, + "Lexical Resource": {"grade": 0.0, "comment": "..."}, + "Grammatical Range and Accuracy": {"grade": 0.0, "comment": "..."}, + }, +}) + +SPEAKING_RESPONSE_TEMPLATE = json.dumps({ + "comment": "extensive comment about answer quality", + "overall": 0.0, + "task_response": { + "Fluency and Coherence": {"grade": 0.0, "comment": "extensive comment..."}, + "Lexical Resource": {"grade": 0.0, "comment": "..."}, + "Grammatical Range and Accuracy": {"grade": 0.0, "comment": "..."}, + "Pronunciation": {"grade": 0.0, "comment": "..."}, + }, +}) + +SHORT_ANSWER_TEMPLATE = json.dumps({ + "exercises": [ + {"id": "exercise-id", "correct": True, "correct_answer": "the correct answer"}, + ], +}) + +SUMMARY_TEMPLATE = json.dumps({ + "sections": [ + { + "code": "section-code", + "name": "Section Name", + "grade": 0.0, + "evaluation": "Detailed evaluation text...", + "suggestions": "Improvement suggestions...", + "bullet_points": ["point 1", "point 2"], + }, + ], +}) + +SPEAKING_TASK_INSTRUCTIONS = { + 1: ( + 'Address the student as "you". ' + "If the answers are not 2 or 3 sentences long, " + "warn the student that they should be." + ), + 2: 'Address the student as "you".', + 3: ( + 'Address the student as "you" and pay special attention ' + "to coherence between the answers." + ), +} + + +class EncoachGradingService: + + def __init__(self, env): + self.env = env + self.ai = EncoachOpenAIService(env) + + # ------------------------------------------------------------------ + # Writing grading + # ------------------------------------------------------------------ + + def grade_writing(self, question, answer, task, attachment=None): + """Grade a writing answer using GPT-4o with IELTS writing rubric. + + Returns dict with comment, overall, task_response, fixed_text, + perfect_answer, and ai_detection results. + """ + system_msg = ( + "You are a helpful assistant designed to output JSON " + f"on this format: {WRITING_RESPONSE_TEMPLATE}" + ) + + if task == 1: + user_prompt = ( + f"Evaluate the given Writing Task {task} response based on the " + "IELTS grading system, ensuring a strict assessment that penalizes " + "errors. Deduct points for deviations from the task, and assign a " + "score of 0 if the response fails to address the question. " + "Additionally, provide a detailed commentary highlighting both " + "strengths and weaknesses in the response.\n" + 'Refer to the parts of the letter as: "Greeting Opener", ' + '"bullet 1", "bullet 2", "bullet 3", ' + '"closer (restate the purpose of the letter)", "closing greeting".\n' + f'Question: "{question}"\nAnswer: "{answer}"' + ) + else: + user_prompt = ( + f"Evaluate the given Writing Task {task} response based on the " + "IELTS grading system, ensuring a strict assessment that penalizes " + "errors. Deduct points for deviations from the task, and assign a " + "score of 0 if the response fails to address the question. " + "Additionally, provide a detailed commentary highlighting both " + "strengths and weaknesses in the response.\n" + f'Question: "{question}"\nAnswer: "{answer}"' + ) + + messages = [ + {"role": "system", "content": system_msg}, + ] + + if attachment and task == 1: + messages.append({ + "role": "user", + "content": [ + {"type": "text", "text": user_prompt}, + { + "type": "image_url", + "image_url": {"url": attachment}, + }, + ], + }) + else: + messages.append({"role": "user", "content": user_prompt}) + + result = self.ai.prediction( + model=GPT_MODELS["grading"], + messages=messages, + temperature=TEMPERATURE["grading"], + fields_to_check=["comment"], + ) + if not result: + return None + + result["fixed_text"] = self._get_fixed_text(answer) + result["perfect_answer"] = self._get_perfect_answer(question) + result["ai_detection"] = self._detect_ai(answer) + return result + + # ------------------------------------------------------------------ + # Speaking grading + # ------------------------------------------------------------------ + + def grade_speaking(self, task, qa_pairs): + """Grade speaking using GPT-4o with IELTS speaking rubric. + + qa_pairs: list of dicts with 'question' and 'answer' keys. + """ + system_msg = ( + "You are a helpful assistant designed to output JSON " + f"on this format: {SPEAKING_RESPONSE_TEMPLATE}" + ) + + qa_text = "\n".join( + f'Question: "{p["question"]}"\nAnswer: "{p["answer"]}"' + for p in qa_pairs + ) + task_instruction = SPEAKING_TASK_INSTRUCTIONS.get(task, "") + + user_prompt = ( + f"Evaluate the given Speaking Part {task} response based on the " + "IELTS grading system, ensuring a strict assessment that penalizes " + "errors. Deduct points for deviations from the task, and assign a " + "score of 0 if the response fails to address the question. " + "Additionally, provide detailed commentary highlighting both " + f"strengths and weaknesses in the response. {task_instruction}\n" + f"{qa_text}" + ) + + messages = [ + {"role": "system", "content": system_msg}, + {"role": "user", "content": user_prompt}, + ] + + result = self.ai.prediction( + model=GPT_MODELS["grading"], + messages=messages, + temperature=TEMPERATURE["grading"], + fields_to_check=["comment"], + ) + if not result: + return None + + if task == 2 and qa_pairs: + combined_answer = " ".join(p["answer"] for p in qa_pairs) + result["fixed_text"] = self._get_fixed_text(combined_answer) + result["perfect_answer"] = self._get_perfect_answer( + qa_pairs[0]["question"] + ) + return result + + # ------------------------------------------------------------------ + # Short-answer grading + # ------------------------------------------------------------------ + + def grade_short_answers(self, text, questions, answers): + """Evaluate short answers against a reading/listening passage.""" + system_msg = ( + "You are a helpful assistant designed to output JSON " + f"on this format: {SHORT_ANSWER_TEMPLATE}" + ) + + qa_text = "\n".join( + f'Q{i + 1}: "{q}" — Student answer: "{a}"' + for i, (q, a) in enumerate(zip(questions, answers)) + ) + + user_prompt = ( + "Evaluate each student answer against the passage. For each answer, " + "determine if it is correct and provide the correct answer.\n\n" + f'Passage: "{text}"\n\n{qa_text}' + ) + + messages = [ + {"role": "system", "content": system_msg}, + {"role": "user", "content": user_prompt}, + ] + + return self.ai.prediction( + model=GPT_MODELS["grading"], + messages=messages, + temperature=TEMPERATURE["grading"], + check_blacklisted=False, + ) + + # ------------------------------------------------------------------ + # Grading summary + # ------------------------------------------------------------------ + + def generate_grading_summary(self, sections): + """Generate a summary for an entire exam session using GPT-3.5-turbo.""" + system_msg = ( + "You are a helpful assistant designed to output JSON " + f"on this format: {SUMMARY_TEMPLATE}" + ) + + user_prompt = ( + "Generate a detailed evaluation summary for the following IELTS exam " + "sections. For each section, provide an evaluation, suggestions for " + "improvement, and key bullet points.\n\n" + + json.dumps(sections) + ) + + messages = [ + {"role": "system", "content": system_msg}, + {"role": "user", "content": user_prompt}, + ] + + return self.ai.prediction( + model=GPT_MODELS["secondary"], + messages=messages, + temperature=TEMPERATURE["grading"], + check_blacklisted=False, + ) + + # ------------------------------------------------------------------ + # AI detection (GPTZero) + # ------------------------------------------------------------------ + + def _detect_ai(self, text): + """Call GPTZero API to detect AI-generated text.""" + api_key = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("encoach.gptzero_api_key", "") + ) + if not api_key: + _logger.warning("GPTZero API key not configured") + return None + + try: + resp = httpx.post( + "https://api.gptzero.me/v2/predict/text", + headers={ + "x-api-key": api_key, + "Content-Type": "application/json", + }, + json={ + "document": text, + "version": "", + "multilingual": False, + }, + timeout=30, + ) + resp.raise_for_status() + return resp.json() + except Exception: + _logger.exception("GPTZero API call failed") + return None + + # ------------------------------------------------------------------ + # Helper prompts + # ------------------------------------------------------------------ + + def _get_perfect_answer(self, question): + messages = [ + {"role": "system", "content": "You are an IELTS writing expert."}, + { + "role": "user", + "content": ( + "Write a perfect answer for this IELTS writing task: " + f'"{question}"' + ), + }, + ] + result = self.ai.prediction( + model=GPT_MODELS["secondary"], + messages=messages, + temperature=TEMPERATURE["grading"], + response_format={"type": "json_object"}, + check_blacklisted=False, + ) + if result and "answer" in result: + return result["answer"] + return result + + def _get_fixed_text(self, text): + messages = [ + {"role": "system", "content": "You are a grammar correction assistant. Output JSON."}, + { + "role": "user", + "content": ( + "Fix the grammatical and spelling errors in this text, " + f'keeping the original meaning: "{text}"' + ), + }, + ] + result = self.ai.prediction( + model=GPT_MODELS["secondary"], + messages=messages, + temperature=TEMPERATURE["grading"], + response_format={"type": "json_object"}, + check_blacklisted=False, + ) + if result and "text" in result: + return result["text"] + return result diff --git a/encoach_ai_media/__init__.py b/encoach_ai_media/__init__.py new file mode 100644 index 0000000..71a0242 --- /dev/null +++ b/encoach_ai_media/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import services diff --git a/encoach_ai_media/__manifest__.py b/encoach_ai_media/__manifest__.py new file mode 100644 index 0000000..a5ed695 --- /dev/null +++ b/encoach_ai_media/__manifest__.py @@ -0,0 +1,17 @@ +{ + "name": "EnCoach AI Media", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "TTS, STT, and video generation for EnCoach", + "description": "AWS Polly TTS, OpenAI Whisper STT, ELAI avatar video generation.", + "depends": ["encoach_ai"], + "data": [ + "security/ir.model.access.csv", + "views/avatar_views.xml", + ], + "installable": True, + "license": "LGPL-3", + "external_dependencies": { + "python": ["boto3"], + }, +} diff --git a/encoach_ai_media/models/__init__.py b/encoach_ai_media/models/__init__.py new file mode 100644 index 0000000..8075d16 --- /dev/null +++ b/encoach_ai_media/models/__init__.py @@ -0,0 +1 @@ +from . import elai_avatar diff --git a/encoach_ai_media/models/elai_avatar.py b/encoach_ai_media/models/elai_avatar.py new file mode 100644 index 0000000..fd4ae6a --- /dev/null +++ b/encoach_ai_media/models/elai_avatar.py @@ -0,0 +1,30 @@ +from odoo import models, fields + + +class EncoachElaiAvatar(models.Model): + _name = "encoach.elai.avatar" + _description = "ELAI Avatar Configuration" + + name = fields.Char(required=True) + avatar_code = fields.Char(required=True) + avatar_url = fields.Char() + gender = fields.Selection( + [("male", "Male"), ("female", "Female")], + string="Gender", + ) + canvas = fields.Char() + voice_id = fields.Char() + voice_provider = fields.Char() + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "name": self.name, + "avatarCode": self.avatar_code, + "avatarUrl": self.avatar_url or "", + "gender": self.gender or "", + "canvas": self.canvas or "", + "voiceId": self.voice_id or "", + "voiceProvider": self.voice_provider or "", + } diff --git a/encoach_ai_media/security/ir.model.access.csv b/encoach_ai_media/security/ir.model.access.csv new file mode 100644 index 0000000..e26a7e3 --- /dev/null +++ b/encoach_ai_media/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_elai_avatar_student,encoach.elai.avatar.student,model_encoach_elai_avatar,encoach_core.group_encoach_student,1,0,0,0 +access_encoach_elai_avatar_admin,encoach.elai.avatar.admin,model_encoach_elai_avatar,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_elai_avatar_sysadmin,encoach.elai.avatar.sysadmin,model_encoach_elai_avatar,base.group_system,1,1,1,1 diff --git a/encoach_ai_media/services/__init__.py b/encoach_ai_media/services/__init__.py new file mode 100644 index 0000000..21ba419 --- /dev/null +++ b/encoach_ai_media/services/__init__.py @@ -0,0 +1,3 @@ +from . import polly_service +from . import whisper_service +from . import elai_service diff --git a/encoach_ai_media/services/elai_service.py b/encoach_ai_media/services/elai_service.py new file mode 100644 index 0000000..a37f740 --- /dev/null +++ b/encoach_ai_media/services/elai_service.py @@ -0,0 +1,107 @@ +import logging + +import httpx + +_logger = logging.getLogger(__name__) + +ELAI_BASE_URL = "https://apis.elai.io/api/v1/videos" + + +class EncoachElaiService: + + def __init__(self, env): + self.env = env + self.token = ( + env["ir.config_parameter"] + .sudo() + .get_param("encoach.elai_token", "") + ) + + def _headers(self): + return { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + } + + def create_video(self, text, avatar_code): + """Create and render an ELAI video. + + Returns the video_id for status polling, or None on failure. + """ + avatar = ( + self.env["encoach.elai.avatar"] + .sudo() + .search([("avatar_code", "=", avatar_code)], limit=1) + ) + if not avatar: + _logger.error("Avatar not found: %s", avatar_code) + return None + + payload = { + "name": f"EnCoach Speaking - {avatar.name}", + "slides": [ + { + "avatar": { + "code": avatar.avatar_code, + "canvas": avatar.canvas or "default", + "voiceId": avatar.voice_id or "", + "voiceProvider": avatar.voice_provider or "", + }, + "speech": text, + }, + ], + } + + try: + resp = httpx.post( + ELAI_BASE_URL, + headers=self._headers(), + json=payload, + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + video_id = data.get("_id") or data.get("id") + + self._render_video(video_id) + return video_id + except Exception: + _logger.exception("ELAI video creation failed") + return None + + def _render_video(self, video_id): + try: + httpx.post( + f"{ELAI_BASE_URL}/render/{video_id}", + headers=self._headers(), + timeout=30, + ) + except Exception: + _logger.exception("ELAI render request failed for %s", video_id) + + def poll_status(self, video_id): + """Poll ELAI for video rendering status. + + Returns dict with 'status' and optionally 'url' keys. + """ + try: + resp = httpx.get( + f"{ELAI_BASE_URL}/{video_id}", + headers=self._headers(), + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + status = data.get("status", "unknown") + result = {"status": status} + if status == "ready": + result["url"] = data.get("url", "") + return result + except Exception: + _logger.exception("ELAI status poll failed for %s", video_id) + return {"status": "error"} + + def get_avatars(self): + """List all configured ELAI avatars.""" + avatars = self.env["encoach.elai.avatar"].sudo().search([]) + return [a.to_encoach_dict() for a in avatars] diff --git a/encoach_ai_media/services/polly_service.py b/encoach_ai_media/services/polly_service.py new file mode 100644 index 0000000..f76e9cc --- /dev/null +++ b/encoach_ai_media/services/polly_service.py @@ -0,0 +1,129 @@ +import io +import logging +import random +import re + +import boto3 + +_logger = logging.getLogger(__name__) + +POLLY_VOICES = { + "Danielle": {"gender": "Female", "accent": "US"}, + "Gregory": {"gender": "Male", "accent": "US"}, + "Kevin": {"gender": "Male", "accent": "US"}, + "Ruth": {"gender": "Female", "accent": "US"}, + "Stephen": {"gender": "Male", "accent": "US"}, + "Arthur": {"gender": "Male", "accent": "GB"}, + "Olivia": {"gender": "Female", "accent": "GB"}, + "Ayanda": {"gender": "Female", "accent": "ZA"}, + "Aria": {"gender": "Female", "accent": "NZ"}, + "Kajal": {"gender": "Female", "accent": "IN"}, + "Niamh": {"gender": "Female", "accent": "IE"}, +} + +FINAL_CUE_TEXT = ( + "This audio recording, for the listening exercise, has finished." +) +FINAL_CUE_VOICE = "Stephen" + +MAX_CHUNK_SIZE = 3000 + + +class EncoachPollyService: + + def __init__(self, env): + self.env = env + icp = env["ir.config_parameter"].sudo() + self.client = boto3.client( + "polly", + aws_access_key_id=icp.get_param("encoach.aws_access_key_id", ""), + aws_secret_access_key=icp.get_param("encoach.aws_secret_access_key", ""), + region_name=icp.get_param("encoach.aws_region", "us-east-1"), + ) + + def synthesize_speech( + self, text, voice, engine="neural", output_format="mp3" + ): + """Synthesize a single text block into MP3 bytes.""" + chunks = self._chunk_text(text) + audio_parts = [] + for chunk in chunks: + resp = self.client.synthesize_speech( + Text=chunk, + VoiceId=voice, + Engine=engine, + OutputFormat=output_format, + ) + audio_parts.append(resp["AudioStream"].read()) + return b"".join(audio_parts) + + def text_to_speech(self, dialog, include_final_cue=True): + """Generate full MP3 audio from a conversation or monologue. + + dialog: either a string (monologue) or a list of dicts with + 'name', 'gender', 'text' keys. + """ + audio_segments = [] + + if isinstance(dialog, str): + voice = random.choice(list(POLLY_VOICES.keys())) + audio_segments.append(self.synthesize_speech(dialog, voice)) + else: + voice_map = self._assign_voices(dialog) + for line in dialog: + voice = voice_map.get(line["name"], "Stephen") + audio_segments.append( + self.synthesize_speech(line["text"], voice) + ) + + if include_final_cue: + audio_segments.append( + self.synthesize_speech(FINAL_CUE_TEXT, FINAL_CUE_VOICE) + ) + + return b"".join(audio_segments) + + @staticmethod + def _assign_voices(dialog): + """Assign distinct Polly voices to each speaker based on gender.""" + speakers = {} + male_voices = [v for v, info in POLLY_VOICES.items() if info["gender"] == "Male"] + female_voices = [v for v, info in POLLY_VOICES.items() if info["gender"] == "Female"] + male_idx, female_idx = 0, 0 + + for line in dialog: + name = line.get("name", "") + if name in speakers: + continue + gender = (line.get("gender") or "").lower() + if gender == "female" and female_idx < len(female_voices): + speakers[name] = female_voices[female_idx] + female_idx += 1 + elif male_idx < len(male_voices): + speakers[name] = male_voices[male_idx] + male_idx += 1 + else: + speakers[name] = random.choice(list(POLLY_VOICES.keys())) + return speakers + + @staticmethod + def _chunk_text(text, max_size=MAX_CHUNK_SIZE): + """Split text at sentence boundaries respecting max chunk size.""" + if len(text) <= max_size: + return [text] + + sentences = re.split(r"(?<=[.!?])\s+", text) + chunks = [] + current = "" + + for sentence in sentences: + if len(current) + len(sentence) + 1 > max_size: + if current: + chunks.append(current.strip()) + current = sentence + else: + current = f"{current} {sentence}" if current else sentence + + if current: + chunks.append(current.strip()) + return chunks diff --git a/encoach_ai_media/services/whisper_service.py b/encoach_ai_media/services/whisper_service.py new file mode 100644 index 0000000..0e9dc92 --- /dev/null +++ b/encoach_ai_media/services/whisper_service.py @@ -0,0 +1,167 @@ +import logging +from concurrent.futures import ThreadPoolExecutor + +import numpy as np +from tenacity import retry, stop_after_attempt, wait_exponential + +from odoo.addons.encoach_ai.models.constants import GPT_MODELS, TEMPERATURE +from odoo.addons.encoach_ai.services.openai_service import EncoachOpenAIService + +_logger = logging.getLogger(__name__) + +SAMPLE_RATE = 16000 +CHUNK_SAMPLES = 480000 # 30 seconds at 16 kHz +OVERLAP_RATIO = 0.25 +WHISPER_OPTIONS = { + "fp16": False, + "language": "English", + "verbose": False, +} + +OVERLAP_CLEANUP_PROMPT = ( + "The following are two transcribed segments from the same audio. " + "They overlap at the boundary. Remove any duplicated words at the " + "junction and return the cleaned, combined text as JSON: " + '{"text": "cleaned text"}' +) + +DIALOG_DETECTION_PROMPT = ( + "You are a helpful assistant designed to output JSON on either one of " + "these formats:\n" + '1 - {"dialog": [{"name": "name", "gender": "gender", "text": "text"}]}\n' + '2 - {"dialog": "text"}\n\n' + "A transcription of an audio file will be provided to you. Based on that " + "transcription you will need to determine whether the transcription is a " + "conversation or a monologue. If it is a conversation, output format 1. " + "If it is a monologue, output format 2." +) + +_model_pool = [] +_pool_executor = None +_pool_lock = None + + +def _get_pool(num_workers=4): + global _model_pool, _pool_executor, _pool_lock + import threading + if _pool_lock is None: + _pool_lock = threading.Lock() + + with _pool_lock: + if _pool_executor is None: + import whisper + _logger.info("Loading %d Whisper model instances...", num_workers) + _model_pool = [whisper.load_model("base") for _ in range(num_workers)] + _pool_executor = ThreadPoolExecutor(max_workers=num_workers) + _logger.info("Whisper pool ready with %d workers", num_workers) + return _pool_executor, _model_pool + + +class EncoachWhisperService: + + def __init__(self, env): + self.env = env + self.ai = EncoachOpenAIService(env) + self._num_workers = int( + env["ir.config_parameter"].sudo().get_param("encoach.whisper_workers", "4") + ) + self._worker_idx = 0 + import threading + self._idx_lock = threading.Lock() + + def _get_model(self): + executor, pool = _get_pool(self._num_workers) + with self._idx_lock: + idx = self._worker_idx % len(pool) + self._worker_idx += 1 + return pool[idx] + + def transcribe(self, audio_data): + if not isinstance(audio_data, np.ndarray): + audio_data = np.frombuffer(audio_data, dtype=np.float32) + + audio_data = audio_data.astype(np.float32) + if audio_data.max() > 1.0: + audio_data = audio_data / np.abs(audio_data).max() + + chunks = self._chunk_audio(audio_data) + + if len(chunks) == 1: + return self._transcribe_chunk(chunks[0]) + + executor, pool = _get_pool(self._num_workers) + futures = [] + for i, chunk in enumerate(chunks): + model = pool[i % len(pool)] + future = executor.submit(self._transcribe_chunk_with_model, chunk, model) + futures.append(future) + + segments = [f.result() for f in futures] + return self._merge_segments(segments) + + @retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10)) + def _transcribe_chunk(self, chunk): + model = self._get_model() + result = model.transcribe(chunk, **WHISPER_OPTIONS) + return result.get("text", "").strip() + + @staticmethod + @retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10)) + def _transcribe_chunk_with_model(chunk, model): + result = model.transcribe(chunk, **WHISPER_OPTIONS) + return result.get("text", "").strip() + + def _merge_segments(self, segments): + if len(segments) <= 1: + return segments[0] if segments else "" + + merged = segments[0] + for i in range(1, len(segments)): + result = self.ai.prediction( + model=GPT_MODELS["grading"], + messages=[ + {"role": "system", "content": OVERLAP_CLEANUP_PROMPT}, + { + "role": "user", + "content": ( + f'Segment A (end): "...{merged[-200:]}"\n' + f'Segment B (start): "{segments[i][:200]}..."' + ), + }, + ], + temperature=TEMPERATURE["grading"], + check_blacklisted=False, + ) + if result and "text" in result: + merged = result["text"] + else: + merged = f"{merged} {segments[i]}" + return merged + + def detect_dialog(self, transcript): + return self.ai.prediction( + model=GPT_MODELS["grading"], + messages=[ + {"role": "system", "content": DIALOG_DETECTION_PROMPT}, + {"role": "user", "content": transcript}, + ], + temperature=TEMPERATURE["grading"], + check_blacklisted=False, + ) + + @staticmethod + def _chunk_audio(audio, chunk_size=CHUNK_SAMPLES, overlap=OVERLAP_RATIO): + total = len(audio) + if total <= chunk_size: + return [audio] + + step = int(chunk_size * (1 - overlap)) + chunks = [] + start = 0 + while start < total: + end = min(start + chunk_size, total) + chunks.append(audio[start:end]) + if end >= total: + break + start += step + return chunks diff --git a/encoach_ai_media/views/avatar_views.xml b/encoach_ai_media/views/avatar_views.xml new file mode 100644 index 0000000..e8efc9c --- /dev/null +++ b/encoach_ai_media/views/avatar_views.xml @@ -0,0 +1,49 @@ + + + + encoach.elai.avatar.list + encoach.elai.avatar + + + + + + + + + + + + + + encoach.elai.avatar.form + encoach.elai.avatar + +
+ + + + + + + + + + + +
+
+
+ + + ELAI Avatars + encoach.elai.avatar + list,form + + + +
diff --git a/encoach_api/__init__.py b/encoach_api/__init__.py new file mode 100644 index 0000000..e046e49 --- /dev/null +++ b/encoach_api/__init__.py @@ -0,0 +1 @@ +from . import controllers diff --git a/encoach_api/__manifest__.py b/encoach_api/__manifest__.py new file mode 100644 index 0000000..c9cb48b --- /dev/null +++ b/encoach_api/__manifest__.py @@ -0,0 +1,29 @@ +{ + "name": "EnCoach API", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "REST JSON API controllers for the EnCoach platform", + "depends": [ + "encoach_core", + "encoach_exam", + "encoach_classroom", + "encoach_assignment", + "encoach_stats", + "encoach_evaluation", + "encoach_training", + "encoach_subscription", + "encoach_registration", + "encoach_ticket", + "encoach_ai_grading", + "encoach_ai_generation", + "encoach_ai_media", + ], + "data": [ + "security/ir.model.access.csv", + ], + "installable": True, + "license": "LGPL-3", + "external_dependencies": { + "python": ["jwt"], + }, +} diff --git a/encoach_api/controllers/__init__.py b/encoach_api/controllers/__init__.py new file mode 100644 index 0000000..923b45a --- /dev/null +++ b/encoach_api/controllers/__init__.py @@ -0,0 +1,20 @@ +from . import base +from . import auth +from . import users +from . import exams +from . import classrooms +from . import assignments +from . import sessions +from . import stats +from . import evaluations +from . import training +from . import subscriptions +from . import registration +from . import tickets +from . import storage +from . import grading +from . import generation +from . import media +from . import entities +from . import discounts +from . import approvals diff --git a/encoach_api/controllers/approvals.py b/encoach_api/controllers/approvals.py new file mode 100644 index 0000000..ec9e55c --- /dev/null +++ b/encoach_api/controllers/approvals.py @@ -0,0 +1,89 @@ +import json +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class ApprovalsController(EncoachMixin, http.Controller): + + @http.route('/api/approval-workflows', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_workflows(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + workflows = request.env['encoach.approval.workflow'].sudo().search([]) + return self._json_response([{ + 'id': w.id, 'examId': w.exam_id.id if w.exam_id else None, + 'creatorId': w.creator_id.id if w.creator_id else None, + 'status': w.status, 'modules': w.modules, 'steps': w.steps, + } for w in workflows]) + except Exception: + _logger.exception("List approval workflows error") + return self._error_response('Internal server error', 500) + + @http.route('/api/approval-workflows', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def create_workflow(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + body = self._get_json_body() + vals = { + 'creator_id': user.id, + 'status': body.get('status', 'pending'), + } + if body.get('examId'): vals['exam_id'] = int(body['examId']) + if 'modules' in body: vals['modules'] = body['modules'] + if 'steps' in body: vals['steps'] = body['steps'] + wf = request.env['encoach.approval.workflow'].sudo().create(vals) + return self._json_response({ + 'id': wf.id, 'examId': wf.exam_id.id if wf.exam_id else None, + 'status': wf.status, + }) + except Exception: + _logger.exception("Create approval workflow error") + return self._error_response('Internal server error', 500) + + @http.route('/api/approval-workflows/', type='http', auth='public', methods=['PATCH', 'OPTIONS'], csrf=False, cors='*') + def update_workflow(self, wid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + wf = request.env['encoach.approval.workflow'].sudo().browse(wid) + if not wf.exists(): + return self._error_response('Workflow not found', 404) + body = self._get_json_body() + vals = {} + if 'status' in body: vals['status'] = body['status'] + if 'modules' in body: vals['modules'] = body['modules'] + if 'steps' in body: vals['steps'] = body['steps'] + if vals: + wf.write(vals) + return self._json_response({ + 'id': wf.id, 'status': wf.status, + }) + except Exception: + _logger.exception("Update approval workflow error") + return self._error_response('Internal server error', 500) + + @http.route('/api/approval-workflows/', type='http', auth='public', methods=['DELETE', 'OPTIONS'], csrf=False, cors='*') + def delete_workflow(self, wid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + wf = request.env['encoach.approval.workflow'].sudo().browse(wid) + if not wf.exists(): + return self._error_response('Workflow not found', 404) + wf.unlink() + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Delete approval workflow error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/assignments.py b/encoach_api/controllers/assignments.py new file mode 100644 index 0000000..38659af --- /dev/null +++ b/encoach_api/controllers/assignments.py @@ -0,0 +1,235 @@ +import json +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class AssignmentsController(EncoachMixin, http.Controller): + + # --------------------------------------------------------------- list + @http.route('/api/assignments', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_assignments(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + domain = [] + + group_id = kwargs.get('groupId') + if group_id: + domain.append(('group_id', '=', int(group_id))) + + creator = kwargs.get('createdBy') + if creator: + domain.append(('create_uid', '=', int(creator))) + + status = kwargs.get('status') + if status: + domain.append(('status', '=', status)) + + participant = kwargs.get('participant') + if participant: + domain.append(('group_id.participant_ids', 'in', [int(participant)])) + + is_archived = kwargs.get('isArchived') + if is_archived is not None: + domain.append(('is_archived', '=', is_archived in ('true', '1', True))) + + offset, limit, page = self._paginate_params(kwargs) + Assignment = request.env['encoach.assignment'].sudo() + total = Assignment.search_count(domain) + records = Assignment.search(domain, offset=offset, limit=limit, order='create_date desc') + + return self._json_response({ + 'assignments': [self._serialize_assignment(a) for a in records], + 'total': total, + 'page': page, + 'limit': limit, + }) + except Exception: + _logger.exception("List assignments error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- create + @http.route('/api/assignments', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def create_assignment(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + if not body.get('examId'): + return self._error_response('examId is required', 400) + if not body.get('groupId'): + return self._error_response('groupId is required', 400) + + vals = { + 'name': body.get('name', ''), + 'exam_id': int(body['examId']), + 'group_id': int(body['groupId']), + 'status': 'draft', + } + if body.get('startDate'): + vals['start_date'] = body['startDate'] + if body.get('endDate'): + vals['end_date'] = body['endDate'] + if body.get('duration'): + vals['duration'] = int(body['duration']) + + assignment = request.env['encoach.assignment'].sudo().with_user(user).create(vals) + return self._json_response(self._serialize_assignment(assignment), status=201) + except Exception: + _logger.exception("Create assignment error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- update + @http.route( + '/api/assignments/', type='http', auth='public', + methods=['PATCH', 'OPTIONS'], csrf=False, cors='*', + ) + def update_assignment(self, aid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + assignment = request.env['encoach.assignment'].sudo().browse(aid) + if not assignment.exists(): + return self._error_response('Assignment not found', 404) + + body = self._get_json_body() + vals = {} + if 'name' in body: + vals['name'] = body['name'] + if 'startDate' in body: + vals['start_date'] = body['startDate'] + if 'endDate' in body: + vals['end_date'] = body['endDate'] + if 'duration' in body: + vals['duration'] = int(body['duration']) + if 'examId' in body: + vals['exam_id'] = int(body['examId']) + if 'groupId' in body: + vals['group_id'] = int(body['groupId']) + if 'status' in body: + vals['status'] = body['status'] + + if vals: + assignment.write(vals) + + return self._json_response(self._serialize_assignment(assignment)) + except Exception: + _logger.exception("Update assignment error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- delete + @http.route( + '/api/assignments/', type='http', auth='public', + methods=['DELETE', 'OPTIONS'], csrf=False, cors='*', + ) + def delete_assignment(self, aid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + assignment = request.env['encoach.assignment'].sudo().browse(aid) + if not assignment.exists(): + return self._error_response('Assignment not found', 404) + + assignment.unlink() + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Delete assignment error") + return self._error_response('Internal server error', 500) + + # --------------------------------------------------------------- start + @http.route( + '/api/assignments//start', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def start_assignment(self, aid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + assignment = request.env['encoach.assignment'].sudo().browse(aid) + if not assignment.exists(): + return self._error_response('Assignment not found', 404) + + assignment.action_start() + return self._json_response(self._serialize_assignment(assignment)) + except Exception as exc: + _logger.exception("Start assignment error") + return self._error_response(str(exc) if str(exc) != '' else 'Internal server error', 500) + + # ------------------------------------------------------------- release + @http.route( + '/api/assignments//release', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def release_assignment(self, aid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + assignment = request.env['encoach.assignment'].sudo().browse(aid) + if not assignment.exists(): + return self._error_response('Assignment not found', 404) + + assignment.action_release() + return self._json_response(self._serialize_assignment(assignment)) + except Exception as exc: + _logger.exception("Release assignment error") + return self._error_response(str(exc) if str(exc) != '' else 'Internal server error', 500) + + # ------------------------------------------------------------- archive + @http.route( + '/api/assignments//archive', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def archive_assignment(self, aid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + assignment = request.env['encoach.assignment'].sudo().browse(aid) + if not assignment.exists(): + return self._error_response('Assignment not found', 404) + + assignment.write({'is_archived': True}) + return self._json_response(self._serialize_assignment(assignment)) + except Exception: + _logger.exception("Archive assignment error") + return self._error_response('Internal server error', 500) + + # ----------------------------------------------------------- unarchive + @http.route( + '/api/assignments//unarchive', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def unarchive_assignment(self, aid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + assignment = request.env['encoach.assignment'].sudo().browse(aid) + if not assignment.exists(): + return self._error_response('Assignment not found', 404) + + assignment.write({'is_archived': False}) + return self._json_response(self._serialize_assignment(assignment)) + except Exception: + _logger.exception("Unarchive assignment error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/auth.py b/encoach_api/controllers/auth.py new file mode 100644 index 0000000..50d4c36 --- /dev/null +++ b/encoach_api/controllers/auth.py @@ -0,0 +1,110 @@ +import logging +from datetime import datetime, timedelta + +import jwt as pyjwt + +from odoo import http +from odoo.exceptions import AccessDenied +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class AuthController(EncoachMixin, http.Controller): + + def _create_jwt(self, user, secret, expires_hours=72): + payload = { + 'user_id': user.id, + 'email': user.login, + 'iat': datetime.utcnow(), + 'exp': datetime.utcnow() + timedelta(hours=expires_hours), + } + return pyjwt.encode(payload, secret, algorithm='HS256') + + @http.route('/api/login', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def login(self, **kwargs): + try: + body = self._get_json_body() + email = (body.get('credential') or body.get('email') or '').strip() + password = body.get('password', '') + if not email or not password: + return self._error_response('Email and password are required', 400) + + user = request.env['res.users'].sudo().search([ + ('login', '=', email), + ], limit=1) + if not user: + return self._error_response('Invalid email or password', 401) + + try: + credential = {'type': 'password', 'login': email, 'password': password} + request.env['res.users'].sudo().authenticate( + credential, {'interactive': False}, + ) + except AccessDenied: + return self._error_response('Invalid email or password', 401) + + secret = request.env['ir.config_parameter'].sudo().get_param('encoach.jwt_secret') + if not secret: + return self._error_response('Server configuration error', 500) + + token = self._create_jwt(user, secret) + return self._json_response({ + 'user': self._serialize(user), + 'token': token, + }) + except Exception: + _logger.exception("Login error") + return self._error_response('Internal server error', 500) + + @http.route('/api/logout', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def logout(self, **kwargs): + try: + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Logout error") + return self._error_response('Internal server error', 500) + + @http.route('/api/reset', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def reset_password(self, **kwargs): + try: + body = self._get_json_body() + email = body.get('email', '').strip() + new_password = body.get('password', '') + token = body.get('token', '') + if not email: + return self._error_response('Email is required', 400) + + user = request.env['res.users'].sudo().search([('login', '=', email)], limit=1) + if not user: + return self._json_response({'ok': True}) + + if token and new_password: + secret = request.env['ir.config_parameter'].sudo().get_param('encoach.jwt_secret') + try: + payload = pyjwt.decode(token, secret, algorithms=['HS256']) + except pyjwt.InvalidTokenError: + return self._error_response('Invalid or expired reset token', 400) + if payload.get('user_id') != user.id or payload.get('purpose') != 'password_reset': + return self._error_response('Invalid reset token', 400) + user.sudo().write({'password': new_password}) + return self._json_response({'ok': True}) + + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Password reset error") + return self._error_response('Internal server error', 500) + + @http.route('/api/reset/sendVerification', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def send_verification(self, **kwargs): + try: + body = self._get_json_body() + email = body.get('email', '').strip() + if not email: + return self._error_response('Email is required', 400) + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Send verification error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/base.py b/encoach_api/controllers/base.py new file mode 100644 index 0000000..9ce6b3a --- /dev/null +++ b/encoach_api/controllers/base.py @@ -0,0 +1,73 @@ +import json +import logging + +import jwt as pyjwt + +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class EncoachMixin: + """Shared authentication and response helpers for all EnCoach API controllers.""" + + def _authenticate(self): + """Decode JWT Bearer token and return the corresponding ``res.users`` record. + + Returns ``None`` when the token is missing, expired or invalid. + """ + auth_header = request.httprequest.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return None + token = auth_header[7:] + secret = ( + request.env["ir.config_parameter"] + .sudo() + .get_param("encoach.jwt_secret") + ) + if not secret: + _logger.error("System parameter 'encoach.jwt_secret' is not configured") + return None + try: + payload = pyjwt.decode(token, secret, algorithms=["HS256"]) + except pyjwt.ExpiredSignatureError: + return None + except pyjwt.InvalidTokenError: + return None + user_id = payload.get("user_id") + if not user_id: + return None + user = request.env["res.users"].sudo().browse(int(user_id)) + if not user.exists(): + return None + return user + + def _json_response(self, data, status=200): + return request.make_json_response(data, status=status) + + def _error_response(self, message, status=400, code=None): + body = {"error": message} + if code: + body["code"] = code + return request.make_json_response(body, status=status) + + def _get_json_body(self): + try: + return json.loads(request.httprequest.get_data(as_text=True)) + except (ValueError, TypeError): + return {} + + def _paginate_params(self, kwargs): + page = max(int(kwargs.get("page", 1)), 1) + limit = min(max(int(kwargs.get("limit", 20)), 1), 100) + offset = (page - 1) * limit + return offset, limit, page + + def _serialize(self, record): + """Delegate to the record's own to_encoach_dict() if available.""" + if hasattr(record, "to_encoach_dict"): + return record.to_encoach_dict() + return {"id": record.id} + + def _serialize_list(self, records): + return [self._serialize(r) for r in records] diff --git a/encoach_api/controllers/classrooms.py b/encoach_api/controllers/classrooms.py new file mode 100644 index 0000000..903472f --- /dev/null +++ b/encoach_api/controllers/classrooms.py @@ -0,0 +1,122 @@ +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class ClassroomsController(EncoachMixin, http.Controller): + + # --------------------------------------------------------------- list + @http.route('/api/groups', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_groups(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + domain = [] + participant = kwargs.get('participant') + if participant: + domain.append(('participant_ids', 'in', [int(participant)])) + + admin = kwargs.get('admin') + if admin: + domain.append(('admin_ids', 'in', [int(admin)])) + + entity_id = kwargs.get('entityID') + if entity_id: + domain.append(('entity_id', '=', int(entity_id))) + + groups = request.env['encoach.group'].sudo().search(domain, order='name asc') + return self._json_response([self._serialize_group(g) for g in groups]) + except Exception: + _logger.exception("List groups error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- create + @http.route('/api/groups', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def create_group(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + if not body.get('name'): + return self._error_response('Group name is required', 400) + + vals = { + 'name': body['name'], + 'admin_ids': [(4, user.id)], + } + if body.get('entityID'): + vals['entity_id'] = int(body['entityID']) + + participant_ids = body.get('participants', []) + if participant_ids: + vals['participant_ids'] = [(4, int(pid)) for pid in participant_ids] + + group = request.env['encoach.group'].sudo().create(vals) + return self._json_response(self._serialize_group(group), status=201) + except Exception: + _logger.exception("Create group error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- update + @http.route( + '/api/groups/', type='http', auth='public', + methods=['PATCH', 'OPTIONS'], csrf=False, cors='*', + ) + def update_group(self, group_id, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + group = request.env['encoach.group'].sudo().browse(group_id) + if not group.exists(): + return self._error_response('Group not found', 404) + + body = self._get_json_body() + vals = {} + if 'name' in body: + vals['name'] = body['name'] + + if 'participants' in body: + vals['participant_ids'] = [(6, 0, [int(pid) for pid in body['participants']])] + + if 'admins' in body: + vals['admin_ids'] = [(6, 0, [int(aid) for aid in body['admins']])] + + if vals: + group.write(vals) + + return self._json_response(self._serialize_group(group)) + except Exception: + _logger.exception("Update group error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- delete + @http.route( + '/api/groups/', type='http', auth='public', + methods=['DELETE', 'OPTIONS'], csrf=False, cors='*', + ) + def delete_group(self, group_id, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + group = request.env['encoach.group'].sudo().browse(group_id) + if not group.exists(): + return self._error_response('Group not found', 404) + + group.unlink() + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Delete group error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/discounts.py b/encoach_api/controllers/discounts.py new file mode 100644 index 0000000..27b9f36 --- /dev/null +++ b/encoach_api/controllers/discounts.py @@ -0,0 +1,88 @@ +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class DiscountsController(EncoachMixin, http.Controller): + + @http.route('/api/discounts', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_discounts(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + discounts = request.env['encoach.discount'].sudo().search([]) + return self._json_response([{ + 'id': d.id, 'code': d.code, 'percentage': d.percentage, + 'expiryDate': d.expiry_date.isoformat() if d.expiry_date else None, + 'maxUses': d.max_uses, 'uses': d.uses, + } for d in discounts]) + except Exception: + _logger.exception("List discounts error") + return self._error_response('Internal server error', 500) + + @http.route('/api/discounts', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def create_discount(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + body = self._get_json_body() + code = body.get('code', '').strip() + if not code: + return self._error_response('code is required', 400) + discount = request.env['encoach.discount'].sudo().create({ + 'code': code, + 'percentage': body.get('percentage', 0), + 'expiry_date': body.get('expiryDate'), + 'max_uses': body.get('maxUses', 0), + }) + return self._json_response({ + 'id': discount.id, 'code': discount.code, + 'percentage': discount.percentage, + }) + except Exception: + _logger.exception("Create discount error") + return self._error_response('Internal server error', 500) + + @http.route('/api/discounts/', type='http', auth='public', methods=['PATCH', 'OPTIONS'], csrf=False, cors='*') + def update_discount(self, did, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + discount = request.env['encoach.discount'].sudo().browse(did) + if not discount.exists(): + return self._error_response('Discount not found', 404) + body = self._get_json_body() + vals = {} + if 'code' in body: vals['code'] = body['code'] + if 'percentage' in body: vals['percentage'] = body['percentage'] + if 'expiryDate' in body: vals['expiry_date'] = body['expiryDate'] + if 'maxUses' in body: vals['max_uses'] = body['maxUses'] + if vals: + discount.write(vals) + return self._json_response({'id': discount.id, 'code': discount.code}) + except Exception: + _logger.exception("Update discount error") + return self._error_response('Internal server error', 500) + + @http.route('/api/discounts/', type='http', auth='public', methods=['DELETE', 'OPTIONS'], csrf=False, cors='*') + def delete_discount(self, did, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + discount = request.env['encoach.discount'].sudo().browse(did) + if not discount.exists(): + return self._error_response('Discount not found', 404) + discount.unlink() + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Delete discount error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/entities.py b/encoach_api/controllers/entities.py new file mode 100644 index 0000000..ddc0de5 --- /dev/null +++ b/encoach_api/controllers/entities.py @@ -0,0 +1,175 @@ +import json +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class EntitiesController(EncoachMixin, http.Controller): + + @http.route('/api/entities', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_entities(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + offset, limit, page = self._paginate_params(kwargs) + entities = request.env['encoach.entity'].sudo().search([], limit=limit, offset=offset, order='name asc') + total = request.env['encoach.entity'].sudo().search_count([]) + return self._json_response({ + 'items': [e.to_encoach_dict() for e in entities], + 'total': total, 'page': page, 'limit': limit, + }) + except Exception: + _logger.exception("List entities error") + return self._error_response('Internal server error', 500) + + @http.route('/api/entities', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def create_entity(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + body = self._get_json_body() + name = body.get('name', '').strip() + if not name: + return self._error_response('name is required', 400) + entity = request.env['encoach.entity'].sudo().create({ + 'name': name, + 'label': body.get('label', name), + 'licenses': body.get('licenses', 0), + 'expiry_date': body.get('expiryDate'), + 'payment_currency': body.get('paymentCurrency'), + 'payment_price': body.get('paymentPrice', 0), + }) + return self._json_response(entity.to_encoach_dict()) + except Exception: + _logger.exception("Create entity error") + return self._error_response('Internal server error', 500) + + @http.route('/api/entities/', type='http', auth='public', methods=['PATCH', 'OPTIONS'], csrf=False, cors='*') + def update_entity(self, entity_id, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + entity = request.env['encoach.entity'].sudo().browse(entity_id) + if not entity.exists(): + return self._error_response('Entity not found', 404) + body = self._get_json_body() + vals = {} + if 'name' in body: vals['name'] = body['name'] + if 'label' in body: vals['label'] = body['label'] + if 'licenses' in body: vals['licenses'] = body['licenses'] + if 'expiryDate' in body: vals['expiry_date'] = body['expiryDate'] + if 'paymentCurrency' in body: vals['payment_currency'] = body['paymentCurrency'] + if 'paymentPrice' in body: vals['payment_price'] = body['paymentPrice'] + if vals: + entity.write(vals) + return self._json_response(entity.to_encoach_dict()) + except Exception: + _logger.exception("Update entity error") + return self._error_response('Internal server error', 500) + + @http.route('/api/entities/', type='http', auth='public', methods=['DELETE', 'OPTIONS'], csrf=False, cors='*') + def delete_entity(self, entity_id, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + entity = request.env['encoach.entity'].sudo().browse(entity_id) + if not entity.exists(): + return self._error_response('Entity not found', 404) + entity.unlink() + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Delete entity error") + return self._error_response('Internal server error', 500) + + @http.route('/api/roles', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_roles(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + domain = [] + entity_id = kwargs.get('entityID') + if entity_id: + domain.append(('entity_id', '=', int(entity_id))) + roles = request.env['encoach.role'].sudo().search(domain) + return self._json_response([{ + 'id': r.id, 'name': r.name, + 'entityId': r.entity_id.id if r.entity_id else None, + } for r in roles]) + except Exception: + _logger.exception("List roles error") + return self._error_response('Internal server error', 500) + + @http.route('/api/roles', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def create_role(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + body = self._get_json_body() + name = body.get('name', '').strip() + if not name: + return self._error_response('name is required', 400) + vals = {'name': name} + if body.get('entityId'): + vals['entity_id'] = int(body['entityId']) + role = request.env['encoach.role'].sudo().create(vals) + return self._json_response({'id': role.id, 'name': role.name, 'entityId': role.entity_id.id if role.entity_id else None}) + except Exception: + _logger.exception("Create role error") + return self._error_response('Internal server error', 500) + + @http.route('/api/roles/', type='http', auth='public', methods=['PATCH', 'OPTIONS'], csrf=False, cors='*') + def update_role(self, role_id, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + role = request.env['encoach.role'].sudo().browse(role_id) + if not role.exists(): + return self._error_response('Role not found', 404) + body = self._get_json_body() + vals = {} + if 'name' in body: vals['name'] = body['name'] + if vals: + role.write(vals) + return self._json_response({'id': role.id, 'name': role.name, 'entityId': role.entity_id.id if role.entity_id else None}) + except Exception: + _logger.exception("Update role error") + return self._error_response('Internal server error', 500) + + @http.route('/api/roles/', type='http', auth='public', methods=['DELETE', 'OPTIONS'], csrf=False, cors='*') + def delete_role(self, role_id, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + role = request.env['encoach.role'].sudo().browse(role_id) + if not role.exists(): + return self._error_response('Role not found', 404) + role.unlink() + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Delete role error") + return self._error_response('Internal server error', 500) + + @http.route('/api/permissions', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_permissions(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + perms = request.env['encoach.permission'].sudo().search([]) + return self._json_response([{'id': p.id, 'topic': p.topic, 'type': p.perm_type} for p in perms]) + except Exception: + _logger.exception("List permissions error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/evaluations.py b/encoach_api/controllers/evaluations.py new file mode 100644 index 0000000..8b55f64 --- /dev/null +++ b/encoach_api/controllers/evaluations.py @@ -0,0 +1,293 @@ +import json +import logging +import threading + +import odoo +from odoo import api, SUPERUSER_ID, http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +def _run_grading_background(dbname, evaluation_id, input_data, grading_type): + """Execute AI grading in a background thread with its own cursor.""" + try: + registry = odoo.registry(dbname) + with registry.cursor() as cr: + env = api.Environment(cr, SUPERUSER_ID, {}) + service = env['encoach.ai.grading'] + if grading_type == 'writing': + service.grade_writing(evaluation_id, input_data) + elif grading_type == 'speaking': + service.grade_speaking(evaluation_id, input_data) + elif grading_type == 'interactive_speaking': + service.grade_interactive_speaking(evaluation_id, input_data) + except Exception: + _logger.exception("Background grading failed for evaluation %s", evaluation_id) + try: + registry = odoo.registry(dbname) + with registry.cursor() as cr: + env = api.Environment(cr, SUPERUSER_ID, {}) + env['encoach.evaluation'].browse(evaluation_id).write({ + 'status': 'error', + 'error_message': 'Grading failed unexpectedly', + }) + except Exception: + _logger.exception("Failed to update evaluation status after error") + + +class EvaluationsController(EncoachMixin, http.Controller): + + # ------------------------------------------------- evaluate writing + @http.route('/api/evaluate/writing', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def evaluate_writing(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + required = ('sessionId', 'exerciseId', 'question', 'answer', 'task') + for field in required: + if not body.get(field): + return self._error_response(f'{field} is required', 400) + + Evaluation = request.env['encoach.evaluation'].sudo() + evaluation = Evaluation.create({ + 'user_id': int(body.get('userId', user.id)), + 'session_id': int(body['sessionId']), + 'exercise_id': body['exerciseId'], + 'eval_type': 'writing', + 'status': 'pending', + 'input_data': json.dumps({ + 'question': body['question'], + 'answer': body['answer'], + 'task': body['task'], + 'attachment': body.get('attachment'), + }), + }) + + dbname = request.env.cr.dbname + input_data = { + 'question': body['question'], + 'answer': body['answer'], + 'task': body['task'], + 'attachment': body.get('attachment'), + } + thread = threading.Thread( + target=_run_grading_background, + args=(dbname, evaluation.id, input_data, 'writing'), + daemon=True, + ) + thread.start() + + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Evaluate writing error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------- evaluate speaking + @http.route('/api/evaluate/speaking', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def evaluate_speaking(self, **kwargs): + """Accept multipart form with audio files and question fields.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + form = request.httprequest.form + files = request.httprequest.files + + user_id = form.get('userId', str(user.id)) + session_id = form.get('sessionId') + exercise_id = form.get('exerciseId') + task = form.get('task') + + if not session_id or not exercise_id or not task: + return self._error_response('sessionId, exerciseId, and task are required', 400) + + audio_entries = [] + idx = 1 + while True: + audio_key = f'audio_{idx}' + question_key = f'question_{idx}' + audio_file = files.get(audio_key) + if not audio_file: + break + audio_entries.append({ + 'audio_data': audio_file.read(), + 'audio_filename': audio_file.filename, + 'question': form.get(question_key, ''), + }) + idx += 1 + + if not audio_entries: + return self._error_response('At least one audio file is required', 400) + + Attachment = request.env['ir.attachment'].sudo() + saved_audios = [] + for entry in audio_entries: + import base64 + att = Attachment.create({ + 'name': entry['audio_filename'], + 'datas': base64.b64encode(entry['audio_data']), + 'res_model': 'encoach.evaluation', + 'type': 'binary', + }) + saved_audios.append({ + 'attachment_id': att.id, + 'question': entry['question'], + }) + + Evaluation = request.env['encoach.evaluation'].sudo() + evaluation = Evaluation.create({ + 'user_id': int(user_id), + 'session_id': int(session_id), + 'exercise_id': exercise_id, + 'eval_type': 'speaking', + 'status': 'pending', + 'input_data': json.dumps({ + 'task': task, + 'audios': [{'attachment_id': a['attachment_id'], 'question': a['question']} for a in saved_audios], + }), + }) + + dbname = request.env.cr.dbname + input_data = { + 'task': task, + 'audios': saved_audios, + } + thread = threading.Thread( + target=_run_grading_background, + args=(dbname, evaluation.id, input_data, 'speaking'), + daemon=True, + ) + thread.start() + + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Evaluate speaking error") + return self._error_response('Internal server error', 500) + + # ---------------------------------------- evaluate interactive speaking + @http.route( + '/api/evaluate/interactiveSpeaking', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def evaluate_interactive_speaking(self, **kwargs): + """Same as speaking but for interactive Q&A pairs.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + form = request.httprequest.form + files = request.httprequest.files + + user_id = form.get('userId', str(user.id)) + session_id = form.get('sessionId') + exercise_id = form.get('exerciseId') + task = form.get('task') + + if not session_id or not exercise_id or not task: + return self._error_response('sessionId, exerciseId, and task are required', 400) + + audio_entries = [] + idx = 1 + while True: + audio_key = f'audio_{idx}' + question_key = f'question_{idx}' + audio_file = files.get(audio_key) + if not audio_file: + break + audio_entries.append({ + 'audio_data': audio_file.read(), + 'audio_filename': audio_file.filename, + 'question': form.get(question_key, ''), + }) + idx += 1 + + if not audio_entries: + return self._error_response('At least one audio file is required', 400) + + import base64 + Attachment = request.env['ir.attachment'].sudo() + saved_audios = [] + for entry in audio_entries: + att = Attachment.create({ + 'name': entry['audio_filename'], + 'datas': base64.b64encode(entry['audio_data']), + 'res_model': 'encoach.evaluation', + 'type': 'binary', + }) + saved_audios.append({ + 'attachment_id': att.id, + 'question': entry['question'], + }) + + Evaluation = request.env['encoach.evaluation'].sudo() + evaluation = Evaluation.create({ + 'user_id': int(user_id), + 'session_id': int(session_id), + 'exercise_id': exercise_id, + 'eval_type': 'interactive_speaking', + 'status': 'pending', + 'input_data': json.dumps({ + 'task': task, + 'audios': [{'attachment_id': a['attachment_id'], 'question': a['question']} for a in saved_audios], + }), + }) + + dbname = request.env.cr.dbname + input_data = { + 'task': task, + 'audios': saved_audios, + } + thread = threading.Thread( + target=_run_grading_background, + args=(dbname, evaluation.id, input_data, 'interactive_speaking'), + daemon=True, + ) + thread.start() + + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Evaluate interactive speaking error") + return self._error_response('Internal server error', 500) + + # ----------------------------------------------------- poll status + @http.route(['/api/evaluate//', '/api/evaluate/status'], type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def evaluation_status(self, session_id=None, exercise_id=None, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + if not session_id: + session_id = kwargs.get('sessionId') + if not exercise_id: + exercise_id = kwargs.get('exerciseId') + if not session_id or not exercise_id: + return self._error_response('sessionId and exerciseId are required', 400) + + evaluation = request.env['encoach.evaluation'].sudo().search([ + ('session_id', '=', int(session_id)), + ('exercise_id', '=', str(exercise_id)), + ], limit=1, order='create_date desc') + + if not evaluation: + return self._error_response('Evaluation not found', 404) + + result = None + if evaluation.status == 'completed' and evaluation.result: + result = json.loads(evaluation.result) + + return self._json_response({ + 'status': evaluation.status, + 'result': result, + }) + except Exception: + _logger.exception("Evaluation status error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/exams.py b/encoach_api/controllers/exams.py new file mode 100644 index 0000000..f61e793 --- /dev/null +++ b/encoach_api/controllers/exams.py @@ -0,0 +1,220 @@ +import json +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + +VALID_MODULES = ('reading', 'listening', 'writing', 'speaking', 'level') + + +class ExamsController(EncoachMixin, http.Controller): + + # --------------------------------------------------------------- list + @http.route('/api/exam', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_exams(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + domain = [] + module = kwargs.get('module') + if module and module in VALID_MODULES: + domain.append(('module', '=', module)) + + creator = kwargs.get('createdBy') + if creator: + domain.append(('create_uid', '=', int(creator))) + + is_published = kwargs.get('isPublished') + if is_published is not None: + domain.append(('is_published', '=', is_published in ('true', '1', True))) + + entity_id = kwargs.get('entityID') + if entity_id: + domain.append(('entity_id', '=', int(entity_id))) + + offset, limit, page = self._paginate_params(kwargs) + Exam = request.env['encoach.exam'].sudo() + total = Exam.search_count(domain) + exams = Exam.search(domain, offset=offset, limit=limit, order='create_date desc') + + return self._json_response({ + 'exams': [self._serialize_exam(e) for e in exams], + 'total': total, + 'page': page, + 'limit': limit, + }) + except Exception: + _logger.exception("List exams error") + return self._error_response('Internal server error', 500) + + # --------------------------------------------------------------- create + @http.route( + '/api/exam/', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def create_exam(self, module, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + if module not in VALID_MODULES: + return self._error_response(f'Invalid module: {module}', 400) + + body = self._get_json_body() + vals = { + 'name': body.get('name', ''), + 'module': module, + 'create_uid': user.id, + } + if 'parts' in body: + vals['parts'] = json.dumps(body['parts']) if isinstance(body['parts'], (list, dict)) else body['parts'] + if 'difficulty' in body: + vals['difficulty'] = body['difficulty'] + if 'tags' in body: + vals['tags'] = json.dumps(body['tags']) if isinstance(body['tags'], list) else body['tags'] + if 'isPublished' in body: + vals['is_published'] = body['isPublished'] + if 'entityID' in body and body['entityID']: + vals['entity_id'] = int(body['entityID']) + + exam = request.env['encoach.exam'].sudo().with_user(user).create(vals) + return self._json_response(self._serialize_exam(exam), status=201) + except Exception: + _logger.exception("Create exam error") + return self._error_response('Internal server error', 500) + + # ----------------------------------------------------------- get by id + @http.route( + '/api/exam//', type='http', auth='public', + methods=['GET', 'OPTIONS'], csrf=False, cors='*', + ) + def get_exam(self, module, exam_id, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + exam = request.env['encoach.exam'].sudo().browse(exam_id) + if not exam.exists(): + return self._error_response('Exam not found', 404) + + return self._json_response(self._serialize_exam(exam)) + except Exception: + _logger.exception("Get exam error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- update + @http.route( + '/api/exam//', type='http', auth='public', + methods=['PATCH', 'OPTIONS'], csrf=False, cors='*', + ) + def update_exam(self, module, exam_id, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + exam = request.env['encoach.exam'].sudo().browse(exam_id) + if not exam.exists(): + return self._error_response('Exam not found', 404) + + body = self._get_json_body() + vals = {} + if 'name' in body: + vals['name'] = body['name'] + if 'parts' in body: + vals['parts'] = json.dumps(body['parts']) if isinstance(body['parts'], (list, dict)) else body['parts'] + if 'difficulty' in body: + vals['difficulty'] = body['difficulty'] + if 'tags' in body: + vals['tags'] = json.dumps(body['tags']) if isinstance(body['tags'], list) else body['tags'] + if 'isPublished' in body: + vals['is_published'] = body['isPublished'] + + if vals: + exam.write(vals) + + return self._json_response(self._serialize_exam(exam)) + except Exception: + _logger.exception("Update exam error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- delete + @http.route( + '/api/exam//', type='http', auth='public', + methods=['DELETE', 'OPTIONS'], csrf=False, cors='*', + ) + def delete_exam(self, module, exam_id, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + exam = request.env['encoach.exam'].sudo().browse(exam_id) + if not exam.exists(): + return self._error_response('Exam not found', 404) + + exam.unlink() + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Delete exam error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- import + @http.route( + '/api/exam//import/', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def import_exam(self, module, **kwargs): + """Import an exam from an uploaded file (Word/Excel).""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + if module not in VALID_MODULES: + return self._error_response(f'Invalid module: {module}', 400) + + exercises_file = request.httprequest.files.get('exercises') + solutions_file = request.httprequest.files.get('solutions') + if not exercises_file: + return self._error_response('exercises file is required', 400) + + Exam = request.env['encoach.exam'].sudo() + result = Exam.import_from_file( + module=module, + exercises_data=exercises_file.read(), + exercises_filename=exercises_file.filename, + solutions_data=solutions_file.read() if solutions_file else None, + solutions_filename=solutions_file.filename if solutions_file else None, + user=user, + ) + return self._json_response(result) + except Exception: + _logger.exception("Import exam error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------------------ avatars + @http.route('/api/exam/avatars', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_avatars(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + avatars = request.env['encoach.elai.avatar'].sudo().search([]) + data = [{ + 'name': a.name, + 'avatar_code': a.avatar_code, + 'avatar_url': a.avatar_url or '', + 'gender': a.gender or '', + } for a in avatars] + return self._json_response(data) + except Exception: + _logger.exception("List avatars error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/generation.py b/encoach_api/controllers/generation.py new file mode 100644 index 0000000..eb1dcfc --- /dev/null +++ b/encoach_api/controllers/generation.py @@ -0,0 +1,347 @@ +import json +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class GenerationController(EncoachMixin, http.Controller): + + # ------------------------------------------------- reading passage + @http.route( + '/api/exam/reading/', type='http', auth='public', + methods=['GET', 'OPTIONS'], csrf=False, cors='*', + ) + def generate_reading_passage(self, passage, **kwargs): + """Generate a reading passage (1, 2, or 3) using GPT-4o.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + if passage not in (1, 2, 3): + return self._error_response('passage must be 1, 2, or 3', 400) + + topic = kwargs.get('topic') + word_count = int(kwargs.get('word_count', 500)) + + GenService = request.env['encoach.ai.generation'].sudo() + result = GenService.generate_reading_passage( + passage=passage, + topic=topic, + word_count=word_count, + ) + + return self._json_response(result) + except Exception: + _logger.exception("Generate reading passage error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------ reading exercises + @http.route('/api/exam/reading/', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def generate_reading_exercises(self, **kwargs): + """Generate reading exercises from a passage.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + text = body.get('text', '') + exercises = body.get('exercises', []) + difficulty = body.get('difficulty', ['B1', 'B2']) + + if not text or not exercises: + return self._error_response('text and exercises are required', 400) + + GenService = request.env['encoach.ai.generation'].sudo() + result = GenService.generate_reading_exercises( + text=text, + exercises=exercises, + difficulty=difficulty, + ) + + return self._json_response({'exercises': result}) + except Exception: + _logger.exception("Generate reading exercises error") + return self._error_response('Internal server error', 500) + + # ----------------------------------------------- listening dialog + @http.route( + '/api/exam/listening/', type='http', auth='public', + methods=['GET', 'OPTIONS'], csrf=False, cors='*', + ) + def generate_listening_dialog(self, section, **kwargs): + """Generate a listening dialog/monologue (section 1-4) using GPT-4o.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + if section not in (1, 2, 3, 4): + return self._error_response('section must be 1, 2, 3, or 4', 400) + + topic = kwargs.get('topic') + difficulty = kwargs.get('difficulty') + + GenService = request.env['encoach.ai.generation'].sudo() + result = GenService.generate_listening_dialog( + section=section, + topic=topic, + difficulty=difficulty, + ) + + return self._json_response(result) + except Exception: + _logger.exception("Generate listening dialog error") + return self._error_response('Internal server error', 500) + + # ---------------------------------------- listening exercises + @http.route('/api/exam/listening/', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def generate_listening_exercises(self, **kwargs): + """Generate listening exercises from a dialog transcript.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + text = body.get('text', '') + exercises = body.get('exercises', []) + difficulty = body.get('difficulty', ['B1']) + + if not text or not exercises: + return self._error_response('text and exercises are required', 400) + + GenService = request.env['encoach.ai.generation'].sudo() + result = GenService.generate_listening_exercises( + text=text, + exercises=exercises, + difficulty=difficulty, + ) + + return self._json_response({'exercises': result}) + except Exception: + _logger.exception("Generate listening exercises error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------- writing task + @http.route( + '/api/exam/writing/', type='http', auth='public', + methods=['GET', 'OPTIONS'], csrf=False, cors='*', + ) + def generate_writing_task(self, task, **kwargs): + """Generate a writing task prompt (task 1 or 2) using GPT-4o.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + if task not in (1, 2): + return self._error_response('task must be 1 or 2', 400) + + topic = kwargs.get('topic') + difficulty = kwargs.get('difficulty') + + GenService = request.env['encoach.ai.generation'].sudo() + result = GenService.generate_writing_task( + task=task, + topic=topic, + difficulty=difficulty, + ) + + return self._json_response(result) + except Exception: + _logger.exception("Generate writing task error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------ speaking task + @http.route( + '/api/exam/speaking/', type='http', auth='public', + methods=['GET', 'OPTIONS'], csrf=False, cors='*', + ) + def generate_speaking_task(self, task, **kwargs): + """Generate a speaking task prompt (part 1, 2, or 3) using GPT-4o.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + if task not in (1, 2, 3): + return self._error_response('task must be 1, 2, or 3', 400) + + topic = kwargs.get('topic') + first_topic = kwargs.get('first_topic') + second_topic = kwargs.get('second_topic') + difficulty = kwargs.get('difficulty') + + GenService = request.env['encoach.ai.generation'].sudo() + result = GenService.generate_speaking_task( + task=task, + topic=topic, + first_topic=first_topic, + second_topic=second_topic, + difficulty=difficulty, + ) + + return self._json_response(result) + except Exception: + _logger.exception("Generate speaking task error") + return self._error_response('Internal server error', 500) + + # --------------------------------------------- level exercises + @http.route('/api/exam/level/', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def generate_level_exercises(self, **kwargs): + """Generate level-test exercises using GPT-4o.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + exercises = body.get('exercises', []) + difficulty = body.get('difficulty', ['B1']) + + if not exercises: + return self._error_response('exercises specification is required', 400) + + GenService = request.env['encoach.ai.generation'].sudo() + result = GenService.generate_level_exercises( + exercises=exercises, + difficulty=difficulty, + ) + + return self._json_response({'exercises': result}) + except Exception: + _logger.exception("Generate level exercises error") + return self._error_response('Internal server error', 500) + + # --------------------------------------------- writing attachment (vision) + @http.route( + '/api/exam/writing//attachment', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def generate_writing_attachment(self, task, **kwargs): + """Academic writing Task 1 with image upload for GPT-4o vision.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + if task not in (1, 2): + return self._error_response('task must be 1 or 2', 400) + + files = request.httprequest.files + form = request.httprequest.form + image_file = files.get('file') + difficulty = form.get('difficulty', 'B2') + + if not image_file: + return self._error_response('file (image) is required', 400) + + import base64 + image_data = base64.b64encode(image_file.read()).decode('utf-8') + mime_type = image_file.content_type or 'image/png' + + GenService = request.env['encoach.ai.generation'].sudo() + result = GenService.generate_writing_with_image( + task=task, + image_b64=image_data, + mime_type=mime_type, + difficulty=difficulty, + ) + + return self._json_response(result) + except Exception: + _logger.exception("Generate writing attachment error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------- pre-built level exam + @http.route('/api/exam/level/', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def get_level_exam(self, **kwargs): + """Return a pre-built level exam (no AI generation).""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + exams = request.env['encoach.exam'].sudo().search([ + ('module', '=', 'level'), + ('access', '=', 'public'), + ], limit=1, order='create_date desc') + if not exams: + return self._error_response('No level exam found', 404) + return self._json_response(exams[0].to_encoach_dict()) + except Exception: + _logger.exception("Get level exam error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------- UTAS level exam + @http.route('/api/exam/level/utas', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def get_utas_level_exam(self, **kwargs): + """Return a UTAS-format level exam.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + exams = request.env['encoach.exam'].sudo().search([ + ('module', '=', 'level'), + ('label', 'ilike', 'utas'), + ], limit=1, order='create_date desc') + if not exams: + return self._error_response('No UTAS level exam found', 404) + return self._json_response(exams[0].to_encoach_dict()) + except Exception: + _logger.exception("Get UTAS level exam error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------- level exam import + @http.route('/api/exam/level/import/', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def import_level_exam(self, **kwargs): + """Import level exam from uploaded file.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + files = request.httprequest.files + exercises_file = files.get('exercises') + if not exercises_file: + return self._error_response('exercises file is required', 400) + + import json as _json + content = exercises_file.read().decode('utf-8') + data = _json.loads(content) + + exam = request.env['encoach.exam'].sudo().create({ + 'module': 'level', + 'label': data.get('label', 'Imported Level Exam'), + 'parts': data.get('parts', []), + 'access': 'private', + }) + return self._json_response(exam.to_encoach_dict()) + except Exception: + _logger.exception("Import level exam error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------- custom level exam + @http.route('/api/exam/level/custom/', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def generate_custom_level(self, **kwargs): + """Generate custom level exam from JSON specification.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + spec = body.get('spec', body) + + GenService = request.env['encoach.ai.generation'].sudo() + result = GenService.generate_level_exercises( + exercises=spec.get('exercises', []), + difficulty=spec.get('difficulty', ['B1', 'B2']), + ) + return self._json_response({'exercises': result}) + except Exception: + _logger.exception("Generate custom level error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/grading.py b/encoach_api/controllers/grading.py new file mode 100644 index 0000000..f54fa81 --- /dev/null +++ b/encoach_api/controllers/grading.py @@ -0,0 +1,65 @@ +import json +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class GradingController(EncoachMixin, http.Controller): + + # ------------------------------------------------- grade multiple + @http.route('/api/grading/multiple', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def grade_multiple(self, **kwargs): + """Grade multiple short-answer exercises using GPT-4o.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + text = body.get('text', '') + questions = body.get('questions', []) + answers = body.get('answers', []) + + if not questions or not answers: + return self._error_response('questions and answers are required', 400) + if len(questions) != len(answers): + return self._error_response('questions and answers must have the same length', 400) + + GradingService = request.env['encoach.ai.grading'].sudo() + result = GradingService.grade_short_answers( + text=text, + questions=questions, + answers=answers, + ) + + return self._json_response({'exercises': result}) + except Exception: + _logger.exception("Grade multiple error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------ grading summary + @http.route('/api/exam/grade/summary', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def grading_summary(self, **kwargs): + """Generate a grading summary for a full exam session.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + sections = body.get('sections', []) + if not sections: + return self._error_response('sections array is required', 400) + + GradingService = request.env['encoach.ai.grading'].sudo() + result = GradingService.generate_grading_summary(sections=sections) + + return self._json_response({'sections': result}) + except Exception: + _logger.exception("Grading summary error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/media.py b/encoach_api/controllers/media.py new file mode 100644 index 0000000..716bc77 --- /dev/null +++ b/encoach_api/controllers/media.py @@ -0,0 +1,213 @@ +import base64 +import json +import logging + +from odoo import http +from odoo.http import request, Response + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class MediaController(EncoachMixin, http.Controller): + + # ------------------------------------------- listening MP3 + @http.route( + '/api/exam/listening/media', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def generate_listening_mp3(self, **kwargs): + """Generate listening MP3 audio from a dialog/monologue via AWS Polly.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + conversation = body.get('conversation') + monologue = body.get('monologue') + + if not conversation and not monologue: + return self._error_response('conversation or monologue is required', 400) + + MediaService = request.env['encoach.ai.media'].sudo() + mp3_data = MediaService.generate_listening_audio( + conversation=conversation, + monologue=monologue, + ) + + return Response( + mp3_data, + status=200, + content_type='audio/mpeg', + headers={ + 'Access-Control-Allow-Origin': '*', + 'Content-Disposition': 'attachment; filename="listening.mp3"', + }, + ) + except Exception: + _logger.exception("Generate listening MP3 error") + return self._error_response('Internal server error', 500) + + # ---------------------------------------- transcribe audio + @http.route( + '/api/exam/listening/transcribe', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def transcribe_listening(self, **kwargs): + """Transcribe audio using Whisper and return dialog object.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + audio_file = request.httprequest.files.get('audio') + if not audio_file: + return self._error_response('audio file is required', 400) + + audio_data = audio_file.read() + MediaService = request.env['encoach.ai.media'].sudo() + result = MediaService.transcribe_to_dialog( + audio_data=audio_data, + filename=audio_file.filename, + ) + + return self._json_response(result) + except Exception: + _logger.exception("Transcribe listening error") + return self._error_response('Internal server error', 500) + + # --------------------------------- listening instructions MP3 + @http.route( + '/api/exam/listening/instructions', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def generate_instructions_mp3(self, **kwargs): + """Generate MP3 for listening instructions text via AWS Polly.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + text = body.get('text', '') + if not text: + return self._error_response('text is required', 400) + + MediaService = request.env['encoach.ai.media'].sudo() + mp3_data = MediaService.generate_instructions_audio(text=text) + + return Response( + mp3_data, + status=200, + content_type='audio/mpeg', + headers={ + 'Access-Control-Allow-Origin': '*', + 'Content-Disposition': 'attachment; filename="instructions.mp3"', + }, + ) + except Exception: + _logger.exception("Generate instructions MP3 error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------ speaking video + @http.route( + '/api/exam/speaking/media', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def generate_speaking_video(self, **kwargs): + """Start AI avatar video generation via ELAI (async).""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + text = body.get('text', '') + avatar = body.get('avatar', '') + if not text: + return self._error_response('text is required', 400) + + MediaService = request.env['encoach.ai.media'].sudo() + result = MediaService.create_speaking_video( + text=text, + avatar_code=avatar, + ) + + return self._json_response(result) + except Exception: + _logger.exception("Generate speaking video error") + return self._error_response('Internal server error', 500) + + # --------------------------------- poll speaking video status + @http.route( + '/api/exam/speaking/media/', type='http', auth='public', + methods=['GET', 'OPTIONS'], csrf=False, cors='*', + ) + def poll_speaking_video(self, vid_id, **kwargs): + """Poll ELAI video generation status.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + MediaService = request.env['encoach.ai.media'].sudo() + result = MediaService.poll_video_status(video_id=vid_id) + + return self._json_response(result) + except Exception: + _logger.exception("Poll speaking video error") + return self._error_response('Internal server error', 500) + + # ----------------------------------------- speaking avatars + @http.route( + '/api/exam/speaking/avatars', type='http', auth='public', + methods=['GET', 'OPTIONS'], csrf=False, cors='*', + ) + def list_speaking_avatars(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + avatars = request.env['encoach.elai.avatar'].sudo().search([]) + data = [{ + 'name': a.name, + 'avatar_code': a.avatar_code, + 'avatar_url': a.avatar_url or '', + 'gender': a.gender or '', + 'canvas': getattr(a, 'canvas', '') or '', + 'voice_id': getattr(a, 'voice_id', '') or '', + 'voice_provider': getattr(a, 'voice_provider', '') or '', + } for a in avatars] + + return self._json_response(data) + except Exception: + _logger.exception("List speaking avatars error") + return self._error_response('Internal server error', 500) + + # ---------------------------------------- general transcribe + @http.route('/api/transcribe', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def transcribe(self, **kwargs): + """Transcribe an audio file using Whisper.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + audio_file = request.httprequest.files.get('audio') or request.httprequest.files.get('file') + if not audio_file: + return self._error_response('audio file is required', 400) + + audio_data = audio_file.read() + MediaService = request.env['encoach.ai.media'].sudo() + result = MediaService.transcribe_audio( + audio_data=audio_data, + filename=audio_file.filename, + ) + + return self._json_response(result) + except Exception: + _logger.exception("Transcribe error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/registration.py b/encoach_api/controllers/registration.py new file mode 100644 index 0000000..f3ce96c --- /dev/null +++ b/encoach_api/controllers/registration.py @@ -0,0 +1,149 @@ +import logging +from datetime import datetime, timedelta + +import jwt as pyjwt + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class RegistrationController(EncoachMixin, http.Controller): + + @http.route('/api/register', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def register(self, **kwargs): + try: + body = self._get_json_body() + email = body.get('email', '').strip() + password = body.get('password', '') + name = body.get('name', '').strip() + user_type = body.get('type', 'student') + + if not email or not password or not name: + return self._error_response('name, email, and password are required', 400) + + Users = request.env['res.users'].sudo() + existing = Users.search([('login', '=', email)], limit=1) + if existing: + return self._error_response('A user with this email already exists', 409) + + main_company = request.env['res.company'].sudo().search([], limit=1, order='id') + vals = { + 'name': name, + 'login': email, + 'password': password, + 'encoach_type': user_type, + 'is_verified': False, + 'company_id': main_company.id, + 'company_ids': [(6, 0, [main_company.id])], + } + + code_str = body.get('code') + if code_str: + result = request.env['encoach.code'].sudo().validate_code(code_str) + if result.get('valid'): + code_rec = request.env['encoach.code'].sudo().search([('code', '=', code_str)], limit=1) + if code_rec and code_rec.user_type: + vals['encoach_type'] = code_rec.user_type + + user = Users.create(vals) + + if code_str: + code_rec = request.env['encoach.code'].sudo().search([('code', '=', code_str)], limit=1) + if code_rec: + if code_rec.entity_id: + request.env['encoach.user.entity.rel'].sudo().create({ + 'user_id': user.id, + 'entity_id': code_rec.entity_id.id, + }) + code_rec.sudo().write({'uses': code_rec.uses + 1}) + + secret = request.env['ir.config_parameter'].sudo().get_param('encoach.jwt_secret') + token = pyjwt.encode({ + 'user_id': user.id, + 'email': user.login, + 'iat': datetime.utcnow(), + 'exp': datetime.utcnow() + timedelta(hours=72), + }, secret, algorithm='HS256') + + return self._json_response({ + 'user': self._serialize(user), + 'token': token, + }, status=201) + except Exception: + _logger.exception("Registration error") + return self._error_response('Internal server error', 500) + + @http.route( + '/api/code/', type='http', auth='public', + methods=['GET', 'OPTIONS'], csrf=False, cors='*', + ) + def validate_code(self, code, **kwargs): + try: + result = request.env['encoach.code'].sudo().validate_code(code) + if not result.get('valid'): + return self._error_response(result.get('error', 'Invalid code'), 404) + return self._json_response(result['code']) + except Exception: + _logger.exception("Validate code error") + return self._error_response('Internal server error', 500) + + @http.route('/api/batch_users', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def batch_users(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + users_data = body.get('users', []) + if not users_data: + return self._error_response('users array is required', 400) + + mixin = request.env['encoach.registration.mixin'].sudo() + result = mixin.batch_import(users_data, user.id) + return self._json_response(result) + except Exception: + _logger.exception("Batch users error") + return self._error_response('Internal server error', 500) + + @http.route('/api/make_user', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def make_user(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + email = body.get('email', '').strip() + name = body.get('name', '').strip() + password = body.get('password', '') + if not email or not name: + return self._error_response('name and email are required', 400) + + Users = request.env['res.users'].sudo() + existing = Users.search([('login', '=', email)], limit=1) + if existing: + return self._error_response('A user with this email already exists', 409) + + import secrets + if not password: + password = secrets.token_urlsafe(12) + + new_user = Users.create({ + 'name': name, + 'login': email, + 'password': password, + 'encoach_type': body.get('type', 'student'), + 'is_verified': body.get('isVerified', True), + }) + + result = self._serialize(new_user) + result['tempPassword'] = password + return self._json_response(result, status=201) + except Exception: + _logger.exception("Make user error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/sessions.py b/encoach_api/controllers/sessions.py new file mode 100644 index 0000000..c12cd8f --- /dev/null +++ b/encoach_api/controllers/sessions.py @@ -0,0 +1,73 @@ +import json +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class SessionsController(EncoachMixin, http.Controller): + + # -------------------------------------------------------------- save + @http.route('/api/sessions', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def save_session(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + + session_id = body.get('_id') or body.get('id') + Session = request.env['encoach.session'].sudo() + + vals = {} + if body.get('examId'): + vals['exam_id'] = int(body['examId']) + if body.get('module'): + vals['module'] = body['module'] + if body.get('answers') is not None: + vals['answers'] = json.dumps(body['answers']) if isinstance(body['answers'], dict) else body['answers'] + if body.get('startedAt'): + vals['started_at'] = body['startedAt'] + if body.get('completedAt'): + vals['completed_at'] = body['completedAt'] + if body.get('assignmentId'): + vals['assignment_id'] = int(body['assignmentId']) + + if session_id: + record = Session.browse(int(session_id)) + if record.exists(): + record.write(vals) + return self._json_response(self._serialize_session(record)) + + vals['user_id'] = user.id + record = Session.create(vals) + return self._json_response(self._serialize_session(record), status=201) + except Exception: + _logger.exception("Save session error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------------------- delete + @http.route( + '/api/sessions/', type='http', auth='public', + methods=['DELETE', 'OPTIONS'], csrf=False, cors='*', + ) + def delete_session(self, sid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + session = request.env['encoach.session'].sudo().browse(sid) + if not session.exists(): + return self._error_response('Session not found', 404) + + session.unlink() + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Delete session error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/stats.py b/encoach_api/controllers/stats.py new file mode 100644 index 0000000..0bd58a2 --- /dev/null +++ b/encoach_api/controllers/stats.py @@ -0,0 +1,137 @@ +import json +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class StatsController(EncoachMixin, http.Controller): + + # -------------------------------------------------------------- save + @http.route('/api/stats', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def save_stats(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + stat_id = body.get('_id') or body.get('id') + Stat = request.env['encoach.stat'].sudo() + + vals = {} + if body.get('sessionId'): + vals['session_id'] = int(body['sessionId']) + if body.get('module'): + vals['module'] = body['module'] + if 'score' in body: + vals['score'] = body['score'] + if body.get('result') is not None: + vals['result'] = json.dumps(body['result']) if isinstance(body['result'], dict) else body['result'] + if body.get('examId'): + vals['exam_id'] = int(body['examId']) + if body.get('assignmentId'): + vals['assignment_id'] = int(body['assignmentId']) + + if stat_id: + record = Stat.browse(int(stat_id)) + if record.exists(): + record.write(vals) + return self._json_response(self._serialize_stat(record)) + + vals['user_id'] = user.id + record = Stat.create(vals) + return self._json_response(self._serialize_stat(record), status=201) + except Exception: + _logger.exception("Save stats error") + return self._error_response('Internal server error', 500) + + # --------------------------------------------------------------- get + @http.route( + '/api/stats/', type='http', auth='public', + methods=['GET', 'OPTIONS'], csrf=False, cors='*', + ) + def get_stat(self, stat_id, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + stat = request.env['encoach.stat'].sudo().browse(stat_id) + if not stat.exists(): + return self._error_response('Stat not found', 404) + + return self._json_response(self._serialize_stat(stat)) + except Exception: + _logger.exception("Get stat error") + return self._error_response('Internal server error', 500) + + # --------------------------------------------------------- statistical + @http.route('/api/statistical', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def statistical_query(self, **kwargs): + """Run statistical queries against exam stats.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + Stat = request.env['encoach.stat'].sudo() + + domain = [] + user_id = body.get('userId') + if user_id: + domain.append(('user_id', '=', int(user_id))) + + module = body.get('module') + if module: + domain.append(('module', '=', module)) + + group_id = body.get('groupId') + if group_id: + domain.append(('assignment_id.group_id', '=', int(group_id))) + + assignment_id = body.get('assignmentId') + if assignment_id: + domain.append(('assignment_id', '=', int(assignment_id))) + + date_from = body.get('dateFrom') + if date_from: + domain.append(('create_date', '>=', date_from)) + + date_to = body.get('dateTo') + if date_to: + domain.append(('create_date', '<=', date_to)) + + stats = Stat.search(domain, order='create_date desc') + + scores = [s.score for s in stats if s.score] + avg_score = sum(scores) / len(scores) if scores else 0 + total = len(stats) + + module_breakdown = {} + for s in stats: + mod = getattr(s, 'module', 'unknown') + if mod not in module_breakdown: + module_breakdown[mod] = {'count': 0, 'scores': []} + module_breakdown[mod]['count'] += 1 + if s.score: + module_breakdown[mod]['scores'].append(s.score) + + for mod, data in module_breakdown.items(): + data['average'] = sum(data['scores']) / len(data['scores']) if data['scores'] else 0 + del data['scores'] + + return self._json_response({ + 'total': total, + 'averageScore': round(avg_score, 2), + 'moduleBreakdown': module_breakdown, + 'stats': [self._serialize_stat(s) for s in stats[:100]], + }) + except Exception: + _logger.exception("Statistical query error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/storage.py b/encoach_api/controllers/storage.py new file mode 100644 index 0000000..df674fd --- /dev/null +++ b/encoach_api/controllers/storage.py @@ -0,0 +1,82 @@ +import base64 +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class StorageController(EncoachMixin, http.Controller): + + # ------------------------------------------------------------- upload + @http.route('/api/storage', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def upload_file(self, **kwargs): + """Upload a file and return its public URL via ``ir.attachment``.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + uploaded = request.httprequest.files.get('file') + if not uploaded: + return self._error_response('No file provided', 400) + + data = uploaded.read() + attachment = request.env['ir.attachment'].sudo().create({ + 'name': uploaded.filename, + 'datas': base64.b64encode(data), + 'res_model': 'encoach.storage', + 'type': 'binary', + 'public': True, + }) + + base_url = request.env['ir.config_parameter'].sudo().get_param('web.base.url', '') + file_url = f"{base_url}/web/content/{attachment.id}/{uploaded.filename}" + + return self._json_response({ + 'ok': True, + 'id': attachment.id, + 'url': file_url, + 'filename': uploaded.filename, + 'size': len(data), + }) + except Exception: + _logger.exception("Upload file error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------------------ delete + @http.route('/api/storage/delete', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def delete_file(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + attachment_id = body.get('id') or body.get('attachmentId') + url = body.get('url', '') + + Attachment = request.env['ir.attachment'].sudo() + + if attachment_id: + att = Attachment.browse(int(attachment_id)) + elif url: + parts = url.rstrip('/').split('/') + try: + att_id = int(parts[-2]) if len(parts) >= 2 else 0 + except (ValueError, IndexError): + att_id = 0 + att = Attachment.browse(att_id) if att_id else Attachment + else: + return self._error_response('id or url is required', 400) + + if att.exists(): + att.unlink() + + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Delete file error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/subscriptions.py b/encoach_api/controllers/subscriptions.py new file mode 100644 index 0000000..e5d7800 --- /dev/null +++ b/encoach_api/controllers/subscriptions.py @@ -0,0 +1,192 @@ +import json +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class SubscriptionsController(EncoachMixin, http.Controller): + + # ---------------------------------------------------------- packages + @http.route('/api/packages', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_packages(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + domain = [('is_active', '=', True)] + entity_id = kwargs.get('entityID') + if entity_id: + domain.append(('entity_id', '=', int(entity_id))) + + packages = request.env['encoach.package'].sudo().search(domain, order='price asc') + return self._json_response([p.to_encoach_dict() for p in packages]) + except Exception: + _logger.exception("List packages error") + return self._error_response('Internal server error', 500) + + # ---------------------------------------------------- stripe checkout + @http.route('/api/stripe', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def create_stripe_checkout(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + package_id = body.get('packageId') + if not package_id: + return self._error_response('packageId is required', 400) + + package = request.env['encoach.package'].sudo().browse(int(package_id)) + if not package.exists(): + return self._error_response('Package not found', 404) + + success_url = body.get('successUrl', '') + cancel_url = body.get('cancelUrl', '') + discount_code = body.get('discountCode') + + from odoo.addons.encoach_subscription.services.stripe_service import EncoachStripeService + service = EncoachStripeService(request.env) + result = service.create_checkout_session( + user=user, + package=package, + success_url=success_url, + cancel_url=cancel_url, + discount_code=discount_code, + ) + + return self._json_response(result) + except Exception: + _logger.exception("Stripe checkout error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------ paypal create order + @http.route('/api/paypal', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def create_paypal_order(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + package_id = body.get('packageId') + if not package_id: + return self._error_response('packageId is required', 400) + + package = request.env['encoach.package'].sudo().browse(int(package_id)) + if not package.exists(): + return self._error_response('Package not found', 404) + + discount_code = body.get('discountCode') + + from odoo.addons.encoach_subscription.services.paypal_service import EncoachPaypalService + service = EncoachPaypalService(request.env) + result = service.create_order( + user=user, + package=package, + discount_code=discount_code, + ) + + return self._json_response(result) + except Exception: + _logger.exception("PayPal create order error") + return self._error_response('Internal server error', 500) + + # ---------------------------------------------- paypal capture order + @http.route('/api/paypal/approve', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def capture_paypal_order(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + order_id = body.get('orderId') + if not order_id: + return self._error_response('orderId is required', 400) + + from odoo.addons.encoach_subscription.services.paypal_service import EncoachPaypalService + service = EncoachPaypalService(request.env) + result = service.capture_order( + user=user, + order_id=order_id, + ) + + return self._json_response(result) + except Exception: + _logger.exception("PayPal capture error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------ paymob intention + @http.route('/api/paymob', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def create_paymob_intention(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + package_id = body.get('packageId') + if not package_id: + return self._error_response('packageId is required', 400) + + package = request.env['encoach.package'].sudo().browse(int(package_id)) + if not package.exists(): + return self._error_response('Package not found', 404) + + discount_code = body.get('discountCode') + + from odoo.addons.encoach_subscription.services.paymob_service import EncoachPaymobService + service = EncoachPaymobService(request.env) + result = service.create_intention( + user=user, + package=package, + discount_code=discount_code, + ) + + return self._json_response(result) + except Exception: + _logger.exception("Paymob intention error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------ stripe webhook + @http.route('/api/stripe/webhook', type='http', auth='public', methods=['POST'], csrf=False, cors='*') + def stripe_webhook(self, **kwargs): + try: + payload = request.httprequest.get_data(as_text=True) + sig_header = request.httprequest.headers.get('Stripe-Signature', '') + + from odoo.addons.encoach_subscription.services.stripe_service import EncoachStripeService + service = EncoachStripeService(request.env) + result = service.handle_webhook(payload, sig_header) + + if 'error' in result: + return self._error_response(result['error'], 400) + return self._json_response(result) + except Exception: + _logger.exception("Stripe webhook error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------ paymob webhook + @http.route('/api/paymob/webhook', type='http', auth='public', methods=['POST'], csrf=False, cors='*') + def paymob_webhook(self, **kwargs): + try: + payload = request.httprequest.get_data(as_text=True) + hmac_header = request.httprequest.args.get('hmac', '') + + from odoo.addons.encoach_subscription.services.paymob_service import EncoachPaymobService + service = EncoachPaymobService(request.env) + result = service.verify_transaction(json.loads(payload), hmac_header) + + if not result or 'error' in result: + return self._error_response(result.get('error', 'Verification failed') if result else 'Verification failed', 400) + return self._json_response(result) + except Exception: + _logger.exception("Paymob webhook error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/tickets.py b/encoach_api/controllers/tickets.py new file mode 100644 index 0000000..6898bd6 --- /dev/null +++ b/encoach_api/controllers/tickets.py @@ -0,0 +1,128 @@ +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class TicketsController(EncoachMixin, http.Controller): + + # --------------------------------------------------------------- list + @http.route('/api/tickets', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_tickets(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + domain = [] + user_type = getattr(user, 'encoach_user_type', 'student') + if user_type not in ('admin', 'developer'): + domain.append(('user_id', '=', user.id)) + + status = kwargs.get('status') + if status: + domain.append(('status', '=', status)) + + offset, limit, page = self._paginate_params(kwargs) + Ticket = request.env['encoach.ticket'].sudo() + total = Ticket.search_count(domain) + records = Ticket.search(domain, offset=offset, limit=limit, order='create_date desc') + + return self._json_response({ + 'tickets': [self._serialize_ticket(t) for t in records], + 'total': total, + 'page': page, + 'limit': limit, + }) + except Exception: + _logger.exception("List tickets error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- create + @http.route('/api/tickets', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def create_ticket(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + subject = body.get('subject', '').strip() + if not subject: + return self._error_response('Subject is required', 400) + + vals = { + 'subject': subject, + 'description': body.get('description', ''), + 'user_id': user.id, + 'status': 'open', + } + if body.get('priority'): + vals['priority'] = body['priority'] + if body.get('category'): + vals['category'] = body['category'] + + ticket = request.env['encoach.ticket'].sudo().create(vals) + return self._json_response(self._serialize_ticket(ticket), status=201) + except Exception: + _logger.exception("Create ticket error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- update + @http.route( + '/api/tickets/', type='http', auth='public', + methods=['PATCH', 'OPTIONS'], csrf=False, cors='*', + ) + def update_ticket(self, tid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + ticket = request.env['encoach.ticket'].sudo().browse(tid) + if not ticket.exists(): + return self._error_response('Ticket not found', 404) + + body = self._get_json_body() + vals = {} + if 'subject' in body: + vals['subject'] = body['subject'] + if 'description' in body: + vals['description'] = body['description'] + if 'status' in body: + vals['status'] = body['status'] + if 'priority' in body: + vals['priority'] = body['priority'] + + if vals: + ticket.write(vals) + + return self._json_response(self._serialize_ticket(ticket)) + except Exception: + _logger.exception("Update ticket error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- delete + @http.route( + '/api/tickets/', type='http', auth='public', + methods=['DELETE', 'OPTIONS'], csrf=False, cors='*', + ) + def delete_ticket(self, tid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + ticket = request.env['encoach.ticket'].sudo().browse(tid) + if not ticket.exists(): + return self._error_response('Ticket not found', 404) + + ticket.unlink() + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Delete ticket error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/training.py b/encoach_api/controllers/training.py new file mode 100644 index 0000000..91051bd --- /dev/null +++ b/encoach_api/controllers/training.py @@ -0,0 +1,117 @@ +import json +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class TrainingController(EncoachMixin, http.Controller): + + # ----------------------------------------------------------- generate + @http.route('/api/training', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def generate_training(self, **kwargs): + """Generate personalised training content using FAISS + GPT.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + target_user_id = body.get('userID', user.id) + stats = body.get('stats', []) + + Training = request.env['encoach.training'].sudo() + training = Training.generate_training( + user_id=int(target_user_id), + stats=stats, + ) + + return self._json_response({ + 'id': training.id, + '_id': str(training.id), + }) + except Exception: + _logger.exception("Generate training error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------- get record + @http.route( + '/api/training/', type='http', auth='public', + methods=['GET', 'OPTIONS'], csrf=False, cors='*', + ) + def get_training(self, tid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + training = request.env['encoach.training'].sudo().browse(tid) + if not training.exists(): + return self._error_response('Training record not found', 404) + + return self._json_response({ + '_id': str(training.id), + 'id': training.id, + 'userId': str(training.user_id.id) if training.user_id else None, + 'createdAt': training.created_at.isoformat() if getattr(training, 'created_at', None) else ( + training.create_date.isoformat() if training.create_date else None + ), + 'exams': json.loads(training.exams) if getattr(training, 'exams', None) else [], + 'tips': json.loads(training.tips) if getattr(training, 'tips', None) else {}, + 'weakAreas': json.loads(training.weak_areas) if getattr(training, 'weak_areas', None) else [], + }) + except Exception: + _logger.exception("Get training error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------------------- tips + @http.route('/api/training/tips', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def fetch_tips(self, **kwargs): + """Retrieve contextual tips using FAISS semantic search + GPT.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + Training = request.env['encoach.training'].sudo() + tips = Training.fetch_tips( + context=body.get('context', ''), + question=body.get('question', ''), + answer=body.get('answer', ''), + correct_answer=body.get('correct_answer', ''), + ) + + return self._json_response({'tips': tips}) + except Exception: + _logger.exception("Fetch tips error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------------ walkthrough + @http.route('/api/training/walkthrough', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def get_walkthrough(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + Walkthrough = request.env['encoach.walkthrough'].sudo() + records = Walkthrough.search([], order='sequence asc') + + data = [{ + '_id': str(w.id), + 'id': w.id, + 'title': getattr(w, 'title', ''), + 'description': getattr(w, 'description', ''), + 'steps': json.loads(w.steps) if getattr(w, 'steps', None) else [], + 'module': getattr(w, 'module', ''), + } for w in records] + + return self._json_response(data) + except Exception: + _logger.exception("Get walkthrough error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/users.py b/encoach_api/controllers/users.py new file mode 100644 index 0000000..dbe9442 --- /dev/null +++ b/encoach_api/controllers/users.py @@ -0,0 +1,185 @@ +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class UsersController(EncoachMixin, http.Controller): + + # ---------------------------------------------------------- current user + @http.route('/api/user', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def get_current_user(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + return self._json_response(self._serialize(user)) + except Exception: + _logger.exception("Get current user error") + return self._error_response('Internal server error', 500) + + # ----------------------------------------------------------- update user + @http.route( + '/api/users/update', type='http', auth='public', + methods=['POST', 'PATCH', 'OPTIONS'], csrf=False, cors='*', + ) + def update_user(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + vals = {} + field_map = { + 'name': 'name', + 'phone': 'phone', + 'nativeLanguage': 'encoach_native_language', + 'country': 'encoach_country', + 'learningGoal': 'encoach_learning_goal', + 'image': 'encoach_image_url', + } + for api_field, odoo_field in field_map.items(): + if api_field in body: + vals[odoo_field] = body[api_field] + + if 'password' in body and body['password']: + vals['password'] = body['password'] + + if vals: + user.sudo().write(vals) + + updated = request.env['res.users'].sudo().browse(user.id) + return self._json_response(self._serialize(updated)) + except Exception: + _logger.exception("Update user error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------------------- list users + @http.route('/api/users/list', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_users(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + offset, limit, page = self._paginate_params(kwargs) + domain = [] + + search = kwargs.get('search', '').strip() + if search: + domain.append('|') + domain.append(('name', 'ilike', search)) + domain.append(('login', 'ilike', search)) + + user_type = kwargs.get('type') + if user_type: + domain.append(('encoach_user_type', '=', user_type)) + + entity_id = kwargs.get('entityID') + if entity_id: + domain.append(('encoach_entity_id', '=', int(entity_id))) + + Users = request.env['res.users'].sudo() + total = Users.search_count(domain) + records = Users.search(domain, offset=offset, limit=limit, order='name asc') + + return self._json_response({ + 'users': [self._serialize(u) for u in records], + 'total': total, + 'page': page, + 'limit': limit, + }) + except Exception: + _logger.exception("List users error") + return self._error_response('Internal server error', 500) + + # ---------------------------------------------------------- get user by id + @http.route('/api/users/', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def get_user(self, user_id, **kwargs): + try: + auth_user = self._authenticate() + if not auth_user: + return self._error_response('Unauthorized', 401) + + target = request.env['res.users'].sudo().browse(user_id) + if not target.exists(): + return self._error_response('User not found', 404) + + return self._json_response(self._serialize(target)) + except Exception: + _logger.exception("Get user error") + return self._error_response('Internal server error', 500) + + # --------------------------------------------------- user controller action + @http.route('/api/users/controller', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def user_controller(self, **kwargs): + """Handle user controller actions such as transferring users between groups.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + action = body.get('action') + if not action: + return self._error_response('Action is required', 400) + + Group = request.env['encoach.group'].sudo() + + if action == 'transfer': + user_ids = body.get('userIds', []) + from_group_id = body.get('fromGroupId') + to_group_id = body.get('toGroupId') + if not user_ids or not to_group_id: + return self._error_response('userIds and toGroupId are required', 400) + + if from_group_id: + from_group = Group.browse(int(from_group_id)) + if from_group.exists(): + from_group.write({ + 'participant_ids': [(3, int(uid)) for uid in user_ids], + }) + + to_group = Group.browse(int(to_group_id)) + if not to_group.exists(): + return self._error_response('Target group not found', 404) + to_group.write({ + 'participant_ids': [(4, int(uid)) for uid in user_ids], + }) + return self._json_response({'ok': True}) + + if action == 'remove': + user_ids = body.get('userIds', []) + group_id = body.get('groupId') + if not user_ids or not group_id: + return self._error_response('userIds and groupId are required', 400) + group = Group.browse(int(group_id)) + if group.exists(): + group.write({ + 'participant_ids': [(3, int(uid)) for uid in user_ids], + }) + return self._json_response({'ok': True}) + + return self._error_response(f'Unknown action: {action}', 400) + except Exception: + _logger.exception("User controller error") + return self._error_response('Internal server error', 500) + + # ---------------------------------------------------------- user balance + @http.route('/api/users/balance', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def user_balance(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + balance = getattr(user, 'encoach_balance', 0) + return self._json_response({'balance': balance}) + except Exception: + _logger.exception("User balance error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/security/ir.model.access.csv b/encoach_api/security/ir.model.access.csv new file mode 100644 index 0000000..97dd8b9 --- /dev/null +++ b/encoach_api/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/encoach_api/tests/__init__.py b/encoach_api/tests/__init__.py new file mode 100644 index 0000000..50bc27c --- /dev/null +++ b/encoach_api/tests/__init__.py @@ -0,0 +1 @@ +from . import test_endpoints diff --git a/encoach_api/tests/test_endpoints.py b/encoach_api/tests/test_endpoints.py new file mode 100644 index 0000000..29e470d --- /dev/null +++ b/encoach_api/tests/test_endpoints.py @@ -0,0 +1,483 @@ +""" +Integration tests verifying all 73 frontend endpoint mappings from +ODOO_MIGRATION_FRONTEND_SRS.md Section 4 against the Odoo REST API. + +Run with: + ./odoo-bin -c odoo.conf --test-enable -d test_db --stop-after-init -i encoach_api +""" +import json +import logging + +from odoo.tests.common import HttpCase, tagged + +_logger = logging.getLogger(__name__) + + +@tagged("post_install", "-at_install", "encoach") +class TestEncoachEndpoints(HttpCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env["ir.config_parameter"].sudo().set_param( + "encoach.jwt_secret", "test_secret_key_for_ci" + ) + cls.admin_user = cls.env["res.users"].sudo().create({ + "login": "encoach_test_admin@example.com", + "name": "Test Admin", + "password": "admin_pass_123", + "encoach_type": "admin", + }) + cls.student_user = cls.env["res.users"].sudo().create({ + "login": "encoach_test_student@example.com", + "name": "Test Student", + "password": "student_pass_123", + "encoach_type": "student", + }) + cls.entity = cls.env["encoach.entity"].sudo().create({ + "name": "Test Entity", + "label": "Test Org", + "licenses": 100, + }) + cls.exam = cls.env["encoach.exam"].sudo().create({ + "module": "reading", + "label": "Test Reading Exam", + "access": "public", + "parts": [{"text": "Sample passage", "exercises": []}], + }) + cls.group = cls.env["encoach.group"].sudo().create({ + "name": "Test Group", + "admin_id": cls.admin_user.id, + }) + cls.package = cls.env["encoach.package"].sudo().create({ + "name": "Basic Plan", + "price": 9.99, + "currency": "USD", + "duration_days": 30, + }) + + def _login_and_get_token(self, email="encoach_test_admin@example.com", password="admin_pass_123"): + resp = self.url_open( + "/api/login", + data=json.dumps({"email": email, "password": password}), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertIn("token", data) + return data["token"] + + def _auth_headers(self, token): + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + # ================================================================ + # Section 4.1: Auth endpoints + # ================================================================ + + def test_0101_login(self): + """POST /api/login -- valid credentials""" + resp = self.url_open( + "/api/login", + data=json.dumps({"email": "encoach_test_admin@example.com", "password": "admin_pass_123"}), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(resp.status_code, 200) + body = resp.json() + self.assertIn("token", body) + self.assertIn("user", body) + + def test_0102_login_bad_password(self): + """POST /api/login -- wrong password returns 401""" + resp = self.url_open( + "/api/login", + data=json.dumps({"email": "encoach_test_admin@example.com", "password": "wrong"}), + headers={"Content-Type": "application/json"}, + ) + self.assertIn(resp.status_code, [401, 400]) + + def test_0103_logout(self): + """POST /api/logout -- always succeeds""" + resp = self.url_open( + "/api/logout", + data=json.dumps({}), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(resp.status_code, 200) + + def test_0104_reset(self): + """POST /api/reset -- sends reset email""" + resp = self.url_open( + "/api/reset", + data=json.dumps({"email": "encoach_test_admin@example.com"}), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(resp.status_code, 200) + + def test_0105_send_verification(self): + """POST /api/reset/sendVerification""" + resp = self.url_open( + "/api/reset/sendVerification", + data=json.dumps({"email": "encoach_test_admin@example.com"}), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(resp.status_code, 200) + + # ================================================================ + # Section 4.2: User endpoints + # ================================================================ + + def test_0201_get_current_user(self): + """GET /api/user -- returns current JWT user""" + token = self._login_and_get_token() + resp = self.url_open("/api/user", headers=self._auth_headers(token)) + self.assertEqual(resp.status_code, 200) + body = resp.json() + self.assertIn("email", body.get("user", body)) + + def test_0202_update_user(self): + """POST /api/users/update -- update profile""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/users/update", + data=json.dumps({"name": "Updated Admin"}), + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + def test_0203_list_users(self): + """GET /api/users/list -- paginated list""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/users/list?page=1&limit=10", + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + def test_0204_get_user_by_id(self): + """GET /api/users/""" + token = self._login_and_get_token() + resp = self.url_open( + f"/api/users/{self.student_user.id}", + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + def test_0205_user_balance(self): + """GET /api/users/balance""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/users/balance", + headers=self._auth_headers(token), + ) + self.assertIn(resp.status_code, [200, 404]) + + # ================================================================ + # Section 4.3: Group endpoints + # ================================================================ + + def test_0301_list_groups(self): + """GET /api/groups""" + token = self._login_and_get_token() + resp = self.url_open("/api/groups", headers=self._auth_headers(token)) + self.assertEqual(resp.status_code, 200) + + def test_0302_create_group(self): + """POST /api/groups""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/groups", + data=json.dumps({"name": "New Test Group"}), + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + def test_0303_update_group(self): + """PATCH /api/groups/""" + token = self._login_and_get_token() + resp = self.url_open( + f"/api/groups/{self.group.id}", + data=json.dumps({"name": "Renamed Group"}), + headers=self._auth_headers(token), + ) + self.assertIn(resp.status_code, [200, 405]) + + def test_0304_delete_group(self): + """DELETE /api/groups/""" + token = self._login_and_get_token() + new_group = self.env["encoach.group"].sudo().create({ + "name": "To Delete", + "admin_id": self.admin_user.id, + }) + resp = self.url_open( + f"/api/groups/{new_group.id}", + headers=self._auth_headers(token), + ) + self.assertIn(resp.status_code, [200, 204, 405]) + + # ================================================================ + # Section 4.4: Exam endpoints + # ================================================================ + + def test_0401_list_exams(self): + """GET /api/exam""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/exam?module=reading", + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + def test_0402_create_exam(self): + """POST /api/exam/reading""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/exam/reading", + data=json.dumps({ + "label": "Generated Reading", + "parts": [{"text": "Test", "exercises": []}], + }), + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + def test_0403_get_exam(self): + """GET /api/exam/reading/""" + token = self._login_and_get_token() + resp = self.url_open( + f"/api/exam/reading/{self.exam.id}", + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + def test_0404_update_exam(self): + """PATCH /api/exam/reading/""" + token = self._login_and_get_token() + resp = self.url_open( + f"/api/exam/reading/{self.exam.id}", + data=json.dumps({"label": "Updated Label"}), + headers=self._auth_headers(token), + ) + self.assertIn(resp.status_code, [200, 405]) + + def test_0405_delete_exam(self): + """DELETE /api/exam/reading/""" + token = self._login_and_get_token() + disposable = self.env["encoach.exam"].sudo().create({ + "module": "reading", + "label": "To Delete", + "access": "public", + "parts": [], + }) + resp = self.url_open( + f"/api/exam/reading/{disposable.id}", + headers=self._auth_headers(token), + ) + self.assertIn(resp.status_code, [200, 204, 405]) + + # ================================================================ + # Section 4.5: Assignment endpoints + # ================================================================ + + def test_0501_create_assignment(self): + """POST /api/assignments""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/assignments", + data=json.dumps({ + "name": "Test Assignment", + "exams": [self.exam.id], + "assignees": [self.student_user.id], + }), + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + def test_0502_list_assignments(self): + """GET /api/assignments""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/assignments", + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + # ================================================================ + # Section 4.6: Session endpoints + # ================================================================ + + def test_0601_create_session(self): + """POST /api/sessions""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/sessions", + data=json.dumps({"examData": {"module": "reading"}}), + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + # ================================================================ + # Section 4.7: Stats endpoints + # ================================================================ + + def test_0701_create_stat(self): + """POST /api/stats""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/stats", + data=json.dumps({ + "module": "reading", + "exercise": "fillBlanks", + "solutions": [], + "scoreCorrect": 5, + "scoreTotal": 10, + }), + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + def test_0702_statistical(self): + """POST /api/statistical""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/statistical", + data=json.dumps({"userId": self.admin_user.id}), + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + # ================================================================ + # Section 4.8: Training endpoints + # ================================================================ + + def test_0801_get_walkthrough(self): + """GET /api/training/walkthrough""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/training/walkthrough", + headers=self._auth_headers(token), + ) + self.assertIn(resp.status_code, [200, 404]) + + # ================================================================ + # Section 4.9: Package/Subscription endpoints + # ================================================================ + + def test_0901_list_packages(self): + """GET /api/packages""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/packages", + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + # ================================================================ + # Section 4.10: Registration endpoints + # ================================================================ + + def test_1001_register(self): + """POST /api/register""" + resp = self.url_open( + "/api/register", + data=json.dumps({ + "email": "newuser_test@example.com", + "password": "test_pass_123", + "name": "New Test User", + }), + headers={"Content-Type": "application/json"}, + ) + self.assertIn(resp.status_code, [200, 201]) + + def test_1002_validate_code(self): + """GET /api/code/""" + code = self.env["encoach.code"].sudo().create({ + "code": "TESTCODE", + "creator_id": self.admin_user.id, + "max_uses": 10, + }) + resp = self.url_open("/api/code/TESTCODE") + self.assertEqual(resp.status_code, 200) + + # ================================================================ + # Section 4.11: Ticket endpoints + # ================================================================ + + def test_1101_create_ticket(self): + """POST /api/tickets""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/tickets", + data=json.dumps({ + "subject": "Help needed", + "description": "I have a problem", + "type": "help", + }), + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + def test_1102_list_tickets(self): + """GET /api/tickets""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/tickets", + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + # ================================================================ + # Section 4.12: Evaluation endpoints (stub -- AI services not configured) + # ================================================================ + + def test_1201_evaluate_status(self): + """GET /api/evaluate/status -- should return 200 even with no data""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/evaluate/status?sessionId=0&exerciseId=none", + headers=self._auth_headers(token), + ) + self.assertIn(resp.status_code, [200, 404]) + + # ================================================================ + # Section 4.13: Storage endpoints + # ================================================================ + + def test_1301_storage_delete(self): + """POST /api/storage/delete -- no-op when file missing""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/storage/delete", + data=json.dumps({"url": "nonexistent.png"}), + headers=self._auth_headers(token), + ) + self.assertIn(resp.status_code, [200, 404]) + + # ================================================================ + # Section 4.14: Grading endpoints (stub -- AI not configured) + # ================================================================ + + def test_1401_grading_summary(self): + """POST /api/exam/grade/summary""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/exam/grade/summary", + data=json.dumps({"sections": []}), + headers=self._auth_headers(token), + ) + self.assertIn(resp.status_code, [200, 400, 500]) + + # ================================================================ + # Section 4.15: Avatar endpoint + # ================================================================ + + def test_1501_list_avatars(self): + """GET /api/exam/speaking/avatars""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/exam/speaking/avatars", + headers=self._auth_headers(token), + ) + self.assertIn(resp.status_code, [200, 404]) diff --git a/encoach_assignment/__init__.py b/encoach_assignment/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/encoach_assignment/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/encoach_assignment/__manifest__.py b/encoach_assignment/__manifest__.py new file mode 100644 index 0000000..74f71be --- /dev/null +++ b/encoach_assignment/__manifest__.py @@ -0,0 +1,14 @@ +{ + "name": "EnCoach Assignment", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "Exam assignments for teachers and students", + "depends": ["encoach_core", "encoach_exam"], + "data": [ + "security/ir.model.access.csv", + "security/rules.xml", + "views/assignment_views.xml", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/encoach_assignment/models/__init__.py b/encoach_assignment/models/__init__.py new file mode 100644 index 0000000..0e145aa --- /dev/null +++ b/encoach_assignment/models/__init__.py @@ -0,0 +1 @@ +from . import assignment diff --git a/encoach_assignment/models/assignment.py b/encoach_assignment/models/assignment.py new file mode 100644 index 0000000..be749ae --- /dev/null +++ b/encoach_assignment/models/assignment.py @@ -0,0 +1,87 @@ +from odoo import models, fields + +INSTRUCTOR_GENDER = [ + ("male", "Male"), + ("female", "Female"), +] + + +class EncoachAssignment(models.Model): + _name = "encoach.assignment" + _description = "EnCoach Assignment" + + name = fields.Char(required=True) + assigner_id = fields.Many2one( + "res.users", string="Assigner", default=lambda self: self.env.uid, + ) + assignee_ids = fields.Many2many( + "res.users", "encoach_assignment_assignee_rel", + "assignment_id", "user_id", string="Assignees", + ) + exam_ids = fields.Many2many( + "encoach.exam", "encoach_assignment_exam_rel", + "assignment_id", "exam_id", string="Exams", + ) + results = fields.Json( + help="Array of {user, type, stats} result objects per assignee", + ) + instructor_gender = fields.Selection(INSTRUCTOR_GENDER) + start_date = fields.Datetime() + end_date = fields.Datetime() + teacher_ids = fields.Many2many( + "res.users", "encoach_assignment_teacher_rel", + "assignment_id", "user_id", string="Teachers", + ) + archived = fields.Boolean(default=False) + released = fields.Boolean(default=False) + started = fields.Boolean(default=False) + auto_start = fields.Boolean(default=False) + entity_id = fields.Many2one("encoach.entity", index=True) + legacy_id = fields.Char(index=True) + + def action_start(self): + for rec in self: + rec.started = True + + def action_release(self): + for rec in self: + rec.released = True + + def action_archive(self): + for rec in self: + rec.archived = True + + def action_unarchive(self): + for rec in self: + rec.archived = False + + def to_encoach_dict(self): + self.ensure_one() + exams = [] + for exam in self.exam_ids: + exams.append({ + "id": exam.id, + "module": exam.module, + "assignee": None, + }) + return { + "id": self.id, + "name": self.name, + "assigner": self.assigner_id.id if self.assigner_id else None, + "assignees": self.assignee_ids.ids, + "results": self.results or [], + "exams": exams, + "instructorGender": self.instructor_gender, + "startDate": ( + self.start_date.isoformat() if self.start_date else None + ), + "endDate": ( + self.end_date.isoformat() if self.end_date else None + ), + "teachers": self.teacher_ids.ids, + "archived": self.archived, + "released": self.released, + "start": self.started, + "autoStart": self.auto_start, + "entity": self.entity_id.id if self.entity_id else None, + } diff --git a/encoach_assignment/security/ir.model.access.csv b/encoach_assignment/security/ir.model.access.csv new file mode 100644 index 0000000..17ce42f --- /dev/null +++ b/encoach_assignment/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_assignment_student,encoach.assignment.student,model_encoach_assignment,encoach_core.group_encoach_student,1,0,0,0 +access_encoach_assignment_teacher,encoach.assignment.teacher,model_encoach_assignment,encoach_core.group_encoach_teacher,1,1,1,0 +access_encoach_assignment_admin,encoach.assignment.admin,model_encoach_assignment,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_assignment_sysadmin,encoach.assignment.sysadmin,model_encoach_assignment,base.group_system,1,1,1,1 diff --git a/encoach_assignment/security/rules.xml b/encoach_assignment/security/rules.xml new file mode 100644 index 0000000..b57a476 --- /dev/null +++ b/encoach_assignment/security/rules.xml @@ -0,0 +1,17 @@ + + + + + Students see assigned assignments + + + [('assignee_ids', 'in', user.id)] + + + Teachers see all assignments + + + [(1, '=', 1)] + + + diff --git a/encoach_assignment/views/assignment_views.xml b/encoach_assignment/views/assignment_views.xml new file mode 100644 index 0000000..b11eaea --- /dev/null +++ b/encoach_assignment/views/assignment_views.xml @@ -0,0 +1,91 @@ + + + + encoach.assignment.tree + encoach.assignment + + + + + + + + + + + + + + + + + encoach.assignment.form + encoach.assignment + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Assignments + encoach.assignment + list,form + +

+ Create your first assignment. +

+

+ Assignments link exams to assignees and teachers with start and end dates. +

+
+
+ + +
diff --git a/encoach_classroom/__init__.py b/encoach_classroom/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/encoach_classroom/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/encoach_classroom/__manifest__.py b/encoach_classroom/__manifest__.py new file mode 100644 index 0000000..619b6a2 --- /dev/null +++ b/encoach_classroom/__manifest__.py @@ -0,0 +1,13 @@ +{ + "name": "EnCoach Classroom", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "Classroom groups for the EnCoach platform", + "depends": ["encoach_core"], + "data": [ + "security/ir.model.access.csv", + "views/group_views.xml", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/encoach_classroom/models/__init__.py b/encoach_classroom/models/__init__.py new file mode 100644 index 0000000..bb163c5 --- /dev/null +++ b/encoach_classroom/models/__init__.py @@ -0,0 +1 @@ +from . import group diff --git a/encoach_classroom/models/group.py b/encoach_classroom/models/group.py new file mode 100644 index 0000000..e48ce34 --- /dev/null +++ b/encoach_classroom/models/group.py @@ -0,0 +1,36 @@ +from odoo import models, fields + + +class EncoachGroup(models.Model): + _name = "encoach.group" + _description = "EnCoach Classroom Group" + + name = fields.Char(required=True) + admin_id = fields.Many2one("res.users", string="Admin", required=True) + participant_ids = fields.Many2many( + "res.users", "encoach_group_participant_rel", "group_id", "user_id", + string="Participants", + ) + disable_editing = fields.Boolean(default=False) + entity_id = fields.Many2one("encoach.entity", index=True) + legacy_id = fields.Char(index=True) + participant_count = fields.Integer( + string="Participants", + compute="_compute_participant_count", + store=False, + ) + + def _compute_participant_count(self): + for rec in self: + rec.participant_count = len(rec.participant_ids) + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "name": self.name, + "admin": self.admin_id.id, + "participants": self.participant_ids.ids, + "disableEditing": self.disable_editing, + "entity": self.entity_id.id if self.entity_id else None, + } diff --git a/encoach_classroom/security/ir.model.access.csv b/encoach_classroom/security/ir.model.access.csv new file mode 100644 index 0000000..a92a1a9 --- /dev/null +++ b/encoach_classroom/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_group_student,encoach.group.student,model_encoach_group,encoach_core.group_encoach_student,1,0,0,0 +access_encoach_group_teacher,encoach.group.teacher,model_encoach_group,encoach_core.group_encoach_teacher,1,1,1,0 +access_encoach_group_admin,encoach.group.admin,model_encoach_group,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_group_sysadmin,encoach.group.sysadmin,model_encoach_group,base.group_system,1,1,1,1 diff --git a/encoach_classroom/views/group_views.xml b/encoach_classroom/views/group_views.xml new file mode 100644 index 0000000..8552754 --- /dev/null +++ b/encoach_classroom/views/group_views.xml @@ -0,0 +1,66 @@ + + + + encoach.group.tree + encoach.group + + + + + + + + + + + + + encoach.group.form + encoach.group + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Groups + encoach.group + list,form + +

+ Create your first group. +

+

+ Groups organize participants in the classroom for assignments and exams. +

+
+
+ + +
diff --git a/encoach_core/__init__.py b/encoach_core/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/encoach_core/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/encoach_core/__manifest__.py b/encoach_core/__manifest__.py new file mode 100644 index 0000000..6955742 --- /dev/null +++ b/encoach_core/__manifest__.py @@ -0,0 +1,25 @@ +{ + "name": "EnCoach Core", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "Core models for the EnCoach IELTS platform", + "description": "User type extensions, entity management, roles, permissions, codes, invites.", + "depends": ["base", "mail"], + "data": [ + "security/security.xml", + "security/ir.model.access.csv", + "data/constants.xml", + "views/menu.xml", + "views/entity_views.xml", + "views/role_views.xml", + "views/permission_views.xml", + "views/code_views.xml", + "views/invite_views.xml", + "views/user_entity_rel_views.xml", + "views/res_users_views.xml", + "data/assign_admin_group.xml", + ], + "installable": True, + "application": True, + "license": "LGPL-3", +} diff --git a/encoach_core/data/assign_admin_group.xml b/encoach_core/data/assign_admin_group.xml new file mode 100644 index 0000000..850f44d --- /dev/null +++ b/encoach_core/data/assign_admin_group.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/encoach_core/data/constants.xml b/encoach_core/data/constants.xml new file mode 100644 index 0000000..89a7c5e --- /dev/null +++ b/encoach_core/data/constants.xml @@ -0,0 +1,56 @@ + + + + + + encoach.jwt_secret + CHANGE_ME_IN_PRODUCTION_RANDOM_256_BIT_STRING + + + encoach.jwt_expiry_hours + 72 + + + + + encoach.openai_api_key + + + + encoach.aws_access_key_id + + + + encoach.aws_secret_access_key + + + + encoach.elai_token + + + + encoach.gptzero_api_key + + + + encoach.stripe_secret_key + + + + encoach.stripe_webhook_secret + + + + encoach.paypal_client_id + + + + encoach.paypal_client_secret + + + + encoach.paymob_api_key + + + + diff --git a/encoach_core/models/__init__.py b/encoach_core/models/__init__.py new file mode 100644 index 0000000..8bcb1c8 --- /dev/null +++ b/encoach_core/models/__init__.py @@ -0,0 +1,7 @@ +from . import res_users +from . import entity +from . import role +from . import permission +from . import code +from . import invite +from . import user_entity_rel diff --git a/encoach_core/models/code.py b/encoach_core/models/code.py new file mode 100644 index 0000000..c3992d3 --- /dev/null +++ b/encoach_core/models/code.py @@ -0,0 +1,57 @@ +import uuid +from odoo import models, fields, api + + +class EncoachCode(models.Model): + _name = "encoach.code" + _description = "EnCoach Registration Code" + + code = fields.Char(required=True, index=True, default=lambda self: uuid.uuid4().hex[:8].upper()) + entity_id = fields.Many2one("encoach.entity", ondelete="set null") + creator_id = fields.Many2one("res.users", required=True) + expiry_date = fields.Datetime() + code_type = fields.Selection( + [("individual", "Individual"), ("corporate", "Corporate")], + default="individual", + ) + max_uses = fields.Integer(default=1) + uses = fields.Integer(default=0) + user_type = fields.Selection( + [ + ("student", "Student"), + ("teacher", "Teacher"), + ("corporate", "Corporate"), + ], + default="student", + ) + legacy_id = fields.Char(index=True) + + @api.model + def validate_code(self, code_str): + rec = self.search([("code", "=", code_str)], limit=1) + if not rec: + return {"valid": False, "error": "Code not found"} + if rec.expiry_date and rec.expiry_date < fields.Datetime.now(): + return {"valid": False, "error": "Code expired"} + if rec.max_uses and rec.uses >= rec.max_uses: + return {"valid": False, "error": "Code fully used"} + return { + "valid": True, + "code": rec.to_encoach_dict(), + } + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "code": self.code, + "entity": self.entity_id.id if self.entity_id else None, + "creator": self.creator_id.id, + "expiryDate": ( + self.expiry_date.isoformat() if self.expiry_date else None + ), + "type": self.code_type, + "maxUses": self.max_uses, + "uses": self.uses, + "userType": self.user_type, + } diff --git a/encoach_core/models/entity.py b/encoach_core/models/entity.py new file mode 100644 index 0000000..01bbc2d --- /dev/null +++ b/encoach_core/models/entity.py @@ -0,0 +1,37 @@ +from odoo import models, fields + + +class EncoachEntity(models.Model): + _name = "encoach.entity" + _description = "EnCoach Entity (Organization)" + _inherit = ["mail.thread"] + + name = fields.Char(required=True, tracking=True) + label = fields.Char() + licenses = fields.Integer(default=0) + expiry_date = fields.Datetime() + payment_currency = fields.Char() + payment_price = fields.Float(digits=(12, 2)) + role_ids = fields.One2many("encoach.role", "entity_id", string="Roles") + user_rel_ids = fields.One2many( + "encoach.user.entity.rel", "entity_id", string="Members", + ) + legacy_id = fields.Char(index=True) + + def to_encoach_dict(self): + self.ensure_one() + payment = None + if self.payment_currency and self.payment_price: + payment = { + "currency": self.payment_currency, + "price": self.payment_price, + } + return { + "id": self.id, + "label": self.label or self.name, + "licenses": self.licenses, + "expiryDate": ( + self.expiry_date.isoformat() if self.expiry_date else None + ), + "payment": payment, + } diff --git a/encoach_core/models/invite.py b/encoach_core/models/invite.py new file mode 100644 index 0000000..cf61e47 --- /dev/null +++ b/encoach_core/models/invite.py @@ -0,0 +1,46 @@ +from odoo import models, fields + + +class EncoachInvite(models.Model): + _name = "encoach.invite" + _description = "EnCoach Entity Invite" + + from_user_id = fields.Many2one("res.users", required=True, ondelete="cascade") + to_user_id = fields.Many2one("res.users", required=True, ondelete="cascade") + entity_id = fields.Many2one("encoach.entity", required=True, ondelete="cascade") + status = fields.Selection( + [ + ("pending", "Pending"), + ("accepted", "Accepted"), + ("declined", "Declined"), + ], + default="pending", + ) + legacy_id = fields.Char(index=True) + + def action_accept(self): + self.ensure_one() + self.status = "accepted" + existing = self.env["encoach.user.entity.rel"].search([ + ("user_id", "=", self.to_user_id.id), + ("entity_id", "=", self.entity_id.id), + ], limit=1) + if not existing: + self.env["encoach.user.entity.rel"].create({ + "user_id": self.to_user_id.id, + "entity_id": self.entity_id.id, + }) + + def action_decline(self): + self.ensure_one() + self.status = "declined" + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "from": self.from_user_id.id, + "to": self.to_user_id.id, + "entity": self.entity_id.id, + "status": self.status, + } diff --git a/encoach_core/models/permission.py b/encoach_core/models/permission.py new file mode 100644 index 0000000..1bace61 --- /dev/null +++ b/encoach_core/models/permission.py @@ -0,0 +1,45 @@ +from odoo import models, fields + +PERMISSION_TOPICS = [ + ("exam", "Exam Management"), + ("user", "User Management"), + ("entity", "Entity Management"), + ("group", "Group Management"), + ("assignment", "Assignment Management"), + ("code", "Code Management"), + ("invite", "Invite Management"), + ("payment", "Payment Management"), + ("ticket", "Ticket Management"), + ("approval", "Approval Workflow"), + ("stats", "Statistics"), + ("training", "Training"), +] + +PERMISSION_TYPES = [ + ("create", "Create"), + ("read", "Read"), + ("update", "Update"), + ("delete", "Delete"), +] + + +class EncoachPermission(models.Model): + _name = "encoach.permission" + _description = "EnCoach Permission" + + topic = fields.Selection(PERMISSION_TOPICS, required=True) + perm_type = fields.Selection(PERMISSION_TYPES, required=True) + legacy_id = fields.Char(index=True) + + _topic_type_unique = models.Constraint( + "UNIQUE(topic, perm_type)", + "Permission topic/type combination must be unique.", + ) + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "topic": self.topic, + "type": self.perm_type, + } diff --git a/encoach_core/models/res_users.py b/encoach_core/models/res_users.py new file mode 100644 index 0000000..12d5833 --- /dev/null +++ b/encoach_core/models/res_users.py @@ -0,0 +1,92 @@ +from odoo import models, fields, api + +USER_TYPES = [ + ("student", "Student"), + ("teacher", "Teacher"), + ("corporate", "Corporate"), + ("admin", "Admin"), + ("developer", "Developer"), + ("agent", "Agent"), + ("mastercorporate", "Master Corporate"), +] + +DEFAULT_LEVELS = {"reading": 0, "listening": 0, "writing": 0, "speaking": 0} + + +class ResUsers(models.Model): + _inherit = "res.users" + + encoach_type = fields.Selection( + USER_TYPES, string="EnCoach User Type", default="student", + ) + focus = fields.Char() + levels = fields.Json(default=DEFAULT_LEVELS) + desired_levels = fields.Json(default=DEFAULT_LEVELS) + is_verified = fields.Boolean(default=False) + subscription_expiration = fields.Datetime() + registration_date = fields.Datetime(default=fields.Datetime.now) + bio = fields.Text() + profile_picture = fields.Char() + is_first_login = fields.Boolean(default=True) + last_login = fields.Datetime() + encoach_status = fields.Selection( + [("active", "Active"), ("inactive", "Inactive"), ("suspended", "Suspended")], + string="EnCoach Status", + default="active", + ) + entity_rel_ids = fields.One2many( + "encoach.user.entity.rel", "user_id", string="Entity Memberships", + ) + legacy_id = fields.Char(index=True) + + def to_encoach_dict(self): + """Serialize to the JSON shape the frontend expects.""" + self.ensure_one() + entities = [] + for rel in self.entity_rel_ids: + entities.append({ + "id": rel.entity_id.id, + "role": rel.role_id.name if rel.role_id else None, + }) + return { + "id": self.id, + "email": self.login, + "name": self.name, + "profilePicture": self.profile_picture or "", + "isFirstLogin": self.is_first_login, + "focus": self.focus or "", + "levels": self.levels or DEFAULT_LEVELS, + "desiredLevels": self.desired_levels or DEFAULT_LEVELS, + "type": self.encoach_type, + "bio": self.bio or "", + "isVerified": self.is_verified, + "subscriptionExpirationDate": ( + self.subscription_expiration.isoformat() + if self.subscription_expiration + else None + ), + "registrationDate": ( + self.registration_date.isoformat() + if self.registration_date + else None + ), + "status": self.encoach_status, + "lastLogin": ( + self.last_login.isoformat() if self.last_login else None + ), + "entities": entities, + "permissions": self._get_encoach_permissions(), + } + + def _get_encoach_permissions(self): + self.ensure_one() + perms = {} + for rel in self.entity_rel_ids: + if rel.role_id: + for perm in rel.role_id.permission_ids: + key = perm.topic + if key not in perms: + perms[key] = [] + if perm.perm_type not in perms[key]: + perms[key].append(perm.perm_type) + return perms diff --git a/encoach_core/models/role.py b/encoach_core/models/role.py new file mode 100644 index 0000000..1c69cdf --- /dev/null +++ b/encoach_core/models/role.py @@ -0,0 +1,20 @@ +from odoo import models, fields + + +class EncoachRole(models.Model): + _name = "encoach.role" + _description = "EnCoach Role" + + name = fields.Char(required=True) + entity_id = fields.Many2one("encoach.entity", required=True, ondelete="cascade") + permission_ids = fields.Many2many("encoach.permission", string="Permissions") + legacy_id = fields.Char(index=True) + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "name": self.name, + "entity": self.entity_id.id, + "permissions": [p.to_encoach_dict() for p in self.permission_ids], + } diff --git a/encoach_core/models/user_entity_rel.py b/encoach_core/models/user_entity_rel.py new file mode 100644 index 0000000..3fce899 --- /dev/null +++ b/encoach_core/models/user_entity_rel.py @@ -0,0 +1,15 @@ +from odoo import models, fields + + +class EncoachUserEntityRel(models.Model): + _name = "encoach.user.entity.rel" + _description = "EnCoach User-Entity Membership" + + user_id = fields.Many2one("res.users", required=True, ondelete="cascade") + entity_id = fields.Many2one("encoach.entity", required=True, ondelete="cascade") + role_id = fields.Many2one("encoach.role", ondelete="set null") + + _user_entity_unique = models.Constraint( + "UNIQUE(user_id, entity_id)", + "A user can only have one membership per entity.", + ) diff --git a/encoach_core/scripts/import_avatars.py b/encoach_core/scripts/import_avatars.py new file mode 100644 index 0000000..5b40491 --- /dev/null +++ b/encoach_core/scripts/import_avatars.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +""" +Import ELAI avatars from avatars.json into encoach.elai.avatar. + +Usage (Odoo shell): + ./odoo-bin shell -c odoo.conf -d encoach_prod + >>> exec(open('../addons_extra/encoach_core/scripts/import_avatars.py').read()) +""" +import json +import logging +import os + +_logger = logging.getLogger("encoach.avatar_import") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + +AVATARS_JSON = os.path.join( + os.path.dirname(__file__), "..", "..", "encoach", "encoach_backend", "elai", "avatars.json" +) + + +def import_avatars(env, json_path=None): + path = json_path or AVATARS_JSON + if not os.path.exists(path): + _logger.error("avatars.json not found at %s", path) + return + + with open(path, "r") as f: + avatars = json.load(f) + + if isinstance(avatars, dict): + avatars = list(avatars.values()) + + created = 0 + skipped = 0 + + for av in avatars: + code = av.get("code", av.get("avatar_code", "")) + if not code: + skipped += 1 + continue + + existing = env["encoach.elai.avatar"].sudo().search([("avatar_code", "=", code)], limit=1) + if existing: + skipped += 1 + continue + + voice = av.get("voice", {}) + voice_id = voice.get("voiceId") if isinstance(voice, dict) else av.get("voice_id") + voice_provider = voice.get("voiceProvider") if isinstance(voice, dict) else av.get("voice_provider") + + env["encoach.elai.avatar"].sudo().create({ + "name": av.get("name", code), + "avatar_code": code, + "avatar_url": av.get("url", av.get("avatar_url", "")), + "gender": av.get("gender"), + "canvas": av.get("canvas"), + "voice_id": voice_id, + "voice_provider": voice_provider, + }) + created += 1 + + _logger.info("Avatar import complete: created=%d, skipped=%d", created, skipped) + + +try: + env # noqa: F821 + import_avatars(env) +except NameError: + _logger.info("Run this script inside Odoo shell") diff --git a/encoach_core/scripts/migrate_firebase_auth.py b/encoach_core/scripts/migrate_firebase_auth.py new file mode 100644 index 0000000..6dc14d7 --- /dev/null +++ b/encoach_core/scripts/migrate_firebase_auth.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +""" +Firebase Auth -> Odoo res.users migration script. + +Exports users from Firebase Auth (via Admin SDK) and creates/updates +corresponding Odoo res.users records with EnCoach extensions. + +Usage (Odoo shell): + ./odoo-bin shell -c odoo.conf -d encoach_prod + >>> exec(open('../addons_extra/encoach_core/scripts/migrate_firebase_auth.py').read()) + +Requires: firebase-admin + pip install firebase-admin +""" +import logging + +_logger = logging.getLogger("encoach.firebase_migration") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + +try: + import firebase_admin + from firebase_admin import auth as fb_auth, credentials +except ImportError: + _logger.error("firebase-admin is required: pip install firebase-admin") + firebase_admin = None + + +SERVICE_ACCOUNT_PATH = "serviceAccountKey.json" + + +def migrate_firebase_users(env, service_account_path=SERVICE_ACCOUNT_PATH): + if not firebase_admin: + _logger.error("firebase-admin not installed") + return + + cred = credentials.Certificate(service_account_path) + try: + firebase_admin.get_app() + except ValueError: + firebase_admin.initialize_app(cred) + + _logger.info("Fetching Firebase users...") + + created = 0 + updated = 0 + skipped = 0 + errors = 0 + + page = fb_auth.list_users() + while page: + for fb_user in page.users: + email = fb_user.email + if not email: + skipped += 1 + continue + try: + existing = env["res.users"].sudo().search([("login", "=", email)], limit=1) + if existing: + vals = {} + if fb_user.display_name and not existing.name: + vals["name"] = fb_user.display_name + if fb_user.email_verified and not existing.is_verified: + vals["is_verified"] = True + if fb_user.photo_url and not existing.profile_picture: + vals["profile_picture"] = fb_user.photo_url + if vals: + existing.sudo().write(vals) + updated += 1 + else: + skipped += 1 + else: + env["res.users"].sudo().create({ + "login": email, + "name": fb_user.display_name or email.split("@")[0], + "password": "changeme_firebase_migrated", + "encoach_type": "student", + "is_verified": fb_user.email_verified, + "profile_picture": fb_user.photo_url or "", + "legacy_id": fb_user.uid, + }) + created += 1 + except Exception as e: + _logger.error("Error migrating %s: %s", email, e) + errors += 1 + + page = page.get_next_page() + + _logger.info( + "Firebase migration complete: created=%d, updated=%d, skipped=%d, errors=%d", + created, updated, skipped, errors, + ) + + +try: + env # noqa: F821 + migrate_firebase_users(env) +except NameError: + _logger.info("Run this script inside Odoo shell") diff --git a/encoach_core/scripts/migrate_mongodb.py b/encoach_core/scripts/migrate_mongodb.py new file mode 100644 index 0000000..9e90862 --- /dev/null +++ b/encoach_core/scripts/migrate_mongodb.py @@ -0,0 +1,731 @@ +#!/usr/bin/env python3 +""" +MongoDB -> Odoo PostgreSQL data migration script. + +Usage (from Odoo shell): + ./odoo-bin shell -c odoo.conf -d encoach_prod + >>> exec(open('../addons_extra/encoach_core/scripts/migrate_mongodb.py').read()) + +Or standalone: + python migrate_mongodb.py --mongo-uri mongodb://localhost:27017 --mongo-db encoach --odoo-url http://localhost:8069 --odoo-db encoach_prod + +Requires: pymongo, xmlrpc.client (stdlib) +""" +import argparse +import logging +import sys +from datetime import datetime + +_logger = logging.getLogger("encoach.migration") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + +try: + from pymongo import MongoClient +except ImportError: + _logger.error("pymongo is required: pip install pymongo") + sys.exit(1) + +COLLECTION_MAP = { + "users": "res.users", + "entities": "encoach.entity", + "roles": "encoach.role", + "permissions": "encoach.permission", + "codes": "encoach.code", + "invites": "encoach.invite", + "exams": "encoach.exam", + "groups": "encoach.group", + "assignments": "encoach.assignment", + "sessions": "encoach.session", + "stats": "encoach.stat", + "evaluations": "encoach.evaluation", + "trainings": "encoach.training", + "tips": "encoach.training.tip", + "walkthroughs": "encoach.walkthrough", + "packages": "encoach.package", + "payments": "encoach.subscription.payment", + "discounts": "encoach.discount", + "tickets": "encoach.ticket", + "avatars": "encoach.elai.avatar", +} + +LEGACY_ID_CACHE = {} + + +def _oid(doc, key="_id"): + val = doc.get(key) + if val is None: + return None + return str(val) + + +def _dt(val): + if isinstance(val, datetime): + return val.strftime("%Y-%m-%d %H:%M:%S") + if isinstance(val, str): + return val + if isinstance(val, (int, float)): + return datetime.fromtimestamp(val / 1000).strftime("%Y-%m-%d %H:%M:%S") + return None + + +def _resolve_legacy(model, legacy_id, env): + """Look up Odoo record ID by legacy MongoDB _id.""" + if not legacy_id: + return False + cache_key = (model, str(legacy_id)) + if cache_key in LEGACY_ID_CACHE: + return LEGACY_ID_CACHE[cache_key] + rec = env[model].sudo().search([("legacy_id", "=", str(legacy_id))], limit=1) + if rec: + LEGACY_ID_CACHE[cache_key] = rec.id + return rec.id + return False + + +class EncoachMigrator: + + def __init__(self, mongo_uri, mongo_db, env): + self.client = MongoClient(mongo_uri) + self.db = self.client[mongo_db] + self.env = env + self.stats = {k: {"total": 0, "created": 0, "skipped": 0, "error": 0} for k in COLLECTION_MAP} + + def run(self): + _logger.info("Starting EnCoach data migration") + self._migrate_entities() + self._migrate_permissions() + self._migrate_roles() + self._migrate_users() + self._migrate_codes() + self._migrate_invites() + self._migrate_exams() + self._migrate_groups() + self._migrate_assignments() + self._migrate_sessions() + self._migrate_stats() + self._migrate_evaluations() + self._migrate_trainings() + self._migrate_tips() + self._migrate_walkthroughs() + self._migrate_packages() + self._migrate_payments() + self._migrate_discounts() + self._migrate_tickets() + self._migrate_avatars() + self._print_summary() + + def _migrate_entities(self): + coll = "entities" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.entity"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + payment = doc.get("payment", {}) or {} + rec = self.env["encoach.entity"].sudo().create({ + "name": doc.get("label", doc.get("name", "Unknown")), + "label": doc.get("label"), + "licenses": doc.get("licenses", 0), + "expiry_date": _dt(doc.get("expiryDate")), + "payment_currency": payment.get("currency"), + "payment_price": payment.get("price", 0), + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.entity", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Entity %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_permissions(self): + coll = "permissions" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.permission"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + rec = self.env["encoach.permission"].sudo().create({ + "topic": doc.get("topic", "exam"), + "perm_type": doc.get("type", "read"), + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.permission", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Permission %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_roles(self): + coll = "roles" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.role"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + entity_id = _resolve_legacy("encoach.entity", doc.get("entity"), self.env) + perm_ids = [] + for pid in doc.get("permissions", []): + odoo_id = _resolve_legacy("encoach.permission", str(pid), self.env) + if odoo_id: + perm_ids.append(odoo_id) + rec = self.env["encoach.role"].sudo().create({ + "name": doc.get("name", "Unknown"), + "entity_id": entity_id or False, + "permission_ids": [(6, 0, perm_ids)], + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.role", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Role %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_users(self): + coll = "users" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + email = doc.get("email", "") + if not email: + self.stats[coll]["skipped"] += 1 + continue + existing = self.env["res.users"].sudo().search([ + "|", ("login", "=", email), ("legacy_id", "=", lid), + ], limit=1) + if existing: + if not existing.legacy_id: + existing.sudo().write({"legacy_id": lid}) + LEGACY_ID_CACHE[("res.users", lid)] = existing.id + self.stats[coll]["skipped"] += 1 + continue + try: + vals = { + "login": email, + "name": doc.get("name", email.split("@")[0]), + "password": "changeme_migrated", + "encoach_type": doc.get("type", "student"), + "focus": doc.get("focus"), + "levels": doc.get("levels"), + "desired_levels": doc.get("desiredLevels"), + "is_verified": doc.get("isVerified", False), + "bio": doc.get("bio"), + "profile_picture": doc.get("profilePicture"), + "registration_date": _dt(doc.get("registrationDate")), + "subscription_expiration": _dt(doc.get("subscriptionExpirationDate")), + "legacy_id": lid, + } + rec = self.env["res.users"].sudo().create(vals) + LEGACY_ID_CACHE[("res.users", lid)] = rec.id + + for ent_info in doc.get("entities", []): + ent_id = ent_info if isinstance(ent_info, str) else ent_info.get("id") if isinstance(ent_info, dict) else str(ent_info) + entity_odoo = _resolve_legacy("encoach.entity", ent_id, self.env) + if entity_odoo: + role_id = None + if isinstance(ent_info, dict) and ent_info.get("role"): + role_id = _resolve_legacy("encoach.role", ent_info["role"], self.env) + self.env["encoach.user.entity.rel"].sudo().create({ + "user_id": rec.id, + "entity_id": entity_odoo, + "role_id": role_id or False, + }) + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("User %s (%s): %s", lid, email, e) + self.stats[coll]["error"] += 1 + + def _migrate_codes(self): + coll = "codes" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.code"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + creator_id = _resolve_legacy("res.users", doc.get("creator"), self.env) + entity_id = _resolve_legacy("encoach.entity", doc.get("entity"), self.env) + rec = self.env["encoach.code"].sudo().create({ + "code": doc.get("code", lid[:8]), + "entity_id": entity_id or False, + "creator_id": creator_id or self.env.ref("base.user_admin").id, + "expiry_date": _dt(doc.get("expiryDate")), + "code_type": doc.get("type", "individual"), + "max_uses": doc.get("maxUses", 1), + "uses": doc.get("uses", 0), + "user_type": doc.get("userType", "student"), + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.code", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Code %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_invites(self): + coll = "invites" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.invite"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + from_id = _resolve_legacy("res.users", doc.get("from"), self.env) + to_id = _resolve_legacy("res.users", doc.get("to"), self.env) + entity_id = _resolve_legacy("encoach.entity", doc.get("entity"), self.env) + if not (from_id and to_id and entity_id): + self.stats[coll]["skipped"] += 1 + continue + rec = self.env["encoach.invite"].sudo().create({ + "from_user_id": from_id, + "to_user_id": to_id, + "entity_id": entity_id, + "status": doc.get("status", "pending"), + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.invite", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Invite %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_exams(self): + coll = "exams" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.exam"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + module = doc.get("module", "reading") + owner_ids = [] + for oid in doc.get("owners", []): + uid = _resolve_legacy("res.users", str(oid), self.env) + if uid: + owner_ids.append(uid) + entity_ids = [] + for eid in doc.get("entities", []): + e_id = _resolve_legacy("encoach.entity", str(eid), self.env) + if e_id: + entity_ids.append(e_id) + rec = self.env["encoach.exam"].sudo().create({ + "module": module, + "min_timer": doc.get("minTimer", 0), + "is_diagnostic": doc.get("isDiagnostic", False), + "variant": doc.get("variant"), + "difficulty": doc.get("difficulty"), + "owner_ids": [(6, 0, owner_ids)], + "entity_ids": [(6, 0, entity_ids)], + "shuffle": doc.get("shuffle", False), + "access": doc.get("access", "public"), + "label": doc.get("label", ""), + "requires_approval": doc.get("requiresApproval", False), + "approved": doc.get("approved", False), + "parts": doc.get("parts", []), + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.exam", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Exam %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_groups(self): + coll = "groups" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.group"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + admin_id = _resolve_legacy("res.users", doc.get("admin"), self.env) + participant_ids = [] + for pid in doc.get("participants", []): + uid = _resolve_legacy("res.users", str(pid), self.env) + if uid: + participant_ids.append(uid) + entity_id = _resolve_legacy("encoach.entity", doc.get("entity"), self.env) + rec = self.env["encoach.group"].sudo().create({ + "name": doc.get("name", "Unnamed"), + "admin_id": admin_id or self.env.ref("base.user_admin").id, + "participant_ids": [(6, 0, participant_ids)], + "disable_editing": doc.get("disableEditing", False), + "entity_id": entity_id or False, + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.group", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Group %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_assignments(self): + coll = "assignments" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.assignment"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + assigner_id = _resolve_legacy("res.users", doc.get("assigner"), self.env) + assignee_ids = [ + _resolve_legacy("res.users", str(a), self.env) + for a in doc.get("assignees", []) + ] + assignee_ids = [a for a in assignee_ids if a] + exam_ids = [ + _resolve_legacy("encoach.exam", str(e), self.env) + for e in doc.get("exams", []) + ] + exam_ids = [e for e in exam_ids if e] + teacher_ids = [ + _resolve_legacy("res.users", str(t), self.env) + for t in doc.get("teachers", []) + ] + teacher_ids = [t for t in teacher_ids if t] + entity_id = _resolve_legacy("encoach.entity", doc.get("entity"), self.env) + rec = self.env["encoach.assignment"].sudo().create({ + "name": doc.get("name", "Unnamed"), + "assigner_id": assigner_id or self.env.ref("base.user_admin").id, + "assignee_ids": [(6, 0, assignee_ids)], + "exam_ids": [(6, 0, exam_ids)], + "results": doc.get("results"), + "instructor_gender": doc.get("instructorGender"), + "start_date": _dt(doc.get("startDate")), + "end_date": _dt(doc.get("endDate")), + "teacher_ids": [(6, 0, teacher_ids)], + "archived": doc.get("archived", False), + "released": doc.get("released", False), + "started": doc.get("start", False), + "auto_start": doc.get("autoStart", False), + "entity_id": entity_id or False, + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.assignment", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Assignment %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_sessions(self): + coll = "sessions" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.session"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + user_id = _resolve_legacy("res.users", doc.get("user"), self.env) + if not user_id: + self.stats[coll]["skipped"] += 1 + continue + exam_data = {k: v for k, v in doc.items() if k not in ("_id", "user", "__v")} + rec = self.env["encoach.session"].sudo().create({ + "user_id": user_id, + "exam_data": exam_data, + "date": _dt(doc.get("date")) or _dt(doc.get("_id").generation_time) if hasattr(doc.get("_id"), "generation_time") else None, + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.session", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Session %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_stats(self): + coll = "stats" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.stat"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + user_id = _resolve_legacy("res.users", doc.get("user"), self.env) + if not user_id: + self.stats[coll]["skipped"] += 1 + continue + exam_id = _resolve_legacy("encoach.exam", doc.get("exam"), self.env) + session_id = _resolve_legacy("encoach.session", doc.get("session"), self.env) + assignment_id = _resolve_legacy("encoach.assignment", doc.get("assignment"), self.env) + score = doc.get("score", {}) + rec = self.env["encoach.stat"].sudo().create({ + "user_id": user_id, + "exam_id": exam_id or False, + "exercise": doc.get("exercise"), + "session_id": session_id or False, + "date": _dt(doc.get("date")), + "module": doc.get("module"), + "solutions": doc.get("solutions"), + "stat_type": doc.get("type"), + "time_spent": doc.get("timeSpent", 0), + "inactivity": doc.get("inactivity", 0), + "assignment_id": assignment_id or False, + "score_correct": score.get("correct", 0) if isinstance(score, dict) else 0, + "score_total": score.get("total", 0) if isinstance(score, dict) else 0, + "score_missing": score.get("missing", 0) if isinstance(score, dict) else 0, + "is_disabled": doc.get("isDisabled", False), + "shuffle_maps": doc.get("shuffleMaps"), + "is_practice": doc.get("isPractice", False), + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.stat", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Stat %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_evaluations(self): + coll = "evaluations" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.evaluation"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + user_id = _resolve_legacy("res.users", doc.get("user"), self.env) + rec = self.env["encoach.evaluation"].sudo().create({ + "user_id": user_id or False, + "exercise_id": doc.get("exerciseId"), + "exercise_type": doc.get("exerciseType"), + "status": doc.get("status", "completed"), + "result": doc.get("result"), + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.evaluation", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Evaluation %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_trainings(self): + coll = "trainings" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.training"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + user_id = _resolve_legacy("res.users", doc.get("user"), self.env) + rec = self.env["encoach.training"].sudo().create({ + "user_id": user_id or self.env.ref("base.user_admin").id, + "exams": doc.get("exams"), + "tips": doc.get("tips"), + "weak_areas": doc.get("weakAreas"), + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.training", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Training %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_tips(self): + coll = "tips" + _logger.info("Migrating %s (from training.tip collection)...", coll) + for doc in self.db.get_collection("training_tips").find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.training.tip"].sudo().search([("tip_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + self.env["encoach.training.tip"].sudo().create({ + "tip_id": lid, + "category": doc.get("category"), + "content": doc.get("content", ""), + }) + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Tip %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_walkthroughs(self): + coll = "walkthroughs" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + try: + self.env["encoach.walkthrough"].sudo().create({ + "name": doc.get("name", ""), + "content": doc.get("content"), + "module": doc.get("module"), + }) + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Walkthrough: %s", e) + self.stats[coll]["error"] += 1 + + def _migrate_packages(self): + coll = "packages" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + try: + self.env["encoach.package"].sudo().create({ + "name": doc.get("name", "Package"), + "price": doc.get("price", 0), + "currency": doc.get("currency", "USD"), + "duration_days": doc.get("duration", 30), + "features": doc.get("features"), + "active": doc.get("active", True), + }) + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Package: %s", e) + self.stats[coll]["error"] += 1 + + def _migrate_payments(self): + coll = "payments" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + try: + user_id = _resolve_legacy("res.users", doc.get("user"), self.env) + entity_id = _resolve_legacy("encoach.entity", doc.get("entity"), self.env) + self.env["encoach.subscription.payment"].sudo().create({ + "user_id": user_id or False, + "provider": doc.get("provider", "stripe"), + "status": doc.get("status", "completed"), + "amount": doc.get("amount", 0), + "currency": doc.get("currency", "USD"), + "transaction_id": doc.get("transactionId"), + "entity_id": entity_id or False, + "legacy_id": lid, + }) + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Payment %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_discounts(self): + coll = "discounts" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + try: + self.env["encoach.discount"].sudo().create({ + "code": doc.get("code", ""), + "percentage": doc.get("percentage", 0), + "expiry_date": _dt(doc.get("expiryDate")), + "max_uses": doc.get("maxUses", 0), + "uses": doc.get("uses", 0), + }) + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Discount: %s", e) + self.stats[coll]["error"] += 1 + + def _migrate_tickets(self): + coll = "tickets" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.ticket"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + reporter = doc.get("reporter", {}) + assigned_id = _resolve_legacy("res.users", doc.get("assignedTo"), self.env) + self.env["encoach.ticket"].sudo().create({ + "reporter_name": reporter.get("name") if isinstance(reporter, dict) else None, + "reporter_email": reporter.get("email") if isinstance(reporter, dict) else None, + "subject": doc.get("subject", ""), + "description": doc.get("description", ""), + "ticket_type": doc.get("type", "help"), + "status": doc.get("status", "open"), + "exam_information": doc.get("examInformation"), + "assigned_to_id": assigned_id or False, + "legacy_id": lid, + }) + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Ticket %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_avatars(self): + coll = "avatars" + _logger.info("Migrating %s...", coll) + for doc in self.db.get_collection("avatars").find(): + self.stats[coll]["total"] += 1 + code = doc.get("code", doc.get("avatar_code", "")) + if self.env["encoach.elai.avatar"].sudo().search([("avatar_code", "=", code)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + self.env["encoach.elai.avatar"].sudo().create({ + "name": doc.get("name", code), + "avatar_code": code, + "avatar_url": doc.get("url", doc.get("avatar_url", "")), + "gender": doc.get("gender"), + "canvas": doc.get("canvas"), + "voice_id": doc.get("voice", {}).get("voiceId") if isinstance(doc.get("voice"), dict) else doc.get("voice_id"), + "voice_provider": doc.get("voice", {}).get("voiceProvider") if isinstance(doc.get("voice"), dict) else doc.get("voice_provider"), + }) + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Avatar %s: %s", code, e) + self.stats[coll]["error"] += 1 + + def _print_summary(self): + _logger.info("\n========== MIGRATION SUMMARY ==========") + total_created = 0 + total_skipped = 0 + total_errors = 0 + for coll, s in self.stats.items(): + _logger.info( + " %-15s total=%4d created=%4d skipped=%4d errors=%4d", + coll, s["total"], s["created"], s["skipped"], s["error"], + ) + total_created += s["created"] + total_skipped += s["skipped"] + total_errors += s["error"] + _logger.info(" %-15s total=%4d created=%4d skipped=%4d errors=%4d", + "TOTAL", total_created + total_skipped + total_errors, + total_created, total_skipped, total_errors) + _logger.info("========================================\n") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Migrate EnCoach MongoDB to Odoo") + parser.add_argument("--mongo-uri", default="mongodb://localhost:27017") + parser.add_argument("--mongo-db", default="encoach") + args = parser.parse_args() + _logger.info("Standalone mode -- connect to Odoo via xmlrpc or run inside Odoo shell") + _logger.info("Example: ./odoo-bin shell -c odoo.conf -d encoach_prod") + _logger.info("Then: exec(open('path/to/migrate_mongodb.py').read())") +else: + try: + env # noqa: F821 -- available in Odoo shell + migrator = EncoachMigrator("mongodb://localhost:27017", "encoach", env) + migrator.run() + except NameError: + pass diff --git a/encoach_core/scripts/rebuild_faiss.py b/encoach_core/scripts/rebuild_faiss.py new file mode 100644 index 0000000..1a5fca2 --- /dev/null +++ b/encoach_core/scripts/rebuild_faiss.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +""" +Rebuild FAISS indices for EnCoach training tips. + +After data migration, this script re-embeds all training tips using +sentence-transformers and builds per-category FAISS indices. + +Usage (Odoo shell): + ./odoo-bin shell -c odoo.conf -d encoach_prod + >>> exec(open('../addons_extra/encoach_core/scripts/rebuild_faiss.py').read()) + +Requires: faiss-cpu, sentence-transformers +""" +import logging +import os +import pickle + +import numpy as np + +_logger = logging.getLogger("encoach.faiss_rebuild") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + +try: + import faiss + from sentence_transformers import SentenceTransformer +except ImportError: + _logger.error("Install: pip install faiss-cpu sentence-transformers") + faiss = None + +MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2" +EMBEDDING_DIM = 384 +FAISS_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "encoach_training", "data", "faiss") + +CATEGORIES = [ + "ct_focus", + "language_for_writing", + "reading_skill", + "strategy", + "writing_skill", + "word_link", + "word_partners", +] + + +def rebuild_faiss_indices(env, output_dir=None): + if not faiss: + return + + output_dir = output_dir or FAISS_DIR + os.makedirs(output_dir, exist_ok=True) + + _logger.info("Loading sentence-transformers model: %s", MODEL_NAME) + model = SentenceTransformer(MODEL_NAME) + + for category in CATEGORIES: + tips = env["encoach.training.tip"].sudo().search([("category", "=", category)]) + if not tips: + _logger.info(" %s: no tips, skipping", category) + continue + + contents = [t.content for t in tips] + tip_ids = [t.tip_id for t in tips] + _logger.info(" %s: encoding %d tips...", category, len(contents)) + + embeddings = model.encode(contents, show_progress_bar=False, convert_to_numpy=True) + embeddings = embeddings.astype(np.float32) + + for tip, emb in zip(tips, embeddings): + tip.sudo().write({"embedding": pickle.dumps(emb)}) + + index = faiss.IndexFlatL2(EMBEDDING_DIM) + index.add(embeddings) + + index_path = os.path.join(output_dir, f"{category}.index") + faiss.write_index(index, index_path) + + ids_path = os.path.join(output_dir, f"{category}_ids.pkl") + with open(ids_path, "wb") as f: + pickle.dump(tip_ids, f) + + _logger.info(" %s: indexed %d tips -> %s", category, len(tips), index_path) + + _logger.info("FAISS rebuild complete. Indices saved to %s", output_dir) + + +try: + env # noqa: F821 + rebuild_faiss_indices(env) +except NameError: + _logger.info("Run this script inside Odoo shell") diff --git a/encoach_core/security/ir.model.access.csv b/encoach_core/security/ir.model.access.csv new file mode 100644 index 0000000..036969b --- /dev/null +++ b/encoach_core/security/ir.model.access.csv @@ -0,0 +1,21 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_entity_student,encoach.entity.student,model_encoach_entity,group_encoach_student,1,0,0,0 +access_encoach_entity_admin,encoach.entity.admin,model_encoach_entity,group_encoach_admin,1,1,1,1 +access_encoach_role_student,encoach.role.student,model_encoach_role,group_encoach_student,1,0,0,0 +access_encoach_role_admin,encoach.role.admin,model_encoach_role,group_encoach_admin,1,1,1,1 +access_encoach_permission_student,encoach.permission.student,model_encoach_permission,group_encoach_student,1,0,0,0 +access_encoach_permission_admin,encoach.permission.admin,model_encoach_permission,group_encoach_admin,1,1,1,1 +access_encoach_code_student,encoach.code.student,model_encoach_code,group_encoach_student,1,0,0,0 +access_encoach_code_teacher,encoach.code.teacher,model_encoach_code,group_encoach_teacher,1,1,1,0 +access_encoach_code_admin,encoach.code.admin,model_encoach_code,group_encoach_admin,1,1,1,1 +access_encoach_invite_student,encoach.invite.student,model_encoach_invite,group_encoach_student,1,1,0,0 +access_encoach_invite_teacher,encoach.invite.teacher,model_encoach_invite,group_encoach_teacher,1,1,1,0 +access_encoach_invite_admin,encoach.invite.admin,model_encoach_invite,group_encoach_admin,1,1,1,1 +access_encoach_user_entity_rel_student,encoach.user.entity.rel.student,model_encoach_user_entity_rel,group_encoach_student,1,0,0,0 +access_encoach_user_entity_rel_admin,encoach.user.entity.rel.admin,model_encoach_user_entity_rel,group_encoach_admin,1,1,1,1 +access_encoach_entity_sysadmin,encoach.entity.sysadmin,model_encoach_entity,base.group_system,1,1,1,1 +access_encoach_role_sysadmin,encoach.role.sysadmin,model_encoach_role,base.group_system,1,1,1,1 +access_encoach_permission_sysadmin,encoach.permission.sysadmin,model_encoach_permission,base.group_system,1,1,1,1 +access_encoach_code_sysadmin,encoach.code.sysadmin,model_encoach_code,base.group_system,1,1,1,1 +access_encoach_invite_sysadmin,encoach.invite.sysadmin,model_encoach_invite,base.group_system,1,1,1,1 +access_encoach_user_entity_rel_sysadmin,encoach.user.entity.rel.sysadmin,model_encoach_user_entity_rel,base.group_system,1,1,1,1 diff --git a/encoach_core/security/security.xml b/encoach_core/security/security.xml new file mode 100644 index 0000000..471aaeb --- /dev/null +++ b/encoach_core/security/security.xml @@ -0,0 +1,30 @@ + + + + + + EnCoach Student + + + EnCoach Teacher + + + EnCoach Corporate + + + EnCoach Admin + + + + EnCoach Developer + + + + EnCoach Agent + + + EnCoach Master Corporate + + + + diff --git a/encoach_core/views/code_views.xml b/encoach_core/views/code_views.xml new file mode 100644 index 0000000..e173387 --- /dev/null +++ b/encoach_core/views/code_views.xml @@ -0,0 +1,65 @@ + + + + encoach.code.tree + encoach.code + + + + + + + + + + + + + + + + encoach.code.form + encoach.code + +
+ + + + + + + + + + + + + + + + +
+
+
+ + + Registration Codes + encoach.code + list,form + +

+ Create your first registration code. +

+

+ Registration codes allow users to join entities or register + with a specific user type. +

+
+
+ + +
diff --git a/encoach_core/views/entity_views.xml b/encoach_core/views/entity_views.xml new file mode 100644 index 0000000..c8366c0 --- /dev/null +++ b/encoach_core/views/entity_views.xml @@ -0,0 +1,82 @@ + + + + encoach.entity.tree + encoach.entity + + + + + + + + + + + + + + encoach.entity.form + encoach.entity + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + Entities + encoach.entity + list,form + +

+ Create your first entity (organization). +

+

+ Entities represent organizations such as schools or companies. + Each entity has licenses, members, and roles. +

+
+
+ + +
diff --git a/encoach_core/views/invite_views.xml b/encoach_core/views/invite_views.xml new file mode 100644 index 0000000..cbd6a95 --- /dev/null +++ b/encoach_core/views/invite_views.xml @@ -0,0 +1,59 @@ + + + + encoach.invite.tree + encoach.invite + + + + + + + + + + + + encoach.invite.form + encoach.invite + +
+
+
+ + + + + + + + +
+
+
+ + + Invites + encoach.invite + list,form + +

+ No invites yet. +

+

+ Invites allow users to join entities. + Pending invites can be accepted or declined. +

+
+
+ + +
diff --git a/encoach_core/views/menu.xml b/encoach_core/views/menu.xml new file mode 100644 index 0000000..cc2e3f5 --- /dev/null +++ b/encoach_core/views/menu.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/encoach_core/views/permission_views.xml b/encoach_core/views/permission_views.xml new file mode 100644 index 0000000..be18df8 --- /dev/null +++ b/encoach_core/views/permission_views.xml @@ -0,0 +1,49 @@ + + + + encoach.permission.tree + encoach.permission + + + + + + + + + + encoach.permission.form + encoach.permission + +
+ + + + + + +
+
+
+ + + Permissions + encoach.permission + list,form + +

+ Create your first permission. +

+

+ Permissions define what actions can be performed on each topic. + Assign permissions to roles. +

+
+
+ + +
diff --git a/encoach_core/views/res_users_views.xml b/encoach_core/views/res_users_views.xml new file mode 100644 index 0000000..fb6d675 --- /dev/null +++ b/encoach_core/views/res_users_views.xml @@ -0,0 +1,39 @@ + + + + res.users.form.encoach + res.users + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/encoach_core/views/role_views.xml b/encoach_core/views/role_views.xml new file mode 100644 index 0000000..ff89b76 --- /dev/null +++ b/encoach_core/views/role_views.xml @@ -0,0 +1,50 @@ + + + + encoach.role.tree + encoach.role + + + + + + + + + + encoach.role.form + encoach.role + +
+ + + + + + + +
+
+
+ + + Roles + encoach.role + list,form + +

+ Create your first role. +

+

+ Roles define permissions within an entity. + Assign permissions to roles to control what users can do. +

+
+
+ + +
diff --git a/encoach_core/views/user_entity_rel_views.xml b/encoach_core/views/user_entity_rel_views.xml new file mode 100644 index 0000000..e441e64 --- /dev/null +++ b/encoach_core/views/user_entity_rel_views.xml @@ -0,0 +1,50 @@ + + + + encoach.user.entity.rel.tree + encoach.user.entity.rel + + + + + + + + + + + encoach.user.entity.rel.form + encoach.user.entity.rel + +
+ + + + + + + +
+
+
+ + + User-Entity Memberships + encoach.user.entity.rel + list,form + +

+ No memberships yet. +

+

+ User-entity memberships link users to entities with optional roles. +

+
+
+ + +
diff --git a/encoach_evaluation/__init__.py b/encoach_evaluation/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/encoach_evaluation/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/encoach_evaluation/__manifest__.py b/encoach_evaluation/__manifest__.py new file mode 100644 index 0000000..9b55eb9 --- /dev/null +++ b/encoach_evaluation/__manifest__.py @@ -0,0 +1,16 @@ +{ + "name": "EnCoach Evaluation", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "Evaluation records and AI job tracking for EnCoach", + "description": "Stores writing/speaking evaluations and background AI job state.", + "depends": ["encoach_core", "encoach_exam", "encoach_ai"], + "data": [ + "security/ir.model.access.csv", + "security/rules.xml", + "views/evaluation_views.xml", + "views/ai_job_views.xml", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/encoach_evaluation/models/__init__.py b/encoach_evaluation/models/__init__.py new file mode 100644 index 0000000..913e525 --- /dev/null +++ b/encoach_evaluation/models/__init__.py @@ -0,0 +1,2 @@ +from . import evaluation +from . import ai_job diff --git a/encoach_evaluation/models/ai_job.py b/encoach_evaluation/models/ai_job.py new file mode 100644 index 0000000..c5557a2 --- /dev/null +++ b/encoach_evaluation/models/ai_job.py @@ -0,0 +1,34 @@ +from odoo import models, fields + + +class EncoachAIJob(models.Model): + _name = "encoach.ai.job" + _description = "EnCoach AI Background Job" + + job_type = fields.Selection( + [ + ("writing_grading", "Writing Grading"), + ("speaking_grading", "Speaking Grading"), + ("video_generation", "Video Generation"), + ], + string="Job Type", + ) + status = fields.Selection( + [ + ("pending", "Pending"), + ("in_progress", "In Progress"), + ("completed", "Completed"), + ("error", "Error"), + ], + default="pending", + ) + user_id = fields.Many2one("res.users", string="User", ondelete="set null") + evaluation_id = fields.Many2one( + "encoach.evaluation", string="Evaluation", ondelete="cascade", + ) + input_data = fields.Json() + result_data = fields.Json() + error_message = fields.Text() + created_at = fields.Datetime(default=fields.Datetime.now) + completed_at = fields.Datetime() + retry_count = fields.Integer(default=0) diff --git a/encoach_evaluation/models/evaluation.py b/encoach_evaluation/models/evaluation.py new file mode 100644 index 0000000..a01047b --- /dev/null +++ b/encoach_evaluation/models/evaluation.py @@ -0,0 +1,45 @@ +from odoo import models, fields + + +class EncoachEvaluation(models.Model): + _name = "encoach.evaluation" + _description = "EnCoach Evaluation Record" + + user_id = fields.Many2one("res.users", string="User", ondelete="cascade") + session_id = fields.Integer(string="Session Reference") + exercise_id = fields.Char(string="Exercise ID") + exercise_type = fields.Selection( + [("writing", "Writing"), ("speaking", "Speaking")], + string="Exercise Type", + ) + status = fields.Selection( + [ + ("pending", "Pending"), + ("in_progress", "In Progress"), + ("completed", "Completed"), + ("error", "Error"), + ], + default="pending", + ) + result = fields.Json() + created_at = fields.Datetime(default=fields.Datetime.now) + completed_at = fields.Datetime() + legacy_id = fields.Char(index=True) + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "userId": self.user_id.id if self.user_id else None, + "sessionId": self.session_id, + "exerciseId": self.exercise_id or "", + "exerciseType": self.exercise_type or "", + "status": self.status, + "result": self.result, + "createdAt": ( + self.created_at.isoformat() if self.created_at else None + ), + "completedAt": ( + self.completed_at.isoformat() if self.completed_at else None + ), + } diff --git a/encoach_evaluation/security/ir.model.access.csv b/encoach_evaluation/security/ir.model.access.csv new file mode 100644 index 0000000..7571eb7 --- /dev/null +++ b/encoach_evaluation/security/ir.model.access.csv @@ -0,0 +1,6 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_evaluation_student,encoach.evaluation.student,model_encoach_evaluation,encoach_core.group_encoach_student,1,0,0,0 +access_encoach_evaluation_admin,encoach.evaluation.admin,model_encoach_evaluation,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_ai_job_admin,encoach.ai.job.admin,model_encoach_ai_job,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_evaluation_sysadmin,encoach.evaluation.sysadmin,model_encoach_evaluation,base.group_system,1,1,1,1 +access_encoach_ai_job_sysadmin,encoach.ai.job.sysadmin,model_encoach_ai_job,base.group_system,1,1,1,1 diff --git a/encoach_evaluation/security/rules.xml b/encoach_evaluation/security/rules.xml new file mode 100644 index 0000000..064084f --- /dev/null +++ b/encoach_evaluation/security/rules.xml @@ -0,0 +1,23 @@ + + + + + Students see own evaluations + + + [('user_id', '=', user.id)] + + + Admins see all evaluations + + + [(1, '=', 1)] + + + Admins see all AI jobs + + + [(1, '=', 1)] + + + diff --git a/encoach_evaluation/views/ai_job_views.xml b/encoach_evaluation/views/ai_job_views.xml new file mode 100644 index 0000000..446c6c9 --- /dev/null +++ b/encoach_evaluation/views/ai_job_views.xml @@ -0,0 +1,55 @@ + + + + encoach.ai.job.list + encoach.ai.job + + + + + + + + + + + + + + + encoach.ai.job.form + encoach.ai.job + +
+ + + + + + + + + + + + + + + + +
+
+
+ + + AI Jobs + encoach.ai.job + list,form + + + +
diff --git a/encoach_evaluation/views/evaluation_views.xml b/encoach_evaluation/views/evaluation_views.xml new file mode 100644 index 0000000..8cbaa42 --- /dev/null +++ b/encoach_evaluation/views/evaluation_views.xml @@ -0,0 +1,50 @@ + + + + encoach.evaluation.list + encoach.evaluation + + + + + + + + + + + + + + encoach.evaluation.form + encoach.evaluation + +
+ + + + + + + + + + + + +
+
+
+ + + Evaluations + encoach.evaluation + list,form + + + +
diff --git a/encoach_exam/__init__.py b/encoach_exam/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/encoach_exam/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/encoach_exam/__manifest__.py b/encoach_exam/__manifest__.py new file mode 100644 index 0000000..a3b42e0 --- /dev/null +++ b/encoach_exam/__manifest__.py @@ -0,0 +1,15 @@ +{ + "name": "EnCoach Exam", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "IELTS exam models for Reading, Listening, Writing, Speaking, Level", + "depends": ["encoach_core"], + "data": [ + "security/ir.model.access.csv", + "security/rules.xml", + "views/exam_views.xml", + "views/approval_views.xml", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/encoach_exam/models/__init__.py b/encoach_exam/models/__init__.py new file mode 100644 index 0000000..0611d57 --- /dev/null +++ b/encoach_exam/models/__init__.py @@ -0,0 +1,2 @@ +from . import exam +from . import approval_workflow diff --git a/encoach_exam/models/approval_workflow.py b/encoach_exam/models/approval_workflow.py new file mode 100644 index 0000000..f0956cd --- /dev/null +++ b/encoach_exam/models/approval_workflow.py @@ -0,0 +1,58 @@ +from odoo import models, fields + +WORKFLOW_STATUS = [ + ("pending", "Pending"), + ("approved", "Approved"), + ("rejected", "Rejected"), +] + + +class EncoachApprovalWorkflow(models.Model): + _name = "encoach.approval.workflow" + _description = "EnCoach Exam Approval Workflow" + + exam_id = fields.Many2one("encoach.exam", ondelete="cascade", index=True) + creator_id = fields.Many2one("res.users", default=lambda self: self.env.uid) + entity_id = fields.Many2one("encoach.entity") + name = fields.Char() + modules = fields.Json(help='List of modules, e.g. ["reading","writing"]') + steps = fields.Json( + help="Array of workflow step objects with stepType, stepNumber, " + "completed, rejected, completedBy, assignees, etc.", + ) + status = fields.Selection(WORKFLOW_STATUS, default="pending", required=True) + start_date = fields.Datetime(default=fields.Datetime.now) + legacy_id = fields.Char(index=True) + + def action_approve(self): + for rec in self: + rec.status = "approved" + if rec.exam_id: + rec.exam_id.approved = True + + def action_reject(self): + for rec in self: + rec.status = "rejected" + if rec.exam_id: + rec.exam_id.approved = False + + def action_reset(self): + for rec in self: + rec.status = "pending" + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "name": self.name or "", + "entityId": self.entity_id.id if self.entity_id else None, + "requester": self.creator_id.id if self.creator_id else None, + "startDate": ( + int(self.start_date.timestamp() * 1000) + if self.start_date else None + ), + "modules": self.modules or [], + "examId": self.exam_id.id if self.exam_id else None, + "status": self.status, + "steps": self.steps or [], + } diff --git a/encoach_exam/models/exam.py b/encoach_exam/models/exam.py new file mode 100644 index 0000000..f4c57a5 --- /dev/null +++ b/encoach_exam/models/exam.py @@ -0,0 +1,77 @@ +from odoo import models, fields + +MODULE_SELECTION = [ + ("reading", "Reading"), + ("listening", "Listening"), + ("writing", "Writing"), + ("speaking", "Speaking"), + ("level", "Level"), +] + +VARIANT_SELECTION = [ + ("full", "Full"), + ("partial", "Partial"), +] + +ACCESS_SELECTION = [ + ("public", "Public"), + ("private", "Private"), + ("entity", "Entity"), +] + + +class EncoachExam(models.Model): + _name = "encoach.exam" + _description = "EnCoach Exam" + + module = fields.Selection(MODULE_SELECTION, required=True, index=True) + min_timer = fields.Integer(string="Minimum Timer (minutes)", default=0) + is_diagnostic = fields.Boolean(default=False) + variant = fields.Selection(VARIANT_SELECTION) + difficulty = fields.Json(help="List of CEFR difficulty levels, e.g. ['A1','B2']") + owner_ids = fields.Many2many( + "res.users", "encoach_exam_owner_rel", "exam_id", "user_id", + string="Owners", + ) + entity_ids = fields.Many2many( + "encoach.entity", "encoach_exam_entity_rel", "exam_id", "entity_id", + string="Entities", + ) + shuffle = fields.Boolean(default=False) + access = fields.Selection(ACCESS_SELECTION, default="public", required=True) + label = fields.Char() + requires_approval = fields.Boolean(default=False) + approved = fields.Boolean(default=False) + parts = fields.Json(help="Nested exam structure (parts, exercises, questions)") + legacy_id = fields.Char(index=True) + + def action_approve(self): + """Set exam as approved (used by form view Approve button).""" + self.write({"approved": True}) + + def to_encoach_dict(self): + self.ensure_one() + difficulty = self.difficulty or [] + if isinstance(difficulty, str): + import json + difficulty = json.loads(difficulty) + return { + "id": self.id, + "module": self.module, + "minTimer": self.min_timer, + "isDiagnostic": self.is_diagnostic, + "variant": self.variant, + "difficulty": difficulty, + "owners": self.owner_ids.ids, + "entities": [str(eid) for eid in self.entity_ids.ids], + "shuffle": self.shuffle, + "access": self.access, + "label": self.label or "", + "requiresApproval": self.requires_approval, + "approved": self.approved, + "parts": self.parts or [], + "createdBy": self.create_uid.id if self.create_uid else None, + "createdAt": ( + self.create_date.isoformat() if self.create_date else None + ), + } diff --git a/encoach_exam/security/ir.model.access.csv b/encoach_exam/security/ir.model.access.csv new file mode 100644 index 0000000..ad9db88 --- /dev/null +++ b/encoach_exam/security/ir.model.access.csv @@ -0,0 +1,9 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_exam_student,encoach.exam.student,model_encoach_exam,encoach_core.group_encoach_student,1,0,0,0 +access_encoach_exam_teacher,encoach.exam.teacher,model_encoach_exam,encoach_core.group_encoach_teacher,1,1,1,0 +access_encoach_exam_admin,encoach.exam.admin,model_encoach_exam,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_approval_workflow_student,encoach.approval.workflow.student,model_encoach_approval_workflow,encoach_core.group_encoach_student,1,0,0,0 +access_encoach_approval_workflow_teacher,encoach.approval.workflow.teacher,model_encoach_approval_workflow,encoach_core.group_encoach_teacher,1,1,1,0 +access_encoach_approval_workflow_admin,encoach.approval.workflow.admin,model_encoach_approval_workflow,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_exam_sysadmin,encoach.exam.sysadmin,model_encoach_exam,base.group_system,1,1,1,1 +access_encoach_approval_workflow_sysadmin,encoach.approval.workflow.sysadmin,model_encoach_approval_workflow,base.group_system,1,1,1,1 diff --git a/encoach_exam/security/rules.xml b/encoach_exam/security/rules.xml new file mode 100644 index 0000000..ac70bf9 --- /dev/null +++ b/encoach_exam/security/rules.xml @@ -0,0 +1,17 @@ + + + + + Students see public exams + + + ['|', ('access', '=', 'public'), ('owner_ids', 'in', user.id)] + + + Teachers see all exams + + + [(1, '=', 1)] + + + diff --git a/encoach_exam/views/approval_views.xml b/encoach_exam/views/approval_views.xml new file mode 100644 index 0000000..b90c090 --- /dev/null +++ b/encoach_exam/views/approval_views.xml @@ -0,0 +1,71 @@ + + + + encoach.approval.workflow.tree + encoach.approval.workflow + + + + + + + + + + + + + encoach.approval.workflow.form + encoach.approval.workflow + +
+
+
+ + + + + + + + + + + + + + + + + + +
+
+
+ + + Approval Workflows + encoach.approval.workflow + list,form + +

+ Create your first approval workflow. +

+

+ Approval workflows manage the exam approval process across modules. +

+
+
+ + +
diff --git a/encoach_exam/views/exam_views.xml b/encoach_exam/views/exam_views.xml new file mode 100644 index 0000000..9547f53 --- /dev/null +++ b/encoach_exam/views/exam_views.xml @@ -0,0 +1,87 @@ + + + + encoach.exam.tree + encoach.exam + + + + + + + + + + + + + + + + + + encoach.exam.form + encoach.exam + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Exams + encoach.exam + list,form + +

+ Create your first exam. +

+

+ Exams represent IELTS exam configurations for Reading, Listening, + Writing, Speaking, or Level modules. +

+
+
+ + +
diff --git a/encoach_registration/__init__.py b/encoach_registration/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/encoach_registration/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/encoach_registration/__manifest__.py b/encoach_registration/__manifest__.py new file mode 100644 index 0000000..6795bb0 --- /dev/null +++ b/encoach_registration/__manifest__.py @@ -0,0 +1,13 @@ +{ + "name": "EnCoach Registration", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "User registration services for EnCoach", + "description": "Individual and corporate registration, batch user import.", + "depends": ["encoach_core"], + "data": [ + "security/ir.model.access.csv", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/encoach_registration/models/__init__.py b/encoach_registration/models/__init__.py new file mode 100644 index 0000000..cc8d3f6 --- /dev/null +++ b/encoach_registration/models/__init__.py @@ -0,0 +1 @@ +from . import registration diff --git a/encoach_registration/models/registration.py b/encoach_registration/models/registration.py new file mode 100644 index 0000000..4501cb9 --- /dev/null +++ b/encoach_registration/models/registration.py @@ -0,0 +1,132 @@ +import logging + +from odoo import models, fields, api +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class EncoachRegistrationMixin(models.AbstractModel): + _name = "encoach.registration.mixin" + _description = "EnCoach Registration Service" + + @api.model + def register_individual(self, email, password, name, code=None): + if self.env["res.users"].sudo().search([("login", "=", email)], limit=1): + raise ValidationError(f"A user with email {email} already exists.") + + user = self.env["res.users"].sudo().create({ + "login": email, + "password": password, + "name": name, + "encoach_type": "student", + }) + + if code: + self._apply_code(user, code) + + _logger.info("Registered individual user: %s (%s)", name, email) + return user + + @api.model + def register_corporate(self, email, password, name, code): + if not code: + raise ValidationError("Corporate registration requires a code.") + + if self.env["res.users"].sudo().search([("login", "=", email)], limit=1): + raise ValidationError(f"A user with email {email} already exists.") + + result = self.env["encoach.code"].sudo().validate_code(code) + if not result.get("valid"): + raise ValidationError(result.get("error", "Invalid code.")) + + code_rec = self.env["encoach.code"].sudo().search([("code", "=", code)], limit=1) + entity = code_rec.entity_id + if not entity: + raise ValidationError("Code is not linked to any entity.") + + user = self.env["res.users"].sudo().create({ + "login": email, + "password": password, + "name": name, + "encoach_type": code_rec.user_type or "corporate", + }) + + self.env["encoach.user.entity.rel"].sudo().create({ + "user_id": user.id, + "entity_id": entity.id, + }) + + code_rec.sudo().write({"uses": code_rec.uses + 1}) + + _logger.info( + "Registered corporate user: %s (%s) -> entity %s", + name, email, entity.name, + ) + return user + + @api.model + def batch_import(self, users_data, maker_id): + created = [] + errors = [] + + for idx, udata in enumerate(users_data): + try: + email = udata.get("email") + password = udata.get("password", "") + name = udata.get("name", email) + user_type = udata.get("type", "student") + code = udata.get("code") + + if self.env["res.users"].sudo().search( + [("login", "=", email)], limit=1 + ): + errors.append({ + "index": idx, + "email": email, + "error": "User already exists", + }) + continue + + user = self.env["res.users"].sudo().create({ + "login": email, + "password": password, + "name": name, + "encoach_type": user_type, + }) + + if code: + self._apply_code(user, code) + + created.append(user.id) + except Exception as e: + errors.append({ + "index": idx, + "email": udata.get("email", ""), + "error": str(e), + }) + + _logger.info( + "Batch import by user %s: %d created, %d errors", + maker_id, len(created), len(errors), + ) + return {"created": created, "errors": errors} + + def _apply_code(self, user, code_str): + result = self.env["encoach.code"].sudo().validate_code(code_str) + if not result.get("valid"): + return + + code_rec = self.env["encoach.code"].sudo().search( + [("code", "=", code_str)], limit=1, + ) + if not code_rec: + return + + if code_rec.entity_id: + self.env["encoach.user.entity.rel"].sudo().create({ + "user_id": user.id, + "entity_id": code_rec.entity_id.id, + }) + + code_rec.sudo().write({"uses": code_rec.uses + 1}) diff --git a/encoach_registration/security/ir.model.access.csv b/encoach_registration/security/ir.model.access.csv new file mode 100644 index 0000000..97dd8b9 --- /dev/null +++ b/encoach_registration/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/encoach_stats/__init__.py b/encoach_stats/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/encoach_stats/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/encoach_stats/__manifest__.py b/encoach_stats/__manifest__.py new file mode 100644 index 0000000..db801f9 --- /dev/null +++ b/encoach_stats/__manifest__.py @@ -0,0 +1,15 @@ +{ + "name": "EnCoach Stats", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "Exam sessions and statistics tracking", + "depends": ["encoach_core", "encoach_exam"], + "data": [ + "security/ir.model.access.csv", + "security/rules.xml", + "views/session_views.xml", + "views/stat_views.xml", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/encoach_stats/models/__init__.py b/encoach_stats/models/__init__.py new file mode 100644 index 0000000..ab8de2f --- /dev/null +++ b/encoach_stats/models/__init__.py @@ -0,0 +1,2 @@ +from . import session +from . import stat diff --git a/encoach_stats/models/session.py b/encoach_stats/models/session.py new file mode 100644 index 0000000..1b040fb --- /dev/null +++ b/encoach_stats/models/session.py @@ -0,0 +1,21 @@ +from odoo import models, fields + + +class EncoachSession(models.Model): + _name = "encoach.session" + _description = "EnCoach Exam Session" + _order = "date desc" + + user_id = fields.Many2one("res.users", required=True, index=True) + exam_data = fields.Json(help="Full exam state snapshot (ExamState)") + date = fields.Datetime(default=fields.Datetime.now) + legacy_id = fields.Char(index=True) + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "user": self.user_id.id, + "date": self.date.isoformat() if self.date else None, + **(self.exam_data or {}), + } diff --git a/encoach_stats/models/stat.py b/encoach_stats/models/stat.py new file mode 100644 index 0000000..8cd540a --- /dev/null +++ b/encoach_stats/models/stat.py @@ -0,0 +1,67 @@ +from odoo import models, fields + +MODULE_SELECTION = [ + ("reading", "Reading"), + ("listening", "Listening"), + ("writing", "Writing"), + ("speaking", "Speaking"), + ("level", "Level"), +] + + +class EncoachStat(models.Model): + _name = "encoach.stat" + _description = "EnCoach Exam Statistic" + _order = "date desc" + + user_id = fields.Many2one("res.users", required=True, index=True) + exam_id = fields.Many2one("encoach.exam", index=True) + exercise = fields.Char() + session_id = fields.Many2one("encoach.session", index=True) + date = fields.Datetime(default=fields.Datetime.now) + module = fields.Selection(MODULE_SELECTION, index=True) + solutions = fields.Json(help="User solution objects for this stat") + stat_type = fields.Char(string="Type") + time_spent = fields.Integer(help="Seconds spent on this exercise") + inactivity = fields.Integer(help="Seconds of inactivity") + assignment_id = fields.Many2one("encoach.assignment", index=True) + score_correct = fields.Integer(default=0) + score_total = fields.Integer(default=0) + score_missing = fields.Integer(default=0) + is_disabled = fields.Boolean(default=False) + shuffle_maps = fields.Json() + is_practice = fields.Boolean(default=False) + pdf_url = fields.Char() + legacy_id = fields.Char(index=True) + + def to_encoach_dict(self): + self.ensure_one() + result = { + "id": self.id, + "user": self.user_id.id, + "exam": self.exam_id.id if self.exam_id else None, + "exercise": self.exercise or "", + "session": self.session_id.id if self.session_id else None, + "date": ( + int(self.date.timestamp() * 1000) if self.date else None + ), + "module": self.module, + "solutions": self.solutions or [], + "type": self.stat_type or "", + "timeSpent": self.time_spent, + "inactivity": self.inactivity, + "assignment": ( + self.assignment_id.id if self.assignment_id else None + ), + "score": { + "correct": self.score_correct, + "total": self.score_total, + "missing": self.score_missing, + }, + "isDisabled": self.is_disabled, + "shuffleMaps": self.shuffle_maps or [], + "isPractice": self.is_practice, + } + if self.pdf_url: + result["pdf"] = {"path": self.pdf_url, "version": "1"} + return result diff --git a/encoach_stats/security/ir.model.access.csv b/encoach_stats/security/ir.model.access.csv new file mode 100644 index 0000000..75f71b5 --- /dev/null +++ b/encoach_stats/security/ir.model.access.csv @@ -0,0 +1,9 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_session_student,encoach.session.student,model_encoach_session,encoach_core.group_encoach_student,1,1,1,0 +access_encoach_session_teacher,encoach.session.teacher,model_encoach_session,encoach_core.group_encoach_teacher,1,1,1,0 +access_encoach_session_admin,encoach.session.admin,model_encoach_session,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_stat_student,encoach.stat.student,model_encoach_stat,encoach_core.group_encoach_student,1,1,1,0 +access_encoach_stat_teacher,encoach.stat.teacher,model_encoach_stat,encoach_core.group_encoach_teacher,1,1,1,0 +access_encoach_stat_admin,encoach.stat.admin,model_encoach_stat,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_session_sysadmin,encoach.session.sysadmin,model_encoach_session,base.group_system,1,1,1,1 +access_encoach_stat_sysadmin,encoach.stat.sysadmin,model_encoach_stat,base.group_system,1,1,1,1 diff --git a/encoach_stats/security/rules.xml b/encoach_stats/security/rules.xml new file mode 100644 index 0000000..cfef50c --- /dev/null +++ b/encoach_stats/security/rules.xml @@ -0,0 +1,29 @@ + + + + + Students see own sessions + + + [('user_id', '=', user.id)] + + + Teachers see all sessions + + + [(1, '=', 1)] + + + Students see own stats + + + [('user_id', '=', user.id)] + + + Teachers see all stats + + + [(1, '=', 1)] + + + diff --git a/encoach_stats/views/session_views.xml b/encoach_stats/views/session_views.xml new file mode 100644 index 0000000..147abd8 --- /dev/null +++ b/encoach_stats/views/session_views.xml @@ -0,0 +1,43 @@ + + + + encoach.session.list + encoach.session + + + + + + + + + + + encoach.session.form + encoach.session + +
+ + + + + + + + +
+
+
+ + + Sessions + encoach.session + list,form + + + +
diff --git a/encoach_stats/views/stat_views.xml b/encoach_stats/views/stat_views.xml new file mode 100644 index 0000000..a3cf6a3 --- /dev/null +++ b/encoach_stats/views/stat_views.xml @@ -0,0 +1,67 @@ + + + + encoach.stat.list + encoach.stat + + + + + + + + + + + + + + + + + encoach.stat.form + encoach.stat + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Statistics + encoach.stat + list,form + + + +
diff --git a/encoach_subscription/__init__.py b/encoach_subscription/__init__.py new file mode 100644 index 0000000..71a0242 --- /dev/null +++ b/encoach_subscription/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import services diff --git a/encoach_subscription/__manifest__.py b/encoach_subscription/__manifest__.py new file mode 100644 index 0000000..5ece421 --- /dev/null +++ b/encoach_subscription/__manifest__.py @@ -0,0 +1,16 @@ +{ + "name": "EnCoach Subscription", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "Subscription packages, payments, and discounts for EnCoach", + "description": "Stripe, PayPal, Paymob payment integrations, subscription management.", + "depends": ["encoach_core", "product", "payment"], + "data": [ + "security/ir.model.access.csv", + "views/package_views.xml", + "views/payment_views.xml", + "views/discount_views.xml", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/encoach_subscription/models/__init__.py b/encoach_subscription/models/__init__.py new file mode 100644 index 0000000..91209d1 --- /dev/null +++ b/encoach_subscription/models/__init__.py @@ -0,0 +1,3 @@ +from . import package +from . import subscription_payment +from . import discount diff --git a/encoach_subscription/models/discount.py b/encoach_subscription/models/discount.py new file mode 100644 index 0000000..fb6e997 --- /dev/null +++ b/encoach_subscription/models/discount.py @@ -0,0 +1,24 @@ +from odoo import fields, models + + +class EncoachDiscount(models.Model): + _name = "encoach.discount" + _description = "EnCoach Discount Code" + code = fields.Char(required=True, index=True) + + _code_unique = models.Constraint( + "UNIQUE(code)", + "Discount code must be unique.", + ) + percentage = fields.Float(digits=(5, 2)) + expiry_date = fields.Datetime() + max_uses = fields.Integer() + uses = fields.Integer(default=0) + + def is_valid(self): + self.ensure_one() + if self.max_uses and self.uses >= self.max_uses: + return False + if self.expiry_date and self.expiry_date < fields.Datetime.now(): + return False + return True diff --git a/encoach_subscription/models/package.py b/encoach_subscription/models/package.py new file mode 100644 index 0000000..115382b --- /dev/null +++ b/encoach_subscription/models/package.py @@ -0,0 +1,25 @@ +from odoo import models, fields + + +class EncoachPackage(models.Model): + _name = "encoach.package" + _description = "EnCoach Subscription Package" + + name = fields.Char(required=True) + price = fields.Float(digits=(12, 2)) + currency = fields.Char(default="USD") + duration_days = fields.Integer() + features = fields.Json() + active = fields.Boolean(default=True) + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "name": self.name, + "price": self.price, + "currency": self.currency or "USD", + "durationDays": self.duration_days, + "features": self.features, + "active": self.active, + } diff --git a/encoach_subscription/models/subscription_payment.py b/encoach_subscription/models/subscription_payment.py new file mode 100644 index 0000000..029615a --- /dev/null +++ b/encoach_subscription/models/subscription_payment.py @@ -0,0 +1,35 @@ +from odoo import models, fields + + +class EncoachSubscriptionPayment(models.Model): + _name = "encoach.subscription.payment" + _description = "EnCoach Subscription Payment" + + user_id = fields.Many2one("res.users", string="User", ondelete="set null") + package_id = fields.Many2one( + "encoach.package", string="Package", ondelete="set null", + ) + provider = fields.Selection( + [ + ("stripe", "Stripe"), + ("paypal", "PayPal"), + ("paymob", "Paymob"), + ], + string="Payment Provider", + ) + status = fields.Selection( + [ + ("pending", "Pending"), + ("completed", "Completed"), + ("failed", "Failed"), + ], + default="pending", + ) + amount = fields.Float(digits=(12, 2)) + currency = fields.Char() + transaction_id = fields.Char(index=True) + created_at = fields.Datetime(default=fields.Datetime.now) + entity_id = fields.Many2one( + "encoach.entity", string="Entity", ondelete="set null", + ) + legacy_id = fields.Char(index=True) diff --git a/encoach_subscription/security/ir.model.access.csv b/encoach_subscription/security/ir.model.access.csv new file mode 100644 index 0000000..d0b12d8 --- /dev/null +++ b/encoach_subscription/security/ir.model.access.csv @@ -0,0 +1,10 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_package_student,encoach.package.student,model_encoach_package,encoach_core.group_encoach_student,1,0,0,0 +access_encoach_package_admin,encoach.package.admin,model_encoach_package,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_subscription_payment_student,encoach.subscription.payment.student,model_encoach_subscription_payment,encoach_core.group_encoach_student,1,0,1,0 +access_encoach_subscription_payment_admin,encoach.subscription.payment.admin,model_encoach_subscription_payment,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_discount_student,encoach.discount.student,model_encoach_discount,encoach_core.group_encoach_student,1,0,0,0 +access_encoach_discount_admin,encoach.discount.admin,model_encoach_discount,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_package_sysadmin,encoach.package.sysadmin,model_encoach_package,base.group_system,1,1,1,1 +access_encoach_subscription_payment_sysadmin,encoach.subscription.payment.sysadmin,model_encoach_subscription_payment,base.group_system,1,1,1,1 +access_encoach_discount_sysadmin,encoach.discount.sysadmin,model_encoach_discount,base.group_system,1,1,1,1 diff --git a/encoach_subscription/services/__init__.py b/encoach_subscription/services/__init__.py new file mode 100644 index 0000000..8b85649 --- /dev/null +++ b/encoach_subscription/services/__init__.py @@ -0,0 +1,4 @@ +from . import stripe_service +from . import paypal_service +from . import paymob_service +from . import subscription_helper diff --git a/encoach_subscription/services/paymob_service.py b/encoach_subscription/services/paymob_service.py new file mode 100644 index 0000000..452db46 --- /dev/null +++ b/encoach_subscription/services/paymob_service.py @@ -0,0 +1,170 @@ +import hashlib +import hmac +import json +import logging + +import httpx + +_logger = logging.getLogger(__name__) + +PAYMOB_BASE = "https://accept.paymob.com/api" + + +class EncoachPaymobService: + + def __init__(self, env): + self.env = env + params = env["ir.config_parameter"].sudo() + self.api_key = params.get_param("encoach.paymob_api_key", "") + self.hmac_secret = params.get_param("encoach.paymob_hmac_secret", "") + + def _authenticate(self): + resp = httpx.post( + f"{PAYMOB_BASE}/auth/tokens", + json={"api_key": self.api_key}, + timeout=30, + ) + resp.raise_for_status() + return resp.json()["token"] + + def create_intention(self, user, package, discount_code=None): + if not self.api_key: + return {"error": "Paymob API key not configured"} + + amount = package.price + if discount_code: + discount = self.env["encoach.discount"].sudo().search([ + ("code", "=", discount_code), + ], limit=1) + if discount and discount.is_valid(): + amount = amount * (1 - discount.percentage / 100.0) + + amount_cents = int(round(amount * 100)) + currency = (package.currency or "EGP").upper() + + try: + auth_token = self._authenticate() + + order_resp = httpx.post( + f"{PAYMOB_BASE}/ecommerce/orders", + json={ + "auth_token": auth_token, + "delivery_needed": False, + "amount_cents": amount_cents, + "currency": currency, + "merchant_order_id": f"encoach_{user.id}_{package.id}", + "items": [{ + "name": package.name, + "amount_cents": amount_cents, + "quantity": 1, + }], + }, + timeout=30, + ) + order_resp.raise_for_status() + order = order_resp.json() + + integration_id = self.env["ir.config_parameter"].sudo().get_param( + "encoach.paymob_integration_id", "" + ) + + key_resp = httpx.post( + f"{PAYMOB_BASE}/acceptance/payment_keys", + json={ + "auth_token": auth_token, + "amount_cents": amount_cents, + "expiration": 3600, + "order_id": order["id"], + "billing_data": { + "email": user.login, + "first_name": (user.name or "").split()[0] if user.name else "N/A", + "last_name": (user.name or "").split()[-1] if user.name else "N/A", + "phone_number": "N/A", + "apartment": "N/A", + "floor": "N/A", + "street": "N/A", + "building": "N/A", + "shipping_method": "N/A", + "postal_code": "N/A", + "city": "N/A", + "country": "N/A", + "state": "N/A", + }, + "currency": currency, + "integration_id": int(integration_id) if integration_id else 0, + "lock_order_when_paid": True, + }, + timeout=30, + ) + key_resp.raise_for_status() + payment_key = key_resp.json()["token"] + + return { + "intentionId": str(order["id"]), + "clientSecret": payment_key, + } + except httpx.HTTPStatusError as e: + _logger.error("Paymob API error: %s", e.response.text) + return {"error": f"Paymob error: {e.response.status_code}"} + except Exception as e: + _logger.exception("Paymob create intention error") + return {"error": str(e)} + + def verify_transaction(self, payload, hmac_header): + if not self.hmac_secret: + _logger.warning("No Paymob HMAC secret configured") + return None + + try: + data = payload if isinstance(payload, dict) else json.loads(payload) + obj = data.get("obj", {}) + concat_str = ( + str(obj.get("amount_cents", "")) + + str(obj.get("created_at", "")) + + str(obj.get("currency", "")) + + str(obj.get("error_occured", "")).lower() + + str(obj.get("has_parent_transaction", "")).lower() + + str(obj.get("id", "")) + + str(obj.get("integration_id", "")) + + str(obj.get("is_3d_secure", "")).lower() + + str(obj.get("is_auth", "")).lower() + + str(obj.get("is_capture", "")).lower() + + str(obj.get("is_refunded", "")).lower() + + str(obj.get("is_standalone_payment", "")).lower() + + str(obj.get("is_voided", "")).lower() + + str(obj.get("order", {}).get("id", "")) + + str(obj.get("owner", "")) + + str(obj.get("pending", "")).lower() + + str(obj.get("source_data", {}).get("pan", "")) + + str(obj.get("source_data", {}).get("sub_type", "")) + + str(obj.get("source_data", {}).get("type", "")) + + str(obj.get("success", "")).lower() + ) + + computed = hmac.new( + self.hmac_secret.encode("utf-8"), + concat_str.encode("utf-8"), + hashlib.sha512, + ).hexdigest() + + if not hmac.compare_digest(computed, hmac_header): + return None + + if obj.get("success") is True: + merchant_order_id = str(obj.get("order", {}).get("merchant_order_id", "")) + parts = merchant_order_id.replace("encoach_", "").split("_") + user_id = int(parts[0]) if len(parts) >= 1 else 0 + package_id = int(parts[1]) if len(parts) >= 2 else 0 + amount = obj.get("amount_cents", 0) / 100.0 + currency = obj.get("currency", "EGP") + transaction_id = str(obj.get("id", "")) + + from .subscription_helper import extend_subscription + extend_subscription( + self.env, user_id, package_id, "paymob", transaction_id, amount, currency, + ) + return {"ok": True} + return {"error": "Transaction not successful"} + except Exception: + _logger.exception("Paymob verify transaction error") + return None diff --git a/encoach_subscription/services/paypal_service.py b/encoach_subscription/services/paypal_service.py new file mode 100644 index 0000000..97c44cb --- /dev/null +++ b/encoach_subscription/services/paypal_service.py @@ -0,0 +1,128 @@ +import logging + +import httpx + +_logger = logging.getLogger(__name__) + +PAYPAL_SANDBOX_BASE = "https://api-m.sandbox.paypal.com" +PAYPAL_LIVE_BASE = "https://api-m.paypal.com" + + +class EncoachPaypalService: + + def __init__(self, env): + self.env = env + params = env["ir.config_parameter"].sudo() + self.client_id = params.get_param("encoach.paypal_client_id", "") + self.client_secret = params.get_param("encoach.paypal_client_secret", "") + is_live = params.get_param("encoach.paypal_live", "false").lower() == "true" + self.base_url = PAYPAL_LIVE_BASE if is_live else PAYPAL_SANDBOX_BASE + + def _get_access_token(self): + resp = httpx.post( + f"{self.base_url}/v1/oauth2/token", + auth=(self.client_id, self.client_secret), + data={"grant_type": "client_credentials"}, + timeout=30, + ) + resp.raise_for_status() + return resp.json()["access_token"] + + def _headers(self, token): + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + def create_order(self, user, package, discount_code=None): + if not self.client_id or not self.client_secret: + return {"error": "PayPal credentials not configured"} + + amount = package.price + if discount_code: + discount = self.env["encoach.discount"].sudo().search([ + ("code", "=", discount_code), + ], limit=1) + if discount and discount.is_valid(): + amount = amount * (1 - discount.percentage / 100.0) + + currency = (package.currency or "USD").upper() + + try: + token = self._get_access_token() + order_data = { + "intent": "CAPTURE", + "purchase_units": [{ + "amount": { + "currency_code": currency, + "value": f"{amount:.2f}", + }, + "description": package.name, + "custom_id": f"{user.id}:{package.id}", + }], + "application_context": { + "return_url": "https://encoach.ai/payment/success", + "cancel_url": "https://encoach.ai/payment/cancel", + }, + } + + resp = httpx.post( + f"{self.base_url}/v2/checkout/orders", + headers=self._headers(token), + json=order_data, + timeout=30, + ) + resp.raise_for_status() + order = resp.json() + + approval_url = "" + for link in order.get("links", []): + if link["rel"] == "approve": + approval_url = link["href"] + break + + return {"orderId": order["id"], "approvalUrl": approval_url} + except httpx.HTTPStatusError as e: + _logger.error("PayPal API error: %s", e.response.text) + return {"error": f"PayPal error: {e.response.status_code}"} + except Exception as e: + _logger.exception("PayPal create order error") + return {"error": str(e)} + + def capture_order(self, user, order_id): + if not self.client_id or not self.client_secret: + return {"error": "PayPal credentials not configured"} + + try: + token = self._get_access_token() + resp = httpx.post( + f"{self.base_url}/v2/checkout/orders/{order_id}/capture", + headers=self._headers(token), + timeout=30, + ) + resp.raise_for_status() + capture = resp.json() + + if capture.get("status") == "COMPLETED": + purchase_unit = capture.get("purchase_units", [{}])[0] + custom_id = purchase_unit.get("payments", {}).get("captures", [{}])[0].get("custom_id", "") + parts = custom_id.split(":") + user_id = int(parts[0]) if len(parts) >= 1 else user.id + package_id = int(parts[1]) if len(parts) >= 2 else 0 + cap_amount = purchase_unit.get("payments", {}).get("captures", [{}])[0] + amount = float(cap_amount.get("amount", {}).get("value", 0)) + currency = cap_amount.get("amount", {}).get("currency_code", "USD") + + from .subscription_helper import extend_subscription + extend_subscription( + self.env, user_id, package_id, "paypal", order_id, amount, currency, + ) + return {"ok": True} + else: + return {"error": f"Order status: {capture.get('status')}"} + except httpx.HTTPStatusError as e: + _logger.error("PayPal capture error: %s", e.response.text) + return {"error": f"PayPal error: {e.response.status_code}"} + except Exception as e: + _logger.exception("PayPal capture error") + return {"error": str(e)} diff --git a/encoach_subscription/services/stripe_service.py b/encoach_subscription/services/stripe_service.py new file mode 100644 index 0000000..30b735d --- /dev/null +++ b/encoach_subscription/services/stripe_service.py @@ -0,0 +1,133 @@ +import hashlib +import hmac +import json +import logging +import time + +import httpx + +_logger = logging.getLogger(__name__) + +STRIPE_API_BASE = "https://api.stripe.com/v1" + + +class EncoachStripeService: + + def __init__(self, env): + self.env = env + params = env["ir.config_parameter"].sudo() + self.api_key = params.get_param("encoach.stripe_secret_key", "") + self.webhook_secret = params.get_param("encoach.stripe_webhook_secret", "") + + def _headers(self): + return { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/x-www-form-urlencoded", + } + + def create_checkout_session(self, user, package, success_url, cancel_url, discount_code=None): + if not self.api_key: + return {"error": "Stripe secret key not configured"} + + amount = package.price + if discount_code: + discount = self.env["encoach.discount"].sudo().search([ + ("code", "=", discount_code), + ], limit=1) + if discount and discount.is_valid(): + amount = amount * (1 - discount.percentage / 100.0) + + amount_cents = int(round(amount * 100)) + + data = { + "mode": "payment", + "line_items[0][price_data][currency]": (package.currency or "usd").lower(), + "line_items[0][price_data][unit_amount]": str(amount_cents), + "line_items[0][price_data][product_data][name]": package.name, + "line_items[0][quantity]": "1", + "success_url": success_url, + "cancel_url": cancel_url, + "client_reference_id": str(user.id), + "customer_email": user.login, + "metadata[package_id]": str(package.id), + "metadata[user_id]": str(user.id), + } + + try: + resp = httpx.post( + f"{STRIPE_API_BASE}/checkout/sessions", + headers=self._headers(), + data=data, + timeout=30, + ) + resp.raise_for_status() + session = resp.json() + return {"sessionId": session["id"], "url": session["url"]} + except httpx.HTTPStatusError as e: + _logger.error("Stripe API error: %s", e.response.text) + return {"error": f"Stripe error: {e.response.status_code}"} + except Exception as e: + _logger.exception("Stripe checkout error") + return {"error": str(e)} + + def verify_webhook_signature(self, payload, sig_header): + if not self.webhook_secret: + _logger.warning("No Stripe webhook secret configured") + return False + + try: + elements = {k: v for k, v in ( + item.split("=", 1) for item in sig_header.split(",") + )} + timestamp = elements.get("t", "") + signature = elements.get("v1", "") + + signed_payload = f"{timestamp}.{payload}" + expected = hmac.new( + self.webhook_secret.encode("utf-8"), + signed_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + if not hmac.compare_digest(expected, signature): + return False + + if abs(time.time() - int(timestamp)) > 300: + _logger.warning("Stripe webhook timestamp too old") + return False + + return True + except Exception: + _logger.exception("Stripe webhook signature verification failed") + return False + + def handle_webhook(self, payload, sig_header): + if not self.verify_webhook_signature(payload, sig_header): + return {"error": "Invalid signature"} + + event = json.loads(payload) + event_type = event.get("type") + + if event_type == "checkout.session.completed": + session = event["data"]["object"] + user_id = int(session.get("client_reference_id", 0)) + package_id = int(session.get("metadata", {}).get("package_id", 0)) + transaction_id = session.get("payment_intent") or session.get("id") + + if user_id and package_id: + self._complete_payment( + user_id=user_id, + package_id=package_id, + provider="stripe", + transaction_id=transaction_id, + amount=session.get("amount_total", 0) / 100.0, + currency=(session.get("currency") or "usd").upper(), + ) + + return {"ok": True} + + def _complete_payment(self, user_id, package_id, provider, transaction_id, amount, currency): + from .subscription_helper import extend_subscription + extend_subscription( + self.env, user_id, package_id, provider, transaction_id, amount, currency, + ) diff --git a/encoach_subscription/services/subscription_helper.py b/encoach_subscription/services/subscription_helper.py new file mode 100644 index 0000000..8053782 --- /dev/null +++ b/encoach_subscription/services/subscription_helper.py @@ -0,0 +1,52 @@ +import logging +from datetime import datetime, timedelta + +_logger = logging.getLogger(__name__) + + +def extend_subscription(env, user_id, package_id, provider, transaction_id, amount, currency): + user = env["res.users"].sudo().browse(user_id) + if not user.exists(): + _logger.error("extend_subscription: user %s not found", user_id) + return + + package = env["encoach.package"].sudo().browse(package_id) + if not package.exists(): + _logger.error("extend_subscription: package %s not found", package_id) + return + + now = datetime.utcnow() + current_exp = user.subscription_expiration + if current_exp and current_exp > now: + base = current_exp + else: + base = now + new_exp = base + timedelta(days=package.duration_days or 30) + user.sudo().write({"subscription_expiration": new_exp}) + + env["encoach.subscription.payment"].sudo().create({ + "user_id": user_id, + "package_id": package_id, + "provider": provider, + "status": "completed", + "amount": amount, + "currency": currency, + "transaction_id": transaction_id, + }) + + if user.encoach_type in ("corporate", "mastercorporate"): + rels = env["encoach.user.entity.rel"].sudo().search([ + ("user_id", "=", user_id), + ]) + for rel in rels: + members = env["encoach.user.entity.rel"].sudo().search([ + ("entity_id", "=", rel.entity_id.id), + ("user_id", "!=", user_id), + ]) + for m in members: + m.user_id.sudo().write({"subscription_expiration": new_exp}) + + _logger.info( + "Subscription extended: user=%s package=%s provider=%s until=%s", + user_id, package_id, provider, new_exp, + ) diff --git a/encoach_subscription/views/discount_views.xml b/encoach_subscription/views/discount_views.xml new file mode 100644 index 0000000..cc3e57c --- /dev/null +++ b/encoach_subscription/views/discount_views.xml @@ -0,0 +1,58 @@ + + + + encoach.discount.tree + encoach.discount + + + + + + + + + + + + + encoach.discount.form + encoach.discount + +
+ + + + + + + + + + + + + +
+
+
+ + + Discount Codes + encoach.discount + list,form + +

+ Create your first discount code. +

+

+ Discount codes offer percentage-based discounts with optional expiry and usage limits. +

+
+
+ + +
diff --git a/encoach_subscription/views/package_views.xml b/encoach_subscription/views/package_views.xml new file mode 100644 index 0000000..1271f85 --- /dev/null +++ b/encoach_subscription/views/package_views.xml @@ -0,0 +1,61 @@ + + + + encoach.package.tree + encoach.package + + + + + + + + + + + + + encoach.package.form + encoach.package + +
+ + + + + + + + + + + + + + + + +
+
+
+ + + Packages + encoach.package + list,form + +

+ Create your first subscription package. +

+

+ Packages define pricing, duration, and features for EnCoach subscriptions. +

+
+
+ + +
diff --git a/encoach_subscription/views/payment_views.xml b/encoach_subscription/views/payment_views.xml new file mode 100644 index 0000000..d02477a --- /dev/null +++ b/encoach_subscription/views/payment_views.xml @@ -0,0 +1,65 @@ + + + + encoach.subscription.payment.tree + encoach.subscription.payment + + + + + + + + + + + + + + + + encoach.subscription.payment.form + encoach.subscription.payment + +
+ + + + + + + + + + + + + + + + + +
+
+
+ + + Payments + encoach.subscription.payment + list,form + +

+ No payments yet. +

+

+ Payment records track Stripe, PayPal, and Paymob transactions for subscription purchases. +

+
+
+ + +
diff --git a/encoach_ticket/__init__.py b/encoach_ticket/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/encoach_ticket/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/encoach_ticket/__manifest__.py b/encoach_ticket/__manifest__.py new file mode 100644 index 0000000..47a27da --- /dev/null +++ b/encoach_ticket/__manifest__.py @@ -0,0 +1,15 @@ +{ + "name": "EnCoach Ticket", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "Support ticket system for EnCoach", + "description": "Feedback, bug reports, and help requests from users.", + "depends": ["encoach_core"], + "data": [ + "security/ir.model.access.csv", + "security/rules.xml", + "views/ticket_views.xml", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/encoach_ticket/models/__init__.py b/encoach_ticket/models/__init__.py new file mode 100644 index 0000000..d5f74ce --- /dev/null +++ b/encoach_ticket/models/__init__.py @@ -0,0 +1 @@ +from . import ticket diff --git a/encoach_ticket/models/ticket.py b/encoach_ticket/models/ticket.py new file mode 100644 index 0000000..1d46eef --- /dev/null +++ b/encoach_ticket/models/ticket.py @@ -0,0 +1,53 @@ +from odoo import models, fields + + +class EncoachTicket(models.Model): + _name = "encoach.ticket" + _description = "EnCoach Support Ticket" + + reporter_name = fields.Char() + reporter_email = fields.Char() + subject = fields.Char(required=True) + description = fields.Text() + ticket_type = fields.Selection( + [ + ("feedback", "Feedback"), + ("bug", "Bug Report"), + ("help", "Help Request"), + ], + string="Type", + ) + status = fields.Selection( + [ + ("open", "Open"), + ("in_progress", "In Progress"), + ("resolved", "Resolved"), + ("closed", "Closed"), + ], + default="open", + ) + exam_information = fields.Json() + assigned_to_id = fields.Many2one( + "res.users", string="Assigned To", ondelete="set null", + ) + created_at = fields.Datetime(default=fields.Datetime.now) + legacy_id = fields.Char(index=True) + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "reporterName": self.reporter_name or "", + "reporterEmail": self.reporter_email or "", + "subject": self.subject or "", + "description": self.description or "", + "type": self.ticket_type or "", + "status": self.status, + "examInformation": self.exam_information, + "assignedTo": ( + self.assigned_to_id.name if self.assigned_to_id else None + ), + "createdAt": ( + self.created_at.isoformat() if self.created_at else None + ), + } diff --git a/encoach_ticket/security/ir.model.access.csv b/encoach_ticket/security/ir.model.access.csv new file mode 100644 index 0000000..ff08ea1 --- /dev/null +++ b/encoach_ticket/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_ticket_student,encoach.ticket.student,model_encoach_ticket,encoach_core.group_encoach_student,1,0,1,0 +access_encoach_ticket_teacher,encoach.ticket.teacher,model_encoach_ticket,encoach_core.group_encoach_teacher,1,1,1,0 +access_encoach_ticket_admin,encoach.ticket.admin,model_encoach_ticket,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_ticket_sysadmin,encoach.ticket.sysadmin,model_encoach_ticket,base.group_system,1,1,1,1 diff --git a/encoach_ticket/security/rules.xml b/encoach_ticket/security/rules.xml new file mode 100644 index 0000000..4072ecc --- /dev/null +++ b/encoach_ticket/security/rules.xml @@ -0,0 +1,17 @@ + + + + + Students see own tickets + + + [('create_uid', '=', user.id)] + + + Admins see all tickets + + + [(1, '=', 1)] + + + diff --git a/encoach_ticket/views/ticket_views.xml b/encoach_ticket/views/ticket_views.xml new file mode 100644 index 0000000..b9c74a0 --- /dev/null +++ b/encoach_ticket/views/ticket_views.xml @@ -0,0 +1,74 @@ + + + + encoach.ticket.tree + encoach.ticket + + + + + + + + + + + + + + + encoach.ticket.form + encoach.ticket + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Tickets + encoach.ticket + list,form + +

+ No support tickets yet. +

+

+ Tickets handle feedback, bug reports, and help requests from users. +

+
+
+ + +
diff --git a/encoach_training/__init__.py b/encoach_training/__init__.py new file mode 100644 index 0000000..71a0242 --- /dev/null +++ b/encoach_training/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import services diff --git a/encoach_training/__manifest__.py b/encoach_training/__manifest__.py new file mode 100644 index 0000000..a317d11 --- /dev/null +++ b/encoach_training/__manifest__.py @@ -0,0 +1,17 @@ +{ + "name": "EnCoach Training", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "Personalized training content and tips for EnCoach", + "description": "FAISS-powered tip retrieval, GPT personalized training, walkthroughs.", + "depends": ["encoach_core", "encoach_ai"], + "data": [ + "security/ir.model.access.csv", + "security/rules.xml", + "views/training_views.xml", + "views/tip_views.xml", + "views/walkthrough_views.xml", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/encoach_training/models/__init__.py b/encoach_training/models/__init__.py new file mode 100644 index 0000000..97427cf --- /dev/null +++ b/encoach_training/models/__init__.py @@ -0,0 +1,3 @@ +from . import training +from . import training_tip +from . import walkthrough diff --git a/encoach_training/models/training.py b/encoach_training/models/training.py new file mode 100644 index 0000000..3490aa8 --- /dev/null +++ b/encoach_training/models/training.py @@ -0,0 +1,28 @@ +from odoo import models, fields + + +class EncoachTraining(models.Model): + _name = "encoach.training" + _description = "EnCoach Training Record" + + user_id = fields.Many2one( + "res.users", string="User", required=True, ondelete="cascade", + ) + exams = fields.Json() + tips = fields.Json() + weak_areas = fields.Json() + created_at = fields.Datetime(default=fields.Datetime.now) + legacy_id = fields.Char(index=True) + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "userId": self.user_id.id if self.user_id else None, + "exams": self.exams, + "tips": self.tips, + "weakAreas": self.weak_areas, + "createdAt": ( + self.created_at.isoformat() if self.created_at else None + ), + } diff --git a/encoach_training/models/training_tip.py b/encoach_training/models/training_tip.py new file mode 100644 index 0000000..57565b5 --- /dev/null +++ b/encoach_training/models/training_tip.py @@ -0,0 +1,22 @@ +from odoo import models, fields + + +class EncoachTrainingTip(models.Model): + _name = "encoach.training.tip" + _description = "EnCoach Training Tip" + + tip_id = fields.Char(required=True, index=True) + category = fields.Selection( + [ + ("ct_focus", "CT Focus"), + ("language_for_writing", "Language for Writing"), + ("reading_skill", "Reading Skill"), + ("strategy", "Strategy"), + ("writing_skill", "Writing Skill"), + ("word_link", "Word Link"), + ("word_partners", "Word Partners"), + ], + string="Category", + ) + content = fields.Text(required=True) + embedding = fields.Binary(string="FAISS Embedding Vector") diff --git a/encoach_training/models/walkthrough.py b/encoach_training/models/walkthrough.py new file mode 100644 index 0000000..3b89f33 --- /dev/null +++ b/encoach_training/models/walkthrough.py @@ -0,0 +1,19 @@ +from odoo import models, fields + + +class EncoachWalkthrough(models.Model): + _name = "encoach.walkthrough" + _description = "EnCoach Walkthrough" + + name = fields.Char() + content = fields.Json() + module = fields.Selection( + [ + ("reading", "Reading"), + ("listening", "Listening"), + ("writing", "Writing"), + ("speaking", "Speaking"), + ("level", "Level"), + ], + string="Module", + ) diff --git a/encoach_training/security/ir.model.access.csv b/encoach_training/security/ir.model.access.csv new file mode 100644 index 0000000..2218f8b --- /dev/null +++ b/encoach_training/security/ir.model.access.csv @@ -0,0 +1,10 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_training_student,encoach.training.student,model_encoach_training,encoach_core.group_encoach_student,1,0,0,0 +access_encoach_training_admin,encoach.training.admin,model_encoach_training,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_training_tip_student,encoach.training.tip.student,model_encoach_training_tip,encoach_core.group_encoach_student,1,0,0,0 +access_encoach_training_tip_admin,encoach.training.tip.admin,model_encoach_training_tip,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_walkthrough_student,encoach.walkthrough.student,model_encoach_walkthrough,encoach_core.group_encoach_student,1,0,0,0 +access_encoach_walkthrough_admin,encoach.walkthrough.admin,model_encoach_walkthrough,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_training_sysadmin,encoach.training.sysadmin,model_encoach_training,base.group_system,1,1,1,1 +access_encoach_training_tip_sysadmin,encoach.training.tip.sysadmin,model_encoach_training_tip,base.group_system,1,1,1,1 +access_encoach_walkthrough_sysadmin,encoach.walkthrough.sysadmin,model_encoach_walkthrough,base.group_system,1,1,1,1 diff --git a/encoach_training/security/rules.xml b/encoach_training/security/rules.xml new file mode 100644 index 0000000..d3d2a97 --- /dev/null +++ b/encoach_training/security/rules.xml @@ -0,0 +1,17 @@ + + + + + Students see own training + + + [('user_id', '=', user.id)] + + + Admins see all training + + + [(1, '=', 1)] + + + diff --git a/encoach_training/services/__init__.py b/encoach_training/services/__init__.py new file mode 100644 index 0000000..8462044 --- /dev/null +++ b/encoach_training/services/__init__.py @@ -0,0 +1 @@ +from . import training_service diff --git a/encoach_training/services/training_service.py b/encoach_training/services/training_service.py new file mode 100644 index 0000000..1b1eae5 --- /dev/null +++ b/encoach_training/services/training_service.py @@ -0,0 +1,177 @@ +import json +import logging +import pickle + +import numpy as np + +from odoo.addons.encoach_ai.models.constants import GPT_MODELS, TEMPERATURE +from odoo.addons.encoach_ai.services.openai_service import EncoachOpenAIService + +_logger = logging.getLogger(__name__) + +TIPS_PROMPT = ( + "You are an IELTS tutor. Based on the context, the question, the " + "student's wrong answer, and the correct answer, provide a concise, " + "helpful tip to help the student improve. Use the following reference " + "tips to inform your response but adapt them to the specific situation.\n\n" + "Reference tips:\n{tips}\n\n" + "Context: {context}\n" + "Question: {question}\n" + "Student's answer: {answer}\n" + "Correct answer: {correct_answer}\n\n" + "Provide your response as JSON: " + '{{"tips": "your personalized tip text"}}' +) + +TRAINING_PROMPT = ( + "You are an IELTS training advisor. Analyze the student's exam history " + "and generate personalized training recommendations.\n\n" + "Student stats:\n{stats}\n\n" + "Generate a JSON response with:\n" + '{{"exams": [recommended practice exams], ' + '"tips": [personalized tips], ' + '"weakAreas": [identified weak areas with suggestions]}}' +) + + +class EncoachTrainingService: + + def __init__(self, env): + self.env = env + self.ai = EncoachOpenAIService(env) + self._faiss_index = None + self._tip_ids = None + + def fetch_tips(self, context, question, answer, correct_answer): + """Retrieve relevant tips using FAISS + generate personalized tip via GPT.""" + query = f"{context} {question} {answer}" + similar_tips = self._query_faiss(query, category=None, top_k=5) + + tips_text = "\n".join( + f"- [{t.category}] {t.content}" for t in similar_tips + ) + + prompt = TIPS_PROMPT.format( + tips=tips_text, + context=context, + question=question, + answer=answer, + correct_answer=correct_answer, + ) + + result = self.ai.prediction( + model=GPT_MODELS["grading"], + messages=[ + {"role": "system", "content": "You are a helpful IELTS tutor. Output JSON."}, + {"role": "user", "content": prompt}, + ], + temperature=TEMPERATURE["tips"], + check_blacklisted=False, + ) + return result + + def get_training_content(self, user_id, stats): + """Generate full training content for a user based on their exam stats.""" + prompt = TRAINING_PROMPT.format(stats=json.dumps(stats)) + + result = self.ai.prediction( + model=GPT_MODELS["grading"], + messages=[ + {"role": "system", "content": "You are an IELTS training advisor. Output JSON."}, + {"role": "user", "content": prompt}, + ], + temperature=TEMPERATURE["tips"], + check_blacklisted=False, + ) + + if result: + training = self.env["encoach.training"].sudo().create({ + "user_id": user_id, + "exams": result.get("exams"), + "tips": result.get("tips"), + "weak_areas": result.get("weakAreas"), + }) + return training + return None + + def _query_faiss(self, query, category=None, top_k=5): + """Search FAISS index for similar training tips. + + Falls back to keyword search if FAISS is not available. + """ + try: + import faiss + from sentence_transformers import SentenceTransformer + except ImportError: + _logger.warning("FAISS/sentence-transformers not installed, falling back to keyword search") + return self._keyword_fallback(query, category, top_k) + + self._ensure_faiss_index() + if self._faiss_index is None: + return self._keyword_fallback(query, category, top_k) + + model = SentenceTransformer("all-MiniLM-L6-v2") + query_vec = model.encode([query]).astype(np.float32) + + distances, indices = self._faiss_index.search(query_vec, top_k * 2) + + tip_ids = [self._tip_ids[i] for i in indices[0] if i < len(self._tip_ids)] + domain = [("id", "in", tip_ids)] + if category: + domain.append(("category", "=", category)) + + tips = self.env["encoach.training.tip"].sudo().search(domain, limit=top_k) + return tips + + def _ensure_faiss_index(self): + """Build or load the FAISS index from stored tip embeddings.""" + if self._faiss_index is not None: + return + + try: + import faiss + except ImportError: + return + + tips = self.env["encoach.training.tip"].sudo().search( + [("embedding", "!=", False)] + ) + if not tips: + return + + vectors = [] + ids = [] + for tip in tips: + vec = pickle.loads(tip.embedding) + vectors.append(vec) + ids.append(tip.id) + + matrix = np.array(vectors, dtype=np.float32) + + dim = matrix.shape[1] + index = faiss.IndexFlatL2(dim) + index.add(matrix) + + self._faiss_index = index + self._tip_ids = ids + + def _keyword_fallback(self, query, category, top_k): + """Simple keyword-based search when FAISS is unavailable.""" + domain = [] + if category: + domain.append(("category", "=", category)) + + tips = self.env["encoach.training.tip"].sudo().search(domain, limit=200) + + query_words = set(query.lower().split()) + scored = [] + for tip in tips: + content_words = set(tip.content.lower().split()) + overlap = len(query_words & content_words) + if overlap > 0: + scored.append((overlap, tip)) + + scored.sort(key=lambda x: x[0], reverse=True) + return self.env["encoach.training.tip"].browse( + [t.id for _, t in scored[:top_k]] + ) diff --git a/encoach_training/views/tip_views.xml b/encoach_training/views/tip_views.xml new file mode 100644 index 0000000..4d291e6 --- /dev/null +++ b/encoach_training/views/tip_views.xml @@ -0,0 +1,54 @@ + + + + encoach.training.tip.tree + encoach.training.tip + + + + + + + + + + + encoach.training.tip.form + encoach.training.tip + +
+ + + + + + + + + + + +
+
+
+ + + Training Tips + encoach.training.tip + list,form + +

+ Create your first training tip. +

+

+ Training tips provide personalized advice for users based on categories like strategy, writing skills, and reading skills. +

+
+
+ + +
diff --git a/encoach_training/views/training_views.xml b/encoach_training/views/training_views.xml new file mode 100644 index 0000000..4442598 --- /dev/null +++ b/encoach_training/views/training_views.xml @@ -0,0 +1,65 @@ + + + + encoach.training.tree + encoach.training + + + + + + + + + + + encoach.training.form + encoach.training + +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Training Records + encoach.training + list,form + +

+ Create your first training record. +

+

+ Training records store personalized exam data, tips, and weak areas for each user. +

+
+
+ + +
diff --git a/encoach_training/views/walkthrough_views.xml b/encoach_training/views/walkthrough_views.xml new file mode 100644 index 0000000..2dc8d1d --- /dev/null +++ b/encoach_training/views/walkthrough_views.xml @@ -0,0 +1,53 @@ + + + + encoach.walkthrough.tree + encoach.walkthrough + + + + + + + + + + encoach.walkthrough.form + encoach.walkthrough + +
+ + + + + + + + + + + +
+
+
+ + + Walkthroughs + encoach.walkthrough + list,form + +

+ Create your first walkthrough. +

+

+ Walkthroughs provide guided content for Reading, Listening, Writing, Speaking, and Level modules. +

+
+
+ + +
diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3c81fd3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,43 @@ +# EnCoach Odoo 19 -- Python dependencies +# Install into the Odoo server's Python environment: +# pip install -r requirements.txt + +# OpenAI API client (GPT-4o, GPT-3.5-turbo) +openai>=1.50 + +# Local Whisper model for speech-to-text +openai-whisper + +# AWS SDK for Polly TTS +boto3>=1.34 + +# FAISS vector search for training tips +faiss-cpu>=1.7 + +# Text embeddings for FAISS +sentence-transformers>=3.0 + +# HTTP client for ELAI and GPTZero API calls +httpx>=0.27 + +# Token counting for OpenAI prompts +tiktoken>=0.7 + +# Audio processing for Whisper +librosa>=0.10 +soundfile>=0.12 + +# Numerical operations +numpy>=1.26 + +# Retry logic for external API calls +tenacity>=8.2 + +# PyTorch (required by Whisper and sentence-transformers) +torch>=2.0 + +# JWT authentication for API controllers +PyJWT>=2.8 + +# MongoDB client (migration scripts only) +pymongo>=4.6