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

feat(accessLogsExport): create new endpoints TASK-1147  #5304

Merged
merged 34 commits into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
09373fb
add AccessLogExportTask class, refactored Project View exports to cre…
RuthShryock Nov 5, 2024
4079ea0
revert to before Submissions name change
RuthShryock Nov 14, 2024
eb67d2b
:This reverts commit 4079ea05a8eed915ad74143a43c7be665b70e6a8.
RuthShryock Nov 14, 2024
0f9ac39
Merge branch 'main' of github.com:kobotoolbox/kpi into access-log-exp…
RuthShryock Nov 14, 2024
2836ea3
fix circular import error
RuthShryock Nov 14, 2024
b69a006
fix formatting
RuthShryock Nov 14, 2024
04dc2cd
rename ExportTaskBase, ExportTask, and SynchronousExport to have Subm…
RuthShryock Nov 15, 2024
4c46fb0
fix formatting
RuthShryock Nov 15, 2024
de7f7f5
Merge branch 'main' of github.com:kobotoolbox/kpi into access-log-exp…
RuthShryock Nov 20, 2024
ff45ba8
fix migration error by creating common export tasks for each project …
RuthShryock Nov 20, 2024
7837192
fix formatting
RuthShryock Nov 20, 2024
6c60768
simplify refactor with ExportTaskMixin and adding get_data and defaul…
RuthShryock Nov 26, 2024
43ce879
updating branch with main
RuthShryock Nov 26, 2024
a0a2d5e
add access log export endpoints and tests
RuthShryock Nov 26, 2024
bd5c994
rename functions and variables, fix grammar, and allow for views to b…
RuthShryock Nov 27, 2024
91fefc7
Merge branch 'main' of github.com:kobotoolbox/kpi into endpoints-for-…
RuthShryock Nov 27, 2024
0640937
Merge branch 'access-log-export-task-class' of github.com:kobotoolbox…
RuthShryock Nov 27, 2024
cd25b98
remove view from data for access log exports
RuthShryock Nov 27, 2024
6c3e3af
Revert "refactor(organizations): add of useSession hook TASK-1305 (#…
RuthShryock Nov 27, 2024
dd5650d
Revert "feat(InlineMessage): add new type TASK-987 (#5305)"
RuthShryock Nov 27, 2024
cd35d96
Merge branch 'main' into endpoints-for-access-logs-export
RuthShryock Dec 2, 2024
039063b
Reapply "refactor(organizations): add of useSession hook TASK-1305 (…
RuthShryock Dec 2, 2024
33e1c0a
Reapply "feat(InlineMessage): add new type TASK-987 (#5305)"
RuthShryock Dec 2, 2024
6ca21ec
refactor to include two endpoints, add documentation, return a list o…
RuthShryock Dec 3, 2024
13d31b2
Merge branch 'main' of github.com:kobotoolbox/kpi into endpoints-for-…
RuthShryock Dec 3, 2024
fee5f37
fix formatting
RuthShryock Dec 3, 2024
6eac898
fix tests
RuthShryock Dec 3, 2024
189a860
fix failing tests
RuthShryock Dec 4, 2024
1d41380
Update kobo/apps/audit_log/views.py
RuthShryock Dec 4, 2024
c9cbb7e
Update kobo/apps/audit_log/views.py
RuthShryock Dec 4, 2024
8d8f763
Update kobo/apps/audit_log/views.py
RuthShryock Dec 4, 2024
7f88c37
check get_all_logs in tests and fix formatting
RuthShryock Dec 4, 2024
0383afb
fix error messages to apply to access logs
RuthShryock Dec 4, 2024
350c22e
fix assert statments in test cases
RuthShryock Dec 4, 2024
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
170 changes: 165 additions & 5 deletions kobo/apps/audit_log/tests/api/v2/test_api_audit_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,14 @@
from rest_framework.reverse import reverse

