From cda1db937c1a1c183ca26f600679a1d8755813fa Mon Sep 17 00:00:00 2001 From: huanphan-tma Date: Mon, 21 Apr 2025 17:54:55 +0700 Subject: [PATCH 1/3] =?UTF-8?q?ref=202.4.=E3=83=A6=E3=83=BC=E3=82=B6?= =?UTF-8?q?=E4=B8=80=E8=A6=A7=E3=81=AECSV=E5=87=BA=E5=8A=9B:=20In=20Projec?= =?UTF-8?q?t=20Limit=20Number=20Setting=20screen:=20-=20Add=20Export=20Use?= =?UTF-8?q?r=20List=20CSV=20function.=20-=20Rename=20from=20EPPN=20to=20eP?= =?UTF-8?q?PN=20in=20User=20List=20table.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...rt-project-limit-user-list-csv-schema.json | 40 +++ admin/project_limit_number/setting/urls.py | 1 + admin/project_limit_number/setting/views.py | 235 +++++++++++++++++- .../project_limit_number/create.html | 53 +++- .../project_limit_number/detail.html | 53 +++- admin/translations/django.pot | 6 + admin/translations/en/LC_MESSAGES/django.po | 6 + admin/translations/ja/LC_MESSAGES/django.po | 6 + 8 files changed, 397 insertions(+), 3 deletions(-) create mode 100644 admin/base/schemas/export-project-limit-user-list-csv-schema.json diff --git a/admin/base/schemas/export-project-limit-user-list-csv-schema.json b/admin/base/schemas/export-project-limit-user-list-csv-schema.json new file mode 100644 index 00000000000..9d522e799ab --- /dev/null +++ b/admin/base/schemas/export-project-limit-user-list-csv-schema.json @@ -0,0 +1,40 @@ +{ + "type": "object", + "properties": { + "institution_id": { + "type": "integer" + }, + "attribute_list": { + "type": "array", + "minItems": 1, + "items": { + "properties": { + "attribute_name": { + "type": "string", + "minLength": 1 + }, + "setting_type": { + "type": "integer", + "minimum": 1, + "maximum": 6 + }, + "attribute_value": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "attribute_name", + "setting_type", + "attribute_value" + ], + "additionalProperties": false + } + } + }, + "required": [ + "institution_id", + "attribute_list" + ], + "additionalProperties": false +} diff --git a/admin/project_limit_number/setting/urls.py b/admin/project_limit_number/setting/urls.py index 1e49c615566..ce18d1aa7a9 100644 --- a/admin/project_limit_number/setting/urls.py +++ b/admin/project_limit_number/setting/urls.py @@ -10,4 +10,5 @@ url(r'^(?P[0-9]+)/$', views.ProjectLimitNumberSettingDetailView.as_view(), name='setting-detail'), url(r'^(?P[0-9]+)/update/$', views.UpdateProjectLimitNumberSettingView.as_view(), name='update-setting'), url(r'^user_list/$', views.UserListView.as_view(), name='user_list'), + url(r'^export_user_list_csv/$', views.ExportUserListCSVView.as_view(), name='export_user_list_csv'), ] diff --git a/admin/project_limit_number/setting/views.py b/admin/project_limit_number/setting/views.py index 02263b1035b..c6be2f58eb0 100644 --- a/admin/project_limit_number/setting/views.py +++ b/admin/project_limit_number/setting/views.py @@ -1,7 +1,9 @@ from __future__ import unicode_literals +import csv import json import math +from datetime import datetime from http import HTTPStatus from django.core.exceptions import PermissionDenied @@ -21,7 +23,7 @@ ProjectLimitNumberTemplateAttribute, ProjectLimitNumberDefault, AbstractNode, UserExtendedData from django.db.models import F, Max, Value, Count from admin.base import settings -from django.http import Http404, JsonResponse +from django.http import Http404, JsonResponse, HttpResponse import logging logger = logging.getLogger(__name__) @@ -1043,3 +1045,234 @@ def get_user_list_met_logic_condition(self, institution_id, page, logic_conditio keys = ['guid', 'id', 'username', 'fullname', 'eppn'] user_list = [dict(zip(keys, t)) for t in user_list] return user_list + + +class ExportUserListCSVView(RdmPermissionMixin, UserPassesTestMixin, View): + """ Export user list match project limit number settings to CSV. """ + institution_id = None + raise_exception = True + + def test_func(self): + """check user permissions""" + if not self.is_authenticated: + # If user is not authenticated then redirect to login page + self.raise_exception = False + return False + return self.is_super_admin or self.is_institutional_admin + + def handle_no_permission(self): + """ Handle user has no permission """ + if not self.raise_exception: + # If user is not authenticated then return HTTP 401 + return JsonResponse( + {'error_message': 'Authentication credentials were not provided.'}, + status=HTTPStatus.UNAUTHORIZED + ) + return super(ExportUserListCSVView, self).handle_no_permission() + + def post(self, request, *args, **kwargs): + try: + request_body = json.loads(request.body) + is_request_valid, error_message = utils.validate_file_json(request_body, + 'export-project-limit-user-list-csv-schema.json') + if not is_request_valid: + return JsonResponse({'error_message': error_message}, status=HTTPStatus.BAD_REQUEST) + + institution_id = request_body.get('institution_id') + attribute_list = request_body.get('attribute_list', []) + + # If institution_id is not exist then return HTTP 400 + if not Institution.objects.filter(id=institution_id, is_deleted=False).exists(): + return JsonResponse({'error_message': 'The institution not exist.'}, status=HTTPStatus.BAD_REQUEST) + + # Handle admin permissions + if self.is_admin: + first_affiliated_institution = self.request.user.affiliated_institutions.filter( + is_deleted=False).first() + if not first_affiliated_institution or first_affiliated_institution.id != institution_id: + return JsonResponse({'error_message': 'Forbidden'}, status=HTTPStatus.FORBIDDEN) + + # Combine logic condition in attribute list + logic_condition_query_string = '' + include_osf_user_query_string = '' + logic_condition_params = [] + include_osf_user_params = [] + for attribute in attribute_list: + if attribute.get('attribute_name') not in settings.ATTRIBUTE_NAME_LIST: + return JsonResponse({'error_message': 'attribute_name is invalid.'}, status=HTTPStatus.BAD_REQUEST) + if attribute.get('attribute_name') == utils.MAIL_GRDM: + # Get query from osf_user table instead + if len(include_osf_user_query_string) > 0: + include_osf_user_query_string += ' AND ' + query_string, params = utils.generate_logic_condition_from_attribute(attribute) + include_osf_user_query_string += query_string + include_osf_user_params.extend(params) + else: + # Get query from osf_userextendeddata table + if len(logic_condition_query_string) > 0: + logic_condition_query_string += ' AND ' + query_string, params = utils.generate_logic_condition_from_attribute(attribute) + logic_condition_query_string += query_string + logic_condition_params.extend(params) + + user_list = self.get_user_list_met_logic_condition(institution_id, logic_condition_query_string, + include_osf_user_query_string, logic_condition_params, + include_osf_user_params) + if len(user_list) == 0: + return self.create_csv_from_user_list([]) + + # Get the list setting for institution + setting_list = ProjectLimitNumberSetting.objects.filter( + institution_id=institution_id, + is_availability=True, + is_deleted=False + ).order_by('priority').all() + setting_id_list = [s.id for s in setting_list] + # Get setting list attribute by setting + all_setting_attribute_list = (ProjectLimitNumberSettingAttribute.objects.select_related( + 'attribute' + ).filter( + setting_id__in=setting_id_list, + is_deleted=False + ).annotate( + setting_type=F('attribute__setting_type'), + attribute_name=F('attribute__attribute_name'), + setting_id=F('setting_id') + ).order_by('id').values( + 'id', + 'attribute_name', + 'setting_type', + 'attribute_value', + 'setting_id' + )) + + setting_attributes_dict = {} + for setting_attribute in all_setting_attribute_list: + setting_id = setting_attribute.get('setting_id') + setting_attributes_dict.setdefault(setting_id, []).append(setting_attribute) + + user_list_met_condition = [] + user_list_response = [] + user_extended_data_attributes = UserExtendedData.objects.filter( + user_id__in=[user.get('id') for user in user_list]) + # Check if user met any logic condition from setting list + for setting in setting_list: + if len(user_list) > 0: + project_limit_number = setting.project_limit_number + for user in user_list: + user_extended_data_attribute = next( + (p for p in user_extended_data_attributes if p.user_id == user.get('id')), None) + # Check if user met the logic condition from this setting + is_user_met_condition = utils.check_logic_condition(user, + setting_attributes_dict.get(setting.id, []), + user_extended_data_attribute) + if is_user_met_condition: + user['project_limit_number'] = project_limit_number + user_list_met_condition.append(user.get('guid')) + user_list_response.append(user) + # Remove user that met condition + user_list = [item for item in user_list if item.get('guid') not in user_list_met_condition] + + if len(user_list) > 0: + # Use project limit number default value for remaining users that does not met any conditions + project_limit_number_default = ProjectLimitNumberDefault.objects.filter( + institution_id=institution_id).first() + project_limit_number_default_value = (project_limit_number_default.project_limit_number + if project_limit_number_default is not None else utils.NO_LIMIT) + for user in user_list: + user['project_limit_number'] = project_limit_number_default_value + user_list_response.append(user) + + # Get created project number list by user id list + user_id_list = [user.get('id') for user in user_list_response] + created_project_number_list = ( + AbstractNode.objects.filter( + type='osf.node', + creator_id__in=user_id_list, + is_deleted=False + ) + .values('creator_id') + .annotate( + user_id=F('creator_id'), + created_project_number=Count('creator_id') + ) + .values('user_id', 'created_project_number')) + + # Set created_project_number for each user if have (default is 0) + created_project_number_map = {item.get('user_id'): item.get('created_project_number') for item in + created_project_number_list} + for user in user_list_response: + user['created_project_number'] = created_project_number_map.get(user.get('id'), 0) + + return self.create_csv_from_user_list(user_list_response) + except json.JSONDecodeError: + return JsonResponse( + {'error_message': 'The request body is invalid.'}, + status=HTTPStatus.BAD_REQUEST + ) + + def get_user_list_met_logic_condition(self, institution_id, logic_condition_query_string, + include_osf_user_query, logic_condition_params, include_osf_user_params): + query = '' + if len(logic_condition_query_string) > 0: + query += f""" + WITH userextendeddata AS ( + SELECT DISTINCT user_id + FROM osf_userextendeddata + WHERE {logic_condition_query_string} ) + """ + + query += """ + SELECT g._id AS guid, u.id, u.username, u.fullname, u.eppn + FROM osf_osfuser AS u + JOIN osf_guid AS g + ON u.id = g.object_id + AND g.content_type_id = 1 + JOIN osf_osfuser_affiliated_institutions AS ui + ON u.id = ui.osfuser_id + """ + if len(logic_condition_query_string) > 0: + query += ' JOIN userextendeddata ux ON u.id = ux.user_id' + query += """ + WHERE ui.institution_id = %s + {} + ORDER BY guid ASC + """ + + if len(include_osf_user_query) > 0: + include_osf_user_query = f' AND {include_osf_user_query}' + + # Format the query with the logic condition + formatted_query = query.format(include_osf_user_query) + + # Execute the raw query + params = logic_condition_params + [institution_id] + include_osf_user_params + with connection.cursor() as cursor: + cursor.execute(formatted_query, params) + user_list = cursor.fetchall() + keys = ['guid', 'id', 'username', 'fullname', 'eppn'] + user_list = [dict(zip(keys, t)) for t in user_list] + return user_list + + def create_csv_from_user_list(self, user_list): + """ Create CSV file from user list """ + response = HttpResponse(content_type='text/csv') + writer = csv.writer(response, quoting=csv.QUOTE_NONNUMERIC) + writer.writerow( + ['GUID', 'ePPN', 'Username', 'Fullname', 'Created Project Number', 'Project Limit Number'] + ) + + for user in user_list: + project_limit_number = user.get('project_limit_number') + writer.writerow([ + user.get('guid'), + user.get('eppn'), + user.get('username'), + user.get('fullname'), + user.get('created_project_number'), + project_limit_number if project_limit_number != utils.NO_LIMIT else 'No Limit' + ]) + + time_now = datetime.today().strftime('%Y%m%d%H%M%S') + response['Content-Disposition'] = f'attachment; filename=export_user_list_{time_now}.csv' + return response diff --git a/admin/templates/project_limit_number/create.html b/admin/templates/project_limit_number/create.html index 43250204151..5084c6cc5de 100644 --- a/admin/templates/project_limit_number/create.html +++ b/admin/templates/project_limit_number/create.html @@ -211,6 +211,7 @@

{% trans 'Project Limit Number Setting' %}({{ institution.name }}){% trans 'Cancel' %} + @@ -347,7 +348,7 @@

{% trans 'User List' %}

{% trans "GUID" %} - {% trans "EPPN" %} + {% trans "ePPN" %} {% trans "Username" %} @@ -372,6 +373,56 @@

{% trans 'User List' %}

}); } + function exportUserListCSV() { + let attributeList = []; + // Extract data from attribute section + $('.attribute-item').each(function (index, element) { + const attribute_name = element.querySelector('.attribute-name').value; + const setting_type = Number(element.querySelector('.setting-type').value, 10); + let attribute_value = element.querySelector('.attribute-value').value; + attributeList.push({ + attribute_name, + setting_type, + attribute_value + }); + }); + const data = { + 'institution_id': {{ institution.id }}, + 'attribute_list': attributeList + }; + $.ajax({ + url: '{% url "project_limit_number:settings:export_user_list_csv" %}', + type: 'post', + data: JSON.stringify(data), + contentType: 'application/json', + success: function (data, status, xhr) { + const contentType = xhr.getResponseHeader('Content-Type'); + if (contentType && contentType.includes('text/csv')) { + // Handle CSV response + const disposition = xhr.getResponseHeader('Content-Disposition'); + let filename = ''; + if (disposition && disposition.indexOf('filename=') !== -1) { + const split = disposition.split('filename='); + filename = split[split.length-1].replace(/['"]/g, ''); + } + + // Convert the text response to a blob + const blob = new Blob([data], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + } + } + }).fail(function (jqXHR, textStatus, error) { + handleAjaxRequestFailure(jqXHR); + }); + } + function changeTemplate() { sessionStorage.setItem('setting_name_input', $('#setting_name_id').val()); sessionStorage.setItem('memo_input', $('#memo_id').val()); diff --git a/admin/templates/project_limit_number/detail.html b/admin/templates/project_limit_number/detail.html index fabd38ef2f1..6d74f0f4a21 100644 --- a/admin/templates/project_limit_number/detail.html +++ b/admin/templates/project_limit_number/detail.html @@ -199,6 +199,7 @@

{% trans 'Project Limit Number Setting' %}({{ institution_name }}){% trans 'Cancel' %} + @@ -325,7 +326,7 @@

{% trans 'User List' %}

{% trans "GUID" %} - {% trans "EPPN" %} + {% trans "ePPN" %} {% trans "Username" %} @@ -350,6 +351,56 @@

{% trans 'User List' %}

}); } + function exportUserListCSV() { + let attributeList = []; + // Extract data from attribute section + $('.attribute-item').each(function (index, element) { + const attribute_name = element.querySelector('.attribute-name').value; + const setting_type = Number(element.querySelector('.setting-type').value, 10); + let attribute_value = element.querySelector('.attribute-value').value; + attributeList.push({ + attribute_name, + setting_type, + attribute_value + }); + }); + const data = { + 'institution_id': {{ institution_id }}, + 'attribute_list': attributeList + }; + $.ajax({ + url: '{% url "project_limit_number:settings:export_user_list_csv" %}', + type: 'post', + data: JSON.stringify(data), + contentType: 'application/json', + success: function (data, status, xhr) { + const contentType = xhr.getResponseHeader('Content-Type'); + if (contentType && contentType.includes('text/csv')) { + // Handle CSV response + const disposition = xhr.getResponseHeader('Content-Disposition'); + let filename = ''; + if (disposition && disposition.indexOf('filename=') !== -1) { + const split = disposition.split('filename='); + filename = split[split.length-1].replace(/['"]/g, ''); + } + + // Convert the text response to a blob + const blob = new Blob([data], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + } + } + }).fail(function (jqXHR, textStatus, error) { + handleAjaxRequestFailure(jqXHR); + }); + } + $('#updateProjectLimitNumberSettingForm').on('submit', function(event) { event.preventDefault(); const settingName = $('#setting_name_id').val(); diff --git a/admin/translations/django.pot b/admin/translations/django.pot index 3a7800decb7..34121e22bda 100644 --- a/admin/translations/django.pot +++ b/admin/translations/django.pot @@ -3803,6 +3803,12 @@ msgstr "" msgid "User List" msgstr "" +msgid "Export User List(CSV)" +msgstr "" + +msgid "ePPN" +msgstr "" + msgid "Project Limit Number Setting Template" msgstr "" diff --git a/admin/translations/en/LC_MESSAGES/django.po b/admin/translations/en/LC_MESSAGES/django.po index a1774d3366e..b48022ef3e1 100644 --- a/admin/translations/en/LC_MESSAGES/django.po +++ b/admin/translations/en/LC_MESSAGES/django.po @@ -3855,6 +3855,12 @@ msgstr "" msgid "User List" msgstr "" +msgid "Export User List(CSV)" +msgstr "" + +msgid "ePPN" +msgstr "" + msgid "Project Limit Number Setting Template" msgstr "" diff --git a/admin/translations/ja/LC_MESSAGES/django.po b/admin/translations/ja/LC_MESSAGES/django.po index d9918ddd0ec..f5c600c0eb7 100644 --- a/admin/translations/ja/LC_MESSAGES/django.po +++ b/admin/translations/ja/LC_MESSAGES/django.po @@ -3895,6 +3895,12 @@ msgstr "設定" msgid "User List" msgstr "ユーザー一覧" +msgid "Export User List(CSV)" +msgstr "ユーザー一覧をエクスポート(CSV)" + +msgid "ePPN" +msgstr "ePPN" + msgid "Project Limit Number Setting Template" msgstr "プロジェクト作成数設定テンプレート" From 1142a3f85709fe71cb313ac87701532ce05bce7b Mon Sep 17 00:00:00 2001 From: huanphan-tma Date: Wed, 23 Apr 2025 15:19:22 +0700 Subject: [PATCH 2/3] =?UTF-8?q?ref=202.4.=E3=83=A6=E3=83=BC=E3=82=B6?= =?UTF-8?q?=E4=B8=80=E8=A6=A7=E3=81=AECSV=E5=87=BA=E5=8A=9B:=20Add=20UT=20?= =?UTF-8?q?test=20case?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../setting/test_views.py | 690 +++++++++++++++++- 1 file changed, 689 insertions(+), 1 deletion(-) diff --git a/admin_tests/project_limit_number/setting/test_views.py b/admin_tests/project_limit_number/setting/test_views.py index 5090f3b5d55..1ffa44a28ab 100644 --- a/admin_tests/project_limit_number/setting/test_views.py +++ b/admin_tests/project_limit_number/setting/test_views.py @@ -1,6 +1,8 @@ +import csv import json import time from http import HTTPStatus +from io import StringIO from unittest.mock import patch from django.db import DatabaseError @@ -23,7 +25,8 @@ ProjectLimitNumberSettingCreateView, DeleteProjectLimitNumberSettingView, UpdateProjectLimitNumberSettingView, - UserListView + UserListView, + ExportUserListCSVView, ) from osf.models import ( UserExtendedData, @@ -3370,3 +3373,688 @@ def test_get_user_list_with_both_conditions(self): [self.users[0].username] ) self.assertEqual(len(user_list), 1) + + +class TestExportUserListCSVView(AdminTestCase): + def setUp(self): + """Set up test data for all test methods""" + self.request_factory = RequestFactory() + + # Create institution + self.institution = InstitutionFactory() + + # Create super admin user + self.super_admin = AuthUserFactory() + self.super_admin.is_staff = True + self.super_admin.is_superuser = True + self.super_admin.save() + + # Create institution admin user + self.institution_admin = AuthUserFactory() + self.institution_admin.is_staff = True + self.institution_admin.save() + + # Create template + self.template = ProjectLimitNumberTemplate.objects.create( + template_name='Test Template', + is_availability=True, + is_deleted=False + ) + + # Create template attributes + self.template_attribute1 = ProjectLimitNumberTemplateAttribute.objects.create( + template=self.template, + attribute_name='displayName', + setting_type=5, + attribute_value='displayName1, displayName2, displayName3', + is_deleted=False + ) + + self.template_attribute2 = ProjectLimitNumberTemplateAttribute.objects.create( + template=self.template, + attribute_name='jaDisplayName', + setting_type=5, + attribute_value='jaDisplayName1, jaDisplayName2, jaDisplayName3', + is_deleted=False + ) + + self.fixed_template_attribute = ProjectLimitNumberTemplateAttribute.objects.create( + template=self.template, + attribute_name='sn', + setting_type=3, + attribute_value='fixed_value', + is_deleted=False + ) + + # Create users + self.users = [AuthUserFactory(username=f'user{item}@test.com') for item in range(5)] + + self.projects = [] + # Affiliate users with institution and add extended data + for i, user in enumerate(self.users): + user.affiliated_institutions.add(self.institution) + data = { + 'idp_attr': { + 'fullname': f'displayName{i + 1}', + 'fullname_ja': f'jaDisplayName{i + 1}', + 'family_name': 'fixed_value' + } + } + UserExtendedData.objects.create( + user=user, + data=data + ) + + # Create project limit number settings with different conditions + self.setting1 = ProjectLimitNumberSetting.objects.create( + institution=self.institution, + template=self.template, + name='Setting 1', + project_limit_number=5, + priority=1, + is_availability=True, + is_deleted=False + ) + + self.setting2 = ProjectLimitNumberSetting.objects.create( + institution=self.institution, + template=self.template, + name='Setting 2', + project_limit_number=10, + priority=2, + is_availability=True, + is_deleted=False + ) + + # Create setting attributes for different conditions + self.setting1_attribute1 = ProjectLimitNumberSettingAttribute.objects.create( + setting=self.setting1, + attribute=self.template_attribute1, + attribute_value='displayName1', + is_deleted=False + ) + + self.setting1_attribute2 = ProjectLimitNumberSettingAttribute.objects.create( + setting=self.setting1, + attribute=self.template_attribute2, + attribute_value='jaDisplayName1', + is_deleted=False + ) + + self.setting2_attribute1 = ProjectLimitNumberSettingAttribute.objects.create( + setting=self.setting2, + attribute=self.template_attribute1, + attribute_value='displayName2', + is_deleted=False + ) + + self.setting2_attribute2 = ProjectLimitNumberSettingAttribute.objects.create( + setting=self.setting2, + attribute=self.fixed_template_attribute, + attribute_value='fixed_value', + is_deleted=False + ) + + # Create default project limit number + self.default_limit = ProjectLimitNumberDefault.objects.create( + institution=self.institution, + project_limit_number=3 + ) + + # Create base URL + self.base_url = '/project_limit_number/settings/export_user_list_csv/' + + # Create view class + self.view = ExportUserListCSVView() + self.view.kwargs = {} + + # Add valid request data + self.valid_data = { + 'institution_id': self.institution.id, + 'attribute_list': [ + { + 'attribute_name': 'displayName', + 'setting_type': 1, + 'attribute_value': 'displayName1' + } + ] + } + + self.csv_header = ['GUID', 'ePPN', 'Username', 'Fullname', 'Created Project Number', 'Project Limit Number'] + + def parse_to_csv(self, response): + """Parse response to CSV format""" + # Convert response content to CSV format + csv_content = response.content.decode('utf-8') + csv_reader = csv.reader(StringIO(csv_content)) + # Convert to list to compare contents + return list(csv_reader) + + def test_permission_unauthenticated(self): + """Test access with unauthenticated user""" + request = self.request_factory.get(self.base_url) + request.user = AnonymousUser() + self.view = setup_view(self.view, request) + + # Assert test_func + self.assertFalse(self.view.test_func()) + self.assertFalse(self.view.raise_exception) + + # Assert handle_no_permission + response = self.view.handle_no_permission() + self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED) + self.assertEqual( + json.loads(response.content), + {'error_message': 'Authentication credentials were not provided.'} + ) + + def test_permission_super_admin(self): + """Test access with super admin""" + request = self.request_factory.get(self.base_url) + request.user = self.super_admin + self.view = setup_view(self.view, request) + + self.assertTrue(self.view.test_func()) + + def test_permission_institution_admin(self): + """Test access with institutional admin""" + self.institution_admin.affiliated_institutions.add(self.institution) + request = self.request_factory.get(self.base_url) + request.user = self.institution_admin + self.view = setup_view(self.view, request) + + self.assertTrue(self.view.test_func()) + + def test_permission_user(self): + """Test access with user""" + request = self.request_factory.get(self.base_url) + request.user = AuthUserFactory() + self.view = setup_view(self.view, request) + + self.assertFalse(self.view.test_func()) + self.assertTrue(self.view.raise_exception) + + with self.assertRaises(Exception): + self.view.handle_no_permission() + + def test_post_invalid_json_body(self): + """Test with invalid JSON in request body""" + request = self.request_factory.post( + self.base_url, + data='invalid json', + content_type='application/json' + ) + request.user = self.super_admin + self.view = setup_view(self.view, request) + + response = self.view.post(request) + self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) + + @patch('admin.project_limit_number.utils.validate_file_json') + def test_post_invalid_schema(self, mock_validate): + """Test with invalid schema""" + mock_validate.return_value = (False, 'Schema validation error') + + request = self.request_factory.post( + self.base_url, + data=json.dumps(self.valid_data), + content_type='application/json' + ) + request.user = self.super_admin + self.view = setup_view(self.view, request) + + response = self.view.post(request) + self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) + self.assertEqual( + json.loads(response.content), + {'error_message': 'Schema validation error'} + ) + + def test_post_nonexistent_institution(self): + """Test with non-existent institution""" + data = self.valid_data.copy() + data['institution_id'] = -1 + + request = self.request_factory.post( + self.base_url, + data=json.dumps(data), + content_type='application/json' + ) + request.user = self.super_admin + self.view = setup_view(self.view, request) + + response = self.view.post(request) + self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) + self.assertEqual( + json.loads(response.content), + {'error_message': 'The institution not exist.'} + ) + + def test_post_institution_admin_wrong_institution(self): + """Test institution admin accessing wrong institution""" + other_institution = InstitutionFactory() + self.institution_admin.affiliated_institutions.add(other_institution) + + request = self.request_factory.post( + self.base_url, + data=json.dumps(self.valid_data), + content_type='application/json' + ) + request.user = self.institution_admin + self.view = setup_view(self.view, request) + + response = self.view.post(request) + self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) + self.assertEqual( + json.loads(response.content), + {'error_message': 'Forbidden'} + ) + + def test_post_invalid_attribute_name(self): + """Test with invalid attribute name""" + data = self.valid_data.copy() + data['attribute_list'] = [{ + 'attribute_name': 'invalid_attribute', + 'setting_type': 1, + 'attribute_value': 'displayName1' + }] + + request = self.request_factory.post( + self.base_url, + data=json.dumps(data), + content_type='application/json' + ) + request.user = self.super_admin + self.view = setup_view(self.view, request) + response = self.view.post(request) + self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) + self.assertEqual( + json.loads(response.content), + {'error_message': 'attribute_name is invalid.'} + ) + + def test_post_mail_grdm_attribute(self): + """Test with mail grdm attribute""" + data = self.valid_data.copy() + data['attribute_list'] = [{ + 'attribute_name': utils.MAIL_GRDM, + 'setting_type': 2, + 'attribute_value': '@test.com' + }, { + 'attribute_name': utils.MAIL_GRDM, + 'setting_type': 1, + 'attribute_value': 'example1@test.com' + }] + + request = self.request_factory.post( + self.base_url, + data=json.dumps(data), + content_type='application/json' + ) + request.user = self.super_admin + self.view = setup_view(self.view, request) + response = self.view.post(request) + self.assertEqual(response.status_code, HTTPStatus.OK) + + def test_post_multiple_attribute(self): + """Test with multiple attributes""" + data = self.valid_data.copy() + data['attribute_list'] = [{ + 'attribute_name': 'displayName', + 'setting_type': 1, + 'attribute_value': 'displayName1' + }, { + 'attribute_name': 'jaDisplayName', + 'setting_type': 1, + 'attribute_value': 'jaDisplayName1' + }] + + request = self.request_factory.post( + self.base_url, + data=json.dumps(data), + content_type='application/json' + ) + request.user = self.super_admin + self.view = setup_view(self.view, request) + response = self.view.post(request) + self.assertEqual(response.status_code, HTTPStatus.OK) + + def test_post_empty_user_list(self): + """Test when no users match the criteria""" + data = self.valid_data.copy() + data['attribute_list'] = [{ + 'attribute_name': 'displayName', + 'setting_type': 1, + 'attribute_value': 'no_match_value' + }] + + request = self.request_factory.post( + self.base_url, + data=json.dumps(data), + content_type='application/json' + ) + request.user = self.super_admin + self.view = setup_view(self.view, request) + response = self.view.post(request) + self.assertEqual(response.status_code, HTTPStatus.OK) + + csv_rows = self.parse_to_csv(response) + self.assertEqual(len(csv_rows), 1) + self.assertEqual(csv_rows[0], self.csv_header) + + def test_post_response_with_settings(self): + """Test successful response including project limit settings""" + request = self.request_factory.post( + self.base_url, + data=json.dumps(self.valid_data), + content_type='application/json' + ) + request.user = self.super_admin + self.view = setup_view(self.view, request) + response = self.view.post(request) + self.assertEqual(response.status_code, HTTPStatus.OK) + csv_rows = self.parse_to_csv(response) + self.assertEqual(len(csv_rows), 2) + first_row = csv_rows[1] + self.assertEqual(first_row[0], self.users[0]._id) + self.assertEqual(first_row[-1], str(5)) + + def test_post_user_meets_first_condition(self): + """Test when user meets first setting's condition""" + with patch('admin.project_limit_number.utils.check_logic_condition') as mock_check: + mock_check.side_effect = [True, False] # Meets first condition, not second + + request = self.request_factory.post( + self.base_url, + data=json.dumps(self.valid_data), + content_type='application/json' + ) + request.user = self.super_admin + self.view = setup_view(self.view, request) + + response = self.view.post(request) + self.assertEqual(response.status_code, HTTPStatus.OK) + + csv_rows = self.parse_to_csv(response) + self.assertNotEqual(len(csv_rows), 1) + # Remove header + csv_rows.pop(0) + for row in csv_rows: + self.assertEqual(row[-1], str(5)) + + def test_post_user_meets_second_condition(self): + """Test when user meets second setting's condition""" + with patch('admin.project_limit_number.utils.check_logic_condition') as mock_check: + mock_check.side_effect = [False, True] # Doesn't meet first, meets second + + request = self.request_factory.post( + self.base_url, + data=json.dumps(self.valid_data), + content_type='application/json' + ) + request.user = self.super_admin + self.view = setup_view(self.view, request) + + response = self.view.post(request) + self.assertEqual(response.status_code, HTTPStatus.OK) + + csv_rows = self.parse_to_csv(response) + self.assertNotEqual(len(csv_rows), 1) + csv_rows.pop(0) + for row in csv_rows: + self.assertEqual(row[-1], str(10)) + + def test_post_user_meets_no_conditions(self): + """Test when user meets no conditions and gets default limit""" + with patch('admin.project_limit_number.utils.check_logic_condition') as mock_check: + mock_check.return_value = False # Meets no conditions + + request = self.request_factory.post( + self.base_url, + data=json.dumps(self.valid_data), + content_type='application/json' + ) + request.user = self.super_admin + self.view = setup_view(self.view, request) + + response = self.view.post(request) + self.assertEqual(response.status_code, HTTPStatus.OK) + + csv_rows = self.parse_to_csv(response) + self.assertNotEqual(len(csv_rows), 1) + csv_rows.pop(0) + for row in csv_rows: + self.assertEqual(row[-1], str(3)) + + def test_post_no_default_limit_configured(self): + """Test when no project default limit number is configured""" + # Delete default limit + self.default_limit.delete() + + with patch('admin.project_limit_number.utils.check_logic_condition') as mock_check: + mock_check.return_value = False # Meets no conditions + + request = self.request_factory.post( + self.base_url, + data=json.dumps(self.valid_data), + content_type='application/json' + ) + request.user = self.super_admin + self.view = setup_view(self.view, request) + + response = self.view.post(request) + self.assertEqual(response.status_code, HTTPStatus.OK) + + csv_rows = self.parse_to_csv(response) + self.assertNotEqual(len(csv_rows), 1) + csv_rows.pop(0) + for row in csv_rows: + self.assertEqual(row[-1], 'No Limit') + + def test_post_project_count_calculation(self): + """Test correct calculation of created project numbers""" + for i, user in enumerate(self.users): + self.projects.append(ProjectFactory(creator=user, is_deleted=False)) + request = self.request_factory.post( + self.base_url, + data=json.dumps(self.valid_data), + content_type='application/json' + ) + request.user = self.super_admin + self.view = setup_view(self.view, request) + + response = self.view.post(request) + self.assertEqual(response.status_code, HTTPStatus.OK) + + csv_rows = self.parse_to_csv(response) + self.assertNotEqual(len(csv_rows), 1) + csv_rows.pop(0) + for row in csv_rows: + user_projects = len([p for p in self.projects + if p.creator._id == row[0]]) + self.assertEqual(row[-2], str(user_projects)) + + def test_post_empty_setting_attributes(self): + """Test handling of settings with no attributes""" + # Create setting without attributes + ProjectLimitNumberSetting.objects.create( + institution=self.institution, + template=self.template, + name='Empty Setting', + project_limit_number=7, + priority=2, + is_availability=True, + is_deleted=False + ) + + request = self.request_factory.post( + self.base_url, + data=json.dumps(self.valid_data), + content_type='application/json' + ) + request.user = self.super_admin + self.view = setup_view(self.view, request) + + response = self.view.post(request) + self.assertEqual(response.status_code, HTTPStatus.OK) + + def test_post_deleted_setting_attributes(self): + """Test handling of deleted setting attributes""" + # Mark attribute as deleted + self.setting1_attribute1.is_deleted = True + self.setting1_attribute1.save() + + request = self.request_factory.post( + self.base_url, + data=json.dumps(self.valid_data), + content_type='application/json' + ) + request.user = self.super_admin + self.view = setup_view(self.view, request) + + response = self.view.post(request) + self.assertEqual(response.status_code, HTTPStatus.OK) + + def test_post_multiple_conditions_same_user(self): + """Test when user meets multiple conditions""" + with patch('admin.project_limit_number.utils.check_logic_condition') as mock_check: + mock_check.return_value = True # Meets all conditions + + request = self.request_factory.post( + self.base_url, + data=json.dumps(self.valid_data), + content_type='application/json' + ) + request.user = self.super_admin + self.view = setup_view(self.view, request) + + response = self.view.post(request) + self.assertEqual(response.status_code, HTTPStatus.OK) + + csv_rows = self.parse_to_csv(response) + self.assertNotEqual(len(csv_rows), 1) + csv_rows.pop(0) + for row in csv_rows: + self.assertEqual(row[-1], str(5)) + def test_get_user_list(self): + """Test get user list function""" + user_list = self.view.get_user_list_met_logic_condition( + self.institution.id, + '', + '', + [], + [] + ) + self.assertEqual(len(user_list), 5) + for user in user_list: + self.assertIn('guid', user) + self.assertIn('username', user) + self.assertIn('fullname', user) + self.assertIn('eppn', user) + + def test_get_user_list_with_logic_condition(self): + """Test get user list function with logic condition""" + logic_condition = "data -> 'idp_attr' ->> 'fullname' = %s" + user_list = self.view.get_user_list_met_logic_condition( + self.institution.id, + logic_condition, + '', + ['displayName1'], + [] + ) + self.assertEqual(len(user_list), 1) + + def test_get_user_list_with_osf_query(self): + """Test get user list function with OSF user query""" + include_osf_query = f'u.username = %s' + user_list = self.view.get_user_list_met_logic_condition( + self.institution.id, + '', + include_osf_query, + [], + [self.users[0].username] + ) + self.assertEqual(len(user_list), 1) + + def test_get_user_list_with_multiple_osf_query(self): + """Test get user list function with multiple OSF user queries""" + include_osf_query = f'u.username = %s AND u.username = %s' + user_list = self.view.get_user_list_met_logic_condition( + self.institution.id, + '', + include_osf_query, + [], + [self.users[0].username, self.users[1].username] + ) + self.assertEqual(len(user_list), 0) + + def test_get_user_list_with_both_conditions(self): + """Test get user list function with both logic condition and OSF query""" + logic_condition = "data -> 'idp_attr' ->> 'fullname' = %s" + include_osf_query = f'u.username = %s' + + user_list = self.view.get_user_list_met_logic_condition( + self.institution.id, + logic_condition, + include_osf_query, + ['displayName1'], + [self.users[0].username] + ) + self.assertEqual(len(user_list), 1) + + def test_create_csv_from_user_list(self): + """Test create CSV data from user list""" + test_users = [{ + 'guid': 'abc123', + 'username': 'user1', + 'fullname': 'Test User 1', + 'eppn': 'user1@test.com', + 'created_project_number': 5, + 'project_limit_number': 10 + }, { + 'guid': 'def456', + 'username': 'user2', + 'fullname': 'Test User 2', + 'eppn': 'user2@test.com', + 'created_project_number': 3, + 'project_limit_number': -1 + }] + + # Generate CSV response + response = self.view.create_csv_from_user_list(test_users) + + # Assert content type + self.assertEqual(response['Content-Type'], 'text/csv') + + # Assert Content-Disposition + self.assertIn('attachment; filename=', response['Content-Disposition']) + + # Convert response content to readable format + content = response.content.decode('utf-8') + csv_reader = csv.reader(StringIO(content)) + rows = list(csv_reader) + + # Assert header row + self.assertEqual(rows[0], self.csv_header) + + # Assert data rows + self.assertEqual(rows[1], [ + 'abc123', + 'user1@test.com', + 'user1', + 'Test User 1', + '5', + '10' + ]) + + self.assertEqual(rows[2], [ + 'def456', + 'user2@test.com', + 'user2', + 'Test User 2', + '3', + 'No Limit' + ]) + + # Assert total number of rows + self.assertEqual(len(rows), 3) From 500ebe96f44ca9c4300c6d7802df3ff7e684a0bc Mon Sep 17 00:00:00 2001 From: huanphan-tma Date: Thu, 8 May 2025 18:24:04 +0700 Subject: [PATCH 3/3] =?UTF-8?q?ref=202.4.=E3=83=A6=E3=83=BC=E3=82=B6?= =?UTF-8?q?=E4=B8=80=E8=A6=A7=E3=81=AECSV=E5=87=BA=E5=8A=9B:=20Fix=20broke?= =?UTF-8?q?n=20layout=20in=20Create/Update=20Project=20Limit=20Number=20Se?= =?UTF-8?q?tting=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/templates/project_limit_number/create.html | 4 ++++ admin/templates/project_limit_number/detail.html | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/admin/templates/project_limit_number/create.html b/admin/templates/project_limit_number/create.html index 5084c6cc5de..aa91617c7f4 100644 --- a/admin/templates/project_limit_number/create.html +++ b/admin/templates/project_limit_number/create.html @@ -91,6 +91,10 @@ .disabled { pointer-events: none; } + + #createProjectLimitNumberSettingForm { + overflow: auto; + } {% endblock %} diff --git a/admin/templates/project_limit_number/detail.html b/admin/templates/project_limit_number/detail.html index 6d74f0f4a21..65f2b98fcf8 100644 --- a/admin/templates/project_limit_number/detail.html +++ b/admin/templates/project_limit_number/detail.html @@ -81,6 +81,10 @@ .disabled { pointer-events: none; } + + #updateProjectLimitNumberSettingForm { + overflow: auto; + } {% endblock %} @@ -94,7 +98,7 @@

{% trans 'Project Limit Number Setting' %}({{ institution_name }})

{% csrf_token %} -
+