Skip to content

Commit 2874bc5

Browse files
Merge pull request #421 from City-of-Helsinki/IO-389-v2
feat: IO-389 Add project phases, phase details and suspension
2 parents 28fb0c4 + a08b7eb commit 2874bc5

38 files changed

Lines changed: 890 additions & 159 deletions

infraohjelmointi_api/admin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
admin.site.register(models.ProjectPhase)
1515
admin.site.register(models.ProjectPriority)
1616
admin.site.register(models.ConstructionPhase)
17-
admin.site.register(models.ConstructionPhaseDetail)
17+
admin.site.register(models.ProjectPhaseDetail)
1818
admin.site.register(models.ConstructionProcurementMethod)
1919
admin.site.register(models.StaraProcurementReason)
2020
admin.site.register(models.Note)
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""IO-389: ProjectPhaseDetail, suspension fields, new phases and details."""
2+
3+
from django.db import migrations, models
4+
from django.db.models import Max
5+
import django.db.models.deletion
6+
7+
8+
def populate_phases_and_details(apps, schema_editor):
9+
ProjectPhase = apps.get_model("infraohjelmointi_api", "ProjectPhase")
10+
ProjectPhaseDetail = apps.get_model("infraohjelmointi_api", "ProjectPhaseDetail")
11+
Project = apps.get_model("infraohjelmointi_api", "Project")
12+
13+
construction_phase = ProjectPhase.objects.filter(value="construction").first()
14+
if construction_phase:
15+
ProjectPhaseDetail.objects.filter(projectPhase__isnull=True).update(
16+
projectPhase=construction_phase
17+
)
18+
Project.objects.exclude(phase=construction_phase).filter(
19+
phaseDetail__isnull=False
20+
).update(phaseDetail=None)
21+
22+
for phase in ProjectPhase.objects.all():
23+
if phase.order is not None and phase.order != phase.index:
24+
phase.index = phase.order
25+
phase.save(update_fields=["index"])
26+
27+
construction_phase_obj = ProjectPhase.objects.filter(value="construction").first()
28+
if construction_phase_obj and construction_phase_obj.order is not None:
29+
insert_order = construction_phase_obj.order
30+
ProjectPhase.objects.filter(order__gte=insert_order).update(
31+
order=models.F("order") + 1,
32+
index=models.F("index") + 1,
33+
)
34+
ProjectPhase.objects.get_or_create(
35+
value="constructionPreparation",
36+
defaults={"order": insert_order, "index": insert_order},
37+
)
38+
else:
39+
max_order = ProjectPhase.objects.aggregate(Max("order"))["order__max"] or -1
40+
ProjectPhase.objects.get_or_create(
41+
value="constructionPreparation",
42+
defaults={"order": max_order + 1, "index": max_order + 1},
43+
)
44+
45+
max_order = ProjectPhase.objects.aggregate(Max("order"))["order__max"] or -1
46+
ProjectPhase.objects.get_or_create(
47+
value="suspended",
48+
defaults={"order": max_order + 1, "index": max_order + 1},
49+
)
50+
51+
phase_details_to_create = {
52+
"programming": [
53+
"waitingProjectManager",
54+
"waitingPlanningStart",
55+
],
56+
"draftInitiation": [
57+
"streetParkPlanDraft",
58+
],
59+
"draftApproval": [
60+
"streetParkPlanApproval",
61+
],
62+
"constructionPlan": [
63+
"constructionDesign",
64+
],
65+
"constructionWait": [
66+
"waitingFunding",
67+
],
68+
"constructionPreparation": [
69+
"movedToConstruction",
70+
"contractPreparation",
71+
],
72+
}
73+
74+
for phase_value, detail_values in phase_details_to_create.items():
75+
phase = ProjectPhase.objects.filter(value=phase_value).first()
76+
if not phase:
77+
continue
78+
for detail_value in detail_values:
79+
ProjectPhaseDetail.objects.get_or_create(
80+
value=detail_value,
81+
projectPhase=phase,
82+
)
83+
84+
85+
def reverse_populate(apps, schema_editor):
86+
ProjectPhaseDetail = apps.get_model("infraohjelmointi_api", "ProjectPhaseDetail")
87+
ProjectPhase = apps.get_model("infraohjelmointi_api", "ProjectPhase")
88+
Project = apps.get_model("infraohjelmointi_api", "Project")
89+
90+
new_detail_values = [
91+
"waitingProjectManager", "waitingPlanningStart",
92+
"streetParkPlanDraft", "streetParkPlanApproval",
93+
"constructionDesign", "waitingFunding",
94+
"movedToConstruction", "contractPreparation",
95+
]
96+
# Clear phaseDetail FK on projects before deleting the detail rows (DO_NOTHING won't cascade)
97+
Project.objects.filter(phaseDetail__value__in=new_detail_values).update(phaseDetail=None)
98+
ProjectPhaseDetail.objects.filter(value__in=new_detail_values).delete()
99+
100+
phases_to_delete = ["constructionPreparation", "suspended"]
101+
# Move projects off phases that are about to be deleted
102+
fallback_phase = ProjectPhase.objects.filter(value="proposal").first()
103+
if fallback_phase:
104+
Project.objects.filter(phase__value__in=phases_to_delete).update(
105+
phase=fallback_phase
106+
)
107+
# Clear suspendedFromPhase references
108+
Project.objects.filter(suspendedFromPhase__value__in=phases_to_delete).update(
109+
suspendedFromPhase=None
110+
)
111+
ProjectPhase.objects.filter(value__in=phases_to_delete).delete()
112+
113+
114+
class Migration(migrations.Migration):
115+
dependencies = [
116+
("infraohjelmointi_api", "0096_alter_budgetoverrunreason_options_and_more"),
117+
]
118+
119+
operations = [
120+
# 1. Rename model
121+
migrations.RenameModel(
122+
old_name="ConstructionPhaseDetail",
123+
new_name="ProjectPhaseDetail",
124+
),
125+
# 2. Increase value max_length
126+
migrations.AlterField(
127+
model_name="projectphasedetail",
128+
name="value",
129+
field=models.CharField(max_length=100),
130+
),
131+
# 3. Add projectPhase FK (nullable initially for data migration)
132+
migrations.AddField(
133+
model_name="projectphasedetail",
134+
name="projectPhase",
135+
field=models.ForeignKey(
136+
null=True,
137+
on_delete=django.db.models.deletion.CASCADE,
138+
related_name="phaseDetails",
139+
to="infraohjelmointi_api.projectphase",
140+
),
141+
),
142+
# 4. Rename FK on Project
143+
migrations.RenameField(
144+
model_name="project",
145+
old_name="constructionPhaseDetail",
146+
new_name="phaseDetail",
147+
),
148+
# 5. Add suspension fields on Project
149+
migrations.AddField(
150+
model_name="project",
151+
name="suspendedDate",
152+
field=models.DateField(blank=True, null=True),
153+
),
154+
migrations.AddField(
155+
model_name="project",
156+
name="suspendedFromPhase",
157+
field=models.ForeignKey(
158+
blank=True,
159+
null=True,
160+
on_delete=django.db.models.deletion.DO_NOTHING,
161+
related_name="suspended_projects",
162+
to="infraohjelmointi_api.projectphase",
163+
),
164+
),
165+
# 6. Data migration
166+
migrations.RunPython(populate_phases_and_details, reverse_populate),
167+
# 7. Make projectPhase required
168+
migrations.AlterField(
169+
model_name="projectphasedetail",
170+
name="projectPhase",
171+
field=models.ForeignKey(
172+
on_delete=django.db.models.deletion.CASCADE,
173+
related_name="phaseDetails",
174+
to="infraohjelmointi_api.projectphase",
175+
),
176+
),
177+
]

infraohjelmointi_api/models/Project.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from .ProjectTypeQualifier import ProjectTypeQualifier
1313
from .ProjectPhase import ProjectPhase
1414
from .ProjectPriority import ProjectPriority
15-
from .ConstructionPhaseDetail import ConstructionPhaseDetail
15+
from .ProjectPhaseDetail import ProjectPhaseDetail
1616
from .ConstructionProcurementMethod import ConstructionProcurementMethod
1717
from .StaraProcurementReason import StaraProcurementReason
1818
from .ProjectCategory import ProjectCategory
@@ -113,8 +113,16 @@ def get_default_projectPriority():
113113
Person, related_name="favourite", blank=True
114114
)
115115
programmed = models.BooleanField(default=False)
116-
constructionPhaseDetail = models.ForeignKey(
117-
ConstructionPhaseDetail, on_delete=models.DO_NOTHING, null=True, blank=True
116+
phaseDetail = models.ForeignKey(
117+
ProjectPhaseDetail, on_delete=models.DO_NOTHING, null=True, blank=True
118+
)
119+
suspendedDate = models.DateField(null=True, blank=True)
120+
suspendedFromPhase = models.ForeignKey(
121+
ProjectPhase,
122+
on_delete=models.DO_NOTHING,
123+
null=True,
124+
blank=True,
125+
related_name="suspended_projects",
118126
)
119127
constructionProcurementMethod = models.ForeignKey(
120128
ConstructionProcurementMethod, on_delete=models.DO_NOTHING, null=True, blank=True

infraohjelmointi_api/models/ConstructionPhaseDetail.py renamed to infraohjelmointi_api/models/ProjectPhaseDetail.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,14 @@
33
from .OrderedLookupModel import OrderedLookupModel
44

55

6-
class ConstructionPhaseDetail(OrderedLookupModel):
6+
class ProjectPhaseDetail(OrderedLookupModel):
77
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
8-
value = models.CharField(max_length=30)
8+
value = models.CharField(max_length=100)
9+
projectPhase = models.ForeignKey(
10+
"ProjectPhase", on_delete=models.CASCADE, related_name="phaseDetails"
11+
)
912
createdDate = models.DateTimeField(auto_now_add=True, blank=True)
1013
updatedDate = models.DateTimeField(auto_now=True, blank=True)
14+
15+
def __str__(self):
16+
return self.value

infraohjelmointi_api/models/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from .ProjectPriority import ProjectPriority
1111
from .TaskStatus import TaskStatus
1212
from .Note import Note
13-
from .ConstructionPhaseDetail import ConstructionPhaseDetail
13+
from .ProjectPhaseDetail import ProjectPhaseDetail
1414
from .ConstructionProcurementMethod import ConstructionProcurementMethod
1515
from .StaraProcurementReason import StaraProcurementReason
1616
from .ProjectCategory import ProjectCategory

infraohjelmointi_api/serializers/ConstructionPhaseDetailSerializer.py

Lines changed: 0 additions & 8 deletions
This file was deleted.

infraohjelmointi_api/serializers/ProjectCreateSerializer.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818
)
1919
from infraohjelmointi_api.serializers.ProjectProgrammerSerializer import ProjectProgrammerSerializer
2020
from infraohjelmointi_api.serializers.BudgetItemSerializer import BudgetItemSerializer
21-
from infraohjelmointi_api.serializers.ConstructionPhaseDetailSerializer import (
22-
ConstructionPhaseDetailSerializer,
21+
from infraohjelmointi_api.serializers.ProjectPhaseDetailSerializer import (
22+
ProjectPhaseDetailSerializer,
2323
)
2424
from infraohjelmointi_api.serializers.ConstructionProcurementMethodSerializer import (
2525
ConstructionProcurementMethodSerializer,
@@ -73,6 +73,7 @@
7373
ProgrammedValidator,
7474
ProjectClassValidator,
7575
ProjectLocationValidator,
76+
ProjectPhaseDetailValidator,
7677
ProjectPhaseValidator,
7778
VisibilityEndValidator,
7879
VisibilityStartValidator,
@@ -206,7 +207,6 @@ class ProjectCreateSerializer(ProjectWithFinancesSerializer):
206207
class Meta(BaseMeta):
207208
model = Project
208209
list_serializer_class = UpdateListSerializer
209-
# removed constructionPhaseDetail validator due to inconsistencies in imported data
210210
validators = [
211211
EstPlanningStartValidator(),
212212
EstPlanningEndValidator(),
@@ -220,6 +220,7 @@ class Meta(BaseMeta):
220220
ProjectClassValidator(),
221221
ProjectLocationValidator(),
222222
ProjectPhaseValidator(),
223+
ProjectPhaseDetailValidator(),
223224
ConstructionEndYearValidator(),
224225
PlanningStartYearValidator(),
225226
ProgrammedValidator(),
@@ -351,7 +352,8 @@ def to_representation(self, instance):
351352
rep["otherPersons"] = self._serialize_optional_field(instance.otherPersons, PersonSerializer, many=True)
352353
rep["category"] = self._serialize_optional_field(instance.category, ProjectCategorySerializer)
353354
rep["riskAssessment"] = self._serialize_optional_field(instance.riskAssessment, ProjectRiskSerializer)
354-
rep["constructionPhaseDetail"] = self._serialize_optional_field(instance.constructionPhaseDetail, ConstructionPhaseDetailSerializer)
355+
rep["phaseDetail"] = self._serialize_optional_field(instance.phaseDetail, ProjectPhaseDetailSerializer)
356+
rep["suspendedFromPhase"] = self._serialize_optional_field(instance.suspendedFromPhase, ProjectPhaseSerializer)
355357
rep["constructionProcurementMethod"] = self._serialize_optional_field(instance.constructionProcurementMethod, ConstructionProcurementMethodSerializer)
356358
rep["staraProcurementReason"] = self._serialize_optional_field(instance.staraProcurementReason, StaraProcurementReasonSerializer)
357359
rep["constructionPhase"] = self._serialize_optional_field(instance.constructionPhase, ConstructionPhaseSerializer)

infraohjelmointi_api/serializers/ProjectGetSerializer.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
PWProjectResponseError,
1515
)
1616
from infraohjelmointi_api.serializers.BudgetItemSerializer import BudgetItemSerializer
17-
from infraohjelmointi_api.serializers.ConstructionPhaseDetailSerializer import (
18-
ConstructionPhaseDetailSerializer,
17+
from infraohjelmointi_api.serializers.ProjectPhaseDetailSerializer import (
18+
ProjectPhaseDetailSerializer,
1919
)
2020
from infraohjelmointi_api.serializers.ConstructionProcurementMethodSerializer import (
2121
ConstructionProcurementMethodSerializer,
@@ -88,7 +88,8 @@ class ProjectGetSerializer(DynamicFieldsModelSerializer, ProjectWithFinancesSeri
8888
frameEstPlanningStart = serializers.DateField(format="%d.%m.%Y")
8989
frameEstPlanningEnd = serializers.DateField(format="%d.%m.%Y")
9090
category = ProjectCategorySerializer(read_only=True)
91-
constructionPhaseDetail = ConstructionPhaseDetailSerializer(read_only=True)
91+
phaseDetail = ProjectPhaseDetailSerializer(read_only=True)
92+
suspendedFromPhase = ProjectPhaseSerializer(read_only=True)
9293
constructionProcurementMethod = ConstructionProcurementMethodSerializer(read_only=True)
9394
staraProcurementReason = StaraProcurementReasonSerializer(read_only=True)
9495
riskAssessment = ProjectRiskSerializer(read_only=True)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from infraohjelmointi_api.models import ProjectPhaseDetail
2+
from infraohjelmointi_api.serializers import BaseMeta
3+
from infraohjelmointi_api.serializers.ProjectPhaseSerializer import ProjectPhaseSerializer
4+
from rest_framework import serializers
5+
6+
7+
class ProjectPhaseDetailSerializer(serializers.ModelSerializer):
8+
projectPhase = ProjectPhaseSerializer(read_only=True)
9+
10+
class Meta(BaseMeta):
11+
model = ProjectPhaseDetail

infraohjelmointi_api/serializers/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from .BaseMeta import BaseMeta
22
from .BudgetItemSerializer import BudgetItemSerializer
3-
from .ConstructionPhaseDetailSerializer import ConstructionPhaseDetailSerializer
3+
from .ProjectPhaseDetailSerializer import ProjectPhaseDetailSerializer
44
from .ConstructionProcurementMethodSerializer import ConstructionProcurementMethodSerializer
55
from .StaraProcurementReasonSerializer import StaraProcurementReasonSerializer
66
from .ConstructionPhaseSerializer import ConstructionPhaseSerializer

0 commit comments

Comments
 (0)