diff --git a/apps/project/firebase.py b/apps/project/firebase.py new file mode 100644 index 00000000..760d61fa --- /dev/null +++ b/apps/project/firebase.py @@ -0,0 +1,96 @@ +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 main.config import Config +from main.logging import log_extra + +logger = logging.getLogger(__name__) + + +class InvalidOrganizationPushException(Exception): ... + + +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), + }, + ) + + +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, + ), + ), + ) + + +@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", + ], + ) 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 new file mode 100644 index 00000000..a301eb1e --- /dev/null +++ b/apps/project/migrations/0009_organization_firebase_last_pushed_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.4 on 2025-07-25 09:42 + +import apps.project.models +import django_choices_field.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('project', '0008_merge_20250722_0437'), + ] + + operations = [ + migrations.AddField( + model_name='organization', + name='firebase_last_pushed', + field=models.DateTimeField(blank=True, help_text='The latest time when organization was pushed to firebase', null=True), + ), + 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), + ), + ] diff --git a/apps/project/models.py b/apps/project/models.py index 42b646c6..de87dd8f 100644 --- a/apps/project/models.py +++ b/apps/project/models.py @@ -161,10 +161,28 @@ 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): Type = ProjectTypeEnum diff --git a/apps/project/serializers.py b/apps/project/serializers.py index bd4fd243..07458b95 100644 --- a/apps/project/serializers.py +++ b/apps/project/serializers.py @@ -6,6 +6,7 @@ from rest_framework import serializers from apps.common.serializers import ArchivableResourceSerializer, UserResourceSerializer +from apps.project.firebase import push_organization_to_firebase from apps.tutorial.models import Tutorial from project_types.store import get_project_property from utils.common import clean_up_none_keys @@ -348,3 +349,15 @@ class OrganizationSerializer(UserResourceSerializer[Organization], ArchivableRes class Meta: # type: ignore[reportIncompatibleVariableOverride] model = Organization fields = ("name", "description", "abbreviation") + + @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)) + 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)) + return organization diff --git a/firebase b/firebase index 1a379822..f83846ff 160000 --- a/firebase +++ b/firebase @@ -1 +1 @@ -Subproject commit 1a379822f1b929bb5f4cb6ca0f3ea56c6d8354f7 +Subproject commit f83846ff6e451c65db7dd3ebd50bcab2c045adba diff --git a/main/config.py b/main/config.py index 44c4440d..08ae3ab7 100644 --- a/main/config.py +++ b/main/config.py @@ -42,6 +42,10 @@ def project_groups(project_id: str | int): def project_tasks(project_id: str | int): return f"/v2/groups/{project_id}/" + @staticmethod + def organization(organization_id: str | int): + return f"/v2/organisation/{organization_id}/" + # FIXME: Import utils/geo/raster_tile_server/config.py here # FIXME: Import utils/geo/vector_tile_server/config.py here diff --git a/project_types/base/project.py b/project_types/base/project.py index 774587bf..f0a29aa6 100644 --- a/project_types/base/project.py +++ b/project_types/base/project.py @@ -384,7 +384,7 @@ def push_to_firebase(self): else: if fb_project is None: logger.error( - "push_to_firebase found did not find project in firebase when updating a project", + "push_to_firebase did not find project in firebase when updating a project", extra=log_extra({"project": self.project.pk}), ) raise InvalidProjectPushException diff --git a/schema.graphql b/schema.graphql index a9ed1440..3ddf64a2 100644 --- a/schema.graphql +++ b/schema.graphql @@ -708,7 +708,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) +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) """ input OrganizationCreateInput { clientId: String! @@ -718,7 +718,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) +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) """ input OrganizationFilter { id: IDBaseFilterLookup @@ -741,7 +741,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) +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) """ type OrganizationType implements UserResourceTypeMixin { clientId: String! @@ -775,7 +775,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) +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) """ input OrganizationUpdateInput { clientId: String!