-
Notifications
You must be signed in to change notification settings - Fork 35
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
Add created_by to record list #708
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
5c36316
Add created_by username to record list
pcaisse e963d0e
Add test for created_by
pcaisse 76e6230
Add created by field to frontend
pcaisse 93acba4
Only show "Create by" field to admins and analysts
pcaisse 61399ce
Add to test to ensure public clients do not see "Created by" field
pcaisse b3ed4d6
Use email address but fall back to username if not available
pcaisse 2cb3206
Separate out created_by tests
pcaisse File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -35,9 +35,15 @@ def set_up_admin_client(self): | |
self.admin_client.force_authenticate(user=self.admin) | ||
|
||
def set_up_public_client(self): | ||
self.public = User.objects.create_user('public', 'public@ashlar', 'public') | ||
self.public.groups.add(Group.objects.get(name='public')) | ||
self.public.save() | ||
try: | ||
self.public = User.objects.get(username='public') | ||
except User.DoesNotExist: | ||
self.public = User.objects.create_user('public', 'public@ashlar', 'public') | ||
self.public.save() | ||
|
||
if not self.public.groups.exists(): | ||
self.public.groups.add(Group.objects.get(name='public')) | ||
|
||
self.public_client = APIClient() | ||
self.public_client.force_authenticate(user=self.public) | ||
|
||
|
@@ -105,13 +111,31 @@ def set_up_records(self): | |
location_text='Equator', | ||
schema=self.schema) | ||
|
||
def set_up_audit_log(self): | ||
self.audit_log_entry1 = RecordAuditLogEntry.objects.create( | ||
user=self.admin, | ||
username=self.admin.username, | ||
action=RecordAuditLogEntry.ActionTypes.CREATE, | ||
record=self.record2, | ||
record_uuid=self.record2.uuid, | ||
) | ||
# CREATE audit log entry where user has been deleted | ||
self.audit_log_entry2 = RecordAuditLogEntry.objects.create( | ||
user=None, | ||
username='banana', | ||
action=RecordAuditLogEntry.ActionTypes.CREATE, | ||
record=self.record3, | ||
record_uuid=self.record3.uuid, | ||
) | ||
|
||
class DriverRecordViewTestCase(APITestCase, ViewTestSetUpMixin): | ||
def setUp(self): | ||
super(DriverRecordViewTestCase, self).setUp() | ||
|
||
self.set_up_admin_client() | ||
self.set_up_public_client() | ||
self.set_up_records() | ||
self.set_up_audit_log() | ||
self.factory = APIRequestFactory() | ||
|
||
def test_toddow(self): | ||
|
@@ -156,6 +180,21 @@ def test_arbitrary_filters(self): | |
response_data2 = json.loads(self.admin_client.get(url2).content) | ||
self.assertEqual(len(response_data2), 2) | ||
|
||
def test_created_by_admin_client_email(self): | ||
url = '/api/records/{uuid}/?details_only=True'.format(uuid=self.record2.uuid) | ||
response_data = json.loads(self.admin_client.get(url).content) | ||
self.assertEqual(response_data['created_by'], self.audit_log_entry1.user.email) | ||
|
||
def test_created_by_admin_client_username(self): | ||
url = '/api/records/{uuid}/?details_only=True'.format(uuid=self.record3.uuid) | ||
response_data = json.loads(self.admin_client.get(url).content) | ||
self.assertEqual(response_data['created_by'], self.audit_log_entry2.username) | ||
|
||
def test_created_by_public_client(self): | ||
url = '/api/records/?details_only=True' | ||
public_response_data = json.loads(self.public_client.get(url).content) | ||
self.assertTrue(all('created_by' not in result for result in public_response_data['results'])) | ||
|
||
def test_tilekey_param(self): | ||
"""Ensure that the tilekey param stores a SQL query in Redis and returns an access token""" | ||
# Since the call to store in redis won't have access to a real Redis instance under test, | ||
|
@@ -178,16 +217,18 @@ def test_tilekey_param(self): | |
|
||
def test_get_serializer_class(self): | ||
"""Test that get_serializer_class returns read-only serializer correctly""" | ||
read_only = User.objects.create_user('public', '[email protected]', 'public') | ||
view = DriverRecordViewSet() | ||
mock_req = mock.Mock(spec=Request) | ||
mock_req.user = read_only | ||
mock_req.user = self.public | ||
view.request = mock_req | ||
serializer_class = view.get_serializer_class() | ||
self.assertEqual(serializer_class, DetailsReadOnlyRecordSerializer) | ||
|
||
def test_audit_log_creation(self): | ||
"""Test that audit logs are generated on create operations""" | ||
# Start from clean slate | ||
RecordAuditLogEntry.objects.all().delete() | ||
|
||
url = '/api/records/' | ||
post_data = { | ||
'data': { | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -13,16 +13,21 @@ | |
|
||
from django.conf import settings | ||
from django.db import transaction | ||
from django.db.models import (Case, | ||
When, | ||
IntegerField, | ||
DateTimeField, | ||
CharField, | ||
UUIDField, | ||
Value, | ||
Count, | ||
Sum, | ||
Q) | ||
from django.db.models import ( | ||
Case, | ||
CharField, | ||
Count, | ||
DateTimeField, | ||
IntegerField, | ||
OuterRef, | ||
Q, | ||
Subquery, | ||
Sum, | ||
UUIDField, | ||
Value, | ||
When, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 Glad I'm not the only one who prefers this over stalactite style. |
||
) | ||
from django.db.models.functions import Coalesce | ||
from django_redis import get_redis_connection | ||
|
||
from rest_framework import viewsets | ||
|
@@ -60,7 +65,8 @@ | |
from models import RecordAuditLogEntry, RecordDuplicate, RecordCostConfig | ||
from serializers import (DriverRecordSerializer, DetailsReadOnlyRecordSerializer, | ||
DetailsReadOnlyRecordSchemaSerializer, RecordAuditLogEntrySerializer, | ||
RecordDuplicateSerializer, RecordCostConfigSerializer) | ||
RecordDuplicateSerializer, RecordCostConfigSerializer, | ||
DetailsReadOnlyRecordNonPublicSerializer) | ||
import transformers | ||
from driver import mixins | ||
|
||
|
@@ -103,13 +109,31 @@ def get_serializer_class(self): | |
if details_only_param == 'True' or details_only_param == 'true': | ||
requested_details_only = True | ||
|
||
if is_admin_or_writer(self.request.user) and not requested_details_only: | ||
return DriverRecordSerializer | ||
if is_admin_or_writer(self.request.user): | ||
if requested_details_only: | ||
return DetailsReadOnlyRecordNonPublicSerializer | ||
else: | ||
return DriverRecordSerializer | ||
return DetailsReadOnlyRecordSerializer | ||
|
||
def get_queryset(self): | ||
"""Override default model ordering""" | ||
qs = super(DriverRecordViewSet, self).get_queryset() | ||
if self.get_serializer_class() is DetailsReadOnlyRecordNonPublicSerializer: | ||
# Add in `created_by` field for user who created the record | ||
created_by_query = ( | ||
RecordAuditLogEntry.objects.filter( | ||
record=OuterRef('pk'), | ||
action=RecordAuditLogEntry.ActionTypes.CREATE | ||
) | ||
.annotate( | ||
# Fall back to username if the user has been deleted | ||
email_or_username=Coalesce('user__email', 'username') | ||
) | ||
.values('email_or_username') | ||
[:1] | ||
) | ||
qs = qs.annotate(created_by=Subquery(created_by_query, output_field=CharField())) | ||
# Override default model ordering | ||
return qs.order_by('-occurred_from') | ||
|
||
def get_filtered_queryset(self, request): | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm a bit worried that having multiple serializer classes for minor differences in response is a bit of an antipatern. Some day I'd like to see what it'd take to consolidate our ReadOnly/Write sererializers, but for this from reading online I think we can add the field dynamically in
__init__()
:There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm having trouble getting this working (
self.context
is empty). I'm also not entirely sure I like the idea of having fewer serializers whose fields we change dynamically. Seems like it might be hard to understand and maintain.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, I didn't realize that VSCode publishes PR comments immediately and edited my comment after I posted. Tested afterwards and
self.context['request']
should work.I may be overly cautious from getting burned by creating subclasses and discovering months later that their pivot factors weren't as fundamental as I originally though. My concern is that class inheritance doesn't scale the way we'd likely need for controlling the fields in a given response. Inheritance forces a linear decision tree, where the process of selecting a serializer class must follow down one pre-determined branch first, then the next, etc. It works well if each decision is a categorical distinction (Like a JSON parser class vs CSV parser class), but as the decisions become less categorical and more business logic it raises the risk that you have conflicting decision points.
Like if we want to add another level of controlling a field based on a factor orthogonal to user role (Such as a feature flag, perhaps), it isn't clear how the levels would be structured - worst case, if they are truly independent we'd need a cartesian product of serializer classes. In that case, although it's decidedly less elegant, I think having fields dynamically determined in code would be the only maintainable solution.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All that said, it's not like doing class inheritance or dynamic fields for this PR specifically is an inflection point that we can't revisit later on. It's pretty squarely a Type 2 decision, so if we like class inheritance more for now then 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Late to this party but one thing I've noticed is that DRF sometimes gets in the way of making serializers too intelligent, so the "different classes plus switching logic in
get_serializer_class
" is often a good way to make DRF happy even if it sometimes feels a bit strange to have a profusion of serializer subclasses.