diff --git a/apps/common/models.py b/apps/common/models.py index 9d0f09ef..1ac46c3f 100644 --- a/apps/common/models.py +++ b/apps/common/models.py @@ -32,12 +32,12 @@ class UserResource(Model): ) created_at = models.DateTimeField(auto_now_add=True) modified_at = models.DateTimeField(auto_now=True) - created_by = models.ForeignKey( + created_by: User = models.ForeignKey( # type: ignore[reportIncompatibleVariableOverride] User, related_name="%(class)s_created", on_delete=models.PROTECT, ) - modified_by = models.ForeignKey( + modified_by: User = models.ForeignKey( # type: ignore[reportIncompatibleVariableOverride] User, related_name="%(class)s_modified", on_delete=models.PROTECT, @@ -60,7 +60,7 @@ def __str__(self): class ArchivableResource(Model): is_archived = models.BooleanField(default=False) archived_at = models.DateTimeField(null=True, blank=True) - archived_by = models.ForeignKey( + archived_by: User = models.ForeignKey( # type: ignore[reportIncompatibleVariableOverride] User, related_name="+", on_delete=models.SET_NULL, @@ -161,3 +161,75 @@ def update_firebase_push_status( class Meta(TypedModelMeta): # type: ignore[reportIncompatibleVariableOverride] abstract = True + + +class AssetMimetypeEnum(models.IntegerChoices): + GEOJSON = 100, "application/geo+json" + + IMAGE_JPEG = 201, "image/jpeg" + IMAGE_PNG = 202, "image/png" + IMAGE_GIF = 203, "image/gif" + + @classmethod + def get_display(cls, value: typing.Self | int) -> str: + if value in cls: + return str(cls(value).label) + return "Unknown" + + @classmethod + def is_valid_mimetype(cls, mimetype: str) -> bool: + """ + Check if the given mimetype is valid for project assets. + """ + return mimetype in [choice.label for choice in cls] + + @classmethod + def get_mimetype_by_label(cls, label: str) -> typing.Self | None: + for choice in cls: + if choice.label == label: + return choice + return None + + +# FIXME(tnagorra): Finalize the enum labels +class AssetTypeEnum(models.IntegerChoices): + INPUT = 100, "Input" + OUTPUT = 200, "Output" + STATS = 300, "Stats" + + @classmethod + def get_display(cls, value: typing.Self | int) -> str: + if value in cls: + return str(cls(value).label) + return "Unknown" + + +class CommonAsset(Model): + Mimetype = AssetMimetypeEnum + MAX_FILE_SIZE: int = 10 * 1024 * 1024 # MB + Type = AssetTypeEnum + + type = IntegerChoicesField( + choices_enum=AssetTypeEnum, + ) + + mimetype = IntegerChoicesField( + choices_enum=AssetMimetypeEnum, + ) + + file_size = models.PositiveIntegerField( + help_text=gettext_lazy("The size of the file in bytes"), + ) + + marked_as_deleted = models.BooleanField( + default=False, + help_text=gettext_lazy("If this flag is enabled, this project asset will be deleted in the future"), + ) + + class Meta(TypedModelMeta): # type: ignore[reportIncompatibleVariableOverride] + abstract = True + + @classmethod + def usable_objects(cls): + """Returns objects that are mot marked for deletion""" + return cls.objects.filter(marked_as_deleted=False) diff --git a/apps/common/serializers.py b/apps/common/serializers.py index ba0d0fbd..6967ed74 100644 --- a/apps/common/serializers.py +++ b/apps/common/serializers.py @@ -1,14 +1,17 @@ import logging +import mimetypes import typing from django.core.exceptions import ValidationError from django.http.request import HttpRequest +from django.template.defaultfilters import filesizeformat from django.utils import timezone from django.utils.translation import gettext from firebase_admin import auth from rest_framework import serializers from apps.common.models import ArchivableResource, UserResource +from apps.project.models import CommonAsset from apps.user.models import User from utils.common import validate_ulid @@ -17,6 +20,10 @@ logger = logging.getLogger(__name__) +if typing.TYPE_CHECKING: + from django.core.files.base import ContentFile + + class DrfContextType(typing.TypedDict): request: HttpRequest @@ -160,3 +167,46 @@ def validate(self, attrs: dict[str, typing.Any]): class FirebaseAuthResponseSerializer(serializers.Serializer[typing.Any]): ok = serializers.BooleanField(required=True) error = serializers.CharField() + + +class CommonAssetSerializer(serializers.ModelSerializer[CommonAsset]): + def _validate_file(self, attrs: dict[str, typing.Any]) -> None: + file_content: ContentFile[bytes] = attrs["file"] + file_size = file_content.size + max_file_size = CommonAsset.MAX_FILE_SIZE + + if file_size > max_file_size: + raise serializers.ValidationError( + gettext("Filesize should be less than: %s. Current is: %s") + % ( + filesizeformat(max_file_size), + filesizeformat(file_size), + ), + ) + + # https://docs.python.org/3/library/mimetypes.html#mimetypes.guess_type + mimetype, *_ = mimetypes.guess_type(file_content.name) # type: ignore[reportUnknownArgumentType] + + # TODO(susilnem): Use library like filemagic to determine mimetype instead? + if mimetype is None: + raise serializers.ValidationError( + gettext("Could not determine mimetype of the file: %s") % file_content.name, + ) + + if not CommonAsset.Mimetype.is_valid_mimetype(mimetype): + raise serializers.ValidationError( + gettext("File mimetype is not supported: %s") % mimetype, + ) + + if "mimetype" in attrs and CommonAsset.Mimetype.get_mimetype_by_label(mimetype) != attrs["mimetype"]: + raise serializers.ValidationError( + gettext("File mimetype does not match the provided mimetype: %s") % mimetype, + ) + + attrs["mimetype"] = CommonAsset.Mimetype.get_mimetype_by_label(mimetype) + attrs["file_size"] = file_size + + @typing.override + def validate(self, attrs: dict[str, typing.Any]) -> dict[str, typing.Any]: + self._validate_file(attrs) + return super().validate(attrs) diff --git a/apps/community_dashboard/models.py b/apps/community_dashboard/models.py index 2c67eead..81d2fe92 100644 --- a/apps/community_dashboard/models.py +++ b/apps/community_dashboard/models.py @@ -32,8 +32,8 @@ def __str__(self): class AggregatedUserStatData(Model): # Ref Fields - project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="+") - user = models.ForeignKey(ContributorUser, on_delete=models.CASCADE, related_name="+") + project: Project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="+") # type: ignore[reportIncompatibleVariableOverride] + user: ContributorUser = models.ForeignKey(ContributorUser, on_delete=models.CASCADE, related_name="+") # type: ignore[reportIncompatibleVariableOverride] timestamp_date = models.DateField() # Aggregated Fields total_time = models.IntegerField() # seconds @@ -59,9 +59,9 @@ def __str__(self): class AggregatedUserGroupStatData(Model): # Ref Fields - project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="+") - user = models.ForeignKey(ContributorUser, on_delete=models.CASCADE, related_name="+") - user_group = models.ForeignKey(ContributorUserGroup, on_delete=models.CASCADE, related_name="+") + project: Project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="+") # type: ignore[reportIncompatibleVariableOverride] + user: ContributorUser = models.ForeignKey(ContributorUser, on_delete=models.CASCADE, related_name="+") # type: ignore[reportIncompatibleVariableOverride] + user_group: ContributorUserGroup = models.ForeignKey(ContributorUserGroup, on_delete=models.CASCADE, related_name="+") # type: ignore[reportIncompatibleVariableOverride] timestamp_date = models.DateField() # Aggregated Fields total_time = models.IntegerField() # seconds diff --git a/apps/contributor/models.py b/apps/contributor/models.py index 9e72c2f6..7860f3ce 100644 --- a/apps/contributor/models.py +++ b/apps/contributor/models.py @@ -19,7 +19,7 @@ class ContributorUser(models.Model): unique=True, help_text="Firebase User ID", ) - team = models.ForeignKey( + team: "ContributorTeam | None" = models.ForeignKey( # type: ignore[reportIncompatibleVariableOverride] "ContributorTeam", on_delete=models.SET_NULL, null=True, @@ -46,8 +46,8 @@ def __str__(self): class ContributorUserGroupMembership(models.Model): - user_group = models.ForeignKey(ContributorUserGroup, on_delete=models.CASCADE) - user = models.ForeignKey(ContributorUser, on_delete=models.CASCADE) + user_group: ContributorUserGroup = models.ForeignKey(ContributorUserGroup, on_delete=models.CASCADE) # type: ignore[reportIncompatibleVariableOverride] + user: ContributorUser = models.ForeignKey(ContributorUser, on_delete=models.CASCADE) # type: ignore[reportIncompatibleVariableOverride] is_active = models.BooleanField() # Type hints @@ -67,7 +67,7 @@ class ContributorUserGroupMembershipLogActionEnum(models.IntegerChoices): class ContributorUserGroupMembershipLog(models.Model): ACTION = ContributorUserGroupMembershipLogActionEnum - membership = models.ForeignKey(ContributorUserGroupMembership, on_delete=models.CASCADE) + membership: ContributorUserGroupMembership = models.ForeignKey(ContributorUserGroupMembership, on_delete=models.CASCADE) # type: ignore[reportIncompatibleVariableOverride] # Sync with firebase action = IntegerChoicesField(choices_enum=ContributorUserGroupMembershipLogActionEnum) date = models.DateTimeField() diff --git a/apps/mapping/models.py b/apps/mapping/models.py index 3bca2c2d..c0bc1962 100644 --- a/apps/mapping/models.py +++ b/apps/mapping/models.py @@ -28,8 +28,8 @@ class MappingSession(models.Model): # FIXME(tnagorra): We might need to skip the indexing old_id = models.CharField(max_length=30, db_index=True, null=True) - project_task_group = models.ForeignKey(ProjectTaskGroup, on_delete=models.PROTECT) - contributor_user = models.ForeignKey(ContributorUser, on_delete=models.PROTECT) + project_task_group: ProjectTaskGroup = models.ForeignKey(ProjectTaskGroup, on_delete=models.PROTECT) # type: ignore[reportIncompatibleVariableOverride] + contributor_user: ContributorUser = models.ForeignKey(ContributorUser, on_delete=models.PROTECT) # type: ignore[reportIncompatibleVariableOverride] app_version = models.CharField(max_length=10) client_type = IntegerChoicesField(choices_enum=MappingSessionClientTypeEnum) @@ -54,8 +54,8 @@ class MappingSessionUserGroup(models.Model): # FIXME(tnagorra): We might need to skip the indexing old_id = models.CharField(max_length=30, db_index=True, null=True) - mapping_session = models.ForeignKey(MappingSession, on_delete=models.PROTECT) - user_group = models.ForeignKey( + mapping_session: MappingSession = models.ForeignKey(MappingSession, on_delete=models.PROTECT) # type: ignore[reportIncompatibleVariableOverride] + user_group: ContributorUserGroup = models.ForeignKey( # type: ignore[reportIncompatibleVariableOverride] ContributorUserGroup, on_delete=models.PROTECT, related_name="+", @@ -73,8 +73,8 @@ class MappingSessionResult(models.Model): # FIXME(tnagorra): We might need to skip the indexing old_id = models.CharField(max_length=30, db_index=True, null=True) - session = models.ForeignKey(MappingSession, on_delete=models.PROTECT) - project_task = models.ForeignKey(ProjectTask, on_delete=models.PROTECT) + session: MappingSession = models.ForeignKey(MappingSession, on_delete=models.PROTECT) # type: ignore[reportIncompatibleVariableOverride] + project_task: ProjectTaskGroup = models.ForeignKey(ProjectTask, on_delete=models.PROTECT) # type: ignore[reportIncompatibleVariableOverride] result = models.PositiveSmallIntegerField() # TODO(thenav56): Add constraint to make sure we have non-duplicate row with task_id, .session.user_id diff --git a/apps/project/migrations/0001_initial.py b/apps/project/migrations/0001_initial.py index 57819d79..3521790f 100644 --- a/apps/project/migrations/0001_initial.py +++ b/apps/project/migrations/0001_initial.py @@ -76,8 +76,8 @@ class Migration(migrations.Migration): ('client_id', models.CharField(editable=False, max_length=26, unique=True, validators=[apps.common.models.validate_ulid])), ('created_at', models.DateTimeField(auto_now_add=True)), ('modified_at', models.DateTimeField(auto_now=True)), - ('type', django_choices_field.fields.IntegerChoicesField(choices=[(100, 'Input'), (200, 'Output'), (300, 'Stats')], choices_enum=apps.project.models.ProjectAssetTypeEnum)), - ('mimetype', django_choices_field.fields.IntegerChoicesField(choices=[(100, 'application/geo+json'), (201, 'image/jpeg'), (202, 'image/png'), (203, 'image/gif')], choices_enum=apps.project.models.ProjectAssetMimetypeEnum)), + ('type', django_choices_field.fields.IntegerChoicesField(choices=[(100, 'Input'), (200, 'Output'), (300, 'Stats')], choices_enum=apps.common.models.AssetTypeEnum)), + ('mimetype', django_choices_field.fields.IntegerChoicesField(choices=[(100, 'application/geo+json'), (201, 'image/jpeg'), (202, 'image/png'), (203, 'image/gif')], choices_enum=apps.common.models.AssetMimetypeEnum)), ('file', models.FileField(help_text='The file associated with the asset', upload_to=apps.project.models.UploadHelper.project_asset)), ('marked_as_deleted', models.BooleanField(default=False, help_text='If this flag is enabled, this project asset will be deleted in the future')), ], diff --git a/apps/project/migrations/0011_projectasset_file_size.py b/apps/project/migrations/0011_projectasset_file_size.py new file mode 100644 index 00000000..b3606dd8 --- /dev/null +++ b/apps/project/migrations/0011_projectasset_file_size.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.4 on 2025-07-29 04:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('project', '0010_merge_20250728_1145'), + ] + + operations = [ + migrations.AddField( + model_name='projectasset', + name='file_size', + field=models.PositiveIntegerField(default=1, help_text='The size of the file in bytes'), + preserve_default=False, + ), + ] diff --git a/apps/project/migrations/0012_merge_20250730_1323.py b/apps/project/migrations/0012_merge_20250730_1323.py new file mode 100644 index 00000000..72910a1a --- /dev/null +++ b/apps/project/migrations/0012_merge_20250730_1323.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.6 on 2025-07-30 13:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('project', '0011_project_canonical_id'), + ('project', '0011_projectasset_file_size'), + ] + + operations = [ + ] diff --git a/apps/project/models.py b/apps/project/models.py index ddfad78a..52dede3f 100644 --- a/apps/project/models.py +++ b/apps/project/models.py @@ -10,7 +10,7 @@ from django.utils.translation import gettext_lazy from django_choices_field import IntegerChoicesField -from apps.common.models import ArchivableResource, FirebasePushStatusEnum, FirebaseResource, UserResource +from apps.common.models import ArchivableResource, CommonAsset, FirebasePushStatusEnum, FirebaseResource, UserResource from apps.contributor.models import ContributorTeam from utils.fields import validate_percentage @@ -18,33 +18,6 @@ from apps.tutorial.models import Tutorial -class ProjectAssetMimetypeEnum(models.IntegerChoices): - GEOJSON = 100, "application/geo+json" - - IMAGE_JPEG = 201, "image/jpeg" - IMAGE_PNG = 202, "image/png" - IMAGE_GIF = 203, "image/gif" - - @classmethod - def get_display(cls, value: typing.Self | int) -> str: - if value in cls: - return str(cls(value).label) - return "Unknown" - - -# FIXME(tnagorra): Finalize the enum labels -class ProjectAssetTypeEnum(models.IntegerChoices): - INPUT = 100, "Input" - OUTPUT = 200, "Output" - STATS = 300, "Stats" - - @classmethod - def get_display(cls, value: typing.Self | int) -> str: - if value in cls: - return str(cls(value).label) - return "Unknown" - - class ProjectTypeEnum(models.IntegerChoices): FIND = 1, "Find" """ Find project type. Previously known as Classification / Build Area. """ @@ -407,39 +380,18 @@ def clean(self): # self.requiredResults += group.requiredCount * group.numberOfTasks -class ProjectAsset(UserResource): - Type = ProjectAssetTypeEnum - Mimetype = ProjectAssetMimetypeEnum - - type = IntegerChoicesField( - choices_enum=ProjectAssetTypeEnum, - ) - - mimetype = IntegerChoicesField( - choices_enum=ProjectAssetMimetypeEnum, - ) - - file = models.FileField( - upload_to=UploadHelper.project_asset, - help_text=gettext_lazy("The file associated with the asset"), - ) - +class ProjectAsset(UserResource, CommonAsset): # type: ignore[reportIncompatibleVariableOverride] project: Project = models.ForeignKey( # type: ignore[reportAssignmentType] Project, on_delete=models.CASCADE, related_name="+", ) - marked_as_deleted = models.BooleanField( - default=False, - help_text=gettext_lazy("If this flag is enabled, this project asset will be deleted in the future"), + file = models.FileField( + upload_to=UploadHelper.project_asset, + help_text=gettext_lazy("The file associated with the asset"), ) - @classmethod - def usable_objects(cls): - """Returns objects that are mot marked for deletion""" - return cls.objects.filter(marked_as_deleted=False) - # Type hints project_id: int diff --git a/apps/project/serializers.py b/apps/project/serializers.py index 54ff0cf7..792200eb 100644 --- a/apps/project/serializers.py +++ b/apps/project/serializers.py @@ -6,7 +6,7 @@ from rest_framework import serializers from apps.common.models import FirebasePushStatusEnum -from apps.common.serializers import ArchivableResourceSerializer, UserResourceSerializer +from apps.common.serializers import ArchivableResourceSerializer, CommonAssetSerializer, UserResourceSerializer from apps.contributor.models import ContributorTeam from apps.project.firebase import push_organization_to_firebase from apps.tutorial.models import Tutorial @@ -357,8 +357,7 @@ def update(self, instance: Project, validated_data: dict[str, typing.Any]) -> Pr # NOTE: Make sure this matches with the strawberry Input ./graphql/inputs.py -# FIXME(tnagorra): Should we validate the mimetype during upload? -class ProjectAssetSerializer(UserResourceSerializer[ProjectAsset]): +class ProjectAssetSerializer(CommonAssetSerializer, UserResourceSerializer[ProjectAsset]): # type: ignore[reportIncompatibleVariableOverride] class Meta: # type: ignore[reportIncompatibleVariableOverride] model = ProjectAsset fields = ( @@ -369,7 +368,7 @@ class Meta: # type: ignore[reportIncompatibleVariableOverride] @typing.override def create(self, validated_data: dict[str, typing.Any]) -> ProjectAsset: - # NOTE: User should only bye able to create INPUT type project assets + # NOTE: User should only be able to create INPUT type project assets validated_data["type"] = ProjectAsset.Type.INPUT return super().create(validated_data) diff --git a/apps/project/tests/mutation_test.py b/apps/project/tests/mutation_test.py index d8c79470..a929bae3 100644 --- a/apps/project/tests/mutation_test.py +++ b/apps/project/tests/mutation_test.py @@ -6,11 +6,11 @@ from django.core.files.temp import NamedTemporaryFile from ulid import ULID +from apps.common.models import AssetMimetypeEnum from apps.contributor.factories import ContributorTeamFactory from apps.project.factories import OrganizationFactory, ProjectFactory from apps.project.models import ( Project, - ProjectAssetMimetypeEnum, ProjectStatusEnum, ProjectTask, ProjectTaskGroup, @@ -666,7 +666,7 @@ def test_project_update(self, mock_requests): project_asset_data = { "clientId": str(ULID()), "project": str(latest_project.pk), - "mimetype": self.genum(ProjectAssetMimetypeEnum.GEOJSON), + "mimetype": self.genum(AssetMimetypeEnum.GEOJSON), } content = self._create_project_aoi_asset(project_asset_data, assert_errors=True) resp_data = content["data"]["createProjectAsset"] @@ -677,13 +677,20 @@ def test_project_update(self, mock_requests): project_asset_data = { "clientId": str(ULID()), "project": str(latest_project.pk), - "mimetype": self.genum(ProjectAssetMimetypeEnum.IMAGE_JPEG), + "mimetype": self.genum(AssetMimetypeEnum.IMAGE_JPEG), } content = self._create_project_image_asset(project_asset_data, assert_errors=True) resp_data = content["data"]["createProjectAsset"] assert resp_data["errors"] is None, content image_asset = resp_data["result"] + # Change the mimetype + # Fails as mimetype mismatching + project_asset_data["mimetype"] = self.genum(AssetMimetypeEnum.IMAGE_PNG) + content = self._create_project_image_asset(project_asset_data, assert_errors=True) + resp_data = content["data"]["createProjectAsset"] + assert resp_data["errors"] is not None, content + # Updating Project: with empty object as project type specifics project_data = { "clientId": proj.client_id, @@ -978,7 +985,7 @@ def test_project_compare(self, mock_requests): # Creating AOI Project Asset project_asset_data = { "project": project_id, - "mimetype": self.genum(ProjectAssetMimetypeEnum.GEOJSON), + "mimetype": self.genum(AssetMimetypeEnum.GEOJSON), "clientId": str(ULID()), } content = self._create_project_aoi_asset(project_asset_data, assert_errors=True) @@ -989,7 +996,7 @@ def test_project_compare(self, mock_requests): # Creating Project Image Asset project_asset_data = { "project": project_id, - "mimetype": self.genum(ProjectAssetMimetypeEnum.IMAGE_JPEG), + "mimetype": self.genum(AssetMimetypeEnum.IMAGE_JPEG), "clientId": str(ULID()), } content = self._create_project_image_asset(project_asset_data, assert_errors=True) diff --git a/apps/tutorial/admin.py b/apps/tutorial/admin.py index d4413913..c85d8870 100644 --- a/apps/tutorial/admin.py +++ b/apps/tutorial/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from djangoql.admin import DjangoQLSearchMixin -from .models import Tutorial +from .models import Tutorial, TutorialAsset @admin.register(Tutorial) @@ -10,3 +10,8 @@ class TutorialAdmin(DjangoQLSearchMixin, admin.ModelAdmin): list_filter = ("status",) list_select_related = True autocomplete_fields = ("project", "created_by") + + +@admin.register(TutorialAsset) +class ProjectAssetAdmin(DjangoQLSearchMixin, admin.ModelAdmin): + pass diff --git a/apps/tutorial/graphql/filters.py b/apps/tutorial/graphql/filters.py index bc5dea93..f5e3e049 100644 --- a/apps/tutorial/graphql/filters.py +++ b/apps/tutorial/graphql/filters.py @@ -2,7 +2,7 @@ import strawberry_django from apps.project.graphql.filters import ProjectFilter -from apps.tutorial.models import Tutorial +from apps.tutorial.models import Tutorial, TutorialAsset @strawberry_django.filters.filter(Tutorial, lookups=True) @@ -11,3 +11,11 @@ class TutorialFilter: name: strawberry.auto status: strawberry.auto project: ProjectFilter | None + + +@strawberry_django.filters.filter(TutorialAsset, lookups=True) +class TutorialAssetFilter: + id: strawberry.auto + type: strawberry.auto + mimetype: strawberry.auto + tutorial_id: strawberry.auto diff --git a/apps/tutorial/graphql/inputs/inputs.py b/apps/tutorial/graphql/inputs/inputs.py index 14a1a5eb..455723fa 100644 --- a/apps/tutorial/graphql/inputs/inputs.py +++ b/apps/tutorial/graphql/inputs/inputs.py @@ -35,7 +35,6 @@ class TutorialTaskProjectTypeSpecificInput: @strawberry_django.input(TutorialTask) class TutorialTaskCreateInput(UserResourceCreateInputMixin): # NOTE: scenario_id will be referenced from parent - reference: strawberry.auto project_type_specifics: TutorialTaskProjectTypeSpecificInput @@ -43,7 +42,6 @@ class TutorialTaskCreateInput(UserResourceCreateInputMixin): @strawberry_django.partial(TutorialTask) class TutorialTaskUpdateInput(UserResourceUpdateInputMixin): # NOTE: scenario_id will be referenced from parent - reference: strawberry.auto project_type_specifics: TutorialTaskProjectTypeSpecificInput @@ -55,7 +53,6 @@ class TutorialTaskInput(CudInput[TutorialTaskCreateInput, TutorialTaskUpdateInpu @strawberry_django.input(TutorialScenarioPage) class TutorialScenarioPageCreateInput(UserResourceCreateInputMixin): # NOTE: tutorial_id will be referenced from parent - scenario_page_number: strawberry.auto instructions_description: strawberry.auto instructions_icon: strawberry.auto @@ -72,7 +69,6 @@ class TutorialScenarioPageCreateInput(UserResourceCreateInputMixin): @strawberry_django.partial(TutorialScenarioPage) class TutorialScenarioPageUpdateInput(UserResourceUpdateInputMixin): # NOTE: tutorial_id will be referenced from parent - scenario_page_number: strawberry.auto instructions_description: strawberry.auto instructions_icon: strawberry.auto @@ -93,7 +89,6 @@ class TutorialScenarioPageInput(CudInput[TutorialScenarioPageCreateInput, Tutori @strawberry_django.input(TutorialInformationPageBlock) class TutorialInformationPageBlockCreateInput(UserResourceCreateInputMixin): # NOTE: page_id will be referenced from parent - block_number: strawberry.auto block_type: strawberry.auto text: strawberry.auto @@ -119,7 +114,6 @@ class TutorialInformationPageBlockInput( @strawberry_django.input(TutorialInformationPage) class TutorialInformationPageCreateInput(UserResourceCreateInputMixin): # NOTE: tutorial_id will be referenced from parent - title: strawberry.auto page_number: strawberry.auto blocks: list[TutorialInformationPageBlockCreateInput] @@ -128,7 +122,6 @@ class TutorialInformationPageCreateInput(UserResourceCreateInputMixin): @strawberry_django.partial(TutorialInformationPage) class TutorialInformationPageUpdateInput(UserResourceUpdateInputMixin): # NOTE: tutorial_id will be referenced from parent - title: strawberry.auto page_number: strawberry.auto blocks: list[TutorialInformationPageBlockInput] | None = strawberry.UNSET @@ -144,16 +137,11 @@ class TutorialCreateInput(UserResourceCreateInputMixin): project: strawberry.ID name: strawberry.auto - scenarios: list[TutorialScenarioPageCreateInput] - information_pages: list[TutorialInformationPageCreateInput] - # NOTE: Make sure this matches with the serializers ../serializers.py @strawberry_django.partial(Tutorial) class TutorialUpdateInput(UserResourceTopLevelUpdateInputMixin): - project: strawberry.ID name: strawberry.auto status: strawberry.auto - scenarios: list[TutorialScenarioPageInput] | None = strawberry.UNSET information_pages: list[TutorialInformationPageInput] | None = strawberry.UNSET diff --git a/apps/tutorial/graphql/mutations.py b/apps/tutorial/graphql/mutations.py index 6fd31ad4..3ddb3245 100644 --- a/apps/tutorial/graphql/mutations.py +++ b/apps/tutorial/graphql/mutations.py @@ -9,7 +9,7 @@ TutorialScenarioPage, TutorialTask, ) -from apps.tutorial.serializers import TutorialSerializer +from apps.tutorial.serializers import TutorialCreateSerializer, TutorialUpdateSerializer from main.graphql.context import Info from utils.graphql.common import DataclassInstance from utils.graphql.mutations import ModelMutation @@ -25,7 +25,7 @@ class Mutation: @strawberry_django.mutation(extensions=[IsAuthenticated()]) async def create_tutorial(self, info: Info, data: TutorialCreateInput) -> MutationResponseType[TutorialType]: - return await ModelMutation(TutorialSerializer).handle_create_mutation(data, info, None) + return await ModelMutation(TutorialCreateSerializer).handle_create_mutation(data, info, None) @strawberry_django.mutation(extensions=[IsAuthenticated()]) async def update_tutorial( @@ -69,7 +69,7 @@ def transformer(obj: DataclassInstance): if block.delete is not None and block.delete != strawberry.UNSET: await TutorialInformationPageBlock.objects.filter(id=block.delete.id).adelete() - return await ModelMutation(TutorialSerializer).handle_update_mutation( + return await ModelMutation(TutorialUpdateSerializer).handle_update_mutation( data, info, tutorial, diff --git a/apps/tutorial/graphql/orders.py b/apps/tutorial/graphql/orders.py index dec33678..cd278033 100644 --- a/apps/tutorial/graphql/orders.py +++ b/apps/tutorial/graphql/orders.py @@ -1,10 +1,15 @@ import strawberry import strawberry_django -from apps.tutorial.models import Tutorial +from apps.tutorial.models import Tutorial, TutorialAsset @strawberry_django.ordering.order(Tutorial) class TutorialOrder: id: strawberry.auto name: strawberry.auto + + +@strawberry_django.ordering.order(TutorialAsset) +class TutorialAssetOrder: + id: strawberry.auto diff --git a/apps/tutorial/graphql/queries.py b/apps/tutorial/graphql/queries.py index 47f5e7a9..97a8d085 100644 --- a/apps/tutorial/graphql/queries.py +++ b/apps/tutorial/graphql/queries.py @@ -6,9 +6,9 @@ from apps.tutorial.models import Tutorial -from .filters import TutorialFilter -from .orders import TutorialOrder -from .types.types import TutorialType +from .filters import TutorialAssetFilter, TutorialFilter +from .orders import TutorialAssetOrder, TutorialOrder +from .types.types import TutorialAssetType, TutorialType @strawberry.type @@ -31,3 +31,12 @@ def tutorials( if include_all: return Tutorial.objects.all() return Tutorial.objects.filter(status=Tutorial.Status.PUBLISHED).all() + + tutorial_asset: TutorialAssetType = strawberry_django.field(extensions=[IsAuthenticated()]) + + # --- Paginated + tutorial_assets: OffsetPaginated[TutorialAssetType] = strawberry_django.offset_paginated( + order=TutorialAssetOrder, + filters=TutorialAssetFilter, + extensions=[IsAuthenticated()], + ) diff --git a/apps/tutorial/graphql/types/types.py b/apps/tutorial/graphql/types/types.py index 5377ce09..1efcd073 100644 --- a/apps/tutorial/graphql/types/types.py +++ b/apps/tutorial/graphql/types/types.py @@ -8,6 +8,7 @@ from apps.project.models import Project, ProjectTypeEnum from apps.tutorial.models import ( Tutorial, + TutorialAsset, TutorialInformationPage, TutorialInformationPageBlock, TutorialScenarioPage, @@ -126,3 +127,12 @@ class TutorialType(UserResourceTypeMixin): # The tests are failing randomly. scenarios: list[TutorialScenarioPageType] information_pages: list[TutorialInformationPageType] + + +@strawberry_django.type(TutorialAsset) +class TutorialAssetType(UserResourceTypeMixin): + id: strawberry.ID + type: strawberry.auto + file: strawberry.auto + tutorial_id: strawberry.ID + marked_as_deleted: strawberry.auto diff --git a/apps/tutorial/migrations/0006_tutorialasset.py b/apps/tutorial/migrations/0006_tutorialasset.py new file mode 100644 index 00000000..8d184a33 --- /dev/null +++ b/apps/tutorial/migrations/0006_tutorialasset.py @@ -0,0 +1,45 @@ +# Generated by Django 5.1.6 on 2025-07-14 16:10 + +import apps.project.models +import apps.tutorial.models +import django.db.models.deletion +import django.db.models.manager +import django_choices_field.fields +import utils.common +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tutorial', '0005_tutorial_old_id'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TutorialAsset', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('client_id', models.CharField(max_length=26, unique=True, validators=[utils.common.validate_ulid])), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('modified_at', models.DateTimeField(auto_now=True)), + ('type', django_choices_field.fields.IntegerChoicesField(choices=[(100, 'Input'), (200, 'Output'), (300, 'Stats')], choices_enum=apps.common.models.AssetTypeEnum)), + ('mimetype', django_choices_field.fields.IntegerChoicesField(choices=[(100, 'application/geo+json'), (201, 'image/jpeg'), (202, 'image/png'), (203, 'image/gif')], choices_enum=apps.common.models.AssetMimetypeEnum)), + ('file', models.FileField(help_text='The file associated with the asset', upload_to=apps.tutorial.models.UploadHelper.tutorial_asset)), + ('file_size', models.PositiveIntegerField(help_text='The size of the file in bytes')), + ('marked_as_deleted', models.BooleanField(default=False, help_text='If this flag is enabled, this tutorial asset will be deleted in the future')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL)), + ('modified_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL)), + ('tutorial', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='tutorial.tutorial')), + ], + options={ + 'ordering': ['-id'], + 'abstract': False, + }, + managers=[ + ('cte_objects', django.db.models.manager.Manager()), + ], + ), + ] diff --git a/apps/tutorial/migrations/0007_alter_tutorialasset_marked_as_deleted.py b/apps/tutorial/migrations/0007_alter_tutorialasset_marked_as_deleted.py new file mode 100644 index 00000000..e27f494e --- /dev/null +++ b/apps/tutorial/migrations/0007_alter_tutorialasset_marked_as_deleted.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-07-31 02:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tutorial', '0006_tutorialasset'), + ] + + operations = [ + migrations.AlterField( + model_name='tutorialasset', + name='marked_as_deleted', + field=models.BooleanField(default=False, help_text='If this flag is enabled, this project asset will be deleted in the future'), + ), + ] diff --git a/apps/tutorial/models.py b/apps/tutorial/models.py index e34b1d91..9430afe0 100644 --- a/apps/tutorial/models.py +++ b/apps/tutorial/models.py @@ -9,7 +9,7 @@ from django_stubs_ext.db.models.manager import RelatedManager from apps.common.models import IconEnum, UserResource -from apps.project.models import Project +from apps.project.models import CommonAsset, Project class UploadHelper: @@ -17,6 +17,10 @@ class UploadHelper: def information_page_block_image(instance: "TutorialInformationPageBlock", filename: str): return f"tutorial/{instance.page.tutorial_id}/block-image/{ulid.ULID()!s}/{filename}" + @staticmethod + def tutorial_asset(instance: "TutorialAsset", filename: str): + return f"tutorial/{instance.tutorial_id}/asset/{instance.type}/{ulid.ULID()!s}/{filename}" + class TutorialStatusEnum(models.IntegerChoices): DRAFT = 10, "Draft" @@ -49,7 +53,7 @@ class Tutorial(UserResource): Status = TutorialStatusEnum # FIXME(tnagorra): We might need to rename this field - project = models.ForeignKey( + project: Project = models.ForeignKey( # type: ignore[reportAssignmentType] Project, on_delete=models.PROTECT, related_name="+", @@ -72,6 +76,22 @@ def status_enum(self) -> TutorialStatusEnum: return TutorialStatusEnum(self.status) +class TutorialAsset(UserResource, CommonAsset): # type: ignore[reportIncompatibleVariableOverride] + tutorial: Tutorial = models.ForeignKey( # type: ignore[reportAssignmentType] + Tutorial, + on_delete=models.CASCADE, + related_name="+", + ) + + file = models.FileField( + upload_to=UploadHelper.tutorial_asset, + help_text=gettext_lazy("The file associated with the asset"), + ) + + # Type hints + tutorial_id: int + + class TutorialScenarioPage(UserResource): tutorial: Tutorial = models.ForeignKey( # type: ignore[reportAssignmentType] Tutorial, diff --git a/apps/tutorial/serializers.py b/apps/tutorial/serializers.py index 9b4fbc6f..22ac0120 100644 --- a/apps/tutorial/serializers.py +++ b/apps/tutorial/serializers.py @@ -5,13 +5,20 @@ from django.utils.translation import gettext from rest_framework import serializers -from apps.common.serializers import DrfContextType, UserResourceSerializer +from apps.common.serializers import CommonAssetSerializer, DrfContextType, UserResourceSerializer from apps.project.models import Project, ProjectTypeEnum from project_types.store import get_tutorial_task_property from utils.common import clean_up_none_keys from utils.graphql.drf import handle_pydantic_validation_error -from .models import Tutorial, TutorialInformationPage, TutorialInformationPageBlock, TutorialScenarioPage, TutorialTask +from .models import ( + Tutorial, + TutorialAsset, + TutorialInformationPage, + TutorialInformationPageBlock, + TutorialScenarioPage, + TutorialTask, +) class TutorialTaskSerializerContextType(DrfContextType): @@ -282,11 +289,34 @@ def update(self, instance: TutorialInformationPage, validated_data: dict[typing. (Tutorial.Status.DRAFT, Tutorial.Status.PUBLISHED), (Tutorial.Status.DRAFT, Tutorial.Status.DISCARDED), (Tutorial.Status.PUBLISHED, Tutorial.Status.ARCHIVED), + (Tutorial.Status.ARCHIVED, Tutorial.Status.PUBLISHED), ], ) -class TutorialSerializer(UserResourceSerializer[Tutorial]): +# NOTE: Make sure this matches with the strawberry Input ./graphql/inputs.py +class TutorialCreateSerializer(UserResourceSerializer[Tutorial]): + class Meta: # type: ignore[reportIncompatibleVariableOverride] + model = Tutorial + fields = ( + "name", + "project", + ) + + def validate_project(self, project: Project) -> Project: + if self.instance and self.instance.project.project_type_enum != project.project_type_enum: + raise serializers.ValidationError( + gettext("Existing tutorial project type '%s' does not match new project type '%s'") + % ( + Project.Type(self.instance.project.project_type_enum).label, + Project.Type(project.project_type_enum).label, + ), + ) + return project + + +# NOTE: Make sure this matches with the strawberry Input ./graphql/inputs.py +class TutorialUpdateSerializer(UserResourceSerializer[Tutorial]): scenarios = TutorialScenarioPageSerializer(many=True) information_pages = TutorialInformationPageSerializer(many=True) @@ -294,40 +324,30 @@ class Meta: # type: ignore[reportIncompatibleVariableOverride] model = Tutorial fields = ( "name", - "project", "status", "information_pages", "scenarios", ) def validate_status(self, new_status: Tutorial.Status | int) -> Tutorial.Status: - if not self.instance and new_status: - raise serializers.ValidationError( - gettext("Cannot set status for a new tutorial. Status can only be set for existing tutorials."), - ) + assert self.instance is not None, "Tutorial does not exist." if not isinstance(new_status, Tutorial.Status): new_status = Tutorial.Status(new_status) if ( - self.instance - and self.instance.status_enum != new_status + self.instance.status_enum != new_status and (self.instance.status_enum, new_status) not in VALID_TUTORIAL_STATUS_TRANSITIONS ): raise serializers.ValidationError( gettext("Tutorial status cannot be changed from %s to %s") - % (Tutorial.Status(self.instance.status).label, new_status.label), + % ( + self.instance.status_enum.label, + new_status.label, + ), ) return new_status - def validate_project(self, project: Project) -> Project: - if self.instance and self.instance.project and self.instance.project.project_type != project.project_type: - raise serializers.ValidationError( - gettext("Existing tutorial project type '%s' does not match new project type '%s'") - % (Project.Type(self.instance.project.project_type).label, Project.Type(project.project_type).label), - ) - return project - @typing.override def create(self, validated_data: dict[typing.Any, typing.Any]): scenarios_data = self.initial_data["scenarios"] @@ -408,3 +428,20 @@ def update(self, instance: Tutorial, validated_data: dict[typing.Any, typing.Any information_page_serializer.save() return tutorial + + +# NOTE: Make sure this matches with the strawberry Input ./graphql/inputs.py +class TutorialAssetSerializer(CommonAssetSerializer, UserResourceSerializer[TutorialAsset]): # type: ignore[reportIncompatibleVariableOverride] + class Meta: # type: ignore[reportIncompatibleVariableOverride] + model = TutorialAsset + fields = ( + "mimetype", + "file", + "tutorial", + ) + + @typing.override + def create(self, validated_data: dict[str, typing.Any]) -> TutorialAsset: + # NOTE: User should only be able to create INPUT type project assets + validated_data["type"] = TutorialAsset.Type.INPUT + return super().create(validated_data) diff --git a/apps/tutorial/tests/mutation_test.py b/apps/tutorial/tests/mutation_test.py index c49d7f14..4ab5435d 100644 --- a/apps/tutorial/tests/mutation_test.py +++ b/apps/tutorial/tests/mutation_test.py @@ -219,143 +219,180 @@ def test_tutorial_create(self): "clientId": str(ULID()), "name": "My Tutorial", "project": self.project.pk, + } + + # Creating Tutorial: Without authentication + content = self._create_tutorial_mutation(tutorial_data) + assert content["data"]["createTutorial"]["messages"] == [ + { + "code": None, + "field": "createTutorial", + "kind": "PERMISSION", + "message": "User is not authenticated.", + }, + ], content + + # Creating Tutorial: With Authentication + self.force_login(self.user) + content = self._create_tutorial_mutation(tutorial_data) + resp_data = content["data"]["createTutorial"] + assert resp_data["errors"] is None, content + + latest_tutorial = Tutorial.objects.get(pk=resp_data["result"]["id"]) + assert latest_tutorial.status == TutorialStatusEnum.DRAFT + assert latest_tutorial.created_by_id == self.user.pk + assert latest_tutorial.modified_by_id == self.user.pk + + # Update Tutorial + + tutorial_data = { + **tutorial_data, "scenarios": [ { - "clientId": str(ULID()), - "scenarioPageNumber": 1, - "instructionsDescription": "Anything that is not naturally occurring", - "instructionsIcon": "STAR_OUTLINE", - "instructionsTitle": "Identify man-made structures", - "hintDescription": "They have sharp boundaries", - "hintIcon": "INFORMATION_OUTLINE", - "hintTitle": "Look closer!", - "successDescription": "You identified all man-made structures", - "successIcon": "CHECK", - "successTitle": "Well done!", - "tasks": [ - { - "clientId": str(ULID()), - "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193196, "tileY": 110087}}, - "reference": 0, - }, - { - "clientId": str(ULID()), - "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193196, "tileY": 110088}}, - "reference": 1, - }, - { - "clientId": str(ULID()), - "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193196, "tileY": 110089}}, - "reference": 0, - }, - { - "clientId": str(ULID()), - "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193197, "tileY": 110087}}, - "reference": 0, - }, - { - "clientId": str(ULID()), - "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193197, "tileY": 110088}}, - "reference": 1, - }, - { - "clientId": str(ULID()), - "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193197, "tileY": 110089}}, - "reference": 0, - }, - ], + "create": { + "clientId": str(ULID()), + "scenarioPageNumber": 1, + "instructionsDescription": "Anything that is not naturally occurring", + "instructionsIcon": "STAR_OUTLINE", + "instructionsTitle": "Identify man-made structures", + "hintDescription": "They have sharp boundaries", + "hintIcon": "INFORMATION_OUTLINE", + "hintTitle": "Look closer!", + "successDescription": "You identified all man-made structures", + "successIcon": "CHECK", + "successTitle": "Well done!", + "tasks": [ + { + "clientId": str(ULID()), + "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193196, "tileY": 110087}}, + "reference": 0, + }, + { + "clientId": str(ULID()), + "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193196, "tileY": 110088}}, + "reference": 1, + }, + { + "clientId": str(ULID()), + "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193196, "tileY": 110089}}, + "reference": 0, + }, + { + "clientId": str(ULID()), + "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193197, "tileY": 110087}}, + "reference": 0, + }, + { + "clientId": str(ULID()), + "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193197, "tileY": 110088}}, + "reference": 1, + }, + { + "clientId": str(ULID()), + "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193197, "tileY": 110089}}, + "reference": 0, + }, + ], + }, }, { - "clientId": str(ULID()), - "scenarioPageNumber": 2, - "instructionsDescription": "Anything that is not naturally occurring", - "instructionsIcon": "STAR_OUTLINE", - "instructionsTitle": "Identify natural structures", - "hintDescription": "They have sharp boundaries", - "hintIcon": "INFORMATION_OUTLINE", - "hintTitle": "Look closer!", - "successDescription": "You identified all natural structures", - "successIcon": "CHECK", - "successTitle": "Well done!", - "tasks": [ - { - "clientId": str(ULID()), - "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193204, "tileY": 110087}}, - "reference": 1, - }, - { - "clientId": str(ULID()), - "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193204, "tileY": 110088}}, - "reference": 1, - }, - { - "clientId": str(ULID()), - "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193204, "tileY": 110089}}, - "reference": 1, - }, - { - "clientId": str(ULID()), - "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193205, "tileY": 110087}}, - "reference": 1, - }, - { - "clientId": str(ULID()), - "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193205, "tileY": 110088}}, - "reference": 1, - }, - { - "clientId": str(ULID()), - "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193205, "tileY": 110089}}, - "reference": 1, - }, - ], + "create": { + "clientId": str(ULID()), + "scenarioPageNumber": 2, + "instructionsDescription": "Anything that is not naturally occurring", + "instructionsIcon": "STAR_OUTLINE", + "instructionsTitle": "Identify natural structures", + "hintDescription": "They have sharp boundaries", + "hintIcon": "INFORMATION_OUTLINE", + "hintTitle": "Look closer!", + "successDescription": "You identified all natural structures", + "successIcon": "CHECK", + "successTitle": "Well done!", + "tasks": [ + { + "clientId": str(ULID()), + "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193204, "tileY": 110087}}, + "reference": 1, + }, + { + "clientId": str(ULID()), + "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193204, "tileY": 110088}}, + "reference": 1, + }, + { + "clientId": str(ULID()), + "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193204, "tileY": 110089}}, + "reference": 1, + }, + { + "clientId": str(ULID()), + "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193205, "tileY": 110087}}, + "reference": 1, + }, + { + "clientId": str(ULID()), + "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193205, "tileY": 110088}}, + "reference": 1, + }, + { + "clientId": str(ULID()), + "projectTypeSpecifics": {"find": {"tileZ": 18, "tileX": 193205, "tileY": 110089}}, + "reference": 1, + }, + ], + }, }, ], "informationPages": [ { - "clientId": str(ULID()), - "title": "Man-made structures", - "pageNumber": 1, - "blocks": [ - { - "clientId": str(ULID()), - "blockNumber": 1, - "blockType": "TEXT", - "text": "Man-made structures are physical constructions created by humans, typically " - "using tools, materials, and engineering principles.", - }, - { - "clientId": str(ULID()), - "blockNumber": 2, - "blockType": "TEXT", - "text": "These structures are built to serve specific purposes, such as housing, " - "transportation, defense, communication, or recreation.", - }, - ], + "create": { + "clientId": str(ULID()), + "title": "Man-made structures", + "pageNumber": 1, + "blocks": [ + { + "clientId": str(ULID()), + "blockNumber": 1, + "blockType": "TEXT", + "text": "Man-made structures are physical constructions created by humans, typically " + "using tools, materials, and engineering principles.", + }, + { + "clientId": str(ULID()), + "blockNumber": 2, + "blockType": "TEXT", + "text": "These structures are built to serve specific purposes, such as housing, " + "transportation, defense, communication, or recreation.", + }, + ], + }, }, { - "clientId": str(ULID()), - "title": "Natural structures", - "pageNumber": 2, - "blocks": [ - { - "clientId": str(ULID()), - "blockNumber": 1, - "blockType": "TEXT", - "text": "Natural structures are physical formations that are created by nature " - "without human intervention", - }, - ], + "create": { + "clientId": str(ULID()), + "title": "Natural structures", + "pageNumber": 2, + "blocks": [ + { + "clientId": str(ULID()), + "blockNumber": 1, + "blockType": "TEXT", + "text": "Natural structures are physical formations that are created by nature " + "without human intervention", + }, + ], + }, }, ], } + tutorial_data.pop("project") - # Creating Tutorial: Without authentication - content = self._create_tutorial_mutation(tutorial_data) - assert content["data"]["createTutorial"]["messages"] == [ + self.logout() + content = self._update_tutorial_mutation(str(latest_tutorial.pk), tutorial_data) + assert content["data"]["updateTutorial"]["messages"] == [ { "code": None, - "field": "createTutorial", + "field": "updateTutorial", "kind": "PERMISSION", "message": "User is not authenticated.", }, @@ -363,13 +400,11 @@ def test_tutorial_create(self): # Creating Tutorial: With Authentication self.force_login(self.user) - content = self._create_tutorial_mutation(tutorial_data) - resp_data = content["data"]["createTutorial"] + content = self._update_tutorial_mutation(str(latest_tutorial.pk), tutorial_data) + resp_data = content["data"]["updateTutorial"] assert resp_data["errors"] is None, content - latest_tutorial = Tutorial.objects.get(pk=resp_data["result"]["id"]) - assert latest_tutorial.created_by_id == self.user.pk - assert latest_tutorial.modified_by_id == self.user.pk + latest_tutorial.refresh_from_db() assert resp_data == self.g_mutation_response( ok=True, @@ -433,11 +468,11 @@ def get_update_for_task(tut: dict): # Updating Tutorial: Without authentication tutorial_from_res = resp_data["result"] - project = tutorial_from_res.pop("projectId") + tutorial_from_res.pop("projectId") tutorial_from_res.pop("id") tutorial_data = { **tutorial_from_res, - "project": project, + "status": self.genum(TutorialStatusEnum.PUBLISHED), "scenarios": [ { "update": { @@ -519,7 +554,7 @@ def get_update_for_task(tut: dict): }, ], content - # Creating Tutorial: With Authentication + # Updating Tutorial: With Authentication self.force_login(self.user) content = self._update_tutorial_mutation(str(latest_tutorial.pk), tutorial_data) resp_data = content["data"]["updateTutorial"] @@ -532,7 +567,7 @@ def get_update_for_task(tut: dict): result=dict( clientId=latest_tutorial.client_id, id=self.gID(latest_tutorial.pk), - status=self.genum(TutorialStatusEnum.DRAFT), + status=self.genum(TutorialStatusEnum.PUBLISHED), projectId=self.gID(latest_tutorial.project_id), scenarios=[ { @@ -583,21 +618,6 @@ def get_update_for_task(tut: dict): ), ), content - # Test the tutorial with different project_type - compare_project = ProjectFactory.create( - **self.user_resource_kwargs, - project_type=ProjectTypeEnum.COMPARE, - requesting_organization=self.organization, - topic="Compare Project 101", - look_for="", - additional_info_url="https://hi-there/about.html", - description="The new **project** from hi-there.", - project_type_specifics=None, - ) - tutorial_data["project"] = self.gID(compare_project.pk) - content = self._update_tutorial_mutation(str(latest_tutorial.pk), tutorial_data) - assert content["data"]["updateTutorial"]["errors"] is not None, content - def test_tutorial_state_transitions(self): # Create a draft tutorial tutorial = TutorialFactory.create( @@ -616,7 +636,6 @@ def test_tutorial_state_transitions(self): tutorial.save(update_fields=["status"]) data = { "clientId": tutorial.client_id, - "project": self.gID(tutorial.project_id), "status": self.genum(new_status), } response = self._update_tutorial_mutation(str(tutorial.pk), data) @@ -628,7 +647,8 @@ def test_tutorial_state_transitions(self): invalid_transitions = [ (TutorialStatusEnum.PUBLISHED, TutorialStatusEnum.DRAFT), - (TutorialStatusEnum.ARCHIVED, TutorialStatusEnum.PUBLISHED), + (TutorialStatusEnum.DISCARDED, TutorialStatusEnum.DRAFT), + (TutorialStatusEnum.ARCHIVED, TutorialStatusEnum.DRAFT), (TutorialStatusEnum.DRAFT, TutorialStatusEnum.ARCHIVED), ] @@ -637,7 +657,6 @@ def test_tutorial_state_transitions(self): tutorial.save(update_fields=["status"]) data = { "clientId": tutorial.client_id, - "project": self.gID(tutorial.project_id), "status": self.genum(new_status), } response = self._update_tutorial_mutation(str(tutorial.pk), data) diff --git a/main/graphql/enums.py b/main/graphql/enums.py index 5007fbb9..13a6a7a4 100644 --- a/main/graphql/enums.py +++ b/main/graphql/enums.py @@ -2,6 +2,7 @@ import strawberry +from apps.common import models as common_models from apps.common.models import IconEnum from apps.contributor import models as contributor_models from apps.mapping import models as mapping_models @@ -21,8 +22,8 @@ project_models.ProjectTypeEnum, project_models.ProjectStatusEnum, project_models.ProjectProcessingStatusEnum, - project_models.ProjectAssetMimetypeEnum, - project_models.ProjectAssetTypeEnum, + common_models.AssetMimetypeEnum, + common_models.AssetTypeEnum, tutorial_models.TutorialStatusEnum, tutorial_models.TutorialInformationPageBlockTypeEnum, mapping_models.MappingSessionClientTypeEnum, diff --git a/project_types/tile_map_service/base/project.py b/project_types/tile_map_service/base/project.py index de739213..25ccabdc 100644 --- a/project_types/tile_map_service/base/project.py +++ b/project_types/tile_map_service/base/project.py @@ -13,11 +13,13 @@ from pyfirebase_mapswipe import models as firebase_models from ulid import ULID +from apps.common.models import ( + AssetMimetypeEnum, + AssetTypeEnum, +) from apps.project.models import ( Project, ProjectAsset, - ProjectAssetMimetypeEnum, - ProjectAssetTypeEnum, ProjectTask, ProjectTaskGroup, ) @@ -47,8 +49,8 @@ def check_aoi_geometry_exists(self, info: ValidationInfo) -> typing.Self: ProjectAsset.usable_objects() .filter( id=self.aoi_geometry, - type=ProjectAssetTypeEnum.INPUT, - mimetype=ProjectAssetMimetypeEnum.GEOJSON, + type=AssetTypeEnum.INPUT, + mimetype=AssetMimetypeEnum.GEOJSON, project_id=project_id, ) .exists() @@ -132,8 +134,9 @@ def get_feature(task: ProjectTask): client_id=str(ULID()), project=self.project, file=file, - type=ProjectAssetTypeEnum.OUTPUT, - mimetype=ProjectAssetMimetypeEnum.GEOJSON, + file_size=file.size, + type=AssetTypeEnum.OUTPUT, + mimetype=AssetMimetypeEnum.GEOJSON, # FIXME(tnagorra): Maybe create a internal user like mapswipe-bot created_by=self.project.modified_by, modified_by=self.project.modified_by, @@ -211,8 +214,8 @@ def validate(self): aoi_asset = ProjectAsset.usable_objects().get( id=self.project_type_specifics.aoi_geometry, - type=ProjectAssetTypeEnum.INPUT, - mimetype=ProjectAssetMimetypeEnum.GEOJSON, + type=AssetTypeEnum.INPUT, + mimetype=AssetMimetypeEnum.GEOJSON, project_id=self.project.pk, ) diff --git a/project_types/validate/project.py b/project_types/validate/project.py index b73c7962..9bb08f52 100644 --- a/project_types/validate/project.py +++ b/project_types/validate/project.py @@ -14,11 +14,13 @@ from typing_extensions import TypedDict from ulid import ULID +from apps.common.models import ( + AssetMimetypeEnum, + AssetTypeEnum, +) from apps.project.models import ( Project, ProjectAsset, - ProjectAssetMimetypeEnum, - ProjectAssetTypeEnum, ProjectTask, ProjectTaskGroup, ) @@ -195,8 +197,8 @@ def _validate_aoi_geojson_file(self): aoi_asset = ProjectAsset.usable_objects().get( id=self.project_type_specifics.object_source.aoi_geometry, - type=ProjectAssetTypeEnum.INPUT, - mimetype=ProjectAssetMimetypeEnum.GEOJSON, + type=AssetTypeEnum.INPUT, + mimetype=AssetMimetypeEnum.GEOJSON, project_id=self.project.pk, ) @@ -322,8 +324,9 @@ def get_feature(task: ProjectTask): client_id=str(ULID()), project=self.project, file=file, - type=ProjectAssetTypeEnum.OUTPUT, - mimetype=ProjectAssetMimetypeEnum.GEOJSON, + file_size=file.size, + type=AssetTypeEnum.OUTPUT, + mimetype=AssetMimetypeEnum.GEOJSON, # FIXME(tnagorra): Maybe create a internal user like mapswipe-bot created_by=self.project.modified_by, modified_by=self.project.modified_by, diff --git a/schema.graphql b/schema.graphql index aaaeebc2..df99321c 100644 --- a/schema.graphql +++ b/schema.graphql @@ -21,14 +21,24 @@ type AppEnumCollection { ProjectTypeEnum: [AppEnumCollectionProjectTypeEnum!]! ProjectStatusEnum: [AppEnumCollectionProjectStatusEnum!]! ProjectProcessingStatusEnum: [AppEnumCollectionProjectProcessingStatusEnum!]! - ProjectAssetMimetypeEnum: [AppEnumCollectionProjectAssetMimetypeEnum!]! - ProjectAssetTypeEnum: [AppEnumCollectionProjectAssetTypeEnum!]! + AssetMimetypeEnum: [AppEnumCollectionAssetMimetypeEnum!]! + AssetTypeEnum: [AppEnumCollectionAssetTypeEnum!]! TutorialStatusEnum: [AppEnumCollectionTutorialStatusEnum!]! TutorialInformationPageBlockTypeEnum: [AppEnumCollectionTutorialInformationPageBlockTypeEnum!]! MappingSessionClientTypeEnum: [AppEnumCollectionMappingSessionClientTypeEnum!]! ContributorUserGroupMembershipLogActionEnum: [AppEnumCollectionContributorUserGroupMembershipLogActionEnum!]! } +type AppEnumCollectionAssetMimetypeEnum { + key: AssetMimetypeEnum! + label: String! +} + +type AppEnumCollectionAssetTypeEnum { + key: AssetTypeEnum! + label: String! +} + type AppEnumCollectionContributorUserGroupMembershipLogActionEnum { key: ContributorUserGroupMembershipLogActionEnum! label: String! @@ -49,16 +59,6 @@ type AppEnumCollectionOverlayLayerTypeEnum { label: String! } -type AppEnumCollectionProjectAssetMimetypeEnum { - key: ProjectAssetMimetypeEnum! - label: String! -} - -type AppEnumCollectionProjectAssetTypeEnum { - key: ProjectAssetTypeEnum! - label: String! -} - type AppEnumCollectionProjectProcessingStatusEnum { key: ProjectProcessingStatusEnum! label: String! @@ -101,6 +101,115 @@ type AppEnumCollectionVectorTileServerNameEnum { scalar AreaSqKm +enum AssetMimetypeEnum { + GEOJSON + IMAGE_JPEG + IMAGE_PNG + IMAGE_GIF +} + +input AssetMimetypeEnumFilterLookup { + """Exact match. Filter will be skipped on `null` value""" + exact: AssetMimetypeEnum + + """Assignment test. Filter will be skipped on `null` value""" + isNull: Boolean + + """ + Exact match of items in a given list. Filter will be skipped on `null` value + """ + inList: [AssetMimetypeEnum!] + + """Case-insensitive exact match. Filter will be skipped on `null` value""" + iExact: AssetMimetypeEnum + + """ + Case-sensitive containment test. Filter will be skipped on `null` value + """ + contains: AssetMimetypeEnum + + """ + Case-insensitive containment test. Filter will be skipped on `null` value + """ + iContains: AssetMimetypeEnum + + """Case-sensitive starts-with. Filter will be skipped on `null` value""" + startsWith: AssetMimetypeEnum + + """Case-insensitive starts-with. Filter will be skipped on `null` value""" + iStartsWith: AssetMimetypeEnum + + """Case-sensitive ends-with. Filter will be skipped on `null` value""" + endsWith: AssetMimetypeEnum + + """Case-insensitive ends-with. Filter will be skipped on `null` value""" + iEndsWith: AssetMimetypeEnum + + """ + Case-sensitive regular expression match. Filter will be skipped on `null` value + """ + regex: AssetMimetypeEnum + + """ + Case-insensitive regular expression match. Filter will be skipped on `null` value + """ + iRegex: AssetMimetypeEnum +} + +enum AssetTypeEnum { + INPUT + OUTPUT + STATS +} + +input AssetTypeEnumFilterLookup { + """Exact match. Filter will be skipped on `null` value""" + exact: AssetTypeEnum + + """Assignment test. Filter will be skipped on `null` value""" + isNull: Boolean + + """ + Exact match of items in a given list. Filter will be skipped on `null` value + """ + inList: [AssetTypeEnum!] + + """Case-insensitive exact match. Filter will be skipped on `null` value""" + iExact: AssetTypeEnum + + """ + Case-sensitive containment test. Filter will be skipped on `null` value + """ + contains: AssetTypeEnum + + """ + Case-insensitive containment test. Filter will be skipped on `null` value + """ + iContains: AssetTypeEnum + + """Case-sensitive starts-with. Filter will be skipped on `null` value""" + startsWith: AssetTypeEnum + + """Case-insensitive starts-with. Filter will be skipped on `null` value""" + iStartsWith: AssetTypeEnum + + """Case-sensitive ends-with. Filter will be skipped on `null` value""" + endsWith: AssetTypeEnum + + """Case-insensitive ends-with. Filter will be skipped on `null` value""" + iEndsWith: AssetTypeEnum + + """ + Case-sensitive regular expression match. Filter will be skipped on `null` value + """ + regex: AssetTypeEnum + + """ + Case-insensitive regular expression match. Filter will be skipped on `null` value + """ + iRegex: AssetTypeEnum +} + input BoolBaseFilterLookup { """Exact match. Filter will be skipped on `null` value""" exact: Boolean @@ -850,11 +959,11 @@ input ProcessedProjectUpdateInput { } """ -ProjectAsset(id, client_id, created_at, modified_at, created_by, modified_by, type, mimetype, file, project, marked_as_deleted) +ProjectAsset(id, client_id, created_at, modified_at, created_by, modified_by, type, mimetype, file_size, marked_as_deleted, project, file) """ input ProjectAssetCreateInput { clientId: String! - mimetype: ProjectAssetMimetypeEnum! + mimetype: AssetMimetypeEnum! """The file associated with the asset""" file: Upload! @@ -862,12 +971,12 @@ input ProjectAssetCreateInput { } """ -ProjectAsset(id, client_id, created_at, modified_at, created_by, modified_by, type, mimetype, file, project, marked_as_deleted) +ProjectAsset(id, client_id, created_at, modified_at, created_by, modified_by, type, mimetype, file_size, marked_as_deleted, project, file) """ input ProjectAssetFilter { id: IDBaseFilterLookup - type: ProjectAssetTypeEnumFilterLookup - mimetype: ProjectAssetMimetypeEnumFilterLookup + type: AssetTypeEnumFilterLookup + mimetype: AssetMimetypeEnumFilterLookup projectId: IDBaseFilterLookup AND: ProjectAssetFilter OR: ProjectAssetFilter @@ -875,67 +984,12 @@ input ProjectAssetFilter { DISTINCT: Boolean } -enum ProjectAssetMimetypeEnum { - GEOJSON - IMAGE_JPEG - IMAGE_PNG - IMAGE_GIF -} - -input ProjectAssetMimetypeEnumFilterLookup { - """Exact match. Filter will be skipped on `null` value""" - exact: ProjectAssetMimetypeEnum - - """Assignment test. Filter will be skipped on `null` value""" - isNull: Boolean - - """ - Exact match of items in a given list. Filter will be skipped on `null` value - """ - inList: [ProjectAssetMimetypeEnum!] - - """Case-insensitive exact match. Filter will be skipped on `null` value""" - iExact: ProjectAssetMimetypeEnum - - """ - Case-sensitive containment test. Filter will be skipped on `null` value - """ - contains: ProjectAssetMimetypeEnum - - """ - Case-insensitive containment test. Filter will be skipped on `null` value - """ - iContains: ProjectAssetMimetypeEnum - - """Case-sensitive starts-with. Filter will be skipped on `null` value""" - startsWith: ProjectAssetMimetypeEnum - - """Case-insensitive starts-with. Filter will be skipped on `null` value""" - iStartsWith: ProjectAssetMimetypeEnum - - """Case-sensitive ends-with. Filter will be skipped on `null` value""" - endsWith: ProjectAssetMimetypeEnum - - """Case-insensitive ends-with. Filter will be skipped on `null` value""" - iEndsWith: ProjectAssetMimetypeEnum - - """ - Case-sensitive regular expression match. Filter will be skipped on `null` value - """ - regex: ProjectAssetMimetypeEnum - - """ - Case-insensitive regular expression match. Filter will be skipped on `null` value - """ - iRegex: ProjectAssetMimetypeEnum -} - input ProjectAssetOrder { id: Ordering } """ -ProjectAsset(id, client_id, created_at, modified_at, created_by, modified_by, type, mimetype, file, project, marked_as_deleted) +ProjectAsset(id, client_id, created_at, modified_at, created_by, modified_by, type, mimetype, file_size, marked_as_deleted, project, file) """ type ProjectAssetType implements UserResourceTypeMixin { clientId: String! @@ -944,11 +998,11 @@ type ProjectAssetType implements UserResourceTypeMixin { createdBy: UserType! modifiedBy: UserType! id: ID! - type: ProjectAssetTypeEnum! + type: AssetTypeEnum! """The file associated with the asset""" file: MapswipeDjangoFileType! - mimetype: ProjectAssetMimetypeEnum! + mimetype: AssetMimetypeEnum! projectId: ID! """ @@ -957,60 +1011,6 @@ type ProjectAssetType implements UserResourceTypeMixin { markedAsDeleted: Boolean! } -enum ProjectAssetTypeEnum { - INPUT - OUTPUT - STATS -} - -input ProjectAssetTypeEnumFilterLookup { - """Exact match. Filter will be skipped on `null` value""" - exact: ProjectAssetTypeEnum - - """Assignment test. Filter will be skipped on `null` value""" - isNull: Boolean - - """ - Exact match of items in a given list. Filter will be skipped on `null` value - """ - inList: [ProjectAssetTypeEnum!] - - """Case-insensitive exact match. Filter will be skipped on `null` value""" - iExact: ProjectAssetTypeEnum - - """ - Case-sensitive containment test. Filter will be skipped on `null` value - """ - contains: ProjectAssetTypeEnum - - """ - Case-insensitive containment test. Filter will be skipped on `null` value - """ - iContains: ProjectAssetTypeEnum - - """Case-sensitive starts-with. Filter will be skipped on `null` value""" - startsWith: ProjectAssetTypeEnum - - """Case-insensitive starts-with. Filter will be skipped on `null` value""" - iStartsWith: ProjectAssetTypeEnum - - """Case-sensitive ends-with. Filter will be skipped on `null` value""" - endsWith: ProjectAssetTypeEnum - - """Case-insensitive ends-with. Filter will be skipped on `null` value""" - iEndsWith: ProjectAssetTypeEnum - - """ - Case-sensitive regular expression match. Filter will be skipped on `null` value - """ - regex: ProjectAssetTypeEnum - - """ - Case-insensitive regular expression match. Filter will be skipped on `null` value - """ - iRegex: ProjectAssetTypeEnum -} - type ProjectAssetTypeMutationResponseType { ok: Boolean! errors: CustomErrorType @@ -1530,6 +1530,8 @@ type Query { organizations(includeAll: Boolean! = false, filters: OrganizationFilter, order: OrganizationOrder, pagination: OffsetPaginationInput): OrganizationTypeOffsetPaginated! @isAuthenticated projects(includeAll: Boolean! = false, filters: ProjectFilter, order: ProjectOrder, pagination: OffsetPaginationInput): ProjectTypeOffsetPaginated! @isAuthenticated tutorial(id: ID!): TutorialType! @isAuthenticated + tutorialAsset(id: ID!): TutorialAssetType! @isAuthenticated + tutorialAssets(pagination: OffsetPaginationInput, filters: TutorialAssetFilter, order: TutorialAssetOrder): TutorialAssetTypeOffsetPaginated! @isAuthenticated tutorials(includeAll: Boolean! = false, filters: TutorialFilter, order: TutorialOrder, pagination: OffsetPaginationInput): TutorialTypeOffsetPaginated! @isAuthenticated contributorUsers(pagination: OffsetPaginationInput, filters: ContributorUserFilter, order: ContributorUserOrder): ContributorUserTypeOffsetPaginated! contributorUser(id: ID!): ContributorUserType! @@ -1628,6 +1630,56 @@ input StrFilterLookup { iRegex: String } +""" +TutorialAsset(id, client_id, created_at, modified_at, created_by, modified_by, type, mimetype, file_size, marked_as_deleted, tutorial, file) +""" +input TutorialAssetFilter { + id: IDBaseFilterLookup + type: AssetTypeEnumFilterLookup + mimetype: AssetMimetypeEnumFilterLookup + tutorialId: IDBaseFilterLookup + AND: TutorialAssetFilter + OR: TutorialAssetFilter + NOT: TutorialAssetFilter + DISTINCT: Boolean +} + +input TutorialAssetOrder { + id: Ordering +} + +""" +TutorialAsset(id, client_id, created_at, modified_at, created_by, modified_by, type, mimetype, file_size, marked_as_deleted, tutorial, file) +""" +type TutorialAssetType implements UserResourceTypeMixin { + clientId: String! + createdAt: DateTime! + modifiedAt: DateTime! + createdBy: UserType! + modifiedBy: UserType! + id: ID! + type: AssetTypeEnum! + + """The file associated with the asset""" + file: MapswipeDjangoFileType! + tutorialId: ID! + + """ + If this flag is enabled, this project asset will be deleted in the future + """ + markedAsDeleted: Boolean! +} + +type TutorialAssetTypeOffsetPaginated { + pageInfo: OffsetPaginationInfo! + + """Total count of existing results.""" + totalCount: Int! + + """List of paginated results.""" + results: [TutorialAssetType!]! +} + """ Tutorial(id, client_id, created_at, modified_at, created_by, modified_by, old_id, project, name, status) """ @@ -1637,8 +1689,6 @@ input TutorialCreateInput { """Project this tutorial is referring to.""" project: ID! name: String! - scenarios: [TutorialScenarioPageCreateInput!]! - informationPages: [TutorialInformationPageCreateInput!]! } """ @@ -1969,9 +2019,6 @@ Tutorial(id, client_id, created_at, modified_at, created_by, modified_by, old_id """ input TutorialUpdateInput { clientId: String! - - """Project this tutorial is referring to.""" - project: ID! name: String status: TutorialStatusEnum scenarios: [TutorialScenarioPageInput!]