Skip to content
13 changes: 13 additions & 0 deletions apps/common/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
85 changes: 85 additions & 0 deletions apps/common/firebase.py
Original file line number Diff line number Diff line change
@@ -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
"""
...
32 changes: 19 additions & 13 deletions apps/common/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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"

Expand Down
6 changes: 3 additions & 3 deletions apps/common/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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],
Expand All @@ -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()

Expand Down
51 changes: 31 additions & 20 deletions apps/contributor/admin.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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('<a href="{}">View Team Members</a>', url)


@admin.register(ContributorUserGroupMembership)
Expand Down
5 changes: 4 additions & 1 deletion apps/contributor/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,16 @@ 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}")


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"
Expand All @@ -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}")

Expand Down
Loading
Loading