From 951dd580a534526a6d2f6d991cb4a287832c9314 Mon Sep 17 00:00:00 2001 From: Sam <78538841+spwoodcock@users.noreply.github.com> Date: Fri, 23 Feb 2024 17:05:31 +0100 Subject: [PATCH] Merge dev --> staging 12/02/2024 (#1267) * feat: project submissions page (#1150) * fix (routes): projectSubmissions route added * fix (icon): icon added * fix (projectSubmissions): setup * fix (projectSubmissions): styling adjustments * feat (projectSubmissions): projectInfo section slicing * feat (AssetModules): icon added * feat (taskSubmissions): taskSection - UI slicing * fix (projectSubmissions): UI fixes * feat (AssetModules): icons added * feat (select): className added to select * feat (projectSubmission): submissionsTable - filter section UI completed * feat: (AssetModules): icons added * fix (routes): normal import for projectSubmissions * feat (customTable): custom table added * feat (submissionsPage): table added to submission page * fix (taskSubmissions): mobile responsive * feat (recharts): recharts package added * icon added * feat (recharts): bar & pie chart added * feat (LineChart): lineChart added * feat (ProjectSubmissions): infographics - infographics slicing * feat (package): html2canvas package added * feat (submissionsPage): chartDownload - download feature added to the charts * fix (lineChart): line color changed * fix (UI): UI changes * fix (barChart): dynamic data for barChart * fix (pieChart): dynamic data for pieChart * fix (barChart): dynamic dataKey for xAxis * fix (lineChart): dynamic data for lineChart * fix (chart): bar/pie color changed * fix (lineChart): x & y axis labels added * feat (projectSubmissions): skeleton loader added * fix (projectSubmissions): filter padding increase * feat (projectSubmissions): skeletonLoader - skeletonLoader added to infographics and table * created api that returns the submission count by date * fix (projectSubmissions): infographicsCard - infographicsCard seperated to another component * feat (projectSubmissions): barChart - api fetch for formSubmissions * refactored response * feat: (projectSubmissions): submissionBar - dummy data replaced with actual data * fix (barChart): maxBarSize set * feat (projectSubmissions): task api call * feat (projectSubmissions): projectInfo - dynamic value for projTitle, tasks count, submission count * feat (projectSubmissions): taskSidebar - dynamic data added * feat (projectSubmissions): taskSidebar - zoomToTask feature added * Created Project dashboard * added organization info * Exception handling in s3 if no object found * refactor * fix: string based date to custom date format for over 7days * feat (projectSubmissions): contributorsTable - contributors table data fetch * fix (projectSubmissions): UI fix * fix (assetModules): duplicated icon removed * feat (projectSubmissions): projectInfo - api integration * fix (projectSubmissions): piechart - outerRadius adjusted * fix (projectSubmissions): message display if no data available * feat (projectSubmissions): taskLoading - skeleton loader added for tasks * fix (projectSubmissions): filter - UI fix * feat submissionsTable: api fetch for submissionsTable field & data * feat projectSubmissionsTable: dynamic header & data add with skeleton loader * fix projectSubmissions: relative path imports replaced with absolute path imports * feat projectSubmissionsTable: refresh table data functionality add * fix import: wrong validation import fix * feat submissionTable: mui tablePagination added * fix projectSubmissions: pagination api fetch * fix createProjectSlice: import fix * fix projectSubmissions: types added to slice * fix projectSubmissions: loading state to false if error arise in table api fetch * fix projectSubmissions: pagination disable on api fetch pending * fix select: optional type of null to value, errorMsg optional * fix projectSubmissions: table filter state added * fix lineChart: line color changed, xLabel & yLabel hidden if not present * fix projectSubmissions: validated vs mapped task lineChart add * fix bar/pieChart - color updated * fix projectSubmissions: redirection to submissionsPage on View Submissions click * fix projectSubmissions: loading state add on projectProgress api fetch * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: sujanadh Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * fix loadingBar: loading bar moved to & imported from createnewproject folder * refactor createproject: old createProject folder deleted * refactor routes: removed import & removed old commented createproject routes * docs: add docs and task board badges to README * docs: update docs/task board badges to track development * fix: project creation draw and max boundary area (#1157) * fix uploadArea: areaValidation for fileUpload, draw hide if selectionType uploadFile, draw available if AOI reset * fix stepSwitcher: remove cursor-pointer on step hover * fix projctDetails: link to setting ODK server placed on Here text * refactor: add extra error handling if data extract download fails (#1158) * fix: downloading data extract accessing download_url key * add error log if data extract download fails * fix: project submission date and time formatted (#1160) * fix button: other btnType UI change * fix useOutsideClick: useOutsideClick hook add * feat projectDetails: buttons added including handleOutsideClick * feat projectOptions: options added for larger screens * fix projectDetails: redirect to projectSubmissions page on viewInfographics click * fix: zlib compress dynamic QR codes from frontend (#1159) * build(frontend): add pako for zlib compression, relock via pnpm * fix(frontend): add zlib compression to dynamic qrcodes * fix: zlib compression for qrcodes pako v2 (Uint8Array) * build: add qrcode conversion container to contrib * fix(frontend): remove svcfmtm from loginSlice initialState * fix(backend): append /v1/ to odk_token url for qrcodes * fix: map default odk credentials to organisations (#1123) * fix: add odk cred org schemas for db and pydantic * fix: add HttpUrlStr type for Url validation of str values * fix: fix OdkCentral schema to handle passwords, inherit * refactor: use new ODKCentralDecrypted, simplify proj create * fix: add org_deps get_org_odk_creds method * refactor(frontend): update create_project json structure * refactor(frontend): remove code to add #FMTM tag automatically * test: fix project route tests * refactor: revert setting outline_geojson in create_app_project * feat(frontend): option to add default odk creds for organisation * refactor(frontend): rename form during project creation --> category * refactor: remove additional route added during rebase * feat: set project delete endpoint to org_admin only * feat(frontend): project deletion capability * fix: organisation logo display full width (#1166) * fix homePage: exploreProjectCard image crop fix * fix splitTasks: text below progressBar remove * fix: create popup outside of async request (fix ios login) (#1167) * fix: baselayer url changed with token * feat: project admin role (#1133) * project_admin role deps * fix: default value for role in user_role table * feat: api to create new project manager * fix: linting errors * fix: linting errors * delete projects endpoint restricted for organisation admin only * fix:linting errors * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add new project admin allowed to project admin role * updated project admin role deps * feat: dependency org_admin to create project * feat: implemented access to project_admin to edit the project * feat: applied user dependency as per the requirement * project_admin role deps * fix: default value for role in user_role table * feat: api to create new project manager * fix: linting errors * fix: linting errors * delete projects endpoint restricted for organisation admin only * fix:linting errors * add new project admin allowed to project admin role * updated project admin role deps * feat: dependency org_admin to create project * feat: implemented access to project_admin to edit the project * feat: applied user dependency as per the requirement * update and refactor: user roles * refactor: simplify role access, combine into single query * refactor: update usage of roles, org_admin return dict of objects * fix: update project manager role usage * perf: add db indexes for org managers and user roles * dependency removed from validate_form endpoint * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * test: fix create_project pass url param * fix: add role for DEBUG enabled user * fix(frontend): pass org_id as url param to create_project * test: create test user account as ADMIN permission * fix: separate login logic from /me/ route * test: fix tests requiring auth permissions --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: sujanadh Co-authored-by: spwoodcock Co-authored-by: Sam <78538841+spwoodcock@users.noreply.github.com> * fix: organisation routes role usage & approval * fix: remove approved flag from organisations endpoint * feat: mapper role (#1163) * fix: remove approved flag from organisations endpoint * feat: Implement Mapper role * Refactor: check role from lower hierarchy and added role in end points * refactor: update mapper role to use check_access func * refactor: fix linting errors for backend --------- Co-authored-by: spwoodcock Co-authored-by: sujanadh * feat: manage / edit project UI (#1154) * fix (routes): projectSubmissions route added * fix (icon): icon added * fix (projectSubmissions): setup * fix (projectSubmissions): styling adjustments * feat (projectSubmissions): projectInfo section slicing * feat (AssetModules): icon added * feat (taskSubmissions): taskSection - UI slicing * fix (projectSubmissions): UI fixes * feat (AssetModules): icons added * feat (select): className added to select * feat (projectSubmission): submissionsTable - filter section UI completed * feat: (AssetModules): icons added * fix (routes): normal import for projectSubmissions * feat (customTable): custom table added * feat (submissionsPage): table added to submission page * fix (taskSubmissions): mobile responsive * feat (recharts): recharts package added * icon added * feat (recharts): bar & pie chart added * feat (LineChart): lineChart added * feat (ProjectSubmissions): infographics - infographics slicing * feat (package): html2canvas package added * feat (submissionsPage): chartDownload - download feature added to the charts * fix (lineChart): line color changed * fix (UI): UI changes * fix (barChart): dynamic data for barChart * fix (pieChart): dynamic data for pieChart * fix (barChart): dynamic dataKey for xAxis * fix (lineChart): dynamic data for lineChart * fix (chart): bar/pie color changed * fix (lineChart): x & y axis labels added * feat (projectSubmissions): skeleton loader added * fix (projectSubmissions): filter padding increase * feat (projectSubmissions): skeletonLoader - skeletonLoader added to infographics and table * created api that returns the submission count by date * fix (projectSubmissions): infographicsCard - infographicsCard seperated to another component * feat (projectSubmissions): barChart - api fetch for formSubmissions * refactored response * feat: (projectSubmissions): submissionBar - dummy data replaced with actual data * fix (barChart): maxBarSize set * feat (projectSubmissions): task api call * feat (projectSubmissions): projectInfo - dynamic value for projTitle, tasks count, submission count * feat (projectSubmissions): taskSidebar - dynamic data added * feat (projectSubmissions): taskSidebar - zoomToTask feature added * Created Project dashboard * added organization info * Exception handling in s3 if no object found * refactor * fix: string based date to custom date format for over 7days * feat (projectSubmissions): contributorsTable - contributors table data fetch * fix (projectSubmissions): UI fix * fix (assetModules): duplicated icon removed * feat (projectSubmissions): projectInfo - api integration * fix (projectSubmissions): piechart - outerRadius adjusted * fix (projectSubmissions): message display if no data available * feat (projectSubmissions): taskLoading - skeleton loader added for tasks * fix (projectSubmissions): filter - UI fix * feat (manageProject): manageProject added * feat (assetModules): icon added * feat (chips): chips added * fix (select): UI fix * feat (manageProject): partial UI completion for manage-project * feat (AssetModules): icon added * feat (UploadArea): custom upload area added * feat (manageProject): editTab - UI slicing completed for editTab * fix (AssetModules): duplicate icon removed * fix (select/manageProject): UI fix * feat (ManageProject): userTab - table added * fix (manageProject): mobile responsive fix * fix (customTable): css fix * feat (AssetModules): icons added * feat kebabMenu: kebabMenu added * feat (manageProject): table - kebab menu added to table * fix manageProject - UI fixes * fix (KebabMenu): added types * fix select: title background transparent * fix button: loading text checked first to display if button state loading * feat (manageProject): editProject - edit project description add * feat manageProject: formCategory list api fetch * feat uuid: uuid package add to generated ids * fix uploadArea: uuid add to uniquely identify each file * fix (manageProject): edit - projectId passes as prop to child * feat (manageProject): formUpdate - postFormUpdate api hit on update click * fix manageProject: individualProjectDetails fetch on parent component * fix navigation: navigation route added to manageProject and back btn * feat newProjectDetails: viewInfographics & manageProject btn add for mobile view under project options --------- Co-authored-by: sujanadh * ci: update all gh-workflows --> v1.4.7 * refactor: cast all fields for sqlalchemy with python types (#1173) * refactor: cast all fields for sqlalchemy with python types * fix: flatgeobuf conversion extract from Column first * feat: task history end point with associated user (#1071) * feat: separate task history end point * fix: add user relationship backref to task history * fix: add user details to task_history endpoint * refactor: revert to sqlalchemy backref names --------- Co-authored-by: sujanadh Co-authored-by: spwoodcock * feat(frontend): default organisation credentials during proj create (#1174) * fix: improve create organisation ui styling * fix: set org odk password field to password type * fix: allow for empty value for HttpUrlStr validation * feat(frontend): create project using default organization creds * refactor: update project creation comments --> tags * refactor: task status update endpoint to adjust mapper role (#1180) * update: task status update endpoint to adjust mapper role * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * fix: update db relations for generating submissions (#1179) * fix: update submission in s3 * Fix: error handling in s3 * removed optional from get_obj_from_bucket --------- Co-authored-by: sujanadh * fix uploadArea: geojson validation for uploaded file * feat: create / edit organization form (#1178) * feat createEditOrganization: routes/header setup * feat createEditOrganization: conditionally showing consent/createEdit forms * fix createEditOrganizationHeader: component renamed * feat consentQuestions: consent questions schema setup in constants * feat consentDetailsForm: leftSide container UI add * feat checkbox: custom radixUI checkbox component add * fix radioButton: className prop add to radioButton * fix consentQuestions: questions update * fix button: hover effect added to btn type other * fix checkBox: UI fix * feat consentDetailsForm: UI complete * feat consentDetailsForm: useForm integration & validations add * fix createEditOrganizationForm: organizationId props pass * fix RadioButton: required prop added * fix inputTextField: sublabel add * feat createEditOrganizationForm: form UI & validation complete * fix inputTextField: subLabel update * fix consentQuestions: redirection links added * fix organizationForm: post create organization detail * feat editOrganizationForm: individualOrganization api fetch * feat organizationForm: patch organizationData on update * fix organizationForm: naming change * fix organization: instructions updated & seperated to another component * fix manageOrganization: hide profile, org_type, osm_profile fields for edit organization * fix projectDetails: dowloadOptions open/close issue fix * feat approveOrganization: UI complete * fix organizationService: loading state false * fix createEditOrganizationForm: osm_profile remove from form * fix createEditOrganizationForm: filedType changed to password * feat userRole: userRole set to localStorage * fix manageOrganizations: UI fix, verified show/hide based on userRoles * fix manageOrganization: UI update * fix organizationDetailsValidation: osmProfile removed * fix createEditOrganization: clear consentForm state on organization creation * feat approveOrganization: approve api hit on verify click * refactor: org creation page consent questions (#1185) * fix consentQuestion: participated_in question required change to false * fix consentDetailsValidation: updated validation where user should mark atleast one checkbox for log_into question * feat: osm-rawdata for generating data extracts (#1183) * refactor: remove old unused DefineMapArea component * refactor: rename /generate endpoint --> /generate_project_data * fix(frontend): correctly send data_extract_type to project create * fix(frontend): add data_extract_url param, refactor DataExtract for clarity * build: add migration for projects.data_extract_url field * fix: add slash to osm-data-extract url for correct method * fix(frontend): pass through data extract url & type during proj creation * fix: set data extract url and type on generate endpoint * fix(backend): use osm-rawdata for data extract generation + filtering * fix(frontend): add form_category for osm data extract post params * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * build: update osm-rawdata --> v0.2.0 * build: update osm-rawdata --> v0.2.1 * feat: correctly set data extract url if custom upload or osm * fix: load fgb data extract and get geojson geoms * fix: accept xls forms in xls and xlsx format * fix: optional data extracts for tasking splitting algo * build: update fmtm-splitter --> v1.1.1 * refactor: simplify project file generation during creation * fix(frontend): passing params for tasking splitting algo * refactor: remove data extract type if osm generated * build: update minio browser to use env var * refactor: do not include minio port bind by default --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * test: fix create project generate_appuser_files usage * fix: project submission card UI enhancement (#1186) * feat assetModules: icon add * fix projectSubmissions: projectDataCard UI enhancement * fix: projectSubmissions: UI enhancement * fix projectSubmissions: infographics section mobile responsive * docs: use mdx_truly_sane_lists plugin for list ordering * fix: feature type not being saved as a geojson type * fix: axios interceptors fixing test * fix: axios interceptor testing * fix: project creation workflow fixes (task splitting) (#1194) * fix: remove Field(Form()) from schemas * refactor: move geometry generation to project_schemas * fix(frontend): send project outline geojson during create * fix: only send custom data extracts to splitter * fix: optional project_task_name in schema * refactor(frontend): remove refs to project_task_name * refactor(backend): remove extract_type from data-extract-url * fix(frontend): setting data extract url if osm generated extract * test: fix or remove tests during project creation * fix: add odk_central_url to org details returned * feat: add community_type for organisations, add unapproved org list endpoint (#1197) * create organisation permission updated to login_required * api to list unapproved organisations * Feat: Added email and community_type field in organisation * fix: changed org_id to mandatory to create project * fix: added organisation_id in payload of create project * fix: await check crs, added email and community type in test organisation * refactor: proper enum field community type in test organisation * build: update community_type migration number & logic * refactor: remove email field from organisation * refactor: remove organisation_id from ProjectUpload model * refactor: remove email from dborg model for conftest * refactor: remove organisation_id from create_project POST json * test: fix remove organisation_id from ProjectUpload * fix: add optional organisation_id to ProjectUpload * feat: add project to org_user_dict if present * fix: extract project from org_user_dict on deletion * test: fix tests to include organisation_id extracted from fixture --------- Co-authored-by: Niraj Adhikari Co-authored-by: sujanadh Co-authored-by: spwoodcock * fix: org creation using Form params * feat: endpoints for task comments (#1171) * feat: taskcomment model added * feat: crud task comment * feat: task comment crud api route added * feat: task comment schema added * fix: removed use of dictionary to object * fix: change row name by id with task_comment_id * fix: renamed comment_task_id_to_id * feat: migration add-task-comment * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * renamed task_comment index * fix: removed new table migration for taskcomment * feat: used task_history table to get task_comment and add task_comment Using task_history table maintained action row as COMMENT and added comment * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: linting issue * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * refactor: improve sql, use query params, fix linting errors * refactor: minor code cleanup bundled in merge --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: spwoodcock * fix: update task status when mapping starts (#1208) Co-authored-by: sujanadh * feat: init public beta org and svcfmtm user migration (#1206) * build: remove odk vars from frontend build dev compose * build: remove user password field from init schema * build: fix only continue migration if sql script succeeds * build: update migration entrypoint envsubst + error handling * build: migration to create fmtm public beta org + svcfmtm * build: add gettext for envsubst command in dockerfile * build: use odk user email for svcfmtm user email * fix: replace org init with startup code for pass encrypt * fix: specify user id for svcfmtm admin init * build: fix remove sequential user id (create manually) * build: remove default user creation from migration script * build: fix project-roles migration if enum exists * fix (manageProject) - formUpdate: remove formCategory dropdown for update * fix (projectSubmissions): taskList: search functionality add to task-list section * fix taskIdSearch: message display if searched taskId not present * fix:added interceptor for cookie issue * feat: filters on submission table (#1191) Co-authored-by: sujanadh * build: increase proxy_read_timeout for longer connection time * refactor: remove old edit-project code (#1210) * refactor (frontend): rename changeset to hashtags (#1211) * fix editProject: fieldName changeset change to tags, tags field required set to false * fix namingChange: field title Tags changed to Hashtags * feat: api returning details of unapproved org (#1218) Co-authored-by: sujanadh * feat: improve submissions dynamic filtering (#1217) * fix submissionTable: filterObj key name change, reviewStateData constant add * feat: filters on submission table * fix inputTextField: input label fix * fix submissionTable: submitted by textField add * feat customDatePicker: react-datepicker add * fix projectSubmissions: datepicker add & payload add for api fetch * fix projectSubmissions: submissionFilters UI enhancement * fix projectSubmissions: toggle infographics/table view UI re-arrangement * feat pieChart: legend add * fix submissionInfographics: dynamic value for projectProgress pieChart * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: sujanadh Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * fix: data extract generation and task splitting with polylines (#1219) * build: bump osm-rawdata & fmtm-splitter versions for fixes * fix: use useStWithin=False for data extract generation with polyline * fix: first coordinate check when check_crs geojson * Updated osm-fieldwork version (#1222) * fix: submissions form fields * refactor central crud * pdm lock file updated --------- Co-authored-by: sujanadh Co-authored-by: Niraj Adhikari * feat (frontend): approve organization api integration (#1215) * fix managProject: UI fix * feat manageOrganization: redirect to approveOrganization on org click on To Be Verified tab org list * fix rejectOrganization: service for rejectOrganization add * feat organizationApproval: state management to show approving/rejecting status, redirect to manage-org after approval success * fix: update schema to edit only required fields (#1223) Co-authored-by: sujanadh * Instructions in create and edit project (#1224) * fix: update schema to edit only required fields * feat: added instructions in project create and edit --------- Co-authored-by: sujanadh * fix editProject: formUpdate file extension spelling corrected to xlsx * fix header: activeTab selection based on url pathname * refactor primaryAppBar: unnecessary commented code remove * feat: added approved in get organisation response (#1226) Co-authored-by: sujanadh * feat assetModules: tuneIcon add * feat shadcnDropdown: shadcn dropdown add * fix select: zIndex add to selectContent * fix submissionsFilter: UI enhancement * fix submissionTable: logic add to handle Point & object to display dynamic table * feat submissionsTable: buttons add * fix modal: modal props type add * fix taskService: josmService comment update * feat projectSubmissions: uploadToJosm api integration & modalOpen on josmError * fix: removed deps from delele org api (#1233) Co-authored-by: sujanadh * fix dropdown: dropdownMenu UI fix * feat submissionTable: download as csv & json dropdown add on download option * fix taskService: axios get replace fetch for convertXMLToJOSM service * fix: required odk credentials if no organisation default (#1205) * fix ICreateProject: projectDetailsTypes fix * fix projectDetialsForm: default of value #FMTM add to tags field * fix projectDetailsForm: odk url, email, password made required * fix projectDetailsForm: default FMTM tag removal * fix checkbox: className props add * fix createEditOrganization: remove email field, checkbox add to conditionally show ODK credentials field * fix organizationForm: remove email field * fix customDatePicker: types add * fix createEditOrganizationFrom: ODK credential fields optional/required based on checkbox * feat createNewProject: conditionally hide/unhide ODK credentials fields based on checkbox selection * fix createNewProject: clear odkCredentials on defaultODKCredentials checkbox uncheck * fix createNewProject - step1: UI enhancement, splitted form updated to single column form * fix organizationForm: getUnapprovedOrganization api replacement * fix unapprovedOrg: return obj instead of array * fix organization: state not clear issue solve * fix organization: editOrganization organizationFormData state clear * fix createEditOrganization: comments add * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * feat (frontend,backend): my organisations tab implemented (#1227) * feat: my organisations api route added * feat: get my organisation function * feat: added get organisation api service * feat: organisationGridCard Component created * feat: Type added to organisation slice * Feat: My organisation tab for data fetching * refactor: remove missed print statement --------- Co-authored-by: spwoodcock * refactor: improve flatgeobuf loading and generation (#1235) * refactor: update flatgeobuf --> geojson to extract properties * fix: create flatgeobuf files with spatial index by default * docs: comment note for future fgb generation * docs: extra info on fgb generation with tags * fix: project org UI issues (#1230) * fix editProject: formUpdate file extension spelling corrected to xlsx * fix header: activeTab selection based on url pathname * refactor primaryAppBar: unnecessary commented code remove * feat assetModules: tuneIcon add * feat shadcnDropdown: shadcn dropdown add * fix select: zIndex add to selectContent * fix submissionsFilter: UI enhancement * fix submissionTable: logic add to handle Point & object to display dynamic table * feat submissionsTable: buttons add * fix modal: modal props type add * fix taskService: josmService comment update * feat projectSubmissions: uploadToJosm api integration & modalOpen on josmError * fix dropdown: dropdownMenu UI fix * feat submissionTable: download as csv & json dropdown add on download option * fix taskService: axios get replace fetch for convertXMLToJOSM service * test (frontend): case config, add test cases (#1231) * feat: Test cases configuration changes for vitest * feat(Test): Button test cases added with docstring * feat(Testcases): added a small test cases to check if test is working * fix: add created_by user id to organisation table (#1232) * feat: added user in organisation * feat: make user org manager after org approval * fix: added user_id in org pytest fixture * refactor: simplify organisation user_id --> created_by int field * build: update migrations to match org db model * refactor: simplify org approval logic * refactor: bullet point in migration file --------- Co-authored-by: sujanadh Co-authored-by: spwoodcock * feat submissionsPage: redirect to respective submissions & task submissions page on button click * Feat verification tag add (#1234) * feat organizationList: verification tag add on organization card * fix projectTaskStatus: enum value display * fix manageOrganization: hide new btn if no token & show verified/unverified tab on ALL tab selection only * fix manageOrganization: add verification tag & redirect to approve org if org not verified * fix: qr popup on task status & styling (#1184) * fix (environment): tasksStatus - actionKey name change, btnBG key add * fix assetModules: icon add * fix button: UI change, id prop inclusion * fix qrCodeComponent: UI enhancement, component moved from projectDetails to taskSectionPopup * fix dialogTaskActions: UI enhancements, buttons add * fix qrCodeComponent: qrcode access form parent component, code cleanup, UI enhancement * fix qrCodeComponent: UI fix * fix projectdetails: old projectDetails page replaced with new * fix button: id prop relaced with btnId prop * fix qrPopup: btnid extract from event.currentTarget * fix projectDetails: navigate path to project_details fix * feat filterParams: filter out null objects * feat projectSubmissions: searchParams add on individual submissions page navigation * fix projectSubmissions: navigate to projectSubmissions with searchParams * feat: added bbox in read project's outline (#1244) Co-authored-by: sujanadh * feat: flatgeobuf data extracts on frontend & backend (#1241) * refactor: add data_extract_url to ProjectOut schema * feat: load remote flatgeobuf data extracts from S3 * fix(frontend): correctly load nested fgb GeometryCollection type * feat: read/write flatgeobuf, split geoms by task in database * feat: split fgb extract by t ask, generate geojson form media * refactor: deletion comment for frontend files not required * build: remove features table from db * feat: allow uploading of custom data extracts in fgb format * test: update tests to use flatgeobuf data extracts * refactor: rename var for clarity * ci: minify backend test data + ignore from prettier * fix: move OdkDecrypted logic into project_deps (#1239) * fix:commit features to db before generating task files * refactor: convert repeated odk_cred into a function * fix: reuse DbProject object when retrieving odk creds --------- Co-authored-by: sujanadh Co-authored-by: spwoodcock * fix: comment broken SubmissionMap import on frontend * refactor: changed sync to async gather_all_submission_csvs (#1258) Co-authored-by: sujanadh * feat(manage-organizations): add `OrganizationCardSkeleton` component * feat(manage-organisations): implement skeleton loader while Organizations are being fetched * refactor: reorganize the code to make it cleaner * fix: send bbox from geojson properties to josm (#1256) * build: upgrade osm-rawdata --> 0.2.3 & relock all versions * fix: file validity check on fileUpload only once (#1262) * fix: use raw sql for organisation crud (#1253) * fix: update initial_feature_count --> feature_count and populate values (#1265) * build: rename tasks.initial_feature_count --> tasks.feature_count * refactor: rename initial_feauture_count in endpoints * fix: dynamic submissions legend chloropeth (#1250) * fix projectSubmissions: dynamic mapLegend and taskChloropeth on increasing submissions count * feat projectSubmissionsMap: popup showing expected/submissions count add * fix homeSCSS: width fix * fix popupHeader: border fix * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * refactor: removing redundant code for removing extra closing tag (#1264) Co-authored-by: sujanadh * fix: task splitting with custom data extract (#1255) * fix: handle GeometryCollection wrappers in geojson parse * fix: task splitting when custom data extract uploaded * feat: add map popups to data extract geometries (#1266) * refactor: always return all properties on map click * refactor: rename submissionModel --> taskModel * refactor: configurable primary property key for map AsyncPopup * feat: add map popup on data extract geom click * build: make initial_feature_count migration idempotent --------- Co-authored-by: Nishit Suwal <81785002+NSUWAL123@users.noreply.github.com> Co-authored-by: sujanadh Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: NSUWAL123 Co-authored-by: Deepak Pradhan (Varun) <37866666+varun2948@users.noreply.github.com> Co-authored-by: Deepak Pradhan Co-authored-by: Niraj Adhikari <41701707+nrjadkry@users.noreply.github.com> Co-authored-by: Sujan Adhikari <109404840+Sujanadh@users.noreply.github.com> Co-authored-by: Niraj Adhikari Co-authored-by: prajwalism --- .github/workflows/build_and_deploy.yml | 10 +- .github/workflows/build_ci_img.yml | 2 +- .github/workflows/build_odk_imgs.yml | 4 +- .github/workflows/build_proxy_imgs.yml | 8 +- .github/workflows/docs.yml | 6 +- .github/workflows/pr_test_backend.yml | 2 +- .github/workflows/pr_test_frontend.yml | 2 +- .github/workflows/tag_build.yml | 2 +- .github/workflows/wiki.yml | 2 +- .pre-commit-config.yaml | 1 + INSTALL.md | 6 +- README.md | 11 +- contrib/qrcode_util/Dockerfile | 67 + contrib/qrcode_util/README.md | 39 + contrib/qrcode_util/build.sh | 5 + contrib/qrcode_util/qrcode_util.py | 69 + docker-compose.yml | 11 +- docs/images/docs_badge.svg | 1 + docs/images/tasks_badge.svg | 1 + mkdocs.yml | 1 + nginx/templates/api.conf.template | 6 +- nginx/templates/dev/api.conf.template | 6 +- src/backend/Dockerfile | 1 + src/backend/app/auth/auth_routes.py | 72 +- src/backend/app/auth/osm.py | 3 +- src/backend/app/auth/roles.py | 269 +++- src/backend/app/central/central_crud.py | 84 +- src/backend/app/central/central_routes.py | 2 +- src/backend/app/config.py | 26 +- src/backend/app/db/db_models.py | 566 ++++--- src/backend/app/db/postgis_utils.py | 437 +++++- src/backend/app/main.py | 12 +- src/backend/app/models/enums.py | 10 + .../app/organisations/organisation_crud.py | 230 ++- .../app/organisations/organisation_deps.py | 57 +- .../app/organisations/organisation_routes.py | 95 +- .../app/organisations/organisation_schemas.py | 93 +- src/backend/app/projects/project_crud.py | 1386 ++++++----------- src/backend/app/projects/project_deps.py | 21 +- src/backend/app/projects/project_routes.py | 496 +++--- src/backend/app/projects/project_schemas.py | 205 ++- src/backend/app/s3.py | 1 + .../app/submissions/submission_crud.py | 139 +- .../app/submissions/submission_routes.py | 71 +- src/backend/app/tasks/tasks_crud.py | 178 ++- src/backend/app/tasks/tasks_routes.py | 132 +- src/backend/app/tasks/tasks_schemas.py | 91 +- src/backend/migrate-entrypoint.sh | 32 +- .../001-project-split-type-fields.sql | 2 +- src/backend/migrations/003-project-roles.sql | 15 +- src/backend/migrations/005-remove-qrcode.sql | 4 +- src/backend/migrations/006-index-roles.sql | 12 + .../migrations/007-add-extract-url.sql | 12 + .../migrations/008-add-user-in-org.sql | 13 + .../migrations/009-add-community-type.sql | 29 + .../migrations/010-drop-features-table.sql | 13 + .../migrations/011-task-features-count.sql | 15 + .../migrations/init/fmtm_base_schema.sql | 55 +- .../migrations/revert/005-remove-qrcode.sql | 2 +- .../migrations/revert/006-index-roles.sql | 8 + .../migrations/revert/007-add-extract-url.sql | 9 + .../migrations/revert/008-add-user-in-org.sql | 9 + .../revert/009-add-community-type.sql | 11 + .../revert/010-drop-features-table.sql | 40 + .../revert/011-task-features-count.sql | 8 + src/backend/pdm.lock | 632 ++++---- src/backend/pyproject.toml | 6 +- src/backend/tests/conftest.py | 72 +- .../test_data/data_extract_kathmandu.fgb | Bin 0 -> 223728 bytes .../test_data/data_extract_kathmandu.geojson | 1 + src/backend/tests/test_projects_routes.py | 244 ++- src/frontend/package.json | 16 +- src/frontend/pnpm-lock.yaml | 516 +++++- src/frontend/src/App.jsx | 22 +- src/frontend/src/api/CreateProjectService.ts | 254 ++- src/frontend/src/api/Files.js | 14 +- src/frontend/src/api/OrganisationService.ts | 149 +- src/frontend/src/api/Project.js | 2 +- src/frontend/src/api/ProjectTaskStatus.js | 10 +- src/frontend/src/api/SubmissionService.ts | 94 +- src/frontend/src/api/task.ts | 18 +- .../ApproveOrganizationHeader.tsx | 24 + .../ApproveOrganization/OrganizationForm.tsx | 154 ++ .../ConsentDetailsForm.tsx | 98 ++ .../CreateEditOrganizationForm.tsx | 286 ++++ .../CreateEditOrganizationHeader.tsx | 26 + .../InstructionsSidebar.tsx | 27 + .../validation/ConsentDetailsValidation.ts | 34 + .../OrganizationDetailsValidation.ts | 72 + .../src/components/DialogTaskActions.jsx | 126 +- .../ManageProject/EditTab/FormUpdateTab.tsx | 48 + .../EditTab/ProjectDescriptionTab.tsx | 107 ++ .../ManageProject/EditTab/index.tsx | 36 + .../EditProjectDetailsValidation.ts | 21 +- .../ManageProject/UserTab/AssignTab.tsx | 68 + .../ManageProject/UserTab/InviteTab.tsx | 68 + .../ManageProject/UserTab/index.tsx | 167 ++ .../AsyncPopup/AsyncPopup.tsx | 186 +++ .../AsyncPopup/asyncpopup.scss | 38 + .../LayerSwitcher/index.js | 4 +- .../OpenLayersComponent/Layers/VectorLayer.js | 157 +- .../components/MapDescriptionComponents.jsx | 2 +- .../ProjectDetails/ProjectOptions.tsx | 15 - .../ProjectDetailsV2/ProjectOptions.tsx | 230 +-- .../ProjectDetailsV2/TaskSectionPopup.tsx | 66 +- .../components/ProjectInfo/ProjectInfomap.jsx | 15 +- .../src/components/ProjectMap/ProjectMap.jsx | 102 -- .../ProjectSubmissions/InfographicsCard.tsx | 32 + .../ProjectSubmissions/ProjectInfo.tsx | 104 ++ .../ProjectSubmissionsSkeletonLoader.tsx | 39 + .../SubmissionsInfographics.tsx | 314 ++++ .../ProjectSubmissions/SubmissionsTable.tsx | 510 ++++++ .../ProjectSubmissions/TaskSubmissions.tsx | 110 ++ .../ProjectSubmissions/TaskSubmissionsMap.tsx | 291 ++++ .../TaskSubmissionsMapLegend.tsx | 33 + .../src/components/QrcodeComponent.jsx | 162 +- .../SubmissionMap/SubmissionMap.jsx | 64 - .../src/components/TasksMap/TasksMap.jsx | 67 - .../src/components/__test__/Button.test.jsx | 69 + .../src/components/common/BarChart.tsx | 47 + src/frontend/src/components/common/Button.tsx | 18 +- .../src/components/common/Checkbox.tsx | 46 + src/frontend/src/components/common/Chips.tsx | 24 + .../components/common/CustomDatePicker.tsx | 29 + .../src/components/common/CustomTable.tsx | 326 ++++ .../src/components/common/Dropdown.tsx | 185 +++ .../src/components/common/InputTextField.tsx | 17 +- .../src/components/common/KebabMenu.tsx | 136 ++ .../src/components/common/LineChart.tsx | 51 + src/frontend/src/components/common/Modal.tsx | 7 +- .../src/components/common/PieChart.tsx | 55 + .../src/components/common/RadioButton.tsx | 25 +- src/frontend/src/components/common/Select.tsx | 18 +- .../src/components/common/StepSwitcher.tsx | 2 +- .../src/components/common/UploadArea.tsx | 161 ++ .../createnewproject/DataExtract.tsx | 176 ++- .../LoadingBar.tsx | 0 .../createnewproject/ProjectDetailsForm.tsx | 273 ++-- .../createnewproject/SelectForm.tsx | 38 +- .../createnewproject/SplitTasks.tsx | 52 +- .../createnewproject/UploadArea.tsx | 59 +- .../validation/CreateProjectValidation.tsx | 18 +- .../validation/DataExtractValidation.tsx | 7 +- .../validation/UploadAreaValidation.tsx | 2 - .../createproject/BasemapSelection.tsx | 56 - .../components/createproject/DataExtract.tsx | 326 ---- .../components/createproject/DefineTasks.tsx | 340 ---- .../src/components/createproject/DrawSvg.tsx | 49 - .../createproject/FormSelection.tsx | 384 ----- .../createproject/ProjectDetailsForm.tsx | 435 ------ .../components/createproject/UploadArea.tsx | 133 -- .../validation/CreateProjectValidation.tsx | 63 - .../validation/DataExtractValidation.tsx | 40 - .../validation/DefineTaskValidation.tsx | 35 - .../validation/SelectFormValidation.tsx | 24 - .../editproject/EditProjectDetails.tsx | 229 --- .../src/components/editproject/UpdateForm.tsx | 117 -- .../editproject/UpdateProjectArea.tsx | 146 -- .../components/home/ExploreProjectCard.tsx | 4 +- .../organisation/OrganisationAddForm.tsx | 1 - .../organisation/OrganisationGridCard.tsx | 81 + .../organisation/OrganizationCardSkeleton.tsx | 57 + .../Validation/OrganisationAddValidation.tsx | 30 +- .../src/constants/ConsentQuestions.tsx | 144 ++ .../constants/EditProjectSidebarContent.ts | 5 + .../src/constants/StepFormConstants.ts | 4 +- src/frontend/src/constants/blockerUrl.ts | 2 +- .../constants/projectSubmissionsConstants.ts | 9 + src/frontend/src/environment.ts | 17 +- src/frontend/src/hooks/useOutsideClick.ts | 46 + src/frontend/src/index.css | 14 + .../createproject/createProjectModel.ts | 11 +- .../models/organisation/organisationModel.ts | 10 +- .../src/models/project/projectModel.ts | 11 + .../src/models/submission/submissionModel.ts | 0 src/frontend/src/models/task/taskModel.ts | 13 + src/frontend/src/routes.jsx | 220 +-- src/frontend/src/shared/AssetModules.js | 30 +- src/frontend/src/shared/CoreModules.js | 4 +- .../src/store/slices/CreateProjectSlice.ts | 10 +- src/frontend/src/store/slices/LoginSlice.ts | 4 +- src/frontend/src/store/slices/ProjectSlice.ts | 9 +- .../src/store/slices/SubmissionSlice.ts | 55 +- .../src/store/slices/organisationSlice.ts | 56 +- .../src/store/types/ICreateProject.ts | 16 +- src/frontend/src/store/types/IOrganisation.ts | 23 + src/frontend/src/store/types/ISubmissions.ts | 15 + src/frontend/src/styles/home.scss | 2 +- src/frontend/src/sum.js | 3 + src/frontend/src/types/enums.ts | 6 + .../src/utilfunctions/downloadChart.ts | 21 + .../src/utilfunctions/filterParams.ts | 11 + src/frontend/src/utilfunctions/login.ts | 8 +- src/frontend/src/utilfunctions/urlChecker.ts | 8 + src/frontend/src/utilities/PrimaryAppBar.tsx | 47 +- .../src/views/ApproveOrganization.tsx | 16 + src/frontend/src/views/Authorized.tsx | 3 +- .../src/views/CreateEditOrganization.tsx | 47 + src/frontend/src/views/CreateNewProject.tsx | 4 +- src/frontend/src/views/CreateOrganisation.tsx | 110 +- src/frontend/src/views/CreateProject.tsx | 19 +- src/frontend/src/views/DefineAreaMap.tsx | 161 -- src/frontend/src/views/EditProject.tsx | 96 -- src/frontend/src/views/Home.jsx | 15 +- src/frontend/src/views/ManageProject.tsx | 62 + src/frontend/src/views/NewDefineAreaMap.tsx | 5 +- src/frontend/src/views/NewProjectDetails.jsx | 89 +- src/frontend/src/views/Organisation.tsx | 306 ++-- src/frontend/src/views/ProjectDetails.jsx | 23 +- src/frontend/src/views/ProjectDetailsV2.tsx | 151 +- src/frontend/src/views/ProjectInfo.tsx | 19 +- src/frontend/src/views/ProjectSubmissions.tsx | 104 ++ src/frontend/src/views/Submissions.tsx | 9 +- src/frontend/src/views/Tasks.tsx | 27 +- src/frontend/tests/App.test.jsx | 8 + src/frontend/tests/App.test.tsx | 38 - src/frontend/tests/CreateProject.test.tsx | 20 - src/frontend/tsconfig.json | 2 +- src/frontend/vite.config.ts | 1 + 219 files changed, 10957 insertions(+), 6784 deletions(-) create mode 100644 contrib/qrcode_util/Dockerfile create mode 100644 contrib/qrcode_util/README.md create mode 100644 contrib/qrcode_util/build.sh create mode 100644 contrib/qrcode_util/qrcode_util.py create mode 100644 docs/images/docs_badge.svg create mode 100644 docs/images/tasks_badge.svg create mode 100644 src/backend/migrations/006-index-roles.sql create mode 100644 src/backend/migrations/007-add-extract-url.sql create mode 100644 src/backend/migrations/008-add-user-in-org.sql create mode 100644 src/backend/migrations/009-add-community-type.sql create mode 100644 src/backend/migrations/010-drop-features-table.sql create mode 100644 src/backend/migrations/011-task-features-count.sql create mode 100644 src/backend/migrations/revert/006-index-roles.sql create mode 100644 src/backend/migrations/revert/007-add-extract-url.sql create mode 100644 src/backend/migrations/revert/008-add-user-in-org.sql create mode 100644 src/backend/migrations/revert/009-add-community-type.sql create mode 100644 src/backend/migrations/revert/010-drop-features-table.sql create mode 100644 src/backend/migrations/revert/011-task-features-count.sql create mode 100644 src/backend/tests/test_data/data_extract_kathmandu.fgb create mode 100644 src/backend/tests/test_data/data_extract_kathmandu.geojson create mode 100644 src/frontend/src/components/ApproveOrganization/ApproveOrganizationHeader.tsx create mode 100644 src/frontend/src/components/ApproveOrganization/OrganizationForm.tsx create mode 100644 src/frontend/src/components/CreateEditOrganization/ConsentDetailsForm.tsx create mode 100644 src/frontend/src/components/CreateEditOrganization/CreateEditOrganizationForm.tsx create mode 100644 src/frontend/src/components/CreateEditOrganization/CreateEditOrganizationHeader.tsx create mode 100644 src/frontend/src/components/CreateEditOrganization/InstructionsSidebar.tsx create mode 100644 src/frontend/src/components/CreateEditOrganization/validation/ConsentDetailsValidation.ts create mode 100644 src/frontend/src/components/CreateEditOrganization/validation/OrganizationDetailsValidation.ts create mode 100644 src/frontend/src/components/ManageProject/EditTab/FormUpdateTab.tsx create mode 100644 src/frontend/src/components/ManageProject/EditTab/ProjectDescriptionTab.tsx create mode 100644 src/frontend/src/components/ManageProject/EditTab/index.tsx rename src/frontend/src/components/{editproject => ManageProject/EditTab}/validation/EditProjectDetailsValidation.ts (58%) create mode 100644 src/frontend/src/components/ManageProject/UserTab/AssignTab.tsx create mode 100644 src/frontend/src/components/ManageProject/UserTab/InviteTab.tsx create mode 100644 src/frontend/src/components/ManageProject/UserTab/index.tsx create mode 100644 src/frontend/src/components/MapComponent/OpenLayersComponent/AsyncPopup/AsyncPopup.tsx create mode 100644 src/frontend/src/components/MapComponent/OpenLayersComponent/AsyncPopup/asyncpopup.scss delete mode 100644 src/frontend/src/components/ProjectMap/ProjectMap.jsx create mode 100644 src/frontend/src/components/ProjectSubmissions/InfographicsCard.tsx create mode 100644 src/frontend/src/components/ProjectSubmissions/ProjectInfo.tsx create mode 100644 src/frontend/src/components/ProjectSubmissions/ProjectSubmissionsSkeletonLoader.tsx create mode 100644 src/frontend/src/components/ProjectSubmissions/SubmissionsInfographics.tsx create mode 100644 src/frontend/src/components/ProjectSubmissions/SubmissionsTable.tsx create mode 100644 src/frontend/src/components/ProjectSubmissions/TaskSubmissions.tsx create mode 100644 src/frontend/src/components/ProjectSubmissions/TaskSubmissionsMap.tsx create mode 100644 src/frontend/src/components/ProjectSubmissions/TaskSubmissionsMapLegend.tsx delete mode 100644 src/frontend/src/components/SubmissionMap/SubmissionMap.jsx delete mode 100644 src/frontend/src/components/TasksMap/TasksMap.jsx create mode 100644 src/frontend/src/components/__test__/Button.test.jsx create mode 100644 src/frontend/src/components/common/BarChart.tsx create mode 100644 src/frontend/src/components/common/Checkbox.tsx create mode 100644 src/frontend/src/components/common/Chips.tsx create mode 100644 src/frontend/src/components/common/CustomDatePicker.tsx create mode 100644 src/frontend/src/components/common/CustomTable.tsx create mode 100644 src/frontend/src/components/common/Dropdown.tsx create mode 100644 src/frontend/src/components/common/KebabMenu.tsx create mode 100644 src/frontend/src/components/common/LineChart.tsx create mode 100644 src/frontend/src/components/common/PieChart.tsx create mode 100644 src/frontend/src/components/common/UploadArea.tsx rename src/frontend/src/components/{createproject => createnewproject}/LoadingBar.tsx (100%) delete mode 100644 src/frontend/src/components/createproject/BasemapSelection.tsx delete mode 100755 src/frontend/src/components/createproject/DataExtract.tsx delete mode 100755 src/frontend/src/components/createproject/DefineTasks.tsx delete mode 100644 src/frontend/src/components/createproject/DrawSvg.tsx delete mode 100755 src/frontend/src/components/createproject/FormSelection.tsx delete mode 100755 src/frontend/src/components/createproject/ProjectDetailsForm.tsx delete mode 100755 src/frontend/src/components/createproject/UploadArea.tsx delete mode 100755 src/frontend/src/components/createproject/validation/CreateProjectValidation.tsx delete mode 100644 src/frontend/src/components/createproject/validation/DataExtractValidation.tsx delete mode 100644 src/frontend/src/components/createproject/validation/DefineTaskValidation.tsx delete mode 100644 src/frontend/src/components/createproject/validation/SelectFormValidation.tsx delete mode 100644 src/frontend/src/components/editproject/EditProjectDetails.tsx delete mode 100644 src/frontend/src/components/editproject/UpdateForm.tsx delete mode 100644 src/frontend/src/components/editproject/UpdateProjectArea.tsx create mode 100644 src/frontend/src/components/organisation/OrganisationGridCard.tsx create mode 100644 src/frontend/src/components/organisation/OrganizationCardSkeleton.tsx create mode 100644 src/frontend/src/constants/ConsentQuestions.tsx create mode 100644 src/frontend/src/constants/projectSubmissionsConstants.ts create mode 100644 src/frontend/src/hooks/useOutsideClick.ts create mode 100644 src/frontend/src/models/project/projectModel.ts delete mode 100644 src/frontend/src/models/submission/submissionModel.ts create mode 100644 src/frontend/src/models/task/taskModel.ts create mode 100644 src/frontend/src/store/types/IOrganisation.ts create mode 100644 src/frontend/src/store/types/ISubmissions.ts create mode 100644 src/frontend/src/sum.js create mode 100644 src/frontend/src/utilfunctions/downloadChart.ts create mode 100644 src/frontend/src/utilfunctions/filterParams.ts create mode 100644 src/frontend/src/utilfunctions/urlChecker.ts create mode 100644 src/frontend/src/views/ApproveOrganization.tsx create mode 100644 src/frontend/src/views/CreateEditOrganization.tsx delete mode 100644 src/frontend/src/views/DefineAreaMap.tsx delete mode 100755 src/frontend/src/views/EditProject.tsx create mode 100644 src/frontend/src/views/ManageProject.tsx create mode 100644 src/frontend/src/views/ProjectSubmissions.tsx create mode 100644 src/frontend/tests/App.test.jsx delete mode 100644 src/frontend/tests/App.test.tsx delete mode 100644 src/frontend/tests/CreateProject.test.tsx diff --git a/.github/workflows/build_and_deploy.yml b/.github/workflows/build_and_deploy.yml index b7c83b7305..a79804ae49 100644 --- a/.github/workflows/build_and_deploy.yml +++ b/.github/workflows/build_and_deploy.yml @@ -17,7 +17,7 @@ on: jobs: pytest: - uses: hotosm/gh-workflows/.github/workflows/test_compose.yml@1.4.5 + uses: hotosm/gh-workflows/.github/workflows/test_compose.yml@1.4.7 with: image_name: ghcr.io/${{ github.repository }}/backend build_context: src/backend @@ -29,12 +29,12 @@ jobs: secrets: inherit frontend-tests: - uses: hotosm/gh-workflows/.github/workflows/test_pnpm.yml@1.4.5 + uses: hotosm/gh-workflows/.github/workflows/test_pnpm.yml@1.4.7 with: working_dir: src/frontend backend-build: - uses: hotosm/gh-workflows/.github/workflows/image_build.yml@1.4.5 + uses: hotosm/gh-workflows/.github/workflows/image_build.yml@1.4.7 needs: [pytest] with: context: src/backend @@ -42,7 +42,7 @@ jobs: image_name: ghcr.io/${{ github.repository }}/backend frontend-build: - uses: hotosm/gh-workflows/.github/workflows/image_build.yml@1.4.5 + uses: hotosm/gh-workflows/.github/workflows/image_build.yml@1.4.7 needs: [frontend-tests] with: context: src/frontend @@ -149,7 +149,7 @@ jobs: needs: - smoke-test-backend - smoke-test-frontend - uses: hotosm/gh-workflows/.github/workflows/remote_deploy.yml@1.4.5 + uses: hotosm/gh-workflows/.github/workflows/remote_deploy.yml@1.4.7 with: environment: ${{ github.ref_name }} docker_compose_file: "docker-compose.${{ github.ref_name }}.yml" diff --git a/.github/workflows/build_ci_img.yml b/.github/workflows/build_ci_img.yml index 6f0e5cf89c..21c59028a1 100644 --- a/.github/workflows/build_ci_img.yml +++ b/.github/workflows/build_ci_img.yml @@ -16,7 +16,7 @@ on: jobs: backend-ci-build: - uses: hotosm/gh-workflows/.github/workflows/image_build.yml@1.4.5 + uses: hotosm/gh-workflows/.github/workflows/image_build.yml@1.4.7 with: context: src/backend build_target: ci diff --git a/.github/workflows/build_odk_imgs.yml b/.github/workflows/build_odk_imgs.yml index d39e65f8d8..eaecb23cf9 100644 --- a/.github/workflows/build_odk_imgs.yml +++ b/.github/workflows/build_odk_imgs.yml @@ -13,7 +13,7 @@ on: jobs: build-odkcentral: - uses: hotosm/gh-workflows/.github/workflows/image_build.yml@1.4.5 + uses: hotosm/gh-workflows/.github/workflows/image_build.yml@1.4.7 with: context: odkcentral/api image_tags: | @@ -26,7 +26,7 @@ jobs: # multi_arch: true build-odkcentral-ui: - uses: hotosm/gh-workflows/.github/workflows/image_build.yml@1.4.5 + uses: hotosm/gh-workflows/.github/workflows/image_build.yml@1.4.7 with: context: odkcentral/ui image_tags: | diff --git a/.github/workflows/build_proxy_imgs.yml b/.github/workflows/build_proxy_imgs.yml index 728c783c1c..7565ac0c54 100644 --- a/.github/workflows/build_proxy_imgs.yml +++ b/.github/workflows/build_proxy_imgs.yml @@ -10,7 +10,7 @@ on: jobs: build-cert-init-main: - uses: hotosm/gh-workflows/.github/workflows/image_build.yml@1.4.5 + uses: hotosm/gh-workflows/.github/workflows/image_build.yml@1.4.7 with: context: nginx build_target: certs-init-main @@ -21,7 +21,7 @@ jobs: multi_arch: true build-cert-init-dev: - uses: hotosm/gh-workflows/.github/workflows/image_build.yml@1.4.5 + uses: hotosm/gh-workflows/.github/workflows/image_build.yml@1.4.7 with: context: nginx build_target: certs-init-development @@ -33,7 +33,7 @@ jobs: multi_arch: true build-proxy-main: - uses: hotosm/gh-workflows/.github/workflows/image_build.yml@1.4.5 + uses: hotosm/gh-workflows/.github/workflows/image_build.yml@1.4.7 with: context: nginx build_target: main @@ -44,7 +44,7 @@ jobs: multi_arch: true build-proxy-dev: - uses: hotosm/gh-workflows/.github/workflows/image_build.yml@1.4.5 + uses: hotosm/gh-workflows/.github/workflows/image_build.yml@1.4.7 with: context: nginx build_target: development diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e46126692f..404feddee7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -12,19 +12,19 @@ on: jobs: build_doxygen: - uses: hotosm/gh-workflows/.github/workflows/doxygen_build.yml@1.4.5 + uses: hotosm/gh-workflows/.github/workflows/doxygen_build.yml@1.4.7 with: output_path: docs/apidocs build_openapi_json: - uses: hotosm/gh-workflows/.github/workflows/openapi_build.yml@1.4.5 + uses: hotosm/gh-workflows/.github/workflows/openapi_build.yml@1.4.7 with: image: ghcr.io/${{ github.repository }}/backend:ci-${{ github.ref_name }} example_env_file_path: ".env.example" output_path: docs/openapi.json publish_docs: - uses: hotosm/gh-workflows/.github/workflows/mkdocs_build.yml@1.4.5 + uses: hotosm/gh-workflows/.github/workflows/mkdocs_build.yml@1.4.7 needs: - build_doxygen - build_openapi_json diff --git a/.github/workflows/pr_test_backend.yml b/.github/workflows/pr_test_backend.yml index 70aeae426d..b7b1ff046e 100644 --- a/.github/workflows/pr_test_backend.yml +++ b/.github/workflows/pr_test_backend.yml @@ -14,7 +14,7 @@ on: jobs: pytest: - uses: hotosm/gh-workflows/.github/workflows/test_compose.yml@1.4.5 + uses: hotosm/gh-workflows/.github/workflows/test_compose.yml@1.4.7 with: image_name: ghcr.io/${{ github.repository }}/backend build_context: src/backend diff --git a/.github/workflows/pr_test_frontend.yml b/.github/workflows/pr_test_frontend.yml index a791f88af4..02233c0caf 100644 --- a/.github/workflows/pr_test_frontend.yml +++ b/.github/workflows/pr_test_frontend.yml @@ -14,6 +14,6 @@ on: jobs: frontend-tests: - uses: hotosm/gh-workflows/.github/workflows/test_pnpm.yml@1.4.5 + uses: hotosm/gh-workflows/.github/workflows/test_pnpm.yml@1.4.7 with: working_dir: src/frontend diff --git a/.github/workflows/tag_build.yml b/.github/workflows/tag_build.yml index f7171d353a..5c762de58d 100644 --- a/.github/workflows/tag_build.yml +++ b/.github/workflows/tag_build.yml @@ -9,7 +9,7 @@ on: jobs: backend-build: - uses: hotosm/gh-workflows/.github/workflows/image_build.yml@1.4.5 + uses: hotosm/gh-workflows/.github/workflows/image_build.yml@1.4.7 with: context: src/backend build_target: prod diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml index d5dc1a430c..26f0fea9ff 100644 --- a/.github/workflows/wiki.yml +++ b/.github/workflows/wiki.yml @@ -10,6 +10,6 @@ on: jobs: publish-docs-to-wiki: - uses: hotosm/gh-workflows/.github/workflows/wiki.yml@1.4.5 + uses: hotosm/gh-workflows/.github/workflows/wiki.yml@1.4.7 with: homepage_path: "wiki_redirect.md" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 04dd7cd7d0..f5fb28b24b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,6 +33,7 @@ repos: "!CONTRIBUTING.md", "!LICENSE.md", "!src/frontend/pnpm-lock.yaml", + "!src/backend/tests/test_data/**", ] # # Lint: Dockerfile (disabled until binary is bundled) diff --git a/INSTALL.md b/INSTALL.md index 244dc912da..ddfbb7bcaf 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -84,19 +84,21 @@ git checkout main These steps are essential to run and test your code! -#### 1. Setup OSM OAUTH 2.0 +#### 1. Setup OSM OAuth 2.0 -The FMTM uses OAUTH2 with OSM to authenticate users. +The FMTM uses OAuth with OSM to authenticate users. To properly configure your FMTM project, you will need to create keys for OSM. 1. [Login to OSM][28] (_If you do not have an account yet, click the signup button at the top navigation bar to create one_). + Click the drop down arrow on the top right of the navigation bar and select My Settings. 2. Register your FMTM instance to OAuth 2 applications. + Put your login redirect url as `http://127.0.0.1:7051/osmauth/` if running locally, or for production replace with https://{YOUR_DOMAIN}/osmauth/ diff --git a/README.md b/README.md index ccc4bbcef5..f98debf034 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ ![HOT Logo](https://github.com/hotosm/fmtm/blob/main/images/hot_logo.png?raw=true) +A project to provide tools for Open Mapping campaigns. + | **Version** | [![Version](https://img.shields.io/github/v/release/hotosm/fmtm?logo=github)](https://github.com/hotosm/fmtm/releases) | @@ -11,16 +13,11 @@ | **Docs** | [![Publish Docs](https://github.com/hotosm/fmtm/actions/workflows/docs.yml/badge.svg?branch=development)](https://github.com/hotosm/fmtm/actions/workflows/docs.yml) [![Publish Docs to Wiki](https://github.com/hotosm/fmtm/actions/workflows/wiki.yml/badge.svg?branch=development)](https://github.com/hotosm/fmtm/actions/workflows/wiki.yml) | | **Tech Stack** | ![FastAPI](https://img.shields.io/badge/FastAPI-005571?style=for-the-badge&logo=fastapi) ![React](https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB) ![Postgres](https://img.shields.io/badge/postgres-%23316192.svg?style=for-the-badge&logo=postgresql&logoColor=white) ![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white) ![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white) | | **Code Style** | ![Backend Style](https://img.shields.io/badge/code%20style-black-black) ![Frontend Style](https://img.shields.io/badge/code%20style-prettier-F7B93E?logo=Prettier) ![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white) | -| **Other Info** | [![GitHub Sponsors](https://img.shields.io/badge/sponsor-30363D?logo=GitHub-Sponsors&logoColor=#EA4AAA)](https://github.com/sponsors/hotosm) [![License](https://img.shields.io/github/license/hotosm/fmtm.svg)](https://github.com/hotosm/fmtm/blob/main/LICENSE.md) [![All Contributors](https://img.shields.io/github/all-contributors/hotosm/fmtm?color=ee8449&style=flat-square)](#contributors-) [![Coverage](https://hotosm.github.io/fmtm/coverage.svg)](https://hotosm.github.io/fmtm/coverage.html) | +| **Metrics** | [![All Contributors](https://img.shields.io/github/all-contributors/hotosm/fmtm?color=ee8449&style=flat-square)](#contributors-) [![Coverage](https://hotosm.github.io/fmtm/coverage.svg)](https://hotosm.github.io/fmtm/coverage.html) | +| **Other Info** | [![Docs](https://github.com/hotosm/fmtm/blob/development/docs/images/docs_badge.svg?raw=true)](https://hotosm.github.io/fmtm/) [![GitHub Sponsors](https://img.shields.io/badge/sponsor-30363D?logo=GitHub-Sponsors&logoColor=#EA4AAA)](https://github.com/sponsors/hotosm) [![Task Board](https://github.com/hotosm/fmtm/blob/development/docs/images/tasks_badge.svg?raw=true)](https://github.com/orgs/hotosm/projects/22) [![License](https://img.shields.io/github/license/hotosm/fmtm.svg)](https://github.com/hotosm/fmtm/blob/main/LICENSE.md) | -📖 [Documentation](https://hotosm.github.io/fmtm/) - -🎯 [Task Board](https://github.com/orgs/hotosm/projects/22) - -A project to provide tools for Open Mapping campaigns. - While we have pretty good field mapping applications, we don’t have great tools to coordinate field mapping. However, we have most of the elements needed to create a field mapping-oriented diff --git a/contrib/qrcode_util/Dockerfile b/contrib/qrcode_util/Dockerfile new file mode 100644 index 0000000000..0da4e689d5 --- /dev/null +++ b/contrib/qrcode_util/Dockerfile @@ -0,0 +1,67 @@ +# Copyright (c) 2022, 2023 Humanitarian OpenStreetMap Team +# This file is part of FMTM. +# +# FMTM is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# FMTM is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with FMTM. If not, see . +# +ARG PYTHON_IMG_TAG=3.10 + + +# Includes all labels and timezone info to extend from +FROM docker.io/python:${PYTHON_IMG_TAG}-slim-bookworm as base +RUN set -ex \ + && apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install \ + -y --no-install-recommends "locales" "ca-certificates" \ + && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \ + && rm -rf /var/lib/apt/lists/* \ + && update-ca-certificates +# Set locale +RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen +ENV LANG en_US.UTF-8 +ENV LANGUAGE en_US:en +ENV LC_ALL en_US.UTF-8 + + +# Build stage will all dependencies required to build Python wheels +FROM base as build +RUN set -ex \ + && apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install \ + -y --no-install-recommends \ + "build-essential" \ + "gcc" \ + "libzbar0" \ + && rm -rf /var/lib/apt/lists/* +RUN pip install --user --no-warn-script-location \ + --no-cache-dir pillow==10.2.0 pyzbar==0.1.9 segno==1.6.0 + + +# Run stage will minimal dependencies required to run Python libraries +FROM base as runtime +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONFAULTHANDLER=1 +RUN set -ex \ + && apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install \ + -y --no-install-recommends \ + "libzbar0" \ + && rm -rf /var/lib/apt/lists/* +# Copy Python deps from build to runtime +COPY --from=build \ + /root/.local \ + /root/.local +WORKDIR /code +COPY qrcode_util.py . +ENTRYPOINT ["python", "qrcode_util.py"] diff --git a/contrib/qrcode_util/README.md b/contrib/qrcode_util/README.md new file mode 100644 index 0000000000..6ae4c1119a --- /dev/null +++ b/contrib/qrcode_util/README.md @@ -0,0 +1,39 @@ +# QR Code Utils + +## Convert QR Code to JSON + +```bash +cat /path/to/your/qrcode.png | \ + docker run -i --rm ghcr.io/hotosm/fmtm/qrcodes:latest --read +``` + +This will output the JSON data to terminal. + +## Convert JSON to QR Code + +```bash +cat file.json | \ +docker run -i --rm ghcr.io/hotosm/fmtm/qrcodes:latest --write > qr.png +``` + +Alternatively pipe from STDIN on the command line: + +```bash +echo '{ + "general": { + "server_url": "https://url/v1/key/token/projects/projectid", + "form_update_mode": "manual", + "basemap_source": "osm", + "autosend": "wifi_and_cellular", + "metadata_username": "svcfmtm", + "metadata_email": "test" + }, + "project": { + "name": "task qrcode conversion" + }, + "admin": {} +}' | docker run -i --rm ghcr.io/hotosm/fmtm/qrcodes:latest --write \ +> qr.png +``` + +This will create a file `qr.png` from your data. diff --git a/contrib/qrcode_util/build.sh b/contrib/qrcode_util/build.sh new file mode 100644 index 0000000000..f9a58d88e7 --- /dev/null +++ b/contrib/qrcode_util/build.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +docker build . -t ghcr.io/hotosm/fmtm/qrcodes:latest + +docker push ghcr.io/hotosm/fmtm/qrcodes:latest diff --git a/contrib/qrcode_util/qrcode_util.py b/contrib/qrcode_util/qrcode_util.py new file mode 100644 index 0000000000..b32e9fed0a --- /dev/null +++ b/contrib/qrcode_util/qrcode_util.py @@ -0,0 +1,69 @@ +"""Convert between JSON and QRCode formats.""" + +import sys +import argparse +import base64 +import json +import zlib +from io import BytesIO + +from PIL import Image +from segno import make as make_qr +from pyzbar.pyzbar import decode as decode_qr + + +def qr_to_json(qr_code_bytes: bytes): + """Extract QR code content to JSON.""" + qr_img = Image.open(BytesIO(qr_code_bytes)) + qr_data = decode_qr(qr_img)[0].data + + # Base64/zlib decoded + decoded_qr = zlib.decompress(base64.b64decode(qr_data)) + odk_json = json.loads(decoded_qr.decode("utf-8")) + # Output to terminal + print(odk_json) + +def json_to_qr(json_bytes: bytes): + """Insert JSON content into QR code.""" + json_string = json.loads(json_bytes.decode('utf8').replace("'", '"')) + + # Base64/zlib encoded + qrcode_data = base64.b64encode( + zlib.compress(json.dumps(json_string).encode("utf-8")) + ) + qrcode = make_qr(qrcode_data, micro=False) + buffer = BytesIO() + qrcode.save(buffer, kind="png", scale=5) + qrcode_binary = buffer.getvalue() + sys.stdout.buffer.write(qrcode_binary) + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Encode or decode QR code data." + ) + parser.add_argument( + "--write", + action="store_true", + help="Write data from STDIN to a QRCode.", + ) + parser.add_argument( + "--read", + action="store_true", + help="Read QRCode data from STDIN and print to terminal.", + ) + + args = parser.parse_args() + + # Read STDIN data, usage: + # $ cat file.png | docker run -i --rm ghcr.io/hotosm/fmtm/qrcodes:latest --write + data_in: bytes = sys.stdin.buffer.read() + if data_in == b"": + print("Data must be provided via STDIN.") + sys.exit(1) + + if args.write: + json_to_qr(data_in) + elif args.read: + qr_to_json(data_in) + else: + print("Please provide either --write or --read flag.") diff --git a/docker-compose.yml b/docker-compose.yml index c1cdef9cd8..06f8edd7ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -122,9 +122,6 @@ services: - /app/node_modules/ environment: - VITE_API_URL=http://api.${FMTM_DOMAIN}:${FMTM_DEV_PORT:-7050} - - VITE_ODK_CENTRAL_URL=${ODK_CENTRAL_URL} - - VITE_ODK_CENTRAL_USER=${ODK_CENTRAL_USER} - - VITE_ODK_CENTRAL_PASSWD=${ODK_CENTRAL_PASSWD} ports: - "7051:7051" networks: @@ -188,13 +185,13 @@ services: MINIO_ROOT_USER: ${S3_ACCESS_KEY:-fmtm} MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-somelongpassword} MINIO_VOLUMES: "/mnt/data" - MINIO_BROWSER: "off" - # MINIO_CONSOLE_ADDRESS: ":9090" + MINIO_BROWSER: ${MINIO_BROWSER:-off} + MINIO_CONSOLE_ADDRESS: ":9090" volumes: - fmtm_data:/mnt/data # ports: - # - 9000:9000 - # - 9090:9090 + # - 9000:9000 + # - 9090:9090 networks: - fmtm-net command: minio server diff --git a/docs/images/docs_badge.svg b/docs/images/docs_badge.svg new file mode 100644 index 0000000000..e05dfed776 --- /dev/null +++ b/docs/images/docs_badge.svg @@ -0,0 +1 @@ +📖 Docs📖 Docs \ No newline at end of file diff --git a/docs/images/tasks_badge.svg b/docs/images/tasks_badge.svg new file mode 100644 index 0000000000..11b55ac061 --- /dev/null +++ b/docs/images/tasks_badge.svg @@ -0,0 +1 @@ +🎯 Task Board🎯 Task Board \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 9ba846a0a4..464b1baef1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -38,6 +38,7 @@ markdown_extensions: - pymdownx.emoji: - pymdownx.tabbed: alternate_style: true + - mdx_truly_sane_lists plugins: - search diff --git a/nginx/templates/api.conf.template b/nginx/templates/api.conf.template index 7730db4c6c..ba6280db4c 100644 --- a/nginx/templates/api.conf.template +++ b/nginx/templates/api.conf.template @@ -47,8 +47,12 @@ server { add_header 'Access-Control-Allow-Headers' 'traceparent,tracestate'; location / { - proxy_read_timeout 40s; + # Max time to initiate connection with backend proxy_connect_timeout 20s; + # Max time for a backend response to return, i.e. download + proxy_read_timeout 60s; + # Max time to send request to backend, i.e. upload + proxy_send_timeout 40s; # Requests headers proxy_set_header Host $http_host; diff --git a/nginx/templates/dev/api.conf.template b/nginx/templates/dev/api.conf.template index 0bfcffbd64..c98f4d6e60 100644 --- a/nginx/templates/dev/api.conf.template +++ b/nginx/templates/dev/api.conf.template @@ -32,8 +32,12 @@ server { # Response headers (note: Access-Control-Allow-Origin already set by FastAPI, not required) location / { - proxy_read_timeout 40s; + # Max time to initiate connection with backend proxy_connect_timeout 20s; + # Max time for a backend response to return, i.e. download + proxy_read_timeout 60s; + # Max time to send request to backend, i.e. upload + proxy_send_timeout 40s; # Requests headers proxy_set_header Host $http_host; diff --git a/src/backend/Dockerfile b/src/backend/Dockerfile index 9035aedafe..77f41b724c 100644 --- a/src/backend/Dockerfile +++ b/src/backend/Dockerfile @@ -96,6 +96,7 @@ RUN set -ex \ -y --no-install-recommends \ "nano" \ "curl" \ + "gettext-base" \ "libpcre3" \ "mime-support" \ "postgresql-client" \ diff --git a/src/backend/app/auth/auth_routes.py b/src/backend/app/auth/auth_routes.py index c428cdfb9c..66b500beab 100644 --- a/src/backend/app/auth/auth_routes.py +++ b/src/backend/app/auth/auth_routes.py @@ -124,50 +124,56 @@ async def logout(): return response +async def get_or_create_user( + db: Session, + user_data: AuthUser, +) -> DbUser: + """Get user from User table if exists, else create.""" + existing_user = await user_crud.get_user(db, user_data.id) + + if existing_user: + # Update an existing user + if user_data.img_url: + existing_user.profile_img = user_data.img_url + db.commit() + return existing_user + + user_by_username = await user_crud.get_user_by_username(db, user_data.username) + if user_by_username: + raise HTTPException( + status_code=400, + detail=( + f"User with this username {user_data.username} already exists. " + "Please contact the administrator." + ), + ) + + # Add user to database + db_user = DbUser( + id=user_data.id, + username=user_data.username, + profile_img=user_data.img_url, + role=user_data.role, + ) + db.add(db_user) + db.commit() + + return db_user + + @router.get("/me/", response_model=AuthUser) async def my_data( - request: Request, db: Session = Depends(database.get_db), user_data: AuthUser = Depends(login_required), -): +) -> AuthUser: """Read access token and get user details from OSM. Args: - request: The HTTP request (automatically included variable). db: The db session. user_data: User data provided by osm-login-python Auth. Returns: user_data(dict): The dict of user data. """ - # Save user info in User table - user = await user_crud.get_user(db, user_data.id) - if not user: - user_by_username = await user_crud.get_user_by_username(db, user_data.username) - if user_by_username: - raise HTTPException( - status_code=400, - detail=( - f"User with this username {user_data.username} already exists. " - "Please contact the administrator." - ), - ) - - # Add user to database - db_user = DbUser( - id=user_data.id, - username=user_data.username, - profile_img=user_data.img_url, - ) - db.add(db_user) - db.commit() - # Append role - user_data.role = db_user.role - else: - if user_data.img_url: - user.profile_img = user_data.img_url - db.commit() - # Append role - user_data.role = user.role - + await get_or_create_user(db, user_data) return user_data diff --git a/src/backend/app/auth/osm.py b/src/backend/app/auth/osm.py index 5435437683..0122bcd604 100644 --- a/src/backend/app/auth/osm.py +++ b/src/backend/app/auth/osm.py @@ -42,7 +42,7 @@ class AuthUser(BaseModel): id: int username: str img_url: Optional[str] = None - role: Optional[UserRole] = None + role: Optional[UserRole] = UserRole.MAPPER async def init_osm_auth(): @@ -65,6 +65,7 @@ async def login_required( return AuthUser( id=20386219, username="svcfmtm", + role=UserRole.ADMIN, ) osm_auth = await init_osm_auth() diff --git a/src/backend/app/auth/roles.py b/src/backend/app/auth/roles.py index b46ecb5038..6fae2904c9 100644 --- a/src/backend/app/auth/roles.py +++ b/src/backend/app/auth/roles.py @@ -26,12 +26,13 @@ from fastapi import Depends, HTTPException from loguru import logger as log +from sqlalchemy import text from sqlalchemy.orm import Session from app.auth.osm import AuthUser, login_required from app.db.database import get_db -from app.db.db_models import DbProject, DbUser, DbUserRoles, organisation_managers -from app.models.enums import HTTPStatus, ProjectRole, UserRole +from app.db.db_models import DbProject, DbUser +from app.models.enums import HTTPStatus, ProjectRole, ProjectVisibility from app.organisations.organisation_deps import check_org_exists from app.projects.project_deps import get_project_by_id @@ -48,73 +49,146 @@ async def get_uid(user_data: AuthUser) -> int: ) -async def check_super_admin( - db: Session, +async def check_access( user: Union[AuthUser, int], -) -> DbUser: - """Database check to determine if super admin role.""" - if isinstance(user, int): - user_id = user - else: - user_id = await get_uid(user) - return db.query(DbUser).filter_by(id=user_id, role=UserRole.ADMIN).first() + db: Session, + org_id: Optional[int] = None, + project_id: Optional[int] = None, + role: Optional[ProjectRole] = None, +) -> Optional[DbUser]: + """Check if the user has access to a project or organisation. + + Access is determined based on the user's role and permissions: + - If the user has an 'ADMIN' role, access is granted. + - If the user has a 'READ_ONLY' role, access is denied. + - For other roles, access is granted if the user is an organisation manager + for the specified organisation (org_id) or has the specified role + in the specified project (project_id). + + Args: + user (AuthUser, int): AuthUser object, or user ID. + db (Session): SQLAlchemy database session. + org_id (Optional[int]): Org ID for organisation-specific access. + project_id (Optional[int]): Project ID for project-specific access. + role (Optional[ProjectRole]): Role to check for project-specific access. + + Returns: + Optional[DbUser]: The user details if access is granted, otherwise None. + """ + user_id = user if isinstance(user, int) else await get_uid(user) + + sql = text( + """ + SELECT * + FROM users + WHERE id = :user_id + AND ( + CASE + WHEN role = 'ADMIN' THEN true + WHEN role = 'READ_ONLY' THEN false + ELSE + EXISTS ( + SELECT 1 + FROM organisation_managers + WHERE organisation_managers.user_id = :user_id + AND organisation_managers.organisation_id = :org_id + ) + OR EXISTS ( + SELECT 1 + FROM user_roles + WHERE user_roles.user_id = :user_id + AND user_roles.project_id = :project_id + AND user_roles.role >= :role + ) + END + ); + """ + ) + + result = db.execute( + sql, + { + "user_id": user_id, + "project_id": project_id, + "org_id": org_id, + "role": getattr(role, "name", None), + }, + ) + db_user = result.first() + + if db_user: + return DbUser(**db_user._asdict()) + + return None async def super_admin( user_data: AuthUser = Depends(login_required), db: Session = Depends(get_db), -) -> AuthUser: - """Super admin role, with access to all endpoints.""" - super_admin = await check_super_admin(db, user_data) - - if not super_admin: - log.error( - f"User {user_data.username} requested an admin endpoint, " - "but is not admin" - ) - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, detail="User must be an administrator" - ) +) -> DbUser: + """Super admin role, with access to all endpoints. - return user_data + Returns: + user_data: DbUser SQLAlchemy object. + """ + db_user = await check_access(user_data, db) + + if db_user: + return db_user + + log.error( + f"User {user_data.username} requested an admin endpoint, " "but is not admin" + ) + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="User must be an administrator" + ) async def check_org_admin( db: Session, user: Union[AuthUser, int], - project: Optional[DbProject], - org_id: Optional[int], -) -> DbUser: - """Database check to determine if org admin role.""" - if isinstance(user, int): - user_id = user - else: - user_id = await get_uid(user) + org_id: int, +) -> dict: + """Database check to determine if org admin role. - if project: - org_id = db.query(DbProject).filter_by(id=project.id).first().organisation_id + Returns: + dict: in format {'user': DbUser, 'org': DbOrganisation}. + """ + db_org = await check_org_exists(db, org_id) - # Check org exists - await check_org_exists(db, org_id) + # Check if org admin, or super admin + db_user = await check_access( + user, + db, + org_id=org_id, + ) - # If user is admin, skip checks - if db_user := await check_super_admin(db, user): - return db_user + if db_user: + return {"user": db_user, "org": db_org} - return ( - db.query(organisation_managers) - .filter_by(organisation_id=org_id, user_id=user_id) - .first() + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="User is not organisation admin", ) async def org_admin( - project: DbProject = Depends(get_project_by_id), - org_id: int = None, + project: Optional[DbProject] = Depends(get_project_by_id), + org_id: Optional[int] = None, db: Session = Depends(get_db), user_data: AuthUser = Depends(login_required), -) -> AuthUser: - """Organisation admin with full permission for projects in an organisation.""" +) -> dict: + """Organisation admin with full permission for projects in an organisation. + + Returns: + dict: in format {'user': DbUser, 'org': DbOrganisation}. + """ + if not (project or org_id): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Either org_id or project_id must be provided", + ) + if project and org_id: log.error("Both org_id and project_id cannot be passed at the same time") raise HTTPException( @@ -122,44 +196,93 @@ async def org_admin( detail="Both org_id and project_id cannot be passed at the same time", ) - org_admin = await check_org_admin(db, user_data, project, org_id) + # Extract org id from project if passed + if project: + org_id = project.organisation_id - if not org_admin: - log.error(f"User {user_data} is not an admin for organisation {org_id}") + # Ensure org_id is provided, raise an exception otherwise + if not org_id: raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="User is not organisation admin", + status_code=HTTPStatus.BAD_REQUEST, + detail="org_id must be provided to check organisation admin role", ) - return user_data + org_user_dict = await check_org_admin( + db, + user_data, + org_id=org_id, + ) + + if project: + org_user_dict["project"] = project + + return org_user_dict + + +async def project_admin( + project: DbProject = Depends(get_project_by_id), + db: Session = Depends(get_db), + user_data: AuthUser = Depends(login_required), +) -> DbUser: + """Project admin role.""" + db_user = await check_access( + user_data, + db, + project_id=project.id, + role=ProjectRole.PROJECT_MANAGER, + ) + + if db_user: + return db_user + + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="User is not a project manager", + ) async def validator( - project_id: int, + project: DbProject = Depends(get_project_by_id), db: Session = Depends(get_db), user_data: AuthUser = Depends(login_required), -) -> AuthUser: +) -> DbUser: """A validator for a specific project.""" - user_id = await get_uid(user_data) + db_user = await check_access( + user_data, + db, + project_id=project.id, + role=ProjectRole.VALIDATOR, + ) + + if db_user: + return db_user - match = ( - db.query(DbUserRoles).filter_by(user_id=user_id, project_id=project_id).first() + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="User does not have validator permission", ) - if not match: - log.error(f"User ID {user_id} has no access to project ID {project_id}") - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, detail="User has no access to project" - ) - if match.role.value < ProjectRole.VALIDATOR.value: - log.error( - f"User ID {user_id} does not have validator permission" - f"for project ID {project_id}" - ) - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="User is not a validator for this project", - ) +async def mapper( + project: DbProject = Depends(get_project_by_id), + db: Session = Depends(get_db), + user_data: AuthUser = Depends(login_required), +) -> Optional[DbUser]: + """A mapper for a specific project.""" + # If project is public, skip permission check + if project.visibility == ProjectVisibility.PUBLIC: + return user_data + db_user = await check_access( + user_data, + db, + project_id=project.id, + role=ProjectRole.MAPPER, + ) + + if db_user: + return db_user - return user_data + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="User does not have mapper permission", + ) diff --git a/src/backend/app/central/central_crud.py b/src/backend/app/central/central_crud.py index 185f557760..95de3a81dd 100644 --- a/src/backend/app/central/central_crud.py +++ b/src/backend/app/central/central_crud.py @@ -18,10 +18,11 @@ """Logic for interaction with ODK Central & data.""" import os +from io import BytesIO +from pathlib import Path +from typing import Optional from xml.etree import ElementTree -# import osm_fieldwork -# Qr code imports from fastapi import HTTPException from fastapi.responses import JSONResponse from loguru import logger as log @@ -35,12 +36,12 @@ from app.projects import project_schemas -def get_odk_project(odk_central: project_schemas.ODKCentral = None): +def get_odk_project(odk_central: Optional[project_schemas.ODKCentralDecrypted] = None): """Helper function to get the OdkProject with credentials.""" if odk_central: url = odk_central.odk_central_url user = odk_central.odk_central_user - pw = odk_central.odk_central_password.get_secret_value() + pw = odk_central.odk_central_password else: log.debug("ODKCentral connection variables not set in function") log.debug("Attempting extraction from environment variables") @@ -60,12 +61,12 @@ def get_odk_project(odk_central: project_schemas.ODKCentral = None): return project -def get_odk_form(odk_central: project_schemas.ODKCentral = None): +def get_odk_form(odk_central: Optional[project_schemas.ODKCentralDecrypted] = None): """Helper function to get the OdkForm with credentials.""" if odk_central: url = odk_central.odk_central_url user = odk_central.odk_central_user - pw = odk_central.odk_central_password.get_secret_value() + pw = odk_central.odk_central_password else: log.debug("ODKCentral connection variables not set in function") @@ -86,12 +87,12 @@ def get_odk_form(odk_central: project_schemas.ODKCentral = None): return form -def get_odk_app_user(odk_central: project_schemas.ODKCentral = None): +def get_odk_app_user(odk_central: Optional[project_schemas.ODKCentralDecrypted] = None): """Helper function to get the OdkAppUser with credentials.""" if odk_central: url = odk_central.odk_central_url user = odk_central.odk_central_user - pw = odk_central.odk_central_password.get_secret_value() + pw = odk_central.odk_central_password else: log.debug("ODKCentral connection variables not set in function") log.debug("Attempting extraction from environment variables") @@ -111,13 +112,17 @@ def get_odk_app_user(odk_central: project_schemas.ODKCentral = None): return form -def list_odk_projects(odk_central: project_schemas.ODKCentral = None): +def list_odk_projects( + odk_central: Optional[project_schemas.ODKCentralDecrypted] = None, +): """List all projects on a remote ODK Server.""" project = get_odk_project(odk_central) return project.listProjects() -def create_odk_project(name: str, odk_central: project_schemas.ODKCentral = None): +def create_odk_project( + name: str, odk_central: Optional[project_schemas.ODKCentralDecrypted] = None +): """Create a project on a remote ODK Server.""" project = get_odk_project(odk_central) @@ -144,7 +149,7 @@ def create_odk_project(name: str, odk_central: project_schemas.ODKCentral = None async def delete_odk_project( - project_id: int, odk_central: project_schemas.ODKCentral = None + project_id: int, odk_central: Optional[project_schemas.ODKCentralDecrypted] = None ): """Delete a project from a remote ODK Server.""" # FIXME: when a project is deleted from Central, we have to update the @@ -159,7 +164,9 @@ async def delete_odk_project( def delete_odk_app_user( - project_id: int, name: str, odk_central: project_schemas.ODKCentral = None + project_id: int, + name: str, + odk_central: Optional[project_schemas.ODKCentralDecrypted] = None, ): """Delete an app-user from a remote ODK Server.""" odk_app_user = get_odk_app_user(odk_central) @@ -168,7 +175,10 @@ def delete_odk_app_user( def upload_xform_media( - project_id: int, xform_id: str, filespec: str, odk_credentials: dict = None + project_id: int, + xform_id: str, + filespec: str, + odk_credentials: Optional[dict] = None, ): """Upload and publish an XForm on ODKCentral.""" title = os.path.basename(os.path.splitext(filespec)[0]) @@ -200,11 +210,11 @@ def upload_xform_media( def create_odk_xform( project_id: int, - xform_id: str, + xform_name: str, filespec: str, - odk_credentials: project_schemas.ODKCentral = None, + feature_geojson: BytesIO, + odk_credentials: Optional[project_schemas.ODKCentralDecrypted] = None, create_draft: bool = False, - upload_media=True, convert_to_draft_when_publishing=True, ): """Create an XForm on a remote ODK Central server.""" @@ -213,7 +223,7 @@ def create_odk_xform( # Pass odk credentials of project in xform if not odk_credentials: - odk_credentials = project_schemas.ODKCentral( + odk_credentials = project_schemas.ODKCentralDecrypted( odk_central_url=settings.ODK_CENTRAL_URL, odk_central_user=settings.ODK_CENTRAL_USER, odk_central_password=settings.ODK_CENTRAL_PASSWD, @@ -226,16 +236,26 @@ def create_odk_xform( status_code=500, detail={"message": "Connection failed to odk central"} ) from e - result = xform.createForm(project_id, xform_id, filespec, create_draft) + result = xform.createForm(project_id, xform_name, filespec, create_draft) if result != 200 and result != 409: return result - data = f"/tmp/{title}.geojson" + + # TODO refactor osm_fieldwork.OdkCentral.OdkForm.uploadMedia + # to accept passing a bytesio object and update + geojson_file = Path(f"/tmp/{title}.geojson") + with open(geojson_file, "w") as f: + f.write(feature_geojson.getvalue().decode("utf-8")) # This modifies an existing published XForm to be in draft mode. # An XForm must be in draft mode to upload an attachment. - if upload_media: - xform.uploadMedia(project_id, title, data, convert_to_draft_when_publishing) + # Upload the geojson of features to be modified + xform.uploadMedia( + project_id, title, str(geojson_file), convert_to_draft_when_publishing + ) + + # Delete temp geojson file + geojson_file.unlink(missing_ok=True) result = xform.publishForm(project_id, title) return result @@ -245,7 +265,7 @@ def delete_odk_xform( project_id: int, xform_id: str, filespec: str, - odk_central: project_schemas.ODKCentral = None, + odk_central: Optional[project_schemas.ODKCentralDecrypted] = None, ): """Delete an XForm from a remote ODK Central server.""" xform = get_odk_form(odk_central) @@ -256,7 +276,7 @@ def delete_odk_xform( def list_odk_xforms( project_id: int, - odk_central: project_schemas.ODKCentral = None, + odk_central: Optional[project_schemas.ODKCentralDecrypted] = None, metadata: bool = False, ): """List all XForms in an ODK Central project.""" @@ -267,7 +287,9 @@ def list_odk_xforms( def get_form_full_details( - odk_project_id: int, form_id: str, odk_central: project_schemas.ODKCentral + odk_project_id: int, + form_id: str, + odk_central: Optional[project_schemas.ODKCentralDecrypted] = None, ): """Get additional metadata for ODK Form.""" form = get_odk_form(odk_central) @@ -276,7 +298,7 @@ def get_form_full_details( def get_odk_project_full_details( - odk_project_id: int, odk_central: project_schemas.ODKCentral + odk_project_id: int, odk_central: project_schemas.ODKCentralDecrypted ): """Get additional metadata for ODK project.""" project = get_odk_project(odk_central) @@ -284,7 +306,9 @@ def get_odk_project_full_details( return project_details -def list_submissions(project_id: int, odk_central: project_schemas.ODKCentral = None): +def list_submissions( + project_id: int, odk_central: Optional[project_schemas.ODKCentralDecrypted] = None +): """List all submissions for a project, aggregated from associated users.""" project = get_odk_project(odk_central) xform = get_odk_form(odk_central) @@ -324,9 +348,9 @@ def get_form_list(db: Session, skip: int, limit: int): def download_submissions( project_id: int, xform_id: str, - submission_id: str = None, + submission_id: Optional[str] = None, get_json: bool = True, - odk_central: project_schemas.ODKCentral = None, + odk_central: Optional[project_schemas.ODKCentralDecrypted] = None, ): """Download all submissions for an XForm.""" xform = get_odk_form(odk_central) @@ -506,7 +530,7 @@ def upload_media( project_id: int, xform_id: str, filespec: str, - odk_central: project_schemas.ODKCentral = None, + odk_central: Optional[project_schemas.ODKCentralDecrypted] = None, ): """Upload a data file to Central.""" xform = get_odk_form(odk_central) @@ -517,7 +541,7 @@ def download_media( project_id: int, xform_id: str, filespec: str, - odk_central: project_schemas.ODKCentral = None, + odk_central: Optional[project_schemas.ODKCentralDecrypted] = None, ): """Upload a data file to Central.""" xform = get_odk_form(odk_central) diff --git a/src/backend/app/central/central_routes.py b/src/backend/app/central/central_routes.py index 60e7f13997..4bd51a5ff7 100644 --- a/src/backend/app/central/central_routes.py +++ b/src/backend/app/central/central_routes.py @@ -211,7 +211,7 @@ async def get_submission( return {"error": "No such project!"} # ODK Credentials - odk_credentials = project_schemas.ODKCentral( + odk_credentials = project_schemas.ODKCentralDecrypted( odk_central_url=first.odk_central_url, odk_central_user=first.odk_central_user, odk_central_password=first.odk_central_password, diff --git a/src/backend/app/config.py b/src/backend/app/config.py index cd422d964f..749b518694 100644 --- a/src/backend/app/config.py +++ b/src/backend/app/config.py @@ -19,12 +19,20 @@ import base64 from functools import lru_cache -from typing import Any, Optional, Union +from typing import Annotated, Any, Optional, Union from cryptography.fernet import Fernet -from pydantic import PostgresDsn, ValidationInfo, field_validator +from pydantic import BeforeValidator, TypeAdapter, ValidationInfo, field_validator +from pydantic.networks import HttpUrl, PostgresDsn from pydantic_settings import BaseSettings, SettingsConfigDict +HttpUrlStr = Annotated[ + str, + BeforeValidator( + lambda value: str(TypeAdapter(HttpUrl).validate_python(value) if value else "") + ), +] + class Settings(BaseSettings): """Main settings class, defining environment variables.""" @@ -74,9 +82,7 @@ def assemble_cors_origins( default_origins += val return default_origins - raise ValueError(f"Not a valid CORS origin list: {val}") - - API_PREFIX: Optional[str] = "/" + API_PREFIX: str = "/" FMTM_DB_HOST: Optional[str] = "fmtm-db" FMTM_DB_USER: Optional[str] = "fmtm" @@ -100,14 +106,14 @@ def assemble_db_connection(cls, v: Optional[str], info: ValidationInfo) -> Any: ) return pg_url - ODK_CENTRAL_URL: Optional[str] = "" + ODK_CENTRAL_URL: Optional[HttpUrlStr] = "" ODK_CENTRAL_USER: Optional[str] = "" ODK_CENTRAL_PASSWD: Optional[str] = "" OSM_CLIENT_ID: str OSM_CLIENT_SECRET: str OSM_SECRET_KEY: str - OSM_URL: str = "https://www.openstreetmap.org" + OSM_URL: HttpUrlStr = "https://www.openstreetmap.org" OSM_SCOPE: str = "read_prefs" OSM_LOGIN_REDIRECT_URI: str = "http://127.0.0.1:7051/osmauth/" @@ -135,7 +141,7 @@ def configure_s3_download_root(cls, v: Optional[str], info: ValidationInfo) -> s # Externally hosted S3 s3_endpoint = info.data.get("S3_ENDPOINT") - if s3_endpoint.startswith("https://"): + if s3_endpoint and s3_endpoint.startswith("https://"): return s3_endpoint # Containerised S3 @@ -147,7 +153,7 @@ def configure_s3_download_root(cls, v: Optional[str], info: ValidationInfo) -> s return f"http://s3.{fmtm_domain}:{dev_port}" return f"https://s3.{fmtm_domain}" - UNDERPASS_API_URL: str = "https://api-prod.raw-data.hotosm.org/v1/" + UNDERPASS_API_URL: HttpUrlStr = "https://api-prod.raw-data.hotosm.org/v1/" SENTRY_DSN: Optional[str] = None model_config = SettingsConfigDict( @@ -170,7 +176,7 @@ def get_cipher_suite(): return Fernet(settings.ENCRYPTION_KEY) -def encrypt_value(password: str) -> str: +def encrypt_value(password: Union[str, HttpUrlStr]) -> str: """Encrypt value before going to the DB.""" cipher_suite = get_cipher_suite() encrypted_password = cipher_suite.encrypt(password.encode("utf-8")) diff --git a/src/backend/app/db/db_models.py b/src/backend/app/db/db_models.py index d7a2dc2831..951d514b23 100644 --- a/src/backend/app/db/db_models.py +++ b/src/backend/app/db/db_models.py @@ -17,7 +17,10 @@ # """SQLAlchemy database models for interacting with Postgresql.""" -from geoalchemy2 import Geometry +from datetime import datetime +from typing import cast + +from geoalchemy2 import Geometry, WKBElement from sqlalchemy import ( ARRAY, BigInteger, @@ -36,8 +39,10 @@ UniqueConstraint, desc, ) -from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR -from sqlalchemy.orm import ( # , declarative_base # , declarative_base +from sqlalchemy.dialects.postgresql import ARRAY as PostgreSQLArray # noqa: N811 +from sqlalchemy.dialects.postgresql import TSVECTOR +from sqlalchemy.orm import ( + # declarative_base, backref, object_session, relationship, @@ -47,6 +52,7 @@ from app.db.postgis_utils import timestamp from app.models.enums import ( BackgroundTaskStatus, + CommunityType, MappingLevel, MappingPermission, OrganisationType, @@ -69,14 +75,17 @@ class DbUserRoles(Base): __tablename__ = "user_roles" # Table has composite PK on (user_id and project_id) - user_id = Column(BigInteger, ForeignKey("users.id"), primary_key=True) - project_id = Column( - Integer, - ForeignKey("projects.id"), - index=True, - primary_key=True, + user_id = cast(int, Column(BigInteger, ForeignKey("users.id"), primary_key=True)) + project_id = cast( + int, + Column( + Integer, + ForeignKey("projects.id"), + index=True, + primary_key=True, + ), ) - role = Column(Enum(ProjectRole), default=UserRole.MAPPER) + role = cast(ProjectRole, Column(Enum(ProjectRole), default=ProjectRole.MAPPER)) class DbUser(Base): @@ -84,29 +93,32 @@ class DbUser(Base): __tablename__ = "users" - id = Column(BigInteger, primary_key=True, index=True) - username = Column(String, unique=True) - profile_img = Column(String) - role = Column(Enum(UserRole), default=UserRole.MAPPER) + id = cast(int, Column(BigInteger, primary_key=True)) + username = cast(str, Column(String, unique=True)) + profile_img = cast(str, Column(String)) + role = cast(UserRole, Column(Enum(UserRole), default=UserRole.MAPPER)) project_roles = relationship( DbUserRoles, backref="user_roles_link", cascade="all, delete, delete-orphan" ) - name = Column(String) - city = Column(String) - country = Column(String) - email_address = Column(String) - is_email_verified = Column(Boolean, default=False) - is_expert = Column(Boolean, default=False) - - mapping_level = Column( - Enum(MappingLevel), - default=MappingLevel.BEGINNER, + name = cast(str, Column(String)) + city = cast(str, Column(String)) + country = cast(str, Column(String)) + email_address = cast(str, Column(String)) + is_email_verified = cast(bool, Column(Boolean, default=False)) + is_expert = cast(bool, Column(Boolean, default=False)) + + mapping_level = cast( + MappingLevel, + Column( + Enum(MappingLevel), + default=MappingLevel.BEGINNER, + ), ) - tasks_mapped = Column(Integer, default=0) - tasks_validated = Column(Integer, default=0) - tasks_invalidated = Column(Integer, default=0) - projects_mapped = Column(ARRAY(Integer)) + tasks_mapped = cast(int, Column(Integer, default=0)) + tasks_validated = cast(int, Column(Integer, default=0)) + tasks_invalidated = cast(int, Column(Integer, default=0)) + projects_mapped = cast(PostgreSQLArray, Column(ARRAY(Integer))) # mentions_notifications = Column(Boolean, default=True, nullable=False) # projects_comments_notifications = Column( @@ -119,9 +131,9 @@ class DbUser(Base): # Boolean, default=True, nullable=False # ) - date_registered = Column(DateTime, default=timestamp) + date_registered = cast(datetime, Column(DateTime, default=timestamp)) # Represents the date the user last had one of their tasks validated - last_validation_date = Column(DateTime, default=timestamp) + last_validation_date = cast(datetime, Column(DateTime, default=timestamp)) # Secondary table defining many-to-many relationship between organisations and managers @@ -140,19 +152,30 @@ class DbOrganisation(Base): __tablename__ = "organisations" # Columns - id = Column(Integer, primary_key=True) - name = Column(String(512), nullable=False, unique=True) - slug = Column(String(255), nullable=False, unique=True) - logo = Column(String) # URL of a logo - description = Column(String) - url = Column(String) - type = Column(Enum(OrganisationType), default=OrganisationType.FREE, nullable=False) - approved = Column(Boolean, default=False) + id = cast(int, Column(Integer, primary_key=True)) + name = cast(str, Column(String(512), nullable=False, unique=True)) + slug = cast(str, Column(String(255), nullable=False, unique=True)) + logo = cast(str, Column(String)) # URL of a logo + description = cast(str, Column(String)) + url = cast(str, Column(String)) + type = cast( + OrganisationType, + Column(Enum(OrganisationType), default=OrganisationType.FREE, nullable=False), + ) + approved = cast(bool, Column(Boolean, default=False)) + created_by = Column(Integer) ## Odk central server - odk_central_url = Column(String) - odk_central_user = Column(String) - odk_central_password = Column(String) + odk_central_url = cast(str, Column(String)) + odk_central_user = cast(str, Column(String)) + odk_central_password = cast(str, Column(String)) + + community_type = cast( + CommunityType, + Column( + Enum(CommunityType), default=CommunityType.OSM_COMMUNITY, nullable=False + ), + ) managers = relationship( DbUser, @@ -167,18 +190,22 @@ class DbTeam(Base): __tablename__ = "teams" # Columns - id = Column(Integer, primary_key=True) - organisation_id = Column( - Integer, - ForeignKey("organisations.id", name="fk_organisations"), - nullable=False, + id = cast(int, Column(Integer, primary_key=True)) + organisation_id = cast( + int, + Column( + Integer, + ForeignKey("organisations.id", name="fk_organisations"), + nullable=False, + ), ) - name = Column(String(512), nullable=False) - logo = Column(String) # URL of a logo - description = Column(String) - invite_only = Column(Boolean, default=False, nullable=False) - visibility = Column( - Enum(TeamVisibility), default=TeamVisibility.PUBLIC, nullable=False + name = cast(str, Column(String(512), nullable=False)) + logo = cast(str, Column(String)) # URL of a logo + description = cast(str, Column(String)) + invite_only = cast(bool, Column(Boolean, default=False, nullable=False)) + visibility = cast( + TeamVisibility, + Column(Enum(TeamVisibility), default=TeamVisibility.PUBLIC, nullable=False), ) organisation = relationship(DbOrganisation, backref="teams") @@ -187,9 +214,9 @@ class DbProjectTeams(Base): """Link table between teams and projects.""" __tablename__ = "project_teams" - team_id = Column(Integer, ForeignKey("teams.id"), primary_key=True) - project_id = Column(Integer, ForeignKey("projects.id"), primary_key=True) - role = Column(Integer, nullable=False) + team_id = cast(int, Column(Integer, ForeignKey("teams.id"), primary_key=True)) + project_id = cast(int, Column(Integer, ForeignKey("projects.id"), primary_key=True)) + role = cast(int, Column(Integer, nullable=False)) project = relationship( "DbProject", backref=backref("teams", cascade="all, delete-orphan") @@ -204,15 +231,15 @@ class DbProjectInfo(Base): __tablename__ = "project_info" - project_id = Column(Integer, ForeignKey("projects.id"), primary_key=True) - project_id_str = Column(String) - name = Column(String(512)) - short_description = Column(String) - description = Column(String) - text_searchable = Column( - TSVECTOR + project_id = cast(int, Column(Integer, ForeignKey("projects.id"), primary_key=True)) + project_id_str = cast(str, Column(String)) + name = cast(str, Column(String(512))) + short_description = cast(str, Column(String)) + description = cast(str, Column(String)) + text_searchable = cast( + TSVECTOR, Column(TSVECTOR) ) # This contains searchable text and is populated by a DB Trigger - per_task_instructions = Column(String) + per_task_instructions = cast(str, Column(String)) __table_args__ = ( Index("textsearch_idx", "text_searchable"), @@ -224,11 +251,13 @@ class DbProjectChat(Base): """Contains all project info localized into supported languages.""" __tablename__ = "project_chat" - id = Column(BigInteger, primary_key=True) - project_id = Column(Integer, ForeignKey("projects.id"), index=True, nullable=False) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - time_stamp = Column(DateTime, nullable=False, default=timestamp) - message = Column(String, nullable=False) + id = cast(int, Column(BigInteger, primary_key=True)) + project_id = cast( + int, Column(Integer, ForeignKey("projects.id"), index=True, nullable=False) + ) + user_id = cast(int, Column(Integer, ForeignKey("users.id"), nullable=False)) + time_stamp = cast(datetime, Column(DateTime, nullable=False, default=timestamp)) + message = cast(str, Column(String, nullable=False)) # Relationships posted_by = relationship(DbUser, foreign_keys=[user_id]) @@ -238,14 +267,14 @@ class DbXForm(Base): """Xform templates and custom uploads.""" __tablename__ = "xlsforms" - id = Column(Integer, primary_key=True, autoincrement=True) + id = cast(int, Column(Integer, primary_key=True, autoincrement=True)) # The XLSForm name is the only unique thing we can use for a key # so on conflict update works. Otherwise we get multiple entries. - title = Column(String, unique=True) - category = Column(String) - description = Column(String) - xml = Column(String) # Internal form representation - xls = Column(LargeBinary) # Human readable representation + title = cast(str, Column(String, unique=True)) + category = cast(str, Column(String)) + description = cast(str, Column(String)) + xml = cast(str, Column(String)) # Internal form representation + xls = cast(bytes, Column(LargeBinary)) # Human readable representation class DbTaskInvalidationHistory(Base): @@ -255,20 +284,25 @@ class DbTaskInvalidationHistory(Base): """ __tablename__ = "task_invalidation_history" - id = Column(Integer, primary_key=True) - project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) - task_id = Column(Integer, nullable=False) - is_closed = Column(Boolean, default=False) - mapper_id = Column(BigInteger, ForeignKey("users.id", name="fk_mappers")) - mapped_date = Column(DateTime) - invalidator_id = Column(BigInteger, ForeignKey("users.id", name="fk_invalidators")) - invalidated_date = Column(DateTime) - invalidation_history_id = Column( - Integer, ForeignKey("task_history.id", name="fk_invalidation_history") - ) - validator_id = Column(BigInteger, ForeignKey("users.id", name="fk_validators")) - validated_date = Column(DateTime) - updated_date = Column(DateTime, default=timestamp) + id = cast(int, Column(Integer, primary_key=True)) + project_id = cast(int, Column(Integer, ForeignKey("projects.id"), nullable=False)) + task_id = cast(int, Column(Integer, nullable=False)) + is_closed = cast(bool, Column(Boolean, default=False)) + mapper_id = cast(int, Column(BigInteger, ForeignKey("users.id", name="fk_mappers"))) + mapped_date = cast(datetime, Column(DateTime)) + invalidator_id = cast( + int, Column(BigInteger, ForeignKey("users.id", name="fk_invalidators")) + ) + invalidated_date = cast(datetime, Column(DateTime)) + invalidation_history_id = cast( + int, + Column(Integer, ForeignKey("task_history.id", name="fk_invalidation_history")), + ) + validator_id = cast( + int, Column(BigInteger, ForeignKey("users.id", name="fk_validators")) + ) + validated_date = cast(datetime, Column(DateTime)) + updated_date = cast(datetime, Column(DateTime, default=timestamp)) __table_args__ = ( ForeignKeyConstraint( @@ -293,27 +327,31 @@ class DbTaskMappingIssue(Base): """ __tablename__ = "task_mapping_issues" - id = Column(Integer, primary_key=True) - task_history_id = Column( - Integer, ForeignKey("task_history.id"), nullable=False, index=True + id = cast(int, Column(Integer, primary_key=True)) + task_history_id = cast( + int, Column(Integer, ForeignKey("task_history.id"), nullable=False, index=True) ) - issue = Column(String, nullable=False) - mapping_issue_category_id = Column( - Integer, - ForeignKey("mapping_issue_categories.id", name="fk_issue_category"), - nullable=False, + issue = cast(str, Column(String, nullable=False)) + mapping_issue_category_id = cast( + int, + Column( + Integer, + ForeignKey("mapping_issue_categories.id", name="fk_issue_category"), + nullable=False, + ), ) - count = Column(Integer, nullable=False) + count = cast(int, Column(Integer, nullable=False)) class DbMappingIssueCategory(Base): """Represents a category of task mapping issues identified during validaton.""" __tablename__ = "mapping_issue_categories" - id = Column(Integer, primary_key=True) - name = Column(String, nullable=False, unique=True) - description = Column(String, nullable=True) - archived = Column(Boolean, default=False, nullable=False) + + id = cast(int, Column(Integer, primary_key=True)) + name = cast(str, Column(String, nullable=False, unique=True)) + description = cast(str, Column(String, nullable=True)) + archived = cast(bool, Column(Boolean, default=False, nullable=False)) class DbTaskHistory(Base): @@ -321,23 +359,28 @@ class DbTaskHistory(Base): __tablename__ = "task_history" - id = Column(Integer, primary_key=True) - project_id = Column(Integer, ForeignKey("projects.id"), index=True) - task_id = Column(Integer, nullable=False) - action = Column(Enum(TaskAction), nullable=False) - action_text = Column(String) - action_date = Column(DateTime, nullable=False, default=timestamp) - user_id = Column( - BigInteger, - ForeignKey("users.id", name="fk_users"), - index=True, - nullable=False, + id = cast(int, Column(Integer, primary_key=True)) + project_id = cast(int, Column(Integer, ForeignKey("projects.id"), index=True)) + task_id = cast(int, Column(Integer, nullable=False)) + action = cast(TaskAction, Column(Enum(TaskAction), nullable=False)) + action_text = cast(str, Column(String)) + action_date = cast(datetime, Column(DateTime, nullable=False, default=timestamp)) + user_id = cast( + int, + Column( + BigInteger, + ForeignKey("users.id", name="fk_users"), + index=True, + nullable=False, + ), ) + + # Define relationships + user = relationship(DbUser, uselist=False, backref="task_history_user") invalidation_history = relationship( DbTaskInvalidationHistory, lazy="dynamic", cascade="all" ) - - actioned_by = relationship(DbUser) + actioned_by = relationship(DbUser, overlaps="task_history_user,user") task_mapping_issues = relationship(DbTaskMappingIssue, cascade="all") __table_args__ = ( @@ -350,34 +393,65 @@ class DbTaskHistory(Base): ) +class TaskComment(Base): + """Represents a comment associated with a task.""" + + __tablename__ = "task_comment" + + id = Column(Integer, primary_key=True) + task_id = Column(Integer, nullable=False) + project_id = Column(Integer, ForeignKey("projects.id"), index=True) + comment_text = Column(String) + commented_by = Column( + BigInteger, + ForeignKey("users.id", name="fk_users"), + index=True, + nullable=False, + ) + created_at = Column(DateTime, nullable=False, default=timestamp) + + __table_args__ = ( + ForeignKeyConstraint( + [task_id, project_id], ["tasks.id", "tasks.project_id"], name="fk_tasks" + ), + Index("idx_task_comment_composite", "task_id", "project_id"), + {}, + ) + + class DbTask(Base): """Describes an individual mapping Task.""" __tablename__ = "tasks" # Table has composite PK on (id and project_id) - id = Column(Integer, primary_key=True, autoincrement=True) - project_id = Column( - Integer, ForeignKey("projects.id"), index=True, primary_key=True + id = cast(int, Column(Integer, primary_key=True, autoincrement=True)) + project_id = cast( + int, Column(Integer, ForeignKey("projects.id"), index=True, primary_key=True) ) - project_task_index = Column(Integer) - project_task_name = Column(String) - outline = Column(Geometry("POLYGON", srid=4326)) - geometry_geojson = Column(String) - initial_feature_count = Column(Integer) - task_status = Column(Enum(TaskStatus), default=TaskStatus.READY) - locked_by = Column( - BigInteger, ForeignKey("users.id", name="fk_users_locked"), index=True + project_task_index = cast(int, Column(Integer)) + project_task_name = cast(str, Column(String)) + outline = cast(WKBElement, Column(Geometry("POLYGON", srid=4326))) + geometry_geojson = cast(str, Column(String)) + feature_count = cast(int, Column(Integer)) + task_status = cast(TaskStatus, Column(Enum(TaskStatus), default=TaskStatus.READY)) + locked_by = cast( + int, + Column(BigInteger, ForeignKey("users.id", name="fk_users_locked"), index=True), ) - mapped_by = Column( - BigInteger, ForeignKey("users.id", name="fk_users_mapper"), index=True + mapped_by = cast( + int, + Column(BigInteger, ForeignKey("users.id", name="fk_users_mapper"), index=True), ) - validated_by = Column( - BigInteger, ForeignKey("users.id", name="fk_users_validator"), index=True + validated_by = cast( + int, + Column( + BigInteger, ForeignKey("users.id", name="fk_users_validator"), index=True + ), ) - odk_token = Column(String, nullable=True) + odk_token = cast(str, Column(String, nullable=True)) - # Mapped objects + # Define relationships task_history = relationship( DbTaskHistory, cascade="all", order_by=desc(DbTaskHistory.action_date) ) @@ -401,18 +475,30 @@ class DbProject(Base): __tablename__ = "projects" # Columns - id = Column(Integer, primary_key=True) - odkid = Column(Integer) + id = cast(int, Column(Integer, primary_key=True)) + odkid = cast(int, Column(Integer)) + organisation_id = cast( + int, + Column( + Integer, + ForeignKey("organisations.id", name="fk_organisations"), + index=True, + ), + ) + organisation = relationship(DbOrganisation, backref="projects") # PROJECT CREATION - author_id = Column( - BigInteger, - ForeignKey("users.id", name="fk_users"), - nullable=False, - server_default="20386219", + author_id = cast( + int, + Column( + BigInteger, + ForeignKey("users.id", name="fk_users"), + nullable=False, + server_default="20386219", + ), ) author = relationship(DbUser, uselist=False, backref="user") - created = Column(DateTime, default=timestamp, nullable=False) + created = cast(datetime, Column(DateTime, default=timestamp, nullable=False)) task_split_type = Column(Enum(TaskSplitType), nullable=True) # split_strategy = Column(Integer) @@ -421,28 +507,34 @@ class DbProject(Base): # target_number_of_features = Column(Integer) # PROJECT DETAILS - project_name_prefix = Column(String) - task_type_prefix = Column(String) + project_name_prefix = cast(str, Column(String)) + task_type_prefix = cast(str, Column(String)) project_info = relationship( DbProjectInfo, cascade="all, delete, delete-orphan", uselist=False, backref="project", ) - location_str = Column(String) + location_str = cast(str, Column(String)) # GEOMETRY - outline = Column(Geometry("POLYGON", srid=4326)) + outline = cast(WKBElement, Column(Geometry("POLYGON", srid=4326))) # geometry = Column(Geometry("POLYGON", srid=4326, from_text='ST_GeomFromWkt')) - centroid = Column(Geometry("POINT", srid=4326)) + centroid = cast(WKBElement, Column(Geometry("POINT", srid=4326))) # PROJECT STATUS - last_updated = Column(DateTime, default=timestamp) - status = Column(Enum(ProjectStatus), default=ProjectStatus.DRAFT, nullable=False) - visibility = Column( - Enum(ProjectVisibility), default=ProjectVisibility.PUBLIC, nullable=False + last_updated = cast(datetime, Column(DateTime, default=timestamp)) + status = cast( + ProjectStatus, + Column(Enum(ProjectStatus), default=ProjectStatus.DRAFT, nullable=False), + ) + visibility = cast( + ProjectVisibility, + Column( + Enum(ProjectVisibility), default=ProjectVisibility.PUBLIC, nullable=False + ), ) - total_tasks = Column(Integer) + total_tasks = cast(int, Column(Integer)) # tasks_mapped = Column(Integer, default=0, nullable=False) # tasks_validated = Column(Integer, default=0, nullable=False) # tasks_bad_imagery = Column(Integer, default=0, nullable=False) @@ -491,7 +583,9 @@ def tasks_bad(self): ) # XFORM DETAILS - xform_title = Column(String, ForeignKey("xlsforms.title", name="fk_xform")) + xform_title = cast( + str, Column(String, ForeignKey("xlsforms.title", name="fk_xform")) + ) xform = relationship(DbXForm) __table_args__ = ( @@ -499,69 +593,79 @@ def tasks_bad(self): {}, ) - mapper_level = Column( - Enum(MappingLevel), - default=MappingLevel.INTERMEDIATE, - nullable=False, - index=True, - ) # Mapper level project is suitable for - priority = Column(Enum(ProjectPriority), default=ProjectPriority.MEDIUM) - featured = Column( - Boolean, default=False + mapper_level = cast( + MappingLevel, + Column( + Enum(MappingLevel), + default=MappingLevel.INTERMEDIATE, + nullable=False, + index=True, + ), + ) + priority = cast( + ProjectPriority, Column(Enum(ProjectPriority), default=ProjectPriority.MEDIUM) + ) + featured = cast( + bool, Column(Boolean, default=False) ) # Only admins can set a project as featured - mapping_permission = Column(Enum(MappingPermission), default=MappingPermission.ANY) - validation_permission = Column( - Enum(ValidationPermission), default=ValidationPermission.LEVEL - ) # Means only users with validator role can validate - organisation_id = Column( - Integer, - ForeignKey("organisations.id", name="fk_organisations"), - index=True, + mapping_permission = cast( + MappingPermission, + Column(Enum(MappingPermission), default=MappingPermission.ANY), ) - organisation = relationship(DbOrganisation, backref="projects") - changeset_comment = Column(String) + validation_permission = cast( + ValidationPermission, + Column(Enum(ValidationPermission), default=ValidationPermission.LEVEL), + ) # Means only users with validator role can validate + changeset_comment = cast(str, Column(String)) - ## Odk central server - odk_central_url = Column(String) - odk_central_user = Column(String) - odk_central_password = Column(String) + # Odk central server + odk_central_url = cast(str, Column(String)) + odk_central_user = cast(str, Column(String)) + odk_central_password = cast(str, Column(String)) # Count of tasks where osm extracts is completed, used for progress bar. - extract_completed_count = Column(Integer, default=0) - - form_xls = Column(LargeBinary) # XLSForm file if custom xls is uploaded - form_config_file = Column(LargeBinary) # Yaml config file if custom xls is uploaded - - data_extract_type = Column(String) # Type of data extract (Polygon or Centroid) - # Options: divide on square, manual upload, task splitting algo - task_split_type = Column(Enum(TaskSplitType), nullable=True) - task_split_dimension = Column(SmallInteger, nullable=True) - task_num_buildings = Column(SmallInteger, nullable=True) - - hashtags = Column(ARRAY(String)) # Project hashtag + extract_completed_count = cast(int, Column(Integer, default=0)) + + form_xls = cast( + bytes, Column(LargeBinary) + ) # XLSForm file if custom xls is uploaded + form_config_file = cast( + bytes, Column(LargeBinary) + ) # Yaml config file if custom xls is uploaded + + data_extract_type = cast( + str, Column(String) + ) # Type of data extract (Polygon or Centroid) + data_extract_url = cast(str, Column(String)) + task_split_type = cast( + TaskSplitType, Column(Enum(TaskSplitType), nullable=True) + ) # Options: divide on square, manual upload, task splitting algo + task_split_dimension = cast(int, Column(SmallInteger, nullable=True)) + task_num_buildings = cast(int, Column(SmallInteger, nullable=True)) + + hashtags = cast(list, Column(ARRAY(String))) # Project hashtag + + # Other Attributes + imagery = cast(str, Column(String)) + osm_preset = cast(str, Column(String)) + odk_preset = cast(str, Column(String)) + josm_preset = cast(str, Column(String)) + id_presets = cast(list, Column(ARRAY(String))) + extra_id_params = cast(str, Column(String)) + license_id = cast( + int, Column(Integer, ForeignKey("licenses.id", name="fk_licenses")) + ) - ## ---------------------------------------------- ## - # FOR REFERENCE: OTHER ATTRIBUTES IN TASKING MANAGER - imagery = Column(String) - osm_preset = Column(String) - odk_preset = Column(String) - josm_preset = Column(String) - id_presets = Column(ARRAY(String)) - extra_id_params = Column(String) - license_id = Column(Integer, ForeignKey("licenses.id", name="fk_licenses")) # GEOMETRY # country = Column(ARRAY(String), default=[]) + # FEEDBACK - project_chat = relationship(DbProjectChat, lazy="dynamic", cascade="all") - osmcha_filter_id = Column( - String + osmcha_filter_id = cast( + str, Column(String) ) # Optional custom filter id for filtering on OSMCha - due_date = Column(DateTime) + due_date = cast(datetime, Column(DateTime)) -# TODO: Add index on project geometry, tried to add in __table args__ -# Index("idx_geometry", DbProject.geometry, postgresql_using="gist") - # Secondary table defining the many-to-many join user_licenses_table = Table( "user_licenses", @@ -576,10 +680,10 @@ class DbLicense(Base): __tablename__ = "licenses" - id = Column(Integer, primary_key=True) - name = Column(String, unique=True) - description = Column(String) - plain_text = Column(String) + id = cast(int, Column(Integer, primary_key=True)) + name = cast(str, Column(String, unique=True)) + description = cast(str, Column(String)) + plain_text = cast(str, Column(String)) projects = relationship(DbProject, backref="license") users = relationship( @@ -587,40 +691,18 @@ class DbLicense(Base): ) # Many to Many relationship -class DbFeatures(Base): - """Features extracted from osm data.""" - - __tablename__ = "features" - - id = Column(Integer, primary_key=True) - project_id = Column(Integer, ForeignKey("projects.id")) - project = relationship(DbProject, backref="features") - - category_title = Column(String, ForeignKey("xlsforms.title", name="fk_xform")) - category = relationship(DbXForm) - task_id = Column(Integer, nullable=True) - properties = Column(JSONB) - geometry = Column(Geometry(geometry_type="GEOMETRY", srid=4326)) - - __table_args__ = ( - ForeignKeyConstraint( - [task_id, project_id], ["tasks.id", "tasks.project_id"], name="fk_tasks" - ), - Index("idx_features_composite", "task_id", "project_id"), - {}, - ) - - class BackgroundTasks(Base): """Table managing long running background tasks.""" __tablename__ = "background_tasks" - id = Column(String, primary_key=True) - name = Column(String) - project_id = Column(Integer, nullable=True) - status = Column(Enum(BackgroundTaskStatus), nullable=False) - message = Column(String) + id = cast(str, Column(String, primary_key=True)) + name = cast(str, Column(String)) + project_id = cast(int, Column(Integer, nullable=True)) + status = cast( + BackgroundTaskStatus, Column(Enum(BackgroundTaskStatus), nullable=False) + ) + message = cast(str, Column(String)) class DbTilesPath(Base): @@ -628,10 +710,12 @@ class DbTilesPath(Base): __tablename__ = "mbtiles_path" - id = Column(Integer, primary_key=True) - project_id = Column(Integer) - status = Column(Enum(BackgroundTaskStatus), nullable=False) - path = Column(String) - tile_source = Column(String) - background_task_id = Column(String) - created_at = Column(DateTime, default=timestamp) + id = cast(int, Column(Integer, primary_key=True)) + project_id = cast(int, Column(Integer)) + status = cast( + BackgroundTaskStatus, Column(Enum(BackgroundTaskStatus), nullable=False) + ) + path = cast(str, Column(String)) + tile_source = cast(str, Column(String)) + background_task_id = cast(str, Column(String)) + created_at = cast(datetime, Column(DateTime, default=timestamp)) diff --git a/src/backend/app/db/postgis_utils.py b/src/backend/app/db/postgis_utils.py index 06e1bfa2aa..1b544e6b0c 100644 --- a/src/backend/app/db/postgis_utils.py +++ b/src/backend/app/db/postgis_utils.py @@ -18,15 +18,24 @@ """PostGIS and geometry handling helper funcs.""" import datetime +import json +import logging +from typing import Optional, Union -from geoalchemy2 import Geometry -from geoalchemy2.shape import to_shape -from geojson import FeatureCollection -from geojson_pydantic import Feature -from shapely.geometry import mapping +import geojson +import requests +from fastapi import HTTPException +from geoalchemy2 import WKBElement +from geoalchemy2.shape import from_shape, to_shape +from geojson_pydantic import Feature, Polygon +from geojson_pydantic import FeatureCollection as FeatCol +from shapely.geometry import mapping, shape from sqlalchemy import text +from sqlalchemy.exc import ProgrammingError from sqlalchemy.orm import Session +log = logging.getLogger(__name__) + def timestamp(): """Get the current time. @@ -36,7 +45,9 @@ def timestamp(): return datetime.datetime.utcnow() -def geometry_to_geojson(geometry: Geometry, properties: str = {}, id: int = None): +def geometry_to_geojson( + geometry: WKBElement, properties: Optional[dict] = None, id: Optional[int] = None +) -> Union[Feature, dict]: """Convert SQLAlchemy geometry to GeoJSON.""" if geometry: shape = to_shape(geometry) @@ -51,7 +62,9 @@ def geometry_to_geojson(geometry: Geometry, properties: str = {}, id: int = None return {} -def get_centroid(geometry: Geometry, properties: str = {}, id: int = None): +def get_centroid( + geometry: WKBElement, properties: Optional[dict] = None, id: Optional[int] = None +): """Convert SQLAlchemy geometry to Centroid GeoJSON.""" if geometry: shape = to_shape(geometry) @@ -66,48 +79,408 @@ def get_centroid(geometry: Geometry, properties: str = {}, id: int = None): return {} -async def geojson_to_flatgeobuf(db: Session, geojson: FeatureCollection) -> bytes: +def geojson_to_geometry( + geojson: Union[FeatCol, Feature, Polygon], +) -> Optional[WKBElement]: + """Convert GeoJSON to SQLAlchemy geometry.""" + parsed_geojson = parse_and_filter_geojson(geojson.model_dump_json(), filter=False) + + if not parsed_geojson: + return None + + features = parsed_geojson.get("features", []) + + if len(features) > 1: + # TODO code to merge all geoms into multipolygon + # TODO do not use convex hull + pass + + geometry = features[0].get("geometry") + + shapely_geom = shape(geometry) + return from_shape(shapely_geom) + + +def read_wkb(wkb: WKBElement): + """Load a WKBElement and return a shapely geometry.""" + return to_shape(wkb) + + +def write_wkb(shape): + """Load shapely geometry and output WKBElement.""" + return from_shape(shape) + + +async def geojson_to_flatgeobuf( + db: Session, geojson: geojson.FeatureCollection +) -> Optional[bytes]: """From a given FeatureCollection, return a memory flatgeobuf obj. + NOTE this generate an fgb with string timestamps, not datetime. + NOTE ogr2ogr would generate datetime, but parsing does not seem to work. + Args: db (Session): SQLAlchemy db session. - geojson (FeatureCollection): a geojson.FeatureCollection object. + geojson (geojson.FeatureCollection): a FeatureCollection object. Returns: flatgeobuf (bytes): a Python bytes representation of a flatgeobuf file. """ - sql = f""" - DROP TABLE IF EXISTS public.temp_features CASCADE; + sql = """ + DROP TABLE IF EXISTS temp_features CASCADE; - CREATE TABLE IF NOT EXISTS public.temp_features( - id serial PRIMARY KEY, - geom geometry + -- Wrap geometries in GeometryCollection + CREATE TEMP TABLE IF NOT EXISTS temp_features( + geom geometry(GeometryCollection, 4326), + osm_id integer, + tags text, + version integer, + changeset integer, + timestamp text ); - WITH data AS (SELECT '{geojson}'::json AS fc) - INSERT INTO public.temp_features (geom) + WITH data AS (SELECT CAST(:geojson AS json) AS fc) + INSERT INTO temp_features + (geom, osm_id, tags, version, changeset, timestamp) SELECT - ST_AsText(ST_GeomFromGeoJSON(feat->>'geometry')) AS geom - FROM ( - SELECT json_array_elements(fc->'features') AS feat - FROM data - ) AS f; - - WITH thegeom AS - (SELECT * FROM public.temp_features) - SELECT ST_AsFlatGeobuf(thegeom.*) - FROM thegeom; + ST_ForceCollection(ST_GeomFromGeoJSON(feat->>'geometry')) AS geom, + (feat->'properties'->>'osm_id')::integer as osm_id, + (feat->'properties'->>'tags')::text as tags, + (feat->'properties'->>'version')::integer as version, + (feat->'properties'->>'changeset')::integer as changeset, + (feat->'properties'->>'timestamp')::text as timestamp + FROM json_array_elements((SELECT fc->'features' FROM data)) AS f(feat); + + -- Second param = generate with spatial index + SELECT ST_AsFlatGeobuf(geoms, true) + FROM (SELECT * FROM temp_features) AS geoms; """ + # Run the SQL - result = db.execute(text(sql)) + result = db.execute(text(sql), {"geojson": json.dumps(geojson)}) # Get a memoryview object, then extract to Bytes - flatgeobuf = result.fetchone()[0] - - # Cleanup table - db.execute(text("DROP TABLE IF EXISTS public.temp_features CASCADE;")) + flatgeobuf = result.first() if flatgeobuf: - return flatgeobuf.tobytes() + return flatgeobuf[0].tobytes() # Nothing returned (either no features passed, or failed) return None + + +async def flatgeobuf_to_geojson( + db: Session, flatgeobuf: bytes +) -> Optional[geojson.FeatureCollection]: + """Converts FlatGeobuf data to GeoJSON. + + Extracts single geometries from wrapped GeometryCollection if used. + + Args: + db (Session): SQLAlchemy db session. + flatgeobuf (bytes): FlatGeobuf data in bytes format. + + Returns: + geojson.FeatureCollection: A FeatureCollection object. + """ + sql = text( + """ + DROP TABLE IF EXISTS public.temp_fgb CASCADE; + + SELECT ST_FromFlatGeobufToTable('public', 'temp_fgb', :fgb_bytes); + + SELECT jsonb_build_object( + 'type', 'FeatureCollection', + 'features', jsonb_agg(feature) + ) AS feature_collection + FROM ( + SELECT jsonb_build_object( + 'type', 'Feature', + 'geometry', ST_AsGeoJSON(ST_GeometryN(fgb_data.geom, 1))::jsonb, + 'properties', jsonb_build_object( + 'osm_id', fgb_data.osm_id, + 'tags', fgb_data.tags, + 'version', fgb_data.version, + 'changeset', fgb_data.changeset, + 'timestamp', fgb_data.timestamp + )::jsonb + ) AS feature + FROM ( + SELECT + geom, + osm_id, + tags, + version, + changeset, + timestamp + FROM ST_FromFlatGeobuf(null::temp_fgb, :fgb_bytes) + ) AS fgb_data + ) AS features; + """ + ) + + try: + result = db.execute(sql, {"fgb_bytes": flatgeobuf}) + feature_collection = result.first() + except ProgrammingError as e: + log.error(e) + log.error( + "Attempted flatgeobuf --> geojson conversion failed. " + "Perhaps there is a duplicate 'id' column?" + ) + return None + + if feature_collection: + return geojson.loads(json.dumps(feature_collection[0])) + + return None + + +async def split_geojson_by_task_areas( + db: Session, + featcol: geojson.FeatureCollection, + project_id: int, +) -> Optional[dict[int, geojson.FeatureCollection]]: + """Split GeoJSON into tagged task area GeoJSONs. + + Args: + db (Session): SQLAlchemy db session. + featcol (bytes): Data extract feature collection. + project_id (int): The project ID for associated tasks. + + Returns: + dict[int, geojson.FeatureCollection]: {task_id: FeatureCollection} mapping. + """ + sql = text( + """ + -- Drop table if already exists + DROP TABLE IF EXISTS temp_features CASCADE; + + -- Create a temporary table to store the parsed GeoJSON features + CREATE TEMP TABLE temp_features ( + id SERIAL PRIMARY KEY, + geometry GEOMETRY, + properties JSONB + ); + + -- Insert parsed geometries and properties into the temporary table + INSERT INTO temp_features (geometry, properties) + SELECT + ST_SetSRID(ST_GeomFromGeoJSON(feature->>'geometry'), 4326) AS geometry, + jsonb_set( + jsonb_set(feature->'properties', '{task_id}', to_jsonb(tasks.id), true), + '{project_id}', to_jsonb(tasks.project_id), true + ) AS properties + FROM ( + SELECT jsonb_array_elements(CAST(:geojson_featcol AS jsonb)->'features') + AS feature + ) AS features + CROSS JOIN tasks + WHERE tasks.project_id = :project_id; + + -- Retrieve task outlines based on the provided project_id + WITH task_outlines AS ( + SELECT id, outline + FROM tasks + WHERE project_id = :project_id + ) + SELECT + task_outlines.id AS task_id, + jsonb_build_object( + 'type', 'FeatureCollection', + 'features', jsonb_agg(features.feature) + ) AS task_features + FROM + task_outlines + LEFT JOIN LATERAL ( + -- Construct a feature collection with geometries per task area + SELECT + jsonb_build_object( + 'type', 'Feature', + 'geometry', ST_AsGeoJSON(temp_features.geometry)::jsonb, + 'properties', temp_features.properties + ) AS feature + FROM + temp_features + WHERE + ST_Within(temp_features.geometry, task_outlines.outline) + ) AS features ON true + GROUP BY + task_outlines.id; + """ + ) + + try: + result = db.execute( + sql, + { + "geojson_featcol": json.dumps(featcol), + "project_id": project_id, + }, + ) + feature_collections = result.all() + + except ProgrammingError as e: + log.error(e) + log.error("Attempted geojson task splitting failed") + return None + + if feature_collections: + task_geojson_dict = { + record[0]: geojson.loads(json.dumps(record[1])) + for record in feature_collections + } + return task_geojson_dict + + return None + + +def parse_and_filter_geojson( + geojson_str: str, filter: bool = True +) -> Optional[geojson.FeatureCollection]: + """Parse geojson string and filter out incomaptible geometries.""" + geojson_parsed = geojson.loads(geojson_str) + + if isinstance(geojson_parsed, geojson.FeatureCollection): + log.debug("Already in FeatureCollection format, skipping reparse") + featcol = geojson_parsed + elif isinstance(geojson_parsed, geojson.Feature): + log.debug("Converting Feature to FeatureCollection") + featcol = geojson.FeatureCollection(features=[geojson_parsed]) + else: + log.debug("Converting Geometry to FeatureCollection") + featcol = geojson.FeatureCollection( + features=[geojson.Feature(geometry=geojson_parsed)] + ) + + # Exit early if no geoms + if not (features := featcol.get("features", [])): + return None + + # Strip out GeometryCollection wrappers + for feat in features: + geom = feat.get("geometry") + if ( + geom.get("type") == "GeometryCollection" + and len(geom.get("geometries")) == 1 + ): + feat["geometry"] = geom.get("geometries")[0] + + # Return unfiltered featcol + if not filter: + return featcol + + # Filter out geoms not matching main type + geom_type = get_featcol_main_geom_type(featcol) + features_filtered = [ + feature + for feature in features + if feature.get("geometry", {}).get("type", "") == geom_type + ] + + return geojson.FeatureCollection(features_filtered) + + +def get_featcol_main_geom_type(featcol: geojson.FeatureCollection) -> str: + """Get the predominant geometry type in a FeatureCollection.""" + geometry_counts = {"Polygon": 0, "Point": 0, "Polyline": 0} + + for feature in featcol.get("features", []): + geometry_type = feature.get("geometry", {}).get("type", "") + if geometry_type in geometry_counts: + geometry_counts[geometry_type] += 1 + + return max(geometry_counts, key=geometry_counts.get) + + +async def check_crs(input_geojson: Union[dict, geojson.FeatureCollection]): + """Validate CRS is valid for a geojson.""" + log.debug("validating coordinate reference system") + + def is_valid_crs(crs_name): + valid_crs_list = [ + "urn:ogc:def:crs:OGC:1.3:CRS84", + "urn:ogc:def:crs:EPSG::4326", + "WGS 84", + ] + return crs_name in valid_crs_list + + def is_valid_coordinate(coord): + if coord is None: + return False + return -180 <= coord[0] <= 180 and -90 <= coord[1] <= 90 + + error_message = ( + "ERROR: Unsupported coordinate system, it is recommended to use a " + "GeoJSON file in WGS84(EPSG 4326) standard." + ) + if "crs" in input_geojson: + crs = input_geojson.get("crs", {}).get("properties", {}).get("name") + if not is_valid_crs(crs): + log.error(error_message) + raise HTTPException(status_code=400, detail=error_message) + return + + if (input_geojson_type := input_geojson.get("type")) == "FeatureCollection": + features = input_geojson.get("features", []) + log.warning(features[-1]) + coordinates = ( + features[-1].get("geometry", {}).get("coordinates", []) if features else [] + ) + log.warning(coordinates) + elif input_geojson_type == "Feature": + coordinates = input_geojson.get("geometry", {}).get("coordinates", []) + else: + coordinates = input_geojson.get("coordinates", {}) + + first_coordinate = None + if coordinates: + while isinstance(coordinates, list): + first_coordinate = coordinates + coordinates = coordinates[0] + + if not is_valid_coordinate(first_coordinate): + log.error(error_message) + raise HTTPException(status_code=400, detail=error_message) + + +def get_address_from_lat_lon(latitude, longitude): + """Get address using Nominatim, using lat,lon.""" + base_url = "https://nominatim.openstreetmap.org/reverse" + + params = { + "format": "json", + "lat": latitude, + "lon": longitude, + "zoom": 18, + } + headers = {"Accept-Language": "en"} # Set the language to English + + log.debug("Getting Nominatim address from project centroid") + response = requests.get(base_url, params=params, headers=headers) + if (status_code := response.status_code) != 200: + log.error(f"Getting address string failed: {status_code}") + return None + + data = response.json() + log.debug(f"Nominatim response: {data}") + + address = data.get("address", None) + if not address: + log.error(f"Getting address string failed: {status_code}") + return None + + country = address.get("country", "") + city = address.get("city", "") + + address_str = f"{city},{country}" + + if not address_str or address_str == ",": + log.error("Getting address string failed") + return None + + return address_str + + +async def get_address_from_lat_lon_async(latitude, longitude): + """Async wrapper for get_address_from_lat_lon.""" + return get_address_from_lat_lon(latitude, longitude) diff --git a/src/backend/app/main.py b/src/backend/app/main.py index 071106caed..d206619f43 100644 --- a/src/backend/app/main.py +++ b/src/backend/app/main.py @@ -34,7 +34,9 @@ from app.central import central_routes from app.config import settings from app.db.database import get_db +from app.models.enums import HTTPStatus from app.organisations import organisation_routes +from app.organisations.organisation_crud import init_admin_org from app.projects import project_routes from app.projects.project_crud import read_xlsforms from app.submissions import submission_routes @@ -53,8 +55,11 @@ async def lifespan(app: FastAPI): """FastAPI startup/shutdown event.""" log.debug("Starting up FastAPI server.") + db_conn = next(get_db()) + log.debug("Initialising admin org and user in DB.") + await init_admin_org(db_conn) log.debug("Reading XLSForms from DB.") - await read_xlsforms(next(get_db()), xlsforms_path) + await read_xlsforms(db_conn, xlsforms_path) yield @@ -186,13 +191,14 @@ async def profile_request(request: Request, call_next): @api.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError): """Exception handler for more descriptive logging.""" + status_code = 500 errors = [] for error in exc.errors(): # TODO Handle this properly if error["msg"] in ["Invalid input", "field required"]: - status_code = 422 # Unprocessable Entity + status_code = HTTPStatus.UNPROCESSABLE_ENTITY else: - status_code = 400 # Bad Request + status_code = HTTPStatus.BAD_REQUEST errors.append( { "loc": error["loc"], diff --git a/src/backend/app/models/enums.py b/src/backend/app/models/enums.py index 0b0525f5be..01defea55f 100644 --- a/src/backend/app/models/enums.py +++ b/src/backend/app/models/enums.py @@ -286,3 +286,13 @@ class ProjectVisibility(IntEnum, Enum): PUBLIC = 0 PRIVATE = 1 INVITE_ONLY = 2 + + +class CommunityType(IntEnum, Enum): + """Enum describing community type.""" + + OSM_COMMUNITY = 0 + COMPANY = 1 + NON_PROFIT = 2 + UNIVERSITY = 3 + OTHER = 4 diff --git a/src/backend/app/organisations/organisation_crud.py b/src/backend/app/organisations/organisation_crud.py index 36e6ca649d..be5cdbf094 100644 --- a/src/backend/app/organisations/organisation_crud.py +++ b/src/backend/app/organisations/organisation_crud.py @@ -18,37 +18,182 @@ """Logic for organisation management.""" from io import BytesIO +from typing import Optional -from fastapi import HTTPException, Response, UploadFile +from fastapi import File, HTTPException, Response, UploadFile from loguru import logger as log -from sqlalchemy import update +from sqlalchemy import text, update from sqlalchemy.orm import Session from app.auth.osm import AuthUser -from app.auth.roles import check_super_admin -from app.config import settings +from app.config import encrypt_value, settings from app.db import db_models from app.models.enums import HTTPStatus from app.organisations.organisation_deps import ( + check_org_exists, get_organisation_by_name, ) from app.organisations.organisation_schemas import OrganisationEdit, OrganisationIn from app.s3 import add_obj_to_bucket -async def get_organisations(db: Session, current_user: AuthUser, is_approved: bool): - """Get all orgs.""" - super_admin = await check_super_admin(db, current_user) +async def init_admin_org(db: Session): + """Init admin org and user at application startup.""" + sql = text( + """ + -- Start a transaction + BEGIN; + + -- Insert FMTM Public Beta organisation + INSERT INTO public.organisations ( + name, + slug, + logo, + description, + url, + type, + approved, + odk_central_url, + odk_central_user, + odk_central_password + ) + VALUES ( + 'FMTM Public Beta', + 'fmtm-public-beta', + 'https://avatars.githubusercontent.com/u/458752?s=280&v=4', + 'HOTOSM Public Beta for FMTM.', + 'https://hotosm.org', + 'FREE', + true, + :odk_url, + :odk_user, + :odk_pass + ) + ON CONFLICT ("name") DO NOTHING; + + -- Insert svcfmtm admin user + INSERT INTO public.users ( + id, + username, + role, + name, + email_address, + is_email_verified, + mapping_level, + tasks_mapped, + tasks_validated, + tasks_invalidated + ) + VALUES ( + :user_id, + :username, + 'ADMIN', + 'Admin', + :odk_user, + true, + 'ADVANCED', + 0, + 0, + 0 + ) + ON CONFLICT ("username") DO NOTHING; + + -- Set svcfmtm user as org admin + WITH org_cte AS ( + SELECT id FROM public.organisations + WHERE name = 'FMTM Public Beta' + ) + INSERT INTO public.organisation_managers (organisation_id, user_id) + SELECT (SELECT id FROM org_cte), :user_id + ON CONFLICT DO NOTHING; + + -- Commit the transaction + COMMIT; + """ + ) + + db.execute( + sql, + { + "user_id": 20386219, + "username": "svcfmtm", + "odk_url": settings.ODK_CENTRAL_URL, + "odk_user": settings.ODK_CENTRAL_USER, + "odk_pass": encrypt_value(settings.ODK_CENTRAL_PASSWD), + }, + ) + + +async def get_organisations( + db: Session, + current_user: AuthUser, +): + """Get all orgs. + + Also returns unapproved orgs if admin user. + """ + user_id = current_user.id + + sql = text( + """ + SELECT * + FROM organisations + WHERE + CASE + WHEN (SELECT role FROM users WHERE id = :user_id) = 'ADMIN' THEN TRUE + ELSE approved + END = TRUE; + """ + ) + return db.execute(sql, {"user_id": user_id}).all() + + +async def get_my_organisations( + db: Session, + current_user: AuthUser, +): + """Get organisations filtered by the current user. + + TODO add extra UNION for all associated projects to user. + + Args: + db (Session): The database session. + current_user (AuthUser): The current user. - if super_admin: - return db.query(db_models.DbOrganisation).filter_by(approved=is_approved).all() + Returns: + list[dict]: A list of organisation objects to be serialised. + """ + user_id = current_user.id + + sql = text( + """ + SELECT DISTINCT org.* + FROM organisations org + JOIN organisation_managers managers + ON managers.organisation_id = org.id + WHERE managers.user_id = :user_id + + UNION + + SELECT DISTINCT org.* + FROM organisations org + JOIN projects project + ON project.organisation_id = org.id + WHERE project.author_id = :user_id; + """ + ) + return db.execute(sql, {"user_id": user_id}).all() - # If user not admin, only show approved orgs - return db.query(db_models.DbOrganisation).filter_by(approved=True).all() + +async def get_unapproved_organisations( + db: Session, +) -> list[db_models.DbOrganisation]: + """Get unapproved orgs.""" + return db.query(db_models.DbOrganisation).filter_by(approved=False) async def upload_logo_to_s3( - db_org: db_models.DbOrganisation, logo_file: UploadFile(None) + db_org: db_models.DbOrganisation, logo_file: UploadFile ) -> str: """Upload logo using standardised /{org_id}/logo.png format. @@ -80,7 +225,10 @@ async def upload_logo_to_s3( async def create_organisation( - db: Session, org_model: OrganisationIn, logo: UploadFile(None) + db: Session, + org_model: OrganisationIn, + current_user: AuthUser, + logo: Optional[UploadFile] = File(None), ) -> db_models.DbOrganisation: """Creates a new organisation with the given name, description, url, type, and logo. @@ -91,6 +239,7 @@ async def create_organisation( org_model (OrganisationIn): Pydantic model for organisation input. logo (UploadFile, optional): logo file of the organisation. Defaults to File(...). + current_user: logged in user. Returns: DbOrganisation: SQLAlchemy Organisation model. @@ -98,7 +247,7 @@ async def create_organisation( if await get_organisation_by_name(db, org_name=org_model.name): raise HTTPException( status_code=HTTPStatus.CONFLICT, - detail=f"Organisation already exists with the name {org_model.name}", + detail=f"Organisation already exists with the name ({org_model.name})", ) # Required to check if exists on error @@ -107,6 +256,7 @@ async def create_organisation( try: # Create new organisation without logo set db_organisation = db_models.DbOrganisation(**org_model.model_dump()) + db_organisation.user_id = current_user.id db.add(db_organisation) db.commit() @@ -194,38 +344,64 @@ async def delete_organisation( return Response(status_code=HTTPStatus.NO_CONTENT) -async def add_organisation_admin( - db: Session, user: db_models.DbUser, organisation: db_models.DbOrganisation -): +async def add_organisation_admin(db: Session, org_id: int, user_id: int): """Adds a user as an admin to the specified organisation. Args: db (Session): The database session. - user (DbUser): The user model instance. - organisation (DbOrganisation): The organisation model instance. + org_id (int): The organisation ID. + user_id (int): The user ID to add as manager. Returns: Response: The HTTP response with status code 200. """ - log.info(f"Adding user ({user.id}) as org ({organisation.id}) admin") - # add data to the managers field in organisation model - organisation.managers.append(user) + log.info(f"Adding user ({user_id}) as org ({org_id}) admin") + sql = text( + """ + INSERT INTO public.organisation_managers + (organisation_id, user_id) VALUES (:org_id, :user_id) + ON CONFLICT DO NOTHING; + """ + ) + + db.execute( + sql, + { + "org_id": org_id, + "user_id": user_id, + }, + ) + db.commit() return Response(status_code=HTTPStatus.OK) -async def approve_organisation(db, organisation): +async def approve_organisation(db, org_id: int): """Approves an oranisation request made by the user . Args: db: The database session. - organisation (DbOrganisation): The organisation model instance. + org_id (int): The organisation ID. Returns: Response: An HTTP response with the status code 200. """ - log.info(f"Approving organisation ID {organisation.id}") - organisation.approved = True + org_obj = await check_org_exists(db, org_id, check_approved=False) + + org_obj.approved = True db.commit() - return Response(status_code=HTTPStatus.OK) + + return org_obj + + +async def get_unapproved_org_detail(db, org_id): + """Returns detail of an unapproved organisation. + + Args: + db: The database session. + org_id: ID of unapproved organisation. + """ + return ( + db.query(db_models.DbOrganisation).filter_by(approved=False, id=org_id).first() + ) diff --git a/src/backend/app/organisations/organisation_deps.py b/src/backend/app/organisations/organisation_deps.py index 36c147302d..f2d18a1fde 100644 --- a/src/backend/app/organisations/organisation_deps.py +++ b/src/backend/app/organisations/organisation_deps.py @@ -23,13 +23,12 @@ from fastapi import Depends from fastapi.exceptions import HTTPException from loguru import logger as log -from sqlalchemy import func from sqlalchemy.orm import Session from app.db.database import get_db from app.db.db_models import DbOrganisation, DbProject from app.models.enums import HTTPStatus -from app.projects import project_deps +from app.projects import project_deps, project_schemas async def get_organisation_by_name( @@ -45,16 +44,20 @@ async def get_organisation_by_name( Returns: DbOrganisation: organisation with the given id """ - org_obj = ( - db.query(DbOrganisation) - .filter(func.lower(DbOrganisation.name).like(func.lower(f"%{org_name}%"))) - .first() - ) + # # For getting org with LIKE match + # org_obj = ( + # db.query(DbOrganisation) + # .filter(func.lower(DbOrganisation.name).like(func.lower(f"%{org_name}%"))) + # .first() + # ) + org_obj = db.query(DbOrganisation).filter_by(name=org_name).first() + if org_obj and check_approved and org_obj.approved is False: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, + status_code=HTTPStatus.FORBIDDEN, detail=f"Organisation ({org_obj.id}) is not approved yet", ) + return org_obj @@ -72,23 +75,51 @@ async def get_organisation_by_id( DbOrganisation: organisation with the given id """ org_obj = db.query(DbOrganisation).filter_by(id=org_id).first() + if org_obj and check_approved and org_obj.approved is False: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"Organisation {org_id} is not approved yet", + status_code=HTTPStatus.FORBIDDEN, + detail=f"Organisation ({org_id}) is not approved yet", ) return org_obj +async def get_org_odk_creds( + org: DbOrganisation, +) -> project_schemas.ODKCentralDecrypted: + """Get odk credentials for an organisation, else error.""" + url = org.odk_central_url + user = org.odk_central_user + password = org.odk_central_password + + if not all([url, user, password]): + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Organisation does not have ODK Central credentials configured", + ) + + return project_schemas.ODKCentralDecrypted( + odk_central_url=org.odk_central_url, + odk_central_user=org.odk_central_user, + odk_central_password=org.odk_central_password, + ) + + async def check_org_exists( db: Session, - org_id: Union[str, int], + org_id: Union[str, int, None], check_approved: bool = True, ) -> DbOrganisation: """Check if organisation name exists, else error. The org_id can also be an org name. """ + if not org_id: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Organisation id not provided", + ) + try: org_id = int(org_id) except ValueError: @@ -98,14 +129,14 @@ async def check_org_exists( log.debug(f"Getting organisation by id: {org_id}") db_organisation = await get_organisation_by_id(db, org_id, check_approved) - if isinstance(org_id, str): + else: # is string log.debug(f"Getting organisation by name: {org_id}") db_organisation = await get_organisation_by_name(db, org_id, check_approved) if not db_organisation: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, - detail=f"Organisation {org_id} does not exist", + detail=f"Organisation ({org_id}) does not exist", ) log.debug(f"Organisation match: {db_organisation}") diff --git a/src/backend/app/organisations/organisation_routes.py b/src/backend/app/organisations/organisation_routes.py index 6b5000f004..5b44b2f97f 100644 --- a/src/backend/app/organisations/organisation_routes.py +++ b/src/backend/app/organisations/organisation_routes.py @@ -17,6 +17,8 @@ # """Routes for organisation management.""" +from typing import Optional + from fastapi import ( APIRouter, Depends, @@ -30,7 +32,7 @@ from app.db import database from app.db.db_models import DbOrganisation, DbUser from app.organisations import organisation_crud, organisation_schemas -from app.organisations.organisation_deps import check_org_exists, org_exists +from app.organisations.organisation_deps import org_exists from app.users.user_deps import user_exists_in_db router = APIRouter( @@ -44,16 +46,45 @@ async def get_organisations( db: Session = Depends(database.get_db), current_user: AuthUser = Depends(login_required), - approved: bool = True, -) -> list[organisation_schemas.OrganisationOut]: +) -> list[DbOrganisation]: + """Get a list of all organisations.""" + return await organisation_crud.get_organisations(db, current_user) + + +@router.get( + "/my-organisations", response_model=list[organisation_schemas.OrganisationOut] +) +async def get_my_organisations( + db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(login_required), +) -> list[DbOrganisation]: + """Get a list of all organisations.""" + return await organisation_crud.get_my_organisations(db, current_user) + + +@router.get("/unapproved/", response_model=list[organisation_schemas.OrganisationOut]) +async def list_unapproved_organisations( + db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(super_admin), +) -> list[DbOrganisation]: """Get a list of all organisations.""" - return await organisation_crud.get_organisations(db, current_user, approved) + return await organisation_crud.get_unapproved_organisations(db) + + +@router.get("/unapproved/{org_id}", response_model=organisation_schemas.OrganisationOut) +async def unapproved_org_detail( + org_id: int, + db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(super_admin), +): + """Get a detail of an unapproved organisations.""" + return await organisation_crud.get_unapproved_org_detail(db, org_id) @router.get("/{org_id}", response_model=organisation_schemas.OrganisationOut) async def get_organisation_detail( organisation: DbOrganisation = Depends(org_exists), - db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(login_required), ): """Get a specific organisation by id or name.""" return organisation @@ -61,12 +92,18 @@ async def get_organisation_detail( @router.post("/", response_model=organisation_schemas.OrganisationOut) async def create_organisation( + # Depends required below to allow logo upload org: organisation_schemas.OrganisationIn = Depends(), - logo: UploadFile = File(None), + logo: Optional[UploadFile] = File(None), db: Session = Depends(database.get_db), + current_user: DbUser = Depends(login_required), ) -> organisation_schemas.OrganisationOut: - """Create an organisation with the given details.""" - return await organisation_crud.create_organisation(db, org, logo) + """Create an organisation with the given details. + + TODO refactor to use base64 encoded logo / no upload file. + TODO then we can use the pydantic model as intended. + """ + return await organisation_crud.create_organisation(db, org, current_user, logo) @router.patch("/{org_id}/", response_model=organisation_schemas.OrganisationOut) @@ -75,6 +112,7 @@ async def update_organisation( logo: UploadFile = File(None), organisation: DbOrganisation = Depends(org_exists), db: Session = Depends(database.get_db), + org_user_dict: DbUser = Depends(org_admin), ): """Partial update for an existing organisation.""" return await organisation_crud.update_organisation( @@ -83,37 +121,60 @@ async def update_organisation( @router.delete("/{org_id}") -async def delete_organisations( - organisation: DbOrganisation = Depends(org_exists), +async def delete_org( db: Session = Depends(database.get_db), + org_user_dict: DbUser = Depends(org_admin), ): """Delete an organisation.""" + return await organisation_crud.delete_organisation(db, org_user_dict["org"]) + + +@router.delete("/unapproved/{org_id}") +async def delete_unapproved_org( + org_id: int, + db: Session = Depends(database.get_db), + current_user: DbUser = Depends(super_admin), +): + """Delete an unapproved organisation. + + ADMIN ONLY ENDPOINT. + """ + organisation = db.query(DbOrganisation).filter(DbOrganisation.id == org_id).first() return await organisation_crud.delete_organisation(db, organisation) -@router.post("/approve/") +@router.post("/approve/", response_model=organisation_schemas.OrganisationOut) async def approve_organisation( org_id: int, db: Session = Depends(database.get_db), - current_user: AuthUser = Depends(super_admin), + current_user: DbUser = Depends(super_admin), ): """Approve the organisation request made by the user. The logged in user must be super admin to perform this action . """ - org_obj = await check_org_exists(db, org_id, check_approved=False) - return await organisation_crud.approve_organisation(db, org_obj) + approved_org = await organisation_crud.approve_organisation(db, org_id) + + # Set organisation requester as organisation manager + if approved_org.created_by: + await organisation_crud.add_organisation_admin( + db, approved_org.id, approved_org.created_by + ) + + return approved_org @router.post("/add_admin/") async def add_new_organisation_admin( db: Session = Depends(database.get_db), - organisation: DbOrganisation = Depends(org_exists), user: DbUser = Depends(user_exists_in_db), - current_user: AuthUser = Depends(org_admin), + org: DbOrganisation = Depends(org_exists), + org_user_dict: DbUser = Depends(org_admin), ): """Add a new organisation admin. The logged in user must be either the owner of the organisation or a super admin. """ - return await organisation_crud.add_organisation_admin(db, user, organisation) + # NOTE extracting the org this way means org_id is not a mandatory URL param + # org_id = org_user_dict["organisation"].id + return await organisation_crud.add_organisation_admin(db, org.id, user.id) diff --git a/src/backend/app/organisations/organisation_schemas.py b/src/backend/app/organisations/organisation_schemas.py index adf9dc6bca..b1c7f97df0 100644 --- a/src/backend/app/organisations/organisation_schemas.py +++ b/src/backend/app/organisations/organisation_schemas.py @@ -18,48 +18,34 @@ """Pydantic models for Organisations.""" from re import sub -from typing import Optional +from typing import Optional, Union from fastapi import Form -from pydantic import BaseModel, Field, HttpUrl, SecretStr, computed_field +from pydantic import BaseModel, Field, computed_field from pydantic.functional_validators import field_validator -from app.config import decrypt_value, encrypt_value -from app.models.enums import OrganisationType +from app.config import HttpUrlStr +from app.models.enums import CommunityType, OrganisationType +from app.projects.project_schemas import ODKCentralIn # class OrganisationBase(BaseModel): # """Base model for organisation to extend.""" -class OrganisationIn(BaseModel): +class OrganisationIn(ODKCentralIn): """Organisation to create from user input.""" name: str = Field(Form(..., description="Organisation name")) description: Optional[str] = Field( Form(None, description="Organisation description") ) - url: Optional[HttpUrl] = Field(Form(None, description=("Organisation website URL"))) - odk_central_url: Optional[str] = Field( - Form(None, description="Organisation default ODK URL") + url: Optional[HttpUrlStr] = Field( + Form(None, description=("Organisation website URL")) ) - odk_central_user: Optional[str] = Field( - Form(None, description="Organisation default ODK User") - ) - odk_central_password: Optional[SecretStr] = Field( - Form(None, description="Organisation default ODK Password") + community_type: Optional[CommunityType] = Field( + Form(None, description=("Organisation community type")) ) - @field_validator("url", mode="after") - @classmethod - def convert_url_to_str(cls, value: HttpUrl) -> str: - """Convert Pydantic Url type to string. - - Database models do not accept type Url for a string field. - """ - if value: - return value.unicode_string() - return "" - @computed_field @property def slug(self) -> str: @@ -72,20 +58,34 @@ def slug(self) -> str: slug = sub(r"[-\s]+", "-", slug) return slug - @field_validator("odk_central_password", mode="before") - @classmethod - def encrypt_odk_password(cls, value: str) -> Optional[SecretStr]: - """Encrypt the ODK Central password before db insertion.""" - if not value: - return None - return SecretStr(encrypt_value(value)) + # TODO replace once computed logo complete below + odk_central_url: Optional[HttpUrlStr] = Field( + Form(None, description=("ODK Central URL")) + ) + odk_central_user: Optional[str] = Field( + Form(None, description=("ODK Central User")) + ) + odk_central_password: Optional[str] = Field( + Form(None, description=("ODK Central Password")) + ) + + # TODO decode base64 logo and upload in computed property + # @computed_field + # @property + # def logo(self) -> Optional[str]: + # """Decode and upload logo to S3, return URL.""" + # if not self.logo_base64: + # return None + # logo_decoded = base64.b64decode(self.logo_base64) + # # upload to S3 + # return url class OrganisationEdit(OrganisationIn): """Organisation to edit via user input.""" # Override to make name optional - name: Optional[str] = Field(Form(None, description="Organisation name")) + name: Optional[str] = None class OrganisationOut(BaseModel): @@ -93,27 +93,18 @@ class OrganisationOut(BaseModel): id: int name: str + approved: bool + type: Union[OrganisationType, str] logo: Optional[str] description: Optional[str] slug: Optional[str] url: Optional[str] - type: OrganisationType - odk_central_url: Optional[str] = None - + odk_central_url: Optional[str] -class OrganisationOutWithCreds(BaseModel): - """Organisation plus ODK Central credentials. - - Note: the password is obsfucated as SecretStr. - """ - - odk_central_user: Optional[str] = None - odk_central_password: Optional[SecretStr] = None - - def model_post_init(self, ctx): - """Run logic after model object instantiated.""" - # Decrypt odk central password from database - if self.odk_central_password: - self.odk_central_password = SecretStr( - decrypt_value(self.odk_central_password.get_secret_value()) - ) + @field_validator("type", mode="before") + @classmethod + def parse_enum_string(cls, value: Union[str, OrganisationType]) -> OrganisationType: + """If a string value is used, parsed as Enum.""" + if isinstance(value, OrganisationType): + return value + return OrganisationType[value] diff --git a/src/backend/app/projects/project_crud.py b/src/backend/app/projects/project_crud.py index c1c22824c9..a5db99a6a5 100644 --- a/src/backend/app/projects/project_crud.py +++ b/src/backend/app/projects/project_crud.py @@ -19,7 +19,6 @@ import json import os -import time import uuid from asyncio import gather from concurrent.futures import ThreadPoolExecutor, wait @@ -32,27 +31,23 @@ import requests import shapely.wkb as wkblib import sozipfile.sozipfile as zipfile -import sqlalchemy from asgiref.sync import async_to_sync -from fastapi import File, HTTPException, UploadFile +from fastapi import File, HTTPException, Response, UploadFile from fastapi.concurrency import run_in_threadpool from fmtm_splitter.splitter import split_by_sql, split_by_square -from geoalchemy2.shape import from_shape, to_shape +from geoalchemy2.shape import to_shape from geojson.feature import Feature, FeatureCollection from loguru import logger as log from osm_fieldwork.basemapper import create_basemap_file -from osm_fieldwork.data_models import data_models_path -from osm_fieldwork.filter_data import FilterData from osm_fieldwork.json2osm import json2osm from osm_fieldwork.OdkCentral import OdkAppUser from osm_fieldwork.xlsforms import xlsforms_path from osm_rawdata.postgres import PostgresClient -from shapely import to_geojson, wkt +from shapely import wkt from shapely.geometry import ( Polygon, shape, ) -from shapely.ops import unary_union from sqlalchemy import and_, column, func, inspect, select, table, text from sqlalchemy.dialects.postgresql import insert from sqlalchemy.orm import Session @@ -61,9 +56,18 @@ from app.config import encrypt_value, settings from app.db import db_models from app.db.database import get_db -from app.db.postgis_utils import geojson_to_flatgeobuf, geometry_to_geojson -from app.models.enums import HTTPStatus -from app.projects import project_schemas +from app.db.postgis_utils import ( + check_crs, + flatgeobuf_to_geojson, + geojson_to_flatgeobuf, + geometry_to_geojson, + get_address_from_lat_lon_async, + get_featcol_main_geom_type, + parse_and_filter_geojson, + split_geojson_by_task_areas, +) +from app.models.enums import HTTPStatus, ProjectRole +from app.projects import project_deps, project_schemas from app.s3 import add_obj_to_bucket, get_obj_from_bucket from app.tasks import tasks_crud from app.users import user_crud @@ -76,8 +80,8 @@ async def get_projects( user_id: int, skip: int = 0, limit: int = 100, - hashtags: List[str] = None, - search: str = None, + hashtags: Optional[List[str]] = None, + search: Optional[str] = None, ): """Get all projects.""" filters = [] @@ -85,16 +89,20 @@ async def get_projects( filters.append(db_models.DbProject.author_id == user_id) if hashtags: - filters.append(db_models.DbProject.hashtags.op("&&")(hashtags)) + filters.append(db_models.DbProject.hashtags.op("&&")(hashtags)) # type: ignore if search: - filters.append(db_models.DbProject.project_name_prefix.ilike(f"%{search}%")) + filters.append( + db_models.DbProject.project_name_prefix.ilike( # type: ignore + f"%{search}%" + ) + ) if len(filters) > 0: db_projects = ( db.query(db_models.DbProject) .filter(and_(*filters)) - .order_by(db_models.DbProject.id.desc()) + .order_by(db_models.DbProject.id.desc()) # type: ignore .offset(skip) .limit(limit) .all() @@ -104,7 +112,7 @@ async def get_projects( else: db_projects = ( db.query(db_models.DbProject) - .order_by(db_models.DbProject.id.desc()) + .order_by(db_models.DbProject.id.desc()) # type: ignore .offset(skip) .limit(limit) .all() @@ -118,8 +126,8 @@ async def get_project_summaries( user_id: int, skip: int = 0, limit: int = 100, - hashtags: str = None, - search: str = None, + hashtags: Optional[List[str]] = None, + search: Optional[str] = None, ): """Get project summary details for main page.""" project_count, db_projects = await get_projects( @@ -135,6 +143,11 @@ async def get_project(db: Session, project_id: int): .filter(db_models.DbProject.id == project_id) .first() ) + if not db_project: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Project with id {project_id} does not exist", + ) return db_project @@ -188,13 +201,20 @@ async def partial_update_project_info( db_project_info = await get_project_info_by_id(db, project_id) # Update project informations - if project_metadata.name: - db_project.project_name_prefix = project_metadata.name - db_project_info.name = project_metadata.name - if project_metadata.description: - db_project_info.description = project_metadata.description - if project_metadata.short_description: - db_project_info.short_description = project_metadata.short_description + if db_project and db_project_info: + if project_metadata.name: + db_project.project_name_prefix = project_metadata.name + db_project_info.name = project_metadata.name + if project_metadata.description: + db_project_info.description = project_metadata.description + if project_metadata.short_description: + db_project_info.short_description = project_metadata.short_description + if project_metadata.per_task_instructions: + db_project_info.per_task_instructions = ( + project_metadata.per_task_instructions + ) + if project_metadata.hashtags: + db_project.hashtags = project_metadata.hashtags db.commit() db.refresh(db_project) @@ -203,23 +223,18 @@ async def partial_update_project_info( async def update_project_info( - db: Session, project_metadata: project_schemas.ProjectUpload, project_id + db: Session, + project_metadata: project_schemas.ProjectUpdate, + project_id: int, + db_user: db_models.DbUser, ): """Full project update for PUT.""" - user = project_metadata.author project_info = project_metadata.project_info - # verify data coming in - if not user: - raise HTTPException("No user passed in") if not project_info: - raise HTTPException("No project info passed in") - - # get db user - db_user = await user_crud.get_user(db, user.id) - if not db_user: raise HTTPException( - status_code=400, detail=f"User {user.username} does not exist" + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + detail="No project info passed in", ) # verify project exists in db @@ -233,16 +248,17 @@ async def update_project_info( project_info = project_metadata.project_info # Update author of the project - db_project.author = db_user + db_project.author_id = db_user.id db_project.project_name_prefix = project_info.name # get project info db_project_info = await get_project_info_by_id(db, project_id) # Update projects meta informations (name, descriptions) - db_project_info.name = project_info.name - db_project_info.short_description = project_info.short_description - db_project_info.description = project_info.description + if db_project and db_project_info: + db_project_info.name = project_info.name + db_project_info.short_description = project_info.short_description + db_project_info.description = project_info.description db.commit() db.refresh(db_project) @@ -251,35 +267,12 @@ async def update_project_info( async def create_project_with_project_info( - db: Session, project_metadata: project_schemas.ProjectUpload, odk_project_id: int + db: Session, + project_metadata: project_schemas.ProjectUpload, + odk_project_id: int, + current_user: db_models.DbUser, ): """Create a new project, including all associated info.""" - # FIXME the ProjectUpload model should be converted to the db model directly - # FIXME we don't need to extract each variable and pass manually - # project_data = project_metadata.model_dump() - - project_user = project_metadata.author - project_info = project_metadata.project_info - xform_title = project_metadata.xform_title - odk_credentials = project_metadata.odk_central - hashtags = project_metadata.hashtags - organisation_id = project_metadata.organisation_id - task_split_type = project_metadata.task_split_type - task_split_dimension = project_metadata.task_split_dimension - task_num_buildings = project_metadata.task_num_buildings - data_extract_type = project_metadata.task_num_buildings - - # verify data coming in - if not project_user: - raise HTTPException( - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - detail="User details are missing", - ) - if not project_info: - raise HTTPException( - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - detail="Project info is missing", - ) if not odk_project_id: raise HTTPException( status_code=HTTPStatus.UNPROCESSABLE_ENTITY, @@ -288,71 +281,35 @@ async def create_project_with_project_info( log.debug( "Creating project in FMTM database with vars: " - f"project_user: {project_user} | " - f"project_info: {project_info} | " - f"xform_title: {xform_title} | " - f"hashtags: {hashtags}| " - f"organisation_id: {organisation_id}" + f"project_user: {current_user.username} | " + f"project_name: {project_metadata.project_info.name} | " + f"xform_title: {project_metadata.xform_title} | " + f"hashtags: {project_metadata.hashtags} | " + f"organisation_id: {project_metadata.organisation_id}" ) - # Check / set credentials - if odk_credentials: - url = odk_credentials.odk_central_url - user = odk_credentials.odk_central_user - pw = odk_credentials.odk_central_password.get_secret_value() + # Extract project_info details, then remove key + project_name = project_metadata.project_info.name + project_description = project_metadata.project_info.description + project_short_description = project_metadata.project_info.short_description + project_instructions = project_metadata.project_info.per_task_instructions - else: - log.debug("ODKCentral connection variables not set in function") - log.debug("Attempting extraction from environment variables") - url = settings.ODK_CENTRAL_URL - user = settings.ODK_CENTRAL_USER - pw = settings.ODK_CENTRAL_PASSWD - - # get db user - # TODO: get this from logged in user / request instead, - # return 403 (forbidden) if not authorized - db_user = await user_crud.get_user(db, project_user.id) - if not db_user: - raise HTTPException( - status_code=400, detail=f"User {project_user.username} does not exist" - ) - - hashtags = ( - list( - map( - lambda hashtag: hashtag if hashtag.startswith("#") else f"#{hashtag}", - hashtags, - ) - ) - if hashtags - else None - ) # create new project db_project = db_models.DbProject( - author=db_user, + author_id=current_user.id, odkid=odk_project_id, - project_name_prefix=project_info.name, - xform_title=xform_title, - odk_central_url=url, - odk_central_user=user, - odk_central_password=pw, - hashtags=hashtags, - organisation_id=organisation_id, - task_split_type=task_split_type, - task_split_dimension=task_split_dimension, - task_num_buildings=task_num_buildings, - data_extract_type=data_extract_type, - # country=[project_metadata.country], - # location_str=f"{project_metadata.city}, {project_metadata.country}", + project_name_prefix=project_name, + **project_metadata.model_dump(exclude=["project_info", "outline_geojson"]), ) db.add(db_project) # add project info (project id needed to create project info) db_project_info = db_models.DbProjectInfo( project=db_project, - name=project_info.name, - short_description=project_info.short_description, - description=project_info.description, + name=project_name, + short_description=project_short_description, + description=project_description, + per_task_instructions=project_instructions, ) db.add(db_project_info) @@ -388,42 +345,26 @@ async def upload_xlsform( return True except Exception as e: log.exception(e) - raise HTTPException(status=400, detail={"message": str(e)}) from e + raise HTTPException(status_code=400, detail={"message": str(e)}) from e -async def update_multi_polygon_project_boundary( +async def create_tasks_from_geojson( db: Session, project_id: int, - boundary: str, + boundaries: str, ): - """Update the boundary for a project & update tasks. - - TODO requires refactoring, as it has too large of - a scope. It should update a project boundary only, then manage - tasks in another function. - - This function receives the project_id and boundary as a parameter - and creates a task for each polygon in the database. - This function also creates a project outline from the multiple - polygons received. - """ + """Create tasks for a project, from provided task boundaries.""" try: - if isinstance(boundary, str): - boundary = json.loads(boundary) - - # verify project exists in db - db_project = await get_project_by_id(db, project_id) - if not db_project: - log.error(f"Project {project_id} doesn't exist!") - return False + if isinstance(boundaries, str): + boundaries = json.loads(boundaries) # Update the boundary polyon on the database. - if boundary["type"] == "Feature": - polygons = [boundary] + if boundaries["type"] == "Feature": + polygons = [boundaries] else: - polygons = boundary["features"] + polygons = boundaries["features"] log.debug(f"Processing {len(polygons)} task geometries") - for polygon in polygons: + for index, polygon in enumerate(polygons): # If the polygon is a MultiPolygon, convert it to a Polygon if polygon["geometry"]["type"] == "MultiPolygon": log.debug("Converting MultiPolygon to Polygon") @@ -432,49 +373,21 @@ async def update_multi_polygon_project_boundary( 0 ] - # def remove_z_dimension(coord): - # """Helper to remove z dimension. - - # To be used in lambda, to remove z dimension from - # each coordinate in the feature's geometry. - # """ - # return coord.pop() if len(coord) == 3 else None - - # # Apply the lambda function to each coordinate in its geometry - # list(map(remove_z_dimension, polygon["geometry"]["coordinates"][0])) - db_task = db_models.DbTask( project_id=project_id, outline=wkblib.dumps(shape(polygon["geometry"]), hex=True), - project_task_index=1, + project_task_index=index, ) db.add(db_task) - db.commit() - - # Id is passed in the task_name too - db_task.project_task_name = str(db_task.id) log.debug( "Created database task | " f"Project ID {project_id} | " - f"Task ID {db_task.project_task_name}" + f"Task index {index}" ) - db.commit() - - # Generate project outline from tasks - query = text( - f"""SELECT ST_AsText(ST_ConvexHull(ST_Collect(outline))) - FROM tasks - WHERE project_id={project_id};""" - ) - - log.debug("Generating project outline from tasks") - result = db.execute(query) - data = result.fetchone() - - await update_project_location_info(db_project, data[0]) + # Commit all tasks and update project location in db db.commit() - db.refresh(db_project) + log.debug("COMPLETE: creating project boundary, based on task boundaries") return True @@ -536,153 +449,66 @@ def remove_z_dimension(coord): ) -async def get_data_extract_from_osm_rawdata( - aoi: UploadFile, - category: str, -): - """Get data extract using OSM RawData module. - - Filters by a specific category. - """ - try: - # read entire file - aoi_content = await aoi.read() - boundary = json.loads(aoi_content) - - # Validatiing Coordinate Reference System - check_crs(boundary) - - # Get pre-configured filter for category - config_path = f"{data_models_path}/{category}.yaml" - - if boundary["type"] == "FeatureCollection": - # Convert each feature into a Shapely geometry - geometries = [ - shape(feature["geometry"]) for feature in boundary["features"] - ] - updated_geometry = unary_union(geometries) - else: - updated_geometry = shape(boundary["geometry"]) - - # Convert the merged MultiPolygon to a single Polygon using convex hull - merged_polygon = updated_geometry.convex_hull - - # Convert the merged polygon back to a GeoJSON-like dictionary - boundary = { - "type": "Feature", - "geometry": to_geojson(merged_polygon), - "properties": {}, - } - - # # OSM Extracts using raw data api - pg = PostgresClient("underpass", config_path) - data_extract = pg.execQuery(boundary) - return data_extract - except Exception as e: - log.error(e) - raise HTTPException(status_code=400, detail=str(e)) from e - - -async def get_data_extract_url( - db: Session, +async def generate_data_extract( aoi: Union[FeatureCollection, Feature, dict], - project_id: Optional[int] = None, + extract_config: Optional[BytesIO] = None, ) -> str: - """Request an extract from raw-data-api and extract the file contents. + """Request a new data extract in flatgeobuf format. - - The query is posted to raw-data-api and job initiated for fetching the extract. - - The status of the job is polled every few seconds, until 'SUCCESS' is returned. - - The resulting flatgeobuf file is streamed in the frontend. + Args: + db (Session): + Database session. + aoi (Union[FeatureCollection, Feature, dict]): + Area of interest for data extraction. + extract_config (Optional[BytesIO], optional): + Configuration for data extraction. Defaults to None. + + Raises: + HTTPException: + When necessary parameters are missing or data extraction fails. Returns: - str: the URL for the flatgeobuf data extract. + str: + URL for the flatgeobuf data extract. """ - if project_id: - db_project = await get_project_by_id(db, project_id) - if not db_project: - log.error(f"Project {project_id} doesn't exist!") - return False - - # TODO update db field data_extract_type --> data_extract_url - fgb_url = db_project.data_extract_type - - # If extract already exists, return url to it - if fgb_url: - return fgb_url - - # FIXME replace below with get_data_extract_from_osm_rawdata - - # Data extract does not exist, continue to create - # Filters for osm extracts - query = { - "filters": { - "tags": { - "all_geometry": { - "join_or": {"building": [], "highway": [], "waterway": []} - } - } - } - } + if not extract_config: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="To generate a new data extract a form_category must be specified.", + ) + + pg = PostgresClient( + "underpass", + extract_config, + # auth_token=settings.OSM_SVC_ACCOUNT_TOKEN, + ) + fgb_url = pg.execQuery( + aoi, + extra_params={ + "fileName": "fmtm_extract", + "outputType": "fgb", + "bind_zip": False, + "useStWithin": False, + "fgb_wrap_geoms": True, + }, + ) + + if not fgb_url: + msg = "Could not get download URL for data extract. Did the API change?" + log.error(msg) + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + detail=msg, + ) - if (geom_type := aoi.get("type")) == "FeatureCollection": - # Convert each feature into a Shapely geometry - geometries = [ - shape(feature.get("geometry")) for feature in aoi.get("features", []) - ] - merged_geom = unary_union(geometries) - elif geom_type == "Feature": - merged_geom = shape(aoi.get("geometry")) - else: - merged_geom = shape(aoi) - # Convert the merged geoms to a single Polygon GeoJSON using convex hull - query["geometry"] = json.loads(to_geojson(merged_geom.convex_hull)) - - # Filename to generate - # query["fileName"] = f"fmtm-project-{project_id}-extract" - query["fileName"] = "fmtm-extract" - # Output to flatgeobuf format - query["outputType"] = "fgb" - # Generate without zipping - query["bind_zip"] = False - # Optional authentication - # headers["access-token"] = settings.OSM_SVC_ACCOUNT_TOKEN - - log.debug(f"Query for raw data api: {query}") - base_url = settings.UNDERPASS_API_URL - query_url = f"{base_url}/snapshot/" - headers = {"accept": "application/json", "Content-Type": "application/json"} - - # Send the request to raw data api - try: - result = requests.post(query_url, data=json.dumps(query), headers=headers) - result.raise_for_status() - except requests.exceptions.HTTPError: - error_dict = result.json() - error_dict["status_code"] = result.status_code - log.error(f"Failed to get extract from raw data api: {error_dict}") - return error_dict - - task_id = result.json()["task_id"] - - # Check status of task (PENDING, or SUCCESS) - task_url = f"{base_url}/tasks/status/{task_id}" - while True: - result = requests.get(task_url, headers=headers) - if result.json()["status"] == "PENDING": - # Wait 2 seconds before polling again - time.sleep(2) - elif result.json()["status"] == "SUCCESS": - break - - fgb_url = result.json()["result"]["download_url"] return fgb_url async def split_geojson_into_tasks( db: Session, project_geojson: Union[dict, FeatureCollection], - extract_geojson: Union[dict, FeatureCollection], no_of_buildings: int, + extract_geojson: Optional[FeatureCollection] = None, ): """Splits a project into tasks. @@ -692,6 +518,9 @@ async def split_geojson_into_tasks( boundary. extract_geojson (Union[dict, FeatureCollection]): A GeoJSON of the project boundary osm data extract (features). + extract_geojson (FeatureCollection): A GeoJSON of the project + boundary osm data extract (features). + If not included, an extract is generated automatically. no_of_buildings (int): The number of buildings to include in each task. Returns: @@ -713,7 +542,11 @@ async def split_geojson_into_tasks( async def update_project_boundary( db: Session, project_id: int, boundary: str, meters: int ): - """Update the boundary for a project and update tasks.""" + """Update the boundary for a project and update tasks. + + TODO this needs a big refactor or removal + # + """ # verify project exists in db db_project = await get_project_by_id(db, project_id) if not db_project: @@ -760,7 +593,12 @@ def remove_z_dimension(coord): else: outline = shape(features[0]["geometry"]) - await update_project_location_info(db_project, outline.wkt) + centroid = (wkt.loads(outline.wkt)).centroid.wkt + db_project.centroid = centroid + geometry = wkt.loads(centroid) + longitude, latitude = geometry.x, geometry.y + address = await get_address_from_lat_lon_async(latitude, longitude) + db_project.location_str = address if address is not None else "" db.commit() db.refresh(db_project) @@ -771,24 +609,19 @@ def remove_z_dimension(coord): boundary, meters=meters, ) - for poly in tasks["features"]: - log.debug(poly) - task_id = str(poly.get("properties", {}).get("id") or poly.get("id")) + for index, poly in enumerate(tasks["features"]): db_task = db_models.DbTask( project_id=project_id, - project_task_name=task_id, outline=wkblib.dumps(shape(poly["geometry"]), hex=True), # qr_code=db_qr, # qr_code_id=db_qr.id, # project_task_index=feature["properties"]["fid"], - project_task_index=1, + project_task_index=index, # geometry_geojson=geojson.dumps(task_geojson), - # initial_feature_count=len(task_geojson["features"]), + # feature_count=len(task_geojson["features"]), ) db.add(db_task) db.commit() - - # FIXME: write to tasks table return True @@ -957,7 +790,7 @@ def remove_z_dimension(coord): # qr_code_id=db_qr.id, # outline=task_outline_shape.wkt, # # geometry_geojson=json.dumps(task_geojson), -# initial_feature_count=len(task_geojson["features"]), +# feature_count=len(task_geojson["features"]), # ) # db.add(task) @@ -1057,17 +890,74 @@ async def get_odk_id_for_project(db: Session, project_id: int): return project_info.odkid -async def upload_custom_data_extract( +async def get_or_set_data_extract_url( db: Session, project_id: int, - geojson_str: str, + url: Optional[str], +) -> str: + """Get or set the data extract URL for a project. + + Args: + db (Session): SQLAlchemy database session. + project_id (int): The ID of the project. + url (str): URL to the streamable flatgeobuf data extract. + If not passed, a new extract is generated. + + Returns: + str: URL to fgb file in S3. + """ + db_project = await get_project_by_id(db, project_id) + if not db_project: + msg = f"Project ({project_id}) not found" + log.error(msg) + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=msg) + + # If url, get extract + # If not url, get new extract / set in db + if not url: + existing_url = db_project.data_extract_url + + if not existing_url: + msg = ( + f"No data extract exists for project ({project_id}). " + "To generate one, call 'projects/generate-data-extract/'" + ) + log.error(msg) + raise HTTPException(status_code=HTTPStatus.UNPROCESSABLE_ENTITY, detail=msg) + return existing_url + + # FIXME Identify data extract type from form type + # FIXME use mapping e.g. building=polygon, waterways=line, etc + extract_type = "polygon" + + await update_data_extract_url_in_db(db, db_project, url, extract_type) + + return url + + +async def update_data_extract_url_in_db( + db: Session, project: db_models.DbProject, url: str, extract_type: str +): + """Update the data extract params in the database for a project.""" + log.debug(f"Setting data extract URL for project ({project.id}): {url}") + project.data_extract_url = url + project.data_extract_type = extract_type + db.commit() + + +async def upload_custom_extract_to_s3( + db: Session, + project_id: int, + fgb_content: bytes, + data_extract_type: str, ) -> str: """Uploads custom data extracts to S3. Args: db (Session): SQLAlchemy database session. project_id (int): The ID of the project. - geojson_str (str): The custom data extracts contents. + fgb_content (bytes): Content of read flatgeobuf file. + data_extract_type (str): centroid/polygon/line for database. Returns: str: URL to fgb file in S3. @@ -1078,52 +968,7 @@ async def upload_custom_data_extract( if not project: raise HTTPException(status_code=404, detail="Project not found") - log.debug("Parsing geojson file") - geojson_parsed = geojson.loads(geojson_str) - if isinstance(geojson_parsed, FeatureCollection): - log.debug("Already in FeatureCollection format, skipping reparse") - featcol = geojson_parsed - elif isinstance(geojson_parsed, Feature): - log.debug("Converting Feature to FeatureCollection") - featcol = FeatureCollection(geojson_parsed) - else: - log.debug("Converting geometry to FeatureCollection") - featcol = FeatureCollection[Feature(geometry=geojson_parsed)] - - # Validating Coordinate Reference System - check_crs(featcol) - - # FIXME use osm-fieldwork filter/clean data - # cleaned = FilterData() - # models = xlsforms_path.replace("xlsforms", "data_models") - # xlsfile = f"{category}.xls" # FIXME: for custom form - # file = f"{xlsforms_path}/{xlsfile}" - # if os.path.exists(file): - # title, extract = cleaned.parse(file) - # elif os.path.exists(f"{file}x"): - # title, extract = cleaned.parse(f"{file}x") - # # Remove anything in the data extract not in the choices sheet. - # cleaned_data = cleaned.cleanData(features_data) - feature_type = featcol.get("features", [])[-1].get("geometry", {}).get("type") - if feature_type not in ["Polygon", "Polyline"]: - msg = ( - "Extract does not contain valid geometry types, from 'Polygon' " - "and 'Polyline'" - ) - log.error(msg) - raise HTTPException(status_code=404, detail=msg) - features_filtered = [ - feature - for feature in featcol.get("features", []) - if feature.get("geometry", {}).get("type", "") == feature_type - ] - featcol_filtered = FeatureCollection(features_filtered) - - log.debug( - "Generating fgb object from geojson with " - f"{len(featcol_filtered.get('features', []))} features" - ) - fgb_obj = BytesIO(await geojson_to_flatgeobuf(db, featcol_filtered)) + fgb_obj = BytesIO(fgb_content) s3_fgb_path = f"/{project.organisation_id}/{project_id}/custom_extract.fgb" log.debug(f"Uploading fgb to S3 path: {s3_fgb_path}") @@ -1134,13 +979,111 @@ async def upload_custom_data_extract( content_type="application/octet-stream", ) - # Add url to database - s3_fgb_url = f"{settings.S3_DOWNLOAD_ROOT}/{settings.S3_BUCKET_NAME}{s3_fgb_path}" - log.debug(f"Commiting extract S3 path to database: {s3_fgb_url}") - project.data_extract_type = s3_fgb_url - db.commit() + # Add url and type to database + s3_fgb_full_url = ( + f"{settings.S3_DOWNLOAD_ROOT}/{settings.S3_BUCKET_NAME}{s3_fgb_path}" + ) + + await update_data_extract_url_in_db(db, project, s3_fgb_full_url, data_extract_type) + + return s3_fgb_full_url + + +async def upload_custom_fgb_extract( + db: Session, + project_id: int, + fgb_content: bytes, +) -> str: + """Upload a flatgeobuf data extract. - return s3_fgb_url + Args: + db (Session): SQLAlchemy database session. + project_id (int): The ID of the project. + fgb_content (bytes): Content of read flatgeobuf file. + + Returns: + str: URL to fgb file in S3. + """ + featcol = await flatgeobuf_to_geojson(db, fgb_content) + + if not featcol: + msg = f"Failed extracting geojson from flatgeobuf for project ({project_id})" + log.error(msg) + raise HTTPException(status_code=HTTPStatus.UNPROCESSABLE_ENTITY, detail=msg) + + data_extract_type = await get_data_extract_type(featcol) + + return await upload_custom_extract_to_s3( + db, + project_id, + fgb_content, + data_extract_type, + ) + + +async def get_data_extract_type(featcol: FeatureCollection) -> str: + """Determine predominant geometry type for extract.""" + geom_type = get_featcol_main_geom_type(featcol) + if geom_type not in ["Polygon", "Polyline", "Point"]: + msg = ( + "Extract does not contain valid geometry types, from 'Polygon' " + ", 'Polyline' and 'Point'." + ) + log.error(msg) + raise HTTPException(status_code=HTTPStatus.UNPROCESSABLE_ENTITY, detail=msg) + geom_name_map = { + "Polygon": "polygon", + "Point": "centroid", + "Polyline": "line", + } + data_extract_type = geom_name_map.get(geom_type, "polygon") + + return data_extract_type + + +async def upload_custom_geojson_extract( + db: Session, + project_id: int, + geojson_str: str, +) -> str: + """Upload a geojson data extract. + + Args: + db (Session): SQLAlchemy database session. + project_id (int): The ID of the project. + geojson_str (str): The custom data extracts contents. + + Returns: + str: URL to fgb file in S3. + """ + project = await get_project(db, project_id) + log.debug(f"Uploading custom data extract for project: {project}") + + featcol_filtered = parse_and_filter_geojson(geojson_str) + if not featcol_filtered: + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + detail="Could not process geojson input", + ) + + await check_crs(featcol_filtered) + + data_extract_type = await get_data_extract_type(featcol_filtered) + + log.debug( + "Generating fgb object from geojson with " + f"{len(featcol_filtered.get('features', []))} features" + ) + fgb_data = await geojson_to_flatgeobuf(db, featcol_filtered) + + if not fgb_data: + msg = f"Failed converting geojson to flatgeobuf for project ({project_id})" + log.error(msg) + raise HTTPException(status_code=HTTPStatus.UNPROCESSABLE_ENTITY, detail=msg) + + return await upload_custom_extract_to_s3( + db, project_id, fgb_data, data_extract_type + ) def flatten_dict(d, parent_key="", sep="_"): @@ -1169,9 +1112,10 @@ def generate_task_files( db: Session, project_id: int, task_id: int, + data_extract: FeatureCollection, xlsform: str, form_type: str, - odk_credentials: project_schemas.ODKCentral, + odk_credentials: project_schemas.ODKCentralDecrypted, ): """Generate all files for a task.""" project_log = log.bind(task="create_project", project_id=project_id) @@ -1194,7 +1138,7 @@ def generate_task_files( appuser = OdkAppUser( odk_credentials.odk_central_url, odk_credentials.odk_central_user, - odk_credentials.odk_central_password.get_secret_value(), + odk_credentials.odk_central_password, ) appuser_json = appuser.create(odk_id, appuser_name) @@ -1209,78 +1153,37 @@ def generate_task_files( get_task_sync = async_to_sync(tasks_crud.get_task) task = get_task_sync(db, task_id) task.odk_token = encrypt_value( - f"{odk_credentials.odk_central_url}/key/{appuser_token}/projects/{odk_id}" + f"{odk_credentials.odk_central_url}/v1/key/{appuser_token}/projects/{odk_id}" ) - db.commit() - db.refresh(task) # This file will store xml contents of an xls form. xform = f"/tmp/{appuser_name}.xml" - extracts = f"/tmp/{appuser_name}.geojson" # This file will store osm extracts # xform_id_format xform_id = f"{appuser_name}".split("_")[2] - # Get the features for this task. - # Postgis query to filter task inside this task outline and of this project - # Update those features and set task_id - query = text( - f"""UPDATE features - SET task_id={task_id} - WHERE id IN ( - SELECT id - FROM features - WHERE project_id={project_id} - AND ST_IsValid(geometry) - AND ST_IsValid('{task.outline}'::Geometry) - AND ST_Contains('{task.outline}'::Geometry, ST_Centroid(geometry)) - )""" - ) - - result = db.execute(query) - - # Get the geojson of those features for this task. - query = text( - f"""SELECT jsonb_build_object( - 'type', 'FeatureCollection', - 'features', jsonb_agg(feature) - ) - FROM ( - SELECT jsonb_build_object( - 'type', 'Feature', - 'id', id, - 'geometry', ST_AsGeoJSON(geometry)::jsonb, - 'properties', properties - ) AS feature - FROM features - WHERE project_id={project_id} and task_id={task_id} - ) features;""" - ) - - result = db.execute(query) - - features = result.fetchone()[0] - - upload_media = False if features["features"] is None else True - - # Update outfile containing osm extracts with the new geojson contents - # containing title in the properties. - with open(extracts, "w") as jsonfile: - jsonfile.truncate(0) # clear the contents of the file - geojson.dump(features, jsonfile) + # Create memory object from split data extract + geojson_string = geojson.dumps(data_extract) + geojson_bytes = geojson_string.encode("utf-8") + geojson_bytesio = BytesIO(geojson_bytes) project_log.info( - f"Generating xform for task: {task_id} " + f"Generating xform for task: {task_id} | " f"using xform: {xform} | form_type: {form_type}" ) - outfile = central_crud.generate_updated_xform(xlsform, xform, form_type) + xform_path = central_crud.generate_updated_xform(xlsform, xform, form_type) # Create an odk xform - project_log.info(f"Uploading media in {task_id}") - result = central_crud.create_odk_xform( - odk_id, task_id, outfile, odk_credentials, False, upload_media + project_log.info(f"Uploading data extract media to task ({task_id})") + central_crud.create_odk_xform( + odk_id, + str(task_id), + xform_path, + geojson_bytesio, + odk_credentials, + False, ) - # result = central_crud.create_odk_xform(odk_id, task_id, outfile, odk_credentials) + task.feature_count = len(data_extract.get("features", [])) project_log.info(f"Updating role for app user in task {task_id}") # Update the user role for the created xform. @@ -1292,6 +1195,8 @@ def generate_task_files( log.exception(e) project.extract_completed_count += 1 + + # Commit db transaction db.commit() db.refresh(project) @@ -1299,14 +1204,12 @@ def generate_task_files( # NOTE defined as non-async to run in separate thread -def generate_appuser_files( +def generate_project_files( db: Session, project_id: int, - extract_polygon: bool, - custom_xls_form: str, - extracts_contents: str, - category: str, - form_type: str, + custom_form: Optional[BytesIO], + form_category: str, + form_format: str, background_task_id: Optional[uuid.UUID] = None, ): """Generate the files for a project. @@ -1316,173 +1219,101 @@ def generate_appuser_files( Parameters: - db: the database session - project_id: Project ID - - extract_polygon: boolean to determine if we should extract the polygon - - custom_xls_form: the xls file to upload if we have a custom form - - extracts_contents: the custom data extract - - category: the category of the project - - form_type: weather the form is xls, xlsx or xml + - custom_form: the xls file to upload if we have a custom form + - form_category: the category for the custom XLS form + - form_format: weather the form is xls, xlsx or xml - background_task_id: the task_id of the background task running this function. """ try: project_log = log.bind(task="create_project", project_id=project_id) - project_log.info(f"Starting generate_appuser_files for project {project_id}") - - # Get the project table contents. - project = table( - "projects", - column("project_name_prefix"), - column("xform_title"), - column("id"), - column("odk_central_url"), - column("odk_central_user"), - column("odk_central_password"), - column("outline"), - ) - - where = f"id={project_id}" - sql = select( - project.c.project_name_prefix, - project.c.xform_title, - project.c.id, - project.c.odk_central_url, - project.c.odk_central_user, - project.c.odk_central_password, - geoalchemy2.functions.ST_AsGeoJSON(project.c.outline).label("outline"), - ).where(text(where)) - result = db.execute(sql) - - # There should only be one match - if result.rowcount != 1: - log.warning(str(sql)) - if result.rowcount < 1: - raise HTTPException(status_code=400, detail="Project not found") - else: - raise HTTPException(status_code=400, detail="Multiple projects found") - - one = result.first() + project_log.info(f"Starting generate_project_files for project {project_id}") - if one: - # Get odk credentials from project. - odk_credentials = { - "odk_central_url": one.odk_central_url, - "odk_central_user": one.odk_central_user, - "odk_central_password": one.odk_central_password, - } + get_project_sync = async_to_sync(get_project) + project = get_project_sync(db, project_id) + if not project: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Project with id {project_id} does not exist", + ) - odk_credentials = project_schemas.ODKCentral(**odk_credentials) + odk_sync = async_to_sync(project_deps.get_odk_credentials) + odk_credentials = odk_sync(db, project) - xform_title = one.xform_title if one.xform_title else None + if custom_form: + log.debug("User provided custom XLSForm") + # TODO uncomment after refactor to use BytesIO + # xlsform = custom_form - category = xform_title - if custom_xls_form: - xlsform = f"/tmp/{category}.{form_type}" - contents = custom_xls_form - with open(xlsform, "wb") as f: - f.write(contents) - else: - xlsform = f"{xlsforms_path}/{xform_title}.xls" + xlsform = f"/tmp/{form_category}.{form_format}" + with open(xlsform, "wb") as f: + f.write(custom_form.getvalue()) + else: + log.debug(f"Using default XLSForm for category {form_category}") + + # TODO uncomment after refactor to use BytesIO + # xlsform_path = f"{xlsforms_path}/{form_category}.xls" + # with open(xlsform_path, "rb") as f: + # xlsform = BytesIO(f.read()) + + xlsform = f"{xlsforms_path}/{form_category}.xls" + + # filter = FilterData(xlsform) + # updated_data_extract = {"type": "FeatureCollection", "features": []} + # filtered_data_extract = ( + # filter.cleanData(data_extract) + # if data_extract + # else updated_data_extract + # ) + + # Generating QR Code, XForm and uploading OSM Extracts to the form. + # Creating app users and updating the role of that user. + + # Extract data extract from flatgeobuf + feat_project_features_sync = async_to_sync(get_project_features_geojson) + feature_collection = feat_project_features_sync(db, project) + + # Split extract by task area + split_geojson_sync = async_to_sync(split_geojson_by_task_areas) + split_extract_dict = split_geojson_sync(db, feature_collection, project_id) + + if not split_extract_dict: + log.warning("Project ({project_id}) failed splitting tasks") + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + detail="Failed splitting extract by tasks.", + ) - # Data Extracts - if extracts_contents is not None: - project_log.info("Uploading data extracts") - upload_extract_sync = async_to_sync(upload_custom_data_extract) - upload_extract_sync(db, project_id, extracts_contents) + # Run with expensive task via threadpool + def wrap_generate_task_files(task_id): + """Func to wrap and return errors from thread. - else: - project = ( - db.query(db_models.DbProject) - .filter(db_models.DbProject.id == project_id) - .first() - ) - config_file_contents = project.form_config_file - - project_log.info("Extracting Data from OSM") - - config_path = "/tmp/config.yaml" - if config_file_contents: - with open(config_path, "w", encoding="utf-8") as config_file_handle: - config_file_handle.write(config_file_contents.decode("utf-8")) - else: - config_path = f"{data_models_path}/{category}.yaml" - - # # OSM Extracts for whole project - pg = PostgresClient("underpass", config_path) - outline = json.loads(one.outline) - boundary = {"type": "Feature", "properties": {}, "geometry": outline} - data_extract = pg.execQuery(boundary) - filter = FilterData(xlsform) - - updated_data_extract = {"type": "FeatureCollection", "features": []} - filtered_data_extract = ( - filter.cleanData(data_extract) - if data_extract - else updated_data_extract + Also passes it's own database session for thread safety. + If we pass a single db session to multiple threads, + there may be inconsistencies or errors. + """ + try: + generate_task_files( + next(get_db()), + project_id, + task_id, + split_extract_dict[task_id], + xlsform, + form_format, + odk_credentials, ) - - # Collect feature mappings for bulk insert - feature_mappings = [] - - for feature in filtered_data_extract["features"]: - # If the osm extracts contents do not have a title, - # provide an empty text for that. - feature["properties"]["title"] = "" - - feature_shape = shape(feature["geometry"]) - - # If the centroid of the Polygon is not inside the outline, - # skip the feature. - if extract_polygon and ( - not shape(outline).contains(shape(feature_shape.centroid)) - ): - continue - - wkb_element = from_shape(feature_shape, srid=4326) - feature_mapping = { - "project_id": project_id, - "category_title": category, - "geometry": wkb_element, - "properties": feature["properties"], - } - updated_data_extract["features"].append(feature) - feature_mappings.append(feature_mapping) - # Bulk insert the osm extracts into the db. - db.bulk_insert_mappings(db_models.DbFeatures, feature_mappings) - - # Generating QR Code, XForm and uploading OSM Extracts to the form. - # Creating app users and updating the role of that user. - get_task_id_list_sync = async_to_sync(tasks_crud.get_task_id_list) - task_list = get_task_id_list_sync(db, project_id) - - # Run with expensive task via threadpool - def wrap_generate_task_files(task): - """Func to wrap and return errors from thread. - - Also passes it's own database session for thread safety. - If we pass a single db session to multiple threads, - there may be inconsistencies or errors. - """ - try: - generate_task_files( - next(get_db()), - project_id, - task, - xlsform, - form_type, - odk_credentials, - ) - except Exception as e: - log.exception(str(e)) - - # Use a ThreadPoolExecutor to run the synchronous code in threads - with ThreadPoolExecutor() as executor: - # Submit tasks to the thread pool - futures = [ - executor.submit(wrap_generate_task_files, task) - for task in task_list - ] - # Wait for all tasks to complete - wait(futures) + except Exception as e: + log.exception(str(e)) + + # Use a ThreadPoolExecutor to run the synchronous code in threads + with ThreadPoolExecutor() as executor: + # Submit tasks to the thread pool + futures = [ + executor.submit(wrap_generate_task_files, task_id) + for task_id in split_extract_dict.keys() + ] + # Wait for all tasks to complete + wait(futures) if background_task_id: # Update background task status to COMPLETED @@ -1555,43 +1386,48 @@ async def get_task_geometry(db: Session, project_id: int): return json.dumps(feature_collection) -async def get_project_features_geojson(db: Session, project_id: int): +async def get_project_features_geojson( + db: Session, project: Union[db_models.DbProject, int] +) -> FeatureCollection: """Get a geojson of all features for a task.""" - db_features = ( - db.query(db_models.DbFeatures) - .filter(db_models.DbFeatures.project_id == project_id) - .all() - ) + if isinstance(project, int): + db_project = await get_project(db, project) + else: + db_project = project + project_id = db_project.id - query = text( - f"""SELECT jsonb_build_object( - 'type', 'FeatureCollection', - 'features', jsonb_agg(feature) - ) - FROM ( - SELECT jsonb_build_object( - 'type', 'Feature', - 'id', id, - 'geometry', ST_AsGeoJSON(geometry)::jsonb, - 'properties', properties - ) AS feature - FROM features - WHERE project_id={project_id} - ) features; - """ - ) + data_extract_url = db_project.data_extract_url - result = db.execute(query) - features = result.fetchone()[0] + if not data_extract_url: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"No data extract exists for project ({project_id})", + ) - # Create mapping feat_id:task_id - task_feature_mapping = {feat.id: feat.task_id for feat in db_features} + # If local debug URL, replace with Docker service name + data_extract_url = data_extract_url.replace( + settings.S3_DOWNLOAD_ROOT, + settings.S3_ENDPOINT, + ) - for feature in features["features"]: - if (feat_id := feature["id"]) in task_feature_mapping: - feature["properties"]["task_id"] = task_feature_mapping[feat_id] + with requests.get(data_extract_url) as response: + if not response.ok: + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + detail=f"Download failed for data extract, project ({project_id})", + ) + data_extract_geojson = await flatgeobuf_to_geojson(db, response.content) - return features + if not data_extract_geojson: + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + detail=( + "Failed to convert flatgeobuf --> geojson for " + f"project ({project_id})" + ), + ) + + return data_extract_geojson async def get_json_from_zip(zip, filename: str, error_detail: str): @@ -1615,7 +1451,7 @@ async def get_outline_from_geojson_file_in_zip( with zip.open(filename) as file: data = file.read() json_dump = json.loads(data) - check_crs(json_dump) # Validatiing Coordinate Reference System + await check_crs(json_dump) # Validatiing Coordinate Reference System feature_collection = FeatureCollection(json_dump) feature = feature_collection["features"][feature_index] geom = feature["geometry"] @@ -1659,7 +1495,7 @@ async def convert_to_app_project(db_project: db_models.DbProject): return None log.debug("Converting db project to app project") - app_project: project_schemas.Project = db_project + app_project = db_project if db_project.outline: log.debug("Converting project outline to geojson") @@ -1749,64 +1585,6 @@ async def convert_summary(project): return [] -async def convert_to_project_feature(db_project_feature: db_models.DbFeatures): - """Legacy function to convert db models --> Pydantic. - - TODO refactor to use Pydantic model methods instead. - """ - if db_project_feature: - app_project_feature: project_schemas.Feature = db_project_feature - - if db_project_feature.geometry: - app_project_feature.geometry = geometry_to_geojson( - db_project_feature.geometry, - db_project_feature.properties, - db_project_feature.id, - ) - - return app_project_feature - else: - return None - - -async def convert_to_project_features( - db_project_features: List[db_models.DbFeatures], -) -> List[project_schemas.Feature]: - """Legacy function to convert db models --> Pydantic. - - TODO refactor to use Pydantic model methods instead. - """ - if db_project_features and len(db_project_features) > 0: - - async def convert_feature(project_feature): - return await convert_to_project_feature(project_feature) - - app_project_features = await gather( - *[convert_feature(feature) for feature in db_project_features] - ) - return [feature for feature in app_project_features if feature is not None] - else: - return [] - - -async def get_project_features(db: Session, project_id: int, task_id: int = None): - """Get features from database for a project.""" - if task_id: - features = ( - db.query(db_models.DbFeatures) - .filter(db_models.DbFeatures.project_id == project_id) - .filter(db_models.DbFeatures.task_id == task_id) - .all() - ) - else: - features = ( - db.query(db_models.DbFeatures) - .filter(db_models.DbFeatures.project_id == project_id) - .all() - ) - return await convert_to_project_features(features) - - async def get_background_task_status(task_id: uuid.UUID, db: Session): """Get the status of a background task.""" task = ( @@ -1883,11 +1661,7 @@ async def update_project_form( odk_id = project.odkid # ODK Credentials - odk_credentials = project_schemas.ODKCentral( - odk_central_url=project.odk_central_url, - odk_central_user=project.odk_central_user, - odk_central_password=project.odk_central_password, - ) + odk_credentials = await project_deps.get_odk_credentials(db, project) if form: xlsform = f"/tmp/custom_form.{form_type}" @@ -1897,17 +1671,15 @@ async def update_project_form( else: xlsform = f"{xlsforms_path}/{category}.xls" - db.query(db_models.DbFeatures).filter( - db_models.DbFeatures.project_id == project_id - ).delete() - db.commit() - - # OSM Extracts for whole project + # TODO fix this to use correct data extract generation pg = PostgresClient("underpass") outfile = ( f"/tmp/{project_title}_{category}.geojson" # This file will store osm extracts ) + # FIXME test this works + # FIXME PostgresClient.getFeatures does not exist... + # FIXME getFeatures is part of the DataExtract osm-fieldwork class extract_polygon = True if project.data_extract_type == "polygon" else False project = table("projects", column("outline")) @@ -1921,112 +1693,34 @@ async def update_project_form( final_outline = json.loads(project_outline.outline) - outline_geojson = pg.getFeatures( + feature_geojson = pg.getFeatures( boundary=final_outline, filespec=outfile, polygon=extract_polygon, xlsfile=f"{category}.xls", category=category, ) - - updated_outline_geojson = {"type": "FeatureCollection", "features": []} - - # Collect feature mappings for bulk insert - feature_mappings = [] - - for feature in outline_geojson["features"]: - # If the osm extracts contents do not have a title, - # provide an empty text for that. - feature["properties"]["title"] = "" - - feature_shape = shape(feature["geometry"]) - - # # If the centroid of the Polygon is not inside the outline, - # skip the feature. - # if extract_polygon and ( - # not shape(outline_geojson).contains( - # shape(feature_shape.centroid - # )) - # ): - # continue - - wkb_element = from_shape(feature_shape, srid=4326) - feature_mapping = { - "project_id": project_id, - "category_title": category, - "geometry": wkb_element, - "properties": feature["properties"], - } - updated_outline_geojson["features"].append(feature) - feature_mappings.append(feature_mapping) - - # Insert features into db - db_feature = db_models.DbFeatures( - project_id=project_id, - category_title=category, - geometry=wkb_element, - properties=feature["properties"], - ) - db.add(db_feature) - db.commit() + # TODO upload data extract to S3 bucket tasks_list = await tasks_crud.get_task_id_list(db, project_id) for task in tasks_list: - task_obj = await tasks_crud.get_task(db, task) - - # Get the features for this task. - # Postgis query to filter task inside this task outline and of this project - # Update those features and set task_id - query = text( - f"""UPDATE features - SET task_id={task} - WHERE id in ( - SELECT id - FROM features - WHERE project_id={project_id} and - ST_Intersects(geometry, '{task_obj.outline}'::Geometry) - )""" - ) - - result = db.execute(query) - - # Get the geojson of those features for this task. - query = text( - f"""SELECT jsonb_build_object( - 'type', 'FeatureCollection', - 'features', jsonb_agg(feature) - ) - FROM ( - SELECT jsonb_build_object( - 'type', 'Feature', - 'id', id, - 'geometry', ST_AsGeoJSON(geometry)::jsonb, - 'properties', properties - ) AS feature - FROM features - WHERE project_id={project_id} and task_id={task} - ) features;""" - ) - - result = db.execute(query) - features = result.fetchone()[0] # This file will store xml contents of an xls form. xform = f"/tmp/{project_title}_{category}_{task}.xml" - # This file will store osm extracts extracts = f"/tmp/{project_title}_{category}_{task}.geojson" # Update outfile containing osm extracts with the new geojson contents # containing title in the properties. with open(extracts, "w") as jsonfile: jsonfile.truncate(0) # clear the contents of the file - geojson.dump(features, jsonfile) + geojson.dump(feature_geojson, jsonfile) outfile = central_crud.generate_updated_xform(xlsform, xform, form_type) # Create an odk xform + # TODO include data extract geojson correctly result = central_crud.create_odk_xform( - odk_id, task, xform, odk_credentials, True, True, False + odk_id, str(task), xform, feature_geojson, odk_credentials, True, False ) return True @@ -2196,106 +1890,6 @@ async def convert_geojson_to_osm(geojson_file: str): return json2osm(geojson_file) -async def get_address_from_lat_lon(latitude, longitude): - """Get address using Nominatim, using lat,lon.""" - base_url = "https://nominatim.openstreetmap.org/reverse" - - params = { - "format": "json", - "lat": latitude, - "lon": longitude, - "zoom": 18, - } - headers = {"Accept-Language": "en"} # Set the language to English - - response = requests.get(base_url, params=params, headers=headers) - data = response.json() - address = data["address"]["country"] - - if response.status_code == 200: - if "city" in data["address"]: - city = data["address"]["city"] - address = f"{city}" + "," + address - return address - else: - return "Address not found." - - -async def update_project_location_info( - db_project: sqlalchemy.orm.declarative_base, project_boundary: str -): - """Update project boundary, centroid, address. - - Args: - db_project(sqlalchemy.orm.declarative_base): The project database record. - project_boundary(str): WKT string geometry. - """ - db_project.outline = project_boundary - centroid = (wkt.loads(project_boundary)).centroid.wkt - db_project.centroid = centroid - geometry = wkt.loads(centroid) - longitude, latitude = geometry.x, geometry.y - address = await get_address_from_lat_lon(latitude, longitude) - db_project.location_str = address if address is not None else "" - - -def check_crs(input_geojson: Union[dict, FeatureCollection]): - """Validate CRS is valid for a geojson.""" - log.debug("validating coordinate reference system") - - def is_valid_crs(crs_name): - valid_crs_list = [ - "urn:ogc:def:crs:OGC:1.3:CRS84", - "urn:ogc:def:crs:EPSG::4326", - "WGS 84", - ] - return crs_name in valid_crs_list - - def is_valid_coordinate(coord): - if coord is None: - return False - return -180 <= coord[0] <= 180 and -90 <= coord[1] <= 90 - - error_message = ( - "ERROR: Unsupported coordinate system, it is recommended to use a " - "GeoJSON file in WGS84(EPSG 4326) standard." - ) - if "crs" in input_geojson: - crs = input_geojson.get("crs", {}).get("properties", {}).get("name") - if not is_valid_crs(crs): - log.error(error_message) - raise HTTPException(status_code=400, detail=error_message) - return - - if input_geojson_type := input_geojson.get("type") == "FeatureCollection": - features = input_geojson.get("features", []) - coordinates = ( - features[-1].get("geometry", {}).get("coordinates", []) if features else [] - ) - elif input_geojson_type := input_geojson.get("type") == "Feature": - coordinates = input_geojson.get("geometry", {}).get("coordinates", []) - - geometry_type = ( - features[0].get("geometry", {}).get("type") - if input_geojson_type == "FeatureCollection" and features - else input_geojson.get("geometry", {}).get("type", "") - ) - if geometry_type == "MultiPolygon": - first_coordinate = coordinates[0][0] if coordinates and coordinates[0] else None - elif geometry_type == "Point": - first_coordinate = coordinates if coordinates else None - - elif geometry_type == "LineString": - first_coordinate = coordinates[0] if coordinates else None - - else: - first_coordinate = coordinates[0][0] if coordinates else None - - if not is_valid_coordinate(first_coordinate): - log.error(error_message) - raise HTTPException(status_code=400, detail=error_message) - - async def get_tasks_count(db: Session, project_id: int): """Get number of tasks for a project.""" db_task = ( @@ -2425,3 +2019,29 @@ def count_user_contributions(db: Session, user_id: int, project_id: int) -> int: ) return contributions_count + + +async def add_project_admin( + db: Session, user: db_models.DbUser, project: db_models.DbProject +): + """Adds a user as an admin to the specified organisation. + + Args: + db (Session): The database session. + user (int): The ID of the user to be added as an admin. + project (DbOrganisation): The Project model instance. + + Returns: + Response: The HTTP response with status code 200. + """ + new_user_role = db_models.DbUserRoles( + user_id=user.id, + project_id=project.id, + role=ProjectRole.PROJECT_MANAGER, + ) + + # add data to the managers field in organisation model + project.roles.append(new_user_role) + db.commit() + + return Response(status_code=HTTPStatus.OK) diff --git a/src/backend/app/projects/project_deps.py b/src/backend/app/projects/project_deps.py index b7520246fd..5a7ebf4d8b 100644 --- a/src/backend/app/projects/project_deps.py +++ b/src/backend/app/projects/project_deps.py @@ -18,6 +18,8 @@ """Project dependencies for use in Depends.""" +from typing import Any, Optional, Union + from fastapi import Depends from fastapi.exceptions import HTTPException from sqlalchemy.orm import Session @@ -25,10 +27,11 @@ from app.db.database import get_db from app.db.db_models import DbProject from app.models.enums import HTTPStatus +from app.projects import project_crud, project_schemas async def get_project_by_id( - db: Session = Depends(get_db), project_id: int = None + db: Session = Depends(get_db), project_id: Optional[int] = None ) -> DbProject: """Get a single project by id.""" if not project_id: @@ -43,3 +46,19 @@ async def get_project_by_id( ) return db_project + + +async def get_odk_credentials(db: Session, project: Union[int, Any]): + """Get odk credentials of project.""" + if isinstance(project, int): + db_project = await project_crud.get_project(db, project) + else: + db_project = project + + odk_credentials = { + "odk_central_url": db_project.odk_central_url, + "odk_central_user": db_project.odk_central_user, + "odk_central_password": db_project.odk_central_password, + } + + return project_schemas.ODKCentralDecrypted(**odk_credentials) diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py index 0c0c3b4676..4f29830edf 100644 --- a/src/backend/app/projects/project_routes.py +++ b/src/backend/app/projects/project_routes.py @@ -20,6 +20,7 @@ import json import os import uuid +from io import BytesIO from pathlib import Path from typing import Optional @@ -37,21 +38,24 @@ ) from fastapi.responses import FileResponse, JSONResponse from loguru import logger as log +from osm_fieldwork.data_models import data_models_path from osm_fieldwork.make_data_extract import getChoices from osm_fieldwork.xlsforms import xlsforms_path from sqlalchemy.orm import Session from sqlalchemy.sql import text from app.auth.osm import AuthUser, login_required +from app.auth.roles import mapper, org_admin, project_admin, super_admin from app.central import central_crud from app.db import database, db_models +from app.db.postgis_utils import check_crs, parse_and_filter_geojson from app.models.enums import TILES_FORMATS, TILES_SOURCE, HTTPStatus from app.organisations import organisation_deps from app.projects import project_crud, project_deps, project_schemas -from app.projects.project_crud import check_crs from app.static import data_path from app.submissions import submission_crud from app.tasks import tasks_crud +from app.users.user_deps import user_exists_in_db router = APIRouter( prefix="/projects", @@ -72,49 +76,54 @@ async def read_projects( return projects -@router.get("/details/{project_id}/") -async def get_projet_details(project_id: int, db: Session = Depends(database.get_db)): - """Returns the project details. - - Also includes ODK project details, so takes extra time to return. - - Parameters: - project_id: int - - Returns: - Response: Project details. - """ - project = await project_crud.get_project(db, project_id) - if not project: - raise HTTPException(status_code=404, details={"Project not found"}) - - # ODK Credentials - odk_credentials = project_schemas.ODKCentral( - odk_central_url=project.odk_central_url, - odk_central_user=project.odk_central_user, - odk_central_password=project.odk_central_password, - ) - - odk_details = central_crud.get_odk_project_full_details( - project.odkid, odk_credentials - ) - - # Features count - query = text( - "select count(*) from features where " - f"project_id={project_id} and task_id is not null" - ) - result = db.execute(query) - features = result.fetchone()[0] - - return { - "id": project_id, - "odkName": odk_details["name"], - "createdAt": odk_details["createdAt"], - "tasks": odk_details["forms"], - "lastSubmission": odk_details["lastSubmission"], - "total_features": features, - } +# TODO delete me +# @router.get("/details/{project_id}/") +# async def get_projet_details( +# project_id: int, +# db: Session = Depends(database.get_db), +# current_user: AuthUser = Depends(mapper), +# ): +# """Returns the project details. + +# Also includes ODK project details, so takes extra time to return. + +# Parameters: +# project_id: int + +# Returns: +# Response: Project details. +# """ +# project = await project_crud.get_project(db, project_id) +# if not project: +# raise HTTPException(status_code=404, detail={"Project not found"}) + +# # ODK Credentials +# odk_credentials = project_schemas.ODKCentralDecrypted( +# odk_central_url=project.odk_central_url, +# odk_central_user=project.odk_central_user, +# odk_central_password=project.odk_central_password, +# ) + +# odk_details = central_crud.get_odk_project_full_details( +# project.odkid, odk_credentials +# ) + +# # Features count +# query = text( +# "select count(*) from features where " +# f"project_id={project_id} and task_id is not null" +# ) +# result = db.execute(query) +# features = result.fetchone()[0] + +# return { +# "id": project_id, +# "odkName": odk_details["name"], +# "createdAt": odk_details["createdAt"], +# "tasks": odk_details["forms"], +# "lastSubmission": odk_details["lastSubmission"], +# "total_features": features, +# } @router.post("/near_me", response_model=list[project_schemas.ProjectSummary]) @@ -214,20 +223,18 @@ async def read_project(project_id: int, db: Session = Depends(database.get_db)): @router.delete("/{project_id}") async def delete_project( - project: db_models.DbProject = Depends(project_deps.get_project_by_id), db: Session = Depends(database.get_db), - current_user: AuthUser = Depends(login_required), + org_user_dict: db_models.DbUser = Depends(org_admin), ): """Delete a project from both ODK Central and the local database.""" + project = org_user_dict.get("project") + log.info( - f"User {current_user.username} attempting deletion of project {project.id}" + f"User {org_user_dict.get('user').username} attempting " + f"deletion of project {project.id}" ) # Odk crendentials - odk_credentials = project_schemas.ODKCentral( - odk_central_url=project.odk_central_url, - odk_central_user=project.odk_central_user, - odk_central_password=project.odk_central_password, - ) + odk_credentials = await project_deps.get_odk_credentials(db, project) # Delete ODK Central project await central_crud.delete_odk_project(project.odkid, odk_credentials) # Delete FMTM project @@ -240,21 +247,52 @@ async def delete_project( @router.post("/create_project", response_model=project_schemas.ProjectOut) async def create_project( project_info: project_schemas.ProjectUpload, + org_user_dict: db_models.DbUser = Depends(org_admin), db: Session = Depends(database.get_db), ): """Create a project in ODK Central and the local database. + The org_id and project_id params are inherited from the org_admin permission. + Either param can be passed to determine if the user has admin permission + to the organisation (or organisation associated with a project). + TODO refactor to standard REST POST to /projects TODO but first check doesn't break other endpoints """ - log.debug(f"Creating project {project_info.project_info.name}") + db_user = org_user_dict.get("user") + db_org = org_user_dict.get("org") + project_info.organisation_id = db_org.id + + log.info( + f"User {db_user.username} attempting creation of project " + f"{project_info.project_info.name} in organisation ({db_org.id})" + ) + + # Must decrypt ODK password & connect to ODK Central before proj created + if project_info.odk_central_url: + odk_creds_decrypted = project_schemas.ODKCentralDecrypted( + odk_central_url=project_info.odk_central_url, + odk_central_user=project_info.odk_central_user, + odk_central_password=project_info.odk_central_password, + ) + else: + # Use default org credentials if none passed + log.debug( + "No odk credentials passed during project creation. " + "Defaulting to organisation credentials." + ) + odk_creds_decrypted = await organisation_deps.get_org_odk_creds(db_org) odkproject = central_crud.create_odk_project( - project_info.project_info.name, project_info.odk_central + project_info.project_info.name, + odk_creds_decrypted, ) project = await project_crud.create_project_with_project_info( - db, project_info, odkproject["id"] + db, + project_info, + odkproject["id"], + db_user, ) if not project: raise HTTPException(status_code=404, detail="Project creation failed") @@ -262,11 +300,12 @@ async def create_project( return project -@router.put("/{id}", response_model=project_schemas.ProjectOut) +@router.put("/{project_id}", response_model=project_schemas.ProjectOut) async def update_project( - id: int, - project_info: project_schemas.ProjectUpload, + project_id: int, + project_info: project_schemas.ProjectUpdate, db: Session = Depends(database.get_db), + current_user: db_models.DbUser = Depends(project_admin), ): """Update an existing project by ID. @@ -275,8 +314,8 @@ async def update_project( Parameters: - id: ID of the project to update - - author: Author username and id - project_info: Updated project information + - current_user (DbUser): Check if user is project_admin Returns: - Updated project information @@ -284,17 +323,20 @@ async def update_project( Raises: - HTTPException with 404 status code if project not found """ - project = await project_crud.update_project_info(db, project_info, id) + project = await project_crud.update_project_info( + db, project_info, project_id, current_user + ) if not project: raise HTTPException(status_code=422, detail="Project could not be updated") return project -@router.patch("/{id}", response_model=project_schemas.ProjectOut) +@router.patch("/{project_id}", response_model=project_schemas.ProjectOut) async def project_partial_update( - id: int, - project_info: project_schemas.ProjectUpdate, + project_id: int, + project_info: project_schemas.ProjectPartialUpdate, db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(project_admin), ): """Partial Update an existing project by ID. @@ -311,7 +353,9 @@ async def project_partial_update( - HTTPException with 404 status code if project not found """ # Update project informations - project = await project_crud.partial_update_project_info(db, project_info, id) + project = await project_crud.partial_update_project_info( + db, project_info, project_id + ) if not project: raise HTTPException(status_code=422, detail="Project could not be updated") @@ -323,6 +367,7 @@ async def upload_custom_xls( upload: UploadFile = File(...), category: str = Form(...), db: Session = Depends(database.get_db), + current_user: db_models.DbUser = Depends(super_admin), ): """Upload a custom XLSForm to the database. @@ -330,6 +375,7 @@ async def upload_custom_xls( upload (UploadFile): the XLSForm file category (str): the category of the XLSForm. db (Session): the DB session, provided automatically. + current_user (DbUser): Check if user is super_admin """ content = await upload.read() # read file content name = upload.filename.split(".")[0] # get name of file without extension @@ -339,40 +385,34 @@ async def upload_custom_xls( return {"xform_title": f"{category}"} -@router.post("/{project_id}/custom_task_boundaries") -async def upload_custom_task_boundaries( +@router.post("/{project_id}/upload-task-boundaries") +async def upload_project_task_boundaries( project_id: int, - project_geojson: UploadFile = File(...), + task_geojson: UploadFile = File(...), db: Session = Depends(database.get_db), + org_user_dict: db_models.DbUser = Depends(org_admin), ): - """Set project task boundaries manually using multi-polygon GeoJSON. + """Set project task boundaries using split GeoJSON from frontend. - Each polygon in the uploaded geojson are made a single task. + Each polygon in the uploaded geojson are made into single task. Required Parameters: project_id (id): ID for associated project. - project_geojson (UploadFile): Multi-polygon GeoJSON file. + task_geojson (UploadFile): Multi-polygon GeoJSON file. Returns: dict: JSON containing success message, project ID, and number of tasks. """ log.debug(f"Uploading project boundary multipolygon for project ID: {project_id}") # read entire file - content = await project_geojson.read() - boundary = json.loads(content) + content = await task_geojson.read() + task_boundaries = json.loads(content) # Validatiing Coordinate Reference System - check_crs(boundary) + await check_crs(task_boundaries) log.debug("Creating tasks for each polygon in project") - result = await project_crud.update_multi_polygon_project_boundary( - db, project_id, boundary - ) - - if not result: - raise HTTPException( - status_code=428, detail=f"Project with id {project_id} does not exist" - ) + await project_crud.create_tasks_from_geojson(db, project_id, task_boundaries) # Get the number of tasks in a project task_count = await tasks_crud.get_task_count_in_project(db, project_id) @@ -384,10 +424,10 @@ async def upload_custom_task_boundaries( } -@router.post("/task_split") +@router.post("/task-split") async def task_split( project_geojson: UploadFile = File(...), - extract_geojson: UploadFile = File(...), + extract_geojson: Optional[UploadFile] = File(None), no_of_buildings: int = Form(50), db: Session = Depends(database.get_db), ): @@ -396,8 +436,9 @@ async def task_split( Args: project_geojson (UploadFile): The geojson to split. Should be a FeatureCollection. - extract_geojson (UploadFile): Data extract geojson containing osm features. - Should be a FeatureCollection. + extract_geojson (UploadFile, optional): Custom data extract geojson + containing osm features (should be a FeatureCollection). + If not included, an extract is generated automatically. no_of_buildings (int, optional): The number of buildings per subtask. Defaults to 50. db (Session, optional): The database session. Injected by FastAPI. @@ -409,18 +450,23 @@ async def task_split( # read project boundary parsed_boundary = geojson.loads(await project_geojson.read()) # Validatiing Coordinate Reference Systems - check_crs(parsed_boundary) + await check_crs(parsed_boundary) # read data extract - parsed_extract = geojson.loads(await extract_geojson.read()) - - check_crs(parsed_extract) + parsed_extract = None + if extract_geojson: + geojson_data = json.dumps(json.loads(await extract_geojson.read())) + parsed_extract = parse_and_filter_geojson(geojson_data, filter=False) + if parsed_extract: + await check_crs(parsed_extract) + else: + log.warning("Parsed geojson file contained no geometries") return await project_crud.split_geojson_into_tasks( db, parsed_boundary, - parsed_extract, no_of_buildings, + parsed_extract, ) @@ -430,6 +476,7 @@ async def upload_project_boundary( boundary_geojson: UploadFile = File(...), dimension: int = Form(500), db: Session = Depends(database.get_db), + org_user_dict: db_models.DbUser = Depends(org_admin), ): """Uploads the project boundary. The boundary is uploaded as a geojson file. @@ -438,6 +485,7 @@ async def upload_project_boundary( boundary_geojson (UploadFile): The boundary file to upload. dimension (int): The new dimension of the project. db (Session): The database session to use. + org_user_dict (AuthUser): Check if user is org_admin. Returns: dict: JSON with message, project ID, and task count for project. @@ -454,7 +502,7 @@ async def upload_project_boundary( boundary = json.loads(content) # Validatiing Coordinate Reference System - check_crs(boundary) + await check_crs(boundary) # update project boundary and dimension result = await project_crud.update_project_boundary( @@ -481,6 +529,7 @@ async def edit_project_boundary( boundary_geojson: UploadFile = File(...), dimension: int = Form(500), db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(project_admin), ): """Edit the existing project boundary.""" # Validating for .geojson File. @@ -495,7 +544,7 @@ async def edit_project_boundary( boundary = json.loads(content) # Validatiing Coordinate Reference System - check_crs(boundary) + await check_crs(boundary) result = await project_crud.update_project_boundary( db, project_id, boundary, dimension @@ -516,9 +565,7 @@ async def edit_project_boundary( @router.post("/validate_form") -async def validate_form( - form: UploadFile, -): +async def validate_form(form: UploadFile): """Tests the validity of the xls form uploaded. Parameters: @@ -535,16 +582,13 @@ async def validate_form( return await central_crud.test_form_validity(contents, file_ext[1:]) -@router.post("/{project_id}/generate") +@router.post("/{project_id}/generate-project-data") async def generate_files( background_tasks: BackgroundTasks, project_id: int, - extract_polygon: bool = Form(False), xls_form_upload: Optional[UploadFile] = File(None), - xls_form_config_file: Optional[UploadFile] = File(None), - data_extracts: Optional[UploadFile] = File(None), db: Session = Depends(database.get_db), - # current_user: AuthUser = Depends(login_required), + org_user_dict: db_models.DbUser = Depends(org_admin), ): """Generate additional content to initialise the project. @@ -561,86 +605,54 @@ async def generate_files( Args: background_tasks (BackgroundTasks): FastAPI bg tasks, provided automatically. project_id (int): The ID of the project for which files are being generated. - extract_polygon (bool): A boolean flag indicating whether the polygon - is extracted or not. xls_form_upload (UploadFile, optional): A custom XLSForm to use in the project. A file should be provided if user wants to upload a custom xls form. - xls_form_config_file (UploadFile, optional): The config YAML for the XLS form. - data_extracts (UploadFile, optional): Custom data extract GeoJSON. db (Session): Database session, provided automatically. + org_user_dict (AuthUser): Current logged in user. Must be org admin. Returns: json (JSONResponse): A success message containing the project ID. """ log.debug(f"Generating media files tasks for project: {project_id}") - custom_xls_form = None - xform_title = None - project = await project_crud.get_project(db, project_id) - if not project: - raise HTTPException( - status_code=428, detail=f"Project with id {project_id} does not exist" - ) - - project.data_extract_type = "polygon" if extract_polygon else "centroid" - db.commit() + project = org_user_dict.get("project") + form_category = project.xform_title + custom_xls_form = None + file_ext = None if xls_form_upload: log.debug("Validating uploaded XLS form") - # Validating for .XLS File. - file_name = os.path.splitext(xls_form_upload.filename) - file_ext = file_name[1] - allowed_extensions = [".xls", ".xlsx", ".xml"] + + file_path = Path(xls_form_upload.filename) + file_ext = file_path.suffix.lower() + allowed_extensions = {".xls", ".xlsx", ".xml"} if file_ext not in allowed_extensions: - raise HTTPException(status_code=400, detail="Provide a valid .xls file") - xform_title = file_name[0] + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + detail=f"Invalid file extension, must be {allowed_extensions}", + ) + + form_category = file_path.stem custom_xls_form = await xls_form_upload.read() + # Write XLS form content to db project.form_xls = custom_xls_form - - if xls_form_config_file: - config_file_name = os.path.splitext(xls_form_config_file.filename) - config_file_ext = config_file_name[1] - if not config_file_ext == ".yaml": - raise HTTPException( - status_code=400, detail="Provide a valid .yaml config file" - ) - config_file_contents = await xls_form_config_file.read() - project.form_config_file = config_file_contents - db.commit() - if data_extracts: - log.debug("Validating uploaded geojson file") - # Validating for .geojson File. - data_extracts_file_name = os.path.splitext(data_extracts.filename) - extracts_file_ext = data_extracts_file_name[1] - if extracts_file_ext != ".geojson": - raise HTTPException(status_code=400, detail="Provide a valid geojson file") - try: - extracts_contents = await data_extracts.read() - json.loads(extracts_contents) - except json.JSONDecodeError as e: - raise HTTPException( - status_code=400, detail="Provide a valid geojson file" - ) from e - # Create task in db and return uuid log.debug(f"Creating export background task for project ID: {project_id}") background_task_id = await project_crud.insert_background_task_into_database( - db, project_id=project_id + db, project_id=str(project_id) ) log.debug(f"Submitting {background_task_id} to background tasks stack") background_tasks.add_task( - project_crud.generate_appuser_files, + project_crud.generate_project_files, db, project_id, - extract_polygon, - custom_xls_form, - extracts_contents if data_extracts else None, - xform_title, - file_ext[1:] if xls_form_upload else "xls", + BytesIO(custom_xls_form) if custom_xls_form else None, + form_category, + file_ext if xls_form_upload else "xls", background_task_id, ) @@ -655,6 +667,7 @@ async def update_project_form( project_id: int, form: Optional[UploadFile], db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(project_admin), ): """Update XLSForm for a project.""" file_name = os.path.splitext(form.filename) @@ -674,31 +687,12 @@ async def update_project_form( return form_updated -@router.get("/{project_id}/features", response_model=list[project_schemas.Feature]) -async def get_project_features( - project_id: int, - task_id: int = None, - db: Session = Depends(database.get_db), -): - """Fetch all the features for a project. - - The features are generated from raw-data-api. - - Args: - project_id (int): The project id. - task_id (int): The task id. - db (Session): the DB session, provided automatically. - - Returns: - feature(json): JSON object containing a list of features - """ - features = await project_crud.get_project_features(db, project_id, task_id) - return features - - @router.get("/generate-log/") async def generate_log( - project_id: int, uuid: uuid.UUID, db: Session = Depends(database.get_db) + project_id: int, + uuid: uuid.UUID, + db: Session = Depends(database.get_db), + org_user_dict: db_models.DbUser = Depends(org_admin), ): r"""Get the contents of a log file in a log format. @@ -751,7 +745,7 @@ async def generate_log( @router.get("/categories/") -async def get_categories(): +async def get_categories(current_user: AuthUser = Depends(login_required)): """Get api for fetching all the categories. This endpoint fetches all the categories from osm_fieldwork. @@ -767,7 +761,7 @@ async def get_categories(): return categories -@router.post("/preview_split_by_square/") +@router.post("/preview-split-by-square/") async def preview_split_by_square( project_geojson: UploadFile = File(...), dimension: int = Form(100) ): @@ -787,44 +781,70 @@ async def preview_split_by_square( boundary = geojson.loads(content) # Validatiing Coordinate Reference System - check_crs(boundary) + await check_crs(boundary) result = await project_crud.preview_split_by_square(boundary, dimension) return result -@router.post("/get_data_extract/") +@router.post("/generate-data-extract/") async def get_data_extract( geojson_file: UploadFile = File(...), - project_id: int = Query(None, description="Project ID"), - db: Session = Depends(database.get_db), + form_category: Optional[str] = Form(None), + # config_file: Optional[str] = Form(None), + current_user: AuthUser = Depends(login_required), ): - """Get the data extract for a given project AOI. + """Get a new data extract for a given project AOI. - Use for both generating a new data extract and for getting - and existing extract. + TODO allow config file (YAML/JSON) upload for data extract generation + TODO alternatively, direct to raw-data-api to generate first, then upload """ boundary_geojson = json.loads(await geojson_file.read()) - fgb_url = await project_crud.get_data_extract_url( - db, + # Get extract config file from existing data_models + if form_category: + data_model = f"{data_models_path}/{form_category}.yaml" + with open(data_model, "rb") as data_model_yaml: + extract_config = BytesIO(data_model_yaml.read()) + else: + extract_config = None + + fgb_url = await project_crud.generate_data_extract( boundary_geojson, + extract_config, + ) + + return JSONResponse(status_code=200, content={"url": fgb_url}) + + +@router.get("/data-extract-url/") +async def get_or_set_data_extract( + url: Optional[str] = None, + project_id: int = Query(..., description="Project ID"), + db: Session = Depends(database.get_db), + org_user_dict: db_models.DbUser = Depends(project_admin), +): + """Get or set the data extract URL for a project.""" + fgb_url = await project_crud.get_or_set_data_extract_url( + db, project_id, + url, ) + return JSONResponse(status_code=200, content={"url": fgb_url}) -@router.post("/upload_custom_extract/") +@router.post("/upload-custom-extract/") async def upload_custom_extract( - background_tasks: BackgroundTasks, custom_extract_file: UploadFile = File(...), project_id: int = Query(..., description="Project ID"), db: Session = Depends(database.get_db), + org_user_dict: db_models.DbUser = Depends(project_admin), ): - """Upload a custom data extract for a project as fgb in S3. + """Upload a custom data extract geojson for a project. Request Body - - 'custom_extract_file' (file): Geojson files with the features. Required. + - 'custom_extract_file' (file): File with the data extract features. Query Params: - 'project_id' (int): the project's id. Required. @@ -832,24 +852,34 @@ async def upload_custom_extract( # Validating for .geojson File. file_name = os.path.splitext(custom_extract_file.filename) file_ext = file_name[1] - allowed_extensions = [".geojson", ".json"] + allowed_extensions = [".geojson", ".json", ".fgb"] if file_ext not in allowed_extensions: - raise HTTPException(status_code=400, detail="Provide a valid .geojson file") + raise HTTPException( + status_code=400, detail="Provide a valid .geojson or .fgb file" + ) # read entire file - geojson_str = await custom_extract_file.read() + extract_data = await custom_extract_file.read() - log.debug("Creating upload_custom_extract background task") - fgb_url = await project_crud.upload_custom_data_extract(db, project_id, geojson_str) + if file_ext == ".fgb": + fgb_url = await project_crud.upload_custom_fgb_extract( + db, project_id, extract_data + ) + else: + fgb_url = await project_crud.upload_custom_geojson_extract( + db, project_id, extract_data + ) return JSONResponse(status_code=200, content={"url": fgb_url}) @router.get("/download_form/{project_id}/") -async def download_form(project_id: int, db: Session = Depends(database.get_db)): +async def download_form( + project_id: int, + db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(login_required), +): """Download the XLSForm for a project.""" project = await project_crud.get_project(db, project_id) - if not project: - raise HTTPException(status_code=404, detail="Project not found") headers = { "Content-Disposition": "attachment; filename=submission_data.xls", @@ -868,10 +898,11 @@ async def download_form(project_id: int, db: Session = Depends(database.get_db)) @router.post("/update_category") async def update_project_category( # background_tasks: BackgroundTasks, - project_id: int = Form(...), + project_id: int, category: str = Form(...), upload: Optional[UploadFile] = File(None), db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(project_admin), ): """Update the XLSForm category for a project. @@ -900,6 +931,7 @@ async def update_project_category( if file_ext not in allowed_extensions: raise HTTPException(status_code=400, detail="Provide a valid .xls file") + # FIXME project.form_xls = contents db.commit() @@ -918,7 +950,11 @@ async def update_project_category( @router.get("/download_template/") -async def download_template(category: str, db: Session = Depends(database.get_db)): +async def download_template( + category: str, + db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(mapper), +): """Download an XLSForm template to fill out.""" xlsform_path = f"{xlsforms_path}/{category}.xls" if os.path.exists(xlsform_path): @@ -931,12 +967,14 @@ async def download_template(category: str, db: Session = Depends(database.get_db async def download_project_boundary( project_id: int, db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(mapper), ): """Downloads the boundary of a project as a GeoJSON file. Args: project_id (int): The id of the project. db (Session): The database session, provided automatically. + current_user (AuthUser): Check if user is mapper. Returns: Response: The HTTP response object containing the downloaded file. @@ -954,12 +992,14 @@ async def download_project_boundary( async def download_task_boundaries( project_id: int, db: Session = Depends(database.get_db), + current_user: Session = Depends(mapper), ): """Downloads the boundary of the tasks for a project as a GeoJSON file. Args: project_id (int): The id of the project. db (Session): The database session, provided automatically. + current_user (AuthUser): Check if user has MAPPER permission. Returns: Response: The HTTP response object containing the downloaded file. @@ -975,24 +1015,31 @@ async def download_task_boundaries( @router.get("/features/download/") -async def download_features(project_id: int, db: Session = Depends(database.get_db)): +async def download_features( + project_id: int, + db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(mapper), +): """Downloads the features of a project as a GeoJSON file. Args: project_id (int): The id of the project. db (Session): The database session, provided automatically. + current_user (AuthUser): Check if user has MAPPER permission. Returns: Response: The HTTP response object containing the downloaded file. """ - out = await project_crud.get_project_features_geojson(db, project_id) + feature_collection = await project_crud.get_project_features_geojson(db, project_id) headers = { - "Content-Disposition": "attachment; filename=project_features.geojson", + "Content-Disposition": ( + f"attachment; filename=fmtm_project_{project_id}_features.geojson" + ), "Content-Type": "application/media", } - return Response(content=json.dumps(out), headers=headers) + return Response(content=json.dumps(feature_collection), headers=headers) @router.get("/tiles/{project_id}") @@ -1010,6 +1057,7 @@ async def generate_project_tiles( description="Provide a custom TMS URL, optional", ), db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(mapper), ): """Returns basemap tiles for a project. @@ -1020,6 +1068,7 @@ async def generate_project_tiles( format (str, optional): Default "mbtiles". Other options: "pmtiles", "sqlite3". tms (str, optional): Default None. Custom TMS provider URL. db (Session): The database session, provided automatically. + current_user (AuthUser): Check if user has MAPPER permission. Returns: str: Success message that tile generation started. @@ -1047,12 +1096,17 @@ async def generate_project_tiles( @router.get("/tiles_list/{project_id}/") -async def tiles_list(project_id: int, db: Session = Depends(database.get_db)): +async def tiles_list( + project_id: int, + db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(login_required), +): """Returns the list of tiles for a project. Parameters: project_id: int db (Session): The database session, provided automatically. + current_user (AuthUser): Check if user is logged in. Returns: Response: List of generated tiles for a project. @@ -1061,7 +1115,11 @@ async def tiles_list(project_id: int, db: Session = Depends(database.get_db)): @router.get("/download_tiles/") -async def download_tiles(tile_id: int, db: Session = Depends(database.get_db)): +async def download_tiles( + tile_id: int, + db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(login_required), +): """Download the basemap tile archive for a project.""" log.debug("Getting tile archive path from DB") tiles_path = ( @@ -1088,12 +1146,14 @@ async def download_tiles(tile_id: int, db: Session = Depends(database.get_db)): async def download_task_boundary_osm( project_id: int, db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(mapper), ): """Downloads the boundary of a task as a OSM file. Args: project_id (int): The id of the project. db (Session): The database session, provided automatically. + current_user (AuthUser): Check if user has MAPPER permission. Returns: Response: The HTTP response object containing the downloaded file. @@ -1165,6 +1225,7 @@ async def get_template_file( file_type: str = Query( ..., enum=["data_extracts", "form"], description="Choose file type" ), + current_user: AuthUser = Depends(login_required), ): """Get template file. @@ -1218,15 +1279,34 @@ async def project_dashboard( @router.get("/contributors/{project_id}") -async def get_contributors(project_id: int, db: Session = Depends(database.get_db)): +async def get_contributors( + project_id: int, + db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(mapper), +): """Get contributors of a project. Args: project_id (int): ID of project. db (Session): The database session. + current_user (AuthUser): Check if user is mapper. Returns: list[project_schemas.ProjectUser]: List of project users. """ project_users = await project_crud.get_project_users(db, project_id) return project_users + + +@router.post("/add_admin/") +async def add_new_project_admin( + db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(project_admin), + user: db_models.DbUser = Depends(user_exists_in_db), + project: db_models.DbProject = Depends(project_deps.get_project_by_id), +): + """Add a new project manager. + + The logged in user must be either the admin of the organisation or a super admin. + """ + return await project_crud.add_project_admin(db, user, project) diff --git a/src/backend/app/projects/project_schemas.py b/src/backend/app/projects/project_schemas.py index f7a02854e0..cd8627a633 100644 --- a/src/backend/app/projects/project_schemas.py +++ b/src/backend/app/projects/project_schemas.py @@ -19,45 +19,105 @@ import uuid from datetime import datetime -from typing import List, Optional, Union +from typing import Any, List, Optional, Union from dateutil import parser -from geojson_pydantic import Feature as GeojsonFeature -from pydantic import BaseModel, SecretStr +from geojson_pydantic import Feature, FeatureCollection, Polygon +from loguru import logger as log +from pydantic import BaseModel, Field, computed_field from pydantic.functional_serializers import field_serializer -from pydantic.functional_validators import field_validator +from pydantic.functional_validators import field_validator, model_validator +from shapely import wkb +from typing_extensions import Self -from app.config import decrypt_value, encrypt_value +from app.config import HttpUrlStr, decrypt_value, encrypt_value from app.db import db_models +from app.db.postgis_utils import ( + geojson_to_geometry, + geometry_to_geojson, + get_address_from_lat_lon, + read_wkb, + write_wkb, +) from app.models.enums import ProjectPriority, ProjectStatus, TaskSplitType from app.tasks import tasks_schemas from app.users.user_schemas import User -class ODKCentral(BaseModel): - """ODK Central credentials.""" +class ODKCentralIn(BaseModel): + """ODK Central credentials inserted to database.""" - odk_central_url: str - odk_central_user: str - odk_central_password: SecretStr + odk_central_url: Optional[HttpUrlStr] = None + odk_central_user: Optional[str] = None + odk_central_password: Optional[str] = None - def model_post_init(self, ctx): - """Run logic after model object instantiated.""" - # Decrypt odk central password from database - self.odk_central_password = SecretStr( - decrypt_value(self.odk_central_password.get_secret_value()) - ) + @field_validator("odk_central_url", mode="after") + @classmethod + def remove_trailing_slash(cls, value: HttpUrlStr) -> Optional[HttpUrlStr]: + """Remove trailing slash from ODK Central URL.""" + if not value: + return None + if value.endswith("/"): + return value[:-1] + return value - @field_validator("odk_central_password", mode="before") + @model_validator(mode="after") + def all_odk_vars_together(self) -> Self: + """Ensure if one ODK variable is set, then all are.""" + if any( + [ + self.odk_central_url, + self.odk_central_user, + self.odk_central_password, + ] + ) and not all( + [ + self.odk_central_url, + self.odk_central_user, + self.odk_central_password, + ] + ): + err = "All ODK details are required together: url, user, password" + log.debug(err) + raise ValueError(err) + return self + + @field_validator("odk_central_password", mode="after") @classmethod - def encrypt_odk_password(cls, value: str) -> SecretStr: + def encrypt_odk_password(cls, value: str) -> Optional[str]: """Encrypt the ODK Central password before db insertion.""" - return SecretStr(encrypt_value(value)) + if not value: + return None + return encrypt_value(value) + + +class ODKCentralDecrypted(BaseModel): + """ODK Central credentials extracted from database. + + WARNING never return this as a response model. + WARNING or log to the terminal. + """ + + odk_central_url: Optional[HttpUrlStr] = None + odk_central_user: Optional[str] = None + odk_central_password: Optional[str] = None - @field_validator("odk_central_url", mode="before") + def model_post_init(self, ctx): + """Run logic after model object instantiated.""" + # Decrypt odk central password from database + if self.odk_central_password: + if isinstance(self.odk_central_password, str): + password = self.odk_central_password + else: + password = self.odk_central_password + self.odk_central_password = decrypt_value(password) + + @field_validator("odk_central_url", mode="after") @classmethod - def remove_trailing_slash(cls, value: str) -> str: + def remove_trailing_slash(cls, value: HttpUrlStr) -> HttpUrlStr: """Remove trailing slash from ODK Central URL.""" + if not value: + return "" if value.endswith("/"): return value[:-1] return value @@ -69,39 +129,96 @@ class ProjectInfo(BaseModel): name: str short_description: str description: str + per_task_instructions: Optional[str] = None -class ProjectUpdate(BaseModel): - """Update project.""" - - name: Optional[str] = None - short_description: Optional[str] = None - description: Optional[str] = None - - -class ProjectUpload(BaseModel): +class ProjectIn(BaseModel): """Upload new project.""" - author: User project_info: ProjectInfo - xform_title: Optional[str] - odk_central: ODKCentral - hashtags: Optional[List[str]] = None + xform_title: str organisation_id: Optional[int] = None + hashtags: Optional[List[str]] = None task_split_type: Optional[TaskSplitType] = None task_split_dimension: Optional[int] = None task_num_buildings: Optional[int] = None data_extract_type: Optional[str] = None - + outline_geojson: Union[FeatureCollection, Feature, Polygon] # city: str # country: str + @computed_field + @property + def outline(self) -> Optional[Any]: + """Compute WKBElement geom from geojson.""" + if not self.outline_geojson: + return None + return geojson_to_geometry(self.outline_geojson) + + @computed_field + @property + def centroid(self) -> Optional[Any]: + """Compute centroid for project outline.""" + if not self.outline: + return None + return write_wkb(read_wkb(self.outline).centroid) + + @computed_field + @property + def location_str(self) -> Optional[str]: + """Compute geocoded location string from centroid.""" + if not self.centroid: + return None + geom = read_wkb(self.centroid) + latitude, longitude = geom.y, geom.x + address = get_address_from_lat_lon(latitude, longitude) + return address if address is not None else "" + + @field_validator("hashtags", mode="after") + @classmethod + def prepend_hash_to_tags(cls, hashtags: List[str]) -> Optional[List[str]]: + """Add '#' to hashtag if missing. Also added default '#FMTM'.""" + if not hashtags: + return None + + hashtags_with_hash = [ + f"#{hashtag}" if hashtag and not hashtag.startswith("#") else hashtag + for hashtag in hashtags + ] + + if "#FMTM" not in hashtags_with_hash: + hashtags_with_hash.append("#FMTM") + + return hashtags_with_hash + + +class ProjectUpload(ProjectIn, ODKCentralIn): + """Project upload details, plus ODK credentials.""" + + pass + -class Feature(BaseModel): +class ProjectPartialUpdate(BaseModel): + """Update projects metadata.""" + + name: Optional[str] = None + short_description: Optional[str] = None + description: Optional[str] = None + hashtags: Optional[List[str]] = None + per_task_instructions: Optional[str] = None + + +class ProjectUpdate(ProjectIn): + """Update project.""" + + pass + + +class GeojsonFeature(BaseModel): """Features used for Task definitions.""" id: int - geometry: Optional[GeojsonFeature] = None + geometry: Optional[Feature] = None class ProjectSummary(BaseModel): @@ -170,18 +287,29 @@ class PaginatedProjectSummaries(BaseModel): class ProjectBase(BaseModel): """Base project model.""" + outline: Any = Field(exclude=True) + id: int odkid: int author: User project_info: ProjectInfo status: ProjectStatus # location_str: str - outline_geojson: Optional[GeojsonFeature] = None project_tasks: Optional[List[tasks_schemas.Task]] xform_title: Optional[str] = None hashtags: Optional[List[str]] = None organisation_id: Optional[int] = None + @computed_field + @property + def outline_geojson(self) -> Optional[Feature]: + """Compute the geojson outline from WKBElement outline.""" + if not self.outline: + return None + geometry = wkb.loads(bytes(self.outline.data)) + bbox = geometry.bounds # Calculate bounding box + return geometry_to_geojson(self.outline, {"id": self.id, "bbox": bbox}, self.id) + class ProjectOut(ProjectBase): """Project display to user.""" @@ -194,6 +322,7 @@ class ReadProject(ProjectBase): project_uuid: uuid.UUID = uuid.uuid4() location_str: Optional[str] = None + data_extract_url: str class BackgroundTaskStatus(BaseModel): diff --git a/src/backend/app/s3.py b/src/backend/app/s3.py index 0e3acd76e9..66ccca3460 100644 --- a/src/backend/app/s3.py +++ b/src/backend/app/s3.py @@ -113,6 +113,7 @@ def get_obj_from_bucket(bucket_name: str, s3_path: str) -> BytesIO: response = client.get_object(bucket_name, s3_path) return BytesIO(response.read()) except Exception as e: + log.warning(f"Failed attempted download from S3 path: {s3_path}") raise ValueError(str(e)) from e finally: if response: diff --git a/src/backend/app/submissions/submission_crud.py b/src/backend/app/submissions/submission_crud.py index 327ca7729d..26ae918c2d 100644 --- a/src/backend/app/submissions/submission_crud.py +++ b/src/backend/app/submissions/submission_crud.py @@ -27,9 +27,11 @@ from collections import Counter from datetime import datetime, timedelta from io import BytesIO +from typing import Optional import sozipfile.sozipfile as zipfile from asgiref.sync import async_to_sync +from dateutil import parser from fastapi import HTTPException, Response from fastapi.responses import FileResponse from loguru import logger as log @@ -39,7 +41,7 @@ from app.central.central_crud import get_odk_form, get_odk_project, list_odk_xforms from app.config import settings from app.db import db_models -from app.projects import project_crud, project_schemas +from app.projects import project_crud, project_deps from app.s3 import add_obj_to_bucket, get_obj_from_bucket from app.tasks import tasks_crud @@ -72,12 +74,8 @@ def get_submission_of_project(db: Session, project_id: int, task_id: int = None) ) # ODK Credentials - odk_credentials = project_schemas.ODKCentral( - odk_central_url=project_info.odk_central_url, - odk_central_user=project_info.odk_central_user, - odk_central_password=project_info.odk_central_password, - ) - + odk_sync = async_to_sync(project_deps.get_odk_credentials) + odk_credentials = odk_sync(db, project_info) xform = get_odk_form(odk_credentials) # If task id is not provided, submission for all the task are listed @@ -142,21 +140,13 @@ def convert_to_osm(db: Session, project_id: int, task_id: int): get_project_sync = async_to_sync(project_crud.get_project) project_info = get_project_sync(db, project_id) - # Return exception if project is not found - if not project_info: - raise HTTPException(status_code=404, detail="Project not found") - odkid = project_info.odkid project_name = project_info.project_name_prefix form_category = project_info.xform_title # ODK Credentials - odk_credentials = project_schemas.ODKCentral( - odk_central_url=project_info.odk_central_url, - odk_central_user=project_info.odk_central_user, - odk_central_password=project_info.odk_central_password, - ) - + odk_sync = async_to_sync(project_deps.get_odk_credentials) + odk_credentials = odk_sync(db, project_info) # Get ODK Form with odk credentials from the project. xform = get_odk_form(odk_credentials) @@ -215,19 +205,14 @@ def convert_to_osm(db: Session, project_id: int, task_id: int): return FileResponse(final_zip_file_path) -def gather_all_submission_csvs(db, project_id): +async def gather_all_submission_csvs(db, project_id): """Gather all of the submission CSVs for a project. Generate a single zip with all submissions. """ log.info(f"Downloading all CSV submissions for project {project_id}") - get_project_sync = async_to_sync(project_crud.get_project) - project_info = get_project_sync(db, project_id) - - # Return empty list if project is not found - if not project_info: - raise HTTPException(status_code=404, detail="Project not found") + project_info = await project_crud.get_project(db, project_id) odkid = project_info.odkid project_name = project_info.project_name_prefix @@ -235,12 +220,8 @@ def gather_all_submission_csvs(db, project_id): project_tasks = project_info.tasks # ODK Credentials - odk_credentials = project_schemas.ODKCentral( - odk_central_url=project_info.odk_central_url, - odk_central_user=project_info.odk_central_user, - odk_central_password=project_info.odk_central_password, - ) - + odk_sync = async_to_sync(project_deps.get_odk_credentials) + odk_credentials = odk_sync(db, project_info) # Get ODK Form with odk credentials from the project. xform = get_odk_form(odk_credentials) @@ -330,11 +311,8 @@ def update_submission_in_s3( project = get_project_sync(db, project_id) # Gather metadata - odk_credentials = project_schemas.ODKCentral( - odk_central_url=project.odk_central_url, - odk_central_user=project.odk_central_user, - odk_central_password=project.odk_central_password, - ) + odk_sync = async_to_sync(project_deps.get_odk_credentials) + odk_credentials = odk_sync(db, project) odk_forms = list_odk_xforms(project.odkid, odk_credentials, True) # Get latest submission date @@ -357,8 +335,9 @@ def update_submission_in_s3( metadata_s3_path = f"/{s3_project_path}/submissions.meta.json" try: # Get the last submission date from the metadata - file = get_obj_from_bucket(settings.S3_BUCKET_NAME, metadata_s3_path) - zip_file_last_submission = (json.loads(file.getvalue()))["last_submission"] + data = get_obj_from_bucket(settings.S3_BUCKET_NAME, metadata_s3_path) + + zip_file_last_submission = (json.loads(data.getvalue()))["last_submission"] if last_submission <= zip_file_last_submission: # Update background task status to COMPLETED update_bg_task_sync = async_to_sync( @@ -369,7 +348,6 @@ def update_submission_in_s3( except Exception as e: log.warning(str(e)) - pass # Zip file is outdated, regenerate metadata = { @@ -427,12 +405,8 @@ def get_all_submissions_json(db: Session, project_id): project_info = get_project_sync(db, project_id) # ODK Credentials - odk_credentials = project_schemas.ODKCentral( - odk_central_url=project_info.odk_central_url, - odk_central_user=project_info.odk_central_user, - odk_central_password=project_info.odk_central_password, - ) - + odk_sync = async_to_sync(project_deps.get_odk_credentials) + odk_credentials = odk_sync(db, project_info) project = get_odk_project(odk_credentials) get_task_id_list_sync = async_to_sync(tasks_crud.get_task_id_list) @@ -457,7 +431,7 @@ def get_all_submissions_json(db: Session, project_id): # project_tasks = project_info.tasks # # ODK Credentials -# odk_credentials = project_schemas.ODKCentral( +# odk_credentials = project_schemas.ODKCentralDecrypted( # odk_central_url=project_info.odk_central_url, # odk_central_user=project_info.odk_central_user, # odk_central_password=project_info.odk_central_password, @@ -489,22 +463,13 @@ async def download_submission( """Download submission data from ODK Central and aggregate.""" project_info = await project_crud.get_project(db, project_id) - # Return empty list if project is not found - if not project_info: - raise HTTPException(status_code=404, detail="Project not found") - odkid = project_info.odkid project_name = project_info.project_name_prefix form_category = project_info.xform_title project_tasks = project_info.tasks # ODK Credentials - odk_credentials = project_schemas.ODKCentral( - odk_central_url=project_info.odk_central_url, - odk_central_user=project_info.odk_central_user, - odk_central_password=project_info.odk_central_password, - ) - + odk_credentials = await project_deps.get_odk_credentials(db, project_info) # Get ODK Form with odk credentials from the project. xform = get_odk_form(odk_credentials) if not export_json: @@ -609,12 +574,7 @@ async def get_submission_points(db: Session, project_id: int, task_id: int = Non form_category = project_info.xform_title # ODK Credentials - odk_credentials = project_schemas.ODKCentral( - odk_central_url=project_info.odk_central_url, - odk_central_user=project_info.odk_central_user, - odk_central_password=project_info.odk_central_password, - ) - + odk_credentials = await project_deps.get_odk_credentials(db, project_info) xform = get_odk_form(odk_credentials) if task_id: @@ -664,22 +624,13 @@ async def get_submission_count_of_a_project(db: Session, project_id: int): """Return the total number of submissions made for a project.""" project_info = await project_crud.get_project(db, project_id) - # Return empty list if project is not found - if not project_info: - raise HTTPException(status_code=404, detail="Project not found") - odkid = project_info.odkid project_name = project_info.project_name_prefix form_category = project_info.xform_title project_tasks = project_info.tasks # ODK Credentials - odk_credentials = project_schemas.ODKCentral( - odk_central_url=project_info.odk_central_url, - odk_central_user=project_info.odk_central_user, - odk_central_password=project_info.odk_central_password, - ) - + odk_credentials = await project_deps.get_odk_credentials(db, project_info) # Get ODK Form with odk credentials from the project. xform = get_odk_form(odk_credentials) @@ -765,7 +716,15 @@ async def get_submissions_by_date( return response -async def get_submission_by_project(project_id: int, skip: 0, limit: 100, db: Session): +async def get_submission_by_project( + project_id: int, + skip: 0, + limit: 100, + db: Session, + submitted_by: Optional[str] = None, + review_state: Optional[str] = None, + submitted_date: Optional[str] = None, +): """Get submission by project. Retrieves a paginated list of submissions for a given project. @@ -775,6 +734,9 @@ async def get_submission_by_project(project_id: int, skip: 0, limit: 100, db: Se skip (int): The number of submissions to skip. limit (int): The maximum number of submissions to retrieve. db (Session): The database session. + submitted_by: username of submitter. + review_state: reviewState of the submission. + submitted_date: date of submissions. Returns: Tuple[int, List]: A tuple containing the total number of submissions and @@ -795,9 +757,25 @@ async def get_submission_by_project(project_id: int, skip: 0, limit: 100, db: Se with zipfile.ZipFile(file, "r") as zip_ref: with zip_ref.open("submissions.json") as file_in_zip: - content = file_in_zip.read() + content = json.loads(file_in_zip.read()) + if submitted_by: + content = [ + sub for sub in content if submitted_by.lower() in sub["username"].lower() + ] + if review_state: + content = [ + sub + for sub in content + if sub.get("__system", {}).get("reviewState") == review_state + ] + if submitted_date: + content = [ + sub + for sub in content + if parser.parse(sub.get("end")).date() + == parser.parse(submitted_date).date() + ] - content = json.loads(content) start_index = skip end_index = skip + limit paginated_content = content[start_index:end_index] @@ -805,7 +783,10 @@ async def get_submission_by_project(project_id: int, skip: 0, limit: 100, db: Se async def get_submission_by_task( - project: db_models.DbProject, task_id: int, filters: dict, db: Session + project: db_models.DbProject, + task_id: int, + filters: dict, + db: Session, ): """Get submissions and count by task. @@ -818,14 +799,10 @@ async def get_submission_by_task( Returns: Tuple: A tuple containing the list of submissions and the count. """ - odk_credentials = project_schemas.ODKCentral( - odk_central_url=project.odk_central_url, - odk_central_user=project.odk_central_user, - odk_central_password=project.odk_central_password, - ) + odk_credentials = await project_deps.get_odk_credentials(db, project) xform = get_odk_form(odk_credentials) - data = xform.listSubmissions(project.odkid, task_id, filters) + data = xform.listSubmissions(project.odkid, str(task_id), filters) submissions = data.get("value", []) count = data.get("@odata.count", 0) diff --git a/src/backend/app/submissions/submission_routes.py b/src/backend/app/submissions/submission_routes.py index 4a2ab2c9d7..fe6c1c053c 100644 --- a/src/backend/app/submissions/submission_routes.py +++ b/src/backend/app/submissions/submission_routes.py @@ -28,6 +28,8 @@ from osm_fieldwork.osmfile import OsmFile from sqlalchemy.orm import Session +from app.auth.osm import AuthUser, login_required +from app.auth.roles import mapper from app.central import central_crud from app.config import settings from app.db import database, db_models @@ -47,6 +49,7 @@ async def read_submissions( project_id: int, task_id: int = None, db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(mapper), ) -> list[dict]: """Get all submissions made for a project. @@ -55,6 +58,7 @@ async def read_submissions( task_id (int, optional): The ID of the task. If provided, returns the submissions made for a specific task only. db (Session): The database session, automatically provided. + current_user (AuthUser): Check if user has MAPPER permission. Returns: list[dict]: The list of submissions. @@ -68,6 +72,7 @@ async def download_submission( task_id: int = None, export_json: bool = True, db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(mapper), ): """Download the submissions for a given project. @@ -79,12 +84,13 @@ async def download_submission( If provided, returns the submissions made for a specific task only. export_json (bool): Export in JSON format, else returns a file. db (Session): The database session, automatically provided. + current_user (AuthUser): Check if user has MAPPER permission. Returns: Union[list[dict], File]: JSON of submissions, or submission file. """ if not (task_id or export_json): - file = submission_crud.gather_all_submission_csvs(db, project_id) + file = await submission_crud.gather_all_submission_csvs(db, project_id) return FileResponse(file) return await submission_crud.download_submission( @@ -97,6 +103,7 @@ async def submission_points( project_id: int, task_id: int = None, db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(login_required), ): """Get submission points for a given project. @@ -105,6 +112,7 @@ async def submission_points( task_id (int, optional): The ID of the task. If provided, returns the submissions made for a specific task only. db (Session): The database session, automatically provided. + current_user (AuthUser): Check if user is logged in. Returns: File: a zip containing submission points. @@ -117,6 +125,7 @@ async def convert_to_osm( project_id: int, task_id: int = None, db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(login_required), ) -> str: """Convert JSON submissions to OSM XML for a project. @@ -125,6 +134,7 @@ async def convert_to_osm( task_id (int, optional): The ID of the task. If provided, returns the submissions made for a specific task only. db (Session): The database session, automatically provided. + current_user (AuthUser): Check if user is logged in. Returns: File: an OSM XML of submissions. @@ -149,6 +159,7 @@ async def get_submission_count( async def conflate_osm_data( project_id: int, db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(login_required), ): """Conflate submission data against existing OSM data.""" # All Submissions JSON @@ -210,6 +221,7 @@ async def download_submission_json( project_id: int, background_task_id: Optional[str] = None, db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(mapper), ): """Download submissions for a project in JSON format. @@ -257,6 +269,7 @@ async def download_submission_json( async def get_osm_xml( project_id: int, db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(login_required), ): """Get the submissions in OSM XML format for a project. @@ -285,21 +298,9 @@ async def get_osm_xml( # Remove the extra closing tag from the end of the file with open(osmoutfile, "r") as f: osmoutfile_data = f.read() - # Find the last index of the closing tag - last_osm_index = osmoutfile_data.rfind("") - # Remove the extra closing tag from the end - processed_xml_string = ( - osmoutfile_data[:last_osm_index] - + osmoutfile_data[last_osm_index + len("") :] - ) - - # Write the modified XML data back to the file - with open(osmoutfile, "w") as f: - f.write(processed_xml_string) # Create a plain XML response - response = Response(content=processed_xml_string, media_type="application/xml") - return response + return Response(content=osmoutfile_data, media_type="application/xml") @router.get("/submission_page/{project_id}") @@ -309,6 +310,7 @@ async def get_submission_page( background_tasks: BackgroundTasks, planned_task: Optional[int] = None, db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(mapper), ): """Summary submissison details for submission page. @@ -318,6 +320,7 @@ async def get_submission_page( project_id (int): The ID of the project. days (int): The number of days to consider for fetching submissions. planned_task (int): Associated task id. + current_user (AuthUser): Check if user has MAPPER permission. Returns: dict: A dictionary containing the submission counts for each date. @@ -340,20 +343,24 @@ async def get_submission_page( @router.get("/submission_form_fields/{project_id}") async def get_submission_form_fields( - project_id: int, db: Session = Depends(database.get_db) + project_id: int, + db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(mapper), ): """Retrieves the submission form for a specific project. Args: project_id (int): The ID of the project. db (Session): The database session, automatically generated. + current_user (AuthUser): Check if user has MAPPER permission. Returns: Any: The response from the submission form API. """ project = await project_crud.get_project(db, project_id) task_list = await tasks_crud.get_task_id_list(db, project_id) - odk_form = central_crud.get_odk_form(project) + odk_credentials = await project_deps.get_odk_credentials(db, project) + odk_form = central_crud.get_odk_form(odk_credentials) response = odk_form.form_fields(project.odkid, str(task_list[0])) return response @@ -364,7 +371,13 @@ async def submission_table( project_id: int, page: int = Query(1, ge=1), results_per_page: int = Query(13, le=100), + submitted_by: Optional[str] = None, + review_state: Optional[str] = None, + submitted_date: Optional[str] = Query( + None, title="Submitted Date", description="Date in format (e.g., 'YYYY-MM-DD')" + ), db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(mapper), ): """This api returns the submission table of a project. @@ -377,7 +390,7 @@ async def submission_table( skip = (page - 1) * results_per_page limit = results_per_page count, data = await submission_crud.get_submission_by_project( - project_id, skip, limit, db + project_id, skip, limit, db, submitted_by, review_state, submitted_date ) background_task_id = await project_crud.insert_background_task_into_database( db, "sync_submission", project_id @@ -400,7 +413,13 @@ async def task_submissions( project: db_models.DbProject = Depends(project_deps.get_project_by_id), page: int = Query(1, ge=1), limit: int = Query(13, le=100), + submitted_by: Optional[str] = None, + review_state: Optional[str] = None, + submitted_date: Optional[str] = Query( + None, title="Submitted Date", description="Date in format (e.g., 'YYYY-MM-DD')" + ), db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(mapper), ): """This api returns the submission table of a project. @@ -418,6 +437,24 @@ async def task_submissions( "$wkt": True, } + if submitted_date: + filters["$filter"] = ( + "__system/submissionDate ge {}T00:00:00+00:00 " + "and __system/submissionDate le {}T23:59:59.999+00:00" + ).format(submitted_date, submitted_date) + + if submitted_by: + if "$filter" in filters: + filters["$filter"] += f"and (username eq '{submitted_by}')" + else: + filters["$filter"] = f"username eq '{submitted_by}'" + + if review_state: + if "$filter" in filters: + filters["$filter"] += f" and (__system/reviewState eq '{review_state}')" + else: + filters["$filter"] = f"__system/reviewState eq '{review_state}'" + data, count = await submission_crud.get_submission_by_task( project, task_id, filters, db ) diff --git a/src/backend/app/tasks/tasks_crud.py b/src/backend/app/tasks/tasks_crud.py index 654a0d33f0..dafad64a25 100644 --- a/src/backend/app/tasks/tasks_crud.py +++ b/src/backend/app/tasks/tasks_crud.py @@ -21,22 +21,17 @@ from typing import List, Optional from fastapi import Depends, HTTPException -from geoalchemy2.shape import from_shape -from geojson import dump from loguru import logger as log -from osm_rawdata.postgres import PostgresClient -from shapely.geometry import shape from sqlalchemy.orm import Session from sqlalchemy.sql import text -from app.central import central_crud +from app.auth.osm import AuthUser from app.db import database, db_models from app.models.enums import ( TaskStatus, get_action_for_status_change, verify_valid_status_update, ) -from app.projects import project_crud from app.tasks import tasks_schemas from app.users import user_crud @@ -221,98 +216,101 @@ async def create_task_history_for_status_change( # TODO: write tests for these -async def update_task_files( - db: Session, - project_id: int, - project_odk_id: int, - project_name: str, - task_id: int, - category: str, - task_boundary: str, -): - """Update associated files for a task.""" - # This file will store osm extracts - task_polygons = f"/tmp/{project_name}_{category}_{task_id}.geojson" - - # Update data extracts in the odk central - pg = PostgresClient("underpass") - - category = "buildings" - - # This file will store osm extracts - outfile = f"/tmp/test_project_{category}.geojson" - - # Delete all tasks of the project if there are some - db.query(db_models.DbFeatures).filter( - db_models.DbFeatures.task_id == task_id - ).delete() - - # OSM Extracts - outline_geojson = pg.getFeatures( - boundary=task_boundary, - filespec=outfile, - polygon=True, - xlsfile=f"{category}.xls", - category=category, +async def get_task_comments(db: Session, project_id: int, task_id: int): + """Get a list of tasks id for a project.""" + query = text( + """ + SELECT + task_history.id, task_history.task_id, users.username, + task_history.action_text, task_history.action_date + FROM + task_history + LEFT JOIN + users ON task_history.user_id = users.id + WHERE + project_id = :project_id + AND task_id = :task_id + AND action = 'COMMENT' + """ ) - updated_outline_geojson = {"type": "FeatureCollection", "features": []} - - # Collect feature mappings for bulk insert - for feature in outline_geojson["features"]: - # If the osm extracts contents do not have a title, - # provide an empty text for that - feature["properties"]["title"] = "" - - feature_shape = shape(feature["geometry"]) + params = {"project_id": project_id, "task_id": task_id} - wkb_element = from_shape(feature_shape, srid=4326) - updated_outline_geojson["features"].append(feature) - - db_feature = db_models.DbFeatures( - project_id=project_id, - geometry=wkb_element, - properties=feature["properties"], - ) - db.add(db_feature) - db.commit() + result = db.execute(query, params) - # Update task_polygons file containing osm extracts with the new - # geojson contents containing title in the properties. - with open(task_polygons, "w") as jsonfile: - jsonfile.truncate(0) # clear the contents of the file - dump(updated_outline_geojson, jsonfile) + # Convert the result to a list of dictionaries + result_dict_list = [ + { + "id": row[0], + "task_id": row[1], + "commented_by": row[2], + "comment": row[3], + "created_at": row[4], + } + for row in result.fetchall() + ] - # Update the osm extracts in the form. - central_crud.upload_xform_media(project_odk_id, task_id, task_polygons, None) + return result_dict_list - return True +async def add_task_comments( + db: Session, comment: tasks_schemas.TaskCommentBase, user_data: AuthUser +): + """Add a comment to a task. -async def edit_task_boundary(db: Session, task_id: int, boundary: str): - """Update the boundary polyon on the database.""" - geometry = boundary["features"][0]["geometry"] - outline = shape(geometry) + Parameters: + - db: SQLAlchemy database session + - comment: TaskCommentBase instance containing the comment details + - user_data: AuthUser instance containing the user details - task = await get_task(db, task_id) - if not task: - raise HTTPException(status_code=404, detail="Task not found") + Returns: + - Dictionary with the details of the added comment + """ + currentdate = datetime.now() + # Construct the query to insert the comment and retrieve inserted comment details + query = text( + """ + INSERT INTO task_history ( + project_id, task_id, action, action_text, + action_date, user_id + ) + VALUES ( + :project_id, :task_id, 'COMMENT', :comment_text, + :current_date, :user_id + ) + RETURNING + task_history.id, + task_history.task_id, + (SELECT username FROM users WHERE id = task_history.user_id) AS user_id, + task_history.action_text, + task_history.action_date; + """ + ) - task.outline = outline.wkt + # Define a dictionary with the parameter values + params = { + "project_id": comment.project_id, + "task_id": comment.task_id, + "comment_text": comment.comment, + "current_date": currentdate, + "user_id": user_data.id, + } + + # Execute the query with the named parameters and commit the transaction + result = db.execute(query, params) db.commit() - # Get category, project_name - project_id = task.project_id - project = await project_crud.get_project(db, project_id) - category = project.xform_title - project_name = project.project_name_prefix - odk_id = project.odkid - - await update_task_files( - db, project_id, odk_id, project_name, task_id, category, geometry - ) + # Fetch the first row of the query result + row = result.fetchone() - return True + # Return the details of the added comment as a dictionary + return { + "id": row[0], + "task_id": row[1], + "commented_by": row[2], + "comment": row[3], + "created_at": row[4], + } async def update_task_history( @@ -341,7 +339,7 @@ def process_history_entry(history_entry): return tasks -def get_task_history( +async def get_project_task_history( project_id: int, end_date: Optional[datetime], db: Session, @@ -349,10 +347,10 @@ def get_task_history( """Retrieves the task history records for a specific project. Args: - project_id: The ID of the project. - end_date: The end date of the task history - records to retrieve (optional). - db: The database session. + project_id (int): The ID of the project. + end_date (datetime, optional): The end date of the task history + records to retrieve. + db (Session): The database session. Returns: A list of task history records for the specified project. diff --git a/src/backend/app/tasks/tasks_routes.py b/src/backend/app/tasks/tasks_routes.py index 4a0df6a699..eaef28dcc0 100644 --- a/src/backend/app/tasks/tasks_routes.py +++ b/src/backend/app/tasks/tasks_routes.py @@ -17,20 +17,21 @@ # """Routes for FMTM tasks.""" -import json from datetime import datetime, timedelta from typing import List -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi import APIRouter, Depends, HTTPException +from loguru import logger as log from sqlalchemy.orm import Session from sqlalchemy.sql import text +from app.auth.osm import AuthUser, login_required +from app.auth.roles import get_uid, mapper from app.central import central_crud from app.db import database from app.models.enums import TaskStatus -from app.projects import project_crud, project_schemas +from app.projects import project_crud, project_deps from app.tasks import tasks_crud, tasks_schemas -from app.users import user_schemas router = APIRouter( prefix="/tasks", @@ -121,14 +122,13 @@ async def get_specific_task(task_id: int, db: Session = Depends(database.get_db) "/{task_id}/new_status/{new_status}", response_model=tasks_schemas.ReadTask ) async def update_task_status( - user: user_schemas.User, task_id: int, new_status: TaskStatus, db: Session = Depends(database.get_db), + current_user: AuthUser = Depends(mapper), ): """Update the task status.""" - user_id = user.id - + user_id = await get_uid(current_user) task = await tasks_crud.update_task_status(db, user_id, task_id, new_status) updated_task = await tasks_crud.update_task_history(task, db) if not task: @@ -136,22 +136,6 @@ async def update_task_status( return updated_task -@router.post("/edit-task-boundary") -async def edit_task_boundary( - task_id: int, - boundary: UploadFile = File(...), - db: Session = Depends(database.get_db), -): - """Update the task boundary manually.""" - # read entire file - content = await boundary.read() - boundary_json = json.loads(content) - - edit_boundary = await tasks_crud.edit_task_boundary(db, task_id, boundary_json) - - return edit_boundary - - @router.get("/tasks-features/") async def task_features_count( project_id: int, @@ -162,39 +146,84 @@ async def task_features_count( project = await project_crud.get_project(db, project_id) # ODK Credentials - odk_credentials = project_schemas.ODKCentral( - odk_central_url=project.odk_central_url, - odk_central_user=project.odk_central_user, - odk_central_password=project.odk_central_password, - ) + odk_credentials = await project_deps.get_odk_credentials(db, project) odk_details = central_crud.list_odk_xforms(project.odkid, odk_credentials, True) # Assemble the final data list data = [] - for x in odk_details: - feature_count_query = text( - f""" - select count(*) from features - where project_id = {project_id} and task_id = {x['xmlFormId']} + feature_count_query = text( """ - ) + SELECT id, feature_count + FROM tasks + WHERE project_id = :project_id; + """ + ) + result = db.execute(feature_count_query, {"project_id": project_id}) + feature_counts = result.all() - result = db.execute(feature_count_query) - feature_count = result.fetchone() + if not feature_counts: + msg = f"To tasks found for project {project_id}" + log.warning(msg) + raise HTTPException(status_code=404, detail=msg) + feature_count_task_dict = { + f"task_{record[0]}": record[1] for record in feature_counts + } + for x in odk_details: data.append( { "task_id": x["xmlFormId"], "submission_count": x["submissions"], "last_submission": x["lastSubmission"], - "feature_count": feature_count[0], + "feature_count": feature_count_task_dict[f"task_{x['xmlFormId']}"], } ) return data +@router.get("/task-comments/", response_model=list[tasks_schemas.TaskCommentResponse]) +async def task_comments( + project_id: int, + task_id: int, + db: Session = Depends(database.get_db), +): + """Retrieve a list of task comments for a specific project and task. + + Args: + project_id (int): The ID of the project. + task_id (int): The ID of the task. + db (Session, optional): The database session. + + Returns: + List[tasks_schemas.TaskCommentResponse]: A list of task comments. + """ + task_comment_list = await tasks_crud.get_task_comments(db, project_id, task_id) + + return task_comment_list + + +@router.post("/task-comments/", response_model=tasks_schemas.TaskCommentResponse) +async def add_task_comments( + comment: tasks_schemas.TaskCommentRequest, + db: Session = Depends(database.get_db), + user_data: AuthUser = Depends(login_required), +): + """Create a new task comment. + + Parameters: + comment (TaskCommentRequest): The task comment to be created. + db (Session): The database session. + user_data (AuthUser): The authenticated user. + + Returns: + TaskCommentResponse: The created task comment. + """ + task_comment_list = await tasks_crud.add_task_comments(db, comment, user_data) + return task_comment_list + + @router.get("/activity/", response_model=List[tasks_schemas.TaskHistoryCount]) async def task_activity( project_id: int, days: int = 10, db: Session = Depends(database.get_db) @@ -202,19 +231,38 @@ async def task_activity( """Retrieves the validate and mapped task count for a specific project. Args: - project_id: The ID of the project. - days: The number of days to consider for the - task activity (default: 10). - db: The database session. + project_id (int): The ID of the project. + days (int): The number of days to consider for the + task activity (default: 10). + db (Session): The database session. Returns: list[TaskHistoryCount]: A list of task history counts. """ end_date = datetime.now() - timedelta(days=days) - task_history = tasks_crud.get_task_history(project_id, end_date, db) + task_history = await tasks_crud.get_project_task_history(project_id, end_date, db) return await tasks_crud.count_validated_and_mapped_tasks( task_history, end_date, ) + + +@router.get("/task_history/", response_model=List[tasks_schemas.TaskHistory]) +async def task_history( + project_id: int, days: int = 10, db: Session = Depends(database.get_db) +): + """Get the detailed task history for a project. + + Args: + project_id (int): The ID of the project. + days (int): The number of days to consider for the + task activity (default: 10). + db (Session): The database session. + + Returns: + List[TaskHistory]: A list of task history. + """ + end_date = datetime.now() - timedelta(days=days) + return await tasks_crud.get_project_task_history(project_id, end_date, db) diff --git a/src/backend/app/tasks/tasks_schemas.py b/src/backend/app/tasks/tasks_schemas.py index 3389c01bb0..59ec3a43ce 100644 --- a/src/backend/app/tasks/tasks_schemas.py +++ b/src/backend/app/tasks/tasks_schemas.py @@ -20,9 +20,9 @@ from datetime import datetime from typing import Any, List, Optional -from geojson_pydantic import Feature +from geojson_pydantic import Feature as GeojsonFeature from loguru import logger as log -from pydantic import BaseModel, ConfigDict, Field, ValidationInfo +from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, computed_field from pydantic.functional_serializers import field_serializer from pydantic.functional_validators import field_validator @@ -70,10 +70,10 @@ class Task(BaseModel): id: int project_id: int project_task_index: int - project_task_name: str - outline_geojson: Optional[Feature] = None - outline_centroid: Optional[Feature] = None - initial_feature_count: Optional[int] = None + project_task_name: Optional[str] + outline_geojson: Optional[GeojsonFeature] = None + outline_centroid: Optional[GeojsonFeature] = None + feature_count: Optional[int] = None task_status: TaskStatus locked_by_uid: Optional[int] = None locked_by_username: Optional[str] = None @@ -96,7 +96,9 @@ def get_geojson_from_outline(cls, value: Any, info: ValidationInfo) -> str: @field_validator("outline_centroid", mode="before") @classmethod - def get_centroid_from_outline(cls, value: Any, info: ValidationInfo) -> str: + def get_centroid_from_outline( + cls, value: Any, info: ValidationInfo + ) -> Optional[str]: """Get outline_centroid from Shapely geom.""" if outline := info.data.get("outline"): properties = { @@ -109,21 +111,21 @@ def get_centroid_from_outline(cls, value: Any, info: ValidationInfo) -> str: return None @field_serializer("locked_by_uid") - def get_locked_by_uid(self, value: str) -> str: + def get_locked_by_uid(self, value: str) -> Optional[str]: """Get lock uid from lock_holder details.""" if self.lock_holder: return self.lock_holder.id return None @field_serializer("locked_by_username") - def get_locked_by_username(self, value: str) -> str: + def get_locked_by_username(self, value: str) -> Optional[str]: """Get lock username from lock_holder details.""" if self.lock_holder: return self.lock_holder.username return None @field_serializer("odk_token") - def decrypt_password(self, value: str) -> str: + def decrypt_password(self, value: str) -> Optional[str]: """Decrypt the ODK Token extracted from the db.""" if not value: return "" @@ -131,7 +133,76 @@ def decrypt_password(self, value: str) -> str: return decrypt_value(value) +class TaskCommentResponse(BaseModel): + """Task mapping history.""" + + id: int + task_id: int + comment: Optional[str] = None + commented_by: str + created_at: datetime + + +class TaskCommentBase(BaseModel): + """Task mapping history.""" + + comment: str + commented_by: str + created_at: datetime + + +class TaskCommentRequest(BaseModel): + """Task mapping history.""" + + task_id: int + project_id: int + comment: str + + class ReadTask(Task): """Task details plus updated task history.""" task_history: Optional[List[TaskHistoryOut]] = None + + +class TaskHistory(BaseModel): + """Task history details.""" + + model_config = ConfigDict( + from_attributes=True, + ) + + # Excluded + user: Any = Field(exclude=True) + + task_id: int + action_text: str + action_date: datetime + + @computed_field + @property + def username(self) -> Optional[str]: + """Get username from user db obj.""" + if self.user: + return self.user.username + return None + + @computed_field + @property + def profile_img(self) -> Optional[str]: + """Get profile_img from user db obj.""" + if self.user: + return self.user.profile_img + return None + + @computed_field + @property + def status(self) -> Optional[str]: + """Extract status from standard format action_text.""" + if self.action_text: + split_text = self.action_text.split() + if len(split_text) > 5: + return split_text[5] + else: + return self.action_text + return None diff --git a/src/backend/migrate-entrypoint.sh b/src/backend/migrate-entrypoint.sh index f7cee00b89..24c4d2f178 100644 --- a/src/backend/migrate-entrypoint.sh +++ b/src/backend/migrate-entrypoint.sh @@ -128,7 +128,7 @@ backup_db() { --host "$FMTM_DB_HOST" --username "$FMTM_DB_USER" "$FMTM_DB_NAME" echo "gzipping file --> ${db_backup_file}.gz" - gzip "$db_backup_file" + gzip --force "$db_backup_file" db_backup_file="${db_backup_file}.gz" BUCKET_NAME="fmtm-db-backups" @@ -145,10 +145,11 @@ execute_migrations() { for script_name in "${scripts_to_execute[@]}"; do script_file="/opt/migrations/$script_name" pretty_echo "Executing migration: $script_name" - psql "$db_url" -a -f "$script_file" - - # Add an entry in the migrations table to indicate completion - psql "$db_url" < tasks.feature_count. + +-- Start a transaction +BEGIN; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'tasks' AND column_name = 'initial_feature_count') THEN + ALTER TABLE public.tasks RENAME COLUMN initial_feature_count TO feature_count; + END IF; +END $$; + +-- Commit the transaction +COMMIT; diff --git a/src/backend/migrations/init/fmtm_base_schema.sql b/src/backend/migrations/init/fmtm_base_schema.sql index f7c07219f6..1be9d91310 100644 --- a/src/backend/migrations/init/fmtm_base_schema.sql +++ b/src/backend/migrations/init/fmtm_base_schema.sql @@ -179,26 +179,6 @@ CREATE TABLE public.background_tasks ( ALTER TABLE public.background_tasks OWNER TO fmtm; -CREATE TABLE public.features ( - id integer NOT NULL, - project_id integer, - category_title character varying, - task_id integer, - properties jsonb, - geometry public.geometry(Geometry,4326) -); -ALTER TABLE public.features OWNER TO fmtm; -CREATE SEQUENCE public.features_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; -ALTER TABLE public.features_id_seq OWNER TO fmtm; -ALTER SEQUENCE public.features_id_seq OWNED BY public.features.id; - - CREATE TABLE public.licenses ( id integer NOT NULL, name character varying, @@ -335,6 +315,7 @@ ALTER TABLE public.project_teams OWNER TO fmtm; CREATE TABLE public.projects ( id integer NOT NULL, + organisation_id integer, odkid integer, author_id bigint NOT NULL, created timestamp without time zone NOT NULL, @@ -352,7 +333,6 @@ CREATE TABLE public.projects ( featured boolean, mapping_permission public.mappingpermission, validation_permission public.validationpermission, - organisation_id integer, due_date timestamp without time zone, changeset_comment character varying, osmcha_filter_id character varying, @@ -371,6 +351,7 @@ CREATE TABLE public.projects ( form_xls bytea, form_config_file bytea, data_extract_type character varying, + data_extract_url character varying, task_split_type character varying, hashtags character varying[] ); @@ -459,7 +440,7 @@ CREATE TABLE public.tasks ( project_task_name character varying, outline public.geometry(Polygon,4326), geometry_geojson character varying, - initial_feature_count integer, + feature_count integer, task_status public.taskstatus, locked_by bigint, mapped_by bigint, @@ -530,18 +511,9 @@ CREATE TABLE public.users ( tasks_invalidated integer NOT NULL, projects_mapped integer[], date_registered timestamp without time zone, - last_validation_date timestamp without time zone, - password character varying + last_validation_date timestamp without time zone ); ALTER TABLE public.users OWNER TO fmtm; -CREATE SEQUENCE public.users_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; -ALTER TABLE public.users_id_seq OWNER TO fmtm; -ALTER SEQUENCE public.users_id_seq OWNED BY public.users.id; CREATE TABLE public.xlsforms ( id integer NOT NULL, @@ -565,7 +537,6 @@ ALTER SEQUENCE public.xlsforms_id_seq OWNED BY public.xlsforms.id; -- nextval for primary keys (autoincrement) -ALTER TABLE ONLY public.features ALTER COLUMN id SET DEFAULT nextval('public.features_id_seq'::regclass); ALTER TABLE ONLY public.licenses ALTER COLUMN id SET DEFAULT nextval('public.licenses_id_seq'::regclass); ALTER TABLE ONLY public.mapping_issue_categories ALTER COLUMN id SET DEFAULT nextval('public.mapping_issue_categories_id_seq'::regclass); ALTER TABLE ONLY public.mbtiles_path ALTER COLUMN id SET DEFAULT nextval('public.mbtiles_path_id_seq'::regclass); @@ -577,7 +548,6 @@ ALTER TABLE ONLY public.task_invalidation_history ALTER COLUMN id SET DEFAULT ne ALTER TABLE ONLY public.task_mapping_issues ALTER COLUMN id SET DEFAULT nextval('public.task_mapping_issues_id_seq'::regclass); ALTER TABLE ONLY public.tasks ALTER COLUMN id SET DEFAULT nextval('public.tasks_id_seq'::regclass); ALTER TABLE ONLY public.teams ALTER COLUMN id SET DEFAULT nextval('public.teams_id_seq'::regclass); -ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_id_seq'::regclass); ALTER TABLE ONLY public.xlsforms ALTER COLUMN id SET DEFAULT nextval('public.xlsforms_id_seq'::regclass); @@ -589,9 +559,6 @@ ALTER TABLE public."_migrations" ALTER TABLE ONLY public.background_tasks ADD CONSTRAINT background_tasks_pkey PRIMARY KEY (id); -ALTER TABLE ONLY public.features - ADD CONSTRAINT features_pkey PRIMARY KEY (id); - ALTER TABLE ONLY public.licenses ADD CONSTRAINT licenses_name_key UNIQUE (name); @@ -667,8 +634,6 @@ ALTER TABLE ONLY public.xlsforms -- Indexing -CREATE INDEX idx_features_composite ON public.features USING btree (task_id, project_id); -CREATE INDEX idx_features_geometry ON public.features USING gist (geometry); CREATE INDEX idx_geometry ON public.projects USING gist (outline); CREATE INDEX idx_projects_centroid ON public.projects USING gist (centroid); CREATE INDEX idx_projects_outline ON public.projects USING gist (outline); @@ -690,13 +655,11 @@ CREATE INDEX ix_tasks_project_id ON public.tasks USING btree (project_id); CREATE INDEX ix_tasks_validated_by ON public.tasks USING btree (validated_by); CREATE INDEX ix_users_id ON public.users USING btree (id); CREATE INDEX textsearch_idx ON public.project_info USING btree (text_searchable); - +CREATE INDEX idx_user_roles ON public.user_roles USING btree (project_id, user_id); +CREATE INDEX idx_org_managers ON public.organisation_managers USING btree (user_id, organisation_id); -- Foreign keys -ALTER TABLE ONLY public.features - ADD CONSTRAINT features_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id); - ALTER TABLE ONLY public.task_invalidation_history ADD CONSTRAINT fk_invalidation_history FOREIGN KEY (invalidation_history_id) REFERENCES public.task_history(id); @@ -721,9 +684,6 @@ ALTER TABLE ONLY public.projects ALTER TABLE ONLY public.task_history ADD CONSTRAINT fk_tasks FOREIGN KEY (task_id, project_id) REFERENCES public.tasks(id, project_id); -ALTER TABLE ONLY public.features - ADD CONSTRAINT fk_tasks FOREIGN KEY (task_id, project_id) REFERENCES public.tasks(id, project_id); - ALTER TABLE ONLY public.task_invalidation_history ADD CONSTRAINT fk_tasks FOREIGN KEY (task_id, project_id) REFERENCES public.tasks(id, project_id); @@ -748,9 +708,6 @@ ALTER TABLE ONLY public.task_invalidation_history ALTER TABLE ONLY public.projects ADD CONSTRAINT fk_xform FOREIGN KEY (xform_title) REFERENCES public.xlsforms(title); -ALTER TABLE ONLY public.features - ADD CONSTRAINT fk_xform FOREIGN KEY (category_title) REFERENCES public.xlsforms(title); - ALTER TABLE ONLY public.organisation_managers ADD CONSTRAINT organisation_managers_organisation_id_fkey FOREIGN KEY (organisation_id) REFERENCES public.organisations(id); diff --git a/src/backend/migrations/revert/005-remove-qrcode.sql b/src/backend/migrations/revert/005-remove-qrcode.sql index 7421b0c764..ca555f0eb1 100644 --- a/src/backend/migrations/revert/005-remove-qrcode.sql +++ b/src/backend/migrations/revert/005-remove-qrcode.sql @@ -21,7 +21,7 @@ ALTER TABLE ONLY public.qr_code ALTER COLUMN id SET DEFAULT nextval('public.qr_c ALTER TABLE ONLY public.qr_code ADD CONSTRAINT qr_code_pkey PRIMARY KEY (id); --- Update field in projects table +-- Update field in tasks table ALTER TABLE IF EXISTS public.tasks DROP COLUMN IF EXISTS odk_token, ADD COLUMN IF NOT EXISTS qr_code_id integer; diff --git a/src/backend/migrations/revert/006-index-roles.sql b/src/backend/migrations/revert/006-index-roles.sql new file mode 100644 index 0000000000..ef3bcb39b5 --- /dev/null +++ b/src/backend/migrations/revert/006-index-roles.sql @@ -0,0 +1,8 @@ +-- Start a transaction +BEGIN; + +DROP INDEX IF EXISTS idx_user_roles; +DROP INDEX IF EXISTS idx_org_managers; + +-- Commit the transaction +COMMIT; diff --git a/src/backend/migrations/revert/007-add-extract-url.sql b/src/backend/migrations/revert/007-add-extract-url.sql new file mode 100644 index 0000000000..c80ecf0a8f --- /dev/null +++ b/src/backend/migrations/revert/007-add-extract-url.sql @@ -0,0 +1,9 @@ +-- Start a transaction +BEGIN; + +-- Update field in projects table +ALTER TABLE IF EXISTS public.projects + DROP COLUMN IF EXISTS data_extract_url; + +-- Commit the transaction +COMMIT; diff --git a/src/backend/migrations/revert/008-add-user-in-org.sql b/src/backend/migrations/revert/008-add-user-in-org.sql new file mode 100644 index 0000000000..f0efa86281 --- /dev/null +++ b/src/backend/migrations/revert/008-add-user-in-org.sql @@ -0,0 +1,9 @@ +-- Start a transaction +BEGIN; + +-- Drop the column created_by +ALTER TABLE organisations +DROP COLUMN IF EXISTS created_by; + +-- Commit the transaction +COMMIT; diff --git a/src/backend/migrations/revert/009-add-community-type.sql b/src/backend/migrations/revert/009-add-community-type.sql new file mode 100644 index 0000000000..82122217bf --- /dev/null +++ b/src/backend/migrations/revert/009-add-community-type.sql @@ -0,0 +1,11 @@ +BEGIN; + +-- Remove the community_type column from organisations table +ALTER TABLE public.organisations + DROP COLUMN IF EXISTS community_type; + +-- Drop the communitytype enum +DROP TYPE IF EXISTS public.communitytype; + +-- Commit the transaction +COMMIT; diff --git a/src/backend/migrations/revert/010-drop-features-table.sql b/src/backend/migrations/revert/010-drop-features-table.sql new file mode 100644 index 0000000000..5da58ca991 --- /dev/null +++ b/src/backend/migrations/revert/010-drop-features-table.sql @@ -0,0 +1,40 @@ +-- Start a transaction +BEGIN; + +-- Add qr_code table +CREATE TABLE IF NOT EXISTS public.features ( + id integer NOT NULL, + project_id integer, + category_title character varying, + task_id integer, + properties jsonb, + geometry public.geometry(Geometry,4326) +); +ALTER TABLE public.features OWNER TO fmtm; + +CREATE SEQUENCE public.features_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; +ALTER TABLE public.features_id_seq OWNER TO fmtm; +ALTER SEQUENCE public.features_id_seq OWNED BY public.features.id; + +ALTER TABLE ONLY public.features ALTER COLUMN id SET DEFAULT nextval('public.features_id_seq'::regclass); +ALTER TABLE ONLY public.features + ADD CONSTRAINT features_pkey PRIMARY KEY (id); + +CREATE INDEX idx_features_composite ON public.features USING btree (task_id, project_id); +CREATE INDEX idx_features_geometry ON public.features USING gist (geometry); + +ALTER TABLE ONLY public.features + ADD CONSTRAINT features_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id); +ALTER TABLE ONLY public.features + ADD CONSTRAINT fk_tasks FOREIGN KEY (task_id, project_id) REFERENCES public.tasks(id, project_id); +ALTER TABLE ONLY public.features + ADD CONSTRAINT fk_xform FOREIGN KEY (category_title) REFERENCES public.xlsforms(title); + +-- Commit the transaction +COMMIT; diff --git a/src/backend/migrations/revert/011-task-features-count.sql b/src/backend/migrations/revert/011-task-features-count.sql new file mode 100644 index 0000000000..5549180122 --- /dev/null +++ b/src/backend/migrations/revert/011-task-features-count.sql @@ -0,0 +1,8 @@ +-- Start a transaction +BEGIN; + +ALTER TABLE IF EXISTS public.tasks + RENAME COLUMN feature_count TO initial_feature_count; + +-- Commit the transaction +COMMIT; diff --git a/src/backend/pdm.lock b/src/backend/pdm.lock index e872692d74..99fbcd59f0 100644 --- a/src/backend/pdm.lock +++ b/src/backend/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "debug", "dev", "docs", "test"] strategy = ["cross_platform"] lock_version = "4.4.1" -content_hash = "sha256:73bb79db4e82351bb07d7b0faf51b90fc523e0d3f316dca54e14dffee0bc077e" +content_hash = "sha256:5c5e8c3b2967b21e3ab4ddbd408a5931d7299eeda76ee2ec9d4bafb2d1bc42ce" [[package]] name = "annotated-types" @@ -34,12 +34,12 @@ files = [ [[package]] name = "argcomplete" -version = "3.1.6" +version = "3.2.2" requires_python = ">=3.8" summary = "Bash tab completion for argparse" files = [ - {file = "argcomplete-3.1.6-py3-none-any.whl", hash = "sha256:71f4683bc9e6b0be85f2b2c1224c47680f210903e23512cfebfe5a41edfd883a"}, - {file = "argcomplete-3.1.6.tar.gz", hash = "sha256:3b1f07d133332547a53c79437527c00be48cca3807b1d4ca5cab1b26313386a6"}, + {file = "argcomplete-3.2.2-py3-none-any.whl", hash = "sha256:e44f4e7985883ab3e73a103ef0acd27299dbfe2dfed00142c35d4ddd3005901d"}, + {file = "argcomplete-3.2.2.tar.gz", hash = "sha256:f3e49e8ea59b4026ee29548e24488af46e30c9de57d48638e24f54a1ea1000a2"}, ] [[package]] @@ -134,7 +134,7 @@ files = [ [[package]] name = "black" -version = "23.12.1" +version = "24.2.0" requires_python = ">=3.8" summary = "The uncompromising code formatter." dependencies = [ @@ -147,30 +147,30 @@ dependencies = [ "typing-extensions>=4.0.1; python_version < \"3.11\"", ] files = [ - {file = "black-23.12.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0aaf6041986767a5e0ce663c7a2f0e9eaf21e6ff87a5f95cbf3675bfd4c41d2"}, - {file = "black-23.12.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c88b3711d12905b74206227109272673edce0cb29f27e1385f33b0163c414bba"}, - {file = "black-23.12.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a920b569dc6b3472513ba6ddea21f440d4b4c699494d2e972a1753cdc25df7b0"}, - {file = "black-23.12.1-cp310-cp310-win_amd64.whl", hash = "sha256:3fa4be75ef2a6b96ea8d92b1587dd8cb3a35c7e3d51f0738ced0781c3aa3a5a3"}, - {file = "black-23.12.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8d4df77958a622f9b5a4c96edb4b8c0034f8434032ab11077ec6c56ae9f384ba"}, - {file = "black-23.12.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:602cfb1196dc692424c70b6507593a2b29aac0547c1be9a1d1365f0d964c353b"}, - {file = "black-23.12.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c4352800f14be5b4864016882cdba10755bd50805c95f728011bcb47a4afd59"}, - {file = "black-23.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:0808494f2b2df923ffc5723ed3c7b096bd76341f6213989759287611e9837d50"}, - {file = "black-23.12.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:25e57fd232a6d6ff3f4478a6fd0580838e47c93c83eaf1ccc92d4faf27112c4e"}, - {file = "black-23.12.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d9e13db441c509a3763a7a3d9a49ccc1b4e974a47be4e08ade2a228876500ec"}, - {file = "black-23.12.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d1bd9c210f8b109b1762ec9fd36592fdd528485aadb3f5849b2740ef17e674e"}, - {file = "black-23.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:ae76c22bde5cbb6bfd211ec343ded2163bba7883c7bc77f6b756a1049436fbb9"}, - {file = "black-23.12.1-py3-none-any.whl", hash = "sha256:78baad24af0f033958cad29731e27363183e140962595def56423e626f4bee3e"}, - {file = "black-23.12.1.tar.gz", hash = "sha256:4ce3ef14ebe8d9509188014d96af1c456a910d5b5cbf434a09fef7e024b3d0d5"}, + {file = "black-24.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6981eae48b3b33399c8757036c7f5d48a535b962a7c2310d19361edeef64ce29"}, + {file = "black-24.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d533d5e3259720fdbc1b37444491b024003e012c5173f7d06825a77508085430"}, + {file = "black-24.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61a0391772490ddfb8a693c067df1ef5227257e72b0e4108482b8d41b5aee13f"}, + {file = "black-24.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:992e451b04667116680cb88f63449267c13e1ad134f30087dec8527242e9862a"}, + {file = "black-24.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:163baf4ef40e6897a2a9b83890e59141cc8c2a98f2dda5080dc15c00ee1e62cd"}, + {file = "black-24.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e37c99f89929af50ffaf912454b3e3b47fd64109659026b678c091a4cd450fb2"}, + {file = "black-24.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9de21bafcba9683853f6c96c2d515e364aee631b178eaa5145fc1c61a3cc92"}, + {file = "black-24.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:9db528bccb9e8e20c08e716b3b09c6bdd64da0dd129b11e160bf082d4642ac23"}, + {file = "black-24.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d84f29eb3ee44859052073b7636533ec995bd0f64e2fb43aeceefc70090e752b"}, + {file = "black-24.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e08fb9a15c914b81dd734ddd7fb10513016e5ce7e6704bdd5e1251ceee51ac9"}, + {file = "black-24.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:810d445ae6069ce64030c78ff6127cd9cd178a9ac3361435708b907d8a04c693"}, + {file = "black-24.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ba15742a13de85e9b8f3239c8f807723991fbfae24bad92d34a2b12e81904982"}, + {file = "black-24.2.0-py3-none-any.whl", hash = "sha256:e8a6ae970537e67830776488bca52000eaa37fa63b9988e8c487458d9cd5ace6"}, + {file = "black-24.2.0.tar.gz", hash = "sha256:bce4f25c27c3435e4dace4815bcb2008b87e167e3bf4ee47ccdc5ce906eb4894"}, ] [[package]] name = "certifi" -version = "2023.11.17" +version = "2024.2.2" requires_python = ">=3.6" summary = "Python package for providing Mozilla's CA Bundle." files = [ - {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, - {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, + {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, + {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, ] [[package]] @@ -342,15 +342,15 @@ files = [ [[package]] name = "commitizen" -version = "3.13.0" +version = "3.15.0" requires_python = ">=3.8" summary = "Python commitizen client tool" dependencies = [ - "argcomplete<3.2,>=1.12.1", + "argcomplete<3.3,>=1.12.1", "charset-normalizer<4,>=2.1.0", "colorama<0.5.0,>=0.4.1", "decli<0.7.0,>=0.6.0", - "importlib-metadata<7,>=4.13", + "importlib-metadata<8,>=4.13", "jinja2>=2.10.3", "packaging>=19", "pyyaml>=3.08", @@ -359,48 +359,48 @@ dependencies = [ "tomlkit<1.0.0,>=0.5.3", ] files = [ - {file = "commitizen-3.13.0-py3-none-any.whl", hash = "sha256:ff57069591ff109136b70841fe79a3434d0525748995531cceb4f3ccadb44ead"}, - {file = "commitizen-3.13.0.tar.gz", hash = "sha256:53cd225ae44fc25cb1582f5d50cda78711a5a1d44a32fee3dcf7a22bc204ce06"}, + {file = "commitizen-3.15.0-py3-none-any.whl", hash = "sha256:5f9f9097f1f14c943982fe159905b1e895f4686f15b7aaa62a1e913fa89f2d6f"}, + {file = "commitizen-3.15.0.tar.gz", hash = "sha256:10b9cc1013a87aaca30562f9f5ac6ddaad47c336f7eee6fbfd11e92b820eee39"}, ] [[package]] name = "coverage" -version = "7.4.0" +version = "7.4.2" requires_python = ">=3.8" summary = "Code coverage measurement for Python" files = [ - {file = "coverage-7.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36b0ea8ab20d6a7564e89cb6135920bc9188fb5f1f7152e94e8300b7b189441a"}, - {file = "coverage-7.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0676cd0ba581e514b7f726495ea75aba3eb20899d824636c6f59b0ed2f88c471"}, - {file = "coverage-7.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ca5c71a5a1765a0f8f88022c52b6b8be740e512980362f7fdbb03725a0d6b9"}, - {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7c97726520f784239f6c62506bc70e48d01ae71e9da128259d61ca5e9788516"}, - {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:815ac2d0f3398a14286dc2cea223a6f338109f9ecf39a71160cd1628786bc6f5"}, - {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:80b5ee39b7f0131ebec7968baa9b2309eddb35b8403d1869e08f024efd883566"}, - {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5b2ccb7548a0b65974860a78c9ffe1173cfb5877460e5a229238d985565574ae"}, - {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:995ea5c48c4ebfd898eacb098164b3cc826ba273b3049e4a889658548e321b43"}, - {file = "coverage-7.4.0-cp310-cp310-win32.whl", hash = "sha256:79287fd95585ed36e83182794a57a46aeae0b64ca53929d1176db56aacc83451"}, - {file = "coverage-7.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:5b14b4f8760006bfdb6e08667af7bc2d8d9bfdb648351915315ea17645347137"}, - {file = "coverage-7.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:04387a4a6ecb330c1878907ce0dc04078ea72a869263e53c72a1ba5bbdf380ca"}, - {file = "coverage-7.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea81d8f9691bb53f4fb4db603203029643caffc82bf998ab5b59ca05560f4c06"}, - {file = "coverage-7.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74775198b702868ec2d058cb92720a3c5a9177296f75bd97317c787daf711505"}, - {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76f03940f9973bfaee8cfba70ac991825611b9aac047e5c80d499a44079ec0bc"}, - {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:485e9f897cf4856a65a57c7f6ea3dc0d4e6c076c87311d4bc003f82cfe199d25"}, - {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6ae8c9d301207e6856865867d762a4b6fd379c714fcc0607a84b92ee63feff70"}, - {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bf477c355274a72435ceb140dc42de0dc1e1e0bf6e97195be30487d8eaaf1a09"}, - {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:83c2dda2666fe32332f8e87481eed056c8b4d163fe18ecc690b02802d36a4d26"}, - {file = "coverage-7.4.0-cp311-cp311-win32.whl", hash = "sha256:697d1317e5290a313ef0d369650cfee1a114abb6021fa239ca12b4849ebbd614"}, - {file = "coverage-7.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:26776ff6c711d9d835557ee453082025d871e30b3fd6c27fcef14733f67f0590"}, - {file = "coverage-7.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:13eaf476ec3e883fe3e5fe3707caeb88268a06284484a3daf8250259ef1ba143"}, - {file = "coverage-7.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846f52f46e212affb5bcf131c952fb4075b55aae6b61adc9856222df89cbe3e2"}, - {file = "coverage-7.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26f66da8695719ccf90e794ed567a1549bb2644a706b41e9f6eae6816b398c4a"}, - {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:164fdcc3246c69a6526a59b744b62e303039a81e42cfbbdc171c91a8cc2f9446"}, - {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:316543f71025a6565677d84bc4df2114e9b6a615aa39fb165d697dba06a54af9"}, - {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bb1de682da0b824411e00a0d4da5a784ec6496b6850fdf8c865c1d68c0e318dd"}, - {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:0e8d06778e8fbffccfe96331a3946237f87b1e1d359d7fbe8b06b96c95a5407a"}, - {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a56de34db7b7ff77056a37aedded01b2b98b508227d2d0979d373a9b5d353daa"}, - {file = "coverage-7.4.0-cp312-cp312-win32.whl", hash = "sha256:51456e6fa099a8d9d91497202d9563a320513fcf59f33991b0661a4a6f2ad450"}, - {file = "coverage-7.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd3c1e4cb2ff0083758f09be0f77402e1bdf704adb7f89108007300a6da587d0"}, - {file = "coverage-7.4.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:c530833afc4707fe48524a44844493f36d8727f04dcce91fb978c414a8556cc6"}, - {file = "coverage-7.4.0.tar.gz", hash = "sha256:707c0f58cb1712b8809ece32b68996ee1e609f71bd14615bd8f87a1293cb610e"}, + {file = "coverage-7.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bf54c3e089179d9d23900e3efc86d46e4431188d9a657f345410eecdd0151f50"}, + {file = "coverage-7.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fe6e43c8b510719b48af7db9631b5fbac910ade4bd90e6378c85ac5ac706382c"}, + {file = "coverage-7.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b98c89db1b150d851a7840142d60d01d07677a18f0f46836e691c38134ed18b"}, + {file = "coverage-7.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5f9683be6a5b19cd776ee4e2f2ffb411424819c69afab6b2db3a0a364ec6642"}, + {file = "coverage-7.4.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78cdcbf7b9cb83fe047ee09298e25b1cd1636824067166dc97ad0543b079d22f"}, + {file = "coverage-7.4.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2599972b21911111114100d362aea9e70a88b258400672626efa2b9e2179609c"}, + {file = "coverage-7.4.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ef00d31b7569ed3cb2036f26565f1984b9fc08541731ce01012b02a4c238bf03"}, + {file = "coverage-7.4.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:20a875bfd8c282985c4720c32aa05056f77a68e6d8bbc5fe8632c5860ee0b49b"}, + {file = "coverage-7.4.2-cp310-cp310-win32.whl", hash = "sha256:b3f2b1eb229f23c82898eedfc3296137cf1f16bb145ceab3edfd17cbde273fb7"}, + {file = "coverage-7.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:7df95fdd1432a5d2675ce630fef5f239939e2b3610fe2f2b5bf21fa505256fa3"}, + {file = "coverage-7.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a8ddbd158e069dded57738ea69b9744525181e99974c899b39f75b2b29a624e2"}, + {file = "coverage-7.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81a5fb41b0d24447a47543b749adc34d45a2cf77b48ca74e5bf3de60a7bd9edc"}, + {file = "coverage-7.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2412e98e70f16243be41d20836abd5f3f32edef07cbf8f407f1b6e1ceae783ac"}, + {file = "coverage-7.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddb79414c15c6f03f56cc68fa06994f047cf20207c31b5dad3f6bab54a0f66ef"}, + {file = "coverage-7.4.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf89ab85027427d351f1de918aff4b43f4eb5f33aff6835ed30322a86ac29c9e"}, + {file = "coverage-7.4.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a178b7b1ac0f1530bb28d2e51f88c0bab3e5949835851a60dda80bff6052510c"}, + {file = "coverage-7.4.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:06fe398145a2e91edaf1ab4eee66149c6776c6b25b136f4a86fcbbb09512fd10"}, + {file = "coverage-7.4.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:18cac867950943fe93d6cd56a67eb7dcd2d4a781a40f4c1e25d6f1ed98721a55"}, + {file = "coverage-7.4.2-cp311-cp311-win32.whl", hash = "sha256:f72cdd2586f9a769570d4b5714a3837b3a59a53b096bb954f1811f6a0afad305"}, + {file = "coverage-7.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:d779a48fac416387dd5673fc5b2d6bd903ed903faaa3247dc1865c65eaa5a93e"}, + {file = "coverage-7.4.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:adbdfcda2469d188d79771d5696dc54fab98a16d2ef7e0875013b5f56a251047"}, + {file = "coverage-7.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ac4bab32f396b03ebecfcf2971668da9275b3bb5f81b3b6ba96622f4ef3f6e17"}, + {file = "coverage-7.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:006d220ba2e1a45f1de083d5022d4955abb0aedd78904cd5a779b955b019ec73"}, + {file = "coverage-7.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3733545eb294e5ad274abe131d1e7e7de4ba17a144505c12feca48803fea5f64"}, + {file = "coverage-7.4.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42a9e754aa250fe61f0f99986399cec086d7e7a01dd82fd863a20af34cbce962"}, + {file = "coverage-7.4.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2ed37e16cf35c8d6e0b430254574b8edd242a367a1b1531bd1adc99c6a5e00fe"}, + {file = "coverage-7.4.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b953275d4edfab6cc0ed7139fa773dfb89e81fee1569a932f6020ce7c6da0e8f"}, + {file = "coverage-7.4.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32b4ab7e6c924f945cbae5392832e93e4ceb81483fd6dc4aa8fb1a97b9d3e0e1"}, + {file = "coverage-7.4.2-cp312-cp312-win32.whl", hash = "sha256:f5df76c58977bc35a49515b2fbba84a1d952ff0ec784a4070334dfbec28a2def"}, + {file = "coverage-7.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:34423abbaad70fea9d0164add189eabaea679068ebdf693baa5c02d03e7db244"}, + {file = "coverage-7.4.2-pp38.pp39.pp310-none-any.whl", hash = "sha256:f593a4a90118d99014517c2679e04a4ef5aee2d81aa05c26c734d271065efcb6"}, + {file = "coverage-7.4.2.tar.gz", hash = "sha256:1a5ee18e3a8d766075ce9314ed1cb695414bae67df6a4b0805f5137d93d6f1cb"}, ] [[package]] @@ -417,63 +417,67 @@ files = [ [[package]] name = "cryptography" -version = "42.0.1" +version = "42.0.4" requires_python = ">=3.7" summary = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." dependencies = [ "cffi>=1.12; platform_python_implementation != \"PyPy\"", ] files = [ - {file = "cryptography-42.0.1-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:265bdc693570b895eb641410b8fc9e8ddbce723a669236162b9d9cfb70bd8d77"}, - {file = "cryptography-42.0.1-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:160fa08dfa6dca9cb8ad9bd84e080c0db6414ba5ad9a7470bc60fb154f60111e"}, - {file = "cryptography-42.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:727387886c9c8de927c360a396c5edcb9340d9e960cda145fca75bdafdabd24c"}, - {file = "cryptography-42.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d84673c012aa698555d4710dcfe5f8a0ad76ea9dde8ef803128cc669640a2e0"}, - {file = "cryptography-42.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e6edc3a568667daf7d349d7e820783426ee4f1c0feab86c29bd1d6fe2755e009"}, - {file = "cryptography-42.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:d50718dd574a49d3ef3f7ef7ece66ef281b527951eb2267ce570425459f6a404"}, - {file = "cryptography-42.0.1-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:9544492e8024f29919eac2117edd8c950165e74eb551a22c53f6fdf6ba5f4cb8"}, - {file = "cryptography-42.0.1-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ab6b302d51fbb1dd339abc6f139a480de14d49d50f65fdc7dff782aa8631d035"}, - {file = "cryptography-42.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2fe16624637d6e3e765530bc55caa786ff2cbca67371d306e5d0a72e7c3d0407"}, - {file = "cryptography-42.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ed1b2130f5456a09a134cc505a17fc2830a1a48ed53efd37dcc904a23d7b82fa"}, - {file = "cryptography-42.0.1-cp37-abi3-win32.whl", hash = "sha256:e5edf189431b4d51f5c6fb4a95084a75cef6b4646c934eb6e32304fc720e1453"}, - {file = "cryptography-42.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:6bfd823b336fdcd8e06285ae8883d3d2624d3bdef312a0e2ef905f332f8e9302"}, - {file = "cryptography-42.0.1-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:351db02c1938c8e6b1fee8a78d6b15c5ccceca7a36b5ce48390479143da3b411"}, - {file = "cryptography-42.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:430100abed6d3652208ae1dd410c8396213baee2e01a003a4449357db7dc9e14"}, - {file = "cryptography-42.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dff7a32880a51321f5de7869ac9dde6b1fca00fc1fef89d60e93f215468e824"}, - {file = "cryptography-42.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b512f33c6ab195852595187af5440d01bb5f8dd57cb7a91e1e009a17f1b7ebca"}, - {file = "cryptography-42.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:95d900d19a370ae36087cc728e6e7be9c964ffd8cbcb517fd1efb9c9284a6abc"}, - {file = "cryptography-42.0.1-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:6ac8924085ed8287545cba89dc472fc224c10cc634cdf2c3e2866fe868108e77"}, - {file = "cryptography-42.0.1-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cb2861a9364fa27d24832c718150fdbf9ce6781d7dc246a516435f57cfa31fe7"}, - {file = "cryptography-42.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25ec6e9e81de5d39f111a4114193dbd39167cc4bbd31c30471cebedc2a92c323"}, - {file = "cryptography-42.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9d61fcdf37647765086030d81872488e4cb3fafe1d2dda1d487875c3709c0a49"}, - {file = "cryptography-42.0.1-cp39-abi3-win32.whl", hash = "sha256:16b9260d04a0bfc8952b00335ff54f471309d3eb9d7e8dbfe9b0bd9e26e67881"}, - {file = "cryptography-42.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:7911586fc69d06cd0ab3f874a169433db1bc2f0e40988661408ac06c4527a986"}, - {file = "cryptography-42.0.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:d3594947d2507d4ef7a180a7f49a6db41f75fb874c2fd0e94f36b89bfd678bf2"}, - {file = "cryptography-42.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8d7efb6bf427d2add2f40b6e1e8e476c17508fa8907234775214b153e69c2e11"}, - {file = "cryptography-42.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:126e0ba3cc754b200a2fb88f67d66de0d9b9e94070c5bc548318c8dab6383cb6"}, - {file = "cryptography-42.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:802d6f83233cf9696b59b09eb067e6b4d5ae40942feeb8e13b213c8fad47f1aa"}, - {file = "cryptography-42.0.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0b7cacc142260ada944de070ce810c3e2a438963ee3deb45aa26fd2cee94c9a4"}, - {file = "cryptography-42.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:32ea63ceeae870f1a62e87f9727359174089f7b4b01e4999750827bf10e15d60"}, - {file = "cryptography-42.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d3902c779a92151f134f68e555dd0b17c658e13429f270d8a847399b99235a3f"}, - {file = "cryptography-42.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:50aecd93676bcca78379604ed664c45da82bc1241ffb6f97f6b7392ed5bc6f04"}, - {file = "cryptography-42.0.1.tar.gz", hash = "sha256:fd33f53809bb363cf126bebe7a99d97735988d9b0131a2be59fbf83e1259a5b7"}, + {file = "cryptography-42.0.4-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:ffc73996c4fca3d2b6c1c8c12bfd3ad00def8621da24f547626bf06441400449"}, + {file = "cryptography-42.0.4-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:db4b65b02f59035037fde0998974d84244a64c3265bdef32a827ab9b63d61b18"}, + {file = "cryptography-42.0.4-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad9c385ba8ee025bb0d856714f71d7840020fe176ae0229de618f14dae7a6e2"}, + {file = "cryptography-42.0.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69b22ab6506a3fe483d67d1ed878e1602bdd5912a134e6202c1ec672233241c1"}, + {file = "cryptography-42.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e09469a2cec88fb7b078e16d4adec594414397e8879a4341c6ace96013463d5b"}, + {file = "cryptography-42.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3e970a2119507d0b104f0a8e281521ad28fc26f2820687b3436b8c9a5fcf20d1"}, + {file = "cryptography-42.0.4-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e53dc41cda40b248ebc40b83b31516487f7db95ab8ceac1f042626bc43a2f992"}, + {file = "cryptography-42.0.4-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:c3a5cbc620e1e17009f30dd34cb0d85c987afd21c41a74352d1719be33380885"}, + {file = "cryptography-42.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bfadd884e7280df24d26f2186e4e07556a05d37393b0f220a840b083dc6a824"}, + {file = "cryptography-42.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:01911714117642a3f1792c7f376db572aadadbafcd8d75bb527166009c9f1d1b"}, + {file = "cryptography-42.0.4-cp37-abi3-win32.whl", hash = "sha256:fb0cef872d8193e487fc6bdb08559c3aa41b659a7d9be48b2e10747f47863925"}, + {file = "cryptography-42.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:c1f25b252d2c87088abc8bbc4f1ecbf7c919e05508a7e8628e6875c40bc70923"}, + {file = "cryptography-42.0.4-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:15a1fb843c48b4a604663fa30af60818cd28f895572386e5f9b8a665874c26e7"}, + {file = "cryptography-42.0.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1327f280c824ff7885bdeef8578f74690e9079267c1c8bd7dc5cc5aa065ae52"}, + {file = "cryptography-42.0.4-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ffb03d419edcab93b4b19c22ee80c007fb2d708429cecebf1dd3258956a563a"}, + {file = "cryptography-42.0.4-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:1df6fcbf60560d2113b5ed90f072dc0b108d64750d4cbd46a21ec882c7aefce9"}, + {file = "cryptography-42.0.4-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:44a64043f743485925d3bcac548d05df0f9bb445c5fcca6681889c7c3ab12764"}, + {file = "cryptography-42.0.4-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:3c6048f217533d89f2f8f4f0fe3044bf0b2090453b7b73d0b77db47b80af8dff"}, + {file = "cryptography-42.0.4-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6d0fbe73728c44ca3a241eff9aefe6496ab2656d6e7a4ea2459865f2e8613257"}, + {file = "cryptography-42.0.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:887623fe0d70f48ab3f5e4dbf234986b1329a64c066d719432d0698522749929"}, + {file = "cryptography-42.0.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce8613beaffc7c14f091497346ef117c1798c202b01153a8cc7b8e2ebaaf41c0"}, + {file = "cryptography-42.0.4-cp39-abi3-win32.whl", hash = "sha256:810bcf151caefc03e51a3d61e53335cd5c7316c0a105cc695f0959f2c638b129"}, + {file = "cryptography-42.0.4-cp39-abi3-win_amd64.whl", hash = "sha256:a0298bdc6e98ca21382afe914c642620370ce0470a01e1bef6dd9b5354c36854"}, + {file = "cryptography-42.0.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5f8907fcf57392cd917892ae83708761c6ff3c37a8e835d7246ff0ad251d9298"}, + {file = "cryptography-42.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:12d341bd42cdb7d4937b0cabbdf2a94f949413ac4504904d0cdbdce4a22cbf88"}, + {file = "cryptography-42.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1cdcdbd117681c88d717437ada72bdd5be9de117f96e3f4d50dab3f59fd9ab20"}, + {file = "cryptography-42.0.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0e89f7b84f421c56e7ff69f11c441ebda73b8a8e6488d322ef71746224c20fce"}, + {file = "cryptography-42.0.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f1e85a178384bf19e36779d91ff35c7617c885da487d689b05c1366f9933ad74"}, + {file = "cryptography-42.0.4-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d2a27aca5597c8a71abbe10209184e1a8e91c1fd470b5070a2ea60cafec35bcd"}, + {file = "cryptography-42.0.4-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4e36685cb634af55e0677d435d425043967ac2f3790ec652b2b88ad03b85c27b"}, + {file = "cryptography-42.0.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:f47be41843200f7faec0683ad751e5ef11b9a56a220d57f300376cd8aba81660"}, + {file = "cryptography-42.0.4.tar.gz", hash = "sha256:831a4b37accef30cccd34fcb916a5d7b5be3cbbe27268a02832c3e450aea39cb"}, ] [[package]] name = "debugpy" -version = "1.8.0" +version = "1.8.1" requires_python = ">=3.8" summary = "An implementation of the Debug Adapter Protocol for Python" files = [ - {file = "debugpy-1.8.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:7fb95ca78f7ac43393cd0e0f2b6deda438ec7c5e47fa5d38553340897d2fbdfb"}, - {file = "debugpy-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef9ab7df0b9a42ed9c878afd3eaaff471fce3fa73df96022e1f5c9f8f8c87ada"}, - {file = "debugpy-1.8.0-cp310-cp310-win32.whl", hash = "sha256:a8b7a2fd27cd9f3553ac112f356ad4ca93338feadd8910277aff71ab24d8775f"}, - {file = "debugpy-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:5d9de202f5d42e62f932507ee8b21e30d49aae7e46d5b1dd5c908db1d7068637"}, - {file = "debugpy-1.8.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:ef54404365fae8d45cf450d0544ee40cefbcb9cb85ea7afe89a963c27028261e"}, - {file = "debugpy-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60009b132c91951354f54363f8ebdf7457aeb150e84abba5ae251b8e9f29a8a6"}, - {file = "debugpy-1.8.0-cp311-cp311-win32.whl", hash = "sha256:8cd0197141eb9e8a4566794550cfdcdb8b3db0818bdf8c49a8e8f8053e56e38b"}, - {file = "debugpy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a64093656c4c64dc6a438e11d59369875d200bd5abb8f9b26c1f5f723622e153"}, - {file = "debugpy-1.8.0-py2.py3-none-any.whl", hash = "sha256:9c9b0ac1ce2a42888199df1a1906e45e6f3c9555497643a85e0bf2406e3ffbc4"}, - {file = "debugpy-1.8.0.zip", hash = "sha256:12af2c55b419521e33d5fb21bd022df0b5eb267c3e178f1d374a63a2a6bdccd0"}, + {file = "debugpy-1.8.1-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:3bda0f1e943d386cc7a0e71bfa59f4137909e2ed947fb3946c506e113000f741"}, + {file = "debugpy-1.8.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dda73bf69ea479c8577a0448f8c707691152e6c4de7f0c4dec5a4bc11dee516e"}, + {file = "debugpy-1.8.1-cp310-cp310-win32.whl", hash = "sha256:3a79c6f62adef994b2dbe9fc2cc9cc3864a23575b6e387339ab739873bea53d0"}, + {file = "debugpy-1.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:7eb7bd2b56ea3bedb009616d9e2f64aab8fc7000d481faec3cd26c98a964bcdd"}, + {file = "debugpy-1.8.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:016a9fcfc2c6b57f939673c874310d8581d51a0fe0858e7fac4e240c5eb743cb"}, + {file = "debugpy-1.8.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd97ed11a4c7f6d042d320ce03d83b20c3fb40da892f994bc041bbc415d7a099"}, + {file = "debugpy-1.8.1-cp311-cp311-win32.whl", hash = "sha256:0de56aba8249c28a300bdb0672a9b94785074eb82eb672db66c8144fff673146"}, + {file = "debugpy-1.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:1a9fe0829c2b854757b4fd0a338d93bc17249a3bf69ecf765c61d4c522bb92a8"}, + {file = "debugpy-1.8.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3ebb70ba1a6524d19fa7bb122f44b74170c447d5746a503e36adc244a20ac539"}, + {file = "debugpy-1.8.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2e658a9630f27534e63922ebf655a6ab60c370f4d2fc5c02a5b19baf4410ace"}, + {file = "debugpy-1.8.1-cp312-cp312-win32.whl", hash = "sha256:caad2846e21188797a1f17fc09c31b84c7c3c23baf2516fed5b40b378515bbf0"}, + {file = "debugpy-1.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:edcc9f58ec0fd121a25bc950d4578df47428d72e1a0d66c07403b04eb93bcf98"}, + {file = "debugpy-1.8.1-py2.py3-none-any.whl", hash = "sha256:28acbe2241222b87e255260c76741e1fbf04fdc3b6d094fcf57b6c6f75ce1242"}, + {file = "debugpy-1.8.1.zip", hash = "sha256:f696d6be15be87aef621917585f9bb94b1dc9e8aced570db1b8a6fc14e8f9b42"}, ] [[package]] @@ -611,19 +615,20 @@ files = [ [[package]] name = "fmtm-splitter" -version = "1.0.0" +version = "1.1.2" requires_python = ">=3.10" summary = "A utility for splitting an AOI into multiple tasks." dependencies = [ "geojson>=2.5.0", "geopandas>=0.11.0", "numpy>=1.21.0", + "osm-rawdata>=0.2.2", "psycopg2>=2.9.1", "shapely>=1.8.1", ] files = [ - {file = "fmtm-splitter-1.0.0.tar.gz", hash = "sha256:e6c823b9341f0f58413ee892c2ebb7b91377cddcafb4e6a9edbb4382aee1dd2b"}, - {file = "fmtm_splitter-1.0.0-py3-none-any.whl", hash = "sha256:cb6b391b32caddcca489aa24bdd1e2bb9c4245f345c0b3d42fdd517694ac9bfc"}, + {file = "fmtm-splitter-1.1.2.tar.gz", hash = "sha256:e6881e04ee2491f7ce3cb50827a2d05d8274228cb0390ddf2e5fbd62f7195c8e"}, + {file = "fmtm_splitter-1.1.2-py3-none-any.whl", hash = "sha256:9ffe3381c1ef435f0a5fa9595515ba4748d822317434c34830740908249048a7"}, ] [[package]] @@ -665,7 +670,7 @@ files = [ [[package]] name = "geopandas" -version = "0.14.2" +version = "0.14.3" requires_python = ">=3.9" summary = "Geographic pandas extensions" dependencies = [ @@ -676,8 +681,8 @@ dependencies = [ "shapely>=1.8.0", ] files = [ - {file = "geopandas-0.14.2-py3-none-any.whl", hash = "sha256:0efa61235a68862c1c6be89fc3707cdeba67667d5676bb19e24f3c57a8c2f723"}, - {file = "geopandas-0.14.2.tar.gz", hash = "sha256:6e71d57b8376f9fdc9f1c3aa3170e7e420e91778de854f51013ae66fd371ccdb"}, + {file = "geopandas-0.14.3-py3-none-any.whl", hash = "sha256:41b31ad39e21bc9e8c4254f78f8dc4ce3d33d144e22e630a00bb336c83160204"}, + {file = "geopandas-0.14.3.tar.gz", hash = "sha256:748af035d4a068a4ae00cab384acb61d387685c833b0022e0729aa45216b23ac"}, ] [[package]] @@ -707,15 +712,15 @@ files = [ [[package]] name = "gitpython" -version = "3.1.41" +version = "3.1.42" requires_python = ">=3.7" summary = "GitPython is a Python library used to interact with Git repositories" dependencies = [ "gitdb<5,>=4.0.1", ] files = [ - {file = "GitPython-3.1.41-py3-none-any.whl", hash = "sha256:c36b6634d069b3f719610175020a9aed919421c87552185b085e04fbbdb10b7c"}, - {file = "GitPython-3.1.41.tar.gz", hash = "sha256:ed66e624884f76df22c8e16066d567aaa5a37d5b5fa19db2c6df6f7156db9048"}, + {file = "GitPython-3.1.42-py3-none-any.whl", hash = "sha256:1bf9cd7c9e7255f77778ea54359e54ac22a72a5b51288c457c881057b7bb9ecd"}, + {file = "GitPython-3.1.42.tar.gz", hash = "sha256:2d99869e0fef71a73cbd242528105af1d6c1b108c60dfabd994bf292f76c3ceb"}, ] [[package]] @@ -756,15 +761,15 @@ files = [ [[package]] name = "griffe" -version = "0.39.1" +version = "0.40.1" requires_python = ">=3.8" summary = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." dependencies = [ "colorama>=0.4", ] files = [ - {file = "griffe-0.39.1-py3-none-any.whl", hash = "sha256:6ce4ecffcf0d2f96362c5974b3f7df812da8f8d4cfcc5ebc8202ef72656fc087"}, - {file = "griffe-0.39.1.tar.gz", hash = "sha256:ead8dfede6e6531cce6bf69090a4f3c6d36fdf923c43f8e85aa530552cef0c09"}, + {file = "griffe-0.40.1-py3-none-any.whl", hash = "sha256:5b8c023f366fe273e762131fe4bfd141ea56c09b3cb825aa92d06a82681cfd93"}, + {file = "griffe-0.40.1.tar.gz", hash = "sha256:66c48a62e2ce5784b6940e603300fcfb807b6f099b94e7f753f1841661fd5c7c"}, ] [[package]] @@ -789,7 +794,7 @@ files = [ [[package]] name = "httpcore" -version = "1.0.2" +version = "1.0.4" requires_python = ">=3.8" summary = "A minimal low-level HTTP client." dependencies = [ @@ -797,8 +802,8 @@ dependencies = [ "h11<0.15,>=0.13", ] files = [ - {file = "httpcore-1.0.2-py3-none-any.whl", hash = "sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7"}, - {file = "httpcore-1.0.2.tar.gz", hash = "sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535"}, + {file = "httpcore-1.0.4-py3-none-any.whl", hash = "sha256:ac418c1db41bade2ad53ae2f3834a3a0f5ae76b56cf5aa497d2d033384fc7d73"}, + {file = "httpcore-1.0.4.tar.gz", hash = "sha256:cb2839ccfcba0d2d3c1131d3c3e26dfc327326fbe7a5dc0dbfe9f6c9151bb022"}, ] [[package]] @@ -820,12 +825,12 @@ files = [ [[package]] name = "identify" -version = "2.5.33" +version = "2.5.35" requires_python = ">=3.8" summary = "File identification library for Python" files = [ - {file = "identify-2.5.33-py2.py3-none-any.whl", hash = "sha256:d40ce5fcd762817627670da8a7d8d8e65f24342d14539c59488dc603bf662e34"}, - {file = "identify-2.5.33.tar.gz", hash = "sha256:161558f9fe4559e1557e1bff323e8631f6a0e4837f7497767c1782832f16b62d"}, + {file = "identify-2.5.35-py2.py3-none-any.whl", hash = "sha256:c4de0081837b211594f8e877a6b4fad7ca32bbfc1a9307fdd61c28bfe923f13e"}, + {file = "identify-2.5.35.tar.gz", hash = "sha256:10a7ca245cfcd756a554a7288159f72ff105ad233c7c4b9c6f0f4d108f5f6791"}, ] [[package]] @@ -840,15 +845,15 @@ files = [ [[package]] name = "importlib-metadata" -version = "6.11.0" +version = "7.0.1" requires_python = ">=3.8" summary = "Read metadata from Python packages" dependencies = [ "zipp>=0.5", ] files = [ - {file = "importlib_metadata-6.11.0-py3-none-any.whl", hash = "sha256:f0afba6205ad8f8947c7d338b5342d5db2afbfd82f9cbef7879a9539cc12eb9b"}, - {file = "importlib_metadata-6.11.0.tar.gz", hash = "sha256:1231cf92d825c9e03cfc4da076a16de6422c863558229ea0b22b675657463443"}, + {file = "importlib_metadata-7.0.1-py3-none-any.whl", hash = "sha256:4805911c3a4ec7c3966410053e9ec6a1fecd629117df5adee56dfc9432a1081e"}, + {file = "importlib_metadata-7.0.1.tar.gz", hash = "sha256:f238736bb06590ae52ac1fab06a3a9ef1d8dce2b7a35b5ab329371d6c8f5d2cc"}, ] [[package]] @@ -938,77 +943,72 @@ files = [ [[package]] name = "levenshtein" -version = "0.23.0" -requires_python = ">=3.7" +version = "0.25.0" +requires_python = ">=3.8" summary = "Python extension for computing string edit distances and similarities." dependencies = [ "rapidfuzz<4.0.0,>=3.1.0", ] files = [ - {file = "Levenshtein-0.23.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2d3f2b8e67915268c49f0faa29a29a8c26811a4b46bd96dd043bc8557428065d"}, - {file = "Levenshtein-0.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:10b980dcc865f8fe04723e448fac4e9a32cbd21fb41ab548725a2d30d9a22429"}, - {file = "Levenshtein-0.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2f8c8c48217b2733ae5bd8ef14e0ad730a30d113c84dc2cfc441435ef900732b"}, - {file = "Levenshtein-0.23.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:854a0962d6f5852b891b6b5789467d1e72b69722df1bc0dd85cbf70efeddc83f"}, - {file = "Levenshtein-0.23.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5abc4ee22340625ec401d6f11136afa387d377b7aa5dad475618ffce1f0d2e2f"}, - {file = "Levenshtein-0.23.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:20f79946481052bbbee5284c755aa0a5feb10a344d530e014a50cb9544745dd3"}, - {file = "Levenshtein-0.23.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6084fc909a218843bb55723fde64a8a58bac7e9086854c37134269b3f946aeb"}, - {file = "Levenshtein-0.23.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0acaae1c20c8ed37915b0cde14b5c77d5a3ba08e05f9ce4f55e16843de9c7bb8"}, - {file = "Levenshtein-0.23.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:54a51036b02222912a029a6efa2ce1ee2be49c88e0bb32995e0999feba183913"}, - {file = "Levenshtein-0.23.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:68ec2ef442621027f290cb5cef80962889d86fff3e405e5d21c7f9634d096bbf"}, - {file = "Levenshtein-0.23.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:d8ba18720bafa4a65f07baba8c3228e98a6f8da7455de4ec58ae06de4ecdaea0"}, - {file = "Levenshtein-0.23.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:af1b70cac87c5627cd2227823318fa39c64fbfed686c8c3c2f713f72bc25813b"}, - {file = "Levenshtein-0.23.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fe2810c42cc5bca15eeb4a2eb192b1f74ceef6005876b1a166ecbde1defbd22d"}, - {file = "Levenshtein-0.23.0-cp310-cp310-win32.whl", hash = "sha256:89a0829637221ff0fd6ce63dfbe59e22b25eeba914d50e191519b9d9b8ccf3e9"}, - {file = "Levenshtein-0.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:b8bc81d59205558326ac75c97e236fd72b8bcdf63fcdbfb7387bd63da242b209"}, - {file = "Levenshtein-0.23.0-cp310-cp310-win_arm64.whl", hash = "sha256:151046d1c70bdf01ede01f46467c11151ceb9c86fefaf400978b990110d0a55e"}, - {file = "Levenshtein-0.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7e992de09832ee11b35910c05c1581e8a9ab8ea9737c2f582c7eb540e2cdde69"}, - {file = "Levenshtein-0.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5e3461d29b3188518464bd3121fc64635ff884ae544147b5d326ce13c50d36"}, - {file = "Levenshtein-0.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1772c4491f6ef6504e591c0dd60e1e418b2015074c3d56ee93af6b1a019906ee"}, - {file = "Levenshtein-0.23.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e125c92cd0ac3b53c4c80fcf2890d89a1d19ff4979dc804031773bc90223859f"}, - {file = "Levenshtein-0.23.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d2f608c5ce7b9a0a0af3c910f43ea7eb060296655aa127b10e4af7be5559303"}, - {file = "Levenshtein-0.23.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe5c3b7d96a838d9d86bb4ec57495749965e598a3ea2c5b877a61aa09478bab7"}, - {file = "Levenshtein-0.23.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:249eaa351b5355b3e3ca7e3a8e2a0bca7bff4491c89a0b0fa3b9d0614cf3efeb"}, - {file = "Levenshtein-0.23.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0033a243510e829ead1ae62720389c9f17d422a98c0525da593d239a9ff434e5"}, - {file = "Levenshtein-0.23.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f956ad16cab9267c0e7d382a37b4baca6bf3bf1637a76fa95fdbf9dd3ea774d7"}, - {file = "Levenshtein-0.23.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3789e4aeaeb830d944e1f502f9aa9024e9cd36b68d6eba6892df7972b884abd7"}, - {file = "Levenshtein-0.23.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:f91335f056b9a548070cb87b3e6cf017a18b27d34a83f222bdf46a5360615f11"}, - {file = "Levenshtein-0.23.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:3497eda857e70863a090673a82442877914c57b5f04673c782642e69caf25c0c"}, - {file = "Levenshtein-0.23.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5e17ea59115179c269c6daea52415faaf54c6340d4ad91d9012750845a445a13"}, - {file = "Levenshtein-0.23.0-cp311-cp311-win32.whl", hash = "sha256:da2063cee1fbecc09e1692e7c4de7624fd4c47a54ee7588b7ea20540f8f8d779"}, - {file = "Levenshtein-0.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:4d3b9c9e2852eca20de6bd8ca7f47d817a056993fd4927a4d50728b62315376b"}, - {file = "Levenshtein-0.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:ef2e3e93ae612ac87c3a28f08e8544b707d67e99f9624e420762a7c275bb13c5"}, - {file = "Levenshtein-0.23.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:85220b27a47df4a5106ef13d43b6181d73da77d3f78646ec7251a0c5eb08ac40"}, - {file = "Levenshtein-0.23.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6bb77b3ade7f256ca5882450aaf129be79b11e074505b56c5997af5058a8f834"}, - {file = "Levenshtein-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b487f08c32530ee608e8aab0c4075048262a7f5a6e113bac495b05154ae427"}, - {file = "Levenshtein-0.23.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f91d0a5d3696e373cae08c80ec99a4ff041e562e55648ebe582725cba555190"}, - {file = "Levenshtein-0.23.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fddda71ae372cd835ffd64990f0d0b160409e881bf8722b6c5dc15dc4239d7db"}, - {file = "Levenshtein-0.23.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7664bcf9a12e62c672a926c4579f74689507beaa24378ad7664f0603b0dafd20"}, - {file = "Levenshtein-0.23.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6d07539502610ee8d6437a77840feedefa47044ab0f35cd3bc37adfc63753bd"}, - {file = "Levenshtein-0.23.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:830a74b6a045a13e1b1d28af62af9878aeae8e7386f14888c84084d577b92771"}, - {file = "Levenshtein-0.23.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f29cbd0c172a8fc1d51eaacd163bdc11596aded5a90db617e6b778c2258c7006"}, - {file = "Levenshtein-0.23.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:df0704fd6a30a7c27c03655ae6dc77345c1655634fe59654e74bb06a3c7c1357"}, - {file = "Levenshtein-0.23.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:0ab52358f54ee48ad7656a773a0c72ef89bb9ba5acc6b380cfffd619fb223a23"}, - {file = "Levenshtein-0.23.0-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:f0a86394c9440e23a29f48f2bbc460de7b19950f46ec2bea3be8c2090839bb29"}, - {file = "Levenshtein-0.23.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a689e6e0514f48a434e7ee44cc1eb29c34b21c51c57accb304eac97fba87bf48"}, - {file = "Levenshtein-0.23.0-cp312-cp312-win32.whl", hash = "sha256:2d3229c1336498c2b72842dd4c850dff1040588a5468abe5104444a372c1a573"}, - {file = "Levenshtein-0.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:5b9b6a8509415bc214d33f5828d7c700c80292ea25f9d9e8cba95ad5a74b3cdf"}, - {file = "Levenshtein-0.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:5a61606bad3afb9fcec0a2a21871319c3f7da933658d2e0e6e55ab4a34814f48"}, - {file = "Levenshtein-0.23.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:760c964ff0be8dea5f7eda20314cf66238fdd0fec63f1ce9c474736bb2904924"}, - {file = "Levenshtein-0.23.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de42400ea86e3e8be3dc7f9b3b9ed51da7fd06dc2f3a426d7effd7fbf35de848"}, - {file = "Levenshtein-0.23.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2080ee52aeac03854a0c6e73d4214d5be2120bdd5f16def4394f9fbc5666e04"}, - {file = "Levenshtein-0.23.0-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb00ecae116e62801613788d8dc3938df26f582efce5a3d3320e9692575e7c4d"}, - {file = "Levenshtein-0.23.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:f351694f65d4df48ee2578d977d37a0560bd3e8535e85dfe59df6abeed12bd6e"}, - {file = "Levenshtein-0.23.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:34859c5ff7261f25daea810b5439ad80624cbb9021381df2c390c20eb75b79c6"}, - {file = "Levenshtein-0.23.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ece1d077d9006cff329bb95eb9704f407933ff4484e5d008a384d268b993439"}, - {file = "Levenshtein-0.23.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35ce82403730dd2a3b397abb2535786af06835fcf3dc40dc8ea67ed589bbd010"}, - {file = "Levenshtein-0.23.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a88aa3b5f49aeca08080b6c3fa7e1095d939eafb13f42dbe8f1b27ff405fd43"}, - {file = "Levenshtein-0.23.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:748fbba6d9c04fc39b956b44ccde8eb14f34e21ab68a0f9965aae3fa5c8fdb5e"}, - {file = "Levenshtein-0.23.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:60440d583986e344119a15cea9e12099f3a07bdddc1c98ec2dda69e96429fb25"}, - {file = "Levenshtein-0.23.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b048a83b07fc869648460f2af1255e265326d75965157a165dde2d9ba64fa73"}, - {file = "Levenshtein-0.23.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4be0e5e742f6a299acf7aa8d2e5cfca946bcff224383fd451d894e79499f0a46"}, - {file = "Levenshtein-0.23.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7a626637c1d967e3e504ced353f89c2a9f6c8b4b4dbf348fdd3e1daa947a23c"}, - {file = "Levenshtein-0.23.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:88d8a13cf310cfc893e3734f8e7e42ef20c52780506e9bdb96e76a8b75e3ba20"}, - {file = "Levenshtein-0.23.0.tar.gz", hash = "sha256:de7ccc31a471ea5bfafabe804c12a63e18b4511afc1014f23c3cc7be8c70d3bd"}, + {file = "Levenshtein-0.25.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3065b26f62e6340bd437875018e417c3b7bb8461ab4447ab58519843f42b6514"}, + {file = "Levenshtein-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dad142561e62f8f3af68533cf79411ccb29ceda4bd9e223d47b63219688c1bc6"}, + {file = "Levenshtein-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:26111ab69e08379b6fbafe84e4ae1b5f6388f649d95a99b21871aee6ac29a7cf"}, + {file = "Levenshtein-0.25.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da13060a78ed723de33757aeddec163a25964748867c3dff01842e48661bc359"}, + {file = "Levenshtein-0.25.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e54dc81c1040acab1456756c217bb998bb5276c1fe32534d543b152bc53ee95a"}, + {file = "Levenshtein-0.25.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fdf8f068a382ba52f2845f75dac84ba8908add5352a883a76aeead28e8021954"}, + {file = "Levenshtein-0.25.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f65744adc3bbb677c04b9eebb48f7a783a84cea2cc9a407d8e6991a80bc2cfb0"}, + {file = "Levenshtein-0.25.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5036edc7bcff3570105bad59c77d959b84413b3556329dbd17fa98a92ad77a5e"}, + {file = "Levenshtein-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e04ba617a4c6f62468aaa30f5a72fbca993b8713718034aa307eb8ab482a3584"}, + {file = "Levenshtein-0.25.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:1ed0eea12fa60418572e639e5c0e076833d33834b473d2d419a0bba39644f91a"}, + {file = "Levenshtein-0.25.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:8ce34cae24199b85424e057982c327157e2728c5278551371c65aff932733f04"}, + {file = "Levenshtein-0.25.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:3c3d013b109fb3190db16658b3217feb3ed0251d0b4bcc4092834b5487c444d3"}, + {file = "Levenshtein-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:34fe20af42dbe594957ba0e9311eefb744a06958f020798450e7d570c04145a3"}, + {file = "Levenshtein-0.25.0-cp310-cp310-win32.whl", hash = "sha256:2f8046b7ffc9eac4ce1539f2d4083f9ad0fc9ab9d7a74d74eb2275743f0e7628"}, + {file = "Levenshtein-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ac6f976d943f7f5255651a6885bfad7a2e11862fa3adfc121b02fbe45ac81fa1"}, + {file = "Levenshtein-0.25.0-cp310-cp310-win_arm64.whl", hash = "sha256:ca0cb57e17af9ff3625811de1401aa3c21194badf33fedce442a422c212508b8"}, + {file = "Levenshtein-0.25.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fb22f81e8a5b22506635acd57fe6b04d4ae5606fb795fc2c4d294dd6fa0d1a85"}, + {file = "Levenshtein-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3987cbcc947e92627b7eba5cbaba31f1bc7e6f09b4367b9e82b45fe243ddb761"}, + {file = "Levenshtein-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c73b92d9a6f01e595ce63268f654e094f5c8c98dd1c84c161fab011999f75651"}, + {file = "Levenshtein-0.25.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3657ad0ec8426ade2580d92b60b7b057de7fbc8973a0115ff63d0705e873ef4f"}, + {file = "Levenshtein-0.25.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6e4d8f245478f21329f8e3b29caac7a8a177fd176e2e159606b12d58ffd3bf8"}, + {file = "Levenshtein-0.25.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d444f6e9e461e948d7262fd25fd1a0692c413ebd6f6a64eaaa7724b8646405eb"}, + {file = "Levenshtein-0.25.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0936cbef6b3d0643c24295565a9eb8f29315fdf38ceda6c61eaa84b9d0822bf5"}, + {file = "Levenshtein-0.25.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea837e7b5756b1f1a6d4b333899e372d0a3cf6e7d7b29523f78875d609b49b33"}, + {file = "Levenshtein-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f74f0b1bcf248d2385d367d18d140f644854b979b010a38e25676c50efb8900c"}, + {file = "Levenshtein-0.25.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:874b6da533198d84a35e1bc18161b2ad0df09a80a3464b0714de4069637ebd1b"}, + {file = "Levenshtein-0.25.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:13985784b12f726df39eed340b5ba883271856da3419e98823c5c46cdc1f6ea9"}, + {file = "Levenshtein-0.25.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:f02ff8f80458737060ccdb23666fc5f8335641e0131794419ab590d808f2e22f"}, + {file = "Levenshtein-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8b8f4ff207714188e56327f1d590be406035c6211863402f4c9c08b8e8c59839"}, + {file = "Levenshtein-0.25.0-cp311-cp311-win32.whl", hash = "sha256:62e2b57793cc1af53dd046e950987b4f27f138bdb48965bb088eea400570031c"}, + {file = "Levenshtein-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:719b3a09214702ac9dd54c4dee4446a218e40948bedef248077e2c90890c2b06"}, + {file = "Levenshtein-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:84d9ab58f663efad5af9bbf1f83d7d86f7a28485a47c1ae689bf768bf1cf62a5"}, + {file = "Levenshtein-0.25.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9011755a7e6dd4528ebb4c6f3aacd083b3b40392629b5ca12c74dd86094ede84"}, + {file = "Levenshtein-0.25.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:699eec3d4c755c23c7a9fa24980a1fe9d81978253f75a502d0ad8c9b6521b514"}, + {file = "Levenshtein-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f60e15a2b0a16222414206f63e47f18863c9a98941815d6e80abdfe05e2082a1"}, + {file = "Levenshtein-0.25.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4f6efdbb7381177f80fd24be7622d45c20144cdf6495302b413628710ce91c5"}, + {file = "Levenshtein-0.25.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80a7005d21dca111dd9ed9a5f40fa3a17411914717e5a23d6b1fa87bf7f98bbf"}, + {file = "Levenshtein-0.25.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40e48975f31e560c6f7f6e8d79ea4a7b4b090987e89da133f8fa90d9eddcae0b"}, + {file = "Levenshtein-0.25.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62dd3c4fb48699f7aa8de7cd664c8e4e15288273c1a46aa0279d7387b5b7820b"}, + {file = "Levenshtein-0.25.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d507bd0cb47fcf41ddbfb0df746f35354c6af4ebccb4fd1a646d6848da42133e"}, + {file = "Levenshtein-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4016d7665c9bf7735d954e9bdb332745fd28b913ea01be7add705d1f458b5c9e"}, + {file = "Levenshtein-0.25.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8a45c0a2c699cee760c03d0a77b320dc3c271b6644a294e317361fb5612dfe76"}, + {file = "Levenshtein-0.25.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:08ef2e2d2a2e4d645e431f61e402285b076c2b694dfc9dbbd8b3fb6cc226ef30"}, + {file = "Levenshtein-0.25.0-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:026f817fad032c41e177416082150eb15617607045616e71ed18915e80a715e1"}, + {file = "Levenshtein-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:79b21bdbdb22fb40ea01676b3a92875f1bef268f5ced15672a8ad916563ace70"}, + {file = "Levenshtein-0.25.0-cp312-cp312-win32.whl", hash = "sha256:28f45bc68e23e21f56e981a4aa9c493eff8b50047c50dbfa6a12efb6bad16d12"}, + {file = "Levenshtein-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:78f16e25acc64f9c65ede1fba24baa8df0827d8eb93e68a2c7863ca429bc4297"}, + {file = "Levenshtein-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:8894dba28c8b29e4dff9e31c5fac99e600e8deb5d757ae2ad1f36a517cb517a6"}, + {file = "Levenshtein-0.25.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:719a7c859dc35722399c71e76dcbc6d1300ba023777755a1d26b77bf3243e537"}, + {file = "Levenshtein-0.25.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72dd10b20cd6608804afe3dddee43966722d957e976d605e562fb21e44968829"}, + {file = "Levenshtein-0.25.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58f42e79bf98ffca3dbb16740969604f75cbd14e32cbecb2183f8d4ffd7cdbb1"}, + {file = "Levenshtein-0.25.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:992434407f85cfb2516ac1624f1471435a1479b1021fcdd3d0bab9b36613ab85"}, + {file = "Levenshtein-0.25.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:2ca472bffa83e68e9d73f96eb4fc67527614522d43c3be1a74f36ea12163c671"}, + {file = "Levenshtein-0.25.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ca0f9966ff84acd779a51d16f8a46565f14b0a3292eb98b11c12537e92fc91f2"}, + {file = "Levenshtein-0.25.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:39089d9283dbf86f69e701121060e5e3fa05984032e743a75adba6479b2e2b5c"}, + {file = "Levenshtein-0.25.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af9dfbf0e7d7968782bd6a2676df825f37ef533b4a6cb1c8e8397aa12e80c8e2"}, + {file = "Levenshtein-0.25.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e05e387bde5e456e95c077b648f730597b98c3e99a5143a268e0750152b5843"}, + {file = "Levenshtein-0.25.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a0166fd91039d1d17329e59523b35bf6783f7a1719d1df06910cc4b6f2af9271"}, + {file = "Levenshtein-0.25.0.tar.gz", hash = "sha256:0bca15031e6b684f82003c9a399172fac6e215410d385f026a07165c69e75fd5"}, ] [[package]] @@ -1037,41 +1037,41 @@ files = [ [[package]] name = "markupsafe" -version = "2.1.4" +version = "2.1.5" requires_python = ">=3.7" summary = "Safely add untrusted strings to HTML/XML markup." files = [ - {file = "MarkupSafe-2.1.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de8153a7aae3835484ac168a9a9bdaa0c5eee4e0bc595503c95d53b942879c84"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e888ff76ceb39601c59e219f281466c6d7e66bd375b4ec1ce83bcdc68306796b"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0b838c37ba596fcbfca71651a104a611543077156cb0a26fe0c475e1f152ee8"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac1ebf6983148b45b5fa48593950f90ed6d1d26300604f321c74a9ca1609f8e"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fbad3d346df8f9d72622ac71b69565e621ada2ce6572f37c2eae8dacd60385d"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5291d98cd3ad9a562883468c690a2a238c4a6388ab3bd155b0c75dd55ece858"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a7cc49ef48a3c7a0005a949f3c04f8baa5409d3f663a1b36f0eba9bfe2a0396e"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b83041cda633871572f0d3c41dddd5582ad7d22f65a72eacd8d3d6d00291df26"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-win32.whl", hash = "sha256:0c26f67b3fe27302d3a412b85ef696792c4a2386293c53ba683a89562f9399b0"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-win_amd64.whl", hash = "sha256:a76055d5cb1c23485d7ddae533229039b850db711c554a12ea64a0fd8a0129e2"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9e9e3c4020aa2dc62d5dd6743a69e399ce3de58320522948af6140ac959ab863"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0042d6a9880b38e1dd9ff83146cc3c9c18a059b9360ceae207805567aacccc69"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d03fea4c4e9fd0ad75dc2e7e2b6757b80c152c032ea1d1de487461d8140efc"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ab3a886a237f6e9c9f4f7d272067e712cdb4efa774bef494dccad08f39d8ae6"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abf5ebbec056817057bfafc0445916bb688a255a5146f900445d081db08cbabb"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e1a0d1924a5013d4f294087e00024ad25668234569289650929ab871231668e7"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e7902211afd0af05fbadcc9a312e4cf10f27b779cf1323e78d52377ae4b72bea"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c669391319973e49a7c6230c218a1e3044710bc1ce4c8e6eb71f7e6d43a2c131"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-win32.whl", hash = "sha256:31f57d64c336b8ccb1966d156932f3daa4fee74176b0fdc48ef580be774aae74"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-win_amd64.whl", hash = "sha256:54a7e1380dfece8847c71bf7e33da5d084e9b889c75eca19100ef98027bd9f56"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:a76cd37d229fc385738bd1ce4cba2a121cf26b53864c1772694ad0ad348e509e"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:987d13fe1d23e12a66ca2073b8d2e2a75cec2ecb8eab43ff5624ba0ad42764bc"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5244324676254697fe5c181fc762284e2c5fceeb1c4e3e7f6aca2b6f107e60dc"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78bc995e004681246e85e28e068111a4c3f35f34e6c62da1471e844ee1446250"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4d176cfdfde84f732c4a53109b293d05883e952bbba68b857ae446fa3119b4f"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f9917691f410a2e0897d1ef99619fd3f7dd503647c8ff2475bf90c3cf222ad74"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:f06e5a9e99b7df44640767842f414ed5d7bedaaa78cd817ce04bbd6fd86e2dd6"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:396549cea79e8ca4ba65525470d534e8a41070e6b3500ce2414921099cb73e8d"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-win32.whl", hash = "sha256:f6be2d708a9d0e9b0054856f07ac7070fbe1754be40ca8525d5adccdbda8f475"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:5045e892cfdaecc5b4c01822f353cf2c8feb88a6ec1c0adef2a2e705eef0f656"}, - {file = "MarkupSafe-2.1.4.tar.gz", hash = "sha256:3aae9af4cac263007fd6309c64c6ab4506dd2b79382d9d19a1994f9240b8db4f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, ] [[package]] @@ -1176,8 +1176,8 @@ files = [ [[package]] name = "mkdocs-git-revision-date-localized-plugin" -version = "1.2.2" -requires_python = ">=3.6" +version = "1.2.4" +requires_python = ">=3.8" summary = "Mkdocs plugin that enables displaying the localized date of the last git modification of a markdown file." dependencies = [ "GitPython", @@ -1186,8 +1186,8 @@ dependencies = [ "pytz", ] files = [ - {file = "mkdocs-git-revision-date-localized-plugin-1.2.2.tar.gz", hash = "sha256:0c43a9aac1fa69df99a823f833cc223bac9967b60d5261a857761c7c6e3b30de"}, - {file = "mkdocs_git_revision_date_localized_plugin-1.2.2-py3-none-any.whl", hash = "sha256:85c7fe9ab06e7a63c4e522c26fee8b51d357cb8cbe605064501ad80f4f31cb94"}, + {file = "mkdocs-git-revision-date-localized-plugin-1.2.4.tar.gz", hash = "sha256:08fd0c6f33c8da9e00daf40f7865943113b3879a1c621b2bbf0fa794ffe997d3"}, + {file = "mkdocs_git_revision_date_localized_plugin-1.2.4-py3-none-any.whl", hash = "sha256:1f94eb510862ef94e982a2910404fa17a1657ecf29f45a07b0f438c00767fc85"}, ] [[package]] @@ -1282,38 +1282,38 @@ files = [ [[package]] name = "numpy" -version = "1.26.3" +version = "1.26.4" requires_python = ">=3.9" summary = "Fundamental package for array computing in Python" files = [ - {file = "numpy-1.26.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:806dd64230dbbfaca8a27faa64e2f414bf1c6622ab78cc4264f7f5f028fee3bf"}, - {file = "numpy-1.26.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02f98011ba4ab17f46f80f7f8f1c291ee7d855fcef0a5a98db80767a468c85cd"}, - {file = "numpy-1.26.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d45b3ec2faed4baca41c76617fcdcfa4f684ff7a151ce6fc78ad3b6e85af0a6"}, - {file = "numpy-1.26.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdd2b45bf079d9ad90377048e2747a0c82351989a2165821f0c96831b4a2a54b"}, - {file = "numpy-1.26.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:211ddd1e94817ed2d175b60b6374120244a4dd2287f4ece45d49228b4d529178"}, - {file = "numpy-1.26.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b1240f767f69d7c4c8a29adde2310b871153df9b26b5cb2b54a561ac85146485"}, - {file = "numpy-1.26.3-cp310-cp310-win32.whl", hash = "sha256:21a9484e75ad018974a2fdaa216524d64ed4212e418e0a551a2d83403b0531d3"}, - {file = "numpy-1.26.3-cp310-cp310-win_amd64.whl", hash = "sha256:9e1591f6ae98bcfac2a4bbf9221c0b92ab49762228f38287f6eeb5f3f55905ce"}, - {file = "numpy-1.26.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b831295e5472954104ecb46cd98c08b98b49c69fdb7040483aff799a755a7374"}, - {file = "numpy-1.26.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9e87562b91f68dd8b1c39149d0323b42e0082db7ddb8e934ab4c292094d575d6"}, - {file = "numpy-1.26.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c66d6fec467e8c0f975818c1796d25c53521124b7cfb760114be0abad53a0a2"}, - {file = "numpy-1.26.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f25e2811a9c932e43943a2615e65fc487a0b6b49218899e62e426e7f0a57eeda"}, - {file = "numpy-1.26.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:af36e0aa45e25c9f57bf684b1175e59ea05d9a7d3e8e87b7ae1a1da246f2767e"}, - {file = "numpy-1.26.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:51c7f1b344f302067b02e0f5b5d2daa9ed4a721cf49f070280ac202738ea7f00"}, - {file = "numpy-1.26.3-cp311-cp311-win32.whl", hash = "sha256:7ca4f24341df071877849eb2034948459ce3a07915c2734f1abb4018d9c49d7b"}, - {file = "numpy-1.26.3-cp311-cp311-win_amd64.whl", hash = "sha256:39763aee6dfdd4878032361b30b2b12593fb445ddb66bbac802e2113eb8a6ac4"}, - {file = "numpy-1.26.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a7081fd19a6d573e1a05e600c82a1c421011db7935ed0d5c483e9dd96b99cf13"}, - {file = "numpy-1.26.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12c70ac274b32bc00c7f61b515126c9205323703abb99cd41836e8125ea0043e"}, - {file = "numpy-1.26.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f784e13e598e9594750b2ef6729bcd5a47f6cfe4a12cca13def35e06d8163e3"}, - {file = "numpy-1.26.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f24750ef94d56ce6e33e4019a8a4d68cfdb1ef661a52cdaee628a56d2437419"}, - {file = "numpy-1.26.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:77810ef29e0fb1d289d225cabb9ee6cf4d11978a00bb99f7f8ec2132a84e0166"}, - {file = "numpy-1.26.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8ed07a90f5450d99dad60d3799f9c03c6566709bd53b497eb9ccad9a55867f36"}, - {file = "numpy-1.26.3-cp312-cp312-win32.whl", hash = "sha256:f73497e8c38295aaa4741bdfa4fda1a5aedda5473074369eca10626835445511"}, - {file = "numpy-1.26.3-cp312-cp312-win_amd64.whl", hash = "sha256:da4b0c6c699a0ad73c810736303f7fbae483bcb012e38d7eb06a5e3b432c981b"}, - {file = "numpy-1.26.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3c67423b3703f8fbd90f5adaa37f85b5794d3366948efe9a5190a5f3a83fc34e"}, - {file = "numpy-1.26.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46f47ee566d98849323f01b349d58f2557f02167ee301e5e28809a8c0e27a2d0"}, - {file = "numpy-1.26.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a8474703bffc65ca15853d5fd4d06b18138ae90c17c8d12169968e998e448bb5"}, - {file = "numpy-1.26.3.tar.gz", hash = "sha256:697df43e2b6310ecc9d95f05d5ef20eacc09c7c4ecc9da3f235d39e71b7da1e4"}, + {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"}, + {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2"}, + {file = "numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07"}, + {file = "numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a"}, + {file = "numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20"}, + {file = "numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0"}, + {file = "numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110"}, + {file = "numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0"}, + {file = "numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010"}, ] [[package]] @@ -1341,7 +1341,7 @@ files = [ [[package]] name = "osm-fieldwork" -version = "0.4.2" +version = "0.4.3rc0" requires_python = ">=3.10" summary = "Processing field data from OpenDataKit to OpenStreetMap format." dependencies = [ @@ -1365,8 +1365,8 @@ dependencies = [ "xmltodict>=0.13.0", ] files = [ - {file = "osm-fieldwork-0.4.2.tar.gz", hash = "sha256:9ae6cb4d90b5dd8a10045a5a3bd512a073c814ad3b16fb245edd023312aed17d"}, - {file = "osm_fieldwork-0.4.2-py3-none-any.whl", hash = "sha256:4e7a596c50bfaef7f91b002d44ed5e3d1b377e50bf0a2a509d915a60d7ddcab1"}, + {file = "osm-fieldwork-0.4.3rc0.tar.gz", hash = "sha256:ffcc992728e0e8274522b05ca4f79f5f64dbc7b70983fc6baa1b325cae2175b5"}, + {file = "osm_fieldwork-0.4.3rc0-py3-none-any.whl", hash = "sha256:c12e0cd2e93e053926f7979a27905404eb2044c280eec95052ecd4548c4f4fab"}, ] [[package]] @@ -1386,7 +1386,7 @@ files = [ [[package]] name = "osm-rawdata" -version = "0.1.7" +version = "0.2.3" requires_python = ">=3.10" summary = "Make data extracts from OSM data." dependencies = [ @@ -1402,8 +1402,8 @@ dependencies = [ "sqlalchemy>=2.0.0", ] files = [ - {file = "osm-rawdata-0.1.7.tar.gz", hash = "sha256:b012a20e15cca925ed4d0494cd65ebf3fd97759323ed64fb94dc8cf46ce67b6f"}, - {file = "osm_rawdata-0.1.7-py3-none-any.whl", hash = "sha256:9de18ac8ddc5d25058b79506aa940ab688fc9bf096e09c641bc76266678611a8"}, + {file = "osm-rawdata-0.2.3.tar.gz", hash = "sha256:56ebb4147041f86cb5f30564e93621813de4a8bdfc2508fb1d9080db392b9151"}, + {file = "osm_rawdata-0.2.3-py3-none-any.whl", hash = "sha256:a0d11bab1143386c9fe05aa0b6d878e577a9386698e3a061ee948eaeecbcd87f"}, ] [[package]] @@ -1496,36 +1496,35 @@ files = [ [[package]] name = "platformdirs" -version = "4.1.0" +version = "4.2.0" requires_python = ">=3.8" summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." files = [ - {file = "platformdirs-4.1.0-py3-none-any.whl", hash = "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380"}, - {file = "platformdirs-4.1.0.tar.gz", hash = "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420"}, + {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, + {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, ] [[package]] name = "pluggy" -version = "1.3.0" +version = "1.4.0" requires_python = ">=3.8" summary = "plugin and hook calling mechanisms for python" files = [ - {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, - {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, + {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, + {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, ] [[package]] name = "pmtiles" -version = "3.2.0" -summary = "Library and utilities to write and read PMTiles files - cloud-optimized archives of map tiles." +version = "3.3.0" +summary = "Library and utilities to write and read PMTiles archives - cloud-optimized archives of map tiles." files = [ - {file = "pmtiles-3.2.0-py3-none-any.whl", hash = "sha256:f44e85c6622249e99044db7de77e81afa79f12faf3b53f6e0dab7a345f597e8a"}, - {file = "pmtiles-3.2.0.tar.gz", hash = "sha256:49b24af5ba59c505bd70e8459b5eec889c1213cd7fd39eb6132a516eb8dd4301"}, + {file = "pmtiles-3.3.0.tar.gz", hash = "sha256:1af931f23d3d4e112ecaaeac30480785d8a64a85768bec65df9efa66c5c9699f"}, ] [[package]] name = "pre-commit" -version = "3.6.0" +version = "3.6.2" requires_python = ">=3.9" summary = "A framework for managing and maintaining multi-language pre-commit hooks." dependencies = [ @@ -1536,8 +1535,8 @@ dependencies = [ "virtualenv>=20.10.0", ] files = [ - {file = "pre_commit-3.6.0-py2.py3-none-any.whl", hash = "sha256:c255039ef399049a5544b6ce13d135caba8f2c28c3b4033277a788f434308376"}, - {file = "pre_commit-3.6.0.tar.gz", hash = "sha256:d30bad9abf165f7785c15a21a1f46da7d0677cb00ee7ff4c579fd38922efe15d"}, + {file = "pre_commit-3.6.2-py2.py3-none-any.whl", hash = "sha256:ba637c2d7a670c10daedc059f5c49b5bd0aadbccfcd7ec15592cf9665117532c"}, + {file = "pre_commit-3.6.2.tar.gz", hash = "sha256:c3ef34f463045c88658c5b99f38c1e297abdcc0ff13f98d3370055fbbfabc67e"}, ] [[package]] @@ -1954,11 +1953,11 @@ files = [ [[package]] name = "pytz" -version = "2023.3.post1" +version = "2024.1" summary = "World timezone definitions, modern and historical" files = [ - {file = "pytz-2023.3.post1-py2.py3-none-any.whl", hash = "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7"}, - {file = "pytz-2023.3.post1.tar.gz", hash = "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b"}, + {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, + {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, ] [[package]] @@ -2188,12 +2187,12 @@ files = [ [[package]] name = "segno" -version = "1.6.0" +version = "1.6.1" requires_python = ">=3.5" summary = "QR Code and Micro QR Code generator for Python" files = [ - {file = "segno-1.6.0-py3-none-any.whl", hash = "sha256:e9c7479e144f750b837f9527fe7492135908b2515586467bc3c893b60a4e4d39"}, - {file = "segno-1.6.0.tar.gz", hash = "sha256:8d3b11098ac6dd93161499544dedbfb187d4459088109b8855ff0bbe98105047"}, + {file = "segno-1.6.1-py3-none-any.whl", hash = "sha256:e90c6ff82c633f757a96d4b1fb06cc932589b5237f33be653f52252544ac64df"}, + {file = "segno-1.6.1.tar.gz", hash = "sha256:f23da78b059251c36e210d0cf5bfb1a9ec1604ae6e9f3d42f9a7c16d306d847e"}, ] [[package]] @@ -2211,12 +2210,12 @@ files = [ [[package]] name = "setuptools" -version = "69.0.3" +version = "69.1.0" requires_python = ">=3.8" summary = "Easily download, build, install, upgrade, and uninstall Python packages" files = [ - {file = "setuptools-69.0.3-py3-none-any.whl", hash = "sha256:385eb4edd9c9d5c17540511303e39a147ce2fc04bc55289c322b9e5904fe2c05"}, - {file = "setuptools-69.0.3.tar.gz", hash = "sha256:be1af57fc409f93647f2e8e4573a142ed38724b8cdd389706a867bb4efcf1e78"}, + {file = "setuptools-69.1.0-py3-none-any.whl", hash = "sha256:c054629b81b946d63a9c6e732bc8b2513a7c3ea645f11d0139a2191d735c60c6"}, + {file = "setuptools-69.1.0.tar.gz", hash = "sha256:850894c4195f09c4ed30dba56213bf7c3f21d86ed6bdaafb5df5972593bfc401"}, ] [[package]] @@ -2435,22 +2434,22 @@ files = [ [[package]] name = "tzdata" -version = "2023.4" +version = "2024.1" requires_python = ">=2" summary = "Provider of IANA time zone data" files = [ - {file = "tzdata-2023.4-py2.py3-none-any.whl", hash = "sha256:aa3ace4329eeacda5b7beb7ea08ece826c28d761cda36e747cfbf97996d39bf3"}, - {file = "tzdata-2023.4.tar.gz", hash = "sha256:dd54c94f294765522c77399649b4fefd95522479a664a0cec87f41bebc6148c9"}, + {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, + {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, ] [[package]] name = "urllib3" -version = "2.1.0" +version = "2.2.1" requires_python = ">=3.8" summary = "HTTP library with thread-safe connection pooling, file post, and more." files = [ - {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, - {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, + {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"}, + {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"}, ] [[package]] @@ -2470,7 +2469,7 @@ files = [ [[package]] name = "virtualenv" -version = "20.25.0" +version = "20.25.1" requires_python = ">=3.7" summary = "Virtual Python Environment builder" dependencies = [ @@ -2479,36 +2478,39 @@ dependencies = [ "platformdirs<5,>=3.9.1", ] files = [ - {file = "virtualenv-20.25.0-py3-none-any.whl", hash = "sha256:4238949c5ffe6876362d9c0180fc6c3a824a7b12b80604eeb8085f2ed7460de3"}, - {file = "virtualenv-20.25.0.tar.gz", hash = "sha256:bf51c0d9c7dd63ea8e44086fa1e4fb1093a31e963b86959257378aef020e1f1b"}, + {file = "virtualenv-20.25.1-py3-none-any.whl", hash = "sha256:961c026ac520bac5f69acb8ea063e8a4f071bcc9457b9c1f28f6b085c511583a"}, + {file = "virtualenv-20.25.1.tar.gz", hash = "sha256:e08e13ecdca7a0bd53798f356d5831434afa5b07b93f0abdf0797b7a06ffe197"}, ] [[package]] name = "watchdog" -version = "3.0.0" -requires_python = ">=3.7" +version = "4.0.0" +requires_python = ">=3.8" summary = "Filesystem events monitoring" files = [ - {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:336adfc6f5cc4e037d52db31194f7581ff744b67382eb6021c868322e32eef41"}, - {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a70a8dcde91be523c35b2bf96196edc5730edb347e374c7de7cd20c43ed95397"}, - {file = "watchdog-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:adfdeab2da79ea2f76f87eb42a3ab1966a5313e5a69a0213a3cc06ef692b0e96"}, - {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b57a1e730af3156d13b7fdddfc23dea6487fceca29fc75c5a868beed29177ae"}, - {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7ade88d0d778b1b222adebcc0927428f883db07017618a5e684fd03b83342bd9"}, - {file = "watchdog-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7e447d172af52ad204d19982739aa2346245cc5ba6f579d16dac4bfec226d2e7"}, - {file = "watchdog-3.0.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:13bbbb462ee42ec3c5723e1205be8ced776f05b100e4737518c67c8325cf6100"}, - {file = "watchdog-3.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8f3ceecd20d71067c7fd4c9e832d4e22584318983cabc013dbf3f70ea95de346"}, - {file = "watchdog-3.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c9d8c8ec7efb887333cf71e328e39cffbf771d8f8f95d308ea4125bf5f90ba64"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0e06ab8858a76e1219e68c7573dfeba9dd1c0219476c5a44d5333b01d7e1743a"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:d00e6be486affb5781468457b21a6cbe848c33ef43f9ea4a73b4882e5f188a44"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:c07253088265c363d1ddf4b3cdb808d59a0468ecd017770ed716991620b8f77a"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:5113334cf8cf0ac8cd45e1f8309a603291b614191c9add34d33075727a967709"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:51f90f73b4697bac9c9a78394c3acbbd331ccd3655c11be1a15ae6fe289a8c83"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:ba07e92756c97e3aca0912b5cbc4e5ad802f4557212788e72a72a47ff376950d"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:d429c2430c93b7903914e4db9a966c7f2b068dd2ebdd2fa9b9ce094c7d459f33"}, - {file = "watchdog-3.0.0-py3-none-win32.whl", hash = "sha256:3ed7c71a9dccfe838c2f0b6314ed0d9b22e77d268c67e015450a29036a81f60f"}, - {file = "watchdog-3.0.0-py3-none-win_amd64.whl", hash = "sha256:4c9956d27be0bb08fc5f30d9d0179a855436e655f046d288e2bcc11adfae893c"}, - {file = "watchdog-3.0.0-py3-none-win_ia64.whl", hash = "sha256:5d9f3a10e02d7371cd929b5d8f11e87d4bad890212ed3901f9b4d68767bee759"}, - {file = "watchdog-3.0.0.tar.gz", hash = "sha256:4d98a320595da7a7c5a18fc48cb633c2e73cda78f93cac2ef42d42bf609a33f9"}, + {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:39cb34b1f1afbf23e9562501673e7146777efe95da24fab5707b88f7fb11649b"}, + {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c522392acc5e962bcac3b22b9592493ffd06d1fc5d755954e6be9f4990de932b"}, + {file = "watchdog-4.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6c47bdd680009b11c9ac382163e05ca43baf4127954c5f6d0250e7d772d2b80c"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8350d4055505412a426b6ad8c521bc7d367d1637a762c70fdd93a3a0d595990b"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c17d98799f32e3f55f181f19dd2021d762eb38fdd381b4a748b9f5a36738e935"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4986db5e8880b0e6b7cd52ba36255d4793bf5cdc95bd6264806c233173b1ec0b"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:11e12fafb13372e18ca1bbf12d50f593e7280646687463dd47730fd4f4d5d257"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5369136a6474678e02426bd984466343924d1df8e2fd94a9b443cb7e3aa20d19"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76ad8484379695f3fe46228962017a7e1337e9acadafed67eb20aabb175df98b"}, + {file = "watchdog-4.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6e949a8a94186bced05b6508faa61b7adacc911115664ccb1923b9ad1f1ccf7b"}, + {file = "watchdog-4.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6a4db54edea37d1058b08947c789a2354ee02972ed5d1e0dca9b0b820f4c7f92"}, + {file = "watchdog-4.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d31481ccf4694a8416b681544c23bd271f5a123162ab603c7d7d2dd7dd901a07"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8fec441f5adcf81dd240a5fe78e3d83767999771630b5ddfc5867827a34fa3d3"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:6a9c71a0b02985b4b0b6d14b875a6c86ddea2fdbebd0c9a720a806a8bbffc69f"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:557ba04c816d23ce98a06e70af6abaa0485f6d94994ec78a42b05d1c03dcbd50"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:d0f9bd1fd919134d459d8abf954f63886745f4660ef66480b9d753a7c9d40927"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:f9b2fdca47dc855516b2d66eef3c39f2672cbf7e7a42e7e67ad2cbfcd6ba107d"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:73c7a935e62033bd5e8f0da33a4dcb763da2361921a69a5a95aaf6c93aa03a87"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6a80d5cae8c265842c7419c560b9961561556c4361b297b4c431903f8c33b269"}, + {file = "watchdog-4.0.0-py3-none-win32.whl", hash = "sha256:8f9a542c979df62098ae9c58b19e03ad3df1c9d8c6895d96c0d51da17b243b1c"}, + {file = "watchdog-4.0.0-py3-none-win_amd64.whl", hash = "sha256:f970663fa4f7e80401a7b0cbeec00fa801bf0287d93d48368fc3e6fa32716245"}, + {file = "watchdog-4.0.0-py3-none-win_ia64.whl", hash = "sha256:9a03e16e55465177d416699331b0f3564138f1807ecc5f2de9d55d8f188d08c7"}, + {file = "watchdog-4.0.0.tar.gz", hash = "sha256:e3e7065cbdabe6183ab82199d7a4f6b3ba0a438c5a512a68559846ccb76a78ec"}, ] [[package]] diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 5bf1107bfb..6fad3bc3ab 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -46,9 +46,9 @@ dependencies = [ "sozipfile==0.3.2", "cryptography>=42.0.1", "osm-login-python==1.0.1", - "osm-fieldwork==0.4.2", - "osm-rawdata==0.1.7", - "fmtm-splitter==1.0.0", + "osm-fieldwork==0.4.3rc0", + "osm-rawdata==0.2.3", + "fmtm-splitter==1.1.2", ] requires-python = ">=3.10" readme = "../../README.md" diff --git a/src/backend/tests/conftest.py b/src/backend/tests/conftest.py index 68bdcf7ae4..72e008e558 100644 --- a/src/backend/tests/conftest.py +++ b/src/backend/tests/conftest.py @@ -24,19 +24,22 @@ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient +from geojson_pydantic import Polygon from loguru import logger as log from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy_utils import create_database, database_exists +from app.auth.auth_routes import get_or_create_user +from app.auth.osm import AuthUser from app.central import central_crud from app.config import settings from app.db.database import Base, get_db -from app.db.db_models import DbOrganisation, DbUser +from app.db.db_models import DbOrganisation from app.main import get_application +from app.models.enums import CommunityType, UserRole from app.projects import project_crud -from app.projects.project_schemas import ODKCentral, ProjectInfo, ProjectUpload -from app.users.user_schemas import User +from app.projects.project_schemas import ODKCentralDecrypted, ProjectInfo, ProjectUpload engine = create_engine(settings.FMTM_DB_URL.unicode_string()) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) @@ -85,10 +88,18 @@ def db(db_engine): @pytest.fixture(scope="function") -def user(db): +async def admin_user(db): """A test user.""" - db_user = DbUser(id=100, username="test_user") - db.add(db_user) + db_user = await get_or_create_user( + db, + AuthUser( + username="svcfmtm", + id=20386219, + role=UserRole.ADMIN, + ), + ) + # Upgrade role from default MAPPER (if user already exists) + db_user.role = UserRole.ADMIN db.commit() return db_user @@ -102,6 +113,8 @@ def organisation(db): description="test org", url="https://test.org", logo="none", + approved=True, + community_type=CommunityType.OSM_COMMUNITY, ) db.add(db_org) db.commit() @@ -109,35 +122,45 @@ def organisation(db): @pytest.fixture(scope="function") -async def project(db, user, organisation): +async def project(db, admin_user, organisation): """A test project, using the test user and org.""" project_metadata = ProjectUpload( - author=User(username=user.username, id=user.id), project_info=ProjectInfo( name="test project", short_description="test", description="test", ), xform_title="buildings", - odk_central=ODKCentral( - odk_central_url=os.getenv("ODK_CENTRAL_URL"), - odk_central_user=os.getenv("ODK_CENTRAL_USER"), - odk_central_password=os.getenv("ODK_CENTRAL_PASSWD"), - ), + odk_central_url=os.getenv("ODK_CENTRAL_URL"), + odk_central_user=os.getenv("ODK_CENTRAL_USER"), + odk_central_password=os.getenv("ODK_CENTRAL_PASSWD"), hashtags=["hot-fmtm"], + outline_geojson=Polygon( + type="Polygon", + coordinates=[ + [ + [85.299989110, 27.7140080437], + [85.299989110, 27.7108923499], + [85.304783157, 27.7108923499], + [85.304783157, 27.7140080437], + [85.299989110, 27.7140080437], + ] + ], + ), organisation_id=organisation.id, ) - # Create ODK Central Project - if project_metadata.odk_central.odk_central_url.endswith("/"): - # Remove trailing slash - project_metadata.odk_central.odk_central_url = ( - project_metadata.odk_central.odk_central_url[:-1] - ) + odk_creds_decrypted = ODKCentralDecrypted( + odk_central_url=project_metadata.odk_central_url, + odk_central_user=project_metadata.odk_central_user, + odk_central_password=project_metadata.odk_central_password, + ) + # Create ODK Central Project try: odkproject = central_crud.create_odk_project( - project_metadata.project_info.name, project_metadata.odk_central + project_metadata.project_info.name, + odk_creds_decrypted, ) log.debug(f"ODK project returned: {odkproject}") assert odkproject is not None @@ -148,7 +171,14 @@ async def project(db, user, organisation): # Create FMTM Project try: new_project = await project_crud.create_project_with_project_info( - db, project_metadata, odkproject["id"] + db, + project_metadata, + odkproject["id"], + AuthUser( + username=admin_user.username, + id=admin_user.id, + role=UserRole.ADMIN, + ), ) log.debug(f"Project returned: {new_project.__dict__}") assert new_project is not None diff --git a/src/backend/tests/test_data/data_extract_kathmandu.fgb b/src/backend/tests/test_data/data_extract_kathmandu.fgb new file mode 100644 index 0000000000000000000000000000000000000000..d7c25b0685264892d64d7a069e99d0bc3f31b1cd GIT binary patch literal 223728 zcmeEvcYGE_*Y?t+7eypUTaXf^n1mKWTdH*FguVd+p(T-o-j-?*3svbXNQodw5eR*y zC@Lt5QWSAf5fFiZG*RK3YxkVFarI^2_j~ww??1l$9v?k(U+2tRXU>$JnVs#^w`Zn4 zeS2njGJ~S13jUsU{+&(9>PYy%T*{N;|MQ7ooWHDL;$KKaFf*TBDx@vf|WyrqVgP01#bGCTkOb3R3xd+**o zk&qL**~LG}{RYJ*CC3a(6#xCc`&$j}73g?v!s~7iSYrZ7Jl}130b^tD%dt22nX;g}gX`v-^>~1};wW0B&6X zXRq*Nk!%>0Z(AXM7QiXwFCF?KS=L)0A)gN5^ueUBlRc_8 zdyJ4T2XF?J&qldLGRKAWnvm}Xa3=Z7guX~NxqNR6IZq&N zOR9^rhq>i-+I7VMPK_lVvkAxiHww9S0B3i0ar#UbC(Ch3%zwU+j9kA$W6CddDSeKMvpc!64!`UU;PmQbkLt~6O!7{F`|Lg( z^+Vb$*PcrDwGV}SI)JmQP(CAFoI>`Dc|yJ%z$sN-oZ8dH*~DKpgnT!EGd=wu^Vd<2 zc>?2!#e8eH_SELCeH(r%7QiXwKeMrmv#EZ}?n173ACB~8e?ax2p+Bu1z*)`Qe2faN zJ-t2gZzklH0i5I);^K^!E>3A8`}fJ=_%QPyDwkrk2SV8($zCz zFFh~-Yt%n651spQ_$eKe82TIK>g#0Brg9h#4wD4;&mre3bdf4~6aX%l#2Hj> z73CJG>NuCbQ4qM?5@%Do@`Q`yamxbkU|xwcsXjGoAFO8=1y0QI=uf454aBcVlW|ZC zxW^f$FitD}cbqfNIrxOc{Y@XK6n`q_LkIIq9H;Yq$R?Zx``HTo1E-a6^JSb3d)c0n zID`3?CLH-me-^lc632cp2&dAxYB{)&f8g}zTzeXi`z=hJJ z9cc2iUDmb7dK&CS7MAwd&nlIVaR&4n7m+xF;~suMD#v{&a76<+YKI!zBR$K(6_YrH z+LwuRk;>(8=E;qVOPoRWRQOAz+H4Q~X`C9s@%)19>7?iMyD23kPR^64zvp)3%!^Y? zN}Nvi%x9>4+`qtnw!nnkp#DXtc;We=8!x4$J(KJyh;xz3$ zS&37~586|%Jy{>n%N8neHuIo7oTrjs4AN84 z4$^A`a6AuYzmT2+Kd0A}IEB&;s$ZLAr$0@vC2`Exi8qm|Qu#C$_G(KUKi4_>B2{Bv z@KbsniQ{ww`XbdNdxnEU3jOhO6tyE6x2T`=x{OjZ(zj?l;`mV!-|6)v&SZWxzA2<< zqkW{;mpJC@JnxEBgX0l;=?x^#p!l_T=C+nNjnb72E>7X{LT_#xiBl=v z&g|lthwb3nN*wE}G~Z=EDbSmXMKbr#=u7P~Bjr25l~u=Hdxj|ntw%5qjc_*nnbskI zqjc#%YNtkS;5teiKcDOQ+qsiux3j=rH;L299={^U}IY zoWVG1zZ&6G^do6KB#zgmOd4nOo^E+H%&*dPiPLqeU+Ra9UW5w;E+&AZbc6b5gYtFO zxzc({oJr}H?_~raSiBoBvOXYas_U7OQ zNt{AB9^ZKVO2IgDK3?KDUjzCgmGjjRKj#zvfwRef+wj zoY29968~Zzh+m`<54#d@&Z;<46|N^#Zu~OE{$#a6=`I{o<5cq+0CH+Q1EyIF-`1NY|dt{aPL1h6ixePpHTTsZI&>(&_^@QsPuvC)D6Cq>_ET25_S!PUn7y>VxY?1OL#`5~onVp@ow@wqFyt zF%rk?-nQoAOzMZ6b??xz636|TlP*#%>Tm3}La*_-0FL{sHm*IDa7riO42d(lx$V_x z?&1{gH`@a@UgCH?R_oy646<)`1#W`GS!7Q~x=6KX-QS1-Zlc5)l&*AhaW?T)Vu70^ zaT@2-(Z%W9@Ad$0asbEt$eu?1oYf1sDFGa%JLMLs2KRG4fqPlvG>T>Q|?@*HDg> zS0zrPbgPAHPv>?Cf2O=9aSHV(a{ZLsWlP{*mpGenHpL6KONW2z8vz{kR~F(;q-vC} z2|uS!mpJYZ%q}jD`vYg)Hg$%?Y1|)B{Tehs*AOqMGbN7GRn(J6mCp+fZkEL9G@my) z{`h&z!OfO9mFM47K9innO&$5pkvNU!?FR8>Jri+|`liGw{G7t^!g?rg>RS?Lae1iT z3@V3-c9}X?;y53pl}JY_&F>A=@4R*tmNU+*3gD=o zbc|CX)uQ^fobfkR;yB)%{REM!Q(P&|xc`pCX%tt^yg{ULTv_l_=xT|R&vi8ZaywF; z@pp~Hsr;Nr^`UV4Xb5}n25?+%q$8EvCH7lG*Ginp`t@9#N;u^O;MPf;&H9KBk!tdK zyJPP?iPL#KlH%T^bxjNHDs;WXX{=w%wP%r@({F}ukT~|2LG`BbIwt(rcw+!Z_T;`Z zuj@fCWm5o0>CSqPNag(iI|8`(CC((Af%-%$t?wGhH)XTLsk{zK<&%2ArEZZp&PT!g zQKV|LUap|NQ@8#DXHeYhgmd_(rAeGzmnIyqdu!E%y|ir-$NCo1MXF}d^9TH%w*4PC zmHG|dA8_W0X*(oNu2Z8uh*X38qM*Lhc1j$VPe&XfmGadwE~f1Y;Ao$~r18=qoYEG! z-4bU}J{phHq-SG%PunALHrG4hOwu#azouCd$8pK$4^-a2fL_`M5@&P$l0D|{j3;S( zC63#_N<1vmQym`rBu?S@qyC=bO2PakZNJ3HILCMZ z=$-iRA2^fdohp@2!Tj&UM-pdKJ2H5Fz~#lh^NE8JXK^~&v$=jSKREHR#Bn;G1JJ47 zbmV*clK_tT35Dl>R38@R6}LZ?IE~X8NAY4{o_PC^#BqJ5AC@?c(rx0) z`*}9zVYfe%IF4rv^BR%LajL;Dw?CISjz10jL@Jk8?I+}HUr3zF`>Fl^jx&>h`%>aG z@|TwE;*9Zb`*qI0WIHNx>}PGLi!(`29WCULuOyD+(u{X;9DgSCLcW$b-Um_>$sX0W zF&wyK5~tI+U?sRXo#ICuCgjlL6363;1wV*XljmWuA9_OKG)lMnx;TU4RC91AC642l z?Qy%*P>#mmNSs3Y7V1f)vfoYkz45ma$K$k$=P;4V?O8#28=sOmo5#yI!rjk5`a6l! zX`EIExj2>d^ua=oJ}q%J&o`)i8n3%TFZ%lcj^~9Ge>%-8obw>jw#2Eto`H5KQf->| zIOT{wBXI`LuLig{o#s*2OUU;JiQ{>bNqkKzuMI!P{U~w#yr4s0q?%M-b(D}(ev&wg z^Wpd-dnV!~^=tq~JT&qjm&0iXsXt4c7X+&FMl;xhZib)tiBIk!q8DW3G@>Z%G{I ztIc$A2Io6Z$i^LsGkL!Qeh{e^k9V+d{2_5V<0xN^+d1+z|CBhD`YY{q*B+1W%6#Dd zk~r>nb@GcG&u0kS`nv%fm50wKa(|`3??>)QoKF3kLG_{Y`2p115hViyIb9zs(visH zFZdtHAaNFttFO5@lg330e2-+5I4+M3e!?7&i^|);Ws*3P<^wkTFK|2_X^VxNo>}79 zUpnGL;B=m^EdVZy#BqPj_L#rAMBucn5~p%~E^_VJHcQof|8t_Cir#F-Q?PP(vXarrh1Tv{%PQz>0VJ_2WO z`G8B$EpZB^Gft;^RDjEuSK@enpssT5SriBMyFw1lCvj5Wba4vJH^j4yz&-vCoVCWq z@jOb|AmqkRNSs0K#@OlNxPF{-w0eGtQ^;Q$;W#dgeL^o02lU-PlkBNmTzjnN*iS4V zaW>B{HoG{T{OQZ@!(g5j1!m z#Ho)XMI}z7{#b?o1&bj6z#UxIr{#pOP#j&3Kp^(#yOPo#PjJ@5(DLhW>1x}SX zjd6%0k*`Vf1#^dxLrY2=_xBe0nd`>}|LA8VPUrE0?AgT2(Mv2PaR#MpXg4BXh35gN zpTyDKgkwLO zC`V{Hi8H8t>N2uNylnJCb_I!JKiDe?M|_oaLY^BYaqMTC`XPhDT)RQ=a_92{kh{%`Qm5zQrrM|@3yl+GKYNThN zyeSPNPNn@kWwuBc_Eg>%UjbZ0iPH$j?=R@Q4}nw3L*N5fi%lmoeK_Q>$7Ql6;eNK!QqI`N+l7YM3UEl^*w?HGyU{a=OWle$|D9rbv-r^M+!+;p4cnf7Z{ z%vVEtOC0aRa=*d$o%d%#V*i1&@EisnWZ%GiKctVu@&2zh!o@K!3-j}kz7l6qxs7pT zkM?zK;3DHBPNj0|qg)*C|C)x7)B8)D!TaE2TpaJa8$?ysEpkwV8woJH;2CLRX$FDBx-u_19L zwSS%Jjr$G9&ne?2PUrn?=0W|7^Zc4JLE=pA-&2SO_b=d^I#J?mKG#Ce@oe7$?%*Ve z)2ZLprnvUxemvsm;ADy8eoaA~i#V{zzT@|UQzXu&@;U1s0>}Ifl=tAv0UYb2JqVma z{;#9&JLtGvlrzX~)Qf3Em6!&(# zi<9vUy;gH2j>j40B^Rerz6RFYTFndKxP1*Kd&I+8|7$g0;!O4n^hLE9RF5Y7-fDrw z=^XdOgU3~89j?_viBl-|bxaDK}3f2u1 zmq?t(`Sx>hvV2$%Of)5q^RaumIF3IZdWlOV&L+PoI^if@ob~PL%OsBLNr`cBD*HKB z$kX2r;3%Ef#})Fk!*}{}iBky2>#;hwKh)#&6%wa&oRVL3(o-DVN{O?%KPA7|r01;j zPhTZ*I^mpsDiH@9XLcO&O_ewv2QA`jlAhidxOXJZrg6rCzOcvRjF|}BYKb#x{4t;} zaNJ(3LBOq%IGe{+;%iX8&OXlccO_1vanPc8mU~Vbi{}^Aj<|eIoK6qC5UX{g zc^Jwq%Ex+kGVHx4?a6rw^WbuzJx^aRadJMy{$oEm{@WmNc59k1?^pdC!# zE^$^17bo{kUnCjjTfal%RLa*xT#9^6;_tj?y?&>}X`C;$H}wmN=7eN^dt`n{kfcEs4{cx%M>jvrT%o6OSJRa83Wt*Cai+U)n2iMiUpu z{hCR7N*_@Ub)UrX`{yS4S!?X-S*Ravzr^u-%-r8=WM2ml?Z7{9PP*`mLO69Ga34w> z>uV^FfZ5E;!F?og7Nv9lqOm{WXYHWG>CBhfxwPkuTOUgtzen!u--&z;(zDPm*MA~$ zD)~V}y1=o04dZJ1rxM5Yrl3E8J*r;=dif6j1Lwri{W$353%qd8`Zo1z{Jyw`{xIL? z0ej>Ji|2n7_v%!E3p*llD&dTgBK^L6Ej(9+eIaqu&-7fTaQr#AF9Wz2-1uZ!^v3=9AcU`i2k;y_7*y>BGW zpm^r>2==q~0dU_+9N$;K>y8|c#ya3m1#ldvE8Tph-`5Da)pr3L)swN@#c5O@_FCXh zOC0wn<_fY${hseqZkWc(9aY`LZ$8)F1*Cd=`0(Va0j9P>v9xCbCOMyEtah#9(wrh{?T`*FG zobMNjV|xbSEVhSw%Xd-Y%$md(>7smW-`pVNuwNz4rgEFeN5J%YuAZ}=7%KCmExN8z;6He}HkbM*TCar#xIQE|fePNH|!r3otbzS0&+EhN; zcQMGHHr64H8xqI&V>sm&_9PDW&6^UZ)Nt)N?O)(jiXUYQaJM9m?{(3+d{I;m^h4(D z04~x^clPguJ)LprC(Yj_&Y*OCn@ATp=C7k4w(dxrPUX{Szs#mMv(dj=e@L8GmC8dn zh05XFCu997aeV)kx!KK^?~SpLul<+ASyjj{_2uH|90Mo<;VYb)Q!GB#!-T(K?bs{&Ut16Cam2joZyM zDj)e%nIYuFCnV0`xJ0_hm)n&Ae~) zXd(U*3rQUJ+q}N3@O%d4NO@Y~6v{`Z`q0Uq0{&)UiDSObJYD3g@puBgu;LP@Q=H3n z2C82b^XV{E;&>d@l0~|(r}8+6b;z(1636NKP#0(N_%Rx|k`iZAx-rbf$>%Bu_l(4` zzCr5(Ci&AjFB(=#;&@!PoaaQ*KU2NgcwTK)TH@HA^E@tius!E_xK$a6Q%T=>zY2V* zygHuK63Yf~6jy3bmj{pgD$0=?GtPl>#`*S8LhymGucnDpI$}cOrA%)NcOn@ za&T28PNVT$Z|&l^AJ-gQHHkB6JW{K=I38!5_Y>12130qB?UTg*=k6fO6^}k zx~LD?kJJXPro?f3Gpo2bZXZT1A&1tIIPNFyFtSJeknve@3S6AX>xofyMi}X|nS5M-2T+qNz*i)!K zu<8prrM|>5Uj_QWG2cc)p4&j;R7y7p$K|tL0Is3LaXxAj*Bk;EBXZo*mA zPb!F?v=;(6?spoy_PD&JW3O=lNBxz~_DIipkL5%YiPNaRGAK?h>aVme!v5{15@%6d zIqzo(w{Tq9=!b4M3*abUevjGWaT@(sXo~=j{I7I(c^LfM(@V&4EhWyTbbc?DpMRZo z|KTr6oJQlV^B#;SpUvxGy0DklO5#jj=jrC=%j+F_jF3;XmN?e8JGeMre{#xuqK(9v z{5(!Lg`eZm&xN#;IDY=q(eH@zF%KK%2x%{IDz^vp69Q+EeFJ)t9VCwJSuI_h#_J-` zi|#0K8rd@_4%kmh3xSJ^mN=F2;d>>eJqOoG;uOk<qbg57su;9I{2pP636SCPJ0!_vuS-uhrP5IiL)qOqkIis&v$S=B~IgY z&}cVbn{j9_X}u(l{ov#y^5u0`%fa=QI9}&c$)3gQVZfb;l{lN%OMAHaDui?1KR?k& z;uPjb?bo96*=TPk`br$9^ZLC;`zy{q!ihME(<$8`dp7O6*iO6bCvh6@A5r@??%%Ju z-Txmrr`-s@aJ~lgZV!++gZGcxxqKPtw9}A55~pxJG(S*j|Hyd{EhJvzROUzX9E*5p zPCHMKIGu3vegU%QjQb&p636l3ydNoe=u}=4{Y~Uc5~q^?o%SVgTn=ac5ji-3qkL7g zYxsrp1+T~?iPOm*zc0z~OqMvVU$voY&m#Le+DqgRiIaY==Hg89i`4+Qp%SN2 zx>3``Y3<$TbFH3`qlZbHN;s3oU#<@g<8<_JiL=>W1&=+HBYK3yad~tqpS%aR7H}ga zPR4zhYmdv}+;b8=O5!wXM=H&8EM7;Cg1ylaXH)yqkq_#F>k;#k;bSC@>zC&xCdGm6 z;KoXvPVJKKA+Wg~Q6Iy{Nt{agI`5l{d`;4G`nkDA0LT3&{4a1i={fg+hfI(-i~C)Q z1C{kKPYIbQaok_2Y>#kGI|!L1aT=wY6bCBvf?nukiQ{|?n*VV>XCW>^r%0T}>-X@# z;KAdBbH7aEmnDwJGmYYk?Q7uGIOU%>j(g&-V7zNQRpM+Ozfn)fm&a-NrSU5P9L;m= zIxddKeH-)1xM>n+(0Fb~x;P#`P3Wb(Dsenc+tpp1LHu3ZYZ7Ntd7Ss*Mfnu2N3?^K z*Cozo|M7DL?`yz*${PV3m5=ZDVZJKLn=<_$II|p;kH?t^;ATi1&r9@DE{@~Jeon}# zGbN7w!1q;|#KXDQ>)t=-CihhHt{u)F6=SRxrZxcvBYsdq(AH86z=cQ4~Hy~ zI4+NK-j>b19UZR&t9QUVu&lv03 zsJFRqOC0-^?@czTe2$;emP?#M{jovy#(bUo+-|RsIFT;`+rVzbJWy9R1EeaL#>`B43FsByhu5OB|1Td|xDghr_Wyca6kx zeJF~XuNoI-K0Ghb>~dN!dqe3Qgk++H)e z_B5(TEr*cjz8}DmzLv|ynOxrdLO!us;xsM~;WXy|q`-x2l{ndcpK$HjR1UqMkR#J1 zP9?r7+v9RTFM6BADU`0Fo`hc%vS$D{e7nT)xT0ovak3nb1Ghus6dr%_xj2jaNyPJs zof4<?t4gL$+BXK;AT2voAjye28EQw=%lkPRuxW7l- zhI|mf@j4B~DUZiaoQCX`IF;5ttPF1X3|=pXz0iFUXY%?8wGWeU&V8t%`z4OoWppZ^ z$?HIly#o@*>wo;c1cmp%!9Vmvi8Enq!;{cA*O^QE@*WVG(>7PiP#{NS;CaRa$$27!q`lk|S@H!Xz34vpO zDu~ncLlUR*dLP;=a1>`Q?r;D{_3Pv#a0bPnbH8``XA-Ayz0vqa~-O(8zMfohkIrmb99F;is1CP@h@zOE=Mt&u6 z8pjdJBkalYIpgZr5@%AqX(e48?`v2nU*s`~(`|7vu+*vjl^->>&Qps%X(Ht*!x!EC|J|8HrA zexlR=fpfmoBK$0IC|~qdiIe+k)Q@w0=!l=_YZ7Nu{VFt%;Pz)Z^Vr`cPUSct9vlZ6 z${T%M;&{K&qWOVNdbY#&hQz7l2mU^eMe*l+mnZI~#AzJQG@fhJPW7xJ-?&>6$NPB- z#%07Q*;i5CxZ5rc*NNm3|G3}dbH4+aO~|_Vr-ArKQOfkXP$h$sU;Njol0Ap?8`!&F zeBY8$YRM6?NhQUi5RL<7QYK&eQGCJ=pop-D@CsoO6~d~v3$GScIWnqpb4;>b@>S==8LC9Cp;`=6PB%OU>Rc4tGAu_FF@&nR;m{5NF}D?d)i zVUrf#!hB@nfXnA0FX?i=X=d^J(#5qiLOvQdIQ%god-eVKmklcZeYYQPGDh&Je6r4q zm`5GY->o40k!9+o%kW2^DX(pWTp(I$3%)nXXFCgggVZ*g5FcKBH1mI}|1gpMq!S{) zR=rv#gMwqAqe}MaH+)bmDxU*EjTs!P#wR4JG0E!S*qDLpz=U2g$>PJ0CCjTNlM;pu z?&ZK+G;G)K#WrfQnB=%YG4Z{JsEzu?4(zSA9Wr=m>;j^yPx9+AjHQoQNsHuMhONYabQ%^h@|A$L9jC@M&vknM2}%H10Blj zji|n{2?G=Q_Ujchut!oqQGS6<8kmqcqQ^kt%6~O_Kkc4_`}G>|ca+09YDj$V*ui}V zCy0N;WPCz=EZQ>~eX6lt@8lW#1D*#))QR5%dBW;1*5LPY%gWq=+~cdu9U=db>FgH7cbPX5rO?z_(!Nv+E%@tCh1JlKMygQ0CsOJ>Wa>`Q|Gze@dEAwL18*yw0ds3+?~MYLn;S_cv=N*M*$@ zkD*Us-q}Tqs|Eb(Yqg$%oM+d8eXz%}*S>#zTn(!D_^isIBf|xsbFYS8!g@zSg;{4H zzke>G3g)k^KD-?UzQZ@4ng{v4&%#y6YdU|~8uNLtzCZsu4~~yVJRkh#V>{Go@6l~L zVclkCrlD0JzgD;GB=F0oyj}}@6GKODfz1ARu}dmnR#HDz(O%sj0LuU;Xnl9<`F z3#$@UC0snoif2|tM&R#1d5$eqbowNWQx(4G)Cc3zh2HPnz;hdqOPx;~&4cIGT<0g( zz&g__)3+}I{*4cpj)m;i_iwl59^7u-aeaG%YucJa?@< zb0*dsOMluV6mph*+ooWD?ZE3I(1qM+;|r;fzdJH}5#&V`)c1k+>ihGL&42N~v5pn; z(?8d-A}U2y35yDUsLwUbxBd^snqi-1ZS&hJz|Sj>ZqR%x)^Qo{l^I{tcV53PXyPAh zdRfP|+Xuek^?#g-{eU+Sm>ZSYTw&z-3g{o|FuEIztmgzrlbA5ccE@w>>K57A3g_i@AD_>VjuH~+xtF8d&puh zX#@GWsF|Na7OP@yfcNVA^WPX${A;e5cmsUSeRzEm_`Oj4w+xscc;%yeO3iWj_S*bA z@X1>*u7K>-_g@#;^kDw({AuNobDr@dJPhlm7(c=v*8BisJWDsw1dq~|sP-g)6_A(me$38L37Yoll1-ah()!p&?0pTv_ zd+qu6AH5&!4_Y^RRP!(9KkC%h&ftHu(b_QhV_*8?;pk5mo_TCE_;UZb>6x#GLEiak z!_mNd_5Jzx2rB-6w*G1!)`7~ z__8wM!K?4jzgtl8KRM^k8Q3>D*l=?S_oF^ldp&7NBxcq@3Il&N5rhuf{<62&#?o1 z-|tvFFWLw92Z!ce$^qF@FB}E_r*C&W2Kmj>QJ=z|*S^1hIt7(~t{(6G8seeY2d~Y> zIV-O3COc~`!g#^)lK0xkFpMX>ufX%G+I4&GK)k%N=9AsPcbFKq6|%m*@D|wf+V}U* zTMr&zssDTw=P(dIz2=Rbign~dnLFeGpVu1Ieh>1L4rTiyUOp>ab~4uCIerTKFk?7m zo}ZMhbh-olt3F$DAL3`ro=vqNvwytyyZX*Q=04azk8pnWl<*I)t2g>Jzw`ddw*`;n z#JW7sFPcxgQws9zT`Lx1eo?te`DY5PxDfa312*tMAYMg;{s~*N+}F z{||du^M5@5pZO~5OvK0TO5dyozj=8UEr$HZ;r+>Y9%g>uyj8i9^RHLm zpa1g@UO#?>{SWwe+q-lt_zaGCX${toD%8$mLGCcVb1tkGasP6%$Iurc$N%2ADdOSH zZ0+s=@74F`-|NBg@AfZ`WM3To=dR3_2tGC1@9vBGZ)GnU4f*L8Grt7Be-?cuBgSK1 zFRlO6^&1$^IbOW_{`@NjRsDaIPbq@&G^X*gV<>ion z**NzE_`j8Sz5@K?)yHce`GjBb=RFHqMEvGnkM@CGLOl2#60!cjvG?~MoNlY~Uv2$G zh|_JA|91NB|Iz8Tpx*tVo3%>J9tX>?mz=7Yi_9>>p}loR6<@6*@rk~J&h zx!RAF8(^Qhe0qWJ(7rQuDIJY-=3agO_G=D&%>P_hg<=1)J`+|oDl9yzQq_maU$ny0 zNrDgWcQs$JBoXHryz<>em%f0181I!C@6UgEQ1Sn?@vc>a1>fOUQo}JmmmXQ@0OY)1 z&+d)>>_9DXl1a#$r_TBV^2CV0;{`}v3@blCop6^{+*k?TWhU*`<$NIw1Ha*^iY%dKxg7vtH(W`S} z{>ST6yM7vX4g7h1!>fN?yio98jrZaRh;lZQ2vQ}rSoz!;l=sDdk1Cna&p9U@j*&DT zCs)Lw>6WpH;>>%Sn55*v{YJ#7%@c;j4p!U7;`DdS;1O!uUU3Nt&SCDrQTP`VW8)jN zX`&`MXWwB=*oqft&Yg4JQh`r@|2;K9oJ)^MP8j?@%3B96OiM-pQd+? z*GDCaQ{_N5uizfH7x1Lyn8C?CdW#e5_#wP{)vyZT)vG(F?0Y2)95N_ADyiQ{=eO#^ z!<|D${gTA#n<(dSJyZt5FFlgjFDODnuU)p0PJ&e z4{64!?mhz(5(Y=zpD!Oa`Wuz_etqMdyer>t%XNeEDuI*kzFH6;J1|hz@M=(S{U0m5 z=DtB8$~riOeP90jEiyK+HnIw{`9vu8Ip%3 zh&K#+$M)#muTP)Y!8olI7wd4k&oxmA!{Wuu5pn$z;moAifqni)RZoooe><+ZYGG=s3L(`nd^D@16{~#pCvMtouw*3Pl0``1eyg zVV&q+*61~mc^#=}iA*CPPf5yMsj~RpTOR*@f53z136Hqn|MbbEeDEKSV*_$Um4K{0 zaq|+!Irb0l`~Fb!Rx

`!rsCfByZ0ioZU*^v8%Ner^xhxczI$tLpqQ1M{X!TXG%- z-?e{6gkT;}Wz0|uGVfz~_5Jz39aQt=>0!B|F)wER!!OtB3i*7+shhxmkI_91{+L>( z)qcpw_m+PjGVfb?_5Jzp$#tuy@B1%F2d{X~Ns4{`u&A)gQ4!T2=5tc{ZLb_i5PUeE z`+iZQK%$VjzWXk^I_4!I&+m4t1KLBm$y!CoyzcJR_varURPmqQ=9ih^v+-D~{^;-W zKYRFR$h_~;=f#W*P~Tm;4DXJ8gy*WYZsyc~DkYW=FyF!d15B_*He~(L$BU>yf3Yqt%y!!t9UkWPzJsYY21x8$b8PxtMAXhZBX%Ne#|G2 z`TZ^MYuI$t2=L8#!pMg9e(~;xd{_rL@!SVLApUa?QmSH~!>jMlzw3kfyX!=cZ2Y_U zm9+D`?~?TSKI{NtMAXh%Y*qp z;`wjJYo*(PU#=_zvtoYt_Jv=2L;f;q^?dNHR;c|H)PFUla6YUHzFFe+A&`%JSzsXW zUVVT5or8-1z|PNR#(ZyLzmVFf&l`D57J|(Ce9IR6H6HS<;U$V!;*ZoVc>(v!<%F?et#(NQst9Cq$`A$N^o)d7+dsXe}J0Wv_ z;MMo%-zKQ|?`WvMi}ud@ByVS$xgK)S;uSu_doxWIU;G2@VfRzw4Wj#G-Y4+t`=7Jv z6EysB&Zfa1(I0?sr`Dnc2!1@DA4KoFw)-jHOQ>&tpLJlF?*~G@{Mk2y@SYTp2W!7? z-u!+U+u^Y1weKGvi-PL;@O=IeGqE0!H*MWqyhnHBi`8#J&ZpM@D36dgF77-A&%-Q# zB3>7U%y_T9e|#){F#kumkAnE<^zz$lz?a{PdoEMf_aWcvQlmBAqvQ2~x7udx33=tQ zKVHN7P-L@@2Rpyhx_aB$&3;6D@cO~Vg_FL7?6vP79}6GsA9wtGWc!S5F(3-QGvEB= zTf|4sBU|P}-cT^d3B*UE&-N~cKW5$So+Gc2U!PrJCS-mO(W~#zUkjT41Ls7}9hmwa z_{3#ep8@JLL%w+C(+0qM_5IFC+(rE= zE1h!^_*Cov#yN@qEf`J5Zy2U7jl-MtXM6{}yK4ZK(1zx~z?s`ktMG2dr#EVy!pzyHBGCuc|O->x`#Wapgz2e)|r z_wP3NpI+ki;M)wSnLgC>OjC1eZ)4u^$E@3@vWvK@(XQ=G#Hm;2cFB0J%y|EHx-6*X z87<46`~}aQ;xz6?jH6Z0=C}g6M&WCp=Mr*{?#3ASXLJtZD&%2i%Mp-^%-?tvc(1-c z|D{32e{jK=SHQPQ_w7x+x<(hv#0P?35L%Kpf)?OUg z6|z^~pMUM3>Zf_%f$ybjyZ*zIsNWiQb7syU_{~|;?hnX(PaQv}{nE8{Q~1NH@6SIn zsQ4Egn^+s|eagM$Oj!gUKA+Su-?=lHh0OEnb4l~qf$Y`y=U+Xj z_&=6^+>_wL_mF)>8eo=5Y&oy>RCAd%So&#UjxKQ^e^NB5IiCW24r zh2e)VKe#oc%Pg$xd*w5qr}xP%erLQ_X1qWD)=;dtNrRo(8G z|K!V-Tn7HAzNy<%$aP-LR}6C367PNpyjS0!f3Kk8Ke6DLENJgfeK)cN_$78^y>Tbe=BI_2Uv%!@XM@I;G3B9&0jFS&z*ASnVv$Pe!X9T zzr_4#?CWnl1-?9g%73ly=Y52ncfr9!m`{53{rOjVaQ)+acP3@Sum4{El_R1mS9zHA z->=9o!@z%7|BBVH?=ocl-OnK(%y}Ul@&92;gQc-Tjye5fbHw}h>(4iVoUhiiv#_t{ z)%WKg@nHV$I{YJR|9e|BnghQ4K1;Xwu&*F@&$Hu-vu`&(OBncmmE-6w@O@7&e6_cb z%S~C63H#h$eSiL=njbCZ+y6bYB2S?It5zW_vO#UC1gZCHqR$AHw?Sb#3u*G&AWPTsqtMAXh;L!*2cl*Bw)PF>! zhZ+AB#p_<+%jbAE{Q3J9$Xvfu&aLR#OUQ-JCDg+?hD_OCDGr(6=kV(L^Z$L=P5*P8 zuWtC4bDZM7%Sz(T&xbnx#p5Co!Ds05JD)u-_?@gPcJhTBaj@GYoZ~yxL(B+;%=aTS zq4uCXe`;Hp_}#1T&;M3X@gKMMSXdpi{F9m@o1D z2VQ-D{)HbL|8D;`Vbj00fAP^0TxI_-ie$`+z6Wb$NE6c0b9FaK6mQ*CGVoY&vowUK9JL^CM?8!H_NHt{zQF$S#Ke&_oyz54$5lMQ|F^N~9~Jd$&MxUX&M zeEGa>$Y)c=T%wRD{8sxq>Q2ASWFonJabe>^`$#=Tb?zh9as5`@g{$7|0& z9)>*FA8vo~$i8#5^62*5;KT2^;B`mH8GoDpEY3adTRikO)>Ec8D>4G}qo?vOcmwk1 z{m0kB`<`BXfBvh3YQ5~)L!%a=zIlGQaeVRy$b7%U{x7XJ;19mvp+?bzBOy=tX;FQg zi}ULH^IsEG{Fxv3mqjxdT?>A?xXuLkS-d>uK&rDoNcUS*Y~ODhivWMGzCZtW zf{OpCij|L}zTZ34WeEDecUQmlJ!HNQ`t#;t3&A&Z-okId_ui31vmjsHap6tiz54$A zQ-g|sVuM@nf)C&4ns17u7mZlv`#ytG5+0MIN{HMLgsUiUVVT5 zD}suD*{1bMf?vfBU+H)r+}po=4CG^j`<4M;ULWQ59#_2FZT!yj1Fyb6|5ZW7KOx`R z0^q~<8)fdieFEg=ZGMfzx&IFHXHUTT@XSr?S0Ub4=h;>WvOcTIFL-|P>ihFw8C3i= ztMg{WyPB)!EsXE8p8iHbeDi$Jo_M?>{K4;iy!LGLkNACE?~fW`JoM^|M?s(aYm*;5 z{y)O`5bj6cnxm%){@3uOL&0a)NAt>n-!E?#{Q=|u>d@}7;LrDcHB(Cuh0OPz@qPl| zXLzSWMJeOFv za2NQDpHqJtioz~|dL;(})(^Lv!}#A`J0$ATYTY7PGLu5S4fzu(+=>HR7~_Uild|2e4m_sHGq zY4DFut#Y`A;KS|vX>r`7s*tah%%2XvAKrPaOf@0%^V5pQ#TNyI?A7<@e>SN2^Z57i zxdJcN5_~Gw*k1?roxRPoLlF-Rn>1dI_E#qwIHb!XIxQ>GlNV zCO5NHfxo=^{`@EZr~6G0?fVBHWibB#(!J~r$iHmLv<~yXclXyU zhxmW4#$P7(F2D zn)=6n!~V_R?CUQ0%qZLBcyqyLXoz^jOUMg~)V|O_$T=GSQ5^nIepuHX{ay2FQ|m%* zRru=*=r5Ar>@*4cuOrr7*;iM{d|qK(O75px3fXJlKR(h!&in62E?9HPbFWNzHS9-= z^9T>`ye9gG>T_Ed*neNOY1Rj=1s^_-P~7;c4ETL;ufs;fL)GL>by^9T_h-eemEW`# zvRB`q|FxjvAN}c~d8ltAcS^pFf={m=8?QnZw<;EBBjnY`D`W)!?n|m)1mDH=3#~>x zJo!wspQDBB)%WLrHK_QX-;?#dZh}vhT*V&;zssZRR6+a5)1<;|w3k^ww!8p;47~6~ zyBCH0TA6~qAkTVt-xuwL?A7<@e?6%9zi?1n4F2T`K6kIP;Ipy!>t`X~eyQ>c9fZv1 z4W_+(s|f0QO`W}A;J>H*rLVdQ*{ko*|M#Hcf9JX1Pk?Xe3mKN_f)9>mq5ki5ub&8h z-FFt>it{R^u61kLO~}o0fxM7ge^YpLjF7$h{`_wQ75^;L_RRyIznX~`3k9Du^K0CJ zTsNm!Q516Ct@Zbzy?^^%rsN(%E*$$;AINVNYjLBukiGi;{C^87{<~Ih>Wp~qQR@A( zsPB(!AKVD}9dp?)=nos*E?5`+U&cv!OQU^M@15xa_@`wpp~4?teSiKpgNpxO;cHuf z-)p(Vn@56=nr-@0$Xx%2#Alu`AKtZJTs$LwPfeJ26!XV-cBO3TC1kI@KmT8YivNf4 zOWK2sooT&fop_Rzk+iuHAV-dWHg$9V8e1+a!^(D|Eo%@PKW$` z0WlL2^6?do&O=`QWB=Z0A7eT<90z}hw>tiSd}q|NH6iD|J2t$FkbBgfzX9@KTwHx0 z{`rB2JHVgb{QcwSVo=2ouHiy^3BiSof=|y|MfXAG`BB#H6^0?6j;H8F@H@9B?oYk? z{`@Zo75}bLKi34`vtQnxjQaoRSfxpj_w`<_w-fR!9WG}9{}NeVEP>yPZ#Q?N|EQm| zs1f>8uf9M3D?!Eo>crpP0pI8!GQ?v3SbpL1-ysiPnf+VL7rFj7R><@j=F?pNJiqel z`}4mPRQ$O=C@}5&C(%D}`{(C}#Nl-tIOkD@6sd!F;PY(d$L+3w`J`9hpZ~?6T3>xU zFkAixhCkL9v~TVy z*gv>9xT%KcrF=Da?}EJlz36>dPuQ|^@Gh)3_pUQ-MPniJek-p>tf`l2gR>t0>W$ov zKJCZEB#0Xu1>S4lKR$j98vkH@VNKW6KjDwRDvAM5@X4Iq<1_F*`qc5;;48jn{{hz9 zo8-TupuaF;e*X>rN-8($Yv*~(qwmlEMo{se9ry8%SYPD%Rh32;Q_((7uPy&YC&8EZ z^Q+F7mlNY7UJrr4y0)8^g87wK-=F{WpyI#e;H>4~gXt9bd>%2a73x3#jxAd;ANuUB zwhi^Y^2mtS@VvFmFe1ycvav$;>ihG*5>))3ZaHrO?yYmyl?9({A7?Csd-_^T9$pIb zU%r=l$(pq%z@P6i{qx6N{lVX>@6Z2WQ1Qn#jku@r@GNoRzu+@C{IO$@opm!I^F8id z|9p?%=}(?m1pZ!qfBxHoivPw*o5OMcfwPVz`0%|%pXRxm7wwNm^$g>1;m(TOPj{ZNb%gjvh&xftIO~vy- zY=-=4xId!E;Tdz#9=!Vg{67jR{8@cI(p!_Vuf{9TMiyBEI)fADu55^<5a zkiGi;^TV{D;g9*@u1dVETTWvLil(i>6fqeVVuzHX?H+^#!{NvU4=YKS)_-EMt?M{q` zKeZS;4bMASN9C`N{^I=P8U-5(`K2G9`VH-8=;;spydY%0FNxO|y!!tA!&gDWAN>R0 z4d3u zguMOnrwSk*rcD3sE@bQcwV&~O>fO4faDL0H@83V12^#+BA0qoU=!g2|^C)~z_mNyb zWU3+f@plhqe={u(GS`2l{=1vj7Bc(msn+?&Kt9o+Peu6W=I2KZoM-Xc_m7Vsg2q42 z`IQB)D`+33)iP%gA0uBWQWA1}Wn(MO?{uwIb{G8j=c_rh;&*q!eB^La@Ajs~u!fPeTq z8K1nkdJOD&?fb{aw?X3{w2ul|N2X#uE%i*7=xTz`-c~OcKz#f@;i)IWgdBI#))5cA zv+T(oF65(2kN$?=`Fylj-=F_CLB*fH`;)(S=5~>S-^uPdM}lv~>E>mAN)4{R<$a|ho5$fjzP$Gk8kUT_R+Z2pCOnp z#TT4^5$AvTzSfeJ3Z~T*vRB`q|FNLrzv;0nYw$e5=iRsMD-!{}<671oTUGGm?;5OI zJnJ;tN83*Zl*aS+!{rS%is_!N9eXD0jH7bev<@-nRc^t%lKPxQn=gYiw9^9+%&woKs@&7HUep@_G;~HwTXI@`e-9&sLO2}(^To?`h{Jzt{;j^1U zuJ?Y2_c0!L_5JyO`QZ3>-yfYE@{{L2*08GWA}Wc?hN8kB+W8jr|NLDOoeZ*XcM)(^b;{``*w75_V9`|kwbdSBEuP@m6~f9?m! z%j)J>jOT%z3kPOGybqrGOkTvtnal5f2mX&2>Tnh7PhNe0{+|aG|00*t&Vb*B)Rd!G zU*i5fa!<@M#QT)?J7pM#D`Tq7dm9T!g zsEK$1O30lntp5o0{o$$2lST`<*dLwBBR=ZiomUrfgWb*E0p6?c|9rG5X!zs#=&Ng) zI-)+yt}wp^pKjtf2IOmRJ#_=|@ahxS3yu^ruNRJdt=lrluWjD5sh0SC(1E)f;jdYP zZ&k;9=+^9o`5|9@e!_=1zu~oC)AxRbg%6$|xc6~}y?WL2{!2t9d{?VEqxs*2yu)~_5ax$FKlx@QWP5j>g_tjQ{G#}L_^;=p=e~!` z?Wfh<%SG}2Y=MKnmWTf$&em@Qd3n3N`62UsV(0oQU7)WY&M*P_dGq&gU!%;dwS4ch zd?fzE{DS$AI&p#v}Uidr2O|KO00h!-F^y>Tb9~o5q zlW;+w;PX|3C2xTLle3r2f&59t{te*&%f`7U(4Vw;+`bMud_<;0kUKuJ<4fSZ`u_ck z`QY~B&M#LEIp>*QhKui#S9Ru>;ScLRL-a43r}bM2zPx_I{j1t2+Z$*r>^ACC-39xIdY8`W@qbe|q)(@AG^6zY`yiq<_GDe$!Tq2Q0zwhc!hHpuemC)AbvW z8%`ScIG(@odG@hFo|2Ti5@h}3_T?e-^ZE1^*PemQ>z7`8{_(Kv!Txx}&m&23GcJKY zufKFDv+pY8dv#jQ#d_R@vEqqC$Y!+y??BFg17bp6{JAj_sP>^*PE(-w(=Q6$hlV?It;$WUOiD3 z@;OCRypVrf^-==xFW-K-0%WhgKmQFuGato#Vbe3>Acx>nkDkYBo;>_2%5Nr}pam^WPX${AVmU^BCGY-!D;q=cf%J^Lk^GrR7JD5i;*@ zt~2V@g8apdu6MB>9xmUn%U7tl)A>aIU zTXURu%T?pmCg6MGXr6J9Cr+MK88W{=oy_PShu# zXN$}BQF+L*`?Hv6-x0G?3!*(dJM+EF@DD#9U+&SVALN+7mh8jtm6pHqLwg}}e~@s@ z9s}8H-#0|s*Wo^fdDm~Xfjp*Gk4>oGoo`Ohi+C8Y_?c^Xf2#GJdI!P3 zM9C_bdJ5UA@6UflQ1R#b6u0z!9xM3#HMv+g_&(mYoVs@m1*ykjpJAa|iAHyM}X@ zAs*UQsrwb?2mF3Xxs>tiAlIp3Z$~_g3rjx*d;C6~*S>#z%nlm=;Q8T3`D|yw&$*XH z@Ze#;?!L2Htos{uKH{ufG3&!mOa- zkNpH*FXZ=$$IO1bAo%6L2OZvEBE9D#EAL`!m5k^wG-bH3=4}2duaQwXb*GJCv``BJLePypJ$rnTZi}`Gpt;* zWFhYt$6e7Myd5Pzj3ne!aY_Q@N!z~rbc~R{$R@t1E9BzW2X=t$wdWrXq0`U#e_we2 zz8?bL*A(9uj;Ih;rCnI{s7jThDnHckOY;0C9|k)EkTcI{I3Iksmdg4o#*55remRTZlVV#9LcDnO{hz1Jpy7|_Xc zL(u-q_6@xZ`T41vN`h~%aVKk`{=crZaU0}Y2Y+4-+4;`8kXzY{Mx(tr-@y^`XNAj7 z#_!_v{B!V+*S>#zEDaj}VE)AC^_=hG2tFUQJvaw^IX?LOl5;>-{LbgKocHL2?A7<@ zzbL5qCm$(Y1bm(Eh6+B+H#aW26*BKP|JI?~%ZQJr**{o)e>`~g{l|;l59a@f#|wVH zgX5jwkExisdMMT(Uw(T{GWrAWdL-X}%J)-&Y~DF=Ljn1h@u!VD`o|A&KdmcU9+2wyQnq)}z

!H_8*XAf5^oaq1@cR&pOA|2)*!M)L*N#7T8s@P%=Z`Pe_E^f z5AzSb2K#ZQJ>8f?e2Dcp)F0^nC0~1thleS{CSZIqc`PyAc^B<0KEwAH=f9=UOLh4! zs_(C3o6Y#3c$_)?&stOGL-75lmD~{xdw4LTW;1-xHjP#}LmrY64J#x5y1&h!&EQ*l z`fC%!pIH4L$5a#=f^S`Q|zv_Hlp4dlxf94Mfae3ksI$?tu{3T`&3*NID34coP zqo`e{$w~*b?VLs65-EIcrlNT;- zoDct!>BG#Q2}jU=X^OHgzCYhzod15U;=kNd(G%a3?YE3*^u|A6@eop6yH@%eGK?=t8^YTt3w#sF`R?}_i7(r(>W zeD8V7oPIzbzVG~W8T$YFc`py(0w*tQ8&8xdBd2U&|6+`&KpF8dA5d8b^FQ0aVe4G^gQ3+l>UDB z@p|gg1q|L|`eok8?pYx++|CsjTF0`!Qk&az+6`16x={+P~B(w3D(Q~XQOuVVF; z!khaY#`kCI4IvIon6kf}F&{8{(DbdjGvvechsmp)SFylx^gOE%D4ZiB%<6&JJnf`} zKJmX#TwkhcReYw7WXU!76HNc>l>Isd{>0d4@qQQ|to~PRo2u6*0OJ4^wL4Y0qc@c> z-~S5PgY5LBJpKG9#)8f#L0`_$zlT>3R0apQ%d1OGs&{GQ)W)%gNhev5x3A34BiN*^ zLhj>X(la<9L>_KpAu+eFZfa89Em+~>q44uU4_`%?+`~0EP>zNGrP5Ox=q>XXUgoRx zQ~E1{+`Y5*k;=S;Mt@~skgJ76BFXIMD+`hbC}ci(tGk!tw@yCt5V=nvTCDJMcbUJ; zT@e&6^_K?(D*c283rUdD>bE~nD)SgDbC>(Mhohq`C`jh+E%*5Cz3xgs4@Hnd=_fo9 zA`ie{mxZC!2Y-~zM;aI&7$o<_Q@%=HxnGcLfILtU7(_yF^$d{t{@%$;uJloQDcof~ zu7QdmfvzXL$km5Ln(fStNfIH z^6De=>l7Bod7k)z!aA{)PaN#rAe{%TCX*hry6#55LR&`?Mp*&O3y!Dkx52JEW$Ziv zY!d#Y8}#^ipT<)#PxP8F)OHf#m5bBIqTT=ehjr5lf9BpFPx=e{g8$=fp&_+>7IgcI zpxej)H+37|^S4i#JGK7n+y2%w7rJduy8Zjm`=9Ezmr}{8(SL|yn`hDLrFsQ2vrj1X z#L`S+?j*66TAS1Ct)ru<`3wF%ThAL+aw#>N^w72fX(;LC@`*QXrV+-qV9?W$X$>0A zAk6gsxk+r{xrF)t;{IK;am8&!^KbHG{>Hdw^;2CWdme)yThDIJ*Rd7GrD5oSQ<#5d zJY5Gvw*U;;3$9BiTuUdC%+U|LzheoqyM$qFDC>RU9gu_L+ zcF%&V<`8aelu-=rC0%Pzf!?yXMz2+UbfXBzMz7U_Udsapdo3W$zhB%By|pF2ndrE0 zSHDJKX89~A$;`^k+!}R*Ea-n4i~GXvu{yG$rPtPtCO)zghMQsca7+R6$hX>NDD2*k zKD`%#zi&{oBic7tk(68rJVRB7mh~66du7z;9TuWLN>|6P?Q5=XlDVCegz5@v@?Lr9 z2V2L_-jj3*{z}=OHi^h9Ve63l6UxUTPvpavk}qKQmOTph$3A+pa)CGWBjeR}SKx`q zE*+UkSjaykJhsqcDKP(jasBA345c2JpD4M%Li_ICRljJRh>5^mmL8R(FeT)4PE2#lKH{yfjy@ z4=Q=249q4DZf+&DvXDybHKh+2FRd*o(og)ZMtM{Qf8R}gy@Wh$zeWno`>ZZhP|fIH zkk_h0mnCT5e}NQ)urV&&COoI9!#T9GbED~ppPxiK|9Iym&V$od|nz zvHGoVqlgd7J4#C^yB~3(6j|r*$kTkhwbnzd^IsTKB!lq#x|Fd@nC~ynzg4MM;`=0d zq95QthSG7tM|V(@@mTML_bsE3@4ev8+=*Bp{6?kShI{8aj7X<!n>A7{Gu4EEu5;U*H~@ozsg_7vt% zA>K#0Wz57fu$O7m&(=U5JDV@~{^Itrg@fAp0&59fow_ZuvyU3C!wCD>zlC>GeE+z> z%bqdB&uicH4mhXL!s^4rMTFV8&PQVgc3Dc8)d|z<(_#fMs|!@M6P@%STxO7S2Htng z=6Kpl!uv85nPL+OjL!*?8ms9&vr@tKL6Tjkk}@a~N;7QfpgH>J!WJN4XL3W%DVM z2dVeK3ns7!DWQ`{^I;wsn;hJ|2#VyTI9ms?#}7^+epndmgfq4%ktds z9jldu@5|1~GyBWV4PAOrZ5qCJZAy#8c#G9qQsb=N zLO%Ta#r31DR_RAc{he#^z3UD99=)3QO#9mPEc9bfW#h5}Kl>UT!M76EEB z-(R#|^9_Xg{!TgA+c5eT{pdfs3Gvw>!+bjR=wDg)nf&BoK_LoxXx2bDwlTuxfr$62 zW+C64w1yV`)|!h8pS$5`1O<)O?hlXA>-|+bv z@bO=jyRRU8(&bGCaM#5{jnU4|AJsoT;{@8Ph0smV)Na(f{`&QV=T@#b7SFd{J3?mU6+UUmOOuT-<#hvoPFJ{@drCb2fNpwqAtsWk;2EH$TB@Ebc^4JiXV z&E(dgO?)#m*D{ z7&kFgZWQ0w^-}pQu-EghQ2|QADNFkrV4dVDOGpQ9Xlzks4PjPqvgE?|)gZ4XhwHY4 ze99g=bPer$?-$F!`vwk<7`2u#|9Oa%JaeP*sx8FR;OnvTkb`Yv*g5D)p^imOA-BdAD%FBLSr=UK3gmLW;l1y$CwzbL z_-&nnYV%fA63CN%O{@#IbsMq>^E}-)+TbAZ$=K_C67%pY5C5nGgu5Hk%|L|1UR|1S zj4-<=(DeO;**WS>!Knd|Pj#-pIR9%}#lO#s z#7EHohx_V1+DUxaxz~h;ZD#|sx`Q64DxCp;_Py&xpJ)XBe1GwIq?J=H{I50+*~bg4 ztuFl+=9-q-NwefSNw4_p@RCd}%VRyeui3~+S7p%nOE z4*iqY>>>PRYfD#*m*e)&p91sm7ne_+j=7M}>Kx*iR+dr=jpeaIKJ471&47nXw-O(g zx0}zg-yZl8EU%aO>nzWEPSi&U% z@>w)s+E_e~^jFA-f4{hX)Ko8@JoGo3e-3;~qCgArS#zMb1NhcyWRVY;ox{Jg zodURo*}2=}y)VSW-{AX;^RK0zziOqXE)QN~`@`U`!wqzgXVm=udl;@Oznjedt=6y@cJ}J`6?w)aN>rFpu-UPdpy-=uh-a zHTrJl6i3u-d>ZyS@7#&a)Gys!$yvk1DI0e8Bf->)`ie^Ebql(i?#6n}>tbUg=)=*@w+@59UhKe|!1JF)Pr~Xq~uSY;$e1Duz$}ax|=k8SJ z7>ot}|2@Z$<=owW{5b~I;aVLv$CGTy)@o}0huvo9UEh}5Q44Wf9dI%*r|y1-(5@|IWIPuhH|OIi9HtkgGW< z`iDpA{(D>$i59uhTutiji?kJQpojPF>(50#$tvABanRG`4IVnsTjqZ-J?&p(+)&Iv zV<|ln_LuLkPH$EAwui89`Cohc|CYU_C4x?RIG?3Z6ZY8nsx zV|H~5>G{t2C;UIfhI4~^C? ze2wQbMw$1AU3aN(^B8#dr6D`upA3yX+aLJmdP-y^%>RDzIMrP~_a0A_l`>YTIrk>x2azdLOaz6+~6)ENg+3G?qC zAgXuWa+BwyHZxOG{z6%j&)#En(F5@>oo`|{6lNzT}K??a>(re&<^gKPJ7WVIEmmFpTJ!W;f zDwd&2c7&@;j$Vhqhxsr3-xrmGeE-g`i}~|BA+^?Cb9!k^a%bajSVnyJCB(bkQ?KWc z^E)@$q!_|ck+HGBF65TsoCedo!HrhF!np!=E|i_?X&Ya;C-%Me>R5G*ruH7~Ej9sv zqwBxXpXJ$H-PZ9L&PlTS0mtlrc^$uhMwHnce5V*6%0MCfi_8qL=a+{vSQ_gi*yGN>^uv zkW2y8uC>I0bsdCUAvd}aQ~aQ|Pbuax6@S$%0G^$TX(|JJJr%&EAN z_-dJsC1`Zr!Rjxt^GdeAVx`b~_oCPAp!YQg1%<;dm_P3~d>LV%77=^U9@=o)N1Q|8 z`yU`(&Z&MyCo1pBHrfe@S=UmA?Ky@-QlLcI>xa zf0t3R%t?j+A17Z%lKUnoy-i^P|{0nVnx{>tr?$#n3SY=Gljqi_mPfq%4NNuY6UqAl;R{!hY2P^+$W9&lxFLA@G;rd^~y%^9#x|S0D zsr}*^I*40FeAwU)|I1>QS2W^51qYjl!QW-^_4L~pqTp}v{d3`82|8PbhUf51w<61l zII_hwZq?;&X*4ey*CY85zt6i1+{e6fbMyBB$OmBa&OOKJ(U8Zg+2$=9;AhO3{j&66!nJ4_0sX)A{aJI!XZh+Q z`Jw-=#k(1!J;`hPXvmB2e?`P@e}aDNe0_x6q{M&iclZap`6%4s1^ix+G)IS7ou@%1 zLWO#q|8+M0v>0g)&W4p8C0o*0*HpX+>t@-p%MIkDhwPkgd&{z2ya==U3fI@PeF}VL znNoi~>8Zh?FSgM0&x@)UMiCb7k0s3N!ws4_Im(kTJ4eR9Uwm8_YnF?;j{QDnSC^R% zJ5YTyjpbt^j(y374(<>i7FXD6da*C~wWczzbBO<}Z(~|OJ}F}Z+d>}kx?3Bd{eFXe zpJ4a+{^I=CT{)@l-U8LUR=D{6-|kU0H?yF6G<5up&iiQ8AN9ol5g)G+t^*KnolxY;~r$!Jm}y5Ztwix z*fkHCFO{g3Qt@EFAfaHe+@H!5%VhyU-a~_BL$JecF4U@2c=$`*WxoEIrHcdom3~1o zKe;kE&=n1tb~U@I#s7C!5`VX9u_{oJSXtPDXEz_YLgpb4G&O0}wM|b`la`7Ax8OjN z);;l;3VniPLBtI7Ss%aO3lsdu6@e~JI+ybzkCNQ2tmp|%*UH9Wu`KlOqj|);o8^W=JD!xN|nsw}R>=*L=#m|+lS8rD^?sRqX#I9l= z@A;ge)4+%AUkUfD5#F~lsucK5*85c*^GsFmf)jwxe3{l77}F2-ZH8RkT0}vZFMsRRQd>pLLL;L^i-%!jxy79WY&mhE}8HH zsXtXn$NK~1?qqQ?BSV2fWTsp_sA4r5&FyU@X6E+xLgjcCfz1pWWsQ9*-Q9!zWqv_Y z51CK`-bUym_=KXi%S{$84a2Xq_|2r@$HKdWAA9;Jl>t)Gcl{lj5-hBWtDJsH!c&!= z&k_d9WvyT(6|1KZvrvQIPY~?iqWGVcz5hpOKpE;sW%a!k{;E$w^97m8%(n*y1q8bX z1qTSU=A|x zBXRVX`?c=T!6Z<)fF!FO!aD*pr>?9)rBAT0pA_4H_yg_3 z%mvaKnTUkxJkt#S%ZW(k8{noE>2DKJpghP;<~}5=q@}r?YJx(Qv}o{l5PdMvJ3($&eMybJiz z%eGRXM;dqRd^j7I1ryS4VH_UqHqi)iS9U&d_#w}|cphOooO|Q@i`yyhO}WT3>=cyU zd0DC#E1`;Enp{T|2y#sZpIIH+CWDV#`>wUY*K1dUk2vQSw4-@9V0IqTtQy^*MYv~d z{{lD<$@jmKgPpRYpBvMERDDhW&WRSgLq?OAvm4?@OpjaL3GzrR?kk%Ilk>wd(C)x9W> zgSdoQ-GFBwy0-=1H0NY9@a?zw#AiJJYHaU8z!diJ zS+Z~t@#%{KY{X|x*A``Dgjqb5#YfExw`_;?YC?sd>2B2Cqxg@e$d~8)i`&QITC*<& z`xs^3?5qp%sqO#55BtaEO7$!0N|@a%wEOb4Nste#<2}80Id|ZomLty~A9?uxTK2$} zJ>F~pmY$6+2kfX@r~~pB`1gzJM^UYj5A379+wvih2dmRwaO$yzz*FKLJi~tceC?56U7$~Reqffb!1ovDUqY++w|wV*b|CTjQk5!55x?*Czoh{8{rbHh_@dkv z^ymAVgdxC<=@<_1f^vhZ;(5NmIR7o`_ZRYb-;(iV_YE$pe&iYWzdfRJbRzNV+`Zv+ z=tJPLN(CnoKDO984EWpNc0*?n=KG7=hs|2UANJvpLHo(z%kIUgk+LTP*nUsY*>K|L zvU1oEjCbLF1HxnHrVl0;lD_M6r{;{&l8XU=775%;X5<)-O?K1o~ zJ@&8bbJcDeYINVU`^O_m&{MKrm!X$+%?9*_-44BeW7cTGW*1)1!aU#QcKu0X3HOQa znIE{~f!Nc~TfV=zUCXCcc0FTsj0Aky{YOW;w4De1;&G~SIPpt*n{pm}**)o476P+- z!ukGDImCOu(zv~iHJ{GF8i-48qJL-ZlL{f)zxPT1B<4&PWXt_zn%@ZRY^K^N#U2^A z37RG9oEa1OYoF8Ve;n}=$z2r(CrI(Pp5U5Zkpde?(l`>Vbzfe7EHi(cB@!RWEZdgtS%+%)v&^_mt8b0SR z(RyAR=(Dun)j3!v44-&w1+Y_bqhc@UG491G9+ltM;Fk0P7bWZ-Md3*3s<#!m(ik55r$;78~|_HnsEp z#rf-NmH#yyg?6*>N&fN64&Ohc&gd`DhkF+5&cME1F(pTo@U)aVV}K3gPd&hWbbNpD zb!ADd;g5A?Y5S2)@xAjI%`ib6Mf$jjJ22f^Tps+M3@v{a*zs}Ik-)-5*o4{r#~a^W z>H>LG7@E%;`O?jMMHT|q+4Aij;t>4%#q}dj{rJe^xI?$K7axL;?Uc>d;K$}OcHeJD z6!4{X_g8OxW>ULdN0%kA564q&BOy<|zc~L=TB9GZ56u3t`>gvHidh4`)iY$Bz<1?# zSr_<0alKzH!ua^yqJaeM?0#pyKj!W=5iWW3j3@e9vFn2M9%=^8_{2tIrsmo37v&jvgD%t_Hy;pdlCA!B9j|E zp(%eAex(U6Y#@DH{AAJy*dvR+w?_j@`QGey)3}G2-2hH5Uwqp0agVt6Ad>fXDxJ@BEvJuET)`8yu*H{!@Ct zE(omjat()`^Y0g*m-Mu1UW!S$*9d$pt&7|RpF8tU7DT=PlSj=~GqwOTKR(TO&2>D# zboaI!@LT!*;(l8Zt>F*-@Tsx%CHOEqbzIu(5cn4C(rNQF;@5NT{o=?sSyr{J9_H!S z=adVOKf#{&w5W6*&(CScva9dSJ?U8H4DE4 zR&1>E0rDz)uiIDT)vqJSI61rbfWA~O|e0{ywF$8=i?Z3xQA$}8k3@?rE zt5-Rw2<-2H@Qtm&Kke?%35bJL>wBaC@Q&Jaa}Qy^+is(Pt86&*0rHx2_IYJswtvaL zUtB)V)yqdUzVhteqwnqRt;YB&owRQc=9hxEKl}v#e(BaS@NIWyY!!?r#{X*5?R$ZH zO&Ds6{n=$F=dOl+JzH#59P)ZKddyC=pWHqu72}V8zxeohrCvVdi{+L5!Mf?Z=hY|R zv*z~58Q{al6XVPBlx+%HG={ty_EJoRJXjot?=Q|@S8L`c@IOvvZG?QizsAGJ7RxQ6 zA7jRvv_M?A<;T^XAs<`c6AI|V=$&Nw0AZ5vwh9Pm-1WSzrnDvG3*Q9U!4D&+~|Lv?VrdWn!W@dW-nbnY)l5PImySL!O>|F?5BMZJo zBlYaCKhy>RBf^j9nn{d@i9RL+v(7hse{udMcb}4zCQ?5q&-k3<{yIZ-LC3~e9-r~#y55F zeb>)CQgswz<}Z%#-eC#s!O8wxN1?s?gW;ng55E6L(%+ooW>(Zz9{R1*U)bA!LUUc_ z-oEOn!M{3B_P@2aFLKB^%l`g9PLq@K(PWUbBg>`qMW25(BT(h`BtJ97fkr%+~?Mw+V>Va|YFu;B|*44s2x`cy&h-}(mm%sg(h0qx8$IMj$dVZv8KM|Fo^kXm)uN3=6Pihuv=T*Y6H{inuX|L^w` zv?BhhI)xJU(fNKEx>p0w8h4l$1fKAWCs0+=#&(L_6JuA&j}^W_ZR11 zSgYpoV)>421D_3FoVwyXfrCR-Bk=tu8GZxbw+6Z4h*KSyKda<0!mk$A$_MO&0+59H z{^IMAN9yBnsyMukZ8r5ZB=%Bs`k%(?U1B}*io&Xh!?^FLaTVXQ?x<;df!X*zn6$7q z;x5ki@h2e<7Kb~1ziv3j3){Det3($S5uR3ZM>OJCf#YoKVITSTi|dDf^<3D;XPdLP zkJgq_bB)#;@tEJV2>1*4p%5QM&)}KBwSV?#t|VM>T$%;qbcKi0&4h&6J|B}G-(Q^n zIrnGQqH)T|S7`+c2*FGXiG{hD#KKHs<77^C8||ocqbB`){e=z2L*K3G+R(woZ(Eo8 z#?TMhhKBd$gf}_pZi76U4B0)W38b zU%lGhn`@O|YO7i|@Ui2mN29KcLDG!j^Y?55xFp*` zKzTRtGmTmO&7CmY$G&^oU`6XyGi^RK2=-=D>6 zg?c>1$F=36ZTP-IU2MW#R=T{!_g^8Ydl}=Q-J{xrf!X}P_ZR2?Oso9o_DJj}KDL(M zFJgYcatWB(%hx+jcLv`v?VQ$QeyIQ0*y1^G@C`?hnFx$RoT@Q=AuL_WowD zvJK=T+z&(TO9vkMg!5y<{nv!~{^I=8wTl1JRuwja--q4W<{|4PZ&&PUr zOzSdrp&zey>7PUVi0?1XU#CJY;zM~BKkpgxy(Q`l(WxmZ_j>V~oToYmbb^Py;2h9dw<_~IDZ4J zil6VP68WM#@i`e5)e-w+rzcZl4`Gxe#rSSV>6^ftlSXa=uKe)RAYiti#`hQJ|Ez-( zUH+0|9;YgPo@eoo6`%Vz27eY0|6XU0Gq7;aZWeh+N;IsD`EIDQ@mI97_%PpJoPS@f zTK{jE;y)LBjwE+kjP+ch183uav8{^r&}rqxQ~2I)4L95b_HP!v8JMkq`TpYkowbU8 zn0rfEH{!$M8PBc_83s(JphDmeZthKyP{O}1oSh4QsBzklRNw))h@LRtU!4D2tQUvR}Je{ueOv`YWSAJeZ6{vEpPUx0Y-$d?Vf0b3^cZ^irO&qvT|&a2B5G9uX7P~YkJD}fciicig!o9mj`QaNKfV2G805*nUtB+mchn~T z;GtKI>s^QsvxgnbqB=ufLfnJ+IpG2&!gbr_TRDhu%Cf{KXlL;;zP~vCep)p?y6zg= z7~hlS53PH0#~Ik@_@>QRFU)JdG8*GS*heSaq+Hu!z;~Z~^@cye_irM4&boGWwfkdv z=pW*obp<4r5TBcKdX>g}-@x$MC}5`FGuMifd`cR=8u+6oPyJ^6FQ!OeYzD~CQoB-d;-B!&4Ha-@!9C&fy+s^RE`2OPjduSE^qg2+S z6Y&x9tBKD6y`E9P!+dRe!@mfBH0&1SA=INJ9Gy{X12Efv=lhG#x5-*F9x&e`&7>po z5%Ls?-^SgAb^){bWLm_o7|3Id_ahg~CmWZ@1_KLus)W-^Psj&sTh_1;_A3S&AAXDe z!hIuz`S**O32jtVXSmS9Oh!688c8`{?0iIqgpd9=W1p*a>-(O6f2Hc0j_Q3g8 ze0mFc4DV91C@^IOo(Hbf=9)3EP!EPM|9iLW7 zzpW?mu*B#$;P2`#S;4`pmkx9V9zC2+|-(ROO-r4$;*^5rn(+PNU*zMwlhuhQv7V3Er=6|2K|K33T`b|_{EH#$j0sq}4XDakhgz*nUgrv~n)MEZ_b?+tX!%7uOBLVo@ev9WzT-2!T0-ul6v5``b5M6VAH)*jw2qx*2646 z&#_9`!Qk)eGG-*6=lhF~k2>n(D=PaepOszyv8B|~UTUqe^9dLqvx~2u3O*N>4Elxr zMT>$%Vt^xJzWjpyFn@8p8{#4CJd;dGH^veU-S;68_7>@>z~#5^y9)fgX8(b}+aq?G z4MBSY76E%6SB>cT|eU}AbJ~xa`GnzzvlExif4lK7@dLDIE*5@0) z8u27nuVHJ7YY6b&YR7dZQ#;>ZoPQauvJWg?#O}-4|9VS%@K5MTC)J7HXcTBBtlO&h zZr~*E%DsU1p77d^`*ryK;`|?~pMORD7tOBs-09k9_$%elFJA#ZFV{w02WID8nSb}? z^S`p#$M&PyDXqdv7qW_BbnURiKgsp6C0E^Z%w*_lCM1nX}26`1N(9ohag? z7d+dcFX2#c=V`#R>rd$eEKT-3dXVt*;HgLOe5IX7KLYdp#ra#Q*MF6NkSG2(^gleg zY$@R$To3=K1H^~ji`&_E!+79UxFC`EvU`R*bR1S5_)AEqfFsn-_ZR2?Rja=Ljhf9< z`w*YTHu>M-dyl@+G#uaed)uJ%z;W;yr};W;i>UgP3C9SpiZwZh=PNu~7!Az# z7w2!Io_`+ezp1=JD)>}gd9iXL@#*!|@&Ndb+E}zWaMNA#Bf#w5Xu}$v9^?7WQ)W7z zq;|f)IRCcl`K#8CW7|g-X7OGNI=pLdW^RQ#z1DUbKEHg$k;vUng85xcgt6Jp6@TtzrA|?s_)<9MKz-?>azYZMuIoTV0+5X7qE6+Cp^Zmu|v0L@$_t>q^=^i^9skMb>>wDEL zZe0|9uzqXD)2oSR^7Lm#FfQs1{2mS5JRey~!u3;w=4>I%_DQxJnROMI?=P+wVSU0$ zcsb_vs^-ngX4msEyJ>E1CZYdPeREr>L_(njO~oyt7wKQ_?*u<~pZcSn2K#`mt}9lr zA$}~+qU@nV*MJ?LpSgs%0UIZLe{udh>K?8v%70MXclk5<)466FI@;`HZb3yKq*fZM z?+*SfZnUSeaoJVGuY^(BPs|ggA1;qrNchZk`B%gVmNhZGvWW2f`Ll-sw+mS#hko$= z#qDM@_47s^$6bR%EbK5(q|WVHa{=*TaevR78S}6%s*Q((q1AlB2 z%qPt9y7cQPGSI(6JNpHY-?*tp*1-Jx#pP33{kk#_{VL2WCogOp5BbdLS9f6y@oBw! z?Lfp;-nq#p0h_v|$bcQH?IEwU@~5g~#Xj+so&uw+G2T~fbJ|y6{{7;1 z^~j&w)%cw3svRXvYSyl@^>bEr@ig$~`-|(v^gq{&lrm&}d%jk~)?;e*Mu7$l# zJ*hhzIAPt@6W8?l`l%8S$xdV)fx@;!|XD{PgC8 z4LV!zfIT&HjeY>kZoWGCy`9^%5uF-YMw6VdJz-Qw9J=4Ia zcdXCz#sF{VT6jCY_tB#r7UB8%v1i_59P$0F=bYh4eTY|=F}EAQ!ck(KLU;ntLXwc zF+E}P&iU0{uHt=X4)nT?d4_+#xZWtVN^h8+G&HuT0zWi5Y0>PK#ILW>gWB-hR`rj4 z27Hi?n`1rWwtR>y^k`0K3Hf@$e1CC2Bviehp2vRL@Q%ro@jcl(WnyB}`S|{aZg!6W z|F5>*vA}HIGB7=*CdN&T4!A< z(T*m>Z;wW^beC`TpYiQC(~N4cL!& z1~K2E?`$2%#z*bx7p3^#wVnLKFh0hlTw1k(@WYNx^&JQs^{&4j<3q2Si7T+LeRL|G zpK3Y59?wUZ(MdMK{QJfA!%Mw>fAlRH_bZsIr#M-zHB3K zUyG#oXg4jrJ_FC=u{gqfe{ud*wMIX%&h`9MVmJJ#f)mTNhCO&Nh!O+{&ueFU6#gd5 zm-+DI);QSLHyaC|#q;iU4irEN=`!anl-#rd14|NeQnj>Y$nu}QOPO?=pVxW#u$669gFeaRc} zFJCmJ1o*bR)^k%V;pE-V!D&?=pl7F!XD0l7Cj03BYc1HeBjXB@XwR|VdMj|`P~=c ze&Vxj>dM0Kmu|G!mjZd*v~x29?l5LiKeQkHJl7m}!=%d{fFGX?i3Dc*Da>E07k#49 z0c!s`WP4lqXU|HWYqFOx|NF%KiE3IiUf@qK|FWh-jg&pahpM^shyRq;gH9L`ZvN1$ z4{*%d^Cf_F7g+ei|M;<0F9Z5=xy<}9;Bl!*3$Xvxr^Sk;ke|n?N@sxi_lu7wrFwnJ z<8`e2>wXU4bH3rd@30?5lqDU&x5mNscVI7>Jf>Yguo3%@1Ivx_$Ma0z`2OPjM{GDpglqrg%@uqQRg z7FsMN%;t|F?a1>a9MN$=5}xP#i`$3UTEidq!Kvq*U_0Vd00~FL=TpbN6M)y5(8)T& zD^@=51HSct^>YEfPmzc%gfB;$Sj`^EKRrq;*@{?_=( z&8L9x+C3Xz!rt{PxM~jYna|afkjJ*eFPCDxygpmt81Sil`xgP9nlZfvELS20U-+vsxzz^Y3?}`7ft?A*1N;38nvZbev6O#q;U3 zrGE{t?iQ@@5pK|-`*s4|y_HHI;Vwmgd4McP8Bm>m+D;K9^OXh3Oxi2FyyXEVJ>_(T zqK7OX+@z=K`H}hQNFR>%jcuIWv_p3kP#GF~jors3#5qd#A7({zD439T3*+jA-Kh1z z$L*g#grN9B~dd4ilp%S4SL#=|czi_!)?M@%_d5PuD8` zhTA{bz|I)Z@pt%Pm)0LS1>Akom{yQSg~ktR;d|%b{rWNDfc;w=&V^k`j90#f-^%xo zp#h$gUHM5dj5~;9=sedmB)hVi{;kRTW*roR<$&>;9x4>rJHY?l> znfWdsx;NG*5WRA7*=%O7tQW_sgqhi-z4yj?DU}|=7nUmmeHHGm0rEfv?uB!ggFZ(i zQ0W;&x5g<0wx#Ij4FjVbm*ZOT>o_?#Sa?*x`yk z;!zBbEnKJ+@Zu+vMj$WcL95C0@qFWZj?;np{^EA0#PM9jtJE$WG@0kxctfL3_|At8 z4!Hn6NMi&(QcLGo6k!&xWw-|pj8nVoPKyV4p6@Ttf3nt$8_YZBs$RYczc|K6Hxzaw zsr_d?_{A*G_4BAzmEi}nJVy*KVf{+wTVY4|{^I;6X%+voFDA7T{Nlgztn>;<=K`1b zGQ0m=;+J;MYXIzStrz}57#~N9MCt+8YhieH0b#zsIRA-S#owzKRZbv2W0n>whVzf{ zGmU-%f7nv;#dN}Z%j>=X-|k)GH_amKGjGsP;88XKcQ8Kq{^I;6XchmaZAz4eezzRG zqTMv&bEHOnCt%NmyMEHF<@|xSE}eT9IDCN9)JcRl7_EH+`@k^YU)*mPuQmMPH_RV!JQe(u z9mBUo5I;8F>n`0<5SaNz>|S#RT;xX2U%zKCW;C_;`&=^)`tjuvZ3Gf#aTIpXIsblf z{rHurP4i=-OWi@34_W<$mj}PDgrC&iJoz5vbH8NsU%;$=Pn_QYJb&%y*G^NZo$oKs zf2`K%2j)lSH@hzAY=rS&`lqrM_*L0(=mYp6tO7izslz$gM_m7gbxcsr=wHBme{ud} zw2D95zZsc)XDj@j>x5N(bTL2j{l)q3 zY15Au7df5-(<7zS)tOhdPJzsGQ1)gLD>Dl^2SsrSItr%I`_WxGoh*#;eP$eOToE6( zU(MsvN&EJ|UtrJkm_0Af-&w2n^GzFc>4F#YeF#fN5+g|`? z=fFDD?|Bx_Q}*b0_{)5MasGqU^H=4^rN7PY{4^aNr;?!>t)J5Vw)->iTie>;AjW(7 znp7#0a1GDT;lM+C-3SHVGJz7W39l@Eq!9Ay*f}`9zj)kW&C^5lP(2#v!>b1>g9HAG zTFS$MLKN~)#5Gw67SYUbc_0Fosw|f)Ih@zArmFWEJePoZk>x?MbyAJNE6?Lx^x49+ zfFe1tx-qw}m0gN?<7s5$a>yfO=Ue#x;&NI0=W@B0vs}z=H5*Uq`%NwZ&vs32oP&Oh zSe)?^a$)-8(O2>g*r<|ADU6?%yQ8h}JeyDX{?pX#v%I9Sa8B4~Ne@|1`z$e8_gRE3 zgTM91(O>S@x=+|BrFH^ox;sj!igbpym# z9z;7KS;z=mCSk(s{_$fWzHk29+cWn{gx9liCH;G^M0i}aQzWOK3ev#a1LR)8KC*z{ zUPc=%ehOkn&A)T-RR;XlkokLZySw5UAeYk~h0IUbm(NO98R{nw2=rF?tNtj}$H{zu zU{FA?dr)wIAYUJ)%)`}9E(@T)(bX+L;XcGQROaL3s_=7lm*cy-(%(s61f2Z6!vhuW zG9OoL>?@!Gc)vgGKcO}7U+$}@J3b{L)1t}v9-SU51pB;n5p9^zcyG-u3)b}obdjONh6T2+Q0U(cOwY%{l)n^{M+xZ zQUA?X$HoNwTt2_(8cKZV6m>u7f7Fno9{B#(yGBI-pF3x}68u?yd$eEO@z7w2Do zcrNN+=9&Mx!=>0G@OwPFdHFEn(~io1!QW_cr^Q&vWBHl}DZqxq(mQ}}%}X{Jc)o(I z^#x$Qzj%DlO{?mo=KI-mBlr}z>vSrB_&i;9-wpO|O5a8kfLGk03z`YD`lln$w~v54 zSpDfjku4Vjm!tAQ(3gH@aY=aJ!Hk;CAV2>7;`-sCHS&Rdz%|VnAM8GWAI=F%tasRb zAx+ym&IV@p?I7%j=g-A;zosPqe1CEMXqX%Om}m8KS9~zukMFtFz_f%H@fkAe=Njk( zkJigYxBfQsfMt$Flp3*Oz~y1mD=`wR-*}kNvx=o8x&_Z=3Hg&cB3K@xOoTqZj-qcAqIb z&og88%hH31Z~Bp5OW`kVX=HE{^9eg&Zt!WGV<2I^zc_!RP__5>slLB%dUp5aQ1I76 zYH6X#^;75j*Y3a{9)0x27CG@b*fZZ3U~C6M|NHc7`yBCMcE2avA7b}A^Zmu=hli7I zh@WTLHXysiavQ||%r$oZ1@;HG45rFe#P7xCvMKJww~8@@$VPck4IV~AK1r2F>5@)XUBQ> zD9oR0+l-t8zRdoyJgUtbqwguGot^Jt_vP~a#rey%YJOzrQJDREA9Xbd;{oYWe#Cc~ z$+kPd{`2aTg8#_$gZbZle{ue)TJ`-q7N->w@ymDU)`6kKukfypao8V!JHe_x=7VQ3 z%Tj^aeG8@^6D;sNlLy~loPUH??GIA6*iYm`u=OO%2Vv`NmOu4gKelE#$-{W^1wHV; z@?N(OFyFrk+4t<~77e1YrpjA=tTo>sd8>!t+&h5pd!_`PkRd)12Pap9J!nvlPWTb_ z8NR<3u>8S`Ap;4whXa)bOFGD>0Pk8h?VXI;S-uUA+5K~2^@2~~_p$pP`QI;40R&6M-k*O??2&{7t^U`21Q=z5b~7 z*IF7r;?E1)A@$wbR%)d&{{r*twX{Q5F@IKUUv6=4;`g*`+%MoxIvMqP5iYkZ{~*X` zOz}yj`Vfvf^0*Rk|MR70^d)@Sx}XK-8$Z9!j?RQx{-)c<+YWf1f4{hX+*dCjmH(9| z{s-pQSwmVKhrZ9~LnqjXPnE=EL&&F#_Z(;7Yw7OQfQ7ogciGG0p_Tsn4g5QIYy&5_ZpUpPEVu6`FLK5yT!uKDs=i#27)E;$nsyA@* z-3q?YAHKgh|9e{X{XhOxtODQ7{%6|Zd%G^&{Tx`hr-pFdw_TgT9 zVXcgffQ9>*32*qJvl_pD-#+(kT?n(h@;az1O!)JH_6oE!T)Xn4LBMCP)C_Bd{(t46 zGQ4E$>}c?1c;_+Kxxhkw9(sO%{DU~iOW5Bc%*#)FTs+aLaq)Tkt9bAWD>gITl=$3j za}7 zmwJRlzb(5}_yv43-*qGKtLwgE|6$fUn0zX#AY~rmI zGIPR*i=CK+IQFU2jm877JN;!EaE-F&FC~Q8zWJJuog1OuXTgehXg@xO6o@eYesTMf z`fu|FG+K|B@_J8{d-cwN_*p!SxPW~hAzz5_L%MDY^UbLz zMvj;-SRKJqQMWz=7k+g$3HG<^ju!jj&++dQA1~*$%3iQM-^5twV{M2Jv;VV;y6a;+ zF#Qnr0|~SFqR_I(5Ai(TUwpjWQ_nw-$4iwTeNCa?uN?xHv?V^TLk?O2Gyl7CQptI+ z7j}nbD(C8-Nd`41WjQ$RgiiypMmsxPIJGFCSGL zN8LWEM)Q!^K6%5_KC?OyAC@;>^yB0vz-J~{&jVlDJwELSyl=EA?9Hc$T2;Cc=KG8D zU)s5Vjhb^LD*kzPPjmg5ei7ieZ9NKS()|v9z z1?KyUj}J4g^2aY7Upo)>(4+a(HSLK{TG(_DqTwwnF;_@-oD)}UJvYm|i!=h@br=5vULJ-}+N0@GJ zzYg3jpJXZU`{=Ttpg$}RY5Ju#qk-9cvtwTLPmCW{mz(Jm|9v7IjVBb6Yk`gFR-i*)dLe=&8MjQFs7FDLI#J`DXRci?FQ+#A63t6|2jI>6sPZ*2+v zSlj-PK~~J}ja1moJT{2h{a>~2fcByddtb?luk5isjNixqe)0LOdT!Q}c_Kf=E7`qX zx9;c$K_2X0g{n*Tz3)r-tKWwcxNq{|V2?hq@5u?4D}d{^%eNBp2-!H|Jmk}M&}JpD z3oeu+d@#{@E#Ak!UwnS+t~L6F`EBBllUu-dQ!NVg6QBF0wax$+y0Lu(^lRhpLc4HJ z9Mdnw$*?HkQZJ_WgMRS+#mASadi_vUV#*W$7~@NkTKRrfzSS%3cnO?8qGT=TgK}9f zPw+(<4_$DB&0O&8J1|ioyMYM}HbwVBdT7iHHTjJB)SQC2kC5#P z$M#$J7VVkxK3lu;4 z0FSNt@)qQKBxOqVs)ctr)h-GdHg(a zeJL=rxAkJ@UBLLNhXOd%{`7F+Sm4sX;?@9PpERfv{3HJT;`X>#o%TO!u*|3}n zkP{z|z^hGwS)Ks<{&C0smSG?HSi8h1$cxF3ofAxX`>`YB_o{Ak_pI;Z-!Ja(nB->t zl_&NG{tk;@v%DMjy}e8qC3hvhil0XtV7xplx~nwSSEma6GzMn!^Dq273i4q4?)>}2 z?aAYRYfm(~|6xtn+tFFa-(Q@6 zM~hted!2Id_be@_^oJ(%zS+H`hK1zD*q{HlvZ)LB3ipE(?y>JRSTybHxVc z=YiStO>v~#Q^-+e0uNeO-taB zCgD#IuUt_6%tv6VvRNPed)I4v8Q605lvMEN`-}50tKOd%_ebqDX+M)8ak)$lHuhn{W^J!&i>mbPCw;<>6%DY6Tsu!C&LwC$0}4 z)XPJq4~64O=w{Z@vK2~vn>$JDP~uyn(Q|ZxH+u$jB|dj&wJiwy_pr;Tsle%_sUSb$ z;tv+c9l`ghcQ4==UrJ~uoZqWnUp&wE7w7-@-}2X}Kk;*B=ZWCU^0<2?tX~c6S~$4^ z#(zH#swhmD&p!{x1MK0-`fD$N&+0S^@DQIeOE0_t zE{AO(!ucvlL%I=mPR%C=4s&lQ1D`{&8}3jQQz=lhHEzo%Y5 z#ODW%+DBpCNPMQQzVj6O1*YvS%YuK4|F635fQl*!)&|T9CQKMHqU0e+R1Am-GiIWK zSy4oh#Z^SXgocCy4KMzSiay@cwae=7^8p?_2W{aPje<%mN9E^78!eXchmZ?yalBK8%~C zR}B1`x|X&A=Jq|lD>DW5fcF!c6h1N+nD+}rd3pX_RQG>n`)kBsLjRZiLgM58v3z5+ z|GMR6Z-L)5{gCpAZ(pZ0^p1piKV;yz26ceV7cKvU_eFVm``A=#_`^P4tTJ^l_@v(T z8VJ5peh*=8@7;UL*Xcx<_fMubh_D1sYxQJ4#{aJ5kgLFR6b-Y0B|nnzh}7?=@ICSS z^8Mq*uZ|D3`j03Z*n#*wE3>vB_$;h8wm&fERkFSYF0T0+@mncBjGps;ohUEgKc1=P zuR;F_{o}LA_HN)i{&Tz&_zhfmcNp-Y4(XE-zYnwea1?kIT~`L&d~J`jz#Q-9@pE&X z`5DA-$L}vz1O1Knm&EVO_m4(eGk#z{-nPFM1HSKzBvt^ws^1r+05^)6UIlq`Y%iil zRCWVyeExSG@aK58C@;@H=fCt1jrk9s4Gax(^WpJ6wBd*poQD}&)U2!*@%3Helnwtn z#dTaN>;<2v5#{CiH_@v0m(KkVpY-4@ZLwd&<3Av_MH!5TH+r;?qUW5Kz2MA@7+@Yx zqP)C)up6K)@dnrjlpjF*bNoHcBHu}1K96gu`!N~qU&^RU4$hNSjxRU{`1}~U(3~*O z7aY%*;u7?nr`NS{c5E!9@zeUx^iR$ zaJr+Z1MCOKuS9uy{(31Y`-i-Lt=9QdIyXstq;)0nt7`M0DfnlGEp$M9h3(<0N|aGc zIPv<{m*9W*!4q%9Cq#L9{`RWJhw^-Z#@e6lf5D8bRPYH}vE`eY8TKQjd4X`X z$n$l;f0E~?k{BOsKSgylFVEjrb^9y(f99%xi$B&!ods*Pog<2)@ZD91YOF_q;Z4fcyig zoeAT3I_x9QpR5m2UVeUX)tdIl{Gg8w72+4S^yKxX#LpfFrU)M%JI=TfVV?ij^tsp( znE6K+qXV^sc|I_M!64kG?Q?z1pA{O`Sc&{+@%!@qqoV5l3Ju$bQ|3k5gAeELq#4qV zHet>mjhL{YdS}9%|GDv&VK(f8bZ(URi}LdPFaGL&rdr1buP=j=tDVPq*#6Yn3;Zu{ zt-Gl);ciE~jlefy!rAi3ck=KV_!7@ijt1jJl$YnPP;DQS?Z3w7_TT#xjur}qg{?LA zCv0fHU#-7uz!v$;PaEp2M!a=ROR8)}IQ;m98Ni(HJvO83I=p{(ZIy#g^xX0NvvgpCjw?{&zF}Rc!YSnUaO|lVE?3ZV1#)-=<~6i1N@6q%iCPQc<`F$YXIE0 z#AZFrx1zi}{{~uRA8s`1S_6EhJc#cCK4YA#+5&SvFVFv+-^lSP_BTX%`SI~pb^B|0 zeBAKN1XgCj8$}6YmgXua};FoI(Wud=G>kz_<1%EsT zj=R$REuOce>so;e70sB9_Tc(AUdj2o=Weg8VyQFmzLPuMY(Vehx-#J4^v@e{eog%M z%a6}*s`am7`;@TL#|rj%Yfxr)@ZmPoQSHAg z`K!t=R%?921a*^$59gPDFwTD;nB!mWQ*=v1Kgsdy-od{eUYt%?5%#Ea`x5d?MS1!2 zM(^6s?@ z@c7JQNst%6C*L0mYgK=^xhA_k_-ofo6xPIfQ}br8|3JKh z^N9vFA2!{YaKlGK=;~X#$F7RkU0|t>8{rK#i(kR}qP#r+5322jhUW{(FCjisUMKM> zM``xZ2gg5{Z}|F``_X@-x_0zjszX6ol$YmU^jF(ot@FjROsAUQ%jfwsTocTI{hvN+ z1N+DPrTb9`mv!E-5&hv&mE^~mFGP8H`{1NC{ygG=!RMBILq3C)he~|rNBY&o`E9B0 z0%6W4WdG}Xv(xkNT&hz+&pCgL{ktPiyPU%FJO07Fpda!3^7?tJdVDDDr$+o)=;zbL ziSLl_E9Es2pXn;;*C$G>O!Uhjapo=uh62fqQw#J&0MM(^A0e6bGtmF`O*%=xY^ zC{#*V{J#A7DWO&4Cvj>_EcjL`-Z~%nN_A`qr&q|AjrdRvDu;>wu;+sFFvJ5mKSrus zKvw3u z&p+!|`KvWv9J{#ZWAKsg_a=TPr#&fx`GM<==G#)i75>!n*zG!qm+d_`r6Mqo4^dv8 z|F!>OA2qVxK>wd}BkM87Khhr2{+$2E^%13W1oZsW#J)w)A3XG3V$ok-POGj1`yk58 z&kxU3$BQ-WU$gz6p);hM74aE)pn4_P`zdRW?KUI)SG?op7Y96-@)+nj$B*^Kom>w8 zdceN-vk_00&RG!NI3&Xb`Vzk{ub-D%r5~<`F?ZU@PxXk;`~~@gp`UA>(W7b+zID_7 zF~-YAx<;)IVfMEiqyH#sN|^gktfAlYx`f#u$uy?Qa)ib2|4cuT)K>%PM@Q$2WIt@_ zw}^k-ARQ7kyni5F|Jq-;N%!wdaO*@#(j99BZAqy!e=4Hy z=FS)|R}U8*Q;GPp|8+5>Z=DK+x!%Fru{v=n zC4Sw9^tT5ewlAxaXJn$k^!tr&Dx~MQz6bh_B!xr;X?E6vGVNblXP8k3{jjuvi;ZeQt zKl7VeUS2=%@@#)4{p2UZoli$&{tf$U^DmF~hW$O!eMt!VM^X?~`XbEx7xoc}Wnqsw zo^J8N%?A1sxILR!0nZ)!*aG9Z$C%ruz?Q>zn8Uvkzb`*tQ}V38T=SjA>>1nh zx?7`GLVwF{QR+KOUznLNR_gAAmCtkUr0aIj{wrGln1%CuzQuRG#Qfc1SzKTj!Yg+~&c%FWabjIb;K0Y1 z8$%z#ucD9Q{U0^@WkFxQ(Q}pnC$uUUiSLQum!F>|&sQ7z5p|Sai$II7_Zo%No z=R+I6D^UabN?bLl8_vhkF4Aw%=jg?5gQ1_@<=)-|o_*V(KX8Hl=`nbJdd-V>@jkET z#qa;De??MTD*dI6c^-d~{*?Af)p|^=>(zC;cbLMTIeoV5Agsss)JyRKW`DZzj?0@d z{&;?GK66V9aAHNP8|ZJMynO#k&9i=T*(Z(lFFp^Y_a?3$))%EGm?c6#8|_{#gMUfY z1HR(CI>$pMFL?Y0nAa;-xR{%8j8o%do4aAGk+)`FVJs^J5+E*|`JrdCwbjZLa}~-~X9@V846QkB&}QFXYG3wSYoAws-L0 z!9gLte;$sNhL>`=t1>-6N1gFl&JWwawb^{=s~ikC>F3A`z1jr`OLeOVcWFL7v>;*j zCpwlm^BDRR_4jXqqh7Utlv0Z*rd_!2}OzT%XKY0F`unB{j$C= z;qf0jrvt~8YJUf~?V{^1fvNXQNB^N+#FNmc$p@D*&?nDdruSPlfWF1=%lEH=dG245 z|D%letFS*d*6N(Uqi% zbp`K>^78uWpJ)4=OFtU(5APY-y+wQ^YR#Fou)nUYsV5Px*5+Lx^y6CI<2Ur5gSv0O z03TacDFB%DDay<1XLz3bj}%{3-ngtW{c!zCGtXNCF}^szpq}69Cg>l0p1K?g$`j^z z{f^}Wb`~Kl%FFBLN}ksjl6_J-h#J!m*R!Q8(#o*U8HH&jM*YJcg%zL=y0#nf)&ukX zPN08Db+rjk^`aY^2#fNrvi5sfp6z!o|4<|Pf&G^10u$fPw>~vPe~{_|6SlkJ)(-t8 zWMz?S7=O<3VHbc${r)fonCCl=e;A()_Je+Te`pMCXJbAPzb~)9QNK3+G`Qae{c(NR zD^23Ymj+)Cw~C182d>v&gY^jKH%*%G@*6OZzl)v6^?-kHDZ1Js=yP<@$QZ@K{ z<6}rz{J#A7{4LM=%VpnH`44LKKRF+)@a((yz?bW*x>S073i_}fuc#r}^FIQvLx1c~ zgp{3ns|;aLUS2;DdDc%Z`=yci8}^S5N9{|*e$k#WRG^&r?p^!fByhMhtyBodr`A7& z{>J*_{JA!l41Ew!66NLfll_0nzPE}PZv}l&_Rx+}#J5X{c@u%(4l%Eb{v*|qA>7?A zApv-G|MKVXe9NG9R?wd)FTZ|Usx|Q=^bg)owV6Ki8Tj-3@0)YJretrbRDLGyU;h~& zY1!Oq9`M{wdcDA3l=uG0{FaHoML#+^U#0#LLcc}S7!oizsD`s~jZXbSf`W`&1q?E7 z9xx!63tj&*X>@i<9JaKd?`ct2BNtZF-@%=>>XS(-6dx-M# z<8MWt{Rd6&51{|qPF!9N`eFNHk$Tu2xEVI;h(FgGaBO`v1pST2C&zn5`Jd^>pZ=CI zep^a)zJ+rA78Lg{=N+wWoo$_*?bTMF1@k}3KqDVT|8cYn>@D96P;1@Tk-b~!+0wZ}_@0wIl8a?)UJhJ@g~W%k!V5Rs3g%d~m{j(Y&AH zWY8fR_eHz*Nwh)yl5XvN4NSL<)`xyfGV&+m{cYQN&VoKf`3W?iR)6Km37ztTf` z5|iUwMpvgS{r=A!n|{F~h6iC+MHw}TZ$+h_VJN17&ft|*+$-vQbZ=c7w6#xCMKUp{V6IZ3*+DU8d5bkwtq1dpp1-cI%6spXBUmHr9`Iii8L=4SVMykqiQv;KaiAZ@ z&u24=d=fVPefCO>uM5wgnPJ`B&u7^KJZ~8_v;fvQqP#qRFRg0-o&leIF)we%Z2BYWK z#XPQmd`U3u>!S9PBB39rO+HD`hr!LR5Agh4bhmHlFQU9Wf9qf5uTbm0?eQIB&C%Z5 z&Nuc2Klbxq8BIP7dud;UZdA*qkA-^k6C}U?PyV93JpX4>+|7{2-aqH?@Sx$C!R6QW zYQ2Z{#mHTD;4`+&h=<5)DY)d}ePE6|b6pLTTf{lh0oDflv98&(aLASFghlyU|H+Su zmh)qjT5vynPM7@{g}sHf9jenRC>7Y&Sz)g}KL+uiR|itcqo03Iczy=?P=m6vCjpmU zv1>2lK>?1Jtby0v^7I65TFtySaQfc%6Ja;lF1$E-bugaudg#Q1f=!V>Bz|AMpZI8% zom|#)!xPwvwDbXsYZ9NV!oekA2l#xoR40&dTx-f~rRUXJwk!etaQ=fRFVFw|uZ|P7 zosYphc_wUI8tjB=)$$i=5I?DoBH@59M=Rhwb>s1E5-={3qUhom!hApTfE6W=)Fv#- z%g09#sOGQ1_y@*E+p+I!!Y=r>m^cyjdZju}gt`8}%3i&F!GElxXPPNJ=ljfWocR%A zL74A<=X%fEPk2qj`&`dL{JwnuFx9H@v3_Ux8ua(LetON!i7)#RcGj=c>JYwUM;Ct6 z^TlZk)|(JMo_O^(`qP)EYZ^g6qP#r+9$LkJE|q*Am6BBwdxX|Z<(DJ1HZ_oLU4rNpE?(b`NMu* z%pKrmBfRW^Th%&q9PiH($HGhS8V9qyg<(tqO`r!FM zs#`{w^(D&7^Y5-z{JqINKznjMnGHqyPXm6t>f;`?w^3}V$r$fkpGK;iN;q^|_6pbw zQC^<^4~X0F!%@g=GhEI`|8)| zc+Qgew6ApG2{61Fm5{73JmQP|Z}YqcrFrAP#k8 z)A4PH6E#7CJn^|&b=Mu>ii4iI!oHszny(?o1Lrq(twX;NHfj4J4CB9q$1_9dE4$LX z>%dZ-Kf;{9m}B191J9QgqQrfAPPaE#20mtUU?cP`{(X7<>1a(H2KpNvIQ#_ zt&!8>^C8Za{-ts?w2$Rsvy#BL-WKnRe_ww5b=E5T!*RN<1uqnYzDBqu8>4@PO)QfS z{)bc-kucY*IWoV_AUt<^Ue5{di}LdP{j`ez#ud#wp*=H>HyaH;(!B|Ulij||gugNI zK!bSn2lhWKX5SeFd^@S`X}mAW%k%H4Rs73!F#H{Sc>Nh16}$jAbU}fuSbuXIy9*uH zM4V&a^%oED+|bl91M5{$UY@_VR`H+Jt>X*uaoyE19&wrF`H*66W<>lAs9;*3kaQ?;moclA^t9v$ieKGKVTx!`PoHva;w{sNm@sA&j z5TDBNcsLU{K6|h&;#WQYaDNZ|a6a>Do7kPeTrWuczI^`(n|#GyS8}~n#5p&5-zmWU znT-PenKk)m^(nmPtWc=aKlA(?Q4jN}sji|3`1`g#QV8}kxklMT(9gEfwQ2)L>^UG*rggU{N*Ul;$reE+$wHTuK) zmibWjXGg4OvHSqvA)hx@14eqIq`yDcf6W`XF9UxZr|6oL{@#aVh-Q z{6Xt`y28%hOJVJ-pdBK$)uYDznml{Z2-pYaTe@7?VHh8kaN!j3OR3x=5PYEq@O8Zr zei!fa`riM9ZWJ)<1INH1-{Z|aE3C)F@5}d((W>>M^uIJVzlMherqm}sYv(L}f_SD> zx0*25*U8$}Fa`FM*Ru_p6)y$6#=PiGj1N&>o_~Z^@gIwWZN!J`FG}}T66X2~aZ9Fo zgD?BvHpds|hrTMd%RdbMs3cE zfZ6}}@X)~%nCBx=Ufw>0XbpeZhn#m?EYbdaj;Q_pmS=$n40N{!f2t~d5}40Xaev@^ zCSAkrBhWwGhW=rK^Yt^Ik9NcReC|m6zI^`}rdmHr`>C<~H|d^J;!}FkrFS@2a_~^b zbVtH`&cy%G$`im{0z(@izk>ZQ@9HUsfJJ$E{=-%C*RXw@S}t)g+LO;&y-GgR8tu*P z8&D}H5V)P&s;M|v^>y;G7`)HxGf`fif2daRH!t3I4EV6Womtb=3w+}&>#qY}qs}qI zfCs+XZ3=%h+HLm8dhN|YT!S+Y~Otp^{QefX_tlpUh{`%c-JcU1euqss?gg!QT z*9Tu74@pa(b_M2hwwoH)F~a*iABf+R?+^V|>qEo+;mKBaOX!3117>ts91ebA*1eYC zT)b(>r0u|5?_$Cw%fWcxeO1Rzh3L5`FVBCFR`IXdH=rH*yJh9VNA-x$#|{Qxu)g*? z|Dr4MC9XR5vOqk${gf^*3J~V?GoM=*<>mPg&?^41*3Hu4|K7TLrvchC-uvKP%zs>u c%wN~j2<^@1P`g|>{u1xE`1Y=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@floating-ui/dom': 1.6.2 + react: 17.0.2 + react-dom: 17.0.2(react@17.0.2) + dev: false + + /@floating-ui/react@0.26.9(react-dom@17.0.2)(react@17.0.2): + resolution: {integrity: sha512-p86wynZJVEkEq2BBjY/8p2g3biQ6TlgT4o/3KgFKyTWoJLU1GZ8wpctwRqtkEl2tseYA+kw7dBAIDFcednfI5w==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@floating-ui/react-dom': 2.0.8(react-dom@17.0.2)(react@17.0.2) + '@floating-ui/utils': 0.2.1 + react: 17.0.2 + react-dom: 17.0.2(react@17.0.2) + tabbable: 6.2.0 + dev: false + /@floating-ui/utils@0.1.6: resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==} dev: false + /@floating-ui/utils@0.2.1: + resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==} + dev: false + /@humanwhocodes/config-array@0.11.11: resolution: {integrity: sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA==} engines: {node: '>=10.10.0'} @@ -2214,6 +2276,34 @@ packages: react-dom: 17.0.2(react@17.0.2) dev: false + /@radix-ui/react-checkbox@1.0.4(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2): + resolution: {integrity: sha512-CBuGQa52aAYnADZVt/KBQzXrwx6TqnlwtcIPGtVt5JkkzQwMOLJjPukimhfKEr4GQNd43C+djUh5Ikopj8pSLg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.1 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/react-context': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/react-use-previous': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/react-use-size': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@types/react': 17.0.67 + '@types/react-dom': 17.0.21 + react: 17.0.2 + react-dom: 17.0.2(react@17.0.2) + dev: false + /@radix-ui/react-collection@1.0.3(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2): resolution: {integrity: sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==} peerDependencies: @@ -2364,6 +2454,33 @@ packages: react-dom: 17.0.2(react@17.0.2) dev: false + /@radix-ui/react-dropdown-menu@2.0.6(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2): + resolution: {integrity: sha512-i6TuFOoWmLWq+M/eCLGd/bQ2HfAX1RJgvrBQ6AQLmzfvsLdefxbWu8G9zczcPFfcSPehz9GcpF6K9QYreFV8hA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.1 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/react-context': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/react-id': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/react-menu': 2.0.6(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@types/react': 17.0.67 + '@types/react-dom': 17.0.21 + react: 17.0.2 + react-dom: 17.0.2(react@17.0.2) + dev: false + /@radix-ui/react-focus-guards@1.0.1(@types/react@17.0.67)(react@17.0.2): resolution: {integrity: sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==} peerDependencies: @@ -2424,6 +2541,14 @@ packages: react-dom: 17.0.2(react@17.0.2) dev: false + /@radix-ui/react-icons@1.3.0(react@17.0.2): + resolution: {integrity: sha512-jQxj/0LKgp+j9BiTXz3O3sgs26RNet2iLWmsPyRz2SIcR4q/4SbazXfnYwbAr+vLYKSfc7qxzyGQA1HLlYiuNw==} + peerDependencies: + react: ^16.x || ^17.x || ^18.x + dependencies: + react: 17.0.2 + dev: false + /@radix-ui/react-id@1.0.1(@types/react@17.0.67)(react@17.0.2): resolution: {integrity: sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==} peerDependencies: @@ -2439,6 +2564,44 @@ packages: react: 17.0.2 dev: false + /@radix-ui/react-menu@2.0.6(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2): + resolution: {integrity: sha512-BVkFLS+bUC8HcImkRKPSiVumA1VPOOEC5WBMiT+QAVsPzW1FJzI9KnqgGxVDPBcql5xXrHkD3JOVoXWEXD8SYA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.1 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-collection': 1.0.3(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/react-context': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/react-direction': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2) + '@radix-ui/react-focus-guards': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2) + '@radix-ui/react-id': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/react-popper': 1.1.3(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2) + '@radix-ui/react-portal': 1.0.4(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2) + '@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2) + '@radix-ui/react-slot': 1.0.2(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@types/react': 17.0.67 + '@types/react-dom': 17.0.21 + aria-hidden: 1.2.3 + react: 17.0.2 + react-dom: 17.0.2(react@17.0.2) + react-remove-scroll: 2.5.5(@types/react@17.0.67)(react@17.0.2) + dev: false + /@radix-ui/react-popper@1.1.2(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2): resolution: {integrity: sha512-1CnGGfFi/bbqtJZZ0P/NQY20xdG3E0LALJaLUEoKwPLwl6PPPfbeiCqMVQnhoFRAxjJj4RpBRJzDmUgsex2tSg==} peerDependencies: @@ -2469,6 +2632,36 @@ packages: react-dom: 17.0.2(react@17.0.2) dev: false + /@radix-ui/react-popper@1.1.3(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2): + resolution: {integrity: sha512-cKpopj/5RHZWjrbF2846jBNacjQVwkP068DfmgrNJXpvVWrOvlAmE9xSiy5OqeE+Gi8D9fP+oDhUnPqNMY8/5w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.1 + '@floating-ui/react-dom': 2.0.8(react-dom@17.0.2)(react@17.0.2) + '@radix-ui/react-arrow': 1.0.3(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/react-context': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/react-use-rect': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/react-use-size': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/rect': 1.0.1 + '@types/react': 17.0.67 + '@types/react-dom': 17.0.21 + react: 17.0.2 + react-dom: 17.0.2(react@17.0.2) + dev: false + /@radix-ui/react-portal@1.0.3(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2): resolution: {integrity: sha512-xLYZeHrWoPmA5mEKEfZZevoVRK/Q43GfzRXkWV6qawIWWK8t6ifIiLQdd7rmQ4Vk1bmI21XhqF9BN3jWf+phpA==} peerDependencies: @@ -2576,6 +2769,35 @@ packages: react-dom: 17.0.2(react@17.0.2) dev: false + /@radix-ui/react-roving-focus@1.0.4(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2): + resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.1 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-collection': 1.0.3(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/react-context': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/react-direction': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/react-id': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@17.0.67)(react@17.0.2) + '@types/react': 17.0.67 + '@types/react-dom': 17.0.21 + react: 17.0.2 + react-dom: 17.0.2(react@17.0.2) + dev: false + /@radix-ui/react-select@1.2.2(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2): resolution: {integrity: sha512-zI7McXr8fNaSrUY9mZe4x/HC0jTLY9fWNhO1oLWYMQGDXuV4UCivIGTxwioSzO0ZCYX9iSLyWmAh/1TOmX3Cnw==} peerDependencies: @@ -3067,6 +3289,48 @@ packages: resolution: {integrity: sha512-VOVRLM1mBxIRxydiViqPcKn6MIxZytrbMpd6RJLIWKxUNr3zux8no0Oc7kJx0WAPIitgZ0gkrDS+btlqQpubpw==} dev: true + /@types/d3-array@3.2.1: + resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} + dev: false + + /@types/d3-color@3.1.3: + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + dev: false + + /@types/d3-ease@3.0.2: + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + dev: false + + /@types/d3-interpolate@3.0.4: + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + dependencies: + '@types/d3-color': 3.1.3 + dev: false + + /@types/d3-path@3.0.2: + resolution: {integrity: sha512-WAIEVlOCdd/NKRYTsqCpOMHQHemKBEINf8YXMYOtXH0GA7SY0dqMB78P3Uhgfy+4X+/Mlw2wDtlETkN6kQUCMA==} + dev: false + + /@types/d3-scale@4.0.8: + resolution: {integrity: sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==} + dependencies: + '@types/d3-time': 3.0.3 + dev: false + + /@types/d3-shape@3.1.6: + resolution: {integrity: sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==} + dependencies: + '@types/d3-path': 3.0.2 + dev: false + + /@types/d3-time@3.0.3: + resolution: {integrity: sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==} + dev: false + + /@types/d3-timer@3.0.2: + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + dev: false + /@types/eslint-scope@3.7.5: resolution: {integrity: sha512-JNvhIEyxVW6EoMIFIvj93ZOywYFatlpu9deeH6eSx6PE3WHYvHaQtmHmQeNw7aA81bYGBPPQqdtBm6b1SsQMmA==} dependencies: @@ -3801,6 +4065,11 @@ packages: /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + /base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + dev: false + /big-integer@1.6.51: resolution: {integrity: sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==} engines: {node: '>=0.6'} @@ -3957,6 +4226,10 @@ packages: clsx: 2.0.0 dev: false + /classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + dev: false + /clsx@2.0.0: resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} engines: {node: '>=6'} @@ -4041,6 +4314,12 @@ packages: engines: {node: '>=8'} dev: true + /css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + dependencies: + utrie: 1.0.2 + dev: false + /css-loader@6.8.1(webpack@5.88.2): resolution: {integrity: sha512-xDAXtEVGlD0gJ07iclwWVkLoZOpEvAWaSyf6W18S2pOC//K8+qUDIx8IIT3D+HjnmkJPQeesOPv5aiUaJsCM2g==} engines: {node: '>= 12.13.0'} @@ -4092,6 +4371,77 @@ packages: /csstype@3.1.2: resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} + /d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + dependencies: + internmap: 2.0.3 + dev: false + + /d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + dev: false + + /d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + dev: false + + /d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + dev: false + + /d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + dependencies: + d3-color: 3.1.0 + dev: false + + /d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + dev: false + + /d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + dev: false + + /d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + dependencies: + d3-path: 3.1.0 + dev: false + + /d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + dependencies: + d3-time: 3.1.0 + dev: false + + /d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.4 + dev: false + + /d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + dev: false + /damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} dev: true @@ -4114,6 +4464,10 @@ packages: whatwg-url: 12.0.1 dev: true + /date-fns@3.3.1: + resolution: {integrity: sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==} + dev: false + /debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -4136,6 +4490,10 @@ packages: dependencies: ms: 2.1.2 + /decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + dev: false + /decimal.js@10.4.3: resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} dev: true @@ -4265,6 +4623,12 @@ packages: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} dev: true + /dom-helpers@3.4.0: + resolution: {integrity: sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==} + dependencies: + '@babel/runtime': 7.23.1 + dev: false + /dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} dependencies: @@ -4794,6 +5158,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + /eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + dev: false + /events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -4845,6 +5213,11 @@ packages: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} dev: false + /fast-equals@5.0.1: + resolution: {integrity: sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==} + engines: {node: '>=6.0.0'} + dev: false + /fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -5172,6 +5545,14 @@ packages: whatwg-encoding: 2.0.0 dev: true + /html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + dev: false + /http-proxy-agent@5.0.0: resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} engines: {node: '>= 6'} @@ -5277,6 +5658,11 @@ packages: side-channel: 1.0.4 dev: true + /internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + dev: false + /invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} dependencies: @@ -5875,7 +6261,6 @@ packages: /lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - dev: true /loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} @@ -6519,6 +6904,21 @@ packages: dependencies: quickselect: 2.0.0 + /react-datepicker@6.1.0(react-dom@17.0.2)(react@17.0.2): + resolution: {integrity: sha512-8uz+hAOpvHqZGvD4Ky1hJ0/tLI4S9B0Gu9LV7LtLxRKXODs/xrxEay0aMVp7AW9iizTeImZh/6aA00fFaRZpJw==} + peerDependencies: + react: ^16.9.0 || ^17 || ^18 + react-dom: ^16.9.0 || ^17 || ^18 + dependencies: + '@floating-ui/react': 0.26.9(react-dom@17.0.2)(react@17.0.2) + classnames: 2.5.1 + date-fns: 3.3.1 + prop-types: 15.8.1 + react: 17.0.2 + react-dom: 17.0.2(react@17.0.2) + react-onclickoutside: 6.13.0(react-dom@17.0.2)(react@17.0.2) + dev: false + /react-dom@17.0.2(react@17.0.2): resolution: {integrity: sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==} peerDependencies: @@ -6551,6 +6951,10 @@ packages: react-dom: 17.0.2(react@17.0.2) dev: false + /react-lifecycles-compat@3.0.4: + resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} + dev: false + /react-loading-skeleton@3.3.1(react@17.0.2): resolution: {integrity: sha512-NilqqwMh2v9omN7LteiDloEVpFyMIa0VGqF+ukqp0ncVlYu1sKYbYGX9JEl+GtOT9TKsh04zCHAbavnQ2USldA==} peerDependencies: @@ -6559,6 +6963,16 @@ packages: react: 17.0.2 dev: false + /react-onclickoutside@6.13.0(react-dom@17.0.2)(react@17.0.2): + resolution: {integrity: sha512-ty8So6tcUpIb+ZE+1HAhbLROvAIJYyJe/1vRrrcmW+jLsaM+/powDRqxzo6hSh9CuRZGSL1Q8mvcF5WRD93a0A==} + peerDependencies: + react: ^15.5.x || ^16.x || ^17.x || ^18.x + react-dom: ^15.5.x || ^16.x || ^17.x || ^18.x + dependencies: + react: 17.0.2 + react-dom: 17.0.2(react@17.0.2) + dev: false + /react-redux@8.1.3(@types/react-dom@17.0.21)(@types/react@17.0.67)(react-dom@17.0.2)(react@17.0.2)(redux@4.2.1): resolution: {integrity: sha512-n0ZrutD7DaX/j9VscF+uTALI3oUPa/pO4Z3soOBIjuRn/FzVu6aehhysxZCLi6y7duMf52WNZGMl7CtuK5EnRw==} peerDependencies: @@ -6656,6 +7070,20 @@ packages: react: 17.0.2 dev: false + /react-smooth@2.0.5(prop-types@15.8.1)(react-dom@17.0.2)(react@17.0.2): + resolution: {integrity: sha512-BMP2Ad42tD60h0JW6BFaib+RJuV5dsXJK9Baxiv/HlNFjvRLqA9xrNKxVWnUIZPQfzUwGXIlU/dSYLU+54YGQA==} + peerDependencies: + prop-types: ^15.6.0 + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + fast-equals: 5.0.1 + prop-types: 15.8.1 + react: 17.0.2 + react-dom: 17.0.2(react@17.0.2) + react-transition-group: 2.9.0(react-dom@17.0.2)(react@17.0.2) + dev: false + /react-spinners@0.13.8(react-dom@17.0.2)(react@17.0.2): resolution: {integrity: sha512-3e+k56lUkPj0vb5NDXPVFAOkPC//XyhKPJjvcGjyMNPWsBKpplfeyialP74G7H7+It7KzhtET+MvGqbKgAqpZA==} peerDependencies: @@ -6683,6 +7111,20 @@ packages: tslib: 2.6.2 dev: false + /react-transition-group@2.9.0(react-dom@17.0.2)(react@17.0.2): + resolution: {integrity: sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==} + peerDependencies: + react: '>=15.0.0' + react-dom: '>=15.0.0' + dependencies: + dom-helpers: 3.4.0 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 17.0.2 + react-dom: 17.0.2(react@17.0.2) + react-lifecycles-compat: 3.0.4 + dev: false + /react-transition-group@4.4.5(react-dom@17.0.2)(react@17.0.2): resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} peerDependencies: @@ -6715,6 +7157,33 @@ packages: dependencies: picomatch: 2.3.1 + /recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + dependencies: + decimal.js-light: 2.5.1 + dev: false + + /recharts@2.10.3(prop-types@15.8.1)(react-dom@17.0.2)(react@17.0.2): + resolution: {integrity: sha512-G4J96fKTZdfFQd6aQnZjo2nVNdXhp+uuLb00+cBTGLo85pChvm1+E67K3wBOHDE/77spcYb2Cy9gYWVqiZvQCg==} + engines: {node: '>=14'} + peerDependencies: + prop-types: ^15.6.0 + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + clsx: 2.0.0 + eventemitter3: 4.0.7 + lodash: 4.17.21 + prop-types: 15.8.1 + react: 17.0.2 + react-dom: 17.0.2(react@17.0.2) + react-is: 16.13.1 + react-smooth: 2.0.5(prop-types@15.8.1)(react-dom@17.0.2)(react@17.0.2) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.1 + victory-vendor: 36.7.0 + dev: false + /redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -7238,6 +7707,10 @@ packages: tslib: 2.6.2 dev: false + /tabbable@6.2.0: + resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + dev: false + /tailwind-merge@1.14.0: resolution: {integrity: sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ==} dev: false @@ -7332,6 +7805,12 @@ packages: commander: 2.20.3 source-map-support: 0.5.21 + /text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + dependencies: + utrie: 1.0.2 + dev: false + /text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -7360,6 +7839,10 @@ packages: webpack: 5.88.2 dev: true + /tiny-invariant@1.3.1: + resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} + dev: false + /tinybench@2.5.1: resolution: {integrity: sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg==} dev: true @@ -7633,6 +8116,36 @@ packages: /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + /utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + dependencies: + base64-arraybuffer: 1.0.2 + dev: false + + /uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + dev: false + + /victory-vendor@36.7.0: + resolution: {integrity: sha512-nqYuTkLSdTTeACyXcCLbL7rl0y6jpzLPtTNGOtSnajdR+xxMxBdjMxDjfNJNlhR+ZU8vbXz+QejntcbY7h9/ZA==} + dependencies: + '@types/d3-array': 3.2.1 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.8 + '@types/d3-shape': 3.1.6 + '@types/d3-time': 3.0.3 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + dev: false + /vite-node@0.34.6(@types/node@20.8.3)(sass@1.69.0): resolution: {integrity: sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA==} engines: {node: '>=v14.18.0'} @@ -8023,6 +8536,7 @@ packages: /workbox-google-analytics@7.0.0: resolution: {integrity: sha512-MEYM1JTn/qiC3DbpvP2BVhyIH+dV/5BjHk756u9VbwuAhu0QHyKscTnisQuz21lfRpOwiS9z4XdqeVAKol0bzg==} + deprecated: It is not compatible with newer versions of GA starting with v4, as long as you are using GAv3 it should be ok, but the package is not longer being maintained dependencies: workbox-background-sync: 7.0.0 workbox-core: 7.0.0 diff --git a/src/frontend/src/App.jsx b/src/frontend/src/App.jsx index 9010333731..2aa63748a9 100755 --- a/src/frontend/src/App.jsx +++ b/src/frontend/src/App.jsx @@ -28,9 +28,27 @@ console.error = function filterWarnings(msg, ...args) { consoleError(msg, ...args); } }; - +axios.interceptors.request.use( + (config) => { + // Do something before request is sent + + // const excludedDomains = ['xxx', 'xxx']; + // const urlIsExcluded = excludedDomains.some((domain) => config.url.includes(domain)); + // if (!urlIsExcluded) { + // config.withCredentials = true; + // } + + config.withCredentials = true; + + return config; + }, + (error) => + // Do something with request error + Promise.reject(error), +); const GlobalInit = () => { useEffect(() => { + console.log('adding interceptors'); axios.interceptors.request.use( (config) => { // Do something before request is sent @@ -133,8 +151,8 @@ const MatomoTrackingInit = () => { ReactDOM.render( - + diff --git a/src/frontend/src/api/CreateProjectService.ts b/src/frontend/src/api/CreateProjectService.ts index e6b192e517..20ad12d6c9 100755 --- a/src/frontend/src/api/CreateProjectService.ts +++ b/src/frontend/src/api/CreateProjectService.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import { API } from '@/api'; import { CreateProjectActions } from '@/store/slices/CreateProjectSlice'; import { ProjectDetailsModel, @@ -11,36 +12,31 @@ import { task_split_type } from '@/types/enums'; const CreateProjectService: Function = ( url: string, - payload: any, - fileUpload: any, + projectData: any, + taskAreaGeojson: any, formUpload: any, dataExtractFile: any, - lineExtractFile: any, + isOsmExtract: boolean, ) => { return async (dispatch) => { dispatch(CreateProjectActions.CreateProjectLoading(true)); dispatch(CommonActions.SetLoading(true)); - const postCreateProjectDetails = async (url, payload, fileUpload, formUpload) => { + const postCreateProjectDetails = async (url, projectData, taskAreaGeojson, formUpload) => { try { - const postNewProjectDetails = await axios.post(url, payload); + // Create project + const postNewProjectDetails = await axios.post(url, projectData); const resp: ProjectDetailsModel = postNewProjectDetails.data; await dispatch(CreateProjectActions.PostProjectDetails(resp)); - if (payload.task_split_type === task_split_type['choose_area_as_task']) { - await dispatch( - UploadAreaService(`${import.meta.env.VITE_API_URL}/projects/${resp.id}/custom_task_boundaries`, fileUpload), - ); - } else if (payload.splitting_algorithm === 'Use natural Boundary') { - await dispatch( - UploadAreaService(`${import.meta.env.VITE_API_URL}/projects/task_split/${resp.id}/`, fileUpload), - ); - } else { - await dispatch( - UploadAreaService(`${import.meta.env.VITE_API_URL}/projects/${resp.id}/custom_task_boundaries`, fileUpload), - ); - // await dispatch(UploadAreaService(`${import.meta.env.VITE_API_URL}/projects/${resp.id}/upload`, fileUpload, { dimension: payload.dimension })); - } + // Submit task boundaries + await dispatch( + UploadTaskAreasService( + `${import.meta.env.VITE_API_URL}/projects/${resp.id}/upload-task-boundaries`, + taskAreaGeojson, + ), + ); + dispatch( CommonActions.SetSnackBar({ open: true, @@ -49,28 +45,30 @@ const CreateProjectService: Function = ( duration: 2000, }), ); - if (dataExtractFile) { + + if (isOsmExtract) { + // Upload data extract generated from raw-data-api + const response = await axios.get( + `${import.meta.env.VITE_API_URL}/projects/data-extract-url/?project_id=${resp.id}&url=${ + projectData.data_extract_url + }`, + ); + } else if (dataExtractFile) { + // Upload custom data extract from user const dataExtractFormData = new FormData(); dataExtractFormData.append('custom_extract_file', dataExtractFile); - await axios.post( - `${import.meta.env.VITE_API_URL}/projects/upload_custom_extract/?project_id=${resp.id}`, + const response = await axios.post( + `${import.meta.env.VITE_API_URL}/projects/upload-custom-extract/?project_id=${resp.id}`, dataExtractFormData, ); } - if (lineExtractFile) { - const lineExtractFormData = new FormData(); - lineExtractFormData.append('custom_extract_file', lineExtractFile); - await axios.post( - `${import.meta.env.VITE_API_URL}/projects/upload_custom_extract/?project_id=${resp.id}`, - lineExtractFormData, - ); - } + + // Generate QR codes await dispatch( GenerateProjectQRService( - `${import.meta.env.VITE_API_URL}/projects/${resp.id}/generate`, - payload, + `${import.meta.env.VITE_API_URL}/projects/${resp.id}/generate-project-data`, + projectData, formUpload, - dataExtractFile, ), ); @@ -96,7 +94,7 @@ const CreateProjectService: Function = ( } }; - await postCreateProjectDetails(url, payload, fileUpload, formUpload); + await postCreateProjectDetails(url, projectData, taskAreaGeojson, formUpload); }; }; const FormCategoryService: Function = (url: string) => { @@ -116,16 +114,13 @@ const FormCategoryService: Function = (url: string) => { await getFormCategoryList(url); }; }; -const UploadAreaService: Function = (url: string, filePayload: any, payload: any) => { +const UploadTaskAreasService: Function = (url: string, filePayload: any, projectData: any) => { return async (dispatch) => { dispatch(CreateProjectActions.UploadAreaLoading(true)); - const postUploadArea = async (url, filePayload, payload) => { + const postUploadArea = async (url, filePayload) => { try { const areaFormData = new FormData(); - areaFormData.append('project_geojson', filePayload); - if (payload?.dimension) { - areaFormData.append('dimension', payload?.dimension); - } + areaFormData.append('task_geojson', filePayload); const postNewProjectDetails = await axios.post(url, areaFormData, { headers: { 'Content-Type': 'multipart/form-data', @@ -135,7 +130,6 @@ const UploadAreaService: Function = (url: string, filePayload: any, payload: any await dispatch(CreateProjectActions.UploadAreaLoading(false)); await dispatch(CreateProjectActions.PostUploadAreaSuccess(postNewProjectDetails.data)); } catch (error: any) { - console.log(error, 'error'); dispatch( CommonActions.SetSnackBar({ open: true, @@ -148,38 +142,31 @@ const UploadAreaService: Function = (url: string, filePayload: any, payload: any } }; - await postUploadArea(url, filePayload, payload); + await postUploadArea(url, filePayload); }; }; -const GenerateProjectQRService: Function = (url: string, payload: any, formUpload: any, dataExtractFile: any) => { +const GenerateProjectQRService: Function = (url: string, projectData: any, formUpload: any) => { return async (dispatch) => { dispatch(CreateProjectActions.GenerateProjectQRLoading(true)); dispatch(CommonActions.SetLoading(true)); - const postUploadArea = async (url, payload: any, formUpload) => { - // debugger; - console.log(formUpload, 'formUpload'); - console.log(payload, 'payload'); + const postUploadArea = async (url, projectData: any, formUpload) => { try { - const isPolygon = payload.data_extractWays === 'Polygon'; - const generateApiFormData = new FormData(); - if (payload.form_ways === 'custom_form') { - generateApiFormData.append('extract_polygon', isPolygon.toString()); - generateApiFormData.append('upload', formUpload); - if (dataExtractFile) { - generateApiFormData.append('data_extracts', dataExtractFile); - } + let postNewProjectDetails; + + if (projectData.form_ways === 'custom_form') { + // TODO move form upload to a separate service / endpoint? + const generateApiFormData = new FormData(); + generateApiFormData.append('xls_form_upload', formUpload); + postNewProjectDetails = await axios.post(url, generateApiFormData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); } else { - generateApiFormData.append('extract_polygon', isPolygon.toString()); - if (dataExtractFile) { - generateApiFormData.append('data_extracts', dataExtractFile); - } + postNewProjectDetails = await axios.post(url, {}); } - const postNewProjectDetails = await axios.post(url, generateApiFormData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - }); + const resp: string = postNewProjectDetails.data; await dispatch(CreateProjectActions.GenerateProjectQRLoading(false)); dispatch(CommonActions.SetLoading(false)); @@ -198,7 +185,7 @@ const GenerateProjectQRService: Function = (url: string, payload: any, formUploa } }; - await postUploadArea(url, payload, formUpload); + await postUploadArea(url, projectData, formUpload); }; }; @@ -220,38 +207,6 @@ const OrganisationService: Function = (url: string) => { }; }; -const UploadCustomXLSFormService: Function = (url: string, payload: any) => { - return async (dispatch) => { - dispatch(CreateProjectActions.UploadCustomXLSFormLoading(true)); - - const postUploadCustomXLSForm = async (url, payload) => { - try { - const customXLSFormData = new FormData(); - customXLSFormData.append('upload', payload[0]); - const postCustomXLSForm = await axios.post(url, customXLSFormData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - }); - await dispatch(CreateProjectActions.UploadCustomXLSFormLoading(false)); - await dispatch(CreateProjectActions.UploadCustomXLSFormSuccess(postCustomXLSForm.data)); - } catch (error: any) { - dispatch( - CommonActions.SetSnackBar({ - open: true, - message: JSON.stringify(error.response.data.detail) || 'Something Went Wrong', - variant: 'error', - duration: 2000, - }), - ); - dispatch(CreateProjectActions.UploadCustomXLSFormLoading(false)); - } - }; - - await postUploadCustomXLSForm(url, payload); - }; -}; - const GenerateProjectLog: Function = (url: string, params: any) => { return async (dispatch) => { dispatch(CreateProjectActions.GenerateProjectLogLoading(true)); @@ -269,15 +224,15 @@ const GenerateProjectLog: Function = (url: string, params: any) => { await getGenerateProjectLog(url, params); }; }; -const GetDividedTaskFromGeojson: Function = (url: string, payload: any) => { +const GetDividedTaskFromGeojson: Function = (url: string, projectData: any) => { return async (dispatch) => { dispatch(CreateProjectActions.SetDividedTaskFromGeojsonLoading(true)); - const getDividedTaskFromGeojson = async (url, payload) => { + const getDividedTaskFromGeojson = async (url, projectData) => { try { const dividedTaskFormData = new FormData(); - dividedTaskFormData.append('project_geojson', payload.geojson); - dividedTaskFormData.append('dimension', payload.dimension); + dividedTaskFormData.append('project_geojson', projectData.geojson); + dividedTaskFormData.append('dimension', projectData.dimension); const getGetDividedTaskFromGeojsonResponse = await axios.post(url, dividedTaskFormData); const resp: OrganisationListModel = getGetDividedTaskFromGeojsonResponse.data; dispatch(CreateProjectActions.SetIsTasksGenerated({ key: 'divide_on_square', value: true })); @@ -291,17 +246,17 @@ const GetDividedTaskFromGeojson: Function = (url: string, payload: any) => { } }; - await getDividedTaskFromGeojson(url, payload); + await getDividedTaskFromGeojson(url, projectData); }; }; -const GetIndividualProjectDetails: Function = (url: string, payload: any) => { +const GetIndividualProjectDetails: Function = (url: string, projectData: any) => { return async (dispatch) => { dispatch(CreateProjectActions.SetIndividualProjectDetailsLoading(true)); - const getIndividualProjectDetails = async (url, payload) => { + const getIndividualProjectDetails = async (url, projectData) => { try { - const getIndividualProjectDetailsResponse = await axios.get(url, { params: payload }); + const getIndividualProjectDetailsResponse = await axios.get(url, { params: projectData }); const resp: ProjectDetailsModel = getIndividualProjectDetailsResponse.data; const formattedOutlineGeojson = { type: 'FeatureCollection', features: [{ ...resp.outline_geojson, id: 1 }] }; const modifiedResponse = { @@ -321,25 +276,28 @@ const GetIndividualProjectDetails: Function = (url: string, payload: any) => { } }; - await getIndividualProjectDetails(url, payload); + await getIndividualProjectDetails(url, projectData); }; }; const TaskSplittingPreviewService: Function = ( url: string, - fileUpload: any, - dataExtractFile: any, + projectAoiFile: any, no_of_buildings: string, + dataExtractFile: any, ) => { return async (dispatch) => { dispatch(CreateProjectActions.GetTaskSplittingPreviewLoading(true)); - const getTaskSplittingGeojson = async (url, fileUpload, dataExtractFile) => { + const getTaskSplittingGeojson = async (url, projectAoiFile, dataExtractFile) => { try { const taskSplittingFileFormData = new FormData(); - taskSplittingFileFormData.append('project_geojson', fileUpload); - taskSplittingFileFormData.append('extract_geojson', dataExtractFile); + taskSplittingFileFormData.append('project_geojson', projectAoiFile); taskSplittingFileFormData.append('no_of_buildings', no_of_buildings); + // Only include data extract if custom extract uploaded + if (dataExtractFile) { + taskSplittingFileFormData.append('extract_geojson', dataExtractFile); + } const getTaskSplittingResponse = await axios.post(url, taskSplittingFileFormData); const resp: OrganisationListModel = getTaskSplittingResponse.data; @@ -366,16 +324,16 @@ const TaskSplittingPreviewService: Function = ( } }; - await getTaskSplittingGeojson(url, fileUpload, dataExtractFile); + await getTaskSplittingGeojson(url, projectAoiFile, dataExtractFile); }; }; -const PatchProjectDetails: Function = (url: string, payload: any) => { +const PatchProjectDetails: Function = (url: string, projectData: any) => { return async (dispatch) => { dispatch(CreateProjectActions.SetPatchProjectDetailsLoading(true)); - const patchProjectDetails = async (url, payload) => { + const patchProjectDetails = async (url, projectData) => { try { - const getIndividualProjectDetailsResponse = await axios.patch(url, payload); + const getIndividualProjectDetailsResponse = await axios.patch(url, projectData); const resp: ProjectDetailsModel = getIndividualProjectDetailsResponse.data; // dispatch(CreateProjectActions.SetIndividualProjectDetails(modifiedResponse)); dispatch(CreateProjectActions.SetPatchProjectDetails(resp)); @@ -395,22 +353,22 @@ const PatchProjectDetails: Function = (url: string, payload: any) => { } }; - await patchProjectDetails(url, payload); + await patchProjectDetails(url, projectData); }; }; -const PostFormUpdate: Function = (url: string, payload: any) => { +const PostFormUpdate: Function = (url: string, projectData: any) => { return async (dispatch) => { dispatch(CreateProjectActions.SetPostFormUpdateLoading(true)); - const postFormUpdate = async (url, payload) => { + const postFormUpdate = async (url, projectData) => { try { const formFormData = new FormData(); - formFormData.append('project_id', payload.project_id); - if (payload.category) { - formFormData.append('category', payload.category); + formFormData.append('project_id', projectData.project_id); + if (projectData.category) { + formFormData.append('category', projectData.category); } - if (payload.upload) { - formFormData.append('upload', payload.upload); + if (projectData.upload) { + formFormData.append('upload', projectData.upload); } const postFormUpdateResponse = await axios.post(url, formFormData); const resp: ProjectDetailsModel = postFormUpdateResponse.data; @@ -429,8 +387,8 @@ const PostFormUpdate: Function = (url: string, payload: any) => { dispatch( CommonActions.SetSnackBar({ open: true, - message: error.response.data.detail, - variant: 'success', + message: error?.response?.data?.detail || 'Failed to update Form', + variant: 'error', duration: 2000, }), ); @@ -440,7 +398,7 @@ const PostFormUpdate: Function = (url: string, payload: any) => { } }; - await postFormUpdate(url, payload); + await postFormUpdate(url, projectData); }; }; const EditProjectBoundaryService: Function = (url: string, geojsonUpload: any, dimension: any) => { @@ -499,6 +457,7 @@ const ValidateCustomForm: Function = (url: string, formUpload: any) => { duration: 2000, }), ); + dispatch(CreateProjectActions.SetCustomFileValidity(true)); } catch (error) { dispatch( CommonActions.SetSnackBar({ @@ -511,6 +470,7 @@ const ValidateCustomForm: Function = (url: string, formUpload: any) => { }), ); dispatch(CreateProjectActions.ValidateCustomFormLoading(false)); + dispatch(CreateProjectActions.SetCustomFileValidity(false)); } finally { dispatch(CreateProjectActions.ValidateCustomFormLoading(false)); } @@ -519,13 +479,50 @@ const ValidateCustomForm: Function = (url: string, formUpload: any) => { await validateCustomForm(url, formUpload); }; }; + +const DeleteProjectService: Function = (url: string) => { + return async (dispatch) => { + const deleteProject = async (url: string) => { + try { + await API.delete(url); + dispatch( + CommonActions.SetSnackBar({ + open: true, + message: 'Project deleted. Redirecting...', + variant: 'success', + duration: 2000, + }), + ); + // Redirect to homepage + setTimeout(() => { + window.location.href = '/'; + }, 2000); + } catch (error) { + if (error.response.status === 404) { + dispatch( + CommonActions.SetSnackBar({ + open: true, + message: 'Project already deleted', + variant: 'success', + duration: 2000, + }), + ); + } else { + } + } + }; + + await deleteProject(url); + // TODO extra cleanup required? + }; +}; + export { - UploadAreaService, + UploadTaskAreasService, CreateProjectService, FormCategoryService, GenerateProjectQRService, OrganisationService, - UploadCustomXLSFormService, GenerateProjectLog, GetDividedTaskFromGeojson, TaskSplittingPreviewService, @@ -534,4 +531,5 @@ export { PostFormUpdate, EditProjectBoundaryService, ValidateCustomForm, + DeleteProjectService, }; diff --git a/src/frontend/src/api/Files.js b/src/frontend/src/api/Files.js index fb4cd24aa0..7355f88497 100755 --- a/src/frontend/src/api/Files.js +++ b/src/frontend/src/api/Files.js @@ -1,5 +1,14 @@ import { useEffect, useState } from 'react'; import qrcodeGenerator from 'qrcode-generator'; +import { deflate } from 'pako/lib/deflate'; + +// function base64zlibdecode(string) { +// return new TextDecoder().decode(inflate(Uint8Array.from(window.atob(string), (c) => c.codePointAt(0)))) +// } + +function base64zlibencode(string) { + return window.btoa(String.fromCodePoint(...deflate(new TextEncoder().encode(string)))); +} export const ProjectFilesById = (odkToken, projectName, osmUser, taskId) => { const [qrcode, setQrcode] = useState(''); @@ -17,7 +26,7 @@ export const ProjectFilesById = (odkToken, projectName, osmUser, taskId) => { basemap_source: 'osm', autosend: 'wifi_and_cellular', metadata_username: osmUser, - metadata_email: taskId, + metadata_email: taskId.toString(), }, project: { name: projectName }, admin: {}, @@ -26,7 +35,8 @@ export const ProjectFilesById = (odkToken, projectName, osmUser, taskId) => { // Note: error correction level = "L" const code = qrcodeGenerator(0, 'L'); // Note: btoa base64 encodes the JSON string - code.addData(btoa(odkCollectJson)); + // Note: pako.deflate zlib encodes to content + code.addData(base64zlibencode(odkCollectJson)); code.make(); // Note: cell size = 3, margin = 5 diff --git a/src/frontend/src/api/OrganisationService.ts b/src/frontend/src/api/OrganisationService.ts index 25d7224db0..5ebb841207 100644 --- a/src/frontend/src/api/OrganisationService.ts +++ b/src/frontend/src/api/OrganisationService.ts @@ -55,8 +55,25 @@ export const OrganisationDataService: Function = (url: string) => { }; }; +export const MyOrganisationDataService: Function = (url: string) => { + return async (dispatch) => { + dispatch(OrganisationAction.GetMyOrganisationDataLoading(true)); + const getMyOrganisationData = async (url) => { + try { + const getMyOrganisationDataResponse = await API.get(url); + const response: GetOrganisationDataModel[] = getMyOrganisationDataResponse.data; + dispatch(OrganisationAction.GetMyOrganisationsData(response)); + } catch (error) { + dispatch(OrganisationAction.GetMyOrganisationDataLoading(false)); + } + }; + await getMyOrganisationData(url); + }; +}; + export const PostOrganisationDataService: Function = (url: string, payload: any) => { return async (dispatch) => { + dispatch(OrganisationAction.SetOrganisationFormData({})); dispatch(OrganisationAction.PostOrganisationDataLoading(true)); const postOrganisationData = async (url, payload) => { @@ -78,24 +95,150 @@ export const PostOrganisationDataService: Function = (url: string, payload: any) dispatch( CommonActions.SetSnackBar({ open: true, - message: 'Organization Successfully Created.', + message: 'Organization Request Submitted.', variant: 'success', duration: 2000, }), ); } catch (error: any) { + dispatch(OrganisationAction.PostOrganisationDataLoading(false)); dispatch( CommonActions.SetSnackBar({ open: true, - message: error.response.data.detail, + message: error.response.data.detail || 'Failed to create organization.', variant: 'error', duration: 2000, }), ); - dispatch(OrganisationAction.PostOrganisationDataLoading(false)); } }; await postOrganisationData(url, payload); }; }; + +export const GetIndividualOrganizationService: Function = (url: string) => { + return async (dispatch) => { + dispatch(OrganisationAction.SetOrganisationFormData({})); + const getOrganisationData = async (url) => { + try { + const getOrganisationDataResponse = await axios.get(url); + const response: GetOrganisationDataModel = getOrganisationDataResponse.data; + dispatch(OrganisationAction.SetIndividualOrganization(response)); + } catch (error) {} + }; + await getOrganisationData(url); + }; +}; + +export const PatchOrganizationDataService: Function = (url: string, payload: any) => { + return async (dispatch) => { + dispatch(OrganisationAction.SetOrganisationFormData({})); + dispatch(OrganisationAction.PostOrganisationDataLoading(true)); + + const patchOrganisationData = async (url, payload) => { + dispatch(OrganisationAction.SetOrganisationFormData(payload)); + + try { + const generateApiFormData = new FormData(); + appendObjectToFormData(generateApiFormData, payload); + + const patchOrganisationData = await axios.patch(url, payload, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + const resp: HomeProjectCardModel = patchOrganisationData.data; + dispatch(OrganisationAction.PostOrganisationDataLoading(false)); + dispatch(OrganisationAction.postOrganisationData(resp)); + dispatch( + CommonActions.SetSnackBar({ + open: true, + message: 'Organization Updated Successfully.', + variant: 'success', + duration: 2000, + }), + ); + } catch (error: any) { + dispatch(OrganisationAction.PostOrganisationDataLoading(false)); + dispatch( + CommonActions.SetSnackBar({ + open: true, + message: error.response.data.detail || 'Failed to update organization.', + variant: 'error', + duration: 2000, + }), + ); + } + }; + + await patchOrganisationData(url, payload); + }; +}; + +export const ApproveOrganizationService: Function = (url: string) => { + return async (dispatch) => { + const approveOrganization = async (url: string) => { + try { + dispatch(OrganisationAction.SetOrganizationApproving(true)); + await axios.post(url); + dispatch( + CommonActions.SetSnackBar({ + open: true, + message: 'Organization approved successfully.', + variant: 'success', + duration: 2000, + }), + ); + dispatch(OrganisationAction.SetOrganizationApproving(false)); + dispatch(OrganisationAction.SetOrganisationFormData({})); + dispatch(OrganisationAction.SetOrganizationApprovalStatus(true)); + } catch (error) { + dispatch(OrganisationAction.SetOrganizationApproving(false)); + dispatch( + CommonActions.SetSnackBar({ + open: true, + message: 'Failed to approve organization.', + variant: 'error', + duration: 2000, + }), + ); + } + }; + await approveOrganization(url); + }; +}; + +export const RejectOrganizationService: Function = (url: string) => { + return async (dispatch) => { + const rejectOrganization = async (url: string) => { + try { + dispatch(OrganisationAction.SetOrganizationRejecting(true)); + await axios.delete(url); + dispatch( + CommonActions.SetSnackBar({ + open: true, + message: 'Organization rejected successfully.', + variant: 'success', + duration: 2000, + }), + ); + dispatch(OrganisationAction.SetOrganizationRejecting(false)); + dispatch(OrganisationAction.SetOrganisationFormData({})); + dispatch(OrganisationAction.SetOrganizationApprovalStatus(true)); + } catch (error) { + dispatch(OrganisationAction.SetOrganizationRejecting(false)); + dispatch( + CommonActions.SetSnackBar({ + open: true, + message: 'Failed to reject organization.', + variant: 'error', + duration: 2000, + }), + ); + } + }; + await rejectOrganization(url); + }; +}; diff --git a/src/frontend/src/api/Project.js b/src/frontend/src/api/Project.js index 1755b3f2b7..175c5b9413 100755 --- a/src/frontend/src/api/Project.js +++ b/src/frontend/src/api/Project.js @@ -20,7 +20,6 @@ export const ProjectById = (existingProjectList, projectId) => { const persistingValues = taskListResp.map((data) => { return { id: data.id, - project_task_name: data.project_task_name, outline_geojson: data.outline_geojson, outline_centroid: data.outline_centroid, task_status: task_priority_str[data.task_status], @@ -58,6 +57,7 @@ export const ProjectById = (existingProjectList, projectId) => { tasks_validated: projectResp.tasks_validated, xform_title: projectResp.xform_title, tasks_bad: projectResp.tasks_bad, + data_extract_url: projectResp.data_extract_url, }), ); dispatch(ProjectActions.SetProjectDetialsLoading(false)); diff --git a/src/frontend/src/api/ProjectTaskStatus.js b/src/frontend/src/api/ProjectTaskStatus.js index 5e3dbe6960..22ed5e0e64 100755 --- a/src/frontend/src/api/ProjectTaskStatus.js +++ b/src/frontend/src/api/ProjectTaskStatus.js @@ -5,14 +5,14 @@ import CoreModules from '@/shared/CoreModules'; import { CommonActions } from '@/store/slices/CommonSlice'; import { task_priority_str } from '@/types/enums'; -const UpdateTaskStatus = (url, style, existingData, currentProjectId, feature, map, view, taskId, body) => { +const UpdateTaskStatus = (url, style, existingData, currentProjectId, feature, map, view, taskId, body, params) => { return async (dispatch) => { const index = existingData.findIndex((project) => project.id == currentProjectId); - const updateTask = async (url, existingData, body, feature) => { + const updateTask = async (url, existingData, body, feature, params) => { try { dispatch(CommonActions.SetLoading(true)); - const response = await CoreModules.axios.post(url, body); + const response = await CoreModules.axios.post(url, body, { params }); const findIndexForUpdation = existingData[index].taskBoundries.findIndex((obj) => obj.id == response.data.id); let project_tasks = [...existingData[index].taskBoundries]; @@ -35,7 +35,7 @@ const UpdateTaskStatus = (url, style, existingData, currentProjectId, feature, m dispatch( HomeActions.SetSnackBar({ open: true, - message: `Task #${response.data.id} has been updated to ${response.data.task_status}`, + message: `Task #${response.data.id} has been updated to ${task_priority_str[response.data.task_status]}`, variant: 'success', duration: 3000, }), @@ -52,7 +52,7 @@ const UpdateTaskStatus = (url, style, existingData, currentProjectId, feature, m ); } }; - await updateTask(url, existingData, body, feature); + await updateTask(url, existingData, body, feature, params); const centroid = await existingData[index].taskBoundries.filter((task) => { return task.id == taskId; })[0].outline_centroid.geometry.coordinates; diff --git a/src/frontend/src/api/SubmissionService.ts b/src/frontend/src/api/SubmissionService.ts index 70d4e2a308..2ba55763b7 100644 --- a/src/frontend/src/api/SubmissionService.ts +++ b/src/frontend/src/api/SubmissionService.ts @@ -1,6 +1,8 @@ import CoreModules from '@/shared/CoreModules'; import { ProjectActions } from '@/store/slices/ProjectSlice'; // import { HomeProjectCardModel } from '@/models/home/homeModel'; +import { SubmissionActions } from '@/store/slices/SubmissionSlice'; +import { basicGeojsonTemplate } from '@/utilities/mapUtils'; export const ProjectSubmissionService: Function = (url: string) => { return async (dispatch) => { @@ -20,21 +22,95 @@ export const ProjectSubmissionService: Function = (url: string) => { await fetchProjectSubmission(url); }; }; -export const ProjectBuildingGeojsonService: Function = (url: string) => { + +export const ProjectSubmissionInfographicsService: Function = (url: string) => { + return async (dispatch) => { + const fetchProjectSubmission = async (url: string) => { + try { + dispatch(SubmissionActions.SetSubmissionInfographicsLoading(true)); + const fetchSubmissionData = await CoreModules.axios.get(url); + const resp: any = fetchSubmissionData.data; + dispatch(SubmissionActions.SetSubmissionInfographicsLoading(false)); + dispatch(SubmissionActions.SetSubmissionInfographics(resp)); + } catch (error) {} + }; + + await fetchProjectSubmission(url); + }; +}; + +export const ValidatedVsMappedInfographicsService: Function = (url: string) => { + return async (dispatch) => { + const fetchProjectSubmission = async (url: string) => { + try { + dispatch(SubmissionActions.SetValidatedVsMappedLoading(true)); + const validatedVsMappedData = await CoreModules.axios.get(url); + const resp: any = validatedVsMappedData.data; + dispatch(SubmissionActions.SetValidatedVsMappedInfographics(resp)); + dispatch(SubmissionActions.SetValidatedVsMappedLoading(false)); + } catch (error) { + dispatch(SubmissionActions.SetValidatedVsMappedLoading(false)); + } + }; + + await fetchProjectSubmission(url); + }; +}; + +export const ProjectContributorsService: Function = (url: string) => { + return async (dispatch) => { + const fetchProjectContributor = async (url: string) => { + try { + dispatch(SubmissionActions.SetSubmissionContributorsLoading(true)); + const fetchContributorsData = await CoreModules.axios.get(url); + const resp: any = fetchContributorsData.data; + dispatch(SubmissionActions.SetSubmissionContributors(resp)); + dispatch(SubmissionActions.SetSubmissionContributorsLoading(false)); + } catch (error) { + dispatch(SubmissionActions.SetSubmissionContributorsLoading(false)); + } + }; + + await fetchProjectContributor(url); + }; +}; + +export const SubmissionFormFieldsService: Function = (url: string) => { return async (dispatch) => { - dispatch(ProjectActions.GetProjectBuildingGeojsonLoading(true)); + const fetchFormFields = async (url: string) => { + try { + dispatch(SubmissionActions.SetSubmissionFormFieldsLoading(true)); + const response = await CoreModules.axios.get(url); + const formFields: any = response.data; + dispatch(SubmissionActions.SetSubmissionFormFields(formFields)); + dispatch(SubmissionActions.SetSubmissionFormFieldsLoading(false)); + dispatch(SubmissionActions.SetSubmissionTableRefreshing(false)); + } catch (error) { + dispatch(SubmissionActions.SetSubmissionFormFieldsLoading(false)); + dispatch(SubmissionActions.SetSubmissionTableRefreshing(false)); + } + }; - const fetchProjectBuildingGeojson = async (url: string) => { + await fetchFormFields(url); + }; +}; + +export const SubmissionTableService: Function = (url: string, payload) => { + return async (dispatch) => { + const fetchSubmissionTable = async (url: string, payload) => { try { - const fetchBuildingGeojsonData = await CoreModules.axios.get(url); - const resp: any = fetchBuildingGeojsonData.data; - dispatch(ProjectActions.SetProjectBuildingGeojson(resp)); - dispatch(ProjectActions.GetProjectBuildingGeojsonLoading(false)); + dispatch(SubmissionActions.SetSubmissionTableLoading(true)); + const response = await CoreModules.axios.get(url, { params: payload }); + const submissionTableData: any = response.data; + dispatch(SubmissionActions.SetSubmissionTable(submissionTableData)); + dispatch(SubmissionActions.SetSubmissionTableLoading(false)); + dispatch(SubmissionActions.SetSubmissionTableRefreshing(false)); } catch (error) { - dispatch(ProjectActions.GetProjectBuildingGeojsonLoading(false)); + dispatch(SubmissionActions.SetSubmissionTableLoading(false)); + dispatch(SubmissionActions.SetSubmissionTableRefreshing(false)); } }; - await fetchProjectBuildingGeojson(url); + await fetchSubmissionTable(url, payload); }; }; diff --git a/src/frontend/src/api/task.ts b/src/frontend/src/api/task.ts index 629fa90a99..128869bb3a 100644 --- a/src/frontend/src/api/task.ts +++ b/src/frontend/src/api/task.ts @@ -178,18 +178,18 @@ export const fetchConvertToOsmDetails: Function = (url: string) => { }; }; -export const ConvertXMLToJOSM: Function = (url: string, projectBbox) => { +export const ConvertXMLToJOSM: Function = (url: string, projectBbox: number[]) => { return async (dispatch) => { dispatch(CoreModules.TaskActions.SetConvertXMLToJOSMLoading(true)); const getConvertXMLToJOSM = async (url) => { try { // checkJOSMOpen - To check if JOSM Editor is Open Or Not. - await CoreModules.axios.get(`http://127.0.0.1:8111/version?jsonp=checkJOSM`); - //importToJosmEditor - To open JOSM Editor and add XML of Project Submission To JOSM. - CoreModules.axios.get( + await fetch(`http://127.0.0.1:8111/version?jsonp=checkJOSM`); + //importToJosmEditor - To open JOSM Editor and add base layer To JOSM. + fetch( `http://127.0.0.1:8111/imagery?title=osm&type=tms&url=https://tile.openstreetmap.org/%7Bzoom%7D/%7Bx%7D/%7By%7D.png`, ); - await CoreModules.axios.get(`http://127.0.0.1:8111/import?url=${url}`); + await fetch(`http://127.0.0.1:8111/import?url=${url}`); // `http://127.0.0.1:8111/load_and_zoom?left=80.0580&right=88.2015&top=27.9268&bottom=26.3470`; const loadAndZoomParams = { @@ -202,9 +202,11 @@ export const ConvertXMLToJOSM: Function = (url: string, projectBbox) => { new_layer: 'true', layer_name: 'OSM Data', }; - await CoreModules.axios.get(`http://127.0.0.1:8111/zoom`, { - params: loadAndZoomParams, - }); + const queryString = Object.keys(loadAndZoomParams) + .map((key) => key + '=' + loadAndZoomParams[key]) + .join('&'); + + await fetch(`http://127.0.0.1:8111/zoom?${queryString}`); } catch (error: any) { dispatch(CoreModules.TaskActions.SetJosmEditorError('JOSM Error')); // alert(error.response.data); diff --git a/src/frontend/src/components/ApproveOrganization/ApproveOrganizationHeader.tsx b/src/frontend/src/components/ApproveOrganization/ApproveOrganizationHeader.tsx new file mode 100644 index 0000000000..79b90db9ac --- /dev/null +++ b/src/frontend/src/components/ApproveOrganization/ApproveOrganizationHeader.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import AssetModules from '@/shared/AssetModules.js'; +import { useNavigate } from 'react-router-dom'; + +const ApproveOrganizationHeader = () => { + const navigate = useNavigate(); + return ( +

+ ); +}; + +export default ApproveOrganizationHeader; diff --git a/src/frontend/src/components/ApproveOrganization/OrganizationForm.tsx b/src/frontend/src/components/ApproveOrganization/OrganizationForm.tsx new file mode 100644 index 0000000000..a0ecbfaf48 --- /dev/null +++ b/src/frontend/src/components/ApproveOrganization/OrganizationForm.tsx @@ -0,0 +1,154 @@ +import React, { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import { useNavigate, useParams } from 'react-router-dom'; +import InputTextField from '@/components/common/InputTextField'; +import TextArea from '@/components/common/TextArea'; +import Button from '@/components/common/Button'; +import { + ApproveOrganizationService, + GetIndividualOrganizationService, + RejectOrganizationService, +} from '@/api/OrganisationService'; +import CoreModules from '@/shared/CoreModules'; +import { OrganisationAction } from '@/store/slices/organisationSlice'; + +const OrganizationForm = () => { + const dispatch = useDispatch(); + const params = useParams(); + const navigate = useNavigate(); + const organizationId = params.id; + const organisationFormData: any = CoreModules.useAppSelector((state) => state.organisation.organisationFormData); + const organizationApproving: any = CoreModules.useAppSelector( + (state) => state.organisation.organizationApprovalStatus.organizationApproving, + ); + const organizationRejecting: any = CoreModules.useAppSelector( + (state) => state.organisation.organizationApprovalStatus.organizationRejecting, + ); + const organizationApprovalSuccess: any = CoreModules.useAppSelector( + (state) => state.organisation.organizationApprovalStatus.isSuccess, + ); + + useEffect(() => { + if (organizationId) { + dispatch( + GetIndividualOrganizationService(`${import.meta.env.VITE_API_URL}/organisation/unapproved/${organizationId}`), + ); + } + }, [organizationId]); + + const approveOrganization = () => { + if (organizationId) { + dispatch( + ApproveOrganizationService( + `${import.meta.env.VITE_API_URL}/organisation/approve/?org_id=${parseInt(organizationId)}`, + ), + ); + } + }; + + const rejectOrganization = () => { + dispatch(RejectOrganizationService(`${import.meta.env.VITE_API_URL}/organisation/${organizationId}`)); + }; + + // redirect to manage-organization page after approve/reject success + useEffect(() => { + if (organizationApprovalSuccess) { + dispatch(OrganisationAction.SetOrganisationFormData({})); + dispatch(OrganisationAction.SetOrganizationApprovalStatus(false)); + navigate('/organisation'); + } + }, [organizationApprovalSuccess]); + + return ( +
+
+
+ Organizational Details +
+
+
+ {}} + fieldType="text" + disabled + /> + {}} + fieldType="text" + disabled + /> +