Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#152] save user metrics preferences in database #166

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
57 changes: 57 additions & 0 deletions backend/projects/migrations/0032_metricspreferences.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Generated by Django 3.0.3 on 2020-04-03 08:56

from django.conf import settings
import django.contrib.postgres.fields
from django.db import migrations, models
import django.db.models.deletion
import uuid


def create_default_metrics_preferences(apps, schema_editor):
MetricsPreferences = apps.get_model("projects", "MetricsPreferences")
Projects = apps.get_model("projects", "project")
for row in Projects.objects.all():
for member in row.members.all():
MetricsPreferences.objects.create(
project_id=row.uuid,
user_id=member.id,
metrics=[
"WPTMetricFirstViewTTI",
"WPTMetricFirstViewSpeedIndex",
"WPTMetricFirstViewLoadTime",
]
)


def delete_metrics_preferences(apps, schema_editor):
MetricsPreferences = apps.get_model("projects", "MetricsPreferences")
MetricsPreferences.objects.all().delete()


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('projects', '0031_auto_20191122_1154'),
]

operations = [
migrations.CreateModel(
name='MetricsPreferences',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('metrics', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('WPTMetricFirstViewTTI', 'WPTMetricFirstViewTTI'), ('WPTMetricRepeatViewTTI', 'WPTMetricRepeatViewTTI'), ('WPTMetricFirstViewSpeedIndex', 'WPTMetricFirstViewSpeedIndex'), ('WPTMetricRepeatViewSpeedIndex', 'WPTMetricRepeatViewSpeedIndex'), ('WPTMetricFirstViewFirstPaint', 'WPTMetricFirstViewFirstPaint'), ('WPTMetricRepeatViewFirstPaint', 'WPTMetricRepeatViewFirstPaint'), ('WPTMetricFirstViewFirstMeaningfulPaint', 'WPTMetricFirstViewFirstMeaningfulPaint'), ('WPTMetricRepeatViewFirstMeaningfulPaint', 'WPTMetricRepeatViewFirstMeaningfulPaint'), ('WPTMetricFirstViewLoadTime', 'WPTMetricFirstViewLoadTime'), ('WPTMetricRepeatViewLoadTime', 'WPTMetricRepeatViewLoadTime'), ('WPTMetricFirstViewFirstContentfulPaint', 'WPTMetricFirstViewFirstContentfulPaint'), ('WPTMetricRepeatViewFirstContentfulPaint', 'WPTMetricRepeatViewFirstContentfulPaint'), ('WPTMetricFirstViewTimeToFirstByte', 'WPTMetricFirstViewTimeToFirstByte'), ('WPTMetricRepeatViewTimeToFirstByte', 'WPTMetricRepeatViewTimeToFirstByte'), ('WPTMetricFirstViewVisuallyComplete', 'WPTMetricFirstViewVisuallyComplete'), ('WPTMetricRepeatViewVisuallyComplete', 'WPTMetricRepeatViewVisuallyComplete'), ('WPTMetricLighthousePerformance', 'WPTMetricLighthousePerformance')], max_length=100), default=['WPTMetricFirstViewTTI', 'WPTMetricFirstViewSpeedIndex', 'WPTMetricFirstViewLoadTime'], null=True, size=None)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='projects.Project')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('project', 'user')},
},
),
migrations.RunPython(
create_default_metrics_preferences,
reverse_code=delete_metrics_preferences,
)
]
71 changes: 71 additions & 0 deletions backend/projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from django.db import models
from django import forms
from fernet_fields import EncryptedTextField
from django.contrib.postgres.fields import ArrayField
from django.db.models.signals import post_save, pre_delete


class Project(BaseModel):
Expand Down Expand Up @@ -52,11 +54,80 @@ class ProjectMemberRole(BaseModel):
user = models.ForeignKey(User, on_delete=models.CASCADE)
is_admin = models.BooleanField(default=False)

def save(self, *args, **kwargs):
project_member_role = super().save(*args, **kwargs)
return project_member_role

def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)

class Meta:
ordering = ("-is_admin", "-created_at")
unique_together = ("project", "user")


def save_project_member(sender, instance, **kwargs):
MetricsPreferences.objects.create(
project=instance.project,
user=instance.user,
metrics=[
"WPTMetricFirstViewTTI",
"WPTMetricFirstViewSpeedIndex",
"WPTMetricFirstViewLoadTime",
],
)


def delete_project_member(sender, instance, **kwargs):
MetricsPreferences.objects.filter(
project=instance.project, user_id=instance.user.id
).delete()


