diff --git a/client/app/admin/mapping-issues/mapping-issue-categories-edit.controller.js b/client/app/admin/mapping-issues/mapping-issue-categories-edit.controller.js new file mode 100644 index 0000000000..f566f00201 --- /dev/null +++ b/client/app/admin/mapping-issues/mapping-issue-categories-edit.controller.js @@ -0,0 +1,133 @@ +(function () { + + 'use strict'; + + /** + * Mapping-issue Categories controller which manages viewing, creating and + * editing categories + */ + angular + .module('taskingManager') + .controller('mappingIssueCategoriesEditController', + ['$routeParams', '$location', 'mappingIssueService', mappingIssueCategoriesEditController]); + + function mappingIssueCategoriesEditController($routeParams, $location, mappingIssueService) { + var vm = this; + + vm.category = {}; + vm.isNew = false; + vm.isCategoryFound = false; + vm.isSaveEditsSuccessful = true; + vm.isArchiveSuccessful = true; + vm.isDeleteSuccessful = true; + vm.isCreateNewSuccessful = true; + vm.id = 0; + + activate(); + + function activate() { + vm.id = $routeParams.id; + if (vm.id === 'new'){ + vm.isNew = true; + } + else { + vm.id = parseInt(vm.id, 10); + var resultsPromise = mappingIssueService.getMappingIssueCategories(true); + resultsPromise.then(function (data) { + // On success + vm.category = data.categories.find(function(category) { + return category.categoryId === vm.id; + }); + + vm.isCategoryFound = !!vm.category; + }, function(){ + // On error + vm.isCategoryFound = false; + }); + } + } + + /** + * Cancel editing the category by going to the list of categories + */ + vm.cancel = function(){ + $location.path('/admin/mapping-issues/categories'); + }; + + /** + * Save the edits made to the category + */ + vm.saveEdits = function(){ + vm.isSaveEditsSuccessful = true; + var resultsPromise = mappingIssueService.updateMappingIssueCategory(vm.category, vm.id); + resultsPromise.then(function () { + // On success + $location.path('/admin/mapping-issues/categories'); + }, function () { + // On error + vm.isSaveEditsSuccessful = false; + }); + }; + + /** + * Archive the category + */ + vm.archive = function(){ + vm.category.archived = true; + vm.updateArchivedFlag() + }; + + /** + * Unarchive the category + */ + vm.unarchive = function(){ + vm.category.archived = false; + vm.updateArchivedFlag() + }; + + /** + * Perform an update of the category's archived flag + */ + vm.updateArchivedFlag = function() { + vm.isArchiveSuccessful = true; + var resultsPromise = mappingIssueService.updateMappingIssueCategory(vm.category, vm.id); + resultsPromise.then(function () { + // On success + $location.path('/admin/mapping-issues/categories'); + }, function () { + // On error + vm.isArchiveSuccessful = false; + }); + }; + + /** + * Delete the category + */ + vm.delete = function(){ + vm.isDeleteSuccessful = true; + var resultsPromise = mappingIssueService.deleteMappingIssueCategory(vm.id); + resultsPromise.then(function () { + // On success + $location.path('/admin/mapping-issues/categories'); + }, function () { + // On error + vm.isDeleteSuccessful = false; + }); + }; + + /** + * Create a new category + */ + vm.createNewMappingIssueCategory = function(){ + vm.isCreateNewSuccessful = true; + var resultsPromise = mappingIssueService.createMappingIssueCategory(vm.category); + resultsPromise.then(function () { + // On success + $location.path('/admin/mapping-issues/categories'); + }, function () { + // On error + vm.isCreateNewSuccessful = false; + }); + }; + } +})(); diff --git a/client/app/admin/mapping-issues/mapping-issue-categories-edit.html b/client/app/admin/mapping-issues/mapping-issue-categories-edit.html new file mode 100644 index 0000000000..6957b04fed --- /dev/null +++ b/client/app/admin/mapping-issues/mapping-issue-categories-edit.html @@ -0,0 +1,89 @@ +
+
+
+
+

+ {{ 'Edit Mapping-issue Category' | translate }} +

+
+
+
+
+
+
+ + + +
+
+ +
+
    +
  • + + +
  • +
  • + + +
  • +
+
+ + +
+
+ + +
+
+
+
+

{{ 'No category found.' | translate }}

+ +
+
+

{{ 'Failed to save edits. Please try again.' | translate }}

+
+
+

{{ 'Failed to archive/unarchive category. Please try again.' | translate }}

+
+
+

{{ 'Failed to delete category. Note that categories cannot be deleted once in use, but must be archived instead' | translate }}

+
+
+

{{ 'Failed to create a new category. Please try again.' | translate }}

+
+
+
+
+
+
+
diff --git a/client/app/admin/mapping-issues/mapping-issue-categories.controller.js b/client/app/admin/mapping-issues/mapping-issue-categories.controller.js new file mode 100644 index 0000000000..5284f4dbe0 --- /dev/null +++ b/client/app/admin/mapping-issues/mapping-issue-categories.controller.js @@ -0,0 +1,48 @@ +(function () { + + 'use strict'; + + /** + * Mapping-issue Categories controller which manages viewing, creating and + * editing categories of mapping issues that can be flagged during validation. + */ + angular + .module('taskingManager') + .controller('mappingIssueCategoriesController', + ['$location', 'mappingIssueService', mappingIssueCategoriesController]); + + function mappingIssueCategoriesController($location, mappingIssueService) { + var vm = this; + + vm.includeArchived = false; + vm.issueCategories = []; + + loadCategories(); + + function loadCategories() { + var resultsPromise = mappingIssueService.getMappingIssueCategories(vm.includeArchived); + resultsPromise.then(function (data) { + // On success + vm.issueCategories = data.categories; + }, function(){ + // On error + }); + } + + /** + * Toggle whether to fetch categories that have been archived when fetching + * the issue categories + */ + vm.toggleIncudeArchived = function(){ + vm.includeArchived = !vm.includeArchived; + loadCategories(); + }; + + /** + * Create a new license + */ + vm.createNewMappingIssueCategory = function(){ + $location.path('/admin/mapping-issues/categories/edit/new'); + } + } +})(); diff --git a/client/app/admin/mapping-issues/mapping-issue-categories.html b/client/app/admin/mapping-issues/mapping-issue-categories.html new file mode 100644 index 0000000000..7426ad6096 --- /dev/null +++ b/client/app/admin/mapping-issues/mapping-issue-categories.html @@ -0,0 +1,57 @@ +
+
+
+
+
+

+ {{ 'Mapping-issue Categories' | translate }} +

+
+ +
+ +
+

+ {{ 'Broad categories for classifying mapping issues noted during validation' | translate }} +

+
+ + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + +
{{ 'Name' | translate }}{{ 'Description' | translate }}{{ 'Archived' | translate }}{{ 'Edit' | translate }}
{{ category.name }}{{ category.description }} + + + + {{ 'Edit' | translate }} + +
+
+
+
+
+
diff --git a/client/app/components/account-nav/account-nav.directive.js b/client/app/components/account-nav/account-nav.directive.js index 46370cb71b..50bfebe7a3 100644 --- a/client/app/components/account-nav/account-nav.directive.js +++ b/client/app/components/account-nav/account-nav.directive.js @@ -113,6 +113,14 @@ $location.path('admin/licenses'); }; + /** + * Navigate to the mapping-issue category management page + */ + vm.goToManageMappingIssueCategories = function () { + $location.path('admin/mapping-issues/categories'); + vm.showDropdown = false; + }; + /** * Navigate to the project dashboard page */ diff --git a/client/app/components/account-nav/account-nav.html b/client/app/components/account-nav/account-nav.html index 81e86b2e70..4f2d8fccdb 100644 --- a/client/app/components/account-nav/account-nav.html +++ b/client/app/components/account-nav/account-nav.html @@ -50,6 +50,11 @@ {{ 'Manage licenses' | translate }} +
  • + + {{ 'Manage issues' | translate }} + +
  • {{ 'Project dashboard' | translate }} diff --git a/client/app/project/project.controller.js b/client/app/project/project.controller.js index f9006cd598..9d23bc9499 100644 --- a/client/app/project/project.controller.js +++ b/client/app/project/project.controller.js @@ -8,11 +8,12 @@ */ angular .module('taskingManager') - .controller('projectController', ['$timeout', '$interval', '$scope', '$location', '$routeParams', '$window', '$q', 'moment', 'configService', 'mapService', 'projectService', 'styleService', 'taskService', 'geospatialService', 'editorService', 'authService', 'accountService', 'userService', 'licenseService', 'messageService', 'drawService', 'languageService', 'userPreferencesService', projectController]); + .controller('projectController', ['$timeout', '$interval', '$scope', '$location', '$routeParams', '$window', '$q', 'moment', 'configService', 'mapService', 'projectService', 'styleService', 'taskService', 'mappingIssueService', 'geospatialService', 'editorService', 'authService', 'accountService', 'userService', 'licenseService', 'messageService', 'drawService', 'languageService', 'userPreferencesService', projectController]); - function projectController($timeout, $interval, $scope, $location, $routeParams, $window, $q, moment, configService, mapService, projectService, styleService, taskService, geospatialService, editorService, authService, accountService, userService, licenseService, messageService, drawService, languageService, userPreferencesService) { + function projectController($timeout, $interval, $scope, $location, $routeParams, $window, $q, moment, configService, mapService, projectService, styleService, taskService, mappingIssueService, geospatialService, editorService, authService, accountService, userService, licenseService, messageService, drawService, languageService, userPreferencesService) { var vm = this; vm.id = 0; + vm.selectedIssueCategory = null; vm.loaded = false; vm.projectData = null; vm.taskVectorLayer = null; @@ -56,6 +57,9 @@ vm.lockTime = {}; vm.multiSelectedTasksData = []; vm.multiLockedTasks = []; + vm.mappingIssueCategories = []; + vm.availableMappingIssueCategories = []; + vm.mappingIssues = []; //editor vm.editorStartError = ''; @@ -108,6 +112,12 @@ mapService.addOverviewMap(); vm.map = mapService.getOSMMap(); vm.loaded = false; + + mappingIssueService.getMappingIssueCategories().then(function(data) { + vm.mappingIssueCategories = data.categories; + vm.availableMappingIssueCategories = vm.remainingMappingIssueCategories(); + }); + vm.id = $routeParams.id; vm.highlightHistory = $routeParams.history ? parseInt($routeParams.history, 10) : null; @@ -166,6 +176,76 @@ return vm.map.getSize()[1] * 0.3; } + /** + * Returns array of mapping issue categories for which no issues have + * been noted as of yet + */ + vm.remainingMappingIssueCategories = function() { + return vm.mappingIssueCategories.filter(function(category) { + return !vm.isMappingIssueCategoryInUse(category); + }); + }; + + /** + * Returns true if mapping issues have already been noted in the given + * issue category + */ + vm.isMappingIssueCategoryInUse = function(category) { + return !!vm.mappingIssues.find(function(issue) { + return issue.mappingIssueCategoryId === category.categoryId; + }); + }; + + /** + * Add the currently selected mapping issue to the list of noted issues + * with an initial count of 1 issue + */ + vm.addMappingIssue = function() { + if (!vm.selectedIssueCategory) { + return; + } + + vm.mappingIssues.push({ + mappingIssueCategoryId: vm.selectedIssueCategory.categoryId, + issue: vm.selectedIssueCategory.name, + count: 1, + }); + + vm.availableMappingIssueCategories = vm.remainingMappingIssueCategories(); + vm.selectedIssueCategory = null; + }; + + /** + * Removes the given issue from the array of current mapping issues + */ + vm.removeMappingIssue = function(issueToRemove) { + vm.mappingIssues = vm.mappingIssues.filter(function(issue) { + return issue.mappingIssueCategoryId !== issueToRemove.mappingIssueCategoryId; + }); + + // Now that we've freed up an additional issue category, update the + // available categories + vm.availableMappingIssueCategories = vm.remainingMappingIssueCategories(); + }; + + /** + * Determines if any mapping issues are currently set + */ + vm.hasMappingIssues = function () { + return !!vm.mappingIssues.find(function(issue) { + return issue.count > 0; + }); + }; + + /** + * Reset mapping issue properties + */ + vm.resetMappingIssues = function() { + vm.mappingIssues = []; + vm.selectedIssueCategory = null; + vm.availableMappingIssueCategories = vm.remainingMappingIssueCategories(); + }; + /** * convenience method to reset task data controller properties */ @@ -175,6 +255,7 @@ vm.multiSelectedTasksData = []; vm.multiLockedTasks = []; vm.lockedTasksForCurrentUser = []; + vm.resetMappingIssues(); }; vm.updatePreferedEditor = function () { @@ -880,6 +961,7 @@ vm.lockedTaskData = null; vm.multiSelectedTasksData = []; vm.multiLockedTasks = []; + vm.resetMappingIssues(); vm.resetErrors(); vm.resetStatusFlags(); vm.clearCurrentSelection(); @@ -909,6 +991,7 @@ vm.lockedTaskData = null; vm.multiSelectedTasksData = []; vm.multiLockedTasks = []; + vm.resetMappingIssues(); setUpSelectedTask(data); // TODO: This is a bit icky. Need to find something better. Maybe when roles are in place. // Need to make a decision on what tab to go to if user has clicked map but is not on mapping or validating @@ -1128,7 +1211,8 @@ var tasks = [{ comment: comment, status: status, - taskId: taskId + taskId: taskId, + validationIssues: vm.mappingIssues, }]; var unLockPromise = taskService.unLockTaskValidation(projectId, tasks); vm.comment = ''; diff --git a/client/app/project/project.html b/client/app/project/project.html index 37e55d38ec..c01f0a6d0d 100644 --- a/client/app/project/project.html +++ b/client/app/project/project.html @@ -822,6 +822,30 @@
    {{ 'Get started by choosing your editor of choice.' | translate }}
    + +
    +
    {{ 'Note Mapping Issues (optional)' | translate }}
    +
    + + +
    + +
    +
    + + + +
    +
    +
    +
    {{ 'Done editing? Leave a comment and select one of the options below that matches your editing status.' | translate }}
    @@ -968,7 +992,7 @@
    {{ 'History' | translate }}
    {{ 'Automatically unlocked for validation' | translate }} {{ 'Marked as bad imagery' | translate }} {{ 'Mapped' | translate }} - {{ 'Validated' | translate }} + {{ 'Validated' | translate }} {{ (item.issues ? '(with fixes)' : '') | translate }} {{ 'Invalidated' | translate }} {{ 'Split' | translate }} {{ 'Marked as ready' | translate }} @@ -985,6 +1009,34 @@
    {{ 'History' | translate }}
    + + +
    + + {{ 'Noted Mapping Issues' | translate }} +
    +
      +
    • + {{ issue.name }}: {{issue.count}} +
    • +
    + + + + +
    + + {{ 'Fixed Mapping Issues' | translate }} +
    +
      +
    • + {{ issue.name }}: {{issue.count}} +
    • +
    + +
    diff --git a/client/app/services/mapping_issue.service.js b/client/app/services/mapping_issue.service.js new file mode 100644 index 0000000000..5102fa270f --- /dev/null +++ b/client/app/services/mapping_issue.service.js @@ -0,0 +1,129 @@ +(function () { + 'use strict'; + /** + * @fileoverview This file provides a service for task-mapping issues. + */ + + angular + .module('taskingManager') + .service('mappingIssueService', ['$http', '$q','configService', 'authService', mappingIssueService]); + + function mappingIssueService($http, $q, configService, authService) { + + var service = { + getMappingIssueCategory: getMappingIssueCategory, + createMappingIssueCategory: createMappingIssueCategory, + deleteMappingIssueCategory: deleteMappingIssueCategory, + updateMappingIssueCategory: updateMappingIssueCategory, + getMappingIssueCategories: getMappingIssueCategories, + }; + + return service; + + /** + * Get the mapping-issue category for the ID + * @param id - mapping-issue category id + * @returns {*} + */ + function getMappingIssueCategory(id) { + // Returns a promise + return $http({ + method: 'GET', + url: configService.tmAPI + '/mapping-issue-category/' + id, + headers: authService.getAuthenticatedHeader() + }).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"); + }); + } + + /** + * Create a new mapping-issue category + * @param categoryData + * @returns {*|!jQuery.deferred|!jQuery.jqXHR|!jQuery.Promise} + */ + function createMappingIssueCategory(categoryData){ + // Returns a promise + return $http({ + method: 'POST', + url: configService.tmAPI + '/mapping-issue-category', + data: categoryData, + headers: authService.getAuthenticatedHeader() + }).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"); + }); + } + + /** + * Delete a mapping-issue category + * @param id + * @returns {*|!jQuery.deferred|!jQuery.jqXHR|!jQuery.Promise} + */ + function deleteMappingIssueCategory(id){ + // Returns a promise + return $http({ + method: 'DELETE', + url: configService.tmAPI + '/mapping-issue-category/' + id, + headers: authService.getAuthenticatedHeader() + }).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"); + }); + } + + function updateMappingIssueCategory(categoryData, id){ + // Returns a promise + return $http({ + method: 'PUT', + url: configService.tmAPI + '/mapping-issue-category/' + id, + data: categoryData, + headers: authService.getAuthenticatedHeader() + }).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"); + }); + } + + /** + * Get all existing mapping-issue categories + * @returns {*|!jQuery.Promise|!jQuery.deferred|!jQuery.jqXHR} + */ + function getMappingIssueCategories(includeArchived){ + // Returns a promise + return $http({ + method: 'GET', + url: configService.tmAPI + '/mapping-issue-categories' + + (includeArchived ? '?includeArchived=true' : ''), + }).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"); + }); + } + } +})(); diff --git a/client/app/taskingmanager.app.js b/client/app/taskingmanager.app.js index 5d8ba80a83..e3ec988e4b 100644 --- a/client/app/taskingmanager.app.js +++ b/client/app/taskingmanager.app.js @@ -160,6 +160,18 @@ title: 'Edit Licenses' }) + .when('/admin/mapping-issues/categories', { + templateUrl: 'app/admin/mapping-issues/mapping-issue-categories.html', + controller: 'mappingIssueCategoriesController', + controllerAs: 'mappingIssueCategoriesCtrl' + }) + + .when('/admin/mapping-issues/categories/edit/:id', { + templateUrl: 'app/admin/mapping-issues/mapping-issue-categories-edit.html', + controller: 'mappingIssueCategoriesEditController', + controllerAs: 'mappingIssueCategoriesEditCtrl' + }) + .when('/admin/dashboard', { templateUrl: 'app/admin/dashboard/dashboard.html', controller: 'dashboardController', diff --git a/client/assets/styles/sass/_project.scss b/client/assets/styles/sass/_project.scss index ca9103424b..b2bae418fd 100644 --- a/client/assets/styles/sass/_project.scss +++ b/client/assets/styles/sass/_project.scss @@ -172,6 +172,25 @@ font-size: smaller; } +.mapping-issues { + .form-field { + label { + display: inline-block; + width: 175px; + } + + input[type="number"] { + max-width: 4em; + text-align: right; + margin: 0 0.5em; + } + } + + &__add-mapping-issue { + margin-top: 1.5em; + } +} + .chat--individual { margin-bottom: 1.5em; } diff --git a/client/assets/styles/sass/_tables.scss b/client/assets/styles/sass/_tables.scss index 0ee32231e0..4d79373961 100644 --- a/client/assets/styles/sass/_tables.scss +++ b/client/assets/styles/sass/_tables.scss @@ -39,6 +39,22 @@ word-break: break-word; } + td.issue-summary { + color: $darker-red; + + .issue-summary__heading { + margin-bottom: 1em; + } + + .issue-summary__marker { + font-size: 18px; + } + + &--fixed { + color: inherit; + } + } + th:first-child, td:first-child { padding-left: 1rem; diff --git a/migrations/versions/22e7d7e0fa02_.py b/migrations/versions/22e7d7e0fa02_.py new file mode 100644 index 0000000000..7057281e45 --- /dev/null +++ b/migrations/versions/22e7d7e0fa02_.py @@ -0,0 +1,61 @@ +"""empty message + +Revision ID: 22e7d7e0fa02 +Revises: fcd9cebaa79c +Create Date: 2018-08-06 15:31:03.973448 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '22e7d7e0fa02' +down_revision = 'fcd9cebaa79c' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + categories_table = op.create_table( + 'mapping_issue_categories', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('description', sa.String(), nullable=True), + sa.Column('archived', sa.Boolean(), nullable=False, server_default='false'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + + op.create_table('task_mapping_issues', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('mapping_issue_category_id', sa.Integer(), nullable=False), + sa.Column('task_history_id', sa.Integer(), nullable=False), + sa.Column('issue', sa.String(), nullable=False), + sa.Column('count', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['task_history_id'], ['task_history.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_task_mapping_issues_task_history_id'), 'task_mapping_issues', ['task_history_id'], unique=False) + op.create_foreign_key('fk_issue_category', 'task_mapping_issues', 'mapping_issue_categories', ['mapping_issue_category_id'], ['id']) + + # Setup some initial issue categories + initial_categories = [ + { 'id': 1, 'name': 'Missed Feature(s)' }, + { 'id': 2, 'name': 'Feature Geometry' }, + ] + + for category in initial_categories: + op.execute(categories_table.insert().values(name=category['name'])) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('fk_issue_category', 'task_mapping_issues', type_='foreignkey') + op.drop_index(op.f('ix_task_mapping_issues_task_history_id'), table_name='task_mapping_issues') + op.drop_table('task_mapping_issues') + op.drop_table('mapping_issue_categories') + # ### end Alembic commands ### diff --git a/server/__init__.py b/server/__init__.py index d91ea5ea5e..77944d1cf1 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -107,6 +107,7 @@ def init_flask_restful_routes(app): from server.api.swagger_docs_api import SwaggerDocsAPI from server.api.stats_api import StatsContributionsAPI, StatsActivityAPI, StatsProjectAPI, HomePageStatsAPI, StatsUserAPI from server.api.tags_apis import CampaignsTagsAPI, OrganisationTagsAPI + from server.api.mapping_issues_apis import MappingIssueCategoryAPI, MappingIssueCategoriesAPI from server.api.users.user_apis import UserAPI, UserIdAPI, UserOSMAPI, UserMappedProjects, UserSetRole, UserSetLevel,\ UserSetExpertMode, UserAcceptLicense, UserSearchFilterAPI, UserSearchAllAPI, UserUpdateAPI from server.api.validator_apis import LockTasksForValidationAPI, UnlockTasksAfterValidationAPI, StopValidatingAPI,\ @@ -166,6 +167,9 @@ def init_flask_restful_routes(app): api.add_resource(HomePageStatsAPI, '/api/v1/stats/summary') api.add_resource(CampaignsTagsAPI, '/api/v1/tags/campaigns') api.add_resource(OrganisationTagsAPI, '/api/v1/tags/organisations') + api.add_resource(MappingIssueCategoryAPI, '/api/v1/mapping-issue-category', endpoint="create_mapping_issue_category", methods=['POST']) + api.add_resource(MappingIssueCategoryAPI, '/api/v1/mapping-issue-category/', methods=['GET', 'PUT', 'DELETE']) + api.add_resource(MappingIssueCategoriesAPI, '/api/v1/mapping-issue-categories') api.add_resource(UserSearchAllAPI, '/api/v1/user/search-all') api.add_resource(UserSearchFilterAPI, '/api/v1/user/search/filter/') api.add_resource(UserAPI, '/api/v1/user/') diff --git a/server/api/mapping_issues_apis.py b/server/api/mapping_issues_apis.py new file mode 100644 index 0000000000..a97d560223 --- /dev/null +++ b/server/api/mapping_issues_apis.py @@ -0,0 +1,234 @@ +from flask_restful import Resource, current_app, request +from schematics.exceptions import DataError + +from server.models.dtos.mapping_issues_dto import MappingIssueCategoryDTO +from server.models.postgis.utils import NotFound +from server.services.mapping_issues_service import MappingIssueCategoryService +from server.services.users.authentication_service import token_auth, tm + + +class MappingIssueCategoryAPI(Resource): + @tm.pm_only() + @token_auth.login_required + def post(self): + """ + Creates a new mapping-issue category + --- + tags: + - mapping issues + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: body + name: body + required: true + description: JSON object for creating a new mapping-issue category + schema: + properties: + name: + type: string + required: true + description: + type: string + responses: + 200: + description: New mapping-issue category created + 400: + description: Invalid Request + 401: + description: Unauthorized - Invalid credentials + 500: + description: Internal Server Error + """ + try: + category_dto = MappingIssueCategoryDTO(request.get_json()) + category_dto.validate() + except DataError as e: + current_app.logger.error(f'Error validating request: {str(e)}') + return str(e), 400 + + try: + new_category_id = MappingIssueCategoryService.create_mapping_issue_category(category_dto) + return {"categoryId": new_category_id}, 200 + except Exception as e: + error_msg = f'Mapping-issue category POST - unhandled error: {str(e)}' + current_app.logger.critical(error_msg) + return {"error": error_msg}, 500 + + def get(self, category_id): + """ + Get specified mapping-issue category + --- + tags: + - mapping issues + produces: + - application/json + parameters: + - name: category_id + in: path + description: The unique mapping-issue category ID + required: true + type: integer + default: 1 + responses: + 200: + description: Mapping-issue category found + 404: + description: Mapping-issue category not found + 500: + description: Internal Server Error + """ + try: + category_dto = MappingIssueCategoryService.get_category_as_dto(category_id) + return category_dto.to_primitive(), 200 + except NotFound: + return {"Error": "Mapping-issue category Not Found"}, 404 + except Exception as e: + error_msg = f'Mapping-issue category PUT - unhandled error: {str(e)}' + current_app.logger.critical(error_msg) + return {"error": error_msg}, 500 + + @tm.pm_only() + @token_auth.login_required + def put(self, category_id): + """ + Update an existing mapping-issue category + --- + tags: + - mapping issues + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: category_id + in: path + description: The unique mapping-issue category ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object for updating a mapping-issue category + schema: + properties: + name: + type: string + description: + type: string + responses: + 200: + description: Mapping-issue category updated + 400: + description: Invalid Request + 401: + description: Unauthorized - Invalid credentials + 500: + description: Internal Server Error + """ + try: + category_dto = MappingIssueCategoryDTO(request.get_json()) + category_dto.category_id = category_id + category_dto.validate() + except DataError as e: + current_app.logger.error(f'Error validating request: {str(e)}') + return str(e), 400 + + try: + updated_category = MappingIssueCategoryService.update_mapping_issue_category(category_dto) + return updated_category.to_primitive(), 200 + except NotFound: + return {"Error": "Mapping-issue category Not Found"}, 404 + except Exception as e: + error_msg = f'Mapping-issue category PUT - unhandled error: {str(e)}' + current_app.logger.critical(error_msg) + return {"error": error_msg}, 500 + + @tm.pm_only() + @token_auth.login_required + def delete(self, category_id): + """ + Delete the specified mapping-issue category. Note that categories can + be deleted only if they have never been associated with a task. To + instead archive a used category that is no longer needed, update the + category with its archived flag set to true. + --- + tags: + - mapping issues + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: category_id + in: path + description: The unique mapping-issue category ID + required: true + type: integer + default: 1 + responses: + 200: + description: Mapping-issue category deleted + 401: + description: Unauthorized - Invalid credentials + 404: + description: Mapping-issue category not found + 500: + description: Internal Server Error + """ + try: + MappingIssueCategoryService.delete_mapping_issue_category(category_id) + return {"Success": "Mapping-issue category deleted"}, 200 + except NotFound: + return {"Error": "Mapping-issue category Not Found"}, 404 + except Exception as e: + error_msg = f'Mapping-issue category DELETE - unhandled error: {str(e)}' + current_app.logger.critical(error_msg) + return {"error": error_msg}, 500 + + +class MappingIssueCategoriesAPI(Resource): + + def get(self): + """ + Gets all mapping issue categories + --- + tags: + - mapping issues + produces: + - application/json + parameters: + - in: query + name: includeArchived + description: Optional filter to include archived categories + type: boolean + default: false + responses: + 200: + description: Mapping issue categories + 500: + description: Internal Server Error + """ + try: + include_archived = request.args.get('includeArchived') == 'true' + categories = MappingIssueCategoryService.get_all_mapping_issue_categories(include_archived) + return categories.to_primitive(), 200 + 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/mapping_dto.py b/server/models/dtos/mapping_dto.py index 09cb7c3541..a558530fda 100644 --- a/server/models/dtos/mapping_dto.py +++ b/server/models/dtos/mapping_dto.py @@ -3,6 +3,7 @@ from schematics.types import StringType, IntType, DateTimeType, BooleanType from schematics.types.compound import ListType, ModelType from server.models.postgis.statuses import TaskStatus +from server.models.dtos.mapping_issues_dto import TaskMappingIssueDTO def is_valid_mapped_status(value): @@ -53,6 +54,7 @@ class TaskHistoryDTO(Model): action_text = StringType(serialized_name='actionText') action_date = DateTimeType(serialized_name='actionDate') action_by = StringType(serialized_name='actionBy') + issues = ListType(ModelType(TaskMappingIssueDTO)) class TaskDTO(Model): diff --git a/server/models/dtos/mapping_issues_dto.py b/server/models/dtos/mapping_issues_dto.py new file mode 100644 index 0000000000..41d843904d --- /dev/null +++ b/server/models/dtos/mapping_issues_dto.py @@ -0,0 +1,24 @@ +from schematics import Model +from schematics.types import IntType, StringType, BooleanType, ModelType +from schematics.types.compound import ListType + +class MappingIssueCategoryDTO(Model): + """ DTO used to define a mapping-issue category """ + category_id = IntType(serialized_name='categoryId') + name = StringType(required=True) + description = StringType(required=False) + archived = BooleanType(required=False) + +class MappingIssueCategoriesDTO(Model): + """ DTO for all mapping-issue categories """ + def __init__(self): + super().__init__() + self.categories = [] + + categories = ListType(ModelType(MappingIssueCategoryDTO)) + +class TaskMappingIssueDTO(Model): + """ DTO used to define a single mapping issue recorded with a task invalidation """ + category_id = IntType(serialized_name='categoryId') + name = StringType(required=True) + count = IntType(required=True) diff --git a/server/models/dtos/validator_dto.py b/server/models/dtos/validator_dto.py index 263a495d01..6abdc99153 100644 --- a/server/models/dtos/validator_dto.py +++ b/server/models/dtos/validator_dto.py @@ -26,17 +26,26 @@ class LockForValidationDTO(Model): preferred_locale = StringType(default='en') +class ValidationMappingIssue(Model): + """ Describes one or more occurences of an identified mapping problem during validation """ + mapping_issue_category_id = IntType(required=True, serialized_name='mappingIssueCategoryId') + issue = StringType(required=True) + count = IntType(required=True) + + class ValidatedTask(Model): """ Describes the model used to update the status of one task after validation """ task_id = IntType(required=True, serialized_name='taskId') status = StringType(required=True, validators=[is_valid_validated_status]) comment = StringType() + issues = ListType(ModelType(ValidationMappingIssue), serialized_name='validationIssues') class ResetValidatingTask(Model): """ Describes the model used to stop validating and reset the status of one task """ task_id = IntType(required=True, serialized_name='taskId') comment = StringType() + issues = ListType(ModelType(ValidationMappingIssue), serialized_name='validationIssues') class UnlockAfterValidationDTO(Model): diff --git a/server/models/postgis/mapping_issues.py b/server/models/postgis/mapping_issues.py new file mode 100644 index 0000000000..0aeac5b964 --- /dev/null +++ b/server/models/postgis/mapping_issues.py @@ -0,0 +1,73 @@ +from server import db +from server.models.dtos.mapping_issues_dto import MappingIssueCategoryDTO, MappingIssueCategoriesDTO + +class MappingIssueCategory(db.Model): + """ Represents a category of task mapping issues identified during validaton """ + __tablename__ = "mapping_issue_categories" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String, nullable=False, unique=True) + description = db.Column(db.String, nullable=True) + archived = db.Column(db.Boolean, default=False, nullable=False) + + def __init__(self, name): + self.name = name + + @staticmethod + def get_by_id(category_id: int): + """ Get category by id """ + return MappingIssueCategory.query.get(category_id) + + @classmethod + def create_from_dto(cls, dto: MappingIssueCategoryDTO) -> int: + """ Creates a new MappingIssueCategory class from dto """ + new_category = cls(dto.name) + new_category.description = dto.description + + db.session.add(new_category) + db.session.commit() + + return new_category.id + + def update_category(self, dto: MappingIssueCategoryDTO): + """ Update existing category """ + self.name = dto.name + self.description = dto.description + if dto.archived is not None: + self.archived = dto.archived + db.session.commit() + + def delete(self): + """ Deletes the current model from the DB """ + db.session.delete(self) + db.session.commit() + + @staticmethod + def get_all_categories(include_archived): + category_query = MappingIssueCategory.query.order_by(MappingIssueCategory.name) + if not include_archived: + category_query = category_query.filter_by(archived=False) + + results = category_query.all() + if len(results) == 0: + raise NotFound() + + dto = MappingIssueCategoriesDTO() + for result in results: + category = MappingIssueCategoryDTO() + category.category_id = result.id + category.name = result.name + category.description = result.description + category.archived = result.archived + dto.categories.append(category) + + return dto + + def as_dto(self) -> MappingIssueCategoryDTO: + """ Convert the category to its DTO representation """ + dto = MappingIssueCategoryDTO() + dto.category_id = self.id + dto.name = self.name + dto.description = self.description + dto.archived = self.archived + + return dto diff --git a/server/models/postgis/task.py b/server/models/postgis/task.py index 35386554b8..4207a05268 100644 --- a/server/models/postgis/task.py +++ b/server/models/postgis/task.py @@ -12,6 +12,7 @@ from server.models.dtos.mapping_dto import TaskDTO, TaskHistoryDTO from server.models.dtos.validator_dto import MappedTasksByUser, MappedTasks, InvalidatedTask, InvalidatedTasks from server.models.dtos.project_dto import ProjectComment, ProjectCommentsDTO +from server.models.dtos.mapping_issues_dto import TaskMappingIssueDTO 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, parse_duration, NotFound @@ -103,6 +104,37 @@ def record_validation(project_id, task_id, validator_id, history): 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" @@ -117,6 +149,7 @@ class TaskHistory(db.Model): 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'), {}) @@ -450,7 +483,7 @@ def is_mappable(self): return True - def set_task_history(self, action, user_id, comment=None, new_state=None): + 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 @@ -458,6 +491,7 @@ def set_task_history(self, action, user_id, comment=None, new_state=None): :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) @@ -470,6 +504,9 @@ def set_task_history(self, action, user_id, comment=None, new_state=None): 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 @@ -522,12 +559,13 @@ def record_auto_unlock(self, lock_duration): auto_unlocked.action_text = lock_duration self.update() - def unlock_task(self, user_id, new_state=None, comment=None, undo=False): + 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) + 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) + 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 @@ -663,6 +701,8 @@ def as_dto_with_instructions(self, preferred_locale: str = 'en') -> TaskDTO: 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 + if action.task_mapping_issues: + history.issues = [issue.as_dto() for issue in action.task_mapping_issues] task_history.append(history) diff --git a/server/services/mapping_issues_service.py b/server/services/mapping_issues_service.py new file mode 100644 index 0000000000..b53f546ad5 --- /dev/null +++ b/server/services/mapping_issues_service.py @@ -0,0 +1,47 @@ +from server.models.postgis.mapping_issues import MappingIssueCategory +from server.models.dtos.mapping_issues_dto import MappingIssueCategoryDTO + +class MappingIssueCategoryService: + + @staticmethod + def get_mapping_issue_category(category_id: int) -> MappingIssueCategory: + """ + Get MappingIssueCategory from DB + :raises: NotFound + """ + category = MappingIssueCategory.get_by_id(category_id) + + if category is None: + raise NotFound() + + return category + + @staticmethod + def get_mapping_issue_category_as_dto(category_id: int) -> MappingIssueCategoryDTO: + """ Get MappingIssueCategory from DB """ + category = MappingIssueCategoryService.get_mapping_issue_category(category_id) + return category.as_dto() + + @staticmethod + def create_mapping_issue_category(category_dto: MappingIssueCategoryDTO) -> int: + """ Create MappingIssueCategory in DB """ + new_mapping_issue_category_id = MappingIssueCategory.create_from_dto(category_dto) + return new_mapping_issue_category_id + + @staticmethod + def update_mapping_issue_category(category_dto: MappingIssueCategoryDTO) -> MappingIssueCategoryDTO: + """ Create MappingIssueCategory in DB """ + category = MappingIssueCategoryService.get_mapping_issue_category(category_dto.category_id) + category.update_category(category_dto) + return category.as_dto() + + @staticmethod + def delete_mapping_issue_category(category_id: int): + """ Delete specified license""" + category = MappingIssueCategoryService.get_mapping_issue_category(category_id) + category.delete() + + @staticmethod + def get_all_mapping_issue_categories(include_archived): + """ Get all mapping issue categories""" + return MappingIssueCategory.get_all_categories(include_archived) diff --git a/server/services/validator_service.py b/server/services/validator_service.py index 6f4bdc8d59..2bcc5be0a5 100644 --- a/server/services/validator_service.py +++ b/server/services/validator_service.py @@ -4,7 +4,7 @@ from server.models.dtos.stats_dto import Pagination from server.models.dtos.validator_dto import LockForValidationDTO, UnlockAfterValidationDTO, MappedTasks, StopValidationDTO, InvalidatedTask, InvalidatedTasks from server.models.postgis.statuses import ValidatingNotAllowed -from server.models.postgis.task import Task, TaskStatus, TaskHistory, TaskInvalidationHistory +from server.models.postgis.task import Task, TaskStatus, TaskHistory, TaskInvalidationHistory, TaskAction, TaskMappingIssue from server.models.postgis.utils import NotFound, UserLicenseError, timestamp from server.models.postgis.project_info import ProjectInfo from server.services.messaging.message_service import MessageService @@ -117,7 +117,8 @@ def unlock_tasks_after_validation(validated_dto: UnlockAfterValidationDTO) -> Ta StatsService.update_stats_after_task_state_change(validated_dto.project_id, validated_dto.user_id, task_to_unlock['new_state'], task.id) - task.unlock_task(validated_dto.user_id, task_to_unlock['new_state'], task_to_unlock['comment']) + task_mapping_issues = ValidatorService.get_task_mapping_issues(task_to_unlock) + task.unlock_task(validated_dto.user_id, task_to_unlock['new_state'], task_to_unlock['comment'], issues=task_mapping_issues) dtos.append(task.as_dto_with_instructions(validated_dto.preferred_locale)) @@ -187,7 +188,8 @@ def get_tasks_locked_by_user(project_id: int, unlock_tasks, user_id: int): new_status = None tasks_to_unlock.append(dict(task=task, new_state=new_status, - comment=unlock_task.comment)) + comment=unlock_task.comment, + issues=unlock_task.issues)) return tasks_to_unlock @@ -271,3 +273,15 @@ def validate_all_tasks(project_id: int, user_id: int): project.tasks_mapped = (project.total_tasks - project.tasks_bad_imagery) project.tasks_validated = project.total_tasks project.save() + + @staticmethod + def get_task_mapping_issues(task_to_unlock: dict): + if task_to_unlock['issues'] is None: + return None + + # map ValidationMappingIssue DTOs to TaskMappingIssue instances for any issues + # that have count above zero. + return list(map(lambda issue_dto: TaskMappingIssue(issue=issue_dto.issue, + count=issue_dto.count, + mapping_issue_category_id=issue_dto.mapping_issue_category_id), + filter(lambda issue_dto: issue_dto.count > 0, task_to_unlock['issues'])))