Skip to content

Commit

Permalink
Notifications (#617)
Browse files Browse the repository at this point in the history
* add notifications models & admin class

* add generators & tests

* WIP notifications

* test

* fix: fixture in test_notification_fixture

* wip - adjuns notification api , model & tests

* fix get active notification , return generic notifications

* update api response, return is_read info

* add deduplication notification & adjust notification generation logic

* update ceramic cache migrations

* update dismissal endpoint

* add the expired stamp and on chain expiration notifications

* update migrations

* feat: backfill expiration and and issuance date in ceramic cache

* fix: notification endpoint throwing 500s

* fix: expiration notifications

* fix: remove unnecesary setter

* fix: failing test

* fix: verification of returned items

* add tests for duplication notifications

* allow blank fields for notifications

* filter only the recent dedup events & limit tops 20 notifications

* add  missing tests

* wip - adjuns notification api , model & tests

* fix migration error

---------

Co-authored-by: Gerald Iakobinyi-Pich <nutrina9@gmail.com>
Co-authored-by: schultztimothy <schultz.timothy52@gmail.com>
  • Loading branch information
3 people authored Jul 5, 2024
1 parent aaec9b8 commit b00c7be
Show file tree
Hide file tree
Showing 16 changed files with 1,278 additions and 36 deletions.
4 changes: 4 additions & 0 deletions api/ceramic_cache/api/v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ def handle_add_stamps(
stamp=p.stamp,
updated_at=now,
compose_db_save_status=CeramicCache.ComposeDBSaveStatus.PENDING,
issuance_date=p.stamp.get("issuanceDate", None),
expiration_date=p.stamp.get("expirationDate", None),
)
for p in payload
]
Expand Down Expand Up @@ -236,6 +238,8 @@ def handle_patch_stamps(
stamp=p.stamp,
updated_at=now,
compose_db_save_status=CeramicCache.ComposeDBSaveStatus.PENDING,
issuance_date=p.stamp.get("issuanceDate", None),
expiration_date=p.stamp.get("expirationDate", None),
)
for p in payload
if p.stamp
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from django.core.management.base import BaseCommand
from django.db import connection
from ceramic_cache.models import CeramicCache


class Command(BaseCommand):
help = "Backfills expiration_date and issuance_date from JSON data"

def handle(self, *args, **options):
batch_size = 10000
max_id = CeramicCache.objects.latest("id").id
current_id = 0

while current_id < max_id:
with connection.cursor() as cursor:
cursor.execute(
"""
UPDATE ceramic_cache_ceramiccache
SET
expiration_date = (stamp::json->>'expirationDate')::timestamp,
issuance_date = (stamp::json->>'issuanceDate')::timestamp
WHERE
id > %s
AND id <= %s
AND (expiration_date IS NULL OR issuance_date IS NULL)
""",
[current_id, current_id + batch_size],
)

self.stdout.write(
self.style.SUCCESS(f"Backfilled up to id {current_id + batch_size}")
)

current_id += batch_size
self.stdout.write(f"Processed up to id {current_id}")

self.stdout.write(self.style.SUCCESS("Data backfill completed successfully"))
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 4.2.6 on 2024-06-20 21:31

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("ceramic_cache", "0020_alter_ceramiccache_compose_db_save_status_and_more"),
]

operations = [
migrations.AddField(
model_name="ceramiccache",
name="expiration_date",
field=models.DateTimeField(db_index=True, null=True),
),
migrations.AddField(
model_name="ceramiccache",
name="issuance_date",
field=models.DateTimeField(db_index=True, null=True),
),
]
7 changes: 7 additions & 0 deletions api/ceramic_cache/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ class ComposeDBSaveStatus(models.TextChoices):
db_index=True,
)

issuance_date = models.DateTimeField(
null=True, db_index=True
) # stamp['issuanceDate']
expiration_date = models.DateTimeField(
null=True, db_index=True
) # stamp['expirationDate']

class Meta:
unique_together = ["type", "address", "provider", "deleted_at"]

Expand Down
28 changes: 26 additions & 2 deletions api/passport_admin/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@

from django.contrib import admin

from .models import DismissedBanners, PassportBanner
from .models import (
DismissedBanners,
PassportBanner,
Notification,
NotificationStatus,
)

# admin.site.register(PassportBanner)
admin.site.register(DismissedBanners)
admin.site.register(NotificationStatus)


@admin.register(PassportBanner)
Expand All @@ -20,3 +25,22 @@ class PassportAdmin(admin.ModelAdmin):
list_display = ("content", "link", "is_active", "application")
search_fields = ("content", "is_active", "link")
list_filter = ("is_active", "application")


@admin.register(Notification)
class NotificationAdmin(admin.ModelAdmin):
"""
Admin class for Notification.
"""

