Skip to content

Commit

Permalink
Add basic heartrate data from oura.
Browse files Browse the repository at this point in the history
  • Loading branch information
brianjp93 committed Jan 21, 2025
1 parent f153e7e commit 13b2c9a
Show file tree
Hide file tree
Showing 24 changed files with 855 additions and 141 deletions.
Empty file added activity/__init__.py
Empty file.
19 changes: 19 additions & 0 deletions activity/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from django.contrib import admin

from activity.models import Application, ApplicationToken, Heartrate


@admin.register(Application)
class ApplicationAdmin(admin.ModelAdmin):
pass


@admin.register(ApplicationToken)
class ApplicationTokenAdmin(admin.ModelAdmin):
raw_id_fields = ["user"]


@admin.register(Heartrate)
class HeartrateAdmin(admin.ModelAdmin):
raw_id_fields = ["user"]
list_display = ["user", "dt", "bpm"]
6 changes: 6 additions & 0 deletions activity/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class ActivityConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'activity'
48 changes: 48 additions & 0 deletions activity/managers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from datetime import timedelta

from django.db.models import Manager

from match.models import Match
from player.models import get_activity_api


class HeartrateManager(Manager):
def get_hr_for_match(self, match: Match, user):
start = match.game_creation_dt
end = start + timedelta(seconds=match.seconds)
qs = self.get_queryset().filter(
user=user,
dt__gt=start,
dt__lt=end,
)
return qs

def import_hr_for_match(self, match: Match, user):
api = get_activity_api(user)
if not api:
return self.none()
start = match.game_creation_dt
end = start + timedelta(seconds=match.seconds)
hr = api.update_or_create_heartrate(start, end, user)
return self.get_queryset().filter(id__in=[x.pk for x in hr]).order_by("dt")

def format_for_match(self, match: Match, user):
qs = self.get_hr_for_match(match, user).order_by("dt")
hr_list = list(qs)
if not hr_list:
return []
items = []
hr = hr_list[0]
hr_minute_map = {
(hr.dt - match.game_creation_dt).total_seconds() // 60: hr
for hr in hr_list
}
seconds = (hr.dt - match.game_creation_dt).total_seconds()
frame_count = int(match.minutes) + 1
for frame_idx in range(frame_count):
hr_maybe = hr_minute_map.get(frame_idx, None)
if hr_maybe:
hr = hr_maybe
seconds = (hr.dt - match.game_creation_dt).total_seconds()
items.append({"x": frame_idx, "y": hr.bpm, "seconds": seconds})
return items
40 changes: 40 additions & 0 deletions activity/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Generated by Django 5.1.2 on 2025-01-20 16:48

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


class Migration(migrations.Migration):

initial = True

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='Application',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(choices=[('OURA', 'Oura')], max_length=32, unique=True)),
('client_id', models.CharField(default='', max_length=32)),
('client_secret', models.CharField(default='', max_length=32)),
],
),
migrations.CreateModel(
name='ApplicationToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('access_token', models.CharField(max_length=64)),
('expires_at', models.DateTimeField()),
('refresh_token', models.CharField(max_length=64)),
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
('modified_at', models.DateTimeField(auto_now=True)),
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='activity.application')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]
25 changes: 25 additions & 0 deletions activity/migrations/0002_heartrate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Generated by Django 5.1.2 on 2025-01-20 18:46

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


class Migration(migrations.Migration):

