-
-
Notifications
You must be signed in to change notification settings - Fork 184
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(accessLogExport)!: create new AccessLogExportTask to generate a csv of access logs TASK-871 #5258
feat(accessLogExport)!: create new AccessLogExportTask to generate a csv of access logs TASK-871 #5258
Changes from 13 commits
09373fb
4079ea0
eb67d2b
0f9ac39
2836ea3
b69a006
04dc2cd
4c46fb0
de7f7f5
ff45ba8
7837192
6c60768
43ce879
bd5c994
6eb71c2
a807f66
83bf0ed
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
# Generated by Django 4.2.15 on 2024-11-26 19:55 | ||
|
||
import django.db.models.deletion | ||
import private_storage.fields | ||
import private_storage.storage.files | ||
from django.conf import settings | ||
from django.db import migrations, models | ||
|
||
import kpi.fields.file | ||
import kpi.fields.kpi_uid | ||
import kpi.models.asset_file | ||
import kpi.models.import_export_task | ||
|
||
|
||
class Migration(migrations.Migration): | ||
|
||
dependencies = [ | ||
migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||
('kpi', '0059_assetexportsettings_date_created_and_more'), | ||
] | ||
|
||
operations = [ | ||
migrations.RenameModel( | ||
old_name='ExportTask', | ||
new_name='SubmissionExportTask', | ||
), | ||
migrations.RenameModel( | ||
old_name='SynchronousExport', | ||
new_name='SubmissionSynchronousExport', | ||
), | ||
migrations.AlterField( | ||
model_name='assetfile', | ||
name='content', | ||
field=kpi.fields.file.PrivateExtendedFileField( | ||
max_length=380, null=True, upload_to=kpi.models.asset_file.upload_to | ||
), | ||
), | ||
migrations.CreateModel( | ||
name='AccessLogExportTask', | ||
fields=[ | ||
( | ||
'id', | ||
models.AutoField( | ||
auto_created=True, | ||
primary_key=True, | ||
serialize=False, | ||
verbose_name='ID', | ||
), | ||
), | ||
('data', models.JSONField()), | ||
('messages', models.JSONField(default=dict)), | ||
( | ||
'status', | ||
models.CharField( | ||
choices=[ | ||
('created', 'created'), | ||
('processing', 'processing'), | ||
('error', 'error'), | ||
('complete', 'complete'), | ||
], | ||
default='created', | ||
max_length=32, | ||
), | ||
), | ||
('date_created', models.DateTimeField(auto_now_add=True)), | ||
('uid', kpi.fields.kpi_uid.KpiUidField(_null=False, uid_prefix='ale')), | ||
('get_all_logs', models.BooleanField(default=False)), | ||
( | ||
'result', | ||
private_storage.fields.PrivateFileField( | ||
max_length=380, | ||
storage=( | ||
private_storage.storage.files.PrivateFileSystemStorage() | ||
), | ||
upload_to=kpi.models.import_export_task.export_upload_to, | ||
), | ||
), | ||
( | ||
'user', | ||
models.ForeignKey( | ||
on_delete=django.db.models.deletion.CASCADE, | ||
to=settings.AUTH_USER_MODEL, | ||
), | ||
), | ||
], | ||
options={ | ||
'abstract': False, | ||
}, | ||
bases=(kpi.models.import_export_task.ExportTaskMixin, models.Model), | ||
), | ||
] |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -18,7 +18,9 @@ | |||||
from django.contrib.postgres.indexes import BTreeIndex, HashIndex | ||||||
from django.core.files.storage import FileSystemStorage | ||||||
from django.db import models, transaction | ||||||
from django.db.models import F | ||||||
from django.db.models import CharField, F, Value | ||||||
from django.db.models.functions import Concat | ||||||
from django.db.models.query import QuerySet | ||||||
from django.urls import reverse | ||||||
from django.utils import timezone | ||||||
from django.utils.translation import gettext as t | ||||||
|
@@ -52,9 +54,18 @@ | |||||
from kpi.exceptions import XlsFormatException | ||||||
from kpi.fields import KpiUidField | ||||||
from kpi.models import Asset | ||||||
from kpi.utils.data_exports import ( | ||||||
ACCESS_LOGS_EXPORT_FIELDS, | ||||||
ASSET_FIELDS, | ||||||
CONFIG, | ||||||
SETTINGS, | ||||||
create_data_export, | ||||||
filter_remaining_metadata, | ||||||
get_q, | ||||||
) | ||||||
from kpi.utils.log import logging | ||||||
from kpi.utils.models import _load_library_content, create_assets, resolve_url_to_asset | ||||||
from kpi.utils.project_view_exports import create_project_view_export | ||||||
from kpi.utils.project_views import get_region_for_view | ||||||
from kpi.utils.rename_xls_sheet import ( | ||||||
ConflictSheetError, | ||||||
NoFromSheetError, | ||||||
|
@@ -129,7 +140,7 @@ def run(self): | |||||
# This method must be implemented by a subclass | ||||||
self._run_task(msgs) | ||||||
self.status = self.COMPLETE | ||||||
except ExportTaskBase.InaccessibleData as e: | ||||||
except SubmissionExportTaskBase.InaccessibleData as e: | ||||||
msgs['error_type'] = t('Cannot access data') | ||||||
msgs['error'] = str(e) | ||||||
self.status = self.ERROR | ||||||
|
@@ -477,27 +488,26 @@ def export_upload_to(self, filename): | |||||
return posixpath.join(self.user.username, 'exports', filename) | ||||||
|
||||||
|
||||||
class ProjectViewExportTask(ImportExportTask): | ||||||
uid = KpiUidField(uid_prefix='pve') | ||||||
result = PrivateFileField(upload_to=export_upload_to, max_length=380) | ||||||
class ExportTaskMixin: | ||||||
|
||||||
@property | ||||||
def default_email_subject(self) -> str: | ||||||
return 'Report Complete' | ||||||
|
||||||
def _get_export_details(self) -> tuple: | ||||||
return self.data['type'], self.data['view'] | ||||||
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. This should account for |
||||||
|
||||||
def _build_export_filename( | ||||||
self, export_type: str, username: str, view: str | ||||||
) -> str: | ||||||
time = timezone.now().strftime('%Y-%m-%dT%H:%M:%SZ') | ||||||
return f'{export_type}-{username}-view_{view}-{time}.csv' | ||||||
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. Same as previous comment. This should be updated to allow an empty/None |
||||||
|
||||||
def _run_task(self, messages: list) -> None: | ||||||
export_type = self.data['type'] | ||||||
view = self.data['view'] | ||||||
|
||||||
filename = self._build_export_filename( | ||||||
export_type, self.user.username, view | ||||||
) | ||||||
def _run_task_base(self, messages: list, buff) -> None: | ||||||
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.
Suggested change
since it's no longer doing the whole task |
||||||
export_type, view = self._get_export_details() | ||||||
filename = self._build_export_filename(export_type, self.user.username, view) | ||||||
absolute_filepath = self.get_absolute_filepath(filename) | ||||||
|
||||||
buff = create_project_view_export(export_type, self.user.username, view) | ||||||
|
||||||
with self.result.storage.open(absolute_filepath, 'wb') as output_file: | ||||||
output_file.write(buff.read().encode()) | ||||||
|
||||||
|
@@ -510,7 +520,95 @@ def delete(self, *args, **kwargs) -> None: | |||||
super().delete(*args, **kwargs) | ||||||
|
||||||
|
||||||
class ExportTaskBase(ImportExportTask): | ||||||
class AccessLogExportTask(ExportTaskMixin, ImportExportTask): | ||||||
uid = KpiUidField(uid_prefix='ale') | ||||||
get_all_logs = models.BooleanField(default=False) | ||||||
result = PrivateFileField(upload_to=export_upload_to, max_length=380) | ||||||
|
||||||
@property | ||||||
def default_email_subject(self) -> str: | ||||||
return 'Access Log Report Complete' | ||||||
|
||||||
def get_data(self, filtered_queryset: QuerySet) -> QuerySet: | ||||||
user_url = Concat( | ||||||
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. didn't know you could do this outside a query. neat. |
||||||
Value(f'{settings.KOBOFORM_URL}/api/v2/users/'), | ||||||
F('user__username'), | ||||||
output_field=CharField(), | ||||||
) | ||||||
|
||||||
return filtered_queryset.annotate( | ||||||
user_url=user_url, | ||||||
username=F('user__username'), | ||||||
auth_type=F('metadata__auth_type'), | ||||||
source=F('metadata__source'), | ||||||
ip_address=F('metadata__ip_address'), | ||||||
initial_superusername=F('metadata__initial_user_username'), | ||||||
initial_superuseruid=F('metadata__initial_user_uid'), | ||||||
authorized_application=F('metadata__authorized_app_name'), | ||||||
other_details=F('metadata'), | ||||||
).values(*ACCESS_LOGS_EXPORT_FIELDS) | ||||||
|
||||||
def _run_task(self, messages: list) -> None: | ||||||
if self.get_all_logs and not self.user.is_superuser: | ||||||
raise PermissionError('Only superusers can export all access logs.') | ||||||
|
||||||
export_type, view = self._get_export_details() | ||||||
config = CONFIG[export_type] | ||||||
|
||||||
queryset = config['queryset']() | ||||||
if not self.get_all_logs: | ||||||
queryset = queryset.filter(user__username=self.user.username) | ||||||
data = self.get_data(queryset) | ||||||
accessed_metadata_fields = [ | ||||||
'auth_type', | ||||||
'source', | ||||||
'ip_address', | ||||||
'initial_user_username', | ||||||
'initial_user_uid', | ||||||
'auth_app_name', | ||||||
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.
Suggested change
|
||||||
] | ||||||
for row in data: | ||||||
row['other_details'] = filter_remaining_metadata( | ||||||
row, accessed_metadata_fields | ||||||
) | ||||||
buff = create_data_export(export_type, data) | ||||||
self._run_task_base(messages, buff) | ||||||
|
||||||
|
||||||
class ProjectViewExportTask(ExportTaskMixin, ImportExportTask): | ||||||
uid = KpiUidField(uid_prefix='pve') | ||||||
result = PrivateFileField(upload_to=export_upload_to, max_length=380) | ||||||
|
||||||
@property | ||||||
def default_email_subject(self) -> str: | ||||||
return 'Project View Report Complete' | ||||||
|
||||||
def get_data(self, filtered_queryset: QuerySet) -> QuerySet: | ||||||
vals = ASSET_FIELDS + (SETTINGS,) | ||||||
return ( | ||||||
filtered_queryset.annotate( | ||||||
owner__name=F('owner__extra_details__data__name'), | ||||||
owner__organization=F('owner__extra_details__data__organization'), | ||||||
form_id=F('_deployment_data__backend_response__formid'), | ||||||
) | ||||||
.values(*vals) | ||||||
.order_by('id') | ||||||
) | ||||||
|
||||||
def _run_task(self, messages: list) -> None: | ||||||
export_type, view = self._get_export_details() | ||||||
config = CONFIG[export_type] | ||||||
|
||||||
region_for_view = get_region_for_view(view) | ||||||
q = get_q(region_for_view, export_type) | ||||||
queryset = config['queryset'].filter(q) | ||||||
|
||||||
data = self.get_data(queryset) | ||||||
buff = create_data_export(export_type, data) | ||||||
self._run_task_base(messages, buff) | ||||||
|
||||||
|
||||||
class SubmissionExportTaskBase(ImportExportTask): | ||||||
""" | ||||||
An (asynchronous) submission data export job. The instantiator must set the | ||||||
`data` attribute to a dictionary with the following keys: | ||||||
|
@@ -940,7 +1038,7 @@ def remove_excess(cls, user, source): | |||||
export.delete() | ||||||
|
||||||
|
||||||
class ExportTask(ExportTaskBase): | ||||||
class SubmissionExportTask(SubmissionExportTaskBase): | ||||||
""" | ||||||
An asynchronous export task, to be run with Celery | ||||||
""" | ||||||
|
@@ -961,7 +1059,7 @@ def _run_task(self, messages): | |||||
self.remove_excess(self.user, source_url) | ||||||
|
||||||
|
||||||
class SynchronousExport(ExportTaskBase): | ||||||
class SubmissionSynchronousExport(SubmissionExportTaskBase): | ||||||
""" | ||||||
A synchronous export, with significant limitations on processing time, but | ||||||
offered for user convenience | ||||||
|
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.
nit, definitely non-blocking