Merge pull request #1031 from pierotofy/taskmgmt

Task Management Improvements
pull/1037/head
Piero Toffanin 2021-08-06 10:56:09 -05:00 zatwierdzone przez GitHub
commit 7ee8fc568c
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
25 zmienionych plików z 555 dodań i 76 usunięć

Wyświetl plik

@ -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)
@ -37,3 +41,22 @@ 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)
@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)

Wyświetl plik

@ -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', ))

Wyświetl plik

@ -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")

Wyświetl plik

@ -310,8 +310,26 @@ 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()
# 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)
@ -376,6 +394,44 @@ class Task(models.Model):
else:
return {}
def duplicate(self, set_new_name=True):
try:
with transaction.atomic():
task = Task.objects.get(pk=self.pk)
task.pk = None
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()
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()
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)))
return False
def get_asset_download_path(self, asset):
"""
Get the path to an asset download

Wyświetl plik

@ -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"):

Wyświetl plik

@ -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"
}
];
}

Wyświetl plik

@ -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]: {

Wyświetl plik

@ -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() ?
[<div className="row preset-name">
[<div className="row preset-name" key="preset">
<label className="col-sm-2 control-label">{_("Name")}</label>
<div className="col-sm-10" style={{marginRight: "40px"}}>
<input type="text" className="form-control" ref={(domNode) => { this.nameInput = domNode; }} value={this.state.name} onChange={this.handleChange('name')} />
</div>
</div>,
<hr/>]
<hr key="hr"/>]
: ""}
<button type="submit" className="btn btn-default search-toggle btn-sm" title={_("Search")} onClick={this.toggleSearchControl}><i className="fa fa-filter"></i></button>

Wyświetl plik

@ -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 (
<FormDialog {...this.props}
getFormData={this.getFormData}
<FormDialog {...this.props}
getFormData={this.getFormData}
reset={this.reset}
onShow={this.onShow}
leftButtons={this.props.showDuplicate ? [<button disabled={this.duplicating} onClick={this.handleDuplicate} className="btn btn-default"><i className={"fa " + (this.state.duplicating ? "fa-circle-notch fa-spin fa-fw" : "fa-copy")}></i> Duplicate</button>] : undefined}
ref={(domNode) => { this.dialog = domNode; }}>
<ErrorMessage bind={[this, "error"]} />
<div className="form-group">
<label className="col-sm-2 control-label">{_("Name")}</label>
<div className="col-sm-10">

Wyświetl plik

@ -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,13 +88,17 @@ class FormDialog extends React.Component {
}
show(){
this.props.reset();
if (this.props.reset) this.props.reset();
this.setState({showModal: true, saving: false, error: ""});
}
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(){
@ -129,6 +140,25 @@ class FormDialog extends React.Component {
}
render(){
let leftButtons = [];
if (this.props.deleteAction){
leftButtons.push(<button
disabled={this.state.deleting}
className="btn btn-danger"
onClick={this.handleDelete}>
{this.state.deleting ?
<span>
<i className="fa fa-circle-notch fa-spin"></i> {_("Deleting...")}
</span>
: <span>
<i className="fa fa-trash"></i> {_("Delete")}
</span>}
</button>);
}
if (this.props.leftButtons){
leftButtons = leftButtons.concat(this.props.leftButtons);
}
return (
<div ref={this.setModal}
className="modal form-dialog" tabIndex="-1"
@ -159,20 +189,10 @@ class FormDialog extends React.Component {
</span>}
</button>
</div>
{this.props.deleteAction ?
{leftButtons.length > 0 ?
<div className="text-left">
<button
disabled={this.state.deleting}
className="btn btn-danger"
onClick={this.handleDelete}>
{this.state.deleting ?
<span>
<i className="fa fa-circle-notch fa-spin"></i> {_("Deleting...")}
</span>
: <span>
<i className="glyphicon glyphicon-trash"></i> {_("Delete")}
</span>}
</button>
{leftButtons}
</div>
: ""}
</div>

Wyświetl plik

@ -0,0 +1,106 @@
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,
projects: [],
loading: true
};
this.getFormData = this.getFormData.bind(this);
this.onShow = this.onShow.bind(this);
}
getFormData(){
return {project: this.state.projectId};
}
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 (
<FormDialog {...this.props}
getFormData={this.getFormData}
onShow={this.onShow}
ref={(domNode) => { this.dialog = domNode; }}>
<div style={{minHeight: '50px'}}>
{!this.state.loading ?
<div className="form-group">
<label className="col-sm-2 control-label">{_("Project")}</label>
<div className="col-sm-10">
<select className="form-control"
value={this.state.projectId}
onChange={this.handleProjectChange}>
{this.state.projects.map(p =>
<option value={p.id} key={p.id}>{p.name}</option>
)}
</select>
</div>
</div>
: <i className="fa fa-circle-notch fa-spin fa-fw name-loading"></i>}
</div>
</FormDialog>
);
}
}
export default MoveTaskDialog;

Wyświetl plik

@ -85,6 +85,16 @@ class ProjectList extends Paginated {
});
}
handleTaskMoved = (task) => {
if (this["projectListItem_" + task.project]){
this["projectListItem_" + task.project].newTaskAdded();
}
}
handleProjectDuplicated = () => {
this.refresh();
}
render() {
if (this.state.loading){
return (<div className="project-list text-center"><i className="fa fa-sync fa-spin fa-2x fa-fw"></i></div>);
@ -95,9 +105,12 @@ class ProjectList extends Paginated {
<ul className={"list-group project-list " + (this.state.refreshing ? "refreshing" : "")}>
{this.state.projects.map(p => (
<ProjectListItem
ref={(domNode) => { this["projectListItem_" + p.id] = domNode }}
key={p.id}
data={p}
onDelete={this.handleDelete}
onDelete={this.handleDelete}
onTaskMoved={this.handleTaskMoved}
onProjectDuplicated={this.handleProjectDuplicated}
history={this.props.history} />
))}
</ul>

Wyświetl plik

@ -20,7 +20,9 @@ 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,
onProjectDuplicated: PropTypes.func
}
constructor(props){
@ -47,6 +49,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 +307,11 @@ class ProjectListItem extends React.Component {
this.refresh();
}
taskMoved(task){
this.refresh();
if (this.props.onTaskMoved) this.props.onTaskMoved(task);
}
handleDelete(){
return $.ajax({
url: `/api/projects/${this.state.data.id}/`,
@ -474,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}
/>
@ -570,6 +581,8 @@ class ProjectListItem extends React.Component {
ref={this.setRef("taskList")}
source={`/api/projects/${data.id}/tasks/?ordering=-created_at`}
onDelete={this.taskDeleted}
onTaskMoved={this.taskMoved}
hasPermission={this.hasPermission}
history={this.props.history}
/> : ""}

Wyświetl plik

@ -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,
onTaskMoved: PropTypes.func,
hasPermission: PropTypes.func.isRequired
}
constructor(props){
@ -69,6 +71,11 @@ class TaskList extends React.Component {
if (this.props.onDelete) this.props.onDelete(id);
}
moveTask = (task) => {
this.refresh();
if (this.props.onTaskMoved) this.props.onTaskMoved(task);
}
render() {
let message = "";
if (this.state.loading){
@ -76,7 +83,7 @@ class TaskList extends React.Component {
}else if (this.state.error){
message = (<span>{interpolate(_("Error: %(error)s"), {error: this.state.error})} <a href="javascript:void(0);" onClick={this.retry}>{_("Try again")}</a></span>);
}else if (this.state.tasks.length === 0){
message = (<span>{_("This project has no tasks. Create one by uploading some images!")}</span>);
message = (<span></span>);
}
return (
@ -88,7 +95,10 @@ class TaskList extends React.Component {
data={task}
key={task.id}
refreshInterval={3000}
onDelete={this.deleteTask}
onDelete={this.deleteTask}
onMove={this.moveTask}
onDuplicate={this.refresh}
hasPermission={this.props.hasPermission}
history={this.props.history} />
))}
</div>

Wyświetl plik

@ -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,10 @@ 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,
onDuplicate: PropTypes.func,
hasPermission: PropTypes.func
}
constructor(props){
@ -37,7 +41,9 @@ class TaskListItem extends React.Component {
memoryError: false,
friendlyTaskError: "",
pluginActionButtons: [],
view: "basic"
view: "basic",
showMoveDialog: false,
actionLoading: false,
}
for (let k in props.data){
@ -194,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
});
}
})
@ -207,6 +214,9 @@ class TaskListItem extends React.Component {
actionError: options.defaultError || _("Cannot complete operation."),
actionButtonsDisabled: false
});
})
.always(() => {
if (options.always !== undefined) options.always();
});
}
@ -271,6 +281,23 @@ class TaskListItem extends React.Component {
this.setAutoRefresh();
}
handleMoveTask = () => {
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;
@ -362,6 +389,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 });
@ -373,6 +412,12 @@ 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);
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){
@ -406,9 +451,7 @@ class TaskListItem extends React.Component {
});
}
// Ability to change options
if ([statusCodes.FAILED, statusCodes.COMPLETED, statusCodes.CANCELED].indexOf(task.status) !== -1 ||
(!task.processing_node)){
if (editable || (!task.processing_node)){
addActionButton(_("Edit"), "btn-primary pull-right edit-button", "glyphicon glyphicon-pencil", () => {
this.startEditing();
}, {
@ -417,12 +460,13 @@ class TaskListItem extends React.Component {
}
if ([statusCodes.QUEUED, statusCodes.RUNNING, null].indexOf(task.status) !== -1 &&
(task.processing_node || imported)){
(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") &&
!imported){
// By default restart reruns every pipeline
// step from the beginning
@ -435,15 +479,12 @@ 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,
pendingActions.RESTART].indexOf(task.pending_action) !== -1);
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 = (<div className="action-buttons">
{task.status === statusCodes.COMPLETED ?
@ -517,11 +558,11 @@ class TaskListItem extends React.Component {
{stats && stats.gsd ?
<div className="labels">
<strong>{_("Average GSD:")} </strong> {stats.gsd.toFixed(2)} cm<br/>
<strong>{_("Average GSD:")} </strong> {parseFloat(stats.gsd.toFixed(2)).toLocaleString()} cm<br/>
</div> : ""}
{stats && stats.area ?
<div className="labels">
<strong>{_("Area:")} </strong> {stats.area.toFixed(2)} m&sup2;<br/>
<strong>{_("Area:")} </strong> {parseFloat(stats.area.toFixed(2)).toLocaleString()} m&sup2;<br/>
</div> : ""}
{stats && stats.pointcloud && stats.pointcloud.points ?
<div className="labels">
@ -580,6 +621,8 @@ class TaskListItem extends React.Component {
</div>;
}
}
let statusIcon = statusCodes.icon(task.status);
// @param type {String} one of: ['neutral', 'done', 'error']
const getStatusLabel = (text, type = 'neutral', progress = 100) => {
@ -589,11 +632,10 @@ class TaskListItem extends React.Component {
return (<div
className={"status-label theme-border-primary " + type}
style={{background: `linear-gradient(90deg, ${color} ${progress}%, rgba(255, 255, 255, 0) ${progress}%)`}}
title={text}>{text}</div>);
title={text}><i className={statusIcon}></i> {text}</div>);
}
let statusLabel = "";
let statusIcon = statusCodes.icon(task.status);
let showEditLink = false;
if (task.last_error){
@ -624,8 +666,55 @@ class TaskListItem extends React.Component {
statusLabel = getStatusLabel(status, type, progress);
}
const taskActions = [];
const addTaskAction = (label, icon, onClick) => {
taskActions.push(
<li key={label}><a href="javascript:void(0)" onClick={onClick}><i className={icon}></i>{label}</a></li>
);
};
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
if (editable || (!task.processing_node)){
taskActions.push(<li key="edit"><a href="javascript:void(0)" onClick={this.startEditing}><i className="glyphicon glyphicon-pencil"></i>{_("Edit")}</a></li>);
}
if (editable){
taskActions.push(
<li key="move"><a href="javascript:void(0)" onClick={this.handleMoveTask}><i className="fa fa-arrows-alt"></i>{_("Move")}</a></li>,
<li key="duplicate"><a href="javascript:void(0)" onClick={this.handleDuplicateTask}><i className="fa fa-copy"></i>{_("Duplicate")}</a></li>
);
}
if (this.props.hasPermission("delete")){
taskActions.push(
<li key="sep" role="separator" className="divider"></li>,
);
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.")
}));
}
let taskActionsIcon = "fa-ellipsis-h";
if (actionLoading) taskActionsIcon = "fa-circle-notch fa-spin fa-fw";
return (
<div className="task-list-item">
{this.state.showMoveDialog ?
<MoveTaskDialog
task={task}
ref={(domNode) => { this.moveTaskDialog = domNode; }}
onHide={() => this.setState({showMoveDialog: false})}
saveAction={this.moveTaskAction}
/>
: ""}
<div className="row">
<div className="col-sm-5 name">
<i onClick={this.toggleExpanded} className={"clickable far " + (this.state.expanded ? "fa-minus-square" : " fa-plus-square")}></i> <a href="javascript:void(0);" onClick={this.toggleExpanded}>{name}</a>
@ -642,9 +731,16 @@ class TaskListItem extends React.Component {
: statusLabel}
</div>
<div className="col-sm-1 text-right">
<div className="status-icon">
<i className={statusIcon}></i>
</div>
{taskActions.length > 0 ?
<div className="btn-group">
<button disabled={disabled || actionLoading} className="btn task-actions btn-secondary btn-xs dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i className={"fa " + taskActionsIcon}></i>
</button>
<ul className="dropdown-menu dropdown-menu-right">
{taskActions}
</ul>
</div>
: ""}
</div>
</div>
{expanded}

Wyświetl plik

@ -4,7 +4,7 @@ import TaskList from '../TaskList';
describe('<TaskList />', () => {
it('renders without exploding', () => {
const wrapper = shallow(<TaskList history={{}} source="tasklist.json" />);
const wrapper = shallow(<TaskList history={{}} source="tasklist.json" hasPermission={() => true} />);
expect(wrapper.exists()).toBe(true);
})
});

Wyświetl plik

@ -6,7 +6,7 @@ const taskMock = require('../../tests/utils/MockLoader').load("task.json");
describe('<TaskListItem />', () => {
it('renders without exploding', () => {
const wrapper = shallow(<TaskListItem history={createHistory()} data={taskMock} />);
const wrapper = shallow(<TaskListItem history={createHistory()} data={taskMock} hasPermission={() => true} />);
expect(wrapper.exists()).toBe(true);
})
});

Wyświetl plik

@ -55,6 +55,11 @@
margin-right: 12px;
}
}
.btn-danger .fa-trash{
margin-right: 2px;
margin-left: 0px;
}
.dz-preview{
display: none;

Wyświetl plik

@ -31,11 +31,8 @@
}
}
.status-icon{
margin-top: 3px;
.fa-cog{
width: auto;
}
.fa-cog{
width: auto;
}
.status-label{

Wyświetl plik

@ -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)
@ -161,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)

Wyświetl plik

@ -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()

Wyświetl plik

@ -32,6 +32,7 @@ services:
- WO_DEBUG
- WO_BROKER
- WO_DEV
- WO_DEV_WATCH_PLUGINS
restart: unless-stopped
oom_score_adj: 0
broker:

Wyświetl plik

@ -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": {

Wyświetl plik

@ -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 <port> 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

Wyświetl plik

@ -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']