Skip to content

Commit 8212ede

Browse files
authored
[ENG-9907] Analytics are increasing (unique views) when a contributor is viewing their own content (Project) (BE) (#11669)
* do not count contrib views * add tests for VOL; parametrize
1 parent 98b15aa commit 8212ede

3 files changed

Lines changed: 183 additions & 1 deletion

File tree

api/metrics/utils.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import re
2+
from urllib.parse import urlsplit
23

34
import pytz
45

56
from datetime import timedelta, datetime
67
from django.utils import timezone
78
from rest_framework.exceptions import ValidationError
89

10+
from osf.models import AbstractNode, Guid
11+
from osf.metrics.counted_usage import _get_immediate_wrapper
12+
913

1014
DATETIME_FORMAT = '%Y-%m-%dT%H:%M'
1115
DATE_FORMAT = '%Y-%m-%d'
@@ -114,3 +118,47 @@ def parse_date_range(query_params, is_monthly=False):
114118
start_date, end_date = parse_dates(query_params, is_monthly=is_monthly)
115119
report_date_range = {'gte': str(start_date), 'lte': str(end_date)}
116120
return report_date_range
121+
122+
123+
def _user_has_read_on_resolved_node(user, guid_referent):
124+
"""True if ``user`` has READ on the node this referent belongs to."""
125+
current = guid_referent
126+
while current is not None and not isinstance(current, AbstractNode):
127+
current = _get_immediate_wrapper(current)
128+
if current is None or not isinstance(current, AbstractNode):
129+
return False
130+
return current.contributors_and_group_members.filter(guids___id=user._id).exists()
131+
132+
133+
def should_skip_counted_usage(user, *, item_guid=None, pageview_info=None):
134+
"""Return True when this usage should not be recorded."""
135+
if not getattr(user, 'is_authenticated', False):
136+
return False
137+
138+
referents = []
139+
seen_ids = set()
140+
141+
def _add_referent(ref):
142+
if ref is None:
143+
return
144+
key = (ref.__class__.__name__, ref.pk)
145+
if key in seen_ids:
146+
return
147+
seen_ids.add(key)
148+
referents.append(ref)
149+
150+
if item_guid:
151+
guid_obj = Guid.load(item_guid)
152+
if guid_obj and guid_obj.referent:
153+
_add_referent(guid_obj.referent)
154+
155+
page_url = (pageview_info or {}).get('page_url')
156+
if page_url:
157+
for segment in urlsplit(page_url).path.split('/'):
158+
if not segment or len(segment) < 5:
159+
continue
160+
guid_obj = Guid.load(segment)
161+
if guid_obj and guid_obj.referent:
162+
_add_referent(guid_obj.referent)
163+
164+
return any(_user_has_read_on_resolved_node(user, ref) for ref in referents)

api/metrics/views.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from api.metrics.utils import (
4444
parse_datetimes,
4545
parse_date_range,
46+
should_skip_counted_usage,
4647
)
4748
from api.nodes.permissions import MustBePublic
4849

@@ -388,6 +389,12 @@ class CountedAuthUsageView(JSONAPIBaseView):
388389
def post(self, request, *args, **kwargs):
389390
serializer = self.serializer_class(data=request.data)
390391
serializer.is_valid(raise_exception=True)
392+
if should_skip_counted_usage(
393+
request.user,
394+
item_guid=serializer.validated_data.get('item_guid'),
395+
pageview_info=serializer.validated_data.get('pageview_info'),
396+
):
397+
return HttpResponse(status=204)
391398
session_id, user_is_authenticated = self._get_session_id(
392399
request,
393400
client_session_id=serializer.validated_data.get('client_session_id'),

api_tests/metrics/test_counted_usage.py

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,18 @@
33
import pytest
44
from unittest import mock
55

6+
from framework.auth.core import Auth
7+
68
from osf_tests.factories import (
79
AuthUserFactory,
8-
PreprintFactory,
910
NodeFactory,
11+
PreprintFactory,
12+
PrivateLinkFactory,
13+
ProjectFactory,
1014
RegistrationFactory,
1115
# UserFactory,
1216
)
17+
from osf.utils.permissions import ADMIN, READ, WRITE
1318
from api_tests.utils import create_test_file
1419

1520

@@ -351,3 +356,125 @@ def test_child_registration_file(self, app, mock_save, child_reg_file_guid, chil
351356
'surrounding_guids': None,
352357
},
353358
)
359+
360+
361+
@pytest.mark.django_db
362+
class TestContributorExclusion:
363+
364+
def test_creator_pageview_not_recorded(self, app, mock_save):
365+
user = AuthUserFactory()
366+
project = ProjectFactory(creator=user)
367+
payload = counted_usage_payload(
368+
item_guid=project._id,
369+
action_labels=['view', 'web'],
370+
pageview_info={'page_url': f'https://osf.io/{project._id}/'},
371+
)
372+
resp = app.post_json_api(COUNTED_USAGE_URL, payload, auth=user.auth)
373+
assert resp.status_code == 204
374+
assert mock_save.call_count == 0
375+
376+
@pytest.mark.parametrize(
377+
'permissions',
378+
[READ, WRITE, ADMIN],
379+
ids=['read', 'write', 'admin'],
380+
)
381+
def test_contributor_pageview_not_recorded(self, app, mock_save, permissions):
382+
creator = AuthUserFactory()
383+
contributor = AuthUserFactory()
384+
project = ProjectFactory(creator=creator)
385+
project.add_contributor(contributor, permissions=permissions, auth=Auth(creator))
386+
payload = counted_usage_payload(
387+
item_guid=project._id,
388+
action_labels=['view', 'web'],
389+
pageview_info={'page_url': f'https://osf.io/{project._id}/analytics/'},
390+
)
391+
resp = app.post_json_api(COUNTED_USAGE_URL, payload, auth=contributor.auth)
392+
assert resp.status_code == 204
393+
assert mock_save.call_count == 0
394+
395+
def test_non_contributor_pageview_recorded(self, app, mock_save):
396+
creator = AuthUserFactory()
397+
visitor = AuthUserFactory()
398+
project = ProjectFactory(creator=creator, is_public=True)
399+
payload = counted_usage_payload(
400+
item_guid=project._id,
401+
action_labels=['view', 'web'],
402+
pageview_info={'page_url': f'https://osf.io/{project._id}/'},
403+
)
404+
resp = app.post_json_api(COUNTED_USAGE_URL, payload, auth=visitor.auth)
405+
assert resp.status_code == 201
406+
assert mock_save.call_count == 1
407+
408+
def test_parent_contributor_not_on_child_component_pageview_recorded(self, app, mock_save):
409+
creator = AuthUserFactory()
410+
child_owner = AuthUserFactory()
411+
parent_reader = AuthUserFactory()
412+
parent = ProjectFactory(creator=creator, is_public=True)
413+
child = NodeFactory(parent=parent, creator=child_owner, is_public=True)
414+
parent.add_contributor(parent_reader, permissions=ADMIN, auth=Auth(creator))
415+
assert not child.contributors_and_group_members.filter(guids___id=parent_reader._id).exists()
416+
payload = counted_usage_payload(
417+
item_guid=child._id,
418+
action_labels=['view', 'web'],
419+
pageview_info={'page_url': f'https://osf.io/{child._id}/'},
420+
)
421+
resp = app.post_json_api(COUNTED_USAGE_URL, payload, auth=parent_reader.auth)
422+
assert resp.status_code == 201
423+
assert mock_save.call_count == 1
424+
425+
def test_anonymous_view_only_link_visitor_pageview_recorded(self, app, mock_save):
426+
creator = AuthUserFactory()
427+
project = ProjectFactory(creator=creator, is_public=False)
428+
link = PrivateLinkFactory(anonymous=True, creator=creator)
429+
link.nodes.add(project)
430+
payload = counted_usage_payload(
431+
item_guid=project._id,
432+
action_labels=['view', 'web'],
433+
client_session_id='vol-client-session',
434+
pageview_info={
435+
'page_url': f'https://osf.io/{project._id}/?view_only={link.key}',
436+
},
437+
)
438+
resp = app.post_json_api(COUNTED_USAGE_URL, payload)
439+
assert resp.status_code == 201
440+
assert mock_save.call_count == 1
441+
442+
def test_logged_in_non_contributor_view_only_link_pageview_recorded(self, app, mock_save):
443+
creator = AuthUserFactory()
444+
visitor = AuthUserFactory()
445+
project = ProjectFactory(creator=creator, is_public=False)
446+
link = PrivateLinkFactory(anonymous=False, creator=creator)
447+
link.nodes.add(project)
448+
payload = counted_usage_payload(
449+
item_guid=project._id,
450+
action_labels=['view', 'web'],
451+
pageview_info={
452+
'page_url': f'https://osf.io/{project._id}/files/?view_only={link.key}',
453+
},
454+
)
455+
resp = app.post_json_api(COUNTED_USAGE_URL, payload, auth=visitor.auth)
456+
assert resp.status_code == 201
457+
assert mock_save.call_count == 1
458+
459+
@pytest.mark.parametrize(
460+
'permissions',
461+
[READ, WRITE, ADMIN],
462+
ids=['read', 'write', 'admin'],
463+
)
464+
def test_logged_in_contributor_view_only_link_pageview_not_recorded(self, app, mock_save, permissions):
465+
creator = AuthUserFactory()
466+
contributor = AuthUserFactory()
467+
project = ProjectFactory(creator=creator, is_public=False)
468+
project.add_contributor(contributor, permissions=permissions, auth=Auth(creator))
469+
link = PrivateLinkFactory(anonymous=False, creator=creator)
470+
link.nodes.add(project)
471+
payload = counted_usage_payload(
472+
item_guid=project._id,
473+
action_labels=['view', 'web'],
474+
pageview_info={
475+
'page_url': f'https://osf.io/{project._id}/?view_only={link.key}',
476+
},
477+
)
478+
resp = app.post_json_api(COUNTED_USAGE_URL, payload, auth=contributor.auth)
479+
assert resp.status_code == 204
480+
assert mock_save.call_count == 0

0 commit comments

Comments
 (0)