Task List Panel

Main page allows viewing of all active, approved, completed and
canceled tasks can click through to view full task details.

Un-approved tasks can be resubmitted with new data, or approved,
tasks can be cancelled.

Change-Id: I35306fdf8e774d39b34c8c6fa50af5c70e010420
This commit is contained in:
Amelia Cordwell 2017-04-24 17:16:39 +12:00
parent 4feb401f9b
commit 113124e14b
18 changed files with 762 additions and 0 deletions

View File

@ -3,5 +3,6 @@ include setup.py
recursive-include stacktask_ui/content/default/templates *
recursive-include stacktask_ui/content/forgotpassword/templates *
recursive-include stacktask_ui/content/project_users/templates *
recursive-include stacktask_ui/content/tasks/templates *
recursive-include stacktask_ui/content/token/templates *
recursive-include stacktask_ui/static *

View File

@ -20,6 +20,8 @@ from six.moves.urllib.parse import urljoin
from django.conf import settings
from horizon.utils import functions as utils
from openstack_dashboard.api import base
LOG = logging.getLogger(__name__)
@ -29,6 +31,12 @@ USER = collections.namedtuple('User',
TOKEN = collections.namedtuple('Token',
['action'])
TASK = collections.namedtuple('Task',
['id', 'task_type', 'valid',
'request_by', 'request_project',
'created_on', 'approved_on', 'page',
'completed_on', 'actions', 'status'])
def _get_endpoint_url(request):
# If the request is made by an anonymous user, this endpoint request fails.
@ -230,3 +238,90 @@ def forgotpassword_submit(request, data):
except Exception as e:
LOG.error(e)
raise
def task_list(request, filters={}, page=1):
tasks_per_page = utils.get_page_size(request)
tasklist = []
prev = more = False
try:
headers = {"Content-Type": "application/json",
'X-Auth-Token': request.user.token.id}
params = {
"filters": json.dumps(filters),
"page": page,
"tasks_per_page": tasks_per_page
}
resp = get(request, "tasks", params=params, data=json.dumps({}),
headers=headers).json()
prev = resp['has_prev']
more = resp['has_more']
for task in resp['tasks']:
tasklist.append(task_obj_get(request, task=task, page=page))
except Exception as e:
LOG.error(e)
raise
return tasklist, prev, more
def task_get(request, task_id):
# Get a single task
headers = {"Content-Type": "application/json",
'X-Auth-Token': request.user.token.id}
return get(request, "tasks/%s" % task_id,
headers=headers)
def task_obj_get(request, task_id=None, task=None, page=0):
if not task:
task = task_get(request, task_id)
status = "Awaiting Approval"
if task['cancelled']:
status = "Cancelled"
elif task['completed_on']:
status = "Completed"
elif task['approved_on']:
status = "Approved; Incomplete"
valid = False not in [action['valid'] for
action in task['actions']]
return TASK(
id=task['uuid'],
task_type=task['task_type'],
valid=valid,
request_by=task['keystone_user'].get('username'),
request_project=task['keystone_user'].get('project_name'),
status=status,
created_on=task['created_on'],
approved_on=task['approved_on'],
completed_on=task['completed_on'],
actions=task['actions'],
page=page
)
def task_cancel(request, task_id):
headers = {"Content-Type": "application/json",
'X-Auth-Token': request.user.token.id}
return delete(request, "tasks/%s" % task_id,
headers=headers)
def task_approve(request, task_id):
headers = {"Content-Type": "application/json",
'X-Auth-Token': request.user.token.id}
return post(request, "tasks/%s" % task_id,
data=json.dumps({"approved": True}), headers=headers)
def task_update(request, task_id, new_data):
headers = {"Content-Type": "application/json",
'X-Auth-Token': request.user.token.id}
return put(request, "tasks/%s" % task_id,
data=new_data, headers=headers)

View File

View File

@ -0,0 +1,55 @@
# Copyright (c) 2016 Catalyst IT Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from django.utils.translation import ugettext_lazy as _
from horizon import forms
from horizon import messages
import json
from stacktask_ui.api import stacktask
class UpdateTaskForm(forms.SelfHandlingForm):
task_id = forms.CharField(widget=forms.HiddenInput())
task_type = forms.CharField(widget=forms.TextInput(
attrs={'readonly': True}))
task_data = forms.CharField(widget=forms.Textarea)
def clean_task_data(self):
taskdata = self.cleaned_data['task_data']
try:
json.loads(taskdata)
except ValueError:
raise forms.ValidationError(
"Invalid non-JSON data in Task Data field")
return taskdata
def handle(self, request, data):
task_id = self.cleaned_data.pop('task_id')
try:
response = stacktask.task_update(
request, task_id, data['task_data'])
if response.status_code == 200:
messages.success(request, _('Updated task successfully.'))
elif response.status_code == 400:
messages.error(request, _(response.text))
else:
messages.error(request, _('Failed to update task.'))
return True
except Exception:
messages.error(request, _('Failed to update task.'))
return False

View File

@ -0,0 +1,23 @@
# Copyright (c) 2016 Catalyst IT Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from django.utils.translation import ugettext_lazy as _
import horizon
class TaskList(horizon.Panel):
name = _('Tasks')
slug = 'tasks'
policy_rules = (("identity", "role:admin"),)

View File

@ -0,0 +1,184 @@
# Copyright (c) 2016 Catalyst IT Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ungettext_lazy
from horizon import exceptions
from horizon import tables
from stacktask_ui.api import stacktask
class CancelTask(tables.DeleteAction):
help_text = _("This will cancel all selected tasks.")
@staticmethod
def action_present(count):
return ungettext_lazy(
u"Cancel Task",
u"Cancel Tasks",
count
)
@staticmethod
def action_past(count):
return ungettext_lazy(
u"Cancelled Task",
u"Cancelled Tasks",
count
)
def delete(self, request, obj_id):
result = stacktask.task_cancel(request, obj_id)
if not result or result.status_code != 200:
exception = exceptions.NotAvailable()
exception._safe_message = False
raise exception
def allowed(self, request, task=None):
if task:
return not(
task.status == "Completed" or task.status == "Cancelled")
return True
class ApproveTask(tables.BatchAction):
name = "approve"
help_text = _("This will approve all of the selected tasks.")
@staticmethod
def action_present(count):
return ungettext_lazy(
u"Approve Task",
u"Approve Tasks",
count
)
@staticmethod
def action_past(count):
return ungettext_lazy(
u"Approved Task",
u"Approved Tasks",
count
)
def action(self, request, obj_id):
result = stacktask.task_approve(request, obj_id)
if not result or result.status_code != 200:
exception = exceptions.NotAvailable()
exception._safe_message = False
raise exception
def allowed(self, request, task=None):
if task:
return not(
task.status == "Completed" or task.status == "Cancelled")
return True
class ReapproveTask(ApproveTask):
name = "approve"
help_text = _("This will approve all of the selected tasks.")
@staticmethod
def action_present(count):
return ungettext_lazy(
u"Reapprove Task",
u"Repprove Tasks",
count
)
@staticmethod
def action_past(count):
return ungettext_lazy(
u"Reapproved Task",
u"Reapproved Tasks",
count
)
class UpdateTask(tables.LinkAction):
name = "update"
verbose_name = _("Update Task")
url = "horizon:management:tasks:update"
classes = ("ajax-modal",)
def allowed(self, request, task=None):
if task:
return task.status == 'Awaiting Approval'
return True
def TaskTypeDisplayFilter(task_type):
return task_type.replace("_", " ").title()
class TaskTable(tables.DataTable):
uuid = tables.Column('id', verbose_name=_('Task ID'),
hidden=True)
task_type = tables.Column('task_type', verbose_name=_('Task Type'),
filters=[TaskTypeDisplayFilter],
link="horizon:management:tasks:detail")
status = tables.Column('status', verbose_name=_('Status'))
request_by = tables.Column('request_by', verbose_name=_('Requestee'))
request_project = tables.Column('request_project',
verbose_name=_('Request Project'))
valid = tables.Column('valid', verbose_name=_("Actions Valid"))
created_on = tables.Column('created_on',
verbose_name=_('Request Date'))
page = tables.Column('page', hidden=True)
class Meta(object):
name = 'task_table'
verbose_name = _('Tasks')
table_actions = (CancelTask, ApproveTask)
row_actions = (ApproveTask, UpdateTask, CancelTask)
prev_pagination_param = pagination_param = 'task_page'
def get_prev_marker(self):
return str(int(self.data[0].page) - 1) if self.data else ''
def get_marker(self):
return str(int(self.data[0].page) + 1) if self.data else ''
def get_object_display(self, obj):
task_type = obj.task_type.replace("_", " ").title()
return "%s (%s)" % (task_type, obj.id)
class ApprovedTaskTable(TaskTable):
class Meta(object):
name = 'approved_table'
verbose_name = _('Tasks')
table_actions = (CancelTask, ReapproveTask)
row_actions = (CancelTask, ReapproveTask)
prev_pagination_param = pagination_param = 'approved_page'
class CompletedTaskTable(TaskTable):
class Meta(object):
name = 'completed_table'
verbose_name = _('Tasks')
prev_pagination_param = pagination_param = 'completed_page'
class CancelledTaskTable(TaskTable):
class Meta(object):
name = 'cancelled_table'
verbose_name = _('Tasks')
prev_pagination_param = pagination_param = 'cancelled_page'

View File

@ -0,0 +1,134 @@
# Copyright (c) 2016 Catalyst IT Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from django.utils.translation import ugettext_lazy as _
from horizon import exceptions
from horizon import tabs
from stacktask_ui.content.tasks import tables as task_tables
from stacktask_ui.api import stacktask
class ActiveTaskListTab(tabs.TableTab):
table_classes = (task_tables.TaskTable,)
template_name = 'horizon/common/_detail_table.html'
page_title = _("Active Tasks")
name = _('Active')
slug = 'active'
filters = {'cancelled': {'exact': False},
'approved': {'exact': False}}
_prev = False
_more = False
def get_task_table_data(self):
tasks = []
marker = self.request.GET.get(
self.table_classes[0]._meta.pagination_param, 1)
try:
tasks, self._prev, self._more = stacktask.task_list(
self.request, filters=self.filters, page=marker)
except Exception:
exceptions.handle(self.request, _('Failed to list tasks.'))
return tasks
def has_prev_data(self, table):
return self._prev
def has_more_data(self, table):
return self._more
class ApprovedTaskListTab(ActiveTaskListTab):
table_classes = (task_tables.ApprovedTaskTable,)
page_title = _("Approved Tasks")
name = _('Approved')
slug = 'approved'
filters = {'cancelled': {'exact': False},
'approved': {'exact': True},
'completed': {'exact': False}}
def get_approved_table_data(self):
return super(ApprovedTaskListTab, self).get_task_table_data()
class CompletedTaskListTab(ActiveTaskListTab):
table_classes = (task_tables.CompletedTaskTable,)
page_title = _("Completed Tasks")
name = _('Completed')
slug = 'completed'
filters = {'completed': {'exact': True}}
def get_completed_table_data(self):
return super(CompletedTaskListTab, self).get_task_table_data()
class CancelledTaskListTab(ActiveTaskListTab):
table_classes = (task_tables.CancelledTaskTable,)
name = _('Cancelled')
slug = 'cancelled'
filters = {'cancelled': {'exact': True}}
def get_cancelled_table_data(self):
return super(CancelledTaskListTab, self).get_task_table_data()
class TaskTabs(tabs.TabGroup):
slug = "tasks"
tabs = (ActiveTaskListTab, ApprovedTaskListTab, CompletedTaskListTab,
CancelledTaskListTab)
sticky = True
def get_selected_tab(self):
super(TaskTabs, self).get_selected_tab()
if not self._selected:
for tab in self.tabs:
param = tab.table_classes[0]._meta.pagination_param
if self.request.GET.get(param):
self._selected = self.get_tab(tab.slug)
return self._selected
class TaskOverviewTab(tabs.Tab):
name = _("Overview")
slug = "overview"
template_name = 'management/tasks/_task_detail_overview.html'
def get_context_data(self, request):
print self.tab_group
print self.tab_group.kwargs
return {"task": self.tab_group.kwargs['task']}
class TaskActionsTab(tabs.Tab):
name = _("Actions")
slug = "actions"
template_name = 'management/tasks/_task_detail_actions.html'
def get_context_data(self, request):
return {"task": self.tab_group.kwargs['task']}
class TaskNotesTab(tabs.Tab):
name = _("Action Notes")
slug = "notes"
template_name = 'management/tasks/_task_detail_notes.html'
def get_context_data(self, request):
return {"task": self.tab_group.kwargs['task']}
class TaskDetailTabs(tabs.DetailTabsGroup):
slug = "task_details"
tabs = (TaskOverviewTab, TaskActionsTab, TaskNotesTab)

View File

@ -0,0 +1,17 @@
{% load i18n sizeformat %}
{% load task_filters %}
<div class="detail">
<h4>{% trans "Actions" %}</h4>
<hr class="header_rule">
{% for action in task.actions %}
<h5>{{ action.action_name }}</h5>
<hr class="header_rule">
<dl class="dl-horizontal">
<dt>{% trans "Valid" %}</dt>
<dd>{{ action.valid }}</dd>
<dt>{% trans "Data" %}</dt>
<dd><pre>{{ action.data|pretty_json }}</pre></dd>
</dl>
{% endfor %}
</div>

View File

@ -0,0 +1,15 @@
{% load i18n sizeformat %}
{% load task_filters %}
<div class="detail">
<h4>{% trans "Action Notes" %}</h4>
<hr class="header_rule">
<dl class="dl-horizontal">
{% for action, notes in task.action_notes.iteritems %}
<dt>{{ action }}</dt>
{% for note in notes %}
<dd>{{ note }}</dd>
{% endfor %}
{% endfor %}
</dl>
</div>

View File

@ -0,0 +1,45 @@
{% load i18n sizeformat %}
{% load task_filters %}
<div class="detail">
<h4>{% trans "Task Details" %}</h4>
<hr class="header_rule">
<dl class="dl-horizontal">
<dt>{% trans "Task Type" %}</dt>
<dd>{{ task.task_type }}</dd>
<dt>{% trans "ID" %}</dt>
<dd>{{ task.uuid }}</dd>
<dt>{% trans "Cancelled" %}</dt>
<dd>{{ task.cancelled }}</dd>
<dt>{% trans "Approved" %}</dt>
<dd>{{ task.approved }}</dd>
<dt>{% trans "Completed" %}</dt>
<dd>{{ task.completed }}</dd>
{% if task.approved_by %}
<dt>{% trans "Approved By" %}</dt>
<dd>{{ task.approved_by.username }}</dd>
{% endif %}
<dt>{% trans "Requested on" %}</dt>
<dd>{{ task.created_on }}</dd>
<dt>{% trans "Approved on" %}</dt>
<dd>{% if task.approved_on %}{{ task.approved_on}}{% else %}-{% endif %}</dd>
<dt>{% trans "Completed on" %}</dt>
<dd>{% if task.completed_on %}{{ task.completed_on}}{% else %}-{% endif %}</dd>
</dl>
{% if task.keystone_user %}
<h4>{% trans "Requestee" %}</h4>
<hr class="header_rule">
<dl class="dl-horizontal">
<dt>{% trans "Username" %}</dt>
<dd>{{ task.keystone_user.username }}</dd>
<dt>{% trans "Project" %}</dt>
<dd>{{ task.keystone_user.project_name }}</dd>
<dt>{% trans "Roles" %}</dt>
<dd>{{ task.keystone_user.roles|pretty_list }}</dd>
<dt>{% trans "IP Address" %}</dt>
<dd>{{ task.ip_address }}</dd>
</dl>
{% endif %}
</div>

View File

@ -0,0 +1,8 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block modal_id %}update_user_form{% endblock %}
{% block modal-body %}
{% include "horizon/common/_form_fields.html" %}
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Tasks" %}{% endblock %}
{% block main %}
<div class="row">
<div class="col-sm-12">
{{ tab_group.render }}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,6 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Update User" %}{% endblock %}
{% block main %}
{% include 'management/tasks/_update.html' %}
{% endblock %}

