From ff7810020536d5e67a1e74d404886968a72aebc6 Mon Sep 17 00:00:00 2001 From: Stephen Jones Date: Wed, 20 Sep 2017 15:18:25 -0400 Subject: [PATCH 001/126] Mention docker version for installation --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index ae0b9a0a..90e5cf2e 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,10 @@ The ``geoq/settings.py`` file contains installation-specific settings. The Datab ### GeoQ Installation ### +**Docker Installation::** +A new docker implementation has been developed and is available at [https://hub.docker.com/r/stephenrjones/geoq-django10]. This is an upgraded +version of the server and has not yet been fully tested. + **Cloud Installation::** 1. You can optionally deploy GeoQ with all dependencies to a Virtual Machine or a cloud VM (such as an Amazon Web Services EC2 box) by using the chef installer at [https://github.com/ngageoint/geoq-chef-installer](https://github.com/ngageoint/geoq-chef-installer) From 6d507439646f3e75fb1699e3d1e54f8d4a657f31 Mon Sep 17 00:00:00 2001 From: Stephen Jones Date: Wed, 20 Sep 2017 15:22:39 -0400 Subject: [PATCH 002/126] Mention docker version for installation --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 35159729..2d5e5bbf 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,9 @@ The ``geoq/settings.py`` file contains installation-specific settings. The Datab ### GeoQ Installation ### **Docker Installation::** -A new docker implementation has been developed and is available at [https://hub.docker.com/r/stephenrjones/geoq-django10]. This is an upgraded -version of the server and has not yet been fully tested. + +1. A new docker implementation has been developed and is available at [https://hub.docker.com/r/stephenrjones/geoq-django10](https://hub.docker.com/r/stephenrjones/geoq-django10). +This is an upgraded version of the server and has not yet been fully tested. **Cloud Installation::** From d25ebfa4fe2968b9f8ae6ee4b8082c540edc732d Mon Sep 17 00:00:00 2001 From: David Emily Date: Fri, 20 Oct 2017 18:57:16 -0500 Subject: [PATCH 003/126] changed formatting on markdown Some of the titles did not have a space after the '##' resulting in the header not properly utilizing markdown. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2d5e5bbf..465ecd5a 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,12 @@ GeoQ is an open source (MIT License) geographic tasking system that allows teams The GeoQ software was developed at the National Geospatial-Intelligence Agency (NGA) in collaboration with [The MITRE Corporation] (http://www.mitre.org). The government has "unlimited rights" and is releasing this software to increase the impact of government investments by providing developers with the opportunity to take things in new directions. The software use, modification, and distribution rights are stipulated within the [MIT] (http://choosealicense.com/licenses/mit/) license. -###Pull Requests +### Pull Requests If you'd like to contribute to this project, please make a pull request. We'll review the pull request and discuss the changes. All pull request contributions to this project will be released under the MIT license. Software source code previously released under an open source license and then modified by NGA staff is considered a "joint work" (see 17 USC ยง 101); it is partially copyrighted, partially public domain, and as a whole is protected by the copyrights of the non-government authors and must be released according to the terms of the original open source license. -###In the News +### In the News For current news regarding GeoQ, see our [Wiki Page](https://github.com/ngageoint/geoq/wiki/In-The-News) ### Screenshots From 1350dd3c65821a30d1a6188767381ad9958ccec0 Mon Sep 17 00:00:00 2001 From: stephenrjones Date: Fri, 30 Mar 2018 11:48:45 -0400 Subject: [PATCH 004/126] first pass at adding workflow package --- geoq/workflow/__init__.py | 0 geoq/workflow/admin.py | 62 + geoq/workflow/apps.py | 5 + .../workflow/fixtures/workflow_test_data.json | 511 ++++++++ geoq/workflow/forms.py | 44 + geoq/workflow/migrations/0001_initial.py | 202 +++ geoq/workflow/migrations/__init__.py | 0 geoq/workflow/models.py | 1120 +++++++++++++++++ geoq/workflow/templates/graphviz/state.dot | 4 + .../templates/graphviz/transition.dot | 1 + geoq/workflow/templates/graphviz/workflow.dot | 17 + geoq/workflow/test_runner.py | 96 ++ geoq/workflow/tests.py | 216 ++++ geoq/workflow/unit_tests/__init__.py | 0 geoq/workflow/unit_tests/test_forms.py | 26 + geoq/workflow/unit_tests/test_models.py | 1006 +++++++++++++++ geoq/workflow/unit_tests/test_views.py | 86 ++ geoq/workflow/urls.py | 8 + geoq/workflow/views.py | 66 + 19 files changed, 3470 insertions(+) create mode 100644 geoq/workflow/__init__.py create mode 100644 geoq/workflow/admin.py create mode 100644 geoq/workflow/apps.py create mode 100644 geoq/workflow/fixtures/workflow_test_data.json create mode 100644 geoq/workflow/forms.py create mode 100644 geoq/workflow/migrations/0001_initial.py create mode 100644 geoq/workflow/migrations/__init__.py create mode 100644 geoq/workflow/models.py create mode 100644 geoq/workflow/templates/graphviz/state.dot create mode 100644 geoq/workflow/templates/graphviz/transition.dot create mode 100644 geoq/workflow/templates/graphviz/workflow.dot create mode 100644 geoq/workflow/test_runner.py create mode 100644 geoq/workflow/tests.py create mode 100644 geoq/workflow/unit_tests/__init__.py create mode 100644 geoq/workflow/unit_tests/test_forms.py create mode 100644 geoq/workflow/unit_tests/test_models.py create mode 100644 geoq/workflow/unit_tests/test_views.py create mode 100644 geoq/workflow/urls.py create mode 100644 geoq/workflow/views.py diff --git a/geoq/workflow/__init__.py b/geoq/workflow/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/geoq/workflow/admin.py b/geoq/workflow/admin.py new file mode 100644 index 00000000..64822a40 --- /dev/null +++ b/geoq/workflow/admin.py @@ -0,0 +1,62 @@ +# -*- coding: UTF-8 -*- +from django.contrib import admin +from models import Role, Workflow, State, Transition, EventType, Event + +class RoleAdmin(admin.ModelAdmin): + """ + Role administration + """ + list_display = ['name', 'description'] + search_fields = ['name', 'description'] + save_on_top = True + +class WorkflowAdmin(admin.ModelAdmin): + """ + Workflow administration + """ + list_display = ['name', 'description', 'status', 'created_on', 'created_by', + 'cloned_from'] + search_fields = ['name', 'description'] + save_on_top = True + exclude = ['created_on', 'cloned_from'] + list_filter = ['status'] + +class StateAdmin(admin.ModelAdmin): + """ + State administration + """ + list_display = ['name', 'description'] + search_fields = ['name', 'description'] + save_on_top = True + +class TransitionAdmin(admin.ModelAdmin): + """ + Transition administation + """ + list_display = ['name', 'from_state', 'to_state'] + search_fields = ['name',] + save_on_top = True + +class EventTypeAdmin(admin.ModelAdmin): + """ + EventType administration + """ + list_display = ['name', 'description'] + save_on_top = True + search_fields = ['name', 'description'] + +class EventAdmin(admin.ModelAdmin): + """ + Event administration + """ + list_display = ['name', 'description', 'workflow', 'state', 'is_mandatory'] + save_on_top = True + search_fields = ['name', 'description'] + list_filter = ['event_types', 'is_mandatory'] + +admin.site.register(Role, RoleAdmin) +admin.site.register(Workflow, WorkflowAdmin) +admin.site.register(State, StateAdmin) +admin.site.register(Transition, TransitionAdmin) +admin.site.register(EventType, EventTypeAdmin) +admin.site.register(Event, EventAdmin) diff --git a/geoq/workflow/apps.py b/geoq/workflow/apps.py new file mode 100644 index 00000000..35481e40 --- /dev/null +++ b/geoq/workflow/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + +class WorkflowConfig(AppConfig): + name = 'geoq.workflow' + verbose_name = 'Workflow' \ No newline at end of file diff --git a/geoq/workflow/fixtures/workflow_test_data.json b/geoq/workflow/fixtures/workflow_test_data.json new file mode 100644 index 00000000..9932d6ba --- /dev/null +++ b/geoq/workflow/fixtures/workflow_test_data.json @@ -0,0 +1,511 @@ +[ + { + "pk": 1, + "model": "auth.user", + "fields": { + "username": "test_admin", + "first_name": "", + "last_name": "", + "is_active": true, + "is_superuser": false, + "is_staff": false, + "last_login": "2009-05-25 06:56:14", + "groups": [], + "user_permissions": [], + "password": "sha1$9428b$ed1706975ace1a2b243b861105c7793e34b9340e", + "email": "test_admin@example.com", + "date_joined": "2009-05-25 06:56:14" + } + }, + { + "pk": 2, + "model": "auth.user", + "fields": { + "username": "test_manager", + "first_name": "", + "last_name": "", + "is_active": true, + "is_superuser": false, + "is_staff": false, + "last_login": "2009-05-25 06:56:42", + "groups": [], + "user_permissions": [], + "password": "sha1$333dc$64129c72885ab4a75fb8d13c9f6a95b5ab9bb7dd", + "email": "test_manager@example.com", + "date_joined": "2009-05-25 06:56:42" + } + }, + { + "pk": 3, + "model": "auth.user", + "fields": { + "username": "test_staff", + "first_name": "", + "last_name": "", + "is_active": true, + "is_superuser": false, + "is_staff": false, + "last_login": "2009-05-25 06:57:03", + "groups": [], + "user_permissions": [], + "password": "sha1$473a9$5b0c8b2b76e3f3c229c23abf415b103d39119580", + "email": "test_staff@example.com", + "date_joined": "2009-05-25 06:57:03" + } + }, + { + "pk": 1, + "model": "workflow.role", + "fields": { + "name": "Administrator", + "description": "An administrative role" + } + }, + { + "pk": 2, + "model": "workflow.role", + "fields": { + "name": "Manager", + "description": "A managerial role" + } + }, + { + "pk": 3, + "model": "workflow.role", + "fields": { + "name": "Staff", + "description": "A generic staff role" + } + }, + { + "pk": 1, + "model": "workflow.workflow", + "fields": { + "status": 0, + "name": "test workflow", + "slug": "test_workflow", + "cloned_from": null, + "created_by": 1, + "created_on": "2009-05-25 06:59:19", + "description": "A simple workflow created for the purposes of testing" + } + }, + { + "pk": 1, + "model": "workflow.state", + "fields": { + "estimation_unit": 86400, + "name": "Start State", + "roles": [ + 1, + 2 + ], + "workflow": 1, + "description": "The start state for the test workflow", + "is_end_state": false, + "is_start_state": true, + "estimation_value": 1 + } + }, + { + "pk": 2, + "model": "workflow.state", + "fields": { + "estimation_unit": 604800, + "name": "State2", + "roles": [ + 1, + 2, + 3 + ], + "workflow": 1, + "description": "A second state in the test workflow", + "is_end_state": false, + "is_start_state": false, + "estimation_value": 1 + } + }, + { + "pk": 3, + "model": "workflow.state", + "fields": { + "estimation_unit": 86400, + "name": "State for Branching", + "roles": [ + 1, + 2 + ], + "workflow": 1, + "description": "A state with more than one transition to simulate a branching in the workflow tree", + "is_end_state": false, + "is_start_state": false, + "estimation_value": 0 + } + }, + { + "pk": 4, + "model": "workflow.state", + "fields": { + "estimation_unit": 86400, + "name": "Branch 1", + "roles": [ + 1, + 2, + 3 + ], + "workflow": 1, + "description": "A state in the first branch of the test workflow", + "is_end_state": false, + "is_start_state": false, + "estimation_value": 0 + } + }, + { + "pk": 5, + "model": "workflow.state", + "fields": { + "estimation_unit": 86400, + "name": "Branch 2", + "roles": [ + 1, + 3 + ], + "workflow": 1, + "description": "A first state on the second branch of the test workflow", + "is_end_state": false, + "is_start_state": false, + "estimation_value": 3 + } + }, + { + "pk": 6, + "model": "workflow.state", + "fields": { + "estimation_unit": 86400, + "name": "Branch 2 state 2", + "roles": [ + 1, + 3 + ], + "workflow": 1, + "description": "A second state on the second branch of the test workflow", + "is_end_state": false, + "is_start_state": false, + "estimation_value": 2 + } + }, + { + "pk": 7, + "model": "workflow.state", + "fields": { + "estimation_unit": 86400, + "name": "End state, branch 1", + "roles": [ + 1, + 2, + 3 + ], + "workflow": 1, + "description": "An end state only available from branch 1", + "is_end_state": true, + "is_start_state": false, + "estimation_value": 0 + } + }, + { + "pk": 8, + "model": "workflow.state", + "fields": { + "estimation_unit": 604800, + "name": "Merge branches", + "roles": [ + 1, + 2, + 3 + ], + "workflow": 1, + "description": "A state that combines the paths from both branches", + "is_end_state": false, + "is_start_state": false, + "estimation_value": 1 + } + }, + { + "pk": 9, + "model": "workflow.state", + "fields": { + "estimation_unit": 86400, + "name": "End state", + "roles": [ + 1, + 2, + 3 + ], + "workflow": 1, + "description": "An end state that is available from both branches", + "is_end_state": true, + "is_start_state": false, + "estimation_value": 0 + } + }, + { + "pk": 1, + "model": "workflow.transition", + "fields": { + "from_state": 1, + "to_state": 2, + "name": "Proceed to state 2", + "roles": [ + 1 + ], + "workflow": 1 + } + }, + { + "pk": 2, + "model": "workflow.transition", + "fields": { + "from_state": 2, + "to_state": 3, + "name": "Proceed to state 3", + "roles": [ + 1, + 2 + ], + "workflow": 1 + } + }, + { + "pk": 3, + "model": "workflow.transition", + "fields": { + "from_state": 3, + "to_state": 4, + "name": "Choose branch 1", + "roles": [ + 1, + 2 + ], + "workflow": 1 + } + }, + { + "pk": 4, + "model": "workflow.transition", + "fields": { + "from_state": 3, + "to_state": 5, + "name": "Choose branch 2", + "roles": [ + 1, + 3 + ], + "workflow": 1 + } + }, + { + "pk": 5, + "model": "workflow.transition", + "fields": { + "from_state": 4, + "to_state": 2, + "name": "Go back to state 2", + "roles": [ + 1, + 2, + 3 + ], + "workflow": 1 + } + }, + { + "pk": 6, + "model": "workflow.transition", + "fields": { + "from_state": 4, + "to_state": 7, + "name": "End early", + "roles": [ + 1, + 2 + ], + "workflow": 1 + } + }, + { + "pk": 7, + "model": "workflow.transition", + "fields": { + "from_state": 4, + "to_state": 8, + "name": "Go to merge branches", + "roles": [ + 1, + 2, + 3 + ], + "workflow": 1 + } + }, + { + "pk": 8, + "model": "workflow.transition", + "fields": { + "from_state": 5, + "to_state": 6, + "name": "Proceed with branch 2", + "roles": [ + 1, + 3 + ], + "workflow": 1 + } + }, + { + "pk": 9, + "model": "workflow.transition", + "fields": { + "from_state": 6, + "to_state": 3, + "name": "Return to step 3", + "roles": [ + 1, + 3 + ], + "workflow": 1 + } + }, + { + "pk": 10, + "model": "workflow.transition", + "fields": { + "from_state": 6, + "to_state": 8, + "name": "Go to merge branches", + "roles": [ + 1, + 3 + ], + "workflow": 1 + } + }, + { + "pk": 11, + "model": "workflow.transition", + "fields": { + "from_state": 8, + "to_state": 9, + "name": "Finish workflow", + "roles": [ + 1, + 2, + 3 + ], + "workflow": 1 + } + }, + { + "pk": 1, + "model": "workflow.eventtype", + "fields": { + "name": "Meeting", + "description": "A meeting of all those participants related to the specific state the event is associated with" + } + }, + { + "pk": 2, + "model": "workflow.eventtype", + "fields": { + "name": "Deadline", + "description": "Something needs to be done by the time this event takes place" + } + }, + { + "pk": 3, + "model": "workflow.eventtype", + "fields": { + "name": "Review", + "description": "A decision needs to made about something as part of this event" + } + }, + { + "pk": 4, + "model": "workflow.eventtype", + "fields": { + "name": "Assessment", + "description": "A thing requires testing" + } + }, + { + "pk": 1, + "model": "workflow.event", + "fields": { + "is_mandatory": true, + "name": "Important meeting", + "roles": [ + 1, + 2 + ], + "workflow": 1, + "state": 2, + "event_types": [ + 1, + 3 + ], + "description": "An event in the test workflow" + } + }, + { + "pk": 2, + "model": "workflow.event", + "fields": { + "is_mandatory": false, + "name": "Branching meeting", + "roles": [ + 1, + 2 + ], + "workflow": 1, + "state": 3, + "event_types": [ + 1, + 4 + ], + "description": "A meeting to discuss which branch to choose" + } + }, + { + "pk": 3, + "model": "workflow.event", + "fields": { + "is_mandatory": false, + "name": "Project Party!", + "roles": [ + 1, + 2, + 3 + ], + "workflow": 1, + "state": 8, + "event_types": [ + 2 + ], + "description": "A party for getting to the end of the workflow" + } + }, + { + "pk": 4, + "model": "workflow.event", + "fields": { + "is_mandatory": false, + "name": "Team meeting", + "roles": [], + "workflow": null, + "state": null, + "event_types": [ + 1 + ], + "description": "Impromptu team meeting" + } + } +] diff --git a/geoq/workflow/forms.py b/geoq/workflow/forms.py new file mode 100644 index 00000000..382fb3e0 --- /dev/null +++ b/geoq/workflow/forms.py @@ -0,0 +1,44 @@ +# -*- coding: UTF-8 -*- +""" +Forms for Workflows. + +Copyright (c) 2009 Nicholas H.Tollervey (http://ntoll.org/contact) + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in +the documentation and/or other materials provided with the +distribution. +* Neither the name of ntoll.org nor the names of its +contributors may be used to endorse or promote products +derived from this software without specific prior written +permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +# Django +from django import forms +from django.forms.util import ErrorList +from django.utils.translation import ugettext as _ + +# Workflow models +from workflow.models import * diff --git a/geoq/workflow/migrations/0001_initial.py b/geoq/workflow/migrations/0001_initial.py new file mode 100644 index 00000000..86d03257 --- /dev/null +++ b/geoq/workflow/migrations/0001_initial.py @@ -0,0 +1,202 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.6 on 2018-03-30 15:39 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Event', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=256, verbose_name='Event summary')), + ('description', models.TextField(blank=True, verbose_name='Description')), + ('is_mandatory', models.BooleanField(default=False, help_text='This event must be marked as complete before moving out of the associated state.', verbose_name='Mandatory event')), + ], + options={ + 'verbose_name': 'Event', + 'verbose_name_plural': 'Events', + }, + ), + migrations.CreateModel( + name='EventType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=256, verbose_name='Event Type Name')), + ('description', models.TextField(blank=True, verbose_name='Description')), + ], + ), + migrations.CreateModel( + name='Participant', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('disabled', models.BooleanField(default=False)), + ], + options={ + 'ordering': ['-disabled', 'workflowactivity', 'user'], + 'verbose_name': 'Participant', + 'verbose_name_plural': 'Participants', + }, + ), + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=64, verbose_name='Name of Role')), + ('description', models.TextField(blank=True, verbose_name='Description')), + ], + options={ + 'ordering': ['name'], + 'verbose_name': 'Role', + 'verbose_name_plural': 'Roles', + 'permissions': (('can_define_roles', 'Can define roles'),), + }, + ), + migrations.CreateModel( + name='State', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=256, verbose_name='Name')), + ('description', models.TextField(blank=True, verbose_name='Description')), + ('is_start_state', models.BooleanField(default=False, help_text='There can only be one start state for a workflow', verbose_name='Is the start state?')), + ('is_end_state', models.BooleanField(default=False, help_text='An end state shows that the workflow is complete', verbose_name='Is an end state?')), + ('estimation_value', models.IntegerField(default=0, help_text='Use whole numbers', verbose_name='Estimated time (value)')), + ('estimation_unit', models.IntegerField(choices=[(1, 'Second(s)'), (60, 'Minute(s)'), (3600, 'Hour(s)'), (86400, 'Day(s)'), (604800, 'Week(s)')], default=86400, verbose_name='Estimation unit of time')), + ('roles', models.ManyToManyField(blank=True, to='workflow.Role')), + ], + options={ + 'ordering': ['-is_start_state', 'is_end_state'], + 'verbose_name': 'State', + 'verbose_name_plural': 'States', + }, + ), + migrations.CreateModel( + name='Transition', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Use an "active" verb. e.g. "Close Issue", "Open Vacancy" or "Start Interviews"', max_length=128, verbose_name='Name of transition')), + ('from_state', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transitions_from', to='workflow.State')), + ('roles', models.ManyToManyField(blank=True, to='workflow.Role')), + ('to_state', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transitions_into', to='workflow.State')), + ], + options={ + 'verbose_name': 'Transition', + 'verbose_name_plural': 'Transitions', + }, + ), + migrations.CreateModel( + name='Workflow', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=128, verbose_name='Workflow Name')), + ('slug', models.SlugField(verbose_name='Slug')), + ('description', models.TextField(blank=True, verbose_name='Description')), + ('status', models.IntegerField(choices=[(0, 'In definition'), (1, 'Active'), (2, 'Retired')], default=0, verbose_name='Status')), + ('created_on', models.DateTimeField(auto_now_add=True)), + ('cloned_from', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='workflow.Workflow')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['status', 'name'], + 'verbose_name': 'Workflow', + 'verbose_name_plural': 'Workflows', + 'permissions': (('can_manage_workflows', 'Can manage workflows'),), + }, + ), + migrations.CreateModel( + name='WorkflowActivity', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_on', models.DateTimeField(auto_now_add=True)), + ('completed_on', models.DateTimeField(blank=True, null=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('workflow', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='workflow.Workflow')), + ], + options={ + 'ordering': ['-completed_on', '-created_on'], + 'verbose_name': 'Workflow Activity', + 'verbose_name_plural': 'Workflow Activites', + 'permissions': (('can_start_workflow', 'Can start a workflow'), ('can_assign_roles', 'Can assign roles')), + }, + ), + migrations.CreateModel( + name='WorkflowHistory', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('log_type', models.IntegerField(choices=[(1, 'Transition'), (2, 'Event'), (3, 'Role'), (4, 'Comment')], help_text='The sort of thing being logged')), + ('created_on', models.DateTimeField(auto_now_add=True)), + ('note', models.TextField(blank=True, verbose_name='Note')), + ('deadline', models.DateTimeField(blank=True, help_text='The deadline for staying in this state', null=True, verbose_name='Deadline')), + ('event', models.ForeignKey(help_text='The event relating to this happening in the workflow history', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='history', to='workflow.Event')), + ('participant', models.ForeignKey(help_text='The participant who triggered this happening in the workflow history', on_delete=django.db.models.deletion.CASCADE, to='workflow.Participant')), + ('state', models.ForeignKey(help_text='The state at this point in the workflow history', null=True, on_delete=django.db.models.deletion.CASCADE, to='workflow.State')), + ('transition', models.ForeignKey(help_text='The transition relating to this happening in the workflow history', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='history', to='workflow.Transition')), + ('workflowactivity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='history', to='workflow.WorkflowActivity')), + ], + options={ + 'ordering': ['-created_on'], + 'verbose_name': 'Workflow History', + 'verbose_name_plural': 'Workflow Histories', + }, + ), + migrations.AddField( + model_name='transition', + name='workflow', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transitions', to='workflow.Workflow'), + ), + migrations.AddField( + model_name='state', + name='workflow', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='states', to='workflow.Workflow'), + ), + migrations.AddField( + model_name='participant', + name='roles', + field=models.ManyToManyField(blank=True, to='workflow.Role'), + ), + migrations.AddField( + model_name='participant', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='participant', + name='workflowactivity', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='workflow.WorkflowActivity'), + ), + migrations.AddField( + model_name='event', + name='event_types', + field=models.ManyToManyField(to='workflow.EventType'), + ), + migrations.AddField( + model_name='event', + name='roles', + field=models.ManyToManyField(to='workflow.Role'), + ), + migrations.AddField( + model_name='event', + name='state', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='events', to='workflow.State'), + ), + migrations.AddField( + model_name='event', + name='workflow', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='events', to='workflow.Workflow'), + ), + migrations.AlterUniqueTogether( + name='participant', + unique_together=set([('user', 'workflowactivity')]), + ), + ] diff --git a/geoq/workflow/migrations/__init__.py b/geoq/workflow/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/geoq/workflow/models.py b/geoq/workflow/models.py new file mode 100644 index 00000000..ed552dec --- /dev/null +++ b/geoq/workflow/models.py @@ -0,0 +1,1120 @@ +# -*- coding: UTF-8 -*- +""" +Models for Workflows. + +Copyright (c) 2009 Nicholas H.Tollervey (http://ntoll.org/contact) + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in +the documentation and/or other materials provided with the +distribution. +* Neither the name of ntoll.org nor the names of its +contributors may be used to endorse or promote products +derived from this software without specific prior written +permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +from django.db import models +from django.core.exceptions import ObjectDoesNotExist +from django.utils.translation import ugettext_lazy as _, ugettext as __ +from django.contrib.auth.models import User +import django.dispatch +import datetime + +############ +# Exceptions +############ + +class UnableToActivateWorkflow(Exception): + """ + To be raised if unable to activate the workflow because it did not pass the + validation steps + """ + +class UnableToCloneWorkflow(Exception): + """ + To be raised if unable to clone a workflow model (and related models) + """ + +class UnableToStartWorkflow(Exception): + """ + To be raised if a WorkflowActivity is unable to start a workflow + """ + +class UnableToProgressWorkflow(Exception): + """ + To be raised if the WorkflowActivity is unable to progress a workflow with a + particular transition. + """ + +class UnableToLogWorkflowEvent(Exception): + """ + To be raised if the WorkflowActivity is unable to log an event in the + WorkflowHistory + """ + +class UnableToAddCommentToWorkflow(Exception): + """ + To be raised if the WorkflowActivity is unable to log a comment in the + WorkflowHistory + """ + +class UnableToDisableParticipant(Exception): + """ + To be raised if the WorkflowActivity is unable to disable a participant + """ + +class UnableToEnableParticipant(Exception): + """ + To be raised if the WorkflowActivity is unable to enable a participant + """ + +######### +# Signals +######### + +# Fired when a role is assigned to a user for a particular run of a workflow +# (defined in the WorkflowActivity). The sender is an instance of the +# WorkflowHistory model logging this event. +role_assigned = django.dispatch.Signal() +# Fired when a role is removed from a user for a particular run of a workflow +# (defined in the WorkflowActivity). The sender is an instance of the +# WorkflowHistory model logging this event. +role_removed = django.dispatch.Signal() +# Fired when a new WorkflowActivity starts navigating a workflow. The sender is +# an instance of the WorkflowActivity model +workflow_started = django.dispatch.Signal() +# Fired just before a WorkflowActivity creates a new item in the Workflow History +# (the sender is an instance of the WorkflowHistory model) +workflow_pre_change = django.dispatch.Signal() +# Fired after a WorkflowActivity creates a new item in the Workflow History (the +# sender is an instance of the WorkflowHistory model) +workflow_post_change = django.dispatch.Signal() +# Fired when a WorkflowActivity causes a transition to a new state (the sender is +# an instance of the WorkflowHistory model) +workflow_transitioned = django.dispatch.Signal() +# Fired when some event happens during the life of a WorkflowActivity (the +# sender is an instance of the WorkflowHistory model) +workflow_event_completed = django.dispatch.Signal() +# Fired when a comment is created during the lift of a WorkflowActivity (the +# sender is an instance of the WorkflowHistory model) +workflow_commented = django.dispatch.Signal() +# Fired when an active WorkflowActivity reaches a workflow's end state. The +# sender is an instance of the WorkflowActivity model +workflow_ended = django.dispatch.Signal() + +######## +# Models +######## +class Role(models.Model): + """ + Represents a type of user who can be associated with a workflow. Used by + the State and Transition models to define *who* has permission to view a + state or use a transition. The Event model uses this model to reference + *who* should be involved in a particular event. + """ + name = models.CharField( + _('Name of Role'), + max_length=64 + ) + description = models.TextField( + _('Description'), + blank=True + ) + + def __unicode__(self): + return self.name + + class Meta: + ordering = ['name',] + verbose_name = _('Role') + verbose_name_plural = _('Roles') + permissions = ( + ('can_define_roles', __('Can define roles')), + ) + +class Workflow(models.Model): + """ + Instances of this class represent a named workflow that achieve a particular + aim through a series of related states / transitions. A name for a directed + graph. + """ + + # A workflow can be in one of three states: + # + # * definition: you're building the thing to meet whatever requirements you + # have + # + # * active: you're using the defined workflow in relation to things in your + # application - the workflow definition is frozen from this point on. + # + # * retired: you no longer use the workflow (but we keep it so it can be + # cloned as the basis of new workflows starting in the definition state) + # + # Why do this? Imagine the mess that could be created if a "live" workflow + # was edited and states were deleted or orphaned. These states at least + # allow us to check things don't go horribly wrong. :-/ + DEFINITION = 0 + ACTIVE = 1 + RETIRED = 2 + + STATUS_CHOICE_LIST = ( + (DEFINITION, _('In definition')), + (ACTIVE, _('Active')), + (RETIRED, _('Retired')), + ) + + name = models.CharField( + _('Workflow Name'), + max_length=128 + ) + slug = models.SlugField( + _('Slug') + ) + description = models.TextField( + _('Description'), + blank=True + ) + status = models.IntegerField( + _('Status'), + choices=STATUS_CHOICE_LIST, + default = DEFINITION + ) + # These next fields are helpful for tracking the history and devlopment of a + # workflow should it have been cloned + created_on = models.DateTimeField( + auto_now_add=True + ) + created_by = models.ForeignKey( + User + ) + cloned_from = models.ForeignKey( + 'self', + null=True + ) + + # To hold error messages created in the validate method + errors = { + 'workflow':[], + 'states': {}, + 'transitions':{}, + } + + def is_valid(self): + """ + Checks that the directed graph doesn't contain any orphaned nodes (is + connected), any cul-de-sac nodes (non-end nodes with no exit + transition), has compatible roles for transitions and states and + contains exactly one start node and at least one end state. + + Any errors are logged in the errors dictionary. + + Returns a boolean + """ + self.errors = { + 'workflow':[], + 'states': {}, + 'transitions':{}, + } + valid = True + + # The graph must have only one start node + if self.states.filter(is_start_state=True).count() != 1: + self.errors['workflow'].append(__('There must be only one start'\ + ' state')) + valid = False + + # The graph must have at least one end state + if self.states.filter(is_end_state=True).count() < 1: + self.errors['workflow'].append(__('There must be at least one end'\ + ' state')) + valid = False + + # Check for orphan nodes / cul-de-sac nodes + all_states = self.states.all() + for state in all_states: + if state.transitions_into.all().count() == 0 and state.is_start_state == False: + if not state.id in self.errors['states']: + self.errors['states'][state.id] = list() + self.errors['states'][state.id].append(__('This state is'\ + ' orphaned. There is no way to get to it given the'\ + ' current workflow topology.')) + valid = False + + if state.transitions_from.all().count() == 0 and state.is_end_state == False: + if not state.id in self.errors['states']: + self.errors['states'][state.id] = list() + self.errors['states'][state.id].append(__('This state is a'\ + ' dead end. It is not marked as an end state and there'\ + ' is no way to exit from it.')) + valid = False + + # Check the role collections are compatible between states and + # transitions (i.e. there cannot be any transitions that are only + # available to participants with roles that are not also roles + # associated with the parent state). + for state in all_states: + # *at least* one role from the state must also be associated + # with each transition where the state is the from_state + state_roles = state.roles.all() + for transition in state.transitions_from.all(): + if not transition.roles.filter(pk__in=[r.id for r in state_roles]): + if not transition.id in self.errors['transitions']: + self.errors['transitions'][transition.id] = list() + self.errors['transitions'][transition.id].append(__('This'\ + ' transition is not navigable because none of the'\ + ' roles associated with the parent state have'\ + ' permission to use it.')) + valid = False + return valid + + def has_errors(self, thing): + """ + Utility method to quickly get a list of errors associated with the + "thing" passed to it (either a state or transition) + """ + if isinstance(thing, State): + if thing.id in self.errors['states']: + return self.errors['states'][thing.id] + else: + return [] + elif isinstance(thing, Transition): + if thing.id in self.errors['transitions']: + return self.errors['transitions'][thing.id] + else: + return [] + else: + return [] + + def activate(self): + """ + Puts the workflow in the "active" state after checking the directed + graph doesn't contain any orphaned nodes (is connected), is in + DEFINITION state, has compatible roles for transitions and states and + contains exactly one start state and at least one end state + """ + # Only workflows in definition state can be activated + if not self.status == self.DEFINITION: + raise UnableToActivateWorkflow, __('Only workflows in the'\ + ' "definition" state may be activated') + if not self.is_valid(): + raise UnableToActivateWorkflow, __("Cannot activate as the"\ + " workflow doesn't validate.") + # Good to go... + self.status = self.ACTIVE + self.save() + + def retire(self): + """ + Retires the workflow so it can no-longer be used with new + WorkflowActivity models + """ + self.status = self.RETIRED + self.save() + + def clone(self, user): + """ + Returns a clone of the workflow. The clone will be in the DEFINITION + state whereas the source workflow *must* be ACTIVE or RETIRED (so we + know it *must* be valid). + """ + + # TODO: A target for refactoring so calling this method doesn't hit the + # database so hard. Would welcome ideas..? + + if self.status >= self.ACTIVE: + # Clone this workflow + clone_workflow = Workflow() + clone_workflow.name = self.name + clone_workflow.slug = self.slug+'_clone' + clone_workflow.description = self.description + clone_workflow.status = self.DEFINITION + clone_workflow.created_by = user + clone_workflow.cloned_from = self + clone_workflow.save() + # Clone the states + state_dict = dict() # key = old pk of state, val = new clone state + for s in self.states.all(): + clone_state = State() + clone_state.name = s.name + clone_state.description = s.description + clone_state.is_start_state = s.is_start_state + clone_state.is_end_state = s.is_end_state + clone_state.workflow = clone_workflow + clone_state.estimation_value = s.estimation_value + clone_state.estimation_unit = s.estimation_unit + clone_state.save() + for r in s.roles.all(): + clone_state.roles.add(r) + state_dict[s.id] = clone_state + # Clone the transitions + for tr in self.transitions.all(): + clone_trans = Transition() + clone_trans.name = tr.name + clone_trans.workflow = clone_workflow + clone_trans.from_state = state_dict[tr.from_state.id] + clone_trans.to_state = state_dict[tr.to_state.id] + clone_trans.save() + for r in tr.roles.all(): + clone_trans.roles.add(r) + # Clone the events + for ev in self.events.all(): + clone_event = Event() + clone_event.name = ev.name + clone_event.description = ev.description + clone_event.workflow = clone_workflow + clone_event.state = state_dict[ev.state.id] + clone_event.is_mandatory = ev.is_mandatory + clone_event.save() + for r in ev.roles.all(): + clone_event.roles.add(r) + return clone_workflow + else: + raise UnableToCloneWorkflow, __('Only active or retired workflows'\ + ' may be cloned') + + def __unicode__(self): + return self.name + + class Meta: + ordering = ['status', 'name'] + verbose_name = _('Workflow') + verbose_name_plural = _('Workflows') + permissions = ( + ('can_manage_workflows', __('Can manage workflows')), + ) + +class State(models.Model): + """ + Represents a specific state that a thing can be in during its progress + through a workflow. A node in a directed graph. + """ + + # Constant values to denote a period of time in seconds + SECOND = 1 + MINUTE = 60 + HOUR = 3600 + DAY = 86400 + WEEK = 604800 + + DURATIONS = ( + (SECOND, _('Second(s)')), + (MINUTE, _('Minute(s)')), + (HOUR, _('Hour(s)')), + (DAY, _('Day(s)')), + (WEEK, _('Week(s)')), + ) + + name = models.CharField( + _('Name'), + max_length=256 + ) + description = models.TextField( + _('Description'), + blank=True + ) + is_start_state = models.BooleanField( + _('Is the start state?'), + help_text=_('There can only be one start state for a workflow'), + default=False + ) + is_end_state = models.BooleanField( + _('Is an end state?'), + help_text=_('An end state shows that the workflow is complete'), + default=False + ) + workflow = models.ForeignKey( + Workflow, + related_name='states') + # The roles defined here define *who* has permission to view the item in + # this state. + roles = models.ManyToManyField( + Role, + blank=True + ) + # The following two fields allow a specification of expected duration to be + # associated with a state. The estimation_value field stores the amount of + # time, whilst estimation_unit stores the unit of time estimation_value is + # in. For example, estimation_value=5, estimation_unit=DAY means something + # is expected to be in this state for 5 days. By doing estimation_value * + # estimation_unit we can get the number of seconds to pass into a timedelta + # to discover when the deadline for a state is. + estimation_value = models.IntegerField( + _('Estimated time (value)'), + default=0, + help_text=_('Use whole numbers') + ) + estimation_unit = models.IntegerField( + _('Estimation unit of time'), + default=DAY, + choices = DURATIONS + ) + + def deadline(self): + """ + Will return the expected deadline (or None) for this state calculated + from datetime.today() + """ + if self.estimation_value > 0: + duration = datetime.timedelta( + seconds=(self.estimation_value*self.estimation_unit) + ) + return (self._today()+duration) + else: + return None + + def _today(self): + """ + To help with the unit tests + """ + return datetime.datetime.today() + + def __unicode__(self): + return self.name + + class Meta: + ordering = ['-is_start_state','is_end_state'] + verbose_name = _('State') + verbose_name_plural = _('States') + +class Transition(models.Model): + """ + Represents how a workflow can move between different states. An edge + between state "nodes" in a directed graph. + """ + name = models.CharField( + _('Name of transition'), + max_length=128, + help_text=_('Use an "active" verb. e.g. "Close Issue", "Open'\ + ' Vacancy" or "Start Interviews"') + ) + # This field is the result of denormalization to help with the Workflow + # class's clone() method. + workflow = models.ForeignKey( + Workflow, + related_name = 'transitions' + ) + from_state = models.ForeignKey( + State, + related_name = 'transitions_from' + ) + to_state = models.ForeignKey( + State, + related_name = 'transitions_into' + ) + # The roles referenced here define *who* has permission to use this + # transition to move between states. + roles = models.ManyToManyField( + Role, + blank=True + ) + + def __unicode__(self): + return self.name + + class Meta: + verbose_name = _('Transition') + verbose_name_plural = _('Transitions') + +class EventType(models.Model): + """ + Defines the types of event that can be associated with a workflow. Examples + might include: meeting, deadline, review, assessment etc... + """ + name = models.CharField( + _('Event Type Name'), + max_length=256 + ) + description = models.TextField( + _('Description'), + blank=True + ) + + def __unicode__(self): + return self.name + +class Event(models.Model): + """ + A definition of something that is supposed to happen when in a particular + state. + """ + name = models.CharField( + _('Event summary'), + max_length=256 + ) + description = models.TextField( + _('Description'), + blank=True + ) + # The workflow field is the result of denormalization to help with the + # Workflow class's clone() method. + # Also, workflow and state can be nullable so an event can be treated as + # "generic" for all workflows / states in the database. + workflow = models.ForeignKey( + Workflow, + related_name='events', + null=True, + blank=True + ) + state = models.ForeignKey( + State, + related_name='events', + null=True, + blank=True + ) + # The roles referenced here indicate *who* is supposed to be a part of the + # event + roles = models.ManyToManyField(Role) + # The event types referenced here help define what sort of event this is. + # For example, a meeting and review (an event might be of more than one + # type) + event_types = models.ManyToManyField(EventType) + # If this field is true then the workflow cannot progress beyond the related + # state without it first appearing in the workflow history + is_mandatory = models.BooleanField( + _('Mandatory event'), + default=False, + help_text=_('This event must be marked as complete before moving'\ + ' out of the associated state.') + ) + + def __unicode__(self): + return self.name + + class Meta: + verbose_name = _('Event') + verbose_name_plural = _('Events') + +class WorkflowActivity(models.Model): + """ + Other models in a project reference this model so they become associated + with a particular workflow. + + The WorkflowActivity object also contains *all* the methods required to + start, progress and stop a workflow. + """ + workflow = models.ForeignKey(Workflow) + created_by = models.ForeignKey(User) + created_on = models.DateTimeField(auto_now_add=True) + completed_on = models.DateTimeField( + null=True, + blank=True + ) + + def current_state(self): + """ + Returns the instance of the WorkflowHistory model that represents the + current state this WorkflowActivity is in. + """ + if self.history.all(): + return self.history.all()[0] + else: + return None + + def start(self, user): + """ + Starts a WorkflowActivity by putting it into the start state of the + workflow defined in the "workflow" field after validating the workflow + activity is in a state appropriate for "starting" + """ + participant = Participant.objects.get(workflowactivity=self, user=user, + disabled=False) + + start_state_result = State.objects.filter( + workflow=self.workflow, + is_start_state=True + ) + # Validation... + # 1. The workflow activity isn't already started + if self.current_state(): + if self.current_state().state: + raise UnableToStartWorkflow, __('Already started') + # 2. The workflow activity hasn't been force_stopped before being + # started + if self.completed_on: + raise UnableToStartWorkflow, __('Already completed') + # 3. There is exactly one start state + if not len(start_state_result) == 1: + raise UnableToStartWorkflow, __('Cannot find single start state') + # Good to go... + first_step = WorkflowHistory( + workflowactivity=self, + state=start_state_result[0], + log_type=WorkflowHistory.TRANSITION, + participant=participant, + note=__('Started workflow'), + deadline=start_state_result[0].deadline() + ) + first_step.save() + return first_step + + def progress(self, transition, user, note=''): + """ + Attempts to progress a workflow activity with the specified transition + as requested by the specified participant. + + The transition is validated (to make sure it is a legal "move" in the + directed graph) and the method returns the new WorkflowHistory state or + raises an UnableToProgressWorkflow exception. + """ + participant = Participant.objects.get(workflowactivity=self, user=user, + disabled=False) + # Validate the transition + current_state = self.current_state() + + # 1. Make sure the workflow activity is started + if not current_state: + raise UnableToProgressWorkflow, __('Start the workflow before'\ + ' attempting to transition') + # 2. Make sure it's parent is the current state + if not transition.from_state == current_state.state: + raise UnableToProgressWorkflow, __('Transition not valid (wrong'\ + ' parent)') + # 3. Make sure all mandatory events for the current state are found in + # the WorkflowHistory + mandatory_events = current_state.state.events.filter(is_mandatory=True) + for me in mandatory_events: + if not me.history.filter(workflowactivity=self): + raise UnableToProgressWorkflow, __('Transition not valid'\ + ' (mandatory event missing)') + # 4. Make sure the user has the appropriate role to allow them to make + # the transition + if not transition.roles.filter(pk__in=[role.id for role in participant.roles.all()]): + raise UnableToProgressWorkflow, __('Participant has insufficient'\ + ' authority to use the specified transition') + # The "progress" request has been validated to store the transition into + # the appropriate WorkflowHistory record and if it is an end state then + # update this WorkflowActivity's record with the appropriate timestamp + if not note: + note = transition.name + wh = WorkflowHistory( + workflowactivity=self, + state=transition.to_state, + log_type=WorkflowHistory.TRANSITION, + transition=transition, + participant=participant, + note=note, + deadline=transition.to_state.deadline() + ) + wh.save() + # If we're at the end then mark the workflow activity as completed on + # today + if transition.to_state.is_end_state: + self.completed_on = datetime.datetime.today() + self.save() + return wh + + def log_event(self, event, user, note=''): + """ + Logs the occurance of an event in the WorkflowHistory of a + WorkflowActivity and returns the resulting record. + + If the event is associated with a workflow or state then this method + validates that the event is associated with the workflow, that the + participant logging the event is also one of the event participants and + if the event is mandatory then it must be done whilst in the + appropriate state. + """ + participant = Participant.objects.get(workflowactivity=self, user=user, + disabled=False) + current_state = self.current_state() + if event.workflow: + # Make sure we have an event for the right workflow + if not event.workflow == self.workflow: + raise UnableToLogWorkflowEvent, __('The event is not associated'\ + ' with the workflow for the WorkflowActivity') + if event.state: + # If the event is mandatory then it must be completed whilst in + # the associated state + if event.is_mandatory: + if not event.state == current_state.state: + raise UnableToLogWorkflowEvent, __('The mandatory'\ + ' event is not associated with the current'\ + ' state') + if event.roles.all(): + # Make sure the participant is associated with the event + if not event.roles.filter(pk__in=[p.id for p in participant.roles.all()]): + raise UnableToLogWorkflowEvent, __('The participant is not'\ + ' associated with the specified event') + if not note: + note=event.name + # Good to go... + current_state = self.current_state().state if self.current_state() else None + deadline = self.current_state().deadline if self.current_state() else None + wh = WorkflowHistory( + workflowactivity=self, + state=current_state, + log_type=WorkflowHistory.EVENT, + event=event, + participant=participant, + note=note, + deadline=deadline + ) + wh.save() + return wh + + def add_comment(self, user, note): + """ + In many sorts of workflow it is necessary to add a comment about + something at a particular state in a WorkflowActivity. + """ + if not note: + raise UnableToAddCommentToWorkflow, __('Cannot add an empty comment') + p, created = Participant.objects.get_or_create(workflowactivity=self, + user=user) + current_state = self.current_state().state if self.current_state() else None + deadline = self.current_state().deadline if self.current_state() else None + wh = WorkflowHistory( + workflowactivity=self, + state=current_state, + log_type=WorkflowHistory.COMMENT, + participant=p, + note=note, + deadline=deadline + ) + wh.save() + return wh + + def assign_role(self, user, assignee, role): + """ + Assigns the role to the assignee for this instance of a workflow + activity. The arg 'user' logs who made the assignment + """ + p_as_user = Participant.objects.get(workflowactivity=self, user=user, + disabled=False) + p_as_assignee, created = Participant.objects.get_or_create( + workflowactivity=self, + user=assignee) + p_as_assignee.roles.add(role) + name = assignee.get_full_name() if assignee.get_full_name() else assignee.username + note = _('Role "%s" assigned to %s')%(role.__unicode__(), name) + current_state = self.current_state().state if self.current_state() else None + deadline = self.current_state().deadline if self.current_state() else None + wh = WorkflowHistory( + workflowactivity=self, + state=current_state, + log_type=WorkflowHistory.ROLE, + participant=p_as_user, + note=note, + deadline=deadline + ) + wh.save() + role_assigned.send(sender=wh) + return wh + + def remove_role(self, user, assignee, role): + """ + Removes the role from the assignee. The 'user' argument is used for + logging purposes. + """ + try: + p_as_user = Participant.objects.get(workflowactivity=self, + user=user, disabled=False) + p_as_assignee = Participant.objects.get(workflowactivity=self, + user=assignee) + if role in p_as_assignee.roles.all(): + p_as_assignee.roles.remove(role) + name = assignee.get_full_name() if assignee.get_full_name() else assignee.username + note = _('Role "%s" removed from %s')%(role.__unicode__(), name) + current_state = self.current_state().state if self.current_state() else None + deadline = self.current_state().deadline if self.current_state() else None + wh = WorkflowHistory( + workflowactivity=self, + state=current_state, + log_type=WorkflowHistory.ROLE, + participant=p_as_user, + note=note, + deadline=deadline + ) + wh.save() + role_removed.send(sender=wh) + return wh + else: + # The role isn't associated with the assignee anyway so there is + # nothing to do + return None + except ObjectDoesNotExist: + # If we can't find the assignee as a participant then there is + # nothing to do + return None + + def clear_roles(self, user, assignee): + """ + Clears all the roles from assignee. The 'user' argument is used for + logging purposes. + """ + try: + p_as_user = Participant.objects.get(workflowactivity=self, + user=user, disabled=False) + p_as_assignee = Participant.objects.get(workflowactivity=self, + user=assignee) + p_as_assignee.roles.clear() + name = assignee.get_full_name() if assignee.get_full_name() else assignee.username + note = _('All roles removed from %s')%name + current_state = self.current_state().state if self.current_state() else None + deadline = self.current_state().deadline if self.current_state() else None + wh = WorkflowHistory( + workflowactivity=self, + state=current_state, + log_type=WorkflowHistory.ROLE, + participant=p_as_user, + note=note, + deadline=deadline + ) + wh.save() + role_removed.send(sender=wh) + return wh + except ObjectDoesNotExist: + # If we can't find the assignee then there is nothing to do + pass + + def disable_participant(self, user, user_to_disable, note): + """ + Mark the user_to_disable as disabled. Must include a note explaining + reasons for this action. Also the 'user' arg is used for logging who + carried this out + """ + if not note: + raise UnableToDisableParticipant, __('Must supply a reason for'\ + ' disabling a participant. None given.') + try: + p_as_user = Participant.objects.get(workflowactivity=self, + user=user, disabled=False) + p_to_disable = Participant.objects.get(workflowactivity=self, + user=user_to_disable) + if not p_to_disable.disabled: + p_to_disable.disabled = True + p_to_disable.save() + name = user_to_disable.get_full_name() if user_to_disable.get_full_name() else user_to_disable.username + note = _('Participant %s disabled with the reason: %s')%(name, note) + current_state = self.current_state().state if self.current_state() else None + deadline = self.current_state().deadline if self.current_state() else None + wh = WorkflowHistory( + workflowactivity=self, + state=current_state, + log_type=WorkflowHistory.ROLE, + participant=p_as_user, + note=note, + deadline=deadline + ) + wh.save() + return wh + else: + # They're already disabled + return None + except ObjectDoesNotExist: + # If we can't find the assignee then there is nothing to do + return None + + def enable_participant(self, user, user_to_enable, note): + """ + Mark the user_to_enable as enabled. Must include a note explaining + reasons for this action. Also the 'user' arg is used for logging who + carried this out + """ + if not note: + raise UnableToEnableParticipant, __('Must supply a reason for'\ + ' enabling a disabled participant. None given.') + try: + p_as_user = Participant.objects.get(workflowactivity=self, + user=user, disabled=False) + p_to_enable = Participant.objects.get(workflowactivity=self, + user=user_to_enable) + if p_to_enable.disabled: + p_to_enable.disabled = False + p_to_enable.save() + name = user_to_enable.get_full_name() if user_to_enable.get_full_name() else user_to_enable.username + note = _('Participant %s enabled with the reason: %s')%(name, + note) + current_state = self.current_state().state if self.current_state() else None + deadline = self.current_state().deadline if self.current_state() else None + wh = WorkflowHistory( + workflowactivity=self, + state=current_state, + log_type=WorkflowHistory.ROLE, + participant=p_as_user, + note=note, + deadline=deadline + ) + wh.save() + return wh + else: + # The participant is already enabled + return None + except ObjectDoesNotExist: + # If we can't find the participant then there is nothing to do + return None + + def force_stop(self, user, reason): + """ + Should a WorkflowActivity need to be abandoned this method cleanly logs + the event and puts the WorkflowActivity in the appropriate state (with + reason provided by participant). + """ + # Lets try to create an appropriate entry in the WorkflowHistory table + current_state = self.current_state() + participant = Participant.objects.get( + workflowactivity=self, + user=user) + if current_state: + final_step = WorkflowHistory( + workflowactivity=self, + state=current_state.state, + log_type=WorkflowHistory.TRANSITION, + participant=participant, + note=__('Workflow forced to stop! Reason given: %s') % reason, + deadline=None + ) + final_step.save() + + self.completed_on = datetime.datetime.today() + self.save() + + class Meta: + ordering = ['-completed_on', '-created_on'] + verbose_name = _('Workflow Activity') + verbose_name_plural = _('Workflow Activites') + permissions = ( + ('can_start_workflow',__('Can start a workflow')), + ('can_assign_roles',__('Can assign roles')) + ) + +class Participant(models.Model): + """ + Defines which users have what roles in a particular run of a workflow + """ + user = models.ForeignKey(User) + # can be nullable because a participant *might* not have a role assigned to + # them (yet), and is many-to-many as they might have many different roles. + roles = models.ManyToManyField( + Role, + blank=True) + workflowactivity= models.ForeignKey( + WorkflowActivity, + related_name='participants' + ) + disabled = models.BooleanField(default=False) + + def __unicode__(self): + name = self.user.get_full_name() if self.user.get_full_name() else self.user.username + if self.roles.all(): + roles = u' - ' + u', '.join([r.__unicode__() for r in self.roles.all()]) + else: + roles = '' + disabled = _(' (disabled)') if self.disabled else '' + return u"%s%s%s"%(name, roles, disabled) + + class Meta: + ordering = ['-disabled', 'workflowactivity', 'user',] + verbose_name = _('Participant') + verbose_name_plural = _('Participants') + unique_together = ('user', 'workflowactivity') + +class WorkflowHistory(models.Model): + """ + Records what has happened and when in a particular run of a workflow. The + latest record for the referenced WorkflowActivity will indicate the current + state. + """ + + # The sort of things we can log in the workflow history + TRANSITION = 1 + EVENT = 2 + ROLE = 3 + COMMENT = 4 + + # Used to indicate what sort of thing we're logging in the workflow history + TYPE_CHOICE_LIST = ( + (TRANSITION, _('Transition')), + (EVENT, _('Event')), + (ROLE, _('Role')), + (COMMENT, _('Comment')), + ) + + workflowactivity= models.ForeignKey( + WorkflowActivity, + related_name='history') + log_type = models.IntegerField( + help_text=_('The sort of thing being logged'), + choices=TYPE_CHOICE_LIST + ) + state = models.ForeignKey( + State, + help_text=_('The state at this point in the workflow history'), + null=True + ) + transition = models.ForeignKey( + Transition, + null=True, + related_name='history', + help_text=_('The transition relating to this happening in the'\ + ' workflow history') + ) + event = models.ForeignKey( + Event, + null=True, + related_name='history', + help_text=_('The event relating to this happening in the workflow'\ + ' history') + ) + participant = models.ForeignKey( + Participant, + help_text=_('The participant who triggered this happening in the'\ + ' workflow history') + ) + created_on = models.DateTimeField(auto_now_add=True) + note = models.TextField( + _('Note'), + blank=True + ) + deadline = models.DateTimeField( + _('Deadline'), + null=True, + blank=True, + help_text=_('The deadline for staying in this state') + ) + + def save(self): + workflow_pre_change.send(sender=self) + super(WorkflowHistory, self).save() + workflow_post_change.send(sender=self) + if self.log_type==self.TRANSITION: + workflow_transitioned.send(sender=self) + if self.log_type==self.EVENT: + workflow_event_completed.send(sender=self) + if self.log_type==self.COMMENT: + workflow_commented.send(sender=self) + if self.state: + if self.state.is_start_state: + workflow_started.send(sender=self.workflowactivity) + elif self.state.is_end_state: + workflow_ended.send(sender=self.workflowactivity) + + def __unicode__(self): + return u"%s created by %s"%(self.note, self.participant.__unicode__()) + + class Meta: + ordering = ['-created_on'] + verbose_name = _('Workflow History') + verbose_name_plural = _('Workflow Histories') diff --git a/geoq/workflow/templates/graphviz/state.dot b/geoq/workflow/templates/graphviz/state.dot new file mode 100644 index 00000000..65942f3a --- /dev/null +++ b/geoq/workflow/templates/graphviz/state.dot @@ -0,0 +1,4 @@ +{% load i18n %} + state{{s.id}} [ + {% if s.is_start_state or s.is_end_state %}shape=box, {% endif %}label="{% if s.is_start_state %}{% trans "START:" %} {% endif %}{% if s.is_end_state %}{% trans "END:" %} {% endif %}{{s.name}}" + ] diff --git a/geoq/workflow/templates/graphviz/transition.dot b/geoq/workflow/templates/graphviz/transition.dot new file mode 100644 index 00000000..44482407 --- /dev/null +++ b/geoq/workflow/templates/graphviz/transition.dot @@ -0,0 +1 @@ + state{{t.from_state.id}} -> state{{t.to_state.id}} [label="{{t.name}}"]; diff --git a/geoq/workflow/templates/graphviz/workflow.dot b/geoq/workflow/templates/graphviz/workflow.dot new file mode 100644 index 00000000..a0d42c37 --- /dev/null +++ b/geoq/workflow/templates/graphviz/workflow.dot @@ -0,0 +1,17 @@ +/* +A definition for a diagram of the workflow: {{ workflow.name }} + +Description: +{% if workflow.description %}{{workflow.description}}{% else %}None{% endif %} + +Created for use with graphviz (http://www.graphviz.org) by the Django workflow +application (http://github.com/ntoll/workflow/tree/master) +*/ +digraph G { + {% for s in workflow.states.all %} + {% include "graphviz/state.dot" %} + {% endfor %} + {% for t in workflow.transitions.all %} + {% include "graphviz/transition.dot" %} + {% endfor %} +} diff --git a/geoq/workflow/test_runner.py b/geoq/workflow/test_runner.py new file mode 100644 index 00000000..42ad1c10 --- /dev/null +++ b/geoq/workflow/test_runner.py @@ -0,0 +1,96 @@ +# -*- coding: UTF-8 -*- +""" + +A custom test runner that includes reporting of unit test coverage. Based upon +code found here: + + http://www.thoughtspark.org/node/6 + +You should also have coverage.py in your python path. See: + + http://nedbatchelder.com/code/modules/coverage.html + +for more information. + +To use this test runner modify your settings.py file with the following: + +# Specify your custom test runner to use +TEST_RUNNER='workflow.test_runner.test_runner_with_coverage' + +# List of modules to enable for code coverage +COVERAGE_MODULES = ['workflow.models', 'workflow.views',] # etc... + +You'll get a code coverage report for your unit tests: + +------------------------------------------------------------------ + Unit Test Code Coverage Results +------------------------------------------------------------------ + Name Stmts Exec Cover Missing +-------------------------------------------- + sample.urls 2 0 0% 1-3 + sample.views 3 0 0% 1-5 +-------------------------------------------- + TOTAL 5 0 0% +------------------------------------------------------------------ + +For every module added to COVERAGE_MODULES you'll get an entry telling you the +number of executable statements, the number executed, the percentage executed +and a list of the code lines not executed (in the above play example, no tests +have been written). Aim for 100% :-) + +!!!! WARNING !!! + +Because of the use of coverage, this test runner is SLOW. + +Also, use with care - this code works with the command: + + python manage.py test workflow + +(Where workflow is the name of this app in your project) + +It probably won't work for all other manage.py test cases. + +TODO: Fix the cause of the warning above! + +""" +import os, shutil, sys, unittest + +# Look for coverage.py in __file__/lib as well as sys.path +sys.path = [os.path.join(os.path.dirname(__file__), "lib")] + sys.path + +import coverage +from django.test.simple import run_tests as django_test_runner + +from django.conf import settings + +def test_runner_with_coverage(test_labels, verbosity=1, interactive=True, extra_tests=[]): + """ + Custom test runner. Follows the django.test.simple.run_tests() interface. + """ + # Start code coverage before anything else if necessary + if hasattr(settings, 'COVERAGE_MODULES'): + coverage.use_cache(0) # Do not cache any of the coverage.py stuff + coverage.start() + + test_results = django_test_runner(test_labels, verbosity, interactive, extra_tests) + + # Stop code coverage after tests have completed + if hasattr(settings, 'COVERAGE_MODULES'): + coverage.stop() + + # Print code metrics header + print '' + print '----------------------------------------------------------------------' + print ' Unit Test Code Coverage Results' + print '----------------------------------------------------------------------' + + # Report code coverage metrics + if hasattr(settings, 'COVERAGE_MODULES'): + coverage_modules = [] + for module in settings.COVERAGE_MODULES: + coverage_modules.append(__import__(module, globals(), locals(), [''])) + coverage.report(coverage_modules, show_missing=1) + # Print code metrics footer + print '----------------------------------------------------------------------' + + return test_results diff --git a/geoq/workflow/tests.py b/geoq/workflow/tests.py new file mode 100644 index 00000000..f63de5bb --- /dev/null +++ b/geoq/workflow/tests.py @@ -0,0 +1,216 @@ +# -*- coding: UTF-8 -*- +""" +Define a simple document management workflow: + +>>> from django.contrib.auth.models import User +>>> from workflow.models import * + +A couple of users to interact with the workflow + +>>> fred = User.objects.create_user('fred','fred@acme.com','password') +>>> joe = User.objects.create_user('joe','joe@acme.com','password') + +A document class that really should be a models.Model class (but you get the +idea) + +>>> class Document(): +... def __init__(self, title, body, workflow_activity): +... self.title = title +... self.body = body +... self.workflow_activity = workflow_activity +... + +Roles define the sort of person involved in a workflow. + +>>> author = Role.objects.create(name="author", description="Author of a document") +>>> boss = Role.objects.create(name="boss", description="Departmental boss") + +EventTypes define what sort of events can happen in a workflow. + +>>> approval = EventType.objects.create(name="Document Approval", description="A document is reviewed by an approver") +>>> meeting = EventType.objects.create(name='Meeting', description='A meeting at the offices of Acme Inc') + +Creating a workflow puts it into the "DEFINITION" status. It can't be used yet. + +>>> wf = Workflow.objects.create(name='Simple Document Approval', slug='docapp', description='A simple document approval process', created_by=joe) + +Adding four states: + +>>> s1 = State.objects.create(name='In Draft', description='The author is writing a draft of the document', is_start_state=True, workflow=wf) +>>> s2 = State.objects.create(name='Under Review', description='The approver is reviewing the document', workflow=wf) +>>> s3 = State.objects.create(name='Published', description='The document is published', workflow=wf) +>>> s4 = State.objects.create(name='Archived', description='The document is put into the archive', is_end_state=True, workflow=wf) + +Defining what sort of person is involved in each state by associating roles. + +>>> s1.roles.add(author) +>>> s2.roles.add(boss) +>>> s2.roles.add(author) +>>> s3.roles.add(boss) +>>> s4.roles.add(boss) + +Adding transitions to define how the states relate to each other. Notice how the +name of each transition is an "active" description of what it does in order to +get to the next state. + +>>> t1 = Transition.objects.create(name='Request Approval', workflow=wf, from_state=s1, to_state=s2) +>>> t2 = Transition.objects.create(name='Revise Draft', workflow=wf, from_state=s2, to_state=s1) +>>> t3 = Transition.objects.create(name='Publish', workflow=wf, from_state=s2, to_state=s3) +>>> t4 = Transition.objects.create(name='Archive', workflow=wf, from_state=s3, to_state=s4) + +Once again, using roles to define what sort of person can transition between +states. + +>>> t1.roles.add(author) +>>> t2.roles.add(boss) +>>> t3.roles.add(boss) +>>> t4.roles.add(boss) + +Creating a mandatory event to be attended by the boss and author during the +"Under Review" state. + +>>> approval_meeting = Event.objects.create(name='Approval Meeting', description='Approver and author meet to discuss document', workflow=wf, state=s2, is_mandatory=True) +>>> approval_meeting.roles.add(author) +>>> approval_meeting.roles.add(boss) + +Notice how we can define what sort of event this is by associating event types +defined earlier + +>>> approval_meeting.event_types.add(approval) +>>> approval_meeting.event_types.add(meeting) + +An event doesn't have to be *so* constrained by workflow, roles or state. The +following state can take place in any workflow, at any state by any role: + +>>> team_meeting = Event.objects.create(name='Team Meeting', description='A team meeting that can happen in any workflow') +>>> team_meeting.event_types.add(meeting) + +The activate method on the workflow validates the directed graph and puts it in +the "active" state so it can be used. + +>>> wf.activate() + +Lets set up a workflow activity and assign roles to users for a new document so +we can interact with the workflow we defined above. + +>>> wa = WorkflowActivity(workflow=wf, created_by=fred) +>>> wa.save() + +Use the built in methods associated with the WorkflowActivity class to ensure +such changes are appropriately logged in the history. + +>>> p1 = Participant() +>>> p1 = Participant(user=fred, workflowactivity=wa) +>>> p1.save() +>>> p2 = Participant(user=joe, workflowactivity=wa) +>>> p2.save() +>>> wa.assign_role(fred, joe, boss) + +>>> wa.assign_role(joe, fred, author) + +>>> d = Document(title='Had..?', body="Bob, where Alice had had 'had', had had 'had had'; 'had had' had had the examiner's approval", workflow_activity=wa) + +Starting the workflow via the workflow activity is easy... notice we have to pass +the participant and that the method returns the current state. + +>>> d.workflow_activity.start(fred) + + +The WorkflowActivity's current_state() method does exactly what it says. You can +find out lots of interesting things... + +>>> current = d.workflow_activity.current_state() +>>> current.participant + +>>> current.note +u'Started workflow' +>>> current.state + +>>> current.state.transitions_from.all() +[] + +Lets progress the workflow for this document (the author has finished the draft +and submits it for approval) + +>>> my_transition = current.state.transitions_from.all()[0] +>>> my_transition + +>>> d.workflow_activity.progress(my_transition, fred) + + +Notice the WorkflowActivity's progress method returns the new state. What is +current_state() telling us..? + +>>> current = d.workflow_activity.current_state() +>>> current.state + +>>> current.state.roles.all() +[, ] +>>> current.transition + +>>> current.note +u'Request Approval' +>>> current.state.events.all() +[] + +So we have an event associated with this event. Lets pretend it's happened. +Notice that I can pass a bespoke "note" to store against the event. + +>>> my_event = current.state.events.all()[0] +>>> d.workflow_activity.log_event(my_event, joe, "A great review meeting, loved the punchline!") + +>>> current = d.workflow_activity.current_state() +>>> current.state + +>>> current.event + +>>> current.note +u'A great review meeting, loved the punchline!' + +Continue with the progress of the workflow activity... Notice I can also pass a +bespoke "note" to the progress method. + +>>> current.state.transitions_from.all().order_by('id') +[, ] +>>> my_transition = current.state.transitions_from.all().order_by('id')[1] +>>> d.workflow_activity.progress(my_transition, joe, "We'll be up for a Pulitzer") + + +We can also log events that have not been associated with specific workflows, +states or roles... + +>>> d.workflow_activity.log_event(team_meeting, joe) + +>>> current = d.workflow_activity.current_state() +>>> current.event + + +Lets finish the workflow just to demonstrate what useful stuff is logged: + +>>> current = d.workflow_activity.current_state() +>>> current.state.transitions_from.all().order_by('id') +[] +>>> my_transition = current.state.transitions_from.all().order_by('id')[0] +>>> d.workflow_activity.progress(my_transition, joe) + +>>> for item in d.workflow_activity.history.all(): +... print '%s by %s'%(item.note, item.participant.user.username) +... +Archive by joe +Team Meeting by joe +We'll be up for a Pulitzer by joe +A great review meeting, loved the punchline! by joe +Request Approval by fred +Started workflow by fred +Role "author" assigned to fred by joe +Role "boss" assigned to joe by fred + +Unit tests are found in the unit_tests module. In addition to doctests this file +is a hook into the Django unit-test framework. + +Author: Nicholas H.Tollervey + +""" +from unit_tests.test_views import * +from unit_tests.test_models import * +from unit_tests.test_forms import * diff --git a/geoq/workflow/unit_tests/__init__.py b/geoq/workflow/unit_tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/geoq/workflow/unit_tests/test_forms.py b/geoq/workflow/unit_tests/test_forms.py new file mode 100644 index 00000000..451c7b25 --- /dev/null +++ b/geoq/workflow/unit_tests/test_forms.py @@ -0,0 +1,26 @@ +# -*- coding: UTF-8 -*- +""" +Forms tests for workflow + +Author: Nicholas H.Tollervey + +""" +# python +import datetime + +# django +from django.test.client import Client +from django.test import TestCase + +# project +from workflow.forms import * + +class FormTestCase(TestCase): + """ + Testing Forms + """ + # Reference fixtures here + fixtures = [] + + def test_something(self): + pass diff --git a/geoq/workflow/unit_tests/test_models.py b/geoq/workflow/unit_tests/test_models.py new file mode 100644 index 00000000..77d1072b --- /dev/null +++ b/geoq/workflow/unit_tests/test_models.py @@ -0,0 +1,1006 @@ +# -*- coding: UTF-8 -*- +""" +Model tests for Workflow + +Author: Nicholas H.Tollervey + +""" +# python +import datetime +import sys + +# django +from django.test.client import Client +from django.test import TestCase +from django.contrib.auth.models import User + +# project +from workflow.models import * + +class ModelTestCase(TestCase): + """ + Testing Models + """ + # Reference fixtures here + fixtures = ['workflow_test_data'] + + def test_workflow_unicode(self): + """ + Makes sure that the slug field (name) is returned from a call to + __unicode__() + """ + w = Workflow.objects.get(id=1) + self.assertEquals(u'test workflow', w.__unicode__()) + + def test_workflow_lifecycle(self): + """ + Makes sure the methods in the Workflow model work as expected + """ + # All new workflows start with status DEFINITION - from the fixtures + w = Workflow.objects.get(id=1) + self.assertEquals(Workflow.DEFINITION, w.status) + + # Activate the workflow + w.activate() + self.assertEquals(Workflow.ACTIVE, w.status) + + # Retire it. + w.retire() + self.assertEquals(Workflow.RETIRED, w.status) + + def test_workflow_is_valid(self): + """ + Makes sure that the validation for a workflow works as expected + """ + # from the fixtures + w = Workflow.objects.get(id=1) + self.assertEquals(Workflow.DEFINITION, w.status) + + # make sure the workflow contains exactly one start state + # 0 start states + state1 = State.objects.get(id=1) + state1.is_start_state=False + state1.save() + self.assertEqual(False, w.is_valid()) + self.assertEqual(True, u'There must be only one start state' in w.errors['workflow']) + state1.is_start_state=True + state1.save() + + # >1 start states + state2 = State.objects.get(id=2) + state2.is_start_state=True + state2.save() + self.assertEqual(False, w.is_valid()) + self.assertEqual(True, u'There must be only one start state' in w.errors['workflow']) + state2.is_start_state=False + state2.save() + + # make sure we have at least one end state + # 0 end states + end_states = w.states.filter(is_end_state=True) + for state in end_states: + state.is_end_state=False + state.save() + self.assertEqual(False, w.is_valid()) + self.assertEqual(True, u'There must be at least one end state' in w.errors['workflow']) + for state in end_states: + state.is_end_state=True + state.save() + + # make sure we don't have any orphan states + orphan_state = State(name='orphaned_state', workflow=w) + orphan_state.save() + self.assertEqual(False, w.is_valid()) + self.assertEqual(True, orphan_state.id in w.errors['states']) + msg = u'This state is orphaned. There is no way to get to it given'\ + ' the current workflow topology.' + self.assertEqual(True, msg in w.errors['states'][orphan_state.id]) + orphan_state.delete() + + # make sure we don't have any cul-de-sacs from which one can't + # escape (re-using an end state for the same effect) + cul_de_sac = end_states[0] + cul_de_sac.is_end_state = False + cul_de_sac.save() + self.assertEqual(False, w.is_valid()) + self.assertEqual(True, cul_de_sac.id in w.errors['states']) + msg = u'This state is a dead end. It is not marked as an end state'\ + ' and there is no way to exit from it.' + self.assertEqual(True, msg in w.errors['states'][cul_de_sac.id]) + cul_de_sac.is_end_state = True + cul_de_sac.save() + + # make sure transition's roles are a subset of the roles associated + # with the transition's from_state (otherwise you'll have a + # transition that none of the participants for a state can make use + # of) + role = Role.objects.get(id=2) + transition = Transition.objects.get(id=10) + transition.roles.clear() + transition.roles.add(role) + self.assertEqual(False, w.is_valid()) + self.assertEqual(True, transition.id in w.errors['transitions']) + msg = u'This transition is not navigable because none of the'\ + ' roles associated with the parent state have permission to'\ + ' use it.' + self.assertEqual(True, msg in w.errors['transitions'][transition.id]) + + # so all the potential pitfalls have been vaidated. Lets make sure + # we *can* validate it as expected. + transition.roles.clear() + admin_role = Role.objects.get(id=1) + staff_role = Role.objects.get(id=3) + transition.roles.add(admin_role) + transition.roles.add(staff_role) + self.assertEqual(True, w.is_valid()) + self.assertEqual([], w.errors['workflow']) + self.assertEqual({}, w.errors['states']) + self.assertEqual({}, w.errors['transitions']) + + def test_workflow_has_errors(self): + """ + Ensures that has_errors() returns the appropriate response for all + possible circumstances + """ + # Some housekeepeing + w = Workflow.objects.get(id=1) + u = User.objects.get(id=1) + w.activate() + w2 = w.clone(u) + + # A state with no errors + state1 = State.objects.get(id=1) + w.is_valid() + self.assertEqual([], w.has_errors(state1)) + + # A state with errors + state1.is_start_state = False + state1.save() + w.is_valid() + msg = u'This state is orphaned. There is no way to get to it given'\ + ' the current workflow topology.' + self.assertEqual([msg], w.has_errors(state1)) + + # A transition with no errors + transition = Transition.objects.get(id=10) + w.is_valid() + self.assertEqual([], w.has_errors(transition)) + + # A transition with errors + role = Role.objects.get(id=2) + transition.roles.clear() + transition.roles.add(role) + w.is_valid() + msg = u'This transition is not navigable because none of the'\ + ' roles associated with the parent state have permission to'\ + ' use it.' + self.assertEqual([msg], w.has_errors(transition)) + + # A state not associated with the workflow + state2 = w2.states.all()[0] + state2.is_start_state = False + state2.save() + w.is_valid() + # The state is a problem state but isn't anything to do with the + # workflow w + self.assertEqual([], w.has_errors(state2)) + + # A transition not associated with the workflow + transition2 = w2.transitions.all()[0] + transition2.roles.clear() + w.is_valid() + # The transition has a problem but isn't anything to do with the + # workflow w + self.assertEqual([], w.has_errors(transition2)) + + # Something not either a state or transition (e.g. a string) + w.is_valid() + self.assertEqual([], w.has_errors("Test")) + + def test_workflow_activate_validation(self): + """ + Makes sure that the appropriate validation of a workflow happens + when the activate() method is called + """ + # from the fixtures + w = Workflow.objects.get(id=1) + self.assertEquals(Workflow.DEFINITION, w.status) + + # make sure only workflows in definition can be activated + w.status=Workflow.ACTIVE + w.save() + try: + w.activate() + except Exception, instance: + self.assertEqual(u'Only workflows in the "definition" state may'\ + ' be activated', instance.args[0]) + else: + self.fail('Exception expected but not thrown') + w.status=Workflow.DEFINITION + w.save() + + # Lets make sure the workflow is validated before being activated by + # making sure the workflow in not valid + state1 = State.objects.get(id=1) + state1.is_start_state=False + state1.save() + try: + w.activate() + except Exception, instance: + self.assertEqual(u"Cannot activate as the workflow doesn't"\ + " validate.", instance.args[0]) + else: + self.fail('Exception expected but not thrown') + state1.is_start_state=True + state1.save() + + # so all the potential pitfalls have been validated. Lets make sure + # we *can* approve it as expected. + w.activate() + self.assertEqual(Workflow.ACTIVE, w.status) + + def test_workflow_retire_validation(self): + """ + Makes sure that the appropriate state is set against a workflow when + this method is called + """ + w = Workflow.objects.get(id=1) + w.retire() + self.assertEqual(Workflow.RETIRED, w.status) + + def test_workflow_clone(self): + """ + Makes sure we can clone a workflow correctly. + """ + # We can't clone workflows that are in definition because they might + # not be "correct" (see the validation that happens when activate() + # method is called + u = User.objects.get(id=1) + w = Workflow.objects.get(id=1) + try: + w.clone(u) + except Exception, instance: + self.assertEqual(u'Only active or retired workflows may be'\ + ' cloned', instance.args[0]) + else: + self.fail('Exception expected but not thrown') + w.activate() + clone = w.clone(u) + self.assertEqual(Workflow.DEFINITION, clone.status) + self.assertEqual(u, clone.created_by) + self.assertEqual(w, clone.cloned_from) + self.assertEqual(w.name, clone.name) + self.assertEqual(w.description, clone.description) + # Lets check we get the right number of states, transitions and + # events + self.assertEqual(w.transitions.all().count(), + clone.transitions.all().count()) + self.assertEqual(w.states.all().count(), clone.states.all().count()) + self.assertEqual(w.events.all().count(), clone.events.all().count()) + + def test_state_deadline(self): + """ + Makes sure we get the right result from the deadline() method in the + State model + """ + w = Workflow.objects.get(id=1) + s = State( + name='test', + workflow=w + ) + s.save() + + # Lets make sure the default is correct + self.assertEquals(None, s.deadline()) + + # Changing the unit of time measurements mustn't change anything + s.estimation_unit = s.HOUR + s.save() + self.assertEquals(None, s.deadline()) + + # Only when we have a positive value in the estimation_value field + # should a deadline be returned + s._today = lambda : datetime.datetime(2000, 1, 1, 0, 0, 0) + + # Seconds + s.estimation_unit = s.SECOND + s.estimation_value = 1 + s.save() + expected = datetime.datetime(2000, 1, 1, 0, 0, 1) + actual = s.deadline() + self.assertEquals(expected, actual) + + # Minutes + s.estimation_unit = s.MINUTE + s.save() + expected = datetime.datetime(2000, 1, 1, 0, 1, 0) + actual = s.deadline() + self.assertEquals(expected, actual) + + # Hours + s.estimation_unit = s.HOUR + s.save() + expected = datetime.datetime(2000, 1, 1, 1, 0) + actual = s.deadline() + self.assertEquals(expected, actual) + + # Days + s.estimation_unit = s.DAY + s.save() + expected = datetime.datetime(2000, 1, 2) + actual = s.deadline() + self.assertEquals(expected, actual) + + # Weeks + s.estimation_unit = s.WEEK + s.save() + expected = datetime.datetime(2000, 1, 8) + actual = s.deadline() + self.assertEquals(expected, actual) + + def test_state_unicode(self): + """ + Makes sure we get the right result from the __unicode__() method in + the State model + """ + w = Workflow.objects.get(id=1) + s = State( + name='test', + workflow=w + ) + s.save() + self.assertEqual(u'test', s.__unicode__()) + + def test_transition_unicode(self): + """ + Makes sure we get the right result from the __unicode__() method in + the Transition model + """ + tr = Transition.objects.get(id=1) + self.assertEqual(u'Proceed to state 2', tr.__unicode__()) + + def test_event_unicode(self): + """ + Makes sure we get the right result from the __unicode__() method in + the Event model + """ + e = Event.objects.get(id=1) + self.assertEqual(u'Important meeting', e.__unicode__()) + + def test_event_type_unicode(self): + """ + Make sure we get the name of the event type + """ + et = EventType.objects.get(id=1) + self.assertEquals(u'Meeting', et.__unicode__()) + + def test_workflowactivity_current_state(self): + """ + Check we always get the latest state (or None if the WorkflowActivity + hasn't started navigating a workflow + """ + w = Workflow.objects.get(id=1) + u = User.objects.get(id=1) + r = Role.objects.get(id=1) + wa = WorkflowActivity(workflow=w, created_by=u) + wa.save() + p = Participant(user=u, workflowactivity=wa) + p.save() + p.roles.add(r) + # We've not started the workflow yet so make sure we don't get + # anything back + self.assertEqual(None, wa.current_state()) + wa.start(p) + # We should be in the first state + s1 = State.objects.get(id=1) # From the fixtures + current_state = wa.current_state() + # check we have a good current state + self.assertNotEqual(None, current_state) + self.assertEqual(s1, current_state.state) + self.assertEqual(p, current_state.participant) + # Lets progress the workflow and make sure the *latest* state is the + # current state + tr = Transition.objects.get(id=1) + wa.progress(tr, u) + s2 = State.objects.get(id=2) + current_state = wa.current_state() + self.assertEqual(s2, current_state.state) + self.assertEqual(tr, current_state.transition) + self.assertEqual(p, current_state.participant) + + def test_workflowactivity_start(self): + """ + Make sure the method works in the right way for all possible + situations + """ + w = Workflow.objects.get(id=1) + u = User.objects.get(id=1) + r = Role.objects.get(id=1) + wa = WorkflowActivity(workflow=w, created_by=u) + wa.save() + p = Participant(user=u, workflowactivity=wa) + p.save() + p.roles.add(r) + # Lets make sure we can't start a workflow that has been stopped + wa.force_stop(p, 'foo') + try: + wa.start(u) + except Exception, instance: + self.assertEqual(u'Already completed', instance.args[0]) + else: + self.fail('Exception expected but not thrown') + wa = WorkflowActivity(workflow=w, created_by=u) + wa.save() + p = Participant(user=u, workflowactivity=wa) + p.save() + p.roles.add(r) + # Lets make sure we can't start a workflow activity if there isn't + # a single start state + s2 = State.objects.get(id=2) + s2.is_start_state=True + s2.save() + try: + wa.start(u) + except Exception, instance: + self.assertEqual(u'Cannot find single start state', + instance.args[0]) + else: + self.fail('Exception expected but not thrown') + s2.is_start_state=False + s2.save() + # Lets make sure we *can* start it now we only have a single start + # state + wa.start(u) + # We should be in the first state + s1 = State.objects.get(id=1) # From the fixtures + current_state = wa.current_state() + # check we have a good current state + self.assertNotEqual(None, current_state) + self.assertEqual(s1, current_state.state) + self.assertEqual(p, current_state.participant) + # Lets make sure we can't "start" the workflowactivity again + try: + wa.start(u) + except Exception, instance: + self.assertEqual(u'Already started', instance.args[0]) + else: + self.fail('Exception expected but not thrown') + + def test_workflowactivity_progress(self): + """ + Make sure the transition from state to state is validated and + recorded in the correct way. + """ + # Some housekeeping... + w = Workflow.objects.get(id=1) + u = User.objects.get(id=1) + r = Role.objects.get(id=1) + wa = WorkflowActivity(workflow=w, created_by=u) + wa.save() + self.assertEqual(None, wa.completed_on) + p = Participant(user=u, workflowactivity=wa) + p.save() + p.roles.add(r) + # Validation checks: + # 1. The workflow activity must be started + tr5 = Transition.objects.get(id=5) + try: + wa.progress(tr5, u) + except Exception, instance: + self.assertEqual(u'Start the workflow before attempting to'\ + ' transition', instance.args[0]) + else: + self.fail('Exception expected but not thrown') + wa.start(p) + # 2. The transition's from_state *must* be the current state + try: + wa.progress(tr5, u) + except Exception, instance: + self.assertEqual(u'Transition not valid (wrong parent)', + instance.args[0]) + else: + self.fail('Exception expected but not thrown') + # Lets test again with a valid transition with the correct + # from_state + tr1 = Transition.objects.get(id=1) + wa.progress(tr1, u) + s2 = State.objects.get(id=2) + self.assertEqual(s2, wa.current_state().state) + # 3. All mandatory events for the state are in the worklow history + # (s2) has a single mandatory event associated with it + tr2 = Transition.objects.get(id=2) + try: + wa.progress(tr2, u) + except Exception, instance: + self.assertEqual(u'Transition not valid (mandatory event'\ + ' missing)', instance.args[0]) + else: + self.fail('Exception expected but not thrown') + # Lets log the event and make sure we *can* progress + e = Event.objects.get(id=1) + wa.log_event(e, u) + # Lets progress with a custom note + wa.progress(tr2, u, 'A Test') + s3 = State.objects.get(id=3) + self.assertEqual(s3, wa.current_state().state) + self.assertEqual('A Test', wa.current_state().note) + # 4. The participant has the correct role to make the transition + r2 = Role.objects.get(id=2) + p.roles.clear() + p.roles.add(r2) + tr4 = Transition.objects.get(id=4) # won't work with r2 + try: + wa.progress(tr4, u) + except Exception, instance: + self.assertEqual(u'Participant has insufficient authority to'\ + ' use the specified transition', instance.args[0]) + else: + self.fail('Exception expected but not thrown') + # We have the good transition so make sure everything is logged in + # the workflow history properly + p.roles.add(r) + s5 = State.objects.get(id=5) + wh = wa.progress(tr4, u) + self.assertEqual(s5, wh.state) + self.assertEqual(tr4, wh.transition) + self.assertEqual(p, wh.participant) + self.assertEqual(tr4.name, wh.note) + self.assertNotEqual(None, wh.deadline) + self.assertEqual(WorkflowHistory.TRANSITION, wh.log_type) + # Get to the end of the workflow and check that by progressing to an + # end state the workflow activity is given a completed on timestamp + tr8 = Transition.objects.get(id=8) + tr10 = Transition.objects.get(id=10) + tr11 = Transition.objects.get(id=11) + wa.progress(tr8, u) + # Lets log a generic event + e2 = Event.objects.get(id=4) + wa.log_event(e2, u, "A generic event has taken place") + wa.progress(tr10, u) + wa.progress(tr11, u) + self.assertNotEqual(None, wa.completed_on) + + def test_workflowactivity_log_event(self): + """ + Make sure the logging of events for a workflow is validated and + recorded in the correct way. + """ + # Some housekeeping... + w = Workflow.objects.get(id=1) + u = User.objects.get(id=1) + r = Role.objects.get(id=1) + wa = WorkflowActivity(workflow=w, created_by=u) + wa.save() + p = Participant(user=u, workflowactivity=wa) + p.save() + p.roles.add(r) + # Lets make sure we can log a generic event prior to starting the + # workflow activity + ge = Event.objects.get(id=4) + wh = wa.log_event(ge, u, 'Another test') + self.assertEqual(None, wh.state) + self.assertEqual(ge, wh.event) + self.assertEqual(p, wh.participant) + self.assertEqual('Another test', wh.note) + self.assertEqual(None, wh.deadline) + wa.start(p) + # Validation checks: + # 1. Make sure the event we're logging is for the appropriate + # workflow + wf2 = Workflow(name="dummy", created_by=u) + wf2.save() + dummy_state = State(name="dummy", workflow=wf2) + dummy_state.save() + dummy_event = Event( + name="dummy event", + workflow=wf2, + state=dummy_state + ) + dummy_event.save() + try: + wa.log_event(dummy_event, u) + except Exception, instance: + self.assertEqual(u'The event is not associated with the'\ + ' workflow for the WorkflowActivity', instance.args[0]) + else: + self.fail('Exception expected but not thrown') + # 2. Make sure the participant has the correct role to log the event + # (Transition to second state where we have an appropriate event + # already specified) + tr1 = Transition.objects.get(id=1) + wa.progress(tr1, u) + e1 = Event.objects.get(id=1) + p.roles.clear() + try: + wa.log_event(e1, u) + except Exception, instance: + self.assertEqual(u'The participant is not associated with the'\ + ' specified event', instance.args[0]) + else: + self.fail('Exception expected but not thrown') + p.roles.add(r) + # Try again but with the right profile + wa.log_event(e1, u) + # 3. Make sure, if the event is mandatory it can only be logged + # whilst in the correct state + e2 = Event.objects.get(id=2) + e2.is_mandatory = True + e2.save() + try: + wa.log_event(e2, u) + except Exception, instance: + self.assertEqual(u'The mandatory event is not associated with'\ + ' the current state', instance.args[0]) + else: + self.fail('Exception expected but not thrown') + # Save a good event instance and check everything is logged in the + # workflow history properly + tr2 = Transition.objects.get(id=2) + s3 = State.objects.get(id=3) + wa.progress(tr2, u) + wh = wa.log_event(e2, u) + self.assertEqual(s3, wh.state) + self.assertEqual(e2, wh.event) + self.assertEqual(p, wh.participant) + self.assertEqual(e2.name, wh.note) + self.assertEqual(WorkflowHistory.EVENT, wh.log_type) + # Lets log a second event of this type and make sure we handle the + # bespoke note + wh = wa.log_event(e2, u, 'A Test') + self.assertEqual(s3, wh.state) + self.assertEqual(e2, wh.event) + self.assertEqual(p, wh.participant) + self.assertEqual('A Test', wh.note) + # Finally, make sure we can log a generic event (not associated with + # a particular workflow, state or set of roles) + e3 = Event.objects.get(id=4) + wh = wa.log_event(e3, u, 'Another test') + self.assertEqual(s3, wh.state) + self.assertEqual(e3, wh.event) + self.assertEqual(p, wh.participant) + self.assertEqual('Another test', wh.note) + + def test_workflowactivity_add_comment(self): + """ + Make sure we can add comments to the workflow history via the + WorkflowActivity instance + """ + w = Workflow.objects.get(id=1) + u = User.objects.get(id=1) + r = Role.objects.get(id=1) + wa = WorkflowActivity(workflow=w, created_by=u) + wa.save() + p = Participant(user=u, workflowactivity=wa) + p.save() + p.roles.add(r) + # Test we can add a comment to an un-started workflow + wh = wa.add_comment(u, 'test') + self.assertEqual('test', wh.note) + self.assertEqual(p, wh.participant) + self.assertEqual(WorkflowHistory.COMMENT, wh.log_type) + self.assertEqual(None, wh.state) + # Start the workflow and add a comment + wa.start(p) + s = State.objects.get(id=1) + wh = wa.add_comment(u, 'test2') + self.assertEqual('test2', wh.note) + self.assertEqual(p, wh.participant) + self.assertEqual(WorkflowHistory.COMMENT, wh.log_type) + self.assertEqual(s, wh.state) + # Add a comment from an unknown user + u2 = User.objects.get(id=2) + wh = wa.add_comment(u2, 'test3') + self.assertEqual('test3', wh.note) + self.assertEqual(u2, wh.participant.user) + self.assertEqual(0, len(wh.participant.roles.all())) + self.assertEqual(WorkflowHistory.COMMENT, wh.log_type) + self.assertEqual(s, wh.state) + # Make sure we can't add an empty comment + try: + wa.add_comment(u, '') + except Exception, instance: + self.assertEqual(u'Cannot add an empty comment', instance.args[0]) + else: + self.fail('Exception expected but not thrown') + + def test_workflowactivity_assign_role(self): + """ + Makes sure the appropriate things happen when a role is assigned to + a user for a workflow activity + """ + w = Workflow.objects.get(id=1) + u = User.objects.get(id=1) + r = Role.objects.get(id=1) + wa = WorkflowActivity(workflow=w, created_by=u) + wa.save() + p = Participant(user=u, workflowactivity=wa) + p.save() + p.roles.add(r) + # Lets test we can assign a role *before* the workflow activity is + # started + u2 = User.objects.get(id=2) + wh = wa.assign_role(u, u2, r) + self.assertEqual('Role "Administrator" assigned to test_manager', + wh.note) + self.assertEqual(p, wh.participant) + self.assertEqual(WorkflowHistory.ROLE, wh.log_type) + self.assertEqual(None, wh.state) + self.assertEqual(None, wh.deadline) + # Lets start the workflow activity and try again + wa.start(p) + s = State.objects.get(id=1) + r2 = Role.objects.get(id=2) + wh = wa.assign_role(u2, u, r2) + self.assertEqual('Role "Manager" assigned to test_admin', wh.note) + self.assertEqual(u2, wh.participant.user) + self.assertEqual(WorkflowHistory.ROLE, wh.log_type) + self.assertEqual(s, wh.state) + + def test_workflowactivity_remove_role(self): + """ + Makes sure the appropriate things happen when a role is removed from + a user for a workflow activity + """ + w = Workflow.objects.get(id=1) + u = User.objects.get(id=1) + r = Role.objects.get(id=1) + wa = WorkflowActivity(workflow=w, created_by=u) + wa.save() + p = Participant(user=u, workflowactivity=wa) + p.save() + p.roles.add(r) + # Lets test we can remove a role *before* the workflow activity is + # started + u2 = User.objects.get(id=2) + p2 = Participant(user=u2, workflowactivity=wa) + p2.save() + p2.roles.add(r) + wh = wa.remove_role(u, u2, r) + self.assertEqual('Role "Administrator" removed from test_manager', + wh.note) + self.assertEqual(p, wh.participant) + self.assertEqual(WorkflowHistory.ROLE, wh.log_type) + self.assertEqual(None, wh.state) + self.assertEqual(None, wh.deadline) + # Lets start the workflow activity and try again + wa.start(p) + s = State.objects.get(id=1) + wh = wa.remove_role(u2, u, r) + self.assertEqual('Role "Administrator" removed from test_admin', wh.note) + self.assertEqual(u2, wh.participant.user) + self.assertEqual(WorkflowHistory.ROLE, wh.log_type) + self.assertEqual(s, wh.state) + # Lets make sure we return None from trying to remove a role that + # isn't associated + p.roles.add(r) + p2.roles.add(r) + r2 = Role.objects.get(id=2) + result = wa.remove_role(u, u2, r2) + self.assertEqual(None, result) + # Lets make sure we return None from trying to use a user who isn't + # a participant + u3 = User.objects.get(id=3) + result = wa.remove_role(u, u3, r) + self.assertEqual(None, result) + + def test_workflowactivity_clear_roles(self): + """ + Makes sure the appropriate things happen when a user has all roles + cleared against a workflow activity + """ + w = Workflow.objects.get(id=1) + u = User.objects.get(id=1) + r = Role.objects.get(id=1) + wa = WorkflowActivity(workflow=w, created_by=u) + wa.save() + p = Participant(user=u, workflowactivity=wa) + p.save() + p.roles.add(r) + # Lets test we can clear roles *before* the workflow activity is + # started + u2 = User.objects.get(id=2) + p2 = Participant(user=u2, workflowactivity=wa) + p2.save() + p2.roles.add(r) + wh = wa.clear_roles(u, u2) + self.assertEqual('All roles removed from test_manager', + wh.note) + self.assertEqual(p, wh.participant) + self.assertEqual(WorkflowHistory.ROLE, wh.log_type) + self.assertEqual(None, wh.state) + self.assertEqual(None, wh.deadline) + # Lets start the workflow activity and try again + wa.start(p) + s = State.objects.get(id=1) + wh = wa.clear_roles(u2, u) + self.assertEqual('All roles removed from test_admin', wh.note) + self.assertEqual(u2, wh.participant.user) + self.assertEqual(WorkflowHistory.ROLE, wh.log_type) + self.assertEqual(s, wh.state) + # Lets make sure we return None from trying to use a user who isn't + # a participant + u3 = User.objects.get(id=3) + result = wa.clear_roles(u, u3) + self.assertEqual(None, result) + + def test_workflowactivity_disable_participant(self): + """ + Makes sure a participant in a workflow activity is disabled + elegantly + """ + w = Workflow.objects.get(id=1) + u = User.objects.get(id=1) + r = Role.objects.get(id=1) + wa = WorkflowActivity(workflow=w, created_by=u) + wa.save() + p = Participant(user=u, workflowactivity=wa) + p.save() + p.roles.add(r) + # Lets test we can disable a participant *before* the workflow + # activity is started + u2 = User.objects.get(id=2) + p2 = Participant(user=u2, workflowactivity=wa) + p2.save() + p2.roles.add(r) + wh = wa.disable_participant(u, u2, 'test') + self.assertEqual('Participant test_manager disabled with the'\ + ' reason: test', wh.note) + self.assertEqual(p, wh.participant) + self.assertEqual(WorkflowHistory.ROLE, wh.log_type) + self.assertEqual(None, wh.state) + self.assertEqual(None, wh.deadline) + p2.disabled=False + p2.save() + # Lets start the workflow activity and try again + wa.start(p) + s = State.objects.get(id=1) + wh = wa.disable_participant(u, u2, 'test') + self.assertEqual('Participant test_manager disabled with the'\ + ' reason: test', wh.note) + self.assertEqual(u, wh.participant.user) + self.assertEqual(WorkflowHistory.ROLE, wh.log_type) + self.assertEqual(s, wh.state) + # Make sure we return None if the participant is already disabled + result = wa.disable_participant(u, u2, 'test') + self.assertEqual(None, result) + # Lets make sure we must supply a note + try: + wa.disable_participant(u, u2, '') + except Exception, instance: + self.assertEqual(u'Must supply a reason for disabling a'\ + ' participant. None given.', instance.args[0]) + else: + self.fail('Exception expected but not thrown') + # Lets make sure we return None from trying to disable a user who + # isn't a participant + u3 = User.objects.get(id=3) + result = wa.disable_participant(u, u3, 'test') + self.assertEqual(None, result) + + def test_workflowactivity_enable_participant(self): + """ + Make sure we can re-enable a participant in a workflow activity + """ + w = Workflow.objects.get(id=1) + u = User.objects.get(id=1) + r = Role.objects.get(id=1) + wa = WorkflowActivity(workflow=w, created_by=u) + wa.save() + p = Participant(user=u, workflowactivity=wa) + p.save() + p.roles.add(r) + # Lets test we can disable a participant *before* the workflow + # activity is started + u2 = User.objects.get(id=2) + p2 = Participant(user=u2, workflowactivity=wa) + p2.save() + p2.roles.add(r) + p2.disabled=True; + p2.save() + wh = wa.enable_participant(u, u2, 'test') + self.assertEqual('Participant test_manager enabled with the'\ + ' reason: test', wh.note) + self.assertEqual(p, wh.participant) + self.assertEqual(WorkflowHistory.ROLE, wh.log_type) + self.assertEqual(None, wh.state) + self.assertEqual(None, wh.deadline) + p2.disabled=True + p2.save() + # Lets start the workflow activity and try again + wa.start(p) + s = State.objects.get(id=1) + wh = wa.enable_participant(u, u2, 'test') + self.assertEqual('Participant test_manager enabled with the'\ + ' reason: test', wh.note) + self.assertEqual(u, wh.participant.user) + self.assertEqual(WorkflowHistory.ROLE, wh.log_type) + self.assertEqual(s, wh.state) + # Make sure we return None if the participant is already disabled + result = wa.enable_participant(u, u2, 'test') + self.assertEqual(None, result) + # Lets make sure we must supply a note + try: + wa.enable_participant(u, u2, '') + except Exception, instance: + self.assertEqual(u'Must supply a reason for enabling a'\ + ' disabled participant. None given.', instance.args[0]) + else: + self.fail('Exception expected but not thrown') + # Lets make sure we return None from trying to disable a user who + # isn't a participant + u3 = User.objects.get(id=3) + result = wa.enable_participant(u, u3, 'test') + self.assertEqual(None, result) + + def test_workflowactivity_force_stop(self): + """ + Make sure a WorkflowActivity is stopped correctly with this method + """ + # Make sure we can appropriately force_stop an un-started workflow + # activity + w = Workflow.objects.get(id=1) + u = User.objects.get(id=1) + r = Role.objects.get(id=1) + wa = WorkflowActivity(workflow=w, created_by=u) + wa.save() + p = Participant(user=u, workflowactivity=wa) + p.save() + p.roles.add(r) + wa.force_stop(u, 'foo') + self.assertNotEqual(None, wa.completed_on) + self.assertEqual(None, wa.current_state()) + # Lets make sure we can force_stop an already started workflow + # activity + wa = WorkflowActivity(workflow=w, created_by=u) + wa.save() + p = Participant(user=u, workflowactivity=wa) + p.save() + p.roles.add(r) + wa.start(u) + wa.force_stop(u, 'foo') + self.assertNotEqual(None, wa.completed_on) + wh = wa.current_state() + self.assertEqual(p, wh.participant) + self.assertEqual(u'Workflow forced to stop! Reason given: foo', + wh.note) + self.assertEqual(None, wh.deadline) + + def test_participant_unicode(self): + """ + Make sure the __unicode__() method returns the correct string in + both enabled / disabled states + """ + w = Workflow.objects.get(id=1) + u = User.objects.get(id=1) + r = Role.objects.get(id=1) + r2 = Role.objects.get(id=2) + wa = WorkflowActivity(workflow=w, created_by=u) + wa.save() + p = Participant(user=u, workflowactivity=wa) + p.save() + p.roles.add(r) + self.assertEquals(u'test_admin - Administrator', p.__unicode__()) + p.roles.add(r2) + self.assertEquals(u'test_admin - Administrator, Manager', p.__unicode__()) + p.disabled = True + p.save() + self.assertEquals(u'test_admin - Administrator, Manager (disabled)', p.__unicode__()) + p.roles.clear() + self.assertEquals(u'test_admin (disabled)', p.__unicode__()) + + def test_workflow_history_unicode(self): + """ + Make sure the __unicode__() method returns the correct string for + workflow history items + """ + w = Workflow.objects.get(id=1) + u = User.objects.get(id=1) + r = Role.objects.get(id=1) + wa = WorkflowActivity(workflow=w, created_by=u) + wa.save() + p = Participant(user=u, workflowactivity=wa) + p.save() + p.roles.add(r) + wh = wa.start(p) + self.assertEqual(u'Started workflow created by test_admin - Administrator', wh.__unicode__()) + diff --git a/geoq/workflow/unit_tests/test_views.py b/geoq/workflow/unit_tests/test_views.py new file mode 100644 index 00000000..f13cdcd1 --- /dev/null +++ b/geoq/workflow/unit_tests/test_views.py @@ -0,0 +1,86 @@ +# -*- coding: UTF-8 -*- +""" +View tests for Workflows + +Author: Nicholas H.Tollervey + +""" +# python +import datetime + +# django +from django.test.client import Client +from django.test import TestCase +from django.conf import settings + +# project +from workflow.views import * +from workflow.models import Workflow + +class ViewTestCase(TestCase): + """ + Testing Views + """ + # Make sure the URLs play nice + urls = 'workflow.urls' + # Reference fixtures here + fixtures = ['workflow_test_data'] + + def test_get_dotfile(self): + """ + Make sure we get the expected .dot file given the current state of + the fixtures + """ + w = Workflow.objects.get(id=1) + result = get_dotfile(w) + for state in w.states.all(): + # make sure we find references to the states + self.assertEqual(True, result.find("state%d"%state.id) > -1) + self.assertEqual(True, result.find(state.name) > -1) + for transition in w.transitions.all(): + # make sure we find references to the transitions + search = 'state%d -> state%d [label="%s"];'%( + transition.from_state.id, + transition.to_state.id, + transition.name) + self.assertEqual(True, result.find(search) > -1) + # Make sure we have START: and END: + self.assertEqual(True, result.find("START:") > -1) + self.assertEqual(True, result.find("END:") > -1) + + def test_dotfile(self): + """ + Makes sure a GET to the url results in the .dot file as an + attachment + """ + c = Client() + response = c.get('/test_workflow/dotfile/') + self.assertContains(response, 'A definition for a diagram of the'\ + ' workflow: test workflow') + + def test_graphviz(self): + """ + Makes sure a GET to the url results in a .png file + """ + c = Client() + response = c.get('/test_workflow.png') + self.assertEqual(200, response.status_code) + self.assertEqual('image/png', response['Content-Type']) + + def test_graphviz_with_no_graphviz(self): + """ + Makes sure the graphviz method returns an appropriate exception if + graphviz path is not specified + """ + _target = settings._target + del _target.GRAPHVIZ_DOT_COMMAND + settings.__setattr__('_target', _target) + c = Client() + try: + response = c.get('/test_workflow.png') + except Exception, instance: + self.assertEqual(u"GRAPHVIZ_DOT_COMMAND constant not set in"\ + " settings.py (to specify the absolute path to"\ + " graphviz's dot command)", instance.args[0]) + else: + self.fail('Exception expected but not thrown') diff --git a/geoq/workflow/urls.py b/geoq/workflow/urls.py new file mode 100644 index 00000000..e851bf1b --- /dev/null +++ b/geoq/workflow/urls.py @@ -0,0 +1,8 @@ +from django.conf.urls.defaults import * + +urlpatterns = patterns('', + # get a dotfile for the referenced workflow + url(r'^(?P\w+)/dotfile/$', 'workflow.views.dotfile', name='dotfile'), + # get a png image generated by graphviz for the referenced workflow + url(r'^(?P\w+).png$', 'workflow.views.graphviz', name='graphviz'), +) diff --git a/geoq/workflow/views.py b/geoq/workflow/views.py new file mode 100644 index 00000000..d1994ef8 --- /dev/null +++ b/geoq/workflow/views.py @@ -0,0 +1,66 @@ +# -*- coding: UTF-8 -*- + +# Python +import subprocess +from os.path import join + +# django +from django.template import Context, loader +from django.shortcuts import get_object_or_404 +from django.http import HttpResponse +from django.conf import settings + +# Workflow app +from workflow.models import Workflow + +################### +# Utility functions +################### + +def get_dotfile(workflow): + """ + Given a workflow will return the appropriate contents of a .dot file for + processing by graphviz + """ + c = Context({'workflow': workflow}) + t = loader.get_template('graphviz/workflow.dot') + return t.render(c) + +################ +# view functions +################ + +def dotfile(request, workflow_slug): + """ + Returns the dot file for use with graphviz given the workflow name (slug) + """ + w = get_object_or_404(Workflow, slug=workflow_slug) + response = HttpResponse(mimetype='text/plain') + response['Content-Disposition'] = 'attachment; filename=%s.dot'%w.name + response.write(get_dotfile(w)) + return response + +def graphviz(request, workflow_slug): + """ + Returns a png representation of the workflow generated by graphviz given + the workflow name (slug) + + The following constant should be defined in settings.py: + + GRAPHVIZ_DOT_COMMAND - absolute path to graphviz's dot command used to + generate the image + """ + if not hasattr(settings, 'GRAPHVIZ_DOT_COMMAND'): + # At least provide a helpful exception message + raise Exception("GRAPHVIZ_DOT_COMMAND constant not set in settings.py"\ + " (to specify the absolute path to graphviz's dot command)") + w = get_object_or_404(Workflow, slug=workflow_slug) + # Lots of "pipe" work to avoid hitting the file-system + proc = subprocess.Popen('%s -Tpng' % settings.GRAPHVIZ_DOT_COMMAND, + shell=True, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + ) + response = HttpResponse(mimetype='image/png') + response.write(proc.communicate(get_dotfile(w).encode('utf_8'))[0]) + return response From 8e96103f58d7c645232c6f96e53d9064688d0008 Mon Sep 17 00:00:00 2001 From: stephenrjones Date: Sun, 1 Apr 2018 09:39:05 -0400 Subject: [PATCH 005/126] add fancytree --- geoq/static/fancytree/jquery.fancytree.dnd.js | 521 +++ geoq/static/fancytree/jquery.fancytree.js | 4028 +++++++++++++++++ geoq/static/fancytree/skin-win7/icons.gif | Bin 0 -> 5512 bytes geoq/static/fancytree/skin-win7/loading.gif | Bin 0 -> 3234 bytes .../fancytree/skin-win7/ui.fancytree.css | 550 +++ .../fancytree/skin-win7/ui.fancytree.less | 158 + .../fancytree/skin-win7/vista_colors.txt | 51 + 7 files changed, 5308 insertions(+) create mode 100644 geoq/static/fancytree/jquery.fancytree.dnd.js create mode 100755 geoq/static/fancytree/jquery.fancytree.js create mode 100755 geoq/static/fancytree/skin-win7/icons.gif create mode 100755 geoq/static/fancytree/skin-win7/loading.gif create mode 100755 geoq/static/fancytree/skin-win7/ui.fancytree.css create mode 100755 geoq/static/fancytree/skin-win7/ui.fancytree.less create mode 100755 geoq/static/fancytree/skin-win7/vista_colors.txt diff --git a/geoq/static/fancytree/jquery.fancytree.dnd.js b/geoq/static/fancytree/jquery.fancytree.dnd.js new file mode 100644 index 00000000..a302ec2e --- /dev/null +++ b/geoq/static/fancytree/jquery.fancytree.dnd.js @@ -0,0 +1,521 @@ +/*! + * jquery.fancytree.dnd.js + * + * Drag-and-drop support. + * (Extension module for jquery.fancytree.js: https://github.com/mar10/fancytree/) + * + * Copyright (c) 2014, Martin Wendt (http://wwWendt.de) + * + * Released under the MIT license + * https://github.com/mar10/fancytree/wiki/LicenseInfo + * + * @version @VERSION + * @date @DATE + */ + +;(function($, window, document, undefined) { + +"use strict"; + +/* ***************************************************************************** + * Private functions and variables + */ +var logMsg = $.ui.fancytree.debug, + didRegisterDnd = false; + +/* Convert number to string and prepend +/-; return empty string for 0.*/ +function offsetString(n){ + return n === 0 ? "" : (( n > 0 ) ? ("+" + n) : ("" + n)); +} + +/* ***************************************************************************** + * Drag and drop support + */ +function _initDragAndDrop(tree) { + var dnd = tree.options.dnd || null; + // Register 'connectToFancytree' option with ui.draggable + if( dnd ) { + _registerDnd(); + } + // Attach ui.draggable to this Fancytree instance + if(dnd && dnd.dragStart ) { + tree.widget.element.draggable($.extend({ + addClasses: false, + appendTo: "body", + containment: false, + delay: 0, + distance: 4, + // TODO: merge Dynatree issue 419 + revert: false, + scroll: true, // issue 244: enable scrolling (if ul.fancytree-container) + scrollSpeed: 7, + scrollSensitivity: 10, + // Delegate draggable.start, drag, and stop events to our handler + connectToFancytree: true, + // Let source tree create the helper element + helper: function(event) { + var sourceNode = $.ui.fancytree.getNode(event.target); + if(!sourceNode){ // Dynatree issue 211 + // might happen, if dragging a table *header* + return "
ERROR?: helper requested but sourceNode not found
"; + } + return sourceNode.tree.ext.dnd._onDragEvent("helper", sourceNode, null, event, null, null); + }, + start: function(event, ui) { + var sourceNode = ui.helper.data("ftSourceNode"); + return !!sourceNode; // Abort dragging if no node could be found + } + }, tree.options.dnd.draggable)); + } + // Attach ui.droppable to this Fancytree instance + if(dnd && dnd.dragDrop) { + tree.widget.element.droppable($.extend({ + addClasses: false, + tolerance: "intersect", + greedy: false +/* + activate: function(event, ui) { + logMsg("droppable - activate", event, ui, this); + }, + create: function(event, ui) { + logMsg("droppable - create", event, ui); + }, + deactivate: function(event, ui) { + logMsg("droppable - deactivate", event, ui); + }, + drop: function(event, ui) { + logMsg("droppable - drop", event, ui); + }, + out: function(event, ui) { + logMsg("droppable - out", event, ui); + }, + over: function(event, ui) { + logMsg("droppable - over", event, ui); + } +*/ + }, tree.options.dnd.droppable)); + } +} + +//--- Extend ui.draggable event handling -------------------------------------- + +function _registerDnd() { + if(didRegisterDnd){ + return; + } + + // Register proxy-functions for draggable.start/drag/stop + + $.ui.plugin.add("draggable", "connectToFancytree", { + start: function(event, ui) { + // 'draggable' was renamed to 'ui-draggable' since jQueryUI 1.10 + var draggable = $(this).data("ui-draggable") || $(this).data("draggable"), + sourceNode = ui.helper.data("ftSourceNode") || null; + + if(sourceNode) { + // Adjust helper offset, so cursor is slightly outside top/left corner + draggable.offset.click.top = -2; + draggable.offset.click.left = + 16; + // Trigger dragStart event + // TODO: when called as connectTo..., the return value is ignored(?) + return sourceNode.tree.ext.dnd._onDragEvent("start", sourceNode, null, event, ui, draggable); + } + }, + drag: function(event, ui) { + // 'draggable' was renamed to 'ui-draggable' since jQueryUI 1.10 + var isHelper, + draggable = $(this).data("ui-draggable") || $(this).data("draggable"), + sourceNode = ui.helper.data("ftSourceNode") || null, + prevTargetNode = ui.helper.data("ftTargetNode") || null, + targetNode = $.ui.fancytree.getNode(event.target); + + if(event.target && !targetNode){ + // We got a drag event, but the targetNode could not be found + // at the event location. This may happen, + // 1. if the mouse jumped over the drag helper, + // 2. or if a non-fancytree element is dragged + // We ignore it: + isHelper = $(event.target).closest("div.fancytree-drag-helper,#fancytree-drop-marker").length > 0; + if(isHelper){ + logMsg("Drag event over helper: ignored."); + return; + } + } + ui.helper.data("ftTargetNode", targetNode); + // Leaving a tree node + if(prevTargetNode && prevTargetNode !== targetNode ) { + prevTargetNode.tree.ext.dnd._onDragEvent("leave", prevTargetNode, sourceNode, event, ui, draggable); + } + if(targetNode){ + if(!targetNode.tree.options.dnd.dragDrop) { + // not enabled as drop target + } else if(targetNode === prevTargetNode) { + // Moving over same node + targetNode.tree.ext.dnd._onDragEvent("over", targetNode, sourceNode, event, ui, draggable); + }else{ + // Entering this node first time + targetNode.tree.ext.dnd._onDragEvent("enter", targetNode, sourceNode, event, ui, draggable); + } + } + // else go ahead with standard event handling + }, + stop: function(event, ui) { + // 'draggable' was renamed to 'ui-draggable' since jQueryUI 1.10 + var draggable = $(this).data("ui-draggable") || $(this).data("draggable"), + sourceNode = ui.helper.data("ftSourceNode") || null, + targetNode = ui.helper.data("ftTargetNode") || null, +// mouseDownEvent = draggable._mouseDownEvent, + eventType = event.type, + dropped = (eventType === "mouseup" && event.which === 1); + + if(!dropped){ + logMsg("Drag was cancelled"); + } + if(targetNode) { + if(dropped){ + targetNode.tree.ext.dnd._onDragEvent("drop", targetNode, sourceNode, event, ui, draggable); + } + targetNode.tree.ext.dnd._onDragEvent("leave", targetNode, sourceNode, event, ui, draggable); + } + if(sourceNode){ + sourceNode.tree.ext.dnd._onDragEvent("stop", sourceNode, null, event, ui, draggable); + } + } + }); + + didRegisterDnd = true; +} + + +/* ***************************************************************************** + * + */ + +$.ui.fancytree.registerExtension({ + name: "dnd", + version: "0.1.0", + // Default options for this extension. + options: { + // Make tree nodes draggable: + dragStart: null, // Callback(sourceNode, data), return true, to enable dnd + dragStop: null, // Callback(sourceNode, data) +// helper: null, + // Make tree nodes accept draggables + autoExpandMS: 1000, // Expand nodes after n milliseconds of hovering. + preventVoidMoves: true, // Prevent dropping nodes 'before self', etc. + preventRecursiveMoves: true, // Prevent dropping nodes on own descendants + dragEnter: null, // Callback(targetNode, data) + dragOver: null, // Callback(targetNode, data) + dragDrop: null, // Callback(targetNode, data) + dragLeave: null, // Callback(targetNode, data) + // + draggable: null, // Additional options passed to jQuery draggable + droppable: null // Additional options passed to jQuery droppable + }, + + treeInit: function(ctx){ + var tree = ctx.tree; + this._super(ctx); + _initDragAndDrop(tree); + }, + /* Override key handler in order to cancel dnd on escape.*/ + nodeKeydown: function(ctx) { + var event = ctx.originalEvent; + if( event.which === $.ui.keyCode.ESCAPE) { + this._local._cancelDrag(); + } + return this._super(ctx); + }, + /* Display drop marker according to hitMode ('after', 'before', 'over', 'out', 'start', 'stop'). */ + _setDndStatus: function(sourceNode, targetNode, helper, hitMode, accept) { + var posOpts, + markerOffsetX = 0, + markerAt = "center", + instData = this._local, + $source = sourceNode ? $(sourceNode.span) : null, + $target = $(targetNode.span); + + if( !instData.$dropMarker ) { + instData.$dropMarker = $("
") + .hide() + .css({"z-index": 1000}) + .prependTo($(this.$div).parent()); +// .prependTo("body"); + } +// this.$dropMarker.attr("class", hitMode); + if(hitMode === "after" || hitMode === "before" || hitMode === "over"){ +// $source && $source.addClass("fancytree-drag-source"); + +// $target.addClass("fancytree-drop-target"); + + switch(hitMode){ + case "before": + instData.$dropMarker.removeClass("fancytree-drop-after fancytree-drop-over"); + instData.$dropMarker.addClass("fancytree-drop-before"); + markerAt = "top"; + break; + case "after": + instData.$dropMarker.removeClass("fancytree-drop-before fancytree-drop-over"); + instData.$dropMarker.addClass("fancytree-drop-after"); + markerAt = "bottom"; + break; + default: + instData.$dropMarker.removeClass("fancytree-drop-after fancytree-drop-before"); + instData.$dropMarker.addClass("fancytree-drop-over"); + $target.addClass("fancytree-drop-target"); + markerOffsetX = 8; + } + + if( $.ui.fancytree.jquerySupports.positionMyOfs ){ + posOpts = { + my: "left" + offsetString(markerOffsetX) + " center", + at: "left " + markerAt, + of: $target + }; + } else { + posOpts = { + my: "left center", + at: "left " + markerAt, + of: $target, + offset: "" + markerOffsetX + " 0" + }; + } + instData.$dropMarker + .show() + .position(posOpts); +// helper.addClass("fancytree-drop-hover"); + } else { +// $source && $source.removeClass("fancytree-drag-source"); + $target.removeClass("fancytree-drop-target"); + instData.$dropMarker.hide(); +// helper.removeClass("fancytree-drop-hover"); + } + if(hitMode === "after"){ + $target.addClass("fancytree-drop-after"); + } else { + $target.removeClass("fancytree-drop-after"); + } + if(hitMode === "before"){ + $target.addClass("fancytree-drop-before"); + } else { + $target.removeClass("fancytree-drop-before"); + } + if(accept === true){ + if($source){ + $source.addClass("fancytree-drop-accept"); + } + $target.addClass("fancytree-drop-accept"); + helper.addClass("fancytree-drop-accept"); + }else{ + if($source){ + $source.removeClass("fancytree-drop-accept"); + } + $target.removeClass("fancytree-drop-accept"); + helper.removeClass("fancytree-drop-accept"); + } + if(accept === false){ + if($source){ + $source.addClass("fancytree-drop-reject"); + } + $target.addClass("fancytree-drop-reject"); + helper.addClass("fancytree-drop-reject"); + }else{ + if($source){ + $source.removeClass("fancytree-drop-reject"); + } + $target.removeClass("fancytree-drop-reject"); + helper.removeClass("fancytree-drop-reject"); + } + }, + + /* + * Handles drag'n'drop functionality. + * + * A standard jQuery drag-and-drop process may generate these calls: + * + * draggable helper(): + * _onDragEvent("helper", sourceNode, null, event, null, null); + * start: + * _onDragEvent("start", sourceNode, null, event, ui, draggable); + * drag: + * _onDragEvent("leave", prevTargetNode, sourceNode, event, ui, draggable); + * _onDragEvent("over", targetNode, sourceNode, event, ui, draggable); + * _onDragEvent("enter", targetNode, sourceNode, event, ui, draggable); + * stop: + * _onDragEvent("drop", targetNode, sourceNode, event, ui, draggable); + * _onDragEvent("leave", targetNode, sourceNode, event, ui, draggable); + * _onDragEvent("stop", sourceNode, null, event, ui, draggable); + */ + _onDragEvent: function(eventName, node, otherNode, event, ui, draggable) { + if(eventName !== "over"){ + logMsg("tree.ext.dnd._onDragEvent(%s, %o, %o) - %o", eventName, node, otherNode, this); + } + var $helper, nodeOfs, relPos, relPos2, + enterResponse, hitMode, r, + opts = this.options, + dnd = opts.dnd, + ctx = this._makeHookContext(node, event, {otherNode: otherNode, ui: ui, draggable: draggable}), + res = null, + nodeTag = $(node.span); + + switch (eventName) { + case "helper": + // Only event and node argument is available + $helper = $("
") + .append(nodeTag.find("span.fancytree-title").clone()); + // DT issue 244: helper should be child of scrollParent + $("ul.fancytree-container", node.tree.$div).append($helper); + // Attach node reference to helper object + $helper.data("ftSourceNode", node); + // logMsg("helper=%o", $helper); + // logMsg("helper.sourceNode=%o", $helper.data("ftSourceNode")); + res = $helper; + break; + + case "start": + if( node.isStatusNode() ) { + res = false; + } else if(dnd.dragStart) { + res = dnd.dragStart(node, ctx); + } + if(res === false) { + this.debug("tree.dragStart() cancelled"); + //draggable._clear(); + // NOTE: the return value seems to be ignored (drag is not canceled, when false is returned) + // TODO: call this._cancelDrag()? + ui.helper.trigger("mouseup"); + ui.helper.hide(); + } else { + nodeTag.addClass("fancytree-drag-source"); + } + break; + + case "enter": + if(dnd.preventRecursiveMoves && node.isDescendantOf(otherNode)){ + r = false; + }else{ + r = dnd.dragEnter ? dnd.dragEnter(node, ctx) : null; + } + if(!r){ + // convert null, undefined, false to false + res = false; + }else if ( $.isArray(r) ) { + // TODO: also accept passing an object of this format directly + res = { + over: ($.inArray("over", r) >= 0), + before: ($.inArray("before", r) >= 0), + after: ($.inArray("after", r) >= 0) + }; + }else{ + res = { + over: ((r === true) || (r === "over")), + before: ((r === true) || (r === "before")), + after: ((r === true) || (r === "after")) + }; + } + ui.helper.data("enterResponse", res); + logMsg("helper.enterResponse: %o", res); + break; + + case "over": + enterResponse = ui.helper.data("enterResponse"); + hitMode = null; + if(enterResponse === false){ + // Don't call dragOver if onEnter returned false. +// break; + } else if(typeof enterResponse === "string") { + // Use hitMode from onEnter if provided. + hitMode = enterResponse; + } else { + // Calculate hitMode from relative cursor position. + nodeOfs = nodeTag.offset(); + relPos = { x: event.pageX - nodeOfs.left, + y: event.pageY - nodeOfs.top }; + relPos2 = { x: relPos.x / nodeTag.width(), + y: relPos.y / nodeTag.height() }; + + if( enterResponse.after && relPos2.y > 0.75 ){ + hitMode = "after"; + } else if(!enterResponse.over && enterResponse.after && relPos2.y > 0.5 ){ + hitMode = "after"; + } else if(enterResponse.before && relPos2.y <= 0.25) { + hitMode = "before"; + } else if(!enterResponse.over && enterResponse.before && relPos2.y <= 0.5) { + hitMode = "before"; + } else if(enterResponse.over) { + hitMode = "over"; + } + // Prevent no-ops like 'before source node' + // TODO: these are no-ops when moving nodes, but not in copy mode + if( dnd.preventVoidMoves ){ + if(node === otherNode){ + logMsg(" drop over source node prevented"); + hitMode = null; + }else if(hitMode === "before" && otherNode && node === otherNode.getNextSibling()){ + logMsg(" drop after source node prevented"); + hitMode = null; + }else if(hitMode === "after" && otherNode && node === otherNode.getPrevSibling()){ + logMsg(" drop before source node prevented"); + hitMode = null; + }else if(hitMode === "over" && otherNode && otherNode.parent === node && otherNode.isLastSibling() ){ + logMsg(" drop last child over own parent prevented"); + hitMode = null; + } + } +// logMsg("hitMode: %s - %s - %s", hitMode, (node.parent === otherNode), node.isLastSibling()); + ui.helper.data("hitMode", hitMode); + } + // Auto-expand node (only when 'over' the node, not 'before', or 'after') + if(hitMode === "over" && dnd.autoExpandMS && node.hasChildren() !== false && !node.expanded) { + node.scheduleAction("expand", dnd.autoExpandMS); + } + if(hitMode && dnd.dragOver){ + // TODO: http://code.google.com/p/dynatree/source/detail?r=625 + ctx.hitMode = hitMode; + res = dnd.dragOver(node, ctx); + } + // DT issue 332 +// this._setDndStatus(otherNode, node, ui.helper, hitMode, res!==false); + this._local._setDndStatus(otherNode, node, ui.helper, hitMode, res!==false && hitMode !== null); + break; + + case "drop": + hitMode = ui.helper.data("hitMode"); + if(hitMode && dnd.dragDrop){ + ctx.hitMode = hitMode; + dnd.dragDrop(node, ctx); + } + break; + + case "leave": + // Cancel pending expand request + node.scheduleAction("cancel"); + ui.helper.data("enterResponse", null); + ui.helper.data("hitMode", null); + this._local._setDndStatus(otherNode, node, ui.helper, "out", undefined); + if(dnd.dragLeave){ + dnd.dragLeave(node, ctx); + } + break; + + case "stop": + nodeTag.removeClass("fancytree-drag-source"); + if(dnd.dragStop){ + dnd.dragStop(node, ctx); + } + break; + + default: + $.error("Unsupported drag event: " + eventName); + } + return res; + }, + + _cancelDrag: function() { + var dd = $.ui.ddmanager.current; + if(dd){ + dd.cancel(); + } + } +}); +}(jQuery, window, document)); diff --git a/geoq/static/fancytree/jquery.fancytree.js b/geoq/static/fancytree/jquery.fancytree.js new file mode 100755 index 00000000..2cb10718 --- /dev/null +++ b/geoq/static/fancytree/jquery.fancytree.js @@ -0,0 +1,4028 @@ +/*! + * jquery.fancytree.js + * Dynamic tree view control, with support for lazy loading of branches. + * https://github.com/mar10/fancytree/ + * + * Copyright (c) 2006-2014, Martin Wendt (http://wwWendt.de) + * Released under the MIT license + * https://github.com/mar10/fancytree/wiki/LicenseInfo + * + * @version @VERSION + * @date @DATE + */ + +/** Core Fancytree module. + */ + + +// Start of local namespace +;(function($, window, document, undefined) { +"use strict"; + +// prevent duplicate loading +if ( $.ui.fancytree && $.ui.fancytree.version ) { + $.ui.fancytree.warn("Fancytree: ignored duplicate include"); + return; +} + + +/* ***************************************************************************** + * Private functions and variables + */ + +function _raiseNotImplemented(msg){ + msg = msg || ""; + $.error("Not implemented: " + msg); +} + +function _assert(cond, msg){ + // TODO: see qunit.js extractStacktrace() + if(!cond){ + msg = msg ? ": " + msg : ""; + $.error("Assertion failed" + msg); + } +} + +function consoleApply(method, args){ + var i, s, + fn = window.console ? window.console[method] : null; + + if(fn){ + if(fn.apply){ + fn.apply(window.console, args); + }else{ + // IE? + s = ""; + for( i=0; i t ); + } + } + return true; +} + +/** Return a wrapper that calls sub.methodName() and exposes + * this : tree + * this._local : tree.ext.EXTNAME + * this._super : base.methodName() + */ +function _makeVirtualFunction(methodName, tree, base, extension, extName){ + // $.ui.fancytree.debug("_makeVirtualFunction", methodName, tree, base, extension, extName); + // if(rexTestSuper && !rexTestSuper.test(func)){ + // // extension.methodName() doesn't call _super(), so no wrapper required + // return func; + // } + // Use an immediate function as closure + var proxy = (function(){ + var prevFunc = tree[methodName], // org. tree method or prev. proxy + baseFunc = extension[methodName], // + _local = tree.ext[extName], + _super = function(){ + return prevFunc.apply(tree, arguments); + }; + + // Return the wrapper function + return function(){ + var prevLocal = tree._local, + prevSuper = tree._super; + try{ + tree._local = _local; + tree._super = _super; + return baseFunc.apply(tree, arguments); + }finally{ + tree._local = prevLocal; + tree._super = prevSuper; + } + }; + })(); // end of Immediate Function + return proxy; +} + +/** + * Subclass `base` by creating proxy functions + */ +function _subclassObject(tree, base, extension, extName){ + // $.ui.fancytree.debug("_subclassObject", tree, base, extension, extName); + for(var attrName in extension){ + if(typeof extension[attrName] === "function"){ + if(typeof tree[attrName] === "function"){ + // override existing method + tree[attrName] = _makeVirtualFunction(attrName, tree, base, extension, extName); + }else if(attrName.charAt(0) === "_"){ + // Create private methods in tree.ext.EXTENSION namespace + tree.ext[extName][attrName] = _makeVirtualFunction(attrName, tree, base, extension, extName); + }else{ + $.error("Could not override tree." + attrName + ". Use prefix '_' to create tree." + extName + "._" + attrName); + } + }else{ + // Create member variables in tree.ext.EXTENSION namespace + if(attrName !== "options"){ + tree.ext[extName][attrName] = extension[attrName]; + } + } + } +} + + +function _getResolvedPromise(context, argArray){ + if(context === undefined){ + return $.Deferred(function(){this.resolve();}).promise(); + }else{ + return $.Deferred(function(){this.resolveWith(context, argArray);}).promise(); + } +} + + +function _getRejectedPromise(context, argArray){ + if(context === undefined){ + return $.Deferred(function(){this.reject();}).promise(); + }else{ + return $.Deferred(function(){this.rejectWith(context, argArray);}).promise(); + } +} + + +function _makeResolveFunc(deferred, context){ + return function(){ + deferred.resolveWith(context); + }; +} + + +function _getElementDataAsDict($el){ + // Evaluate 'data-NAME' attributes with special treatment for 'data-json'. + var d = $.extend({}, $el.data()), + json = d.json; + delete d.fancytree; // added to container by widget factory + if( json ) { + delete d.json; + //
  • is already returned as object (http://api.jquery.com/data/#data-html5) + d = $.extend(d, json); + } + return d; +} + + +// TODO: use currying +function _makeNodeTitleMatcher(s){ + s = s.toLowerCase(); + return function(node){ + return node.title.toLowerCase().indexOf(s) >= 0; + }; +} + +var i, + FT = null, // initialized below + ENTITY_MAP = {"&": "&", "<": "<", ">": ">", "\"": """, "'": "'", "/": "/"}, + //boolean attributes that can be set with equivalent class names in the LI tags + CLASS_ATTRS = "active expanded focus folder hideCheckbox lazy selected unselectable".split(" "), + CLASS_ATTR_MAP = {}, + // Top-level Fancytree node attributes, that can be set by dict + NODE_ATTRS = "expanded extraClasses folder hideCheckbox key lazy refKey selected title tooltip unselectable".split(" "), + NODE_ATTR_MAP = {}, + // Attribute names that should NOT be added to node.data + NONE_NODE_DATA_MAP = {"active": true, "children": true, "data": true, "focus": true}; + +for(i=0; i + * For lazy nodes, null or undefined means 'not yet loaded'. Use an empty array + * to define a node that has no children. + * @property {boolean} expanded Use isExpanded(), setExpanded() to access this property. + * @property {string} extraClasses Addtional CSS classes, added to the node's `<span>` + * @property {boolean} folder Folder nodes have different default icons and click behavior.
    + * Note: Also non-folders may have children. + * @property {string} statusNodeType null or type of temporarily generated system node like 'loading', or 'error'. + * @property {boolean} lazy True if this node is loaded on demand, i.e. on first expansion. + * @property {boolean} selected Use isSelected(), setSelected() to access this property. + * @property {string} tooltip Alternative description used as hover banner + */ +function FancytreeNode(parent, obj){ + var i, l, name, cl; + + this.parent = parent; + this.tree = parent.tree; + this.ul = null; + this.li = null; //
  • tag + this.statusNodeType = null; // if this is a temp. node to display the status of its parent + this._isLoading = false; // if this node itself is loading + this._error = null; // {message: '...'} if a load error occured + this.data = {}; + + // TODO: merge this code with node.toDict() + // copy attributes from obj object + for(i=0, l=NODE_ATTRS.length; i= 0, "insertBefore must be an existing child"); + // insert nodeList after children[pos] + this.children.splice.apply(this.children, [pos, 0].concat(nodeList)); + } + if( !this.parent || this.parent.ul || this.tr ){ + // render if the parent was rendered (or this is a root node) + this.render(); + } + if( this.tree.options.selectMode === 3 ){ + this.fixSelection3FromEndNodes(); + } + return firstNode; + }, + /** + * Append or prepend a node, or append a child node. + * + * This a convenience function that calls addChildren() + * + * @param {NodeData} node node definition + * @param {string} [mode=child] 'before', 'after', or 'child' ('over' is a synonym for 'child') + * @returns {FancytreeNode} new node + */ + addNode: function(node, mode){ + if(mode === undefined || mode === "over"){ + mode = "child"; + } + switch(mode){ + case "after": + return this.getParent().addChildren(node, this.getNextSibling()); + case "before": + return this.getParent().addChildren(node, this); + case "child": + case "over": + return this.addChildren(node); + } + _assert(false, "Invalid mode: " + mode); + }, + /** + * Append new node after this. + * + * This a convenience function that calls addNode(node, 'after') + * + * @param {NodeData} node node definition + * @returns {FancytreeNode} new node + */ + appendSibling: function(node){ + return this.addNode(node, "after"); + }, + /** + * Modify existing child nodes. + * + * @param {NodePatch} patch + * @returns {$.Promise} + * @see FancytreeNode#addChildren + */ + applyPatch: function(patch) { + // patch [key, null] means 'remove' + if(patch === null){ + this.remove(); + return _getResolvedPromise(this); + } + // TODO: make sure that root node is not collapsed or modified + // copy (most) attributes to node.ATTR or node.data.ATTR + var name, promise, v, + IGNORE_MAP = { children: true, expanded: true, parent: true }; // TODO: should be global + + for(name in patch){ + v = patch[name]; + if( !IGNORE_MAP[name] && !$.isFunction(v)){ + if(NODE_ATTR_MAP[name]){ + this[name] = v; + }else{ + this.data[name] = v; + } + } + } + // Remove and/or create children + if(patch.hasOwnProperty("children")){ + this.removeChildren(); + if(patch.children){ // only if not null and not empty list + // TODO: addChildren instead? + this._setChildren(patch.children); + } + // TODO: how can we APPEND or INSERT child nodes? + } + if(this.isVisible()){ + this.renderTitle(); + this.renderStatus(); + } + // Expand collapse (final step, since this may be async) + if(patch.hasOwnProperty("expanded")){ + promise = this.setExpanded(patch.expanded); + }else{ + promise = _getResolvedPromise(this); + } + return promise; + }, + /** Collapse all sibling nodes. + * @returns {$.Promise} + */ + collapseSiblings: function() { + return this.tree._callHook("nodeCollapseSiblings", this); + }, + /** Copy this node as sibling or child of `node`. + * + * @param {FancytreeNode} node source node + * @param {string} mode 'before' | 'after' | 'child' + * @param {Function} [map] callback function(NodeData) that could modify the new node + * @returns {FancytreeNode} new + */ + copyTo: function(node, mode, map) { + return node.addNode(this.toDict(true, map), mode); + }, + /** Count direct and indirect children. + * + * @param {boolean} [deep=true] pass 'false' to only count direct children + * @returns {int} number of child nodes + */ + countChildren: function(deep) { + var cl = this.children, i, l, n; + if( !cl ){ + return 0; + } + n = cl.length; + if(deep !== false){ + for(i=0, l=n; i= 2 (prepending node info) + * + * @param {*} msg string or object or array of such + */ + debug: function(msg){ + if( this.tree.options.debugLevel >= 2 ) { + Array.prototype.unshift.call(arguments, this.toString()); + consoleApply("debug", arguments); + } + }, + /** Deprecated. + * @deprecated since 2014-02-16. Use resetLazy() instead. + */ + discard: function(){ + this.warn("FancytreeNode.discard() is deprecated since 2014-02-16. Use .resetLazy() instead."); + return this.resetLazy(); + }, + // TODO: expand(flag) + /**Find all nodes that contain `match` in the title. + * + * @param {string | function(node)} match string to search for, of a function that + * returns `true` if a node is matched. + * @returns {FancytreeNode[]} array of nodes (may be empty) + * @see FancytreeNode#findAll + */ + findAll: function(match) { + match = $.isFunction(match) ? match : _makeNodeTitleMatcher(match); + var res = []; + this.visit(function(n){ + if(match(n)){ + res.push(n); + } + }); + return res; + }, + /**Find first node that contains `match` in the title (not including self). + * + * @param {string | function(node)} match string to search for, of a function that + * returns `true` if a node is matched. + * @returns {FancytreeNode} matching node or null + * @example + * fat text + */ + findFirst: function(match) { + match = $.isFunction(match) ? match : _makeNodeTitleMatcher(match); + var res = null; + this.visit(function(n){ + if(match(n)){ + res = n; + return false; + } + }); + return res; + }, + /* Apply selection state (internal use only) */ + _changeSelectStatusAttrs: function (state) { + var changed = false; + + switch(state){ + case false: + changed = ( this.selected || this.partsel ); + this.selected = false; + this.partsel = false; + break; + case true: + changed = ( !this.selected || !this.partsel ); + this.selected = true; + this.partsel = true; + break; + case undefined: + changed = ( this.selected || !this.partsel ); + this.selected = false; + this.partsel = true; + break; + default: + _assert(false, "invalid state: " + state); + } + // this.debug("fixSelection3AfterLoad() _changeSelectStatusAttrs()", state, changed); + if( changed ){ + this.renderStatus(); + } + return changed; + }, + /** + * Fix selection status, after this node was (de)selected in multi-hier mode. + * This includes (de)selecting all children. + */ + fixSelection3AfterClick: function() { + var flag = this.isSelected(); + +// this.debug("fixSelection3AfterClick()"); + + this.visit(function(node){ + node._changeSelectStatusAttrs(flag); + }); + this.fixSelection3FromEndNodes(); + }, + /** + * Fix selection status for multi-hier mode. + * Only end-nodes are considered to update the descendants branch and parents. + * Should be called after this node has loaded new children or after + * children have been modified using the API. + */ + fixSelection3FromEndNodes: function() { +// this.debug("fixSelection3FromEndNodes()"); + _assert(this.tree.options.selectMode === 3, "expected selectMode 3"); + + // Visit all end nodes and adjust their parent's `selected` and `partsel` + // attributes. Return selection state true, false, or undefined. + function _walk(node){ + var i, l, child, s, state, allSelected,someSelected, + children = node.children; + + if( children ){ + // check all children recursively + allSelected = true; + someSelected = false; + + for( i=0, l=children.length; i= 0; i--){ + // that.debug("pushexpand" + parents[i]); + deferreds.push(parents[i].setExpanded(true, opts)); + } + $.when.apply($, deferreds).done(function(){ + // All expands have finished + // that.debug("expand DONE", scroll); + if( scroll ){ + that.scrollIntoView(effects).done(function(){ + // that.debug("scroll DONE"); + dfd.resolve(); + }); + } else { + dfd.resolve(); + } + }); + return dfd.promise(); + }, + /** Move this node to targetNode. + * @param {FancytreeNode} targetNode + * @param {string} mode
    +	 *      'child': append this node as last child of targetNode.
    +	 *               This is the default. To be compatble with the D'n'd
    +	 *               hitMode, we also accept 'over'.
    +	 *      'before': add this node as sibling before targetNode.
    +	 *      'after': add this node as sibling after targetNode.
    + * @param {function} [map] optional callback(FancytreeNode) to allow modifcations + */ + moveTo: function(targetNode, mode, map) { + if(mode === undefined || mode === "over"){ + mode = "child"; + } + var pos, + prevParent = this.parent, + targetParent = (mode === "child") ? targetNode : targetNode.parent; + + if(this === targetNode){ + return; + }else if( !this.parent ){ + throw "Cannot move system root"; + }else if( targetParent.isDescendantOf(this) ){ + throw "Cannot move a node to its own descendant"; + } + // Unlink this node from current parent + if( this.parent.children.length === 1 ) { + this.parent.children = this.parent.lazy ? [] : null; + this.parent.expanded = false; + } else { + pos = $.inArray(this, this.parent.children); + _assert(pos >= 0); + this.parent.children.splice(pos, 1); + } + // Remove from source DOM parent +// if(this.parent.ul){ +// this.parent.ul.removeChild(this.li); +// } + + // Insert this node to target parent's child list + this.parent = targetParent; + if( targetParent.hasChildren() ) { + switch(mode) { + case "child": + // Append to existing target children + targetParent.children.push(this); + break; + case "before": + // Insert this node before target node + pos = $.inArray(targetNode, targetParent.children); + _assert(pos >= 0); + targetParent.children.splice(pos, 0, this); + break; + case "after": + // Insert this node after target node + pos = $.inArray(targetNode, targetParent.children); + _assert(pos >= 0); + targetParent.children.splice(pos+1, 0, this); + break; + default: + throw "Invalid mode " + mode; + } + } else { + targetParent.children = [ this ]; + } + // Parent has no
      tag yet: +// if( !targetParent.ul ) { +// // This is the parent's first child: create UL tag +// // (Hidden, because it will be +// targetParent.ul = document.createElement("ul"); +// targetParent.ul.style.display = "none"; +// targetParent.li.appendChild(targetParent.ul); +// } +// // Issue 319: Add to target DOM parent (only if node was already rendered(expanded)) +// if(this.li){ +// targetParent.ul.appendChild(this.li); +// }^ + + // Let caller modify the nodes + if( map ){ + targetNode.visit(map, true); + } + // Handle cross-tree moves + if( this.tree !== targetNode.tree ) { + // Fix node.tree for all source nodes +// _assert(false, "Cross-tree move is not yet implemented."); + this.warn("Cross-tree moveTo is experimantal!"); + this.visit(function(n){ + // TODO: fix selection state and activation, ... + n.tree = targetNode.tree; + }, true); + } + + // A collaposed node won't re-render children, so we have to remove it manually + // if( !targetParent.expanded ){ + // prevParent.ul.removeChild(this.li); + // } + + // Update HTML markup + if( !prevParent.isDescendantOf(targetParent)) { + prevParent.render(); + } + if( !targetParent.isDescendantOf(prevParent) && targetParent !== prevParent) { + targetParent.render(); + } + // TODO: fix selection state + // TODO: fix active state + +/* + var tree = this.tree; + var opts = tree.options; + var pers = tree.persistence; + + + // Always expand, if it's below minExpandLevel +// tree.logDebug ("%s._addChildNode(%o), l=%o", this, ftnode, ftnode.getLevel()); + if ( opts.minExpandLevel >= ftnode.getLevel() ) { +// tree.logDebug ("Force expand for %o", ftnode); + this.bExpanded = true; + } + + // In multi-hier mode, update the parents selection state + // DT issue #82: only if not initializing, because the children may not exist yet +// if( !ftnode.data.isStatusNode() && opts.selectMode==3 && !isInitializing ) +// ftnode._fixSelectionState(); + + // In multi-hier mode, update the parents selection state + if( ftnode.bSelected && opts.selectMode==3 ) { + var p = this; + while( p ) { + if( !p.hasSubSel ) + p._setSubSel(true); + p = p.parent; + } + } + // render this node and the new child + if ( tree.bEnableUpdate ) + this.render(); + + return ftnode; + +*/ + }, + /** Set focus relative to this node and optionally activate. + * + * @param {number} where The keyCode that would normally trigger this move, + * e.g. `$.ui.keyCode.LEFT` would collapse the node if it + * is expanded or move to the parent oterwise. + * @param {boolean} [activate=true] + * @returns {$.Promise} + */ + navigate: function(where, activate) { + var i, parents, + handled = true, + KC = $.ui.keyCode, + sib = null; + + // Navigate to node + function _goto(n){ + if( n ){ + n.makeVisible(); + // Node may still be hidden by a filter + if( ! $(n.span).is(":visible") ) { + n.debug("Navigate: skipping hidden node"); + n.navigate(where, activate); + return; + } + return activate === false ? n.setFocus() : n.setActive(); + } + } + + switch( where ) { + case KC.BACKSPACE: + if( this.parent && this.parent.parent ) { + _goto(this.parent); + } + break; + case KC.LEFT: + if( this.expanded ) { + this.setExpanded(false); + _goto(this); + } else if( this.parent && this.parent.parent ) { + _goto(this.parent); + } + break; + case KC.RIGHT: + if( !this.expanded && (this.children || this.lazy) ) { + this.setExpanded(); + _goto(this); + } else if( this.children && this.children.length ) { + _goto(this.children[0]); + } + break; + case KC.UP: + sib = this.getPrevSibling(); + while( sib && sib.expanded && sib.children && sib.children.length ){ + sib = sib.children[sib.children.length - 1]; + } + if( !sib && this.parent && this.parent.parent ){ + sib = this.parent; + } + _goto(sib); + break; + case KC.DOWN: + if( this.expanded && this.children && this.children.length ) { + sib = this.children[0]; + } else { + parents = this.getParentList(false, true); + for(i=parents.length-1; i>=0; i--) { + sib = parents[i].getNextSibling(); + if( sib ){ break; } + } + } + _goto(sib); + break; + default: + handled = false; + } + }, + /** + * Remove this node (not allowed for system root). + */ + remove: function() { + return this.parent.removeChild(this); + }, + /** + * Remove childNode from list of direct children. + * @param {FancytreeNode} childNode + */ + removeChild: function(childNode) { + return this.tree._callHook("nodeRemoveChild", this, childNode); + }, + /** + * Remove all child nodes and descendents. This converts the node into a leaf.
      + * If this was a lazy node, it is still considered 'loaded'; call node.resetLazy() + * in order to trigger lazyLoad on next expand. + */ + removeChildren: function() { + return this.tree._callHook("nodeRemoveChildren", this); + }, + /** + * This method renders and updates all HTML markup that is required + * to display this node in its current state.
      + * Note: + *
        + *
      • It should only be neccessary to call this method after the node object + * was modified by direct access to its properties, because the common + * API methods (node.setTitle(), moveTo(), addChildren(), remove(), ...) + * already handle this. + *
      • {@link FancytreeNode#renderTitle} and {@link FancytreeNode#renderStatus} + * are implied. If changes are more local, calling only renderTitle() or + * renderStatus() may be sufficient and faster. + *
      • If a node was created/removed, node.render() must be called on the parent. + *
      + * + * @param {boolean} [force=false] re-render, even if html markup was already created + * @param {boolean} [deep=false] also render all descendants, even if parent is collapsed + */ + render: function(force, deep) { + return this.tree._callHook("nodeRender", this, force, deep); + }, + /** Create HTML markup for the node's outer (expander, checkbox, icon, and title). + * @see Fancytree_Hooks#nodeRenderTitle + */ + renderTitle: function() { + return this.tree._callHook("nodeRenderTitle", this); + }, + /** Update element's CSS classes according to node state. + * @see Fancytree_Hooks#nodeRenderStatus + */ + renderStatus: function() { + return this.tree._callHook("nodeRenderStatus", this); + }, + /** + * Remove all children, collapse, and set the lazy-flag, so that the lazyLoad + * event is triggered on next expand. + */ + resetLazy: function() { + this.removeChildren(); + this.expanded = false; + this.lazy = true; + this.children = undefined; + this.renderStatus(); + }, + /** Schedule activity for delayed execution (cancel any pending request). + * scheduleAction('cancel') will only cancel a pending request (if any). + * @param {string} mode + * @param {number} ms + */ + scheduleAction: function(mode, ms) { + if( this.tree.timer ) { + clearTimeout(this.tree.timer); +// this.tree.debug("clearTimeout(%o)", this.tree.timer); + } + this.tree.timer = null; + var self = this; // required for closures + switch (mode) { + case "cancel": + // Simply made sure that timer was cleared + break; + case "expand": + this.tree.timer = setTimeout(function(){ + self.tree.debug("setTimeout: trigger expand"); + self.setExpanded(true); + }, ms); + break; + case "activate": + this.tree.timer = setTimeout(function(){ + self.tree.debug("setTimeout: trigger activate"); + self.setActive(true); + }, ms); + break; + default: + throw "Invalid mode " + mode; + } +// this.tree.debug("setTimeout(%s, %s): %s", mode, ms, this.tree.timer); + }, + /** + * + * @param {boolean | PlainObject} [effects=false] animation options. + * @param {FancytreeNode} [topNode=null] this node will remain visible in + * any case, even if `this` is outside the scroll pane. + * @returns {$.Promise} + */ + scrollIntoView: function(effects, topNode) { + effects = (effects === true) ? {duration: 200, queue: false} : effects; + var topNodeY, + dfd = new $.Deferred(), + that = this, + nodeY = $(this.span).position().top, + nodeHeight = $(this.span).height(), + $container = this.tree.$container, + scrollTop = $container[0].scrollTop, + horzScrollHeight = Math.max(0, ($container.innerHeight() - $container[0].clientHeight)), +// containerHeight = $container.height(), + containerHeight = $container.height() - horzScrollHeight, + newScrollTop = null; + +// console.log("horzScrollHeight: " + horzScrollHeight); +// console.log("$container[0].scrollTop: " + $container[0].scrollTop); +// console.log("$container[0].scrollHeight: " + $container[0].scrollHeight); +// console.log("$container[0].clientHeight: " + $container[0].clientHeight); +// console.log("$container.innerHeight(): " + $container.innerHeight()); +// console.log("$container.height(): " + $container.height()); + + if(nodeY < 0){ + newScrollTop = scrollTop + nodeY; + }else if((nodeY + nodeHeight) > containerHeight){ + newScrollTop = scrollTop + nodeY - containerHeight + nodeHeight; + // If a topNode was passed, make sure that it is never scrolled + // outside the upper border + if(topNode){ + topNodeY = topNode ? $(topNode.span).position().top : 0; + if((nodeY - topNodeY) > containerHeight){ + newScrollTop = scrollTop + topNodeY; + } + } + } + if(newScrollTop !== null){ + if(effects){ + // TODO: resolve dfd after animation +// var that = this; + effects.complete = function(){ + dfd.resolveWith(that); + }; + $container.animate({ + scrollTop: newScrollTop + }, effects); + }else{ + $container[0].scrollTop = newScrollTop; + dfd.resolveWith(this); + } + }else{ + dfd.resolveWith(this); + } + return dfd.promise(); +/* from jQuery.menu: + var borderTop, paddingTop, offset, scroll, elementHeight, itemHeight; + if ( this._hasScroll() ) { + borderTop = parseFloat( $.css( this.activeMenu[0], "borderTopWidth" ) ) || 0; + paddingTop = parseFloat( $.css( this.activeMenu[0], "paddingTop" ) ) || 0; + offset = item.offset().top - this.activeMenu.offset().top - borderTop - paddingTop; + scroll = this.activeMenu.scrollTop(); + elementHeight = this.activeMenu.height(); + itemHeight = item.height(); + + if ( offset < 0 ) { + this.activeMenu.scrollTop( scroll + offset ); + } else if ( offset + itemHeight > elementHeight ) { + this.activeMenu.scrollTop( scroll + offset - elementHeight + itemHeight ); + } + } + */ + }, + + /**Activate this node. + * @param {boolean} [flag=true] pass false to deactivate + * @param {object} [opts] additional options. Defaults to {noEvents: false} + */ + setActive: function(flag, opts){ + return this.tree._callHook("nodeSetActive", this, flag, opts); + }, + /**Expand or collapse this node. Promise is resolved, when lazy loading and animations are done. + * @param {boolean} [flag=true] pass false to collapse + * @param {object} [opts] additional options. Defaults to {noAnimation: false, noEvents: false} + * @returns {$.Promise} + */ + setExpanded: function(flag, opts){ + return this.tree._callHook("nodeSetExpanded", this, flag, opts); + }, + /**Set keyboard focus to this node. + * @param {boolean} [flag=true] pass false to blur + * @see Fancytree#setFocus + */ + setFocus: function(flag){ + return this.tree._callHook("nodeSetFocus", this, flag); + }, + // TODO: setLazyNodeStatus + /**Select this node, i.e. check the checkbox. + * @param {boolean} [flag=true] pass false to deselect + */ + setSelected: function(flag){ + return this.tree._callHook("nodeSetSelected", this, flag); + }, + /**Rename this node. + * @param {string} title + */ + setTitle: function(title){ + this.title = title; + this.renderTitle(); + }, + /**Sort child list by title. + * @param {function} [cmp] custom compare function(a, b) that returns -1, 0, or 1 (defaults to sort by title). + * @param {boolean} [deep=false] pass true to sort all descendant nodes + */ + sortChildren: function(cmp, deep) { + var i,l, + cl = this.children; + + if( !cl ){ + return; + } + cmp = cmp || function(a, b) { + var x = a.title.toLowerCase(), + y = b.title.toLowerCase(); + return x === y ? 0 : x > y ? 1 : -1; + }; + cl.sort(cmp); + if( deep ){ + for(i=0, l=cl.length; i"; + }, + /** Call fn(node) for all child nodes.
      + * Stop iteration, if fn() returns false. Skip current branch, if fn() returns "skip".
      + * Return false if iteration was stopped. + * + * @param {function} fn the callback function. + * Return false to stop iteration, return "skip" to skip this node and children only. + * @param {boolean} [includeSelf=false] + * @returns {boolean} + */ + visit: function(fn, includeSelf) { + var i, l, + res = true, + children = this.children; + + if( includeSelf === true ) { + res = fn(this); + if( res === false || res === "skip" ){ + return res; + } + } + if(children){ + for(i=0, l=children.length; i + * Stop iteration, if fn() returns false.
      + * Return false if iteration was stopped. + * + * @param {function} fn the callback function. + * Return false to stop iteration, return "skip" to skip this node and children only. + * @param {boolean} [includeSelf=false] + * @returns {boolean} + */ + visitParents: function(fn, includeSelf) { + // Visit parent nodes (bottom up) + if(includeSelf && fn(this) === false){ + return false; + } + var p = this.parent; + while( p ) { + if(fn(p) === false){ + return false; + } + p = p.parent; + } + return true; + }, + /** Write warning to browser console (prepending node info) + * + * @param {*} msg string or object or array of such + */ + warn: function(msg){ + Array.prototype.unshift.call(arguments, this.toString()); + consoleApply("warn", arguments); + } +}; + + +/* ***************************************************************************** + * Fancytree + */ +/** + * Construct a new tree object. + * + * @class Fancytree + * @classdesc The controller behind a fancytree. + * This class also contains 'hook methods': see {@link Fancytree_Hooks}. + * + * @param {Widget} widget + * + * @property {FancytreeOptions} options + * @property {FancytreeNode} rootNode + * @property {FancytreeNode} activeNode + * @property {FancytreeNode} focusNode + * @property {jQueryObject} $div + * @property {object} widget + * @property {object} ext + * @property {object} data + * @property {object} options + * @property {string} _id + * @property {string} statusClassPropName + * @property {string} ariaPropName + * @property {string} nodeContainerAttrName + * @property {string} $container + * @property {FancytreeNode} lastSelectedNode + */ +function Fancytree(widget) { + this.widget = widget; + this.$div = widget.element; + this.options = widget.options; + if( this.options && $.isFunction(this.options.lazyload) ) { + if( ! $.isFunction(this.options.lazyLoad ) ) { + this.options.lazyLoad = function() { + FT.warn("The 'lazyload' event is deprecated since 2014-02-25. Use 'lazyLoad' (with uppercase L) instead."); + widget.options.lazyload.apply(this, arguments); + }; + } + } + this.ext = {}; // Active extension instances + // allow to init tree.data.foo from
      + this.data = _getElementDataAsDict(this.$div); + this._id = $.ui.fancytree._nextId++; + this._ns = ".fancytree-" + this._id; // append for namespaced events + this.activeNode = null; + this.focusNode = null; + this._hasFocus = null; + this.lastSelectedNode = null; + this.systemFocusElement = null; + + this.statusClassPropName = "span"; + this.ariaPropName = "li"; + this.nodeContainerAttrName = "li"; + + // Remove previous markup if any + this.$div.find(">ul.fancytree-container").remove(); + + // Create a node without parent. + var fakeParent = { tree: this }, + $ul; + this.rootNode = new FancytreeNode(fakeParent, { + title: "root", + key: "root_" + this._id, + children: null, + expanded: true + }); + this.rootNode.parent = null; + + // Create root markup + $ul = $("
        ", { + "class": "ui-fancytree fancytree-container" + }).appendTo(this.$div); + this.$container = $ul; + this.rootNode.ul = $ul[0]; + + if(this.options.debugLevel == null){ + this.options.debugLevel = FT.debugLevel; + } + // Add container to the TAB chain + // See http://www.w3.org/TR/wai-aria-practices/#focus_activedescendant + this.$container.attr("tabindex", this.options.tabbable ? "0" : "-1"); + if(this.options.aria){ + this.$container + .attr("role", "tree") + .attr("aria-multiselectable", true); + } +} + + +Fancytree.prototype = /** @lends Fancytree# */{ + /* Return a context object that can be re-used for _callHook(). + * @param {Fancytree | FancytreeNode | EventData} obj + * @param {Event} originalEvent + * @param {Object} extra + * @returns {EventData} + */ + _makeHookContext: function(obj, originalEvent, extra) { + var ctx, tree; + if(obj.node !== undefined){ + // obj is already a context object + if(originalEvent && obj.originalEvent !== originalEvent){ + $.error("invalid args"); + } + ctx = obj; + }else if(obj.tree){ + // obj is a FancytreeNode + tree = obj.tree; + ctx = { node: obj, tree: tree, widget: tree.widget, options: tree.widget.options, originalEvent: originalEvent }; + }else if(obj.widget){ + // obj is a Fancytree + ctx = { node: null, tree: obj, widget: obj.widget, options: obj.widget.options, originalEvent: originalEvent }; + }else{ + $.error("invalid args"); + } + if(extra){ + $.extend(ctx, extra); + } + return ctx; + }, + /* Trigger a hook function: funcName(ctx, [...]). + * + * @param {string} funcName + * @param {Fancytree|FancytreeNode|EventData} contextObject + * @param {any} [_extraArgs] optional additional arguments + * @returns {any} + */ + _callHook: function(funcName, contextObject, _extraArgs) { + var ctx = this._makeHookContext(contextObject), + fn = this[funcName], + args = Array.prototype.slice.call(arguments, 2); + if(!$.isFunction(fn)){ + $.error("_callHook('" + funcName + "') is not a function"); + } + args.unshift(ctx); +// this.debug("_hook", funcName, ctx.node && ctx.node.toString() || ctx.tree.toString(), args); + return fn.apply(this, args); + }, + /* Check if current extensions dependencies are met and throw an error if not. + * + * This method may be called inside the `treeInit` hook for custom extensions. + * + * @param {string} extension name of the required extension + * @param {boolean} [required=true] pass `false` if the extension is optional, but we want to check for order if it is present + * @param {boolean} [before] `true` if `name` must be included before this, `false` otherwise (use `null` if order doesn't matter) + * @param {string} [message] optional error message (defaults to a descriptve error message) + */ + _requireExtension: function(name, required, before, message) { + before = !!before; + var thisName = this._local.name, + extList = this.options.extensions, + isBefore = $.inArray(name, extList) < $.inArray(thisName, extList), + isMissing = required && this.ext[name] == null, + badOrder = !isMissing && before != null && (before !== isBefore); + + _assert(thisName && thisName !== name); + + if( isMissing || badOrder ){ + if( !message ){ + if( isMissing || required ){ + message = "'" + thisName + "' extension requires '" + name + "'"; + if( badOrder ){ + message += " to be registered " + (before ? "before" : "after") + " itself"; + } + }else{ + message = "If used together, `" + name + "` must be registered " + (before ? "before" : "after") + " `" + thisName + "`"; + } + } + $.error(message); + return false; + } + return true; + }, + /** Activate node with a given key and fire focus and activate events. + * + * A prevously activated node will be deactivated. + * If activeVisible option is set, all parents will be expanded as necessary. + * Pass key = false, to deactivate the current node only. + * @param {string} key + * @returns {FancytreeNode} activated node (null, if not found) + */ + activateKey: function(key) { + var node = this.getNodeByKey(key); + if(node){ + node.setActive(); + }else if(this.activeNode){ + this.activeNode.setActive(false); + } + return node; + }, + /** (experimental) + * + * @param {Array} patchList array of [key, NodePatch] arrays + * @returns {$.Promise} resolved, when all patches have been applied + * @see TreePatch + */ + applyPatch: function(patchList) { + var dfd, i, p2, key, patch, node, + patchCount = patchList.length, + deferredList = []; + + for(i=0; i= 2 (prepending tree name) + * + * @param {*} msg string or object or array of such + */ + debug: function(msg){ + if( this.options.debugLevel >= 2 ) { + Array.prototype.unshift.call(arguments, this.toString()); + consoleApply("debug", arguments); + } + }, + // TODO: disable() + // TODO: enable() + // TODO: enableUpdate() + // TODO: fromDict + /** + * Generate INPUT elements that can be submitted with html forms. + * + * In selectMode 3 only the topmost selected nodes are considered. + * + * @param {boolean | string} [selected=true] + * @param {boolean | string} [active=true] + */ + generateFormElements: function(selected, active) { + // TODO: test case + var nodeList, + selectedName = (selected !== false) ? "ft_" + this._id : selected, + activeName = (active !== false) ? "ft_" + this._id + "_active" : active, + id = "fancytree_result_" + this._id, + $result = this.$container.find("div#" + id); + + if($result.length){ + $result.empty(); + }else{ + $result = $("
        ", { + id: id + }).hide().appendTo(this.$container); + } + if(selectedName){ + nodeList = this.getSelectedNodes( this.options.selectMode === 3 ); + $.each(nodeList, function(idx, node){ + $result.append($("", { + type: "checkbox", + name: selectedName, + value: node.key, + checked: true + })); + }); + } + if(activeName && this.activeNode){ + $result.append($("", { + type: "radio", + name: activeName, + value: this.activeNode.key, + checked: true + })); + } + }, + /** + * Return the currently active node or null. + * @returns {FancytreeNode} + */ + getActiveNode: function() { + return this.activeNode; + }, + /** Return the first top level node if any (not the invisible root node). + * @returns {FancytreeNode | null} + */ + getFirstChild: function() { + return this.rootNode.getFirstChild(); + }, + /** + * Return node that has keyboard focus. + * @param {boolean} [ifTreeHasFocus=false] (not yet implemented) + * @returns {FancytreeNode} + */ + getFocusNode: function(ifTreeHasFocus) { + // TODO: implement ifTreeHasFocus + return this.focusNode; + }, + /** + * Return node with a given key or null if not found. + * @param {string} key + * @param {FancytreeNode} [searchRoot] only search below this node + * @returns {FancytreeNode | null} + */ + getNodeByKey: function(key, searchRoot) { + // Search the DOM by element ID (assuming this is faster than traversing all nodes). + // $("#...") has problems, if the key contains '.', so we use getElementById() + var el, match; + if(!searchRoot){ + el = document.getElementById(this.options.idPrefix + key); + if( el ){ + return el.ftnode ? el.ftnode : null; + } + } + // Not found in the DOM, but still may be in an unrendered part of tree + // TODO: optimize with specialized loop + // TODO: consider keyMap? + searchRoot = searchRoot || this.rootNode; + match = null; + searchRoot.visit(function(node){ +// window.console.log("getNodeByKey(" + key + "): ", node.key); + if(node.key === key) { + match = node; + return false; + } + }, true); + return match; + }, + // TODO: getRoot() + /** + * Return an array of selected nodes. + * @param {boolean} [stopOnParents=false] only return the topmost selected + * node (useful with selectMode 3) + * @returns {FancytreeNode[]} + */ + getSelectedNodes: function(stopOnParents) { + var nodeList = []; + this.rootNode.visit(function(node){ + if( node.selected ) { + nodeList.push(node); + if( stopOnParents === true ){ + return "skip"; // stop processing this branch + } + } + }); + return nodeList; + }, + /** Return true if the tree control has keyboard focus + * @returns {boolean} + */ + hasFocus: function(){ + return !!this._hasFocus; + }, + /** Write to browser console if debugLevel >= 1 (prepending tree name) + * @param {*} msg string or object or array of such + */ + info: function(msg){ + if( this.options.debugLevel >= 1 ) { + Array.prototype.unshift.call(arguments, this.toString()); + consoleApply("info", arguments); + } + }, +/* + TODO: isInitializing: function() { + return ( this.phase=="init" || this.phase=="postInit" ); + }, + TODO: isReloading: function() { + return ( this.phase=="init" || this.phase=="postInit" ) && this.options.persist && this.persistence.cookiesFound; + }, + TODO: isUserEvent: function() { + return ( this.phase=="userEvent" ); + }, +*/ + + /** + * Make sure that a node with a given ID is loaded, by traversing - and + * loading - its parents. This method is ment for lazy hierarchies. + * A callback is executed for every node as we go. + * @example + * tree.loadKeyPath("/_3/_23/_26/_27", function(node, status){ + * if(status === "loaded") { + * console.log("loaded intermiediate node " + node); + * }else if(status === "ok") { + * node.activate(); + * } + * }); + * + * @param {string | string[]} keyPathList one or more key paths (e.g. '/3/2_1/7') + * @param {function} callback callback(node, status) is called for every visited node ('loading', 'loaded', 'ok', 'error') + * @returns {$.Promise} + */ + loadKeyPath: function(keyPathList, callback, _rootNode) { + var deferredList, dfd, i, path, key, loadMap, node, segList, + root = _rootNode || this.rootNode, + sep = this.options.keyPathSeparator, + self = this; + + if(!$.isArray(keyPathList)){ + keyPathList = [keyPathList]; + } + // Pass 1: handle all path segments for nodes that are already loaded + // Collect distinct top-most lazy nodes in a map + loadMap = {}; + + for(i=0; i
        diff --git a/geoq/core/templatetags/settings_variables.py b/geoq/core/templatetags/settings_variables.py index 5449bbec..dc8da4e7 100644 --- a/geoq/core/templatetags/settings_variables.py +++ b/geoq/core/templatetags/settings_variables.py @@ -5,6 +5,6 @@ # gamification host -@register.filter(name='settings_value') +@register.simple_tag def settings_value(name): return getattr(settings, name, "") diff --git a/geoq/core/urls.py b/geoq/core/urls.py index 8c1ba8f8..7f5cf612 100644 --- a/geoq/core/urls.py +++ b/geoq/core/urls.py @@ -38,7 +38,7 @@ # JOBS path('jobs/', TabbedJobListView.as_view(), name='job-list'), - path('jobs///', + path('jobs//', JobDetailedListView.as_view(template_name='core/job_detail.html'), name='job-detail'), path('jobs/metrics//', diff --git a/geoq/core/utils.py b/geoq/core/utils.py index d618eba4..6ec41dc1 100644 --- a/geoq/core/utils.py +++ b/geoq/core/utils.py @@ -81,5 +81,5 @@ def default(self, o): if default is None: default=str return json.dumps(obj, indent=indent, skipkeys=skipkeys, ensure_ascii=ensure_ascii, check_circular=check_circular, - allow_nan=allow_nan, cls=cls, separators=separators, encoding=encoding, default=default, + allow_nan=allow_nan, cls=cls, separators=separators, default=default, sort_keys=sort_keys).replace('<', '<').replace('>', '>').replace("javascript:", "j_script-") diff --git a/geoq/settings.py b/geoq/settings.py index 4db09a10..d1fe90bf 100644 --- a/geoq/settings.py +++ b/geoq/settings.py @@ -459,13 +459,13 @@ 'base_url': STATIC_URL + 'bootstrap/', # The complete URL to the Bootstrap CSS file (None means derive it from base_url) - 'css_url': None, + 'css_url': STATIC_URL + 'bootstrap/css/bootstrap.css', # The complete URL to the Bootstrap CSS file (None means no theme) - 'theme_url': None, + 'theme_url': STATIC_URL + 'bootstrap/css/bootstrap-theme.css', # The complete URL to the Bootstrap JavaScript file (None means derive it from base_url) - 'javascript_url': None, + 'javascript_url': STATIC_URL + 'bootstrap/js/bootstrap.min.js', # Put JavaScript in the HEAD section of the HTML document (only relevant if you use bootstrap3.html) 'javascript_in_head': False, diff --git a/geoq/training/urls.py b/geoq/training/urls.py index d1745853..b3a4073b 100644 --- a/geoq/training/urls.py +++ b/geoq/training/urls.py @@ -2,6 +2,7 @@ from django.urls import path from .views import * +app_name = 'training' urlpatterns = [ path('', login_required(TrainingListView.as_view()), name='course_list'), path('/', login_required(TrainingView.as_view()), name='course_view_information'), diff --git a/geoq/urls.py b/geoq/urls.py index 7b0aa0e7..31585f57 100644 --- a/geoq/urls.py +++ b/geoq/urls.py @@ -19,7 +19,7 @@ path('feedback/', include('geoq.feedback.urls')), path('accounts/', include('geoq.accounts.urls')), path('proxy/', include('geoq.proxy.urls')), - path('training/', include('geoq.training.urls')), + path('training/', include('geoq.training.urls', namespace='training')), path('messages/', include('userena.contrib.umessages.urls'), name='userena_messages') ] From 8cb48b9bf4237d02144b3a65bd9ce3b423c0fa37 Mon Sep 17 00:00:00 2001 From: Jones Date: Fri, 17 Apr 2020 14:36:11 -0400 Subject: [PATCH 048/126] updated reqs --- geoq/core/templates/core/project_detail.html | 2 +- geoq/requirements.txt | 85 +++++++++++--------- 2 files changed, 47 insertions(+), 40 deletions(-) diff --git a/geoq/core/templates/core/project_detail.html b/geoq/core/templates/core/project_detail.html index 8bdf755a..190a2345 100644 --- a/geoq/core/templates/core/project_detail.html +++ b/geoq/core/templates/core/project_detail.html @@ -5,7 +5,7 @@ {% block title %}GeoQ Project: {{ object.name }}{% endblock %} {% block static_libraries %} -// {% leaflet_js plugins="proj4js, draw, esri" %} +{% leaflet_js plugins="proj4js, draw, esri" %} {% leaflet_js %} diff --git a/geoq/requirements.txt b/geoq/requirements.txt index 9bb7e088..94e70a9c 100644 --- a/geoq/requirements.txt +++ b/geoq/requirements.txt @@ -1,45 +1,52 @@ -Django==1.10.6 -Django-Select2==5.8.10 -MapProxy==1.9.1 -Paver==1.2.4 -Pillow==2.9.0 -PyYAML==3.12 appdirs==1.4.3 -django-appconf==1.0.2 +asgiref==3.2.7 +certifi==2020.4.5.1 +chardet==3.0.4 +Django==3.0.5 +django-appconf==1.0.4 django-bootstrap-toolkit==2.15.0 -django-bootstrap3==8.2.1 -django-braces==1.11.0 -django-compressor==2.1.1 -django-crispy-forms==1.6.1 -django-debug-toolbar==1.7 -django-denorm==0.2.0 -django-extensions==1.7.7 -django-geoexplorer==4.0.5 -django-guardian==1.4.6 +django-bootstrap3==12.0.3 +django-braces==1.14.0 +django-compressor==2.4 +django-crispy-forms==1.9.0 +django-debug-toolbar==2.2 +django-denorm==1.0.0 +django-extensions==2.2.9 +django-geoexplorer==4.0.42 +django-guardian==2.2.0 django-http-proxy==0.4.3 -django-leaflet==0.22.0 -django-reversion==2.0.8 +django-leaflet==0.26.0 +django-reversion==3.0.7 +django-select2==7.2.3 django-singleton==0.1.9 -django-statsd-mozilla==0.3.16 -django-userena==2.0.1 -easy-thumbnails==2.3 -future==0.16.0 -geojson==1.3.4 -gunicorn==19.6.0 -html2text==2016.9.19 -jsonfield==2.0.1 -nodeenv==1.1.2 +django-statsd-mozilla==0.4.0 +django-userena-ce==6.0.0 +easy-thumbnails==2.7 +feedgen==0.9.0 +future==0.18.2 +geojson==2.5.0 +gunicorn==20.0.4 +html2text==2020.1.16 +idna==2.9 +IPy==1.0 +jsonfield==3.1.0 +lxml==4.5.0 +MapProxy==1.12.0 +nodeenv==1.3.5 ordereddict==1.1 -packaging==16.8 -psycopg2==2.7.1 -pyparsing==2.2.0 -pytz==2016.10 +packaging==20.3 +Paver==1.3.4 +Pillow==7.1.1 +psycopg2==2.8.5 +pyparsing==2.4.7 +python-dateutil==2.8.1 +pytz==2019.3 +PyYAML==5.3.1 rcssmin==1.0.6 -requests==2.13.0 -rjsmin==1.0.12 -six==1.10.0 -sqlparse==0.2.3 -statsd==3.2.1 -webcolors==1.7 -wsgiref==0.1.2 -feedgen==0.6.1 +requests==2.23.0 +rjsmin==1.1.0 +six==1.14.0 +sqlparse==0.3.1 +statsd==3.3.0 +urllib3==1.25.9 +webcolors==1.11.1 From ef1b315516be55f5df8a12ed489a49640eb9bf38 Mon Sep 17 00:00:00 2001 From: Jones Date: Fri, 17 Apr 2020 15:26:17 -0400 Subject: [PATCH 049/126] fixes to urls, edit view --- geoq/core/models.py | 4 ++-- geoq/core/templates/core/job_detail.html | 6 +++--- geoq/core/urls.py | 5 ++++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/geoq/core/models.py b/geoq/core/models.py index 9717570e..458c3ec4 100644 --- a/geoq/core/models.py +++ b/geoq/core/models.py @@ -574,9 +574,9 @@ def properties_json(self): status=self.status, analyst=(self.analyst.username if self.analyst is not None else 'Unassigned'), priority=self.priority) - prop_json = dict(properties_built.items() + properties_main.items()) + properties_built.update(properties_main) - return clean_dumps(prop_json) + return clean_dumps(properties_built) def summary_properties_json(self): """ diff --git a/geoq/core/templates/core/job_detail.html b/geoq/core/templates/core/job_detail.html index 8cdb2197..c474be88 100644 --- a/geoq/core/templates/core/job_detail.html +++ b/geoq/core/templates/core/job_detail.html @@ -435,9 +435,9 @@