diff --git a/aws/backend.json b/aws/backend.json index 815bfbc7bf..a0a32179ae 100644 --- a/aws/backend.json +++ b/aws/backend.json @@ -262,6 +262,18 @@ { "valueFrom": "/care/backend/ABDM_CLIENT_SECRET", "name": "ABDM_CLIENT_SECRET" + }, + { + "valueFrom": "/care/backend/PLAUSIBLE_HOST", + "name": "PLAUSIBLE_HOST" + }, + { + "valueFrom": "/care/backend/PLAUSIBLE_SITE_ID", + "name": "PLAUSIBLE_SITE_ID" + }, + { + "valueFrom": "/care/backend/PLAUSIBLE_AUTH_TOKEN", + "name": "PLAUSIBLE_AUTH_TOKEN" } ], "name": "care-backend" diff --git a/aws/celery.json b/aws/celery.json index e9844ba75f..3b0aa109e3 100644 --- a/aws/celery.json +++ b/aws/celery.json @@ -248,6 +248,18 @@ "valueFrom": "/care/backend/HCX_IG_URL", "name": "HCX_IG_URL" }, + { + "valueFrom": "/care/backend/PLAUSIBLE_HOST", + "name": "PLAUSIBLE_HOST" + }, + { + "valueFrom": "/care/backend/PLAUSIBLE_SITE_ID", + "name": "PLAUSIBLE_SITE_ID" + }, + { + "valueFrom": "/care/backend/PLAUSIBLE_AUTH_TOKEN", + "name": "PLAUSIBLE_AUTH_TOKEN" + }, { "valueFrom": "/care/backend/ABDM_CLIENT_ID", "name": "ABDM_CLIENT_ID" @@ -505,6 +517,18 @@ "valueFrom": "/care/backend/HCX_IG_URL", "name": "HCX_IG_URL" }, + { + "valueFrom": "/care/backend/PLAUSIBLE_HOST", + "name": "PLAUSIBLE_HOST" + }, + { + "valueFrom": "/care/backend/PLAUSIBLE_SITE_ID", + "name": "PLAUSIBLE_SITE_ID" + }, + { + "valueFrom": "/care/backend/PLAUSIBLE_AUTH_TOKEN", + "name": "PLAUSIBLE_AUTH_TOKEN" + }, { "valueFrom": "/care/backend/ABDM_CLIENT_ID", "name": "ABDM_CLIENT_ID" diff --git a/care/facility/migrations/0388_goal_goalentry_goalproperty_goalpropertyentry.py b/care/facility/migrations/0388_goal_goalentry_goalproperty_goalpropertyentry.py new file mode 100644 index 0000000000..afe651f1c0 --- /dev/null +++ b/care/facility/migrations/0388_goal_goalentry_goalproperty_goalpropertyentry.py @@ -0,0 +1,175 @@ +# Generated by Django 4.2.2 on 2023-09-06 08:36 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("facility", "0387_merge_20230911_2303"), + ] + + operations = [ + migrations.CreateModel( + name="Goal", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "external_id", + models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), + ), + ( + "created_date", + models.DateTimeField(auto_now_add=True, db_index=True, null=True), + ), + ( + "modified_date", + models.DateTimeField(auto_now=True, db_index=True, null=True), + ), + ("deleted", models.BooleanField(db_index=True, default=False)), + ("name", models.CharField(max_length=200)), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="GoalEntry", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "external_id", + models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), + ), + ( + "created_date", + models.DateTimeField(auto_now_add=True, db_index=True, null=True), + ), + ( + "modified_date", + models.DateTimeField(auto_now=True, db_index=True, null=True), + ), + ("deleted", models.BooleanField(db_index=True, default=False)), + ("date", models.DateField()), + ("visitors", models.IntegerField(null=True)), + ("events", models.IntegerField(null=True)), + ( + "goal", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="entries", + to="facility.goal", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="GoalProperty", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "external_id", + models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), + ), + ( + "created_date", + models.DateTimeField(auto_now_add=True, db_index=True, null=True), + ), + ( + "modified_date", + models.DateTimeField(auto_now=True, db_index=True, null=True), + ), + ("deleted", models.BooleanField(db_index=True, default=False)), + ("name", models.CharField(max_length=200)), + ( + "goal", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="properties", + to="facility.goal", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="GoalPropertyEntry", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "external_id", + models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), + ), + ( + "created_date", + models.DateTimeField(auto_now_add=True, db_index=True, null=True), + ), + ( + "modified_date", + models.DateTimeField(auto_now=True, db_index=True, null=True), + ), + ("deleted", models.BooleanField(db_index=True, default=False)), + ("value", models.CharField(max_length=200)), + ("visitors", models.IntegerField(null=True)), + ("events", models.IntegerField(null=True)), + ( + "goal_entry", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="properties", + to="facility.goalentry", + ), + ), + ( + "goal_property", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="entries", + to="facility.goalproperty", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/care/facility/models/stats.py b/care/facility/models/stats.py new file mode 100644 index 0000000000..47f2ba734c --- /dev/null +++ b/care/facility/models/stats.py @@ -0,0 +1,43 @@ +from django.db import models + +from care.utils.models.base import BaseModel + + +class Goal(BaseModel): + name = models.CharField(max_length=200) + + +class GoalEntry(BaseModel): + goal = models.ForeignKey( + Goal, + on_delete=models.PROTECT, + related_name="entries", + ) + date = models.DateField() + visitors = models.IntegerField(null=True) + events = models.IntegerField(null=True) + + +class GoalProperty(BaseModel): + name = models.CharField(max_length=200) + goal = models.ForeignKey( + Goal, + on_delete=models.CASCADE, + related_name="properties", + ) + + +class GoalPropertyEntry(BaseModel): + goal_entry = models.ForeignKey( + GoalEntry, + on_delete=models.PROTECT, + related_name="properties", + ) + goal_property = models.ForeignKey( + GoalProperty, + on_delete=models.PROTECT, + related_name="entries", + ) + value = models.CharField(max_length=200) + visitors = models.IntegerField(null=True) + events = models.IntegerField(null=True) diff --git a/care/facility/tasks/__init__.py b/care/facility/tasks/__init__.py index 1a9383d32d..6231b4f9a3 100644 --- a/care/facility/tasks/__init__.py +++ b/care/facility/tasks/__init__.py @@ -3,6 +3,7 @@ from care.facility.tasks.asset_monitor import check_asset_status from care.facility.tasks.cleanup import delete_old_notifications +from care.facility.tasks.plausible_stats import capture_goals from care.facility.tasks.summarisation import ( summarise_district_patient, summarise_facility_capacity, @@ -15,7 +16,7 @@ @current_app.on_after_finalize.connect def setup_periodic_tasks(sender, **kwargs): sender.add_periodic_task( - crontab(minute="0", hour="0"), + crontab(hour="0", minute="0"), delete_old_notifications.s(), name="delete_old_notifications", ) @@ -49,3 +50,8 @@ def setup_periodic_tasks(sender, **kwargs): check_asset_status.s(), name="check_asset_status", ) + sender.add_periodic_task( + crontab(hour="0", minute="0"), + capture_goals.s(), + name="capture_goals", + ) diff --git a/care/facility/tasks/plausible_stats.py b/care/facility/tasks/plausible_stats.py new file mode 100644 index 0000000000..ea9e89b1d7 --- /dev/null +++ b/care/facility/tasks/plausible_stats.py @@ -0,0 +1,151 @@ +import logging +from datetime import timedelta +from enum import Enum + +import requests +from celery import shared_task +from django.conf import settings +from django.utils.timezone import now + +from care.facility.models.stats import Goal, GoalEntry, GoalProperty, GoalPropertyEntry + +logger = logging.getLogger(__name__) + + +class Goals(Enum): + PATIENT_CONSULTATION_VIEWED = ("facilityId", "consultationId", "userId") + DOCTOR_CONNECT_CLICKED = ("consultationId", "facilityId", "userId", "page") + CAMERA_PRESET_CLICKED = ("presetName", "consultationId", "userId", "result") + CAMERA_FEED_MOVED = ("direction", "consultationId", "userId") + PATIENT_PROFILE_VIEWED = ("facilityId", "userId") + DEVICE_VIEWED = ("bedId", "assetId", "userId") + PAGEVIEW = ("page",) + + @property + def formatted_name(self): + if self == Goals.PAGEVIEW: + return "pageview" # pageview is a reserved goal in plausible + return self.name.replace("_", " ").title() + + +def get_goal_stats(plausible_host, site_id, date, goal_name): + goal_filter = f"event:name=={goal_name}" + url = f"https://{plausible_host}/api/v1/stats/aggregate" + + params = { + "site_id": site_id, + "filters": goal_filter, + "period": "day", + "date": date, + "metrics": "visitors,events", + } + + response = requests.get( + url, + params=params, + headers={ + "Authorization": "Bearer " + settings.PLAUSIBLE_AUTH_TOKEN, + }, + timeout=60, + ) + + response.raise_for_status() + + return response.json() + + +def get_goal_event_stats(plausible_host, site_id, date, goal_name, event_name): + goal_filter = f"event:name=={goal_name}" + + # pageview is a reserved goal in plausible which uses event:page + if goal_name == "pageview" and event_name == "page": + goal_event = "event:page" + else: + goal_event = f"event:props:{event_name}" + + url = f"https://{plausible_host}/api/v1/stats/breakdown" + + params = { + "site_id": site_id, + "property": goal_event, + "filters": goal_filter, + "period": "day", + "date": date, + "metrics": "visitors,events", + } + + response = requests.get( + url, + params=params, + headers={ + "Authorization": "Bearer " + settings.PLAUSIBLE_AUTH_TOKEN, + }, + timeout=60, + ) + + response.raise_for_status() + + return response.json() + + +@shared_task +def capture_goals(): + if ( + not settings.PLAUSIBLE_HOST + or not settings.PLAUSIBLE_SITE_ID + or not settings.PLAUSIBLE_AUTH_TOKEN + ): + logger.info("Plausible is not configured, skipping") + return + today = now().date() + yesterday = today - timedelta(days=1) + logger.info(f"Capturing Goals for {yesterday}") + + for goal in Goals: + try: + goal_name = goal.formatted_name + goal_data = get_goal_stats( + settings.PLAUSIBLE_HOST, + settings.PLAUSIBLE_SITE_ID, + yesterday, + goal_name, + ) + goal_object, _ = Goal.objects.get_or_create( + name=goal_name, + ) + goal_entry_object, _ = GoalEntry.objects.get_or_create( + goal=goal_object, + date=yesterday, + ) + goal_entry_object.visitors = goal_data["results"]["visitors"]["value"] + goal_entry_object.events = goal_data["results"]["events"]["value"] + goal_entry_object.save() + + logger.info(f"Saved goal entry for {goal_name} on {yesterday}") + + for property_name in goal.value: + goal_property_stats = get_goal_event_stats( + settings.PLAUSIBLE_HOST, + settings.PLAUSIBLE_SITE_ID, + yesterday, + goal_name, + property_name, + ) + for property_statistic in goal_property_stats["results"]: + property_object, _ = GoalProperty.objects.get_or_create( + goal=goal_object, + name=property_name, + ) + property_entry_object, _ = GoalPropertyEntry.objects.get_or_create( + goal_property=property_object, + goal_entry=goal_entry_object, + value=property_statistic[property_name], + ) + property_entry_object.visitors = property_statistic["visitors"] + property_entry_object.events = property_statistic["events"] + property_entry_object.save() + logger.info( + f"Saved goal property entry for {goal_name} and property {property_name} on {yesterday}" + ) + except Exception as e: + logger.error(f"Failed to process goal {goal_name} due to error: {str(e)}") diff --git a/config/settings/base.py b/config/settings/base.py index 053c81f882..dfdad9bf37 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -567,3 +567,7 @@ HCX_PASSWORD = env("HCX_PASSWORD", default="") HCX_ENCRYPTION_PRIVATE_KEY_URL = env("HCX_ENCRYPTION_PRIVATE_KEY_URL", default="") HCX_IG_URL = env("HCX_IG_URL", default="https://ig.hcxprotocol.io/v0.7.1") + +PLAUSIBLE_HOST = env("PLAUSIBLE_HOST", default="") +PLAUSIBLE_SITE_ID = env("PLAUSIBLE_SITE_ID", default="") +PLAUSIBLE_AUTH_TOKEN = env("PLAUSIBLE_AUTH_TOKEN", default="")