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

Incidents API update #95

Merged
merged 8 commits into from
Aug 13, 2019
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ django-bootstrap4==0.0.8
djangorestframework==3.10.1
docopt==0.6.2
emoji-data-python==1.1.0
factory-boy>=2.12.0
faker>=2.0.0
idna==2.8
importlib-metadata==0.18
markdown2==2.3.8
Expand Down
61 changes: 43 additions & 18 deletions response/core/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,36 +8,61 @@
from django.contrib.auth.models import User


class ExternalUserSerializer(serializers.HyperlinkedModelSerializer):
owner = serializers.PrimaryKeyRelatedField(queryset=User.objects.all(), required=False)

class ExternalUserSerializer(serializers.ModelSerializer):
class Meta:
model = ExternalUser
fields = ('app_id', 'external_id', 'owner', 'display_name')
fields = ("app_id", "external_id", "display_name")
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Previously we were returning the owner user primary key. I took it out, as it's basically meaningless, and we can more easily identify an External User by the other three (we have a UNIQUE constraint on them)



class ActionSerializer(serializers.HyperlinkedModelSerializer):
class ActionSerializer(serializers.ModelSerializer):
# Serializers define the API representation.
incident = serializers.PrimaryKeyRelatedField(queryset=Incident.objects.all(), required=False)
user = serializers.PrimaryKeyRelatedField(queryset=ExternalUser.objects.all(), required=False)
incident = serializers.PrimaryKeyRelatedField(
queryset=Incident.objects.all(), required=False
)
user = serializers.PrimaryKeyRelatedField(
queryset=ExternalUser.objects.all(), required=False
)

class Meta:
model = Action
fields = ('pk', 'details', 'done', 'incident', 'user')
fields = ("pk", "details", "done", "incident", "user")


class IncidentSerializer(serializers.HyperlinkedModelSerializer):
reporter = serializers.PrimaryKeyRelatedField(queryset=ExternalUser.objects.all(), required=False)
lead = serializers.PrimaryKeyRelatedField(queryset=ExternalUser.objects.all(), required=False)
class IncidentSerializer(serializers.ModelSerializer):
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't have context on why we were using a HyperlinkedModelSerializer, but this means we return hyperlinks to other API endpoints instead of primary keys. We don't need either in this serializer, so might as well use the simpler ModelSerializer

reporter = ExternalUserSerializer(read_only=True)
lead = ExternalUserSerializer()

class Meta:
model = Incident
fields = ('pk','report', 'reporter', 'lead', 'start_time', 'end_time', 'report_time', 'action_set')
fields = (
"action_set",
"end_time",
"impact",
"lead",
"pk",
"report",
"report_time",
"reporter",
"severity",
"start_time",
"summary",
)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Formatted, sorted and added a few more fields to the request/response


def update(self, instance, validated_data):
instance.end_time = validated_data.get("end_time", instance.end_time)
instance.impact = validated_data.get("impact", instance.impact)

new_lead = validated_data.get("lead", None)
if new_lead:
instance.lead = ExternalUser.objects.get(
display_name=new_lead["display_name"],
external_id=new_lead["external_id"],
)

def __init__(self, *args, **kwargs):
super(IncidentSerializer, self).__init__(*args, **kwargs)
request = kwargs['context']['request']
expand = request.GET.get('expand', "").split(',')
instance.report = validated_data.get("report", instance.report)
instance.start_time = validated_data.get("start_time", instance.start_time)
instance.summary = validated_data.get("summary", instance.summary)
instance.severity = validated_data.get("severity", instance.severity)

if 'actions' in expand:
self.fields['action_set'] = ActionSerializer(many=True, read_only=True)
instance.save()
return instance
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added a new update method here to control exactly how we're deserializing a PUT request and updating the incident instance.

I removed the previous __init__ function as I don't think we need this functionality, though might well put it back in when I get to the Actions API

2 changes: 1 addition & 1 deletion response/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

# Routers provide an easy way of automatically determining the URL conf.
router = routers.DefaultRouter()
router.register(r'incidents', IncidentViewSet, base_name='Incidents')
router.register(r'incidents', IncidentViewSet, base_name='incident')
router.register(r'actions', ActionViewSet)
router.register(r'ExternalUser', ExternalUserViewSet)

Expand Down
30 changes: 7 additions & 23 deletions response/core/views.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from rest_framework import viewsets
from rest_framework import pagination, viewsets

from response.core.models.incident import Incident
from response.core.models.action import Action
from response.core.models.user_external import ExternalUser
from response.core.serializers import ExternalUserSerializer, ActionSerializer, IncidentSerializer
from response.core import serializers
Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's simpler to just import the serializers module and qualify it - much easier to add new serializers