from kobo.apps.audit_log.audit_actions import AuditAction
from kobo.apps.audit_log.models import (
AccessLog,
AuditLog,
AuditType,
)
from kobo.apps.audit_log.models import AccessLog, AuditLog, AuditType
from kobo.apps.audit_log.tests.test_signals import skip_login_access_log
from kobo.apps.kobo_auth.shortcuts import User
from kpi.constants import (
ACCESS_LOG_SUBMISSION_AUTH_TYPE,
ACCESS_LOG_SUBMISSION_GROUP_AUTH_TYPE,
)
from kpi.models.import_export_task import AccessLogExportTask
from kpi.tests.base_test_case import BaseTestCase
from kpi.urls.router_api_v2 import URL_NAMESPACE as ROUTER_URL_NAMESPACE

Expand Down Expand Up @@ -430,3 +427,166 @@ def test_can_search_access_logs_by_date_including_submission_groups(self):
group['metadata']['auth_type'],
ACCESS_LOG_SUBMISSION_GROUP_AUTH_TYPE,
)


class ApiAccessLogsExportTestCase(BaseAuditLogTestCase):

def get_endpoint_basename(self):
return 'access-logs-export-list'

def test_export_as_anonymous_returns_unauthorized(self):
self.client.logout()
response = self.client.post(self.url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED

def test_export_for_user_commences(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

non-blocking nit, since this doesn't technically test that the task started, only that the request suceeded

Suggested change
def test_export_for_user_commences(self):
def test_export_for_user_returns_success(self):

self.force_login_user(User.objects.get(username='anotheruser'))
response = self.client.post(self.url)
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)

def test_export_for_superuser_commences(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

same as above

self.force_login_user(User.objects.get(username='admin'))
response = self.client.post(self.url)
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)

def test_create_export_task_on_post(self):
test_user = User.objects.get(username='anotheruser')
self.force_login_user(test_user)

response = self.client.post(self.url)
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)

task = (
AccessLogExportTask.objects.filter(user=test_user)
.order_by('-date_created')
.first()
)
self.assertIsNotNone(task)
self.assertEqual(task.status, 'complete')
Copy link
Contributor

Choose a reason for hiding this comment

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

This is a tiny bit risky since we don't know exactly when the task will finish. Maybe test status in ['created', 'processing', 'complete']?


def test_get_status_of_tasks(self):
self.force_login_user(User.objects.get(username='anotheruser'))
response = self.client.post(self.url)
Copy link
Contributor

Choose a reason for hiding this comment

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

to make this test more unit-y, I think we should manually create a task with AccessLogExportTask.objects.create(...). That way, we're only testing the list functionality and not the create functionality as well, since that's already tested.

self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)

response_status = self.client.get(self.url)
self.assertEqual(response_status.status_code, status.HTTP_200_OK)

# Assert the response contains a list of tasks
tasks = response_status.json()
self.assertIsInstance(tasks, list)
self.assertGreater(len(tasks), 0) # Ensure at least one task is present

# Assert the structure of the first task in the list
first_task = tasks[0]
self.assertIn('uid', first_task)
self.assertIn('status', first_task)
self.assertIn('date_created', first_task)
self.assertEqual(first_task['status'], 'complete')

def test_multiple_export_tasks_not_allowed(self):
test_user = User.objects.get(username='anotheruser')
self.force_login_user(test_user)

response_first = self.client.post(self.url)
self.assertEqual(response_first.status_code, status.HTTP_202_ACCEPTED)

task = (
AccessLogExportTask.objects.filter(user=test_user)
.order_by('-date_created')
.first()
)
task.status = 'processing'
task.save()

