diff --git a/apps/common/admin.py b/apps/common/admin.py index a1a74b70..eb3db9f5 100644 --- a/apps/common/admin.py +++ b/apps/common/admin.py @@ -10,6 +10,19 @@ DjangoModel = typing.TypeVar("DjangoModel", bound=models.Model) +# FIXME(tnagorra): Do we use Mixin or extend admin.ModelAdmin +# TODO(tnagorra): Use readonly mixin +class ReadOnlyMixin: + def has_add_permission(self, *args, **kwargs): + return False + + def has_change_permission(self, *args, **kwargs): + return False + + def has_delete_permission(self, *args, **kwargs): + return False + + class UserResourceAdmin(admin.ModelAdmin): @typing.override def get_readonly_fields(self, *args, **kwargs): diff --git a/apps/common/firebase.py b/apps/common/firebase.py new file mode 100644 index 00000000..cea76e6c --- /dev/null +++ b/apps/common/firebase.py @@ -0,0 +1,85 @@ +import abc +import logging +import typing + +from firebase_admin.db import Reference as FbReference + +from apps.common.models import FirebasePushResource, FirebasePushStatusEnum +from main.celery import app +from main.config import Config + +logger = logging.getLogger(__name__) + + +class InvalidObjectPushException(Exception): ... + + +class FirebasePush[T: FirebasePushResource](abc.ABC): + model: type[T] + + def __init__( + self, + obj_id: int, + ): + self.obj_id = obj_id + + @abc.abstractmethod + def handle_new_object_on_firebase(self, model_obj: T, fb_reference: FbReference): ... + + @abc.abstractmethod + def handle_object_update_on_firebase(self, model_obj: T, fb_reference: FbReference): ... + + @abc.abstractmethod + def get_firebase_path(self, firebase_id: str, model: type[T]) -> str: ... + + def push(self) -> None: + model_obj = self.model.objects.get(id=self.obj_id) + + model_obj.update_firebase_push_status(FirebasePushStatusEnum.PROCESSING) + + try: + model_ref = Config.FIREBASE_HELPER.ref( + self.get_firebase_path(model_obj.firebase_id, self.model), + ) + fb_model: typing.Any = model_ref.get() + + if not model_obj.firebase_last_pushed: + if fb_model is not None: + logger.error( + "Firebase creation error: existing %s found", + model_obj._meta.label, + extra={"model_obj_id": model_obj.pk}, + ) + raise InvalidObjectPushException + self.handle_new_object_on_firebase(model_obj, model_ref) + else: + if fb_model is None: + logger.error( + "Firebase update error: missing %s in Firebase", + model_obj._meta.label, + extra={"model_obj_id": model_obj.pk}, + ) + raise InvalidObjectPushException + self.handle_object_update_on_firebase(model_obj, model_ref) + + except InvalidObjectPushException: + model_obj.update_firebase_push_status(FirebasePushStatusEnum.FAILED) + except Exception: + logger.error( + "Unexpected error while pushing to Firebase", + extra={"model_obj_id": model_obj.pk}, + exc_info=True, + ) + model_obj.update_firebase_push_status(FirebasePushStatusEnum.FAILED) + else: + model_obj.update_firebase_push_status(FirebasePushStatusEnum.SUCCESS) + + # FIXME: Implement init subclass to check if abstract methods are implemented on subclasses + @staticmethod + @abc.abstractmethod + @app.task() + def task(obj_id: int) -> None: + """ + if you define this func in multiple classes in the same file celery will always use the first one + """ + ... diff --git a/apps/common/models.py b/apps/common/models.py index 1ac46c3f..4a35227b 100644 --- a/apps/common/models.py +++ b/apps/common/models.py @@ -2,11 +2,11 @@ import typing from django.db import models -from django.db.models.functions import Cast, Coalesce from django.utils import timezone from django.utils.translation import gettext_lazy from django_choices_field import IntegerChoicesField from django_stubs_ext.db.models import TypedModelMeta +from ulid import ULID from apps.user.models import User from main.db import Model @@ -114,9 +114,12 @@ class IconEnum(models.IntegerChoices): SWIPE_LEFT = 32, "swipe-left" -class FirebaseResource(Model): +class FirebasePushResource(Model): + # NOTE: We should not directly use old_id. This is ID reference to old system old_id = models.CharField(max_length=30, db_index=True, null=True, blank=True) + firebase_id = models.CharField(max_length=30, unique=True, default=ULID) + firebase_push_status: int | None = IntegerChoicesField( # type: ignore[reportAssignmentType] choices_enum=FirebasePushStatusEnum, null=True, @@ -125,17 +128,7 @@ class FirebaseResource(Model): firebase_last_pushed = models.DateTimeField( null=True, blank=True, - help_text=gettext_lazy("The latest time when project was pushed to firebase"), - ) - - canonical_id = models.GeneratedField( # type: ignore[reportAttributeAccessIssue] - expression=Coalesce( - models.F("old_id"), - Cast(models.F("id"), models.CharField()), - ), - output_field=models.CharField(), - db_persist=True, - unique=True, + help_text=gettext_lazy("The latest time when resource was pushed to firebase"), ) @property @@ -163,6 +156,19 @@ class Meta(TypedModelMeta): # type: ignore[reportIncompatibleVariableOverride] abstract = True +class FirebasePullResource(Model): + firebase_id = models.CharField(max_length=30, unique=True) + + firebase_last_pulled = models.DateTimeField( + null=True, + blank=True, + help_text=gettext_lazy("The latest time when resource was pulled from firebase"), + ) + + class Meta(TypedModelMeta): # type: ignore[reportIncompatibleVariableOverride] + abstract = True + + class AssetMimetypeEnum(models.IntegerChoices): GEOJSON = 100, "application/geo+json" diff --git a/apps/common/tasks.py b/apps/common/tasks.py index 98b14e59..80f21ac1 100644 --- a/apps/common/tasks.py +++ b/apps/common/tasks.py @@ -6,7 +6,7 @@ from django.core import management from firebase_admin.db import Reference -from apps.common.models import FirebasePushStatusEnum, FirebaseResource +from apps.common.models import FirebasePushResource, FirebasePushStatusEnum from main.cache import CeleryLock from main.config import Config from main.logging import log_extra @@ -28,7 +28,7 @@ def clear_expired_django_sessions(): # TODO(tnagorra): We might need to create a common class @shared_task -def push_django_to_firebase[T: FirebaseResource]( +def push_django_to_firebase[T: FirebasePushResource]( obj_id: int, model: type[T], handle_new_object_on_firebase: Callable[[T, Reference], None], @@ -43,7 +43,7 @@ def push_django_to_firebase[T: FirebaseResource]( try: model_ref = Config.FIREBASE_HELPER.ref( - get_firebase_path(model_obj.canonical_id, model_obj), + get_firebase_path(model_obj.firebase_id, model_obj), ) fb_model: typing.Any = model_ref.get() diff --git a/apps/contributor/admin.py b/apps/contributor/admin.py index f053c2a4..6b7926e0 100644 --- a/apps/contributor/admin.py +++ b/apps/contributor/admin.py @@ -1,13 +1,14 @@ import typing -from datetime import datetime from django.contrib import admin from django.db import transaction +from django.urls import reverse +from django.utils.html import format_html from djangoql.admin import DjangoQLSearchMixin from apps.common.admin import ArchivableResourceAdmin -from apps.contributor.firebase import push_contributor_team_to_firebase +from .firebase import FirebaseContributorTeam, firebase_contributor_user from .models import ContributorTeam, ContributorUser, ContributorUserGroup, ContributorUserGroupMembership @@ -19,45 +20,55 @@ class ContributorUserAdmin(DjangoQLSearchMixin, admin.ModelAdmin): "created_at", "modified_at", ) + readonly_fields = ( + "old_id", + "user_id", + "username", + "firebase_last_pushed", + "firebase_push_status", + "created_at", + "modified_at", + ) list_filter = ("team",) + @typing.override + def has_add_permission(self, *args, **kwargs): + return False + + @typing.override + def has_delete_permission(self, *args, **kwargs): + return False + + @typing.override + def save_model(self, request, obj, form, change): + super().save_model(request, obj, form, change) # type: ignore[reportAttributeAccessIssue] + transaction.on_commit(lambda: firebase_contributor_user.delay(obj.id)) + @admin.register(ContributorUserGroup) class ContributorUserGroupAdmin(DjangoQLSearchMixin, admin.ModelAdmin): pass -class ContributorUserInline(admin.TabularInline): - model = ContributorUser - extra = 1 - fields = ("user_id", "username", "created_at", "modified_at") - can_delete = False - - @admin.register(ContributorTeam) class ContributorTeamAdmin(ArchivableResourceAdmin, DjangoQLSearchMixin, admin.ModelAdmin): - inlines = [ContributorUserInline] list_display = ( "name", "is_archived", "created_at", "modified_at", + "view_team_members", ) list_filter = ("is_archived",) @typing.override def save_model(self, request, obj, form, change): - if not change: - obj.created_by = request.user - obj.modified_by = request.user - if obj.is_archived: - obj.archived_by = request.user - obj.archived_at = datetime.now() - else: - obj.archived_by = None - obj.archived_at = None super().save_model(request, obj, form, change) # type: ignore[reportAttributeAccessIssue] - transaction.on_commit(lambda: push_contributor_team_to_firebase.delay(obj.pk)) + transaction.on_commit(lambda: FirebaseContributorTeam.task.delay(obj.id)) + + def view_team_members(self, obj): + url = reverse("admin:contributor_contributoruser_changelist") + f"?team__id__exact={obj.id}" + return format_html('View Team Members', url) @admin.register(ContributorUserGroupMembership) diff --git a/apps/contributor/factories.py b/apps/contributor/factories.py index 5911cd7d..3e669fc4 100644 --- a/apps/contributor/factories.py +++ b/apps/contributor/factories.py @@ -20,7 +20,8 @@ class ContributorUserFactory(DjangoModelFactory): class Meta: model = ContributorUser - user_id = factory.Sequence(lambda n: f"unique-contributor-user-id-{n}") + firebase_id = factory.LazyFunction(lambda: str(ULID())) + user_id = factory.LazyFunction(lambda: str(ULID())) username = factory.Sequence(lambda n: f"Contributor User {n}") @@ -28,6 +29,7 @@ class ContributorUserGroupFactory(DjangoModelFactory): class Meta: model = ContributorUserGroup + firebase_id = factory.LazyFunction(lambda: str(ULID())) client_id = factory.LazyFunction(lambda: str(ULID())) name = factory.Sequence(lambda n: f"Contributor User Group {n}") description = "Some description" @@ -51,6 +53,7 @@ class ContributorTeamFactory(DjangoModelFactory): class Meta: model = ContributorTeam + firebase_id = factory.LazyFunction(lambda: str(ULID())) client_id = factory.LazyFunction(lambda: str(ULID())) name = factory.Sequence(lambda n: f"Contributor User Team {n}") diff --git a/apps/contributor/firebase.py b/apps/contributor/firebase.py index e0feb9a4..8fdb3ad1 100644 --- a/apps/contributor/firebase.py +++ b/apps/contributor/firebase.py @@ -1,50 +1,87 @@ import logging +import typing from celery import shared_task from firebase_admin.db import Reference as FbReference from pyfirebase_mapswipe import models as firebase_models from pyfirebase_mapswipe import utils as firebase_utils -from apps.common.tasks import push_django_to_firebase -from apps.contributor.models import ContributorTeam +from apps.common.firebase import FirebasePush +from apps.contributor.models import ContributorTeam, ContributorUser +from main.celery import app from main.config import Config logger = logging.getLogger(__name__) -def handle_new_contributor_team_on_firebase(contributor_team: ContributorTeam, contributor_team_ref: FbReference): - contributor_team_data = firebase_models.FbTeam( - teamName=contributor_team.name, - isArchived=contributor_team.is_archived, - teamToken=str(contributor_team.token), - ) +class FirebaseContributorTeam(FirebasePush[ContributorTeam]): + model_obj: ContributorTeam + model = ContributorTeam - contributor_team_ref.set( - value=firebase_utils.serialize(contributor_team_data), - ) + @typing.override + def handle_new_object_on_firebase(self, model_obj: ContributorTeam, fb_reference: FbReference): + contributor_team_data = firebase_models.FbTeam( + teamName=model_obj.name, + isArchived=model_obj.is_archived, + teamToken=str(model_obj.token), + ) + fb_reference.set( + value=firebase_utils.serialize(contributor_team_data), + ) -def handle_contributor_team_update_on_firebase(contributor_team: ContributorTeam, contributor_team_ref: FbReference): - contributor_team_ref.update( - value=firebase_utils.serialize( - firebase_models.FbTeam( - teamName=contributor_team.name, - isArchived=contributor_team.is_archived, - teamToken=str(contributor_team.token), + @typing.override + def handle_object_update_on_firebase(self, model_obj: ContributorTeam, fb_reference: FbReference): + fb_reference.update( + value=firebase_utils.serialize( + firebase_models.FbTeam( + teamName=model_obj.name, + isArchived=model_obj.is_archived, + teamToken=str(model_obj.token), + ), ), - ), - ) + ) + + @typing.override + def get_firebase_path(self, firebase_id: str, model=ContributorTeam): + return Config.FirebaseKeys.contributor_team(firebase_id) + + @staticmethod + @typing.override + @app.task() + def task(obj_id: int) -> None: + FirebaseContributorTeam(obj_id).push() + + +class FirebaseContributorUser(FirebasePush[ContributorUser]): + model_obj: ContributorUser + model = ContributorUser + + @typing.override + def handle_new_object_on_firebase(self, model_obj: ContributorUser, fb_reference: FbReference): + # FIXME(tnagorra): Use a better exception + raise Exception("User cannot be created from mapswipe-backend") + + @typing.override + def handle_object_update_on_firebase(self, model_obj: ContributorUser, fb_reference: FbReference): + fb_reference.update( + value=firebase_utils.serialize( + firebase_models.FbUserUpdateInput( + teamId=model_obj.team.firebase_id if model_obj.team else firebase_models.UNDEFINED, + ), + ), + ) + + @typing.override + def get_firebase_path(self, firebase_id: str, model=ContributorUser): + return Config.FirebaseKeys.contributor_user(firebase_id) + + @staticmethod + @typing.override + @app.task() + def task(obj_id: int) -> None: ... @shared_task -def push_contributor_team_to_firebase(contributor_team_id: int): - def get_firebase_path(canonical_id: str, team: ContributorTeam): - return Config.FirebaseKeys.contributor_team(canonical_id) - - push_django_to_firebase( - contributor_team_id, - ContributorTeam, - handle_new_contributor_team_on_firebase, - handle_contributor_team_update_on_firebase, - get_firebase_path, - ) +def firebase_contributor_user(obj_id: int): + FirebaseContributorUser(obj_id).push() diff --git a/apps/contributor/management/__init__.py b/apps/contributor/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/contributor/management/commands/__init__.py b/apps/contributor/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/contributor/management/commands/create_contributor_users.py b/apps/contributor/management/commands/create_contributor_users.py new file mode 100644 index 00000000..6c5919a1 --- /dev/null +++ b/apps/contributor/management/commands/create_contributor_users.py @@ -0,0 +1,77 @@ +import logging +import typing +import uuid + +from django.conf import settings +from django.core.management.base import BaseCommand +from django.utils import timezone +from pyfirebase_mapswipe import extended_models as firebase_extended_models +from pyfirebase_mapswipe import models as firebase_models +from pyfirebase_mapswipe import utils as firebase_utils + +from apps.contributor.factories import ContributorTeamFactory, ContributorUserFactory +from apps.contributor.models import ContributorTeam, ContributorUser +from apps.user.factories import UserFactory +from main.config import Config + +logger = logging.getLogger(__name__) + + +def push_team_to_firebase(team: ContributorTeam): + fb_reference = Config.FIREBASE_HELPER.ref( + Config.FirebaseKeys.contributor_team(team.firebase_id), + ) + contributor_team_data = firebase_models.FbTeam( + teamName=team.name, + isArchived=team.is_archived, + teamToken=str(team.token), + ) + fb_reference.set( + value=firebase_utils.serialize(contributor_team_data), + ) + + +def push_team_member_to_firebase(member: ContributorUser): + fb_reference = Config.FIREBASE_HELPER.ref( + Config.FirebaseKeys.contributor_user(member.firebase_id), + ) + team_member_data = firebase_extended_models.FbUser( + userName=member.username, + username=member.username, + userNameKey=member.username.lower(), + usernameKey=member.username.lower(), + teamId=member.team.firebase_id if member.team else firebase_models.UNDEFINED, + created=timezone.now(), + ) + fb_reference.set( + value=firebase_utils.serialize(team_member_data), + ) + + +class Command(BaseCommand): + help = "Create dummy contributor users. Also sync these users to firebase" + + @typing.override + def handle(self, *args, **options): + if not settings.ENABLE_DANGER_MODE: + logger.warning("Dummy data generation is disabled") + return + + user = UserFactory.create(email=f"user-{uuid.uuid4()}@mapwsipe.com") + user_resources = dict( + created_by=user, + modified_by=user, + firebase_last_pushed=timezone.now(), + ) + team_a, team_b = ContributorTeamFactory.create_batch(2, **user_resources) + for team in [team_a, team_b]: + push_team_to_firebase(team) + + team_a_members = ContributorUserFactory.create_batch(5, team=team_a, firebase_last_pushed=timezone.now()) + team_b_members = ContributorUserFactory.create_batch(5, team=team_b, firebase_last_pushed=timezone.now()) + no_team_members = ContributorUserFactory.create_batch(5, firebase_last_pushed=timezone.now()) + for team_members in [team_a_members, team_b_members, no_team_members]: + for member in team_members: + push_team_member_to_firebase(member) + + logger.info("Contributor users created successfully") diff --git a/apps/contributor/migrations/0008_alter_contributoruser_managers_and_more.py b/apps/contributor/migrations/0008_alter_contributoruser_managers_and_more.py new file mode 100644 index 00000000..547e6653 --- /dev/null +++ b/apps/contributor/migrations/0008_alter_contributoruser_managers_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.4 on 2025-07-30 11:30 + +import apps.common.models +import django.db.models.functions.comparison +import django.db.models.manager +import django_choices_field.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contributor', '0007_contributorteam_canonical_id'), + ] + + operations = [ + migrations.AlterModelManagers( + name='contributoruser', + managers=[ + ('cte_objects', django.db.models.manager.Manager()), + ], + ), + migrations.AddField( + model_name='contributoruser', + name='old_id', + field=models.CharField(blank=True, db_index=True, max_length=30, null=True), + ), + migrations.AddField( + model_name='contributoruser', + name='firebase_last_pushed', + field=models.DateTimeField(blank=True, help_text='The latest time when project was pushed to firebase', null=True), + ), + migrations.AddField( + model_name='contributoruser', + name='firebase_push_status', + field=django_choices_field.fields.IntegerChoicesField(blank=True, choices=[(1, 'Pending'), (2, 'Processing'), (3, 'Success'), (4, 'Failed')], choices_enum=apps.common.models.FirebasePushStatusEnum, null=True), + ), + migrations.AddField( + model_name='contributoruser', + name='canonical_id', + field=models.GeneratedField(db_persist=True, expression=django.db.models.functions.comparison.Coalesce(models.F('old_id'), django.db.models.functions.comparison.Cast(models.F('id'), models.CharField())), output_field=models.CharField(), unique=True), + ), + ] diff --git a/apps/contributor/migrations/0009_remove_contributorteam_canonical_id_and_more.py b/apps/contributor/migrations/0009_remove_contributorteam_canonical_id_and_more.py new file mode 100644 index 00000000..7e1c0bb1 --- /dev/null +++ b/apps/contributor/migrations/0009_remove_contributorteam_canonical_id_and_more.py @@ -0,0 +1,99 @@ +# Generated by Django 5.1.6 on 2025-08-01 06:05 + +import apps.common.models +import django_choices_field.fields +from django.db import migrations, models +from ulid import ULID + +def set_firebase_id_for_team(apps, schema_editor): + Model = apps.get_model('contributor', 'ContributorTeam') + for obj in Model.cte_objects.all(): + obj.firebase_id = str(ULID()) + obj.save(update_fields=['firebase_id']) + +def set_firebase_id_for_user(apps, schema_editor): + Model = apps.get_model('contributor', 'ContributorUser') + for obj in Model.cte_objects.all(): + obj.firebase_id = str(ULID()) + obj.save(update_fields=['firebase_id']) + +def set_firebase_id_for_user_group(apps, schema_editor): + Model = apps.get_model('contributor', 'ContributorUserGroup') + for obj in Model.cte_objects.all(): + obj.firebase_id = str(ULID()) + obj.save(update_fields=['firebase_id']) + +class Migration(migrations.Migration): + + dependencies = [ + ('contributor', '0008_alter_contributoruser_managers_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='contributorteam', + name='canonical_id', + ), + migrations.RemoveField( + model_name='contributoruser', + name='canonical_id', + ), + migrations.AddField( + model_name='contributorteam', + name='firebase_id', + field=models.CharField(blank=True, max_length=30, null=True), + ), + migrations.AddField( + model_name='contributoruser', + name='firebase_id', + field=models.CharField(blank=True, max_length=30, null=True), + ), + migrations.AddField( + model_name='contributorusergroup', + name='firebase_id', + field=models.CharField(blank=True, max_length=30, null=True), + ), + migrations.AddField( + model_name='contributorusergroup', + name='firebase_last_pushed', + field=models.DateTimeField(blank=True, help_text='The latest time when resource was pushed to firebase', null=True), + ), + migrations.AddField( + model_name='contributorusergroup', + name='firebase_push_status', + field=django_choices_field.fields.IntegerChoicesField(blank=True, choices=[(1, 'Pending'), (2, 'Processing'), (3, 'Success'), (4, 'Failed')], choices_enum=apps.common.models.FirebasePushStatusEnum, null=True), + ), + migrations.AlterField( + model_name='contributorteam', + name='firebase_last_pushed', + field=models.DateTimeField(blank=True, help_text='The latest time when resource was pushed to firebase', null=True), + ), + migrations.AlterField( + model_name='contributoruser', + name='firebase_last_pushed', + field=models.DateTimeField(blank=True, help_text='The latest time when resource was pushed to firebase', null=True), + ), + migrations.AlterField( + model_name='contributorusergroup', + name='old_id', + field=models.CharField(blank=True, db_index=True, max_length=30, null=True), + ), + migrations.RunPython(set_firebase_id_for_team, reverse_code=migrations.RunPython.noop), + migrations.RunPython(set_firebase_id_for_user, reverse_code=migrations.RunPython.noop), + migrations.RunPython(set_firebase_id_for_user_group, reverse_code=migrations.RunPython.noop), + migrations.AlterField( + model_name='contributorteam', + name='firebase_id', + field=models.CharField(default=ULID, max_length=30, unique=True), + ), + migrations.AlterField( + model_name='contributoruser', + name='firebase_id', + field=models.CharField(default=ULID, max_length=30, unique=True), + ), + migrations.AlterField( + model_name='contributorusergroup', + name='firebase_id', + field=models.CharField(default=ULID, max_length=30, unique=True), + ), + ] diff --git a/apps/contributor/models.py b/apps/contributor/models.py index 7860f3ce..656a52a1 100644 --- a/apps/contributor/models.py +++ b/apps/contributor/models.py @@ -7,18 +7,19 @@ from django.utils.translation import gettext from django_choices_field import IntegerChoicesField -from apps.common.models import ArchivableResource, FirebaseResource, UserResource +from apps.common.models import ArchivableResource, FirebasePushResource, UserResource # NOTE: Users are created from Apps (Web/Mobile) -class ContributorUser(models.Model): - # NOTE: Sync with firebase +class ContributorUser(FirebasePushResource): + # TODO(tnagorra): Remove this later and use firebase_id instead user_id = models.CharField( max_length=30, db_index=True, unique=True, help_text="Firebase User ID", ) + team: "ContributorTeam | None" = models.ForeignKey( # type: ignore[reportIncompatibleVariableOverride] "ContributorTeam", on_delete=models.SET_NULL, @@ -35,8 +36,7 @@ def __str__(self): return self.username -class ContributorUserGroup(ArchivableResource, UserResource): # type: ignore[reportIncompatibleVariableOverride] - old_id = models.CharField(max_length=30, db_index=True, null=True) +class ContributorUserGroup(ArchivableResource, UserResource, FirebasePushResource): # type: ignore[reportIncompatibleVariableOverride] name = models.CharField(max_length=255) description = models.TextField() @@ -45,6 +45,7 @@ def __str__(self): return self.name +# NOTE: Extend FirebasePullResource later if necessary class ContributorUserGroupMembership(models.Model): 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] @@ -81,7 +82,7 @@ def __str__(self): # TEAM -class ContributorTeam(ArchivableResource, UserResource, FirebaseResource): # type: ignore[reportIncompatibleVariableOverride] +class ContributorTeam(ArchivableResource, UserResource, FirebasePushResource): # type: ignore[reportIncompatibleVariableOverride] name = models.CharField(max_length=255) token = models.UUIDField(default=uuid.uuid4, unique=True) diff --git a/apps/mapping/factories.py b/apps/mapping/factories.py index 5e79936c..3893b49a 100644 --- a/apps/mapping/factories.py +++ b/apps/mapping/factories.py @@ -3,7 +3,9 @@ # pyright: reportMissingTypeArgument=false import typing +import factory from factory.django import DjangoModelFactory +from ulid import ULID from .models import ( MappingSession, @@ -17,6 +19,8 @@ class MappingSessionFactory(DjangoModelFactory): class Meta: model = MappingSession + # NOTE: Adding firebase_id just to pass validation when creating using factory + firebase_id = factory.LazyFunction(lambda: str(ULID())) app_version = "v1" client_type = MappingSessionClientTypeEnum.MOBILE_ANDROID @@ -25,11 +29,17 @@ class MappingSessionUserGroupFactory(DjangoModelFactory): class Meta: model = MappingSessionUserGroup + # NOTE: Adding firebase_id just to pass validation when creating using factory + firebase_id = factory.LazyFunction(lambda: str(ULID())) + class MappingSessionResultFactory(DjangoModelFactory): class Meta: model = MappingSessionResult + # NOTE: Adding firebase_id just to pass validation when creating using factory + firebase_id = factory.LazyFunction(lambda: str(ULID())) + # NOTE: Make sure to add type hints for each factory class defined below # NOTE: This needs to be at the end of this file diff --git a/apps/mapping/migrations/0003_alter_mappingsession_managers_and_more.py b/apps/mapping/migrations/0003_alter_mappingsession_managers_and_more.py new file mode 100644 index 00000000..6196f34b --- /dev/null +++ b/apps/mapping/migrations/0003_alter_mappingsession_managers_and_more.py @@ -0,0 +1,115 @@ +# Generated by Django 5.1.6 on 2025-08-01 06:05 + +import django.db.models.manager +from django.db import migrations, models +from ulid import ULID + + +def set_firebase_id_for_mapping_session(apps, schema_editor): + Model = apps.get_model('mapping', 'MappingSession') + for obj in Model.cte_objects.all(): + obj.firebase_id = str(ULID()) + obj.save(update_fields=['firebase_id']) + +def set_firebase_id_for_mapping_session_result(apps, schema_editor): + Model = apps.get_model('mapping', 'MappingSessionResult') + for obj in Model.cte_objects.all(): + obj.firebase_id = str(ULID()) + obj.save(update_fields=['firebase_id']) + +def set_firebase_id_for_mapping_session_user_group(apps, schema_editor): + Model = apps.get_model('mapping', 'MappingSessionUserGroup') + for obj in Model.cte_objects.all(): + obj.firebase_id = str(ULID()) + obj.save(update_fields=['firebase_id']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('mapping', '0002_initial'), + ] + + operations = [ + migrations.AlterModelManagers( + name='mappingsession', + managers=[ + ('cte_objects', django.db.models.manager.Manager()), + ], + ), + migrations.AlterModelManagers( + name='mappingsessionresult', + managers=[ + ('cte_objects', django.db.models.manager.Manager()), + ], + ), + migrations.AlterModelManagers( + name='mappingsessionusergroup', + managers=[ + ('cte_objects', django.db.models.manager.Manager()), + ], + ), + migrations.RemoveField( + model_name='mappingsession', + name='old_id', + ), + migrations.RemoveField( + model_name='mappingsessionresult', + name='old_id', + ), + migrations.RemoveField( + model_name='mappingsessionusergroup', + name='old_id', + ), + migrations.AddField( + model_name='mappingsession', + name='firebase_id', + field=models.CharField(blank=True, max_length=30, null=True, unique=True), + ), + migrations.AddField( + model_name='mappingsession', + name='firebase_last_pulled', + field=models.DateTimeField(blank=True, help_text='The latest time when resource was pulled from firebase', null=True), + ), + migrations.AddField( + model_name='mappingsessionresult', + name='firebase_id', + field=models.CharField(blank=True, max_length=30, null=True, unique=True), + ), + migrations.AddField( + model_name='mappingsessionresult', + name='firebase_last_pulled', + field=models.DateTimeField(blank=True, help_text='The latest time when resource was pulled from firebase', null=True), + ), + migrations.AddField( + model_name='mappingsessionusergroup', + name='firebase_id', + field=models.CharField(blank=True, max_length=30, null=True, unique=True), + ), + migrations.AddField( + model_name='mappingsessionusergroup', + name='firebase_last_pulled', + field=models.DateTimeField(blank=True, help_text='The latest time when resource was pulled from firebase', null=True), + ), + migrations.RunPython(set_firebase_id_for_mapping_session, reverse_code=migrations.RunPython.noop), + migrations.RunPython(set_firebase_id_for_mapping_session_result, reverse_code=migrations.RunPython.noop), + migrations.RunPython(set_firebase_id_for_mapping_session_user_group, reverse_code=migrations.RunPython.noop), + migrations.AlterField( + model_name='mappingsession', + name='firebase_id', + field=models.CharField(default=None, max_length=30, unique=True), + preserve_default=False, + ), + migrations.AlterField( + model_name='mappingsessionresult', + name='firebase_id', + field=models.CharField(default=None, max_length=30, unique=True), + preserve_default=False, + ), + migrations.AlterField( + model_name='mappingsessionusergroup', + name='firebase_id', + field=models.CharField(default=None, max_length=30, unique=True), + preserve_default=False, + ), + ] diff --git a/apps/mapping/models.py b/apps/mapping/models.py index c0bc1962..31300853 100644 --- a/apps/mapping/models.py +++ b/apps/mapping/models.py @@ -5,6 +5,7 @@ from django.db import models from django_choices_field import IntegerChoicesField +from apps.common.models import FirebasePullResource from apps.contributor.models import ContributorUser, ContributorUserGroup from apps.project.models import ProjectTask, ProjectTaskGroup @@ -24,10 +25,7 @@ def get_client_type(cls, value: str) -> "MappingSessionClientTypeEnum": }.get(value, cls.UNKNOWN) -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) - +class MappingSession(FirebasePullResource): 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] @@ -42,7 +40,7 @@ class MappingSession(models.Model): project_task_group_id: int contributor_user_id: int - class Meta: + class Meta: # type: ignore[reportIncompatibleVariableOverride] unique_together = (("project_task_group", "contributor_user"),) @typing.override @@ -50,10 +48,7 @@ def __str__(self): return str(self.pk) -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) - +class MappingSessionUserGroup(FirebasePullResource): mapping_session: MappingSession = models.ForeignKey(MappingSession, on_delete=models.PROTECT) # type: ignore[reportIncompatibleVariableOverride] user_group: ContributorUserGroup = models.ForeignKey( # type: ignore[reportIncompatibleVariableOverride] ContributorUserGroup, @@ -61,7 +56,7 @@ class MappingSessionUserGroup(models.Model): related_name="+", ) - class Meta: + class Meta: # type: ignore[reportIncompatibleVariableOverride] unique_together = (("mapping_session", "user_group"),) @typing.override @@ -69,10 +64,7 @@ def __str__(self): return str(self.pk) -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) - +class MappingSessionResult(FirebasePullResource): 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() diff --git a/apps/project/factories.py b/apps/project/factories.py index 65fe5841..3af93fb4 100644 --- a/apps/project/factories.py +++ b/apps/project/factories.py @@ -20,6 +20,7 @@ class OrganizationFactory(DjangoModelFactory): class Meta: model = Organization + firebase_id = factory.LazyFunction(lambda: str(ULID())) client_id = factory.LazyFunction(lambda: str(ULID())) name = factory.Sequence(lambda n: f"Organization {n}") description = "Test description" @@ -30,6 +31,7 @@ class ProjectFactory(DjangoModelFactory): class Meta: model = Project + firebase_id = factory.LazyFunction(lambda: str(ULID())) client_id = factory.LazyFunction(lambda: str(ULID())) topic = factory.Sequence(lambda n: f"Project Topic {n}") region = factory.Sequence(lambda n: f"Region {n}") @@ -48,6 +50,8 @@ class ProjectTaskGroupFactory(DjangoModelFactory): class Meta: model = ProjectTaskGroup + # NOTE: Adding firebase_id just to pass validation when creating using factory + firebase_id = factory.LazyFunction(lambda: str(ULID())) project_type_specifics = factory.LazyAttribute(lambda _: {}) number_of_tasks = 100 required_count = 50 @@ -63,6 +67,8 @@ class ProjectTaskFactory(DjangoModelFactory): class Meta: model = ProjectTask + # NOTE: Adding firebase_id just to pass validation when creating using factory + firebase_id = factory.LazyFunction(lambda: str(ULID())) project_type_specifics = factory.LazyAttribute(lambda _: {}) diff --git a/apps/project/firebase.py b/apps/project/firebase.py index ae290926..700189c0 100644 --- a/apps/project/firebase.py +++ b/apps/project/firebase.py @@ -1,97 +1,58 @@ import logging import typing -from celery import shared_task -from django.utils import timezone from firebase_admin.db import Reference as FbReference from pyfirebase_mapswipe import models as firebase_models from pyfirebase_mapswipe import utils as firebase_utils -from apps.project.models import FirebasePushStatusEnum, Organization +from apps.common.firebase import FirebasePush +from apps.project.models import Organization +from main.celery import app from main.config import Config -from main.logging import log_extra logger = logging.getLogger(__name__) -class InvalidOrganizationPushException(Exception): ... +class FirebaseOrganizationPush(FirebasePush[Organization]): + model_obj: Organization + model = Organization + @typing.override + def handle_new_object_on_firebase(self, model_obj: Organization, fb_reference: FbReference): + organization_data = firebase_models.FbOrganisation( + name=model_obj.name, + # NOTE: nameKey is is deprecated + nameKey="", + description=model_obj.description or firebase_models.UNDEFINED, + abbreviation=model_obj.abbreviation or firebase_models.UNDEFINED, + isArchived=model_obj.is_archived, + ) -def handle_new_organization_on_firebase(organization: Organization, organization_ref: FbReference): - organization_data = firebase_models.FbOrganisation( - name=organization.name, - nameKey="", - description=organization.description if organization.description else firebase_models.UNDEFINED, - abbreviation=organization.abbreviation if organization.abbreviation else firebase_models.UNDEFINED, - isArchived=organization.is_archived, - ) - - organization_ref.set( - value={ - **firebase_utils.serialize(organization_data), - }, - ) - + fb_reference.set( + value=firebase_utils.serialize(organization_data), + ) -def handle_organization_update_on_firebase(organization: Organization, organization_ref: FbReference): - organization_ref.update( - value=firebase_utils.serialize( - firebase_models.FbOrganisation( - name=organization.name, - nameKey="", - description=organization.description if organization.description else firebase_models.UNDEFINED, - abbreviation=organization.abbreviation if organization.abbreviation else firebase_models.UNDEFINED, - isArchived=organization.is_archived, + @typing.override + def handle_object_update_on_firebase(self, model_obj: Organization, fb_reference: FbReference): + fb_reference.update( + value=firebase_utils.serialize( + firebase_models.FbOrganisation( + name=model_obj.name, + # NOTE: nameKey is is deprecated + nameKey="", + description=model_obj.description or firebase_models.UNDEFINED, + abbreviation=model_obj.abbreviation or firebase_models.UNDEFINED, + isArchived=model_obj.is_archived, + ), ), - ), - ) - - -# FIXME(rup): use common push_django_to_firebase() method -@shared_task -def push_organization_to_firebase(organization_id: int): - organization = Organization.objects.filter(id=organization_id).first() - if not organization: - return - organization.update_firebase_push_status(FirebasePushStatusEnum.PROCESSING) - - try: - organization_ref = Config.FIREBASE_HELPER.ref( - Config.FirebaseKeys.organization(organization.id), ) - fb_organization: typing.Any = organization_ref.get() - if not organization.firebase_last_pushed: - if fb_organization is not None: - logger.error( - "push_to_firebase found a organization already in firebase when creating a organization", - extra=log_extra({"organization": organization.pk}), - ) - raise InvalidOrganizationPushException - handle_new_organization_on_firebase(organization, organization_ref) - else: - if fb_organization is None: - logger.error( - "push_to_firebase did not find organization in firebase when updating a organization", - extra=log_extra({"organization": organization.pk}), - ) - raise InvalidOrganizationPushException - handle_organization_update_on_firebase(organization, organization_ref) - except InvalidOrganizationPushException: - organization.update_firebase_push_status(FirebasePushStatusEnum.FAILED) - except Exception: - logger.error( - "push_to_firebase failed", - extra=log_extra({"organization": organization.pk}), - exc_info=True, - ) - organization.update_firebase_push_status(FirebasePushStatusEnum.FAILED) - else: - organization.firebase_last_pushed = timezone.now() - organization.update_firebase_push_status(FirebasePushStatusEnum.SUCCESS, commit=False) - organization.save( - update_fields=[ - "firebase_last_pushed", - "firebase_push_status", - ], - ) + @typing.override + def get_firebase_path(self, firebase_id: str, model=Organization): + return Config.FirebaseKeys.organization(firebase_id) + + @staticmethod + @typing.override + @app.task() + def task(obj_id: int) -> None: + FirebaseOrganizationPush(obj_id).push() diff --git a/apps/project/migrations/0009_organization_firebase_last_pushed_and_more.py b/apps/project/migrations/0009_organization_firebase_last_pushed_and_more.py index a301eb1e..d96d71f1 100644 --- a/apps/project/migrations/0009_organization_firebase_last_pushed_and_more.py +++ b/apps/project/migrations/0009_organization_firebase_last_pushed_and_more.py @@ -20,6 +20,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='organization', name='firebase_push_status', - field=django_choices_field.fields.IntegerChoicesField(blank=True, choices=[(1, 'Pending'), (2, 'Processing'), (3, 'Success'), (4, 'Failed')], choices_enum=apps.project.models.FirebasePushStatusEnum, null=True), + field=django_choices_field.fields.IntegerChoicesField(blank=True, choices=[(1, 'Pending'), (2, 'Processing'), (3, 'Success'), (4, 'Failed')], choices_enum=apps.common.models.FirebasePushStatusEnum, null=True), ), ] diff --git a/apps/project/migrations/0012_organization_old_id_and_more.py b/apps/project/migrations/0012_organization_old_id_and_more.py new file mode 100644 index 00000000..176e6f06 --- /dev/null +++ b/apps/project/migrations/0012_organization_old_id_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.4 on 2025-07-30 12:49 + +import django.db.models.functions.comparison +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('project', '0011_project_canonical_id'), + ] + + operations = [ + migrations.AddField( + model_name='organization', + name='old_id', + field=models.CharField(blank=True, db_index=True, max_length=30, null=True), + ), + migrations.AlterField( + model_name='organization', + name='firebase_last_pushed', + field=models.DateTimeField(blank=True, help_text='The latest time when project was pushed to firebase', null=True), + ), + migrations.AddField( + model_name='organization', + name='canonical_id', + field=models.GeneratedField(db_persist=True, expression=django.db.models.functions.comparison.Coalesce(models.F('old_id'), django.db.models.functions.comparison.Cast(models.F('id'), models.CharField())), output_field=models.CharField(), unique=True), + ), + ] diff --git a/apps/project/migrations/0013_merge_20250731_0850.py b/apps/project/migrations/0013_merge_20250731_0850.py new file mode 100644 index 00000000..4ed068ac --- /dev/null +++ b/apps/project/migrations/0013_merge_20250731_0850.py @@ -0,0 +1,14 @@ +# Generated by Django 5.2.4 on 2025-07-31 08:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('project', '0012_merge_20250730_1323'), + ('project', '0012_organization_old_id_and_more'), + ] + + operations = [ + ] diff --git a/apps/project/migrations/0014_alter_projecttask_managers_and_more.py b/apps/project/migrations/0014_alter_projecttask_managers_and_more.py new file mode 100644 index 00000000..e9d63240 --- /dev/null +++ b/apps/project/migrations/0014_alter_projecttask_managers_and_more.py @@ -0,0 +1,146 @@ +# Generated by Django 5.1.6 on 2025-08-01 06:05 + +import apps.common.models +import django.db.models.manager +import django_choices_field.fields +from django.db import migrations, models +from ulid import ULID + + +def set_firebase_id_for_project(apps, schema_editor): + Model = apps.get_model('project', 'Project') + for obj in Model.cte_objects.all(): + obj.firebase_id = str(ULID()) + obj.save(update_fields=['firebase_id']) + +def set_firebase_id_for_organization(apps, schema_editor): + Model = apps.get_model('project', 'Organization') + for obj in Model.cte_objects.all(): + obj.firebase_id = str(ULID()) + obj.save(update_fields=['firebase_id']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('project', '0013_merge_20250731_0850'), + ] + + operations = [ + migrations.AlterModelManagers( + name='projecttask', + managers=[ + ('cte_objects', django.db.models.manager.Manager()), + ], + ), + migrations.AlterModelManagers( + name='projecttaskgroup', + managers=[ + ('cte_objects', django.db.models.manager.Manager()), + ], + ), + migrations.RemoveField( + model_name='organization', + name='canonical_id', + ), + migrations.RemoveField( + model_name='project', + name='canonical_id', + ), + migrations.RenameField( + model_name='projecttask', + old_name='legacy_task_id', + new_name='firebase_id', + ), + migrations.RenameField( + model_name='projecttaskgroup', + old_name='legacy_group_id', + new_name='firebase_id', + ), + migrations.AddField( + model_name='organization', + name='firebase_id', + field=models.CharField(blank=True, max_length=30, null=True), + ), + migrations.AddField( + model_name='project', + name='firebase_id', + field=models.CharField(blank=True, max_length=30, null=True), + ), + migrations.AlterField( + model_name='projecttask', + name='firebase_id', + field=models.CharField(blank=True, max_length=30, null=True), + ), + migrations.AddField( + model_name='projecttask', + name='firebase_last_pushed', + field=models.DateTimeField(blank=True, help_text='The latest time when resource was pushed to firebase', null=True), + ), + migrations.AddField( + model_name='projecttask', + name='firebase_push_status', + field=django_choices_field.fields.IntegerChoicesField(blank=True, choices=[(1, 'Pending'), (2, 'Processing'), (3, 'Success'), (4, 'Failed')], choices_enum=apps.common.models.FirebasePushStatusEnum, null=True), + ), + migrations.AlterField( + model_name='projecttaskgroup', + name='firebase_id', + field=models.CharField(blank=True, max_length=30, null=True), + ), + migrations.AddField( + model_name='projecttaskgroup', + name='firebase_last_pushed', + field=models.DateTimeField(blank=True, help_text='The latest time when resource was pushed to firebase', null=True), + ), + migrations.AddField( + model_name='projecttaskgroup', + name='firebase_push_status', + field=django_choices_field.fields.IntegerChoicesField(blank=True, choices=[(1, 'Pending'), (2, 'Processing'), (3, 'Success'), (4, 'Failed')], choices_enum=apps.common.models.FirebasePushStatusEnum, null=True), + ), + migrations.AlterField( + model_name='organization', + name='firebase_last_pushed', + field=models.DateTimeField(blank=True, help_text='The latest time when resource was pushed to firebase', null=True), + ), + migrations.AlterField( + model_name='project', + name='firebase_last_pushed', + field=models.DateTimeField(blank=True, help_text='The latest time when resource was pushed to firebase', null=True), + ), + migrations.AlterField( + model_name='projecttask', + name='old_id', + field=models.CharField(blank=True, db_index=True, max_length=30, null=True), + ), + migrations.AlterField( + model_name='projecttaskgroup', + name='old_id', + field=models.CharField(blank=True, db_index=True, max_length=30, null=True), + ), + migrations.RunPython(set_firebase_id_for_project, reverse_code=migrations.RunPython.noop), + migrations.RunPython(set_firebase_id_for_organization, reverse_code=migrations.RunPython.noop), + migrations.AlterField( + model_name='organization', + name='firebase_id', + field=models.CharField(default=ULID, max_length=30, unique=True), + ), + migrations.AlterField( + model_name='project', + name='firebase_id', + field=models.CharField(default=ULID, max_length=30, unique=True), + ), + migrations.AlterField( + model_name='projecttaskgroup', + name='firebase_id', + field=models.CharField(default=None, max_length=30), + preserve_default=False, + ), + migrations.AlterUniqueTogether( + name='projecttask', + unique_together={('task_group', 'firebase_id')}, + ), + migrations.AlterUniqueTogether( + name='projecttaskgroup', + unique_together={('project', 'firebase_id')}, + ), + ] diff --git a/apps/project/models.py b/apps/project/models.py index 0507dbcf..4e929239 100644 --- a/apps/project/models.py +++ b/apps/project/models.py @@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy from django_choices_field import IntegerChoicesField -from apps.common.models import ArchivableResource, CommonAsset, FirebasePushStatusEnum, FirebaseResource, UserResource +from apps.common.models import ArchivableResource, CommonAsset, FirebasePushResource, UserResource from apps.contributor.models import ContributorTeam from utils.fields import validate_percentage @@ -116,7 +116,7 @@ def project_image(instance: "Project", filename: str): return f"project/{instance.pk}/image/{filename}" -class Organization(UserResource, ArchivableResource): # type: ignore[reportIncompatibleVariableOverride] +class Organization(UserResource, ArchivableResource, FirebasePushResource): # type: ignore[reportIncompatibleVariableOverride] name = models.CharField(max_length=255) description = models.TextField(null=True, blank=True) abbreviation = models.CharField(max_length=50, null=True, blank=True) @@ -129,30 +129,12 @@ class Organization(UserResource, ArchivableResource): # type: ignore[reportInco unique=True, ) - # FIREBASE FIELDS - - firebase_push_status: int | None = IntegerChoicesField( # type: ignore[reportAssignmentType] - choices_enum=FirebasePushStatusEnum, - null=True, - blank=True, - ) - firebase_last_pushed = models.DateTimeField( - null=True, - blank=True, - help_text=gettext_lazy("The latest time when organization was pushed to firebase"), - ) - @typing.override def __str__(self) -> str: return self.name - def update_firebase_push_status(self, firebase_push_status: FirebasePushStatusEnum, *, commit: bool = True): - self.firebase_push_status = firebase_push_status - if commit: - self.save(update_fields=("firebase_push_status",)) - -class Project(UserResource, FirebaseResource): # type: ignore[reportIncompatibleVariableOverride] +class Project(UserResource, FirebasePushResource): # type: ignore[reportIncompatibleVariableOverride] Type = ProjectTypeEnum Status = ProjectStatusEnum ProcessingStatus = ProjectProcessingStatusEnum @@ -397,10 +379,8 @@ class ProjectAsset(UserResource, CommonAsset): # type: ignore[reportIncompatibl project_id: int -class ProjectTaskGroup(models.Model): - # FIXME(tnagorra): We might need to skip the indexing - old_id = models.CharField(max_length=30, db_index=True, null=True) - legacy_group_id = models.CharField(max_length=30, db_index=True) +class ProjectTaskGroup(FirebasePushResource): + firebase_id = models.CharField(max_length=30) project: Project = models.ForeignKey( # type: ignore[reportAssignmentType] Project, @@ -426,15 +406,20 @@ class ProjectTaskGroup(models.Model): # Type hints project_id: int + class Meta: # type: ignore[reportIncompatibleVariableOverride] + unique_together = ( + "project", + "firebase_id", + ) + @typing.override def __str__(self): return f"(project={self.project_id}, id={self.pk})" -class ProjectTask(models.Model): - # FIXME(tnagorra): We might need to skip the indexing - old_id = models.CharField(max_length=30, db_index=True, null=True) - legacy_task_id = models.CharField(max_length=30, db_index=True) +class ProjectTask(FirebasePushResource): + # TODO(tnagorra): We need to change the uniqueness constraint + firebase_id = models.CharField(max_length=30, null=True, blank=True) task_group: ProjectTaskGroup = models.ForeignKey( # type: ignore[reportAssignmentType] ProjectTaskGroup, @@ -454,6 +439,13 @@ class ProjectTask(models.Model): # Type hints task_group_id: int + class Meta: # type: ignore[reportIncompatibleVariableOverride] + unique_together = ( + # FIXME(tnagorra): Should we use project instead of task_group here? + "task_group", + "firebase_id", + ) + @typing.override def __str__(self): return f"task_group_id={self.task_group_id}, id={self.pk}" diff --git a/apps/project/serializers.py b/apps/project/serializers.py index 792200eb..1e2c109f 100644 --- a/apps/project/serializers.py +++ b/apps/project/serializers.py @@ -8,7 +8,7 @@ from apps.common.models import FirebasePushStatusEnum 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.project.firebase import FirebaseOrganizationPush from apps.tutorial.models import Tutorial from project_types.store import get_project_property from utils.common import clean_up_none_keys @@ -381,11 +381,11 @@ class Meta: # type: ignore[reportIncompatibleVariableOverride] @typing.override def create(self, validated_data: dict[str, typing.Any]) -> Organization: organization = super().create(validated_data) - transaction.on_commit(lambda: push_organization_to_firebase.delay(organization.pk)) + transaction.on_commit(lambda: FirebaseOrganizationPush.task.delay(organization.pk)) return organization @typing.override def update(self, instance: Organization, validated_data: dict[typing.Any, typing.Any]): organization = super().update(instance, validated_data) - transaction.on_commit(lambda: push_organization_to_firebase.delay(organization.pk)) + transaction.on_commit(lambda: FirebaseOrganizationPush.task.delay(organization.pk)) return organization diff --git a/apps/project/tests/mutation_test.py b/apps/project/tests/mutation_test.py index a929bae3..da0fa9fa 100644 --- a/apps/project/tests/mutation_test.py +++ b/apps/project/tests/mutation_test.py @@ -493,6 +493,7 @@ def test_project_create(self): # Creating project # Fails as project with name already exist + project_data["clientId"] = str(ULID()) content = self._create_project_mutation(project_data) response = content["data"]["createProject"] assert response["errors"] is not None, content @@ -503,6 +504,7 @@ def test_project_create(self): **self.user_resource_kwargs, is_archived=True, ) + project_data["clientId"] = str(ULID()) project_data["team"] = archived_team.pk response = content["data"]["createProject"] assert response["errors"] is not None, content @@ -546,6 +548,7 @@ def test_project_create(self): **self.user_resource_kwargs, is_archived=True, ) + project_data["clientId"] = str(ULID()) project_data["requestingOrganization"] = archived_organization.pk content = self._create_project_mutation(project_data) assert content["data"]["createProject"]["errors"] is not None, content @@ -1128,14 +1131,14 @@ class TaskGroupSpecificsType(typing.TypedDict): y_min: int class TaskGroupType(typing.TypedDict): - legacy_group_id: str + firebase_id: str number_of_tasks: int required_count: int project_type_specifics: TaskGroupSpecificsType expected_task_groups: list[TaskGroupType] = [ { - "legacy_group_id": "g101", + "firebase_id": "g101", "number_of_tasks": 18, "required_count": 18 * 10, "project_type_specifics": { @@ -1146,7 +1149,7 @@ class TaskGroupType(typing.TypedDict): }, }, { - "legacy_group_id": "g102", + "firebase_id": "g102", "number_of_tasks": 24, "required_count": 24 * 10, "project_type_specifics": { @@ -1157,7 +1160,7 @@ class TaskGroupType(typing.TypedDict): }, }, { - "legacy_group_id": "g103", + "firebase_id": "g103", "number_of_tasks": 24, "required_count": 24 * 10, "project_type_specifics": { @@ -1168,7 +1171,7 @@ class TaskGroupType(typing.TypedDict): }, }, { - "legacy_group_id": "g104", + "firebase_id": "g104", "number_of_tasks": 6, "required_count": 6 * 10, "project_type_specifics": { @@ -1181,35 +1184,35 @@ class TaskGroupType(typing.TypedDict): ] expected_last_5_tasks = [ { - "legacy_task_id": "15-24147-13753", + "firebase_id": "15-24147-13753", "project_type_specifics": { "tile_x": 24147, "tile_y": 13753, }, }, { - "legacy_task_id": "15-24147-13754", + "firebase_id": "15-24147-13754", "project_type_specifics": { "tile_x": 24147, "tile_y": 13754, }, }, { - "legacy_task_id": "15-24147-13755", + "firebase_id": "15-24147-13755", "project_type_specifics": { "tile_x": 24147, "tile_y": 13755, }, }, { - "legacy_task_id": "15-24148-13753", + "firebase_id": "15-24148-13753", "project_type_specifics": { "tile_x": 24148, "tile_y": 13753, }, }, { - "legacy_task_id": "15-24148-13754", + "firebase_id": "15-24148-13754", "project_type_specifics": { "tile_x": 24148, "tile_y": 13754, @@ -1226,7 +1229,7 @@ class TaskGroupType(typing.TypedDict): "tasks_groups_count": project_task_group_qs.count(), "tasks_groups": list( project_task_group_qs.order_by("id").values( - "legacy_group_id", + "firebase_id", "number_of_tasks", "required_count", "project_type_specifics", @@ -1235,7 +1238,7 @@ class TaskGroupType(typing.TypedDict): "tasks_count": project_task_qs.count(), "tasks": list( project_task_qs.order_by("id").values( - "legacy_task_id", + "firebase_id", "project_type_specifics", )[:5], ), diff --git a/apps/tutorial/factories.py b/apps/tutorial/factories.py index 2a90b512..5ff9e25d 100644 --- a/apps/tutorial/factories.py +++ b/apps/tutorial/factories.py @@ -15,6 +15,7 @@ class TutorialFactory(DjangoModelFactory): class Meta: # type: ignore[reportIncompatibleVariableOverride] model = Tutorial + firebase_id = factory.LazyFunction(lambda: str(ULID())) client_id = factory.LazyFunction(lambda: str(ULID())) # name = factory.Sequence(lambda n: f"Tutorial {n}") diff --git a/apps/tutorial/migrations/0008_tutorial_firebase_id_tutorial_firebase_last_pushed_and_more.py b/apps/tutorial/migrations/0008_tutorial_firebase_id_tutorial_firebase_last_pushed_and_more.py new file mode 100644 index 00000000..c7e561ae --- /dev/null +++ b/apps/tutorial/migrations/0008_tutorial_firebase_id_tutorial_firebase_last_pushed_and_more.py @@ -0,0 +1,49 @@ +# Generated by Django 5.1.6 on 2025-08-01 06:05 + +import apps.common.models +import django_choices_field.fields +from django.db import migrations, models +from ulid import ULID + + +def set_firebase_id_for_tutorial(apps, schema_editor): + Model = apps.get_model('tutorial', 'Tutorial') + for obj in Model.cte_objects.all(): + obj.firebase_id = str(ULID()) + obj.save(update_fields=['firebase_id']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('tutorial', '0007_alter_tutorialasset_marked_as_deleted'), + ] + + operations = [ + migrations.AddField( + model_name='tutorial', + name='firebase_id', + field=models.CharField(blank=True, max_length=30, null=True), + ), + migrations.AddField( + model_name='tutorial', + name='firebase_last_pushed', + field=models.DateTimeField(blank=True, help_text='The latest time when resource was pushed to firebase', null=True), + ), + migrations.AddField( + model_name='tutorial', + name='firebase_push_status', + field=django_choices_field.fields.IntegerChoicesField(blank=True, choices=[(1, 'Pending'), (2, 'Processing'), (3, 'Success'), (4, 'Failed')], choices_enum=apps.common.models.FirebasePushStatusEnum, null=True), + ), + migrations.AlterField( + model_name='tutorial', + name='old_id', + field=models.CharField(blank=True, db_index=True, max_length=30, null=True), + ), + migrations.RunPython(set_firebase_id_for_tutorial, reverse_code=migrations.RunPython.noop), + migrations.AlterField( + model_name='tutorial', + name='firebase_id', + field=models.CharField(default=ULID, max_length=30, unique=True), + ), + ] diff --git a/apps/tutorial/migrations/0009_merge_20250801_1651.py b/apps/tutorial/migrations/0009_merge_20250801_1651.py new file mode 100644 index 00000000..6358fa9a --- /dev/null +++ b/apps/tutorial/migrations/0009_merge_20250801_1651.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.6 on 2025-08-01 16:51 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tutorial', '0008_alter_tutorialinformationpageblock_image'), + ('tutorial', '0008_tutorial_firebase_id_tutorial_firebase_last_pushed_and_more'), + ] + + operations = [ + ] diff --git a/apps/tutorial/models.py b/apps/tutorial/models.py index 858de2d7..c8b13f33 100644 --- a/apps/tutorial/models.py +++ b/apps/tutorial/models.py @@ -9,7 +9,7 @@ from django_choices_field import IntegerChoicesField from django_stubs_ext.db.models.manager import RelatedManager -from apps.common.models import IconEnum, UserResource +from apps.common.models import FirebasePushResource, IconEnum, UserResource from apps.project.models import CommonAsset, Project @@ -48,10 +48,7 @@ class TutorialStatusEnum(models.IntegerChoices): """ -class Tutorial(UserResource): - # FIXME(tnagorra): We might need to skip the indexing - old_id = models.CharField(max_length=30, db_index=True, null=True) - +class Tutorial(UserResource, FirebasePushResource): # type: ignore[reportIncompatibleVariableOverride] Status = TutorialStatusEnum # FIXME(tnagorra): We might need to rename this field diff --git a/firebase b/firebase index 27ce6237..38ffc10a 160000 --- a/firebase +++ b/firebase @@ -1 +1 @@ -Subproject commit 27ce62375b94ff0033868b569fc3833590916a72 +Subproject commit 38ffc10ad51bd222e904381c6cd6748068df3747 diff --git a/main/config.py b/main/config.py index 040d1f96..b6a9ece3 100644 --- a/main/config.py +++ b/main/config.py @@ -40,7 +40,7 @@ def project_groups(project_id: str | int): @staticmethod def project_tasks(project_id: str | int): - return f"/v2/groups/{project_id}/" + return f"/v2/tasks/{project_id}/" @staticmethod def organization(organization_id: str | int): @@ -50,6 +50,10 @@ def organization(organization_id: str | int): def contributor_team(contributor_team_id: str | int): return f"/v2/teams/{contributor_team_id}/" + @staticmethod + def contributor_user(user_id: str | int): + return f"/v2/users/{user_id}" + # FIXME: Import utils/geo/raster_tile_server/config.py here # FIXME: Import utils/geo/vector_tile_server/config.py here diff --git a/main/settings.py b/main/settings.py index 9f159c02..7162c4a4 100644 --- a/main/settings.py +++ b/main/settings.py @@ -98,8 +98,12 @@ GOOGLE_APPLICATION_CREDENTIALS=str, # Pytest PYTEST_XDIST_WORKER=(str, None), + # Test + ENABLE_DANGER_MODE=(bool, False), ) +ENABLE_DANGER_MODE = env("ENABLE_DANGER_MODE") + GIT_HELPER = GitHelper(BASE_DIR) # Quick-start development settings - unsuitable for production diff --git a/project_types/base/project.py b/project_types/base/project.py index c54f2602..243a8876 100644 --- a/project_types/base/project.py +++ b/project_types/base/project.py @@ -203,6 +203,7 @@ def handle_new_tasks_on_firebase(self, task_ref: FbReference): projectId=str(self.project.pk), ) task_project_specific_data = self.get_task_project_specifics_for_firebase(task) + # TODO(tnagorra): Need to group by groups fb_tasks[task.pk] = { **firebase_utils.serialize(task_data), **firebase_utils.serialize(task_project_specific_data), diff --git a/project_types/tile_map_service/base/project.py b/project_types/tile_map_service/base/project.py index 25ccabdc..7d40a35f 100644 --- a/project_types/tile_map_service/base/project.py +++ b/project_types/tile_map_service/base/project.py @@ -162,7 +162,7 @@ def create_tasks(self, group: ProjectTaskGroup, raw_group: tile_grouping.RawGrou ) bulk_mgr.add( ProjectTask( - legacy_task_id=f"{self.project_type_specifics.zoom_level}-{tile_x}-{tile_y}", + firebase_id=f"{self.project_type_specifics.zoom_level}-{tile_x}-{tile_y}", task_group_id=group.pk, geometry=geometry, project_type_specifics=self.project_task_property_class( @@ -190,7 +190,7 @@ def create_groups(self, resp: tile_grouping.AoiGeometry): # Create new group # FIXME(thenav56): Bulk create here as well? new_group = ProjectTaskGroup.objects.create( - legacy_group_id=group_key, + firebase_id=group_key, project_id=self.project.pk, number_of_tasks=0, progress=0, @@ -255,7 +255,7 @@ def get_group_project_specifics_for_firebase(self, group: ProjectTaskGroup) -> B **group.project_type_specifics, ) return firebase_models.FbMappingGroupTileMapServiceCreateOnlyInput( - groupId=group.legacy_group_id, + groupId=group.firebase_id, xMax=task_group_specifics.x_max, xMin=task_group_specifics.x_min, yMax=task_group_specifics.y_max, diff --git a/project_types/tile_map_service/compare/project.py b/project_types/tile_map_service/compare/project.py index 4269c5ef..952bb0a1 100644 --- a/project_types/tile_map_service/compare/project.py +++ b/project_types/tile_map_service/compare/project.py @@ -47,7 +47,7 @@ def get_task_project_specifics_for_firebase(self, task: ProjectTask): tsp_b = self.project_type_specifics.tile_server_b_property return firebase_models.FbMappingTaskCompareCreateOnlyInput( - groupId=str(task.task_group.legacy_group_id), + groupId=str(task.task_group.firebase_id), taskId=str(task.task_group_id), taskX=task_specifics.tile_x, taskY=task_specifics.tile_y, diff --git a/project_types/validate/project.py b/project_types/validate/project.py index 9bb08f52..af421d90 100644 --- a/project_types/validate/project.py +++ b/project_types/validate/project.py @@ -254,7 +254,7 @@ def create_tasks(self, group: ProjectTaskGroup, raw_group: ValidateRawGroupItem) bulk_mgr.add( ProjectTask( - legacy_task_id=f"t{tasks_count + 1}", + firebase_id=f"t{tasks_count + 1}", task_group_id=group.pk, geometry=geometry_str, project_type_specifics=self.project_task_property_class( @@ -275,7 +275,7 @@ def create_groups(self, resp: list[ValidFeature]): for group_key, raw_group in raw_groups.items(): new_group = ProjectTaskGroup.objects.create( - legacy_group_id=group_key, + firebase_id=group_key, project_id=self.project.pk, number_of_tasks=0, progress=0, @@ -337,7 +337,7 @@ def get_feature(task: ProjectTask): @typing.override def get_task_project_specifics_for_firebase(self, task: ProjectTask): return firebase_models.FbMappingTaskValidateCreateOnlyInput( - taskId=str(task.legacy_task_id), + taskId=str(task.firebase_id), # FIXME(tnagorra): Check if we need to convert this? geojson=task.geometry, ) @@ -345,7 +345,7 @@ def get_task_project_specifics_for_firebase(self, task: ProjectTask): @typing.override def get_group_project_specifics_for_firebase(self, group: ProjectTaskGroup): return firebase_models.FbMappingGroupValidateCreateOnlyInput( - groupId=str(group.legacy_group_id), + groupId=str(group.firebase_id), ) @typing.override diff --git a/project_types/validate_image/project.py b/project_types/validate_image/project.py index 800f5918..32a3ae55 100644 --- a/project_types/validate_image/project.py +++ b/project_types/validate_image/project.py @@ -50,14 +50,14 @@ def get_task_project_specifics_for_firebase(self, task): **task.project_type_specifics, ) return firebase_models.FbMappingTaskValidateImageCreateOnlyInput( - taskId=str(task.legacy_task_id), + taskId=str(task.firebase_id), question=task_specifics.question or firebase_models.UNDEFINED, ) @typing.override def get_group_project_specifics_for_firebase(self, group): return firebase_models.FbMappingGroupValidateImageCreateOnlyInput( - groupId=str(group.legacy_group_id), + groupId=str(group.firebase_id), ) # TODO(tnagorra): Define validate diff --git a/schema.graphql b/schema.graphql index 276a6615..8d0c0543 100644 --- a/schema.graphql +++ b/schema.graphql @@ -300,7 +300,7 @@ type ContributorSwipeStatType { } """ -ContributorTeam(id, client_id, created_at, modified_at, created_by, modified_by, is_archived, archived_at, archived_by, old_id, firebase_push_status, firebase_last_pushed, canonical_id, name, token) +ContributorTeam(id, client_id, created_at, modified_at, created_by, modified_by, is_archived, archived_at, archived_by, old_id, firebase_id, firebase_push_status, firebase_last_pushed, name, token) """ input ContributorTeamFilter { id: IDBaseFilterLookup @@ -318,7 +318,7 @@ input ContributorTeamOrder { } """ -ContributorTeam(id, client_id, created_at, modified_at, created_by, modified_by, is_archived, archived_at, archived_by, old_id, firebase_push_status, firebase_last_pushed, canonical_id, name, token) +ContributorTeam(id, client_id, created_at, modified_at, created_by, modified_by, is_archived, archived_at, archived_by, old_id, firebase_id, firebase_push_status, firebase_last_pushed, name, token) """ type ContributorTeamType implements UserResourceTypeMixin { clientId: String! @@ -352,7 +352,9 @@ type ContributorTimeStatType { totalSwipeTime: Int! } -"""ContributorUser(id, user_id, team, username, created_at, modified_at)""" +""" +ContributorUser(id, old_id, firebase_id, firebase_push_status, firebase_last_pushed, user_id, team, username, created_at, modified_at) +""" input ContributorUserFilter { id: IDBaseFilterLookup username: StrFilterLookup @@ -374,7 +376,7 @@ type ContributorUserFilteredStats implements ContributorUserUserGroupBaseFilterS } """ -ContributorUserGroup(id, client_id, created_at, modified_at, created_by, modified_by, is_archived, archived_at, archived_by, old_id, name, description) +ContributorUserGroup(id, client_id, created_at, modified_at, created_by, modified_by, is_archived, archived_at, archived_by, old_id, firebase_id, firebase_push_status, firebase_last_pushed, name, description) """ input ContributorUserGroupCreateInput { clientId: String! @@ -383,7 +385,7 @@ input ContributorUserGroupCreateInput { } """ -ContributorUserGroup(id, client_id, created_at, modified_at, created_by, modified_by, is_archived, archived_at, archived_by, old_id, name, description) +ContributorUserGroup(id, client_id, created_at, modified_at, created_by, modified_by, is_archived, archived_at, archived_by, old_id, firebase_id, firebase_push_status, firebase_last_pushed, name, description) """ input ContributorUserGroupFilter { id: IDBaseFilterLookup @@ -481,7 +483,7 @@ type ContributorUserGroupStatsType { } """ -ContributorUserGroup(id, client_id, created_at, modified_at, created_by, modified_by, is_archived, archived_at, archived_by, old_id, name, description) +ContributorUserGroup(id, client_id, created_at, modified_at, created_by, modified_by, is_archived, archived_at, archived_by, old_id, firebase_id, firebase_push_status, firebase_last_pushed, name, description) """ type ContributorUserGroupType implements UserResourceTypeMixin { clientId: String! @@ -516,7 +518,7 @@ type ContributorUserGroupTypeOffsetPaginated { } """ -ContributorUserGroup(id, client_id, created_at, modified_at, created_by, modified_by, is_archived, archived_at, archived_by, old_id, name, description) +ContributorUserGroup(id, client_id, created_at, modified_at, created_by, modified_by, is_archived, archived_at, archived_by, old_id, firebase_id, firebase_push_status, firebase_last_pushed, name, description) """ input ContributorUserGroupUpdateInput { clientId: String! @@ -557,7 +559,9 @@ type ContributorUserStats { filteredStats(dateRange: DateRangeInput = null): ContributorUserFilteredStats! } -"""ContributorUser(id, user_id, team, username, created_at, modified_at)""" +""" +ContributorUser(id, old_id, firebase_id, firebase_push_status, firebase_last_pushed, user_id, team, username, created_at, modified_at) +""" type ContributorUserType { id: ID! @@ -841,7 +845,7 @@ enum Ordering { } """ -Organization(id, client_id, created_at, modified_at, created_by, modified_by, is_archived, archived_at, archived_by, name, description, abbreviation, unique_name, firebase_push_status, firebase_last_pushed) +Organization(id, client_id, created_at, modified_at, created_by, modified_by, is_archived, archived_at, archived_by, old_id, firebase_id, firebase_push_status, firebase_last_pushed, name, description, abbreviation, unique_name) """ input OrganizationCreateInput { clientId: String! @@ -851,7 +855,7 @@ input OrganizationCreateInput { } """ -Organization(id, client_id, created_at, modified_at, created_by, modified_by, is_archived, archived_at, archived_by, name, description, abbreviation, unique_name, firebase_push_status, firebase_last_pushed) +Organization(id, client_id, created_at, modified_at, created_by, modified_by, is_archived, archived_at, archived_by, old_id, firebase_id, firebase_push_status, firebase_last_pushed, name, description, abbreviation, unique_name) """ input OrganizationFilter { id: IDBaseFilterLookup @@ -874,7 +878,7 @@ type OrganizationSwipeStatsType { } """ -Organization(id, client_id, created_at, modified_at, created_by, modified_by, is_archived, archived_at, archived_by, name, description, abbreviation, unique_name, firebase_push_status, firebase_last_pushed) +Organization(id, client_id, created_at, modified_at, created_by, modified_by, is_archived, archived_at, archived_by, old_id, firebase_id, firebase_push_status, firebase_last_pushed, name, description, abbreviation, unique_name) """ type OrganizationType implements UserResourceTypeMixin { clientId: String! @@ -908,7 +912,7 @@ type OrganizationTypeOffsetPaginated { } """ -Organization(id, client_id, created_at, modified_at, created_by, modified_by, is_archived, archived_at, archived_by, name, description, abbreviation, unique_name, firebase_push_status, firebase_last_pushed) +Organization(id, client_id, created_at, modified_at, created_by, modified_by, is_archived, archived_at, archived_by, old_id, firebase_id, firebase_push_status, firebase_last_pushed, name, description, abbreviation, unique_name) """ input OrganizationUpdateInput { clientId: String! @@ -924,7 +928,7 @@ enum OverlayLayerTypeEnum { } """ -Project(id, client_id, created_at, modified_at, created_by, modified_by, old_id, firebase_push_status, firebase_last_pushed, canonical_id, project_type, requesting_organization, topic, region, project_number, look_for, additional_info_url, description, image, tutorial, verification_number, group_size, max_tasks_per_user, project_type_specifics, project_type_specific_output, centroid, is_featured, status, processing_status, team, is_private, progress, required_results, result_count) +Project(id, client_id, created_at, modified_at, created_by, modified_by, old_id, firebase_id, firebase_push_status, firebase_last_pushed, project_type, requesting_organization, topic, region, project_number, look_for, additional_info_url, description, image, tutorial, verification_number, group_size, max_tasks_per_user, project_type_specifics, project_type_specific_output, centroid, is_featured, status, processing_status, team, is_private, progress, required_results, result_count) """ input ProcessedProjectUpdateInput { clientId: String! @@ -1024,7 +1028,7 @@ type ProjectAssetTypeOffsetPaginated { } """ -Project(id, client_id, created_at, modified_at, created_by, modified_by, old_id, firebase_push_status, firebase_last_pushed, canonical_id, project_type, requesting_organization, topic, region, project_number, look_for, additional_info_url, description, image, tutorial, verification_number, group_size, max_tasks_per_user, project_type_specifics, project_type_specific_output, centroid, is_featured, status, processing_status, team, is_private, progress, required_results, result_count) +Project(id, client_id, created_at, modified_at, created_by, modified_by, old_id, firebase_id, firebase_push_status, firebase_last_pushed, project_type, requesting_organization, topic, region, project_number, look_for, additional_info_url, description, image, tutorial, verification_number, group_size, max_tasks_per_user, project_type_specifics, project_type_specific_output, centroid, is_featured, status, processing_status, team, is_private, progress, required_results, result_count) """ input ProjectCreateInput { clientId: String! @@ -1070,7 +1074,7 @@ type ProjectCustomSubOption { } """ -Project(id, client_id, created_at, modified_at, created_by, modified_by, old_id, firebase_push_status, firebase_last_pushed, canonical_id, project_type, requesting_organization, topic, region, project_number, look_for, additional_info_url, description, image, tutorial, verification_number, group_size, max_tasks_per_user, project_type_specifics, project_type_specific_output, centroid, is_featured, status, processing_status, team, is_private, progress, required_results, result_count) +Project(id, client_id, created_at, modified_at, created_by, modified_by, old_id, firebase_id, firebase_push_status, firebase_last_pushed, project_type, requesting_organization, topic, region, project_number, look_for, additional_info_url, description, image, tutorial, verification_number, group_size, max_tasks_per_user, project_type_specifics, project_type_specific_output, centroid, is_featured, status, processing_status, team, is_private, progress, required_results, result_count) """ input ProjectFilter { id: IDBaseFilterLookup @@ -1286,7 +1290,7 @@ input ProjectStatusEnumFilterLookup { } """ -Project(id, client_id, created_at, modified_at, created_by, modified_by, old_id, firebase_push_status, firebase_last_pushed, canonical_id, project_type, requesting_organization, topic, region, project_number, look_for, additional_info_url, description, image, tutorial, verification_number, group_size, max_tasks_per_user, project_type_specifics, project_type_specific_output, centroid, is_featured, status, processing_status, team, is_private, progress, required_results, result_count) +Project(id, client_id, created_at, modified_at, created_by, modified_by, old_id, firebase_id, firebase_push_status, firebase_last_pushed, project_type, requesting_organization, topic, region, project_number, look_for, additional_info_url, description, image, tutorial, verification_number, group_size, max_tasks_per_user, project_type_specifics, project_type_specific_output, centroid, is_featured, status, processing_status, team, is_private, progress, required_results, result_count) """ type ProjectType implements UserResourceTypeMixin { clientId: String! @@ -1445,7 +1449,7 @@ type ProjectTypeSwipeStatsType { } """ -Project(id, client_id, created_at, modified_at, created_by, modified_by, old_id, firebase_push_status, firebase_last_pushed, canonical_id, project_type, requesting_organization, topic, region, project_number, look_for, additional_info_url, description, image, tutorial, verification_number, group_size, max_tasks_per_user, project_type_specifics, project_type_specific_output, centroid, is_featured, status, processing_status, team, is_private, progress, required_results, result_count) +Project(id, client_id, created_at, modified_at, created_by, modified_by, old_id, firebase_id, firebase_push_status, firebase_last_pushed, project_type, requesting_organization, topic, region, project_number, look_for, additional_info_url, description, image, tutorial, verification_number, group_size, max_tasks_per_user, project_type_specifics, project_type_specific_output, centroid, is_featured, status, processing_status, team, is_private, progress, required_results, result_count) """ input ProjectUpdateInput { clientId: String! @@ -1699,7 +1703,7 @@ type TutorialAssetTypeOffsetPaginated { } """ -Tutorial(id, client_id, created_at, modified_at, created_by, modified_by, old_id, project, name, status) +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) """ input TutorialCreateInput { clientId: String! @@ -1710,7 +1714,7 @@ input TutorialCreateInput { } """ -Tutorial(id, client_id, created_at, modified_at, created_by, modified_by, old_id, project, name, status) +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) """ input TutorialFilter { id: IDBaseFilterLookup @@ -1999,7 +2003,7 @@ input TutorialTaskUpdateInput { } """ -Tutorial(id, client_id, created_at, modified_at, created_by, modified_by, old_id, project, name, status) +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) """ type TutorialType implements UserResourceTypeMixin { clientId: String! @@ -2034,7 +2038,7 @@ type TutorialTypeOffsetPaginated { } """ -Tutorial(id, client_id, created_at, modified_at, created_by, modified_by, old_id, project, name, status) +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) """ input TutorialUpdateInput { clientId: String!