View File

@ -0,0 +1,18 @@
from django import template
import json
register = template.Library()
@register.simple_tag
def pretty_json(value):
return json.dumps(value, indent=4)
@register.simple_tag
def pretty_list(value):
return ', '.join(value)
register.filter('pretty_json', pretty_json)
register.filter('pretty_list', pretty_list)

View File

@ -0,0 +1,29 @@
# Copyright (c) 2016 Catalyst IT Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from django.conf.urls import patterns
from django.conf.urls import url
from stacktask_ui.content.tasks import views
urlpatterns = patterns(
'',
url(r'^(?P<task_id>[^/]+)/$',
views.TaskDetailView.as_view(),
name='detail'),
url(r'^(?P<task_id>[^/]+)/update/$',
views.UpdateTaskView.as_view(), name='update'),
url(r'^$', views.IndexView.as_view(), name='index'),
)

View File

@ -0,0 +1,108 @@
# Copyright (c) 2016 Catalyst IT Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
from horizon import exceptions
from horizon import forms
from horizon import tabs
from horizon.utils import memoized
from stacktask_ui.content.tasks import tables as task_tables
from stacktask_ui.content.tasks import forms as task_forms
from stacktask_ui.content.tasks import tabs as task_tabs
from stacktask_ui.api import stacktask
import json
class IndexView(tabs.TabbedTableView):
tab_group_class = task_tabs.TaskTabs
template_name = 'management/tasks/index.html'
redirect_url = 'horizon:management:tasks:index'
page_title = _("Tasks")
class TaskDetailView(tabs.TabView):
tab_group_class = task_tabs.TaskDetailTabs
template_name = 'horizon/common/_detail.html'
redirect_url = 'horizon:management:tasks:index'
page_title = "{{ task.task_type }}"
def get_context_data(self, **kwargs):
context = super(TaskDetailView, self).get_context_data(**kwargs)
task = self.get_data()
context["task"] = task
context["url"] = reverse(self.redirect_url)
context["actions"] = self._get_actions(
stacktask.task_obj_get(self.request, task=task))
return context
def _get_actions(self, task):
table = task_tables.TaskTable(self.request)
return table.render_row_actions(task)
@memoized.memoized_method
def get_data(self):
return stacktask.task_get(self.request, self.kwargs['task_id']).json()
def get_tabs(self, request, *args, **kwargs):
task = self.get_data()
return self.tab_group_class(request, task=task, **kwargs)
class UpdateTaskView(forms.ModalFormView):
form_class = task_forms.UpdateTaskForm
form_id = "update_user_form"
modal_header = _("Update Task")
submit_label = _("Update")
submit_url = 'horizon:management:tasks:update'
template_name = 'management/tasks/update.html'
context_object_name = 'project_users'
success_url = "horizon:management:tasks:detail"
page_title = _("Update Task")
def get_success_url(self):
return reverse(self.success_url,
args=(self.kwargs['task_id'],))
@memoized.memoized_method
def get_object(self):
try:
return stacktask.task_get(self.request,
self.kwargs['task_id']).json()
except Exception:
msg = _('Unable to retrieve user.')
url = reverse('horizon:management:tasks:index')
exceptions.handle(self.request, msg, redirect=url)
def get_context_data(self, **kwargs):
context = super(UpdateTaskView, self).get_context_data(**kwargs)
context['task'] = self.get_object()
args = (self.kwargs['task_id'],)
context['submit_url'] = reverse(self.submit_url, args=args)
return context
def get_initial(self):
task = self.get_object()
task_data = {}
for data in [action['data'] for action in task['actions']]:
task_data.update(data)
data = {'task_id': self.kwargs['task_id'],
'task_type': task['task_type'],
'task_data': json.dumps(task_data, indent=4),
}
return data

View File

@ -0,0 +1,13 @@
# The slug of the panel to be added to HORIZON_CONFIG. Required.
PANEL = 'tasks'
# The slug of the dashboard the PANEL associated with. Required.
PANEL_DASHBOARD = 'management'
# The slug of the panel group the PANEL is associated with.
PANEL_GROUP = 'default'
# Python panel class of the PANEL to be added.
ADD_PANEL = 'stacktask_ui.content.tasks.panel.TaskList'
ADD_INSTALLED_APPS = [
'stacktask_ui.content.tasks'
]