from datetime import datetime
from calendar import monthrange
Expand All @@ -12,35 +12,19 @@
class ExternalUserViewSet(viewsets.ModelViewSet):
# ViewSets define the view behavior.
queryset = ExternalUser.objects.all()
serializer_class = ExternalUserSerializer
serializer_class = serializers.ExternalUserSerializer


class ActionViewSet(viewsets.ModelViewSet):
# ViewSets define the view behavior.
queryset = Action.objects.all()
serializer_class = ActionSerializer
serializer_class = serializers.ActionSerializer


# Will return the incidents of the current month
# Can pass ?start=2019-05-28&end=2019-06-03 to change range
class IncidentViewSet(viewsets.ModelViewSet):
# ViewSets define the view behavior.

serializer_class = IncidentSerializer
pagination_class = None # Remove pagination
queryset = Incident.objects.order_by("-report_time")

def get_queryset(self):
# Same query is used to get single items so we check if pk is passed
# incident/2/ if we use the filter below we would have to have correct time range
if 'pk' in self.kwargs:
return Incident.objects.filter(pk=self.kwargs['pk'])

today = datetime.today()
first_day_of_current_month = datetime(today.year, today.month, 1)
days_in_month = monthrange(today.year, today.month)[1]
last_day_of_current_month = datetime(today.year, today.month, days_in_month)

start = self.request.GET.get('start', first_day_of_current_month)
end = self.request.GET.get('end', last_day_of_current_month)

return Incident.objects.filter(start_time__gte=start, start_time__lte=end)
serializer_class = serializers.IncidentSerializer
pagination_class = pagination.LimitOffsetPagination
Copy link
Contributor Author

Choose a reason for hiding this comment

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

UIs should be controlling pagination, so remove logic around getting the previous months worth of incidents, and simplify the queryset. We'll sort by newest reported incidents to oldest.

Empty file added tests/api/__init__.py
Empty file.
14 changes: 14 additions & 0 deletions tests/api/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import pytest
from rest_framework.test import APIRequestFactory

from tests.factories import UserFactory, ExternalUserFactory

@pytest.fixture
def arf():
Copy link
Contributor

Choose a reason for hiding this comment

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

🐶

return APIRequestFactory()


@pytest.fixture()
def api_user(transactional_db):
e = ExternalUserFactory()
return e.owner
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Used for auth. Any authenticated user will do (though may have to update this in future if we start doing more fine-grained permissions)

138 changes: 138 additions & 0 deletions tests/api/test_incidents.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
from django.urls import reverse
from faker import Faker
from rest_framework.test import force_authenticate
import json
import pytest
import random

from response.models import Incident, ExternalUser
from response import serializers
from response.core.views import IncidentViewSet
from tests.factories import IncidentFactory

faker = Faker()


def test_list_incidents(arf, api_user):
persisted_incidents = IncidentFactory.create_batch(5)

req = arf.get(reverse("incident-list"))
force_authenticate(req, user=api_user)
response = IncidentViewSet.as_view({"get": "list"})(req)

assert response.status_code == 200, "Got non-200 response from API"
content = json.loads(response.rendered_content)

assert "results" in content, "Response didn't have results key"
incidents = content["results"]
assert len(incidents) == len(
persisted_incidents
), "Didn't get expected number of incidents back"

for idx, incident in enumerate(incidents):
assert incident["report_time"]

# incidents should be in order of newest to oldest
if idx != len(incidents) - 1:
Copy link
Contributor

Choose a reason for hiding this comment

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

You could store report_time outside the loop and update/compare it on each iteration to simplify here, not a blocker though.

assert (
incident["report_time"] >= incidents[idx + 1]["report_time"]
), "Incidents are not in order of newest to oldest by report time"

assert "end_time" in incident # end_time can be null for open incidents
assert incident["impact"]
assert incident["report"]
assert incident["start_time"]
assert incident["summary"]
assert incident["severity"]

reporter = incident["reporter"]
assert reporter["display_name"]
assert reporter["external_id"]

lead = incident["lead"]
assert lead["display_name"]
assert lead["external_id"]

# TODO: verify actions are serialised inline
mattrco marked this conversation as resolved.
Show resolved Hide resolved


@pytest.mark.parametrize(
"update_key,update_value",
(
("", ""), # no update
("impact", faker.paragraph(nb_sentences=2, variable_nb_sentences=True)),
mattrco marked this conversation as resolved.
Show resolved Hide resolved
("report", faker.paragraph(nb_sentences=1, variable_nb_sentences=True)),
("summary", faker.paragraph(nb_sentences=3, variable_nb_sentences=True)),
(
"start_time",
faker.date_time_between(start_date="-3d", end_date="now", tzinfo=None),
),
(
"end_time",
faker.date_time_between(start_date="-3d", end_date="now", tzinfo=None),
),
("severity", str(random.randint(1, 4))),
),
)
def test_update_incident(arf, api_user, update_key, update_value):
"""
Tests that we can PUT /incidents/<pk> and mutate fields that get saved to
the DB.
"""
persisted_incidents = IncidentFactory.create_batch(5)

