diff --git a/kobo/apps/audit_log/permissions.py b/kobo/apps/audit_log/permissions.py index 40f6ca0f19..0c073938b5 100644 --- a/kobo/apps/audit_log/permissions.py +++ b/kobo/apps/audit_log/permissions.py @@ -1,8 +1,8 @@ from rest_framework.permissions import IsAdminUser -from kpi.mixins.validation_password_permission import ( - ValidationPasswordPermissionMixin, -) +from kpi.constants import PERM_MANAGE_ASSET +from kpi.mixins.validation_password_permission import ValidationPasswordPermissionMixin +from kpi.permissions import IsAuthenticated class SuperUserPermission(ValidationPasswordPermissionMixin, IsAdminUser): @@ -10,3 +10,13 @@ class SuperUserPermission(ValidationPasswordPermissionMixin, IsAdminUser): def has_permission(self, request, view): self.validate_password(request) return bool(request.user and request.user.is_superuser) + + +class ViewProjectHistoryLogsPermission(IsAuthenticated): + + def has_permission(self, request, view): + has_asset_perm = bool( + request.user + and view.asset.has_perm(user_obj=request.user, perm=PERM_MANAGE_ASSET) + ) + return has_asset_perm and super().has_permission(request, view) diff --git a/kobo/apps/audit_log/serializers.py b/kobo/apps/audit_log/serializers.py index 55a02a4e76..5307018584 100644 --- a/kobo/apps/audit_log/serializers.py +++ b/kobo/apps/audit_log/serializers.py @@ -2,7 +2,7 @@ from rest_framework import serializers from kpi.fields import RelativePrefixHyperlinkedRelatedField -from .models import AuditLog +from .models import AuditLog, ProjectHistoryLog class AuditLogSerializer(serializers.ModelSerializer): @@ -64,3 +64,39 @@ class AccessLogSerializer(serializers.Serializer): def get_date_created(self, audit_log): return audit_log['date_created'].strftime('%Y-%m-%dT%H:%M:%SZ') + + +class ProjectHistoryLogSerializer(serializers.ModelSerializer): + user = serializers.HyperlinkedRelatedField( + queryset=get_user_model().objects.all(), + lookup_field='username', + view_name='user-kpi-detail', + ) + date_created = serializers.SerializerMethodField() + username = serializers.SerializerMethodField() + + class Meta: + model = ProjectHistoryLog + fields = ( + 'user', + 'user_uid', + 'username', + 'action', + 'metadata', + 'date_created', + ) + + read_only_fields = ( + 'user', + 'user_uid', + 'username', + 'action', + 'metadata', + 'date_created', + ) + + def get_date_created(self, audit_log): + return audit_log.date_created.strftime('%Y-%m-%dT%H:%M:%SZ') + + def get_username(self, audit_log): + return audit_log.user.username diff --git a/kobo/apps/audit_log/tests/api/v2/test_api_audit_log.py b/kobo/apps/audit_log/tests/api/v2/test_api_audit_log.py index 3e4a9d7654..a36d2b8037 100644 --- a/kobo/apps/audit_log/tests/api/v2/test_api_audit_log.py +++ b/kobo/apps/audit_log/tests/api/v2/test_api_audit_log.py @@ -6,13 +6,17 @@ 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, ProjectHistoryLog 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, + PERM_MANAGE_ASSET, + PROJECT_HISTORY_LOG_PERMISSION_SUBTYPE, + PROJECT_HISTORY_LOG_PROJECT_SUBTYPE, ) +from kpi.models import Asset 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 @@ -41,6 +45,124 @@ def force_login_user(self, user): self.client.force_login(user) +class ProjectHistoryLogTestCaseMixin: + """ + Common tests for /project-history-logs and asset//history + """ + + def test_results_have_expected_fields(self): + now = timezone.now() + metadata_dict = { + 'asset_uid': self.asset.uid, + 'ip_address': '1.2.3.4', + 'source': 'source', + 'log_subtype': 'project', + 'some': 'thing', + } + ProjectHistoryLog.objects.create( + user=self.user, + object_id=self.asset.id, + action=AuditAction.DELETE, + metadata=metadata_dict, + date_created=now, + ) + response = self.client.get(self.url) + self.assertEqual(response.data['count'], 1) + ph_log = response.data['results'][0] + self.assertListEqual( + sorted(list(ph_log.keys())), + ['action', 'date_created', 'metadata', 'user', 'user_uid', 'username'], + ) + self.assertEqual(ph_log['action'], AuditAction.DELETE), + self.assertEqual(ph_log['date_created'], now.strftime('%Y-%m-%dT%H:%M:%SZ')) + self.assertEqual( + ph_log['user'], + reverse( + 'api_v2:user-kpi-detail', + kwargs={'username': self.user.username}, + request=response.wsgi_request, + ), + ) + self.assertEqual(ph_log['user_uid'], self.user.extra_details.uid) + self.assertEqual(ph_log['username'], self.user.username) + self.assertDictEqual(ph_log['metadata'], metadata_dict) + + def test_results_are_sorted_by_date_descending(self): + now = timezone.now() + yesterday = now - timedelta(days=1) + ProjectHistoryLog.objects.create( + user=self.user, + object_id=self.asset.id, + action=AuditAction.DELETE, + metadata={ + 'asset_uid': self.asset.uid, + 'ip_address': '1.2.3.4', + 'source': 'source', + 'log_subtype': 'project', + }, + date_created=yesterday, + ) + ProjectHistoryLog.objects.create( + user=self.user, + object_id=self.asset.id, + action=AuditAction.DELETE, + metadata={ + 'asset_uid': self.asset.uid, + 'ip_address': '1.2.3.4', + 'source': 'source', + 'log_subtype': 'project', + }, + date_created=now, + ) + response = self.client.get(self.url) + self.assertEqual(response.data['count'], 2) + self.assertEqual( + response.data['results'][0]['date_created'], + now.strftime('%Y-%m-%dT%H:%M:%SZ'), + ) + self.assertEqual( + response.data['results'][1]['date_created'], + yesterday.strftime('%Y-%m-%dT%H:%M:%SZ'), + ) + + def test_results_can_be_searched_by_subtype(self): + now = timezone.now() + yesterday = now - timedelta(days=1) + ProjectHistoryLog.objects.create( + user=self.user, + object_id=self.asset.id, + action=AuditAction.DELETE, + metadata={ + 'asset_uid': self.asset.uid, + 'ip_address': '1.2.3.4', + 'source': 'source', + 'log_subtype': PROJECT_HISTORY_LOG_PROJECT_SUBTYPE, + }, + date_created=now, + ) + ProjectHistoryLog.objects.create( + user=self.user, + object_id=self.asset.id, + action=AuditAction.DELETE, + metadata={ + 'asset_uid': self.asset.uid, + 'ip_address': '1.2.3.4', + 'source': 'source', + 'log_subtype': PROJECT_HISTORY_LOG_PERMISSION_SUBTYPE, + }, + date_created=yesterday, + ) + response = self.client.get( + f'{self.url}?q=metadata__log_subtype:' + f'{PROJECT_HISTORY_LOG_PERMISSION_SUBTYPE}' + ) + self.assertEqual(response.data['count'], 1) + self.assertEqual( + response.data['results'][0]['metadata']['log_subtype'], + PROJECT_HISTORY_LOG_PERMISSION_SUBTYPE, + ) + + class ApiAuditLogTestCase(BaseAuditLogTestCase): def get_endpoint_basename(self): @@ -429,6 +551,109 @@ def test_can_search_access_logs_by_date_including_submission_groups(self): ) +class ApiProjectHistoryLogsTestCase(BaseTestCase, ProjectHistoryLogTestCaseMixin): + + fixtures = ['test_data'] + + def setUp(self): + super().setUp() + self.asset = Asset.objects.get(pk=1) + self.url = reverse( + 'api_v2:history-list', kwargs={'parent_lookup_asset': self.asset.uid} + ) + self.user = User.objects.get(username='someuser') + self.asset.assign_perm(user_obj=self.user, perm=PERM_MANAGE_ASSET) + self.client.force_login(self.user) + + def test_list_without_permissions_returns_forbidden(self): + user2 = User.objects.get(username='anotheruser') + self.client.force_login(user2) + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.asset.assign_perm(user_obj=user2, perm=PERM_MANAGE_ASSET) + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_show_project_history_logs_filters_to_project(self): + asset2 = Asset.objects.get(pk=2) + ProjectHistoryLog.objects.create( + user=self.user, + object_id=self.asset.id, + action=AuditAction.DELETE, + metadata={ + 'asset_uid': self.asset.uid, + 'ip_address': '1.2.3.4', + 'source': 'source', + 'log_subtype': 'project', + }, + ) + ProjectHistoryLog.objects.create( + user=self.user, + object_id=asset2.id, + action=AuditAction.DELETE, + metadata={ + 'asset_uid': asset2.uid, + 'ip_address': '1.2.3.4', + 'source': 'source', + 'log_subtype': 'project', + }, + ) + response = self.client.get(self.url) + self.assertEqual(response.data['count'], 1) + self.assertEqual( + response.data['results'][0]['metadata']['asset_uid'], self.asset.uid + ) + + +class ApiAllProjectHistoryLogsTestCase( + BaseAuditLogTestCase, ProjectHistoryLogTestCaseMixin +): + + def get_endpoint_basename(self): + return 'all-project-history-logs-list' + + def setUp(self): + super().setUp() + self.user = User.objects.get(username='admin') + self.asset = Asset.objects.get(pk=1) + self.force_login_user(self.user) + + def test_show_all_project_history_logs(self): + asset1 = Asset.objects.get(pk=1) + asset2 = Asset.objects.get(pk=2) + ProjectHistoryLog.objects.create( + user=self.user, + object_id=asset1.id, + action=AuditAction.DELETE, + metadata={ + 'asset_uid': asset1.uid, + 'ip_address': '1.2.3.4', + 'source': 'source', + 'log_subtype': 'project', + }, + ) + ProjectHistoryLog.objects.create( + user=self.user, + object_id=asset2.id, + action=AuditAction.DELETE, + metadata={ + 'asset_uid': asset2.uid, + 'ip_address': '1.2.3.4', + 'source': 'source', + 'log_subtype': 'project', + }, + ) + response = self.client.get(self.url) + self.assertEqual(response.data['count'], 2) + self.assertEqual( + response.data['results'][0]['metadata']['asset_uid'], asset2.uid + ) + self.assertEqual( + response.data['results'][1]['metadata']['asset_uid'], asset1.uid + + ) + + class ApiAccessLogsExportTestCase(BaseAuditLogTestCase): def get_endpoint_basename(self): diff --git a/kobo/apps/audit_log/urls.py b/kobo/apps/audit_log/urls.py index f2f9625b13..1fcc4947f1 100644 --- a/kobo/apps/audit_log/urls.py +++ b/kobo/apps/audit_log/urls.py @@ -5,6 +5,7 @@ AccessLogViewSet, AllAccessLogsExportViewSet, AllAccessLogViewSet, + AllProjectHistoryLogViewSet, AuditLogViewSet, ) @@ -12,6 +13,12 @@ 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') +# routes for PH logs for individual assets are registered in router_api_v2.py +router.register( + r'project-history-logs', + AllProjectHistoryLogViewSet, + basename='all-project-history-logs', +) router.register( r'access-logs/export', AllAccessLogsExportViewSet, basename='all-access-logs-export' ) diff --git a/kobo/apps/audit_log/views.py b/kobo/apps/audit_log/views.py index dce8deaf5a..27dc6a3d9d 100644 --- a/kobo/apps/audit_log/views.py +++ b/kobo/apps/audit_log/views.py @@ -1,15 +1,21 @@ from rest_framework import mixins, status, viewsets from rest_framework.renderers import BrowsableAPIRenderer, JSONRenderer +from rest_framework_extensions.mixins import NestedViewSetMixin 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.utils.viewset_mixins import AssetNestedObjectViewsetMixin from kpi.tasks import export_task_in_background from .filters import AccessLogPermissionsFilter -from .models import AccessLog, AuditLog -from .permissions import SuperUserPermission -from .serializers import AccessLogSerializer, AuditLogSerializer +from .models import AccessLog, AuditLog, ProjectHistoryLog +from .permissions import SuperUserPermission, ViewProjectHistoryLogsPermission +from .serializers import ( + AccessLogSerializer, + AuditLogSerializer, + ProjectHistoryLogSerializer, +) class AuditLogViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): @@ -326,6 +332,507 @@ class AccessLogViewSet(AuditLogViewSet): serializer_class = AccessLogSerializer +class AllProjectHistoryLogViewSet(AuditLogViewSet): + """ + Project history logs + + Lists all project history logs for all projects. Only available to superusers. + +
+    GET /api/v2/project-history-logs/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/project-history-logs/ + + > Response 200 + + > { + > "count": 10, + > "next": null, + > "previous": null, + > "results": [ + > { + > "user": "http://localhost/api/v2/users/admin/", + > "user_uid": "u12345", + > "username": "admin", + > "action": "modify-user-permissions" + > "metadata": { + > "source": "Firefox (Ubuntu)", + > "ip_address": "172.18.0.6", + > "asset_uid": "a678910", + > "log_subtype": "permissions", + > "permissions": + > { + > "username": "user1", + > "added": ["add_submissions", "view_submissions"], + > "removed": ["change_asset"], + > } + > }, + > "date_created": "2024-08-19T16:48:58Z", + > }, + > { + > "user": "http://localhost/api/v2/users/admin/", + > "user_uid": "u56789", + > "username": "someuser", + > "action": "update-settings", + > "metadata": { + > "source": "Firefox (Ubuntu)", + > "ip_address": "172.18.0.6", + > "asset_uid": "a111213", + > "log_subtype": "project", + > "settings": + > { + > "description": + > { + > "old": "old_description", + > "new": "new_description", + > } + > "countries": + > { + > "added": ["USA"], + > "removed": ["ALB"], + > } + > } + > }, + > "date_created": "2024-08-19T16:48:58Z", + > }, + > ... + > ] + > } + + Results from this endpoint can be filtered by a Boolean query + specified in the `q` parameter. + + **Filterable fields for all project history logs:** + + 1. date_created + + 2. user_uid + + 3. user__* + + a. user__username + + b. user__email + + c. user__is_superuser + + 4. action + + available actions: + + > add-media + > allow-anonymous-submissions + > archive + > connect-project + > delete-media + > delete-service + > deploy + > disable-sharing + > disallow-anonymous-submissions + > disconnect-project + > enable-sharing + > export + > modify-imported-fields + > modify-service + > modify_sharing + > modify-user-permissions + > redeploy + > register-service + > replace-form + > share-data-publicly + > share-form-publicly + > transfer + > unarchive + > unshare-data-publicly + > unshare-form-publicly + > update_content + > update-name + > update-settings + > update-qa + + 4. metadata__* + + b. metadata__source + + c. metadata__ip_address + + d. metadata__asset_uid + + e. metadata__log_subtype + + available subtypes: + + project + permission + + **Filterable fields by action:** + + 1. add-media + + a. metadata__asset-file__uid + + b. metadata__asset-file__filename + + 2. archive + + a. metadata__latest_version_uid + + 3. clone-permissions + + a. metadata__cloned_from + + 4. connect-project + + a. metadata__paired-data__source_uid + + b. metadata__paired-data__source_name + + 5. delete-media + + a. metadata__asset-file__uid + + b. metadata__asset-file__filename + + 6. delete-service + + a. metadata__hook__uid + + b. metadata__hook__endpoint + + c. metadata__hook__active + + 7. deploy + + a. metadata__latest_version_uid + + b. metadata__latest_deployed_version_uid + + 8. disconnect-project + + a. metadata__paired-data__source_uid + + b. metadata__paired-data__source_name + + 9. modify-imported-fields + + a. metadata__paired-data__source_uid + + b. metadata__paired-data__source_name + + 10. modify-service + + a. metadata__hook__uid + + b. metadata__hook__endpoint + + c. metadata__hook__active + + 11. modify-user-permissions + + a. metadata__permissions__username + + 12. redeploy + + a. metadata__latest_version_uid + + b. metadata__latest_deployed_version_uid + + 13. register-service + + a. metadata__hook__uid + + b. metadata__hook__endpoint + + c. metadata__hook__active + + 14. transfer + + a. metadata__username + + 15. unarchive + + a. metadata__latest_version_uid + + 16. update-name + + a. metadata__name__old + + b. metadata__name__new + + 17. update-settings + + a. metadata__settings__description__old + + b. metadata__settings__description__new + + This endpoint can be paginated with 'offset' and 'limit' parameters, eg + > curl -X GET https://[kpi-url]/project-history-logs/?offset=100&limit=50 + """ + + queryset = ProjectHistoryLog.objects.all().order_by('-date_created') + serializer_class = ProjectHistoryLogSerializer + filter_backends = (SearchFilter,) + + +class ProjectHistoryLogViewSet( + AuditLogViewSet, AssetNestedObjectViewsetMixin, NestedViewSetMixin +): + """ + Project history logs + + Lists all project history logs for a single project. Only available to + those with 'manage_asset' permissions. + +
+    GET /api/v2/asset/{asset_uid}/history/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/api/v2/asset/aSAvYreNzVEkrWg5Gdcvg/history/ + + + > Response 200 + + > { + > "count": 10, + > "next": null, + > "previous": null, + > "results": [ + > { + > "user": "http://localhost/api/v2/users/admin/", + > "user_uid": "u12345", + > "username": "admin", + > "action": "modify-user-permissions" + > "metadata": { + > "source": "Firefox (Ubuntu)", + > "ip_address": "172.18.0.6", + > "asset_uid": "a678910", + > "log_subtype": "permissions", + > "permissions": + > { + > "username": "user1", + > "added": ["add_submissions", "view_submissions"], + > "removed": ["change_asset"], + > } + > }, + > "date_created": "2024-08-19T16:48:58Z", + > }, + > { + > "user": "http://localhost/api/v2/users/admin/", + > "user_uid": "u56789", + > "username": "someuser", + > "action": "update-settings", + > "metadata": { + > "source": "Firefox (Ubuntu)", + > "ip_address": "172.18.0.6", + > "asset_uid": "a111213", + > "log_subtype": "project", + > "settings": + > { + > "description": + > { + > "old": "old_description", + > "new": "new_description", + > } + > "countries": + > { + > "added": ["USA"], + > "removed": ["ALB"], + > } + > } + > }, + > "date_created": "2024-08-19T16:48:58Z", + > }, + > ... + > ] + > } + + Results from this endpoint can be filtered by a Boolean query + specified in the `q` parameter. + + **Filterable fields for all project history logs:** + + 1. date_created + + 2. user_uid + + 3. user__* + + a. user__username + + b. user__email + + c. user__is_superuser + + 4. action + + available actions: + + > add-media + > allow-anonymous-submissions + > archive + > connect-project + > delete-media + > delete-service + > deploy + > disable-sharing + > disallow-anonymous-submissions + > disconnect-project + > enable-sharing + > export + > modify-imported-fields + > modify-service + > modify_sharing + > modify-user-permissions + > redeploy + > register-service + > replace-form + > share-data-publicly + > share-form-publicly + > transfer + > unarchive + > unshare-data-publicly + > unshare-form-publicly + > update_content + > update-name + > update-settings + > update-qa + + 4. metadata__* + + b. metadata__source (browser/OS) + + c. metadata__ip_address + + d. metadata__asset_uid + + e. metadata__log_subtype + + available subtypes: + + project + permission + + **Filterable fields by action:** + + 1. add-media + + a. metadata__asset-file__uid + + b. metadata__asset-file__filename + + 2. archive + + a. metadata__latest_version_uid + + 3. clone-permissions + + a. metadata__cloned_from + + 4. connect-project + + a. metadata__paired-data__source_uid + + b. metadata__paired-data__source_name + + 5. delete-media + + a. metadata__asset-file__uid + + b. metadata__asset-file__filename + + 6. delete-service + + a. metadata__hook__uid + + b. metadata__hook__endpoint + + c. metadata__hook__active + + 7. deploy + + a. metadata__latest_version_uid + + b. metadata__latest_deployed_version_uid + + 8. disconnect-project + + a. metadata__paired-data__source_uid + + b. metadata__paired-data__source_name + + 9. modify-imported-fields + + a. metadata__paired-data__source_uid + + b. metadata__paired-data__source_name + + 10. modify-service + + a. metadata__hook__uid + + b. metadata__hook__endpoint + + c. metadata__hook__active + + 11. modify-user-permissions + + a. metadata__permissions__username + + 12. redeploy + + a. metadata__latest_version_uid + + b. metadata__latest_deployed_version_uid + + 13. register-service + + a. metadata__hook__uid + + b. metadata__hook__endpoint + + c. metadata__hook__active + + 14. transfer + + a. metadata__username + + 15. unarchive + + a. metadata__latest_version_uid + + 16. update-name + + a. metadata__name__old + + b. metadata__name__new + + 17. update-settings + + a. metadata__settings__description__old + + b. metadata__settings__description__new + + This endpoint can be paginated with 'offset' and 'limit' parameters, eg + > curl -X GET https://[kpi-url]/assets/ap732ywWxc/history/?offset=100&limit=50 + """ + + serializer_class = ProjectHistoryLogSerializer + model = ProjectHistoryLog + permission_classes = (ViewProjectHistoryLogsPermission,) + lookup_field = 'uid' + filter_backends = (SearchFilter,) + + def get_queryset(self): + return self.model.objects.filter(metadata__asset_uid=self.asset_uid).order_by( + '-date_created' + ) + + class BaseAccessLogsExportViewSet(viewsets.ViewSet): permission_classes = (IsAuthenticated,) lookup_field = 'uid' @@ -443,6 +950,7 @@ class AllAccessLogsExportViewSet(BaseAccessLogsExportViewSet): > Example > + > curl -X GET https://[kpi-url]/access-logs/export > Response 200 diff --git a/kpi/urls/router_api_v2.py b/kpi/urls/router_api_v2.py index 411d77de2b..b2c3f90fac 100644 --- a/kpi/urls/router_api_v2.py +++ b/kpi/urls/router_api_v2.py @@ -1,12 +1,14 @@ # coding: utf-8 + from django.urls import path from rest_framework_extensions.routers import ExtendedDefaultRouter from kobo.apps.audit_log.urls import router as audit_log_router +from kobo.apps.audit_log.views import ProjectHistoryLogViewSet from kobo.apps.hook.views.v2.hook import HookViewSet from kobo.apps.hook.views.v2.hook_log import HookLogViewSet from kobo.apps.languages.urls import router as language_router -from kobo.apps.organizations.views import OrganizationViewSet, OrganizationMemberViewSet +from kobo.apps.organizations.views import OrganizationMemberViewSet, OrganizationViewSet from kobo.apps.project_ownership.urls import router as project_ownership_router from kobo.apps.project_views.views import ProjectViewViewSet from kpi.views.v2.asset import AssetViewSet @@ -103,11 +105,19 @@ def get_urls(self, *args, **kwargs): parents_query_lookups=['asset'], ) -asset_routes.register(r'paired-data', - PairedDataViewset, - basename='paired-data', - parents_query_lookups=['asset'], - ) +asset_routes.register( + r'paired-data', + PairedDataViewset, + basename='paired-data', + parents_query_lookups=['asset'], +) + +asset_routes.register( + r'history', + ProjectHistoryLogViewSet, + basename='history', + parents_query_lookups=['asset'], +) data_routes = asset_routes.register(r'data', DataViewSet,