backend/models/postgis/task.py (835 lines of code) (raw):

import bleach import datetime import geojson import json from enum import Enum from flask import current_app from sqlalchemy.types import Float, Text from sqlalchemy import desc, cast, func, distinct from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound from sqlalchemy.orm.session import make_transient from geoalchemy2 import Geometry from backend import db from typing import List from backend.models.dtos.mapping_dto import TaskDTO, TaskHistoryDTO from backend.models.dtos.validator_dto import MappedTasksByUser, MappedTasks from backend.models.dtos.project_dto import ( ProjectComment, ProjectCommentsDTO, LockedTasksForUser, ) from backend.models.dtos.mapping_issues_dto import TaskMappingIssueDTO from backend.models.postgis.statuses import TaskStatus, MappingLevel from backend.models.postgis.user import User from backend.models.postgis.utils import ( InvalidData, InvalidGeoJson, ST_GeomFromGeoJSON, ST_SetSRID, timestamp, parse_duration, NotFound, ) from backend.models.postgis.task_annotation import TaskAnnotation class TaskAction(Enum): """ Describes the possible actions that can happen to to a task, that we'll record history for """ LOCKED_FOR_MAPPING = 1 LOCKED_FOR_VALIDATION = 2 STATE_CHANGE = 3 COMMENT = 4 AUTO_UNLOCKED_FOR_MAPPING = 5 AUTO_UNLOCKED_FOR_VALIDATION = 6 class TaskInvalidationHistory(db.Model): """ Describes the most recent history of task invalidation and subsequent validation """ __tablename__ = "task_invalidation_history" id = db.Column(db.Integer, primary_key=True) project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=False) task_id = db.Column(db.Integer, nullable=False) is_closed = db.Column(db.Boolean, default=False) mapper_id = db.Column(db.BigInteger, db.ForeignKey("users.id", name="fk_mappers")) mapped_date = db.Column(db.DateTime) invalidator_id = db.Column( db.BigInteger, db.ForeignKey("users.id", name="fk_invalidators") ) invalidated_date = db.Column(db.DateTime) invalidation_history_id = db.Column( db.Integer, db.ForeignKey("task_history.id", name="fk_invalidation_history") ) validator_id = db.Column( db.BigInteger, db.ForeignKey("users.id", name="fk_validators") ) validated_date = db.Column(db.DateTime) updated_date = db.Column(db.DateTime, default=timestamp) __table_args__ = ( db.ForeignKeyConstraint( [task_id, project_id], ["tasks.id", "tasks.project_id"], name="fk_tasks" ), db.Index("idx_task_validation_history_composite", "task_id", "project_id"), db.Index( "idx_task_validation_mapper_status_composite", "invalidator_id", "is_closed" ), db.Index( "idx_task_validation_mapper_status_composite", "mapper_id", "is_closed" ), {}, ) def __init__(self, project_id, task_id): self.project_id = project_id self.task_id = task_id self.is_closed = False def delete(self): """ Deletes the current model from the DB """ db.session.delete(self) db.session.commit() @staticmethod def get_open_for_task(project_id, task_id): return TaskInvalidationHistory.query.filter_by( task_id=task_id, project_id=project_id, is_closed=False ).one_or_none() @staticmethod def close_all_for_task(project_id, task_id): TaskInvalidationHistory.query.filter_by( task_id=task_id, project_id=project_id, is_closed=False ).update({"is_closed": True}) @staticmethod def record_invalidation(project_id, task_id, invalidator_id, history): # Invalidation always kicks off a new entry for a task, so close any existing ones. TaskInvalidationHistory.close_all_for_task(project_id, task_id) last_mapped = TaskHistory.get_last_mapped_action(project_id, task_id) if last_mapped is None: return entry = TaskInvalidationHistory(project_id, task_id) entry.invalidation_history_id = history.id entry.mapper_id = last_mapped.user_id entry.mapped_date = last_mapped.action_date entry.invalidator_id = invalidator_id entry.invalidated_date = history.action_date entry.updated_date = timestamp() db.session.add(entry) @staticmethod def record_validation(project_id, task_id, validator_id, history): entry = TaskInvalidationHistory.get_open_for_task(project_id, task_id) # If no open invalidation to update, then nothing to do if entry is None: return last_mapped = TaskHistory.get_last_mapped_action(project_id, task_id) entry.mapper_id = last_mapped.user_id entry.mapped_date = last_mapped.action_date entry.validator_id = validator_id entry.validated_date = history.action_date entry.is_closed = True entry.updated_date = timestamp() class TaskMappingIssue(db.Model): """Describes an issue (along with an occurrence count) with a task mapping that contributed to invalidation of the task""" __tablename__ = "task_mapping_issues" id = db.Column(db.Integer, primary_key=True) task_history_id = db.Column( db.Integer, db.ForeignKey("task_history.id"), nullable=False, index=True ) issue = db.Column(db.String, nullable=False) mapping_issue_category_id = db.Column( db.Integer, db.ForeignKey("mapping_issue_categories.id", name="fk_issue_category"), nullable=False, ) count = db.Column(db.Integer, nullable=False) def __init__(self, issue, count, mapping_issue_category_id, task_history_id=None): self.task_history_id = task_history_id self.issue = issue self.count = count self.mapping_issue_category_id = mapping_issue_category_id def delete(self): """ Deletes the current model from the DB """ db.session.delete(self) db.session.commit() def as_dto(self): issue_dto = TaskMappingIssueDTO() issue_dto.category_id = self.mapping_issue_category_id issue_dto.name = self.issue issue_dto.count = self.count return issue_dto def __repr__(self): return "{0}: {1}".format(self.issue, self.count) class TaskHistory(db.Model): """ Describes the history associated with a task """ __tablename__ = "task_history" id = db.Column(db.Integer, primary_key=True) project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), index=True) task_id = db.Column(db.Integer, nullable=False) action = db.Column(db.String, nullable=False) action_text = db.Column(db.String) action_date = db.Column(db.DateTime, nullable=False, default=timestamp) user_id = db.Column( db.BigInteger, db.ForeignKey("users.id", name="fk_users"), index=True, nullable=False, ) invalidation_history = db.relationship( TaskInvalidationHistory, lazy="dynamic", cascade="all" ) actioned_by = db.relationship(User) task_mapping_issues = db.relationship(TaskMappingIssue, cascade="all") __table_args__ = ( db.ForeignKeyConstraint( [task_id, project_id], ["tasks.id", "tasks.project_id"], name="fk_tasks" ), db.Index("idx_task_history_composite", "task_id", "project_id"), db.Index("idx_task_history_project_id_user_id", "user_id", "project_id"), {}, ) def __init__(self, task_id, project_id, user_id): self.task_id = task_id self.project_id = project_id self.user_id = user_id def set_task_locked_action(self, task_action: TaskAction): if task_action not in [ TaskAction.LOCKED_FOR_MAPPING, TaskAction.LOCKED_FOR_VALIDATION, ]: raise ValueError("Invalid Action") self.action = task_action.name def set_comment_action(self, comment): self.action = TaskAction.COMMENT.name clean_comment = bleach.clean( comment ) # Bleach input to ensure no nefarious script tags etc self.action_text = clean_comment def set_state_change_action(self, new_state): self.action = TaskAction.STATE_CHANGE.name self.action_text = new_state.name def set_auto_unlock_action(self, task_action: TaskAction): self.action = task_action.name def delete(self): """ Deletes the current model from the DB """ db.session.delete(self) db.session.commit() @staticmethod def update_task_locked_with_duration( task_id: int, project_id: int, lock_action: TaskStatus, user_id: int ): """ Calculates the duration a task was locked for and sets it on the history record :param task_id: Task in scope :param project_id: Project ID in scope :param lock_action: The lock action, either Mapping or Validation :param user_id: Logged in user updating the task :return: """ try: last_locked = TaskHistory.query.filter_by( task_id=task_id, project_id=project_id, action=lock_action.name, action_text=None, user_id=user_id, ).one() except NoResultFound: # We suspect there's some kind or race condition that is occasionally deleting history records # prior to user unlocking task. Most likely stemming from auto-unlock feature. However, given that # we're trying to update a row that doesn't exist, it's better to return without doing anything # rather than showing the user an error that they can't fix return except MultipleResultsFound: # Again race conditions may mean we have multiple rows within the Task History. Here we attempt to # remove the oldest duplicate rows, and update the newest on the basis that this was the last action # the user was attempting to make. TaskHistory.remove_duplicate_task_history_rows( task_id, project_id, lock_action, user_id ) # Now duplicate is removed, we recursively call ourself to update the duration on the remaining row TaskHistory.update_task_locked_with_duration( task_id, project_id, lock_action, user_id ) return duration_task_locked = datetime.datetime.utcnow() - last_locked.action_date # Cast duration to isoformat for later transmission via api last_locked.action_text = ( (datetime.datetime.min + duration_task_locked).time().isoformat() ) db.session.commit() @staticmethod def remove_duplicate_task_history_rows( task_id: int, project_id: int, lock_action: TaskStatus, user_id: int ): """Method used in rare cases where we have duplicate task history records for a given action by a user This method will remove the oldest duplicate record, on the basis that the newest record was the last action the user was attempting to perform """ dupe = ( TaskHistory.query.filter( TaskHistory.project_id == project_id, TaskHistory.task_id == task_id, TaskHistory.action == lock_action.name, TaskHistory.user_id == user_id, ) .order_by(TaskHistory.id.asc()) .first() ) dupe.delete() @staticmethod def update_expired_and_locked_actions( project_id: int, task_id: int, expiry_date: datetime, action_text: str ): """ Sets auto unlock state to all not finished actions, that are older then the expiry date. Action is considered as a not finished, when it is in locked state and doesn't have action text :param project_id: Project ID in scope :param task_id: Task in scope :param expiry_date: Action created before this date is treated as expired :param action_text: Text which will be set for all changed actions :return: """ all_expired = TaskHistory.query.filter( TaskHistory.task_id == task_id, TaskHistory.project_id == project_id, TaskHistory.action_text.is_(None), TaskHistory.action.in_( [ TaskAction.LOCKED_FOR_VALIDATION.name, TaskAction.LOCKED_FOR_MAPPING.name, ] ), TaskHistory.action_date <= expiry_date, ).all() for task_history in all_expired: unlock_action = ( TaskAction.AUTO_UNLOCKED_FOR_MAPPING if task_history.action == "LOCKED_FOR_MAPPING" else TaskAction.AUTO_UNLOCKED_FOR_VALIDATION ) task_history.set_auto_unlock_action(unlock_action) task_history.action_text = action_text db.session.commit() @staticmethod def get_all_comments(project_id: int) -> ProjectCommentsDTO: """ Gets all comments for the supplied project_id""" comments = ( db.session.query( TaskHistory.task_id, TaskHistory.action_date, TaskHistory.action_text, User.username, ) .join(User) .filter( TaskHistory.project_id == project_id, TaskHistory.action == TaskAction.COMMENT.name, ) .all() ) comments_dto = ProjectCommentsDTO() for comment in comments: dto = ProjectComment() dto.comment = comment.action_text dto.comment_date = comment.action_date dto.user_name = comment.username dto.task_id = comment.task_id comments_dto.comments.append(dto) return comments_dto @staticmethod def get_last_status(project_id: int, task_id: int, for_undo: bool = False): """ Get the status the task was set to the last time the task had a STATUS_CHANGE""" result = ( db.session.query(TaskHistory.action_text) .filter( TaskHistory.project_id == project_id, TaskHistory.task_id == task_id, TaskHistory.action == TaskAction.STATE_CHANGE.name, ) .order_by(TaskHistory.action_date.desc()) .all() ) if not result: return TaskStatus.READY # No result so default to ready status if len(result) == 1 and for_undo: # We're looking for the previous status, however, there isn't any so we'll return Ready return TaskStatus.READY if for_undo and result[0][0] in [ TaskStatus.MAPPED.name, TaskStatus.BADIMAGERY.name, ]: # We need to return a READY when last status of the task is badimagery or mapped. return TaskStatus.READY if for_undo: # Return the second last status which was status the task was previously set to return TaskStatus[result[1][0]] else: return TaskStatus[result[0][0]] @staticmethod def get_last_action(project_id: int, task_id: int): """Gets the most recent task history record for the task""" return ( TaskHistory.query.filter( TaskHistory.project_id == project_id, TaskHistory.task_id == task_id ) .order_by(TaskHistory.action_date.desc()) .first() ) @staticmethod def get_last_action_of_type( project_id: int, task_id: int, allowed_task_actions: list ): """Gets the most recent task history record having provided TaskAction""" return ( TaskHistory.query.filter( TaskHistory.project_id == project_id, TaskHistory.task_id == task_id, TaskHistory.action.in_(allowed_task_actions), ) .order_by(TaskHistory.action_date.desc()) .first() ) @staticmethod def get_last_locked_action(project_id: int, task_id: int): """Gets the most recent task history record with locked action for the task""" return TaskHistory.get_last_action_of_type( project_id, task_id, [TaskAction.LOCKED_FOR_MAPPING.name, TaskAction.LOCKED_FOR_VALIDATION.name], ) @staticmethod def get_last_locked_or_auto_unlocked_action(project_id: int, task_id: int): """Gets the most recent task history record with locked or auto unlocked action for the task""" return TaskHistory.get_last_action_of_type( project_id, task_id, [ TaskAction.LOCKED_FOR_MAPPING.name, TaskAction.LOCKED_FOR_VALIDATION.name, TaskAction.AUTO_UNLOCKED_FOR_MAPPING.name, TaskAction.AUTO_UNLOCKED_FOR_VALIDATION.name, ], ) def get_last_mapped_action(project_id: int, task_id: int): """Gets the most recent mapped action, if any, in the task history""" return ( db.session.query(TaskHistory) .filter( TaskHistory.project_id == project_id, TaskHistory.task_id == task_id, TaskHistory.action == TaskAction.STATE_CHANGE.name, TaskHistory.action_text.in_( [TaskStatus.BADIMAGERY.name, TaskStatus.MAPPED.name] ), ) .order_by(TaskHistory.action_date.desc()) .first() ) class Task(db.Model): """ Describes an individual mapping Task """ __tablename__ = "tasks" # Table has composite PK on (id and project_id) id = db.Column(db.Integer, primary_key=True) project_id = db.Column( db.Integer, db.ForeignKey("projects.id"), index=True, primary_key=True ) x = db.Column(db.Integer) y = db.Column(db.Integer) zoom = db.Column(db.Integer) extra_properties = db.Column(db.Unicode) # Tasks need to be split differently if created from an arbitrary grid or were clipped to the edge of the AOI is_square = db.Column(db.Boolean, default=True) geometry = db.Column(Geometry("MULTIPOLYGON", srid=4326)) task_status = db.Column(db.Integer, default=TaskStatus.READY.value) locked_by = db.Column( db.BigInteger, db.ForeignKey("users.id", name="fk_users_locked"), index=True ) mapped_by = db.Column( db.BigInteger, db.ForeignKey("users.id", name="fk_users_mapper"), index=True ) validated_by = db.Column( db.BigInteger, db.ForeignKey("users.id", name="fk_users_validator"), index=True ) # Mapped objects task_history = db.relationship( TaskHistory, cascade="all", order_by=desc(TaskHistory.action_date) ) task_annotations = db.relationship(TaskAnnotation, cascade="all") lock_holder = db.relationship(User, foreign_keys=[locked_by]) mapper = db.relationship(User, foreign_keys=[mapped_by]) def create(self): """ Creates and saves the current model to the DB """ db.session.add(self) db.session.commit() def update(self): """ Updates the DB with the current state of the Task """ db.session.commit() def delete(self): """ Deletes the current model from the DB """ db.session.delete(self) db.session.commit() @classmethod def from_geojson_feature(cls, task_id, task_feature): """ Constructs and validates a task from a GeoJson feature object :param task_id: Unique ID for the task :param task_feature: A geojson feature object :raises InvalidGeoJson, InvalidData """ if type(task_feature) is not geojson.Feature: raise InvalidGeoJson("Task: Invalid GeoJson should be a feature") task_geometry = task_feature.geometry if type(task_geometry) is not geojson.MultiPolygon: raise InvalidGeoJson("Task: Geometry must be a MultiPolygon") is_valid_geojson = geojson.is_valid(task_geometry) if is_valid_geojson["valid"] == "no": raise InvalidGeoJson( f"Task: Invalid MultiPolygon - {is_valid_geojson['message']}" ) task = cls() try: task.x = task_feature.properties["x"] task.y = task_feature.properties["y"] task.zoom = task_feature.properties["zoom"] task.is_square = task_feature.properties["isSquare"] except KeyError as e: raise InvalidData(f"Task: Expected property not found: {str(e)}") if "extra_properties" in task_feature.properties: task.extra_properties = json.dumps( task_feature.properties["extra_properties"] ) task.id = task_id task_geojson = geojson.dumps(task_geometry) task.geometry = ST_SetSRID(ST_GeomFromGeoJSON(task_geojson), 4326) return task @staticmethod def get(task_id: int, project_id: int): """ Gets specified task :param task_id: task ID in scope :param project_id: project ID in scope :return: Task if found otherwise None """ # LIKELY PROBLEM AREA return Task.query.filter_by(id=task_id, project_id=project_id).one_or_none() @staticmethod def get_tasks(project_id: int, task_ids: List[int]): """ Get all tasks that match supplied list """ return Task.query.filter( Task.project_id == project_id, Task.id.in_(task_ids) ).all() @staticmethod def get_all_tasks(project_id: int): """ Get all tasks for a given project """ return Task.query.filter(Task.project_id == project_id).all() @staticmethod def auto_unlock_delta(): return parse_duration(current_app.config["TASK_AUTOUNLOCK_AFTER"]) @staticmethod def auto_unlock_tasks(project_id: int): """Unlock all tasks locked for longer than the auto-unlock delta""" expiry_delta = Task.auto_unlock_delta() lock_duration = (datetime.datetime.min + expiry_delta).time().isoformat() expiry_date = datetime.datetime.utcnow() - expiry_delta old_tasks = ( db.session.query(Task.id) .filter(Task.id == TaskHistory.task_id) .filter(Task.project_id == TaskHistory.project_id) .filter(Task.task_status.in_([1, 3])) .filter( TaskHistory.action.in_(["LOCKED_FOR_VALIDATION", "LOCKED_FOR_MAPPING"]) ) .filter(TaskHistory.action_text.is_(None)) .filter(Task.project_id == project_id) .filter(TaskHistory.action_date <= str(expiry_date)) ) if old_tasks.count() == 0: # no tasks older than the delta found, return without further processing return for old_task in old_tasks: task = Task.get(old_task[0], project_id) task.auto_unlock_expired_tasks(expiry_date, lock_duration) def auto_unlock_expired_tasks(self, expiry_date, lock_duration): """Unlock all tasks locked before expiry date. Clears task lock if needed""" TaskHistory.update_expired_and_locked_actions( self.project_id, self.id, expiry_date, lock_duration ) last_action = TaskHistory.get_last_locked_or_auto_unlocked_action( self.project_id, self.id ) if last_action.action in [ "AUTO_UNLOCKED_FOR_MAPPING", "AUTO_UNLOCKED_FOR_VALIDATION", ]: self.clear_lock() def is_mappable(self): """ Determines if task in scope is in suitable state for mapping """ if TaskStatus(self.task_status) not in [ TaskStatus.READY, TaskStatus.INVALIDATED, ]: return False return True def set_task_history( self, action, user_id, comment=None, new_state=None, mapping_issues=None ): """ Sets the task history for the action that the user has just performed :param task: Task in scope :param user_id: ID of user performing the action :param action: Action the user has performed :param comment: Comment user has added :param new_state: New state of the task :param mapping_issues: Identified issues leading to invalidation """ history = TaskHistory(self.id, self.project_id, user_id) if action in [TaskAction.LOCKED_FOR_MAPPING, TaskAction.LOCKED_FOR_VALIDATION]: history.set_task_locked_action(action) elif action == TaskAction.COMMENT: history.set_comment_action(comment) elif action == TaskAction.STATE_CHANGE: history.set_state_change_action(new_state) elif action in [ TaskAction.AUTO_UNLOCKED_FOR_MAPPING, TaskAction.AUTO_UNLOCKED_FOR_VALIDATION, ]: history.set_auto_unlock_action(action) if mapping_issues is not None: history.task_mapping_issues = mapping_issues self.task_history.append(history) return history def lock_task_for_mapping(self, user_id: int): self.set_task_history(TaskAction.LOCKED_FOR_MAPPING, user_id) self.task_status = TaskStatus.LOCKED_FOR_MAPPING.value self.locked_by = user_id self.update() def lock_task_for_validating(self, user_id: int): self.set_task_history(TaskAction.LOCKED_FOR_VALIDATION, user_id) self.task_status = TaskStatus.LOCKED_FOR_VALIDATION.value self.locked_by = user_id self.update() def reset_task(self, user_id: int): expiry_delta = Task.auto_unlock_delta() lock_duration = (datetime.datetime.min + expiry_delta).time().isoformat() if TaskStatus(self.task_status) in [ TaskStatus.LOCKED_FOR_MAPPING, TaskStatus.LOCKED_FOR_VALIDATION, ]: self.record_auto_unlock(lock_duration) self.set_task_history(TaskAction.STATE_CHANGE, user_id, None, TaskStatus.READY) self.mapped_by = None self.validated_by = None self.locked_by = None self.task_status = TaskStatus.READY.value self.update() def clear_task_lock(self): """ Unlocks task in scope in the database. Clears the lock as though it never happened. No history of the unlock is recorded. :return: """ # clear the lock action for the task in the task history last_action = TaskHistory.get_last_locked_action(self.project_id, self.id) last_action.delete() # Set locked_by to null and status to last status on task self.clear_lock() def record_auto_unlock(self, lock_duration): locked_user = self.locked_by last_action = TaskHistory.get_last_locked_action(self.project_id, self.id) next_action = ( TaskAction.AUTO_UNLOCKED_FOR_MAPPING if last_action.action == "LOCKED_FOR_MAPPING" else TaskAction.AUTO_UNLOCKED_FOR_VALIDATION ) self.clear_task_lock() # Add AUTO_UNLOCKED action in the task history auto_unlocked = self.set_task_history(action=next_action, user_id=locked_user) auto_unlocked.action_text = lock_duration self.update() def unlock_task( self, user_id, new_state=None, comment=None, undo=False, issues=None ): """ Unlock task and ensure duration task locked is saved in History """ if comment: self.set_task_history( action=TaskAction.COMMENT, comment=comment, user_id=user_id, mapping_issues=issues, ) history = self.set_task_history( action=TaskAction.STATE_CHANGE, new_state=new_state, user_id=user_id, mapping_issues=issues, ) if ( new_state in [TaskStatus.MAPPED, TaskStatus.BADIMAGERY] and TaskStatus(self.task_status) != TaskStatus.LOCKED_FOR_VALIDATION ): # Don't set mapped if state being set back to mapped after validation self.mapped_by = user_id elif new_state == TaskStatus.VALIDATED: TaskInvalidationHistory.record_validation( self.project_id, self.id, user_id, history ) self.validated_by = user_id elif new_state == TaskStatus.INVALIDATED: TaskInvalidationHistory.record_invalidation( self.project_id, self.id, user_id, history ) self.mapped_by = None self.validated_by = None if not undo: # Using a slightly evil side effect of Actions and Statuses having the same name here :) TaskHistory.update_task_locked_with_duration( self.id, self.project_id, TaskStatus(self.task_status), user_id ) self.task_status = new_state.value self.locked_by = None self.update() def reset_lock(self, user_id, comment=None): """Removes a current lock from a task, resets to last status and updates history with duration of lock""" if comment: self.set_task_history( action=TaskAction.COMMENT, comment=comment, user_id=user_id ) # Using a slightly evil side effect of Actions and Statuses having the same name here :) TaskHistory.update_task_locked_with_duration( self.id, self.project_id, TaskStatus(self.task_status), user_id ) self.clear_lock() def clear_lock(self): """ Resets to last status and removes current lock from a task """ self.task_status = TaskHistory.get_last_status(self.project_id, self.id).value self.locked_by = None self.update() @staticmethod def get_tasks_as_geojson_feature_collection( project_id, task_ids_str: str = None, order_by: str = None, order_by_type: str = "ASC", status: int = None, ): """ Creates a geoJson.FeatureCollection object for tasks related to the supplied project ID :param project_id: Owning project ID :order_by: sorting option: available values update_date and building_area_diff :status: task status id to filter by :return: geojson.FeatureCollection """ # subquery = ( # db.session.query(func.max(TaskHistory.action_date)) # .filter( # Task.id == TaskHistory.task_id, # Task.project_id == TaskHistory.project_id, # ) # .correlate(Task) # .group_by(Task.id) # .label("update_date") # ) query = db.session.query( Task.id, Task.x, Task.y, Task.zoom, Task.is_square, Task.task_status, Task.geometry.ST_AsGeoJSON().label("geojson"), Task.locked_by, # subquery, ) filters = [Task.project_id == project_id] if task_ids_str: task_ids = map(int, task_ids_str.split(",")) tasks = Task.get_tasks(project_id, task_ids) if not tasks or len(tasks) == 0: raise NotFound() else: tasks_filters = [task.id for task in tasks] filters = [Task.project_id == project_id, Task.id.in_(tasks_filters)] else: tasks = Task.get_all_tasks(project_id) if not tasks or len(tasks) == 0: raise NotFound() if status: filters.append(Task.task_status == status) if order_by == "effort_prediction": query = query.outerjoin(TaskAnnotation).filter(*filters) if order_by_type == "DESC": query = query.order_by( desc( cast( cast(TaskAnnotation.properties["building_area_diff"], Text), Float, ) ) ) else: query = query.order_by( cast( cast(TaskAnnotation.properties["building_area_diff"], Text), Float, ) ) # elif order_by == "last_updated": # if order_by_type == "DESC": # query = query.filter(*filters).order_by(desc("update_date")) # else: # query = query.filter(*filters).order_by("update_date") else: query = query.filter(*filters) project_tasks = query.all() tasks_features = [] for task in project_tasks: task_geometry = geojson.loads(task.geojson) task_properties = dict( taskId=task.id, taskX=task.x, taskY=task.y, taskZoom=task.zoom, taskIsSquare=task.is_square, taskStatus=TaskStatus(task.task_status).name, lockedBy=task.locked_by, ) feature = geojson.Feature( geometry=task_geometry, properties=task_properties ) tasks_features.append(feature) return geojson.FeatureCollection(tasks_features) @staticmethod def get_tasks_as_geojson_feature_collection_no_geom(project_id): """ Creates a geoJson.FeatureCollection object for all tasks related to the supplied project ID without geometry :param project_id: Owning project ID :return: geojson.FeatureCollection """ project_tasks = ( db.session.query( Task.id, Task.x, Task.y, Task.zoom, Task.is_square, Task.task_status ) .filter(Task.project_id == project_id) .all() ) tasks_features = [] for task in project_tasks: task_properties = dict( taskId=task.id, taskX=task.x, taskY=task.y, taskZoom=task.zoom, taskIsSquare=task.is_square, taskStatus=TaskStatus(task.task_status).name, ) feature = geojson.Feature(properties=task_properties) tasks_features.append(feature) return geojson.FeatureCollection(tasks_features) @staticmethod def get_mapped_tasks_by_user(project_id: int): """ Gets all mapped tasks for supplied project grouped by user""" results = ( db.session.query( User.username, User.mapping_level, func.count(distinct(Task.id)), func.json_agg(distinct(Task.id)), func.max(TaskHistory.action_date), User.date_registered, User.last_validation_date, ) .filter(Task.project_id == TaskHistory.project_id) .filter(Task.id == TaskHistory.task_id) .filter(Task.mapped_by == User.id) .filter(Task.project_id == project_id) .filter(Task.task_status == 2) .filter(TaskHistory.action_text == "MAPPED") .group_by( User.username, User.mapping_level, User.date_registered, User.last_validation_date, ) ) mapped_tasks_dto = MappedTasks() for row in results: user_mapped = MappedTasksByUser() user_mapped.username = row[0] user_mapped.mapping_level = MappingLevel(row[1]).name user_mapped.mapped_task_count = row[2] user_mapped.tasks_mapped = row[3] user_mapped.last_seen = row[4] user_mapped.date_registered = row[5] user_mapped.last_validation_date = row[6] mapped_tasks_dto.mapped_tasks.append(user_mapped) return mapped_tasks_dto @staticmethod def get_max_task_id_for_project(project_id: int): """Gets the nights task id currently in use on a project""" result = ( db.session.query(func.max(Task.id)) .filter(Task.project_id == project_id) .group_by(Task.project_id) ) if result.count() == 0: raise NotFound() for row in result: return row[0] def as_dto( self, task_history: List[TaskHistoryDTO] = [], last_updated: datetime.datetime = None, comments: int = None, ): """Just converts to a TaskDTO""" task_dto = TaskDTO() task_dto.task_id = self.id task_dto.project_id = self.project_id task_dto.task_status = TaskStatus(self.task_status).name task_dto.lock_holder = self.lock_holder.username if self.lock_holder else None task_dto.task_history = task_history task_dto.last_updated = last_updated if last_updated else None task_dto.auto_unlock_seconds = Task.auto_unlock_delta().total_seconds() task_dto.comments_number = comments if type(comments) == int else None return task_dto def as_dto_with_instructions(self, preferred_locale: str = "en") -> TaskDTO: """Get dto with any task instructions""" task_history = [] for action in self.task_history: history = TaskHistoryDTO() history.history_id = action.id history.action = action.action history.action_text = action.action_text history.action_date = action.action_date history.action_by = ( action.actioned_by.username if action.actioned_by else None ) history.picture_url = ( action.actioned_by.picture_url if action.actioned_by else None ) if action.task_mapping_issues: history.issues = [ issue.as_dto() for issue in action.task_mapping_issues ] task_history.append(history) last_updated = None if len(task_history) > 0: last_updated = task_history[0].action_date task_dto = self.as_dto(task_history, last_updated=last_updated) per_task_instructions = self.get_per_task_instructions(preferred_locale) # If we don't have instructions in preferred locale try again for default locale task_dto.per_task_instructions = ( per_task_instructions if per_task_instructions else self.get_per_task_instructions(self.projects.default_locale) ) annotations = self.get_per_task_annotations() task_dto.task_annotations = annotations if annotations else [] return task_dto def get_per_task_annotations(self): result = [ta.get_dto() for ta in self.task_annotations] return result def get_per_task_instructions(self, search_locale: str) -> str: """ Gets any per task instructions attached to the project """ project_info = self.projects.project_info.all() for info in project_info: if info.locale == search_locale: return self.format_per_task_instructions(info.per_task_instructions) def format_per_task_instructions(self, instructions) -> str: """ Format instructions by looking for X, Y, Z tokens and replacing them with the task values """ if not instructions: return "" # No instructions so return empty string properties = {} if self.x: properties["x"] = str(self.x) if self.y: properties["y"] = str(self.y) if self.zoom: properties["z"] = str(self.zoom) if self.extra_properties: properties.update(json.loads(self.extra_properties)) try: instructions = instructions.format(**properties) except KeyError: pass return instructions def copy_task_history(self) -> list: copies = [] for entry in self.task_history: db.session.expunge(entry) make_transient(entry) entry.id = None entry.task_id = None db.session.add(entry) copies.append(entry) return copies def get_locked_tasks_for_user(user_id: int): """ Gets tasks on project owned by specified user id""" tasks = Task.query.filter_by(locked_by=user_id) tasks_dto = LockedTasksForUser() for task in tasks: tasks_dto.locked_tasks.append(task.id) tasks_dto.project = task.project_id tasks_dto.task_status = TaskStatus(task.task_status).name return tasks_dto def get_locked_tasks_details_for_user(user_id: int): """ Gets tasks on project owned by specified user id""" tasks = Task.query.filter_by(locked_by=user_id) locked_tasks = [task for task in tasks] return locked_tasks