post_save.connect(save_project_member, sender=ProjectMemberRole)
pre_delete.connect(delete_project_member, sender=ProjectMemberRole)


class MetricOptions(Enum):
FIRST_VIEW_TTI = "WPTMetricFirstViewTTI"
REPEAT_VIEW_TTI = "WPTMetricRepeatViewTTI"
FIRST_VIEW_SPEED_INDEX = "WPTMetricFirstViewSpeedIndex"
REPEAT_VIEW_SPEED_INDEX = "WPTMetricRepeatViewSpeedIndex"
FIRST_VIEW_PAINT = "WPTMetricFirstViewFirstPaint"
REPEAT_VIEW_FIRST_PAINT = "WPTMetricRepeatViewFirstPaint"
FIRST_VIEW_FIRST_MEANINGFUL_PAINT = "WPTMetricFirstViewFirstMeaningfulPaint"
REPEAT_VIEW_FIRST_MEANINGFUL_PAINT = "WPTMetricRepeatViewFirstMeaningfulPaint"
FIRST_VIEW_LOAD_TIME = "WPTMetricFirstViewLoadTime"
REPEAT_VIEW_LOAD_TIME = "WPTMetricRepeatViewLoadTime"
FIRST_VIEW_FIRST_CONTENTFUL_PAINT = "WPTMetricFirstViewFirstContentfulPaint"
REPEAT_VIEW_FIRST_CONTENTFUL_PAINT = "WPTMetricRepeatViewFirstContentfulPaint"
FIRST_VIEW_TIME_TO_FIRST_BYTE = "WPTMetricFirstViewTimeToFirstByte"
REPEAT_VIEW_TIME_TO_FIRST_BYTE = "WPTMetricRepeatViewTimeToFirstByte"
FIRST_VIEW_VISUALLY_COMPLETE = "WPTMetricFirstViewVisuallyComplete"
REPEAT_VIEW_VISUALLY_COMPLETE = "WPTMetricRepeatViewVisuallyComplete"
LIGHTHOUSE_PERFORMANCE = "WPTMetricLighthousePerformance"


class MetricsPreferences(BaseModel):
user = models.ForeignKey(User, on_delete=models.CASCADE)
project = models.ForeignKey(Project, on_delete=models.CASCADE)
metrics = ArrayField(
models.CharField(
max_length=100,
choices=[(metric.value, metric.value) for metric in MetricOptions],
),
null=True,
default=[
"WPTMetricFirstViewTTI",
"WPTMetricFirstViewSpeedIndex",
"WPTMetricFirstViewLoadTime",
],
)

class Meta:
unique_together = ("project", "user")


fargito marked this conversation as resolved.
Show resolved Hide resolved
class Page(BaseModel):
name = models.CharField(max_length=100)
url = models.CharField(max_length=500)
Expand Down
25 changes: 25 additions & 0 deletions backend/projects/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
ProjectMemberRole,
Script,
AvailableAuditParameters,
MetricsPreferences,
)

from rest_framework import serializers
Expand Down Expand Up @@ -132,8 +133,23 @@ class Meta:
fields = ("id", "email", "username", "is_admin")


class MetricsPreferencesSerializer(serializers.ModelSerializer):
project = serializers.ReadOnlyField(source="project.uuid")
user = serializers.ReadOnlyField(source="user.id")

def validate(self, data):
if "metrics" not in data:
raise serializers.ValidationError("You must provide metrics")
return data

class Meta:
model = MetricsPreferences
fields = ("uuid", "project", "user", "metrics")


class ProjectSerializer(DynamicFieldsModelSerializer):
has_siblings = serializers.SerializerMethodField("_has_siblings")
user_metrics = serializers.SerializerMethodField("_user_metrics")

def _has_siblings(self, obj) -> bool:
return (
Expand All @@ -143,6 +159,14 @@ def _has_siblings(self, obj) -> bool:
> 1
)

def _user_metrics(self, obj):
if self.context.get("user_id") is None:
return
metrics = MetricsPreferences.objects.filter(
project=obj, user_id=self.context.get("user_id")
)
return metrics.values_list("metrics", flat=True).get()

