Skip to content

Commit 8c44cbe

Browse files
authored
Merge pull request #150 from mapswipe/feat/project-asset-links
Attach AOI geometry to project
2 parents 03b4509 + 47247e6 commit 8c44cbe

File tree

13 files changed

+188
-62
lines changed

13 files changed

+188
-62
lines changed

apps/project/graphql/types/types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ class ProjectType(UserResourceTypeMixin, ProjectExportAssetTypeMixin, FirebasePu
157157
team: ContributorTeamType | None
158158
is_private: strawberry.auto
159159
required_results: strawberry.auto
160+
aoi_geometry_input_asset: ProjectAssetType | None
161+
project_type_specific_output_asset: ProjectAssetType | None
160162

161163
@strawberry_django.field(
162164
description="No. of unique contributors in this project",
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.2.5 on 2025-09-04 01:48
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('project', '0023_alter_projectasset_file'),
10+
]
11+
12+
operations = [
13+
migrations.RenameField(
14+
model_name='project',
15+
old_name='project_type_specific_output',
16+
new_name='project_type_specific_output_asset',
17+
),
18+
]
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated by Django 5.2.5 on 2025-09-04 02:48
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
from utils.common import recursively_find_value
6+
7+
8+
def set_aoi_asset(apps, schema_editor):
9+
Model = apps.get_model('project', 'Project')
10+
for obj in Model.cte_objects.all():
11+
aoi_id = recursively_find_value(obj.project_type_specifics, "aoi_geometry")
12+
if aoi_id:
13+
obj.aoi_geometry_input_asset = int(aoi_id)
14+
obj.save(update_fields=['aoi_geometry_input_asset'])
15+
16+
17+
class Migration(migrations.Migration):
18+
19+
dependencies = [
20+
('project', '0024_rename_project_type_specific_output_project_project_type_specific_output_asset'),
21+
]
22+
23+
operations = [
24+
migrations.AddField(
25+
model_name='project',
26+
name='aoi_geometry_input_asset',
27+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='project.projectasset'),
28+
),
29+
migrations.RunPython(set_aoi_asset, reverse_code=migrations.RunPython.noop),
30+
]

apps/project/models.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -337,8 +337,14 @@ class Project(UserResource, FirebasePushResource):
337337
# Also, used in SQL queries
338338
project_type_specifics = models.JSONField(blank=True, null=True)
339339

340-
# FIXME(tnagorra): Do we need to reference this to project table?
341-
project_type_specific_output = models.ForeignKey["ProjectAsset | None", "ProjectAsset | None"](
340+
aoi_geometry_input_asset = models.ForeignKey["ProjectAsset | None", "ProjectAsset | None"](
341+
"project.ProjectAsset",
342+
related_name="+",
343+
blank=True,
344+
null=True,
345+
on_delete=models.SET_NULL,
346+
)
347+
project_type_specific_output_asset = models.ForeignKey["ProjectAsset | None", "ProjectAsset | None"](
342348
"project.ProjectAsset",
343349
related_name="+",
344350
blank=True,
@@ -511,6 +517,7 @@ def input_type_enum(self):
511517

512518
# Type hints
513519
project_id: int
520+
id: int
514521

515522

516523
class ProjectTaskGroup(FirebasePushResource):

apps/project/serializers.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from apps.contributor.models import ContributorTeam
1616
from apps.project.firebase.push import FirebaseOrganizationPush
1717
from apps.tutorial.models import Tutorial
18-
from project_types.store import get_project_property
18+
from project_types.store import get_project_property, get_project_type_handler
1919
from utils.asset_types.models import AoiGeometryAssetProperty, ObjectImageAssetProperty
2020
from utils.common import clean_up_none_keys
2121
from utils.graphql.drf import handle_pydantic_validation_error
@@ -76,7 +76,6 @@ class ProjectUpdateSerializer(UserResourceSerializer[Project]):
7676
class Meta: # type: ignore[reportIncompatibleVariableOverride]
7777
model = Project
7878
fields = (
79-
"project_type",
8079
"topic",
8180
"region",
8281
"project_number",
@@ -156,14 +155,7 @@ def _validate_project_instruction(self, attrs: dict[str, typing.Any]):
156155
def _validate_project_type_specifics(self, attrs: dict[str, typing.Any]):
157156
assert self.instance is not None
158157

159-
project_type = attrs.get("project_type") or self.instance.project_type_enum
160-
if project_type is None:
161-
raise serializers.ValidationError(
162-
{
163-
"project_type": gettext("Project type is required."),
164-
},
165-
)
166-
158+
project_type = self.instance.project_type_enum
167159
project_type_label = ProjectTypeEnum.get_display(project_type)
168160

169161
project_property = get_project_property(project_type)
@@ -221,6 +213,18 @@ def validate(self, attrs: dict[str, typing.Any]):
221213
self._validate_project_type_specifics(attrs)
222214
return super().validate(attrs)
223215

216+
@typing.override
217+
def update(self, instance: Project, validated_data: dict[typing.Any, typing.Any]):
218+
proj = super().update(instance, validated_data)
219+
220+
# NOTE: We only need to attach aoi_geometry_input_asset if project_type_specifics is defined
221+
if proj.project_type_specifics:
222+
project_handler = get_project_type_handler(proj.project_type_enum)(proj)
223+
proj.aoi_geometry_input_asset = project_handler.get_aoi_geometry_asset()
224+
proj.save(update_fields=["aoi_geometry_input_asset"])
225+
226+
return proj
227+
224228

225229
# NOTE: Make sure this matches with the strawberry Input ./graphql/inputs.py
226230
class ProcessedProjectSerializer(UserResourceSerializer[Project]):

apps/project/tests/mutation_test.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,9 @@ class Mutation:
244244
tutorial {
245245
id
246246
}
247+
aoiGeometryInputAsset {
248+
id
249+
}
247250
}
248251
}
249252
}
@@ -685,7 +688,7 @@ def test_project_update(self, mock_requests):
685688
project_instruction="Buildings",
686689
additional_info_url="https://hi-there/about.html",
687690
description="The new **project** from hi-there.",
688-
project_type_specifics=None,
691+
project_type_specifics={},
689692
)
690693

