Skip to content

Commit

Permalink
GH-89: Only allow construction milestones to be updated for construct…
Browse files Browse the repository at this point in the history
…ion schemes
  • Loading branch information
markhobson committed Jan 21, 2025
1 parent 9d6433b commit 4bf21a3
Show file tree
Hide file tree
Showing 6 changed files with 234 additions and 117 deletions.
83 changes: 41 additions & 42 deletions schemes/views/schemes/milestones.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,7 @@ def from_domain(cls, scheme: Scheme) -> ChangeMilestoneDatesContext:
name = scheme.overview.name
assert name is not None

return ChangeMilestoneDatesContext(
id=scheme.id, name=name, form=ChangeMilestoneDatesForm.from_domain(scheme.milestones)
)
return ChangeMilestoneDatesContext(id=scheme.id, name=name, form=ChangeMilestoneDatesForm.from_domain(scheme))


class MilestoneDateField(CustomMessageDateField):
Expand Down Expand Up @@ -153,49 +151,50 @@ def update_domain(self, milestones: SchemeMilestones, now: datetime, milestone:


class ChangeMilestoneDatesForm(FlaskForm): # type: ignore
feasibility_design_completed = FormField(
MilestoneDatesForm.create_class(Milestone.FEASIBILITY_DESIGN_COMPLETED),
label=MilestoneContext.from_domain(Milestone.FEASIBILITY_DESIGN_COMPLETED).name,
)
preliminary_design_completed = FormField(
MilestoneDatesForm.create_class(Milestone.PRELIMINARY_DESIGN_COMPLETED),
label=MilestoneContext.from_domain(Milestone.PRELIMINARY_DESIGN_COMPLETED).name,
)
detailed_design_completed = FormField(
MilestoneDatesForm.create_class(Milestone.DETAILED_DESIGN_COMPLETED),
label=MilestoneContext.from_domain(Milestone.DETAILED_DESIGN_COMPLETED).name,
)
construction_started = FormField(
MilestoneDatesForm.create_class(Milestone.CONSTRUCTION_STARTED),
label=MilestoneContext.from_domain(Milestone.CONSTRUCTION_STARTED).name,
)
construction_completed = FormField(
MilestoneDatesForm.create_class(Milestone.CONSTRUCTION_COMPLETED),
label=MilestoneContext.from_domain(Milestone.CONSTRUCTION_COMPLETED).name,
)
feasibility_design_completed: FormField[MilestoneDatesForm]
preliminary_design_completed: FormField[MilestoneDatesForm]
detailed_design_completed: FormField[MilestoneDatesForm]
construction_started: FormField[MilestoneDatesForm]
construction_completed: FormField[MilestoneDatesForm]

@staticmethod
def create_class(scheme: Scheme) -> type[ChangeMilestoneDatesForm]:
class DynamicChangeMilestoneDatesForm(ChangeMilestoneDatesForm):
pass

for milestone in sorted(
scheme.milestones_eligible_for_authority_update, key=lambda milestone: milestone.stage_order
):
field = FormField(
form_class=MilestoneDatesForm.create_class(milestone),
label=MilestoneContext.from_domain(milestone).name,
name=ChangeMilestoneDatesForm._to_field_name(milestone),
)
setattr(DynamicChangeMilestoneDatesForm, field.name, field)

return DynamicChangeMilestoneDatesForm

@classmethod
def from_domain(cls, milestones: SchemeMilestones) -> ChangeMilestoneDatesForm:
return cls(
feasibility_design_completed=MilestoneDatesForm.from_domain(
milestones, Milestone.FEASIBILITY_DESIGN_COMPLETED
).data,
preliminary_design_completed=MilestoneDatesForm.from_domain(
milestones, Milestone.PRELIMINARY_DESIGN_COMPLETED
).data,
detailed_design_completed=MilestoneDatesForm.from_domain(
milestones, Milestone.DETAILED_DESIGN_COMPLETED
).data,
construction_started=MilestoneDatesForm.from_domain(milestones, Milestone.CONSTRUCTION_STARTED).data,
construction_completed=MilestoneDatesForm.from_domain(milestones, Milestone.CONSTRUCTION_COMPLETED).data,
def from_domain(cls, scheme: Scheme) -> ChangeMilestoneDatesForm:
form_class = cls.create_class(scheme)

return form_class(
data={
cls._to_field_name(milestone): MilestoneDatesForm.from_domain(scheme.milestones, milestone).data
for milestone in scheme.milestones_eligible_for_authority_update
}
)

def update_domain(self, milestones: SchemeMilestones, now: datetime) -> None:
self.feasibility_design_completed.form.update_domain(milestones, now, Milestone.FEASIBILITY_DESIGN_COMPLETED)
self.preliminary_design_completed.form.update_domain(milestones, now, Milestone.PRELIMINARY_DESIGN_COMPLETED)
self.detailed_design_completed.form.update_domain(milestones, now, Milestone.DETAILED_DESIGN_COMPLETED)
self.construction_started.form.update_domain(milestones, now, Milestone.CONSTRUCTION_STARTED)
self.construction_completed.form.update_domain(milestones, now, Milestone.CONSTRUCTION_COMPLETED)
def update_domain(self, scheme: Scheme, now: datetime) -> None:
for milestone in sorted(
scheme.milestones_eligible_for_authority_update, key=lambda milestone: milestone.stage_order
):
field_name = self._to_field_name(milestone)
self[field_name].form.update_domain(scheme.milestones, now, milestone)

@staticmethod
def _to_field_name(milestone: Milestone) -> str:
return milestone.name.lower()


@dataclass(frozen=True)
Expand Down
4 changes: 2 additions & 2 deletions schemes/views/schemes/schemes.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,12 +356,12 @@ def milestones(clock: Clock, users: UserRepository, schemes: SchemeRepository, s
if user.authority_id != scheme.overview.authority_id:
abort(403)

form = ChangeMilestoneDatesForm.from_domain(scheme.milestones)
form = ChangeMilestoneDatesForm.from_domain(scheme)

if not form.validate():
return milestones_form(scheme_id)

form.update_domain(scheme.milestones, clock.now)
form.update_domain(scheme, clock.now)
schemes.update(scheme)

return redirect(url_for("schemes.get", scheme_id=scheme_id))
Expand Down
68 changes: 31 additions & 37 deletions schemes/views/templates/scheme/milestones.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,37 @@ <h1 class="govuk-heading-l govuk-!-margin-bottom-3">
<form method="post" action="{{ url_for('schemes.milestones', scheme_id=id) }}" aria-label="Change milestone dates">
{{ form.csrf_token }}

{{ milestone(form.feasibility_design_completed) }}
{{ milestone(form.preliminary_design_completed) }}
{{ milestone(form.detailed_design_completed) }}
{{ milestone(form.construction_started) }}
{{ milestone(form.construction_completed) }}
{% for field in form if not field == form.csrf_token %}
<h2 class="govuk-heading-m">{{ field.label.text }}</h2>
<div class="govuk-grid-row">
<div class="govuk-grid-column-one-half">
{% set legendHtml %}
<span class="govuk-visually-hidden">{{ field.label.text }}</span> Planned date
{% endset %}
{{ field.planned(params={
"fieldset": {
"describedBy": "date-hint",
"legend": {
"html": legendHtml
}
}
}) }}
</div>
<div class="govuk-grid-column-one-half">
{% set legendHtml %}
<span class="govuk-visually-hidden">{{ field.label.text }}</span> Actual date
{% endset %}
{{ field.actual(params={
"fieldset": {
"describedBy": "date-hint",
"legend": {
"html": legendHtml
}
}
}) }}
</div>
</div>
{% endfor %}

<div class="govuk-button-group">
{{ govukButton({
Expand All @@ -62,35 +88,3 @@ <h1 class="govuk-heading-l govuk-!-margin-bottom-3">
</div>
</div>
{% endblock %}

{% macro milestone(field) -%}
<h2 class="govuk-heading-m">{{ field.label.text }}</h2>
<div class="govuk-grid-row">
<div class="govuk-grid-column-one-half">
{% set legendHtml %}
<span class="govuk-visually-hidden">{{ field.label.text }}</span> Planned date
{% endset %}
{{ field.planned(params={
"fieldset": {
"describedBy": "date-hint",
"legend": {
"html": legendHtml
}
}
}) }}
</div>
<div class="govuk-grid-column-one-half">
{% set legendHtml %}
<span class="govuk-visually-hidden">{{ field.label.text }}</span> Actual date
{% endset %}
{{ field.actual(params={
"fieldset": {
"describedBy": "date-hint",
"legend": {
"html": legendHtml
}
}
}) }}
</div>
</div>
{%- endmacro %}
4 changes: 3 additions & 1 deletion tests/integration/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,13 +445,15 @@ def __init__(self, form: Tag):
self.preliminary_design_completed_actual = DateComponent(
one(form.select("fieldset:has(legend:-soup-contains('Preliminary design completed Actual date'))"))
)
self.detailed_design_completed_heading = one(
form.select("h2:-soup-contains('Detailed design completed')")
).string
self.detailed_design_completed_planned = DateComponent(
one(form.select("fieldset:has(legend:-soup-contains('Detailed design completed Planned date'))"))
)
self.detailed_design_completed_actual = DateComponent(
one(form.select("fieldset:has(legend:-soup-contains('Detailed design completed Actual date'))"))
)
self.construction_started_heading = one(form.select("h2:-soup-contains('Construction started')")).string
self.construction_started_planned = DateComponent(
one(form.select("fieldset:has(legend:-soup-contains('Construction started Planned date'))"))
)
Expand Down
40 changes: 23 additions & 17 deletions tests/integration/test_scheme_milestones.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,11 @@ def test_milestones_form_shows_scheme(self, schemes: SchemeRepository, client: F
assert change_milestone_dates_page.heading and change_milestone_dates_page.heading.caption == "Wirral Package"

def test_milestones_form_shows_fields(self, schemes: SchemeRepository, client: FlaskClient) -> None:
schemes.add(build_scheme(id_=1, reference="ATE00001", name="Wirral Package", authority_id=1))
schemes.add(
build_scheme(
id_=1, reference="ATE00001", name="Wirral Package", authority_id=1, type_=SchemeType.CONSTRUCTION
)
)

change_milestone_dates_page = ChangeMilestoneDatesPage.open(client, id_=1)

Expand Down Expand Up @@ -263,15 +267,15 @@ def test_milestones_form_shows_milestone_heading(self, schemes: SchemeRepository

change_milestone_dates_page = ChangeMilestoneDatesPage.open(client, id_=1)

assert change_milestone_dates_page.form.construction_started_heading == "Construction started"
assert change_milestone_dates_page.form.detailed_design_completed_heading == "Detailed design completed"

def test_milestones_form_shows_date(self, schemes: SchemeRepository, client: FlaskClient) -> None:
scheme = build_scheme(id_=1, reference="ATE00001", name="Wirral Package", authority_id=1)
scheme.milestones.update_milestones(
MilestoneRevision(
id_=1,
effective=DateRange(datetime(2020, 1, 1, 12), None),
milestone=Milestone.CONSTRUCTION_STARTED,
milestone=Milestone.DETAILED_DESIGN_COMPLETED,
observation_type=ObservationType.ACTUAL,
status_date=date(2020, 1, 2),
source=DataSource.ATF4_BID,
Expand All @@ -281,7 +285,7 @@ def test_milestones_form_shows_date(self, schemes: SchemeRepository, client: Fla

change_milestone_dates_page = ChangeMilestoneDatesPage.open(client, id_=1)

assert change_milestone_dates_page.form.construction_started_actual.value == "2 1 2020"
assert change_milestone_dates_page.form.detailed_design_completed_actual.value == "2 1 2020"

def test_milestones_form_shows_confirm(self, schemes: SchemeRepository, client: FlaskClient) -> None:
schemes.add(build_scheme(id_=1, reference="ATE00001", name="Wirral Package", authority_id=1))
Expand Down Expand Up @@ -332,16 +336,17 @@ def test_cannot_milestones_form_when_not_updateable_scheme(

assert not_found_page.is_visible and not_found_page.is_not_found

@pytest.mark.parametrize("scheme_type", [SchemeType.DEVELOPMENT, SchemeType.CONSTRUCTION])
def test_milestones_updates_milestones(
self, clock: Clock, schemes: SchemeRepository, client: FlaskClient, csrf_token: str
self, clock: Clock, schemes: SchemeRepository, client: FlaskClient, csrf_token: str, scheme_type: SchemeType
) -> None:
clock.now = datetime(2020, 2, 1, 13)
scheme = build_scheme(id_=1, reference="ATE00001", name="Wirral Package", authority_id=1)
scheme = build_scheme(id_=1, reference="ATE00001", name="Wirral Package", authority_id=1, type_=scheme_type)
scheme.milestones.update_milestones(
MilestoneRevision(
id_=1,
effective=DateRange(datetime(2020, 1, 1, 12), None),
milestone=Milestone.CONSTRUCTION_STARTED,
milestone=Milestone.DETAILED_DESIGN_COMPLETED,
observation_type=ObservationType.ACTUAL,
status_date=date(2020, 1, 2),
source=DataSource.ATF4_BID,
Expand All @@ -350,7 +355,8 @@ def test_milestones_updates_milestones(
schemes.add(scheme)

client.post(
"/schemes/1/milestones", data={"csrf_token": csrf_token, "construction_started-actual": ["3", "1", "2020"]}
"/schemes/1/milestones",
data={"csrf_token": csrf_token, "detailed_design_completed-actual": ["3", "1", "2020"]},
)

actual_scheme = schemes.get(1)
Expand All @@ -361,7 +367,7 @@ def test_milestones_updates_milestones(
assert milestone_revision1.id == 1 and milestone_revision1.effective.date_to == datetime(2020, 2, 1, 13)
assert (
milestone_revision2.effective == DateRange(datetime(2020, 2, 1, 13), None)
and milestone_revision2.milestone == Milestone.CONSTRUCTION_STARTED
and milestone_revision2.milestone == Milestone.DETAILED_DESIGN_COMPLETED
and milestone_revision2.observation_type == ObservationType.ACTUAL
and milestone_revision2.status_date == date(2020, 1, 3)
and milestone_revision2.source == DataSource.AUTHORITY_UPDATE
Expand All @@ -382,7 +388,7 @@ def test_cannot_milestones_when_error(
MilestoneRevision(
id_=1,
effective=DateRange(datetime(2020, 1, 1, 12), None),
milestone=Milestone.CONSTRUCTION_STARTED,
milestone=Milestone.DETAILED_DESIGN_COMPLETED,
observation_type=ObservationType.ACTUAL,
status_date=date(2020, 1, 2),
source=DataSource.ATF4_BID,
Expand All @@ -394,7 +400,7 @@ def test_cannot_milestones_when_error(
client.post(
"/schemes/1/milestones",
data=self.empty_change_milestone_dates_form()
| {"csrf_token": csrf_token, "construction_started-actual": ["x", "x", "x"]},
| {"csrf_token": csrf_token, "detailed_design_completed-actual": ["x", "x", "x"]},
)
)

Expand All @@ -403,13 +409,13 @@ def test_cannot_milestones_when_error(
== "Error: Change milestone dates - Update your capital schemes - Active Travel England - GOV.UK"
)
assert change_milestone_dates_page.errors and list(change_milestone_dates_page.errors) == [
"Construction started actual date must be a real date"
"Detailed design completed actual date must be a real date"
]
assert (
change_milestone_dates_page.form.construction_started_actual.is_errored
and change_milestone_dates_page.form.construction_started_actual.error
== "Error: Construction started actual date must be a real date"
and change_milestone_dates_page.form.construction_started_actual.value == "x x x"
change_milestone_dates_page.form.detailed_design_completed_actual.is_errored
and change_milestone_dates_page.form.detailed_design_completed_actual.error
== "Error: Detailed design completed actual date must be a real date"
and change_milestone_dates_page.form.detailed_design_completed_actual.value == "x x x"
)
actual_scheme = schemes.get(1)
assert actual_scheme
Expand All @@ -418,7 +424,7 @@ def test_cannot_milestones_when_error(
assert (
milestone_revision1.id == 1
and milestone_revision1.effective == DateRange(datetime(2020, 1, 1, 12), None)
and milestone_revision1.milestone == Milestone.CONSTRUCTION_STARTED
and milestone_revision1.milestone == Milestone.DETAILED_DESIGN_COMPLETED
and milestone_revision1.observation_type == ObservationType.ACTUAL
and milestone_revision1.status_date == date(2020, 1, 2)
and milestone_revision1.source == DataSource.ATF4_BID
Expand Down
Loading

0 comments on commit 4bf21a3

Please sign in to comment.