diff --git a/.all-contributorsrc b/.all-contributorsrc index 347f178845..e22a2991a7 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -148,6 +148,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/123072058?v=4", "profile": "https://github.com/Prajwalism", "contributions": ["code"] + }, + { + "login": "manjitapandey", + "name": "Manjita Pandey", + "avatar_url": "https://avatars.githubusercontent.com/u/97273021?v=4", + "profile": "https://github.com/manjitapandey", + "contributions": ["bug"] } ], "contributorsPerLine": 7, diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 235c1fe91c..68339be87b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,20 +2,14 @@ - [ ] πŸ• Feature - [ ] πŸ› Bug Fix -- [ ] πŸ“ Documentation Update -- [ ] 🎨 Style -- [ ] πŸ§‘β€πŸ’» Code Refactor -- [ ] πŸ”₯ Performance Improvements +- [ ] πŸ“ Documentation +- [ ] πŸ§‘β€πŸ’» Refactor - [ ] βœ… Test -- [ ] πŸ€– Build -- [ ] πŸ” CI -- [ ] πŸ“¦ Chore (Release) -- [ ] ⏩ Revert +- [ ] πŸ€– Build or CI +- [ ] ❓ Other (please specify) ## Related Issue -Ticket number, link, or description. - Example: Fixes #123 ## Describe this PR @@ -26,10 +20,18 @@ A brief description of how this solves the issue. Please provide screenshots of the change. +## Alternative Approaches Considered + +Did you attempt any other approaches that are not documented in code? + +## Review Guide + +Notes for the reviewer. How to test this change? + ## Checklist before requesting a review - πŸ“– Read the FMTM Contributing Guide: -- πŸ“– Read the FMTM Code of Conduct: +- πŸ“– Read the HOT Code of Conduct: - πŸ‘·β€β™€οΈ Create small PRs. In most cases, this will be possible. - βœ… Provide tests for your changes. - πŸ“ Use descriptive commit messages. diff --git a/INSTALL.md b/INSTALL.md index 4181e1044f..56a6755be4 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -34,7 +34,7 @@ On a Linux-based machine with `bash` installed, run the script: > However, if you run as root, a user svcfmtm will be created for you. ```bash -curl -L https://https://hotosm.github.io/fmtm-installer/install.sh -o install.sh +curl -L https://hotosm.github.io/fmtm-installer/install.sh -o install.sh bash install.sh # Then follow the prompts diff --git a/README.md b/README.md index 73595d8081..60a472d736 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ and [contributor guidance](https://hotosm.github.io/fmtm/CONTRIBUTING/) for more details! Reach out to us if any questions! πŸ‘πŸŽ‰ -## Using OpenDataKit's Select From Map feature +## Using ODK's Select From Map feature As of mid-2022, ODK incorporates a new functionality, select from map, that allows field mappers to select an object from a map, @@ -110,7 +110,7 @@ field mappers to go out and collect data. They need to: ### Field mappers Field mappers select (or are allocated) individual tasks within a project -AOI and use ODK Collect to gather data in those areas. They need to: +AOI and use the ODK mobile app to gather data in those areas. They need to: - Visit a mobile-friendly Web page where they can see available tasks on a map - Choose an area and launch ODK Collect @@ -138,7 +138,7 @@ To install for a quick test, or on a production instance, use the convenience script: ```bash -curl -L https://https://hotosm.github.io/fmtm-installer/install.sh -o install.sh +curl -L https://hotosm.github.io/fmtm-installer/install.sh -o install.sh bash install.sh ``` @@ -149,7 +149,7 @@ A breakdown of the components: ### ODK Collect A mobile data collection tool that functions on almost all Android phones. -Field mappers use ODK Collect to select features such as buildings or amenities, +Field mappers use the ODK mobile app to select features such as buildings or amenities, and fill out forms with survey questions to collect attributes or data about those features (normally at least some of these attributes are intended to become OSM tags associated with those features). @@ -303,6 +303,7 @@ Thanks goes to these wonderful people: Uju
Uju

πŸ“– JC CorMan
JC CorMan

πŸ“– Prajwal Khadgi
Prajwal Khadgi

πŸ’» + Manjita Pandey
Manjita Pandey