691694
project_data = {
@@ -724,6 +727,7 @@ def test_project_update(self, mock_requests):
724727
clientId=latest_project.client_id,
725728
projectType=self.genum(ProjectTypeEnum.FIND),
726729
requestingOrganizationId=self.gID(latest_project.requesting_organization.pk),
730+
aoiGeometryInputAsset=None,
727731
requestingOrganization=dict(
728732
id=self.gID(latest_project.requesting_organization.pk),
729733
name=latest_project.requesting_organization.name,
@@ -915,6 +919,8 @@ def test_project_update(self, mock_requests):
915919

916920
latest_project.refresh_from_db()
917921
assert latest_project.image_id == int(image_asset["id"])
922+
assert latest_project.aoi_geometry_input_asset
923+
assert latest_project.aoi_geometry_input_asset.id == int(aoi_geometry_asset["id"])
918924
assert latest_project.project_type_specifics == {
919925
"zoom_level": 15,
920926
"aoi_geometry": aoi_geometry_asset["id"],
@@ -1336,6 +1342,8 @@ def test_project_compare(self, mock_requests):
13361342
assert latest_project.created_by_id == self.user.pk
13371343
assert latest_project.modified_by_id == self.user.pk
13381344
assert latest_project.image_id == int(image_asset["id"])
1345+
assert latest_project.aoi_geometry_input_asset
1346+
assert latest_project.aoi_geometry_input_asset.id == int(aoi_geometry_asset["id"])
13391347
assert latest_project.project_type_specifics == {
13401348
"aoi_geometry": aoi_geometry_asset["id"],
13411349
"zoom_level": 15,
@@ -1629,6 +1637,8 @@ def test_project_street(self, mock_requests):
16291637
assert latest_project.modified_by_id == self.user.pk
16301638
assert latest_project.image_id == int(image_asset["id"])
16311639
assert latest_project.project_type_specifics is not None
1640+
assert latest_project.aoi_geometry_input_asset
1641+
assert latest_project.aoi_geometry_input_asset.id == int(aoi_geometry_asset["id"])
16321642

16331643
street_project.StreetProjectProperty.model_validate(
16341644
latest_project.project_type_specifics,

project_types/base/project.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ def __init_subclass__(cls, **kwargs):
8686
super().__init_subclass__(**kwargs)
8787
cls._inheritance_checks()
8888

89+
def get_aoi_geometry_asset(self) -> ProjectAsset | None:
90+
return None
91+
8992
def analyze_groups(self):
9093
# Update number_of_tasks
9194
self.project.update_processing_status(Project.ProcessingStatus.ANALYZING_GROUPS_AND_TASK, True)

project_types/street/project.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,17 +71,23 @@ def __init__(self, project: Project):
7171
if typing.TYPE_CHECKING:
7272
assert project.project_type == ProjectTypeEnum.STREET, f"{type(self)} is defined for STREET"
7373

74+
@typing.override
75+
def get_aoi_geometry_asset(self) -> ProjectAsset | None:
76+
return ProjectAsset.usable_objects().get(
77+
id=int(self.project_type_specifics.aoi_geometry),
78+
type=ProjectAsset.Type.INPUT,
79+
input_type=ProjectAssetInputTypeEnum.AOI_GEOMETRY,
80+
project_id=self.project.pk,
81+
)
82+
7483
@typing.override
7584
def validate(self) -> Grouping[StreetFeature]:
7685
"""Validate project before creating groups."""
7786
self.project.update_processing_status(Project.ProcessingStatus.VALIDATING_GEOMETRY, True)
7887

79-
aoi_asset = ProjectAsset.usable_objects().get(
80-
id=self.project_type_specifics.aoi_geometry,
81-
type=AssetTypeEnum.INPUT,
82-
input_type=ProjectAssetInputTypeEnum.AOI_GEOMETRY,
83-
project_id=self.project.pk,
84-
)
88+
aoi_asset = self.project.aoi_geometry_input_asset
89+
if not aoi_asset:
90+
raise Exception("Could not find AOI geometry asset")
8591

8692
with aoi_asset.file.open() as aoi_file:
8793
aoi_geojson = json.loads(aoi_file.read())
@@ -203,8 +209,8 @@ def get_feature(task: ProjectTask):
203209
created_by=self.project.modified_by,
204210
modified_by=self.project.modified_by,
205211
)
206-
self.project.project_type_specific_output = asset
207-
self.project.save(update_fields=("project_type_specific_output",))
212+
self.project.project_type_specific_output_asset = asset
213+
self.project.save(update_fields=("project_type_specific_output_asset",))
208214

209215
@typing.override
210216
def get_max_time_spend_percentile(self) -> float:

project_types/tile_map_service/base/project.py

Lines changed: 15 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from django.conf import settings
99
from django.contrib.gis.geos import GEOSGeometry
1010
from django.core.files.base import ContentFile
11-
from pydantic import BaseModel, ValidationInfo, model_validator
11+
from pydantic import BaseModel
1212
from pyfirebase_mapswipe import extended_models as firebase_ext_models
1313
from pyfirebase_mapswipe import models as firebase_models
1414
from ulid import ULID
@@ -39,29 +39,6 @@ class TileMapServiceProjectProperty(base_project.BaseProjectProperty):
3939
tile_server_property: RasterTileServerConfig
4040
aoi_geometry: custom_fields.PydanticId
4141

42-
@model_validator(mode="after")
43-
def check_aoi_geometry_exists(self, info: ValidationInfo) -> typing.Self:
44-
if not isinstance(info.context, dict):
45-
# Skipping validation in case context is not defined
46-
return self
47-
48-
project_id = info.context.get("project_id")
49-
asset_exists = (
50-
ProjectAsset.usable_objects()
51-
.filter(
52-
id=self.aoi_geometry,
53-
type=AssetTypeEnum.INPUT,
54-
input_type=ProjectAssetInputTypeEnum.AOI_GEOMETRY,
55-
project_id=project_id,
56-
)
57-
.exists()
58-
)
59-
60-
# FIXME(tnagorra): Handle error
61-
if not asset_exists:
62-
raise ValueError(f"ProjectAsset with id {self.aoi_geometry} is invalid or does not exist.")
63-
return self
64-
6542

6643
class TileMapServiceProjectTaskGroupProperty(base_project.BaseProjectTaskGroupProperty):
6744
x_max: int
@@ -92,6 +69,15 @@ class TileMapServiceBaseProject[
9269
def __init__(self, project: Project):
9370
super().__init__(project)
9471

72+
@typing.override
73+
def get_aoi_geometry_asset(self) -> ProjectAsset | None:
74+
return ProjectAsset.usable_objects().get(
75+
id=int(self.project_type_specifics.aoi_geometry),
76+
type=ProjectAsset.Type.INPUT,
77+
input_type=ProjectAssetInputTypeEnum.AOI_GEOMETRY,
78+
project_id=self.project.pk,
79+
)
80+
9581
@typing.override
9682
def post_create_groups(self):
9783
# NOTE: Create a geojson from the tasks (useful for tutorial creation)
@@ -142,8 +128,8 @@ def get_feature(task: ProjectTask):
142128
created_by=self.project.modified_by,
143129
modified_by=self.project.modified_by,
144130
)
145-
self.project.project_type_specific_output = asset
146-
self.project.save(update_fields=("project_type_specific_output",))
131+
self.project.project_type_specific_output_asset = asset
132+
self.project.save(update_fields=("project_type_specific_output_asset",))
147133

148134
# TODO(thenav56): Calculate centroid, bounding box, etc.
149135
# TODO(thenav56): Calculate: total_area, time_spent_max_allowed
@@ -222,13 +208,9 @@ def validate(self):
222208
"""Validate project before creating groups."""
223209
self.project.update_processing_status(Project.ProcessingStatus.VALIDATING_GEOMETRY, True)
224210

225-
aoi_asset = ProjectAsset.usable_objects().get(
226-
id=self.project_type_specifics.aoi_geometry,
227-
type=AssetTypeEnum.INPUT,
228-
input_type=ProjectAssetInputTypeEnum.AOI_GEOMETRY,
229-
mimetype=AssetMimetypeEnum.GEOJSON,
230-
project_id=self.project.pk,
231-
)
211+
aoi_asset = self.project.aoi_geometry_input_asset
212+
if not aoi_asset:
213+
raise Exception("Could not find AOI geometry asset")
232214

233215
extension = Path(aoi_asset.file.name).suffix
234216
with tempfile.NamedTemporaryFile(suffix=extension, dir=settings.TEMP_DIR) as temp_file:

project_types/validate/project.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,20 @@ class ValidateProject(
124124
def __init__(self, project: Project):
125125
super().__init__(project)
126126

127+
@typing.override
128+
def get_aoi_geometry_asset(self) -> ProjectAsset | None:
129+
if self.project_type_specifics.object_source.source_type != ValidateObjectSourceTypeEnum.AOI_GEOJSON_FILE:
130+
return None
131+
if not self.project_type_specifics.object_source.aoi_geometry:
132+
return None
133+
134+
return ProjectAsset.usable_objects().get(
135+
id=int(self.project_type_specifics.object_source.aoi_geometry),
136+
type=ProjectAsset.Type.INPUT,
137+
input_type=ProjectAssetInputTypeEnum.AOI_GEOMETRY,
138+
project_id=self.project.pk,
139+
)
140+
127141
def _process_polygons(self, geojson_data: dict[str, Any]) -> list[ValidFeature]:
128142
"""We only want polygon and multipolygon features."""
129143
try:
@@ -164,12 +178,9 @@ def _validate_aoi_geojson_file(self):
164178
if self.project_type_specifics.object_source.ohsome_filter is None:
165179
raise Exception("Ohsome filter is missing for validate geojson file")
166180

167-
aoi_asset = ProjectAsset.usable_objects().get(
168-
id=self.project_type_specifics.object_source.aoi_geometry,
169-
type=AssetTypeEnum.INPUT,
170-
input_type=ProjectAssetInputTypeEnum.AOI_GEOMETRY,
171-
project_id=self.project.pk,
172-
)
181+
aoi_asset = self.project.aoi_geometry_input_asset
182+
if not aoi_asset:
183+
raise Exception("Could not find AOI geometry asset")
173184

174185
with aoi_asset.file.open() as aoi_file:
175186
aoi_geojson = json.loads(aoi_file.read())
@@ -355,8 +366,8 @@ def get_feature(task: ProjectTask):
355366
created_by=self.project.modified_by,
356367
modified_by=self.project.modified_by,
357368
)
358-
self.project.project_type_specific_output = asset
359-
self.project.save(update_fields=("project_type_specific_output",))
369+
self.project.project_type_specific_output_asset = asset
370+
self.project.save(update_fields=("project_type_specific_output_asset",))
360371

361372
@typing.override
362373
def get_max_time_spend_percentile(self) -> float:

0 commit comments

Comments
 (0)