Skip to content

Commit

Permalink
Add mobile endpoint, tests
Browse files Browse the repository at this point in the history
  • Loading branch information
sravfeyn committed Jul 14, 2024
1 parent 51ffdb5 commit f97b2c5
Show file tree
Hide file tree
Showing 9 changed files with 215 additions and 43 deletions.
53 changes: 53 additions & 0 deletions commcare_connect/events/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Generated by Django 4.2.5 on 2024-07-13 14:45

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):
initial = True

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("opportunity", "0044_opportunityverificationflags"),
]

operations = [
migrations.CreateModel(
name="Event",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("date_created", models.DateTimeField(auto_now_add=True, db_index=True)),
(
"event_type",
models.CharField(
choices=[
("invite_sent", "Invite Sent"),
("records_approved", "Records Approved"),
("records_flagged", "Records Flagged"),
("records_rejected", "Records Rejected"),
("payment_approved", "Payment Approved"),
("payment_accrued", "Payment Accrued"),
("payment_transferred", "Payment Transferred"),
("notifications_sent", "Notifications Sent"),
("additional_budget_added", "Additional Budget Added"),
],
max_length=40,
),
),
(
"opportunity",
models.ForeignKey(
null=True, on_delete=django.db.models.deletion.PROTECT, to="opportunity.opportunity"
),
),
(
"user",
models.ForeignKey(
null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
),
]
Empty file.
43 changes: 13 additions & 30 deletions commcare_connect/events/models.py
Original file line number Diff line number Diff line change
@@ -1,54 +1,37 @@
from abc import ABCMeta, abstractproperty
from dataclasses import dataclass, field, fields
from dataclasses import dataclass
from datetime import datetime

from django.db import models
from django.utils.translation import gettext as _

from commcare_connect.users.models import User

EVENT_TYPES = {"invite_sent": {}}


@dataclass(frozen=True)
class EventTypeChoice:
INVITE_SENT: tuple[str, str] = field(default=("invite_sent", _("Invite Sent")))
RECORDS_APPROVED: tuple[str, str] = field(default=("records_approved", _("Records Approved")))
RECORDS_FLAGGED: tuple[str, str] = field(default=("records_flagged", _("Records Flagged")))
RECORDS_REJECTED: tuple[str, str] = field(default=("records_rejected", _("Records Rejected")))
PAYMENT_APPROVED: tuple[str, str] = field(default=("payment_approved", _("Payment Approved")))
PAYMENT_ACCRUED: tuple[str, str] = field(default=("payment_accrued", _("Payment Accrued")))
PAYMENT_TRANSFERRED: tuple[str, str] = field(default=("payment_transferred", _("Payment Transferred")))
NOTIFICATIONS_SENT: tuple[str, str] = field(default=("notifications_sent", _("Notifications Sent")))
ADDITIONAL_BUDGET_ADDED: tuple[str, str] = field(default=("additional_budget_added", _("Additional Budget Added")))

@classmethod
@property
def choices(cls):
return [(field.default[0], field.default[1]) for field in fields(cls)]
from . import types


class Event(models.Model):
# this allows referring to Event.Type without importing EventTypeChoice separately
Type = EventTypeChoice
from commcare_connect.opportunity.models import Opportunity

# this allows referring to event types in this style: Event.Type.INVITE_SENT
Type = types

date_created = models.DateTimeField(auto_now_add=True, db_index=True)
event_type = models.CharField(max_length=40, choices=Type.choices)
event_type = models.CharField(max_length=40, choices=types.EVENT_TYPE_CHOICES)
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True)
opportunity = models.ForeignKey("Opportunity", on_delete=models.PROTECT, null=True)
opportunity = models.ForeignKey(Opportunity, on_delete=models.PROTECT, null=True)

@classmethod
def track(cls, use_async=True):
def track(self, use_async=True):
"""
To track an event instantiate the object and call this method,
To track an event, instantiate the object and call this method,
instead of calling save directly.
If use_async is True, the event is queued in Redis and saved
via celery, otherwise it's saved directly.
"""
from commcare_connect.events.tasks import track_event

track_event(cls, use_async=use_async)
track_event(self, use_async=use_async)


@dataclass
Expand All @@ -64,7 +47,7 @@ class InferredEvent:
class InferredEventSpec(metaclass=ABCMeta):
"""
Use this to define an Event that can be inferred
based on other models.
based on other models. See RecordsFlagged for example
"""

@abstractproperty
Expand Down Expand Up @@ -136,7 +119,7 @@ def get_events(self, user=None, from_date=None, to_date=None):


class RecordsFlagged(InferredEventSpec):
event_type = ("RECORDS_FLAGGED", _("Records Flagged"))
event_type = (types.RECORDS_FLAGGED, _("Records Flagged"))
model_cls = "UserVisit"
event_filters = {"flagged": True}
user = "user"
Expand Down
37 changes: 24 additions & 13 deletions commcare_connect/events/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,38 @@
REDIS_EVENTS_QUEUE = "events_queue"


class EventQueue:
def __init__(self):
self.redis_conn = get_redis_connection("default")

def push(self, event_obj):
serialized_event = pickle.dumps(event_obj)
self.redis_conn.rpush(REDIS_EVENTS_QUEUE, serialized_event)

def pop(self):
events = [pickle.loads(event) for event in self.redis_conn.lrange(REDIS_EVENTS_QUEUE, 0, -1)]
self.redis_conn.ltrim(REDIS_EVENTS_QUEUE, len(events), -1)
return events


@celery_app.task
def process_events_batch():
redis_conn = get_redis_connection("default")
events = redis_conn.lrange(REDIS_EVENTS_QUEUE, 0, -1)
event_queue = EventQueue()
events = event_queue.pop()
if not events:
return

with transaction.atomic():
event_objs = []
try:
with transaction.atomic():
Event.objects.bulk_create(events)
except Exception as e:
for event in events:
event_objs.append(pickle.loads(event))
Event.objects.bulk_create(event_objs)

redis_conn.ltrim(REDIS_EVENTS_QUEUE, len(events), -1)
event_queue.push(event)
raise e


def track_event(event_obj, use_async=True):
event_obj.date_created = datetime.now()
event_obj.date_created = datetime.utcnow()
if use_async:
redis_conn = get_redis_connection("default")
serialized_event = pickle.dumps(event_obj)
redis_conn.rpush(REDIS_EVENTS_QUEUE, serialized_event)
EventQueue().push(event_obj)
else:
event_obj.save()
60 changes: 60 additions & 0 deletions commcare_connect/events/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from rest_framework.test import APIClient

from commcare_connect.opportunity.models import Opportunity
from commcare_connect.users.models import User

from .models import Event
from .tasks import EventQueue, process_events_batch


def test_post_events(mobile_user_with_connect_link: User, api_client: APIClient, opportunity: Opportunity):
api_client.force_authenticate(mobile_user_with_connect_link)
response = api_client.post(
"/api/events/",
data=[
{
"event_type": "invalid_event_name",
"user": mobile_user_with_connect_link.pk,
"opportunity": opportunity.pk,
}
],
format="json",
)
assert response.status_code == 400
assert Event.objects.count() == 0
response = api_client.post(
"/api/events/",
data=[
{
"event_type": Event.Type.INVITE_SENT,
"user": mobile_user_with_connect_link.pk,
"opportunity": opportunity.pk,
},
{
"event_type": Event.Type.RECORDS_APPROVED,
"user": mobile_user_with_connect_link.pk,
"opportunity": opportunity.pk,
},
],
format="json",
)
assert response.status_code == 201
assert Event.objects.count() == 2


def test_event_queue(mobile_user_with_connect_link: User, opportunity: Opportunity):
event_queue = EventQueue()
assert event_queue.pop() == []

# queue the event
event = Event(event_type=Event.Type.INVITE_SENT, user=mobile_user_with_connect_link, opportunity=opportunity)
event.track()
queued_events = event_queue.pop()
process_events_batch()
assert len(queued_events) == 1
assert Event.objects.count() == 0
# process the batch
event.track()
process_events_batch()
assert Event.objects.count() == 1
assert Event.objects.first().user == event.user
28 changes: 28 additions & 0 deletions commcare_connect/events/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from django.utils.translation import gettext as _

# Server/Web events
INVITE_SENT = "invite_sent"
RECORDS_APPROVED = "records_approved"
RECORDS_REJECTED = "records_rejected"
PAYMENT_APPROVED = "payment_approved"
PAYMENT_ACCRUED = "payment_accrued"
PAYMENT_TRANSFERRED = "payment_transferred"
NOTIFICATIONS_SENT = "notifications_sent"
ADDITIONAL_BUDGET_ADDED = "additional_budget_added"

EVENT_TYPES = {
INVITE_SENT: _("Invite Sent"),
RECORDS_APPROVED: _("Records Approved"),
RECORDS_REJECTED: _("Records Rejected"),
PAYMENT_APPROVED: _("Payment Approved"),
PAYMENT_ACCRUED: _("Payment Accrued"),
PAYMENT_TRANSFERRED: _("Payment Transferred"),
NOTIFICATIONS_SENT: _("Notifications Sent"),
ADDITIONAL_BUDGET_ADDED: _("Additional Budget Added"),
}

EVENT_TYPE_CHOICES = list(EVENT_TYPES.items())


# Inferred Events
RECORDS_FLAGGED = "records_flagged"
34 changes: 34 additions & 0 deletions commcare_connect/events/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from rest_framework import serializers, status
from rest_framework.generics import ListCreateAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response

from .models import Event


class EventSerializer(serializers.ModelSerializer):
class Meta:
model = Event
fields = ["date_created", "event_type", "user", "opportunity"]


@method_decorator(csrf_exempt, name="dispatch")
class EventListCreateView(ListCreateAPIView):
queryset = Event.objects.all()
serializer_class = EventSerializer
permission_classes = [IsAuthenticated]

def create(self, request, *args, **kwargs):
if not isinstance(request.data, list):
return Response({"error": "Expected a list of items"}, status=status.HTTP_400_BAD_REQUEST)

serializer = self.get_serializer(data=request.data, many=True)
serializer.is_valid(raise_exception=True)

event_objects = [Event(**item) for item in serializer.validated_data]
Event.objects.bulk_create(event_objects)

headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
1 change: 1 addition & 0 deletions commcare_connect/opportunity/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ def invite_user(user_id, opportunity_access_id):
from commcare_connect.events.models import Event

Event(event_type=Event.Type.INVITE_SENT, user=user, opportunity=opportunity_access.opportunity).track(
# this is already in async worker, so user_async is False
use_async=False
)

Expand Down
2 changes: 2 additions & 0 deletions config/api_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter, SimpleRouter

from commcare_connect.events.views import EventListCreateView
from commcare_connect.form_receiver.views import FormReceiver
from commcare_connect.opportunity.api.views import (
ClaimOpportunityView,
Expand All @@ -25,6 +26,7 @@
app_name = "api"
urlpatterns = [
path("", include(router.urls)),
path("events/", EventListCreateView.as_view(), name="create_events"),
path("receiver/", FormReceiver.as_view(), name="receiver"),
path("opportunity/<int:pk>/learn_progress", UserLearnProgressView.as_view(), name="learn_progress"),
path("opportunity/<int:pk>/claim", ClaimOpportunityView.as_view()),
Expand Down

0 comments on commit f97b2c5

Please sign in to comment.