diff --git a/opentech/apply/templates/apply/demo_workflow.html b/opentech/apply/templates/apply/demo_workflow.html new file mode 100644 index 0000000000000000000000000000000000000000..0187115e51dd9930bb31df9825de4903e4f4d906 --- /dev/null +++ b/opentech/apply/templates/apply/demo_workflow.html @@ -0,0 +1,60 @@ +<head> + <link rel="stylesheet" href="//fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic"> + <link rel="stylesheet" href="//cdn.rawgit.com/necolas/normalize.css/master/normalize.css"> + <link rel="stylesheet" href="//cdn.rawgit.com/milligram/milligram/master/dist/milligram.min.css"> +</head> +<body> + <main class="wrapper"> + <nav> + <section class="container"> + <a class="button button-clear" href="{% url 'workflow_demo' 1 %}">Single Stage</a> + <a class="button button-clear" href="{% url 'workflow_demo' 2 %}">Double Stage</a> + </section> + </nav> + <section class="container"> + <h1>Demo of interacting with the workflow</h1> + </section> + <section class="container"> + <h2>{{ workflow}}</h2> + <h3>{{ phase.stage }}</h3> + {% if form %} + <form method="post"> + {% csrf_token %} + {{ form }} + <input id="current" type="hidden" name="current" value="{{ phase }}" /> + <button id="submit" name="submit">Submit</button> + </form> + {% else %} + <h4>OTF: {{ phase.name }}</h4> + <h4>Public: {{ phase.public_name }}</h4> + <form method="post"> + {% csrf_token %} + <input id="current" type="hidden" name="current" value="{{ phase }}" /> + {% for action in phase.action_names %} + <button id="action" name="action" value="{{ action }}">{{ action }}</button> + {% empty %} + <h4>There are no actions</h4> + {% endfor %} + </form> + </section> + <section class="container"> + <h3>Logs</h3> + <ul> + {% for log in logs %} + <li>{{ log }}</li> + {% endfor %} + </ul> + </section> + <section class="container"> + <h3>Submission</h3> + {% for key, value in data.items %} + <h4>{{ key }}</h4> + {{ value }} + {% endfor %} + {% endif %} + </section> + <section class="container"> + <a class="button" href="">Reset</a> + </section> + </main> +</body> diff --git a/opentech/apply/tests.py b/opentech/apply/tests.py deleted file mode 100644 index a79ca8be565f44aacce95bad20c1ee34d175ed20..0000000000000000000000000000000000000000 --- a/opentech/apply/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -# from django.test import TestCase - -# Create your tests here. diff --git a/opentech/apply/tests/__init__.py b/opentech/apply/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/opentech/apply/tests/factories.py b/opentech/apply/tests/factories.py new file mode 100644 index 0000000000000000000000000000000000000000..a4f2cbd4066d2c7d9e8c754f95b5ad8dddf8910c --- /dev/null +++ b/opentech/apply/tests/factories.py @@ -0,0 +1,109 @@ +from django.forms import Form +import factory + +from opentech.apply.workflow import Action, Phase, Stage, Workflow + + +class ListSubFactory(factory.SubFactory): + def __init__(self, *args, count=0, **kwargs): + self.count = count + super().__init__(*args, **kwargs) + + def evaluate(self, *args, **kwargs): + if isinstance(self.count, factory.declarations.BaseDeclaration): + self.evaluated_count = self.count.evaluate(*args, **kwargs) + else: + self.evaluated_count = self.count + + return super().evaluate(*args, **kwargs) + + def generate(self, step, params): + subfactory = self.get_factory() + force_sequence = step.sequence if self.FORCE_SEQUENCE else None + return [ + step.recurse(subfactory, params, force_sequence=force_sequence) + for _ in range(self.evaluated_count) + ] + + +class ActionFactory(factory.Factory): + class Meta: + model = Action + + name = factory.Faker('word') + + +class PhaseFactory(factory.Factory): + class Meta: + model = Phase + + class Params: + num_actions = factory.Faker('random_int', min=1, max=5) + + name = factory.Faker('word') + actions = ListSubFactory(ActionFactory, count=factory.SelfAttribute('num_actions')) + stage = factory.PostGeneration( + lambda obj, create, extracted, **kwargs: StageFactory.build(phases=[obj]) + ) + + @classmethod + def _create(cls, model_class, *args, **kwargs): + actions = kwargs.pop('actions') + new_class = type(model_class.__name__, (model_class,), {'actions': actions}) + return new_class(*args, **kwargs) + + @classmethod + def _build(cls, model_class, *args, **kwargs): + # defer to create because parent uses build + return cls._create(model_class, *args, **kwargs) + + +class StageFactory(factory.Factory): + class Meta: + model = Stage + inline_args = ('form',) + + class Params: + num_phases = factory.Faker('random_int', min=1, max=3) + + name = factory.Faker('word') + form = factory.LazyFunction(Form) + phases = ListSubFactory(PhaseFactory, count=factory.SelfAttribute('num_phases')) + + @classmethod + def _create(cls, model_class, *args, **kwargs): + # Returns a new class + phases = kwargs.pop('phases') + name = kwargs.pop('name') + return type(model_class.__name__, (model_class,), {'phases': phases, 'name': name}) + + @classmethod + def _build(cls, model_class, *args, **kwargs): + # returns an instance of the stage class + phases = kwargs.pop('phases') + name = kwargs.pop('name') + new_class = type(model_class.__name__, (model_class,), {'phases': phases, 'name': name}) + return new_class(*args, **kwargs) + + +class WorkflowFactory(factory.Factory): + class Meta: + model = Workflow + rename = {'stages': 'stage_classes'} + + class Params: + num_stages = factory.Faker('random_int', min=1, max=3) + + name = factory.Faker('word') + stages = ListSubFactory(StageFactory, count=factory.SelfAttribute('num_stages')) + + @factory.LazyAttribute + def forms(self): + return [Form() for _ in range(self.num_stages)] + + @classmethod + def _create(cls, model_class, *args, **kwargs): + name = kwargs.pop('name') + stages = kwargs.pop('stage_classes') + new_class = type(model_class.__name__, (model_class,), {'name': name, 'stage_classes': stages}) + return new_class(*args, **kwargs) diff --git a/opentech/apply/tests/test_workflow.py b/opentech/apply/tests/test_workflow.py new file mode 100644 index 0000000000000000000000000000000000000000..4b6cbce3c5ccbe52df7acfdda9c6eb16553358bc --- /dev/null +++ b/opentech/apply/tests/test_workflow.py @@ -0,0 +1,129 @@ +from django.test import SimpleTestCase +from django.forms import Form + +from opentech.apply.workflow import ( + Action, + ChangePhaseAction, + NextPhaseAction, + Phase, + Stage, + Workflow, +) + +from .factories import ActionFactory, PhaseFactory, StageFactory, WorkflowFactory + + +class TestWorkflowCreation(SimpleTestCase): + def test_can_create_workflow(self): + stage = StageFactory() + + class NewWorkflow(Workflow): + name = 'single_stage' + stage_classes = [stage] + workflow = NewWorkflow([Form()]) + self.assertEqual(workflow.name, NewWorkflow.name) + self.assertEqual(len(workflow.stages), 1) + + def test_returns_first_phase_if_no_arg(self): + workflow = WorkflowFactory(num_stages=1, stages__num_phases=1) + self.assertEqual(workflow.next(), workflow.stages[0].phases[0]) + + def test_can_get_the_current_phase(self): + workflow = WorkflowFactory(num_stages=1, stages__num_phases=2) + phase = workflow.stages[0].phases[0] + self.assertEqual(workflow.current(str(phase)), phase) + + def test_returns_next_stage(self): + workflow = WorkflowFactory(num_stages=2, stages__num_phases=1) + self.assertEqual(workflow.next_stage(workflow.stages[0]), workflow.stages[1]) + + def test_returns_none_if_no_next(self): + workflow = WorkflowFactory(num_stages=1, stages__num_phases=1) + self.assertEqual(workflow.next(workflow.stages[0].phases[0]), None) + + def test_returns_next_phase(self): + workflow = WorkflowFactory(num_stages=2, stages__num_phases=1) + self.assertEqual(workflow.next(workflow.stages[0].phases[0]), workflow.stages[1].phases[0]) + + def test_returns_next_phase_shared_name(self): + workflow = WorkflowFactory(num_stages=1, stages__num_phases=3, stages__phases__name='the_same') + self.assertEqual(workflow.next(workflow.stages[0].phases[0]), workflow.stages[0].phases[1]) + + +class TestStageCreation(SimpleTestCase): + def test_can_create_stage(self): + name = 'the_stage' + form = Form() + stage = Stage(form, name=name) + self.assertEqual(stage.name, name) + self.assertEqual(stage.form, form) + + def test_can_get_next_phase(self): + stage = StageFactory.build(num_phases=2) + self.assertEqual(stage.next(stage.phases[0]), stage.phases[1]) + + def test_get_none_if_no_next_phase(self): + stage = StageFactory.build(num_phases=1) + self.assertEqual(stage.next(stage.phases[0]), None) + + +class TestPhaseCreation(SimpleTestCase): + def test_can_create_phase(self): + name = 'the_phase' + phase = Phase(name) + self.assertEqual(phase.name, name) + + def test_can_get_action_from_phase(self): + actions = ActionFactory.create_batch(3) + action = actions[1] + phase = PhaseFactory(actions=actions) + self.assertEqual(phase[action.name], action) + + def test_uses_name_if_no_public(self): + phase = Phase('Phase Name') + self.assertEqual(phase.public_name, phase.name) + + def test_uses_public_if_provided(self): + public_name = 'Public Name' + phase = Phase('Phase Name', public_name=public_name) + self.assertEqual(phase.public_name, public_name) + self.assertNotEqual(phase.public_name, phase.name) + + def test_uses_public_if_provided_on_class(self): + class NewPhase(Phase): + public_name = 'Public Name' + phase = NewPhase('Phase Name') + self.assertEqual(phase.public_name, NewPhase.public_name) + self.assertNotEqual(phase.public_name, phase.name) + + +class TestActions(SimpleTestCase): + def test_can_create_action(self): + name = 'action stations' + action = Action(name) + self.assertEqual(action.name, name) + + def test_calling_processes_the_action(self): + action = ActionFactory() + with self.assertRaises(NotImplementedError): + action.process('') + + +class TestCustomActions(SimpleTestCase): + def test_next_phase_action_returns_none_if_no_next(self): + action = NextPhaseAction('the next!') + phase = PhaseFactory(actions=[action]) + self.assertEqual(phase.process(action.name), None) + + def test_next_phase_action_returns_next_phase(self): + action = NextPhaseAction('the next!') + stage = StageFactory.build(num_phases=2, phases__actions=[action]) + self.assertEqual(stage.phases[0].process(action.name), stage.phases[1]) + + def test_change_phase_will_skip_phase(self): + target_phase = PhaseFactory() + action = ChangePhaseAction(target_phase.name, 'skip!') + other_phases = PhaseFactory.create_batch(2, actions=[action]) + stage = StageFactory.build(phases=[*other_phases, target_phase]) + self.assertEqual(stage.phases[0].process(action.name), stage.phases[2]) + self.assertEqual(stage.phases[1].process(action.name), stage.phases[2]) diff --git a/opentech/apply/urls.py b/opentech/apply/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..9d1bd77962f3ed7588235332bf0f14ffedc8e163 --- /dev/null +++ b/opentech/apply/urls.py @@ -0,0 +1,7 @@ +from django.conf.urls import url + +from .views import demo_workflow + +urlpatterns = [ + url(r'^demo/(?P<wf_id>[1-2])/$', demo_workflow, name="workflow_demo") +] diff --git a/opentech/apply/views.py b/opentech/apply/views.py index fd0e0449559b2e00e226cc9f96df7caed44172aa..49fa8d1b708e88cf8359ec41e664e59fa5e6fd1b 100644 --- a/opentech/apply/views.py +++ b/opentech/apply/views.py @@ -1,3 +1,62 @@ -# from django.shortcuts import render +from django import forms +from django.template.response import TemplateResponse -# Create your views here. +from .workflow import SingleStage, DoubleStage + + +workflows = [SingleStage, DoubleStage] + + +class BasicSubmissionForm(forms.Form): + who_are_you = forms.CharField() + + +def demo_workflow(request, wf_id): + logs = request.session.get('logs', list()) + submission = request.session.get('submission', dict()) + + wf = int(wf_id) + workflow_class = workflows[wf - 1] + workflow = workflow_class([BasicSubmissionForm] * wf) + + current_phase = request.POST.get('current') + current = workflow.current(current_phase) + + if request.POST: + if current.stage.name not in submission: + submitted_form = current.stage.form(request.POST) + if submitted_form.is_valid(): + submission[current.stage.name] = submitted_form.cleaned_data + phase = current + logs.append( + f'{phase.stage}: Form was submitted' + ) + form = None + else: + form = submitted_form + else: + phase = workflow.process(current_phase, request.POST['action']) + logs.append( + f'{current.stage}: {current.name} was updated to {phase.stage}: {phase.name}' + ) + else: + phase = current + logs.clear() + submission.clear() + + if phase.stage.name not in submission: + form = phase.stage.form + else: + form = None + + request.session['logs'] = logs + request.session['submission'] = submission + + context = { + 'workflow': workflow, + 'phase': phase, + 'logs': logs, + 'data': submission, + 'form': form, + } + return TemplateResponse(request, 'apply/demo_workflow.html', context) diff --git a/opentech/apply/workflow.py b/opentech/apply/workflow.py new file mode 100644 index 0000000000000000000000000000000000000000..ff774fc820c1e7fd550f9d221d6b8059eeed0ef7 --- /dev/null +++ b/opentech/apply/workflow.py @@ -0,0 +1,252 @@ +import copy + +from typing import List, Sequence, Type, Union + +from django.forms import Form +from django.utils.text import slugify + + +class Workflow: + name: str = '' + stage_classes: Sequence[Type['Stage']] = list() + + def __init__(self, forms: Sequence[Form]) -> None: + if len(self.stage_classes) != len(forms): + raise ValueError('Number of forms does not equal the number of stages') + + self.stages = [stage(form) for stage, form in zip(self.stage_classes, forms)] + + def current(self, current_phase: Union[str, 'Phase']) -> Union['Phase', None]: + if isinstance(current_phase, Phase): + return current_phase + + if not current_phase: + return self.first() + + stage_name, phase_name, occurance = current_phase.split('__') + for stage in self.stages: + if stage.name == stage_name: + return stage.current(phase_name, occurance) + return None + + def first(self) -> 'Phase': + return self.stages[0].next() + + def process(self, current_phase: str, action: str) -> Union['Phase', None]: + phase = self.current(current_phase) + new_phase = phase.process(action) + if not new_phase: + new_stage = self.next_stage(phase.stage) + if new_stage: + return new_stage.first() + return new_phase + + def next_stage(self, current_stage: 'Stage') -> 'Stage': + for i, stage in enumerate(self.stages): + if stage == current_stage: + try: + return self.stages[i + 1] + except IndexError: + pass + + return None + + def next(self, current_phase: Union[str, 'Phase']=None) -> Union['Phase', None]: + if not current_phase: + return self.first() + + phase = self.current(current_phase) + + for stage in self.stages: + if stage == phase.stage: + next_phase = stage.next(phase) + if not next_phase: + continue + return next_phase + + next_stage = self.next_stage(phase.stage) + if next_stage: + return stage.next() + return None + + def __str__(self) -> str: + return self.name + + +class Stage: + name: str = 'Stage' + phases: list = list() + + def __init__(self, form: Form, name: str='') -> None: + if name: + self.name = name + self.form = form + # Make the phases new instances to prevent errors with mutability + existing_phases: set = set() + new_phases: list = list() + for phase in self.phases: + phase.stage = self + while str(phase) in existing_phases: + phase.occurance += 1 + existing_phases.add(str(phase)) + new_phases.append(copy.copy(phase)) + self.phases = new_phases + + def __str__(self) -> str: + return self.name + + def current(self, phase_name: str, occurance: str) -> 'Phase': + for phase in self.phases: + if phase._internal == phase_name and int(occurance) == phase.occurance: + return phase + return None + + def first(self) -> 'Phase': + return self.phases[0] + + def next(self, current_phase: 'Phase'=None) -> 'Phase': + if not current_phase: + return self.first() + + for i, phase in enumerate(self.phases): + if phase == current_phase: + try: + return self.phases[i + 1] + except IndexError: + pass + return None + + +class Phase: + actions: Sequence['Action'] = list() + name: str = '' + public_name: str = '' + + def __init__(self, name: str='', public_name: str ='') -> None: + if name: + self.name = name + + if public_name: + self.public_name = public_name + elif not self.public_name: + self.public_name = self.name + + self._internal = slugify(self.name) + self.stage: Union['Stage', None] = None + self._actions = {action.name: action for action in self.actions} + self.occurance: int = 0 + + def __eq__(self, other: object) -> bool: + to_match = ['name', 'occurance'] + return all(getattr(self, attr) == getattr(other, attr) for attr in to_match) + + @property + def action_names(self) -> List[str]: + return list(self._actions.keys()) + + def __str__(self) -> str: + return '__'.join([self.stage.name, self._internal, str(self.occurance)]) + + def __getitem__(self, value: str) -> 'Action': + return self._actions[value] + + def process(self, action: str) -> Union['Phase', None]: + return self[action].process(self) + + +class Action: + def __init__(self, name: str) -> None: + self.name = name + + def process(self, phase: 'Phase') -> Union['Phase', None]: + # Use this to define the behaviour of the action + raise NotImplementedError + + +# --- OTF Workflow --- + +class ChangePhaseAction(Action): + def __init__(self, phase: Union['Phase', str], *args: str, **kwargs: str) -> None: + self.target_phase = phase + super().__init__(*args, **kwargs) + + def process(self, phase: 'Phase') -> Union['Phase', None]: + if isinstance(self.target_phase, str): + return phase.stage.current(self.target_phase, '0') + return self.target_phase + + +class NextPhaseAction(Action): + def process(self, phase: 'Phase') -> Union['Phase', None]: + return phase.stage.next(phase) + + +reject_action = ChangePhaseAction('rejected', 'Reject') + +accept_action = ChangePhaseAction('accepted', 'Accept') + +progress_stage = ChangePhaseAction(None, 'Invite to Proposal') + +next_phase = NextPhaseAction('Progress') + + +class ReviewPhase(Phase): + name = 'Internal Review' + public_name = 'In review' + actions = [NextPhaseAction('Close Review')] + + +class DeterminationWithProgressionPhase(Phase): + name = 'Under Discussion' + public_name = 'In review' + actions = [progress_stage, reject_action] + + +class DeterminationPhase(Phase): + name = 'Under Discussion' + public_name = 'In review' + actions = [accept_action, reject_action] + + +class DeterminationWithNextPhase(Phase): + name = 'Under Discussion' + public_name = 'In review' + actions = [NextPhaseAction('Open Review'), reject_action] + + +rejected = Phase(name='Rejected') + +accepted = Phase(name='Accepted') + + +class ConceptStage(Stage): + name = 'Concept' + phases = [ + DeterminationWithNextPhase(), + ReviewPhase(), + DeterminationWithProgressionPhase(), + rejected, + ] + + +class ProposalStage(Stage): + name = 'Proposal' + phases = [ + DeterminationWithNextPhase(), + ReviewPhase(), + DeterminationWithNextPhase(), + ReviewPhase('AC Review', public_name='In AC review'), + DeterminationPhase(public_name='In AC review'), + accepted, + rejected, + ] + + +class SingleStage(Workflow): + name = 'Single Stage' + stage_classes = [ConceptStage] + + +class DoubleStage(Workflow): + name = 'Two Stage' + stage_classes = [ConceptStage, ProposalStage] diff --git a/opentech/urls.py b/opentech/urls.py index 9170ff6011991dac7f1b35c16beaf574bf762c50..4adc24202baca49bb2c2af00948994ab85ba87fb 100644 --- a/opentech/urls.py +++ b/opentech/urls.py @@ -10,6 +10,7 @@ from wagtail.wagtailadmin import urls as wagtailadmin_urls from wagtail.wagtailcore import urls as wagtail_urls from wagtail.wagtaildocs import urls as wagtaildocs_urls +from opentech.apply import urls as apply_urls from opentech.esi import views as esi_views from opentech.search import views as search_views @@ -22,6 +23,8 @@ urlpatterns = [ url(r'^search/$', search_views.search, name='search'), url(r'^esi/(.*)/$', esi_views.esi, name='esi'), url('^sitemap\.xml$', sitemap), + + url(r'^apply/', include(apply_urls)), ] diff --git a/requirements.txt b/requirements.txt index 62cc28d588b9785c386fc88336b4cca8b2fe556c..39136e562a2012ebf060279c94802196dfd1b4a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ stellar==0.4.3 wagtail-django-recaptcha==0.1 uwsgidecorators==1.1.0 mypy==0.550 +factory_boy==2.9.2 flake8 # Production dependencies diff --git a/setup.cfg b/setup.cfg index c6e0c156292251fb1e830d6de120b0fb0ad45a29..4ab5cc3deff7bec1cd567a0cbb99204f57f8bd0b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,6 +9,9 @@ ignore_errors = True check_untyped_defs = True ignore_errors = False +[mypy-opentech.apply.workflow*] +disallow_untyped_defs = True + [flake8] ignore=E501,F405 exclude=*/migrations/* diff --git a/vagrant/provision.sh b/vagrant/provision.sh index 380720c099df288c1cc870a9dc23434247e9df7e..77866f4b201720c13e624d15e2813e5d6635835b 100755 --- a/vagrant/provision.sh +++ b/vagrant/provision.sh @@ -50,4 +50,7 @@ alias djrunp="dj runserver_plus 0.0.0.0:8000" source $VIRTUALENV_DIR/bin/activate export PS1="[$PROJECT_NAME \W]\\$ " cd $PROJECT_DIR + +alias djtestapply="dj test opentech.apply --keepdb; mypy ." + EOF