πŸ› diff --git a/docker-compose.development.yml b/docker-compose.development.yml index a1aaaf72b9..a3401aaf72 100644 --- a/docker-compose.development.yml +++ b/docker-compose.development.yml @@ -152,6 +152,15 @@ services: - fmtm-net restart: "unless-stopped" + pyxform: + image: "ghcr.io/getodk/pyxform-http:v2.0.2" + depends_on: + central-db: + condition: service_healthy + networks: + - fmtm-net + restart: "unless-stopped" + central-ui: # This service simply builds the frontend to a volume # accessible to the proxy, then shuts down diff --git a/docker-compose.main.yml b/docker-compose.main.yml index ab87c41e4b..c6cbe61d9c 100644 --- a/docker-compose.main.yml +++ b/docker-compose.main.yml @@ -105,6 +105,7 @@ services: args: APP_VERSION: main VITE_API_URL: https://${FMTM_API_DOMAIN:-api.${FMTM_DOMAIN}} + NODE_ENV: production volumes: - fmtm_frontend:/frontend network_mode: none diff --git a/docker-compose.yml b/docker-compose.yml index 7b83b740ce..579c809f09 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -167,6 +167,15 @@ services: - fmtm-net restart: "unless-stopped" + pyxform: + image: "ghcr.io/getodk/pyxform-http:v2.0.2" + depends_on: + central-db: + condition: service_healthy + networks: + - fmtm-net + restart: "unless-stopped" + central-ui: # This service simply builds the frontend to a volume # accessible to the proxy, then shuts down diff --git a/docs/About.md b/docs/About.md index 1a68db48bc..49fcad2fb2 100644 --- a/docs/About.md +++ b/docs/About.md @@ -64,9 +64,9 @@ you're interested in getting involved, please see our for more information. We welcome questions and feedback, so don't hesitate to reach out to us. πŸ‘πŸŽ‰ -## Using OpenDataKit's Select From Map feature +## Using ODK's Select From Map feature -OpenDataKit's Select From Map feature is a useful tool for field mappers to +ODK's Select From Map feature is a useful tool for field mappers to collect data in a well-structured questionnaire format. The tool was incorporated into ODK in mid-2022 and allows mappers to select an object from a map, view its existing attributes, and fill out a form with new information @@ -116,7 +116,7 @@ to go out and collect data. They need to: ### Field mappers Field mappers select (or are allocated) individual tasks within a project AOI -and use ODK Collect to gather data in those areas. They need to: +and use the ODK mobile app to gather data in those areas. They need to: - Visit a mobile-friendly Web page where they can see available tasks on a map - Choose an area and launch ODK Collect with the form corresponding to their diff --git a/docs/Doxyfile b/docs/Doxyfile index 78b71f718f..c53a328c74 100644 --- a/docs/Doxyfile +++ b/docs/Doxyfile @@ -54,7 +54,7 @@ PROJECT_NUMBER = # for a project that appears at the top of each page and should give viewer a # quick idea about the purpose of the project. Keep the description short. -PROJECT_BRIEF = "Organize field mapping with OpenDataKit and OpenStreetMap" +PROJECT_BRIEF = "Organize field mapping with ODK and OpenStreetMap" # With the PROJECT_LOGO tag one can specify a logo or an icon that is included # in the documentation. The maximum height of the logo should not exceed 55 diff --git a/docs/Guide-On-Improving-Documentation.md b/docs/Guide-On-Improving-Documentation.md index c228cf0bef..bde1ed7eb4 100644 --- a/docs/Guide-On-Improving-Documentation.md +++ b/docs/Guide-On-Improving-Documentation.md @@ -109,7 +109,7 @@ added to and built upon. 1. **Acronyms should be stated in full before repetitive use**. Acronyms like ODK, OSM, etc, should be stated in full before - use or after each use within brackets e.g ODK (Open Data Kit). This + use or after each use within brackets e.g ODK (ODK). This negates confusion for readers / users and just simplifies things. 2. **Important features should be put in bold**. For example β€œselect diff --git a/docs/User-Manual-For-Project-Managers.md b/docs/User-Manual-For-Project-Managers.md index cf8d6a612d..aef53f9b05 100644 --- a/docs/User-Manual-For-Project-Managers.md +++ b/docs/User-Manual-For-Project-Managers.md @@ -54,7 +54,7 @@ their progress. The tool includes features for collaborative editing, data validation, and error detection. This ensures that the data collected by volunteers is accurate and reliable. -**FMTM** is designed to be used in conjunction with **Open Data Kit +**FMTM** is designed to be used in conjunction with **ODK (ODK)**. **ODK** is a free and open-source set of tools that allows users to create, collect, and manage data with mobile devices. The **ODK** provides a set of open-source tools that allow users to build @@ -133,7 +133,7 @@ and improve the effectiveness of humanitarian efforts. 10. If your organization's name is not listed, you can add it through the "Manage Organization" tab. -11. Provide the necessary credentials for the ODK (Open Data Kit) central setup, +11. Provide the necessary credentials for the ODK (ODK) central setup, including URL, username, and password. 12. Proceed to the next step, which is uploading the area for field mapping. Choose the file option and select the AOI (Area of Interest) file in GEOJSON diff --git a/docs/dev/Production.md b/docs/dev/Production.md index 0a0eb922e7..586a9c46c8 100644 --- a/docs/dev/Production.md +++ b/docs/dev/Production.md @@ -17,7 +17,7 @@ your own cloud server. ### Run the install script ```bash -curl -L https://https://hotosm.github.io/fmtm-installer/install.sh -o install.sh +curl -L https://hotosm.github.io/fmtm-installer/install.sh -o install.sh bash install.sh # Then follow the prompts diff --git a/docs/dev/Setup.md b/docs/dev/Setup.md index 9735145b9c..7e0fda2a49 100644 --- a/docs/dev/Setup.md +++ b/docs/dev/Setup.md @@ -49,7 +49,7 @@ A computer-screen-optimized web app that allows Campaign Managers to: #### [ODK Collect](https://docs.getodk.org/collect-intro/) A mobile data collection tool that functions on almost all Android phones. -Field mappers use ODK Collect to select features such as buildings or amenities, +Field mappers use the ODK mobile app to select features such as buildings or amenities, and fill out forms with survey questions to collect attributes or data about those features (normally at least some of these attributes are intended to become OSM tags associated with those features). diff --git a/src/backend/Dockerfile b/src/backend/Dockerfile index 77f41b724c..0607c3a886 100644 --- a/src/backend/Dockerfile +++ b/src/backend/Dockerfile @@ -17,6 +17,7 @@ ARG PYTHON_IMG_TAG=3.10 ARG MINIO_TAG=${MINIO_TAG:-RELEASE.2024-01-01T16-36-33Z} FROM docker.io/minio/minio:${MINIO_TAG} as minio +FROM docker.io/protomaps/go-pmtiles:v1.19.0 as go-pmtiles # Includes all labels and timezone info to extend from @@ -107,6 +108,9 @@ RUN set -ex \ && rm -rf /var/lib/apt/lists/* # Copy minio mc client COPY --from=minio /usr/bin/mc /usr/local/bin/ +# Copy go-pmtiles until for mbtiles-->pmtiles conversion +# FIXME osm-fieldwork should do this, but is currently broken +COPY --from=go-pmtiles /go-pmtiles /usr/local/bin/pmtiles COPY *-entrypoint.sh / ENTRYPOINT ["/app-entrypoint.sh"] # Copy Python deps from build to runtime diff --git a/src/backend/app/auth/auth_routes.py b/src/backend/app/auth/auth_routes.py index 66b500beab..7209e468d2 100644 --- a/src/backend/app/auth/auth_routes.py +++ b/src/backend/app/auth/auth_routes.py @@ -17,17 +17,18 @@ # """Auth routes, to login, logout, and get user details.""" +from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import JSONResponse from loguru import logger as log +from sqlalchemy import text from sqlalchemy.orm import Session from app.auth.osm import AuthUser, init_osm_auth, login_required from app.config import settings from app.db import database -from app.db.db_models import DbUser -from app.users import user_crud +from app.models.enums import UserRole router = APIRouter( prefix="/auth", @@ -127,45 +128,86 @@ async def logout(): 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." - ), + try: + update_sql = text( + """ + INSERT INTO users ( + id, username, profile_img, role, mapping_level, + is_email_verified, is_expert, tasks_mapped, tasks_validated, + tasks_invalidated, date_registered, last_validation_date + ) + VALUES ( + :user_id, :username, :profile_img, :role, + :mapping_level, FALSE, FALSE, 0, 0, 0, + :current_date, :current_date + ) + ON CONFLICT (id) + DO UPDATE SET profile_img = :profile_img; + """ ) + role = UserRole(user_data.role).name + db.execute( + update_sql, + { + "user_id": user_data.id, + "username": user_data.username, + "profile_img": user_data.img_url, + "role": role, + "mapping_level": "BEGINNER", + "current_date": datetime.now(timezone.utc), + }, + ) + db.commit() - # 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) + get_sql = text( + """ + SELECT users.*, + user_roles.project_id as project_id, + organisation_managers.organisation_id as created_org, + COALESCE(user_roles.role, 'MAPPER') as project_role + FROM users + LEFT JOIN user_roles ON users.id = user_roles.user_id + LEFT JOIN organisation_managers on users.id = organisation_managers.user_id + WHERE users.id = :user_id; + """ + ) + result = db.execute( + get_sql, + {"user_id": user_data.id}, + ) + db_user = result.first() + + user = { + "id": db_user.id, + "username": db_user.username, + "profile_img": db_user.profile_img, + "role": db_user.role, + "project_id": db_user.project_id, + "project_role": db_user.project_role, + "created_org": db_user.created_org, + } + return user + + except Exception as e: + # Check if the exception is due to username already existing + if 'duplicate key value violates unique constraint "users_username_key"' in str( + e + ): + raise HTTPException( + status_code=400, + detail=f"User with this username {user_data.username} already exists.", + ) from e + else: + raise HTTPException(status_code=400, detail=str(e)) from e + + +@router.get("/me/") async def my_data( db: Session = Depends(database.get_db), user_data: AuthUser = Depends(login_required), -) -> AuthUser: +): """Read access token and get user details from OSM. Args: @@ -175,5 +217,16 @@ async def my_data( Returns: user_data(dict): The dict of user data. """ - await get_or_create_user(db, user_data) + return await get_or_create_user(db, user_data) + + +@router.get("/introspect", response_model=AuthUser) +async def check_login( + db: Session = Depends(database.get_db), + user_data: AuthUser = Depends(login_required), +): + """Verifies the validity of login cookies. + + Returns True if authenticated, False otherwise. + """ return user_data diff --git a/src/backend/app/central/central_crud.py b/src/backend/app/central/central_crud.py index 4d90d58595..505d9b69eb 100644 --- a/src/backend/app/central/central_crud.py +++ b/src/backend/app/central/central_crud.py @@ -27,7 +27,8 @@ from loguru import logger as log from osm_fieldwork.CSVDump import CSVDump from osm_fieldwork.OdkCentral import OdkAppUser, OdkForm, OdkProject -from pyxform.xls2xform import xls2xform_convert +from pyxform.builder import create_survey_element_from_dict +from pyxform.xls2json import parse_file_to_json from sqlalchemy import text from sqlalchemy.orm import Session @@ -52,6 +53,15 @@ def get_odk_project(odk_central: Optional[project_schemas.ODKCentralDecrypted] = try: log.debug(f"Connecting to ODKCentral: url={url} user={user}") project = OdkProject(url, user, pw) + + except ValueError as e: + log.error(e) + raise HTTPException( + status_code=401, + detail=""" + ODK credentials are invalid, or may have been updated. Please update them. + """, + ) from e except Exception as e: log.exception(e) raise HTTPException( @@ -466,14 +476,17 @@ async def read_and_test_xform( else: try: log.debug("Converting xlsform -> xform") - # NOTE do not enable validate=True, as this requires Java installed - xform_bytesio, warnings = xls2xform_convert( - xlsform_path=f"/tmp/form{file_ext}", - validate=False, - xlsform_object=input_data, + json_data = parse_file_to_json( + path="/dummy/path/with/file/ext.xls", + file_object=input_data, + ) + generated_xform = create_survey_element_from_dict(json_data) + # NOTE do not enable validate=True, as this requires Java to be installed + xform_bytesio = BytesIO( + generated_xform.to_xml( + validate=False, + ).encode("utf-8") ) - if warnings: - log.warning(warnings) except Exception as e: log.error(e) msg = f"XLSForm is invalid: {str(e)}" diff --git a/src/backend/app/models/enums.py b/src/backend/app/models/enums.py index 01defea55f..62912f6938 100644 --- a/src/backend/app/models/enums.py +++ b/src/backend/app/models/enums.py @@ -296,3 +296,11 @@ class CommunityType(IntEnum, Enum): NON_PROFIT = 2 UNIVERSITY = 3 OTHER = 4 + + +class ReviewStateEnum(StrEnum, Enum): + """Enum describing review states of submission.""" + + hasissues = "hasIssues" + approved = "approved" + rejected = "rejected" diff --git a/src/backend/app/projects/project_crud.py b/src/backend/app/projects/project_crud.py index e042ca1895..844276c330 100644 --- a/src/backend/app/projects/project_crud.py +++ b/src/backend/app/projects/project_crud.py @@ -19,6 +19,7 @@ import json import os +import subprocess import uuid from asyncio import gather from concurrent.futures import ThreadPoolExecutor, wait @@ -1769,8 +1770,7 @@ def get_project_tiles( tms (str, optional): Default None. Custom TMS provider URL. """ zooms = "12-19" - tiles_path_id = uuid.uuid4() - tiles_dir = f"{TILESDIR}/{tiles_path_id}" + tiles_dir = f"{TILESDIR}/{project_id}" outfile = f"{tiles_dir}/{project_id}_{source}tiles.{output_format}" tile_path_instance = db_models.DbTilesPath( @@ -1815,15 +1815,34 @@ def get_project_tiles( f"xy={False} | " f"tms={tms}" ) - create_basemap_file( - boundary=f"{min_lon},{min_lat},{max_lon},{max_lat}", - outfile=outfile, - zooms=zooms, - outdir=tiles_dir, - source=source, - xy=False, - tms=tms, - ) + + # TODO replace this temp workaround with osm-fieldwork code + # TODO to generate pmtiles directly instead of with go-pmtiles + if output_format == "pmtiles": + create_basemap_file( + boundary=f"{min_lon},{min_lat},{max_lon},{max_lat}", + outfile=outfile.replace("pmtiles", "mbtiles"), + zooms=zooms, + outdir=tiles_dir, + source=source, + xy=False, + tms=tms, + ) + subprocess.call( + "pmtiles convert " f"{outfile.replace('pmtiles', 'mbtiles')} {outfile}", + shell=True, + ) + else: + create_basemap_file( + boundary=f"{min_lon},{min_lat},{max_lon},{max_lat}", + outfile=outfile, + zooms=zooms, + outdir=tiles_dir, + source=source, + xy=False, + tms=tms, + ) + log.info(f"Basemap created for project ID {project_id}: {outfile}") tile_path_instance.status = 4 diff --git a/src/backend/app/projects/project_schemas.py b/src/backend/app/projects/project_schemas.py index ce7cec6e91..9e0a48eee8 100644 --- a/src/backend/app/projects/project_schemas.py +++ b/src/backend/app/projects/project_schemas.py @@ -234,7 +234,7 @@ class ProjectSummary(BaseModel): priority: ProjectPriority = ProjectPriority.MEDIUM priority_str: str = priority.name title: Optional[str] = None - centroid: list[float] + centroid: Optional[list[float]] = None location_str: Optional[str] = None description: Optional[str] = None total_tasks: Optional[int] = None @@ -253,9 +253,11 @@ def from_db_project( ) -> "ProjectSummary": """Generate model from database obj.""" priority = project.priority - centroid_point = read_wkb(project.centroid) - # NOTE format x,y (lon,lat) required for GeoJSON - centroid_coords = [centroid_point.x, centroid_point.y] + centroid_coords = [] + if project.centroid: + centroid_point = read_wkb(project.centroid) + # NOTE format x,y (lon,lat) required for GeoJSON + centroid_coords = [centroid_point.x, centroid_point.y] return cls( id=project.id, diff --git a/src/backend/app/submissions/submission_crud.py b/src/backend/app/submissions/submission_crud.py index 56f35d4b07..2cf4d28d0e 100644 --- a/src/backend/app/submissions/submission_crud.py +++ b/src/backend/app/submissions/submission_crud.py @@ -19,6 +19,7 @@ import concurrent.futures import csv +import hashlib import io import json import os @@ -328,6 +329,13 @@ def update_submission_in_s3( for form in odk_forms if form["lastSubmission"] is not None ] + + review_states = [ + form["reviewStates"] + for form in odk_forms + if form["reviewStates"] is not None + ] + last_submission = ( max( valid_datetimes, @@ -348,11 +356,29 @@ def update_submission_in_s3( s3_project_path = f"/{project.organisation_id}/{project_id}" metadata_s3_path = f"/{s3_project_path}/submissions.meta.json" try: - # Get the last submission date from the metadata + # Get the last submission date and review state from the metadata data = get_obj_from_bucket(settings.S3_BUCKET_NAME, metadata_s3_path) + submission_metadata = json.loads(data.getvalue()) + + zip_file_last_submission = submission_metadata["last_submission"] + zip_file_review_states = ( + submission_metadata["review_states"] + if "review_states" in submission_metadata + else None + ) - zip_file_last_submission = (json.loads(data.getvalue()))["last_submission"] - if last_submission <= zip_file_last_submission: + # converting list into hash str to compare them easily + odk_review_state_hash = hashlib.sha256( + json.dumps(review_states).encode() + ).hexdigest() + s3_review_state_hash = hashlib.sha256( + json.dumps(zip_file_review_states).encode() + ).hexdigest() + + if ( + last_submission <= zip_file_last_submission + and odk_review_state_hash == s3_review_state_hash + ): # Update background task status to COMPLETED update_bg_task_sync = async_to_sync( project_crud.update_background_task_status_in_database @@ -366,6 +392,7 @@ def update_submission_in_s3( # Zip file is outdated, regenerate metadata = { "last_submission": last_submission, + "review_states": review_states, } # Get submissions from ODK Central diff --git a/src/backend/app/submissions/submission_routes.py b/src/backend/app/submissions/submission_routes.py index 3946ea2ff5..7b957d52e2 100644 --- a/src/backend/app/submissions/submission_routes.py +++ b/src/backend/app/submissions/submission_routes.py @@ -21,7 +21,7 @@ import os from typing import Optional -from fastapi import APIRouter, BackgroundTasks, Depends, Query, Response +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Response from fastapi.concurrency import run_in_threadpool from fastapi.responses import FileResponse, JSONResponse from osm_fieldwork.odk_merge import OdkMerge @@ -33,6 +33,7 @@ from app.central import central_crud from app.config import settings from app.db import database, db_models +from app.models.enums import ReviewStateEnum from app.projects import project_crud, project_deps, project_schemas from app.submissions import submission_crud, submission_schemas from app.tasks import tasks_crud @@ -471,3 +472,35 @@ async def task_submissions( response = submission_detail.get("value", [])[0] return response + + +@router.post("/update_review_state/{project_id}") +async def update_review_state( + project_id: int, + instance_id: str, + review_state: ReviewStateEnum, + task_id: int, + db: Session = Depends(database.get_db), +): + """Updates the review state of a project submission. + + Args: + project_id (int): The ID of the project. + instance_id (str): The ID of the submission instance. + review_state (ReviewStateEnum): The new review state to be set. + task_id (int): The ID of the task associated with the submission. + db (Session): The database session dependency. + """ + try: + project = await project_crud.get_project(db, project_id) + odk_creds = await project_deps.get_odk_credentials(db, project_id) + odk_project = central_crud.get_odk_project(odk_creds) + response = odk_project.updateReviewState( + project.odkid, + f"{project.project_name_prefix}_task_{task_id}", + instance_id, + {"reviewState": review_state}, + ) + return response + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) from e diff --git a/src/backend/app/tasks/tasks_crud.py b/src/backend/app/tasks/tasks_crud.py index dafad64a25..55c6c4fb3c 100644 --- a/src/backend/app/tasks/tasks_crud.py +++ b/src/backend/app/tasks/tasks_crud.py @@ -341,28 +341,51 @@ def process_history_entry(history_entry): async def get_project_task_history( project_id: int, - end_date: Optional[datetime], + comment: bool, + end_date: datetime, + task_id: Optional[int], db: Session, -) -> list[db_models.DbTaskHistory]: +): """Retrieves the task history records for a specific project. Args: project_id (int): The ID of the project. + comment (bool): True or False, True to get comments + from the project tasks and False by default for + entire task status history. end_date (datetime, optional): The end date of the task history records to retrieve. + task_id (int): The task_id of the project. db (Session): The database session. Returns: A list of task history records for the specified project. """ - query = db.query(db_models.DbTaskHistory).filter( - db_models.DbTaskHistory.project_id == project_id - ) + query = f"""SELECT * + FROM task_history + WHERE project_id = {project_id} + AND action_date >= '{end_date}' + """ - if end_date: - query = query.filter(db_models.DbTaskHistory.action_date >= end_date) + query += " AND action = 'COMMENT'" if comment else " AND action != 'COMMENT'" - return query.all() + if task_id: + query += f" AND task_id = {task_id}" + + result = db.execute(text(query)).fetchall() + task_history = [ + { + "id": row[0], + "project_id": row[1], + "task_id": row[2], + "action": row[3], + "action_text": row[4], + "action_date": row[5], + "status": None if comment else row[4].split()[5], + } + for row in result + ] + return task_history async def count_validated_and_mapped_tasks( diff --git a/src/backend/app/tasks/tasks_routes.py b/src/backend/app/tasks/tasks_routes.py index 840295fc15..c75e411421 100644 --- a/src/backend/app/tasks/tasks_routes.py +++ b/src/backend/app/tasks/tasks_routes.py @@ -18,7 +18,7 @@ """Routes for FMTM tasks.""" from datetime import datetime, timedelta -from typing import List +from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException from loguru import logger as log @@ -256,9 +256,13 @@ async def task_activity( ) -@router.get("/task_history/", response_model=List[tasks_schemas.TaskHistory]) +@router.get("/task_history/") async def task_history( - project_id: int, days: int = 10, db: Session = Depends(database.get_db) + project_id: int, + days: int = 10, + comment: bool = False, + task_id: Optional[int] = None, + db: Session = Depends(database.get_db), ): """Get the detailed task history for a project. @@ -266,10 +270,16 @@ async def task_history( project_id (int): The ID of the project. days (int): The number of days to consider for the task activity (default: 10). + comment (bool): True or False, True to get comments + from the project tasks and False by default for + entire task status history. + task_id (int): The task_id of the project. 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) + return await tasks_crud.get_project_task_history( + project_id, comment, end_date, task_id, db + ) diff --git a/src/backend/pdm.lock b/src/backend/pdm.lock index dff899275b..a71a4b180f 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:9a77021c0e0e984c129c8223a5266f24537307731a8aeed6d944a837a22a9c55" +content_hash = "sha256:40e828b16d7a8f7e20e3f3815214857dd6f2efa9c005380162ac2e1a87c528e6" [[package]] name = "annotated-types" @@ -112,16 +112,6 @@ files = [ {file = "asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0"}, ] -[[package]] -name = "attrs" -version = "23.2.0" -requires_python = ">=3.7" -summary = "Classes Without Boilerplate" -files = [ - {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, - {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, -] - [[package]] name = "babel" version = "2.14.0" @@ -295,31 +285,6 @@ files = [ {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, ] -[[package]] -name = "click-plugins" -version = "1.1.1" -summary = "An extension module for click to enable registering CLI commands via setuptools entry-points." -dependencies = [ - "click>=4.0", -] -files = [ - {file = "click-plugins-1.1.1.tar.gz", hash = "sha256:46ab999744a9d831159c3411bb0c79346d94a444df9a3a3742e9ed63645f264b"}, - {file = "click_plugins-1.1.1-py2.py3-none-any.whl", hash = "sha256:5d262006d3222f5057fd81e1623d4443e41dcda5dc815c06b442aa3c02889fc8"}, -] - -[[package]] -name = "cligj" -version = "0.7.2" -requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, <4" -summary = "Click params for commmand line interfaces to GeoJSON" -dependencies = [ - "click>=4.0", -] -files = [ - {file = "cligj-0.7.2-py3-none-any.whl", hash = "sha256:c1ca117dbce1fe20a5809dc96f01e1c2840f6dcc939b3ddbb1111bf330ba82df"}, - {file = "cligj-0.7.2.tar.gz", hash = "sha256:a4bc13d623356b373c2c27c53dbd9c68cae5d526270bfa71f6c6fa69669c6b27"}, -] - [[package]] name = "codetiming" version = "1.4.0" @@ -575,36 +540,6 @@ files = [ {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, ] -[[package]] -name = "fiona" -version = "1.9.5" -requires_python = ">=3.7" -summary = "Fiona reads and writes spatial data files" -dependencies = [ - "attrs>=19.2.0", - "certifi", - "click-plugins>=1.0", - "click~=8.0", - "cligj>=0.5", - "setuptools", - "six", -] -files = [ - {file = "fiona-1.9.5-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:5f40a40529ecfca5294260316cf987a0420c77a2f0cf0849f529d1afbccd093e"}, - {file = "fiona-1.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:374efe749143ecb5cfdd79b585d83917d2bf8ecfbfc6953c819586b336ce9c63"}, - {file = "fiona-1.9.5-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:35dae4b0308eb44617cdc4461ceb91f891d944fdebbcba5479efe524ec5db8de"}, - {file = "fiona-1.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:5b4c6a3df53bee8f85bb46685562b21b43346be1fe96419f18f70fa1ab8c561c"}, - {file = "fiona-1.9.5-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:6ad04c1877b9fd742871b11965606c6a52f40706f56a48d66a87cc3073943828"}, - {file = "fiona-1.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9fb9a24a8046c724787719e20557141b33049466145fc3e665764ac7caf5748c"}, - {file = "fiona-1.9.5-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:d722d7f01a66f4ab6cd08d156df3fdb92f0669cf5f8708ddcb209352f416f241"}, - {file = "fiona-1.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:7ede8ddc798f3d447536080c6db9a5fb73733ad8bdb190cb65eed4e289dd4c50"}, - {file = "fiona-1.9.5-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:8b098054a27c12afac4f819f98cb4d4bf2db9853f70b0c588d7d97d26e128c39"}, - {file = "fiona-1.9.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d9f29e9bcbb33232ff7fa98b4a3c2234db910c1dc6c4147fc36c0b8b930f2e0"}, - {file = "fiona-1.9.5-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:f1af08da4ecea5036cb81c9131946be4404245d1b434b5b24fd3871a1d4030d9"}, - {file = "fiona-1.9.5-cp312-cp312-win_amd64.whl", hash = "sha256:c521e1135c78dec0d7774303e5a1b4c62e0efb0e602bb8f167550ef95e0a2691"}, - {file = "fiona-1.9.5.tar.gz", hash = "sha256:99e2604332caa7692855c2ae6ed91e1fffdf9b59449aa8032dd18e070e59a2f7"}, -] - [[package]] name = "flatdict" version = "4.0.1" @@ -615,20 +550,19 @@ files = [ [[package]] name = "fmtm-splitter" -version = "1.1.2" +version = "1.2.1" 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.1.2.tar.gz", hash = "sha256:e6881e04ee2491f7ce3cb50827a2d05d8274228cb0390ddf2e5fbd62f7195c8e"}, - {file = "fmtm_splitter-1.1.2-py3-none-any.whl", hash = "sha256:9ffe3381c1ef435f0a5fa9595515ba4748d822317434c34830740908249048a7"}, + {file = "fmtm-splitter-1.2.1.tar.gz", hash = "sha256:51e79cc8f15e4e2ad571d5bff4403dcfff2c0d0a75f4c0a26c4469557708403c"}, + {file = "fmtm_splitter-1.2.1-py3-none-any.whl", hash = "sha256:80d2ae657a2596668a19f193a874d091b153d203415c9e87d76b7f76810b6180"}, ] [[package]] @@ -668,23 +602,6 @@ files = [ {file = "geojson_pydantic-1.0.1.tar.gz", hash = "sha256:a996ffccd5a016d3acb4a0c6aac941d2c569e3c6163d5ce6a04b61ee131c8f94"}, ] -[[package]] -name = "geopandas" -version = "0.14.3" -requires_python = ">=3.9" -summary = "Geographic pandas extensions" -dependencies = [ - "fiona>=1.8.21", - "packaging", - "pandas>=1.4.0", - "pyproj>=3.3.0", - "shapely>=1.8.0", -] -files = [ - {file = "geopandas-0.14.3-py3-none-any.whl", hash = "sha256:41b31ad39e21bc9e8c4254f78f8dc4ce3d33d144e22e630a00bb336c83160204"}, - {file = "geopandas-0.14.3.tar.gz", hash = "sha256:748af035d4a068a4ae00cab384acb61d387685c833b0022e0729aa45216b23ac"}, -] - [[package]] name = "ghp-import" version = "2.1.0" @@ -1340,20 +1257,20 @@ files = [ [[package]] name = "openpyxl" -version = "3.1.2" +version = "3.0.9" requires_python = ">=3.6" summary = "A Python library to read/write Excel 2010 xlsx/xlsm files" dependencies = [ "et-xmlfile", ] files = [ - {file = "openpyxl-3.1.2-py2.py3-none-any.whl", hash = "sha256:f91456ead12ab3c6c2e9491cf33ba6d08357d802192379bb482f1033ade496f5"}, - {file = "openpyxl-3.1.2.tar.gz", hash = "sha256:a6f5977418eff3b2d5500d54d9db50c8277a368436f4e4f8ddb1be3422870184"}, + {file = "openpyxl-3.0.9-py2.py3-none-any.whl", hash = "sha256:8f3b11bd896a95468a4ab162fc4fcd260d46157155d1f8bfaabb99d88cfcf79f"}, + {file = "openpyxl-3.0.9.tar.gz", hash = "sha256:40f568b9829bf9e446acfffce30250ac1fa39035124d55fc024025c41481c90f"}, ] [[package]] name = "osm-fieldwork" -version = "0.5.3" +version = "0.5.4" requires_python = ">=3.10" summary = "Processing field data from OpenDataKit to OpenStreetMap format." dependencies = [ @@ -1377,8 +1294,8 @@ dependencies = [ "xmltodict>=0.13.0", ] files = [ - {file = "osm-fieldwork-0.5.3.tar.gz", hash = "sha256:95d827d80c53d4a9584cf4ff17153ed540b504dedc73a283e7b45c71a4fa99ea"}, - {file = "osm_fieldwork-0.5.3-py3-none-any.whl", hash = "sha256:64c3209ed35d83d7fd90bed63e731388666c2173663a3cbf6c84185ffd434b18"}, + {file = "osm-fieldwork-0.5.4.tar.gz", hash = "sha256:5ff6b2c53a661836116fab565c6387b947710386b7efeab460917f276da59ab8"}, + {file = "osm_fieldwork-0.5.4-py3-none-any.whl", hash = "sha256:692f9146789b98bb7513aee4fa811afff9529b85fb0672d6d39bfc314bd0ff51"}, ] [[package]] @@ -1974,17 +1891,18 @@ files = [ [[package]] name = "pyxform" -version = "2.0.0" +version = "2.0.2" requires_python = ">=3.7" -git = "https://github.com/hotosm/pyxform" -ref = "feat/xls2xform_convert_bytesio" -revision = "79f0db7403dcd1124c29af5ba5710ab396cfe793" summary = "A Python package to create XForms for ODK Collect." dependencies = [ "defusedxml==0.7.1", - "openpyxl==3.1.2", + "openpyxl==3.0.9", "xlrd==2.0.1", ] +files = [ + {file = "pyxform-2.0.2-py3-none-any.whl", hash = "sha256:a480009d2c75c5ea2aaab509b0c3aa5c751cf285c319f5dbb9cc9dd141957125"}, + {file = "pyxform-2.0.2.tar.gz", hash = "sha256:833cfd0fbae1ccadf12edd9d5af45a11f9b2b12c724bd22467f4b6d324d98843"}, +] [[package]] name = "pyyaml" diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 81e88f9517..d3c263fb6c 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ "geoalchemy2==0.14.2", "geojson==3.1.0", "shapely==2.0.2", - "pyxform @ git+https://github.com/hotosm/pyxform@feat/xls2xform_convert_bytesio", + "pyxform==2.0.2", "sentry-sdk==1.38.0", "py-cpuinfo==9.0.0", "loguru==0.7.2", @@ -47,9 +47,9 @@ dependencies = [ "cryptography>=42.0.1", "defusedxml>=0.7.1", "osm-login-python==1.0.1", - "osm-fieldwork==0.5.3", + "osm-fieldwork==0.5.4", "osm-rawdata==0.2.3", - "fmtm-splitter==1.1.2", + "fmtm-splitter==1.2.1", ] requires-python = ">=3.10" readme = "../../README.md" diff --git a/src/backend/tests/conftest.py b/src/backend/tests/conftest.py index 415f1e0d48..e7f53918dd 100644 --- a/src/backend/tests/conftest.py +++ b/src/backend/tests/conftest.py @@ -99,7 +99,7 @@ async def admin_user(db): ), ) # Upgrade role from default MAPPER (if user already exists) - db_user.role = UserRole.ADMIN + db_user["role"] = UserRole.ADMIN db.commit() return db_user @@ -175,8 +175,8 @@ async def project(db, admin_user, organisation): project_metadata, odkproject["id"], AuthUser( - username=admin_user.username, - id=admin_user.id, + username=admin_user["username"], + id=admin_user["id"], role=UserRole.ADMIN, ), ) diff --git a/src/frontend/index.html b/src/frontend/index.html index 3cd0352ecd..c16bbb1c3b 100755 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -11,6 +11,6 @@
- + diff --git a/src/frontend/package.json b/src/frontend/package.json index e8b60d60db..3e6b874acb 100755 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -96,6 +96,7 @@ "mini-css-extract-plugin": "^2.7.5", "ol-ext": "^4.0.11", "pako": "^2.1.0", + "pmtiles": "^3.0.5", "qrcode-generator": "^1.4.4", "react": "^17.0.2", "react-datepicker": "^6.1.0", diff --git a/src/frontend/pnpm-lock.yaml b/src/frontend/pnpm-lock.yaml index b19ccd3c11..046b40899d 100644 --- a/src/frontend/pnpm-lock.yaml +++ b/src/frontend/pnpm-lock.yaml @@ -155,6 +155,9 @@ dependencies: pako: specifier: ^2.1.0 version: 2.1.0 + pmtiles: + specifier: ^3.0.5 + version: 3.0.5 qrcode-generator: specifier: ^1.4.4 version: 1.4.4 @@ -3693,6 +3696,10 @@ packages: /@types/estree@1.0.2: resolution: {integrity: sha512-VeiPZ9MMwXjO32/Xu7+OwflfmeoRwkE/qzndw42gGtgJwZopBnzy2gD//NN1+go1mADzkDcqf/KnFRSjTJ8xJA==} + /@types/geojson@7946.0.14: + resolution: {integrity: sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==} + dev: false + /@types/hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-YIQtIg4PKr7ZyqNPZObpxfHsHEmuB8dXCxd6qVcGuQVDK2bpsF7bYNnBJ4Nn7giuACZg+WewExgrtAJ3XnA4Xw==} dependencies: @@ -3734,6 +3741,12 @@ packages: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} dev: true + /@types/leaflet@1.9.8: + resolution: {integrity: sha512-EXdsL4EhoUtGm2GC2ZYtXn+Fzc6pluVgagvo2VC1RHWToLGlTRwVYoDpqS/7QXa01rmDyBjJk3Catpf60VMkwg==} + dependencies: + '@types/geojson': 7946.0.14 + dev: false + /@types/node@20.8.3: resolution: {integrity: sha512-jxiZQFpb+NlH5kjW49vXxvxTjeeqlbsnTAdBTKpzEdPs9itay7MscYXz3Fo9VYFEsfQ6LJFitHad3faerLAjCw==} @@ -5605,6 +5618,10 @@ packages: dependencies: reusify: 1.0.4 + /fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + dev: false + /file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -7124,6 +7141,13 @@ packages: pathe: 1.1.1 dev: true + /pmtiles@3.0.5: + resolution: {integrity: sha512-K6NxVvW/vXE3052VZKF2ppyjdyhLx41FidR5yV8L/+El+lcMJpXS0vHBSPFmjdag5zkYv2XGDdq+3VjB2K7l6w==} + dependencies: + '@types/leaflet': 1.9.8 + fflate: 0.8.2 + dev: false + /postcss-import@15.1.0(postcss@8.4.31): resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} diff --git a/src/frontend/prod.dockerfile b/src/frontend/prod.dockerfile index fb2417a368..e69de8acc6 100644 --- a/src/frontend/prod.dockerfile +++ b/src/frontend/prod.dockerfile @@ -10,7 +10,7 @@ ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable RUN pnpm install -ARG NODE_ENV=production +ARG NODE_ENV ENV NODE_ENV ${NODE_ENV} COPY . . RUN pnpm run build --mode ${NODE_ENV} diff --git a/src/frontend/src/App.jsx b/src/frontend/src/App.tsx similarity index 87% rename from src/frontend/src/App.jsx rename to src/frontend/src/App.tsx index 5eb7cbea52..99828ba9ed 100755 --- a/src/frontend/src/App.jsx +++ b/src/frontend/src/App.tsx @@ -2,8 +2,9 @@ import axios from 'axios'; import React, { useEffect } from 'react'; import ReactDOM from 'react-dom'; import { RouterProvider } from 'react-router-dom'; -import { Provider } from 'react-redux'; +import { Provider, useDispatch } from 'react-redux'; import { PersistGate } from 'redux-persist/integration/react'; +import { CommonActions } from '@/store/slices/CommonSlice'; import { store, persistor } from '@/store/Store'; import routes from '@/routes'; @@ -13,6 +14,11 @@ import '@/index.css'; import 'ol/ol.css'; import 'react-loading-skeleton/dist/skeleton.css'; +enum Status { + 'online', + 'offline', +} + // Added Fix of Console Error of MUI Issue const consoleError = console.error; const SUPPRESSED_WARNINGS = [ @@ -51,9 +57,27 @@ axios.interceptors.request.use( ); const GlobalInit = () => { + const dispatch = useDispatch(); + const checkStatus = (status: string) => { + console.log(status); + dispatch( + CommonActions.SetSnackBar({ + open: true, + message: 'Connection Status: ' + status, + variant: status === 'online' ? 'success' : 'error', + duration: 2000, + }), + ); + }; useEffect(() => { + window.addEventListener('offline', () => checkStatus('offline')); + window.addEventListener('online', () => checkStatus('online')); + // Do stuff at init here - return () => {}; + return () => { + window.removeEventListener('offline', () => checkStatus('offline')); + window.removeEventListener('online', () => checkStatus('online')); + }; }, []); return null; // Renders nothing }; diff --git a/src/frontend/src/api/Files.js b/src/frontend/src/api/Files.js index b6747b612c..f9670cf2aa 100755 --- a/src/frontend/src/api/Files.js +++ b/src/frontend/src/api/Files.js @@ -54,3 +54,69 @@ export const ProjectFilesById = (odkToken, projectName, osmUser, taskId) => { }, [taskId]); return { qrcode }; }; + +export async function readFileFromOPFS(filePath) { + const opfsRoot = await navigator.storage.getDirectory(); + const directories = filePath.split('/'); + + let currentDirectoryHandle = opfsRoot; + + // Iterate dirs and get directoryHandles + for (const directory of directories.slice(0, -1)) { + console.log(`Reading OPFS dir: ${directory}`); + try { + currentDirectoryHandle = await currentDirectoryHandle.getDirectoryHandle(directory); + } catch { + return null; // Directory doesn't exist + } + } + + // Get file within final directory handle + try { + const filename = directories.pop(); + console.log(`Getting OPFS file: ${filename}`); + const fileHandle = await currentDirectoryHandle.getFileHandle(filename); + const fileData = await fileHandle.getFile(); // Read the file + return fileData; + } catch { + return null; // File doesn't exist or error occurred + } +} + +export async function writeBinaryToOPFS(filePath, data) { + console.log(`Starting write to OPFS file: ${filePath}`); + + const opfsRoot = await navigator.storage.getDirectory(); + + // Split the filePath into directories and filename + const directories = filePath.split('/'); + const filename = directories.pop(); + + // Start with the root directory handle + let currentDirectoryHandle = opfsRoot; + + // Iterate over directories and create nested directories + for (const directory of directories) { + console.log(`Creating OPFS dir: ${directory}`); + try { + currentDirectoryHandle = await currentDirectoryHandle.getDirectoryHandle(directory, { create: true }); + } catch (error) { + console.error('Error creating directory:', error); + } + } + + // Create the file handle within the last directory + const fileHandle = await currentDirectoryHandle.getFileHandle(filename, { create: true }); + const writable = await fileHandle.createWritable(); + + // Write data to the writable stream + try { + await writable.write(data); + } catch (error) { + console.log(error); + } + + // Close the writable stream + await writable.close(); + console.log(`Finished write to OPFS file: ${filePath}`); +} diff --git a/src/frontend/src/api/OrganisationService.ts b/src/frontend/src/api/OrganisationService.ts index 57f31c2676..aac8b53f9e 100644 --- a/src/frontend/src/api/OrganisationService.ts +++ b/src/frontend/src/api/OrganisationService.ts @@ -48,6 +48,7 @@ export const OrganisationDataService: Function = (url: string) => { const getOrganisationDataResponse = await API.get(url); const response: GetOrganisationDataModel = getOrganisationDataResponse.data; dispatch(OrganisationAction.GetOrganisationsData(response)); + dispatch(OrganisationAction.GetOrganisationDataLoading(false)); } catch (error) { dispatch(OrganisationAction.GetOrganisationDataLoading(false)); if (error.response.status === 401) { @@ -67,6 +68,7 @@ export const MyOrganisationDataService: Function = (url: string) => { const getMyOrganisationDataResponse = await API.get(url); const response: GetOrganisationDataModel[] = getMyOrganisationDataResponse.data; dispatch(OrganisationAction.GetMyOrganisationsData(response)); + dispatch(OrganisationAction.GetMyOrganisationDataLoading(false)); } catch (error) { dispatch(OrganisationAction.GetMyOrganisationDataLoading(false)); } diff --git a/src/frontend/src/api/Project.js b/src/frontend/src/api/Project.js index c9b62d1279..78f5d41b31 100755 --- a/src/frontend/src/api/Project.js +++ b/src/frontend/src/api/Project.js @@ -2,6 +2,8 @@ import { ProjectActions } from '@/store/slices/ProjectSlice'; import { CommonActions } from '@/store/slices/CommonSlice'; import CoreModules from '@/shared/CoreModules'; import { task_priority_str } from '@/types/enums'; +import axios from 'axios'; +import { writeBinaryToOPFS } from '@/api/Files'; export const ProjectById = (existingProjectList, projectId) => { return async (dispatch) => { @@ -42,6 +44,7 @@ export const ProjectById = (existingProjectList, projectId) => { xform_category: projectResp.xform_category, tasks_bad: projectResp.tasks_bad, data_extract_url: projectResp.data_extract_url, + instructions: projectResp?.project_info?.per_task_instructions, }), ); dispatch(ProjectActions.SetProjectDetialsLoading(false)); @@ -141,9 +144,11 @@ export const GenerateProjectTiles = (url, payload) => { const generateProjectTiles = async (url, payload) => { try { const response = await CoreModules.axios.get(url); + console.log(response, 'response-mbtiles'); dispatch(GetTilesList(`${import.meta.env.VITE_API_URL}/projects/tiles_list/${payload}/`)); dispatch(ProjectActions.SetGenerateProjectTilesLoading(false)); } catch (error) { + console.log(error, 'error-mbtiles'); dispatch(ProjectActions.SetGenerateProjectTilesLoading(false)); } finally { dispatch(ProjectActions.SetGenerateProjectTilesLoading(false)); @@ -153,23 +158,42 @@ export const GenerateProjectTiles = (url, payload) => { }; }; -export const DownloadTile = (url, payload) => { +export const DownloadTile = (url, payload, toOpfs = false) => { return async (dispatch) => { dispatch(ProjectActions.SetDownloadTileLoading({ type: payload, loading: true })); - const getDownloadTile = async (url, payload) => { + const getDownloadTile = async (url, payload, toOpfs) => { try { const response = await CoreModules.axios.get(url, { - responseType: 'blob', + responseType: 'arraybuffer', }); // Get filename from content-disposition header + const tileData = response.data; + + if (toOpfs) { + // Copy to OPFS filesystem for offline use + const projectId = payload.id; + const filePath = `${projectId}/all.pmtiles`; + await writeBinaryToOPFS(filePath, tileData); + // Set the OPFS file path to project state + dispatch(ProjectActions.SetProjectOpfsBasemapPath(filePath)); + return; + } + const filename = response.headers['content-disposition'].split('filename=')[1]; + // Create Blob from ArrayBuffer + const blob = new Blob([tileData], { type: response.headers['content-type'] }); + const downloadUrl = URL.createObjectURL(blob); - var a = document.createElement('a'); - a.href = window.URL.createObjectURL(response.data); + const a = document.createElement('a'); + a.href = downloadUrl; a.download = filename; a.click(); + + // Clean up object URL + URL.revokeObjectURL(downloadUrl); + dispatch(ProjectActions.SetDownloadTileLoading({ type: payload, loading: false })); } catch (error) { dispatch(ProjectActions.SetDownloadTileLoading({ type: payload, loading: false })); @@ -177,7 +201,7 @@ export const DownloadTile = (url, payload) => { dispatch(ProjectActions.SetDownloadTileLoading({ type: payload, loading: false })); } }; - await getDownloadTile(url, payload); + await getDownloadTile(url, payload, toOpfs); }; }; @@ -198,3 +222,39 @@ export const GetProjectDashboard = (url) => { await getProjectDashboard(url); }; }; + +export const GetProjectComments = (url) => { + return async (dispatch) => { + const getProjectComments = async (url) => { + try { + dispatch(ProjectActions.SetProjectGetCommentsLoading(true)); + const response = await CoreModules.axios.get(url); + dispatch(ProjectActions.SetProjectCommentsList(response.data)); + dispatch(ProjectActions.SetProjectGetCommentsLoading(false)); + } catch (error) { + dispatch(ProjectActions.SetProjectGetCommentsLoading(false)); + } finally { + dispatch(ProjectActions.SetProjectGetCommentsLoading(false)); + } + }; + await getProjectComments(url); + }; +}; + +export const PostProjectComments = (url, payload) => { + return async (dispatch) => { + const postProjectComments = async (url) => { + try { + dispatch(ProjectActions.SetPostProjectCommentsLoading(true)); + const response = await CoreModules.axios.post(url, payload); + dispatch(ProjectActions.UpdateProjectCommentsList(response.data)); + dispatch(ProjectActions.SetPostProjectCommentsLoading(false)); + } catch (error) { + dispatch(ProjectActions.SetPostProjectCommentsLoading(false)); + } finally { + dispatch(ProjectActions.SetPostProjectCommentsLoading(false)); + } + }; + await postProjectComments(url); + }; +}; diff --git a/src/frontend/src/api/ProjectTaskStatus.js b/src/frontend/src/api/ProjectTaskStatus.js index 22ed5e0e64..2feb3b0207 100755 --- a/src/frontend/src/api/ProjectTaskStatus.js +++ b/src/frontend/src/api/ProjectTaskStatus.js @@ -14,12 +14,14 @@ const UpdateTaskStatus = (url, style, existingData, currentProjectId, feature, m const response = await CoreModules.axios.post(url, body, { params }); const findIndexForUpdation = existingData[index].taskBoundries.findIndex((obj) => obj.id == response.data.id); + console.log(response, 'response'); let project_tasks = [...existingData[index].taskBoundries]; project_tasks[findIndexForUpdation] = { ...response.data, task_status: task_priority_str[response.data.task_status], }; + console.log(project_tasks, 'project_tasks'); let updatedProject = [...existingData]; const finalProjectOBJ = { @@ -29,6 +31,7 @@ const UpdateTaskStatus = (url, style, existingData, currentProjectId, feature, m updatedProject[index] = finalProjectOBJ; dispatch(ProjectActions.SetProjectTaskBoundries(updatedProject)); + console.log(updatedProject, 'updatedProject'); await feature.setStyle(style); dispatch(CommonActions.SetLoading(false)); @@ -41,6 +44,7 @@ const UpdateTaskStatus = (url, style, existingData, currentProjectId, feature, m }), ); } catch (error) { + console.log(error, 'error'); dispatch(CommonActions.SetLoading(false)); dispatch( HomeActions.SetSnackBar({ diff --git a/src/frontend/src/api/Submission.ts b/src/frontend/src/api/Submission.ts index fe96f2c484..6ba74b31dc 100644 --- a/src/frontend/src/api/Submission.ts +++ b/src/frontend/src/api/Submission.ts @@ -8,7 +8,8 @@ export const SubmissionService: Function = (url: string) => { try { const getSubmissionDetailsResponse = await axios.get(url); const response: any = getSubmissionDetailsResponse.data; - dispatch(SubmissionActions.SetSubmissionDetails(response[0].value[0])); + dispatch(SubmissionActions.SetSubmissionDetails(response)); + dispatch(SubmissionActions.SetSubmissionDetailsLoading(false)); } catch (error) { dispatch(SubmissionActions.SetSubmissionDetailsLoading(false)); } finally { diff --git a/src/frontend/src/api/SubmissionService.ts b/src/frontend/src/api/SubmissionService.ts index 2ba55763b7..9d6a6b18fc 100644 --- a/src/frontend/src/api/SubmissionService.ts +++ b/src/frontend/src/api/SubmissionService.ts @@ -1,4 +1,5 @@ import CoreModules from '@/shared/CoreModules'; +import { CommonActions } from '@/store/slices/CommonSlice'; import { ProjectActions } from '@/store/slices/ProjectSlice'; // import { HomeProjectCardModel } from '@/models/home/homeModel'; import { SubmissionActions } from '@/store/slices/SubmissionSlice'; @@ -114,3 +115,37 @@ export const SubmissionTableService: Function = (url: string, payload) => { await fetchSubmissionTable(url, payload); }; }; + +export const UpdateReviewStateService: Function = (url: string) => { + return async (dispatch) => { + const UpdateReviewState = async (url: string) => { + try { + dispatch(SubmissionActions.UpdateReviewStateLoading(true)); + const response = await CoreModules.axios.post(url); + dispatch(SubmissionActions.UpdateSubmissionTableDataReview(response.data)); + dispatch( + SubmissionActions.SetUpdateReviewStatusModal({ + toggleModalStatus: false, + projectId: null, + instanceId: null, + taskId: null, + reviewState: '', + }), + ); + dispatch(SubmissionActions.UpdateReviewStateLoading(false)); + } catch (error) { + dispatch( + CommonActions.SetSnackBar({ + open: true, + message: 'Failed to update review state.', + variant: 'error', + duration: 2000, + }), + ); + dispatch(SubmissionActions.UpdateReviewStateLoading(false)); + } + }; + + await UpdateReviewState(url); + }; +}; diff --git a/src/frontend/src/assets/images/LocationDot.png b/src/frontend/src/assets/images/LocationDot.png new file mode 100644 index 0000000000..3555cf5a30 Binary files /dev/null and b/src/frontend/src/assets/images/LocationDot.png differ diff --git a/src/frontend/src/assets/images/navigation.png b/src/frontend/src/assets/images/navigation.png new file mode 100644 index 0000000000..04170377de Binary files /dev/null and b/src/frontend/src/assets/images/navigation.png differ diff --git a/src/frontend/src/assets/images/navigation.svg b/src/frontend/src/assets/images/navigation.svg new file mode 100644 index 0000000000..e11370e122 --- /dev/null +++ b/src/frontend/src/assets/images/navigation.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/src/frontend/src/assets/images/png bluedot.png b/src/frontend/src/assets/images/png bluedot.png new file mode 100644 index 0000000000..807e3a9a26 Binary files /dev/null and b/src/frontend/src/assets/images/png bluedot.png differ diff --git a/src/frontend/src/assets/images/rednavigationmarker.svg b/src/frontend/src/assets/images/rednavigationmarker.svg new file mode 100644 index 0000000000..1dfb2c3c0e --- /dev/null +++ b/src/frontend/src/assets/images/rednavigationmarker.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/src/components/CreateEditOrganization/validation/OrganizationDetailsValidation.ts b/src/frontend/src/components/CreateEditOrganization/validation/OrganizationDetailsValidation.ts index b2b2c73a74..ae455414b9 100644 --- a/src/frontend/src/components/CreateEditOrganization/validation/OrganizationDetailsValidation.ts +++ b/src/frontend/src/components/CreateEditOrganization/validation/OrganizationDetailsValidation.ts @@ -1,3 +1,4 @@ +import { isInputEmpty } from '@/utilfunctions/commonUtils'; import { isValidUrl } from '@/utilfunctions/urlChecker'; interface OrganisationValues { @@ -31,16 +32,16 @@ interface ValidationErrors { function OrganizationDetailsValidation(values: OrganisationValues) { const errors: ValidationErrors = {}; - if (!values?.name) { + if (isInputEmpty(values?.name)) { errors.name = 'Name is Required.'; } - if (!values?.description) { + if (isInputEmpty(values?.description)) { errors.description = 'Description is Required.'; } if (!values?.id) { - if (!values?.url) { + if (isInputEmpty(values?.url)) { errors.url = 'Organization Url is Required.'; } else if (!isValidUrl(values.url)) { errors.url = 'Invalid URL.'; @@ -54,15 +55,15 @@ function OrganizationDetailsValidation(values: OrganisationValues) { errors.odk_central_url = 'Invalid URL.'; } - if (values?.fillODKCredentials && !values.odk_central_url) { + if (values?.fillODKCredentials && isInputEmpty(values.odk_central_url)) { errors.odk_central_url = 'ODK central URL is Required.'; } - if (values?.fillODKCredentials && !values.odk_central_user) { + if (values?.fillODKCredentials && isInputEmpty(values.odk_central_user)) { errors.odk_central_user = 'ODK central URL is Required.'; } - if (values?.fillODKCredentials && !values.odk_central_password) { + if (values?.fillODKCredentials && isInputEmpty(values.odk_central_password)) { errors.odk_central_password = 'ODK central URL is Required.'; } diff --git a/src/frontend/src/components/GenerateBasemap.jsx b/src/frontend/src/components/GenerateBasemap.jsx index f20ef0b1f7..77386b4436 100644 --- a/src/frontend/src/components/GenerateBasemap.jsx +++ b/src/frontend/src/components/GenerateBasemap.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import CoreModules from '@/shared/CoreModules'; import AssetModules from '@/shared/AssetModules'; +import { CommonActions } from '@/store/slices/CommonSlice'; import environment from '@/environment'; import { DownloadTile, GenerateProjectTiles, GetTilesList } from '@/api/Project'; import { ProjectActions } from '@/store/slices/ProjectSlice'; @@ -29,8 +30,10 @@ const GenerateBasemap = ({ projectInfo }) => { padding: '16px 32px 24px 32px', maxWidth: '1000px', }); - const downloadBasemap = (tileId) => { - dispatch(DownloadTile(`${import.meta.env.VITE_API_URL}/projects/download_tiles/?tile_id=${tileId}`, projectInfo)); + const downloadBasemap = (tileId, toOpfs = false) => { + dispatch( + DownloadTile(`${import.meta.env.VITE_API_URL}/projects/download_tiles/?tile_id=${tileId}`, projectInfo, toOpfs), + ); }; const getTilesList = () => { @@ -253,7 +256,7 @@ const GenerateBasemap = ({ projectInfo }) => { component={CoreModules.Paper} className="scrollbar fmtm-overflow-y-auto fmtm-max-h-[38vh] lg:fmtm-max-h-[45vh] sm:fmtm-mb-5" > - + {/* Id */} @@ -297,18 +300,32 @@ const GenerateBasemap = ({ projectInfo }) => {
- {list.status === 'SUCCESS' ? ( - downloadBasemap(list.id)} - className="fmtm-text-gray-500 hover:fmtm-text-blue-500" - > - ) : ( - <> + {list.status === 'SUCCESS' && ( + <> + downloadBasemap(list.id)} + className="fmtm-text-gray-500 hover:fmtm-text-blue-500" + /> + downloadBasemap(list.id, true)} + className="fmtm-text-red-500 hover:fmtm-text-red-700" + /> + )} {}} + onClick={() => { + dispatch( + CommonActions.SetSnackBar({ + open: true, + message: 'Not implemented', + variant: 'error', + duration: 2000, + }), + ); + }} className="fmtm-text-red-500 hover:fmtm-text-red-700" >
diff --git a/src/frontend/src/components/ManageProject/EditTab/FormUpdateTab.tsx b/src/frontend/src/components/ManageProject/EditTab/FormUpdateTab.tsx index b1f2ddbee4..a326f59271 100644 --- a/src/frontend/src/components/ManageProject/EditTab/FormUpdateTab.tsx +++ b/src/frontend/src/components/ManageProject/EditTab/FormUpdateTab.tsx @@ -19,6 +19,7 @@ const FormUpdateTab = ({ projectId }) => { const [uploadForm, setUploadForm] = useState(null); const [selectedCategory, setSelectedCategory] = useState(null); + const [error, setError] = useState({ formError: '', categoryError: '' }); const formCategoryList = useAppSelector((state) => state.createproject.formCategoryList); const sortedFormCategoryList = formCategoryList.slice().sort((a, b) => a.title.localeCompare(b.title)); @@ -26,13 +27,29 @@ const FormUpdateTab = ({ projectId }) => { dispatch(FormCategoryService(`${import.meta.env.VITE_API_URL}/central/list-forms`)); }, []); + const validateForm = () => { + setError({ formError: '', categoryError: '' }); + let isValid = true; + if (!uploadForm || (uploadForm && uploadForm?.length === 0)) { + setError((prev) => ({ ...prev, formError: 'Form is required.' })); + isValid = false; + } + if (!selectedCategory) { + setError((prev) => ({ ...prev, categoryError: 'Category is required.' })); + isValid = false; + } + return isValid; + }; + const onSave = () => { - dispatch( - PostFormUpdate(`${import.meta.env.VITE_API_URL}/projects/update-form?project_id=${projectId}`, { - category: selectedCategory, - upload: uploadForm && uploadForm?.[0]?.url, - }), - ); + if (validateForm()) { + dispatch( + PostFormUpdate(`${import.meta.env.VITE_API_URL}/projects/update-form?project_id=${projectId}`, { + category: selectedCategory, + upload: uploadForm && uploadForm?.[0]?.url, + }), + ); + } }; return ( @@ -51,6 +68,7 @@ const FormUpdateTab = ({ projectId }) => { }} className="fmtm-max-w-[13.5rem]" /> + {error.categoryError &&

{error.categoryError}

}