dependencies = [
('activity', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='Heartrate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('bpm', models.IntegerField()),
('dt', models.DateTimeField()),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Generated by Django 5.1.2 on 2025-01-20 20:50

import django.utils.timezone
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('activity', '0002_heartrate'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.AlterField(
model_name='applicationtoken',
name='created_at',
field=models.DateTimeField(db_index=True, default=django.utils.timezone.now),
),
migrations.AlterUniqueTogether(
name='heartrate',
unique_together={('user', 'dt')},
),
]
Empty file added activity/migrations/__init__.py
Empty file.
80 changes: 80 additions & 0 deletions activity/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from django.db import models
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.conf import settings
from django.contrib.auth import get_user_model
from django.utils import timezone

from activity.managers import HeartrateManager
from ext.activity import ACTIVITY
from ext.activity.api import ActivityAPIBase


User = get_user_model()


class ApplicationType(models.TextChoices):
OURA = "OURA", _("Oura")


class Application(models.Model):
code = models.CharField(choices=ApplicationType.choices, max_length=32, unique=True)
client_id = models.CharField(max_length=32, default="")
client_secret = models.CharField(max_length=32, default="")

def __str__(self) -> str:
return self.code

@cached_property
def api(self):
api = ACTIVITY[self.code]()
assert isinstance(api, ActivityAPIBase)
return api

def get_client_id(self):
if self.client_id:
return self.client_id
elif client_id := getattr(settings, f"{self.code}_CLIENT_ID", ""):
return client_id
return ""

def get_client_secret(self):
if self.client_secret:
return self.client_secret
elif client_secret := getattr(settings, f"{self.code}_CLIENT_SECRET", ""):
return client_secret
return ""


class ApplicationToken(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
application = models.ForeignKey(Application, on_delete=models.CASCADE)
access_token = models.CharField(max_length=64)
expires_at = models.DateTimeField()
refresh_token = models.CharField(max_length=64)
created_at = models.DateTimeField(default=timezone.now, db_index=True)
modified_at = models.DateTimeField(auto_now=True)

def __str__(self) -> str:
return self.access_token[:7] + "..."

def refresh(self):
application = self.application
assert isinstance(application, Application)
application.api.refresh(self.refresh_token, self.user)



class Heartrate(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
bpm = models.IntegerField()
dt = models.DateTimeField()

objects = HeartrateManager()

class Meta:
unique_together = [('user', 'dt')]


def __str__(self) -> str:
return f"{self.user} - {self.bpm}"
3 changes: 3 additions & 0 deletions activity/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.test import TestCase

# Create your tests here.
9 changes: 9 additions & 0 deletions activity/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.urls import path
from . import views

app_name = 'activity'

urlpatterns = [
path("integrations/", views.IntegrationsListView.as_view(), name="integrations"),
path("<slug:code>/callback/", views.IntegrationCallbackView.as_view(), name="integration-callback"),
]
50 changes: 50 additions & 0 deletions activity/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from django.contrib import messages
from django.urls import reverse
from django.views import generic
from django.contrib.auth.mixins import LoginRequiredMixin

from activity.models import Application, ApplicationToken


class IntegrationsListView(LoginRequiredMixin, generic.TemplateView):
template_name = "activity/integrations.html"

def get_context_data(self, **kwargs):
qs = (
ApplicationToken.objects.filter(
user=self.request.user,
)
.order_by("application", "created_at")
.select_related("application")
.distinct("application")
)
items = {x.application.code: x for x in qs}
context = super().get_context_data(**kwargs)
context["OURA"] = items.get("OURA")
context["applications"] = {
x.code: {"obj": x, "authorize_url": x.api.get_authorize_url(self.request)}
for x in Application.objects.all()
}
return context


class IntegrationCallbackView(LoginRequiredMixin, generic.RedirectView):
def get_redirect_url(self, *args, **kwargs) -> str | None:
return reverse("activity:integrations")

def get(self, request, *args, **kwargs):
self.error = request.GET.get("error", None)
code = self.kwargs["code"]
if self.error:
messages.error(request, f"{code} integration denied.")
return super().get(request, *args, **kwargs)
else:
self.handle_callback(request)
messages.success(request, f"{code} integration added successfully.")
return super().get(request, *args, **kwargs)

def handle_callback(self, request):
code = self.kwargs["code"]
application = Application.objects.get(code=code.upper())
api = application.api
token = api.handle_authorize_request(request)
13 changes: 13 additions & 0 deletions ext/activity/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from typing import Type, TypedDict
from .oura import OuraAPI


class ACTIVITY_TYPE(TypedDict):
OURA: Type[OuraAPI]

ACTIVITY: ACTIVITY_TYPE = {
"OURA": OuraAPI,
}


__all__ = ["ACTIVITY"]
Loading

0 comments on commit 13b2c9a

Please sign in to comment.