From 9a70c07aca34f59d4f3ee98cb17617e260c30617 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Wed, 4 Aug 2021 13:09:27 -0400 Subject: [PATCH 01/11] Task UI improvements, PoC move task dialog --- app/api/projects.py | 6 + app/static/app/js/classes/StatusCodes.js | 2 +- app/static/app/js/components/FormDialog.jsx | 4 +- .../app/js/components/MoveTaskDialog.jsx | 104 ++++++++++++++++++ .../app/js/components/ProjectListItem.jsx | 7 ++ app/static/app/js/components/TaskList.jsx | 15 ++- app/static/app/js/components/TaskListItem.jsx | 99 ++++++++++++----- app/static/app/js/css/TaskListItem.scss | 7 +- 8 files changed, 207 insertions(+), 37 deletions(-) create mode 100644 app/static/app/js/components/MoveTaskDialog.jsx diff --git a/app/api/projects.py b/app/api/projects.py index 8cbd3b04..b1c749b8 100644 --- a/app/api/projects.py +++ b/app/api/projects.py @@ -37,3 +37,9 @@ class ProjectViewSet(viewsets.ModelViewSet): serializer_class = ProjectSerializer queryset = models.Project.objects.prefetch_related('task_set').filter(deleting=False).order_by('-created_at') ordering_fields = '__all__' + + # Disable pagination when not requesting any page + def paginate_queryset(self, queryset): + if self.paginator and self.request.query_params.get(self.paginator.page_query_param, None) is None: + return None + return super().paginate_queryset(queryset) \ No newline at end of file diff --git a/app/static/app/js/classes/StatusCodes.js b/app/static/app/js/classes/StatusCodes.js index 7339230f..6e0cce90 100644 --- a/app/static/app/js/classes/StatusCodes.js +++ b/app/static/app/js/classes/StatusCodes.js @@ -12,7 +12,7 @@ let statusCodes = { icon: "far fa-hourglass fa-fw" }, [RUNNING]: { - descr: _("Running"), + descr: _("Processing"), icon: "fa fa-cog fa-spin fa-fw" }, [FAILED]: { diff --git a/app/static/app/js/components/FormDialog.jsx b/app/static/app/js/components/FormDialog.jsx index e82c1d88..5597710c 100644 --- a/app/static/app/js/components/FormDialog.jsx +++ b/app/static/app/js/components/FormDialog.jsx @@ -17,7 +17,7 @@ class FormDialog extends React.Component { static propTypes = { getFormData: PropTypes.func.isRequired, - reset: PropTypes.func.isRequired, + reset: PropTypes.func, saveAction: PropTypes.func.isRequired, onShow: PropTypes.func, onHide: PropTypes.func, @@ -88,7 +88,7 @@ class FormDialog extends React.Component { } show(){ - this.props.reset(); + if (this.props.reset) this.props.reset(); this.setState({showModal: true, saving: false, error: ""}); } diff --git a/app/static/app/js/components/MoveTaskDialog.jsx b/app/static/app/js/components/MoveTaskDialog.jsx new file mode 100644 index 00000000..734bb097 --- /dev/null +++ b/app/static/app/js/components/MoveTaskDialog.jsx @@ -0,0 +1,104 @@ +import React from 'react'; +import FormDialog from './FormDialog'; +import PropTypes from 'prop-types'; +import { _ } from '../classes/gettext'; +import $ from 'jquery'; + +class MoveTaskDialog extends React.Component { + static defaultProps = { + title: _("Move Task"), + saveLabel: _("Save Changes"), + savingLabel: _("Moving..."), + saveIcon: "far fa-edit", + show: true + }; + + static propTypes = { + task: PropTypes.object.isRequired, + saveAction: PropTypes.func.isRequired, + title: PropTypes.string, + saveLabel: PropTypes.string, + savingLabel: PropTypes.string, + saveIcon: PropTypes.string, + show: PropTypes.bool + }; + + constructor(props){ + super(props); + + this.state = { + projectId: props.task.project.id, + projects: [], + loading: true + }; + + this.getFormData = this.getFormData.bind(this); + this.onShow = this.onShow.bind(this); + } + + getFormData(){ + return this.state; + } + + onShow(){ + this.setState({loading: true, projects: []}); + + // Load projects from API + this.serverRequest = $.getJSON(`/api/projects/?ordering=-created_at`, json => { + this.setState({ + projects: json.filter(p => p.permissions.indexOf("add") !== -1) + }); + }) + .fail((jqXHR, textStatus, errorThrown) => { + this.dialog.setState({ + error: interpolate(_("Could not load projects list: %(error)s"), {error: textStatus}) + }); + }) + .always(() => { + this.setState({loading: false}); + this.serverRequest = null; + }); + } + + show(){ + this.dialog.show(); + } + + hide(){ + this.dialog.hide(); + } + + componentWillUnmount(){ + if (this.serverRquest) this.serverRquest.abort(); + } + + handleProjectChange = e => { + this.setState({projectId: e.target.value}); + } + + render(){ + return ( + { this.dialog = domNode; }}> + {!this.state.loading ? +
+ +
+ +
+
+ : } +
+ ); + } +} + +export default MoveTaskDialog; \ No newline at end of file diff --git a/app/static/app/js/components/ProjectListItem.jsx b/app/static/app/js/components/ProjectListItem.jsx index 4b26fc1f..eb49af61 100644 --- a/app/static/app/js/components/ProjectListItem.jsx +++ b/app/static/app/js/components/ProjectListItem.jsx @@ -47,6 +47,7 @@ class ProjectListItem extends React.Component { this.handleEditProject = this.handleEditProject.bind(this); this.updateProject = this.updateProject.bind(this); this.taskDeleted = this.taskDeleted.bind(this); + this.taskMoved = this.taskMoved.bind(this); this.hasPermission = this.hasPermission.bind(this); } @@ -304,6 +305,10 @@ class ProjectListItem extends React.Component { this.refresh(); } + taskMoved(){ + this.refresh(); + } + handleDelete(){ return $.ajax({ url: `/api/projects/${this.state.data.id}/`, @@ -570,6 +575,8 @@ class ProjectListItem extends React.Component { ref={this.setRef("taskList")} source={`/api/projects/${data.id}/tasks/?ordering=-created_at`} onDelete={this.taskDeleted} + onMove={this.taskMoved} + hasPermission={this.hasPermission} history={this.props.history} /> : ""} diff --git a/app/static/app/js/components/TaskList.jsx b/app/static/app/js/components/TaskList.jsx index 330c535b..6d6279a5 100644 --- a/app/static/app/js/components/TaskList.jsx +++ b/app/static/app/js/components/TaskList.jsx @@ -9,7 +9,9 @@ class TaskList extends React.Component { static propTypes = { history: PropTypes.object.isRequired, source: PropTypes.string.isRequired, // URL where to load task list - onDelete: PropTypes.func + onDelete: PropTypes.func, + onMove: PropTypes.func, + hasPermission: PropTypes.func.isRequired } constructor(props){ @@ -69,6 +71,13 @@ class TaskList extends React.Component { if (this.props.onDelete) this.props.onDelete(id); } + moveTask(id){ + this.setState({ + tasks: this.state.tasks.filter(t => t.id !== id) + }); + if (this.props.onMove) this.props.onMove(id); + } + render() { let message = ""; if (this.state.loading){ @@ -88,7 +97,9 @@ class TaskList extends React.Component { data={task} key={task.id} refreshInterval={3000} - onDelete={this.deleteTask} + onDelete={this.deleteTask} + onMove={this.moveTask} + hasPermission={this.props.hasPermission} history={this.props.history} /> ))} diff --git a/app/static/app/js/components/TaskListItem.jsx b/app/static/app/js/components/TaskListItem.jsx index 145d7537..d76f05f5 100644 --- a/app/static/app/js/components/TaskListItem.jsx +++ b/app/static/app/js/components/TaskListItem.jsx @@ -9,6 +9,7 @@ import AssetDownloadButtons from './AssetDownloadButtons'; import HistoryNav from '../classes/HistoryNav'; import PropTypes from 'prop-types'; import TaskPluginActionButtons from './TaskPluginActionButtons'; +import MoveTaskDialog from './MoveTaskDialog'; import PipelineSteps from '../classes/PipelineSteps'; import Css from '../classes/Css'; import Trans from './Trans'; @@ -19,7 +20,9 @@ class TaskListItem extends React.Component { history: PropTypes.object.isRequired, data: PropTypes.object.isRequired, // task json refreshInterval: PropTypes.number, // how often to refresh info - onDelete: PropTypes.func + onDelete: PropTypes.func, + onMove: PropTypes.func, + hasPermission: PropTypes.func } constructor(props){ @@ -37,7 +40,8 @@ class TaskListItem extends React.Component { memoryError: false, friendlyTaskError: "", pluginActionButtons: [], - view: "basic" + view: "basic", + showMoveDialog: false } for (let k in props.data){ @@ -271,6 +275,10 @@ class TaskListItem extends React.Component { this.setAutoRefresh(); } + handleMoveTask = () => { + this.setState({showMoveDialog: true}); + } + getRestartSubmenuItems(){ const { task } = this.state; @@ -406,21 +414,6 @@ class TaskListItem extends React.Component { }); } - // Ability to change options - if ([statusCodes.FAILED, statusCodes.COMPLETED, statusCodes.CANCELED].indexOf(task.status) !== -1 || - (!task.processing_node)){ - addActionButton(_("Edit"), "btn-primary pull-right edit-button", "glyphicon glyphicon-pencil", () => { - this.startEditing(); - }, { - className: "inline" - }); - } - - if ([statusCodes.QUEUED, statusCodes.RUNNING, null].indexOf(task.status) !== -1 && - (task.processing_node || imported)){ - addActionButton(_("Cancel"), "btn-primary", "glyphicon glyphicon-remove-circle", this.genActionApiCall("cancel", {defaultError: _("Cannot cancel task.")})); - } - if ([statusCodes.FAILED, statusCodes.COMPLETED, statusCodes.CANCELED].indexOf(task.status) !== -1 && task.processing_node && !imported){ @@ -435,11 +428,6 @@ class TaskListItem extends React.Component { }); } - addActionButton(_("Delete"), "btn-danger", "glyphicon glyphicon-trash", this.genActionApiCall("remove", { - confirm: _("All information related to this task, including images, maps and models will be deleted. Continue?"), - defaultError: _("Cannot delete task.") - })); - const disabled = this.state.actionButtonsDisabled || ([pendingActions.CANCEL, pendingActions.REMOVE, @@ -580,6 +568,8 @@ class TaskListItem extends React.Component { ; } } + + let statusIcon = statusCodes.icon(task.status); // @param type {String} one of: ['neutral', 'done', 'error'] const getStatusLabel = (text, type = 'neutral', progress = 100) => { @@ -589,11 +579,10 @@ class TaskListItem extends React.Component { return (
{text}
); + title={text}> {text}); } let statusLabel = ""; - let statusIcon = statusCodes.icon(task.status); let showEditLink = false; if (task.last_error){ @@ -624,8 +613,57 @@ class TaskListItem extends React.Component { statusLabel = getStatusLabel(status, type, progress); } + const taskActions = []; + const addTaskAction = (label, icon, onClick) => { + taskActions.push( +
  • {label}
  • + ); + }; + + if ([statusCodes.QUEUED, statusCodes.RUNNING, null].indexOf(task.status) !== -1 && + (task.processing_node || imported) && this.props.hasPermission("change")){ + addTaskAction(_("Cancel"), "glyphicon glyphicon-remove-circle", this.genActionApiCall("cancel", {defaultError: _("Cannot cancel task.")})); + } + + // Ability to change options + const canAddDelPerms = this.props.hasPermission("add") && this.props.hasPermission("delete"); + const editable = [statusCodes.FAILED, statusCodes.COMPLETED, statusCodes.CANCELED].indexOf(task.status) !== -1; + + if (canAddDelPerms){ + if (editable || (!task.processing_node)){ + taskActions.push(
  • {_("Edit")}
  • ); + } + + if (editable){ + taskActions.push( +
  • {_("Move")}
  • , +
  • {_("Duplicate")}
  • + ); + } + } + + + if (this.props.hasPermission("delete")){ + taskActions.push( +
  • , + ); + + addTaskAction(_("Delete"), "glyphicon glyphicon-trash", this.genActionApiCall("remove", { + confirm: _("All information related to this task, including images, maps and models will be deleted. Continue?"), + defaultError: _("Cannot delete task.") + })); + } + return (
    + {this.state.showMoveDialog ? + { this.moveTaskDialog = domNode; }} + onHide={() => this.setState({showMoveDialog: false})} + saveAction={() => {}} + /> + : ""}
    {name} @@ -642,9 +680,16 @@ class TaskListItem extends React.Component { : statusLabel}
    -
    - -
    + {taskActions.length > 0 ? +
    + +
      + {taskActions} +
    +
    + : ""}
    {expanded} diff --git a/app/static/app/js/css/TaskListItem.scss b/app/static/app/js/css/TaskListItem.scss index 74847d58..bb34217c 100644 --- a/app/static/app/js/css/TaskListItem.scss +++ b/app/static/app/js/css/TaskListItem.scss @@ -31,11 +31,8 @@ } } - .status-icon{ - margin-top: 3px; - .fa-cog{ - width: auto; - } + .fa-cog{ + width: auto; } .status-label{ From 9bb8b1bed5e6bd71f8db8c0a140b1ea2e6339eb7 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Wed, 4 Aug 2021 16:20:51 -0400 Subject: [PATCH 02/11] Task moving functionality working --- app/models/task.py | 3 -- app/plugins/functions.py | 2 +- app/static/app/js/components/FormDialog.jsx | 21 +++++++++---- .../app/js/components/MoveTaskDialog.jsx | 30 ++++++++++--------- app/static/app/js/components/ProjectList.jsx | 10 ++++++- .../app/js/components/ProjectListItem.jsx | 10 ++++--- app/static/app/js/components/TaskList.jsx | 12 ++++---- app/static/app/js/components/TaskListItem.jsx | 17 +++++++++-- docker-compose.yml | 1 + webodm.sh | 5 ++++ webodm/settings.py | 1 + 11 files changed, 74 insertions(+), 38 deletions(-) diff --git a/app/models/task.py b/app/models/task.py index cf8d1d95..9de1d1de 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -310,9 +310,6 @@ class Task(models.Model): self.move_assets(self.__original_project_id, self.project.id) self.__original_project_id = self.project.id - # Autovalidate on save - self.full_clean() - super(Task, self).save(*args, **kwargs) def assets_path(self, *args): diff --git a/app/plugins/functions.py b/app/plugins/functions.py index 1ca0384d..1092f15f 100644 --- a/app/plugins/functions.py +++ b/app/plugins/functions.py @@ -123,7 +123,7 @@ def build_plugins(): # Check for webpack.config.js (if we need to build it) if plugin.path_exists("public/webpack.config.js"): - if settings.DEV and webpack_watch_process_count() <= 2: + if settings.DEV and webpack_watch_process_count() <= 2 and settings.DEV_WATCH_PLUGINS: logger.info("Running webpack with watcher for {}".format(plugin.get_name())) subprocess.Popen(['webpack-cli', '--watch'], cwd=plugin.get_path("public")) elif not plugin.path_exists("public/build"): diff --git a/app/static/app/js/components/FormDialog.jsx b/app/static/app/js/components/FormDialog.jsx index 5597710c..0333c7f2 100644 --- a/app/static/app/js/components/FormDialog.jsx +++ b/app/static/app/js/components/FormDialog.jsx @@ -95,6 +95,10 @@ class FormDialog extends React.Component { hide(){ this.setState({showModal: false}); if (this.props.onHide) this.props.onHide(); + if (this.serverRequest){ + this.serverRequest.abort(); + this.serverRequest = null; + } } handleSave(e){ @@ -105,13 +109,20 @@ class FormDialog extends React.Component { let formData = {}; if (this.props.getFormData) formData = this.props.getFormData(); - this.props.saveAction(formData).fail(e => { - this.setState({error: e.message || (e.responseJSON || {}).detail || e.responseText || _("Could not apply changes")}); - }).always(() => { + this.serverRequest = this.props.saveAction(formData); + if (this.serverRequest){ + this.serverRequest.fail(e => { + this.setState({error: e.message || (e.responseJSON || {}).detail || e.responseText || _("Could not apply changes")}); + }).always(() => { + this.setState({saving: false}); + this.serverRequest = null; + }).done(() => { + this.hide(); + }); + }else{ this.setState({saving: false}); - }).done(() => { this.hide(); - }); + } } handleDelete(){ diff --git a/app/static/app/js/components/MoveTaskDialog.jsx b/app/static/app/js/components/MoveTaskDialog.jsx index 734bb097..0c8ce89a 100644 --- a/app/static/app/js/components/MoveTaskDialog.jsx +++ b/app/static/app/js/components/MoveTaskDialog.jsx @@ -27,7 +27,7 @@ class MoveTaskDialog extends React.Component { super(props); this.state = { - projectId: props.task.project.id, + projectId: props.task.project, projects: [], loading: true }; @@ -37,7 +37,7 @@ class MoveTaskDialog extends React.Component { } getFormData(){ - return this.state; + return {project: this.state.projectId}; } onShow(){ @@ -82,20 +82,22 @@ class MoveTaskDialog extends React.Component { getFormData={this.getFormData} onShow={this.onShow} ref={(domNode) => { this.dialog = domNode; }}> - {!this.state.loading ? -
    - -
    - +
    + {!this.state.loading ? +
    + +
    + +
    + : }
    - : } ); } diff --git a/app/static/app/js/components/ProjectList.jsx b/app/static/app/js/components/ProjectList.jsx index c2d662ce..12cdad95 100644 --- a/app/static/app/js/components/ProjectList.jsx +++ b/app/static/app/js/components/ProjectList.jsx @@ -85,6 +85,12 @@ class ProjectList extends Paginated { }); } + handleTaskMoved = (task) => { + if (this["projectListItem_" + task.project]){ + this["projectListItem_" + task.project].newTaskAdded(); + } + } + render() { if (this.state.loading){ return (
    ); @@ -95,9 +101,11 @@ class ProjectList extends Paginated {
      {this.state.projects.map(p => ( { this["projectListItem_" + p.id] = domNode }} key={p.id} data={p} - onDelete={this.handleDelete} + onDelete={this.handleDelete} + onTaskMoved={this.handleTaskMoved} history={this.props.history} /> ))}
    diff --git a/app/static/app/js/components/ProjectListItem.jsx b/app/static/app/js/components/ProjectListItem.jsx index eb49af61..218ffc72 100644 --- a/app/static/app/js/components/ProjectListItem.jsx +++ b/app/static/app/js/components/ProjectListItem.jsx @@ -20,7 +20,8 @@ class ProjectListItem extends React.Component { static propTypes = { history: PropTypes.object.isRequired, data: PropTypes.object.isRequired, // project json - onDelete: PropTypes.func + onDelete: PropTypes.func, + onTaskMoved: PropTypes.func, } constructor(props){ @@ -305,8 +306,9 @@ class ProjectListItem extends React.Component { this.refresh(); } - taskMoved(){ - this.refresh(); + taskMoved(task){ + this.refresh(); + if (this.props.onTaskMoved) this.props.onTaskMoved(task); } handleDelete(){ @@ -575,7 +577,7 @@ class ProjectListItem extends React.Component { ref={this.setRef("taskList")} source={`/api/projects/${data.id}/tasks/?ordering=-created_at`} onDelete={this.taskDeleted} - onMove={this.taskMoved} + onTaskMoved={this.taskMoved} hasPermission={this.hasPermission} history={this.props.history} /> : ""} diff --git a/app/static/app/js/components/TaskList.jsx b/app/static/app/js/components/TaskList.jsx index 6d6279a5..67a8c071 100644 --- a/app/static/app/js/components/TaskList.jsx +++ b/app/static/app/js/components/TaskList.jsx @@ -10,7 +10,7 @@ class TaskList extends React.Component { history: PropTypes.object.isRequired, source: PropTypes.string.isRequired, // URL where to load task list onDelete: PropTypes.func, - onMove: PropTypes.func, + onTaskMoved: PropTypes.func, hasPermission: PropTypes.func.isRequired } @@ -71,11 +71,9 @@ class TaskList extends React.Component { if (this.props.onDelete) this.props.onDelete(id); } - moveTask(id){ - this.setState({ - tasks: this.state.tasks.filter(t => t.id !== id) - }); - if (this.props.onMove) this.props.onMove(id); + moveTask = (task) => { + this.refresh(); + if (this.props.onTaskMoved) this.props.onTaskMoved(task); } render() { @@ -85,7 +83,7 @@ class TaskList extends React.Component { }else if (this.state.error){ message = ({interpolate(_("Error: %(error)s"), {error: this.state.error})} {_("Try again")}); }else if (this.state.tasks.length === 0){ - message = ({_("This project has no tasks. Create one by uploading some images!")}); + message = (); } return ( diff --git a/app/static/app/js/components/TaskListItem.jsx b/app/static/app/js/components/TaskListItem.jsx index d76f05f5..9328599a 100644 --- a/app/static/app/js/components/TaskListItem.jsx +++ b/app/static/app/js/components/TaskListItem.jsx @@ -370,6 +370,18 @@ class TaskListItem extends React.Component { }; } + moveTaskAction = (formData) => { + if (formData.project !== this.state.task.project){ + return $.ajax({ + url: `/api/projects/${this.state.task.project}/tasks/${this.state.task.id}/`, + contentType: 'application/json', + data: JSON.stringify(formData), + dataType: 'json', + type: 'PATCH' + }).done(this.props.onMove); + }else return false; + } + render() { const task = this.state.task; const name = task.name !== null ? task.name : interpolate(_("Task #%(number)s"), { number: task.id }); @@ -626,10 +638,9 @@ class TaskListItem extends React.Component { } // Ability to change options - const canAddDelPerms = this.props.hasPermission("add") && this.props.hasPermission("delete"); const editable = [statusCodes.FAILED, statusCodes.COMPLETED, statusCodes.CANCELED].indexOf(task.status) !== -1; - if (canAddDelPerms){ + if (this.props.hasPermission("change")){ if (editable || (!task.processing_node)){ taskActions.push(
  • {_("Edit")}
  • ); } @@ -661,7 +672,7 @@ class TaskListItem extends React.Component { task={task} ref={(domNode) => { this.moveTaskDialog = domNode; }} onHide={() => this.setState({showMoveDialog: false})} - saveAction={() => {}} + saveAction={this.moveTaskAction} /> : ""}
    diff --git a/docker-compose.yml b/docker-compose.yml index 1a091090..2de0d85e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,7 @@ services: - WO_DEBUG - WO_BROKER - WO_DEV + - WO_DEV_WATCH_PLUGINS restart: unless-stopped oom_score_adj: 0 broker: diff --git a/webodm.sh b/webodm.sh index f4456ca7..865ea7d9 100755 --- a/webodm.sh +++ b/webodm.sh @@ -82,6 +82,10 @@ case $key in export WO_DEBUG=YES shift # past argument ;; + --dev-watch-plugins) + export WO_DEV_WATCH_PLUGINS=YES + shift # past argument + ;; --dev) export WO_DEBUG=YES export WO_DEV=YES @@ -148,6 +152,7 @@ usage(){ echo " --ssl-insecure-port-redirect Insecure port number to redirect from when SSL is enabled (default: $DEFAULT_SSL_INSECURE_PORT_REDIRECT)" echo " --debug Enable debug for development environments (default: disabled)" echo " --dev Enable development mode. In development mode you can make modifications to WebODM source files and changes will be reflected live. (default: disabled)" + echo " --dev-watch-plugins Automatically build plugins while in dev mode. (default: disabled)" echo " --broker Set the URL used to connect to the celery broker (default: $DEFAULT_BROKER)" echo " --detached Run WebODM in detached mode. This means WebODM will run in the background, without blocking the terminal (default: disabled)" exit diff --git a/webodm/settings.py b/webodm/settings.py index 2e0bc146..e9551635 100644 --- a/webodm/settings.py +++ b/webodm/settings.py @@ -52,6 +52,7 @@ WORKER_RUNNING = sys.argv[2:3] == ["worker"] # SECURITY WARNING: don't run with debug turned on a public facing server! DEBUG = os.environ.get('WO_DEBUG', 'YES') == 'YES' or TESTING DEV = os.environ.get('WO_DEV', 'NO') == 'YES' and not TESTING +DEV_WATCH_PLUGINS = DEV and os.environ.get('WO_DEV_WATCH_PLUGINS', 'NO') == 'YES' SESSION_COOKIE_SECURE = CSRF_COOKIE_SECURE = os.environ.get('WO_SSL', 'NO') == 'YES' INTERNAL_IPS = ['127.0.0.1'] From 667b7eb5e52e977be49aa008d5341ef807e64909 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Wed, 4 Aug 2021 16:26:36 -0400 Subject: [PATCH 03/11] Fix disable logic --- app/static/app/js/components/TaskListItem.jsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/static/app/js/components/TaskListItem.jsx b/app/static/app/js/components/TaskListItem.jsx index 9328599a..952f8478 100644 --- a/app/static/app/js/components/TaskListItem.jsx +++ b/app/static/app/js/components/TaskListItem.jsx @@ -393,6 +393,10 @@ class TaskListItem extends React.Component { if (!task.processing_node && !imported) status = _("Waiting for a node..."); if (task.pending_action !== null) status = pendingActions.description(task.pending_action); + const disabled = this.state.actionButtonsDisabled || + ([pendingActions.CANCEL, + pendingActions.REMOVE, + pendingActions.RESTART].indexOf(task.pending_action) !== -1); let expanded = ""; if (this.state.expanded){ @@ -440,11 +444,6 @@ class TaskListItem extends React.Component { }); } - const disabled = this.state.actionButtonsDisabled || - ([pendingActions.CANCEL, - pendingActions.REMOVE, - pendingActions.RESTART].indexOf(task.pending_action) !== -1); - actionButtons = (
    {task.status === statusCodes.COMPLETED ? @@ -693,7 +692,7 @@ class TaskListItem extends React.Component {
    {taskActions.length > 0 ?
    -
      From dee1e0d8801931f15ac04deb36792a04dba7feda Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Wed, 4 Aug 2021 16:47:03 -0400 Subject: [PATCH 04/11] Re-add buttons --- app/static/app/js/components/FormDialog.jsx | 2 +- app/static/app/js/components/TaskListItem.jsx | 39 ++++++++++++------- app/static/app/js/css/ProjectListItem.scss | 5 +++ 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/app/static/app/js/components/FormDialog.jsx b/app/static/app/js/components/FormDialog.jsx index 0333c7f2..bd290226 100644 --- a/app/static/app/js/components/FormDialog.jsx +++ b/app/static/app/js/components/FormDialog.jsx @@ -181,7 +181,7 @@ class FormDialog extends React.Component { {_("Deleting...")} : - {_("Delete")} + {_("Delete")} }
    diff --git a/app/static/app/js/components/TaskListItem.jsx b/app/static/app/js/components/TaskListItem.jsx index 952f8478..290951de 100644 --- a/app/static/app/js/components/TaskListItem.jsx +++ b/app/static/app/js/components/TaskListItem.jsx @@ -397,6 +397,7 @@ class TaskListItem extends React.Component { ([pendingActions.CANCEL, pendingActions.REMOVE, pendingActions.RESTART].indexOf(task.pending_action) !== -1); + const editable = this.props.hasPermission("change") && [statusCodes.FAILED, statusCodes.COMPLETED, statusCodes.CANCELED].indexOf(task.status) !== -1; let expanded = ""; if (this.state.expanded){ @@ -430,8 +431,17 @@ class TaskListItem extends React.Component { }); } + if (editable || (!task.processing_node)){ + addActionButton(_("Edit"), "btn-primary pull-right edit-button", "glyphicon glyphicon-pencil", () => { + this.startEditing(); + }, { + className: "inline" + }); + } + if ([statusCodes.FAILED, statusCodes.COMPLETED, statusCodes.CANCELED].indexOf(task.status) !== -1 && task.processing_node && + this.props.hasPermission("change") && !imported){ // By default restart reruns every pipeline // step from the beginning @@ -444,6 +454,13 @@ class TaskListItem extends React.Component { }); } + if (this.props.hasPermission("delete")){ + addActionButton(_("Delete"), "btn-danger", "fa fa-trash fa-fw", this.genActionApiCall("remove", { + confirm: _("All information related to this task, including images, maps and models will be deleted. Continue?"), + defaultError: _("Cannot delete task.") + })); + } + actionButtons = (
    {task.status === statusCodes.COMPLETED ? @@ -637,19 +654,15 @@ class TaskListItem extends React.Component { } // Ability to change options - const editable = [statusCodes.FAILED, statusCodes.COMPLETED, statusCodes.CANCELED].indexOf(task.status) !== -1; + if (editable || (!task.processing_node)){ + taskActions.push(
  • {_("Edit")}
  • ); + } - if (this.props.hasPermission("change")){ - if (editable || (!task.processing_node)){ - taskActions.push(
  • {_("Edit")}
  • ); - } - - if (editable){ - taskActions.push( -
  • {_("Move")}
  • , -
  • {_("Duplicate")}
  • - ); - } + if (editable){ + taskActions.push( +
  • {_("Move")}
  • , +
  • {_("Duplicate")}
  • + ); } @@ -658,7 +671,7 @@ class TaskListItem extends React.Component {
  • , ); - addTaskAction(_("Delete"), "glyphicon glyphicon-trash", this.genActionApiCall("remove", { + addTaskAction(_("Delete"), "fa fa-trash", this.genActionApiCall("remove", { confirm: _("All information related to this task, including images, maps and models will be deleted. Continue?"), defaultError: _("Cannot delete task.") })); diff --git a/app/static/app/js/css/ProjectListItem.scss b/app/static/app/js/css/ProjectListItem.scss index be11da48..5c723b98 100644 --- a/app/static/app/js/css/ProjectListItem.scss +++ b/app/static/app/js/css/ProjectListItem.scss @@ -55,6 +55,11 @@ margin-right: 12px; } } + + .btn-danger .fa-trash{ + margin-right: 2px; + margin-left: 0px; + } .dz-preview{ display: none; From 8fa2cc96439c3f18038e2accfb5d42c2aa16e265 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Thu, 5 Aug 2021 12:10:30 -0400 Subject: [PATCH 05/11] Add duplicate task functionality, post process pipeline step --- app/api/tasks.py | 17 +++++++ app/models/task.py | 35 +++++++++++++++ app/static/app/js/classes/PipelineSteps.js | 5 +++ app/static/app/js/components/TaskList.jsx | 1 + app/static/app/js/components/TaskListItem.jsx | 44 +++++++++++++++---- 5 files changed, 94 insertions(+), 8 deletions(-) diff --git a/app/api/tasks.py b/app/api/tasks.py index fd253909..401be92c 100644 --- a/app/api/tasks.py +++ b/app/api/tasks.py @@ -210,6 +210,23 @@ class TaskViewSet(viewsets.ViewSet): return Response({'success': True}, status=status.HTTP_200_OK) + @detail_route(methods=['post']) + def duplicate(self, request, pk=None, project_pk=None): + """ + Duplicate a task + """ + get_and_check_project(request, project_pk, ('change_project', )) + try: + task = self.queryset.get(pk=pk, project=project_pk) + except (ObjectDoesNotExist, ValidationError): + raise exceptions.NotFound() + + new_task = task.duplicate() + if new_task: + return Response({'success': True, 'task': TaskSerializer(new_task).data}, status=status.HTTP_200_OK) + else: + return Response({'error': _("Cannot duplicate task")}, status=status.HTTP_200_OK) + def create(self, request, project_pk=None): project = get_and_check_project(request, project_pk, ('change_project', )) diff --git a/app/models/task.py b/app/models/task.py index 9de1d1de..3db91c32 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -373,6 +373,41 @@ class Task(models.Model): else: return {} + def duplicate(self): + try: + with transaction.atomic(): + task = Task.objects.get(pk=self.pk) + task.pk = None + task.name = gettext('Copy of %(task)s') % {'task': self.name} + task.created_at = timezone.now() + task.save() + task.refresh_from_db() + + logger.info("Duplicating {} to {}".format(self, task)) + + for img in self.imageupload_set.all(): + img.pk = None + img.task = task + + prev_name = img.image.name + img.image.name = assets_directory_path(task.id, task.project.id, + os.path.basename(img.image.name)) + + img.save() + + try: + # Try to use hard links first + shutil.copytree(self.task_path(), task.task_path(), copy_function=os.link) + except Exception as e: + logger.warning("Cannot duplicate task using hard links, will use normal copy instead: {}".format(str(e))) + shutil.copytree(self.task_path(), task.task_path()) + + return task + except Exception as e: + logger.warning("Cannot duplicate task: {}".format(str(e))) + + return False + def get_asset_download_path(self, asset): """ Get the path to an asset download diff --git a/app/static/app/js/classes/PipelineSteps.js b/app/static/app/js/classes/PipelineSteps.js index b1b1e973..018a2553 100644 --- a/app/static/app/js/classes/PipelineSteps.js +++ b/app/static/app/js/classes/PipelineSteps.js @@ -51,6 +51,11 @@ export default { action: "odm_report", label: _("Report"), icon: "far fa-file-alt" + }, + { + action: "odm_postprocess", + label: _("Postprocess"), + icon: "fa fa-cog" } ]; } diff --git a/app/static/app/js/components/TaskList.jsx b/app/static/app/js/components/TaskList.jsx index 67a8c071..9672a1d6 100644 --- a/app/static/app/js/components/TaskList.jsx +++ b/app/static/app/js/components/TaskList.jsx @@ -97,6 +97,7 @@ class TaskList extends React.Component { refreshInterval={3000} onDelete={this.deleteTask} onMove={this.moveTask} + onDuplicate={this.refresh} hasPermission={this.props.hasPermission} history={this.props.history} /> ))} diff --git a/app/static/app/js/components/TaskListItem.jsx b/app/static/app/js/components/TaskListItem.jsx index 290951de..5c3aadaa 100644 --- a/app/static/app/js/components/TaskListItem.jsx +++ b/app/static/app/js/components/TaskListItem.jsx @@ -22,6 +22,7 @@ class TaskListItem extends React.Component { refreshInterval: PropTypes.number, // how often to refresh info onDelete: PropTypes.func, onMove: PropTypes.func, + onDuplicate: PropTypes.func, hasPermission: PropTypes.func } @@ -41,7 +42,8 @@ class TaskListItem extends React.Component { friendlyTaskError: "", pluginActionButtons: [], view: "basic", - showMoveDialog: false + showMoveDialog: false, + actionLoading: false, } for (let k in props.data){ @@ -198,11 +200,12 @@ class TaskListItem extends React.Component { ).done(json => { if (json.success){ this.refresh(); - if (options.success !== undefined) options.success(); + if (options.success !== undefined) options.success(json); }else{ this.setState({ actionError: json.error || options.defaultError || _("Cannot complete operation."), - actionButtonsDisabled: false + actionButtonsDisabled: false, + expanded: true }); } }) @@ -211,6 +214,9 @@ class TaskListItem extends React.Component { actionError: options.defaultError || _("Cannot complete operation."), actionButtonsDisabled: false }); + }) + .always(() => { + if (options.always !== undefined) options.always(); }); } @@ -279,6 +285,19 @@ class TaskListItem extends React.Component { this.setState({showMoveDialog: true}); } + handleDuplicateTask = () => { + this.setState({actionLoading: true}); + this.genActionApiCall("duplicate", { + success: (json) => { + if (json.task){ + if (this.props.onDuplicate) this.props.onDuplicate(json.task); + } + }, + always: () => { + this.setState({actionLoading: false}); + }})(); + } + getRestartSubmenuItems(){ const { task } = this.state; @@ -398,6 +417,7 @@ class TaskListItem extends React.Component { pendingActions.REMOVE, pendingActions.RESTART].indexOf(task.pending_action) !== -1); const editable = this.props.hasPermission("change") && [statusCodes.FAILED, statusCodes.COMPLETED, statusCodes.CANCELED].indexOf(task.status) !== -1; + const actionLoading = this.state.actionLoading; let expanded = ""; if (this.state.expanded){ @@ -439,6 +459,11 @@ class TaskListItem extends React.Component { }); } + if ([statusCodes.QUEUED, statusCodes.RUNNING, null].indexOf(task.status) !== -1 && + (task.processing_node || imported) && this.props.hasPermission("change")){ + addActionButton(_("Cancel"), "btn-primary", "glyphicon glyphicon-remove-circle", this.genActionApiCall("cancel", {defaultError: _("Cannot cancel task.")})); + } + if ([statusCodes.FAILED, statusCodes.COMPLETED, statusCodes.CANCELED].indexOf(task.status) !== -1 && task.processing_node && this.props.hasPermission("change") && @@ -533,11 +558,11 @@ class TaskListItem extends React.Component { {stats && stats.gsd ?
    - {_("Average GSD:")} {stats.gsd.toFixed(2)} cm
    + {_("Average GSD:")} {parseFloat(stats.gsd.toFixed(2)).toLocaleString()} cm
    : ""} {stats && stats.area ?
    - {_("Area:")} {stats.area.toFixed(2)} m²
    + {_("Area:")} {parseFloat(stats.area.toFixed(2)).toLocaleString()} m²
    : ""} {stats && stats.pointcloud && stats.pointcloud.points ?
    @@ -661,7 +686,7 @@ class TaskListItem extends React.Component { if (editable){ taskActions.push(
  • {_("Move")}
  • , -
  • {_("Duplicate")}
  • +
  • {_("Duplicate")}
  • ); } @@ -677,6 +702,9 @@ class TaskListItem extends React.Component { })); } + let taskActionsIcon = "fa-ellipsis-h"; + if (actionLoading) taskActionsIcon = "fa-circle-notch fa-spin fa-fw"; + return (
    {this.state.showMoveDialog ? @@ -705,8 +733,8 @@ class TaskListItem extends React.Component {
    {taskActions.length > 0 ?
    -
      {taskActions} From 05694db5b7c168b19e4ba01c9c7c9a4ef02d3269 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Thu, 5 Aug 2021 12:42:02 -0400 Subject: [PATCH 06/11] Fix unit tests --- app/static/app/js/components/EditPresetDialog.jsx | 4 ++-- app/static/app/js/components/tests/TaskList.test.jsx | 2 +- app/static/app/js/components/tests/TaskListItem.test.jsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/static/app/js/components/EditPresetDialog.jsx b/app/static/app/js/components/EditPresetDialog.jsx index c589ccc5..441720db 100644 --- a/app/static/app/js/components/EditPresetDialog.jsx +++ b/app/static/app/js/components/EditPresetDialog.jsx @@ -109,13 +109,13 @@ class EditPresetDialog extends React.Component { deleteWarning={false} deleteAction={(this.props.preset.id !== -1 && !this.props.preset.system) ? this.props.deleteAction : undefined}> {!this.isCustomPreset() ? - [
      + [
      { this.nameInput = domNode; }} value={this.state.name} onChange={this.handleChange('name')} />
      , -
      ] +
      ] : ""} diff --git a/app/static/app/js/components/tests/TaskList.test.jsx b/app/static/app/js/components/tests/TaskList.test.jsx index a987b3c6..6f3fdf03 100644 --- a/app/static/app/js/components/tests/TaskList.test.jsx +++ b/app/static/app/js/components/tests/TaskList.test.jsx @@ -4,7 +4,7 @@ import TaskList from '../TaskList'; describe('', () => { it('renders without exploding', () => { - const wrapper = shallow(); + const wrapper = shallow( true} />); expect(wrapper.exists()).toBe(true); }) }); \ No newline at end of file diff --git a/app/static/app/js/components/tests/TaskListItem.test.jsx b/app/static/app/js/components/tests/TaskListItem.test.jsx index 28d4a286..d51b578b 100644 --- a/app/static/app/js/components/tests/TaskListItem.test.jsx +++ b/app/static/app/js/components/tests/TaskListItem.test.jsx @@ -6,7 +6,7 @@ const taskMock = require('../../tests/utils/MockLoader').load("task.json"); describe('', () => { it('renders without exploding', () => { - const wrapper = shallow(); + const wrapper = shallow( true} />); expect(wrapper.exists()).toBe(true); }) }); \ No newline at end of file From 5f7833c71d6acf211fbb979e050336f232d6f648 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Thu, 5 Aug 2021 13:12:21 -0400 Subject: [PATCH 07/11] Duplicate unit test --- app/tests/test_api_task.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/tests/test_api_task.py b/app/tests/test_api_task.py index 5ecc93a6..5100ed02 100644 --- a/app/tests/test_api_task.py +++ b/app/tests/test_api_task.py @@ -292,6 +292,10 @@ class TestApiTask(BootTransactionTestCase): res = client.get("/api/projects/{}/tasks/{}/images/download/tiny_drone_image.jpg".format(other_project.id, other_task.id)) self.assertTrue(res.status_code == status.HTTP_404_NOT_FOUND) + # Cannot duplicate a task we have no access to + res = client.post("/api/projects/{}/tasks/{}/duplicate/".format(other_project.id, other_task.id)) + self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND) + # Cannot export orthophoto res = client.post("/api/projects/{}/tasks/{}/orthophoto/export".format(project.id, task.id), { 'formula': 'NDVI', @@ -898,6 +902,21 @@ class TestApiTask(BootTransactionTestCase): self.assertFalse('orthophoto_tiles.zip' in res.data['available_assets']) self.assertTrue('textured_model.zip' in res.data['available_assets']) + # Can duplicate a task + res = client.post("/api/projects/{}/tasks/{}/duplicate/".format(project.id, task.id)) + self.assertTrue(res.status_code, status.HTTP_200_OK) + self.assertTrue(res.data['success']) + new_task_id = res.data['task']['id'] + self.assertNotEqual(res.data['task']['id'], task.id) + + new_task = Task.objects.get(pk=new_task_id) + + # New task has same number of image uploads + self.assertEqual(task.imageupload_set.count(), new_task.imageupload_set.count()) + + # Directories have been created + self.assertTrue(os.path.exists(new_task.task_path())) + image1.close() image2.close() multispec_image.close() From 6fae3f29f3454ac56a0e87db7279103647d0536c Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Thu, 5 Aug 2021 15:08:38 -0400 Subject: [PATCH 08/11] Fix project list api test --- app/tests/test_api.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/tests/test_api.py b/app/tests/test_api.py index ed9283bd..546f20a2 100644 --- a/app/tests/test_api.py +++ b/app/tests/test_api.py @@ -48,10 +48,14 @@ class TestApi(BootTestCase): client.login(username="testuser", password="test1234") res = client.get('/api/projects/') self.assertEqual(res.status_code, status.HTTP_200_OK) - self.assertTrue(len(res.data["results"]) > 0) + self.assertTrue(len(res.data) > 0) + + res = client.get('/api/projects/?page=1') + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertTrue(len(res.data['results']) > 0) # Can sort - res = client.get('/api/projects/?ordering=-created_at') + res = client.get('/api/projects/?ordering=-created_at&page=1') last_project = Project.objects.filter(owner=user).latest('created_at') self.assertTrue(res.data["results"][0]['id'] == last_project.id) @@ -65,12 +69,12 @@ class TestApi(BootTestCase): self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND) # Can filter - res = client.get('/api/projects/?name=999') + res = client.get('/api/projects/?name=999&page=1') self.assertEqual(res.status_code, status.HTTP_200_OK) self.assertTrue(len(res.data["results"]) == 0) # Cannot list somebody else's project without permission - res = client.get('/api/projects/?id={}'.format(other_project.id)) + res = client.get('/api/projects/?id={}&page=1'.format(other_project.id)) self.assertEqual(res.status_code, status.HTTP_200_OK) self.assertTrue(len(res.data["results"]) == 0) From 573be9a0fd15d4d0500a286fd52818b27c48f90d Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Thu, 5 Aug 2021 16:46:57 -0400 Subject: [PATCH 09/11] Fixed model validation --- app/models/task.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/app/models/task.py b/app/models/task.py index 3db91c32..30132116 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -310,6 +310,27 @@ class Task(models.Model): self.move_assets(self.__original_project_id, self.project.id) self.__original_project_id = self.project.id + # Manually validate the fields we want, + # since Django's clean_fields() method obliterates + # our foreign keys without explanation :/ + errors = {} + for f in self._meta.fields: + if f.attname in ["options"]: + raw_value = getattr(self, f.attname) + if f.blank and raw_value in f.empty_values: + continue + + try: + setattr(self, f.attname, f.clean(raw_value, self)) + except ValidationError as e: + errors[f.name] = e.error_list + + if errors: + raise ValidationError(errors) + + self.clean() + self.validate_unique() + super(Task, self).save(*args, **kwargs) def assets_path(self, *args): From cbbd84198373b6c1033756c7853766459b136881 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Fri, 6 Aug 2021 11:33:43 -0400 Subject: [PATCH 10/11] Duplicate projects working --- app/api/projects.py | 21 ++++++- app/models/project.py | 30 +++++++++- app/models/task.py | 23 ++++---- .../app/js/components/EditProjectDialog.jsx | 59 ++++++++++++++++--- app/static/app/js/components/FormDialog.jsx | 35 +++++++---- app/static/app/js/components/ProjectList.jsx | 5 ++ .../app/js/components/ProjectListItem.jsx | 4 ++ app/tests/test_api.py | 15 +++++ 8 files changed, 159 insertions(+), 33 deletions(-) diff --git a/app/api/projects.py b/app/api/projects.py index b1c749b8..5aaf4cb2 100644 --- a/app/api/projects.py +++ b/app/api/projects.py @@ -1,9 +1,13 @@ from guardian.shortcuts import get_perms from rest_framework import serializers, viewsets +from rest_framework.decorators import detail_route +from rest_framework.response import Response +from rest_framework import status from app import models from .tasks import TaskIDsSerializer - +from .common import get_and_check_project +from django.utils.translation import gettext as _ class ProjectSerializer(serializers.ModelSerializer): tasks = TaskIDsSerializer(many=True, read_only=True) @@ -42,4 +46,17 @@ class ProjectViewSet(viewsets.ModelViewSet): def paginate_queryset(self, queryset): if self.paginator and self.request.query_params.get(self.paginator.page_query_param, None) is None: return None - return super().paginate_queryset(queryset) \ No newline at end of file + return super().paginate_queryset(queryset) + + @detail_route(methods=['post']) + def duplicate(self, request, pk=None): + """ + Duplicate a task + """ + project = get_and_check_project(request, pk, ('change_project', )) + + new_project = project.duplicate() + if new_project: + return Response({'success': True, 'project': ProjectSerializer(new_project).data}, status=status.HTTP_200_OK) + else: + return Response({'error': _("Cannot duplicate project")}, status=status.HTTP_200_OK) diff --git a/app/models/project.py b/app/models/project.py index 7fb879ba..e3a56253 100644 --- a/app/models/project.py +++ b/app/models/project.py @@ -9,7 +9,8 @@ from django.utils import timezone from guardian.models import GroupObjectPermissionBase from guardian.models import UserObjectPermissionBase from guardian.shortcuts import get_perms_for_model, assign_perm -from django.utils.translation import gettext_lazy as _ +from django.utils.translation import gettext_lazy as _, gettext +from django.db import transaction from app import pending_actions @@ -51,6 +52,33 @@ class Project(models.Model): status=status_codes.COMPLETED ).filter(Q(orthophoto_extent__isnull=False) | Q(dsm_extent__isnull=False) | Q(dtm_extent__isnull=False)) .only('id', 'project_id')] + + def duplicate(self): + try: + with transaction.atomic(): + project = Project.objects.get(pk=self.pk) + project.pk = None + project.name = gettext('Copy of %(task)s') % {'task': self.name} + project.created_at = timezone.now() + project.save() + project.refresh_from_db() + + for task in self.task_set.all(): + new_task = task.duplicate(set_new_name=False) + if not new_task: + raise Exception("Failed to duplicate {}".format(new_task)) + + # Move/Assign to new duplicate + new_task.project = project + new_task.save() + + return project + except Exception as e: + logger.warning("Cannot duplicate project: {}".format(str(e))) + + return False + + class Meta: verbose_name = _("Project") diff --git a/app/models/task.py b/app/models/task.py index 30132116..8f97c67c 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -324,7 +324,7 @@ class Task(models.Model): setattr(self, f.attname, f.clean(raw_value, self)) except ValidationError as e: errors[f.name] = e.error_list - + if errors: raise ValidationError(errors) @@ -394,12 +394,13 @@ class Task(models.Model): else: return {} - def duplicate(self): + def duplicate(self, set_new_name=True): try: with transaction.atomic(): task = Task.objects.get(pk=self.pk) task.pk = None - task.name = gettext('Copy of %(task)s') % {'task': self.name} + if set_new_name: + task.name = gettext('Copy of %(task)s') % {'task': self.name} task.created_at = timezone.now() task.save() task.refresh_from_db() @@ -416,13 +417,15 @@ class Task(models.Model): img.save() - try: - # Try to use hard links first - shutil.copytree(self.task_path(), task.task_path(), copy_function=os.link) - except Exception as e: - logger.warning("Cannot duplicate task using hard links, will use normal copy instead: {}".format(str(e))) - shutil.copytree(self.task_path(), task.task_path()) - + if os.path.isdir(self.task_path()): + try: + # Try to use hard links first + shutil.copytree(self.task_path(), task.task_path(), copy_function=os.link) + except Exception as e: + logger.warning("Cannot duplicate task using hard links, will use normal copy instead: {}".format(str(e))) + shutil.copytree(self.task_path(), task.task_path()) + else: + logger.warning("Task {} doesn't have folder, will skip copying".format(self)) return task except Exception as e: logger.warning("Cannot duplicate task: {}".format(str(e))) diff --git a/app/static/app/js/components/EditProjectDialog.jsx b/app/static/app/js/components/EditProjectDialog.jsx index 2f24012e..71efb4cc 100644 --- a/app/static/app/js/components/EditProjectDialog.jsx +++ b/app/static/app/js/components/EditProjectDialog.jsx @@ -1,23 +1,28 @@ import React from 'react'; import FormDialog from './FormDialog'; import PropTypes from 'prop-types'; +import ErrorMessage from './ErrorMessage'; import { _ } from '../classes/gettext'; class EditProjectDialog extends React.Component { static defaultProps = { projectName: "", projectDescr: "", + projectId: -1, title: _("New Project"), saveLabel: _("Create Project"), savingLabel: _("Creating project..."), saveIcon: "glyphicon glyphicon-plus", deleteWarning: _("All tasks, images and models associated with this project will be permanently deleted. Are you sure you want to continue?"), - show: false + show: false, + showDuplicate: false, + onDuplicated: () => {} }; static propTypes = { projectName: PropTypes.string, projectDescr: PropTypes.string, + projectId: PropTypes.number, saveAction: PropTypes.func.isRequired, onShow: PropTypes.func, deleteAction: PropTypes.func, @@ -26,7 +31,9 @@ class EditProjectDialog extends React.Component { savingLabel: PropTypes.string, saveIcon: PropTypes.string, deleteWarning: PropTypes.string, - show: PropTypes.bool + show: PropTypes.bool, + showDuplicate: PropTypes.bool, + onDuplicated: PropTypes.func }; constructor(props){ @@ -34,7 +41,9 @@ class EditProjectDialog extends React.Component { this.state = { name: props.projectName, - descr: props.projectDescr !== null ? props.projectDescr : "" + descr: props.projectDescr !== null ? props.projectDescr : "", + duplicating: false, + error: "" }; this.reset = this.reset.bind(this); @@ -46,12 +55,17 @@ class EditProjectDialog extends React.Component { reset(){ this.setState({ name: this.props.projectName, - descr: this.props.projectDescr + descr: this.props.projectDescr, + duplicating: false, + error: "" }); } getFormData(){ - return this.state; + return { + name: this.state.name, + descr: this.state.descr, + }; } onShow(){ @@ -64,6 +78,11 @@ class EditProjectDialog extends React.Component { hide(){ this.dialog.hide(); + + if (this.duplicateRequest){ + this.duplicateRequest.abort(); + this.duplicateRequest = null; + } } handleChange(field){ @@ -74,13 +93,39 @@ class EditProjectDialog extends React.Component { } } + handleDuplicate = () => { + this.setState({duplicating: true}); + this.duplicateRequest = $.post(`/api/projects/${this.props.projectId}/duplicate/`) + .done(json => { + if (json.success){ + this.hide(); + this.props.onDuplicated(json.project); + }else{ + this.setState({ + error: json.error || _("Cannot complete operation.") + }); + } + }) + .fail(() => { + this.setState({ + error: _("Cannot complete operation."), + }); + }) + .always(() => { + this.setState({duplicating: false}); + this.duplicateRequest = null; + }); + } + render(){ return ( - Duplicate] : undefined} ref={(domNode) => { this.dialog = domNode; }}> +
      diff --git a/app/static/app/js/components/FormDialog.jsx b/app/static/app/js/components/FormDialog.jsx index bd290226..d6f7415a 100644 --- a/app/static/app/js/components/FormDialog.jsx +++ b/app/static/app/js/components/FormDialog.jsx @@ -140,6 +140,25 @@ class FormDialog extends React.Component { } render(){ + let leftButtons = []; + if (this.props.deleteAction){ + leftButtons.push(); + } + if (this.props.leftButtons){ + leftButtons = leftButtons.concat(this.props.leftButtons); + } + return (
      }
      - {this.props.deleteAction ? + + {leftButtons.length > 0 ?
      - + {leftButtons}
      : ""}
      diff --git a/app/static/app/js/components/ProjectList.jsx b/app/static/app/js/components/ProjectList.jsx index 12cdad95..bbe6c1be 100644 --- a/app/static/app/js/components/ProjectList.jsx +++ b/app/static/app/js/components/ProjectList.jsx @@ -91,6 +91,10 @@ class ProjectList extends Paginated { } } + handleProjectDuplicated = () => { + this.refresh(); + } + render() { if (this.state.loading){ return (
      ); @@ -106,6 +110,7 @@ class ProjectList extends Paginated { data={p} onDelete={this.handleDelete} onTaskMoved={this.handleTaskMoved} + onProjectDuplicated={this.handleProjectDuplicated} history={this.props.history} /> ))}
    diff --git a/app/static/app/js/components/ProjectListItem.jsx b/app/static/app/js/components/ProjectListItem.jsx index 218ffc72..e09c5ebe 100644 --- a/app/static/app/js/components/ProjectListItem.jsx +++ b/app/static/app/js/components/ProjectListItem.jsx @@ -22,6 +22,7 @@ class ProjectListItem extends React.Component { data: PropTypes.object.isRequired, // project json onDelete: PropTypes.func, onTaskMoved: PropTypes.func, + onProjectDuplicated: PropTypes.func } constructor(props){ @@ -481,8 +482,11 @@ class ProjectListItem extends React.Component { saveLabel={_("Save Changes")} savingLabel={_("Saving changes...")} saveIcon="far fa-edit" + showDuplicate={true} + onDuplicated={this.props.onProjectDuplicated} projectName={data.name} projectDescr={data.description} + projectId={data.id} saveAction={this.updateProject} deleteAction={this.hasPermission("delete") ? this.handleDelete : undefined} /> diff --git a/app/tests/test_api.py b/app/tests/test_api.py index 546f20a2..9144c5f8 100644 --- a/app/tests/test_api.py +++ b/app/tests/test_api.py @@ -165,6 +165,21 @@ class TestApi(BootTestCase): res = client.get('/api/projects/{}/tasks/{}/'.format(project.id, other_task.id)) self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND) + # Cannot duplicate a project we have no access to + res = client.post('/api/projects/{}/duplicate/'.format(other_project.id)) + self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND) + + # Can duplicate a project we have access to + res = client.post('/api/projects/{}/duplicate/'.format(project.id)) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertTrue(res.data.get('success')) + new_project_id = res.data['project']['id'] + self.assertNotEqual(new_project_id, project.id) + + # Tasks have been duplicated + duplicated_project = Project.objects.get(pk=new_project_id) + self.assertEqual(project.task_set.count(), duplicated_project.task_set.count()) + # Cannot access task details for a task that doesn't exist res = client.get('/api/projects/{}/tasks/4004d1e9-ed2c-4983-8b93-fc7577ee6d89/'.format(project.id)) self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND) From 05285b4c9b16124892dcc4d4afd0694b05fd3d5c Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Fri, 6 Aug 2021 11:56:01 -0400 Subject: [PATCH 11/11] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c16685b9..e3077594 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "WebODM", - "version": "1.9.4", + "version": "1.9.5", "description": "User-friendly, extendable application and API for processing aerial imagery.", "main": "index.js", "scripts": {