The category will be used to set the OpenStreetMap{' '} { {`if uploading the final submissions to OSM.`}

- { - setUploadForm(updatedFiles); - }} - acceptedInput=".xls, .xlsx, .xml" - /> +
+ { + setUploadForm(updatedFiles); + }} + acceptedInput=".xls, .xlsx, .xml" + /> + {error.formError &&

{error.formError}

} +
diff --git a/src/frontend/src/components/ManageProject/EditTab/validation/EditProjectDetailsValidation.ts b/src/frontend/src/components/ManageProject/EditTab/validation/EditProjectDetailsValidation.ts index 53fd3a941d..40760ea11e 100644 --- a/src/frontend/src/components/ManageProject/EditTab/validation/EditProjectDetailsValidation.ts +++ b/src/frontend/src/components/ManageProject/EditTab/validation/EditProjectDetailsValidation.ts @@ -1,3 +1,5 @@ +import { isInputEmpty } from '@/utilfunctions/commonUtils'; + interface ProjectValues { organisation: string; name: string; @@ -20,16 +22,16 @@ const regexForSymbol = /_/g; function EditProjectValidation(values: ProjectValues) { const errors: ValidationErrors = {}; - if (!values?.name) { + if (isInputEmpty(values?.name)) { errors.name = 'Project Name is Required.'; } if (values?.name && regexForSymbol.test(values.name)) { errors.name = 'Project Name should not contain _.'; } - if (!values?.short_description) { + if (isInputEmpty(values?.short_description)) { errors.short_description = 'Short Description is Required.'; } - if (!values?.description) { + if (isInputEmpty(values?.description)) { errors.description = 'Description is Required.'; } diff --git a/src/frontend/src/components/MapComponent/OpenLayersComponent/AsyncPopup/AsyncPopup.tsx b/src/frontend/src/components/MapComponent/OpenLayersComponent/AsyncPopup/AsyncPopup.tsx index 6fd3d09511..0c9be3a556 100644 --- a/src/frontend/src/components/MapComponent/OpenLayersComponent/AsyncPopup/AsyncPopup.tsx +++ b/src/frontend/src/components/MapComponent/OpenLayersComponent/AsyncPopup/AsyncPopup.tsx @@ -50,6 +50,11 @@ const AsyncPopup = ({ element: popupRef.current, positioning: 'center-center', id: popupId, + autoPan: { + animation: { + duration: 250, + }, + }, }); setOverlay(overlayInstance); }, [map, popupRef]); @@ -98,7 +103,7 @@ const AsyncPopup = ({ if (!overlay) return; map.on(showOnHover, (evt) => { - map.updateSize(); + // map.updateSize(); overlay.setPosition(undefined); setPopupHTML(''); setProperties(null); diff --git a/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/index.js b/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/index.js index d1ca68f3ea..6b8a4aad9d 100644 --- a/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/index.js +++ b/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/index.js @@ -3,12 +3,16 @@ import 'ol-layerswitcher/dist/ol-layerswitcher.css'; // import "../../node_modules/ol-layerswitcher/dist/ol-layerswitcher.css"; import LayerGroup from 'ol/layer/Group'; +import Collection from 'ol/Collection.js'; import LayerTile from 'ol/layer/Tile'; import SourceOSM from 'ol/source/OSM'; import LayerSwitcher from 'ol-layerswitcher'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { XYZ } from 'ol/source'; import { useLocation } from 'react-router-dom'; +import DataTile from 'ol/source/DataTile.js'; +import TileLayer from 'ol/layer/WebGLTile.js'; +import { FileSource, PMTiles } from 'pmtiles'; // const mapboxOutdoors = new MapboxVector({ // styleUrl: 'mapbox://styles/geovation/ckpicg3of094w17nyqyd2ziie', @@ -21,6 +25,7 @@ const osm = (visible) => visible: visible === 'osm', source: new SourceOSM(), }); + const none = (visible) => new LayerTile({ title: 'None', @@ -28,6 +33,7 @@ const none = (visible) => visible: visible === 'none', source: null, }); + const bingMaps = (visible) => new LayerTile({ title: 'Satellite', @@ -45,6 +51,7 @@ const bingMaps = (visible) => // crossOrigin: 'Anonymous', // }), }); + const mapboxMap = (visible) => new LayerTile({ title: 'Mapbox Light', @@ -60,6 +67,7 @@ const mapboxMap = (visible) => crossOrigin: 'Anonymous', }), }); + const mapboxOutdoors = (visible) => new LayerTile({ title: 'Mapbox Outdoors', @@ -126,29 +134,64 @@ const monochromeMidNight = (visible = false) => }), }); -const LayerSwitcherControl = ({ map, visible = 'osm' }) => { - useEffect(() => { - if (!map) return; +const pmTileLayer = (pmTileLayerData, visible) => { + function loadImage(src) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.addEventListener('load', () => resolve(img)); + img.addEventListener('error', () => reject(new Error('load failed'))); + img.src = src; + }); + } + + const pmTiles = new PMTiles(new FileSource(pmTileLayerData)); - const baseMaps = new LayerGroup({ + async function loader(z, x, y) { + const response = await pmTiles.getZxy(z, x, y); + const blob = new Blob([response.data]); + const src = URL.createObjectURL(blob); + const image = await loadImage(src); + URL.revokeObjectURL(src); + return image; + } + return new TileLayer({ + title: `${pmTileLayerData.name}`, + type: 'raster pm tiles', + visible: true, + source: new DataTile({ + loader, + wrapX: true, + tileSize: [512, 512], + maxZoom: 22, + attributions: 'Tiles Β© OpenAerialMap', + }), + }); +}; + +const LayerSwitcherControl = ({ map, visible = 'osm', pmTileLayerData = null }) => { + const [basemapLayers, setBasemapLayers] = useState( + new LayerGroup({ title: 'Base maps', - layers: [bingMaps(visible), osm(visible), mapboxMap(visible), mapboxOutdoors(visible), none(visible)], - }); + layers: [ + bingMaps(visible), + osm(visible), + mapboxMap(visible), + mapboxOutdoors(visible), + none(visible), + // pmTileLayer(pmTileLayerData, visible), + ], + }), + ); - const layerSwitcher = new LayerSwitcher({ + useEffect(() => { + if (!map) return; + + const layerSwitcherControl = new LayerSwitcher({ reverse: true, groupSelectStyle: 'group', }); - map.addLayer(baseMaps); - map.addControl(layerSwitcher); - // eslint-disable-next-line consistent-return - return () => { - map.removeLayer(baseMaps); - }; - }, [map, visible]); - - const location = useLocation(); - useEffect(() => { + map.addLayer(basemapLayers); + map.addControl(layerSwitcherControl); const layerSwitcher = document.querySelector('.layer-switcher'); if (layerSwitcher) { const layerSwitcherButton = layerSwitcher.querySelector('button'); @@ -178,7 +221,31 @@ const LayerSwitcherControl = ({ map, visible = 'osm' }) => { layerSwitcher.style.zIndex = '1000'; } } - }, [map]); + // eslint-disable-next-line consistent-return + return () => { + map.removeLayer(basemapLayers); + map.removeControl(layerSwitcherControl); + }; + }, [map, visible]); + + useEffect(() => { + if (!pmTileLayerData) { + return; + } + + const pmTileBaseLayer = pmTileLayer(pmTileLayerData, visible); + + const currentLayers = basemapLayers.getLayers(); + currentLayers.push(pmTileBaseLayer); + basemapLayers.setLayers(currentLayers); + + return () => { + basemapLayers.getLayers().remove(pmTileBaseLayer); + }; + }, [pmTileLayerData]); + + const location = useLocation(); + useEffect(() => {}, [map]); return null; }; diff --git a/src/frontend/src/components/MapComponent/OpenLayersComponent/Layers/VectorLayer.js b/src/frontend/src/components/MapComponent/OpenLayersComponent/Layers/VectorLayer.js index d2c7f6cbc1..1089bb47b7 100644 --- a/src/frontend/src/components/MapComponent/OpenLayersComponent/Layers/VectorLayer.js +++ b/src/frontend/src/components/MapComponent/OpenLayersComponent/Layers/VectorLayer.js @@ -239,6 +239,9 @@ const VectorLayer = ({ declutter: true, }); + const vlFeature = vectorLyr?.getSource().getFeatures(); + if (!vlFeature || (vlFeature && vlFeature?.length === 0)) return; + map.on('click', (evt) => { var pixel = evt.pixel; const feature = map.forEachFeatureAtPixel(pixel, function (feature, layer) { @@ -360,24 +363,6 @@ const VectorLayer = ({ vectorLayer.setProperties(layerProperties); }, [map, vectorLayer, layerProperties]); - useEffect(() => { - if (!map) return; - map.on('pointermove', function (e) { - const pixel = map.getEventPixel(e.originalEvent); - const features = map.getFeaturesAtPixel(pixel); - if (features.length > 0) { - document.getElementById('ol-map').style.cursor = 'pointer'; - } else { - document.getElementById('ol-map').style.cursor = 'default'; - } - }); - - // Clean up - return () => { - map.setTarget(null); - }; - }, [map]); - // style on hover useEffect(() => { if (!map) return null; diff --git a/src/frontend/src/components/MapLegends.jsx b/src/frontend/src/components/MapLegends.jsx index c0c31adf4e..c46b9cce2f 100755 --- a/src/frontend/src/components/MapLegends.jsx +++ b/src/frontend/src/components/MapLegends.jsx @@ -57,13 +57,13 @@ const MapLegends = ({ direction, spacing, iconBtnProps, defaultTheme, valueStatu {...iconBtnProps} color="primary" component="label" - className="fmtm-w-10 fmtm-h-10" + className="fmtm-w-7 fmtm-h-7 sm:fmtm-w-10 sm:fmtm-h-10" > ) : ( - + )} -

{data.value}

+

{data.value}

); }; @@ -89,12 +89,8 @@ const MapLegends = ({ direction, spacing, iconBtnProps, defaultTheme, valueStatu // ); // })} // -
-
- -

Map Legend

-
-
+
+
{MapDetails.map((data, index) => { return ; })} diff --git a/src/frontend/src/components/ProjectDetailsV2/ActivitiesPanel.tsx b/src/frontend/src/components/ProjectDetailsV2/ActivitiesPanel.tsx index 538ea86261..733983eedb 100644 --- a/src/frontend/src/components/ProjectDetailsV2/ActivitiesPanel.tsx +++ b/src/frontend/src/components/ProjectDetailsV2/ActivitiesPanel.tsx @@ -8,13 +8,6 @@ import { ActivitiesCardSkeletonLoader, ShowingCountSkeletonLoader } from '@/comp import { taskHistoryListType } from '@/models/project/projectModel'; import { useAppSelector } from '@/types/reduxTypes'; -const sortByList = [ - { id: 'activities', name: 'Activities' }, - { id: 'date', name: 'Date' }, - { id: 'users', name: 'Users' }, - { id: 'taskid', name: 'Task ID' }, -]; - const ActivitiesPanel = ({ defaultTheme, state, params, map, view, mapDivPostion, states }) => { const displayLimit = 10; const [searchText, setSearchText] = useState(''); @@ -129,56 +122,16 @@ const ActivitiesPanel = ({ defaultTheme, state, params, map, view, mapDivPostion }; return ( -
-
+
+
-
-
-
setShowSortBy(!showShortBy)} - > - -

Sort

-
- {showShortBy && ( -
- {/*

Sort By:

*/} - {sortByList.map((item, i) => ( -
i && - 'fmtm-border-b-[1px] fmtm-border-b-slate-200 sm:fmtm-border-gray-100' - }`} - onClick={() => { - if (item.name === sortBy) { - setSortBy(null); - } else { - setSortBy(item.name); - } - setShowSortBy(false); - }} - > - {/* {sortBy === item.name &&} */} - -
{item.name}
-
- ))} -
- )} -
-
{projectDetailsLoading ? ( @@ -188,13 +141,15 @@ const ActivitiesPanel = ({ defaultTheme, state, params, map, view, mapDivPostion

)}
-
+
{projectDetailsLoading ? (
{Array.from({ length: 10 }).map((_, i) => ( ))}
+ ) : taskHistories?.length === 0 ? ( +

No Task History!

) : (
{taskHistories?.map((taskHistory) => )}
)} diff --git a/src/frontend/src/components/ProjectDetailsV2/Comments.tsx b/src/frontend/src/components/ProjectDetailsV2/Comments.tsx new file mode 100644 index 0000000000..24f84b79f9 --- /dev/null +++ b/src/frontend/src/components/ProjectDetailsV2/Comments.tsx @@ -0,0 +1,158 @@ +import React, { useEffect, useState } from 'react'; +import RichTextEditor from '@/components/common/Editor/Editor'; +import Button from '@/components/common/Button'; +import { PostProjectComments, GetProjectComments } from '@/api/Project'; +import { useDispatch } from 'react-redux'; +import { useParams } from 'react-router-dom'; +import environment from '@/environment'; +import { useAppSelector } from '@/types/reduxTypes'; +import AssetModules from '@/shared/AssetModules'; +import { ProjectCommentsSekeletonLoader } from '@/components/ProjectDetailsV2/SkeletonLoader'; +import { ProjectActions } from '@/store/slices/ProjectSlice'; +import { CommonActions } from '@/store/slices/CommonSlice'; + +const Comments = () => { + const dispatch = useDispatch(); + const params = useParams(); + const [comment, setComment] = useState(''); + const [isEditorEmpty, setIsEditorEmpty] = useState(true); + const projectCommentsList = useAppSelector((state) => state?.project?.projectCommentsList); + const projectGetCommentsLoading = useAppSelector((state) => state?.project?.projectGetCommentsLoading); + const projectPostCommentsLoading = useAppSelector((state) => state?.project?.projectPostCommentsLoading); + const selectedTask = useAppSelector((state) => state.task.selectedTask); + + const projectId = environment.decode(params.id); + + useEffect(() => { + dispatch( + GetProjectComments( + `${import.meta.env.VITE_API_URL}/tasks/task-comments/?project_id=${projectId}&task_id=${selectedTask}`, + ), + ); + }, [selectedTask, projectId]); + + const clearComment = () => { + dispatch(ProjectActions.ClearEditorContent(true)); + setComment(''); + }; + + const handleComment = () => { + if (isEditorEmpty) { + dispatch( + CommonActions.SetSnackBar({ + open: true, + message: 'Empty comment field.', + variant: 'error', + duration: 2000, + }), + ); + return; + } + dispatch( + PostProjectComments(`${import.meta.env.VITE_API_URL}/tasks/task-comments/`, { + task_id: selectedTask, + project_id: projectId, + comment, + }), + ); + clearComment(); + }; + + return ( +
+
+ {projectGetCommentsLoading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) : ( +
+ {projectCommentsList?.length > 0 ? ( +
+ {projectCommentsList?.map((projectComment, i) => ( +
+
+ +
+
+
+

{projectComment?.commented_by}

+
+
+ +
+
+

#{selectedTask}

+
+
+ +
+

+ {projectComment?.created_at?.split('T')[0]} + + {projectComment?.created_at?.split('T')[1].split(':')[0]}: + {projectComment?.created_at?.split('T')[1].split(':')[1]} + +

+
+
+
+
+ ))} +
+ ) : ( +

No Comments!

+ )} +
+ )} +
+
+ setComment(content)} + editable={true} + isEditorEmpty={(status) => setIsEditorEmpty(status)} + /> +
+
+
+
+
+
+
+
+ ); +}; + +export default Comments; diff --git a/src/frontend/src/components/ProjectDetailsV2/Instructions.tsx b/src/frontend/src/components/ProjectDetailsV2/Instructions.tsx new file mode 100644 index 0000000000..dbba6d21f5 --- /dev/null +++ b/src/frontend/src/components/ProjectDetailsV2/Instructions.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import RichTextEditor from '@/components/common/Editor/Editor'; + +const Instructions = ({ instructions }: { instructions: string }) => { + return ( +
+ {instructions ? ( + + ) : ( +

No Instructions!

+ )} +
+ ); +}; + +export default Instructions; diff --git a/src/frontend/src/components/ProjectDetailsV2/MapControlComponent.tsx b/src/frontend/src/components/ProjectDetailsV2/MapControlComponent.tsx index 453794f563..41882956f7 100644 --- a/src/frontend/src/components/ProjectDetailsV2/MapControlComponent.tsx +++ b/src/frontend/src/components/ProjectDetailsV2/MapControlComponent.tsx @@ -10,23 +10,29 @@ const MapControlComponent = ({ map }) => { { id: 'add', icon: , + title: 'Zoom In', }, { id: 'minus', icon: , + title: 'Zoom Out', }, { id: 'currentLocation', icon: , + title: 'My Location', }, { id: 'taskBoundries', icon: , + title: 'Zoom to Project', }, ]; + const dispatch = CoreModules.useAppDispatch(); const [toggleCurrentLoc, setToggleCurrentLoc] = useState(false); const geolocationStatus = useAppSelector((state) => state.project.geolocationStatus); + const handleOnClick = (btnId) => { if (btnId === 'add') { const actualZoom = map.getView().getZoom(); @@ -59,8 +65,11 @@ const MapControlComponent = ({ map }) => { {btnList.map((btn) => (
handleOnClick(btn.id)} + title={btn.title} > {btn.icon}
diff --git a/src/frontend/src/components/ProjectDetailsV2/MobileActivitiesContents.tsx b/src/frontend/src/components/ProjectDetailsV2/MobileActivitiesContents.tsx index ba21bcb9bc..93f2757006 100644 --- a/src/frontend/src/components/ProjectDetailsV2/MobileActivitiesContents.tsx +++ b/src/frontend/src/components/ProjectDetailsV2/MobileActivitiesContents.tsx @@ -10,17 +10,15 @@ const MobileActivitiesContents = ({ map, mainView, mapDivPostion }) => { return (
- - - +
); }; diff --git a/src/frontend/src/components/ProjectDetailsV2/MobileFooter.tsx b/src/frontend/src/components/ProjectDetailsV2/MobileFooter.tsx index 4e3c280d75..247ecbfd8e 100644 --- a/src/frontend/src/components/ProjectDetailsV2/MobileFooter.tsx +++ b/src/frontend/src/components/ProjectDetailsV2/MobileFooter.tsx @@ -9,17 +9,6 @@ const MobileFooter = () => { const mobileFooterSelection = useAppSelector((state) => state.project.mobileFooterSelection); const footerItem = [ - { - id: 'explore', - title: 'Explore', - icon: ( - - ), - }, { id: 'projectInfo', title: 'Project Info', @@ -43,12 +32,23 @@ const MobileFooter = () => { ), }, { - id: 'mapLegend', - title: 'Legend', + id: 'instructions', + title: 'Instructions', + icon: ( + + ), + }, + { + id: 'comment', + title: 'Comment', icon: ( - ), diff --git a/src/frontend/src/components/ProjectDetailsV2/ProjectInfo.tsx b/src/frontend/src/components/ProjectDetailsV2/ProjectInfo.tsx index 6ac4c21390..b622ab78f8 100644 --- a/src/frontend/src/components/ProjectDetailsV2/ProjectInfo.tsx +++ b/src/frontend/src/components/ProjectDetailsV2/ProjectInfo.tsx @@ -93,7 +93,7 @@ const ProjectInfo = () => { alt="Organization Photo" />
-

{projectDashboardDetail?.organisation}

+

{projectDashboardDetail?.organisation_name}

)}
diff --git a/src/frontend/src/components/ProjectDetailsV2/SkeletonLoader.tsx b/src/frontend/src/components/ProjectDetailsV2/SkeletonLoader.tsx index d22843bcf2..483f684732 100644 --- a/src/frontend/src/components/ProjectDetailsV2/SkeletonLoader.tsx +++ b/src/frontend/src/components/ProjectDetailsV2/SkeletonLoader.tsx @@ -33,3 +33,18 @@ export const ShowingCountSkeletonLoader = () => {
); }; + +export const ProjectCommentsSekeletonLoader = () => { + return ( +
+ +
+
+ + +
+ +
+
+ ); +}; diff --git a/src/frontend/src/components/ProjectDetailsV2/TaskSectionPopup.tsx b/src/frontend/src/components/ProjectDetailsV2/TaskSectionPopup.tsx index a5856e49fd..979c459fcf 100644 --- a/src/frontend/src/components/ProjectDetailsV2/TaskSectionPopup.tsx +++ b/src/frontend/src/components/ProjectDetailsV2/TaskSectionPopup.tsx @@ -5,6 +5,7 @@ import { ProjectActions } from '@/store/slices/ProjectSlice'; import environment from '@/environment'; import { ProjectFilesById } from '@/api/Files'; import QrcodeComponent from '@/components/QrcodeComponent'; +import { useNavigate } from 'react-router-dom'; type TaskSectionPopupPropType = { taskId: number | null; @@ -14,6 +15,7 @@ type TaskSectionPopupPropType = { const TaskSectionPopup = ({ taskId, body, feature }: TaskSectionPopupPropType) => { const dispatch = CoreModules.useAppDispatch(); + const navigate = useNavigate(); const [task_status, set_task_status] = useState('READY'); const taskModalStatus = CoreModules.useAppSelector((state) => state.project.taskModalStatus); const params = CoreModules.useParams(); @@ -54,7 +56,7 @@ const TaskSectionPopup = ({ taskId, body, feature }: TaskSectionPopupPropType) =
- { dispatch(ProjectActions.ToggleGenerateMbTilesModalStatus(true)); + dispatch(ProjectActions.ToggleTaskModalStatus(false)); }} - /> - {}} - /> - dispatch(ProjectActions.ToggleTaskModalStatus(false))} - /> + > + +

MB TILES

+
+
+ dispatch(ProjectActions.ToggleTaskModalStatus(false))} + /> +
diff --git a/src/frontend/src/components/ProjectSubmissions/ProjectInfo.tsx b/src/frontend/src/components/ProjectSubmissions/ProjectInfo.tsx index 286938d7bb..689efe8aa2 100644 --- a/src/frontend/src/components/ProjectSubmissions/ProjectInfo.tsx +++ b/src/frontend/src/components/ProjectSubmissions/ProjectInfo.tsx @@ -48,18 +48,22 @@ const ProjectInfo = () => { return (
-
-

- navigate(`/project_details/${encodedId}`)} - > - {projectInfo?.title}{' '} - - > - Dashboard -

-
+ {projectDashboardLoading ? ( + + ) : ( +
+

+ navigate(`/project_details/${encodedId}`)} + > + {projectInfo?.title}{' '} + + > + Dashboard +

+
+ )}
{projectDashboardLoading ? ( diff --git a/src/frontend/src/components/ProjectSubmissions/SubmissionsTable.tsx b/src/frontend/src/components/ProjectSubmissions/SubmissionsTable.tsx index 481541fdef..2cafde9e42 100644 --- a/src/frontend/src/components/ProjectSubmissions/SubmissionsTable.tsx +++ b/src/frontend/src/components/ProjectSubmissions/SubmissionsTable.tsx @@ -18,8 +18,10 @@ import { ConvertXMLToJOSM, getDownloadProjectSubmission, getDownloadProjectSubmi import { Modal } from '@/components/common/Modal'; import { useNavigate, useSearchParams } from 'react-router-dom'; import filterParams from '@/utilfunctions/filterParams'; +import UpdateReviewStatusModal from '@/components/ProjectSubmissions/UpdateReviewStatusModal'; import { projectInfoType } from '@/models/project/projectModel'; import { useAppSelector } from '@/types/reduxTypes'; +import { camelToFlat } from '@/utilfunctions/commonUtils'; type filterType = { task_id: string | null; @@ -240,6 +242,7 @@ const SubmissionsTable = ({ toggleView }) => { dispatch(CoreModules.TaskActions.SetJosmEditorError(null)); }} /> +
{ rowClassName="snRow" dataFormat={(row, _, index) => {index + 1}} /> + ( +
+ {row?.__system?.reviewState ? camelToFlat(row?.__system?.reviewState) : '-'} +
+ )} + /> {updatedSubmissionFormFields?.map((field: any): React.ReactNode | null => { if (field) { return ( @@ -427,10 +440,10 @@ const SubmissionsTable = ({ toggleView }) => { })} ( -
+
{ @@ -438,9 +451,20 @@ const SubmissionsTable = ({ toggleView }) => { }} />{' '} {' '} - {' '} - {' '} - + { + dispatch( + SubmissionActions.SetUpdateReviewStatusModal({ + toggleModalStatus: true, + instanceId: row?.meta?.instanceID, + taskId: row?.phonenumber, + projectId: decodedId, + reviewState: row?.__system?.reviewState, + }), + ); + }} + />
)} /> diff --git a/src/frontend/src/components/ProjectSubmissions/UpdateReviewStatusModal.tsx b/src/frontend/src/components/ProjectSubmissions/UpdateReviewStatusModal.tsx new file mode 100644 index 0000000000..c104ee4ee3 --- /dev/null +++ b/src/frontend/src/components/ProjectSubmissions/UpdateReviewStatusModal.tsx @@ -0,0 +1,143 @@ +import React, { useEffect, useState } from 'react'; +import { Modal } from '@/components/common/Modal'; +import { useDispatch } from 'react-redux'; +import { SubmissionActions } from '@/store/slices/SubmissionSlice'; +import { reviewListType } from '@/models/submission/submissionModel'; +import { UpdateReviewStateService } from '@/api/SubmissionService'; +import TextArea from '../common/TextArea'; +import Button from '../common/Button'; +import { useAppSelector } from '@/types/reduxTypes'; + +const reviewList: reviewListType[] = [ + { + id: 'approved', + title: 'Approved', + className: 'fmtm-bg-[#E7F3E8] fmtm-text-[#40B449] fmtm-border-[#40B449]', + hoverClass: 'hover:fmtm-text-[#40B449] hover:fmtm-border-[#40B449]', + }, + { + id: 'hasIssues', + title: 'Has Issue', + className: 'fmtm-bg-[#E9DFCF] fmtm-text-[#D99F00] fmtm-border-[#D99F00]', + hoverClass: 'hover:fmtm-text-[#D99F00] hover:fmtm-border-[#D99F00]', + }, + { + id: 'rejected', + title: 'Rejected', + className: 'fmtm-bg-[#E8D5D5] fmtm-text-[#D73F37] fmtm-border-[#D73F37]', + hoverClass: 'hover:fmtm-text-[#D73F37] hover:fmtm-border-[#D73F37]', + }, +]; + +const UpdateReviewStatusModal = () => { + const dispatch = useDispatch(); + const [noteComments, setNoteComments] = useState(''); + const [error, setError] = useState(''); + const [reviewStatus, setReviewStatus] = useState(''); + const updateReviewStatusModal = useAppSelector((state) => state.submission.updateReviewStatusModal); + const updateReviewStateLoading = useAppSelector((state) => state.submission.updateReviewStateLoading); + + useEffect(() => { + setReviewStatus(updateReviewStatusModal.reviewState); + }, [updateReviewStatusModal.reviewState]); + + const handleStatusUpdate = () => { + if (!updateReviewStatusModal.instanceId || !updateReviewStatusModal.projectId || !updateReviewStatusModal.taskId) { + return; + } + + if (!reviewStatus) { + setError('Review state needs to be selected.'); + return; + } + dispatch( + UpdateReviewStateService( + `${import.meta.env.VITE_API_URL}/submission/update_review_state/${ + updateReviewStatusModal.projectId + }?project_id=${updateReviewStatusModal.projectId}&task_id=${parseInt( + updateReviewStatusModal.taskId, + )}&instance_id=${updateReviewStatusModal.instanceId}&review_state=${reviewStatus}`, + ), + ); + }; + + return ( + +

Update Review Status

+
+ } + className="!fmtm-w-fit !fmtm-outline-none fmtm-rounded-xl" + description={ +
+
+
+ {reviewList.map((reviewBtn) => ( + + ))} +
+ {error &&

{error}

} +
+