pages = PageSerializer(many=True)
scripts = ScriptSerializer(many=True)
audit_parameters_list = ProjectAuditParametersSerializer(many=True)
Expand All @@ -164,4 +188,5 @@ class Meta:
"wpt_api_key",
"wpt_instance_url",
"has_siblings",
"user_metrics",
)
1 change: 1 addition & 0 deletions backend/projects/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@
),
path("<uuid:project_uuid>/scripts", views.project_scripts),
path("<uuid:project_uuid>/scripts/<uuid:script_uuid>", views.project_script_detail),
path("<uuid:project_uuid>/metrics", views.metrics),
]
47 changes: 47 additions & 0 deletions backend/projects/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
ProjectAuditParameters,
AvailableAuditParameters,
Script,
MetricsPreferences,
)
from projects.serializers import (
PageSerializer,
Expand All @@ -22,6 +23,7 @@
ProjectAuditParametersSerializer,
AvailableAuditParameterSerializer,
ScriptSerializer,
MetricsPreferencesSerializer,
)
from projects.permissions import (
check_if_member_of_project,
Expand Down Expand Up @@ -133,6 +135,7 @@ def project_detail(request, project_uuid):
"audit_parameters_list",
"screenshot_url",
"latest_audit_at",
"user_metrics",
),
context={"user_id": request.user.id},
)
Expand Down Expand Up @@ -363,6 +366,11 @@ def project_member_detail(request, project_uuid, user_id):

elif request.method == "DELETE":
project_member.delete()
metrics_preferences = MetricsPreferences.objects.filter(
project=project_uuid, user_id=user_id
)
if metrics_preferences:
metrics_preferences.delete()
return JsonResponse({}, status=status.HTTP_204_NO_CONTENT)


Expand Down Expand Up @@ -396,6 +404,15 @@ def project_members(request, project_uuid):
)
project = Project.objects.filter(uuid=project_uuid).first()
project.members.add(user.first(), through_defaults={"is_admin": False})
MetricsPreferences.objects.create(
project_id=project.uuid,
user_id=data["user_id"],
metrics=[
"WPTMetricFirstViewTTI",
"WPTMetricFirstViewSpeedIndex",
"WPTMetricFirstViewLoadTime",
],
)
serializer = ProjectSerializer(project)
return JsonResponse(serializer.data)
return HttpResponse(
Expand Down Expand Up @@ -528,3 +545,33 @@ def project_script_detail(request, project_uuid, script_uuid):
check_if_admin_of_project(request.user.id, project.uuid)
script.delete()
return JsonResponse({}, status=status.HTTP_204_NO_CONTENT)


@swagger_auto_schema(
methods=["post"],
responses={
200: openapi.Response(
"Updates a user’s metric preferences for a given project."
)
},
tags=["Metrics"],
)
@api_view(["POST"])
@permission_classes([permissions.IsAuthenticated])
def metrics(request, project_uuid):
check_if_member_of_project(request.user.id, project_uuid)
data = JSONParser().parse(request)
serializer = MetricsPreferencesSerializer(data=data)

if not serializer.is_valid():
return JsonResponse(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

new_metrics = data["metrics"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happens if a metric is not recognized here (either because of a bug or because someone is trying to attack this endpoint)? Do we reply with a 400 or a 500?

I think we should reply with a 400, meaning that we might have to check here whether the metrics sent by the users all belong to the list of “accepted” metrics.


metrics = MetricsPreferences.objects.filter(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

project_id=project_uuid, user_id=request.user.id
).update(metrics=new_metrics)

serializer = MetricsPreferencesSerializer(metrics)

return JsonResponse(serializer.data, safe=False)
29 changes: 19 additions & 10 deletions frontend/src/__fixtures__/state.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { PersistState } from 'redux-persist/es/types';
import { RootState } from "redux/types";

export const state = {
lead: {
leadSubmission: null,
},
export const state: RootState = {
login: {
isAuthenticated: false,
loginError: 'some login error message',
Expand All @@ -19,26 +17,37 @@ export const state = {
currentPageId: null,
currentScriptId: null,
currentScriptStepId: null,
displayedMetrics: {},
_persist: {} as PersistState
},
entities: {
projects: {
byId: null,
toastrDisplay: ''
},
pages: {
byId: null,
}
},
scripts: {
byId: null,
},
audits: {
runningAuditByPageOrScriptId: {}
},
auditParameters: {
byId: null,
},
auditStatusHistories: {
byPageOrScriptIdAndAuditParametersId: null,
},
},
auditResults: {
isLoading: false,
byAuditId: {},
sortedByPageId: {},
sortedByScriptId: {},
},
content: {
lastUpdateOfWhatsNew: null,
lastClickOnWhatsNew: null,
},
user: null,
toastr: {
toastrs: []
},
};
Loading