From c4f0cb901315761ba405c6636e03df30095257e9 Mon Sep 17 00:00:00 2001 From: spwoodcock Date: Sat, 26 Oct 2024 01:01:25 +0100 Subject: [PATCH 01/20] build: add migrations to update task_history --> task_events --- src/backend/migrations/006-basemaps-table.sql | 8 - .../migrations/007-rename-task-history.sql | 124 +++++++++++++++ .../migrations/008-recreate-indexes.sql | 57 +++++++ .../migrations/init/fmtm_base_schema.sql | 148 ++++++++++-------- 4 files changed, 268 insertions(+), 69 deletions(-) create mode 100644 src/backend/migrations/007-rename-task-history.sql create mode 100644 src/backend/migrations/008-recreate-indexes.sql diff --git a/src/backend/migrations/006-basemaps-table.sql b/src/backend/migrations/006-basemaps-table.sql index 522a5a9b6..23c63c47d 100644 --- a/src/backend/migrations/006-basemaps-table.sql +++ b/src/backend/migrations/006-basemaps-table.sql @@ -4,7 +4,6 @@ -- * Update default background task status to 'PENDING'. -- * Update background_tasks.id --> UUID type. -- * Update basemaps.id --> UUID type. --- * Also add a composite index to task_history on task_id and action_date -- Start a transaction BEGIN; @@ -77,12 +76,5 @@ BEGIN END IF; END $$; --- Create extra index on task_history - -CREATE INDEX IF NOT EXISTS idx_task_history_date -ON public.task_history USING btree ( - task_id, action_date -); - -- Commit the transaction COMMIT; diff --git a/src/backend/migrations/007-rename-task-history.sql b/src/backend/migrations/007-rename-task-history.sql new file mode 100644 index 000000000..7b7f60f22 --- /dev/null +++ b/src/backend/migrations/007-rename-task-history.sql @@ -0,0 +1,124 @@ +-- ## Migration to: +-- * Rename table task_history --> task_events. +-- * Rename columns https://github.com/hotosm/fmtm/issues/1610 + +-- Start a transaction +BEGIN; + + +-- Rename table +ALTER TABLE IF EXISTS public.task_history RENAME TO task_events; + + + +-- Create new enums + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'taskevent') THEN + CREATE TYPE public.taskevent AS ENUM ( + 'MAP', + 'FINISH', + 'VALIDATE', + 'GOOD', + 'BAD', + 'SPLIT', + 'GROUP', + 'ASSIGN', + 'COMMENT' + ); + END IF; +END $$; +ALTER TYPE public.taskevent OWNER TO fmtm; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'entityevent') THEN + CREATE TYPE public.entitystate AS ENUM ( + 'READY', + 'OPEN_IN_ODK', + 'SURVEY_SUBMITTED', + 'MARKED_BAD' + ); + END IF; +END $$; +ALTER TYPE public.entitystate OWNER TO fmtm; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'mappingstate') THEN + CREATE TYPE public.mappingstate AS ENUM ( + 'UNLOCKED_TO_MAP', + 'LOCKED_FOR_MAPPING', + 'UNLOCKED_TO_VALIDATE', + 'LOCKED_FOR_VALIDATION', + 'UNLOCKED_DONE' + ); + END IF; +END $$; +ALTER TYPE public.mappingstate OWNER TO fmtm; + + + +-- Update task_events field names and types + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'task_events' AND column_name = 'action') THEN + ALTER TABLE public.task_events RENAME COLUMN action TO event; + -- Change from taskaction --> taskevent + ALTER TABLE task_events + ALTER COLUMN event TYPE public.taskevent + USING CASE event + WHEN 'RELEASED_FOR_MAPPING' THEN 'BAD'::public.taskevent + WHEN 'LOCKED_FOR_MAPPING' THEN 'MAP'::public.taskevent + WHEN 'MARKED_MAPPED' THEN 'FINISH'::public.taskevent + WHEN 'LOCKED_FOR_VALIDATION' THEN 'VALIDATE'::public.taskevent + WHEN 'VALIDATED' THEN 'GOOD'::public.taskevent + WHEN 'MARKED_INVALID' THEN 'BAD'::public.taskevent + WHEN 'MARKED_BAD' THEN 'BAD'::public.taskevent + WHEN 'SPLIT_NEEDED' THEN 'SPLIT'::public.taskevent + WHEN 'RECREATED' THEN 'BAD'::public.taskevent + WHEN 'COMMENT' THEN 'COMMENT'::public.taskevent + ELSE NULL + END; + ALTER TABLE public.task_events RENAME COLUMN action_text TO comment; + ALTER TABLE public.task_events RENAME COLUMN action_date TO created_at; + END IF; +END $$; + + + +-- Drop old enums + +DROP TYPE IF EXISTS public.taskaction; +-- Note this no longer used +DROP TYPE IF EXISTS public.taskstatus; + + + +-- Add task_events foreign keys + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_projects') THEN + ALTER TABLE ONLY public.task_events + ADD CONSTRAINT fk_projects FOREIGN KEY (project_id) + REFERENCES public.projects (id); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_project_task_id') THEN + ALTER TABLE ONLY public.task_events + ADD CONSTRAINT fk_project_task_id FOREIGN KEY (task_id, project_id) + REFERENCES public.tasks (id, project_id); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_users') THEN + ALTER TABLE ONLY public.task_events + ADD CONSTRAINT fk_users FOREIGN KEY (user_id) + REFERENCES public.users (id); + END IF; +END $$; + +-- Commit the transaction +COMMIT; diff --git a/src/backend/migrations/008-recreate-indexes.sql b/src/backend/migrations/008-recreate-indexes.sql new file mode 100644 index 000000000..ab59ab972 --- /dev/null +++ b/src/backend/migrations/008-recreate-indexes.sql @@ -0,0 +1,57 @@ +-- ## Migration to: +-- * Drop and recreate some indexes. +-- * Add some new indexes for task_events. + + +-- Start a transaction +BEGIN; + +-- Drop some existing indexes + +DROP INDEX IF EXISTS idx_geometry; +DROP INDEX IF EXISTS ix_projects_mapper_level; +DROP INDEX IF EXISTS ix_projects_organisation_id; +DROP INDEX IF EXISTS ix_tasks_project_id; +DROP INDEX IF EXISTS ix_users_id; +DROP INDEX IF EXISTS idx_task_history_composite; +DROP INDEX IF EXISTS idx_task_history_project_id_user_id; +DROP INDEX IF EXISTS ix_task_history_project_id; +DROP INDEX IF EXISTS ix_task_history_user_id; +DROP INDEX IF EXISTS idx_task_history_date; + +-- Create new indexes +CREATE INDEX IF NOT EXISTS idx_projects_mapper_level +ON public.projects USING btree ( + mapper_level +); +CREATE INDEX IF NOT EXISTS idx_projects_organisation_id +ON public.projects USING btree ( + organisation_id +); +CREATE INDEX IF NOT EXISTS idx_tasks_composite +ON public.tasks USING btree ( + id, project_id +); +CREATE INDEX IF NOT EXISTS idx_task_event_composite +ON public.task_events USING btree ( + task_id, project_id +); +CREATE INDEX IF NOT EXISTS idx_task_event_project_user +ON public.task_events USING btree ( + user_id, project_id +); +CREATE INDEX IF NOT EXISTS idx_task_event_project_id +ON public.task_events USING btree ( + task_id, project_id +); +CREATE INDEX IF NOT EXISTS idx_task_event_user_id +ON public.task_events USING btree ( + task_id, user_id +); +CREATE INDEX IF NOT EXISTS idx_task_event_date +ON public.task_events USING btree ( + task_id, created_at +); + +-- 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 19089cc0e..e29ce7c52 100644 --- a/src/backend/migrations/init/fmtm_base_schema.sql +++ b/src/backend/migrations/init/fmtm_base_schema.sql @@ -80,33 +80,6 @@ CREATE TYPE public.projectstatus AS ENUM ( ); ALTER TYPE public.projectstatus OWNER TO fmtm; -CREATE TYPE public.taskaction AS ENUM ( - 'RELEASED_FOR_MAPPING', - 'LOCKED_FOR_MAPPING', - 'MARKED_MAPPED', - 'LOCKED_FOR_VALIDATION', - 'VALIDATED', - 'MARKED_INVALID', - 'MARKED_BAD', - 'SPLIT_NEEDED', - 'RECREATED', - 'COMMENT' -); -ALTER TYPE public.taskaction OWNER TO fmtm; - -CREATE TYPE public.taskstatus AS ENUM ( - 'READY', - 'LOCKED_FOR_MAPPING', - 'MAPPED', - 'LOCKED_FOR_VALIDATION', - 'VALIDATED', - 'INVALIDATED', - 'BAD', - 'SPLIT', - 'ARCHIVED' -); -ALTER TYPE public.taskstatus OWNER TO fmtm; - CREATE TYPE public.userrole AS ENUM ( 'READ_ONLY', 'MAPPER', @@ -146,6 +119,37 @@ CREATE TYPE public.communitytype AS ENUM ( ); ALTER TYPE public.communitytype OWNER TO fmtm; +CREATE TYPE public.taskevent AS ENUM ( + 'MAP', + 'FINISH', + 'VALIDATE', + 'GOOD', + 'BAD', + 'SPLIT', + 'GROUP', + 'ASSIGN', + 'COMMENT' +); +ALTER TYPE public.taskevent OWNER TO fmtm; + +CREATE TYPE public.mappingstate AS ENUM ( + 'UNLOCKED_TO_MAP', + 'LOCKED_FOR_MAPPING', + 'UNLOCKED_TO_VALIDATE', + 'LOCKED_FOR_VALIDATION', + 'UNLOCKED_DONE' +); +ALTER TYPE public.mappingstate OWNER TO fmtm; + +CREATE TYPE public.entitystate AS ENUM ( + 'READY', + 'OPEN_IN_ODK', + 'SURVEY_SUBMITTED', + 'MARKED_BAD' +); +ALTER TYPE public.entitystate OWNER TO fmtm; + + -- Extra SET default_tablespace = ''; @@ -217,6 +221,7 @@ CACHE 1; ALTER TABLE public.organisations_id_seq OWNER TO fmtm; ALTER SEQUENCE public.organisations_id_seq OWNED BY public.organisations.id; + CREATE TABLE public.projects ( id integer NOT NULL, organisation_id integer, @@ -266,18 +271,17 @@ ALTER TABLE public.projects_id_seq OWNER TO fmtm; ALTER SEQUENCE public.projects_id_seq OWNED BY public.projects.id; --- TODO SQL rename this table & add foreign keys back in --- TODO SQL Also ensure we have an index -CREATE TABLE public.task_history ( - event_id UUID NOT NULL, +CREATE TABLE public.task_events ( + event_id UUID, project_id integer, - task_id integer NOT NULL, - action public.taskaction NOT NULL, - action_text character varying, - action_date timestamp with time zone NOT NULL DEFAULT now(), - user_id integer NOT NULL + task_id integer, + user_id integer, + event public.taskevent, + state public.mappingstate, + comment text, + created_at timestamp without time zone NOT NULL DEFAULT now() ); -ALTER TABLE public.task_history OWNER TO fmtm; +ALTER TABLE public.task_events OWNER TO fmtm; CREATE TABLE public.tasks ( @@ -408,8 +412,8 @@ ADD CONSTRAINT organisations_slug_key UNIQUE (slug); ALTER TABLE ONLY public.projects ADD CONSTRAINT projects_pkey PRIMARY KEY (id); -ALTER TABLE ONLY public.task_history -ADD CONSTRAINT task_history_pkey PRIMARY KEY (event_id); +ALTER TABLE ONLY public.task_events +ADD CONSTRAINT task_events_pkey PRIMARY KEY (event_id); ALTER TABLE ONLY public.tasks ADD CONSTRAINT tasks_pkey PRIMARY KEY (id, project_id); @@ -434,39 +438,46 @@ ADD CONSTRAINT submission_photos_pkey PRIMARY KEY (id); -- Indexing -CREATE INDEX idx_geometry ON public.projects USING gist (outline); CREATE INDEX idx_projects_outline ON public.projects USING gist (outline); -CREATE INDEX idx_task_history_composite ON public.task_history USING btree ( - task_id, project_id -); -CREATE INDEX idx_task_history_project_id_user_id ON public.task_history -USING btree ( - user_id, project_id -); -CREATE INDEX ix_task_history_project_id ON public.task_history USING btree ( - project_id -); -CREATE INDEX ix_task_history_user_id ON public.task_history USING btree ( - user_id -); -CREATE INDEX idx_task_history_date ON public.task_history USING btree ( - task_id, action_date -); -CREATE INDEX idx_tasks_outline ON public.tasks USING gist (outline); -CREATE INDEX ix_projects_mapper_level ON public.projects USING btree ( +CREATE INDEX IF NOT EXISTS idx_projects_mapper_level +ON public.projects USING btree ( mapper_level ); -CREATE INDEX ix_projects_organisation_id ON public.projects USING btree ( +CREATE INDEX IF NOT EXISTS idx_projects_organisation_id +ON public.projects USING btree ( organisation_id ); -CREATE INDEX ix_tasks_project_id ON public.tasks USING btree (project_id); -CREATE INDEX ix_users_id ON public.users USING btree (id); +CREATE INDEX idx_tasks_outline ON public.tasks USING gist (outline); +CREATE INDEX IF NOT EXISTS idx_tasks_composite +ON public.tasks USING btree ( + id, project_id +); 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 ); +CREATE INDEX IF NOT EXISTS idx_task_event_composite +ON public.task_events USING btree ( + task_id, project_id +); +CREATE INDEX IF NOT EXISTS idx_task_event_project_user +ON public.task_events USING btree ( + user_id, project_id +); +CREATE INDEX IF NOT EXISTS idx_task_event_project_id +ON public.task_events USING btree ( + task_id, project_id +); +CREATE INDEX IF NOT EXISTS idx_task_event_user_id +ON public.task_events USING btree ( + task_id, user_id +); +CREATE INDEX IF NOT EXISTS idx_task_history_date +ON public.task_history USING btree ( + task_id, created_at +); -- Foreign keys @@ -493,6 +504,21 @@ ADD CONSTRAINT tasks_project_id_fkey FOREIGN KEY ( project_id ) REFERENCES public.projects (id); +ALTER TABLE ONLY public.task_events +ADD CONSTRAINT fk_projects FOREIGN KEY ( + project_id +) REFERENCES public.projects (id); + +ALTER TABLE ONLY public.task_events +ADD CONSTRAINT fk_project_task_id FOREIGN KEY ( + task_id, project_id +) REFERENCES public.tasks (id, project_id); + +ALTER TABLE ONLY public.task_events +ADD CONSTRAINT fk_users FOREIGN KEY ( + user_id +) REFERENCES public.users (id); + ALTER TABLE ONLY public.user_roles ADD CONSTRAINT user_roles_project_id_fkey FOREIGN KEY ( project_id From bd0c727af2b30414ffb3b4a33e1cf6b67c8b4b1f Mon Sep 17 00:00:00 2001 From: spwoodcock Date: Sat, 26 Oct 2024 01:49:21 +0100 Subject: [PATCH 02/20] refactor(backend): wip update to use TaskEvent / State enums --- src/backend/app/central/central_crud.py | 8 +- src/backend/app/central/central_schemas.py | 16 +- src/backend/app/db/enums.py | 144 +++++++++--------- src/backend/app/db/models.py | 56 +++---- src/backend/app/projects/project_crud.py | 2 +- src/backend/app/tasks/task_crud.py | 24 +-- src/backend/app/tasks/task_routes.py | 16 +- src/backend/app/tasks/task_schemas.py | 24 +-- .../migrations/007-rename-task-history.sql | 2 +- .../migrations/init/fmtm_base_schema.sql | 2 +- src/backend/tests/conftest.py | 10 +- src/backend/tests/test_projects_routes.py | 6 +- src/backend/tests/test_task_routes.py | 8 +- 13 files changed, 156 insertions(+), 162 deletions(-) diff --git a/src/backend/app/central/central_crud.py b/src/backend/app/central/central_crud.py index 47e7af8e6..6bf891633 100644 --- a/src/backend/app/central/central_crud.py +++ b/src/backend/app/central/central_crud.py @@ -33,7 +33,7 @@ from app.central import central_deps, central_schemas from app.config import settings -from app.db.enums import EntityStatus, HTTPStatus +from app.db.enums import EntityState, HTTPStatus from app.db.models import DbXLSForm from app.db.postgis_utils import ( geojson_to_javarosa_geom, @@ -511,7 +511,7 @@ async def feature_geojson_to_entity_dict( properties = { str(key): str(value) for key, value in feature.get("properties", {}).items() } - # Set to TaskStatus enum READY value (0) + # Set to MappingState enum READY value (0) properties["status"] = "0" task_id = properties.get("task_id") @@ -769,7 +769,7 @@ async def update_entity_mapping_status( odk_id: int, entity_uuid: str, label: str, - status: EntityStatus, + status: EntityState, dataset_name: str = "features", ) -> dict: """Update the Entity mapping status. @@ -781,7 +781,7 @@ async def update_entity_mapping_status( odk_id (str): The project ID in ODK Central. entity_uuid (str): The unique entity UUID for ODK Central. label (str): New label, with emoji prepended for status. - status (EntityStatus): New EntityStatus to assign, in string form. + status (EntityState): New EntityState to assign, in string form. dataset_name (str): Override the default dataset / Entity list name 'features'. Returns: diff --git a/src/backend/app/central/central_schemas.py b/src/backend/app/central/central_schemas.py index 62faa4f33..0bb8b10f2 100644 --- a/src/backend/app/central/central_schemas.py +++ b/src/backend/app/central/central_schemas.py @@ -26,7 +26,7 @@ from pydantic.functional_validators import field_validator, model_validator from app.config import HttpUrlStr, decrypt_value, encrypt_value -from app.db.enums import EntityStatus +from app.db.enums import EntityState class ODKCentral(BaseModel): @@ -225,7 +225,7 @@ class EntityMappingStatus(EntityOsmID, EntityTaskID): """The status for mapping an Entity/feature.""" updatedAt: Optional[str] = Field(exclude=True) # noqa: N815 - status: Optional[EntityStatus] = None + status: Optional[EntityState] = None @computed_field @property @@ -238,18 +238,18 @@ class EntityMappingStatusIn(BaseModel): """Update the mapping status for an Entity.""" entity_id: str - status: EntityStatus + status: EntityState label: str @field_validator("label", mode="before") @classmethod def append_status_emoji(cls, value: str, info: ValidationInfo) -> str: """Add 🔒 (locked), ✅ (complete) or ❌ (invalid) emojis.""" - status = info.data.get("status", EntityStatus.UNLOCKED.value) + status = info.data.get("status", EntityState.READY.value) emojis = { - str(EntityStatus.LOCKED.value): "🔒", - str(EntityStatus.MAPPED.value): "✅", - str(EntityStatus.BAD.value): "❌", + str(EntityState.OPEN_IN_ODK.value): "🔒", + str(EntityState.SURVEY_SUBMITTED.value): "✅", + str(EntityState.MARKED_BAD.value): "❌", } # Remove any existing emoji at the start of the label @@ -265,6 +265,6 @@ def append_status_emoji(cls, value: str, info: ValidationInfo) -> str: @field_validator("status", mode="after") @classmethod - def integer_status_to_string(cls, value: EntityStatus) -> str: + def integer_status_to_string(cls, value: EntityState) -> str: """Convert integer status to string for ODK Entity data.""" return str(value.value) diff --git a/src/backend/app/db/enums.py b/src/backend/app/db/enums.py index 6773bf032..6a8f0fa12 100644 --- a/src/backend/app/db/enums.py +++ b/src/backend/app/db/enums.py @@ -110,51 +110,57 @@ class MappingLevel(StrEnum, Enum): ADVANCED = "ADVANCED" -class TaskStatus(StrEnum, Enum): - """Available Task Statuses.""" +class TaskEvent(StrEnum, Enum): + """Task events via API. + + `map` -- Set to *locked for mapping*, i.e. mapping in progress. + `finish` -- Set to *unlocked to validate*, i.e. is mapped. + `validate` -- Request recent task ready to be validate. + `good` -- Set the state to *unlocked done*. + `bad` -- Set the state *unlocked to map* again, to be mapped once again. + `split` -- Set the state *unlocked done* then generate additional + subdivided task areas. + `merge` -- Set the state *unlocked done* then generate additional + merged task area. + `assign` -- For a requester user to assign a task to another user. + Set the state *locked for mapping* passing in the required user id. + Also notify the user they should map the area. + `comment` -- Keep the state the same, but simply add a comment. + """ - READY = "READY" - LOCKED_FOR_MAPPING = "LOCKED_FOR_MAPPING" - MAPPED = "MAPPED" - LOCKED_FOR_VALIDATION = "LOCKED_FOR_VALIDATION" - VALIDATED = "VALIDATED" - INVALIDATED = "INVALIDATED" - BAD = "BAD" # Task cannot be mapped - SPLIT = "SPLIT" # Task has been split - ARCHIVED = "ARCHIVED" # When renew replacement task has been uploaded + MAP = "MAP" + FINISH = "FINISH" + VALIDATE = "VALIDATE" + GOOD = "GOOD" + BAD = "BAD" + SPLIT = "SPLIT" + MERGE = "MERGE" + ASSIGN = "ASSIGN" + COMMENT = "COMMENT" -class TaskAction(StrEnum, Enum): - """All possible task actions, recorded in task history.""" +class MappingState(StrEnum, Enum): + """State options for tasks in FMTM.""" - RELEASED_FOR_MAPPING = "RELEASED_FOR_MAPPING" + UNLOCKED_TO_MAP = "UNLOCKED_TO_MAP" LOCKED_FOR_MAPPING = "LOCKED_FOR_MAPPING" - MARKED_MAPPED = "MARKED_MAPPED" + UNLOCKED_TO_VALIDATE = "UNLOCKED_TO_VALIDATE" LOCKED_FOR_VALIDATION = "LOCKED_FOR_VALIDATION" - VALIDATED = "VALIDATED" - MARKED_INVALID = "MARKED_INVALID" - MARKED_BAD = "MARKED_BAD" # Task cannot be mapped - SPLIT_NEEDED = "SPLIT_NEEDED" # Task needs split - RECREATED = "RECREATED" - COMMENT = "COMMENT" + UNLOCKED_DONE = "UNLOCKED_DONE" -class EntityStatus(IntEnum, Enum): - """Statuses for Entities in ODK. +class EntityState(IntEnum, Enum): + """State options for Entities in ODK. NOTE here we started with int enums and it's hard to migrate. NOTE we will continue to use int values in the form. + NOTE we keep BAD=6 for legacy reasons too. """ - UNLOCKED = 0 - LOCKED = 1 - MAPPED = 2 - BAD = 6 - # Should we also add extra statuses? - # LUMPED - # SPLIT - # VALIDATED - # INVALIDATED + READY = 0 + OPEN_IN_ODK = 1 + SURVEY_SUBMITTED = 2 + MARKED_BAD = 6 class TaskType(StrEnum, Enum): @@ -243,47 +249,47 @@ class XLSFormType(StrEnum, Enum): # waterways = "waterways" -def get_action_for_status_change(task_status: TaskStatus) -> TaskAction: +def get_action_for_status_change(task_state: MappingState) -> TaskEvent: """Update task action inferred from previous state.""" - match task_status: - case TaskStatus.READY: - return TaskAction.RELEASED_FOR_MAPPING - case TaskStatus.LOCKED_FOR_MAPPING: - return TaskAction.LOCKED_FOR_MAPPING - case TaskStatus.MAPPED: - return TaskAction.MARKED_MAPPED - case TaskStatus.LOCKED_FOR_VALIDATION: - return TaskAction.LOCKED_FOR_VALIDATION - case TaskStatus.VALIDATED: - return TaskAction.VALIDATED - case TaskStatus.BAD: - return TaskAction.MARKED_BAD - case TaskStatus.SPLIT: - return TaskAction.SPLIT_NEEDED - case TaskStatus.INVALIDATED: - return TaskAction.MARKED_INVALID + match task_state: + case MappingState.READY: + return TaskEvent.RELEASED_FOR_MAPPING + case MappingState.LOCKED_FOR_MAPPING: + return TaskEvent.LOCKED_FOR_MAPPING + case MappingState.MAPPED: + return TaskEvent.MARKED_MAPPED + case MappingState.LOCKED_FOR_VALIDATION: + return TaskEvent.LOCKED_FOR_VALIDATION + case MappingState.VALIDATED: + return TaskEvent.VALIDATED + case MappingState.BAD: + return TaskEvent.MARKED_BAD + case MappingState.SPLIT: + return TaskEvent.SPLIT_NEEDED + case MappingState.INVALIDATED: + return TaskEvent.MARKED_INVALID case _: - return TaskAction.RELEASED_FOR_MAPPING + return TaskEvent.RELEASED_FOR_MAPPING -def get_status_for_action(task_action: TaskAction) -> TaskStatus: +def get_status_for_action(task_action: TaskEvent) -> MappingState: """Get the task status inferred from the action.""" match task_action: - case TaskAction.RELEASED_FOR_MAPPING: - return TaskStatus.READY - case TaskAction.LOCKED_FOR_MAPPING: - return TaskStatus.LOCKED_FOR_MAPPING - case TaskAction.MARKED_MAPPED: - return TaskStatus.MAPPED - case TaskAction.LOCKED_FOR_VALIDATION: - return TaskStatus.LOCKED_FOR_VALIDATION - case TaskAction.VALIDATED: - return TaskStatus.VALIDATED - case TaskAction.MARKED_BAD: - return TaskStatus.BAD - case TaskAction.SPLIT_NEEDED: - return TaskStatus.SPLIT - case TaskAction.MARKED_INVALID: - return TaskStatus.INVALIDATED + case TaskEvent.RELEASED_FOR_MAPPING: + return MappingState.READY + case TaskEvent.LOCKED_FOR_MAPPING: + return MappingState.LOCKED_FOR_MAPPING + case TaskEvent.MARKED_MAPPED: + return MappingState.MAPPED + case TaskEvent.LOCKED_FOR_VALIDATION: + return MappingState.LOCKED_FOR_VALIDATION + case TaskEvent.VALIDATED: + return MappingState.VALIDATED + case TaskEvent.MARKED_BAD: + return MappingState.BAD + case TaskEvent.SPLIT_NEEDED: + return MappingState.SPLIT + case TaskEvent.MARKED_INVALID: + return MappingState.INVALIDATED case _: - return TaskStatus.READY + return MappingState.READY diff --git a/src/backend/app/db/models.py b/src/backend/app/db/models.py index ce2ea2ca9..0baec26f4 100644 --- a/src/backend/app/db/models.py +++ b/src/backend/app/db/models.py @@ -43,12 +43,13 @@ CommunityType, HTTPStatus, MappingLevel, + MappingState, OrganisationType, ProjectPriority, ProjectRole, ProjectStatus, ProjectVisibility, - TaskAction, + TaskEvent, TaskSplitType, UserRole, XLSFormType, @@ -590,20 +591,21 @@ async def all( return [{"id": form.id, "title": form.title} for form in forms] -class DbTaskHistory(BaseModel): - """Table task_history. +class DbTaskEvent(BaseModel): + """Table task_events. Task events such as locking, marking mapped, and comments. """ event_id: UUID task_id: int + event: TaskEvent + state: MappingState + project_id: Optional[int] = None user_id: Optional[int] = None - - action: TaskAction - action_text: Optional[str] = None - action_date: Optional[datetime] = None + comment: Optional[str] = None + created_at: Optional[datetime] = None # Computed username: Optional[str] = None @@ -628,7 +630,7 @@ async def all( comments (bool): show comments rather than events. Returns: - list[DbTaskHistory]: list of task event objects. + list[DbTaskEvent]: list of task event objects. """ if project_id and task_id: raise ValueError("Specify either project_id or task_id, not both.") @@ -646,7 +648,7 @@ async def all( params["task_id"] = task_id if days is not None: end_date = datetime.now() - timedelta(days=days) - filters.append("action_date >= %(end_date)s") + filters.append("created_at >= %(end_date)s") params["end_date"] = end_date if comments: filters.append("action = 'COMMENT'") @@ -661,11 +663,11 @@ async def all( u.username, u.profile_img FROM - public.task_history + public.task_events JOIN - users u ON u.id = task_history.user_id + users u ON u.id = task_events.user_id WHERE {filters_joined} - ORDER BY action_date DESC; + ORDER BY created_at DESC; """ async with db.cursor(row_factory=class_row(cls)) as cur: @@ -687,7 +689,7 @@ async def create( await cur.execute( f""" WITH inserted AS ( - INSERT INTO public.task_history ( + INSERT INTO public.task_events ( event_id, project_id, {columns} @@ -730,7 +732,7 @@ class DbTask(BaseModel): feature_count: Optional[int] = None # Calculated - task_status: Optional[TaskAction] = None + task_state: Optional[TaskEvent] = None actioned_by_uid: Optional[int] = None actioned_by_username: Optional[str] = None @@ -748,7 +750,7 @@ async def one(cls, db: Connection, task_id: int) -> Self: ST_AsGeoJSON(tasks.outline)::jsonb AS outline, COALESCE( latest_event.action, 'RELEASED_FOR_MAPPING' - ) AS task_status, + ) AS task_state, COALESCE(latest_event.user_id, NULL) AS actioned_by_uid, COALESCE(latest_event.username, NULL) AS actioned_by_username FROM @@ -759,14 +761,14 @@ async def one(cls, db: Connection, task_id: int) -> Self: th.user_id, u.username FROM - task_history th + task_events th LEFT JOIN users u ON u.id = th.user_id WHERE th.task_id = tasks.id AND th.action != 'COMMENT' ORDER BY - th.action_date DESC + th.created_at DESC LIMIT 1 ) latest_event ON true WHERE @@ -795,7 +797,7 @@ async def all( SELECT tasks.*, ST_AsGeoJSON(tasks.outline)::jsonb AS outline, - COALESCE(latest_event.action, 'RELEASED_FOR_MAPPING') AS task_status, + COALESCE(latest_event.action, 'RELEASED_FOR_MAPPING') AS task_state, COALESCE(latest_event.user_id, NULL) AS actioned_by_uid, COALESCE(latest_event.username, NULL) AS actioned_by_username FROM @@ -806,14 +808,14 @@ async def all( th.user_id, u.username FROM - task_history th + task_events th LEFT JOIN users u ON u.id = th.user_id WHERE th.task_id = tasks.id AND th.action != 'COMMENT' ORDER BY - th.action_date DESC + th.created_at DESC LIMIT 1 ) latest_event ON true WHERE @@ -952,17 +954,17 @@ async def one(cls, db: Connection, project_id: int) -> Self: SELECT DISTINCT ON (task_id) th.task_id, th.action, - th.action_date, + th.created_at, th.user_id, u.username AS username FROM - task_history th + task_events th LEFT JOIN users u ON u.id = th.user_id WHERE th.action != 'COMMENT' ORDER BY - th.task_id, th.action_date DESC + th.task_id, th.created_at DESC ), project_bbox AS ( @@ -983,7 +985,7 @@ async def one(cls, db: Connection, project_id: int) -> Self: ] AS bbox, project_org.name AS organisation_name, project_org.logo AS organisation_logo, - latest_event_per_task.action_date AS last_active, + latest_event_per_task.created_at AS last_active, COALESCE( NULLIF(p.odk_central_url, ''), project_org.odk_central_url @@ -1004,7 +1006,7 @@ async def one(cls, db: Connection, project_id: int) -> Self: 'project_task_index', tasks.project_task_index, 'outline', ST_AsGeoJSON(tasks.outline)::jsonb, 'feature_count', tasks.feature_count, - 'task_status', COALESCE( + 'task_state', COALESCE( latest_event_per_task.action, 'RELEASED_FOR_MAPPING' ), @@ -1039,7 +1041,7 @@ async def one(cls, db: Connection, project_id: int) -> Self: p.id = %(project_id)s GROUP BY p.id, project_org.id, project_bbox.bbox, - latest_event_per_task.action_date; + latest_event_per_task.created_at; """ # Simpler query without additional metadata @@ -1271,7 +1273,7 @@ async def delete(cls, db: Connection, project_id: int) -> bool: ) await cur.execute( """ - DELETE FROM task_history WHERE project_id = %(project_id)s; + DELETE FROM task_events WHERE project_id = %(project_id)s; """, {"project_id": project_id}, ) diff --git a/src/backend/app/projects/project_crud.py b/src/backend/app/projects/project_crud.py index 266b86567..1570721b6 100644 --- a/src/backend/app/projects/project_crud.py +++ b/src/backend/app/projects/project_crud.py @@ -897,7 +897,7 @@ async def get_project_users_plus_contributions(db: Connection, project_id: int): FROM users u JOIN - task_history th ON u.id = th.user_id + task_events th ON u.id = th.user_id WHERE th.project_id = %(project_id)s GROUP BY u.username diff --git a/src/backend/app/tasks/task_crud.py b/src/backend/app/tasks/task_crud.py index 1ba0fc945..190166bd8 100644 --- a/src/backend/app/tasks/task_crud.py +++ b/src/backend/app/tasks/task_crud.py @@ -26,24 +26,24 @@ from app.db.enums import ( HTTPStatus, - TaskStatus, + MappingState, get_action_for_status_change, ) -from app.db.models import DbTask, DbTaskHistory +from app.db.models import DbTask, DbTaskEvent from app.tasks import task_schemas # TODO SQL refactor this to use case statements on /next async def new_task_event( - db: Connection, task_id: int, user_id: int, new_status: TaskStatus + db: Connection, task_id: int, user_id: int, new_status: MappingState ): """Add a new entry to the task events.""" log.debug(f"Checking if task ({task_id}) is already locked") task_entry = await DbTask.one(db, task_id) - if task_entry and task_entry.task_status in [ - TaskStatus.LOCKED_FOR_MAPPING, - TaskStatus.LOCKED_FOR_VALIDATION, + if task_entry and task_entry.task_state in [ + MappingState.LOCKED_FOR_MAPPING, + MappingState.LOCKED_FOR_VALIDATION, ]: if task_entry.actioned_by_uid != user_id: msg = f"Task is locked by user {task_entry.username}" @@ -57,7 +57,7 @@ async def new_task_event( action=get_action_for_status_change(new_status), # NOTE we don't include a comment unless necessary ) - new_task_event = await DbTaskHistory.create(db, new_event) + new_task_event = await DbTaskEvent.create(db, new_event) return new_task_event @@ -81,18 +81,18 @@ async def get_project_task_activity( sql = """ SELECT - to_char(action_date::date, 'dd/mm/yyyy') as date, + to_char(created_at::date, 'dd/mm/yyyy') as date, COUNT(*) FILTER (WHERE action = 'VALIDATED') AS validated, COUNT(*) FILTER (WHERE action = 'MARKED_MAPPED') AS mapped FROM - task_history + task_events WHERE project_id = %(project_id)s - AND action_date >= %(end_date)s + AND created_at >= %(end_date)s GROUP BY - action_date::date + created_at::date ORDER BY - action_date::date; + created_at::date; """ async with db.cursor(row_factory=class_row(task_schemas.TaskHistoryCount)) as cur: diff --git a/src/backend/app/tasks/task_routes.py b/src/backend/app/tasks/task_routes.py index ef5193562..758876cf5 100644 --- a/src/backend/app/tasks/task_routes.py +++ b/src/backend/app/tasks/task_routes.py @@ -25,8 +25,8 @@ from app.auth.auth_schemas import ProjectUserDict from app.auth.roles import get_uid, mapper from app.db.database import db_conn -from app.db.enums import HTTPStatus, TaskAction, TaskStatus -from app.db.models import DbTask, DbTaskHistory +from app.db.enums import HTTPStatus, MappingState, TaskEvent +from app.db.models import DbTask, DbTaskEvent from app.tasks import task_crud, task_schemas from app.tasks.task_deps import get_task @@ -75,7 +75,7 @@ async def get_specific_task( async def add_new_task_event( db_task: Annotated[DbTask, Depends(get_task)], project_user: Annotated[ProjectUserDict, Depends(mapper)], - new_status: TaskStatus, + new_status: MappingState, db: Annotated[Connection, Depends(db_conn)], ): """Add a new event to the events table / update task status.""" @@ -111,10 +111,10 @@ async def add_task_comment( new_comment = task_schemas.TaskHistoryIn( task_id=db_task.id, user_id=user_id, - action=TaskAction.COMMENT, - action_text=comment, + action=TaskEvent.COMMENT, + comment=comment, ) - return await DbTaskHistory.create(db, new_comment) + return await DbTaskEvent.create(db, new_comment) # NOTE this endpoint isn't used? @@ -140,7 +140,7 @@ async def task_activity( @router.get("/{task_id}/history/", response_model=list[task_schemas.TaskHistoryOut]) -async def task_history( +async def get_task_event_history( db: Annotated[Connection, Depends(db_conn)], db_task: Annotated[DbTask, Depends(get_task)], project_user: Annotated[ProjectUserDict, Depends(mapper)], @@ -148,4 +148,4 @@ async def task_history( comments: bool = False, ): """Get the detailed history for a task.""" - return await DbTaskHistory.all(db, task_id=db_task.id, days=days, comments=comments) + return await DbTaskEvent.all(db, task_id=db_task.id, days=days, comments=comments) diff --git a/src/backend/app/tasks/task_schemas.py b/src/backend/app/tasks/task_schemas.py index b087c002c..fb30ae592 100644 --- a/src/backend/app/tasks/task_schemas.py +++ b/src/backend/app/tasks/task_schemas.py @@ -21,10 +21,9 @@ from uuid import UUID from geojson_pydantic import Polygon -from pydantic import BaseModel, Field, computed_field +from pydantic import BaseModel, Field -from app.db.enums import TaskAction, TaskStatus, get_status_for_action -from app.db.models import DbTask, DbTaskHistory +from app.db.models import DbTask, DbTaskEvent # NOTE we don't have a TaskIn as tasks are only generated once during project creation @@ -36,7 +35,7 @@ class TaskOut(DbTask): outline: Polygon -class TaskHistoryIn(DbTaskHistory): +class TaskHistoryIn(DbTaskEvent): """Create a new task event.""" # Exclude, as the uuid is generated in the database @@ -49,29 +48,16 @@ class TaskHistoryIn(DbTaskHistory): profile_img: Annotated[Optional[str], Field(exclude=True)] = None -class TaskHistoryOut(DbTaskHistory): +class TaskHistoryOut(DbTaskEvent): """A task event to display to the user.""" # Ensure project_id is removed, as we only use this to query for tasks project_id: Annotated[Optional[int], Field(exclude=True)] = None - # We calculate the 'status' field in place of the action enum - action: Annotated[Optional[TaskAction], Field(exclude=True)] = None - - @computed_field - @property - def status(self) -> Optional[TaskStatus]: - """Get the status from the recent action. - - TODO remove this, replace with 'action' or similar? - """ - if not self.action: - return None - return get_status_for_action(self.action) class TaskHistoryCount(BaseModel): """Task mapping history status counts per day.""" date: str - validated: int mapped: int + validated: int diff --git a/src/backend/migrations/007-rename-task-history.sql b/src/backend/migrations/007-rename-task-history.sql index 7b7f60f22..9186a72d3 100644 --- a/src/backend/migrations/007-rename-task-history.sql +++ b/src/backend/migrations/007-rename-task-history.sql @@ -23,7 +23,7 @@ BEGIN 'GOOD', 'BAD', 'SPLIT', - 'GROUP', + 'MERGE', 'ASSIGN', 'COMMENT' ); diff --git a/src/backend/migrations/init/fmtm_base_schema.sql b/src/backend/migrations/init/fmtm_base_schema.sql index e29ce7c52..3aff5679e 100644 --- a/src/backend/migrations/init/fmtm_base_schema.sql +++ b/src/backend/migrations/init/fmtm_base_schema.sql @@ -126,7 +126,7 @@ CREATE TYPE public.taskevent AS ENUM ( 'GOOD', 'BAD', 'SPLIT', - 'GROUP', + 'MERGE', 'ASSIGN', 'COMMENT' ); diff --git a/src/backend/tests/conftest.py b/src/backend/tests/conftest.py index 4b0c1262f..5165acec3 100644 --- a/src/backend/tests/conftest.py +++ b/src/backend/tests/conftest.py @@ -39,8 +39,8 @@ from app.central.central_schemas import ODKCentralDecrypted, ODKCentralIn from app.config import encrypt_value, settings from app.db.database import db_conn -from app.db.enums import TaskStatus, UserRole, get_action_for_status_change -from app.db.models import DbProject, DbTask, DbTaskHistory +from app.db.enums import MappingState, UserRole, get_action_for_status_change +from app.db.models import DbProject, DbTask, DbTaskEvent from app.main import get_application from app.organisations.organisation_deps import get_organisation from app.projects import project_crud @@ -226,10 +226,10 @@ async def task_event(db, project, tasks, admin_user): new_event = TaskHistoryIn( task_id=task.id, user_id=user.id, - action=get_action_for_status_change(TaskStatus.READY), - action_text="We added a comment!", + action=get_action_for_status_change(MappingState.READY), + comment="We added a comment!", ) - db_task_event = await DbTaskHistory.create(db, new_event) + db_task_event = await DbTaskEvent.create(db, new_event) return db_task_event diff --git a/src/backend/tests/test_projects_routes.py b/src/backend/tests/test_projects_routes.py index 12710d539..45cbe6163 100644 --- a/src/backend/tests/test_projects_routes.py +++ b/src/backend/tests/test_projects_routes.py @@ -31,7 +31,7 @@ from app.central.central_crud import create_odk_project from app.config import settings -from app.db.enums import EntityStatus, HTTPStatus, TaskAction +from app.db.enums import EntityState, HTTPStatus, TaskEvent from app.db.models import DbProject, slugify from app.db.postgis_utils import check_crs from app.projects import project_crud @@ -387,7 +387,7 @@ async def test_generate_project_files(db, client, project): # Now check required values were added to project new_project = await DbProject.one(db, project_id) assert len(new_project.tasks) == 1 - assert new_project.tasks[0].task_status == TaskAction.RELEASED_FOR_MAPPING + assert new_project.tasks[0].task_state == TaskEvent.RELEASED_FOR_MAPPING assert isinstance(new_project.odk_token, str) @@ -463,7 +463,7 @@ async def test_project_by_id(client, project): async def test_set_entity_mapping_status(client, odk_project, entities): """Test set the ODK entity mapping status.""" entity = entities[0] - new_status = EntityStatus.LOCKED + new_status = EntityState.OPEN_IN_ODK response = await client.post( f"/projects/{odk_project.id}/entity/status", diff --git a/src/backend/tests/test_task_routes.py b/src/backend/tests/test_task_routes.py index 7d68f881c..b1960e080 100644 --- a/src/backend/tests/test_task_routes.py +++ b/src/backend/tests/test_task_routes.py @@ -21,7 +21,7 @@ import pytest -from app.db.enums import TaskStatus +from app.db.enums import MappingState async def test_read_task_history(client, task_event): @@ -40,15 +40,15 @@ async def test_read_task_history(client, task_event): assert UUID(data["event_id"]) == task_event.event_id assert data["username"] == task_event.username assert data["profile_img"] == task_event.profile_img - assert data["action_text"] == task_event.action_text - assert data["status"] == TaskStatus.READY + assert data["comment"] == task_event.comment + assert data["status"] == MappingState.READY async def test_update_task_status(client, tasks): """Test update the task status.""" task_id = tasks[0].id project_id = tasks[0].project_id - new_status = TaskStatus.LOCKED_FOR_MAPPING + new_status = MappingState.LOCKED_FOR_MAPPING response = await client.post( f"tasks/{task_id}/new-status/{new_status.value}?project_id={project_id}" From ccb0b2e919e578b7443f1a890bd961da42a4b547 Mon Sep 17 00:00:00 2001 From: spwoodcock Date: Sat, 26 Oct 2024 01:49:44 +0100 Subject: [PATCH 03/20] refactor(frontend): wip update to use TaskEvent / State enums --- src/frontend/src/api/Project.ts | 20 ++++++------ src/frontend/src/api/ProjectTaskStatus.ts | 2 +- .../src/components/DialogTaskActions.tsx | 12 +++---- .../ProjectDetailsV2/ActivitiesPanel.tsx | 20 ++++++------ .../components/ProjectDetailsV2/Comments.tsx | 15 ++++----- .../FeatureSelectionPopup.tsx | 16 +++++----- .../ProjectDetailsV2/ProjectInfo.tsx | 4 +-- .../ProjectDetailsV2/TaskSelectionPopup.tsx | 10 +++--- .../SubmissionsInfographics.tsx | 12 +++---- .../ProjectSubmissions/SubmissionsTable.tsx | 2 +- .../SubmissionInstance/SubmissionComments.tsx | 12 +++---- .../createproject/createProjectModel.ts | 8 ++--- .../src/models/project/projectModel.ts | 31 ++++--------------- src/frontend/src/store/slices/ProjectSlice.ts | 10 +++--- .../src/store/types/ICreateProject.ts | 2 +- src/frontend/src/store/types/IProject.ts | 21 ++++++------- src/frontend/src/types/enums.ts | 2 +- .../src/utilfunctions/getTaskStatusStyle.ts | 6 ++-- src/frontend/src/views/ProjectDetailsV2.tsx | 2 +- .../src/lib/components/event-card.svelte | 8 ++--- src/mapper/src/lib/task-events.ts | 28 ++++++++--------- src/mapper/src/lib/types.ts | 5 +-- .../src/routes/[projectId]/+page.svelte | 4 +-- 23 files changed, 116 insertions(+), 136 deletions(-) diff --git a/src/frontend/src/api/Project.ts b/src/frontend/src/api/Project.ts index 111f2c43f..addf3c4e3 100755 --- a/src/frontend/src/api/Project.ts +++ b/src/frontend/src/api/Project.ts @@ -1,7 +1,7 @@ import { ProjectActions } from '@/store/slices/ProjectSlice'; import { CommonActions } from '@/store/slices/CommonSlice'; import CoreModules from '@/shared/CoreModules'; -import { task_status } from '@/types/enums'; +import { task_state } from '@/types/enums'; import { writeBinaryToOPFS } from '@/api/Files'; import { projectInfoType } from '@/models/project/projectModel'; @@ -19,7 +19,7 @@ export const ProjectById = (projectId: string) => { id: data.id, index: data.project_task_index, outline: data.outline, - task_status: task_status[data.task_status], + task_state: task_state[data.task_state], actioned_by_uid: data.actioned_by_uid, actioned_by_username: data.actioned_by_username, task_history: data.task_history, @@ -278,7 +278,7 @@ export const GetProjectComments = (url: string) => { }; }; -export const PostProjectComments = (url: string, payload: { task_id: number; project_id: any; comment: string }) => { +export const PostProjectComments = (url: string, payload: { task_id: number; comment: string }) => { return async (dispatch) => { const postProjectComments = async (url: string) => { try { @@ -314,19 +314,19 @@ export const GetProjectTaskActivity = (url: string) => { }; }; -export const UpdateEntityStatus = (url: string, payload: { entity_id: string; status: number; label: string }) => { +export const UpdateEntityState = (url: string, payload: { entity_id: string; status: number; label: string }) => { return async (dispatch) => { - const updateEntityStatus = async (url: string, payload: { entity_id: string; status: number; label: string }) => { + const updateEntityState = async (url: string, payload: { entity_id: string; status: number; label: string }) => { try { - dispatch(ProjectActions.UpdateEntityStatusLoading(true)); + dispatch(ProjectActions.UpdateEntityStateLoading(true)); const response = await CoreModules.axios.post(url, payload); - dispatch(ProjectActions.UpdateEntityStatus(response.data)); - dispatch(ProjectActions.UpdateEntityStatusLoading(false)); + dispatch(ProjectActions.UpdateEntityState(response.data)); + dispatch(ProjectActions.UpdateEntityStateLoading(false)); } catch (error) { - dispatch(ProjectActions.UpdateEntityStatusLoading(false)); + dispatch(ProjectActions.UpdateEntityStateLoading(false)); } }; - await updateEntityStatus(url, payload); + await updateEntityState(url, payload); }; }; diff --git a/src/frontend/src/api/ProjectTaskStatus.ts b/src/frontend/src/api/ProjectTaskStatus.ts index 10834963f..f836cc793 100755 --- a/src/frontend/src/api/ProjectTaskStatus.ts +++ b/src/frontend/src/api/ProjectTaskStatus.ts @@ -42,7 +42,7 @@ export const UpdateTaskStatus = ( taskId, actioned_by_uid: body?.id, actioned_by_username: body?.username, - task_status: response.data.status, + task_state: response.data.status, }), ); } diff --git a/src/frontend/src/components/DialogTaskActions.tsx b/src/frontend/src/components/DialogTaskActions.tsx index 5c274256c..9068fce6c 100755 --- a/src/frontend/src/components/DialogTaskActions.tsx +++ b/src/frontend/src/components/DialogTaskActions.tsx @@ -4,7 +4,7 @@ import { UpdateTaskStatus } from '@/api/ProjectTaskStatus'; import MapStyles from '@/hooks/MapStyles'; import CoreModules from '@/shared/CoreModules'; import { CommonActions } from '@/store/slices/CommonSlice'; -import { task_status as taskStatusEnum } from '@/types/enums'; +import { task_state as taskStateEnum } from '@/types/enums'; import Button from '@/components/common/Button'; import { useNavigate } from 'react-router-dom'; import { GetProjectTaskActivity } from '@/api/Project'; @@ -30,7 +30,7 @@ export default function Dialog({ taskId, feature }: dialogPropType) { const geojsonStyles = MapStyles(); const [list_of_task_status, set_list_of_task_status] = useState([]); - const [task_status, set_task_status] = useState('RELEASED_FOR_MAPPING'); + const [task_state, set_task_state] = useState('RELEASED_FOR_MAPPING'); const [currentTaskInfo, setCurrentTaskInfo] = useState(); const [toggleMappedConfirmationModal, setToggleMappedConfirmationModal] = useState(false); @@ -77,7 +77,7 @@ export default function Dialog({ taskId, feature }: dialogPropType) { const findCorrectTaskStatusIndex = environment.tasksStatus.findIndex((data) => data.label == currentStatus); const tasksStatus = feature.id_ != undefined ? environment.tasksStatus[findCorrectTaskStatusIndex]?.['label'] : ''; - set_task_status(tasksStatus); + set_task_state(tasksStatus); const tasksStatusList = feature.id_ != undefined ? environment.tasksStatus[findCorrectTaskStatusIndex]?.['action'] : []; set_list_of_task_status(tasksStatusList); @@ -87,7 +87,7 @@ export default function Dialog({ taskId, feature }: dialogPropType) { const handleOnClick = async (event: React.MouseEvent) => { const btnId = event.currentTarget.dataset.btnid; if (!btnId) return; - const status = taskStatusEnum[btnId]; + const status = taskStateEnum[btnId]; const authDetailsCopy = authDetails != null ? { ...authDetails } : {}; const geoStyle = geojsonStyles[btnId]; if (btnId != undefined) { @@ -211,7 +211,7 @@ export default function Dialog({ taskId, feature }: dialogPropType) { })} )} - {task_status !== 'RELEASED_FOR_MAPPING' && task_status !== 'LOCKED_FOR_MAPPING' && ( + {task_state !== 'RELEASED_FOR_MAPPING' && task_state !== 'LOCKED_FOR_MAPPING' && (
)} - {task_status === 'LOCKED_FOR_MAPPING' && ( + {task_state === 'LOCKED_FOR_MAPPING' && (
) : filteredTaskCommentsList?.length > 0 ? ( - filteredTaskCommentsList?.map((comment) => ( + filteredTaskCommentsList?.map((entry) => (
-

{comment?.username}

+

{entry?.username}

-

{comment?.action_date?.split('T')[0]}

+

{entry?.created_at?.split('T')[0]}

-

{comment?.action_text?.split('-SUBMISSION_INST-')[1]}

+

{entry?.comment?.split('-SUBMISSION_INST-')[1]}

)) ) : ( diff --git a/src/frontend/src/models/createproject/createProjectModel.ts b/src/frontend/src/models/createproject/createProjectModel.ts index a1a4e398c..91bbec656 100755 --- a/src/frontend/src/models/createproject/createProjectModel.ts +++ b/src/frontend/src/models/createproject/createProjectModel.ts @@ -1,4 +1,4 @@ -import { task_status as taskStatusEnum } from '@/types/enums'; +import { task_state as taskStateEnum } from '@/types/enums'; export interface ProjectDetailsModel { id: number; @@ -36,13 +36,13 @@ export interface ProjectDetailsModel { id: string; bbox: null | number[]; }; - task_status: taskStatusEnum; + task_state: taskStateEnum; actioned_by_uid: number; actioned_by_username: string; task_history: { event_id: string; - action_text: string; - action_date: string; + state: string; + comment: string; }[]; qr_code_base64: string; }[]; diff --git a/src/frontend/src/models/project/projectModel.ts b/src/frontend/src/models/project/projectModel.ts index 136cba828..79518ce13 100644 --- a/src/frontend/src/models/project/projectModel.ts +++ b/src/frontend/src/models/project/projectModel.ts @@ -11,32 +11,13 @@ export type dataExtractPropertyType = { }; export type taskHistoryTypes = { - action_date: string; - action_text: string; - id: number; - profile_img: null | string; - status: string; + event_id: string; + event: number; + state: string; + comment: string; username: string; -}; - -export type taskHistoryListType = { - action: string; - action_date: string; - action_text: string; - project_id: number; - outlineGeojson: { - type: string; - geometry: { - coordinates: []; - type: string; - }; - properties: Record; - id: string; - }; profile_img: null | string; - status: string; - task_id: number; - username: string; + created_at: string; }; export type projectInfoType = { @@ -111,7 +92,7 @@ export type taskBoundriesTypes = { bbox: null | number[]; }; task_history: taskHistoryTypes[]; - task_status: string; + task_state: string; }; export type taskBoundriesGeojson = { diff --git a/src/frontend/src/store/slices/ProjectSlice.ts b/src/frontend/src/store/slices/ProjectSlice.ts index 583c50634..264b1d212 100755 --- a/src/frontend/src/store/slices/ProjectSlice.ts +++ b/src/frontend/src/store/slices/ProjectSlice.ts @@ -32,7 +32,7 @@ const initialState: ProjectStateTypes = { }, entityOsmMap: [], entityOsmMapLoading: false, - updateEntityStatusLoading: false, + updateEntityStateLoading: false, projectDashboardLoading: false, geolocationStatus: false, projectCommentsList: [], @@ -143,10 +143,10 @@ const ProjectSlice = createSlice({ UpdateProjectTaskActivity(state, action) { state.projectTaskActivity = [action.payload, ...state.projectTaskActivity]; }, - UpdateEntityStatusLoading(state, action) { - state.updateEntityStatusLoading = action.payload; + UpdateEntityStateLoading(state, action) { + state.updateEntityStateLoading = action.payload; }, - UpdateEntityStatus(state, action) { + UpdateEntityState(state, action) { const updatedEntityOsmMap = state.entityOsmMap?.map((entity) => { if (entity.id === action.payload.id) { return action.payload; @@ -162,7 +162,7 @@ const ProjectSlice = createSlice({ if (taskBoundary?.index === +action.payload.taskId) { return { ...taskBoundary, - task_status: action.payload.task_status, + task_state: action.payload.task_state, actioned_by_uid: action.payload.actioned_by_uid, actioned_by_username: action.payload.actioned_by_username, }; diff --git a/src/frontend/src/store/types/ICreateProject.ts b/src/frontend/src/store/types/ICreateProject.ts index a964f9457..797efb53f 100644 --- a/src/frontend/src/store/types/ICreateProject.ts +++ b/src/frontend/src/store/types/ICreateProject.ts @@ -60,7 +60,7 @@ export type ProjectTaskTypes = { index: number; project_id: number; outline: GeoJSONFeatureTypes; - task_status: number; + task_state: string; actioned_by_uid: number | null; actioned_by_username: string | null; task_history: any[]; diff --git a/src/frontend/src/store/types/IProject.ts b/src/frontend/src/store/types/IProject.ts index 27c6a10b2..db6be9cca 100644 --- a/src/frontend/src/store/types/IProject.ts +++ b/src/frontend/src/store/types/IProject.ts @@ -26,7 +26,7 @@ export type ProjectStateTypes = { projectDashboardDetail: projectDashboardDetailTypes; entityOsmMap: EntityOsmMap[]; entityOsmMapLoading: boolean; - updateEntityStatusLoading: boolean; + updateEntityStateLoading: boolean; projectDashboardLoading: boolean; geolocationStatus: boolean; projectCommentsList: projectCommentsListTypes[]; @@ -57,23 +57,20 @@ type tilesListTypes = { type projectCommentsListTypes = { id: number; - project_id: number; - action: string; - action_text: string; - action_date: string; - username: string; task_id: number; + comment: string; + created_at: string; + username: string; profile_img: string; - status: any; }; export type projectTaskActivity = { - id: number; + event_id: string; task_id: number; - action: string; - action_text: string; - action_date: string; - status: string; + event: string; + state: string; + comment: string; profile_img: null | string; username: string; + created_at: string; }; diff --git a/src/frontend/src/types/enums.ts b/src/frontend/src/types/enums.ts index 39c44f3d4..b48caec2d 100644 --- a/src/frontend/src/types/enums.ts +++ b/src/frontend/src/types/enums.ts @@ -4,7 +4,7 @@ export enum task_split_type { task_splitting_algorithm = 'TASK_SPLITTING_ALGORITHM', } -export enum task_status { +export enum task_state { RELEASED_FOR_MAPPING = 'RELEASED_FOR_MAPPING', LOCKED_FOR_MAPPING = 'LOCKED_FOR_MAPPING', MARKED_MAPPED = 'MARKED_MAPPED', diff --git a/src/frontend/src/utilfunctions/getTaskStatusStyle.ts b/src/frontend/src/utilfunctions/getTaskStatusStyle.ts index 256a77e60..43da94ae3 100644 --- a/src/frontend/src/utilfunctions/getTaskStatusStyle.ts +++ b/src/frontend/src/utilfunctions/getTaskStatusStyle.ts @@ -2,7 +2,7 @@ import { Fill, Icon, Stroke, Style } from 'ol/style'; import { getCenter } from 'ol/extent'; import { Point } from 'ol/geom'; import AssetModules from '@/shared/AssetModules'; -import { task_status } from '@/types/enums'; +import { task_state } from '@/types/enums'; import { EntityOsmMap } from '@/store/types/IProject'; function createPolygonStyle(fillColor: string, strokeColor: string) { @@ -38,7 +38,7 @@ const strokeColor = 'rgb(0,0,0,0.3)'; const secondaryStrokeColor = 'rgb(0,0,0,1)'; const getTaskStatusStyle = (feature: Record, mapTheme: Record, taskLockedByUser: boolean) => { - const status = feature.getProperties().task_status; + const status = feature.getProperties().task_state; const isTaskStatusLocked = ['LOCKED_FOR_MAPPING', 'LOCKED_FOR_VALIDATION'].includes(status); const borderStrokeColor = isTaskStatusLocked && taskLockedByUser ? secondaryStrokeColor : strokeColor; @@ -118,7 +118,7 @@ const getTaskStatusStyle = (feature: Record, mapTheme: Record, entityOsmMap: EntityOsmMap[]) => { const entity = entityOsmMap?.find((entity) => entity?.osm_id === osmId) as EntityOsmMap; - let status = task_status[entity?.status]; + let status = task_state[entity?.status]; const borderStrokeColor = '#FF0000'; diff --git a/src/frontend/src/views/ProjectDetailsV2.tsx b/src/frontend/src/views/ProjectDetailsV2.tsx index b701754b5..6dea186c3 100644 --- a/src/frontend/src/views/ProjectDetailsV2.tsx +++ b/src/frontend/src/views/ProjectDetailsV2.tsx @@ -140,7 +140,7 @@ const ProjectDetailsV2 = () => { geometry: { ...taskObj.outline }, properties: { ...taskObj.outline.properties, - task_status: taskObj?.task_status, + task_state: taskObj?.task_state, actioned_by_uid: taskObj?.actioned_by_uid, actioned_by_username: taskObj?.actioned_by_username, }, diff --git a/src/mapper/src/lib/components/event-card.svelte b/src/mapper/src/lib/components/event-card.svelte index 91e0bb133..77c84988f 100644 --- a/src/mapper/src/lib/components/event-card.svelte +++ b/src/mapper/src/lib/components/event-card.svelte @@ -45,16 +45,16 @@
- {record?.action} by {record.action_text?.split(' ').at(-1)} + {record?.event} by {record.comment?.split(' ').at(-1)} - +

#{record?.task_id}

- {#if record?.action_date} - {@const formattedDate = formatDate(record.action_date)} + {#if record?.created_at} + {@const formattedDate = formatDate(record.created_at)} {formattedDate.date} {formattedDate.time} {:else} diff --git a/src/mapper/src/lib/task-events.ts b/src/mapper/src/lib/task-events.ts index cba132496..338e464d2 100644 --- a/src/mapper/src/lib/task-events.ts +++ b/src/mapper/src/lib/task-events.ts @@ -1,6 +1,6 @@ import { v4 as uuidv4 } from 'uuid'; import { TaskStatusEnum } from '$lib/types'; -import type { TaskStatus, TaskEvent } from '$lib/types'; +import type { MappingState, TaskEvent } from '$lib/types'; const API_URL = import.meta.env.VITE_API_URL; @@ -10,17 +10,17 @@ export function statusEnumLabelToValue(statusLabel: string): string { if (!(statusLabel in TaskStatusEnum)) { throw new Error(`Invalid status string: ${statusLabel}`); } - const statusValue = TaskStatusEnum[statusLabel as keyof TaskStatus]; + const statusValue = TaskStatusEnum[statusLabel as keyof MappingState]; return statusValue; } -export function statusEnumValueToLabel(statusValue: string): keyof TaskStatus { +export function statusEnumValueToLabel(statusValue: string): keyof MappingState { // Validate if statusValue exists in TaskStatusEnum const statusEntry = Object.entries(TaskStatusEnum).find(([_, value]) => value === statusValue); if (statusEntry) { - return statusEntry[0] as keyof TaskStatus; + return statusEntry[0] as keyof MappingState; } else { throw new Error(`Invalid status value: ${statusValue}`); } @@ -38,7 +38,7 @@ async function add_event( taskId: number, // userId: number, actionId: string, - // action_text: string = '', + // comment: string = '', // ): Promise { ): Promise { // const eventId = uuidv4() @@ -56,14 +56,14 @@ async function add_event( return newEvent; // // Uncomment this for local first approach - // await db.task_history.create({ + // await db.task_events.create({ // data: { // event_id: uuidv4(), // project_id: projectId, // task_id: taskId, - // action: action, - // action_text: action_text, - // action_date: new Date().toISOString(), + // event: action, + // comment: comment, + // created_at: new Date().toISOString(), // user_id: userId, // }, // }); @@ -74,8 +74,8 @@ export async function mapTask(/* db, */ projectId: number, taskId: number): Prom } export async function finishTask(/* db, */ projectId: number, taskId: number): Promise { - // TODO the backend /new-status endpoint is actually posting TaskStatus - // TODO it should likely be posting TaskAction (TaskEvent) to the endpoint + // TODO the backend /new-status endpoint is actually posting MappingState + // TODO it should likely be posting TaskEvent (TaskEvent) to the endpoint // TODO then we handle the status of the task internally // i.e. it's duplicated info! await add_event(/* db, */ projectId, taskId, TaskStatusEnum.MARKED_MAPPED); @@ -89,7 +89,7 @@ export async function resetTask(/* db, */ projectId: number, taskId: number): Pr // // const query = ` // // WITH last AS ( // // SELECT * -// // FROM task_history +// // FROM task_events // // WHERE project_id = ? AND task_id = ? // // ORDER BY aid DESC // // LIMIT 1 @@ -99,9 +99,9 @@ export async function resetTask(/* db, */ projectId: number, taskId: number): Pr // // FROM last // // WHERE user_id = ? AND action = 'LOCKED_FOR_MAPPING' // // ) -// // INSERT INTO task_history ( +// // INSERT INTO task_events ( // // event_id, project_id, task_id, action, -// // action_text, action_date, user_id +// // comment, created_at, user_id // // ) // // SELECT // // ?, -- event_id diff --git a/src/mapper/src/lib/types.ts b/src/mapper/src/lib/types.ts index e290315be..f2e144e89 100644 --- a/src/mapper/src/lib/types.ts +++ b/src/mapper/src/lib/types.ts @@ -78,10 +78,11 @@ export const TaskStatusEnum: TaskStatus = Object.freeze({ ARCHIVED: '8', }); +// TODO fix me export type TaskEvent = { event_id: string; - action_text: string; - action_date: string; + comment: string; + created_at: string; username: string; profile_img: string; status: TaskStatus; diff --git a/src/mapper/src/routes/[projectId]/+page.svelte b/src/mapper/src/routes/[projectId]/+page.svelte index 0a8bcc8bd..1c7256cf0 100644 --- a/src/mapper/src/routes/[projectId]/+page.svelte +++ b/src/mapper/src/routes/[projectId]/+page.svelte @@ -65,7 +65,7 @@ // *** Task history sync *** // const taskFeatcolStore = writable({ type: 'FeatureCollection', features: [] }); const taskHistoryStream = new ShapeStream({ - url: 'http://localhost:7055/v1/shape/task_history', + url: 'http://localhost:7055/v1/shape/task_events', where: `project_id=${data.projectId}`, }); const taskHistoryEvents = new Shape(taskHistoryStream); @@ -293,7 +293,7 @@ {#if $latestEvent} {/if} From 3c07231e72f7b17def36052ba6ff8b3260307c5d Mon Sep 17 00:00:00 2001 From: spwoodcock Date: Sat, 26 Oct 2024 02:38:23 +0100 Subject: [PATCH 04/20] build: add trigger to set task_event.state automatically from event --- .../migrations/007-rename-task-history.sql | 49 +++++++++++++++++-- .../migrations/init/fmtm_base_schema.sql | 32 ++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/backend/migrations/007-rename-task-history.sql b/src/backend/migrations/007-rename-task-history.sql index 9186a72d3..222ae30b0 100644 --- a/src/backend/migrations/007-rename-task-history.sql +++ b/src/backend/migrations/007-rename-task-history.sql @@ -60,12 +60,56 @@ ALTER TYPE public.mappingstate OWNER TO fmtm; --- Update task_events field names and types +-- Update task_event fields prior to trigger addition DO $$ BEGIN IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'task_events' AND column_name = 'action') THEN + ALTER TABLE public.task_events ADD COLUMN state public.mappingstate; ALTER TABLE public.task_events RENAME COLUMN action TO event; + ALTER TABLE public.task_events RENAME COLUMN action_text TO comment; + ALTER TABLE public.task_events RENAME COLUMN action_date TO created_at; + END IF; +END $$; + +-- Create trigger function to set task state automatically + +CREATE OR REPLACE FUNCTION set_task_state() +RETURNS TRIGGER AS $$ +BEGIN + CASE NEW.event + WHEN 'MAP' THEN + NEW.state := 'LOCKED_FOR_MAPPING'; + WHEN 'FINISH' THEN + NEW.state := 'UNLOCKED_TO_VALIDATE'; + WHEN 'GOOD' THEN + NEW.state := 'UNLOCKED_DONE'; + WHEN 'BAD' THEN + NEW.state := 'UNLOCKED_TO_MAP'; + WHEN 'SPLIT' THEN + NEW.state := 'UNLOCKED_DONE'; + WHEN 'MERGE' THEN + NEW.state := 'UNLOCKED_DONE'; + WHEN 'ASSIGN' THEN + NEW.state := 'LOCKED_FOR_MAPPING'; + ELSE + RAISE EXCEPTION 'Unknown task event type: %', NEW.event; + END CASE; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Apply trigger to task_events table +CREATE TRIGGER task_event_state_trigger +BEFORE INSERT ON public.task_events +FOR EACH ROW +EXECUTE FUNCTION set_task_state(); + +-- Update action field values --> taskevent enum + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'task_events' AND column_name = 'action') THEN -- Change from taskaction --> taskevent ALTER TABLE task_events ALTER COLUMN event TYPE public.taskevent @@ -82,8 +126,6 @@ BEGIN WHEN 'COMMENT' THEN 'COMMENT'::public.taskevent ELSE NULL END; - ALTER TABLE public.task_events RENAME COLUMN action_text TO comment; - ALTER TABLE public.task_events RENAME COLUMN action_date TO created_at; END IF; END $$; @@ -120,5 +162,6 @@ BEGIN 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 3aff5679e..e1b423082 100644 --- a/src/backend/migrations/init/fmtm_base_schema.sql +++ b/src/backend/migrations/init/fmtm_base_schema.sql @@ -534,6 +534,38 @@ ADD CONSTRAINT fk_project_id FOREIGN KEY ( project_id ) REFERENCES public.projects (id); +-- Triggers + +CREATE OR REPLACE FUNCTION set_task_state() +RETURNS TRIGGER AS $$ +BEGIN + CASE NEW.event + WHEN 'MAP' THEN + NEW.state := 'LOCKED_FOR_MAPPING'; + WHEN 'FINISH' THEN + NEW.state := 'UNLOCKED_TO_VALIDATE'; + WHEN 'GOOD' THEN + NEW.state := 'UNLOCKED_DONE'; + WHEN 'BAD' THEN + NEW.state := 'UNLOCKED_TO_MAP'; + WHEN 'SPLIT' THEN + NEW.state := 'UNLOCKED_DONE'; + WHEN 'MERGE' THEN + NEW.state := 'UNLOCKED_DONE'; + WHEN 'ASSIGN' THEN + NEW.state := 'LOCKED_FOR_MAPPING'; + ELSE + RAISE EXCEPTION 'Unknown task event type: %', NEW.event; + END CASE; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE TRIGGER task_event_state_trigger +BEFORE INSERT ON public.task_events +FOR EACH ROW +EXECUTE FUNCTION set_task_state(); + -- Finalise REVOKE USAGE ON SCHEMA public FROM public; From 9577f6fc4c22767fae59add77bc407d132f8c88c Mon Sep 17 00:00:00 2001 From: spwoodcock Date: Sat, 26 Oct 2024 02:39:07 +0100 Subject: [PATCH 05/20] refactor: continued wip to use task events --- src/backend/app/db/enums.py | 64 +++---------------- src/backend/app/db/models.py | 32 +++++----- src/backend/app/tasks/task_crud.py | 24 +++---- src/backend/app/tasks/task_routes.py | 14 ++-- src/backend/app/tasks/task_schemas.py | 9 ++- src/backend/tests/conftest.py | 8 +-- src/backend/tests/test_projects_routes.py | 4 +- src/backend/tests/test_task_routes.py | 2 +- .../src/components/DialogTaskActions.tsx | 7 +- .../ProjectDetailsV2/ActivitiesPanel.tsx | 4 +- .../FeatureSelectionPopup.tsx | 9 ++- .../ProjectDetailsV2/TaskSelectionPopup.tsx | 2 +- src/frontend/src/environment.ts | 18 +++--- src/frontend/src/types/enums.ts | 11 +--- .../src/utilfunctions/getTaskStatusStyle.ts | 2 +- src/mapper/src/lib/task-events.ts | 6 +- src/mapper/src/lib/types.ts | 4 +- .../src/routes/[projectId]/+page.svelte | 12 ++-- 18 files changed, 90 insertions(+), 142 deletions(-) diff --git a/src/backend/app/db/enums.py b/src/backend/app/db/enums.py index 6a8f0fa12..f4883ca6d 100644 --- a/src/backend/app/db/enums.py +++ b/src/backend/app/db/enums.py @@ -113,19 +113,19 @@ class MappingLevel(StrEnum, Enum): class TaskEvent(StrEnum, Enum): """Task events via API. - `map` -- Set to *locked for mapping*, i.e. mapping in progress. - `finish` -- Set to *unlocked to validate*, i.e. is mapped. - `validate` -- Request recent task ready to be validate. - `good` -- Set the state to *unlocked done*. - `bad` -- Set the state *unlocked to map* again, to be mapped once again. - `split` -- Set the state *unlocked done* then generate additional + `MAP` -- Set to *locked for mapping*, i.e. mapping in progress. + `FINISH` -- Set to *unlocked to validate*, i.e. is mapped. + `FINISH` -- Request recent task ready to be validate. + `GOOD` -- Set the state to *unlocked done*. + `BAD` -- Set the state *unlocked to map* again, to be mapped once again. + `SPLIT` -- Set the state *unlocked done* then generate additional subdivided task areas. - `merge` -- Set the state *unlocked done* then generate additional + `MERGE` -- Set the state *unlocked done* then generate additional merged task area. - `assign` -- For a requester user to assign a task to another user. + `ASSIGN` -- For a requester user to assign a task to another user. Set the state *locked for mapping* passing in the required user id. Also notify the user they should map the area. - `comment` -- Keep the state the same, but simply add a comment. + `COMMENT` -- Keep the state the same, but simply add a comment. """ MAP = "MAP" @@ -247,49 +247,3 @@ class XLSFormType(StrEnum, Enum): # religious = "religious" # landusage = "landusage" # waterways = "waterways" - - -def get_action_for_status_change(task_state: MappingState) -> TaskEvent: - """Update task action inferred from previous state.""" - match task_state: - case MappingState.READY: - return TaskEvent.RELEASED_FOR_MAPPING - case MappingState.LOCKED_FOR_MAPPING: - return TaskEvent.LOCKED_FOR_MAPPING - case MappingState.MAPPED: - return TaskEvent.MARKED_MAPPED - case MappingState.LOCKED_FOR_VALIDATION: - return TaskEvent.LOCKED_FOR_VALIDATION - case MappingState.VALIDATED: - return TaskEvent.VALIDATED - case MappingState.BAD: - return TaskEvent.MARKED_BAD - case MappingState.SPLIT: - return TaskEvent.SPLIT_NEEDED - case MappingState.INVALIDATED: - return TaskEvent.MARKED_INVALID - case _: - return TaskEvent.RELEASED_FOR_MAPPING - - -def get_status_for_action(task_action: TaskEvent) -> MappingState: - """Get the task status inferred from the action.""" - match task_action: - case TaskEvent.RELEASED_FOR_MAPPING: - return MappingState.READY - case TaskEvent.LOCKED_FOR_MAPPING: - return MappingState.LOCKED_FOR_MAPPING - case TaskEvent.MARKED_MAPPED: - return MappingState.MAPPED - case TaskEvent.LOCKED_FOR_VALIDATION: - return MappingState.LOCKED_FOR_VALIDATION - case TaskEvent.VALIDATED: - return MappingState.VALIDATED - case TaskEvent.MARKED_BAD: - return MappingState.BAD - case TaskEvent.SPLIT_NEEDED: - return MappingState.SPLIT - case TaskEvent.MARKED_INVALID: - return MappingState.INVALIDATED - case _: - return MappingState.READY diff --git a/src/backend/app/db/models.py b/src/backend/app/db/models.py index 0baec26f4..c046b86ee 100644 --- a/src/backend/app/db/models.py +++ b/src/backend/app/db/models.py @@ -70,7 +70,7 @@ ProjectIn, ProjectUpdate, ) - from app.tasks.task_schemas import TaskHistoryIn + from app.tasks.task_schemas import TaskEventIn from app.users.user_schemas import UserIn @@ -598,12 +598,12 @@ class DbTaskEvent(BaseModel): """ event_id: UUID - task_id: int + task_id: Annotated[Optional[int], Field(gt=0)] = None event: TaskEvent state: MappingState - project_id: Optional[int] = None - user_id: Optional[int] = None + project_id: Annotated[Optional[int], Field(gt=0)] = None + user_id: Annotated[Optional[int], Field(gt=0)] = None comment: Optional[str] = None created_at: Optional[datetime] = None @@ -620,7 +620,7 @@ async def all( days: Optional[int] = None, comments: Optional[bool] = None, ) -> Optional[list[Self]]: - """Fetch all task history entries for a project. + """Fetch all task event entries for a project. Args: db (Connection): the database connection. @@ -678,7 +678,7 @@ async def all( async def create( cls, db: Connection, - task_in: "TaskHistoryIn", + task_in: "TaskEventIn", ) -> Self: """Create a new task event.""" model_dump = dump_and_check_model(task_in) @@ -749,7 +749,7 @@ async def one(cls, db: Connection, task_id: int) -> Self: tasks.*, ST_AsGeoJSON(tasks.outline)::jsonb AS outline, COALESCE( - latest_event.action, 'RELEASED_FOR_MAPPING' + latest_event.event, 'UNLOCKED_TO_MAP' ) AS task_state, COALESCE(latest_event.user_id, NULL) AS actioned_by_uid, COALESCE(latest_event.username, NULL) AS actioned_by_username @@ -757,7 +757,7 @@ async def one(cls, db: Connection, task_id: int) -> Self: tasks LEFT JOIN LATERAL ( SELECT - th.action, + th.event, th.user_id, u.username FROM @@ -766,7 +766,7 @@ async def one(cls, db: Connection, task_id: int) -> Self: users u ON u.id = th.user_id WHERE th.task_id = tasks.id - AND th.action != 'COMMENT' + AND th.event != 'COMMENT' ORDER BY th.created_at DESC LIMIT 1 @@ -797,14 +797,14 @@ async def all( SELECT tasks.*, ST_AsGeoJSON(tasks.outline)::jsonb AS outline, - COALESCE(latest_event.action, 'RELEASED_FOR_MAPPING') AS task_state, + COALESCE(latest_event.event, 'UNLOCKED_TO_MAP') AS task_state, COALESCE(latest_event.user_id, NULL) AS actioned_by_uid, COALESCE(latest_event.username, NULL) AS actioned_by_username FROM tasks LEFT JOIN LATERAL ( SELECT - th.action, + th.event, th.user_id, u.username FROM @@ -813,7 +813,7 @@ async def all( users u ON u.id = th.user_id WHERE th.task_id = tasks.id - AND th.action != 'COMMENT' + AND th.event != 'COMMENT' ORDER BY th.created_at DESC LIMIT 1 @@ -953,7 +953,7 @@ async def one(cls, db: Connection, project_id: int) -> Self: WITH latest_event_per_task AS ( SELECT DISTINCT ON (task_id) th.task_id, - th.action, + th.event, th.created_at, th.user_id, u.username AS username @@ -962,7 +962,7 @@ async def one(cls, db: Connection, project_id: int) -> Self: LEFT JOIN users u ON u.id = th.user_id WHERE - th.action != 'COMMENT' + th.event != 'COMMENT' ORDER BY th.task_id, th.created_at DESC ), @@ -1007,8 +1007,8 @@ async def one(cls, db: Connection, project_id: int) -> Self: 'outline', ST_AsGeoJSON(tasks.outline)::jsonb, 'feature_count', tasks.feature_count, 'task_state', COALESCE( - latest_event_per_task.action, - 'RELEASED_FOR_MAPPING' + latest_event_per_task.event, + 'UNLOCKED_TO_MAP' ), 'actioned_by_uid', COALESCE( latest_event_per_task.user_id, diff --git a/src/backend/app/tasks/task_crud.py b/src/backend/app/tasks/task_crud.py index 190166bd8..581abb568 100644 --- a/src/backend/app/tasks/task_crud.py +++ b/src/backend/app/tasks/task_crud.py @@ -27,7 +27,7 @@ from app.db.enums import ( HTTPStatus, MappingState, - get_action_for_status_change, + TaskEvent, ) from app.db.models import DbTask, DbTaskEvent from app.tasks import task_schemas @@ -35,7 +35,7 @@ # TODO SQL refactor this to use case statements on /next async def new_task_event( - db: Connection, task_id: int, user_id: int, new_status: MappingState + db: Connection, task_id: int, user_id: int, new_event: TaskEvent ): """Add a new entry to the task events.""" log.debug(f"Checking if task ({task_id}) is already locked") @@ -50,22 +50,22 @@ async def new_task_event( log.error(msg) raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=msg) - log.info(f"Updating task ID {task_id} to status {new_status}") - new_event = task_schemas.TaskHistoryIn( + log.info(f"Updating task ID {task_id} to status {new_event}") + event_in = task_schemas.TaskEventIn( task_id=task_id, user_id=user_id, - action=get_action_for_status_change(new_status), + event=new_event, # NOTE we don't include a comment unless necessary ) - new_task_event = await DbTaskEvent.create(db, new_event) - return new_task_event + created_task_event = await DbTaskEvent.create(db, event_in) + return created_task_event async def get_project_task_activity( db: Connection, project_id: int, days: int, -) -> task_schemas.TaskHistoryCount: +) -> task_schemas.TaskEventCount: """Get number of tasks mapped and validated for project. Args: @@ -75,15 +75,15 @@ async def get_project_task_activity( db (Connection): The database connection. Returns: - list[task_schemas.TaskHistoryCount]: A list of task history counts. + list[task_schemas.TaskEventCount]: A list of task event counts. """ end_date = datetime.now() - timedelta(days=days) sql = """ SELECT to_char(created_at::date, 'dd/mm/yyyy') as date, - COUNT(*) FILTER (WHERE action = 'VALIDATED') AS validated, - COUNT(*) FILTER (WHERE action = 'MARKED_MAPPED') AS mapped + COUNT(*) FILTER (WHERE state = 'UNLOCKED_DONE') AS validated, + COUNT(*) FILTER (WHERE state = 'UNLOCKED_TO_VALIDATE') AS mapped FROM task_events WHERE @@ -95,6 +95,6 @@ async def get_project_task_activity( created_at::date; """ - async with db.cursor(row_factory=class_row(task_schemas.TaskHistoryCount)) as cur: + async with db.cursor(row_factory=class_row(task_schemas.TaskEventCount)) as cur: await cur.execute(sql, {"project_id": project_id, "end_date": end_date}) return await cur.fetchall() diff --git a/src/backend/app/tasks/task_routes.py b/src/backend/app/tasks/task_routes.py index 758876cf5..8ce37c820 100644 --- a/src/backend/app/tasks/task_routes.py +++ b/src/backend/app/tasks/task_routes.py @@ -68,9 +68,9 @@ async def get_specific_task( raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) from e -# TODO SQL update this to be something like /next +# TODO update this to be POST /project/{pid}/events ? @router.post( - "/{task_id}/new-status/{new_status}", response_model=task_schemas.TaskHistoryOut + "/{task_id}/new-status/{new_status}", response_model=task_schemas.TaskEventOut ) async def add_new_task_event( db_task: Annotated[DbTask, Depends(get_task)], @@ -88,7 +88,7 @@ async def add_new_task_event( ) -@router.post("/{task_id}/comment/", response_model=task_schemas.TaskHistoryOut) +@router.post("/{task_id}/comment/", response_model=task_schemas.TaskEventOut) async def add_task_comment( comment: str, db_task: Annotated[DbTask, Depends(get_task)], @@ -105,10 +105,10 @@ async def add_task_comment( db (Connection): The database connection. Returns: - TaskHistoryOut: The created task comment. + TaskEventOut: The created task comment. """ user_id = await get_uid(project_user.get("user")) - new_comment = task_schemas.TaskHistoryIn( + new_comment = task_schemas.TaskEventIn( task_id=db_task.id, user_id=user_id, action=TaskEvent.COMMENT, @@ -118,7 +118,7 @@ async def add_task_comment( # NOTE this endpoint isn't used? -@router.get("/activity/", response_model=list[task_schemas.TaskHistoryCount]) +@router.get("/activity/", response_model=list[task_schemas.TaskEventCount]) async def task_activity( project_id: int, db: Annotated[Connection, Depends(db_conn)], @@ -139,7 +139,7 @@ async def task_activity( return await task_crud.get_project_task_activity(db, project_id, days) -@router.get("/{task_id}/history/", response_model=list[task_schemas.TaskHistoryOut]) +@router.get("/{task_id}/history/", response_model=list[task_schemas.TaskEventOut]) async def get_task_event_history( db: Annotated[Connection, Depends(db_conn)], db_task: Annotated[DbTask, Depends(get_task)], diff --git a/src/backend/app/tasks/task_schemas.py b/src/backend/app/tasks/task_schemas.py index fb30ae592..eda923d0d 100644 --- a/src/backend/app/tasks/task_schemas.py +++ b/src/backend/app/tasks/task_schemas.py @@ -23,6 +23,7 @@ from geojson_pydantic import Polygon from pydantic import BaseModel, Field +from app.db.enums import MappingState from app.db.models import DbTask, DbTaskEvent # NOTE we don't have a TaskIn as tasks are only generated once during project creation @@ -35,27 +36,29 @@ class TaskOut(DbTask): outline: Polygon -class TaskHistoryIn(DbTaskEvent): +class TaskEventIn(DbTaskEvent): """Create a new task event.""" # Exclude, as the uuid is generated in the database event_id: Annotated[Optional[UUID], Field(exclude=True)] = None # Exclude, as we get the project_id in the db from the task id project_id: Annotated[Optional[int], Field(exclude=True)] = None + # Exclude, as state is generated based on event type in db + state: Annotated[Optional[MappingState], Field(exclude=True)] = None # Omit computed fields username: Annotated[Optional[str], Field(exclude=True)] = None profile_img: Annotated[Optional[str], Field(exclude=True)] = None -class TaskHistoryOut(DbTaskEvent): +class TaskEventOut(DbTaskEvent): """A task event to display to the user.""" # Ensure project_id is removed, as we only use this to query for tasks project_id: Annotated[Optional[int], Field(exclude=True)] = None -class TaskHistoryCount(BaseModel): +class TaskEventCount(BaseModel): """Task mapping history status counts per day.""" date: str diff --git a/src/backend/tests/conftest.py b/src/backend/tests/conftest.py index 5165acec3..c8d3ecf3a 100644 --- a/src/backend/tests/conftest.py +++ b/src/backend/tests/conftest.py @@ -39,13 +39,13 @@ from app.central.central_schemas import ODKCentralDecrypted, ODKCentralIn from app.config import encrypt_value, settings from app.db.database import db_conn -from app.db.enums import MappingState, UserRole, get_action_for_status_change +from app.db.enums import TaskEvent, UserRole from app.db.models import DbProject, DbTask, DbTaskEvent from app.main import get_application from app.organisations.organisation_deps import get_organisation from app.projects import project_crud from app.projects.project_schemas import ProjectIn -from app.tasks.task_schemas import TaskHistoryIn +from app.tasks.task_schemas import TaskEventIn from app.users.user_deps import get_user from tests.test_data import test_data_path @@ -223,10 +223,10 @@ async def task_event(db, project, tasks, admin_user): """Create a new task event in the database.""" user = await get_user(admin_user.id, db) for task in tasks: - new_event = TaskHistoryIn( + new_event = TaskEventIn( task_id=task.id, user_id=user.id, - action=get_action_for_status_change(MappingState.READY), + event=TaskEvent.MAP, comment="We added a comment!", ) db_task_event = await DbTaskEvent.create(db, new_event) diff --git a/src/backend/tests/test_projects_routes.py b/src/backend/tests/test_projects_routes.py index 45cbe6163..960258045 100644 --- a/src/backend/tests/test_projects_routes.py +++ b/src/backend/tests/test_projects_routes.py @@ -31,7 +31,7 @@ from app.central.central_crud import create_odk_project from app.config import settings -from app.db.enums import EntityState, HTTPStatus, TaskEvent +from app.db.enums import EntityState, HTTPStatus, MappingState from app.db.models import DbProject, slugify from app.db.postgis_utils import check_crs from app.projects import project_crud @@ -387,7 +387,7 @@ async def test_generate_project_files(db, client, project): # Now check required values were added to project new_project = await DbProject.one(db, project_id) assert len(new_project.tasks) == 1 - assert new_project.tasks[0].task_state == TaskEvent.RELEASED_FOR_MAPPING + assert new_project.tasks[0].task_state == MappingState.UNLOCKED_TO_MAP assert isinstance(new_project.odk_token, str) diff --git a/src/backend/tests/test_task_routes.py b/src/backend/tests/test_task_routes.py index b1960e080..dec5ee51a 100644 --- a/src/backend/tests/test_task_routes.py +++ b/src/backend/tests/test_task_routes.py @@ -25,7 +25,7 @@ async def test_read_task_history(client, task_event): - """Test task history for a project.""" + """Test task events for a project.""" task_id = task_event.task_id assert task_id is not None diff --git a/src/frontend/src/components/DialogTaskActions.tsx b/src/frontend/src/components/DialogTaskActions.tsx index 9068fce6c..6545a619a 100755 --- a/src/frontend/src/components/DialogTaskActions.tsx +++ b/src/frontend/src/components/DialogTaskActions.tsx @@ -30,7 +30,7 @@ export default function Dialog({ taskId, feature }: dialogPropType) { const geojsonStyles = MapStyles(); const [list_of_task_status, set_list_of_task_status] = useState([]); - const [task_state, set_task_state] = useState('RELEASED_FOR_MAPPING'); + const [task_state, set_task_state] = useState('UNLOCKED_TO_MAP'); const [currentTaskInfo, setCurrentTaskInfo] = useState(); const [toggleMappedConfirmationModal, setToggleMappedConfirmationModal] = useState(false); @@ -72,8 +72,7 @@ export default function Dialog({ taskId, feature }: dialogPropType) { useEffect(() => { if (projectIndex != -1) { - const currentStatus = - projectTaskActivityList.length > 0 ? projectTaskActivityList[0].status : 'RELEASED_FOR_MAPPING'; + const currentStatus = projectTaskActivityList.length > 0 ? projectTaskActivityList[0].state : 'UNLOCKED_TO_MAP'; const findCorrectTaskStatusIndex = environment.tasksStatus.findIndex((data) => data.label == currentStatus); const tasksStatus = feature.id_ != undefined ? environment.tasksStatus[findCorrectTaskStatusIndex]?.['label'] : ''; @@ -211,7 +210,7 @@ export default function Dialog({ taskId, feature }: dialogPropType) { })}
)} - {task_state !== 'RELEASED_FOR_MAPPING' && task_state !== 'LOCKED_FOR_MAPPING' && ( + {task_state !== 'UNLOCKED_TO_MAP' && task_state !== 'LOCKED_FOR_MAPPING' && (
- {(task_state === 'RELEASED_FOR_MAPPING' || task_state === 'LOCKED_FOR_MAPPING') && ( + {(task_state === 'UNLOCKED_TO_MAP' || task_state === 'LOCKED_FOR_MAPPING') && (