list_display = ("notification_id", "type", "is_active", "eth_address")
search_fields = ("eth_address", "type")
list_filter = (
"type",
"eth_address",
"is_active",
"created_at",
"expires_at",
"link",
"link_text",
)
172 changes: 157 additions & 15 deletions api/passport_admin/api.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,35 @@
from typing import List, Optional

from ceramic_cache.api.v1 import JWTDidAuth
from django.db.models import Subquery
from ninja import Router, Schema
from django.db.models import Subquery, Q, OuterRef, Value
from django.db.models.functions import Coalesce
from ninja import Router
from passport_admin.schema import (
Banner,
GenericResponse,
NotificationSchema,
NotificationResponse,
NotificationPayload,
DismissPayload,
)
from passport_admin.notification_generators.deduplication_events import (
generate_deduplication_notifications,
)
from passport_admin.notification_generators.expired_stamp import (
generate_stamp_expired_notifications,
)
from passport_admin.notification_generators.on_chain_expired import (
generate_on_chain_expired_notifications,
)

from .models import DismissedBanners, PassportBanner

from .models import (
DismissedBanners,
PassportBanner,
Notification,
NotificationStatus,
)
from django.utils import timezone

router = Router()

Expand All @@ -14,13 +39,6 @@ def get_address(did: str):
return did[start:]


class Banner(Schema):
content: str
link: Optional[str] = None
banner_id: int
application: str = "passport"


@router.get(
"/banners",
response=List[Banner],
Expand Down Expand Up @@ -52,16 +70,12 @@ def get_banners(request, application: Optional[str] = "passport"):
)
for b in banners
]
except:
except PassportBanner.DoesNotExist:
return {
"status": "failed",
}


class GenericResponse(Schema):
status: str


@router.post(
"/banners/{banner_id}/dismiss",
response={200: GenericResponse},
Expand All @@ -82,3 +96,131 @@ def dismiss_banner(request, banner_id: int):
return {
"status": "failed",
}


@router.post(
"/notifications",
response=NotificationResponse,
auth=JWTDidAuth(),
)
def get_notifications(request, payload: NotificationPayload):
"""
Get all notifications for a specific address.
This also includes the generic notifications
"""
try:
address = get_address(request.auth.did)
current_date = timezone.now().date()

generate_deduplication_notifications(address=address)
generate_stamp_expired_notifications(address=address)
if payload.expired_chain_ids:
generate_on_chain_expired_notifications(
address=address, expired_chains=payload.expired_chain_ids
)

notification_status_subquery = NotificationStatus.objects.filter(
notification=OuterRef("pk"), eth_address=address
).values(
"is_read"
)[
:1
] # [:1] is used to limit the subquery to 1 result. There should be only 1 NotificationStatus per Notification

custom_notifications = (
Notification.objects.filter(
Q(is_active=True, eth_address=address)
& (Q(expires_at__gte=current_date) | Q(expires_at__isnull=True))
& (
Q(notificationstatus__is_deleted=False)
| Q(notificationstatus__isnull=True)
)
)
.annotate(
is_read=Coalesce(Subquery(notification_status_subquery), Value(False))
)
.order_by("-created_at")
)

general_notifications = (
Notification.objects.filter(
Q(is_active=True, eth_address=None)
& (Q(expires_at__gte=current_date) | Q(expires_at__isnull=True))
& (
Q(notificationstatus__is_deleted=False)
| Q(notificationstatus__isnull=True)
)
)
.annotate(
is_read=Coalesce(Subquery(notification_status_subquery), Value(False))
)
.order_by("-created_at")
)

all_notifications = sorted(
[
NotificationSchema(
notification_id=n.notification_id,
type=n.type,
content=n.content,
link=n.link,
link_text=n.link_text,
is_read=n.is_read,
created_at=n.created_at,
).dict()
for n in [*custom_notifications, *general_notifications]
],
key=lambda x: x["created_at"],
reverse=True,
)[:20] # Limit to the 20 newest notifications

return NotificationResponse(items=all_notifications).dict()

except Notification.DoesNotExist:
return {
"status": "failed",
}


@router.post(
"/notifications/{notification_id}",
response={200: GenericResponse},
auth=JWTDidAuth(),
)
def dismiss_notification(request, notification_id: str, payload: DismissPayload):
"""
Dismiss a notification
"""
try:
address = get_address(request.auth.did)
notification = Notification.objects.get(notification_id=notification_id)

if payload.dismissal_type not in ["read", "delete"]:
return {
"status": "Failed! Bad dismissal type.",
}

notification_status, created = NotificationStatus.objects.get_or_create(
eth_address=address, notification=notification
)

if payload.dismissal_type == "read":
if not notification_status.is_read:
notification_status.is_read = True
notification_status.save()
return {
"status": "success",
}

if payload.dismissal_type == "delete":
if not notification_status.is_deleted:
notification_status.is_deleted = True
notification_status.save()
return {
"status": "success",
}

except Notification.DoesNotExist:
return {
"status": "failed",
}

This file was deleted.

Loading

0 comments on commit b00c7be

Please sign in to comment.