response_second = self.client.post(self.url)
self.assertEqual(response_second.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(
'You already have a running export task for your own logs',
response_second.json()['error'],
)


class AllApiAccessLogsExportTestCase(BaseAuditLogTestCase):

def get_endpoint_basename(self):
return 'all-access-logs-export-list'

def test_export_as_anonymous_returns_unauthorized(self):
self.client.logout()
response = self.client.post(self.url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED

def test_regular_user_cannot_export_access_logs(self):
self.force_login_user(User.objects.get(username='anotheruser'))
response = self.client.post(self.url)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

def test_export_access_logs_for_superuser_commences(self):
self.force_login_user(User.objects.get(username='admin'))
response = self.client.post(self.url)
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)

def test__superuser_create_export_task_on_post(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
def test__superuser_create_export_task_on_post(self):
def test_superuser_create_export_task_on_post(self):

test_superuser = User.objects.get(username='admin')
self.force_login_user(test_superuser)

response = self.client.post(self.url)
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)

task = (
AccessLogExportTask.objects.filter(user=test_superuser)
.order_by('-date_created')
.first()
)
self.assertIsNotNone(task)
self.assertEqual(task.status, 'complete')
Copy link
Contributor

Choose a reason for hiding this comment

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

same as previous test, the test should pass if the status is 'processing' or 'created'


def test_superuser_get_status_tasks(self):
self.force_login_user(User.objects.get(username='admin'))
response = self.client.post(self.url)
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)

response_status = self.client.get(self.url)
self.assertEqual(response_status.status_code, status.HTTP_200_OK)

# Assert the response contains a list of tasks
tasks = response_status.json()
self.assertIsInstance(tasks, list)
self.assertGreater(len(tasks), 0) # Ensure at least one task is present

# Assert the structure of the first task in the list
first_task = tasks[0]
self.assertIn('uid', first_task)
self.assertIn('status', first_task)
rgraber marked this conversation as resolved.
Show resolved Hide resolved
self.assertIn('date_created', first_task)
self.assertEqual(first_task['status'], 'complete')

def test_permission_denied_for_non_superusers_on_get_status(self):
non_superuser = User.objects.get(username='anotheruser')
self.force_login_user(non_superuser)

response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

def test_multiple_export_tasks_not_allowed(self):
test_superuser = User.objects.get(username='admin')
self.force_login_user(test_superuser)

response_first = self.client.post(self.url)
self.assertEqual(response_first.status_code, status.HTTP_202_ACCEPTED)

task = (
AccessLogExportTask.objects.filter(user=test_superuser)
.order_by('-date_created')
.first()
)
task.status = 'processing'
task.save()

