Skip to content
Snippets Groups Projects
Unverified Commit da68edbe authored by Todd Dembrey's avatar Todd Dembrey Committed by GitHub
Browse files

Merge pull request #129 from OpenTechFund/feature/171-progress-application

Feature/171 progress application
parents f8794f68 f7626814
No related branches found
No related tags found
No related merge requests found
Showing
with 400 additions and 84 deletions
# -*- coding: utf-8 -*-
# Generated by Django 1.11.8 on 2018-03-01 15:49
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('activity', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='activity',
name='type',
field=models.CharField(choices=[('comment', 'Comment'), ('activity', 'Activity')], default='comment', max_length=30),
preserve_default=False,
),
]
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
COMMENT = 'comment'
ACTION = 'action'
ACTIVITY_TYPES = {
COMMENT: 'Comment',
ACTION: 'Action',
}
class ActivityBaseManager(models.Manager):
def create(self, **kwargs):
kwargs.update(type=self.type)
return super().create(**kwargs)
def get_queryset(self):
return super().get_queryset().filter(type=self.type)
class CommentManger(ActivityBaseManager):
type = COMMENT
class ActionManager(ActivityBaseManager):
type = ACTION
class Activity(models.Model): class Activity(models.Model):
timestamp = models.DateTimeField(auto_now_add=True) timestamp = models.DateTimeField(auto_now_add=True)
type = models.CharField(choices=ACTIVITY_TYPES.items(), max_length=30)
user = models.ForeignKey(settings.AUTH_USER_MODEL) user = models.ForeignKey(settings.AUTH_USER_MODEL)
submission = models.ForeignKey('funds.ApplicationSubmission', related_name='activities') submission = models.ForeignKey('funds.ApplicationSubmission', related_name='activities')
message = models.TextField() message = models.TextField()
objects = models.Manager()
comments = CommentManger()
actions = ActionManager()
class Meta: class Meta:
ordering = ['-timestamp'] ordering = ['-timestamp']
def __str__(self):
return '{}: for "{}"'.format(self.get_type_display(), self.submission)
{% for action in actions %}
{% include "activity/include/listing_base.html" with activity=action %}
{% endfor %}
{% for activity in all_activity %}
{% include "activity/include/listing_base.html" with activity=activity %}
{% endfor %}
<form method="post"> <form method="post" id="comment-form">
{% csrf_token %} {% csrf_token %}
{{ comment_form }} {{ comment_form }}
<input type="submit" value="Comment"> <input id="comment-form-submit" name="form-submitted" type="submit" form="comment-form" value="Comment">
</form> </form>
{% for comment in comments %} {% for comment in comments %}
<div> {% include "activity/include/listing_base.html" with activity=comment %}
<p>{{ comment.timestamp }}</p>
<p>{{ comment.user }}</p>
<p>{{ comment.message }}</p>
</div>
{% endfor %} {% endfor %}
<div>
<p>{{ activity.timestamp }}</p>
<p>{{ activity.user }}</p>
<p>{{ activity.message }}</p>
</div>
from django.views.generic import CreateView from django.views.generic import CreateView, View
from .forms import CommentForm from .forms import CommentForm
from .models import Activity from .models import Activity, COMMENT
class CommentContextMixin: ACTIVITY_LIMIT = 50
class AllActivityContextMixin:
def get_context_data(self, **kwargs):
extra = {
'actions': Activity.actions.filter(submission__in=self.object_list)[:ACTIVITY_LIMIT],
'comments': Activity.comments.filter(submission__in=self.object_list[:ACTIVITY_LIMIT]),
'all_activity': Activity.objects.filter(submission__in=self.object_list)[:ACTIVITY_LIMIT],
}
return super().get_context_data(**extra, **kwargs)
class ActivityContextMixin:
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
extra = { extra = {
'comments': Activity.objects.filter(submission=self.object), 'actions': Activity.actions.filter(submission=self.object),
CommentFormView.context_name: CommentFormView.form_class(), 'comments': Activity.comments.filter(submission=self.object),
} }
return super().get_context_data(**extra, **kwargs) return super().get_context_data(**extra, **kwargs)
class DelegatedCreateView(CreateView): class DelegatedViewMixin(View):
"""For use on create views accepting forms from another view""" """For use on create views accepting forms from another view"""
def get_template_names(self): def get_template_names(self):
return self.kwargs['template_names'] return self.kwargs['template_names']
...@@ -26,15 +39,25 @@ class DelegatedCreateView(CreateView): ...@@ -26,15 +39,25 @@ class DelegatedCreateView(CreateView):
kwargs.update(**{self.context_name: form}) kwargs.update(**{self.context_name: form})
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
@classmethod
def contribute_form(cls, submission):
return cls.context_name, cls.form_class(instance=submission)
class CommentFormView(DelegatedCreateView):
class CommentFormView(DelegatedViewMixin, CreateView):
form_class = CommentForm form_class = CommentForm
context_name = 'comment_form' context_name = 'comment_form'
def form_valid(self, form): def form_valid(self, form):
form.instance.user = self.request.user form.instance.user = self.request.user
form.instance.submission = self.kwargs['submission'] form.instance.submission = self.kwargs['submission']
form.instance.type = COMMENT
return super().form_valid(form) return super().form_valid(form)
def get_success_url(self): def get_success_url(self):
return self.object.application.get_absolute_url() return self.object.submission.get_absolute_url()
@classmethod
def contribute_form(cls, submission):
# We dont want to pass the submission as the instance
return super().contribute_form(None)
from wagtail.wagtailadmin.forms import WagtailAdminPageForm
class WorkflowFormAdminForm(WagtailAdminPageForm):
def clean(self):
cleaned_data = super().clean()
model = self._meta.model
workflow = model.workflow_class_from_name(cleaned_data['workflow_name'])
application_forms = self.formsets['forms']
self.validate_stages_equal_forms(workflow, application_forms)
return cleaned_data
def validate_stages_equal_forms(self, workflow, application_forms):
if application_forms.is_valid():
valid_forms = [form for form in application_forms if not form.cleaned_data['DELETE']]
number_of_forms = len(valid_forms)
plural_form = 's' if number_of_forms > 1 else ''
number_of_stages = len(workflow.stage_classes)
plural_stage = 's' if number_of_stages > 1 else ''
if number_of_forms != number_of_stages:
self.add_error(
None,
'Number of forms does not match number of stages: '
f'{number_of_stages} stage{plural_stage} and {number_of_forms} '
f'form{plural_form} provided',
)
for form in valid_forms[number_of_stages:]:
form.add_error('form', 'Exceeds required number of forms for stage, please remove.')
from wagtail.wagtailadmin.forms import WagtailAdminPageForm from django import forms
from .models import ApplicationSubmission
class WorkflowFormAdminForm(WagtailAdminPageForm):
def clean(self):
cleaned_data = super().clean()
model = self._meta.model
workflow = model.workflow_class_from_name(cleaned_data['workflow_name']) class ProgressSubmissionForm(forms.ModelForm):
application_forms = self.formsets['forms'] action = forms.ChoiceField()
self.validate_stages_equal_forms(workflow, application_forms) class Meta:
model = ApplicationSubmission
fields: list = []
return cleaned_data def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
choices = [(action, action) for action in self.instance.phase.action_names]
self.fields['action'].choices = choices
self.fields['action'].label = self.instance.phase.name
self.should_show = bool(choices)
def validate_stages_equal_forms(self, workflow, application_forms): def save(self, *args, **kwargs):
if application_forms.is_valid(): new_phase = self.instance.workflow.process(self.instance.phase, self.cleaned_data['action'])
valid_forms = [form for form in application_forms if not form.cleaned_data['DELETE']] self.instance.status = str(new_phase)
number_of_forms = len(valid_forms) return super().save(*args, **kwargs)
plural_form = 's' if number_of_forms > 1 else ''
number_of_stages = len(workflow.stage_classes)
plural_stage = 's' if number_of_stages > 1 else ''
if number_of_forms != number_of_stages: class UpdateSubmissionLeadForm(forms.ModelForm):
self.add_error( class Meta:
None, model = ApplicationSubmission
'Number of forms does not match number of stages: ' fields = ('lead',)
f'{number_of_stages} stage{plural_stage} and {number_of_forms} '
f'form{plural_form} provided',
)
for form in valid_forms[number_of_stages:]: def __init__(self, *args, **kwargs):
form.add_error('form', 'Exceeds required number of forms for stage, please remove.') super().__init__(*args, **kwargs)
qs = self.fields['lead'].queryset
self.fields['lead'].queryset = qs.exclude(id=self.instance.lead.id)
# -*- coding: utf-8 -*-
# Generated by Django 1.11.8 on 2018-03-02 17:08
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):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('funds', '0025_update_with_file_blocks'),
]
operations = [
migrations.AddField(
model_name='applicationsubmission',
name='lead',
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='submission_lead', to=settings.AUTH_USER_MODEL),
preserve_default=False,
),
migrations.AddField(
model_name='labtype',
name='lead',
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='lab_lead', to=settings.AUTH_USER_MODEL),
preserve_default=False,
),
]
...@@ -38,7 +38,7 @@ from opentech.apply.users.groups import STAFF_GROUP_NAME ...@@ -38,7 +38,7 @@ from opentech.apply.users.groups import STAFF_GROUP_NAME
from .blocks import CustomFormFieldsBlock, MustIncludeFieldBlock, REQUIRED_BLOCK_NAMES from .blocks import CustomFormFieldsBlock, MustIncludeFieldBlock, REQUIRED_BLOCK_NAMES
from .edit_handlers import FilteredFieldPanel, ReadOnlyPanel, ReadOnlyInlinePanel from .edit_handlers import FilteredFieldPanel, ReadOnlyPanel, ReadOnlyInlinePanel
from .forms import WorkflowFormAdminForm from .admin_forms import WorkflowFormAdminForm
from .workflow import SingleStage, DoubleStage from .workflow import SingleStage, DoubleStage
...@@ -382,11 +382,17 @@ class LabType(EmailForm, WorkflowStreamForm, SubmittableStreamForm): # type: ig ...@@ -382,11 +382,17 @@ class LabType(EmailForm, WorkflowStreamForm, SubmittableStreamForm): # type: ig
class Meta: class Meta:
verbose_name = _("Lab") verbose_name = _("Lab")
lead = models.ForeignKey(settings.AUTH_USER_MODEL, limit_choices_to={'groups__name': STAFF_GROUP_NAME}, related_name='lab_lead')
parent_page_types = ['apply_home.ApplyHomePage'] parent_page_types = ['apply_home.ApplyHomePage']
subpage_types = [] # type: ignore subpage_types = [] # type: ignore
content_panels = WorkflowStreamForm.content_panels + [
FieldPanel('lead')
]
edit_handler = TabbedInterface([ edit_handler = TabbedInterface([
ObjectList(WorkflowStreamForm.content_panels, heading='Content'), ObjectList(content_panels, heading='Content'),
EmailForm.email_tab, EmailForm.email_tab,
ObjectList(WorkflowStreamForm.promote_panels, heading='Promote'), ObjectList(WorkflowStreamForm.promote_panels, heading='Promote'),
]) ])
...@@ -434,6 +440,7 @@ class ApplicationSubmission(WorkflowHelpers, AbstractFormSubmission): ...@@ -434,6 +440,7 @@ class ApplicationSubmission(WorkflowHelpers, AbstractFormSubmission):
form_fields = StreamField(CustomFormFieldsBlock()) form_fields = StreamField(CustomFormFieldsBlock())
page = models.ForeignKey('wagtailcore.Page', on_delete=models.PROTECT) page = models.ForeignKey('wagtailcore.Page', on_delete=models.PROTECT)
round = models.ForeignKey('wagtailcore.Page', on_delete=models.PROTECT, related_name='submissions', null=True) round = models.ForeignKey('wagtailcore.Page', on_delete=models.PROTECT, related_name='submissions', null=True)
lead = models.ForeignKey(settings.AUTH_USER_MODEL, limit_choices_to={'groups__name': STAFF_GROUP_NAME}, related_name='submission_lead')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
search_data = models.TextField() search_data = models.TextField()
...@@ -515,6 +522,12 @@ class ApplicationSubmission(WorkflowHelpers, AbstractFormSubmission): ...@@ -515,6 +522,12 @@ class ApplicationSubmission(WorkflowHelpers, AbstractFormSubmission):
self.workflow_name = self.page.workflow_name self.workflow_name = self.page.workflow_name
self.status = str(self.workflow.first()) self.status = str(self.workflow.first())
try:
self.lead = self.round.specific.lead
except AttributeError:
# Its a lab
self.lead = self.page.specific.lead
# add a denormed version of the answer for searching # add a denormed version of the answer for searching
self.search_data = ' '.join(self.prepare_search_values()) self.search_data = ' '.join(self.prepare_search_values())
...@@ -568,4 +581,7 @@ class ApplicationSubmission(WorkflowHelpers, AbstractFormSubmission): ...@@ -568,4 +581,7 @@ class ApplicationSubmission(WorkflowHelpers, AbstractFormSubmission):
raise AttributeError('{} has no attribute "{}"'.format(repr(self), item)) raise AttributeError('{} has no attribute "{}"'.format(repr(self), item))
def __str__(self): def __str__(self):
return str(super().__str__()) return f'{self.title} from {self.full_name} for {self.page.title}'
def __repr__(self):
return f'<{self.__class__.__name__}: {str(self.form_data)}>'
...@@ -9,9 +9,9 @@ ...@@ -9,9 +9,9 @@
<span>{{ object.stage }}</span> <span>{{ object.stage }}</span>
<span>{{ object.page }}</span> <span>{{ object.page }}</span>
<span>{{ object.round }}</span> <span>{{ object.round }}</span>
<span>Lead: {{ object.round.specific.lead }}</span> <span>Lead: {{ object.lead }}</span>
</h5> </h5>
{% include "funds/includes/status_bar.html" with workflow=object.workflow status=object.status %} {% include "funds/includes/status_bar.html" with workflow=object.workflow status=object.phase %}
</div> </div>
</div> </div>
...@@ -50,6 +50,8 @@ ...@@ -50,6 +50,8 @@
</div> </div>
<aside class="sidebar"> <aside class="sidebar">
{% include "funds/includes/progress_form.html" %}
{% include "funds/includes/update_lead_form.html" %}
{% if other_submissions %} {% if other_submissions %}
<div class="sidebar__inner"> <div class="sidebar__inner">
<h6 class="heading heading--light-grey heading--small heading--uppercase">Past Submissions</h6> <h6 class="heading heading--light-grey heading--small heading--uppercase">Past Submissions</h6>
...@@ -61,6 +63,7 @@ ...@@ -61,6 +63,7 @@
{% endif %} {% endif %}
{% include "activity/include/comment_form.html" %} {% include "activity/include/comment_form.html" %}
{% include "activity/include/comment_list.html" %} {% include "activity/include/comment_list.html" %}
{% include "activity/include/action_list.html" %}
</aside> </aside>
</div> </div>
</div> </div>
......
{% if progress_form.should_show %}
<form method="post" id="progress-form">
{% csrf_token %}
{{ progress_form }}
<input id="progress-form-submit" name="form-submitted" type="submit" form="progress-form" value="Progress">
</form>
{% endif %}
<div class="status-bar"> <div class="status-bar">
{# '.status-bar__item--is-complete' needs to be added to each 'complete' step #} {% for phase in status.stage %}
{# '.status-bar__item--is-current' needs to be added to the current step #} <div class="status-bar__item
{% for phase in workflow %} {% if phase == status %}
<div class="status-bar__item {% if phase == status %}status-bar__item--is-current{% endif %}"> status-bar__item--is-current
<span class="status-bar__tooltip" data-title="{{ phase.name }}" aria-label="{{ phase.name }}"></span> {% elif phase.step < status.step %}
status-bar__item--is-complete
{% endif %}">
<span class="status-bar__tooltip"
{% if phase == status %}
data-title="{{ status.name }}" aria-label="{{ status.name }}"
{% else %}
data-title="{{ phase.name }}" aria-label="{{ phase.name }}"
{% endif %}
></span>
<svg class="status-bar__icon"><use xlink:href="#tick-alt"></use></svg> <svg class="status-bar__icon"><use xlink:href="#tick-alt"></use></svg>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<div class="status-bar--mobile"> <div class="status-bar--mobile">
{% for phase in workflow %} {% for phase in status.stage %}
{% if phase == status %} {% if phase == status %}
<h6 class="status-bar__subheading">{{ phase.name }}</h6> <h6 class="status-bar__subheading">{{ status.name }}</h6>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>
<form method="post" id="update-lead-form">
{% csrf_token %}
{{ lead_form }}
<input id="update-form-submit" name="form-submitted" type="submit" form="update-lead-form" value="Update">
</form>
...@@ -39,6 +39,10 @@ ...@@ -39,6 +39,10 @@
{% render_table table %} {% render_table table %}
</div> </div>
{% include "activity/include/comment_list.html" %}
{% include "activity/include/action_list.html" %}
{% include "activity/include/all_activity_list.html" %}
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
......
...@@ -58,6 +58,28 @@ class TestStageCreation(SimpleTestCase): ...@@ -58,6 +58,28 @@ class TestStageCreation(SimpleTestCase):
self.assertEqual(stage.name, name) self.assertEqual(stage.name, name)
self.assertEqual(stage.form, form) self.assertEqual(stage.form, form)
def test_can_create_with_multi_phase_step(self):
first_phase, second_phase = Phase(name='first'), Phase(name='second')
change_first = ChangePhaseAction(first_phase, 'first')
change_second = ChangePhaseAction(second_phase, 'second')
class PhaseSwitch(Phase):
actions = [change_first, change_second]
class MultiPhaseStep(Stage):
name = 'stage'
phases = [
PhaseSwitch(),
[first_phase, second_phase],
]
stage = MultiPhaseStep(None)
self.assertEqual(stage.steps, 2)
current_phase = stage.phases[0]
self.assertEqual(current_phase.process(change_first.name), stage.phases[1]) # type: ignore
self.assertEqual(current_phase.process(change_second.name), stage.phases[2]) # type: ignore
def test_can_get_next_phase(self): def test_can_get_next_phase(self):
stage = StageFactory.build(num_phases=2) stage = StageFactory.build(num_phases=2)
self.assertEqual(stage.next(stage.phases[0]), stage.phases[1]) self.assertEqual(stage.next(stage.phases[0]), stage.phases[1])
......
from django import forms from django import forms
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.views.generic import DetailView from django.views.generic import DetailView, UpdateView, View
from django_filters.views import FilterView from django_filters.views import FilterView
from django_tables2.views import SingleTableMixin from django_tables2.views import SingleTableMixin
from opentech.apply.activity.views import CommentContextMixin, CommentFormView from opentech.apply.activity.views import (
AllActivityContextMixin,
ActivityContextMixin,
CommentFormView,
DelegatedViewMixin,
)
from opentech.apply.activity.models import Activity
from .forms import ProgressSubmissionForm, UpdateSubmissionLeadForm
from .models import ApplicationSubmission from .models import ApplicationSubmission
from .tables import SubmissionsTable, SubmissionFilter, SubmissionFilterAndSearch from .tables import SubmissionsTable, SubmissionFilter, SubmissionFilterAndSearch
from .workflow import SingleStage, DoubleStage from .workflow import SingleStage, DoubleStage
class SubmissionListView(SingleTableMixin, FilterView): class SubmissionListView(AllActivityContextMixin, SingleTableMixin, FilterView):
template_name = 'funds/submissions.html' template_name = 'funds/submissions.html'
table_class = SubmissionsTable table_class = SubmissionsTable
...@@ -42,13 +49,54 @@ class SubmissionSearchView(SingleTableMixin, FilterView): ...@@ -42,13 +49,54 @@ class SubmissionSearchView(SingleTableMixin, FilterView):
) )
class SubmissionDetailView(CommentContextMixin, DetailView): class ProgressSubmissionView(DelegatedViewMixin, UpdateView):
model = ApplicationSubmission model = ApplicationSubmission
form_class = ProgressSubmissionForm
context_name = 'progress_form'
def form_valid(self, form):
old_phase = form.instance.phase.name
response = super().form_valid(form)
new_phase = form.instance.phase.name
Activity.actions.create(
user=self.request.user,
submission=self.kwargs['submission'],
message=f'Progressed from {old_phase} to {new_phase}'
)
return response
class UpdateLeadView(DelegatedViewMixin, UpdateView):
model = ApplicationSubmission
form_class = UpdateSubmissionLeadForm
context_name = 'lead_form'
def form_valid(self, form):
old_lead = form.instance.lead
response = super().form_valid(form)
new_lead = form.instance.lead
Activity.actions.create(
user=self.request.user,
submission=self.kwargs['submission'],
message=f'Lead changed from {old_lead} to {new_lead}'
)
return response
class SubmissionDetailView(ActivityContextMixin, DetailView):
model = ApplicationSubmission
form_views = {
'progress': ProgressSubmissionView,
'comment': CommentFormView,
'update': UpdateLeadView,
}
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
forms = dict(form_view.contribute_form(self.object) for form_view in self.form_views.values())
return super().get_context_data( return super().get_context_data(
other_submissions=self.model.objects.filter(user=self.object.user).exclude(id=self.object.id), other_submissions=self.model.objects.filter(user=self.object.user).exclude(id=self.object.id),
**kwargs **forms,
**kwargs,
) )
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
...@@ -60,7 +108,8 @@ class SubmissionDetailView(CommentContextMixin, DetailView): ...@@ -60,7 +108,8 @@ class SubmissionDetailView(CommentContextMixin, DetailView):
kwargs['template_names'] = self.get_template_names() kwargs['template_names'] = self.get_template_names()
kwargs['context'] = self.get_context_data() kwargs['context'] = self.get_context_data()
view = CommentFormView.as_view() form_submitted = request.POST['form-submitted'].lower()
view = self.form_views[form_submitted].as_view()
return view(request, *args, **kwargs) return view(request, *args, **kwargs)
......
from collections import defaultdict
import copy import copy
from typing import Iterable, Iterator, List, Sequence, Type, Union from typing import Dict, Iterable, Iterator, List, Sequence, Type, Union
from django.forms import Form from django.forms import Form
from django.utils.text import slugify from django.utils.text import slugify
...@@ -12,14 +13,14 @@ Workflow -> Stage -> Phase -> Action ...@@ -12,14 +13,14 @@ Workflow -> Stage -> Phase -> Action
""" """
def phase_name(stage: 'Stage', phase: Union['Phase', str], occurrence: int) -> str: def phase_name(stage: 'Stage', phase: Union['Phase', str], step: int) -> str:
# Build the identifiable name for a phase # Build the identifiable name for a phase
if not isinstance(phase, str): if not isinstance(phase, str):
phase_name = phase._internal phase_name = phase._internal
else: else:
phase_name = phase phase_name = phase
return '__'.join([stage.name, phase_name, str(occurrence)]) return '__'.join([stage.name, phase_name, str(step)])
class Workflow(Iterable): class Workflow(Iterable):
...@@ -113,27 +114,43 @@ class Stage(Iterable): ...@@ -113,27 +114,43 @@ class Stage(Iterable):
# TODO: consider removing form from stage as the stage is generic and # TODO: consider removing form from stage as the stage is generic and
# shouldn't care about forms. # shouldn't care about forms.
self.form = form self.form = form
self.steps = len(self.phases)
# Make the phases new instances to prevent errors with mutability # Make the phases new instances to prevent errors with mutability
existing_phases: set = set() self.phases = self.copy_phases(self.phases)
new_phases: list = list()
for phase in self.phases: def copy_phases(self, phases: List['Phase']) -> List['Phase']:
phase.stage = self new_phases = list()
while str(phase) in existing_phases: for step, phase in enumerate(self.phases):
phase.occurrence += 1 try:
existing_phases.add(str(phase)) new_phases.append(self.copy_phase(phase, step))
new_phases.append(copy.copy(phase)) except AttributeError:
self.phases = new_phases # We have a step with multiple equivalent phases
for sub_phase in phase:
def __iter__(self) -> Iterator['Phase']: new_phases.append(self.copy_phase(sub_phase, step))
yield from self.phases return new_phases
def copy_phase(self, phase: 'Phase', step: int) -> 'Phase':
phase.stage = self
phase.step = step
return copy.copy(phase)
def __iter__(self) -> 'PhaseIterator':
return PhaseIterator(self.phases, self.steps)
def __str__(self) -> str: def __str__(self) -> str:
return self.name return self.name
def get_phase(self, phase_name: str) -> 'Phase': def get_phase(self, phase_name: str) -> 'Phase':
for phase in self.phases: for phase in self.phases:
if str(phase) == phase_name: if phase == phase_name:
return phase
# We don't have the exact name
for phase in self.phases:
if phase._internal == phase_name:
# Grab the first phase to match the name
return phase return phase
return None return None
def first(self) -> 'Phase': def first(self) -> 'Phase':
...@@ -152,6 +169,45 @@ class Stage(Iterable): ...@@ -152,6 +169,45 @@ class Stage(Iterable):
return None return None
class PhaseIterator(Iterator):
class Step:
"""Allow handling phases which are equivalent e.g. outcomes (accepted/rejected)
Delegates to the underlying phases except where naming is concerned
"""
def __init__(self, phases: List['Phase']) -> None:
self.phases = phases
@property
def step(self) -> int:
return self.phases[0].step
@property
def name(self) -> str:
# Hardcode a name for multi-phased step - always outcome at the moment
if len(self.phases) > 1:
return 'Outcome'
return self.phases[0].name
def __eq__(self, other: object) -> bool:
return any(phase == other for phase in self.phases)
def __init__(self, phases: List['Phase'], steps: int) -> None:
self.current = 0
self.phases: Dict[int, List['Phase']] = defaultdict(list)
for phase in phases:
self.phases[phase.step].append(phase)
self.steps = steps
def __iter__(self) -> 'PhaseIterator':
return self
def __next__(self) -> 'Step':
self.current += 1
if self.current > self.steps:
raise StopIteration
return self.Step(self.phases[self.current - 1])
class Phase: class Phase:
""" """
Holds the Actions which a user can perform at each stage. A Phase with no actions is Holds the Actions which a user can perform at each stage. A Phase with no actions is
...@@ -173,12 +229,12 @@ class Phase: ...@@ -173,12 +229,12 @@ class Phase:
self._internal = slugify(self.name) self._internal = slugify(self.name)
self.stage: Union['Stage', None] = None self.stage: Union['Stage', None] = None
self._actions = {action.name: action for action in self.actions} self._actions = {action.name: action for action in self.actions}
self.occurrence: int = 0 self.step: int = 0
def __eq__(self, other: Union[object, str]) -> bool: def __eq__(self, other: Union[object, str]) -> bool:
if isinstance(other, str): if isinstance(other, str):
return str(self) == other return str(self) == other
to_match = ['name', 'occurrence'] to_match = ['name', 'step']
return all(getattr(self, attr) == getattr(other, attr) for attr in to_match) return all(getattr(self, attr) == getattr(other, attr) for attr in to_match)
@property @property
...@@ -186,7 +242,7 @@ class Phase: ...@@ -186,7 +242,7 @@ class Phase:
return list(self._actions.keys()) return list(self._actions.keys())
def __str__(self) -> str: def __str__(self) -> str:
return phase_name(self.stage, self, self.occurrence) return phase_name(self.stage, self, self.step)
def __getitem__(self, value: str) -> 'Action': def __getitem__(self, value: str) -> 'Action':
return self._actions[value] return self._actions[value]
...@@ -218,7 +274,7 @@ class ChangePhaseAction(Action): ...@@ -218,7 +274,7 @@ class ChangePhaseAction(Action):
def process(self, phase: 'Phase') -> Union['Phase', None]: def process(self, phase: 'Phase') -> Union['Phase', None]:
if isinstance(self.target_phase, str): if isinstance(self.target_phase, str):
return phase.stage.get_phase(phase_name(phase.stage, self.target_phase, 0)) return phase.stage.get_phase(self.target_phase)
return self.target_phase return self.target_phase
...@@ -275,8 +331,7 @@ class RequestStage(Stage): ...@@ -275,8 +331,7 @@ class RequestStage(Stage):
DiscussionWithNextPhase(), DiscussionWithNextPhase(),
ReviewPhase(), ReviewPhase(),
DiscussionPhase(), DiscussionPhase(),
accepted, [accepted, rejected]
rejected,
] ]
...@@ -285,8 +340,7 @@ class ConceptStage(Stage): ...@@ -285,8 +340,7 @@ class ConceptStage(Stage):
phases = [ phases = [
DiscussionWithNextPhase(), DiscussionWithNextPhase(),
ReviewPhase(), ReviewPhase(),
DiscussionWithProgressionPhase(), [DiscussionWithProgressionPhase(), rejected]
rejected,
] ]
...@@ -298,8 +352,7 @@ class ProposalStage(Stage): ...@@ -298,8 +352,7 @@ class ProposalStage(Stage):
DiscussionWithNextPhase(), DiscussionWithNextPhase(),
ReviewPhase('AC Review', public_name='In AC review'), ReviewPhase('AC Review', public_name='In AC review'),
DiscussionPhase(public_name='In AC review'), DiscussionPhase(public_name='In AC review'),
accepted, [accepted, rejected]
rejected,
] ]
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment