In order to start contributing, please login first .
@@ -644,7 +653,11 @@
{{ 'Validation' | translate }}
-
{{ 'Validation' | translate }}
+
{{ 'Validation' | translate }}
+
+
+ {{ projectCtrl.getLockTime() }} {{ 'minutes left' | translate }}
+
@@ -653,6 +666,11 @@
{{ 'Validation' | translate }}
{{ 'The task could not be locked for validation.' | translate }} {{ projectCtrl.taskLockErrorMessage }}
+
+
+
{{ 'The task expired and was automatically unlocked.' | translate }}
+
+
diff --git a/client/app/services/editor.service.js b/client/app/services/editor.service.js
index f4b35a3cba..157f4bc308 100644
--- a/client/app/services/editor.service.js
+++ b/client/app/services/editor.service.js
@@ -6,9 +6,12 @@
angular
.module('taskingManager')
- .service('editorService', ['$window', '$location', 'mapService', 'configService', editorService]);
+ .service('editorService', ['$window', '$location', '$q', 'mapService', 'configService', editorService]);
- function editorService($window, $location, mapService, configService) {
+ var JOSM_COMMAND_TIMEOUT = 1000;
+ var josmLastCommand = 0;
+
+ function editorService($window, $location, $q, mapService, configService) {
var service = {
sendJOSMCmd: sendJOSMCmd,
@@ -109,36 +112,39 @@
* @returns {boolean} Did JOSM Repond successfully
*/
function sendJOSMCmd(endpoint, params) {
- // This has been implemented using XMLHTTP rather than Angular promises
- // THis was done because angular was adding request headers such that the browser was
- // preflighing the GET request with an OPTIONS requests due to CORS.
- // JOSM does not suppport the OPTIONS requests
- // After some time, we were unable to find a way to control the headrer to stop the preflighting
- // The workaround is as you see here, to use XMLHttpRequest in synchrounous mode
-
- var reqObj = window.XMLHttpRequest ? new XMLHttpRequest() : new ActiveXObject("Microsoft.XMLHTTP");//new XMLHttpRequest();
- var url = endpoint + formatUrlParams_(params);
- var success = false;
- reqObj.onreadystatechange = function () {
- if (this.readyState == 4) {
- if (this.status == 200) {
- success = true;
- }
- else {
- success = false;
+ var url = endpoint + formatUrlParams_(params),
+ loaded,
+ iframe;
+
+ return $q(function (resolve, reject) {
+ // Figure out when we can next run a command
+ var wait = Math.max(josmLastCommand + JOSM_COMMAND_TIMEOUT - Date.now(), 0);
+
+ // This remembers when we are going to run THIS command, and adds the timeout (yes, it is double-counted - this seems to be more reliable).
+ josmLastCommand = Date.now() + wait + JOSM_COMMAND_TIMEOUT;
+
+ setTimeout(function () {
+ iframe = document.createElement('iframe');
+ iframe.style.display = 'none';
+ iframe.addEventListener('load', function () {
+ if (loaded === undefined) {
+ loaded = true;
+ resolve();
+ iframe.parentElement.removeChild(iframe);
+ }
+ });
+ iframe.setAttribute('src', url);
+ document.body.appendChild(iframe);
+ }, wait);
+
+ setTimeout(function () {
+ if (loaded === undefined) {
+ loaded = false;
+ reject();
+ iframe.parentElement.removeChild(iframe);
}
- }
- };
- try {
- //use synchronous mode. Not ideal but should be ok since JOSM is local.
- //Otherwise callbacks would be required
- reqObj.open('GET', url, false);
- reqObj.send();
- }
- catch (e) {
- success = false;
- }
- return success;
+ }, wait + JOSM_COMMAND_TIMEOUT);
+ });
}
/**
@@ -149,7 +155,7 @@
* @returns string - gpxUrl
*/
function getGPXUrl(projectId, taskIds, as_file){
- var gpxUrl = configService.tmAPI + '/project/' + projectId + '/tasks_as_gpx?tasks=' + taskIds + '&as_file='+(as_file?true:false);
+ var gpxUrl = configService.tmAPI + '/project/' + projectId + '/tasks_as_gpx?tasks=' + taskIds + '&as_file='+(as_file?true:false) + '&filename=task.gpx';
// If it is not a full path, then it must be relative and for the GPX callback to work it needs
// a full URL so get the current host and append it
// Check if it is a full URL
diff --git a/client/app/services/user.service.js b/client/app/services/user.service.js
index 19b84b4922..ef67882fe3 100644
--- a/client/app/services/user.service.js
+++ b/client/app/services/user.service.js
@@ -15,6 +15,7 @@
setLevel: setLevel,
getOSMUserDetails: getOSMUserDetails,
getUserProjects: getUserProjects,
+ getUserStats: getUserStats,
searchUser: searchUser,
searchAllUsers: searchAllUsers,
acceptLicense: acceptLicense,
@@ -117,6 +118,30 @@
})
}
+ /**
+ * Get detailed stats about the user
+ * @param username
+ * @returns {!jQuery.jqXHR|!jQuery.deferred|*|!jQuery.Promise}
+ */
+ function getUserStats(username){
+ // Returns a promise
+ return $http({
+ method: 'GET',
+ url: configService.tmAPI + '/stats/user/' + username,
+ headers: {
+ 'Content-Type': 'application/json; charset=UTF-8'
+ }
+ }).then(function successCallback(response) {
+ // this callback will be called asynchronously
+ // when the response is available
+ return response.data;
+ }, function errorCallback() {
+ // called asynchronously if an error occurs
+ // or server returns response with an error status.
+ return $q.reject("error");
+ })
+ }
+
/**
* Search a user
* @returns {!jQuery.jqXHR|*|!jQuery.deferred|!jQuery.Promise}
diff --git a/devops/tm2-pg-migration/migrationscripts.sql b/devops/tm2-pg-migration/migrationscripts.sql
index 7f5805125b..14e886d318 100644
--- a/devops/tm2-pg-migration/migrationscripts.sql
+++ b/devops/tm2-pg-migration/migrationscripts.sql
@@ -65,6 +65,17 @@ INSERT INTO hotnew.projects(
select setval('hotnew.projects_id_seq',(select max(id) from hotnew.projects));
+-- Set the task_creation_mode to 'arbitrary' when project's zoom was None in
+-- TM2 or 'grid' when it was not None
+Update hotnew.projects
+ set task_creation_mode = 1
+ from hotold.projects as p
+ where p.id = hotnew.projects.id and p.zoom is NULL;
+
+Update hotnew.projects
+ set task_creation_mode = 0
+ from hotold.projects as p
+ where p.id = hotnew.projects.id and p.zoom is not NULL;
-- Project info & translations
-- Skip any records relating to projects that have not been imported
diff --git a/migrations/versions/deec8123583d_.py b/migrations/versions/deec8123583d_.py
new file mode 100644
index 0000000000..c01d70ca08
--- /dev/null
+++ b/migrations/versions/deec8123583d_.py
@@ -0,0 +1,43 @@
+"""empty message
+
+Revision ID: deec8123583d
+Revises: ac55902fcc3d
+Create Date: 2018-08-07 23:09:58.621826
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from server.models.postgis.project import Project
+from server.models.postgis.task import Task
+
+
+# revision identifiers, used by Alembic.
+revision = 'deec8123583d'
+down_revision = 'ac55902fcc3d'
+branch_labels = None
+depends_on = None
+
+projects = Project.__table__
+tasks = Task.__table__
+
+def upgrade():
+ conn = op.get_bind()
+
+ for project in conn.execute(projects.select()):
+ zooms = conn.execute(
+ sa.sql.expression.select([tasks.c.zoom]).distinct(tasks.c.zoom)
+ .where(tasks.c.project_id == project.id))
+ zooms = zooms.fetchall()
+
+ if len(zooms) == 1 and zooms[0] == (None,):
+ op.execute(
+ projects.update().where(projects.c.id == project.id)
+ .values(task_creation_mode=1))
+ else:
+ op.execute(
+ projects.update().where(projects.c.id == project.id)
+ .values(task_creation_mode=0))
+
+
+def downgrade():
+ pass
diff --git a/server/__init__.py b/server/__init__.py
index 8d010344df..044c459503 100644
--- a/server/__init__.py
+++ b/server/__init__.py
@@ -108,7 +108,7 @@ def init_flask_restful_routes(app):
from server.api.project_apis import ProjectAPI, ProjectAOIAPI, ProjectSearchAPI, HasUserTaskOnProject,\
HasUserTaskOnProjectDetails, ProjectSearchBBoxAPI, ProjectSummaryAPI
from server.api.swagger_docs_api import SwaggerDocsAPI
- from server.api.stats_api import StatsContributionsAPI, StatsActivityAPI, StatsProjectAPI, HomePageStatsAPI
+ from server.api.stats_api import StatsContributionsAPI, StatsActivityAPI, StatsProjectAPI, HomePageStatsAPI, StatsUserAPI
from server.api.tags_apis import CampaignsTagsAPI, OrganisationTagsAPI
from server.api.users.user_apis import UserAPI, UserOSMAPI, UserMappedProjects, UserSetRole, UserSetLevel,\
UserAcceptLicense, UserSearchFilterAPI, UserSearchAllAPI, UserUpdateAPI
@@ -161,6 +161,7 @@ def init_flask_restful_routes(app):
api.add_resource(StatsContributionsAPI, '/api/v1/stats/project/
/contributions')
api.add_resource(StatsActivityAPI, '/api/v1/stats/project//activity')
api.add_resource(StatsProjectAPI, '/api/v1/stats/project/')
+ api.add_resource(StatsUserAPI, '/api/v1/stats/user/')
api.add_resource(HomePageStatsAPI, '/api/v1/stats/home-page')
api.add_resource(CampaignsTagsAPI, '/api/v1/tags/campaigns')
api.add_resource(OrganisationTagsAPI, '/api/v1/tags/organisations')
diff --git a/server/api/stats_api.py b/server/api/stats_api.py
index c95e965161..c378d78094 100644
--- a/server/api/stats_api.py
+++ b/server/api/stats_api.py
@@ -1,6 +1,7 @@
from flask_restful import Resource, current_app, request
from server.services.stats_service import StatsService, NotFound
from server.services.project_service import ProjectService
+from server.services.users.user_service import UserService
class StatsContributionsAPI(Resource):
@@ -143,3 +144,38 @@ def get(self):
error_msg = f'Unhandled error: {str(e)}'
current_app.logger.critical(error_msg)
return {"error": error_msg}, 500
+
+
+class StatsUserAPI(Resource):
+ def get(self, username):
+ """
+ Get detailed stats about user
+ ---
+ tags:
+ - user
+ produces:
+ - application/json
+ parameters:
+ - name: username
+ in: path
+ description: The users username
+ required: true
+ type: string
+ default: Thinkwhere
+ responses:
+ 200:
+ description: User found
+ 404:
+ description: User not found
+ 500:
+ description: Internal Server Error
+ """
+ try:
+ stats_dto = UserService.get_detailed_stats(username)
+ return stats_dto.to_primitive(), 200
+ except NotFound:
+ return {"Error": "User not found"}, 404
+ except Exception as e:
+ error_msg = f'User GET - unhandled error: {str(e)}'
+ current_app.logger.critical(error_msg)
+ return {"error": error_msg}, 500
diff --git a/server/models/dtos/project_dto.py b/server/models/dtos/project_dto.py
index 1f022cd3a7..65fd0e6a32 100644
--- a/server/models/dtos/project_dto.py
+++ b/server/models/dtos/project_dto.py
@@ -4,7 +4,7 @@
from schematics.types.compound import ListType, ModelType
from server.models.dtos.user_dto import is_known_mapping_level
from server.models.dtos.stats_dto import Pagination
-from server.models.postgis.statuses import ProjectStatus, ProjectPriority, MappingTypes
+from server.models.postgis.statuses import ProjectStatus, ProjectPriority, MappingTypes, TaskCreationMode
def is_known_project_status(value):
@@ -42,6 +42,15 @@ def is_known_mapping_type(value):
f'{MappingTypes.LAND_USE.name}, {MappingTypes.OTHER.name}')
+def is_known_task_creation_mode(value):
+ """ Validates Task Creation Mode is known value """
+ try:
+ TaskCreationMode[value.upper()]
+ except KeyError:
+ raise ValidationError(f'Unknown taskCreationMode: {value} Valid values are {TaskCreationMode.GRID.name}, '
+ f'{TaskCreationMode.ARBITRARY.name}')
+
+
class DraftProjectDTO(Model):
""" Describes JSON model used for creating draft project """
cloneFromProjectId = IntType(serialized_name='cloneFromProjectId')
@@ -90,9 +99,12 @@ class ProjectDTO(Model):
license_id = IntType(serialized_name='licenseId')
allowed_usernames = ListType(StringType(), serialized_name='allowedUsernames', default=[])
priority_areas = BaseType(serialized_name='priorityAreas')
+ created = DateTimeType()
last_updated = DateTimeType(serialized_name='lastUpdated')
author = StringType()
active_mappers = IntType(serialized_name='activeMappers')
+ task_creation_mode = StringType(required=True, serialized_name='taskCreationMode',
+ validators=[is_known_task_creation_mode], serialize_when_none=False)
class ProjectSearchDTO(Model):
diff --git a/server/models/dtos/user_dto.py b/server/models/dtos/user_dto.py
index 83105c9968..165b2a626f 100644
--- a/server/models/dtos/user_dto.py
+++ b/server/models/dtos/user_dto.py
@@ -45,6 +45,11 @@ class UserDTO(Model):
linkedin_id = StringType(serialized_name='linkedinId')
+class UserStatsDTO(Model):
+ """ DTO containing statistics about the user """
+ time_spent_mapping = IntType(serialized_name='timeSpentMapping')
+
+
class UserOSMDTO(Model):
""" DTO containing OSM details for the user """
account_created = StringType(required=True, serialized_name='accountCreated')
diff --git a/server/models/postgis/project.py b/server/models/postgis/project.py
index e096fa0397..38b7f04b93 100644
--- a/server/models/postgis/project.py
+++ b/server/models/postgis/project.py
@@ -12,7 +12,7 @@
from server.models.dtos.project_dto import ProjectDTO, DraftProjectDTO, ProjectSummary, PMDashboardDTO
from server.models.postgis.priority_area import PriorityArea, project_priority_areas
from server.models.postgis.project_info import ProjectInfo
-from server.models.postgis.statuses import ProjectStatus, ProjectPriority, MappingLevel, TaskStatus, MappingTypes
+from server.models.postgis.statuses import ProjectStatus, ProjectPriority, MappingLevel, TaskStatus, MappingTypes, TaskCreationMode
from server.models.postgis.tags import Tags
from server.models.postgis.task import Task
from server.models.postgis.user import User
@@ -56,6 +56,7 @@ class Project(db.Model):
license_id = db.Column(db.Integer, db.ForeignKey('licenses.id', name='fk_licenses'))
geometry = db.Column(Geometry('MULTIPOLYGON', srid=4326))
centroid = db.Column(Geometry('POINT', srid=4326))
+ task_creation_mode = db.Column(db.Integer, default=TaskCreationMode.GRID.value, nullable=False)
# Tags
mapping_types = db.Column(ARRAY(db.Integer), index=True)
@@ -347,9 +348,11 @@ def _get_project_and_base_dto(self):
base_dto.campaign_tag = self.campaign_tag
base_dto.organisation_tag = self.organisation_tag
base_dto.license_id = self.license_id
+ base_dto.created = self.created
base_dto.last_updated = self.last_updated
base_dto.author = User().get_by_id(self.author_id).username
base_dto.active_mappers = Project.get_active_mappers(self.id)
+ base_dto.task_creation_mode = TaskCreationMode(self.task_creation_mode).name
if self.private:
# If project is private it should have a list of allowed users
diff --git a/server/models/postgis/statuses.py b/server/models/postgis/statuses.py
index 4c25849a4f..3141dc1e27 100644
--- a/server/models/postgis/statuses.py
+++ b/server/models/postgis/statuses.py
@@ -16,6 +16,12 @@ class ProjectPriority(Enum):
LOW = 3
+class TaskCreationMode(Enum):
+ """ Enum to describe task creation mode """
+ GRID = 0
+ ARBITRARY = 1
+
+
class TaskStatus(Enum):
""" Enum describing available Task Statuses """
READY = 0
diff --git a/server/models/postgis/task.py b/server/models/postgis/task.py
index cb744e3bf3..f38bae84d9 100644
--- a/server/models/postgis/task.py
+++ b/server/models/postgis/task.py
@@ -1,6 +1,7 @@
import bleach
import datetime
import geojson
+import json
from enum import Enum
from geoalchemy2 import Geometry
from server import db
@@ -19,6 +20,8 @@ class TaskAction(Enum):
LOCKED_FOR_VALIDATION = 2
STATE_CHANGE = 3
COMMENT = 4
+ AUTO_UNLOCKED_FOR_MAPPING = 5
+ AUTO_UNLOCKED_FOR_VALIDATION = 6
class TaskHistory(db.Model):
@@ -58,6 +61,9 @@ 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)
@@ -142,6 +148,7 @@ class Task(db.Model):
x = db.Column(db.Integer)
y = db.Column(db.Integer)
zoom = db.Column(db.Integer)
+ extra_properties = db.Column(db.Unicode)
# Tasks are not splittable if created from an arbitrary grid or were clipped to the edge of the AOI
splittable = db.Column(db.Boolean, default=True)
geometry = db.Column(Geometry('MULTIPOLYGON', srid=4326))
@@ -198,6 +205,10 @@ def from_geojson_feature(cls, task_id, task_feature):
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)
@@ -227,6 +238,7 @@ def get_all_tasks(project_id: int):
@staticmethod
def auto_unlock_tasks(project_id: int):
"""Unlock all tasks locked more than 2 hours ago"""
+ lock_duration = (datetime.datetime.min + datetime.timedelta(hours=2)).time().isoformat()
old_locks_query = '''SELECT t.id
FROM tasks t, task_history th
WHERE t.id = th.task_id
@@ -235,8 +247,8 @@ def auto_unlock_tasks(project_id: int):
AND th.action IN ( 'LOCKED_FOR_VALIDATION','LOCKED_FOR_MAPPING' )
AND th.action_text IS NULL
AND t.project_id = {0}
- AND AGE(TIMESTAMP '{1}', th.action_date) > '2 hours'
- '''.format(project_id, str(datetime.datetime.utcnow()))
+ AND AGE(TIMESTAMP '{1}', th.action_date) > '{2}'
+ '''.format(project_id, str(datetime.datetime.utcnow()), lock_duration)
old_tasks = db.engine.execute(old_locks_query)
@@ -246,7 +258,7 @@ def auto_unlock_tasks(project_id: int):
for old_task in old_tasks:
task = Task.get(old_task[0], project_id)
- task.clear_task_lock()
+ task.clear_task_lock(lock_duration)
def is_mappable(self):
""" Determines if task in scope is in suitable state for mapping """
@@ -272,8 +284,11 @@ def set_task_history(self, action, user_id, comment=None, new_state=None):
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)
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)
@@ -287,21 +302,26 @@ def lock_task_for_validating(self, user_id: int):
self.locked_by = user_id
self.update()
- def clear_task_lock(self):
+ def clear_task_lock(self, lock_duration):
"""
Unlocks task in scope in the database. Clears the lock as though it never happened.
- No history of the unlock is recorded.
:return:
"""
- # Set locked_by to null and status to last status on task
- self.locked_by = None
+ # Set status to last status on task
self.task_status = TaskHistory.get_last_status(self.project_id, self.id).value
- self.update()
# clear the lock action for the task in the task history
last_action = TaskHistory.get_last_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
last_action.delete()
+ # Add AUTO_UNLOCKED action in the task history and set locked_by to null
+ auto_unlocked = self.set_task_history(action=next_action, user_id=self.locked_by)
+ auto_unlocked.action_text = lock_duration
+ self.locked_by = None
+ self.update()
+
def unlock_task(self, user_id, new_state=None, comment=None, undo=False):
""" Unlock task and ensure duration task locked is saved in History """
if comment:
@@ -446,18 +466,20 @@ def format_per_task_instructions(self, instructions) -> str:
if not instructions:
return '' # No instructions so return empty string
- # If there's no dynamic URL (e.g. url containing '{x}, {y} and {z}' pattern)
- # - ALWAYS return instructions unaltered
-
- if not all(item in instructions for item in ['{x}','{y}','{z}']):
- return instructions
+ properties = {}
- # If there is a dyamic URL only return instructions if task is splittable, since we have the X, Y, Z
- if not self.splittable:
- return 'No extra instructions available for this task'
-
- instructions = instructions.replace('{x}', str(self.x))
- instructions = instructions.replace('{y}', str(self.y))
- instructions = instructions.replace('{z}', str(self.zoom))
+ 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
+
diff --git a/server/services/grid/grid_service.py b/server/services/grid/grid_service.py
index 76de43b982..87fd63688c 100644
--- a/server/services/grid/grid_service.py
+++ b/server/services/grid/grid_service.py
@@ -71,11 +71,13 @@ def tasks_from_aoi_features(feature_collection: str) -> geojson.FeatureCollectio
feature.geometry = shapely.geometry.mapping(feature.geometry)
# set default properties
+ # and put any already existing properties in `extra_properties`
feature.properties = {
'x': None,
'y': None,
'zoom': None,
- 'splittable': False
+ 'splittable': False,
+ 'extra_properties': feature.properties
}
tasks.append(feature)
diff --git a/server/services/project_admin_service.py b/server/services/project_admin_service.py
index 05e9187a69..f499a9f74d 100644
--- a/server/services/project_admin_service.py
+++ b/server/services/project_admin_service.py
@@ -5,6 +5,7 @@
from server.models.dtos.project_dto import DraftProjectDTO, ProjectDTO, ProjectCommentsDTO
from server.models.postgis.project import Project, Task, ProjectStatus
+from server.models.postgis.statuses import TaskCreationMode
from server.models.postgis.task import TaskHistory
from server.models.postgis.utils import NotFound, InvalidData, InvalidGeoJson
from server.services.grid.grid_service import GridService
@@ -49,6 +50,7 @@ def create_draft_project(draft_project_dto: DraftProjectDTO) -> int:
# if arbitrary_tasks requested, create tasks from aoi otherwise use tasks in DTO
if draft_project_dto.has_arbitrary_tasks:
tasks = GridService.tasks_from_aoi_features(draft_project_dto.area_of_interest)
+ draft_project.task_creation_mode = TaskCreationMode.ARBITRARY.value
else:
tasks = draft_project_dto.tasks
ProjectAdminService._attach_tasks_to_project(draft_project, tasks)
diff --git a/server/services/users/user_service.py b/server/services/users/user_service.py
index 593eef66b1..ec58373e6b 100644
--- a/server/services/users/user_service.py
+++ b/server/services/users/user_service.py
@@ -1,7 +1,12 @@
from cachetools import TTLCache, cached
from flask import current_app
+from functools import reduce
+import dateutil.parser
+import datetime
-from server.models.dtos.user_dto import UserDTO, UserOSMDTO, UserFilterDTO, UserSearchQuery, UserSearchDTO
+from server.models.dtos.user_dto import UserDTO, UserOSMDTO, UserFilterDTO, UserSearchQuery, UserSearchDTO, \
+ UserStatsDTO
+from server.models.postgis.task import TaskHistory
from server.models.postgis.user import User, UserRole, MappingLevel
from server.models.postgis.utils import NotFound
from server.services.users.osm_service import OSMService, OSMServiceError
@@ -81,6 +86,29 @@ def get_user_dto_by_username(requested_username: str, logged_in_user_id: int) ->
return requested_user.as_dto(logged_in_user.username)
+ @staticmethod
+ def get_detailed_stats(username: str):
+ user = UserService.get_user_by_username(username)
+ stats_dto = UserStatsDTO()
+
+ actions = TaskHistory.query.filter(
+ TaskHistory.user_id == user.id,
+ TaskHistory.action == 'LOCKED_FOR_MAPPING',
+ TaskHistory.action_text != ''
+ ).all()
+
+ total_time = datetime.datetime.min
+ for action in actions:
+ duration = dateutil.parser.parse(action.action_text)
+ total_time += datetime.timedelta(hours=duration.hour,
+ minutes=duration.minute,
+ seconds=duration.second,
+ microseconds=duration.microsecond)
+
+ stats_dto.time_spent_mapping = total_time.time().isoformat()
+
+ return stats_dto
+
@staticmethod
def update_user_details(user_id: int, user_dto: UserDTO) -> dict:
""" Update user with info supplied by user, if they add or change their email address a verification mail
diff --git a/server/web/static/swagger-ui/css/hot.css b/server/web/static/swagger-ui/css/hot.css
new file mode 100644
index 0000000000..81d02347d0
--- /dev/null
+++ b/server/web/static/swagger-ui/css/hot.css
@@ -0,0 +1,9 @@
+.swagger-section #header {
+ background-color: white;
+}
+
+.swagger-section #explore,
+.swagger-section #explore:hover {
+
+ background-color: #d53f3f;
+}
diff --git a/server/web/static/swagger-ui/images/hot-favicon.ico b/server/web/static/swagger-ui/images/hot-favicon.ico
new file mode 100644
index 0000000000..aa26f8b04f
Binary files /dev/null and b/server/web/static/swagger-ui/images/hot-favicon.ico differ
diff --git a/server/web/static/swagger-ui/images/hot-tm-logo.svg b/server/web/static/swagger-ui/images/hot-tm-logo.svg
new file mode 100644
index 0000000000..45338ca84b
--- /dev/null
+++ b/server/web/static/swagger-ui/images/hot-tm-logo.svg
@@ -0,0 +1,116 @@
+
+
+
+image/svg+xml
\ No newline at end of file
diff --git a/server/web/static/swagger-ui/index.html b/server/web/static/swagger-ui/index.html
index a7e46d5b32..f215d7660b 100644
--- a/server/web/static/swagger-ui/index.html
+++ b/server/web/static/swagger-ui/index.html
@@ -2,14 +2,14 @@
- Swagger UI
-
-
+ API Docs
+
+
@@ -91,7 +91,7 @@