From b0f43874f650df5875ba73ab9091a365b57a736f Mon Sep 17 00:00:00 2001 From: Neil Rotstan Date: Thu, 15 Nov 2018 11:24:01 -0800 Subject: [PATCH] Make task auto-unlock duration configurable * Add `TASK_AUTOUNLOCK_AFTER` configuration setting to server that allows task auto-unlock delay to be configured (defaults to 2 hours, preserving prior behavior) * Include configured delay in task data sent to front-end * Update front-end task timer to make use of delay duration provided by server instead of using hard-coded 2 hours --- client/app/project/project.controller.js | 2 +- server/api/project_apis.py | 2 +- server/config.py | 4 ++++ server/models/dtos/mapping_dto.py | 1 + server/models/postgis/task.py | 14 ++++++++++---- server/models/postgis/utils.py | 16 ++++++++++++++++ 6 files changed, 33 insertions(+), 6 deletions(-) diff --git a/client/app/project/project.controller.js b/client/app/project/project.controller.js index 95daa4d2d8..4c15f4e96a 100644 --- a/client/app/project/project.controller.js +++ b/client/app/project/project.controller.js @@ -381,7 +381,7 @@ } if (task != null && task.taskId in vm.lockTime) { var lockTime = moment.utc(vm.lockTime[task.taskId]); - return lockTime.add(2, 'hours').diff(moment.utc(), 'minutes'); + return lockTime.add(task.autoUnlockSeconds, 'seconds').diff(moment.utc(), 'minutes'); } else { return null; diff --git a/server/api/project_apis.py b/server/api/project_apis.py index 0d65f79969..6ae4339b44 100644 --- a/server/api/project_apis.py +++ b/server/api/project_apis.py @@ -68,7 +68,7 @@ def get(self, project_id): current_app.logger.critical(error_msg) return {"error": error_msg}, 500 finally: - # this will try to unlock tasks older than 2 hours + # this will try to unlock tasks that have been locked too long try: ProjectService.auto_unlock_tasks(project_id) except Exception as e: diff --git a/server/config.py b/server/config.py index 32bc46cf70..c1a8552d4b 100644 --- a/server/config.py +++ b/server/config.py @@ -11,6 +11,10 @@ class EnvironmentConfig: # Mapper Level values represent number of OSM changesets MAPPER_LEVEL_INTERMEDIATE = 250 MAPPER_LEVEL_ADVANCED = 500 + # Time to wait until task auto-unlock, + # e.g. '2h' (2 hours) or '7d' (7 days) or '30m' (30 minutes) or '1h30m' (1.5 hours) + TASK_AUTOUNLOCK_AFTER = '2h' + OSM_OAUTH_SETTINGS = { 'base_url': 'https://www.openstreetmap.org/api/0.6/', 'consumer_key': os.getenv('TM_CONSUMER_KEY', None), diff --git a/server/models/dtos/mapping_dto.py b/server/models/dtos/mapping_dto.py index 2ab6db645a..2a817de206 100644 --- a/server/models/dtos/mapping_dto.py +++ b/server/models/dtos/mapping_dto.py @@ -62,6 +62,7 @@ class TaskDTO(Model): task_history = ListType(ModelType(TaskHistoryDTO), serialized_name='taskHistory') per_task_instructions = StringType(serialized_name='perTaskInstructions', serialize_when_none=False) is_undoable = BooleanType(serialized_name='isUndoable', default=False) + auto_unlock_seconds = IntType(serialized_name='autoUnlockSeconds') class TaskDTOs(Model): diff --git a/server/models/postgis/task.py b/server/models/postgis/task.py index d12277ddd2..88ed859730 100644 --- a/server/models/postgis/task.py +++ b/server/models/postgis/task.py @@ -3,6 +3,7 @@ import geojson import json from enum import Enum +from flask import current_app from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound from sqlalchemy.orm.session import make_transient from geoalchemy2 import Geometry @@ -13,7 +14,7 @@ from server.models.dtos.project_dto import ProjectComment, ProjectCommentsDTO from server.models.postgis.statuses import TaskStatus, MappingLevel from server.models.postgis.user import User -from server.models.postgis.utils import InvalidData, InvalidGeoJson, ST_GeomFromGeoJSON, ST_SetSRID, timestamp, NotFound +from server.models.postgis.utils import InvalidData, InvalidGeoJson, ST_GeomFromGeoJSON, ST_SetSRID, timestamp, parse_duration, NotFound class TaskAction(Enum): @@ -319,10 +320,14 @@ 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 more than 2 hours ago""" - expiry_delta = datetime.timedelta(hours=2) + """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_locks_query = '''SELECT t.id @@ -339,7 +344,7 @@ def auto_unlock_tasks(project_id: int): old_tasks = db.engine.execute(old_locks_query) if old_tasks.rowcount == 0: - # no tasks older than 2 hours found, return without further processing + # no tasks older than the delta found, return without further processing return for old_task in old_tasks: @@ -558,6 +563,7 @@ def as_dto_with_instructions(self, preferred_locale: str = 'en') -> TaskDTO: 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.auto_unlock_seconds = Task.auto_unlock_delta().total_seconds() per_task_instructions = self.get_per_task_instructions(preferred_locale) diff --git a/server/models/postgis/utils.py b/server/models/postgis/utils.py index 4f551d1d19..e67fa0a49e 100644 --- a/server/models/postgis/utils.py +++ b/server/models/postgis/utils.py @@ -1,5 +1,6 @@ import datetime import json +import re from flask import current_app from geoalchemy2 import Geometry from geoalchemy2.functions import GenericFunction @@ -88,6 +89,21 @@ def timestamp(): return datetime.datetime.utcnow() +# Based on https://stackoverflow.com/a/51916936 +duration_regex = re.compile(r'^((?P[\.\d]+?)d)?((?P[\.\d]+?)h)?((?P[\.\d]+?)m)?((?P[\.\d]+?)s)?$') +def parse_duration(time_str): + """ + Parse a duration string e.g. (2h13m) into a timedelta object. + + :param time_str: A string identifying a duration. (eg. 2h13m) + :return datetime.timedelta: A datetime.timedelta object + """ + parts = duration_regex.match(time_str) + assert parts is not None, "Could not parse duration from '{}'".format(time_str) + time_params = {name: float(param) for name, param in parts.groupdict().items() if param} + return datetime.timedelta(**time_params) + + class DateTimeEncoder(json.JSONEncoder): """ Custom JSON Encoder that handles Python date/times