incident = persisted_incidents[0]
serializer = serializers.IncidentSerializer(incident)
serialized = serializer.data

updated = serialized
del updated["reporter"] # can't update reporter
if update_key:
updated[update_key] = update_value

req = arf.put(
reverse("incident-detail", kwargs={"pk": incident.pk}), updated, format="json"
)
force_authenticate(req, user=api_user)

response = IncidentViewSet.as_view({"put": "update"})(req, pk=incident.pk)
print(response.rendered_content)
assert response.status_code == 200, "Got non-200 response from API"

if update_key:
new_incident = Incident.objects.get(pk=incident.pk)
assert (
getattr(new_incident, update_key) == update_value
), "Updated value wasn't persisted to the DB"


def test_update_incident_lead(arf, api_user):
"""
Tests that we can update the incident lead by name
"""
persisted_incidents = IncidentFactory.create_batch(5)

incident = persisted_incidents[0]
serializer = serializers.IncidentSerializer(incident)
updated = serializer.data

users = ExternalUser.objects.all()

new_lead = users[0]
while new_lead == incident.lead:
new_lead = random.choices(users)

updated["lead"] = serializers.ExternalUserSerializer(new_lead).data
del updated["reporter"] # can't update reporter

req = arf.put(
reverse("incident-detail", kwargs={"pk": incident.pk}), updated, format="json"
)
force_authenticate(req, user=api_user)

response = IncidentViewSet.as_view({"put": "update"})(req, pk=incident.pk)
print(response.rendered_content)
assert response.status_code == 200, "Got non-200 response from API"

new_incident = Incident.objects.get(pk=incident.pk)
assert new_incident.lead == new_lead
6 changes: 5 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@
from response.slack.authentication import generate_signature
from response.slack.client import SlackClient

@pytest.fixture()
@pytest.fixture(autouse=True)
def mock_slack(monkeypatch):
mock_slack = MagicMock(spec=SlackClient(""))
mock_slack.send_or_update_message_block.return_value = {
"ok": True,
"ts": 1234,
}
monkeypatch.setattr(settings, "SLACK_CLIENT", mock_slack)
return mock_slack

Expand Down
2 changes: 2 additions & 0 deletions tests/factories/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .incident import *
from .user import *
42 changes: 42 additions & 0 deletions tests/factories/incident.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import random

from django.db.models.signals import post_save
import factory
from faker import Factory

from response.core.models import Incident, ExternalUser

faker = Factory.create()


@factory.django.mute_signals(post_save)
class IncidentFactory(factory.DjangoModelFactory):
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Use factory_boy to easily generate random test data 🙌

class Meta:
model = Incident

impact = factory.LazyFunction(
lambda: faker.paragraph(nb_sentences=1, variable_nb_sentences=True)
)
report = factory.LazyFunction(
lambda: faker.paragraph(nb_sentences=3, variable_nb_sentences=True)
)
report_time = factory.LazyFunction(
lambda: faker.date_time_between(start_date="-3d", end_date="now", tzinfo=None)
)

reporter = factory.SubFactory("tests.factories.ExternalUserFactory")
lead = factory.SubFactory("tests.factories.ExternalUserFactory")

start_time = factory.LazyFunction(
lambda: faker.date_time_between(start_date="-3d", end_date="now", tzinfo=None)
)

if random.random() > 0.5:
end_time = factory.LazyAttribute(
lambda a: faker.date_time_between(start_date=a.start_time, end_date="now")
)

severity = factory.LazyFunction(lambda: str(random.randint(1, 4)))
summary = factory.LazyFunction(
lambda: faker.paragraph(nb_sentences=3, variable_nb_sentences=True)
)
26 changes: 26 additions & 0 deletions tests/factories/user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from django.contrib.auth.models import User
import factory
from faker import Factory


from response.core.models import ExternalUser

faker = Factory.create()


class UserFactory(factory.DjangoModelFactory):
class Meta:
model = User

username = factory.LazyFunction(faker.user_name)
password = factory.LazyFunction(faker.password)


class ExternalUserFactory(factory.DjangoModelFactory):
class Meta:
model = ExternalUser

app_id = "slack"
display_name = factory.LazyFunction(faker.name)
external_id = factory.LazyFunction(lambda: "U" + faker.word())
owner = factory.SubFactory("tests.factories.UserFactory")