response_second = self.client.post(self.url)
self.assertEqual(response_second.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(
'You already have a running export task for this type.',
response_second.json()['error'],
)
14 changes: 13 additions & 1 deletion kobo/apps/audit_log/urls.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
from rest_framework.routers import DefaultRouter

from .views import AccessLogViewSet, AllAccessLogViewSet, AuditLogViewSet
from .views import (
AccessLogsExportViewSet,
AccessLogViewSet,
AllAccessLogsExportViewSet,
AllAccessLogViewSet,
AuditLogViewSet,
)

router = DefaultRouter()
router.register(r'audit-logs', AuditLogViewSet, basename='audit-log')
router.register(r'access-logs', AllAccessLogViewSet, basename='all-access-logs')
router.register(r'access-logs/me', AccessLogViewSet, basename='access-log')
router.register(
r'access-logs/export', AllAccessLogsExportViewSet, basename='all-access-logs-export'
)
router.register(
r'access-logs/me/export', AccessLogsExportViewSet, basename='access-logs-export'
)

urlpatterns = []
175 changes: 174 additions & 1 deletion kobo/apps/audit_log/views.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from rest_framework import mixins, viewsets
from rest_framework import mixins, status, viewsets
from rest_framework.renderers import BrowsableAPIRenderer, JSONRenderer
from rest_framework.response import Response

from kpi.filters import SearchFilter
from kpi.models.import_export_task import AccessLogExportTask
from kpi.permissions import IsAuthenticated
from kpi.tasks import export_task_in_background
from .filters import AccessLogPermissionsFilter
from .models import AccessLog, AuditLog
from .permissions import SuperUserPermission
Expand Down Expand Up @@ -321,3 +324,173 @@ class AccessLogViewSet(AuditLogViewSet):
permission_classes = (IsAuthenticated,)
filter_backends = (AccessLogPermissionsFilter,)
serializer_class = AccessLogSerializer


class BaseAccessLogsExportViewSet(viewsets.ViewSet):
permission_classes = (IsAuthenticated,)
lookup_field = 'uid'

def create_task(self, request, get_all_logs):

export_task = AccessLogExportTask.objects.create(
user=request.user,
get_all_logs=get_all_logs,
data={
'type': 'access_logs_export',
},
)

export_task_in_background.delay(
export_task_uid=export_task.uid,
username=export_task.user.username,
export_task_name='kpi.AccessLogExportTask',
)
return Response(
{f'status: {export_task.status}'},
status=status.HTTP_202_ACCEPTED,
)

def list_tasks(self, request):
tasks = AccessLogExportTask.objects.filter(user=request.user).order_by(
'-date_created'
Copy link
Contributor

Choose a reason for hiding this comment

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

Doesn't this mean we'll only get a superuser's tasks even if they hit the all endpoint? Not a huge deal if that ends up being the functionality since it's optional anyway but we should make sure we document the correct behavior

)

tasks_data = [
{'uid': task.uid, 'status': task.status, 'date_created': task.date_created}
for task in tasks
]

return Response(tasks_data, status=status.HTTP_200_OK)


class AccessLogsExportViewSet(BaseAccessLogsExportViewSet):
"""
Access logs export

Lists all access logs export tasks for the authenticated user

<pre class="prettyprint">
<b>GET</b> /api/v2/access-logs/me/export
</pre>

> Example
>
> curl -X GET https://[kpi-url]/access-logs/me/export

> Response 200
>
> [
> {
> "uid": "aleooVUrhe3cRrLY5urRhxLA",
> "status": "complete",
> "date_created": "2024-11-26T21:27:08.403181Z"
> },
> {
> "uid": "aleMzK7RnuaPokb86TZF2N4d",
> "status": "complete",
> "date_created": "2024-11-26T20:18:55.982974Z"
> }
> ]

### Creates an export task

<pre class="prettyprint">
<b>POST</b> /api/v2/access-log/me/export
</pre>

> Example
>
> curl -X POST https://[kpi-url]/access-logs/me/export

> Response 202
>
> [
> "status: created"
> ]
>
"""

def create(self, request, *args, **kwargs):
if AccessLogExportTask.objects.filter(
user=request.user,
status=AccessLogExportTask.PROCESSING,
get_all_logs=False,
).exists():
return Response(
{
'error': (
'You already have a running export task for your own logs.'
)
RuthShryock marked this conversation as resolved.
Show resolved Hide resolved
},
status=status.HTTP_400_BAD_REQUEST,
)
return self.create_task(request, get_all_logs=False)

def list(self, request, *args, **kwargs):
return self.list_tasks(request)


class AllAccessLogsExportViewSet(BaseAccessLogsExportViewSet):
"""
Access logs export

Lists all access logs export tasks for all users. Only available to superusers.

<pre class="prettyprint">
<b>GET</b> /api/v2/access-logs/export
</pre>

> Example
>
> curl -X GET https://[kpi-url]/access-logs/export

> Response 200
>
> [
> {
> "uid": "aleooVUrhe3cRrLY5urRhxLA",
> "status": "complete",
> "date_created": "2024-11-26T21:27:08.403181Z"
> },
> {
> "uid": "aleMzK7RnuaPokb86TZF2N4d",
> "status": "complete",
> "date_created": "2024-11-26T20:18:55.982974Z"
> }
> ]

### Creates an export task

<pre class="prettyprint">
<b>POST</b> /api/v2/access-log/export
</pre>

> Example
>
> curl -X POST https://[kpi-url]/access-logs/export

> Response 202
>
> [
> "status: created"
> ]
>
"""

permission_classes = (SuperUserPermission,)

def create(self, request, *args, **kwargs):
# Check if the superuser has a task running for all or just their own logs
if AccessLogExportTask.objects.filter(
RuthShryock marked this conversation as resolved.
Show resolved Hide resolved
user=request.user,
status=AccessLogExportTask.PROCESSING,
get_all_logs=True,
).exists():
return Response(
{'error': 'You already have a running export task for this type.'},
status=status.HTTP_400_BAD_REQUEST,
RuthShryock marked this conversation as resolved.
Show resolved Hide resolved
)
return self.create_task(request, get_all_logs=True)

def list(self, request, *args, **kwargs):
return self.list_tasks(request)
Loading
Loading