diff --git a/apps/common/firebase.py b/apps/common/firebase.py index 324a5ae3..4a1ca4af 100644 --- a/apps/common/firebase.py +++ b/apps/common/firebase.py @@ -100,7 +100,7 @@ class RelaxedModel(self.firebase_model_class): # NOTE: we want to ignore extra fields from firebase valid_fb_model = RelaxedModel.model_validate(obj=fb_model) - valid_fb_model = self.firebase_model_class.model_validate(valid_fb_model) + valid_fb_model = self.firebase_model_class.model_validate(obj=valid_fb_model) self.handle_object_update_on_firebase(model_obj, valid_fb_model, model_ref) except InvalidObjectPushException: diff --git a/apps/tutorial/migrations/0010_alter_tutorial_firebase_id.py b/apps/tutorial/migrations/0010_alter_tutorial_firebase_id.py new file mode 100644 index 00000000..5867ffad --- /dev/null +++ b/apps/tutorial/migrations/0010_alter_tutorial_firebase_id.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.4 on 2025-08-03 16:00 + +import apps.tutorial.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tutorial', '0009_merge_20250801_1651'), + ] + + operations = [ + migrations.AlterField( + model_name='tutorial', + name='firebase_id', + field=models.CharField(default=apps.tutorial.models.generate_tutorial_firebase_id, max_length=40, unique=True), + ), + ] diff --git a/apps/tutorial/models.py b/apps/tutorial/models.py index c8b13f33..3397cc13 100644 --- a/apps/tutorial/models.py +++ b/apps/tutorial/models.py @@ -48,6 +48,10 @@ class TutorialStatusEnum(models.IntegerChoices): """ +def generate_tutorial_firebase_id(): + return f"tutorial_{ulid.ULID()}" + + class Tutorial(UserResource, FirebasePushResource): # type: ignore[reportIncompatibleVariableOverride] Status = TutorialStatusEnum @@ -65,6 +69,8 @@ class Tutorial(UserResource, FirebasePushResource): # type: ignore[reportIncomp default=TutorialStatusEnum.DRAFT, ) + firebase_id = models.CharField(max_length=40, unique=True, default=generate_tutorial_firebase_id) + # Type hints project_id: int scenarios: RelatedManager["TutorialScenarioPage"] @@ -121,6 +127,22 @@ class Meta: # type: ignore[reportIncompatibleVariableOverride] models.UniqueConstraint(fields=["tutorial", "scenario_page_number"], name="unique_scenario_on_tutorials"), ] + @property + def instructions_icon_enum(self) -> IconEnum: + return IconEnum(self.instructions_icon) + + @property + def hint_icon_enum(self) -> IconEnum | None: + if self.hint_icon: + return IconEnum(self.hint_icon) + return None + + @property + def success_icon_enum(self) -> IconEnum | None: + if self.success_icon: + return IconEnum(self.success_icon) + return None + @typing.override def __str__(self): return self.scenario_page_number diff --git a/firebase b/firebase index 61eafe83..572fdbed 160000 --- a/firebase +++ b/firebase @@ -1 +1 @@ -Subproject commit 61eafe8316f80138ab00f9179306762796c94dc7 +Subproject commit 572fdbedcd5db08acca3fc5c2099aa44957b3a10 diff --git a/main/config.py b/main/config.py index ad8b7920..84ec0cc5 100644 --- a/main/config.py +++ b/main/config.py @@ -44,27 +44,39 @@ def v2(): return "/v2" @staticmethod - def project(project_id: str | int): + def project(project_id: str): return f"/v2/projects/{project_id}" @staticmethod - def project_groups(project_id: str | int): + def project_groups(project_id: str): return f"/v2/groups/{project_id}/" @staticmethod - def project_tasks(project_id: str | int): + def project_tasks(project_id: str): return f"/v2/tasks/{project_id}/" @staticmethod - def organization(organization_id: str | int): + def tutorial(tutorial_id: str): + return f"/v2/projects/{tutorial_id}" + + @staticmethod + def tutorial_groups(tutorial_id: str): + return f"/v2/groups/{tutorial_id}/" + + @staticmethod + def tutorial_tasks(tutorial_id: str): + return f"/v2/tasks/{tutorial_id}/" + + @staticmethod + def organization(organization_id: str): return f"/v2/organisation/{organization_id}/" @staticmethod - def contributor_team(contributor_team_id: str | int): + def contributor_team(contributor_team_id: str): return f"/v2/teams/{contributor_team_id}/" @staticmethod - def contributor_user(user_id: str | int): + def contributor_user(user_id: str): return f"/v2/users/{user_id}" diff --git a/project_types/base/project.py b/project_types/base/project.py index f024569b..7a57ce0e 100644 --- a/project_types/base/project.py +++ b/project_types/base/project.py @@ -5,7 +5,7 @@ from django.contrib.gis.db.models.functions import Area from django.db import models from firebase_admin.db import Reference as FbReference -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from pyfirebase_mapswipe import extended_models as firebase_ext_models from pyfirebase_mapswipe import models as firebase_models from pyfirebase_mapswipe import utils as firebase_utils @@ -251,11 +251,11 @@ def handle_new_project_on_firebase(self, project_ref: FbReference): # NOTE: We are not reading data from group_ref as it's an expensive operation # FIXME(tnagorra): We need to check if the key exists later group_ref = self.firebase_helper.ref( - Config.FirebaseKeys.project_groups(self.project.id), + Config.FirebaseKeys.project_groups(self.project.firebase_id), ) # FIXME(tnagorra): We need to check if the key exists later task_ref = self.firebase_helper.ref( - Config.FirebaseKeys.project_tasks(self.project.id), + Config.FirebaseKeys.project_tasks(self.project.firebase_id), ) # FIXME: If taskId is defined, should be private_inactive @@ -353,7 +353,6 @@ def handle_project_update_on_firebase(self, project_ref: FbReference, fb_project ), ) - # FIXME(rup): use common method push_django_to_firebase() def push_to_firebase(self): if self.project.firebase_push_status_enum != FirebasePushStatusEnum.PENDING: logger.warning("%s - push_to_firebase called when push is not required", self.project.pk) @@ -363,7 +362,7 @@ def push_to_firebase(self): try: project_ref = self.firebase_helper.ref( - Config.FirebaseKeys.project(self.project.id), + Config.FirebaseKeys.project(self.project.firebase_id), ) fb_project: typing.Any = project_ref.get() @@ -382,7 +381,14 @@ def push_to_firebase(self): extra=log_extra({"project": self.project.pk}), ) raise InvalidProjectPushException - valid_project = firebase_ext_models.FbProject.model_validate(obj=fb_project) + + class RelaxedModel(firebase_ext_models.FbProject): + model_config = ConfigDict(extra="ignore") + + # NOTE: we want to ignore extra fields from firebase + valid_project = RelaxedModel.model_validate(obj=fb_project) + valid_project = firebase_ext_models.FbProject.model_validate(obj=valid_project) + self.handle_project_update_on_firebase(project_ref, valid_project) except InvalidProjectPushException: self.project.update_firebase_push_status(FirebasePushStatusEnum.FAILED) diff --git a/project_types/base/tutorial.py b/project_types/base/tutorial.py index aed303e3..54149c1d 100644 --- a/project_types/base/tutorial.py +++ b/project_types/base/tutorial.py @@ -1,9 +1,315 @@ import logging -from abc import ABC +import typing +from abc import ABC, abstractmethod -from pydantic import BaseModel +from firebase_admin.db import Reference as FbReference +from pydantic import BaseModel, ConfigDict +from pyfirebase_mapswipe import models as firebase_models +from pyfirebase_mapswipe import utils as firebase_utils + +from apps.common.models import FirebasePushStatusEnum, IconEnum +from apps.tutorial.models import ( + Tutorial, + TutorialInformationPageBlockTypeEnum, + TutorialTask, +) +from main.config import Config +from main.logging import log_extra +from project_types.firebase import block_type_enum_to_firebase + +from .project import BaseProjectProperty logger = logging.getLogger(__name__) +class InvalidTutorialPushException(Exception): ... + + class BaseTutorialTaskProperty(BaseModel, ABC): ... + + +class BaseTutorial[ + ProjectPropertyTypeVar: BaseProjectProperty, + TutorialTaskPropertyTypeVar: BaseTutorialTaskProperty, +](ABC): + project_property_class: type[ProjectPropertyTypeVar] + tutorial_task_property_class: type[TutorialTaskPropertyTypeVar] + + def __init__(self, tutorial: Tutorial): + self.tutorial = tutorial + self.project_type_specifics = self.project_property_class(**self.tutorial.project.project_type_specifics) + + self.firebase_helper = Config.FIREBASE_HELPER + + @classmethod + def _inheritance_checks(cls): + # FIXME(tnagorra): Find a better way to skip for base classes + if cls.__name__.endswith("BaseTutorial"): + # Skip check for the abstract class + return + + missing_fields = [] + for attr_name in [ + "project_property_class", + "tutorial_task_property_class", + ]: + if getattr(cls, attr_name, None) is None: + missing_fields.append(attr_name) + + if missing_fields: + raise NotImplementedError(f"Please define {','.join(missing_fields)} for {cls}") + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + cls._inheritance_checks() + + def get_tutorial_group_key(self) -> int: + return 101 + + def get_task_sort_keys(self, existing_values: list[str]) -> list[str]: + return existing_values + + def handle_new_tasks_on_firebase(self, task_ref: FbReference): + tasks = TutorialTask.objects.filter( + scenario__tutorial_id=self.tutorial.pk, + ).order_by( + *self.get_task_sort_keys(["scenario__scenario_page_number"]), + ) + + fb_tasks: dict[str, dict[str, dict[str, dict]]] = {} + index = 1 + for task in tasks.iterator(): + task_tutorial_specific_data = self.get_task_tutorial_specifics_for_firebase(task, index) + fb_tasks[task.pk] = firebase_utils.serialize(task_tutorial_specific_data) + index += 1 + + group_key = self.get_tutorial_group_key() + + task_ref.set( + value={ + str(group_key): fb_tasks, + }, + ) + + def handle_new_groups_on_firebase(self, group_ref: FbReference): + fb_groups: dict[str, dict[str, dict]] = {} + + group_key = self.get_tutorial_group_key() + + tasks_count = TutorialTask.objects.filter(scenario__tutorial_id=self.tutorial.pk).count() + + base_tutorial_specific_data = firebase_models.FbBaseTutorialGroup( + finishedCount=0, + groupId=group_key, + numberOfTasks=tasks_count, + progress=0, + projectId=self.tutorial.firebase_id, + requiredCount=0, + ) + group_tutorial_specific_data = self.get_group_tutorial_specifics_for_firebase() + fb_groups[str(group_key)] = { + **firebase_utils.serialize(base_tutorial_specific_data), + **firebase_utils.serialize(group_tutorial_specific_data), + } + + group_ref.set(value=fb_groups) + + @abstractmethod + def get_task_tutorial_specifics_for_firebase(self, task: TutorialTask, index: int) -> BaseModel: ... + + @abstractmethod + def get_group_tutorial_specifics_for_firebase(self) -> BaseModel: ... + + @abstractmethod + def get_tutorial_specifics_for_firebase(self) -> BaseModel: ... + + def handle_new_tutorial_on_firebase(self, tutorial_ref: FbReference): + # NOTE: We are not reading data from group_ref as it's an expensive operation + # FIXME(tnagorra): We need to check if the key exists later + group_ref = self.firebase_helper.ref( + Config.FirebaseKeys.tutorial_groups(self.tutorial.firebase_id), + ) + # FIXME(tnagorra): We need to check if the key exists later + task_ref = self.firebase_helper.ref( + Config.FirebaseKeys.tutorial_tasks(self.tutorial.firebase_id), + ) + + self.handle_new_tasks_on_firebase(task_ref) + self.handle_new_groups_on_firebase(group_ref) + + scenarios = self.tutorial.scenarios.all() + informationPages = self.tutorial.information_pages.all() + + tutorial_data = firebase_models.FbBaseTutorial( + exampleImage1=None, + exampleImage2=None, + contributorCount=0, + informationPages=[ + firebase_models.FbInformationPage( + title=informationPage.title, + pageNumber=informationPage.page_number, + blocks=[ + firebase_models.FbInformationPageBlock( + blockNumber=block.block_number, + blockType=block_type_enum_to_firebase(TutorialInformationPageBlockTypeEnum(block.block_type)), + textDescription=block.text, + image=block.image.file.url if block.image else None, + ) + for block in informationPage.blocks.all() + ], + ) + for informationPage in informationPages + ], + lookFor=self.tutorial.project.look_for, + name=self.tutorial.name, + progress=0, + # FIXME(tnagorra): We should add description in tutorial + projectDetails=self.tutorial.project.description or "n/a", + projectId=self.tutorial.firebase_id, + projectTopicKey=self.tutorial.name.lower().strip(), + status="tutorial", + tutorialDraftId="", + screens=[ + firebase_models.FbScreen( + hint=firebase_models.FbScreenBlock( + title=scenario.hint_title or "?", + description=scenario.hint_description or "?", + icon=str(scenario.hint_icon_enum.label) + if scenario.hint_icon_enum + else str(IconEnum.ALERT_OUTLINE.label), + ), + instructions=firebase_models.FbScreenBlock( + title=scenario.instructions_title, + description=scenario.instructions_description, + icon=str(scenario.instructions_icon_enum.label), + ), + success=firebase_models.FbScreenBlock( + title=scenario.success_title or "?", + description=scenario.success_description or "?", + icon=str(scenario.success_icon_enum.label) + if scenario.success_icon_enum + else str(IconEnum.ALERT_OUTLINE.label), + ), + ) + for scenario in scenarios + ], + ) + + tutorial_specific_data = self.get_tutorial_specifics_for_firebase() + + tutorial_ref.set( + value={ + **firebase_utils.serialize(tutorial_data), + **firebase_utils.serialize(tutorial_specific_data), + }, + ) + + def handle_tutorial_update_on_firebase(self, tutorial_ref: FbReference, fb_tutorial: firebase_models.FbBaseTutorial): + # NOTE: We are not reading data from group_ref as it's an expensive operation + # FIXME(tnagorra): We need to check if the key exists later + group_ref = self.firebase_helper.ref( + Config.FirebaseKeys.project_groups(self.tutorial.firebase_id), + ) + # FIXME(tnagorra): We need to check if the key exists later + task_ref = self.firebase_helper.ref( + Config.FirebaseKeys.project_tasks(self.tutorial.firebase_id), + ) + + self.handle_new_tasks_on_firebase(task_ref) + self.handle_new_groups_on_firebase(group_ref) + + scenarios = self.tutorial.scenarios.all() + + tutorial_data = firebase_models.FbBaseTutorial( + exampleImage1=None, + exampleImage2=None, + contributorCount=0, + informationPages=[], + lookFor=self.tutorial.project.look_for, + name=self.tutorial.name, + progress=0, + # FIXME(tnagorra): We should add description in tutorial + projectDetails=self.tutorial.project.description or "n/a", + projectId=self.tutorial.firebase_id, + projectTopicKey=self.tutorial.name.lower().strip(), + status="tutorial", + tutorialDraftId="", + screens=[ + firebase_models.FbScreen( + hint=firebase_models.FbScreenBlock( + title=scenario.hint_title or "", + description=scenario.hint_description or "", + icon=str(scenario.hint_icon_enum.label) if scenario.hint_icon_enum else "", + ), + instructions=firebase_models.FbScreenBlock( + title=scenario.instructions_title or "", + description=scenario.instructions_description or "", + icon=str(scenario.instructions_icon_enum.label) if scenario.instructions_icon_enum else "", + ), + success=firebase_models.FbScreenBlock( + title=scenario.success_title or "", + description=scenario.success_description or "", + icon=str(scenario.success_icon_enum.label) if scenario.success_icon_enum else "", + ), + ) + for scenario in scenarios + ], + ) + + tutorial_specific_data = self.get_tutorial_specifics_for_firebase() + + tutorial_ref.update( + value={ + **firebase_utils.serialize(tutorial_data), + **firebase_utils.serialize(tutorial_specific_data), + }, + ) + + def push_to_firebase(self): + if self.tutorial.firebase_push_status_enum != FirebasePushStatusEnum.PENDING: + logger.warning("%s - push_to_firebase called when push is not required", self.tutorial.pk) + return + + self.tutorial.update_firebase_push_status(FirebasePushStatusEnum.PROCESSING) + + try: + tutorial_ref = self.firebase_helper.ref( + Config.FirebaseKeys.tutorial(self.tutorial.firebase_id), + ) + fb_tutorial: typing.Any = tutorial_ref.get() + + if not self.tutorial.firebase_last_pushed: + if fb_tutorial is not None: + logger.error( + "push_to_firebase found a tutorial already in firebase when creating a tutorial", + extra=log_extra({"tutorial": self.tutorial.pk}), + ) + raise InvalidTutorialPushException + self.handle_new_tutorial_on_firebase(tutorial_ref) + else: + if fb_tutorial is None: + logger.error( + "push_to_firebase did not find tutorial in firebase when updating a tutorial", + extra=log_extra({"tutorial": self.tutorial.pk}), + ) + raise InvalidTutorialPushException + + class RelaxedModel(firebase_models.FbBaseTutorial): + model_config = ConfigDict(extra="ignore") + + # NOTE: we want to ignore extra fields from firebase + valid_tutorial = RelaxedModel.model_validate(obj=fb_tutorial) + valid_tutorial = firebase_models.FbBaseTutorial.model_validate(obj=valid_tutorial) + + self.handle_tutorial_update_on_firebase(tutorial_ref, valid_tutorial) + except InvalidTutorialPushException: + self.tutorial.update_firebase_push_status(FirebasePushStatusEnum.FAILED) + except Exception: + logger.error( + "push_to_firebase failed", + extra=log_extra({"tutorial": self.tutorial.pk}), + exc_info=True, + ) + self.tutorial.update_firebase_push_status(FirebasePushStatusEnum.FAILED) + else: + self.tutorial.update_firebase_push_status(FirebasePushStatusEnum.SUCCESS) diff --git a/project_types/firebase.py b/project_types/firebase.py index 0cf0fabc..704aebca 100644 --- a/project_types/firebase.py +++ b/project_types/firebase.py @@ -1,6 +1,7 @@ from pyfirebase_mapswipe import models as firebase_models from apps.project.models import ProjectTypeEnum +from apps.tutorial.models import TutorialInformationPageBlockTypeEnum from utils.geo.raster_tile_server.config import RasterTileServerNameEnum from utils.geo.vector_tile_server.config import VectorTileServerNameEnum @@ -51,3 +52,13 @@ def vector_tile_server_name_enum_to_firebase( return firebase_models.FbEnumVectorTileServerName.OPEN_FREE_MAP case VectorTileServerNameEnum.VERSATILES: return firebase_models.FbEnumVectorTileServerName.VERSATILES + + +def block_type_enum_to_firebase( + input_enum: TutorialInformationPageBlockTypeEnum, +) -> firebase_models.FbEnumInformationPageBlockType: + match input_enum: + case TutorialInformationPageBlockTypeEnum.TEXT: + return firebase_models.FbEnumInformationPageBlockType.TEXT + case TutorialInformationPageBlockTypeEnum.IMAGE: + return firebase_models.FbEnumInformationPageBlockType.IMAGE diff --git a/project_types/tile_map_service/base/tutorial.py b/project_types/tile_map_service/base/tutorial.py index 6307bf9c..27cfc79a 100644 --- a/project_types/tile_map_service/base/tutorial.py +++ b/project_types/tile_map_service/base/tutorial.py @@ -1,11 +1,86 @@ import logging +import typing +from pyfirebase_mapswipe import models as firebase_models + +from apps.tutorial.models import Tutorial, TutorialScenarioPage, TutorialTask from project_types.base import tutorial as base_tutorial +from .project import TileMapServiceProjectProperty + logger = logging.getLogger(__name__) class TileMapServiceTutorialTaskProperty(base_tutorial.BaseTutorialTaskProperty): tile_x: int tile_y: int + # FIXME(tnagorra): Do we save this or get zoom_level from project tile_z: int + + +class TileMapServiceBaseTutorial[ + ProjectPropertyVar: TileMapServiceProjectProperty, + TaskPropertyVar: TileMapServiceTutorialTaskProperty, +]( + base_tutorial.BaseTutorial[ + ProjectPropertyVar, + TaskPropertyVar, + ], +): + def __init__(self, tutorial: Tutorial): + super().__init__(tutorial) + + @typing.override + def get_task_sort_keys(self, existing_values: list[str]) -> list[str]: + return [*existing_values, "project_type_specifics__tile_x", "project_type_specifics__tile_y"] + + @typing.override + def get_task_tutorial_specifics_for_firebase( + self, + task: TutorialTask, + index: int, + ) -> firebase_models.FbTileMapServiceTutorialTask: + task_specifics = self.tutorial_task_property_class( + **task.project_type_specifics, + ) + + # FIXME(tnagorra): Add validation that scenario_page_number should start from 1 + + i = index % 6 + + task_x = 100 + (2 * task.scenario.scenario_page_number - 1) + if i < 3: + task_x += 0 + else: + task_x += 1 + + task_y = 131072 + if i in [0, 3]: + task_y += 0 + elif i in [1, 4]: + task_y += 1 + elif i in [2, 5]: + task_y += 2 + + return firebase_models.FbTileMapServiceTutorialTask( + geometry="", + groupId=self.get_tutorial_group_key(), + projectId=self.tutorial.firebase_id, + referenceAnswer=task.reference, + screen=task.scenario.scenario_page_number, + taskId_real=f"{task_specifics.tile_z}-{task_specifics.tile_x}-{task_specifics.tile_y}", + taskX=task_x, + taskY=task_y, + taskId=f"{task_specifics.tile_z}-{task_x}-{task_y}", + ) + + @typing.override + def get_group_tutorial_specifics_for_firebase(self): + scenarios_count = TutorialScenarioPage.objects.filter(tutorial_id=self.tutorial.pk).count() + + return firebase_models.FbTileMapServiceTutorialGroup( + xMin=100, # this will be always set to 100 + xMax=100 + (2 * scenarios_count) - 1, # this depends on the number of screens/tasks to show + yMin=131072, # this is set to be at the equator + yMax=131072 + 3 - 1, # this is set to be at the equator + ) diff --git a/project_types/tile_map_service/compare/tutorial.py b/project_types/tile_map_service/compare/tutorial.py index 6e1ef9eb..9c01f665 100644 --- a/project_types/tile_map_service/compare/tutorial.py +++ b/project_types/tile_map_service/compare/tutorial.py @@ -1,4 +1,87 @@ +import typing + +from pyfirebase_mapswipe import extended_models as firebase_ext_models +from pyfirebase_mapswipe import models as firebase_models + +from apps.project.models import ProjectTypeEnum +from apps.tutorial.models import Tutorial, TutorialTask +from project_types.firebase import raster_tile_server_name_enum_to_firebase from project_types.tile_map_service.base import tutorial as tile_map_service_tutorial +from .project import CompareProjectProperty + class CompareTutorialTaskProperty(tile_map_service_tutorial.TileMapServiceTutorialTaskProperty): ... + + +class CompareTutorial( + tile_map_service_tutorial.TileMapServiceBaseTutorial[ + CompareProjectProperty, + CompareTutorialTaskProperty, + ], +): + project_property_class = CompareProjectProperty + tutorial_task_property_class = CompareTutorialTaskProperty + + def __init__(self, tutorial: Tutorial): + super().__init__(tutorial) + + @typing.override + def get_task_tutorial_specifics_for_firebase(self, task: TutorialTask, index: int): + tsp = self.project_type_specifics.tile_server_property + tsp_b = self.project_type_specifics.tile_server_b_property + + task_specifics = self.tutorial_task_property_class( + **task.project_type_specifics, + ) + + resp = super().get_task_tutorial_specifics_for_firebase(task, index) + + return firebase_ext_models.FbCompareTutorialTaskComplete( + geometry=resp.geometry, + groupId=resp.groupId, + projectId=resp.projectId, + referenceAnswer=resp.referenceAnswer, + screen=resp.screen, + taskId_real=resp.taskId_real, + taskX=resp.taskX, + taskY=resp.taskY, + taskId=resp.taskId, + url=tsp.generate_url( + task_specifics.tile_x, + task_specifics.tile_y, + task_specifics.tile_z, + ), + urlB=tsp_b.generate_url( + task_specifics.tile_x, + task_specifics.tile_y, + task_specifics.tile_z, + ), + ) + + @typing.override + def get_tutorial_specifics_for_firebase(self): + tsp = self.project_type_specifics.tile_server_property + tsp_b = self.project_type_specifics.tile_server_b_property + + projectType = ProjectTypeEnum.COMPARE.value + assert projectType == 3, "Project COMPARE should be 3" + + return firebase_models.FbCompareTutorial( + zoomLevel=self.project_type_specifics.zoom_level, + projectType=projectType, + tileServer=firebase_models.FbObjRasterTileServer( + name=raster_tile_server_name_enum_to_firebase(tsp.name), + credits=tsp.get_config()["credits"], + url=tsp.get_config()["raw_url"], + apiKey=tsp.get_config()["api_key"], + wmtsLayerName=None, + ), + tileServerB=firebase_models.FbObjRasterTileServer( + name=raster_tile_server_name_enum_to_firebase(tsp_b.name), + credits=tsp_b.get_config()["credits"], + url=tsp_b.get_config()["raw_url"], + apiKey=tsp_b.get_config()["api_key"], + wmtsLayerName=None, + ), + ) diff --git a/project_types/tile_map_service/completeness/project.py b/project_types/tile_map_service/completeness/project.py index 840af110..fb10c0f8 100644 --- a/project_types/tile_map_service/completeness/project.py +++ b/project_types/tile_map_service/completeness/project.py @@ -11,6 +11,10 @@ from utils.geo.raster_tile_server.models import RasterTileServerConfig from utils.geo.vector_tile_server.models import VectorTileServerConfig +FALLBACK_RASTER_LAYER = ( + "https://raw.githubusercontent.com/mapswipe/mapswipe-assets/refs/heads/main/images/raster-layer-404-message.png" +) + class OverlayLayerTypeEnum(models.TextChoices): VECTOR_TILE = "VECTOR_TILE", "Vector Tile" @@ -116,7 +120,6 @@ def get_project_specifics_for_firebase(self): ) # NOTE: Setting background layer as fallback for overlay layer - # FIXME(tnagorra): Handle vector tiles in the future if tsp_overlay.type == OverlayLayerTypeEnum.RASTER_TILE and tsp_overlay.raster: fb_overlay_tile_server = firebase_models.FbObjRasterTileServer( name=raster_tile_server_name_enum_to_firebase(tsp_overlay.raster.tile_server.name), @@ -129,7 +132,7 @@ def get_project_specifics_for_firebase(self): fb_overlay_tile_server = firebase_models.FbObjRasterTileServer( name=firebase_models.FbEnumRasterTileServerName.CUSTOM, credits="n/a", - url="https://raw.githubusercontent.com/mapswipe/mapswipe-assets/refs/heads/main/images/raster-layer-404-message.png", + url=FALLBACK_RASTER_LAYER, apiKey="", wmtsLayerName=None, ) diff --git a/project_types/tile_map_service/completeness/tutorial.py b/project_types/tile_map_service/completeness/tutorial.py index 2b2b49c1..d68db6b7 100644 --- a/project_types/tile_map_service/completeness/tutorial.py +++ b/project_types/tile_map_service/completeness/tutorial.py @@ -1,4 +1,137 @@ +import typing + +from pyfirebase_mapswipe import extended_models as firebase_ext_models +from pyfirebase_mapswipe import models as firebase_models + +from apps.project.models import ProjectTypeEnum +from apps.tutorial.models import Tutorial, TutorialTask +from project_types.firebase import raster_tile_server_name_enum_to_firebase, vector_tile_server_name_enum_to_firebase from project_types.tile_map_service.base import tutorial as tile_map_service_tutorial +from .project import FALLBACK_RASTER_LAYER, CompletenessProjectProperty, OverlayLayerTypeEnum, overlay_type_enum_to_firebase + class CompletenessTutorialTaskProperty(tile_map_service_tutorial.TileMapServiceTutorialTaskProperty): ... + + +class CompletenessTutorial( + tile_map_service_tutorial.TileMapServiceBaseTutorial[ + CompletenessProjectProperty, + CompletenessTutorialTaskProperty, + ], +): + project_property_class = CompletenessProjectProperty + tutorial_task_property_class = CompletenessTutorialTaskProperty + + def __init__(self, tutorial: Tutorial): + super().__init__(tutorial) + + @typing.override + def get_task_tutorial_specifics_for_firebase(self, task: TutorialTask, index: int): + tsp = self.project_type_specifics.tile_server_property + tsp_overlay = self.project_type_specifics.overlay_tile_server_property + + task_specifics = self.tutorial_task_property_class( + **task.project_type_specifics, + ) + + resp = super().get_task_tutorial_specifics_for_firebase(task, index) + + return firebase_ext_models.FbCompletenessTutorialTaskComplete( + geometry=resp.geometry, + groupId=resp.groupId, + projectId=resp.projectId, + referenceAnswer=resp.referenceAnswer, + screen=resp.screen, + taskId_real=resp.taskId_real, + taskX=resp.taskX, + taskY=resp.taskY, + taskId=resp.taskId, + url=tsp.generate_url( + task_specifics.tile_x, + task_specifics.tile_y, + task_specifics.tile_z, + ), + urlB=tsp_overlay.raster.tile_server.generate_url( + task_specifics.tile_x, + task_specifics.tile_y, + task_specifics.tile_z, + ) + if tsp_overlay.type == OverlayLayerTypeEnum.RASTER_TILE and tsp_overlay.raster + else FALLBACK_RASTER_LAYER, + ) + + @typing.override + def get_tutorial_specifics_for_firebase(self): + tsp = self.project_type_specifics.tile_server_property + tsp_overlay = self.project_type_specifics.overlay_tile_server_property + + projectType = ProjectTypeEnum.COMPLETENESS.value + assert projectType == 4, "Project Completeness should be 4" + + # NOTE: Setting background layer as fallback for overlay layer + if tsp_overlay.type == OverlayLayerTypeEnum.RASTER_TILE and tsp_overlay.raster: + fb_overlay_tile_server = firebase_models.FbObjRasterTileServer( + name=raster_tile_server_name_enum_to_firebase(tsp_overlay.raster.tile_server.name), + credits=tsp_overlay.raster.tile_server.get_config()["credits"], + url=tsp_overlay.raster.tile_server.get_config()["raw_url"], + apiKey=tsp_overlay.raster.tile_server.get_config()["api_key"], + wmtsLayerName=None, + ) + else: + fb_overlay_tile_server = firebase_models.FbObjRasterTileServer( + name=firebase_models.FbEnumRasterTileServerName.CUSTOM, + credits="n/a", + url=FALLBACK_RASTER_LAYER, + apiKey="", + wmtsLayerName=None, + ) + + return firebase_models.FbCompletenessTutorial( + zoomLevel=self.project_type_specifics.zoom_level, + projectType=projectType, + tileServer=firebase_models.FbObjRasterTileServer( + name=raster_tile_server_name_enum_to_firebase(tsp.name), + credits=tsp.get_config()["credits"], + url=tsp.get_config()["raw_url"], + apiKey=tsp.get_config()["api_key"], + wmtsLayerName=None, + ), + tileServerB=fb_overlay_tile_server, + overlayTileServer=firebase_models.FbObjUnifiedOverlayTileServer( + type=overlay_type_enum_to_firebase(tsp_overlay.type), + vector=firebase_models.FbObjVectorTileServerOverlay( + tileServer=firebase_models.FbObjVectorTileServer( + name=vector_tile_server_name_enum_to_firebase(tsp_overlay.vector.tile_server.name), + sourceLayer=tsp_overlay.vector.tile_server.get_config()["source_layer"], + credits=tsp_overlay.vector.tile_server.get_config()["credits"], + url=tsp_overlay.vector.tile_server.get_config()["url"], + minZoom=tsp_overlay.vector.tile_server.get_config()["min_zoom"], + maxZoom=tsp_overlay.vector.tile_server.get_config()["max_zoom"], + ), + fillColor=tsp_overlay.vector.fill_color, + fillOpacity=tsp_overlay.vector.fill_opacity, + lineColor=tsp_overlay.vector.line_color, + lineOpacity=tsp_overlay.vector.line_opacity, + lineWidth=tsp_overlay.vector.line_width, + lineDasharray=tsp_overlay.vector.line_dasharray, + circleColor=tsp_overlay.vector.circle_color, + circleOpacity=tsp_overlay.vector.circle_opacity, + circleRadius=tsp_overlay.vector.circle_radius, + ) + if tsp_overlay.vector + else None, + raster=firebase_models.FbObjRasterTileServerOverlay( + opacity=tsp_overlay.raster.opacity, + tileServer=firebase_models.FbObjRasterTileServer( + name=raster_tile_server_name_enum_to_firebase(tsp_overlay.raster.tile_server.name), + credits=tsp_overlay.raster.tile_server.get_config()["credits"], + url=tsp_overlay.raster.tile_server.get_config()["raw_url"], + apiKey=tsp_overlay.raster.tile_server.get_config()["api_key"], + wmtsLayerName=None, + ), + ) + if tsp_overlay.raster + else None, + ), + ) diff --git a/project_types/tile_map_service/find/tutorial.py b/project_types/tile_map_service/find/tutorial.py index 4080b60e..a0073618 100644 --- a/project_types/tile_map_service/find/tutorial.py +++ b/project_types/tile_map_service/find/tutorial.py @@ -1,4 +1,73 @@ +import typing + +from pyfirebase_mapswipe import extended_models as firebase_ext_models +from pyfirebase_mapswipe import models as firebase_models + +from apps.project.models import ProjectTypeEnum +from apps.tutorial.models import Tutorial, TutorialTask +from project_types.firebase import raster_tile_server_name_enum_to_firebase from project_types.tile_map_service.base import tutorial as tile_map_service_tutorial +from .project import FindProjectProperty + class FindTutorialTaskProperty(tile_map_service_tutorial.TileMapServiceTutorialTaskProperty): ... + + +class FindTutorial( + tile_map_service_tutorial.TileMapServiceBaseTutorial[ + FindProjectProperty, + FindTutorialTaskProperty, + ], +): + project_property_class = FindProjectProperty + tutorial_task_property_class = FindTutorialTaskProperty + + def __init__(self, tutorial: Tutorial): + super().__init__(tutorial) + + @typing.override + def get_task_tutorial_specifics_for_firebase(self, task: TutorialTask, index: int): + tsp = self.project_type_specifics.tile_server_property + + task_specifics = self.tutorial_task_property_class( + **task.project_type_specifics, + ) + + resp = super().get_task_tutorial_specifics_for_firebase(task, index) + + return firebase_ext_models.FbFindTutorialTaskComplete( + geometry=resp.geometry, + groupId=resp.groupId, + projectId=resp.projectId, + referenceAnswer=resp.referenceAnswer, + screen=resp.screen, + taskId_real=resp.taskId_real, + taskX=resp.taskX, + taskY=resp.taskY, + taskId=resp.taskId, + url=tsp.generate_url( + task_specifics.tile_x, + task_specifics.tile_y, + task_specifics.tile_z, + ), + ) + + @typing.override + def get_tutorial_specifics_for_firebase(self): + tsp = self.project_type_specifics.tile_server_property + + projectType = ProjectTypeEnum.FIND.value + assert projectType == 1, "Project Find should be 1" + + return firebase_models.FbFindTutorial( + zoomLevel=self.project_type_specifics.zoom_level, + projectType=projectType, + tileServer=firebase_models.FbObjRasterTileServer( + name=raster_tile_server_name_enum_to_firebase(tsp.name), + credits=tsp.get_config()["credits"], + url=tsp.get_config()["raw_url"], + apiKey=tsp.get_config()["api_key"], + wmtsLayerName=None, + ), + ) diff --git a/project_types/validate/tutorial.py b/project_types/validate/tutorial.py index d86b516a..1cf1d794 100644 --- a/project_types/validate/tutorial.py +++ b/project_types/validate/tutorial.py @@ -1,12 +1,106 @@ +import json import logging import typing -from pydantic import Field +from osgeo import ogr +from pyfirebase_mapswipe import extended_models as firebase_ext_models +from pyfirebase_mapswipe import models as firebase_models +from apps.project.models import ProjectTypeEnum +from apps.tutorial.models import Tutorial, TutorialTask +from project_types.base import tutorial as base_tutorial from project_types.base.tutorial import BaseTutorialTaskProperty +from project_types.firebase import raster_tile_server_name_enum_to_firebase + +from .project import ValidateProjectProperty logger = logging.getLogger(__name__) class ValidateTutorialTaskProperty(BaseTutorialTaskProperty): - object_geometry: typing.Annotated[str, Field(strict=True, pattern=r"^\d+$")] | None = None + # FIXME(tnagorra): add positive integer + identifier: int + # FIXME(tnagorra): Use geometry from TutorialTask + object_geometry: str + + +class ValidateTutorial( + base_tutorial.BaseTutorial[ + ValidateProjectProperty, + ValidateTutorialTaskProperty, + ], +): + project_property_class = ValidateProjectProperty + tutorial_task_property_class = ValidateTutorialTaskProperty + + def __init__(self, tutorial: Tutorial): + super().__init__(tutorial) + + @typing.override + def get_task_tutorial_specifics_for_firebase(self, task: TutorialTask, index: int): + task_specifics = self.tutorial_task_property_class( + **task.project_type_specifics, + ) + + geojson = json.loads(task_specifics.object_geometry) + geometry_ogr = ogr.CreateGeometryFromJson(task_specifics.object_geometry) + geometry_wkt = geometry_ogr.ExportToWkt() + + return firebase_models.FbValidateTutorialTask( + taskId=f"t{index}", + geojson=geojson, + properties=firebase_models.FbValidateTutorialTaskProperties( + id=task_specifics.identifier, + reference=task.reference, + screen=task.scenario.scenario_page_number, + ), + geometry=geometry_wkt, + ) + + @typing.override + def get_group_tutorial_specifics_for_firebase(self): + return firebase_ext_models.FbEmptyModel() + + @typing.override + def get_tutorial_specifics_for_firebase(self): + tsp = self.project_type_specifics.tile_server_property + custom_opts = self.project_type_specifics.custom_options + + projectType = ProjectTypeEnum.VALIDATE.value + assert projectType == 2, "Project Validate should be 2" + + return firebase_models.FbValidateTutorial( + # FIXME(tnagorra): This is the path to local storage. + inputGeometries="", + # FIXME(tnagorra): Check if this is always 18, app is calculating zoomLevel using geometry + zoomLevel=18, + projectType=projectType, + tileServer=firebase_models.FbObjRasterTileServer( + name=raster_tile_server_name_enum_to_firebase(tsp.name), + credits=tsp.get_config()["credits"], + url=tsp.get_config()["raw_url"], + apiKey=tsp.get_config()["api_key"], + wmtsLayerName=None, + ), + customOptions=[ + firebase_models.FbObjCustomOption( + title=opt.title, + description=opt.description, + value=opt.value, + icon=str(opt.icon.label), + iconColor=opt.icon_color, + subOptions=[ + firebase_models.FbBaseObjCustomSubOption( + value=sub_opt.value, + description=sub_opt.description, + ) + for sub_opt in opt.sub_options + ] + if opt.sub_options is not None + else None, + ) + for opt in custom_opts + ] + if custom_opts is not None + else None, + ) diff --git a/schema.graphql b/schema.graphql index 6e5989db..85966708 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1743,7 +1743,7 @@ type TutorialAssetTypeOffsetPaginated { } """ -Tutorial(id, client_id, created_at, modified_at, created_by, modified_by, old_id, firebase_id, firebase_push_status, firebase_last_pushed, project, name, status) +Tutorial(id, client_id, created_at, modified_at, created_by, modified_by, old_id, firebase_push_status, firebase_last_pushed, project, name, status, firebase_id) """ input TutorialCreateInput { clientId: String! @@ -1754,7 +1754,7 @@ input TutorialCreateInput { } """ -Tutorial(id, client_id, created_at, modified_at, created_by, modified_by, old_id, firebase_id, firebase_push_status, firebase_last_pushed, project, name, status) +Tutorial(id, client_id, created_at, modified_at, created_by, modified_by, old_id, firebase_push_status, firebase_last_pushed, project, name, status, firebase_id) """ input TutorialFilter { id: IDBaseFilterLookup @@ -2043,7 +2043,7 @@ input TutorialTaskUpdateInput { } """ -Tutorial(id, client_id, created_at, modified_at, created_by, modified_by, old_id, firebase_id, firebase_push_status, firebase_last_pushed, project, name, status) +Tutorial(id, client_id, created_at, modified_at, created_by, modified_by, old_id, firebase_push_status, firebase_last_pushed, project, name, status, firebase_id) """ type TutorialType implements UserResourceTypeMixin { clientId: String! @@ -2078,7 +2078,7 @@ type TutorialTypeOffsetPaginated { } """ -Tutorial(id, client_id, created_at, modified_at, created_by, modified_by, old_id, firebase_id, firebase_push_status, firebase_last_pushed, project, name, status) +Tutorial(id, client_id, created_at, modified_at, created_by, modified_by, old_id, firebase_push_status, firebase_last_pushed, project, name, status, firebase_id) """ input TutorialUpdateInput { clientId: String! @@ -2211,11 +2211,13 @@ type ValidateProjectPropertyType { } input ValidateTutorialTaskPropertyInput { - objectGeometry: String = null + identifier: Int! + objectGeometry: String! } type ValidateTutorialTaskPropertyType { - objectGeometry: String + identifier: Int! + objectGeometry: String! } input VectorTileServerCommonConfigInput {