diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..1e25877 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +exclude=.git,__pycache__ +max-line-length=88 diff --git a/.gitignore b/.gitignore index 70600ce..1cbfce9 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ infra/.terraform data ipython-data coverage +tags +.mypy_cache # Sqlite *.db diff --git a/fittrak/workouts/admin.py b/fittrak/workouts/admin.py index 24871b9..cd7fc31 100644 --- a/fittrak/workouts/admin.py +++ b/fittrak/workouts/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import Exercise, ExerciseType, Set, Workout +from .models import Exercise, ExerciseType, Set, Workout, WorkoutEvent @admin.register(Workout) @@ -42,3 +42,8 @@ def get_type(obj): return obj.exercise_type.name get_type.short_description = "Type" + + +@admin.register(WorkoutEvent) +class WorkoutEventAdmin(admin.ModelAdmin): + date_hierarchy = "created_at" diff --git a/fittrak/workouts/graphql/mutations.py b/fittrak/workouts/graphql/mutations.py index 2e4f3f4..6aa71cb 100644 --- a/fittrak/workouts/graphql/mutations.py +++ b/fittrak/workouts/graphql/mutations.py @@ -6,16 +6,13 @@ from django.utils import timezone from graphql import GraphQLError -from workouts.models import MuscleGroup, Workout +from workouts.helpers import create_workout_event, merge_dicts, model_as_dict +from workouts.models import Exercise +from workouts.models import ExerciseType as ExerciseTypeModel +from workouts.models import MuscleGroup, Set, Workout -from .types import ( - ExerciseInputType, - ExerciseType, - SetFieldInputType, - SetType, - WorkoutFieldInputType, - WorkoutType, -) +from .types import (ExerciseInputType, ExerciseType, SetFieldInputType, + SetType, WorkoutFieldInputType, WorkoutType) class CreateWorkout(graphene.Mutation): @@ -30,6 +27,13 @@ def mutate(_, info): new_workout = Workout.objects.create(user=user, date_started=timezone.now()) + create_workout_event( + user=user, + action="create_workout", + workout=new_workout, + state=model_as_dict(new_workout), + ) + return CreateWorkout(workout=new_workout) @@ -52,6 +56,13 @@ def mutate(_, info, workout_id): workout.is_active = False workout.save() + create_workout_event( + user=user, + action="remove_workout", + workout=workout, + state=model_as_dict(workout), + ) + return RemoveWorkout(workout=workout) @@ -72,16 +83,24 @@ def mutate(_, info, workout_id, workout_fields): raise GraphQLError("Workout not found.") exerciseTypes = None + if "exerciseTypes" in workout_fields: exerciseTypes = workout_fields.pop("exerciseTypes") dirty = False + # Unpack the fields and update the model - for name, value in workout_fields.items(): - if not hasattr(workout, name): + prev_state = model_as_dict(workout) + changed = {} + + for field_name, value in workout_fields.items(): + if not hasattr(workout, field_name): continue - setattr(workout, name, value) + setattr(workout, field_name, value) + + changed.update({field_name: value}) + dirty = True if exerciseTypes: @@ -100,6 +119,19 @@ def mutate(_, info, workout_id, workout_fields): workout.updated_at = timezone.now() workout.save() + create_workout_event( + user=user, + action="update_workout", + workout=workout, + state=merge_dicts( + { + "previous": {**prev_state}, + "current": model_as_dict(workout), + "changed": changed, + } + ), + ) + return UpdateWorkout(workout=workout) diff --git a/fittrak/workouts/helpers.py b/fittrak/workouts/helpers.py new file mode 100644 index 0000000..e035a94 --- /dev/null +++ b/fittrak/workouts/helpers.py @@ -0,0 +1,60 @@ +import datetime +from typing import Dict, Optional + +from .models import WorkoutEvent + +action_template: dict = { + "create_workout": 'New workout created at "{state[created_at]}"', + "update_workout": 'Workout "{state[current[slug]]}" has been updated with: "{state[changed]}"', + "remove_workout": 'Workout "{state[slug]}" has been removed', +} + + +def merge_dicts(*dicts) -> dict: + result: dict = {} + + for d in dicts: + result.update(d) + + return result + + +def model_as_dict(model) -> dict: + """ + Turns a model into a dict stripping away 'unwanted' fields, i.e start + with an underscore + """ + fields = vars(model) + + return {key: value for key, value in fields.items() if not key.startswith("_")} + + +def serialize_state(state): + for k, v in state.items(): + if isinstance(v, datetime.datetime): + state[k] = v.strftime("%Y-%m-%d %H:%M") + + return state + + +def create_workout_event( + *, workout, action: str, user, state: Optional[Dict] = None +) -> None: + try: + template = action_template[action] + except KeyError: + # TODO: Logging + pass + + # Use the current model as state by default, + # otherwise favour the new state passed in - useful for + # updated fields. + + if not state: + state = model_as_dict(workout) + + message = template.format(state=serialize_state(state)) + + WorkoutEvent.objects.create( + workout=workout, action=action, user=user, state=state, message=message + ) diff --git a/fittrak/workouts/migrations/0005_workoutevent.py b/fittrak/workouts/migrations/0005_workoutevent.py new file mode 100644 index 0000000..310abb5 --- /dev/null +++ b/fittrak/workouts/migrations/0005_workoutevent.py @@ -0,0 +1,35 @@ +# Generated by Django 2.2.3 on 2019-08-04 18:20 + +from django.conf import settings +import django.contrib.postgres.fields.jsonb +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('workouts', '0004_set_bodyweight'), + ] + + operations = [ + migrations.CreateModel( + name='WorkoutEvent', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('action', models.CharField(max_length=64)), + ('message', models.TextField(blank=True, help_text='Store the human friendly representation of the change')), + ('state', django.contrib.postgres.fields.jsonb.JSONField(encoder=django.core.serializers.json.DjangoJSONEncoder, help_text='Store the model state')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('workout', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='workouts.Workout')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/fittrak/workouts/models.py b/fittrak/workouts/models.py index da340c2..7ea0729 100644 --- a/fittrak/workouts/models.py +++ b/fittrak/workouts/models.py @@ -1,5 +1,7 @@ import hashids from django.conf import settings +from django.contrib.postgres.fields import JSONField +from django.core.serializers.json import DjangoJSONEncoder from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver @@ -37,10 +39,29 @@ def __str__(self): format_by = "%Y-%m-%d %H:%m" start = self.date_started.strftime(format_by) end = "N/A" + if self.date_ended: end = self.date_ended.strftime(format_by) - return "{} -- {} to {}".format(self.user, start, end) + return f"{self.user} - Start: {start} - End: {end}" + + +class WorkoutEvent(BaseModel, UserBaseModel): + """ + Tracks state change on a Workout + """ + + workout = models.ForeignKey("Workout", on_delete=models.CASCADE) + action = models.CharField(max_length=64) + message = models.TextField( + blank=True, help_text="Store the human friendly representation of the change" + ) + + # DjangoJSONEncoder deals with datetimes and uuids, default encoder does not + state = JSONField(encoder=DjangoJSONEncoder, help_text="Store the model state") + + def __str__(self): + return f"{self.workout}" class MuscleGroup(BaseModel):