From cd368148c47698c3de364cb74ee5deb0c7ec1a16 Mon Sep 17 00:00:00 2001 From: hcphat Date: Tue, 16 Dec 2025 11:01:50 +0700 Subject: [PATCH 1/7] =?UTF-8?q?ref:=202.3.=E3=82=B0=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=97=E7=AE=A1=E7=90=86=E9=80=A3=E6=90=BA=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E9=96=8B=E7=99=BA:=20commit=20source=20code=20and=20UT=20imple?= =?UTF-8?q?ment=20mapcore=20group=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/base/pagination.py | 3 + api/base/settings/defaults.py | 2 + api/base/urls.py | 1 + api/institutions/authentication.py | 55 + api/logs/serializers.py | 25 + api/mapcore/serializers.py | 28 + api/mapcore/urls.py | 12 + api/mapcore/views.py | 46 + api/nodes/serializers.py | 447 ++++++- api/nodes/urls.py | 2 + api/nodes/views.py | 309 ++++- api/users/serializers.py | 8 + api/users/views.py | 3 +- .../test_authentication_mapcore.py | 415 ++++++ .../mapcore/serializers/test_serializer.py | 29 + .../mapcore/views/test_mapcore_group_list.py | 51 + .../test_mapcore_group_serializers.py | 1167 +++++++++++++++++ .../views/test_node_mapcore_group_views.py | 890 +++++++++++++ .../users/serializers/test_serializers.py | 40 + ...group_mapcorenodegroup_mapcoreusergroup.py | 64 + osf/migrations/0262_auto_20260202_0643.py | 24 + osf/models/mapcore_group.py | 14 + osf/models/mapcore_node_group.py | 35 + osf/models/mapcore_user_group.py | 12 + osf/models/mixins.py | 67 +- osf/models/node.py | 34 +- osf/models/nodelog.py | 7 + osf/models/user.py | 3 +- osf_tests/test_mapcore_group.py | 129 ++ osf_tests/test_mapcore_node_group.py | 40 + tests/test_node_groups_view.py | 97 ++ tests/test_serializers.py | 139 +- website/profile/utils.py | 59 + website/project/views/node.py | 17 + website/routes.py | 10 + website/settings/defaults.py | 5 + website/static/js/addProjectPlugin.js | 2 +- .../static/js/anonymousLogActionsList.json | 6 + website/static/js/groupsAdder.js | 517 ++++++++ website/static/js/groupsManager.js | 527 ++++++++ website/static/js/groupsRemover.js | 239 ++++ website/static/js/logActionsList.json | 6 + website/static/js/logActionsList_extract.js | 7 + website/static/js/logTextParser.js | 44 +- website/static/js/myProjects.js | 5 +- website/static/js/pages/sharing-page.js | 27 +- website/static/js/project-organizer.js | 37 +- website/static/js/project.js | 7 + .../static/js/projectSettingsTreebeardBase.js | 3 +- website/templates/project/groups.mako | 298 +++++ .../templates/project/modal_add_group.mako | 220 ++++ .../templates/project/modal_remove_group.mako | 126 ++ website/templates/project/project.mako | 16 + website/templates/project/project_header.mako | 4 + website/templates/util/group_list.mako | 30 + website/templates/util/render_node.mako | 4 + .../en/LC_MESSAGES/js_messages.po | 72 +- .../translations/en/LC_MESSAGES/messages.po | 81 +- .../ja/LC_MESSAGES/js_messages.po | 74 +- .../translations/ja/LC_MESSAGES/messages.po | 83 ++ website/translations/js_messages.pot | 72 +- website/translations/messages.pot | 78 ++ website/views.py | 35 + 63 files changed, 6885 insertions(+), 24 deletions(-) create mode 100644 api/mapcore/serializers.py create mode 100644 api/mapcore/urls.py create mode 100644 api/mapcore/views.py create mode 100644 api_tests/institutions/test_authentication_mapcore.py create mode 100644 api_tests/mapcore/serializers/test_serializer.py create mode 100644 api_tests/mapcore/views/test_mapcore_group_list.py create mode 100644 api_tests/nodes/serializers/test_mapcore_group_serializers.py create mode 100644 api_tests/nodes/views/test_node_mapcore_group_views.py create mode 100644 osf/migrations/0261_mapcoregroup_mapcorenodegroup_mapcoreusergroup.py create mode 100644 osf/migrations/0262_auto_20260202_0643.py create mode 100644 osf/models/mapcore_group.py create mode 100644 osf/models/mapcore_node_group.py create mode 100644 osf/models/mapcore_user_group.py create mode 100644 osf_tests/test_mapcore_group.py create mode 100644 osf_tests/test_mapcore_node_group.py create mode 100644 tests/test_node_groups_view.py create mode 100644 website/static/js/groupsAdder.js create mode 100644 website/static/js/groupsManager.js create mode 100644 website/static/js/groupsRemover.js create mode 100644 website/templates/project/groups.mako create mode 100644 website/templates/project/modal_add_group.mako create mode 100644 website/templates/project/modal_remove_group.mako create mode 100644 website/templates/util/group_list.mako diff --git a/api/base/pagination.py b/api/base/pagination.py index 0248a37d752..c8427b76ca1 100644 --- a/api/base/pagination.py +++ b/api/base/pagination.py @@ -477,3 +477,6 @@ def get_response_dict_deprecated(self, data, url): ]), ), ]) + +class MapCoreGroupPagination(JSONAPIPagination): + page_size = 5 diff --git a/api/base/settings/defaults.py b/api/base/settings/defaults.py index 781dbd6fb82..bd519626617 100644 --- a/api/base/settings/defaults.py +++ b/api/base/settings/defaults.py @@ -501,3 +501,5 @@ BASE_FOR_METRIC_PREFIX = 1000 SIZE_UNIT_GB = BASE_FOR_METRIC_PREFIX ** 3 NII_STORAGE_REGION_ID = 1 + +MAP_GATEWAY_ISMEMBEROF_PREFIX = osf_settings.MAP_GATEWAY_ISMEMBEROF_PREFIX diff --git a/api/base/urls.py b/api/base/urls.py index 0fc32825291..64a44621d19 100644 --- a/api/base/urls.py +++ b/api/base/urls.py @@ -73,6 +73,7 @@ url(r'^view_only_links/', include('api.view_only_links.urls', namespace='view-only-links')), url(r'^wikis/', include('api.wikis.urls', namespace='wikis')), url(r'^_waffle/', include(('api.waffle.urls', 'waffle'), namespace='waffle')), + url(r'^map_core/', include('api.mapcore.urls', namespace='mapcore')), ], ), ), diff --git a/api/institutions/authentication.py b/api/institutions/authentication.py index 4a6e5e8217e..d5904970197 100644 --- a/api/institutions/authentication.py +++ b/api/institutions/authentication.py @@ -1,9 +1,12 @@ import json +from urllib.parse import unquote import uuid import logging import jwe import jwt +from osf.models.mapcore_group import MapCoreGroup +from osf.models.mapcore_user_group import MapCoreUserGroup import waffle #from django.utils import timezone @@ -23,6 +26,8 @@ from website.mails import send_mail, WELCOME_OSF4I from website.settings import OSF_SUPPORT_EMAIL, DOMAIN, to_bool from website.util.quota import update_default_storage +from django_bulk_update.helper import bulk_update +from django.utils import timezone logger = logging.getLogger(__name__) @@ -466,6 +471,7 @@ def get_next(obj, *args): # update every login. (for mAP API v1) init_cloud_gateway_groups(user, provider) + update_mapcore_groups(user, provider) return user, None @@ -521,3 +527,52 @@ def init_cloud_gateway_groups(user, provider): else: user.add_group(groupname) user.save() + +def update_mapcore_groups(user, provider): + prefix = settings.MAP_GATEWAY_ISMEMBEROF_PREFIX + if not prefix: + return + groups_str = provider['user'].get('groups', '') + groups_error = provider['user'].get('groupsError') + # if get mapcore groups error, do not update groups. + if not groups_str and groups_error: + try: + groups_error = unquote(groups_error) + logger.warning('MAP Core groups retrieval error for user {}: {}'.format(user.username, groups_error)) + except Exception: + logger.warning('Failed to URL-decode groups_error: %s', groups_error) + return + import re + patt_prefix = re.compile('^' + prefix) + patt_admin = re.compile('(.+)/admin$') + groups_str_set = set() + for group in groups_str.split(';'): + if patt_prefix.match(group): + groupname = patt_prefix.sub('', group) + if groupname is None or groupname == '': + continue + m = patt_admin.search(groupname) + if m: # is admin + groups_str_set.add(m.group(1)) + else: + groups_str_set.add(groupname) + mapcore_user_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + to_delete = [] + for mapcore_user_group in mapcore_user_groups: + groupname = mapcore_user_group.mapcore_group._id + if groupname not in groups_str_set: + mapcore_user_group.is_deleted = True + mapcore_user_group.modified = timezone.now() + to_delete.append(mapcore_user_group) + else: + # keep + groups_str_set.remove(groupname) + if to_delete: + bulk_update(to_delete, update_fields=['is_deleted', 'modified']) + # add new groups + for groupname in groups_str_set: + mapcore_group, created = MapCoreGroup.objects.get_or_create(_id=groupname) + MapCoreUserGroup.objects.create( + user=user, + mapcore_group=mapcore_group, + ) diff --git a/api/logs/serializers.py b/api/logs/serializers.py index 1a7aea4e67c..c0cd5876950 100644 --- a/api/logs/serializers.py +++ b/api/logs/serializers.py @@ -1,4 +1,5 @@ from past.builtins import basestring +from osf.models.mapcore_group import MapCoreGroup from rest_framework import serializers as ser from addons.osfstorage.models import Region @@ -101,6 +102,7 @@ class NodeLogParamsSerializer(RestrictedDictSerializer): institution = NodeLogInstitutionSerializer(read_only=True) anonymous_link = ser.BooleanField(read_only=True) file_format = ser.CharField(read_only=True) + mapcore_groups = ser.SerializerMethodField(read_only=True) def get_view_url(self, obj): urls = obj.get('urls', None) @@ -225,6 +227,29 @@ def get_storage_name(self, obj): return 'Institutional Storage' return None + def get_mapcore_groups(self, obj): + mapcore_group_info = [] + + if is_anonymized(self.context['request']): + return mapcore_group_info + + mapcore_group_data = obj.get('mapcore_groups', None) + + if mapcore_group_data: + mapcore_group_ids = [each for each in mapcore_group_data if isinstance(each, int)] + mapcore_groups = ( + MapCoreGroup.objects.filter(id__in=mapcore_group_ids) + .only('id', '_id') + .order_by('_id') + ) + for mapcore_group in mapcore_groups: + mapcore_group_info.append({ + 'id': mapcore_group.id, + 'name': mapcore_group._id, + }) + return mapcore_group_info + + class NodeLogSerializer(JSONAPISerializer): filterable_fields = frozenset(['action', 'date', 'user']) diff --git a/api/mapcore/serializers.py b/api/mapcore/serializers.py new file mode 100644 index 00000000000..a234e6bd58e --- /dev/null +++ b/api/mapcore/serializers.py @@ -0,0 +1,28 @@ +from django.apps import apps +from rest_framework import serializers as ser +from api.base.serializers import JSONAPISerializer, LinksField, TypeField, VersionedDateTimeField +from website.settings import MAPCORE_GROUP_HOSTNAME, MAPCORE_GROUP_API_PATH + + +MapCoreGroup = apps.get_model('osf.MapCoreGroup') + +class MapCoreGroupSerializer(JSONAPISerializer): + """ + JSONAPI serializer for MapCoreGroup model. + Keep fields minimal — expand if the model exposes more attributes that should be surfaced. + """ + id = ser.IntegerField(read_only=True) + mapcore_group_id = ser.IntegerField(source='id', read_only=True) + name = ser.CharField(source='_id', read_only=True) + created = VersionedDateTimeField(read_only=True) + modified = VersionedDateTimeField(read_only=True) + links = LinksField({ + 'self': 'get_absolute_url', + }) + type = TypeField() + + class Meta: + type_ = 'mapcore-groups' + + def get_absolute_url(self, obj): + return f'{MAPCORE_GROUP_HOSTNAME}{MAPCORE_GROUP_API_PATH}{obj._id}/' diff --git a/api/mapcore/urls.py b/api/mapcore/urls.py new file mode 100644 index 00000000000..ef236dc7ed7 --- /dev/null +++ b/api/mapcore/urls.py @@ -0,0 +1,12 @@ +from django.conf.urls import url + +from api.mapcore import views + +app_name = 'osf' + +urlpatterns = [ + # Examples: + # url(r'^$', 'api.views.home', name='home'), + # url(r'^blog/', include('blog.urls')), + url(r'^groups/$', views.MapCoreGroupList.as_view(), name=views.MapCoreGroupList.view_name), +] diff --git a/api/mapcore/views.py b/api/mapcore/views.py new file mode 100644 index 00000000000..9d4354ac939 --- /dev/null +++ b/api/mapcore/views.py @@ -0,0 +1,46 @@ +from django.apps import apps +from rest_framework import generics +from rest_framework import permissions as drf_permissions +from api.base.views import JSONAPIBaseView +from framework.auth.oauth_scopes import CoreScopes +from api.mapcore.serializers import MapCoreGroupSerializer +from api.base import permissions as base_permissions +from api.base.utils import get_user_auth +from api.base.pagination import MapCoreGroupPagination + + +class MapCoreGroupList(JSONAPIBaseView, generics.ListAPIView): + """ + List of MapCoreGroups + """ + permission_classes = ( + drf_permissions.IsAuthenticated, + base_permissions.TokenHasScope, + ) + required_read_scopes = [CoreScopes.NODE_CONTRIBUTORS_READ] + model_class = apps.get_model('osf.MapCoreGroup') + + serializer_class = MapCoreGroupSerializer + view_category = 'mapcore_groups' + view_name = 'mapcore-group-list' + + ordering = ('_id', ) # default ordering + pagination_class = MapCoreGroupPagination + + def get_queryset(self): + auth = get_user_auth(self.request) + if not auth or not auth.user or not auth.user.is_authenticated: + return self.model_class.objects.none() + + qs = self.model_class.objects.filter(mapcore_user_groups__user=auth.user, mapcore_user_groups__is_deleted=False) + q = self.request.GET.get('search') or self.request.query_params.get('search') + if q: + q = q.strip() + if q: + qs = qs.filter(_id__icontains=q) + + return qs + + def get(self, request, *args, **kwargs): + result = super().get(request, *args, **kwargs) + return result diff --git a/api/nodes/serializers.py b/api/nodes/serializers.py index dcf73d2c5fc..19e8513dcbd 100644 --- a/api/nodes/serializers.py +++ b/api/nodes/serializers.py @@ -1,5 +1,8 @@ from django.db import connection from distutils.version import StrictVersion +from django.db import transaction +from django.utils import timezone +from django.db.models import Max from api.base.exceptions import ( Conflict, EndpointNotImplementedError, @@ -29,6 +32,8 @@ from framework.auth.core import Auth from framework.exceptions import PermissionsError from osf.models import Tag +from osf.models.mapcore_group import MapCoreGroup +from osf.models.mapcore_node_group import MapCoreNodeGroup from rest_framework import serializers as ser from rest_framework import exceptions from addons.base.exceptions import InvalidAuthError, InvalidFolderError @@ -45,7 +50,7 @@ from osf.utils import permissions as osf_permissions from api.base import settings as api_settings from website import settings as website_settings - +from django_bulk_update.helper import bulk_update class RegistrationProviderRelationshipField(RelationshipField): def get_object(self, _id): @@ -293,6 +298,7 @@ class NodeSerializer(TaxonomizableSerializerMixin, JSONAPISerializer): 'wiki_enabled', 'wikis', 'addons', + 'mapcore_groups', ] id = IDField(source='_id', read_only=True) @@ -680,6 +686,22 @@ def get_node_count(self, obj): FROM parents ) OR G.content_object_id = %s) AND UG.osfuser_id = %s) + )),has_admin_group AS (SELECT EXISTS( + SELECT P.codename + FROM auth_permission AS P + INNER JOIN osf_nodegroupobjectpermission AS G ON (P.id = G.permission_id) + INNER JOIN osf_mapcore_node_group AS OMNG + ON (G.group_id = OMNG.group_id) AND OMNG.is_deleted IS FALSE + INNER JOIN osf_mapcore_user_group AS OMUG + ON (OMNG.mapcore_group_id = OMUG.mapcore_group_id) AND OMUG.is_deleted IS FALSE + INNER JOIN osf_osfuser AS UG + ON (OMUG.user_id = UG.id) + WHERE (P.codename = 'admin_node' + AND (G.content_object_id IN ( + SELECT parent_id + FROM parents + ) OR G.content_object_id = %s) + AND UG.id = %s) )) SELECT COUNT(DISTINCT child_id) FROM @@ -692,6 +714,7 @@ def get_node_count(self, obj): AND ( osf_abstractnode.is_public OR (SELECT exists from has_admin) = TRUE + OR (SELECT exists from has_admin_group) = TRUE OR (SELECT EXISTS( SELECT P.codename FROM auth_permission AS P @@ -704,7 +727,7 @@ def get_node_count(self, obj): ) OR (osf_privatelink.key = %s AND osf_privatelink.is_deleted = FALSE) ); - """, [obj.id, obj.id, user_id, obj.id, user_id, auth.private_key], + """, [obj.id, obj.id, user_id, obj.id, user_id, obj.id, user_id, auth.private_key], ) return int(cursor.fetchone()[0]) @@ -840,6 +863,34 @@ def create(self, validated_data): for group in parent.osf_groups: if group.is_manager(user): node.add_osf_group(group, group.get_permission_to_node(parent), auth=auth) + parent_node_groups = MapCoreNodeGroup.objects.filter(node=parent, is_deleted=False).select_related('group', 'mapcore_group') + auth_groups = get_group_by_node(node.id) + to_create = [] + to_create_mapcore_group_ids = [] + for node_group in parent_node_groups: + parent_permission = 'read' + parts = node_group.group.name.rsplit('_', 1) + if len(parts) == 2: + parent_permission = parts[1] + to_create.append(MapCoreNodeGroup( + node=node, + group_id=auth_groups.get(parent_permission), + mapcore_group=node_group.mapcore_group, + visible=node_group.visible, + _order=node_group._order, + creator=user, + )) + to_create_mapcore_group_ids.append(node_group.mapcore_group.id) + MapCoreNodeGroup.objects.bulk_create(to_create) + params = node.log_params + params['mapcore_groups'] = to_create_mapcore_group_ids + node.add_log( + action=node.log_class.MAPCORE_GROUP_ADDED, + params=params, + auth=auth, + save=False, + ) + if is_truthy(request.GET.get('inherit_subjects')) and validated_data['parent'].has_permission(user, osf_permissions.WRITE): parent = validated_data['parent'] node.subjects.add(parent.subjects.all()) @@ -2025,3 +2076,395 @@ def update(self, obj, validated_data): # permission is in writeable_method_fields, so validation happens on OSF Group model raise exceptions.ValidationError(detail=str(e)) return obj + + +class NodeMapCoreGroupSerializer(JSONAPISerializer): + """ + Serializer for MapCore Groups associated with a Node + """ + id = ser.IntegerField(read_only=True) + node_group_id = ser.IntegerField(source='id', read_only=True) + creator_id = ser.IntegerField(read_only=True) + creator = ser.CharField(source='creator.fullname', read_only=True) + permission = ser.SerializerMethodField() + mapcore_group_id = ser.IntegerField(read_only=True) + name = ser.CharField(source='mapcore_group._id', read_only=True) + created = VersionedDateTimeField(read_only=True) + modified = VersionedDateTimeField(read_only=True) + visible = ser.BooleanField(read_only=True) + links = LinksField( + { + 'self': 'get_absolute_url', + }, + ) + type = TypeField() + + class Meta: + type_ = 'node-mapcore-group' + + def get_absolute_url(self, obj): + group_id = getattr(getattr(obj, 'mapcore_group', None), '_id', None) + return ( + f'{website_settings.MAPCORE_GROUP_HOSTNAME}{website_settings.MAPCORE_GROUP_API_PATH}{group_id}' + if group_id + else None + ) + + def get_permission(self, obj): + """ + Return permission codenames that obj.group has on the node. + Expects serializer context to include 'node' (like NodeGroupsSerializer). + Falls back to view.get_node() if necessary. + """ + # Remove everything after the first underscore, e.g. 'read_node' -> 'read' + short_perms = getattr(obj, 'permissions', []) + # Return highest permission only: admin > write > read + for perm in ('admin', 'write', 'read'): + if perm in short_perms: + return perm + return None + + +class NodeMapCoreGroupCreateSerializer(NodeMapCoreGroupSerializer): + """ + Serializer for creating MapCore Groups associated with a Node + """ + node_groups = ser.ListField(required=True) + component_ids = ser.ListField(required=False) + + def load_mapcore_group(self, mapcore_group_id): + try: + mapcore_group = MapCoreGroup.objects.get(id=mapcore_group_id) + except MapCoreGroup.DoesNotExist: + raise exceptions.NotFound( + detail='MapCore Group with id {} does not exist.'.format( + mapcore_group_id, + ), + ) + return mapcore_group + + def create(self, validated_data): + auth = get_user_auth(self.context['request']) + node = self.context['node'] + auth_groups_map = get_group_by_node(node.id) + node_groups = validated_data.get('node_groups', []) + created_instances = [] + response_data = [] + + # Prepare instances to bulk_create for missing pairs + permission_dict = dict() + to_create_mapcore_ids = set() + to_create = [] + to_create_node_ids = [node.id] + to_update = [] + visible_dict = dict() + last_index_map = {} + last_node_order = MapCoreNodeGroup.objects.filter(node=node, is_deleted=False).values('node_id').annotate(last_order=Max('_order')) + if last_node_order: + last_index_map = {entry['node_id']: entry['last_order'] for entry in last_node_order} + + for index, ng in enumerate(node_groups): + mgid = ng.get('mapcore_group_id') + permission = ng.get('permission') + permission_dict[mgid] = permission + to_create_mapcore_ids.add(mgid) + visible_dict[mgid] = ng.get('visible', True) + to_create.append( + MapCoreNodeGroup( + node=node, + mapcore_group_id=mgid, + group_id=auth_groups_map[permission], + creator=auth.user, + visible=ng.get('visible', True), + _order=last_index_map.get(node.id, -1) + index + 1, + ), + ) + + # Handle components if provided + component_ids = validated_data.get('component_ids', []) + if component_ids: + components = node.descendants.prefetch_related('guids').filter(guids___id__in=component_ids, is_deleted=False) + component_ids_found = [component.id for component in components] + last_component_order = MapCoreNodeGroup.objects.filter( + node_id__in=component_ids_found, + is_deleted=False, + ).values('node_id').annotate(last_order=Max('_order')) + if last_component_order: + for entry in last_component_order: + last_index_map[entry['node_id']] = entry['last_order'] + mapcore_group_components = MapCoreNodeGroup.objects.filter( + node_id__in=component_ids_found, + mapcore_group_id__in=to_create_mapcore_ids, + is_deleted=False, + ) + mapcore_group_component_map = {} + to_update_node_ids = [] + for mcg in mapcore_group_components: + mapcore_group_component_map[(mcg.node_id, mcg.mapcore_group_id)] = mcg + to_update_node_ids.append(mcg.node_id) + + to_update_components = [] + to_create_components = [] + component_auth_group_dict = dict() + for component in components: + auth_group = get_group_by_node(component.id) + component_auth_group_dict[component.id] = auth_group + if component.id in to_update_node_ids: + to_update_components.append(component) + else: + to_create_components.append(component) + to_create_node_ids.append(component.id) + for index, ng in enumerate(node_groups): + mgid = ng.get('mapcore_group_id') + permission = permission_dict.get(mgid) + for component in to_update_components: + component_auth_group = component_auth_group_dict.get(component.id) + mapcore_group_component = mapcore_group_component_map.get((component.id, mgid)) + if mapcore_group_component: + mapcore_group_component.group_id = component_auth_group[permission] + mapcore_group_component.modified = timezone.now() + to_update.append(mapcore_group_component) + else: + to_create.append( + MapCoreNodeGroup( + node=component, + mapcore_group_id=mgid, + group_id=component_auth_group[permission], + creator=auth.user, + _order=last_index_map.get(component.id, -1) + index + 1, + visible=visible_dict.get(mgid, True), + ), + ) + for component in to_create_components: + component_auth_group = component_auth_group_dict.get(component.id) + to_create.append( + MapCoreNodeGroup( + node=component, + mapcore_group_id=mgid, + group_id=component_auth_group[permission], + creator=auth.user, + _order=last_index_map.get(component.id, -1) + index + 1, + visible=visible_dict.get(mgid, True), + ), + ) + + # Check for existing MapCoreNodeGroup entries to avoid duplicates + existing_qs = MapCoreNodeGroup.objects.filter( + node_id__in=to_create_node_ids, mapcore_group_id__in=to_create_mapcore_ids, is_deleted=False, + ) + if existing_qs.exists(): + existing_pairs = [e.mapcore_group_id for e in existing_qs] + raise exceptions.ValidationError( + detail=f'MapCoreNodeGroup already exists for mapcore_group_id(s): {existing_pairs}', + ) + + # Bulk create MapCoreNodeGroup entries + with transaction.atomic(): + if to_create: + MapCoreNodeGroup.objects.bulk_create(to_create) + created_instances = MapCoreNodeGroup.objects.filter( + node=node, + mapcore_group_id__in=to_create_mapcore_ids, + is_deleted=False, + ).select_related('creator', 'node', 'group', 'mapcore_group') + if to_update: + bulk_update(to_update, update_fields=['group_id', 'modified']) + + # Prepare response data + for mapcore_node_group in created_instances: + response_data.append( + { + 'id': mapcore_node_group.id, + 'type': 'node-mapcore-group', + 'attributes': { + 'node_group_id': mapcore_node_group.id, + 'creator_id': mapcore_node_group.creator.id, + 'creator': mapcore_node_group.creator.fullname, + 'permission': permission_dict.get(mapcore_node_group.mapcore_group_id), + 'mapcore_group_id': mapcore_node_group.mapcore_group_id, + 'name': getattr( + mapcore_node_group.mapcore_group, '_id', None, + ), + 'visible': mapcore_node_group.visible, + 'index': mapcore_node_group._order, + 'created': mapcore_node_group.created, + 'modified': mapcore_node_group.modified, + }, + 'links': { + 'self': self.get_absolute_url(mapcore_node_group), + }, + }, + ) + params = node.log_params + params['mapcore_groups'] = [mgid for mgid in to_create_mapcore_ids] + # Add log entry + node.add_log( + action=node.log_class.MAPCORE_GROUP_ADDED, + params=params, + auth=auth, + save=False, + ) + # Update node modified date + node.modified = timezone.now() + node.save() + return response_data + +class NodeMapCoreGroupUpdateSerializer(NodeMapCoreGroupSerializer): + """ + Serializer for updating MapCore Groups associated with a Node + """ + node_groups = ser.ListField(required=True) + + def load_mapcore_group(self, mapcore_group_id): + try: + mapcore_group = MapCoreGroup.objects.get(id=mapcore_group_id) + except MapCoreGroup.DoesNotExist: + raise exceptions.NotFound( + detail='MapCore Group with id {} does not exist.'.format(mapcore_group_id), + ) + return mapcore_group + + def create(self, validated_data): + auth = get_user_auth(self.context['request']) + node = self.context['node'] + auth_groups_map = get_group_by_node(node.id) + node_groups = validated_data.get('node_groups', []) + response_data = [] + # Prepare instances to bulk_create for missing pairs + to_update_node_group_ids = set() + to_update = [] + permission_dict = dict() + visible_dict = dict() + order_dict = dict() + update_permission = {} + update_visible_list = [] + update_invisible_list = [] + update_order_dict = dict() + is_sorted = False + for index, ng in enumerate(node_groups): + ngid = ng.get('node_group_id') + permission = ng.get('permission') + permission_dict[ngid] = permission + visible_dict[ngid] = ng.get('visible', True) + order_dict[ngid] = index + to_update_node_group_ids.add(ngid) + + mapcore_node_groups = list(MapCoreNodeGroup.objects.filter( + node=node, + id__in=to_update_node_group_ids, + is_deleted=False, + )) + for updated_mapcore_node_group in mapcore_node_groups: + permission = permission_dict.get(updated_mapcore_node_group.id) + if permission and updated_mapcore_node_group.group_id != auth_groups_map[permission]: + updated_mapcore_node_group.group_id = auth_groups_map[permission] + update_permission[updated_mapcore_node_group.mapcore_group_id] = permission + visible = visible_dict.get(updated_mapcore_node_group.id) + if visible is not None and updated_mapcore_node_group.visible != visible: + updated_mapcore_node_group.visible = visible + if visible: + update_visible_list.append(updated_mapcore_node_group.mapcore_group_id) + else: + update_invisible_list.append(updated_mapcore_node_group.mapcore_group_id) + index = order_dict.get(updated_mapcore_node_group.id) + update_order_dict[updated_mapcore_node_group.mapcore_group_id] = index + if index is not None and updated_mapcore_node_group._order != index: + updated_mapcore_node_group._order = index + is_sorted = True + updated_mapcore_node_group.modified = timezone.now() + to_update.append(updated_mapcore_node_group) + + # Bulk create MapCoreNodeGroup entries + with transaction.atomic(): + if to_update_node_group_ids: + bulk_update(to_update, update_fields=['group_id', 'modified', 'visible', '_order']) + # Prepare response data + for updated_mapcore_node_group in to_update: + response_data.append( + { + 'id': updated_mapcore_node_group.id, + 'type': 'node-mapcore-group', + 'attributes': { + 'node_group_id': updated_mapcore_node_group.id, + 'creator_id': updated_mapcore_node_group.creator.id, + 'creator': updated_mapcore_node_group.creator.fullname, + 'permission': permission_dict.get(updated_mapcore_node_group.id), + 'mapcore_group_id': updated_mapcore_node_group.mapcore_group_id, + 'name': getattr( + updated_mapcore_node_group.mapcore_group, '_id', None, + ), + 'visible': updated_mapcore_node_group.visible, + 'index': updated_mapcore_node_group._order, + 'created': updated_mapcore_node_group.created, + 'modified': updated_mapcore_node_group.modified, + }, + 'links': { + 'self': self.get_absolute_url(updated_mapcore_node_group), + }, + }, + ) + # Add log entry + params = node.log_params + if update_permission: + params['mapcore_groups'] = update_permission + node.add_log( + action=node.log_class.MAPCORE_GROUP_PERMISSION_UPDATED, + params=params, + auth=auth, + save=False, + ) + if update_visible_list: + for mgid in update_visible_list: + params['mapcore_groups'] = [mgid] + node.add_log( + action=node.log_class.MADE_MAPCORE_GROUP_VISIBLE, + params=params, + auth=auth, + save=False, + ) + if update_invisible_list: + for mgid in update_invisible_list: + params['mapcore_groups'] = [mgid] + node.add_log( + action=node.log_class.MADE_MAPCORE_GROUP_INVISIBLE, + params=params, + auth=auth, + save=False, + ) + if is_sorted: + update_order_dict = dict(sorted(update_order_dict.items(), key=lambda item: item[1])) + update_order_list = [mgid for mgid, index in update_order_dict.items()] + params['mapcore_groups'] = update_order_list + node.add_log( + action=node.log_class.MAPCORE_GROUP_REORDERED, + params=params, + auth=auth, + save=False, + ) + # Update node modified date + node.modified = timezone.now() + node.save() + return response_data + +def get_group_by_node(node_id): + """ + Return a mapping of permission codename to auth_group id for a given node. + E.g. {'read': 1, 'write': 2, 'admin': 3} + """ + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT * + FROM auth_group + WHERE name LIKE %s + """, + [f'node_{node_id}_%'], + ) + rows = cursor.fetchall() + perm_map = {} + for gid, name in rows: + parts = name.rsplit('_', 1) + if len(parts) == 2: + perm = parts[1] + perm_map[perm] = gid + return perm_map diff --git a/api/nodes/urls.py b/api/nodes/urls.py index b6f0e518eb6..1f6c18e4241 100644 --- a/api/nodes/urls.py +++ b/api/nodes/urls.py @@ -52,4 +52,6 @@ url(r'^(?P\w+)/view_only_links/$', views.NodeViewOnlyLinksList.as_view(), name=views.NodeViewOnlyLinksList.view_name), url(r'^(?P\w+)/view_only_links/(?P\w+)/$', views.NodeViewOnlyLinkDetail.as_view(), name=views.NodeViewOnlyLinkDetail.view_name), url(r'^(?P\w+)/wikis/$', views.NodeWikiList.as_view(), name=views.NodeWikiList.view_name), + url(r'^(?P\w+)/map_core/groups/$', views.NodeMapCoreGroupList.as_view(), name=views.NodeMapCoreGroupList.view_name), + url(r'^(?P\w+)/map_core/groups/(?P[0-9]+)/$', views.NodeMapCoreGroupRemove.as_view(), name=views.NodeMapCoreGroupList.view_name), ] diff --git a/api/nodes/views.py b/api/nodes/views.py index 72882d252db..72d55db03a1 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -6,10 +6,12 @@ from django.db.models import Q, OuterRef, Exists, Subquery, F from django.utils import timezone from django.contrib.contenttypes.models import ContentType +from osf.models.mapcore_group import MapCoreGroup +from osf.models.mapcore_node_group import MapCoreNodeGroup from rest_framework import generics, permissions as drf_permissions from rest_framework.exceptions import PermissionDenied, ValidationError, NotFound, MethodNotAllowed, NotAuthenticated from rest_framework.response import Response -from rest_framework.status import HTTP_204_NO_CONTENT, HTTP_200_OK +from rest_framework.status import HTTP_204_NO_CONTENT, HTTP_200_OK, HTTP_201_CREATED from addons.base.exceptions import InvalidAuthError from addons.osfstorage.models import OsfStorageFolder @@ -88,6 +90,8 @@ AdminOrPublicOrSuperUser, ) from api.nodes.serializers import ( + NodeMapCoreGroupCreateSerializer, + NodeMapCoreGroupUpdateSerializer, NodeSerializer, ForwardNodeAddonSettingsSerializer, NodeAddonSettingsSerializer, @@ -110,6 +114,7 @@ NodeGroupsSerializer, NodeGroupsCreateSerializer, NodeGroupsDetailSerializer, + NodeMapCoreGroupSerializer, ) from api.nodes.utils import NodeOptimizationMixin, enforce_no_children from api.osf_groups.views import OSFGroupMixin @@ -813,6 +818,7 @@ class NodeChildrenList(BaseChildrenList, bulk_views.ListBulkCreateJSONAPIView, N view_category = 'nodes' view_name = 'node-children' model_class = Node + include_mapcore_groups = True def get_serializer_context(self): context = super(NodeChildrenList, self).get_serializer_context() @@ -2361,3 +2367,304 @@ def get_serializer_context(self): context['wiki_addon'] = node.get_addon('wiki') context['forward_addon'] = node.get_addon('forward') return context + + +class NodeMapCoreGroupList(JSONAPIBaseView, generics.ListAPIView, bulk_views.BulkUpdateJSONAPIView, bulk_views.ListBulkCreateJSONAPIView, NodeMixin): + """ + API endpoint that allows the core groups of a node to be viewed and edited. + """ + permission_classes = ( + AdminOrPublic, + drf_permissions.IsAuthenticatedOrReadOnly, + ReadOnlyIfRegistration, + base_permissions.TokenHasScope, + ) + + required_read_scopes = [CoreScopes.NODE_CONTRIBUTORS_READ] + required_write_scopes = [CoreScopes.NODE_CONTRIBUTORS_WRITE] + model_class = OSFUser + + throttle_classes = (AddContributorThrottle, UserRateThrottle, NonCookieAuthThrottle, BurstRateThrottle, ) + + pagination_class = MaxSizePagination + serializer_class = NodeMapCoreGroupSerializer + view_category = 'nodes' + view_name = 'node-map-core-groups' + ordering = ('mapcore_group___id',) # default ordering + + def get_serializer_class(self): + """ + Use NodeContributorDetailSerializer which requires 'id' + """ + if self.request.method == 'PUT' or self.request.method == 'PATCH' or self.request.method == 'DELETE': + return NodeMapCoreGroupUpdateSerializer + elif self.request.method == 'POST': + return NodeMapCoreGroupCreateSerializer + else: + return NodeMapCoreGroupSerializer + + # overrides ListBulkCreateJSON APIView, BulkUpdateJSONAPIView + def get_queryset(self): + node = self.get_node() + qs = MapCoreNodeGroup.objects.filter(node=node, is_deleted=False) + # Avoid N+1 on foreign-key relations reported by nplusone + qs = qs.select_related('creator', 'mapcore_group') + # Precompute permissions via ORM (no raw SQL) + group_ids = list(qs.values_list('group_id', flat=True)) + perm_map = {} + if group_ids: + NodeGroupObjectPermission = apps.get_model('osf.NodeGroupObjectPermission') + perms_qs = ( + NodeGroupObjectPermission.objects + .filter(group_id__in=group_ids, content_object_id=node.id) + .select_related('permission') + ) + for p in perms_qs: + codename = getattr(getattr(p, 'permission', None), 'codename', '') or '' + short = codename.split('_', 1)[0] if '_' in codename else codename + perm_map.setdefault(p.group_id, []).append(short) + # Attach computed permission arrays to instances so serializer can read obj.permissions + for obj in qs: + obj.permissions = perm_map.get(obj.group_id, []) + + # If any related fields are reverse or many-to-many, use prefetch_related: + # qs = qs.prefetch_related('some_m2m_field') + return qs + + # Overrides BulkDestroyJSONAPIView + def perform_destroy(self, instance): + pass + + def get_serializer_context(self): + """ + Ensure serializers have the node available as 'node' in context. + """ + context = super(NodeMapCoreGroupList, self).get_serializer_context() + node = self.get_node() + context['node'] = node + return context + + def list(self, request, *args, **kwargs): + """List the MapCoreNodeGroup relationships for this node. + """ + queryset = self.get_queryset() + if request.query_params.get('visible'): + queryset = queryset.filter(visible=True) + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + serializer = self.get_serializer(queryset, many=True) + data = { + 'data': serializer.data, + } + return Response(data) + + def create(self, request, *args, **kwargs): + # Normalize incoming payload: accept JSON:API {"data": ...} or raw dict/list + request_body = request.data + if 'data' in request.data: + request_body = request.data['data'] + attrs = request_body.get('attributes', request_body) + node_groups = attrs.get('node_groups') + if not node_groups or not isinstance(node_groups, (list, tuple)) or len(node_groups) == 0: + raise ValidationError(detail='Request must include a non-empty attributes.node_groups list.') + mapcore_group_id_set = set() + duplicates = set() + allowed_perms = {'read', 'write', 'admin'} + for idx, ng in enumerate(node_groups): + # Validate required fields + if 'mapcore_group_id' not in ng or 'permission' not in ng: + raise ValidationError(detail='Each node_group must include a mapcore_group_id and permission.') + # Validate permission value + perm = ng.get('permission') + if perm not in allowed_perms: + raise ValidationError(detail=f'Permission "{perm}" is invalid (must be one of {sorted(allowed_perms)}) (failed at index {idx}).') + # Check for duplicate mapcore_group_id in request + mgid = ng.get('mapcore_group_id') + try: + mgid = int(mgid) + except (TypeError, ValueError): + raise ValidationError(detail=f'mapcore_group_id must be an integer (failed at index {idx}).') + + if mgid in mapcore_group_id_set: + duplicates.add(mgid) + else: + mapcore_group_id_set.add(mgid) + # If any duplicates found, raise error + if duplicates: + raise ValidationError(detail=f'Duplicate mapcore_group_id(s) in request: {sorted(list(duplicates))}') + # Validate that all mapcore_group_id values exist + mapcore_groups = MapCoreGroup.objects.filter(id__in=mapcore_group_id_set) + mapcore_groups_found_ids = [mg.id for mg in mapcore_groups] + missing_ids = mapcore_group_id_set - set(mapcore_groups_found_ids) + if missing_ids: + raise ValidationError(detail=f'mapcore_group_id(s) not found: {sorted(list(missing_ids))}') + + # Check for existing MapCoreNodeGroup relationships + existing_qs = MapCoreNodeGroup.objects.filter(node=self.get_node(), mapcore_group_id__in=mapcore_group_id_set, is_deleted=False) + if existing_qs.exists(): + existing_pairs = [e.mapcore_group_id for e in existing_qs] + raise ValidationError(detail=f'MapCoreNodeGroup already exists for mapcore_group_id(s): {existing_pairs}') + + component_ids = attrs.get('component_ids', []) + component_ids_set = set() + duplicate_component_ids = [] + for cid in component_ids: + if cid in component_ids_set: + duplicate_component_ids.append(cid) + else: + component_ids_set.add(cid) + if duplicate_component_ids: + raise ValidationError(detail=f'Duplicate component_ids in request: {sorted(duplicate_component_ids)}') + + node = self.get_node() + components = node.descendants.prefetch_related('guids').filter( + guids___id__in=component_ids_set, + is_deleted=False, + ) + components_found_ids = set(c.guids.first()._id for c in components) + missing_component_ids = component_ids_set - components_found_ids + if missing_component_ids: + raise ValidationError(detail=f'component_ids not found or not children of this node: {sorted(list(missing_component_ids))}') + # Use the create serializer to validate & create objects + create_serializer = self.get_serializer(data=request_body) + create_serializer.is_valid(raise_exception=True) + created_objects = create_serializer.save() + data = { + 'data': created_objects, + } + return Response(data, status=HTTP_201_CREATED) + + def bulk_update(self, request, *args, **kwargs): + """Bulk update MapCoreNodeGroup relationships for this node. + """ + request_body = request.data + if 'data' in request.data: + request_body = request.data['data'] + attrs = request_body.get('attributes', request_body) + node_groups = attrs.get('node_groups') + if not node_groups or not isinstance(node_groups, (list, tuple)) or len(node_groups) == 0: + raise ValidationError(detail='Request must include a non-empty attributes.node_groups list.') + node_group_id_set = set() + duplicates = set() + for idx, ng in enumerate(node_groups): + # Validate required fields + if 'node_group_id' not in ng or 'permission' not in ng: + raise ValidationError(detail='Each node_group must include a node_group_id and permission.') + # Validate permission value + perm = ng.get('permission') + allowed_perms = {'read', 'write', 'admin'} + if perm not in allowed_perms: + raise ValidationError(detail=f'Permission "{perm}" is invalid (must be one of {sorted(allowed_perms)}) (failed at index {idx}).') + # Validate that node_group_id exists + ngid = ng.get('node_group_id') + try: + ngid = int(ngid) + except (TypeError, ValueError): + raise ValidationError(detail=f'node_group_id must be an integer (failed at index {idx}).') + # Check for duplicate node_group_id in request + if ngid in node_group_id_set: + duplicates.add(ngid) + else: + node_group_id_set.add(ngid) + # If any duplicates found, raise error + if duplicates: + raise ValidationError(detail=f'Duplicate node_group_id(s) in request: {sorted(list(duplicates))}') + node_group_db = MapCoreNodeGroup.objects.filter(node=self.get_node(), id__in=node_group_id_set, is_deleted=False) + node_group_db_ids = set(ng.id for ng in node_group_db) + missing_ids = node_group_id_set - node_group_db_ids + if missing_ids: + raise ValidationError(detail=f'node_group_id(s) not found: {sorted(list(missing_ids))}') + # Use the update serializer to validate & update objects + update_serializer = self.get_serializer(data=request_body) + update_serializer.is_valid(raise_exception=True) + updated_objects = update_serializer.save() + data = { + 'data': updated_objects, + } + return Response(data, status=HTTP_200_OK) + +class NodeMapCoreGroupRemove(JSONAPIBaseView, generics.DestroyAPIView, NodeMixin): + """ + API endpoint that allows the core groups of a node to be removed. + """ + permission_classes = ( + AdminOrPublic, + drf_permissions.IsAuthenticatedOrReadOnly, + ReadOnlyIfRegistration, + base_permissions.TokenHasScope, + ) + + required_read_scopes = [CoreScopes.NODE_CONTRIBUTORS_READ] + required_write_scopes = [CoreScopes.NODE_CONTRIBUTORS_WRITE] + + view_category = 'nodes' + view_name = 'node-map-core-group-remove' + def delete(self, request, *args, **kwargs): + """Remove a MapCoreNodeGroup relationship from this node. + """ + query_params = self.request.query_params + component_ids = query_params.get('component_ids', '') + if component_ids: + component_ids = component_ids.split(',') + component_ids_set = set() + duplicate_component_ids = [] + for cid in component_ids: + if cid in component_ids_set: + duplicate_component_ids.append(cid) + else: + component_ids_set.add(cid) + if duplicate_component_ids: + raise ValidationError(detail=f'Duplicate component_ids in request: {sorted(duplicate_component_ids)}') + components = self.get_node().descendants.prefetch_related('guids').filter(guids___id__in=component_ids_set, is_deleted=False) + components_found_ids = set(c.guids.first()._id for c in components) + missing_component_ids = component_ids_set - components_found_ids + if missing_component_ids: + raise ValidationError(detail=f'component_ids not found or not children of this node: {sorted(list(missing_component_ids))}') + # Use the create serializer to validate & create objects + instance = self.get_object() + if component_ids: + for component in components: + try: + mapcore_node_group = MapCoreNodeGroup.objects.get( + node=component, + mapcore_group_id=instance.mapcore_group_id, + is_deleted=False, + ) + except MapCoreNodeGroup.DoesNotExist: + raise NotFound(detail=f'MapCoreNodeGroup not found for component {component._id}.') + self.perform_destroy(mapcore_node_group) + self.perform_destroy(instance) + return Response(status=HTTP_204_NO_CONTENT) + + # overrides DestroyAPIView + def get_object(self): + node = self.get_node() + try: + mapcore_node_group = MapCoreNodeGroup.objects.get( + node=node, + id=self.kwargs['node_group_id'], + is_deleted=False, + ) + except MapCoreNodeGroup.DoesNotExist: + raise NotFound(detail='MapCoreNodeGroup not found.') + return mapcore_node_group + def perform_destroy(self, instance): + assert isinstance(instance, MapCoreNodeGroup), 'instance must be a MapCoreNodeGroup' + instance.is_deleted = True + instance.modified = timezone.now() + instance.save() + auth = get_user_auth(self.request) + node = instance.node + params = node.log_params + params['mapcore_groups'] = [instance.mapcore_group_id] + node.add_log( + action=node.log_class.MAPCORE_GROUP_REMOVED, + params=params, + auth=auth, + save=False, + ) + # Update node modified date + node.modified = timezone.now() + node.save() diff --git a/api/users/serializers.py b/api/users/serializers.py index 7622049a2b0..ba390371d2c 100644 --- a/api/users/serializers.py +++ b/api/users/serializers.py @@ -1,6 +1,7 @@ import jsonschema from django.utils import timezone +from osf.models.mapcore_node_group import MapCoreNodeGroup from rest_framework import serializers as ser from rest_framework import exceptions @@ -669,3 +670,10 @@ def update(self, instance, validated_data): class UserNodeSerializer(NodeSerializer): filterable_fields = NodeSerializer.filterable_fields | {'current_user_permissions'} + mapcore_groups = ser.SerializerMethodField() + + def get_mapcore_groups(self, obj): + if isinstance(obj, Node): + node_groups = MapCoreNodeGroup.objects.filter(node=obj, is_deleted=False).select_related('mapcore_group') + return [group.mapcore_group._id for group in node_groups] + return [] diff --git a/api/users/views.py b/api/users/views.py index 2a561309161..de7fd1f0181 100644 --- a/api/users/views.py +++ b/api/users/views.py @@ -322,10 +322,11 @@ class UserNodes(JSONAPIBaseView, generics.ListAPIView, UserMixin, UserNodesFilte def get_default_queryset(self): user = self.get_user() # Nodes the requested user has read_permissions on + user.include_mapcore_groups = True default_queryset = user.nodes_contributor_or_group_member_to if user != self.request.user: # Further restrict UserNodes to nodes the *requesting* user can view - return Node.objects.get_nodes_for_user(self.request.user, base_queryset=default_queryset, include_public=True) + return Node.objects.get_nodes_for_user(self.request.user, base_queryset=default_queryset, include_public=True, include_mapcore_groups=True) return self.optimize_node_queryset(default_queryset) # overrides ListAPIView diff --git a/api_tests/institutions/test_authentication_mapcore.py b/api_tests/institutions/test_authentication_mapcore.py new file mode 100644 index 00000000000..04cd15311aa --- /dev/null +++ b/api_tests/institutions/test_authentication_mapcore.py @@ -0,0 +1,415 @@ +import pytest +from unittest import mock + +from api.institutions.authentication import update_mapcore_groups +from osf.models.mapcore_group import MapCoreGroup +from osf.models.mapcore_user_group import MapCoreUserGroup +from osf_tests.factories import AuthUserFactory + + +@pytest.mark.django_db +class TestUpdateMapcoreGroups: + """Test cases for update_mapcore_groups function""" + + @pytest.fixture + def user(self): + """Create a test user""" + return AuthUserFactory() + + @pytest.fixture + def mapcore_group(self): + """Create a test mapcore group""" + group, created = MapCoreGroup.objects.get_or_create(_id='test_group') + return group + + def test_returns_early_when_prefix_not_set(self, user): + """Test that function returns early when MAP_GATEWAY_ISMEMBEROF_PREFIX is not set""" + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/group1' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', None): + update_mapcore_groups(user, provider) + + # No groups should be created + assert MapCoreUserGroup.objects.filter(user=user).count() == 0 + + def test_returns_early_when_prefix_empty(self, user): + """Test that function returns early when MAP_GATEWAY_ISMEMBEROF_PREFIX is empty""" + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/group1' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', ''): + update_mapcore_groups(user, provider) + + # No groups should be created + assert MapCoreUserGroup.objects.filter(user=user).count() == 0 + + def test_returns_early_when_no_groups_provided(self, user): + """Test that function returns early when no groups are provided in provider""" + provider = { + 'user': {} + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # No groups should be created + assert MapCoreUserGroup.objects.filter(user=user).count() == 0 + + def test_returns_early_when_groups_empty_string(self, user): + """Test that function returns early when groups is empty string""" + provider = { + 'user': { + 'groups': '' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # No groups should be created + assert MapCoreUserGroup.objects.filter(user=user).count() == 0 + + def test_adds_single_new_group(self, user): + """Test adding a single new group""" + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/group1' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # One group should be created + user_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert user_groups.count() == 1 + assert user_groups.first().mapcore_group._id == 'group1' + + def test_adds_multiple_new_groups(self, user): + """Test adding multiple new groups separated by semicolon""" + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/group1;https://cg.gakunin.jp/gr/group2;https://cg.gakunin.jp/gr/group3' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # Three groups should be created + user_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert user_groups.count() == 3 + group_ids = set(ug.mapcore_group._id for ug in user_groups) + assert group_ids == {'group1', 'group2', 'group3'} + + def test_filters_groups_by_prefix(self, user): + """Test that only groups with matching prefix are added""" + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/group1;https://other.prefix.jp/group2;https://cg.gakunin.jp/gr/group3' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # Only two groups with matching prefix should be created + user_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert user_groups.count() == 2 + group_ids = set(ug.mapcore_group._id for ug in user_groups) + assert group_ids == {'group1', 'group3'} + + def test_ignores_empty_group_names(self, user): + """Test that empty group names after prefix removal are ignored""" + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/;https://cg.gakunin.jp/gr/group1;https://cg.gakunin.jp/gr/' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # Only one valid group should be created + user_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert user_groups.count() == 1 + assert user_groups.first().mapcore_group._id == 'group1' + + def test_marks_removed_groups_as_deleted(self, user, mapcore_group): + """Test that existing groups not in new list are marked as deleted""" + # Create an existing user group + existing_group = MapCoreUserGroup.objects.create( + user=user, + mapcore_group=mapcore_group, + is_deleted=False + ) + + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/new_group' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # Existing group should be marked as deleted + existing_group.refresh_from_db() + assert existing_group.is_deleted is True + assert existing_group.modified is not None + + # New group should be created + new_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert new_groups.count() == 1 + assert new_groups.first().mapcore_group._id == 'new_group' + + def test_keeps_existing_groups_in_new_list(self, user, mapcore_group): + """Test that existing groups in new list are kept and not deleted""" + # Create an existing user group + existing_group = MapCoreUserGroup.objects.create( + user=user, + mapcore_group=mapcore_group, + is_deleted=False + ) + + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/test_group;https://cg.gakunin.jp/gr/new_group' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # Existing group should still be active + existing_group.refresh_from_db() + assert existing_group.is_deleted is False + + # Two active groups should exist + active_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert active_groups.count() == 2 + group_ids = set(ug.mapcore_group._id for ug in active_groups) + assert group_ids == {'test_group', 'new_group'} + + def test_handles_mixed_update_scenario(self, user): + """Test handling multiple groups: keep existing, delete removed, add new""" + # Create existing groups + group1, _ = MapCoreGroup.objects.get_or_create(_id='keep_group') + group2, _ = MapCoreGroup.objects.get_or_create(_id='delete_group') + + MapCoreUserGroup.objects.create(user=user, mapcore_group=group1, is_deleted=False) + MapCoreUserGroup.objects.create(user=user, mapcore_group=group2, is_deleted=False) + + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/keep_group;https://cg.gakunin.jp/gr/new_group' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # Verify results + active_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert active_groups.count() == 2 + group_ids = set(ug.mapcore_group._id for ug in active_groups) + assert group_ids == {'keep_group', 'new_group'} + + # Verify deleted group + deleted_group = MapCoreUserGroup.objects.get(user=user, mapcore_group=group2) + assert deleted_group.is_deleted is True + + def test_handles_no_existing_groups(self, user): + """Test adding groups when user has no existing groups""" + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/group1;https://cg.gakunin.jp/gr/group2' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # Two groups should be created + user_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert user_groups.count() == 2 + group_ids = set(ug.mapcore_group._id for ug in user_groups) + assert group_ids == {'group1', 'group2'} + + def test_handles_all_groups_removed(self, user): + """Test when all existing groups are removed (no new groups match)""" + # Create existing groups + group1, _ = MapCoreGroup.objects.get_or_create(_id='group1') + group2, _ = MapCoreGroup.objects.get_or_create(_id='group2') + + MapCoreUserGroup.objects.create(user=user, mapcore_group=group1, is_deleted=False) + MapCoreUserGroup.objects.create(user=user, mapcore_group=group2, is_deleted=False) + + provider = { + 'user': { + 'groups': 'https://other.prefix.jp/group3' # Different prefix, won't match + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # All existing groups should be marked as deleted + active_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert active_groups.count() == 0 + + deleted_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=True) + assert deleted_groups.count() == 2 + + def test_reuses_existing_mapcore_group(self, user, mapcore_group): + """Test that existing MapCoreGroup is reused, not duplicated""" + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/test_group' + } + } + + initial_mapcore_group_count = MapCoreGroup.objects.count() + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # MapCoreGroup count should not increase (reused existing) + assert MapCoreGroup.objects.count() == initial_mapcore_group_count + + # User group should be created with existing mapcore_group + user_group = MapCoreUserGroup.objects.get(user=user, is_deleted=False) + assert user_group.mapcore_group == mapcore_group + + def test_handles_duplicate_groups_in_input(self, user): + """Test that duplicate group names in input are handled correctly""" + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/group1;https://cg.gakunin.jp/gr/group1;https://cg.gakunin.jp/gr/group2' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # Only unique groups should be created (set deduplication) + user_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert user_groups.count() == 2 + group_ids = set(ug.mapcore_group._id for ug in user_groups) + assert group_ids == {'group1', 'group2'} + + def test_ignores_already_deleted_groups(self, user): + """Test that already deleted groups are not processed""" + # Create existing groups, one already deleted + group1, _ = MapCoreGroup.objects.get_or_create(_id='group1') + group2, _ = MapCoreGroup.objects.get_or_create(_id='group2') + + MapCoreUserGroup.objects.create(user=user, mapcore_group=group1, is_deleted=False) + MapCoreUserGroup.objects.create(user=user, mapcore_group=group2, is_deleted=True) + + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/new_group' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # Only the active group should be marked as deleted + active_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert active_groups.count() == 1 + assert active_groups.first().mapcore_group._id == 'new_group' + + # Two groups should be deleted (group1 newly deleted, group2 already deleted) + deleted_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=True) + assert deleted_groups.count() == 2 + + def test_handles_special_characters_in_group_names(self, user): + """Test handling group names with special characters""" + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/group-with-dashes;https://cg.gakunin.jp/gr/group_with_underscores;https://cg.gakunin.jp/gr/group.with.dots' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # All groups should be created with special characters preserved + user_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert user_groups.count() == 3 + group_ids = set(ug.mapcore_group._id for ug in user_groups) + assert group_ids == {'group-with-dashes', 'group_with_underscores', 'group.with.dots'} + + def test_bulk_update_called_when_groups_deleted(self, user): + """Test that bulk_update is called when groups need to be deleted""" + # Create existing groups + group1, _ = MapCoreGroup.objects.get_or_create(_id='group1') + group2, _ = MapCoreGroup.objects.get_or_create(_id='group2') + + MapCoreUserGroup.objects.create(user=user, mapcore_group=group1, is_deleted=False) + MapCoreUserGroup.objects.create(user=user, mapcore_group=group2, is_deleted=False) + + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/new_group' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + with mock.patch('api.institutions.authentication.bulk_update') as mock_bulk_update: + update_mapcore_groups(user, provider) + + # bulk_update should be called with the deleted groups + assert mock_bulk_update.called + call_args = mock_bulk_update.call_args + deleted_list = call_args[0][0] + assert len(deleted_list) == 2 + assert call_args[1]['update_fields'] == ['is_deleted', 'modified'] + + def test_no_bulk_update_when_no_deletions(self, user): + """Test that bulk_update is not called when no groups need to be deleted""" + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/group1' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + with mock.patch('api.institutions.authentication.bulk_update') as mock_bulk_update: + update_mapcore_groups(user, provider) + + # bulk_update should not be called + assert not mock_bulk_update.called + + def test_returns_early_when_groups_error_and_groups_empty(self, user): + """If groups is empty but groupsError exists, function returns early and logs decoded message.""" + provider = { + 'user': { + 'groups': '', + 'groupsError': 'Unable%20to%20obtain%20a%20SAML%20response%20from%20attribute%20authority.' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + with mock.patch('api.institutions.authentication.logger') as mock_logger: + update_mapcore_groups(user, provider) + + # logger.warning should have been called with a decoded message + assert mock_logger.warning.called + called_args = mock_logger.warning.call_args + # The first positional arg is the formatted message (code creates a formatted string) + message = called_args[0][0] if called_args and called_args[0] else '' + assert 'MAP Core groups retrieval error for user' in message + assert 'Unable to obtain a SAML response' in message + + # Ensure no MapCoreUserGroup rows were created + assert MapCoreUserGroup.objects.filter(user=user).count() == 0 diff --git a/api_tests/mapcore/serializers/test_serializer.py b/api_tests/mapcore/serializers/test_serializer.py new file mode 100644 index 00000000000..cbdaffa4156 --- /dev/null +++ b/api_tests/mapcore/serializers/test_serializer.py @@ -0,0 +1,29 @@ +import pytest + +from api.mapcore.serializers import MapCoreGroupSerializer +from osf.models.mapcore_group import MapCoreGroup +from tests.utils import make_drf_request_with_version +from website import settings as website_settings + + +@pytest.mark.django_db +def test_mapcore_group_serializer_basic(): + mg = MapCoreGroup.objects.create(_id='test-group-serializer') + + req = make_drf_request_with_version(version='2.0') + serializer = MapCoreGroupSerializer(mg, context={'request': req}) + result = serializer.data + # JSONAPI serializers in the project produce a top-level 'data' key + data = result['data'] if 'data' in result else result + + assert data['type'] == 'mapcore-groups' + assert data['id'] == mg.id + + attrs = data['attributes'] + assert attrs['mapcore_group_id'] == mg.id + assert attrs['name'] == mg._id + assert 'created' in attrs + assert 'modified' in attrs + + expected_url = f'{website_settings.MAPCORE_GROUP_HOSTNAME}{website_settings.MAPCORE_GROUP_API_PATH}{mg._id}/' + assert data['links']['self'] == expected_url diff --git a/api_tests/mapcore/views/test_mapcore_group_list.py b/api_tests/mapcore/views/test_mapcore_group_list.py new file mode 100644 index 00000000000..2f6e92090bf --- /dev/null +++ b/api_tests/mapcore/views/test_mapcore_group_list.py @@ -0,0 +1,51 @@ +import pytest + +from api.base.settings.defaults import API_BASE +from osf.models.mapcore_group import MapCoreGroup +from osf.models.mapcore_user_group import MapCoreUserGroup +from osf_tests.factories import AuthUserFactory +from tests.base import ApiTestCase + + +@pytest.mark.django_db +class TestMapCoreGroupList(ApiTestCase): + def setUp(self): + super().setUp() + self.user = AuthUserFactory() + + # Create MapCoreGroups and link them to the user via MapCoreUserGroup + self.mapcore_group1 = MapCoreGroup.objects.create(_id='test-mapcore-1') + self.mapcore_group2 = MapCoreGroup.objects.create(_id='another-group') + + MapCoreUserGroup.objects.create( + mapcore_group=self.mapcore_group1, + user=self.user + ) + MapCoreUserGroup.objects.create( + mapcore_group=self.mapcore_group2, + user=self.user + ) + + self.url = f'/{API_BASE}map_core/groups/' + + def test_list_mapcore_groups_for_authenticated_user(self): + res = self.app.get(self.url, auth=self.user.auth) + assert res.status_code == 200 + assert len(res.json['data']) == 2 + + item = res.json['data'][0] + assert 'id' in item + assert 'attributes' in item + assert 'links' in item + assert item['type'] == 'mapcore-groups' + + def test_search_param_filters_results(self): + # Create an extra group that won't match the search term + MapCoreGroup.objects.create(_id='zzz-unmatched') + + # Search for 'another' should only return the matching group(s) + res = self.app.get(f'{self.url}?search=another', auth=self.user.auth) + assert res.status_code == 200 + data = res.json['data'] + assert len(data) == 1 + assert data[0]['attributes']['name'] == 'another-group' diff --git a/api_tests/nodes/serializers/test_mapcore_group_serializers.py b/api_tests/nodes/serializers/test_mapcore_group_serializers.py new file mode 100644 index 00000000000..42e50a9b816 --- /dev/null +++ b/api_tests/nodes/serializers/test_mapcore_group_serializers.py @@ -0,0 +1,1167 @@ +import pytest +from unittest.mock import patch, MagicMock +from django.contrib.auth.models import Group as AuthGroup +from rest_framework import exceptions + +from api.nodes.serializers import ( + NodeMapCoreGroupSerializer, + NodeMapCoreGroupCreateSerializer, + NodeMapCoreGroupUpdateSerializer, + NodeSerializer +) +from osf.models.mapcore_group import MapCoreGroup +from osf.models.mapcore_node_group import MapCoreNodeGroup +from osf_tests.factories import AuthUserFactory, NodeFactory +from tests.utils import make_drf_request_with_version +from website import settings as website_settings + + +@pytest.fixture() +def user(): + return AuthUserFactory() + + +@pytest.fixture() +def node(user): + return NodeFactory(creator=user) + + +@pytest.fixture() +def mapcore_group(): + return MapCoreGroup.objects.create(_id='test-group-1') + + +@pytest.fixture() +def auth_group(node): + return AuthGroup.objects.get_or_create(name=f'node_{node.id}_admin')[0] + + +@pytest.fixture() +def mapcore_node_group(node, mapcore_group, auth_group, user): + return MapCoreNodeGroup.objects.create( + node=node, + group=auth_group, + mapcore_group=mapcore_group, + creator=user, + ) + + +@pytest.mark.django_db +class TestNodeMapCoreGroupSerializer: + """Test cases for NodeMapCoreGroupSerializer""" + + def test_basic_serialization(self, mapcore_node_group, node): + """Test basic serialization of MapCoreNodeGroup""" + # Simulate permissions attached by view + mapcore_node_group.permissions = ['admin'] + + req = make_drf_request_with_version(version='2.0') + result = NodeMapCoreGroupSerializer( + mapcore_node_group, + context={'request': req, 'node': node} + ).data + data = result['data'] + + # Test top-level structure + assert data['id'] == mapcore_node_group.id + assert data['type'] == 'node-mapcore-group' + + # Test attributes + attrs = data['attributes'] + assert attrs['node_group_id'] == mapcore_node_group.id + assert attrs['creator_id'] == mapcore_node_group.creator.id + assert attrs['creator'] == mapcore_node_group.creator.fullname + assert attrs['permission'] == 'admin' + assert attrs['mapcore_group_id'] == mapcore_node_group.mapcore_group.id + assert attrs['name'] == mapcore_node_group.mapcore_group._id + assert 'created' in attrs + assert 'modified' in attrs + + # Test links + expected_url = f'{website_settings.MAPCORE_GROUP_HOSTNAME}{website_settings.MAPCORE_GROUP_API_PATH}{mapcore_node_group.mapcore_group._id}' + assert data['links']['self'] == expected_url + + def test_get_permission_with_multiple_permissions(self, mapcore_node_group, node): + """Test that get_permission returns highest permission""" + # Test admin priority + mapcore_node_group.permissions = ['read', 'write', 'admin'] + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupSerializer( + mapcore_node_group, + context={'request': req, 'node': node} + ) + assert serializer.get_permission(mapcore_node_group) == 'admin' + + # Test write priority over read + mapcore_node_group.permissions = ['read', 'write'] + assert serializer.get_permission(mapcore_node_group) == 'write' + + # Test read only + mapcore_node_group.permissions = ['read'] + assert serializer.get_permission(mapcore_node_group) == 'read' + + def test_get_permission_no_permissions(self, mapcore_node_group, node): + """Test get_permission returns None when no permissions""" + mapcore_node_group.permissions = [] + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupSerializer( + mapcore_node_group, + context={'request': req, 'node': node} + ) + assert serializer.get_permission(mapcore_node_group) is None + + def test_get_permission_unknown_permissions(self, mapcore_node_group, node): + """Test get_permission with unknown permissions""" + mapcore_node_group.permissions = ['unknown', 'invalid'] + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupSerializer( + mapcore_node_group, + context={'request': req, 'node': node} + ) + assert serializer.get_permission(mapcore_node_group) is None + + def test_get_permission_missing_permissions_attribute(self, mapcore_node_group, node): + """Test get_permission when permissions attribute is missing""" + # Don't set permissions attribute at all + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupSerializer( + mapcore_node_group, + context={'request': req, 'node': node} + ) + + # Should default to empty list and return None + assert serializer.get_permission(mapcore_node_group) is None + + +@pytest.mark.django_db +class TestNodeMapCoreGroupCreateSerializer: + """Test cases for NodeMapCoreGroupCreateSerializer""" + + def setup_auth_groups(self, node): + """Helper to create auth groups for a node""" + groups = {} + for perm in ['read', 'write', 'admin']: + groups[perm] = AuthGroup.objects.get_or_create(name=f'node_{node.id}_{perm}')[0] + return groups + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_create_basic(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test basic creation of MapCoreNodeGroup""" + # Setup + auth_groups = self.setup_auth_groups(node) + mapcore_group = MapCoreGroup.objects.create(_id='test-group-create') + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group.id, + 'permission': 'admin' + } + ] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupCreateSerializer( + context={'request': req, 'node': node} + ) + + # Execute + result = serializer.create(validated_data) + + # Verify + assert len(result) == 1 + response_item = result[0] + assert response_item['type'] == 'node-mapcore-group' + assert response_item['attributes']['permission'] == 'admin' + assert response_item['attributes']['mapcore_group_id'] == mapcore_group.id + + # Verify database + mcng = MapCoreNodeGroup.objects.get( + node=node, + mapcore_group=mapcore_group, + is_deleted=False + ) + assert mcng.group == auth_groups['admin'] + assert mcng.creator == user + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_create_with_components(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test creation with component nodes""" + # Setup + auth_groups = self.setup_auth_groups(node) + component1 = NodeFactory(creator=user, parent=node) + component2 = NodeFactory(creator=user, parent=node) + node.descendants.add(component1, component2) + mapcore_group = MapCoreGroup.objects.create(_id='test-group-components') + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group.id, + 'permission': 'write', + 'visible': True + } + ], + 'component_ids': [component1._id, component2._id] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupCreateSerializer( + context={'request': req, 'node': node} + ) + + # Execute + result = serializer.create(validated_data) + # Verify + assert len(result) == 1 + + # Verify main node relationship created + main_mcng = MapCoreNodeGroup.objects.get( + node=node, + mapcore_group=mapcore_group, + is_deleted=False + ) + assert main_mcng.group == auth_groups['write'] + + # Verify component relationships created + for component in [component1, component2]: + comp_mcng = MapCoreNodeGroup.objects.get( + node=component, + mapcore_group=mapcore_group, + is_deleted=False + ) + assert comp_mcng.group == auth_groups['write'] + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_create_with_existing_component_relationship(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test creation with existing component relationship (update scenario)""" + # Setup + auth_groups = self.setup_auth_groups(node) + component = NodeFactory(creator=user, parent=node) + node.descendants.add(component) + mapcore_group = MapCoreGroup.objects.create(_id='test-group-update') + + # Create existing relationship with read permission + existing_mcng = MapCoreNodeGroup.objects.create( + node=component, + mapcore_group=mapcore_group, + group=auth_groups['read'], + creator=user, + is_deleted=False + ) + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group.id, + 'permission': 'admin' # Upgrade to admin + } + ], + 'component_ids': [component._id] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupCreateSerializer( + context={'request': req, 'node': node} + ) + + # Execute + result = serializer.create(validated_data) + # Verify + assert len(result) == 1 + + # Verify component relationship was updated + existing_mcng.refresh_from_db() + assert existing_mcng.group == auth_groups['admin'] + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_create_duplicate_mapcore_group_raises_error(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test that creating duplicate MapCoreNodeGroup raises ValidationError""" + # Setup + auth_groups = self.setup_auth_groups(node) + mapcore_group = MapCoreGroup.objects.create(_id='test-group-duplicate') + + # Create existing relationship + MapCoreNodeGroup.objects.create( + node=node, + mapcore_group=mapcore_group, + group=auth_groups['admin'], + creator=user, + is_deleted=False + ) + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group.id, + 'permission': 'admin' + } + ] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupCreateSerializer( + context={'request': req, 'node': node} + ) + + # Execute and verify exception + with pytest.raises(Exception) as exc_info: + serializer.create(validated_data) + + assert 'MapCoreNodeGroup already exists' in str(exc_info.value) + + def test_load_mapcore_group_not_found(self, user, node): + """Test load_mapcore_group raises NotFound for nonexistent group""" + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupCreateSerializer( + context={'request': req, 'node': node} + ) + + with pytest.raises(exceptions.NotFound) as exc_info: + serializer.load_mapcore_group(99999) + + assert 'MapCore Group with id 99999 does not exist' in str(exc_info.value) + + def test_load_mapcore_group_success(self, user, node): + """Test load_mapcore_group returns correct group""" + mapcore_group = MapCoreGroup.objects.create(_id='test-load-group') + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupCreateSerializer( + context={'request': req, 'node': node} + ) + + result = serializer.load_mapcore_group(mapcore_group.id) + assert result == mapcore_group + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_create_multiple_node_groups(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test creating multiple MapCoreNodeGroups in single call""" + # Setup + auth_groups = self.setup_auth_groups(node) + mapcore_group1 = MapCoreGroup.objects.create(_id='test-group-multi-1') + mapcore_group2 = MapCoreGroup.objects.create(_id='test-group-multi-2') + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group1.id, + 'permission': 'admin' + }, + { + 'mapcore_group_id': mapcore_group2.id, + 'permission': 'write' + } + ] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupCreateSerializer( + context={'request': req, 'node': node} + ) + + # Execute + result = serializer.create(validated_data) + + # Verify + assert len(result) == 2 + + # Verify both relationships created with correct permissions + mcng1 = MapCoreNodeGroup.objects.get( + node=node, + mapcore_group=mapcore_group1, + is_deleted=False + ) + assert mcng1.group == auth_groups['admin'] + + mcng2 = MapCoreNodeGroup.objects.get( + node=node, + mapcore_group=mapcore_group2, + is_deleted=False + ) + assert mcng2.group == auth_groups['write'] + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_create_component_two_groups_update_and_add_new(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Create for a component with two mapcore groups: one updates, one is added""" + # Setup auth groups + auth_groups = self.setup_auth_groups(node) + + # Create a component and two mapcore groups (one existing on component, one new) + component = NodeFactory(creator=user, parent=node) + node.descendants.add(component) + existing_mapcore_group = MapCoreGroup.objects.create(_id='test-component-existing') + new_mapcore_group = MapCoreGroup.objects.create(_id='test-component-new') + + # Existing relationship on the component (read) + existing_mcng = MapCoreNodeGroup.objects.create( + node=component, + mapcore_group=existing_mapcore_group, + group=auth_groups['read'], + creator=user, + is_deleted=False + ) + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'mapcore_group_id': existing_mapcore_group.id, + 'permission': 'admin' # upgrade existing on component + }, + { + 'mapcore_group_id': new_mapcore_group.id, + 'permission': 'write' # add new to component + } + ], + 'component_ids': [component._id] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupCreateSerializer( + context={'request': req, 'node': node} + ) + + # Execute + result = serializer.create(validated_data) + + # Verify serializer response for two items + assert len(result) == 2 + + # Verify existing relationship was updated on the component + existing_mcng.refresh_from_db() + assert existing_mcng.group == auth_groups['admin'] + + # Verify new relationship was created for the component + new_mcng = MapCoreNodeGroup.objects.get( + node=component, + mapcore_group=new_mapcore_group, + is_deleted=False + ) + assert new_mcng.group == auth_groups['write'] + + # Optionally verify main node relationships were also created/updated + main_existing = MapCoreNodeGroup.objects.get(node=node, mapcore_group=existing_mapcore_group, is_deleted=False) + assert main_existing.group == auth_groups['admin'] + main_new = MapCoreNodeGroup.objects.get(node=node, mapcore_group=new_mapcore_group, is_deleted=False) + assert main_new.group == auth_groups['write'] + + +@pytest.mark.django_db +class TestNodeMapCoreGroupUpdateSerializer: + """Test cases for NodeMapCoreGroupUpdateSerializer""" + + def setup_auth_groups(self, node): + """Helper to create auth groups for a node""" + groups = {} + for perm in ['read', 'write', 'admin']: + groups[perm] = AuthGroup.objects.get_or_create(name=f'node_{node.id}_{perm}')[0] + return groups + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_update_basic(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test basic update of MapCoreNodeGroup permissions""" + # Setup + auth_groups = self.setup_auth_groups(node) + mapcore_group = MapCoreGroup.objects.create(_id='test-group-update') + + # Create existing relationship with read permission + existing_mcng = MapCoreNodeGroup.objects.create( + node=node, + mapcore_group=mapcore_group, + group=auth_groups['read'], + creator=user, + is_deleted=False + ) + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'node_group_id': existing_mcng.id, + 'permission': 'admin' # Update to admin + } + ] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupUpdateSerializer( + context={'request': req, 'node': node} + ) + + # Execute + result = serializer.create(validated_data) + + # Verify response + assert len(result) == 1 + response_item = result[0] + assert response_item['attributes']['permission'] == 'admin' + assert response_item['attributes']['node_group_id'] == existing_mcng.id + + # Verify database update + existing_mcng.refresh_from_db() + assert existing_mcng.group == auth_groups['admin'] + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_update_multiple_node_groups(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test updating multiple MapCoreNodeGroups""" + # Setup + auth_groups = self.setup_auth_groups(node) + mapcore_group1 = MapCoreGroup.objects.create(_id='test-group-update-1') + mapcore_group2 = MapCoreGroup.objects.create(_id='test-group-update-2') + + # Create existing relationships + mcng1 = MapCoreNodeGroup.objects.create( + node=node, + mapcore_group=mapcore_group1, + group=auth_groups['read'], + creator=user, + is_deleted=False + ) + mcng2 = MapCoreNodeGroup.objects.create( + node=node, + mapcore_group=mapcore_group2, + group=auth_groups['read'], + creator=user, + is_deleted=False + ) + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'node_group_id': mcng1.id, + 'permission': 'admin' + }, + { + 'node_group_id': mcng2.id, + 'permission': 'write' + } + ] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupUpdateSerializer( + context={'request': req, 'node': node} + ) + + # Execute + result = serializer.create(validated_data) + + # Verify response + assert len(result) == 2 + + # Verify database updates + mcng1.refresh_from_db() + mcng2.refresh_from_db() + assert mcng1.group == auth_groups['admin'] + assert mcng2.group == auth_groups['write'] + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_update_no_permission_change(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test update with no permission provided (should skip)""" + # Setup + auth_groups = self.setup_auth_groups(node) + mapcore_group = MapCoreGroup.objects.create(_id='test-group-no-change') + + existing_mcng = MapCoreNodeGroup.objects.create( + node=node, + mapcore_group=mapcore_group, + group=auth_groups['read'], + creator=user, + is_deleted=False, + visible=True, + _order=0 + ) + original_modified = existing_mcng.modified + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'node_group_id': existing_mcng.id, + 'permission': None, # No permission change + 'visible': True, # No visible change + '_order': 0 + } + ] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupUpdateSerializer( + context={'request': req, 'node': node} + ) + + # Execute + result = serializer.create(validated_data) + + # Verify no changes made + assert len(result) == 1 + existing_mcng.refresh_from_db() + assert existing_mcng.group == auth_groups['read'] # Unchanged + assert existing_mcng.visible is True # Unchanged + assert existing_mcng._order == 0 # Unchanged + assert existing_mcng.modified != original_modified # Changed + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_update_nonexistent_node_group_id(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test update with nonexistent node_group_id (should be ignored)""" + auth_groups = self.setup_auth_groups(node) + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'node_group_id': 99999, # Nonexistent ID + 'permission': 'admin' + } + ] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupUpdateSerializer( + context={'request': req, 'node': node} + ) + + # Execute + result = serializer.create(validated_data) + + # Verify no updates made + assert len(result) == 0 + + def test_load_mapcore_group_not_found(self, user, node): + """Test load_mapcore_group raises NotFound for nonexistent group""" + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupUpdateSerializer( + context={'request': req, 'node': node} + ) + + with pytest.raises(exceptions.NotFound) as exc_info: + serializer.load_mapcore_group(99999) + + assert 'MapCore Group with id 99999 does not exist' in str(exc_info.value) + + def test_load_mapcore_group_success(self, user, node): + """Test load_mapcore_group returns correct group""" + mapcore_group = MapCoreGroup.objects.create(_id='test-load-group') + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupUpdateSerializer( + context={'request': req, 'node': node} + ) + + result = serializer.load_mapcore_group(mapcore_group.id) + assert result == mapcore_group + + +@pytest.mark.django_db +class TestGetGroupByNode: + """Test cases for the get_group_by_node helper function""" + + def test_get_group_by_node(self, node): + """Test get_group_by_node returns correct mapping""" + from api.nodes.serializers import get_group_by_node + + # Create auth groups for the node + admin_group = AuthGroup.objects.get_or_create(name=f'node_{node.id}_admin')[0] + write_group = AuthGroup.objects.get_or_create(name=f'node_{node.id}_write')[0] + read_group = AuthGroup.objects.get_or_create(name=f'node_{node.id}_read')[0] + + # Also create some unrelated groups to ensure they're filtered out + AuthGroup.objects.get_or_create(name='unrelated_group')[0] + AuthGroup.objects.get_or_create(name=f'node_{node.id + 1}_admin')[0] # Different node + + result = get_group_by_node(node.id) + + expected = { + 'admin': admin_group.id, + 'write': write_group.id, + 'read': read_group.id + } + assert result == expected + + def test_get_group_by_node_no_groups(self, node): + """Test get_group_by_node with no matching groups""" + from api.nodes.serializers import get_group_by_node + + # Ensure no leftover groups from other tests remain for this node + AuthGroup.objects.filter(name__startswith=f'node_{node.id}_').delete() + + result = get_group_by_node(node.id) + assert result == {} + + def test_get_group_by_node_partial_groups(self, node): + """Test get_group_by_node with only some permission groups""" + from api.nodes.serializers import get_group_by_node + + # Remove any leftover groups for this node to ensure test isolation + AuthGroup.objects.filter(name__startswith=f'node_{node.id}_').delete() + + # Only create admin and read groups, no write + admin_group = AuthGroup.objects.create(name=f'node_{node.id}_admin') + read_group = AuthGroup.objects.create(name=f'node_{node.id}_read') + + result = get_group_by_node(node.id) + + expected = { + 'admin': admin_group.id, + 'read': read_group.id + } + assert result == expected + + +@pytest.mark.django_db +class TestNodeMapCoreGroupSerializerEdgeCases: + """Additional edge case tests for complete coverage""" + + def test_get_permission_missing_permissions_attribute(self, user, node): + """Test get_permission when permissions attribute is missing""" + mapcore_group = MapCoreGroup.objects.create(_id='test-missing-perm') + auth_group = AuthGroup.objects.get_or_create(name=f'node_{node.id}_admin')[0] + mapcore_node_group = MapCoreNodeGroup.objects.create( + node=node, + group=auth_group, + mapcore_group=mapcore_group, + creator=user, + ) + # Don't set permissions attribute at all + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupSerializer( + mapcore_node_group, + context={'request': req, 'node': node} + ) + + # Should default to empty list and return None + assert serializer.get_permission(mapcore_node_group) is None + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_create_with_empty_component_ids(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test creation with empty component_ids list""" + # Setup + auth_groups = {} + for perm in ['read', 'write', 'admin']: + auth_groups[perm] = AuthGroup.objects.get_or_create(name=f'node_{node.id}_{perm}')[0] + + mapcore_group = MapCoreGroup.objects.create(_id='test-group-empty-components') + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group.id, + 'permission': 'admin' + } + ], + 'component_ids': [] # Empty list + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupCreateSerializer( + context={'request': req, 'node': node} + ) + + # Execute + result = serializer.create(validated_data) + + # Verify only main node relationship created + assert len(result) == 1 + mcng = MapCoreNodeGroup.objects.get( + node=node, + mapcore_group=mapcore_group, + is_deleted=False + ) + assert mcng.group == auth_groups['admin'] + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_create_node_logging(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test that node logging occurs during creation""" + # Setup + auth_groups = {} + for perm in ['read', 'write', 'admin']: + auth_groups[perm] = AuthGroup.objects.get_or_create(name=f'node_{node.id}_{perm}')[0] + + mapcore_group = MapCoreGroup.objects.create(_id='test-group-logging') + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group.id, + 'permission': 'admin' + } + ] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupCreateSerializer( + context={'request': req, 'node': node} + ) + + original_modified = node.modified + + # Execute + result = serializer.create(validated_data) + assert len(result) == 1 + + # Verify node was modified (for logging) + node.refresh_from_db() + assert node.modified > original_modified + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_update_empty_permission_string(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test update with empty permission string""" + # Setup + auth_groups = {} + for perm in ['read', 'write', 'admin']: + auth_groups[perm] = AuthGroup.objects.get_or_create(name=f'node_{node.id}_{perm}')[0] + + mapcore_group = MapCoreGroup.objects.create(_id='test-group-empty-perm') + + existing_mcng = MapCoreNodeGroup.objects.create( + node=node, + mapcore_group=mapcore_group, + group=auth_groups['read'], + creator=user, + is_deleted=False + ) + original_modified = existing_mcng.modified + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'node_group_id': existing_mcng.id, + 'permission': '' # Empty permission string + } + ] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupUpdateSerializer( + context={'request': req, 'node': node} + ) + + # Execute + result = serializer.create(validated_data) + + # Verify no changes made (empty string is falsy) + assert len(result) == 1 + existing_mcng.refresh_from_db() + assert existing_mcng.group == auth_groups['read'] # Unchanged + assert existing_mcng.modified != original_modified # Changed + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_update_node_logging(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test that node logging occurs during update""" + # Setup + auth_groups = {} + for perm in ['read', 'write', 'admin']: + auth_groups[perm] = AuthGroup.objects.get_or_create(name=f'node_{node.id}_{perm}')[0] + + mapcore_group = MapCoreGroup.objects.create(_id='test-group-update-logging') + + existing_mcng = MapCoreNodeGroup.objects.create( + node=node, + mapcore_group=mapcore_group, + group=auth_groups['read'], + creator=user, + is_deleted=False + ) + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'node_group_id': existing_mcng.id, + 'permission': 'admin' + } + ] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupUpdateSerializer( + context={'request': req, 'node': node} + ) + + original_modified = node.modified + + # Execute + result = serializer.create(validated_data) + assert len(result) == 1 + + # Verify node was modified (for logging) + node.refresh_from_db() + assert node.modified > original_modified + + def setup_auth_groups(self, node): + """Helper to create auth groups for a node""" + groups = {} + for perm in ['read', 'write', 'admin']: + groups[perm] = AuthGroup.objects.get_or_create(name=f'node_{node.id}_{perm}')[0] + return groups + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_update_visible_only(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test updating only the visible field""" + auth_groups = self.setup_auth_groups(node) + mapcore_group = MapCoreGroup.objects.create(_id='test-group-visible') + existing_mcng = MapCoreNodeGroup.objects.create( + node=node, mapcore_group=mapcore_group, group=auth_groups['read'], + creator=user, is_deleted=False, visible=True, _order=0 + ) + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = {'read': auth_groups['read'].id} + + validated_data = {'node_groups': [{'node_group_id': existing_mcng.id, 'visible': False}]} + serializer = NodeMapCoreGroupUpdateSerializer(context={'request': make_drf_request_with_version(), 'node': node}) + serializer.create(validated_data) + + existing_mcng.refresh_from_db() + assert existing_mcng.visible is False + assert existing_mcng._order == 0 # Unchanged + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_update_order_only(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test updating only the _order field based on list index""" + auth_groups = self.setup_auth_groups(node) + mapcore_group = MapCoreGroup.objects.create(_id='test-group-order') + existing_mcng = MapCoreNodeGroup.objects.create( + node=node, mapcore_group=mapcore_group, group=auth_groups['read'], + creator=user, is_deleted=False, visible=True, _order=0 + ) + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = {'read': auth_groups['read'].id} + + # The _order field in the request is ignored; order is based on list index. + validated_data = {'node_groups': [{'node_group_id': existing_mcng.id}]} + serializer = NodeMapCoreGroupUpdateSerializer(context={'request': make_drf_request_with_version(), 'node': node}) + serializer.create(validated_data) + + existing_mcng.refresh_from_db() + assert existing_mcng.visible is True # Unchanged + assert existing_mcng._order == 0 # Based on index + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_update_visible_and_order(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test updating both visible and _order fields""" + auth_groups = self.setup_auth_groups(node) + mapcore_group = MapCoreGroup.objects.create(_id='test-group-visible-order') + existing_mcng = MapCoreNodeGroup.objects.create( + node=node, mapcore_group=mapcore_group, group=auth_groups['read'], + creator=user, is_deleted=False, visible=True, _order=1 + ) + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = {'read': auth_groups['read'].id} + + # _order is determined by index (0), not the passed value. + validated_data = {'node_groups': [{'node_group_id': existing_mcng.id, 'visible': False}]} + serializer = NodeMapCoreGroupUpdateSerializer(context={'request': make_drf_request_with_version(), 'node': node}) + serializer.create(validated_data) + + existing_mcng.refresh_from_db() + assert existing_mcng.visible is False + assert existing_mcng._order == 0 # Updated to 0 based on index + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_reorder_multiple_groups(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test that sending a list of groups updates their order""" + auth_groups = self.setup_auth_groups(node) + mc_group1 = MapCoreGroup.objects.create(_id='reorder-1') + mc_group2 = MapCoreGroup.objects.create(_id='reorder-2') + + mcng1 = MapCoreNodeGroup.objects.create( + node=node, mapcore_group=mc_group1, group=auth_groups['read'], + creator=user, _order=0 + ) + mcng2 = MapCoreNodeGroup.objects.create( + node=node, mapcore_group=mc_group2, group=auth_groups['write'], + creator=user, _order=1 + ) + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'read': auth_groups['read'].id, + 'write': auth_groups['write'].id + } + + # Reverse the order in the request + validated_data = { + 'node_groups': [ + {'node_group_id': mcng2.id}, # Should become order 0 + {'node_group_id': mcng1.id}, # Should become order 1 + ] + } + serializer = NodeMapCoreGroupUpdateSerializer(context={'request': make_drf_request_with_version(), 'node': node}) + serializer.create(validated_data) + + mcng1.refresh_from_db() + mcng2.refresh_from_db() + + assert mcng1._order == 1 + assert mcng2._order == 0 + + +@pytest.mark.django_db +class TestNodeSerializerMapCoreIntegration: + """Test NodeSerializer get_node_count and node creation with MapCore group""" + + def test_get_node_count(self, node): + """Test get_node_count returns correct count""" + NodeFactory(creator=node.creator, parent=node, is_deleted=False, is_public=True) + NodeFactory(creator=node.creator, parent=node, is_deleted=False, is_public=True) + + req = make_drf_request_with_version(version='2.0') + serializer = NodeSerializer(instance=node, context={'request': req}) + count = serializer.get_node_count(node) + assert count == 2 + + @pytest.mark.django_db + def test_create_node_with_mapcore_group_parent_writable(self, user): + # Create a MapCore group and parent node + mapcore_group = MapCoreGroup.objects.create(_id='test-mapcore-create-parent-writable') + parent_node = NodeFactory(creator=user) + + # Attach a MapCoreNodeGroup to the parent so it can be inherited + auth_group = AuthGroup.objects.get_or_create(name=f'node_{parent_node.id}_admin')[0] + MapCoreNodeGroup.objects.create( + node=parent_node, + group=auth_group, + mapcore_group=mapcore_group, + creator=user, + is_deleted=False, + ) + + # Grant the test user write permission on the parent so has_permission(...) returns True + parent_node.add_contributor(user, permissions='admin', save=True) + assert parent_node.has_permission(user, 'write') + user.is_registered = True + user.save() + # Prepare request that requests inheritance + req = make_drf_request_with_version(version='2.0') + req._request.GET = {'inherit_contributors': 'true'} + req.user = user + + validated_data = { + 'title': 'Child Node inheriting MapCore', + 'category': 'project', + 'parent': parent_node, + 'creator': user, + } + + serializer = NodeSerializer(context={'request': req}) + child_node = serializer.create(validated_data) + + # Verify a MapCoreNodeGroup was copied from parent to child + assert MapCoreNodeGroup.objects.filter(node=child_node, mapcore_group=mapcore_group, is_deleted=False).exists() diff --git a/api_tests/nodes/views/test_node_mapcore_group_views.py b/api_tests/nodes/views/test_node_mapcore_group_views.py new file mode 100644 index 00000000000..23effc4d69e --- /dev/null +++ b/api_tests/nodes/views/test_node_mapcore_group_views.py @@ -0,0 +1,890 @@ +import pytest +from django.contrib.auth.models import Group as AuthGroup + +from api.base.settings.defaults import API_BASE +from osf.models.mapcore_group import MapCoreGroup +from osf.models.mapcore_node_group import MapCoreNodeGroup +from osf.models.mapcore_user_group import MapCoreUserGroup +from osf_tests.factories import AuthUserFactory, NodeFactory, ProjectFactory, UserFactory +from tests.base import ApiTestCase + +@pytest.mark.django_db +class TestNodeMapCoreGroupList(ApiTestCase): + """Test cases for NodeMapCoreGroupList view""" + + def setUp(self): + super().setUp() + self.user = AuthUserFactory() + self.admin_user = AuthUserFactory() + self.read_only_user = AuthUserFactory() + + self.node = ProjectFactory(creator=self.admin_user, is_public=False) + self.node.add_contributor(self.user, permissions='admin') + self.node.add_contributor(self.read_only_user, permissions='read') + self.node.save() + + # Create auth groups for the node + self.auth_groups = {} + for perm in ['read', 'write', 'admin']: + self.auth_groups[perm] = AuthGroup.objects.get_or_create( + name=f'node_{self.node.id}_{perm}' + )[0] + + # Create MapCoreGroups + self.mapcore_group1 = MapCoreGroup.objects.create(_id='test-mapcore-1') + self.mapcore_group2 = MapCoreGroup.objects.create(_id='test-mapcore-2') + + # Create MapCoreNodeGroup relationships + self.mcng1 = MapCoreNodeGroup.objects.create( + node=self.node, + group=self.auth_groups['admin'], + mapcore_group=self.mapcore_group1, + creator=self.admin_user, + ) + self.mcng2 = MapCoreNodeGroup.objects.create( + node=self.node, + group=self.auth_groups['write'], + mapcore_group=self.mapcore_group2, + creator=self.admin_user, + ) + + self.url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/' + + def test_list_mapcore_groups_success(self): + """Test listing MapCoreNodeGroup relationships""" + res = self.app.get(self.url, auth=self.user.auth) + assert res.status_code == 200 + assert len(res.json['data']) == 2 + + # Verify structure of response + item = res.json['data'][0] + assert 'id' in item + assert item['type'] == 'node-mapcore-group' + assert 'attributes' in item + assert 'links' in item + + def test_list_mapcore_groups_permissions_attached(self): + """Test that permissions are properly attached to serialized objects""" + res = self.app.get(self.url, auth=self.user.auth) + assert res.status_code == 200 + + # Find the item with mapcore_group1 + items = res.json['data'] + item1 = next((i for i in items if i['attributes']['mapcore_group_id'] == self.mapcore_group1.id), None) + assert item1 is not None + assert 'permission' in item1['attributes'] + + def test_list_mapcore_groups_unauthenticated_public_node(self): + """Test listing MapCoreGroups on public node without auth""" + self.node.is_public = True + self.node.save() + + res = self.app.get(self.url) + assert res.status_code == 200 + + def test_list_mapcore_groups_unauthenticated_private_node(self): + """Test listing MapCoreGroups on private node without auth returns 401""" + res = self.app.get(self.url, expect_errors=True) + assert res.status_code == 401 + + def test_list_mapcore_groups_read_only_user(self): + """Test read-only user can list MapCoreGroups on public node""" + self.node.is_public = True + self.node.save() + + res = self.app.get(self.url, auth=self.read_only_user.auth) + assert res.status_code == 200 + + def test_list_mapcore_groups_ordering(self): + """Test that MapCoreGroups are ordered by mapcore_group___id""" + # Create another group with _id that sorts before 'test-mapcore-1' + mapcore_group3 = MapCoreGroup.objects.create(_id='aaa-test-mapcore') + MapCoreNodeGroup.objects.create( + node=self.node, + group=self.auth_groups['read'], + mapcore_group=mapcore_group3, + creator=self.admin_user, + ) + + res = self.app.get(self.url, auth=self.user.auth) + assert res.status_code == 200 + + # Check ordering + items = res.json['data'] + assert len(items) == 3 + # Should be ordered by mapcore_group___id + assert items[0]['attributes']['name'] == 'test-mapcore-1' + assert items[1]['attributes']['name'] == 'test-mapcore-2' + assert items[2]['attributes']['name'] == 'aaa-test-mapcore' + + def test_create_mapcore_group_success(self): + """Test creating a new MapCoreNodeGroup relationship""" + mapcore_group3 = MapCoreGroup.objects.create(_id='test-mapcore-3') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group3.id, + 'permission': 'write' + } + ] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.admin_user.auth) + assert res.status_code == 201 + assert len(res.json['data']) == 1 + + created_item = res.json['data'][0] + assert created_item['attributes']['mapcore_group_id'] == mapcore_group3.id + assert created_item['attributes']['permission'] == 'write' + + # Verify database + mcng = MapCoreNodeGroup.objects.get( + node=self.node, + mapcore_group=mapcore_group3, + is_deleted=False + ) + assert mcng.group == self.auth_groups['write'] + + def test_create_mapcore_group_multiple(self): + """Test creating multiple MapCoreNodeGroup relationships at once""" + mapcore_group3 = MapCoreGroup.objects.create(_id='test-mapcore-3') + mapcore_group4 = MapCoreGroup.objects.create(_id='test-mapcore-4') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group3.id, + 'permission': 'write' + }, + { + 'mapcore_group_id': mapcore_group4.id, + 'permission': 'admin' + } + ] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.admin_user.auth) + assert res.status_code == 201 + assert len(res.json['data']) == 2 + + def test_create_mapcore_group_with_components(self): + """Test creating MapCoreNodeGroup with component nodes""" + component1 = NodeFactory(creator=self.admin_user, parent=self.node) + component2 = NodeFactory(creator=self.admin_user, parent=self.node) + auth_groups_component = {} + for component in [component1, component2]: + auth_groups_component[component.id] = AuthGroup.objects.get_or_create( + name=f'node_{component.id}_write' + )[0] + mapcore_group3 = MapCoreGroup.objects.create(_id='test-mapcore-3') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group3.id, + 'permission': 'write' + } + ], + 'component_ids': [component1._id, component2._id] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.admin_user.auth) + assert res.status_code == 201 + + # Verify main node relationship + mcng_main = MapCoreNodeGroup.objects.get( + node=self.node, + mapcore_group=mapcore_group3, + is_deleted=False + ) + assert mcng_main.group == self.auth_groups['write'] + + # Verify component relationships + for component in [component1, component2]: + mcng_comp = MapCoreNodeGroup.objects.get( + node=component, + mapcore_group=mapcore_group3, + is_deleted=False + ) + assert mcng_comp.group == auth_groups_component[component.id] + + def test_create_mapcore_group_empty_node_groups_fails(self): + """Test creating with empty node_groups fails""" + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'non-empty' in res.json['errors'][0]['detail'].lower() + + def test_create_mapcore_group_missing_required_fields(self): + """Test creating without required fields fails""" + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': 123 + # Missing 'permission' + } + ] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'permission' in res.json['errors'][0]['detail'].lower() + + def test_create_mapcore_group_invalid_permission(self): + """Test creating with invalid permission fails""" + mapcore_group3 = MapCoreGroup.objects.create(_id='test-mapcore-3') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group3.id, + 'permission': 'invalid_permission' + } + ] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'invalid' in res.json['errors'][0]['detail'].lower() + + def test_create_mapcore_group_duplicate_in_request(self): + """Test creating with duplicate mapcore_group_id in request fails""" + mapcore_group3 = MapCoreGroup.objects.create(_id='test-mapcore-3') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group3.id, + 'permission': 'write' + }, + { + 'mapcore_group_id': mapcore_group3.id, + 'permission': 'admin' + } + ] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'duplicate' in res.json['errors'][0]['detail'].lower() + + def test_create_mapcore_group_nonexistent_mapcore_group_id(self): + """Test creating with nonexistent mapcore_group_id fails""" + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': 99999, + 'permission': 'write' + } + ] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'not found' in res.json['errors'][0]['detail'].lower() + + def test_create_mapcore_group_already_exists(self): + """Test creating duplicate MapCoreNodeGroup fails""" + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': self.mapcore_group1.id, + 'permission': 'write' + } + ] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'already exists' in res.json['errors'][0]['detail'].lower() + + def test_create_mapcore_group_duplicate_component_ids(self): + """Test creating with duplicate component_ids fails""" + component = NodeFactory(creator=self.admin_user, parent=self.node) + mapcore_group3 = MapCoreGroup.objects.create(_id='test-mapcore-3') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group3.id, + 'permission': 'write' + } + ], + 'component_ids': [component._id, component._id] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'duplicate' in res.json['errors'][0]['detail'].lower() + + def test_create_mapcore_group_nonexistent_component_ids(self): + """Test creating with nonexistent component_ids fails""" + mapcore_group3 = MapCoreGroup.objects.create(_id='test-mapcore-3') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group3.id, + 'permission': 'write' + } + ], + 'component_ids': ['nonexistent123'] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'not found' in res.json['errors'][0]['detail'].lower() + + def test_create_mapcore_group_non_admin_fails(self): + """Test non-admin user cannot create MapCoreNodeGroup""" + mapcore_group3 = MapCoreGroup.objects.create(_id='test-mapcore-3') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group3.id, + 'permission': 'write' + } + ] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.read_only_user.auth, expect_errors=True) + assert res.status_code == 403 + + def test_update_mapcore_group_success(self): + """Test updating MapCoreNodeGroup permission""" + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'node_group_id': self.mcng1.id, + 'permission': 'read' + } + ] + } + } + } + + res = self.app.patch_json(self.url, payload, auth=self.admin_user.auth) + assert res.status_code == 200 + assert len(res.json['data']) == 1 + + updated_item = res.json['data'][0] + assert updated_item['attributes']['permission'] == 'read' + + # Verify database + self.mcng1.refresh_from_db() + assert self.mcng1.group == self.auth_groups['read'] + + def test_update_mapcore_group_multiple(self): + """Test updating multiple MapCoreNodeGroups""" + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'node_group_id': self.mcng1.id, + 'permission': 'read' + }, + { + 'node_group_id': self.mcng2.id, + 'permission': 'admin' + } + ] + } + } + } + + res = self.app.patch_json(self.url, payload, auth=self.admin_user.auth) + assert res.status_code == 200 + assert len(res.json['data']) == 2 + + # Verify database + self.mcng1.refresh_from_db() + self.mcng2.refresh_from_db() + assert self.mcng1.group == self.auth_groups['read'] + assert self.mcng2.group == self.auth_groups['admin'] + + def test_update_mapcore_group_empty_node_groups_fails(self): + """Test updating with empty node_groups fails""" + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [] + } + } + } + + res = self.app.patch_json(self.url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + + def test_update_mapcore_group_duplicate_node_group_ids(self): + """Test updating with duplicate node_group_ids fails""" + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'node_group_id': self.mcng1.id, + 'permission': 'read' + }, + { + 'node_group_id': self.mcng1.id, + 'permission': 'write' + } + ] + } + } + } + + res = self.app.patch_json(self.url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'duplicate' in res.json['errors'][0]['detail'].lower() + + def test_update_mapcore_group_nonexistent_node_group_id(self): + """Test updating with nonexistent node_group_id fails""" + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'node_group_id': 99999, + 'permission': 'read' + } + ] + } + } + } + + res = self.app.patch_json(self.url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'not found' in res.json['errors'][0]['detail'].lower() + + def test_update_mapcore_group_non_admin_fails(self): + """Test non-admin user cannot update MapCoreNodeGroup""" + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'node_group_id': self.mcng1.id, + 'permission': 'read' + } + ] + } + } + } + + res = self.app.patch_json(self.url, payload, auth=self.read_only_user.auth, expect_errors=True) + assert res.status_code == 403 + + +@pytest.mark.django_db +class TestNodeMapCoreGroupRemove(ApiTestCase): + """Test cases for NodeMapCoreGroupRemove view""" + + def setUp(self): + super().setUp() + self.user = AuthUserFactory() + self.admin_user = AuthUserFactory() + self.read_only_user = AuthUserFactory() + + self.node = ProjectFactory(creator=self.admin_user, is_public=False) + self.node.add_contributor(self.user, permissions='admin') + self.node.add_contributor(self.read_only_user, permissions='read') + self.node.save() + + # Create auth groups for the node + self.auth_groups = {} + for perm in ['read', 'write', 'admin']: + self.auth_groups[perm] = AuthGroup.objects.get_or_create( + name=f'node_{self.node.id}_{perm}' + )[0] + + # Create MapCoreGroup + self.mapcore_group = MapCoreGroup.objects.create(_id='test-mapcore-remove') + + # Create MapCoreNodeGroup relationship + self.mcng = MapCoreNodeGroup.objects.create( + node=self.node, + group=self.auth_groups['admin'], + mapcore_group=self.mapcore_group, + creator=self.admin_user, + ) + + def test_delete_mapcore_group_success(self): + """Test deleting a MapCoreNodeGroup relationship""" + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/' + + res = self.app.delete(url, auth=self.admin_user.auth) + assert res.status_code == 204 + + # Verify soft delete in database + self.mcng.refresh_from_db() + assert self.mcng.is_deleted is True + + def test_delete_mapcore_group_with_components(self): + """Test deleting MapCoreNodeGroup with component relationships""" + component1 = NodeFactory(creator=self.admin_user, parent=self.node) + component2 = NodeFactory(creator=self.admin_user, parent=self.node) + + # Create component relationships + mcng_comp1 = MapCoreNodeGroup.objects.create( + node=component1, + group=self.auth_groups['write'], + mapcore_group=self.mapcore_group, + creator=self.admin_user, + ) + mcng_comp2 = MapCoreNodeGroup.objects.create( + node=component2, + group=self.auth_groups['write'], + mapcore_group=self.mapcore_group, + creator=self.admin_user, + ) + + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/?component_ids={component1._id},{component2._id}' + + res = self.app.delete(url, auth=self.admin_user.auth) + assert res.status_code == 204 + + # Verify all relationships are soft deleted + self.mcng.refresh_from_db() + mcng_comp1.refresh_from_db() + mcng_comp2.refresh_from_db() + + assert self.mcng.is_deleted is True + assert mcng_comp1.is_deleted is True + assert mcng_comp2.is_deleted is True + + def test_delete_mapcore_group_duplicate_component_ids_fails(self): + """Test deleting with duplicate component_ids fails""" + component = NodeFactory(creator=self.admin_user, parent=self.node) + + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/?component_ids={component._id},{component._id}' + + res = self.app.delete(url, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'duplicate' in res.json['errors'][0]['detail'].lower() + + def test_delete_mapcore_group_nonexistent_component_ids_fails(self): + """Test deleting with nonexistent component_ids fails""" + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/?component_ids=nonexistent123' + + res = self.app.delete(url, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'not found' in res.json['errors'][0]['detail'].lower() + + def test_delete_mapcore_group_component_not_child_fails(self): + """Test deleting with component_id not a child of the node fails""" + other_node = ProjectFactory(creator=self.admin_user) + component = NodeFactory(creator=self.admin_user, parent=other_node) + + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/?component_ids={component._id}' + + res = self.app.delete(url, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'not children' in res.json['errors'][0]['detail'].lower() + + def test_delete_mapcore_group_component_missing_relationship_fails(self): + """Test deleting component that doesn't have the relationship fails""" + component = NodeFactory(creator=self.admin_user, parent=self.node) + # No MapCoreNodeGroup relationship created for component + + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/?component_ids={component._id}' + + res = self.app.delete(url, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 404 + assert 'not found' in res.json['errors'][0]['detail'].lower() + + def test_delete_mapcore_group_nonexistent_node_group_id_fails(self): + """Test deleting with nonexistent node_group_id fails""" + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/99999/' + + res = self.app.delete(url, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 404 + + def test_delete_mapcore_group_already_deleted_fails(self): + """Test deleting already deleted MapCoreNodeGroup fails""" + self.mcng.is_deleted = True + self.mcng.save() + + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/' + + res = self.app.delete(url, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 404 + + def test_delete_mapcore_group_non_admin_fails(self): + """Test non-admin user cannot delete MapCoreNodeGroup""" + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/' + + res = self.app.delete(url, auth=self.read_only_user.auth, expect_errors=True) + assert res.status_code == 403 + + def test_delete_mapcore_group_unauthenticated_fails(self): + """Test unauthenticated user cannot delete MapCoreNodeGroup""" + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/' + + res = self.app.delete(url, expect_errors=True) + assert res.status_code == 401 + + def test_delete_mapcore_group_creates_log(self): + """Test that deleting creates a log entry""" + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/' + + initial_log_count = self.node.logs.count() + + res = self.app.delete(url, auth=self.admin_user.auth) + assert res.status_code == 204 + + # Verify log was created + self.node.refresh_from_db() + assert self.node.logs.count() == initial_log_count + 1 + + latest_log = self.node.logs.first() + assert latest_log.action == self.node.log_class.MAPCORE_GROUP_REMOVED + + def test_delete_mapcore_group_updates_node_modified(self): + """Test that deleting updates node modified timestamp""" + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/' + + original_modified = self.node.modified + + res = self.app.delete(url, auth=self.admin_user.auth) + assert res.status_code == 204 + + # Verify node modified was updated + self.node.refresh_from_db() + assert self.node.modified > original_modified + + def test_delete_mapcore_group_from_public_node(self): + """Test deleting MapCoreNodeGroup from public node""" + self.node.is_public = True + self.node.save() + + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/' + + res = self.app.delete(url, auth=self.admin_user.auth) + assert res.status_code == 204 + +@pytest.mark.django_db +class TestMixinMapCorePermissions: + def test_mapcore_node_group_get_permission(self): + node = ProjectFactory() + creator = node.creator + mg = MapCoreGroup.objects.create(_id='mcg-parse') + + ag_admin = AuthGroup.objects.create(name=f'node_{node._id}_admin') + ag_read = AuthGroup.objects.create(name=f'node_{node._id}_read') + ag_write = AuthGroup.objects.create(name=f'node_{node._id}_write') + ag_other = AuthGroup.objects.create(name='some_other_group') + + mng_admin = MapCoreNodeGroup.objects.create(node=node, group=ag_admin, mapcore_group=mg, creator=creator) + mng_read = MapCoreNodeGroup.objects.create(node=node, group=ag_read, mapcore_group=mg, creator=creator) + mng_write = MapCoreNodeGroup.objects.create(node=node, group=ag_write, mapcore_group=mg, creator=creator) + mng_other = MapCoreNodeGroup.objects.create(node=node, group=ag_other, mapcore_group=mg, creator=creator) + + assert mng_admin.get_permission == 'admin' + assert mng_read.get_permission == 'read' + assert mng_write.get_permission == 'write' + assert mng_other.get_permission is None + + def test_has_permission_mapcore_grants_and_denies(self): + from django.contrib.auth.models import Group as AuthGroup, Permission + from osf.models.node import NodeGroupObjectPermission + + node = ProjectFactory() + # user that will be "in" the mapcore group + mapcore_user = UserFactory() + # other user not in group + other_user = UserFactory() + + mg = MapCoreGroup.objects.create(_id='mcg-grant') + ag = AuthGroup.objects.create(name=f'node_{node._id}_read') + + # link auth group <-> node via mapcore mapping + MapCoreNodeGroup.objects.create(node=node, group=ag, mapcore_group=mg, creator=node.creator) + + # link user <-> mapcore group + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + # give the auth group the node 'read' permission via NodeGroupObjectPermission + perm = Permission.objects.get(codename='read_node') + NodeGroupObjectPermission.objects.create(group=ag, permission=perm, content_object=node) + + # user who is in mapcore group should have read permission + assert node.has_permission(mapcore_user, 'read') is True + + # user not in mapcore group should not have read permission + assert node.has_permission(other_user, 'read') is False + + def test_has_permission_by_is_admin_group_parent(self): + from django.contrib.auth.models import Group as AuthGroup, Permission + from osf.models.node import NodeGroupObjectPermission + + # Create a root node and a child node + root = ProjectFactory() + child = NodeFactory(creator=root.creator, parent=root) + + # Users + mapcore_user = UserFactory() + other_user = UserFactory() + + # MapCore group and corresponding auth group for the root node (admin) + mg = MapCoreGroup.objects.create(_id='mcg-parent-admin') + ag = AuthGroup.objects.create(name=f'node_{root._id}_admin') + + # Link auth group <-> root node via MapCoreNodeGroup + MapCoreNodeGroup.objects.create(node=root, group=ag, mapcore_group=mg, creator=root.creator) + + # Link user <-> mapcore group + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + # Give the auth group the 'admin_node' permission on the root node + perm = Permission.objects.get(codename='admin_node') + NodeGroupObjectPermission.objects.create(group=ag, permission=perm, content_object=root) + + # Because the auth group on the parent (root) has admin_node, the child should + # grant read permission to users that belong to the MapCore group + assert child.has_permission(mapcore_user, 'read') is True + + # A user not in the linked MapCore group should not get read via this chain + assert child.has_permission(other_user, 'read') is False + + def test_has_permission_handles_mapcore_node_group_filter_exception(self): + from unittest import mock + + node = ProjectFactory() + mapcore_user = UserFactory() + + mg = MapCoreGroup.objects.create(_id='mcg-ex-hasperm') + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + # Simulate MapCoreNodeGroup.objects.filter raising an exception both where used in has_permission + # and potential calls to is_admin_group_parent (which also calls MapCoreNodeGroup.objects.filter). + with mock.patch('osf.models.mapcore_node_group.MapCoreNodeGroup.objects.filter', side_effect=Exception('boom')): + # Should not raise; should fall back to normal permission checks and return False + assert node.has_permission(mapcore_user, 'read') is False + + def test_get_permissions_mapcore_includes_and_excludes(self): + from django.contrib.auth.models import Group as AuthGroup, Permission + from osf.models.node import NodeGroupObjectPermission + + node = ProjectFactory() + mapcore_user = UserFactory() + other_user = UserFactory() + + mg = MapCoreGroup.objects.create(_id='mcg-getperms') + ag = AuthGroup.objects.create(name=f'node_{node._id}_read') + + MapCoreNodeGroup.objects.create(node=node, group=ag, mapcore_group=mg, creator=node.creator) + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + perm = Permission.objects.get(codename='read_node') + NodeGroupObjectPermission.objects.create(group=ag, permission=perm, content_object=node) + + perms_mapcore = node.get_permissions(mapcore_user) + assert 'read' in perms_mapcore + + perms_other = node.get_permissions(other_user) + # other_user has no group-derived permission and is not a contributor, expect no 'read' + assert 'read' not in perms_other + + def test_get_permissions_handles_mapcore_node_group_filter_exception(self): + from unittest import mock + + node = ProjectFactory() + user = UserFactory() + + # Create a MapCoreGroup and link the user (so MapCoreUserGroup.filter would normally return ids) + mg = MapCoreGroup.objects.create(_id='mcg-ex-getperms') + MapCoreUserGroup.objects.create(user=user, mapcore_group=mg, is_deleted=False) + + # Simulate MapCoreNodeGroup.objects.filter raising an exception + with mock.patch('osf.models.mapcore_node_group.MapCoreNodeGroup.objects.filter', side_effect=Exception('boom')): + perms = node.get_permissions(user) + + # Should handle exception and return an empty list (no derived group perms) + assert perms == [] + + def test_is_admin_group_parent_handles_mapcore_node_group_filter_exception(self): + from unittest import mock + from osf.models.mixins import is_admin_group_parent + + parent = ProjectFactory() + mg = MapCoreGroup.objects.create(_id='mcg-ex-isadmin') + # user_mapcore_group_ids could be anything; if MapCoreNodeGroup.filter raises, function should return False + user_mapcore_group_ids = [mg.id] + + with mock.patch('osf.models.mapcore_node_group.MapCoreNodeGroup.objects.filter', side_effect=Exception('boom')): + assert is_admin_group_parent(parent, user_mapcore_group_ids) is False diff --git a/api_tests/users/serializers/test_serializers.py b/api_tests/users/serializers/test_serializers.py index b974daffda1..26395cc2666 100644 --- a/api_tests/users/serializers/test_serializers.py +++ b/api_tests/users/serializers/test_serializers.py @@ -248,3 +248,43 @@ def test_user_serializer_get_can_create_project(self, user): req.user = user result = UserSerializer(user, context={'request': req}) assert result.get_can_create_new_project(user) is True + + +@pytest.mark.django_db +@pytest.mark.enable_quickfiles_creation +class TestUserNodeSerializer: + params = {} + + def test_get_mapcore_groups(self, user): + from osf.models.mapcore_group import MapCoreGroup + from osf.models.mapcore_node_group import MapCoreNodeGroup + from django.contrib.auth.models import Group as AuthGroup + from api.users.serializers import UserNodeSerializer + from osf_tests.factories import ProjectFactory + + node = ProjectFactory(creator=user) + + # Create two MapCoreGroup records + g1 = MapCoreGroup.objects.create(_id='group-one') + g2 = MapCoreGroup.objects.create(_id='group-two') + + # Create auth groups required by MapCoreNodeGroup + ag1 = AuthGroup.objects.create(name=f'node_{node._id}_read') + ag2 = AuthGroup.objects.create(name=f'node_{node._id}_write') + + # Attach both groups to the node; add one deleted entry to ensure it's ignored + MapCoreNodeGroup.objects.create(node=node, group=ag1, mapcore_group=g1, creator=user) + MapCoreNodeGroup.objects.create(node=node, group=ag2, mapcore_group=g2, creator=user) + MapCoreNodeGroup.objects.create(node=node, group=ag2, mapcore_group=g2, creator=user, is_deleted=True) + + # Build a request and serializer with context (TaxonomizableSerializerMixin expects request) + req = make_drf_request_with_version(version='2.0') + req.user = user + serializer = UserNodeSerializer(context={'request': req}) + + # Serializer should return mapcore group _ids ordered by _id + result = serializer.get_mapcore_groups(node) + assert result == [g1._id, g2._id] + + # Non-node input should return an empty list + assert serializer.get_mapcore_groups({}) == [] diff --git a/osf/migrations/0261_mapcoregroup_mapcorenodegroup_mapcoreusergroup.py b/osf/migrations/0261_mapcoregroup_mapcorenodegroup_mapcoreusergroup.py new file mode 100644 index 00000000000..1a664541b2c --- /dev/null +++ b/osf/migrations/0261_mapcoregroup_mapcorenodegroup_mapcoreusergroup.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.28 on 2025-11-04 08:32 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import osf.models.base + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0260_merge_20251126_1230'), + ] + + operations = [ + migrations.CreateModel( + name='MapCoreGroup', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('_id', models.CharField(max_length=255, unique=True)), + ], + options={ + 'db_table': 'osf_mapcore_group', + }, + bases=(models.Model, osf.models.base.QuerySetExplainMixin), + ), + migrations.CreateModel( + name='MapCoreNodeGroup', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('is_deleted', models.BooleanField(default=False)), + ('creator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mapcore_node_group_creator', to=settings.AUTH_USER_MODEL)), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auth_group_mapcore_nodes', to='auth.Group')), + ('mapcore_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mapcore_group_nodes', to='osf.MapCoreGroup')), + ('node', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mapcore_node_groups', to='osf.Node')), + ], + options={ + 'db_table': 'osf_mapcore_node_group', + }, + bases=(models.Model, osf.models.base.QuerySetExplainMixin), + ), + migrations.CreateModel( + name='MapCoreUserGroup', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('is_deleted', models.BooleanField(default=False)), + ('mapcore_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mapcore_user_groups', to='osf.MapCoreGroup')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mapcore_user_groups', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'osf_mapcore_user_group', + }, + bases=(models.Model, osf.models.base.QuerySetExplainMixin), + ), + ] diff --git a/osf/migrations/0262_auto_20260202_0643.py b/osf/migrations/0262_auto_20260202_0643.py new file mode 100644 index 00000000000..aa5f3ebc5e0 --- /dev/null +++ b/osf/migrations/0262_auto_20260202_0643.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.28 on 2026-02-02 06:43 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0261_mapcoregroup_mapcorenodegroup_mapcoreusergroup'), + ] + + operations = [ + migrations.AddField( + model_name='mapcorenodegroup', + name='visible', + field=models.BooleanField(default=False), + ), + migrations.AlterOrderWithRespectTo( + name='mapcorenodegroup', + order_with_respect_to='mapcore_group', + ), + ] diff --git a/osf/models/mapcore_group.py b/osf/models/mapcore_group.py new file mode 100644 index 00000000000..0c16c48f04e --- /dev/null +++ b/osf/models/mapcore_group.py @@ -0,0 +1,14 @@ +from django.db import models +from osf.models.base import BaseModel +from website.settings import MAPCORE_GROUP_HOSTNAME, MAPCORE_GROUP_API_PATH + + +class MapCoreGroup(BaseModel): + _id = models.CharField(max_length=255, unique=True) + + class Meta: + db_table = 'osf_mapcore_group' + + @property + def absolute_url(self): + return f'{MAPCORE_GROUP_HOSTNAME}{MAPCORE_GROUP_API_PATH}{self._id}/' diff --git a/osf/models/mapcore_node_group.py b/osf/models/mapcore_node_group.py new file mode 100644 index 00000000000..01117b4b5ef --- /dev/null +++ b/osf/models/mapcore_node_group.py @@ -0,0 +1,35 @@ +from django.db import models +from osf.models.base import BaseModel +from osf.models.mapcore_group import MapCoreGroup +from django.contrib.auth.models import Group as AuthGroup +import logging + +logger = logging.getLogger(__name__) + +class MapCoreNodeGroup(BaseModel): + node = models.ForeignKey('osf.Node', on_delete=models.CASCADE, related_name='mapcore_node_groups') + group = models.ForeignKey(AuthGroup, on_delete=models.CASCADE, related_name='auth_group_mapcore_nodes') + mapcore_group = models.ForeignKey(MapCoreGroup, on_delete=models.CASCADE, related_name='mapcore_group_nodes') + creator = models.ForeignKey('osf.OSFUser', related_name='mapcore_node_group_creator', on_delete=models.CASCADE) + is_deleted = models.BooleanField(default=False) + visible = models.BooleanField(default=False) + class Meta: + db_table = 'osf_mapcore_node_group' + order_with_respect_to = 'mapcore_group' + + @property + def get_permission(self): + """ + If the auth group name matches patterns like: + - node__admin + - node__read + - node__write + return the permission string: 'admin', 'read', or 'write'. + Otherwise return None. + """ + import re + name = getattr(self.group, 'name', '') or '' + m = re.match(r'^node_[^_]+_(admin|read|write)$', name) + if m: + return m.group(1) + return None diff --git a/osf/models/mapcore_user_group.py b/osf/models/mapcore_user_group.py new file mode 100644 index 00000000000..70913a46e7f --- /dev/null +++ b/osf/models/mapcore_user_group.py @@ -0,0 +1,12 @@ +from django.db import models +from osf.models.base import BaseModel +from osf.models.mapcore_group import MapCoreGroup + + +class MapCoreUserGroup(BaseModel): + mapcore_group = models.ForeignKey(MapCoreGroup, on_delete=models.CASCADE, related_name='mapcore_user_groups') + user = models.ForeignKey('osf.OSFUser', related_name='mapcore_user_groups', on_delete=models.CASCADE) + is_deleted = models.BooleanField(default=False) + + class Meta: + db_table = 'osf_mapcore_user_group' diff --git a/osf/models/mixins.py b/osf/models/mixins.py index 36e56444759..1179f2c7efb 100644 --- a/osf/models/mixins.py +++ b/osf/models/mixins.py @@ -46,7 +46,8 @@ from website import settings, mails, language from website.project.licenses import set_license from api.base.rdmlogger import RdmLogger, rdmlog - +from osf.models.mapcore_node_group import MapCoreNodeGroup +from osf.models.mapcore_user_group import MapCoreUserGroup logger = logging.getLogger(__name__) @@ -1939,16 +1940,38 @@ def has_permission(self, user, permission, check_parent=True): :returns: User has required permission """ object_type = self.guardian_object_type + group_perm = [] + # Also check Auth Groups linked via MapCoreNodeGroup (by auth_group id) + if object_type == 'node': + try: + user_mapcore_group_ids = MapCoreUserGroup.objects.filter(user=user, is_deleted=False).values_list('mapcore_group_id', flat=True) + # get auth group ids linked to this object + auth_group_ids = MapCoreNodeGroup.objects.filter(node=self, mapcore_group_id__in=user_mapcore_group_ids, is_deleted=False).values_list('group_id', flat=True) + except Exception: + auth_group_ids = [] + if auth_group_ids: + NodeGroupPermModel = apps.get_model('osf', 'NodeGroupObjectPermission') + for gid in auth_group_ids: + perms_qs = NodeGroupPermModel.objects.filter(group_id=gid, content_object_id=self.id) + for perm in list(perms_qs.values_list('permission__codename', flat=True)): + if perm not in group_perm: + group_perm.append(perm) if not user or user.is_anonymous: return False perm = '{}_{}'.format(permission, object_type) + # If any permission codename matches expected perm, grant access + if perm in group_perm: + return True # Using get_group_perms to get permissions that are inferred through # group membership - not inherited from superuser status has_permission = perm in get_group_perms(user, self) if object_type == 'node': if not has_permission and permission == READ and check_parent: - return self.is_admin_parent(user) + if is_admin_group_parent(self.root, user_mapcore_group_ids): + return True + else: + return self.is_admin_parent(user) return has_permission # TODO: Remove save parameter @@ -1972,9 +1995,33 @@ def get_permissions(self, user): # Overrides guardian mixin - returns readable perms instead of literal perms if isinstance(user, AnonymousUser): return [] + + try: + user_mapcore_group_ids = MapCoreUserGroup.objects.filter(user=user, is_deleted=False).values_list('mapcore_group_id', flat=True) + # get auth group ids linked to this object + auth_group_ids = MapCoreNodeGroup.objects.filter(node=self, mapcore_group_id__in=user_mapcore_group_ids, is_deleted=False).values_list('group_id', flat=True) + except Exception: + auth_group_ids = [] + group_perms = [] + if auth_group_ids: + # Try OSF-specific node-group-permission model(s), then fallback to guardian's GroupObjectPermission + NodeGroupPermModel = apps.get_model('osf', 'NodeGroupObjectPermission') + for gid in auth_group_ids: + perms_qs = NodeGroupPermModel.objects.filter(group_id=gid, content_object_id=self.id) + for perm in list(perms_qs.values_list('permission__codename', flat=True)): + if perm not in group_perms: + group_perms.append(perm) + # If base_perms not on model, will error perms = self.base_perms user_perms = sorted(set(get_group_perms(user, self)).intersection(perms), key=perms.index) + + # Union distinct permissions from group_perms and perm_names, preserving base_perms order + combined_set = set(user_perms) | set(group_perms) + if combined_set: + user_perms = [p for p in perms if p in combined_set] + else: + user_perms = [] return [perm.split('_')[0] for perm in user_perms] def set_permissions(self, user, permissions, validate=True, save=False): @@ -2332,3 +2379,19 @@ def copy_editable_fields(self, resource, auth=None, alternative_resource=None, s class Meta: abstract = True + + +def is_admin_group_parent(parent_node, user_mapcore_group_ids): + try: + # get auth group ids linked to this object + auth_group_ids = MapCoreNodeGroup.objects.filter(node=parent_node, mapcore_group_id__in=user_mapcore_group_ids, is_deleted=False).values_list('group_id', flat=True) + except Exception: + auth_group_ids = [] + if auth_group_ids: + NodeGroupPermModel = apps.get_model('osf', 'NodeGroupObjectPermission') + for gid in auth_group_ids: + perms_qs = NodeGroupPermModel.objects.filter(group_id=gid, content_object_id=parent_node.id) + group_perm = list(perms_qs.values_list('permission__codename', flat=True)) + if 'admin_node' in group_perm: + return True + return False diff --git a/osf/models/node.py b/osf/models/node.py index f0dd3e36752..5628284a02f 100644 --- a/osf/models/node.py +++ b/osf/models/node.py @@ -5,6 +5,8 @@ import re from future.moves.urllib.parse import urljoin import warnings +from osf.models.mapcore_node_group import MapCoreNodeGroup +from osf.models.mapcore_user_group import MapCoreUserGroup from rest_framework import status as http_status import bson @@ -145,7 +147,7 @@ def get_children(self, root, active=False, include_root=False): row.append(root.pk) return AbstractNode.objects.filter(id__in=row) - def can_view(self, user=None, private_link=None): + def can_view(self, user=None, private_link=None, include_mapcore_groups=False): qs = self.filter(is_public=True) if private_link is not None: @@ -178,6 +180,30 @@ def can_view(self, user=None, private_link=None): ) SELECT * FROM implicit_read ) """], params=(user.id, )) + # Mapcore group permissions + if include_mapcore_groups: + qs |= self.extra(where=[""" + "osf_abstractnode".id in ( + WITH RECURSIVE implicit_read AS ( + SELECT distinct N.id as node_id + FROM osf_abstractnode as N, auth_permission as P, osf_nodegroupobjectpermission as G, osf_mapcore_user_group as OMUG, osf_mapcore_group as OMG, osf_mapcore_node_group as OMNG + WHERE P.codename = 'admin_node' + AND G.permission_id = P.id + AND OMUG.user_id = %s + AND OMNG.mapcore_group_id = OMUG.mapcore_group_id + AND G.group_id = OMNG.group_id + AND G.content_object_id = N.id + AND N.type = 'osf.node' + AND OMNG.is_deleted = false + UNION ALL + SELECT "osf_noderelation"."child_id" + FROM "implicit_read" + LEFT JOIN "osf_noderelation" ON "osf_noderelation"."parent_id" = "implicit_read"."node_id" + WHERE "osf_noderelation"."is_node_link" IS FALSE + ) SELECT * FROM implicit_read + ) + """], params=(user.id, )) + return qs.filter(is_deleted=False) @@ -199,7 +225,7 @@ def get_children(self, root, active=False, include_root=False): def can_view(self, user=None, private_link=None): return self.get_queryset().can_view(user=user, private_link=private_link) - def get_nodes_for_user(self, user, permission=READ_NODE, base_queryset=None, include_public=False): + def get_nodes_for_user(self, user, permission=READ_NODE, base_queryset=None, include_public=False, include_mapcore_groups=False): """ Return all AbstractNodes that the user has permissions to - either through contributorship or group membership. - similar to guardian.get_objects_for_user(self, READ_NODE, AbstractNode, with_superuser=False). If include_public is True, @@ -223,6 +249,10 @@ def get_nodes_for_user(self, user, permission=READ_NODE, base_queryset=None, inc user_groups = OSFUserGroup.objects.filter(osfuser_id=user.id if user else None).values_list('group_id', flat=True) node_groups = NodeGroupObjectPermission.objects.filter(group_id__in=user_groups, permission_id=permission_object_id).values_list('content_object_id', flat=True) query = Q(id__in=node_groups) + if include_mapcore_groups and user and not isinstance(user, AnonymousUser): + mapcore_user_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False).values_list('mapcore_group_id', flat=True) + node_mapcore_groups = MapCoreNodeGroup.objects.filter(mapcore_group_id__in=mapcore_user_groups, is_deleted=False).values_list('node_id', flat=True) + query = Q(id__in=node_groups) | Q(id__in=node_mapcore_groups) if include_public: query |= Q(is_public=True) return nodes.filter(query) diff --git a/osf/models/nodelog.py b/osf/models/nodelog.py index 8780ae73ad6..32043099514 100644 --- a/osf/models/nodelog.py +++ b/osf/models/nodelog.py @@ -53,6 +53,13 @@ class NodeLog(ObjectIDMixin, BaseModel): CONTRIB_REJECTED = 'contributor_rejected' CONTRIB_REORDERED = 'contributors_reordered' + MAPCORE_GROUP_ADDED = 'mapcore_group_added' + MAPCORE_GROUP_REMOVED = 'mapcore_group_removed' + MAPCORE_GROUP_PERMISSION_UPDATED = 'mapcore_group_permission_updated' + MAPCORE_GROUP_REORDERED = 'mapcore_group_reordered' + MADE_MAPCORE_GROUP_VISIBLE = 'made_mapcore_group_visible' + MADE_MAPCORE_GROUP_INVISIBLE = 'made_mapcore_group_invisible' + CHECKED_IN = 'checked_in' CHECKED_OUT = 'checked_out' diff --git a/osf/models/user.py b/osf/models/user.py index 2725f9360be..abfb708a247 100644 --- a/osf/models/user.py +++ b/osf/models/user.py @@ -698,7 +698,8 @@ def nodes_contributor_or_group_member_to(self): Nodes that user has perms to through contributorship or group membership """ from osf.models import Node - return Node.objects.get_nodes_for_user(self) + include_mapcore = getattr(self, 'include_mapcore_groups', False) + return Node.objects.get_nodes_for_user(self, include_mapcore_groups=include_mapcore) def set_unusable_username(self): """Sets username to an unusable value. Used for, e.g. for invited contributors diff --git a/osf_tests/test_mapcore_group.py b/osf_tests/test_mapcore_group.py new file mode 100644 index 00000000000..4991f9b4254 --- /dev/null +++ b/osf_tests/test_mapcore_group.py @@ -0,0 +1,129 @@ +import pytest +from django.contrib.auth.models import Group as AuthGroup, Permission +from osf.models.mapcore_group import MapCoreGroup +from osf.models.mapcore_node_group import MapCoreNodeGroup +from osf.models.mapcore_user_group import MapCoreUserGroup +from osf.models.node import Node +from osf.models.node import NodeGroupObjectPermission +from osf_tests.factories import PrivateLinkFactory, UserFactory, NodeFactory + +pytestmark = pytest.mark.django_db + + +class TestCanViewMapcoreGroups: + + def _make_node_group_permission(self, node, group, perm_codename='admin_node'): + """ + Helper: create NodeGroupObjectPermission linking the auth group to a permission on the node. + """ + # Get permission object id + perm = Permission.objects.get(codename=perm_codename) + # NodeGroupObjectPermission is defined in osf.models.node as a guardian-backed model + # We can create via NodeGroupObjectPermission.objects.create + return NodeGroupObjectPermission.objects.create( + group_id=group.id, + permission_id=perm.id, + content_object_id=node.id + ) + + def test_can_view_via_mapcore_group_when_included(self): + # Setup: node, user, mapcore group, auth group that follows 'node__admin' naming. + user = UserFactory() + node = NodeFactory(is_public=False) + # Create the MapCoreGroup + mc_group = MapCoreGroup.objects.create(_id='mc-1') + + # Create a Django Auth Group with name matching mapcore node group pattern + auth_group = AuthGroup.objects.create(name=f'node_{node._id}_admin') + + # Create MapCoreNodeGroup linking node, auth group and mapcore group (not deleted) + MapCoreNodeGroup.objects.create(node=node, group=auth_group, mapcore_group=mc_group, creator=user, is_deleted=False) + + # Create MapCoreUserGroup linking user to the MapCoreGroup (not deleted) + MapCoreUserGroup.objects.create(mapcore_group=mc_group, user=user, is_deleted=False) + + # Create the NodeGroupObjectPermission that actually grants the admin_node perm to the auth_group on the node + self._make_node_group_permission(node, auth_group, perm_codename='admin_node') + + # Now assert that with include_mapcore_groups True the node is visible to the user via Node.objects.can_view(...) + qs = Node.objects.get_queryset().can_view(user=user, private_link=None, include_mapcore_groups=True) + assert node in qs + + def test_can_view_with_private_link(self): + node = NodeFactory(is_public=False) + + # Create a private link and attach it to the node + pl = PrivateLinkFactory() + pl.nodes.add(node) + pl.save() + + # Passing the PrivateLink instance should return the node + qs_obj = Node.objects.get_queryset().can_view(user=None, private_link=pl) + assert node in qs_obj + + # Passing the key string should also return the node + qs_key = Node.objects.get_queryset().can_view(user=None, private_link=pl.key) + assert node in qs_key + + # Passing an invalid type should raise a TypeError + with pytest.raises(TypeError): + Node.objects.get_queryset().can_view(user=None, private_link=123) + + def test_cannot_view_via_mapcore_group_when_not_included(self): + # Same setup but do NOT include mapcore groups in the queryset + user = UserFactory() + node = NodeFactory(is_public=False) + mc_group = MapCoreGroup.objects.create(_id='mc-2') + auth_group = AuthGroup.objects.create(name=f'node_{node._id}_admin') + MapCoreNodeGroup.objects.create(node=node, group=auth_group, mapcore_group=mc_group, creator=user, is_deleted=False) + MapCoreUserGroup.objects.create(mapcore_group=mc_group, user=user, is_deleted=False) + self._make_node_group_permission(node, auth_group, perm_codename='admin_node') + + qs = Node.objects.get_queryset().can_view(user=user, private_link=None, include_mapcore_groups=False) + assert node not in qs + + def test_deleted_mapcore_node_group_is_ignored(self): + # If the MapCoreNodeGroup is marked is_deleted=True it should not grant visibility + user = UserFactory() + node = NodeFactory(is_public=False) + mc_group = MapCoreGroup.objects.create(_id='mc-3') + auth_group = AuthGroup.objects.create(name=f'node_{node._id}_admin') + MapCoreNodeGroup.objects.create(node=node, group=auth_group, mapcore_group=mc_group, creator=user, is_deleted=True) + MapCoreUserGroup.objects.create(mapcore_group=mc_group, user=user, is_deleted=False) + self._make_node_group_permission(node, auth_group, perm_codename='admin_node') + + qs = Node.objects.get_queryset().can_view(user=user, private_link=None, include_mapcore_groups=True) + assert node not in qs + + def test_get_nodes_for_user_include_mapcore_group(self): + user = UserFactory() + node = NodeFactory(is_public=False) + mc_group = MapCoreGroup.objects.create(_id='mc-4') + auth_group = AuthGroup.objects.create(name=f'node_{node._id}_admin') + MapCoreNodeGroup.objects.create(node=node, group=auth_group, mapcore_group=mc_group, creator=user, is_deleted=False) + MapCoreUserGroup.objects.create(mapcore_group=mc_group, user=user, is_deleted=False) + self._make_node_group_permission(node, auth_group, perm_codename='admin_node') + + qs_included = Node.objects.get_nodes_for_user(user, permission='admin_node', include_mapcore_groups=True) + assert node in qs_included + + qs_excluded = Node.objects.get_nodes_for_user(user, permission='admin_node', include_mapcore_groups=False) + assert node not in qs_excluded + + def test_get_nodes_for_user_invalid_permission_raises(self): + user = UserFactory() + with pytest.raises(ValueError): + Node.objects.get_nodes_for_user(user, permission='not_a_real_permission') + + def test_get_nodes_for_user_include_public(self): + user = UserFactory() + private_node = NodeFactory(is_public=False) + public_node = NodeFactory(is_public=True) + # Ensure public node is not returned when include_public=False + qs_no_public = Node.objects.get_nodes_for_user(user, permission='read_node', include_public=False) + assert public_node not in qs_no_public + # Ensure public node is returned when include_public=True + qs_with_public = Node.objects.get_nodes_for_user(user, permission='read_node', include_public=True) + assert public_node in qs_with_public + # Private node should still not be returned without explicit permission + assert private_node not in qs_with_public diff --git a/osf_tests/test_mapcore_node_group.py b/osf_tests/test_mapcore_node_group.py new file mode 100644 index 00000000000..70d9e2d36a3 --- /dev/null +++ b/osf_tests/test_mapcore_node_group.py @@ -0,0 +1,40 @@ +import pytest +from django.contrib.auth.models import Group as AuthGroup +from osf.models.mapcore_group import MapCoreGroup +from osf.models.mapcore_node_group import MapCoreNodeGroup +from osf_tests.factories import UserFactory, NodeFactory + +pytestmark = pytest.mark.django_db + +def _make_mc_node_group(node, user, group_name): + auth_group = AuthGroup.objects.create(name=group_name) + mc_group = MapCoreGroup.objects.create(_id=f'mc-{group_name}') + return MapCoreNodeGroup.objects.create( + node=node, + group=auth_group, + mapcore_group=mc_group, + creator=user, + ) + +def test_get_permission_parses_admin_write_read(): + user = UserFactory() + node = NodeFactory(is_public=False) + + mc_admin = _make_mc_node_group(node, user, f'node_{node._id}_admin') + assert mc_admin.get_permission == 'admin' + + mc_write = _make_mc_node_group(node, user, f'node_{node._id}_write') + assert mc_write.get_permission == 'write' + + mc_read = _make_mc_node_group(node, user, f'node_{node._id}_read') + assert mc_read.get_permission == 'read' + +def test_get_permission_returns_none_for_unmatched_name(): + user = UserFactory() + node = NodeFactory(is_public=False) + + mc_other = _make_mc_node_group(node, user, 'some-random-group') + assert mc_other.get_permission is None + + mc_empty = _make_mc_node_group(node, user, '') + assert mc_empty.get_permission is None diff --git a/tests/test_node_groups_view.py b/tests/test_node_groups_view.py new file mode 100644 index 00000000000..d79f4624754 --- /dev/null +++ b/tests/test_node_groups_view.py @@ -0,0 +1,97 @@ +import mock +from osf.models import Registration +import pytest +from rest_framework import status as http_status +from framework.exceptions import HTTPError +from framework.auth.core import Auth + +from tests.base import OsfTestCase +from osf_tests.factories import RetractionFactory, Sanction, UserFactory, ProjectFactory, NodeFactory +from website.project.views.node import node_groups +from website import ember_osf_web +import waffle + +pytestmark = pytest.mark.django_db + + +class TestNodeGroupsView(OsfTestCase): + + def test_node_groups_requires_read_permission(self): + """ + If the calling user does not have READ permission, must_have_permission should raise 403. + We call the decorated view using kwargs `nid` and `user` so the decorators can + construct the Auth object from kwargs. + """ + with self.context: + node = ProjectFactory(is_public=False) + user = UserFactory() + + with pytest.raises(HTTPError) as excinfo: + # call decorated view; decorators expect nid/pid in kwargs + node_groups(nid=node._id, user=user) + err = excinfo.value + assert err.code == http_status.HTTP_403_FORBIDDEN + + def test_node_groups_returns_expected_keys_when_permitted(self): + """ + When user has permission, node_groups should return a dict containing 'groups' and 'adminGroups'. + """ + with self.context: + node = ProjectFactory(is_public=False) + creator = node.creator + # Create a user and grant READ permission + user = UserFactory() + # grant read via add_contributor / permission helpers + node.add_contributor(contributor=user, auth=Auth(creator), permissions='read') + node.save() + + # Ensure ember flag does not divert to the ember app + with mock.patch('waffle.flag_is_active', return_value=False): + result = node_groups(nid=node._id, user=user) + assert isinstance(result, dict) + assert 'groups' in result + assert 'adminGroups' in result + + def test_node_groups_redirects_if_retracted(self): + """ + If node is retracted, must_not_be_retracted_registration makes the view return a redirect response. + """ + with self.context: + # Create a registration and an approved retraction using the factories + retraction = RetractionFactory(state=Sanction.APPROVED, approve=True) + registration = Registration.objects.get(retraction=retraction) + # Ensure registration is public (decorator logic expects registration-like object) + registration.is_public = True + registration.save() + + # Use a user that has permission (creator) + user = registration.creator + + # Call — decorator should return a Flask redirect Response + # Use pid=... because this is a registration + with mock.patch('waffle.flag_is_active', return_value=False): + resp = node_groups(pid=registration._id, user=user) + + # Redirect responses from Flask typically have status_code 302 + # Accept either a Response-like object with status_code or a werkzeug Response + assert hasattr(resp, 'status_code') + assert resp.status_code in (301, 302, 303, 307) + + def test_node_groups_returns_ember_app_when_flag_active(self): + """ + When the EMBER feature flag is active, the ember_flag_is_active decorator should return use_ember_app() + instead of executing the view. Patch the decorator's use_ember_app to return a sentinel. + """ + with self.context: + node = ProjectFactory() + user = node.creator + + # Patch waffle to report the feature flag active + with mock.patch('waffle.flag_is_active', return_value=True): + # Patch the imported use_ember_app name in the decorators module to return a sentinel + # The decorator uses use_ember_app imported into website.ember_osf_web.decorators, + # so patch that name to avoid loading actual assets. + with mock.patch('website.ember_osf_web.decorators.use_ember_app', return_value='EMBER-SENTINEL'): + resp = node_groups(nid=node._id, user=user) + # Should be the sentinel we returned from patched use_ember_app + assert resp == 'EMBER-SENTINEL' diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 291f7901a60..cd4ac3994bb 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -4,6 +4,9 @@ import datetime as dt from nose.tools import * # noqa (PEP8 asserts) +from osf.models.mapcore_group import MapCoreGroup +from osf.models.mapcore_node_group import MapCoreNodeGroup +from django.contrib.auth.models import Group as AuthGroup import pytest from osf_tests.factories import ( ProjectFactory, @@ -19,7 +22,7 @@ from framework.auth import Auth from website.project.views.node import _view_project, _serialize_node_search, _get_children, _get_readable_descendants -from website.views import serialize_node_summary +from website.views import serialize_node_summary, serialize_mapcore_group_for_summary from website.profile import utils from website import filters, settings @@ -441,6 +444,35 @@ def test_serialize_node_for_logs(self): assert_equal(d['is_public'], node.is_public) assert_equal(d['is_registration'], node.is_registration) + def test_get_mapcore_groups(self): + from types import SimpleNamespace + from api.logs.serializers import NodeLogParamsSerializer + + # Create two MapCoreGroup records + g1 = MapCoreGroup.objects.create(_id='group-one') + g2 = MapCoreGroup.objects.create(_id='group-two') + + # Params includes integers (PKs) and some non-integer noise + params = {'mapcore_groups': [g1.id, 'invalid', g2.id, None]} + + # Non-anonymized request must include a user attribute + req = SimpleNamespace(query_params={}, user=UserFactory()) + data = NodeLogParamsSerializer(params, context={'request': req}).data + + # Serializer should return only the created group objects ordered by _id + assert_in('mapcore_groups', data) + assert_equal( + data['mapcore_groups'], + [ + {'id': g1.id, 'name': g1._id}, + {'id': g2.id, 'name': g2._id}, + ] + ) + + # Anonymized request should hide mapcore groups + req_anon = SimpleNamespace(query_params={}, _is_anonymized=True, user=UserFactory()) + data_anon = NodeLogParamsSerializer(params, context={'request': req_anon}).data + assert_equal(data_anon.get('mapcore_groups', None), []) class TestAddContributorJson(OsfTestCase): @@ -532,3 +564,108 @@ def test_add_contributor_json_with_job_and_edu(self): assert_equal(user_info['active'], True) assert_in('secure.gravatar.com', user_info['profile_image_url']) assert_equal(user_info['profile_url'], self.profile) + + +class TestSerializeMapcoreGroups(OsfTestCase): + + def test_serialize_mapcore_node_groups(self): + user = UserFactory() + node = NodeFactory(is_public=False) + + # two MapCore groups, one attached, one deleted + g1 = MapCoreGroup.objects.create(_id='group-one') + g2 = MapCoreGroup.objects.create(_id='group-two') + + auth1 = AuthGroup.objects.get_or_create(name=f'node_{node._id}_admin')[0] + auth2 = AuthGroup.objects.get_or_create(name=f'node_{node._id}_read')[0] + + m1 = MapCoreNodeGroup.objects.create(node=node, group=auth1, mapcore_group=g1, creator=user, is_deleted=False) + _m2 = MapCoreNodeGroup.objects.create(node=node, group=auth2, mapcore_group=g2, creator=user, is_deleted=True) + + data = utils.serialize_mapcore_node_groups(node) + + # only the non-deleted mapping should appear + assert_equal(len(data), 1) + item = data[0] + assert_equal(item['id'], str(m1.id)) + assert_equal(item['mapcore_group']['id'], g1.id) + assert_equal(item['mapcore_group']['name'], g1._id) + assert_equal(item['creator'], user.fullname) + assert_equal(item['is_deleted'], False) + assert_equal(item['permission'], m1.get_permission) + assert_in(g1._id, item['url']) + + def test_serialize_parent_admin_groups(self): + user = UserFactory() + root = ProjectFactory() + child = NodeFactory(parent=root) + + # admin group on root that should be exposed + g_admin = MapCoreGroup.objects.create(_id='parent-admin') + auth_admin = AuthGroup.objects.get_or_create(name=f'node_{root._id}_admin')[0] + m_admin = MapCoreNodeGroup.objects.create(node=root, group=auth_admin, mapcore_group=g_admin, creator=user, is_deleted=False) + + # admin group on root that should be excluded via current_group + g_excl = MapCoreGroup.objects.create(_id='parent-excl') + auth_excl = AuthGroup.objects.get_or_create(name=f'node_{root._id}_admin')[0] + m_excl = MapCoreNodeGroup.objects.create(node=root, group=auth_excl, mapcore_group=g_excl, creator=user, is_deleted=False) + + # Exclude g_excl by passing its id in current_group + current_group = [g_excl.id] + + result = utils.serialize_parent_admin_groups(child, current_group) + + # Only the non-excluded admin mapping should be returned and permission should be 'read' per serializer + assert_equal(len(result), 1) + r = result[0] + assert_equal(r['mapcore_group']['id'], g_admin.id) + assert_equal(r['mapcore_group']['name'], g_admin._id) + assert_equal(r['permission'], 'read') + assert_in(g_admin._id, r['url']) + + def test_serialize_parent_admin_groups_no_parent(self): + user = UserFactory() + node = NodeFactory() # node with no parent + + # No current_group filters + result = utils.serialize_parent_admin_groups(node, []) + + # Expect empty list when node has no parents + assert_equal(result, []) + + def test_serialize_mapcore_group_for_summary(self): + user = UserFactory() + node = NodeFactory(is_public=True) + + # two MapCore groups, one attached, one deleted + g1 = MapCoreGroup.objects.create(_id='group-one') + g2 = MapCoreGroup.objects.create(_id='group-two') + + auth1 = AuthGroup.objects.get_or_create(name=f'node_{node._id}_admin')[0] + auth2 = AuthGroup.objects.get_or_create(name=f'node_{node._id}_read')[0] + + m1 = MapCoreNodeGroup.objects.create(node=node, group=auth1, mapcore_group=g1, creator=user, is_deleted=False, visible=True) + _m2 = MapCoreNodeGroup.objects.create(node=node, group=auth2, mapcore_group=g2, creator=user, is_deleted=True, visible=True) + + data = serialize_mapcore_group_for_summary(node) + + # only the non-deleted mapping should appear + assert_in('mapcore_groups', data) + assert_equal(len(data['mapcore_groups']), 1) + item = data['mapcore_groups'][0] + assert_equal(item['name'], g1._id) + assert_in(g1._id, item['url']) + + def test_serialize_node_summary_includes_mapcore_groups(self): + node = NodeFactory(is_public=True) + user = node.creator + + # attach a MapCore group to the node + g = MapCoreGroup.objects.create(_id='group-summary') + auth_group = AuthGroup.objects.get_or_create(name=f'node_{node._id}_admin')[0] + MapCoreNodeGroup.objects.create(node=node, group=auth_group, mapcore_group=g, creator=user, is_deleted=False, visible=True) + + summary = serialize_node_summary(node, Auth(user)) + assert_in('mapcore_groups', summary) + assert_equal(len(summary['mapcore_groups']), 1) + assert_equal(summary['mapcore_groups'][0]['name'], g._id) diff --git a/website/profile/utils.py b/website/profile/utils.py index 7b80d5c9509..2a2ebe2d464 100644 --- a/website/profile/utils.py +++ b/website/profile/utils.py @@ -247,3 +247,62 @@ def serialize_access_requests(node): machine_state=workflows.DefaultStates.PENDING.value ).select_related('creator') ] + +def serialize_mapcore_node_groups(node, visible_only=False): + """Serialize MapCore groups associated with a node""" + mapcore_node_groups = node.mapcore_node_groups.select_related('mapcore_group', 'group', 'creator') + if visible_only: + mapcore_node_groups = mapcore_node_groups.filter(is_deleted=False, visible=True) + else: + mapcore_node_groups = mapcore_node_groups.filter(is_deleted=False) + return [ + { + 'id': str(mapcore_node_group.id), + 'mapcore_group': { + 'id': mapcore_node_group.mapcore_group.id, + 'name': mapcore_node_group.mapcore_group._id, + }, + 'creator': mapcore_node_group.creator.fullname, + 'is_deleted': mapcore_node_group.is_deleted, + 'permission': mapcore_node_group.get_permission, + 'url': mapcore_node_group.mapcore_group.absolute_url, + 'visible': mapcore_node_group.visible, + 'index': mapcore_node_group._order, + } for mapcore_node_group in mapcore_node_groups + ] + +def serialize_parent_admin_groups(node, current_group): + """Serialize MapCore groups associated with a node""" + result = [] + + for mapcore_node_group in _mapcore_node_group_parent(node, current_group): + result.append({ + 'id': str(mapcore_node_group.id), + 'mapcore_group': { + 'id': mapcore_node_group.mapcore_group.id, + 'name': mapcore_node_group.mapcore_group._id, + }, + 'creator': mapcore_node_group.creator.fullname, + 'is_deleted': mapcore_node_group.is_deleted, + 'permission': 'read', + 'url': mapcore_node_group.mapcore_group.absolute_url, + 'visible': mapcore_node_group.visible, + 'index': mapcore_node_group._order, + }) + return result + +def _mapcore_node_group_parent(node, current_group): + """Get list of parent MapCore groups associated with a node""" + def get_admin_mapcore_node_groups(node): + result = [] + for mapcore_node_group in node.mapcore_node_groups.select_related('mapcore_group', 'group', 'creator').filter(is_deleted=False): + if mapcore_node_group.get_permission == 'admin' and mapcore_node_group.mapcore_group.id not in current_group: + result.append(mapcore_node_group) + return result + result = set() + for parent in node.parents: + admins = get_admin_mapcore_node_groups(parent) + for admin in admins: + if admin not in result: + result.add(admin) + return result diff --git a/website/project/views/node.py b/website/project/views/node.py index 5b796d7672e..86578d38f9f 100644 --- a/website/project/views/node.py +++ b/website/project/views/node.py @@ -3,6 +3,7 @@ import logging from api.base.utils import CREATED_ERROR, check_user_can_create_project, LIMITED_ERROR +from osf.models.mapcore_node_group import MapCoreNodeGroup from rest_framework import status as http_status import math from collections import defaultdict @@ -532,6 +533,16 @@ def node_contributors(auth, node, **kwargs): ret['adminContributors'] = utils.serialize_contributors(admin_contribs, node, admin=True) return ret +@must_be_valid_project +@must_not_be_retracted_registration +@must_have_permission(READ) +@ember_flag_is_active(features.EMBER_PROJECT_CONTRIBUTORS) +def node_groups(auth, node, **kwargs): + ret = _view_project(node, auth, primary=True) + ret['groups'] = utils.serialize_mapcore_node_groups(node) + current_group = [group['mapcore_group']['id'] for group in ret['groups']] + ret['adminGroups'] = utils.serialize_parent_admin_groups(node, current_group) + return ret @must_have_permission(ADMIN) def configure_comments(node, **kwargs): @@ -901,6 +912,7 @@ def _view_project(node, auth, primary=False, ) is_registration = node.is_registration timestamp_pattern = get_timestamp_pattern_division(auth, node) + mapcore_groups = utils.serialize_mapcore_node_groups(node, visible_only=True) data = { 'node': { 'disapproval_link': disapproval_link, @@ -971,6 +983,7 @@ def _view_project(node, auth, primary=False, 'waterbutler_url': node.osfstorage_region.waterbutler_url, 'mfr_url': node.osfstorage_region.mfr_url, 'groups': list(node.osf_groups.values_list('name', flat=True)), + 'mapcore_groups': mapcore_groups, }, 'parent_node': { 'exists': parent is not None, @@ -1215,6 +1228,7 @@ def serialize_child_tree(child_list, user, nested): 'user__guids___id', 'is_admin', 'user__date_confirmed', 'visible' ) ) + mapcore_groups = MapCoreNodeGroup.objects.filter(node=child, is_deleted=False).values('mapcore_group_id') contributors = [ { @@ -1235,6 +1249,7 @@ def serialize_child_tree(child_list, user, nested): 'contributors': contributors, 'is_admin': child.has_permission(user, ADMIN), 'is_supplemental_project': child.has_linked_published_preprints, + 'mapcore_groups': [mapcore_group['mapcore_group_id'] for mapcore_group in mapcore_groups], }, 'user_id': user._id, 'children': serialize_child_tree(nested.get(child._id), user, nested) if child._id in nested.keys() else [], @@ -1280,6 +1295,7 @@ def node_child_tree(user, node): is_admin = node.has_permission(user, ADMIN) if can_read or node.has_permission_on_children(user, READ): + mapcore_groups = MapCoreNodeGroup.objects.filter(node=node, is_deleted=False).values('mapcore_group_id') serialized_nodes.append({ 'node': { 'id': node._id, @@ -1289,6 +1305,7 @@ def node_child_tree(user, node): 'contributors': contributors, 'is_admin': is_admin, 'is_supplemental_project': node.has_linked_published_preprints, + 'mapcore_groups': [mapcore_group['mapcore_group_id'] for mapcore_group in mapcore_groups], }, 'user_id': user._id, diff --git a/website/routes.py b/website/routes.py index b3de0fbefdd..2f6f47b8410 100644 --- a/website/routes.py +++ b/website/routes.py @@ -1278,6 +1278,16 @@ def make_url_map(app): OsfWebRenderer('project/contributors.mako', trust=False), ), + Rule( + [ + '/project//groups/', + '/project//node//groups/', + ], + 'get', + project_views.node.node_groups, + OsfWebRenderer('project/groups.mako', trust=False), + ), + Rule( [ '/project//settings/', diff --git a/website/settings/defaults.py b/website/settings/defaults.py index 7f58ef5d4c4..d5e0b748792 100644 --- a/website/settings/defaults.py +++ b/website/settings/defaults.py @@ -2053,6 +2053,8 @@ class CeleryConfig: MAPCORE_AUTHCODE_MAGIC = 'GRDM_mAP_AuthCode' MAPCORE_CLIENTID = None MAPCORE_SECRET = None +MAPCORE_GROUP_HOSTNAME = 'https://sptest.cg.gakunin.jp' +MAPCORE_GROUP_API_PATH = '/map/rd/' # allow logged-in-user to search private projects ENABLE_PRIVATE_SEARCH = False @@ -2093,3 +2095,6 @@ class CeleryConfig: 'ja_jp': '日本語' } BABEL_DEFAULT_LOCALE = 'ja' + +# Prefix of isMemberOf attribute for groups. +MAP_GATEWAY_ISMEMBEROF_PREFIX = 'https://sptest.cg.gakunin.jp/gr/' diff --git a/website/static/js/addProjectPlugin.js b/website/static/js/addProjectPlugin.js index 3a125ab338c..8fc81edb310 100644 --- a/website/static/js/addProjectPlugin.js +++ b/website/static/js/addProjectPlugin.js @@ -257,7 +257,7 @@ var AddProject = { onchange : function() { ctrl.newProjectInheritContribs(this.checked); } - }), _(' Add contributors from '), m('b', options.parentTitle), + }), _(' Add contributors and groups from '), m('b', options.parentTitle), m('br'), m('i', _(' Admins of '), m('b', options.parentTitle), _(' will have read access to this component.')) ), diff --git a/website/static/js/anonymousLogActionsList.json b/website/static/js/anonymousLogActionsList.json index 6112b457a00..796878072da 100644 --- a/website/static/js/anonymousLogActionsList.json +++ b/website/static/js/anonymousLogActionsList.json @@ -31,6 +31,12 @@ "contributor_rejected": "Contributor(s) cancelled invitation from a project", "contributors_reordered" : "A user reordered contributors for a project", "permissions_updated" : "A user changed permissions for a project", + "mapcore_group_added": "A user added group(s) to a project", + "mapcore_group_removed": "A user removed group from a project", + "mapcore_group_permission_updated": "A user updated permissions for group(s) on a project", + "mapcore_group_reordered": "A user reordered groups for a project", + "made_mapcore_group_visible": "A user made group(s) visible on a project", + "made_mapcore_group_invisible": "A user made group(s) invisible on a project", "made_contributor_visible" : "A user made contributor(s) visible on a project", "made_contributor_invisible" : "A user made contributor(s) invisible on a project", "wiki_updated" : "A user updated a wiki page of a project", diff --git a/website/static/js/groupsAdder.js b/website/static/js/groupsAdder.js new file mode 100644 index 00000000000..52bda4af3bf --- /dev/null +++ b/website/static/js/groupsAdder.js @@ -0,0 +1,517 @@ +/** + * Controller for the Add Group modal. + */ +'use strict'; + +require('css/add-contributors.css'); + +var $ = require('jquery'); +var ko = require('knockout'); +var Raven = require('raven-js'); +var lodashGet = require('lodash.get'); + +var oop = require('js/oop'); +var $osf = require('js/osfHelpers'); +var osfLanguage = require('js/osfLanguage'); +var Paginator = require('js/paginator'); +var NodeSelectTreebeard = require('js/nodeSelectTreebeard'); +var m = require('mithril'); +var projectSettingsTreebeardBase = require('js/projectSettingsTreebeardBase'); +var _ = require('js/rdmGettext')._; +var sprintf = require('agh.sprintf').sprintf; + +function Group(data) { + $.extend(this, data); +} + +var AddGroupViewModel; +AddGroupViewModel = oop.extend(Paginator, { + constructor: function (title, nodeId, parentId, parentTitle, treeDataPromise, options) { + this.super.constructor.call(this); + var self = this; + + self.title = title; + self.nodeId = nodeId; + self.nodeApiUrl = '/api/v1/project/' + self.nodeId + '/'; + self.parentId = parentId; + self.parentTitle = parentTitle; + self.treeDataPromise = treeDataPromise; + self.async = options.async || false; + self.callback = options.callback || function () { + }; + self.nodesOriginal = {}; + //state of current nodes + self.childrenToChange = ko.observableArray(); + self.nodesState = ko.observable(); + self.canSubmit = ko.observable(true); + //nodesState is passed to nodesSelectTreebeard which can update it and key off needed action. + self.nodesState.subscribe(function (newValue) { + //The subscribe causes treebeard changes to change which nodes will be affected + var childrenToChange = []; + for (var key in newValue) { + newValue[key].changed = newValue[key].checked !== self.nodesOriginal[key].checked; + if (newValue[key].changed && key !== self.nodeId) { + childrenToChange.push(key); + } + } + self.childrenToChange(childrenToChange); + m.redraw(true); + }); + + //list of permission objects for select. + self.permissionList = [ + {value: 'read', text: _('Read')}, + {value: 'write', text: _('Read + Write')}, + {value: 'admin', text: _('Administrator')} + ]; + + self.page = ko.observable('whom'); + self.pageTitle = ko.computed(function () { + return { + whom: _('Add Groups'), + which: _('Select Components') + }[self.page()]; + }); + self.query = ko.observable(); + self.results = ko.observableArray([]); + self.groups = ko.observableArray([]); + self.selection = ko.observableArray(); + + self.groupIDsToAdd = ko.pureComputed(function () { + return self.selection().map(function (user) { + return user.mapcore_group_id; + }); + }); + + self.notification = ko.observable(''); + self.doneSearching = ko.observable(false); + self.parentImport = ko.observable(false); + self.totalPages = ko.observable(0); + self.childrenToChange = ko.observableArray(); + self.hasSearch = ko.observable(false); + self.foundResults = ko.pureComputed(function () { + return self.query() && self.results().length && !self.parentImport(); + }); + + self.noResults = ko.pureComputed(function () { + return self.query() && !self.results().length && self.doneSearching() && self.hasSearch(); + }); + + self.showLoading = ko.pureComputed(function () { + return !self.doneSearching() && !!self.query() && self.hasSearch(); + }); + + self.addAllVisible = ko.pureComputed(function () { + var selected_ids = self.selection().map(function (group) { + return group.mapcore_group_id; + }); + var groups = self.groups(); + return ($osf.any( + $.map(self.results(), function (result) { + return groups.indexOf(result.mapcore_group_id) === -1 && selected_ids.indexOf(result.mapcore_group_id) === -1; + }) + )); + }); + + self.removeAllVisible = ko.pureComputed(function () { + return self.selection().length > 0; + }); + + self.addingSummary = ko.computed(function () { + var names = $.map(self.selection(), function (result) { + return result.name; + }); + return names.join(', '); + }); + }, + hide: function () { + $('.modal').modal('hide'); + }, + selectWhom: function () { + this.page('whom'); + }, + selectWhich: function () { + //when the next button is hit by the user, the nodes to change and disable are decided + var self = this; + var nodesState = self.nodesState(); + for (var key in nodesState) { + var i; + var node = nodesState[key]; + var enabled = nodesState[key].isAdmin; + var checked = nodesState[key].checked; + if (enabled) { + var nodeGroups = []; + for (i = 0; i < node.mapcoreGroups.length; i++) { + nodeGroups.push(node.mapcoreGroups[i]); + } + for (i = 0; i < self.groupIDsToAdd().length; i++) { + if (nodeGroups.indexOf(self.groupIDsToAdd()[i]) < 0) { + enabled = true; + break; + } + else { + checked = true; + enabled = false; + } + if (checked && !enabled) { + self.childrenToChange.remove(key); + } + } + } + nodesState[key].enabled = enabled; + nodesState[key].checked = checked; + } + self.nodesState(nodesState); + this.page('which'); + }, + goToPage: function (page) { + this.page(page); + }, + /** + * A simple Group model that receives data from the + * group search endpoint. Adds an additional displayProjectsinCommon + * attribute which is the human-readable display of the number of projects the + * currently logged-in user has in common with the group. + */ + startSearch: function () { + this.parentImport(false); + this.hasSearch(true); + this.pageToGet(0); + this.fetchResults(); + }, + fetchResults: function () { + if (this.parentImport()){ + this.importFromParent(); + } else { + var self = this; + self.doneSearching(false); + self.notification(false); + if (self.query()) { + var url = $osf.apiV2Url('map_core/groups/'); + // url += '?search='+encodeURIComponent(self.query()) + '&page=' + self.pageToGet(); + return $.ajax({ + url: url, + type: 'GET', + dataType: 'json', + data: { + search: self.query(), + page: self.pageToGet()+1 + }, + contentType: 'application/vnd.api+json;', + crossOrigin: true, + xhrFields: {withCredentials: true} + }).done(function (result) { + var groups = result.data.map(function (groupData) { + groupData.attributes.added = (self.groups().indexOf(groupData.id) !== -1); + groupData.attributes.id = groupData.id; + groupData.attributes.profileUrl = groupData.links.self; + return new Group(groupData.attributes); + }); + self.doneSearching(true); + self.results(groups); + self.currentPage(self.pageToGet()); + self.numberOfPages(Math.ceil(result.links.meta.total/result.links.meta.per_page)); + self.addNewPaginators(false); + }); + } else { + self.results([]); + self.currentPage(0); + self.totalPages(0); + self.doneSearching(true); + } + } + }, + getGroups: function () { + var self = this; + self.notification(false); + var url = $osf.apiV2Url('nodes/' + window.contextVars.node.id + '/map_core/groups/'); + + return $.ajax({ + url: url, + type: 'GET', + dataType: 'json', + contentType: 'application/vnd.api+json;', + crossOrigin: true, + xhrFields: {withCredentials: true}, + processData: false + }).done(function (response) { + var groups = response.data.map(function (group) { + // contrib ID has the form - + return group.attributes.mapcore_group_id; + }); + self.groups(groups); + }); + }, + startSearchParent: function () { + this.parentImport(true); + this.importFromParent(); + }, + importFromParent: function () { + var self = this; + var url = $osf.apiV2Url('nodes/' + self.parentId + '/map_core/groups/'); + self.doneSearching(false); + self.notification(false); + return $.ajax({ + url: url, + type: 'GET', + dataType: 'json', + contentType: 'application/vnd.api+json;', + crossOrigin: true, + xhrFields: {withCredentials: true}, + processData: false + }).done( + function (result) { + var groups = result.data.filter(function(group) {return self.groups().indexOf(group.attributes.mapcore_group_id) === -1;}).map(function (group) { + var added = (self.groups().indexOf(group.attributes.mapcore_group_id) !== -1); + var updatedGroup = $.extend({}, group.attributes, {added: added}); + var group_permission = self.permissionList.find(function (permission) { + return permission.value === group.attributes.permission; + }); + updatedGroup.permission = ko.observable(group_permission); + updatedGroup.name = group.attributes.name; + updatedGroup.profileUrl = group.attributes.profileUrl; + return updatedGroup; + }); + var pageToShow = []; + var startingSpot = (self.pageToGet() * 5); + if (groups.length > startingSpot + 5){ + for (var iterate = startingSpot; iterate < startingSpot + 5; iterate++) { + pageToShow.push(groups[iterate]); + } + } else { + for (var iterateTwo = startingSpot; iterateTwo < groups.length; iterateTwo++) { + pageToShow.push(groups[iterateTwo]); + } + } + self.parentImport(false); + self.doneSearching(true); + self.selection(groups); + } + ); + }, + addTips: function (elements) { + elements.forEach(function (element) { + $(element).find('.contrib-button').tooltip(); + }); + }, + afterRender: function (elm, data) { + var self = this; + self.addTips(elm, data); + }, + makeAfterRender: function () { + var self = this; + return function (elm, data) { + return self.afterRender(elm, data); + }; + }, + add: function (data) { + var self = this; + data.permission = ko.observable(self.permissionList[1]); //default permission write + // All manually added groups are visible + data.visible = true; + this.selection.push(data); + // self.query(''); + // Hack: Hide and refresh tooltips + $('.tooltip').hide(); + $('.contrib-button').tooltip(); + }, + remove: function (data) { + this.selection.splice( + this.selection.indexOf(data), 1 + ); + // Hack: Hide and refresh tooltips + $('.tooltip').hide(); + $('.contrib-button').tooltip(); + }, + addAll: function () { + var self = this; + var selected_ids = self.selection().map(function (group) { + return group.mapcore_group_id; + }); + $.each(self.results(), function (idx, result) { + if (selected_ids.indexOf(result.mapcore_group_id) === -1 && self.groups().indexOf(result.mapcore_group_id) === -1) { + self.add(result); + } + }); + }, + removeAll: function () { + var self = this; + $.each(self.selection(), function (idx, selected) { + self.remove(selected); + }); + }, + selected: function (data) { + var self = this; + for (var idx = 0; idx < self.selection().length; idx++) { + if (data.mapcore_group_id === self.selection()[idx].mapcore_group_id) { + return true; + } + } + return false; + }, + selectAllNodes: function () { + //select all nodes to add a group to. THe changed variable is set here for timing between + // treebeard and knockout + var self = this; + var nodesState = ko.toJS(self.nodesState()); + for (var key in nodesState) { + if (nodesState[key].enabled) { + nodesState[key].checked = true; + } + } + self.nodesState(nodesState); + }, + selectNoNodes: function () { + //select no nodes to add a group to. THe changed variable is set here for timing between + // treebeard and knockout + var self = this; + var nodesState = ko.toJS(self.nodesState()); + for (var key in nodesState) { + if (nodesState[key].enabled && nodesState[key].checked) { + nodesState[key].checked = false; + } + } + self.nodesState(nodesState); + }, + submit: function () { + var self = this; + self.canSubmit(false); + $osf.block(); + var url = $osf.apiV2Url('nodes/' + window.contextVars.node.id + '/map_core/groups/'); + var node_ids = self.childrenToChange(); + var createGroupsData = { + data: { + type: 'node-mapcore-group', + attributes: { + node_groups: ko.utils.arrayMap(self.selection(), function (group) { + return { + mapcore_group_id: group.mapcore_group_id, + permission: group.permission().value, + visible: group.visible !== undefined ? group.visible : true + }; + }), + component_ids: node_ids, + } + } + }; + return $.ajax({ + url: url, + type: 'POST', + dataType: 'json', + contentType: 'application/vnd.api+json;', + crossOrigin: true, + xhrFields: {withCredentials: true}, + data: JSON.stringify(createGroupsData), + }).done(function (response) { + if (self.async) { + self.groups($.map(response.groups, function (contrib) { + return contrib.id; + })); + if (self.callback) { + self.callback(response); + } + } else { + window.location.reload(); + } + }).fail(function (xhr, status, error) { + var errorMessage = lodashGet(xhr, 'responseJSON.message') || (sprintf(_('There was a problem trying to add groups%1$s.') , osfLanguage.REFRESH_OR_SUPPORT)); + $osf.growl(_('Could not add groups'), errorMessage); + Raven.captureMessage(_('Error adding groups'), { + extra: { + url: url, + status: status, + error: error + } + }); + }).always(function () { + self.hide(); + $osf.unblock(); + self.canSubmit(true); + }); + }, + clear: function () { + var self = this; + self.page('whom'); + self.parentImport(false); + self.query(''); + self.results([]); + self.selection([]); + self.childrenToChange([]); + self.notification(false); + self.hasSearch(false); + }, + hasChildren: function() { + var self = this; + return (Object.keys(self.nodesOriginal).length > 1); + }, + /** + * get node tree for treebeard from API V1 + */ + fetchNodeTree: function (treebeardUrl) { + var self = this; + return $.when(self.treeDataPromise).done(function (response) { + self.nodesOriginal = projectSettingsTreebeardBase.getNodesOriginal(response[0], self.nodesOriginal); + var nodesState = $.extend(true, {}, self.nodesOriginal); + var nodeParent = response[0].node.id; + //parent node is changed by default + nodesState[nodeParent].checked = true; + //parent node cannot be changed + nodesState[nodeParent].isAdmin = false; + self.nodesState(nodesState); + }).fail(function (xhr, status, error) { + $osf.growl('Error', _('Unable to retrieve project settings')); + Raven.captureMessage(_('Could not GET project settings.'), { + extra: { + url: treebeardUrl, status: status, error: error + } + }); + }); + } +}); + + +//////////////// +// Public API // +//////////////// + +function GroupsAdder(selector, nodeTitle, nodeId, parentId, parentTitle, treeDataPromise, options) { + var self = this; + self.selector = selector; + self.$element = $(selector); + self.nodeTitle = nodeTitle; + self.nodeId = nodeId; + self.parentId = parentId; + self.parentTitle = parentTitle; + self.treeDataPromise = treeDataPromise; + self.options = options || {}; + self.viewModel = new AddGroupViewModel( + self.nodeTitle, + self.nodeId, + self.parentId, + self.parentTitle, + self.treeDataPromise, + self.options + ); + self.init(); +} + +GroupsAdder.prototype.init = function() { + var self = this; + var treebeardUrl = window.contextVars.node.urls.api + 'tree/'; + self.viewModel.getGroups(); + self.viewModel.fetchNodeTree(treebeardUrl).done(function(response) { + new NodeSelectTreebeard('addGroupsTreebeard', response, self.viewModel.nodesState); + }); + $osf.applyBindings(self.viewModel, self.$element[0]); + // Clear popovers on dismiss start + self.$element.on('hide.bs.modal', function() { + self.$element.find('.popover').popover('hide'); + }); + // Clear user search modal when dismissed; catches dismiss by escape key + // or cancel button. + self.$element.on('hidden.bs.modal', function() { + self.viewModel.clear(); + }); +}; + +module.exports = GroupsAdder; diff --git a/website/static/js/groupsManager.js b/website/static/js/groupsManager.js new file mode 100644 index 00000000000..08c340b0b57 --- /dev/null +++ b/website/static/js/groupsManager.js @@ -0,0 +1,527 @@ +'use strict'; + +var $ = require('jquery'); +var ko = require('knockout'); +var Raven = require('raven-js'); +var bootbox = require('bootbox'); +require('jquery-ui'); +require('knockout-sortable'); +var lodashGet = require('lodash.get'); +var GroupsAdder = require('js/groupsAdder'); +var GroupsRemover = require('js/groupsRemover'); +var osfLanguage = require('js/osfLanguage'); + +var rt = require('js/responsiveTable'); +var $osf = require('./osfHelpers'); +require('js/filters'); + +var _ = require('js/rdmGettext')._; +var sprintf = require('agh.sprintf').sprintf; + +//http://stackoverflow.com/questions/12822954/get-previous-value-of-an-observable-in-subscribe-of-same-observable +ko.subscribable.fn.subscribeChanged = function (callback) { + var self = this; + var savedValue = self.peek(); + return self.subscribe(function (latestValue) { + var oldValue = savedValue; + savedValue = latestValue; + callback(latestValue, oldValue); + }); +}; + +ko.bindingHandlers.filters = { + init: function(element, valueAccessor, allBindingsAccessor, data, context) { + var $element = $(element); + var value = ko.utils.unwrapObservable(valueAccessor()) || {}; + value.callback = data.callback; + $element.filters(value); + } +}; + +// TODO: We shouldn't need both pageOwner (the current user) and currentUserCanEdit. Separate +// out the permissions-related functions and remove currentUserCanEdit. +var GroupModel = function(group, currentUserCanEdit, pageOwner, isRegistration, isParentAdmin, index, options, groupShouter, changeShouter) { + var self = this; + self.options = options; + $.extend(self, group); + + self.originals = { + permission: group.permission, + visible: group.visible, + index: index, + }; + self.visible = ko.observable(group.visible); + self.visible.subscribeChanged(function(newValue, oldValue) { + self.options.onVisibleChanged(newValue, oldValue); + }); + self.toggleExpand = function() { + self.expanded(!self.expanded()); + }; + + self.expanded = ko.observable(false); + + self.filtered = ko.observable(false); + + self.permission = ko.observable(group.permission); + + self.permissionText = ko.observable(self.options.permissionMap[self.permission()]); + + self.permission.subscribeChanged(function(newValue, oldValue) { + self.options.onPermissionChanged(newValue, oldValue); + self.permissionText(self.options.permissionMap[newValue]); + }); + + self.permissionChange = ko.computed(function() { + return self.permission() !== self.originals.permission; + }); + + self.reset = function(adminCount, visibleCount) { + if (self.deleteStaged()) { + if (self.visible()) { + visibleCount(visibleCount() + 1); + } + if (self.permission() === 'admin') { + adminCount(adminCount() + 1); + } + self.deleteStaged(false); + } + self.permission(self.originals.permission); + self.visible(self.originals.visible); + }; + + self.currentUserCanEdit = currentUserCanEdit; + // User is an admin on the parent project + self.isParentAdmin = isParentAdmin; + + self.deleteStaged = ko.observable(false); + + self.pageOwner = pageOwner; + self.groupToRemove = ko.observable(); + + self.groupToRemove.subscribe(function(newValue) { + groupShouter.notifySubscribers(newValue, 'groupMessageToPublish'); + }); + + self.serialize = function() { + return JSON.parse(ko.toJSON(self)); + }; + + self.canEdit = ko.computed(function() { + return self.currentUserCanEdit && !self.isParentAdmin; + }); + + self.remove = function() { + self.groupToRemove({ + name: self.mapcore_group.name, + id:self.id, + mapcoreGroupID: self.mapcore_group.id}); + }; + + self.addParentAdmin = function() { + // Immediately adds parent admin to the component with permissions=read and visible=True + $osf.block(); + var url = $osf.apiV2Url('nodes/' + window.contextVars.node.id + '/map_core/groups/'); + var groupData = self.serialize(); + var createGroupsData = { + data: { + type: 'node-mapcore-group', + attributes: { + node_groups: [ + { + mapcore_group_id: groupData.mapcore_group.id, + permission: 'read', + visible: true + } + ] + } + } + }; + return $.ajax({ + url: url, + type: 'POST', + dataType: 'json', + contentType: 'application/vnd.api+json;', + crossOrigin: true, + xhrFields: {withCredentials: true}, + data: JSON.stringify(createGroupsData), + }).done(function(response) { + window.location.reload(); + }).fail(function(xhr, status, error){ + $osf.unblock(); + var errorMessage = lodashGet(xhr, 'responseJSON.message') || (sprintf(_('There was a problem trying to add the group. ') , osfLanguage.REFRESH_OR_SUPPORT)); + $osf.growl(_('Could not add group'), errorMessage); + Raven.captureMessage(_('Error adding groups'), { + extra: { + url: url, + status: status, + error: error + } + }); + }); + }; + + self.unremove = function() { + if (self.deleteStaged()) { + self.deleteStaged(false); + self.options.onPermissionChanged(self.permission(), null); + self.options.onVisibleChanged(self.visible(), null); + } + // Allow default action + return true; + }; + self.profileUrl = ko.observable(group.url); + + self.canRemove = ko.computed(function(){ + return (self.id === pageOwner.id) && !isRegistration && !self.isParentAdmin; + }); + + self.canAddAdminContrib = ko.computed(function() { + return self.currentUserCanEdit && self.isParentAdmin; + }); + + self.isDirty = ko.pureComputed(function() { + return self.permissionChange() || + self.visible() !== self.originals.visible || self.deleteStaged(); + }); + + self.optionsText = function(val) { + return self.options.permissionMap[val]; + }; +}; + +var MessageModel = function(text, level) { + + var self = this; + + + self.text = ko.observable(text || ''); + self.level = ko.observable(level || ''); + + var classes = { + success: 'text-success', + error: 'text-danger' + }; + + self.cssClass = ko.computed(function() { + var out = classes[self.level()]; + if (out === undefined) { + out = ''; + } + return out; + }); + +}; + +var GroupsViewModel = function(groups, adminGroups, user, isRegistration, table, adminTable, groupShouter, pageChangedShouter) { + + var self = this; + + self.original = ko.observableArray(groups); + self.table = $(table); + self.adminTable = $(adminTable); + + self.permissionMap = { + read: _('Read'), + write: _('Read + Write'), + admin: _('Administrator') + }; + + self.permissionList = Object.keys(self.permissionMap); + self.groupToRemove = ko.observable(''); + + self.groups = ko.observableArray(); + self.adminGroups = ko.observableArray(); + self.filteredGroups = ko.pureComputed(function() { + return ko.utils.arrayFilter(self.groups(), function(item) { + return item.filtered(); + }); + }); + self.filteredAdmins = ko.pureComputed(function() { + return ko.utils.arrayFilter(self.adminGroups(), function(item) { + return item.filtered(); + }); + }); + + self.empty = ko.pureComputed(function() { + return (self.groups().length - self.filteredGroups().length) === 0; + }); + + self.adminEmpty = ko.pureComputed(function() { + return (self.adminGroups().length - self.filteredAdmins().length === 0); + }); + + self.callback = function (filtered, empty, activeItems) { + $.each(activeItems, function (i, group) { + activeItems[i] = ko.dataFor(group); + }); + $.each(self.groups(), function (i, group) { + group.filtered($.inArray(group, activeItems) === -1); + }); + $.each(self.adminGroups(), function (i, group) { + group.filtered($.inArray(group, activeItems) === -1); + }); + }; + + self.user = ko.observable(user); + self.canEdit = ko.computed(function() { + return ($.inArray('admin', user.permissions) > -1) && !isRegistration; + }); + + self.isSortable = ko.computed(function() { + return self.canEdit() && self.filteredGroups().length === 0; + }); + + // Hack: Ignore beforeunload when submitting + // TODO: Single-page-ify and remove this + self.forceSubmit = ko.observable(false); + + self.changed = ko.computed(function() { + for (var i = 0, group; group = self.groups()[i]; i++) { + if (group.isDirty() || group.originals.index !== i){ + return true; + } + } + return false; + }); + + self.retainedGroups = ko.computed(function() { + return ko.utils.arrayFilter(self.groups(), function(item) { + return !item.deleteStaged(); + }); + }); + + self.adminCount = ko.observable(0); + + self.visibleCount = ko.observable(0); + + self.canSubmit = ko.computed(function() { + return self.changed(); + }); + + self.changed.subscribe(function(newValue) { + pageChangedShouter.notifySubscribers(newValue, 'changedMessageToPublish'); + }); + + self.messages = ko.computed(function() { + var messages = []; + return messages; + }); + + self.handlePermissionChanged = function(newPerm, oldPerm) { + if (oldPerm === 'admin') { + self.adminCount(self.adminCount() - 1); + } + if (newPerm === 'admin') { + self.adminCount(self.adminCount() + 1); + } + }; + self.handleVisibleChanged = function(newVis, oldVis) { + if (oldVis) { + self.visibleCount(self.visibleCount() - 1); + } + if (newVis) { + self.visibleCount(self.visibleCount() + 1); + } + }; + + self.options = { + onPermissionChanged: self.handlePermissionChanged, + onVisibleChanged: self.handleVisibleChanged, + permissionMap: self.permissionMap + }; + + self.init = function() { + var index = -1; + self.groups(self.original().map(function(item) { + index++; + if (item.visible) { + self.visibleCount(self.visibleCount() + 1); + } + return new GroupModel(item, self.canEdit(), self.user(), isRegistration, false, index, self.options, groupShouter, pageChangedShouter); + })); + self.adminGroups(adminGroups.map(function(item) { + return new GroupModel(item, self.canEdit(), self.user(), isRegistration, true, index, self.options, groupShouter, pageChangedShouter); + })); + + }; + + // Warn on add groups if pending changes + $('[href="#addGroups"]').on('click', function() { + if (self.changed()) { + $osf.growl('Error:', + _('Your group list has unsaved changes. Please ') + + _('save or cancel your changes before adding groups.') + ); + return false; + } + }); + // Warn on URL change if pending changes + $(window).bind('beforeunload', function() { + if (self.changed() && !self.forceSubmit()) { + // TODO: Use GrowlBox. + return _('There are unsaved changes to your group settings'); + } + }); + + self.init(); + + self.serialize = function() { + return ko.utils.arrayMap( + ko.utils.arrayFilter(self.groups(), function(group) { + return !group.deleteStaged(); + }), + function(group) { + return group.serialize(); + } + ); + }; + + self.cancel = function() { + ko.utils.arrayForEach(self.groups(), function(group) { + group.permission(group.originals.permission); + }); + self.groups().forEach(function(group) { + group.reset(self.visibleCount); + }); + self.groups(self.groups().sort(function(left, right) { + return left.originals.index > right.originals.index ? 1 : -1; + })); + }; + + self.submit = function() { + self.forceSubmit(true); + var groups = self.serialize(); + var nodeGroups = []; + groups.forEach(function(item) { + nodeGroups.push({ + 'node_group_id': parseInt(item.id), + 'permission': item.permission, + 'visible': item.visible + }); + }); + + var updateData = {'data':{ + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': nodeGroups + } + }}; + var url = $osf.apiV2Url('nodes/' + window.contextVars.node.id + '/map_core/groups/'); + + bootbox.confirm({ + title: _('Save changes?'), + message: _('Are you sure you want to save these changes?'), + callback: function(result) { + if (result) { + $.ajax({ + url: url, + type: 'PUT', + dataType: 'json', + contentType: 'application/vnd.api+json;', + crossOrigin: true, + xhrFields: {withCredentials: true}, + data: JSON.stringify(updateData) + }).done(function(response) { + // TODO: Don't reload the page here; instead use code below + if (response.redirectUrl) { + window.location.href = response.redirectUrl; + } else { + window.location.reload(); + } + }).fail(function(xhr) { + var response = xhr.responseJSON; + $osf.growl('Error:', + _('Submission failed: ') + response.message_long + ); + self.forceSubmit(false); + }); + } + }, + buttons:{ + confirm:{ + label:_('Save'), + className:'btn-success' + }, + cancel:{ + label:_('Cancel') + } + } + }); + }; + + self.afterRender = function(elements, data) { + var table; + if (data === 'contrib') { + table = self.table[0]; + }else if (data === 'admin') { + table = self.adminTable[0]; + } + if (!!table) { + rt.responsiveTable(table); + } + }; + + self.collapsed = ko.observable(true); + + self.onWindowResize = function() { + self.collapsed(self.table.children().filter('thead').is(':hidden')); + }; + +}; + +//////////////// +// Public API // +//////////////// + +function GroupManager(selector, groups, adminGroups, user, isRegistration, table, adminTable) { + var self = this; + //shouter allows communication between GroupManager and GroupsRemover, in particular which group needs to + // be removed is passed to GroupsRemover + var groupShouter = new ko.subscribable(); + var pageChangedShouter = new ko.subscribable(); + self.selector = selector; + self.$element = $(selector); + self.groups = groups; + self.adminGroups = adminGroups; + self.viewModel = new GroupsViewModel(groups, adminGroups, user, isRegistration, table, adminTable, groupShouter, pageChangedShouter); + $('body').on('nodeLoad', function(event, data) { + // If user is a group, initialize the group modal + // controller + + var treeDataPromise = $.ajax({ + url: window.contextVars.node.urls.api + 'tree/', + type: 'GET', + dataType: 'json', + }); + if (data.user.can_edit) { + new GroupsAdder( + '#addGroups', + data.node.title, + data.node.id, + data.parent_node.id, + data.parent_node.title, + treeDataPromise + ); + } + if (data.user.can_edit) { + new GroupsRemover( + '#removeGroup', + data.node.title, + data.node.id, + data.user.username, + data.user.id, + groupShouter, + pageChangedShouter, + treeDataPromise + ); + } + }); + self.init(); +} + +GroupManager.prototype.init = function() { + $osf.applyBindings(this.viewModel, this.$element[0]); + this.$element.show(); +}; + +module.exports = GroupManager; diff --git a/website/static/js/groupsRemover.js b/website/static/js/groupsRemover.js new file mode 100644 index 00000000000..b0e2675dfce --- /dev/null +++ b/website/static/js/groupsRemover.js @@ -0,0 +1,239 @@ +/** + * Controller for the Remove Group modal. + */ +'use strict'; + +var $ = require('jquery'); +var ko = require('knockout'); +var Raven = require('raven-js'); + +var oop = require('./oop'); +var $osf = require('./osfHelpers'); +var Paginator = require('./paginator'); +var projectSettingsTreebeardBase = require('js/projectSettingsTreebeardBase'); +var _ = require('js/rdmGettext')._; + +function removeNodesGroups(group, nodes) { + + var url = $osf.apiV2Url('nodes/' + window.contextVars.node.id + '/map_core/groups/'); + + return $.ajax({url: url+group+'/?component_ids=' + nodes.join(','), + type: 'DELETE', + dataType: 'json', + contentType: 'application/vnd.api+json;', + crossOrigin: true, + xhrFields: {withCredentials: true}, + }); +} + + +var RemoveGroupViewModel = oop.extend(Paginator, { + constructor: function(title, nodeId, userName, userId, groupShouter, pageChangedShouter, treeDataPromise) { + this.super.constructor.call(this); + var self = this; + self.title = title; + self.nodeId = nodeId; + self.userId = userId; + self.groupToRemove = ko.observable(''); + self.REMOVE = 'remove'; + self.REMOVE_ALL = 'removeAll'; + self.REMOVE_NO_CHILDREN = 'removeNoChildren'; + self.REMOVE_SELF = 'removeSelf'; + self.treeDataPromise = treeDataPromise; + + //This shouter allows the GroupsViewModel to share which group to remove + // with the RemoveGroupViewModel + groupShouter.subscribe(function(newValue) { + self.groupToRemove(newValue); + }, self, 'groupMessageToPublish'); + + //This shouter allows RemoveGroupViewModel to know if the + // GroupsViewModel is in a dirty state to prevent removal + self.pageChanged = ko.observable(false); + pageChangedShouter.subscribe(function(newValue) { + self.pageChanged(newValue); + }, self, 'changedMessageToPublish'); + + self.page = ko.observable(self.REMOVE); + self.pageTitle = ko.computed(function() { + return { + remove: _('Remove Group'), + removeAll: _('Remove Group'), + removeNoChildren: _('Remove Group') + }[self.page()]; + }); + self.userName = ko.observable(userName); + self.deleteAll = ko.observable(false); + var nodesOriginal = {}; + self.nodesOriginal = ko.observable(); + self.loadingSubmit = ko.observable(false); + + /* + * To remove, a group, you must have admin permissions on the node. + */ + self.canRemoveNodes = ko.computed(function() { + var canRemoveNodes = {}; + var nodesOriginalLocal = ko.toJS(self.nodesOriginal()); + if (self.groupToRemove()) { + for (var key in nodesOriginalLocal) { + var node = nodesOriginalLocal[key]; + //User cannot modify the node without admin permissions. + canRemoveNodes[key] = node.isAdmin; + } + } + return canRemoveNodes; + }); + + self.removeSelf = ko.pureComputed(function() { + return self.groupToRemove().id === window.contextVars.currentUser.id; + }); + + self.canRemoveNode = ko.computed(function() { + return self.canRemoveNodes()[self.nodeId]; + }); + + self.canRemoveNodesLength = ko.pureComputed(function() { + return Object.keys(self.canRemoveNodes()).length; + }); + + self.hasChildrenToRemove = ko.computed(function() { + //if there is more then one node to remove, then show a simplified page + if (self.canRemoveNodesLength() > 1 && self.titlesToRemove().length > 1) { + self.page(self.REMOVE); + return true; + } + else { + self.page(self.REMOVE_NO_CHILDREN); + return false; + } + }); + + self.modalSize = ko.pureComputed(function() { + return self.hasChildrenToRemove() && self.canRemoveNode() ? 'modal-dialog modal-lg' : 'modal-dialog modal-md'; + }); + + self.titlesToRemove = ko.computed(function() { + var titlesToRemove = []; + for (var key in self.nodesOriginal()) { + if (self.nodesOriginal().hasOwnProperty(key) && self.canRemoveNodes()[key]) { + var node = self.nodesOriginal()[key]; + var groups = node.mapcoreGroups; + for (var i = 0; i < groups.length; i++) { + if (groups[i] === self.groupToRemove().mapcoreGroupID) { + titlesToRemove.push(node.title); + break; + } + } + } + } + return titlesToRemove; + }); + + self.titlesToKeep = ko.computed(function() { + var titlesToKeep = []; + for (var key in self.nodesOriginal()) { + if (self.nodesOriginal().hasOwnProperty(key) && !self.canRemoveNodes()[key]) { + var node = self.nodesOriginal()[key]; + var groups = node.mapcoreGroups; + for (var i = 0; i < groups.length; i++) { + if (groups[i] === self.groupToRemove().mapcoreGroupID) { + titlesToKeep.push(node.title); + break; + } + } + } + } + return titlesToKeep; + }); + + self.componentIDsToRemove = ko.computed(function() { + var componentIDsToRemove = []; + if (!self.deleteAll()) { + return []; + } + for (var key in self.nodesOriginal()) { + if (key === self.nodeId) { + continue; + } + if (self.nodesOriginal().hasOwnProperty(key) && self.canRemoveNodes()[key]) { + var node = self.nodesOriginal()[key]; + var groups = node.mapcoreGroups; + for (var i = 0; i < groups.length; i++) { + if (groups[i] === self.groupToRemove().mapcoreGroupID) { + componentIDsToRemove.push(node.id); + break; + } + } + } + } + return componentIDsToRemove; + }); + + $.when(self.treeDataPromise).done(function(response) { + nodesOriginal = projectSettingsTreebeardBase.getNodesOriginal(response[0], nodesOriginal); + self.nodesOriginal(nodesOriginal); + }).fail(function(xhr, status, error) { + $osf.growl('Error', _('Unable to retrieve projects and components')); + Raven.captureMessage(_('Unable to retrieve projects and components'), { + extra: { + url: self.nodeApiUrl, status: status, error: error + } + }); + }); + }, + clear: function() { + var self = this; + self.deleteAll(false); + }, + back: function() { + var self = this; + self.page(self.REMOVE); + }, + submit: function() { + var self = this; + removeNodesGroups(self.groupToRemove().id, self.componentIDsToRemove()).then(function (data) { + window.location.reload(); + }).fail(function(xhr, status, error) { + $osf.growl('Error', _('Unable to delete Group')); + Raven.captureMessage(_('Could not DELETE Group.') + error, { + extra: { + url: window.contextVars.node.urls.api + 'group/remove/', status: status, error: error + } + }); + self.clear(); + window.location.reload(); + }); + }, + deleteAllNodes: function() { + var self = this; + self.page(self.REMOVE_ALL); + } +}); + +//////////////// +// Public API // +//////////////// + +function GroupsRemover(selector, nodeTitle, nodeId, userName, userId, groupShouter, pageChangedShouter, treeDataPromise) { + var self = this; + self.selector = selector; + self.$element = $(selector); + self.nodeTitle = nodeTitle; + self.nodeId = nodeId; + self.userName = userName; + self.userId = userId; + self.viewModel = new RemoveGroupViewModel(self.nodeTitle, self.nodeId, self.userName, self.userId, groupShouter, pageChangedShouter, treeDataPromise); + self.init(); +} + +GroupsRemover.prototype.init = function() { + var self = this; + $osf.applyBindings(self.viewModel, self.$element[0]); + // Clear popovers on dismiss start + self.$element.on('hide.bs.modal', function() { + self.$element.find('.popover').popover('hide'); + self.viewModel.clear(); + }); +}; + +module.exports = GroupsRemover; diff --git a/website/static/js/logActionsList.json b/website/static/js/logActionsList.json index d8a11334943..c3e9972dc0f 100644 --- a/website/static/js/logActionsList.json +++ b/website/static/js/logActionsList.json @@ -32,6 +32,12 @@ "contributor_rejected": "${contributors} cancelled invitation as contributor(s) from ${node}", "contributors_reordered": "${user} reordered contributors for ${node}", "permissions_updated": "${user} changed permissions for ${node}", + "mapcore_group_added": "${user} added group ${mapcore_groups} to ${node}", + "mapcore_group_removed": "${user} removed group ${mapcore_groups} from ${node}", + "mapcore_group_permission_updated": "${user} updated group permissions on ${node}", + "mapcore_group_reordered": "${user} reordered groups for ${node}", + "made_mapcore_group_visible": "${user} made non-bibliographic group ${mapcore_groups} a bibliographic group on ${node}", + "made_mapcore_group_invisible": "${user} made bibliographic group ${mapcore_groups} a non-bibliographic group on ${node}", "made_contributor_visible": "${user} made non-bibliographic contributor ${contributors} a bibliographic contributor on ${node}", "made_contributor_invisible": "${user} made bibliographic contributor ${contributors} a non-bibliographic contributor on ${node}", "wiki_updated": "${user} updated wiki page ${page} to version ${version} of ${node}", diff --git a/website/static/js/logActionsList_extract.js b/website/static/js/logActionsList_extract.js index 30f10c090e6..5baeac3f388 100644 --- a/website/static/js/logActionsList_extract.js +++ b/website/static/js/logActionsList_extract.js @@ -25,12 +25,19 @@ var updated_fields = _('${user} changed the ${updated_fields} for ${node}'); var external_ids_added = _('${user} created external identifier(s) ${identifiers} on ${node}'); var custom_citation_added = _('${user} created a custom citation for ${node}'); var custom_citation_edited = _('${user} edited a custom citation for ${node}'); +var admin_contributor_added = _('The Integrated Admin added ${contributors} as contributor(s) to ${node}'); var custom_citation_removed = _('${user} removed a custom citation from ${node}'); var contributor_added = _('${user} added ${contributors} as contributor(s) to ${node}'); var contributor_removed = _('${user} removed ${contributors} as contributor(s) from ${node}'); var contributor_rejected = _('${contributors} cancelled invitation as contributor(s) from ${node}'); var contributors_reordered = _('${user} reordered contributors for ${node}'); var permissions_updated = _('${user} changed permissions for ${node}'); +var mapcore_group_added = _('${user} added group ${mapcore_groups} to ${node}'); +var mapcore_group_removed = _('${user} removed group ${mapcore_groups} from ${node}'); +var mapcore_group_permission_updated = _('${user} updated group permissions on ${node}'); +var mapcore_group_reordered = _('${user} reordered groups for ${node}'); +var made_mapcore_group_visible = _('${user} made group ${mapcore_groups} visible on ${node}'); +var made_mapcore_group_invisible = _('${user} made group ${mapcore_groups} invisible on ${node}'); var made_contributor_visible = _('${user} made non-bibliographic contributor ${contributors} a bibliographic contributor on ${node}'); var made_contributor_invisible = _('${user} made bibliographic contributor ${contributors} a non-bibliographic contributor on ${node}'); var wiki_updated = _('${user} updated wiki page ${page} to version ${version} of ${node}'); diff --git a/website/static/js/logTextParser.js b/website/static/js/logTextParser.js index ec84b64cc5c..a395efe9d28 100644 --- a/website/static/js/logTextParser.js +++ b/website/static/js/logTextParser.js @@ -17,7 +17,7 @@ var nodeCategories = require('json-loader!built/nodeCategories.json'); //Used when calling getContributorList to limit the number of contributors shown in a single log when many are mentioned var numContributorsShown = 3; - +var numMapcoreGroupsShown = 3; /** * Utility function to not repeat logging errors to Sentry * @param message {String} Custom message for error @@ -141,6 +141,38 @@ var getContributorList = function (contributors, maxShown){ return contribList; }; +/** + * Returns a list of mapcore groups to show in log as well as the trailing punctuation/text after each group. + * If a group has a OSF profile, group is returned as a mithril link to user. + * @param mapcoreGroups {string} The list of mapcore groups (OSF users or unregistered) + * @param maxShown {int} the number of mapcore groups shown before saying "and # others" + * Note: if there is only 1 over maxShown, all mapcore groups are shown + * @returns {array} + */ +var getMapcoreGroupList = function (mapcoreGroups, maxShown){ + var mapcoreGroupList = []; + var justOneMore = numMapcoreGroupsShown === mapcoreGroups.length -1; + for(var i = 0; i < mapcoreGroups.length; i++){ + var item = mapcoreGroups[i]; + var comma = ''; + if(i !== mapcoreGroups.length -1 && ((i !== maxShown -1) || justOneMore)){ + comma = ', '; + } + if(i === mapcoreGroups.length -2 || ((i === maxShown -1) && !justOneMore) && (i !== mapcoreGroups.length -1)) { + if (mapcoreGroups.length === 2) + comma = ' and '; + else + comma = ', and '; + } + + if (i === maxShown && !justOneMore){ + mapcoreGroupList.push([((mapcoreGroups.length - i).toString() + ' others'), ' ']); + return mapcoreGroupList; + } + mapcoreGroupList.push([item.name, comma]);} + return mapcoreGroupList; +}; + var LogText = { view : function(ctrl, logObject) { var userInfoReturned = function(userObject){ @@ -278,6 +310,16 @@ var LogPieces = { return m('span', 'some users'); } }, + // Mapcore group list of added, updated etc. + mapcore_groups: { + view: function (ctrl, logObject) { + var mapcoreGroups = logObject.attributes.params.mapcore_groups; + if(paramIsReturned(mapcoreGroups, logObject)) { + return m('span', getMapcoreGroupList(mapcoreGroups, numMapcoreGroupsShown)); + } + return m('span', 'some users'); + } + }, // The tag added to item involved tag: { view: function (ctrl, logObject) { diff --git a/website/static/js/myProjects.js b/website/static/js/myProjects.js index 10db4bab6d7..6969c3d2fc9 100644 --- a/website/static/js/myProjects.js +++ b/website/static/js/myProjects.js @@ -37,7 +37,8 @@ var sparseNodeFields = String([ 'parent', 'public', 'tags', - 'title' + 'title', + 'mapcore_groups' ]); var sparseRegistrationFields = String([ @@ -354,6 +355,8 @@ function _formatDataforPO(item) { } }); } + var groupList = lodashGet(item, 'attributes.mapcore_groups', []); + item.groups = Array.isArray(groupList) ? groupList.join(' ') : (groupList || ''); item.date = new $osf.FormattableDate(item.attributes.date_modified); item.sortDate = item.date.date; // diff --git a/website/static/js/pages/sharing-page.js b/website/static/js/pages/sharing-page.js index b2cb72d1dff..2a88383676c 100644 --- a/website/static/js/pages/sharing-page.js +++ b/website/static/js/pages/sharing-page.js @@ -2,6 +2,7 @@ var $ = require('jquery'); var ContribManager = require('js/contribManager'); +var GroupManager = require('js/groupsManager'); var AccessRequestManager = require('js/accessRequestManager'); var PrivateLinkManager = require('js/privateLinkManager'); @@ -18,12 +19,18 @@ var nodeApiUrl = ctx.node.urls.api; var isContribPage = $('#manageContributors').length; var hasAccessRequests = $('#manageAccessRequests').length; var cm; +var gm; var arm; - +var isGroupPage = $('#manageGroups').length; if (isContribPage) { cm = new ContribManager('#manageContributors', ctx.contributors, ctx.adminContributors, ctx.currentUser, ctx.isRegistration, '#manageContributorsTable', '#adminContributorsTable'); } +if (isGroupPage) { + // cm = new ContribManager('#manageContributors', ctx.contributors, ctx.adminContributors, ctx.currentUser, ctx.isRegistration, '#manageContributorsTable', '#adminContributorsTable'); + gm = new GroupManager('#manageGroups', ctx.groups,ctx.adminGroups, ctx.currentUser, ctx.isRegistration, '#manageGroupsTable', '#adminGroupsTable'); +} + if (hasAccessRequests) { arm = new AccessRequestManager('#manageAccessRequests', ctx.accessRequests, ctx.currentUser, ctx.isRegistration, '#manageAccessRequestsTable'); } @@ -31,10 +38,18 @@ if (hasAccessRequests) { if ($.inArray('admin', ctx.currentUser.permissions) !== -1) { // Controls the modal var configUrl = ctx.node.urls.api + 'get_editable_children/'; - var privateLinkManager = new PrivateLinkManager('#addPrivateLink', configUrl); + var $addPrivateLink = $('#addPrivateLink'); + var privateLinkManager; + if ($addPrivateLink.length) { + privateLinkManager = new PrivateLinkManager('#addPrivateLink', configUrl); + } var tableUrl = nodeApiUrl + 'private_link/'; var linkTable = $('#privateLinkTable'); - var privateLinkTable = new PrivateLinkTable('#linkScope', tableUrl, ctx.node.isPublic, linkTable); + var $linkScope = $('#linkScope'); + var privateLinkTable; + if ($linkScope.length) { + privateLinkTable = new PrivateLinkTable('#linkScope', tableUrl, ctx.node.isPublic, linkTable); + } } $(function() { @@ -50,6 +65,9 @@ $(window).on('load', function() { if (typeof arm !== 'undefined') { arm.viewModel.onWindowResize(); } + if (typeof gm !== 'undefined') { + gm.viewModel.onWindowResize(); + } if (!!privateLinkTable){ privateLinkTable.viewModel.onWindowResize(); rt.responsiveTable(linkTable[0]); @@ -70,4 +88,7 @@ $(window).resize(function() { if (typeof arm !== 'undefined') { arm.viewModel.onWindowResize(); } + if (typeof gm !== 'undefined') { + gm.viewModel.onWindowResize(); + } }); diff --git a/website/static/js/project-organizer.js b/website/static/js/project-organizer.js index f8ca9a0b462..4bc85c3ff23 100644 --- a/website/static/js/project-organizer.js +++ b/website/static/js/project-organizer.js @@ -115,6 +115,31 @@ function _poContributors(item) { }); } + +function _poGroups(item) { + var groupList = lodashGet(item, 'data.attributes.mapcore_groups', []); + + if (groupList.length === 0) { + return ''; + } + + return groupList.map(function (group, index) { + var comma; + if (index === 0) { + comma = ''; + } else { + comma = ', '; + } + if (index > 2) { + return m('span'); + } + if (index === 2) { + return m('span', ' + ' + (groupList.length - 2)); // We already show names of the two + } + return m('span', comma + group); + }); +} + /** * Displays date modified * @param {Object} item A Treebeard _item object for the row involved. Node information is inside item.data @@ -162,6 +187,10 @@ function _poResolveRows(item) { data : 'sortDate', filter : false, custom : _poModified + },{ + data : 'groups', + filter : true, + custom : _poGroups }); } else { defaultColumns.push({ @@ -190,7 +219,7 @@ function _poColumnTitles() { if(!mobile){ columns.push({ title: _('Name'), - width : '55%', + width : '35%', sort : true, sortType : 'text' },{ @@ -202,6 +231,10 @@ function _poColumnTitles() { width : '20%', sort : true, sortType : 'date' + },{ + title : _('Groups'), + width : '20%', + sort : false }); } else { columns.push({ @@ -432,7 +465,7 @@ var tbOptions = { }), m('.filterReset', { onclick : resetFilter }, tb.options.removeIcon())]; }, - hiddenFilterRows : ['tags', 'contributors'], + hiddenFilterRows : ['tags', 'contributors', 'groups'], lazyLoadOnLoad : function (tree, event) { var tb = this; function formatItems (arr) { diff --git a/website/static/js/project.js b/website/static/js/project.js index 9be7646f419..ad89e8eb058 100644 --- a/website/static/js/project.js +++ b/website/static/js/project.js @@ -247,6 +247,7 @@ $(document).ready(function() { }); var bibliographicContribInfoHtml = _('Only bibliographic contributors will be displayed in the Contributors list and in project citations. Non-bibliographic contributors can read and modify the project as normal.'); + var bibliographicGroupInfoHtml = _('Only bibliographic groups will be displayed in the Groups list and in project citations. Non-bibliographic groups can read and modify the project as normal.'); $('.visibility-info').attr( 'data-content', bibliographicContribInfoHtml @@ -254,6 +255,12 @@ $(document).ready(function() { trigger: 'hover' }); + $('.visibility-group-info').attr( + 'data-content', bibliographicGroupInfoHtml + ).popover({ + trigger: 'hover' + }); + //////////////////// // Event Handlers // //////////////////// diff --git a/website/static/js/projectSettingsTreebeardBase.js b/website/static/js/projectSettingsTreebeardBase.js index 0412cca34d8..e65cee42c7b 100644 --- a/website/static/js/projectSettingsTreebeardBase.js +++ b/website/static/js/projectSettingsTreebeardBase.js @@ -64,7 +64,8 @@ function getNodesOriginal(nodeTree, nodesOriginal) { institutions: nodeInstitutions, changed: false, checked: false, - enabled: true + enabled: true, + mapcoreGroups: nodeTree.node.mapcore_groups || [] }; if (nodeTree.children) { diff --git a/website/templates/project/groups.mako b/website/templates/project/groups.mako new file mode 100644 index 00000000000..38f76bfa780 --- /dev/null +++ b/website/templates/project/groups.mako @@ -0,0 +1,298 @@ +<%inherit file="project/project_base.mako"/> +<%def name="title()">${node['title']} ${_("Groups")} + +<%include file="project/modal_add_group.mako"/> +<%include file="project/modal_remove_group.mako"/> + + + +
+
+ +
${_("Permissions")} +
+
+
+ +
+
+ +
+
+ +
+
+
${_("Bibliographic Group")} +
+
+
+ +
+
+ +
+
+
+
+ +
+
+

${_("Groups")} + + + ${_("Add")} + + +

+ + % if permissions.ADMIN in user['permissions'] and not node['is_registration']: +

${_("Drag and drop groups to change listing order.")}

+ % endif + +
+ +
+
+
+ ${_("No groups found")} +
+ +
+

+ ${_("Admins on Parent Projects")} + +

+ +
+
+ ${_("No administrators from parent project found.")} +
+
+ ${buttonGroup()} +
+ +
+ + + + + + + + +<%def name="buttonGroup()"> + % if permissions.ADMIN in user['permissions']: + + % endif +
+
+
+ + +<%def name="javascript_bottom()"> + ${parent.javascript_bottom()} + + + + + + diff --git a/website/templates/project/modal_add_group.mako b/website/templates/project/modal_add_group.mako new file mode 100644 index 00000000000..078cdf25212 --- /dev/null +++ b/website/templates/project/modal_add_group.mako @@ -0,0 +1,220 @@ + diff --git a/website/templates/project/modal_remove_group.mako b/website/templates/project/modal_remove_group.mako new file mode 100644 index 00000000000..72719facc03 --- /dev/null +++ b/website/templates/project/modal_remove_group.mako @@ -0,0 +1,126 @@ + + + diff --git a/website/templates/project/project.mako b/website/templates/project/project.mako index a79f6faa890..36b88240311 100644 --- a/website/templates/project/project.mako +++ b/website/templates/project/project.mako @@ -1,6 +1,7 @@ <%inherit file="project/project_base.mako"/> <%namespace name="render_nodes" file="util/render_nodes.mako" /> <%namespace name="contributor_list" file="util/contributor_list.mako" /> +<%namespace name="group_list" file="util/group_list.mako" /> <%namespace name="render_addon_widget" file="util/render_addon_widget.mako" /> <%include file="project/nodes_privacy.mako"/> <%include file="util/render_grdm_addons_context.mako"/> @@ -174,6 +175,21 @@ % endif +
+ % if user['is_contributor_or_group_member']: + ${_("Groups")}: + % else: + ${_("Groups:")} + % endif + + % if node['anonymous']: +
    ${_("Anonymous Groups")}
+ % else: +
    + ${group_list.render_groups_full(groups=node['mapcore_groups'])} +
+ % endif +
% if node['groups']:
Groups: diff --git a/website/templates/project/project_header.mako b/website/templates/project/project_header.mako index 79f51a2d6e3..a9b96c3ac4c 100644 --- a/website/templates/project/project_header.mako +++ b/website/templates/project/project_header.mako @@ -108,6 +108,10 @@
  • ${_("Contributors")}
  • % endif + % if user['is_contributor_or_group_member']: +
  • ${_("Groups")}
  • + % endif + % if permissions.WRITE in user['permissions'] and not node['is_registration']:
  • ${ _("Add-ons") }
  • % endif diff --git a/website/templates/util/group_list.mako b/website/templates/util/group_list.mako new file mode 100644 index 00000000000..c79bbb7a82e --- /dev/null +++ b/website/templates/util/group_list.mako @@ -0,0 +1,30 @@ +<%def name="render_group_dict(group)"> + ${group['name']}${ group['separator'] | n } + + +<%def name="render_groups(groups, others_count, node_url)"> + % for i, group in enumerate(groups): + ${render_group_dict(group) if isinstance(group, dict) else render_user_obj(group)} + % endfor + % if others_count: + ${_("%(othersCount)s more") % dict(othersCount=others_count)} + % endif + + +<%def name="render_groups_full(groups)"> + % for group in groups: +
  • + <% + condensed = group['mapcore_group']['name'] + is_condensed = False + if len(condensed) >= 50: + condensed = condensed[:23] + "..." + condensed[-23:] + is_condensed = True + %> +
  • + % endfor + diff --git a/website/templates/util/render_node.mako b/website/templates/util/render_node.mako index 39235511700..cea10f148f0 100644 --- a/website/templates/util/render_node.mako +++ b/website/templates/util/render_node.mako @@ -1,4 +1,5 @@ <%namespace name="contributor_list" file="./contributor_list.mako" /> +<%namespace name="group_list" file="./group_list.mako" /> ## TODO: Rename summary to node <%def name="render_node(summary, show_path)"> ## TODO: Don't rely on ID @@ -100,6 +101,9 @@
    ${contributor_list.render_contributors(contributors=summary['contributors'], others_count=summary['others_count'], node_url=summary['url'])}
    +
    + ${group_list.render_groups(groups=summary['mapcore_groups'], others_count=summary['mapcore_groups_others_count'], node_url=summary['url'])} +
    % if summary['groups']:
    ${summary['groups']} diff --git a/website/translations/en/LC_MESSAGES/js_messages.po b/website/translations/en/LC_MESSAGES/js_messages.po index a1bff9090cb..a83c82618e2 100644 --- a/website/translations/en/LC_MESSAGES/js_messages.po +++ b/website/translations/en/LC_MESSAGES/js_messages.po @@ -2966,7 +2966,7 @@ msgid "Storage location" msgstr "" #: website/static/js/addProjectPlugin.js:251 -msgid " Add contributors from " +msgid " Add contributors and groups from " msgstr "" #: website/static/js/addProjectPlugin.js:253 @@ -9270,3 +9270,73 @@ msgstr "" msgid "The Integrated Admin added ${contributors} as contributor(s) to ${node}" msgstr "" + +msgid "${user} added group ${mapcore_groups} to ${node}" +msgstr "" + +msgid "${user} removed group ${mapcore_groups} from ${node}" +msgstr "" + +msgid "${user} updated group permissions on ${node}" +msgstr "" + +msgid "Add Groups" +msgstr "" + +msgid "Remove Group" +msgstr "" + +msgid "Your group list has unsaved changes. Please " +msgstr "" + +msgid "save or cancel your changes before adding groups." +msgstr "" + +msgid "Unable to delete Group" +msgstr "" + +msgid "Could not DELETE Group." +msgstr "" + +msgid "There was a problem trying to add groups%1$s." +msgstr "" + +msgid "There was a problem trying to add the group." +msgstr "" + +msgid "Could not add groups" +msgstr "" + +msgid "Could not add group" +msgstr "" + +msgid "Error adding groups" +msgstr "" + +msgid "" +"Only bibliographic groups will be displayed in the Groups " +"list and in project citations. Non-bibliographic groups can read " +"and modify the project as normal." +msgstr "" + +msgid "A user reordered groups for a project" +msgstr "" + +msgid "A user made group(s) visible on a project" +msgstr "" + +msgid "A user made group(s) invisible on a project" +msgstr "" + +msgid "${user} reordered groups for ${node}" +msgstr "" + +msgid "" +"${user} made non-bibliographic group ${mapcore_groups} a " +"bibliographic group on ${node}" +msgstr "" + +msgid "" +"${user} made bibliographic group ${mapcore_groups} a " +"non-bibliographic group on ${node}" +msgstr "" diff --git a/website/translations/en/LC_MESSAGES/messages.po b/website/translations/en/LC_MESSAGES/messages.po index 6d84a486354..216d3050ae6 100644 --- a/website/translations/en/LC_MESSAGES/messages.po +++ b/website/translations/en/LC_MESSAGES/messages.po @@ -4080,4 +4080,83 @@ msgid "\"Full name\", \"Family name\", \"Given name\", \"Family name (EN)\", \"G msgstr "" msgid "If you do not have an email address registered, please enter or add your email address in the \"Registered email address\" entry field first." -msgstr "" \ No newline at end of file +msgstr "" + +msgid "Groups" +msgstr "" + +msgid "Group name" +msgstr "" + +msgid "Registered by" +msgstr "" + +msgid "Add Groups" +msgstr "" + +msgid "Search by group name" +msgstr "" + +msgid "Remove Group" +msgstr "" + +msgid "" +"Do you want to remove from" +" , or from and every component in it?" +msgstr "" + +msgid "" +"Remove from" +" ." +msgstr "" + +msgid "" +"Remove from" +" and every" +" component in it." +msgstr "" + +msgid "Remove from ?" +msgstr "" + +msgid "" +" " +"will be removed from the following projects and/or components." +msgstr "" + +msgid "" +" " +"cannot be removed from the following projects and/or components." +msgstr "" + +msgid "Searching groups..." +msgstr "" + +msgid "Adding group(s)" +msgstr "" + +msgid "Remove from ?" +msgstr "" + +msgid "" +"You can also add the group(s) to any components on which you are an" +" admin." +msgstr "" + +msgid "No groups found" +msgstr "" + +msgid "" +"Please save or discard your existing changes before removing a " +"groups." +msgstr "" + +msgid "Drag and drop groups to change listing order." +msgstr "" + +msgid "Bibliographic Group" +msgstr "" + +msgid "Bibliographic Group Information" +msgstr "" diff --git a/website/translations/ja/LC_MESSAGES/js_messages.po b/website/translations/ja/LC_MESSAGES/js_messages.po index 39bcc9e353d..0d8e38c39ca 100644 --- a/website/translations/ja/LC_MESSAGES/js_messages.po +++ b/website/translations/ja/LC_MESSAGES/js_messages.po @@ -4216,8 +4216,8 @@ msgid "Storage location" msgstr "ストレージロケーション" #: website/static/js/addProjectPlugin.js:251 -msgid " Add contributors from " -msgstr "次からメンバーを追加する:" +msgid " Add contributors and groups from " +msgstr "次からメンバーとグループを追加する:" #: website/static/js/addProjectPlugin.js:253 msgid " Admins of " @@ -10557,3 +10557,73 @@ msgstr "作成したプロジェクト数が作成可能なプロジェクトの msgid "The Integrated Admin added ${contributors} as contributor(s) to ${node}" msgstr "統合管理者代理アカウントが${contributors}をコントリビューターとして${node}に追加しました" + +msgid "${user} added group ${mapcore_groups} to ${node}" +msgstr "${user}が${mapcore_groups}グループを${node}に追加しました" + +msgid "${user} removed group ${mapcore_groups} from ${node}" +msgstr "${user}が${node}から${mapcore_groups}グループを削除しました" + +msgid "${user} updated group permissions on ${node}" +msgstr "${user}が${node}のグループ権限を変更しました" + +msgid "Add Groups" +msgstr "グループを追加" + +msgid "Remove Group" +msgstr "グループを削除" + +msgid "Your group list has unsaved changes. Please " +msgstr "グループリストには未保存の変更があります。 " + +msgid "save or cancel your changes before adding groups." +msgstr "グループを追加する前に、変更を保存またはキャンセルしてください。" + +msgid "Unable to delete Group" +msgstr "グループを削除できません" + +msgid "Could not DELETE Group." +msgstr "グループを削除できませんでした" + +msgid "There was a problem trying to add groups%1$s." +msgstr "グループ%1$sを追加しようとして問題が発生しました。" + +msgid "There was a problem trying to add the group." +msgstr "グループを追加しようとして問題が発生しました。" + +msgid "Could not add groups" +msgstr "グループを追加できませんでした" + +msgid "Could not add group" +msgstr "グループを追加できませんでした" + +msgid "Error adding groups" +msgstr "グループの追加エラー" + +msgid "" +"Only bibliographic groups will be displayed in the Groups " +"list and in project citations. Non-bibliographic groups can read " +"and modify the project as normal." +msgstr "グループリストおよびプロジェクトの引用には、書誌のグループのみが表示されます。 書誌以外のグループは、通常どおりプロジェクトを読んで修正できます。" + +msgid "A user reordered groups for a project" +msgstr "" + +msgid "A user made group(s) visible on a project" +msgstr "" + +msgid "A user made group(s) invisible on a project" +msgstr "" + +msgid "${user} reordered groups for ${node}" +msgstr "${user}が${node}のグループを並べ替えました" + +msgid "" +"${user} made non-bibliographic group ${mapcore_groups} a " +"bibliographic group on ${node}" +msgstr "${user}が目録非表示グループ(${mapcore_groups})を${node}の目録表示グループにしました" + +msgid "" +"${user} made bibliographic group ${mapcore_groups} a " +"non-bibliographic group on ${node}" +msgstr "${user}が目録表示グループ(${mapcore_groups})を${node}の目録非表示グループにしました" diff --git a/website/translations/ja/LC_MESSAGES/messages.po b/website/translations/ja/LC_MESSAGES/messages.po index 10b07e9746a..8a8de45f350 100644 --- a/website/translations/ja/LC_MESSAGES/messages.po +++ b/website/translations/ja/LC_MESSAGES/messages.po @@ -4556,3 +4556,86 @@ msgstr "メンバー管理" #~ "will keep the registration private until" #~ " the embargo period ends." #~ msgstr "この%(nodeType)sは現在登録を保留しており、プロジェクト管理者からの承認を待っています。この登録は最終的なものであり、すべてのプロジェクト管理者が登録を承認するか、48時間のパスのいずれか早いほうを承認した時点で禁止期間に入ります。禁止措置は、禁止期間が終了するまで登録を非公開にします。" + +msgid "Groups" +msgstr "グループ" + +msgid "Group name" +msgstr "グループ名" + +msgid "Registered by" +msgstr "登録者" + +msgid "Add Groups" +msgstr "グループを追加" + +msgid "Search by group name" +msgstr "グループ名で検索する" + +msgid "Remove Group" +msgstr "グループを削除" + +msgid "" +"Do you want to remove from" +" , or from and every component in it?" +msgstr "" +"から、またはとその中のすべてのコンポーネントから削除しますか?" + +msgid "" +"Remove from" +" ." +msgstr "からを削除します。" + +msgid "" +"Remove from" +" and every" +" component in it." +msgstr "" +"およびその中のすべてのコンポーネントから削除します。" + +msgid "Remove from ?" +msgstr "からを削除しますか?" + +msgid "" +" " +"will be removed from the following projects and/or components." +msgstr "は、以下のプロジェクトおよび/またはコンポーネントから削除されます。" + +msgid "" +" " +"cannot be removed from the following projects and/or components." +msgstr "は、以下のプロジェクトやコンポーネントから削除できません。" + +msgid "Searching groups..." +msgstr "グループを検索しています..." + +msgid "Adding group(s)" +msgstr "メンバーを追加中" + +msgid "Remove from ?" +msgstr "からを削除しますか?" + +msgid "" +"You can also add the group(s) to any components on which you are an" +" admin." +msgstr "管理者であるコンポーネントにグループを追加することもできます。" + +msgid "No groups found" +msgstr "グループが見つかりません" + +msgid "" +"Please save or discard your existing changes before removing a " +"groups." +msgstr "グループを削除する前に、既存の変更を保存または破棄してください。" + +msgid "Drag and drop groups to change listing order." +msgstr "グループをドラッグ&ドロップして、リストの順序を変更します。" + +msgid "Bibliographic Group" +msgstr "目録表示グループ" + +msgid "Bibliographic Group Information" +msgstr "目録表示グループの情報" diff --git a/website/translations/js_messages.pot b/website/translations/js_messages.pot index a05c7aac7ca..06f32593f4a 100644 --- a/website/translations/js_messages.pot +++ b/website/translations/js_messages.pot @@ -2907,7 +2907,7 @@ msgid "Storage location" msgstr "" #: website/static/js/addProjectPlugin.js:251 -msgid " Add contributors from " +msgid " Add contributors and groups from " msgstr "" #: website/static/js/addProjectPlugin.js:253 @@ -9223,3 +9223,73 @@ msgstr "" msgid "The Integrated Admin added ${contributors} as contributor(s) to ${node}" msgstr "" + +msgid "${user} added group ${mapcore_groups} to ${node}" +msgstr "" + +msgid "${user} removed group ${mapcore_groups} from ${node}" +msgstr "" + +msgid "${user} updated group permissions on ${node}" +msgstr "" + +msgid "Add Groups" +msgstr "" + +msgid "Remove Group" +msgstr "" + +msgid "Your group list has unsaved changes. Please " +msgstr "" + +msgid "save or cancel your changes before adding groups." +msgstr "" + +msgid "Unable to delete Group" +msgstr "" + +msgid "Could not DELETE Group." +msgstr "" + +msgid "There was a problem trying to add groups%1$s." +msgstr "" + +msgid "There was a problem trying to add the group." +msgstr "" + +msgid "Could not add groups" +msgstr "" + +msgid "Could not add group" +msgstr "" + +msgid "Error adding groups" +msgstr "" + +msgid "" +"Only bibliographic groups will be displayed in the Groups " +"list and in project citations. Non-bibliographic groups can read " +"and modify the project as normal." +msgstr "" + +msgid "A user reordered groups for a project" +msgstr "" + +msgid "A user made group(s) visible on a project" +msgstr "" + +msgid "A user made group(s) invisible on a project" +msgstr "" + +msgid "${user} reordered groups for ${node}" +msgstr "" + +msgid "" +"${user} made non-bibliographic group ${mapcore_groups} a " +"bibliographic group on ${node}" +msgstr "" + +msgid "" +"${user} made bibliographic group ${mapcore_groups} a " +"non-bibliographic group on ${node}" +msgstr "" diff --git a/website/translations/messages.pot b/website/translations/messages.pot index b1d25f76719..e358e12b68b 100644 --- a/website/translations/messages.pot +++ b/website/translations/messages.pot @@ -4345,3 +4345,81 @@ msgstr "" msgid "Manage Contributors" msgstr "" +msgid "Groups" +msgstr "" + +msgid "Group name" +msgstr "" + +msgid "Registered by" +msgstr "" + +msgid "Add Groups" +msgstr "" + +msgid "Search by group name" +msgstr "" + +msgid "Remove Group" +msgstr "" + +msgid "" +"Do you want to remove from" +" , or from and every component in it?" +msgstr "" + +msgid "" +"Remove from" +" ." +msgstr "" + +msgid "" +"Remove from" +" and every" +" component in it." +msgstr "" + +msgid "Remove from ?" +msgstr "" + +msgid "" +" " +"will be removed from the following projects and/or components." +msgstr "" + +msgid "" +" " +"cannot be removed from the following projects and/or components." +msgstr "" + +msgid "Searching groups..." +msgstr "" + +msgid "Adding group(s)" +msgstr "" + +msgid "Remove from ?" +msgstr "" + +msgid "" +"You can also add the group(s) to any components on which you are an" +" admin." +msgstr "" + +msgid "No groups found" +msgstr "" + +msgid "" +"Please save or discard your existing changes before removing a " +"groups." +msgstr "" + +msgid "Drag and drop groups to change listing order." +msgstr "" + +msgid "Bibliographic Group" +msgstr "" + +msgid "Bibliographic Group Information" +msgstr "" diff --git a/website/views.py b/website/views.py index 1d82d22d33b..e8a041d8450 100644 --- a/website/views.py +++ b/website/views.py @@ -71,6 +71,38 @@ def serialize_contributors_for_summary(node, max_count=3): 'others_count': others_count, } + +def serialize_mapcore_group_for_summary(node, max_count=3): + # # TODO: Use .filter(visible=True) when chaining is fixed in django-include + node_mapcore_groups = node.mapcore_node_groups.filter(is_deleted=False, visible=True).select_related('mapcore_group') + mapcore_groups = [] + n_node_mapcore_groups = node_mapcore_groups.count() + others_count = '' + + for index, node_mapcore_group in enumerate(node_mapcore_groups[:max_count]): + + if index == max_count - 1 and n_node_mapcore_groups > max_count: + separator = ' &' + others_count = str(n_node_mapcore_groups - 3) + elif index == n_node_mapcore_groups - 1: + separator = '' + elif index == n_node_mapcore_groups - 2: + separator = ' &' + else: + separator = ',' + + mapcore_group_summary = { + 'name': node_mapcore_group.mapcore_group._id, + 'url': node_mapcore_group.mapcore_group.absolute_url, + } + mapcore_group_summary['separator'] = separator + + mapcore_groups.append(mapcore_group_summary) + return { + 'mapcore_groups': mapcore_groups, + 'mapcore_groups_others_count': others_count, + } + def serialize_groups_for_summary(node): groups = node.osf_groups n_groups = len(groups) @@ -108,6 +140,7 @@ def serialize_node_summary(node, auth, primary=True, show_path=False): user = auth.user if node.can_view(auth): contributor_data = serialize_contributors_for_summary(node) + mapcore_group_data = serialize_mapcore_group_for_summary(node) summary.update({ 'can_view': True, 'can_edit': node.can_edit(auth), @@ -143,6 +176,8 @@ def serialize_node_summary(node, auth, primary=True, show_path=False): 'contributors': contributor_data['contributors'], 'others_count': contributor_data['others_count'], 'groups': serialize_groups_for_summary(node), + 'mapcore_groups': mapcore_group_data['mapcore_groups'], + 'mapcore_groups_others_count': mapcore_group_data['mapcore_groups_others_count'], 'description': node.description if len(node.description) <= 150 else node.description[0:150] + '...', }) else: From ff45fafeed5a614821ffcbd139aec68508c4e516 Mon Sep 17 00:00:00 2001 From: ndnhat Date: Mon, 23 Feb 2026 14:23:04 +0700 Subject: [PATCH 2/7] =?UTF-8?q?ref:=202.3.=E3=82=B0=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=97=E7=AE=A1=E7=90=86=E9=80=A3=E6=90=BA=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E9=96=8B=E7=99=BA:=20Resolve=20migrations=20conflict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- osf/migrations/0264_merge_20260223_0712.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 osf/migrations/0264_merge_20260223_0712.py diff --git a/osf/migrations/0264_merge_20260223_0712.py b/osf/migrations/0264_merge_20260223_0712.py new file mode 100644 index 00000000000..35f71f5eb88 --- /dev/null +++ b/osf/migrations/0264_merge_20260223_0712.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.28 on 2026-02-23 07:12 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0262_auto_20260202_0643'), + ('osf', '0263_merge_20260130_1152'), + ] + + operations = [ + ] From 0eb00643ae08a33e2d2df86df3690f46e108a392 Mon Sep 17 00:00:00 2001 From: hcphat Date: Wed, 18 Mar 2026 18:21:43 +0700 Subject: [PATCH 3/7] =?UTF-8?q?ref:=202.3.=E3=82=B0=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=97=E7=AE=A1=E7=90=86=E9=80=A3=E6=90=BA=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E9=96=8B=E7=99=BA:=20Update=20code=20handle=20groups=20addon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons.json | 9 +- addons/groups/README.md | 5 + addons/groups/__init__.py | 6 + addons/groups/apps.py | 53 +++++ addons/groups/migrations/0001_initial.py | 58 ++++++ addons/groups/migrations/__init__.py | 0 addons/groups/models.py | 10 + addons/groups/routes.py | 24 +++ addons/groups/settings/__init__.py | 10 + addons/groups/settings/defaults.py | 3 + addons/groups/static/comicon.png | Bin 0 -> 2443 bytes addons/groups/static/groupsNodeConfig.js | 185 +++++++++++++++++ addons/groups/static/node-cfg.js | 5 + .../templates/groups_node_settings.mako | 17 ++ addons/groups/tests/__init__.py | 0 addons/groups/tests/test_models.py | 13 ++ addons/groups/tests/test_views.py | 30 +++ addons/groups/views.py | 26 +++ admin/base/settings/defaults.py | 4 +- admin/rdm_addons/views.py | 5 + admin/rdm_timestampadd/views.py | 5 +- .../templates/rdm_timestampadd/node_list.html | 10 + admin_tests/rdm_timestampadd/test_views.py | 14 +- api/base/settings/defaults.py | 1 + api/nodes/views.py | 5 +- api/users/serializers.py | 3 + .../views/test_node_mapcore_group_views.py | 196 +++++++++++++++++- .../users/serializers/test_serializers.py | 2 + framework/addons/data/addons.json | 30 +++ osf/models/__init__.py | 1 + osf/models/mapcore_group.py | 4 + osf/models/mixins.py | 46 ++-- osf/models/node.py | 7 + tests/test_node_groups_view.py | 1 + website/project/views/node.py | 7 + website/static/css/pages/contributor-page.css | 12 ++ website/static/js/groupsManager.js | 8 +- website/static/js/pages/sharing-page.js | 2 +- website/templates/project/groups.mako | 5 + website/templates/project/project.mako | 6 + website/templates/project/project_header.mako | 5 +- website/templates/util/group_list.mako | 2 +- website/templates/util/render_node.mako | 2 + .../en/LC_MESSAGES/js_messages.po | 114 ++++++++++ .../translations/en/LC_MESSAGES/messages.po | 9 + .../ja/LC_MESSAGES/js_messages.po | 108 ++++++++++ .../translations/ja/LC_MESSAGES/messages.po | 9 + website/translations/messages.pot | 9 + website/views.py | 4 + 49 files changed, 1052 insertions(+), 38 deletions(-) create mode 100644 addons/groups/README.md create mode 100644 addons/groups/__init__.py create mode 100644 addons/groups/apps.py create mode 100644 addons/groups/migrations/0001_initial.py create mode 100644 addons/groups/migrations/__init__.py create mode 100644 addons/groups/models.py create mode 100644 addons/groups/routes.py create mode 100644 addons/groups/settings/__init__.py create mode 100644 addons/groups/settings/defaults.py create mode 100644 addons/groups/static/comicon.png create mode 100644 addons/groups/static/groupsNodeConfig.js create mode 100644 addons/groups/static/node-cfg.js create mode 100644 addons/groups/templates/groups_node_settings.mako create mode 100644 addons/groups/tests/__init__.py create mode 100644 addons/groups/tests/test_models.py create mode 100644 addons/groups/tests/test_views.py create mode 100644 addons/groups/views.py diff --git a/addons.json b/addons.json index 21eff15cf2b..88d90c2c997 100644 --- a/addons.json +++ b/addons.json @@ -33,7 +33,8 @@ "onedrivebusiness", "metadata", "onlyoffice", - "workflow" + "workflow", + "groups" ], "addons_default": [ "osfstorage", @@ -140,7 +141,8 @@ "onedrivebusiness": "OneDrive for Office365 is a file storage add-on. Connect your Microsoft OneDrive account to a GakuNin RDM project to interact with files hosted on Microsoft OneDrive via the GakuNin RDM.", "metadata": "The Metadata addon provides the functionality to register metadata to files and generate reports.", "onlyoffice": "ONLYOFFICE document server.", - "workflow": "Workflow gateway integration that serves engine key material to trusted services." + "workflow": "Workflow gateway integration that serves engine key material to trusted services.", + "groups": "Groups is an add-on that allows users to create and manage groups of users within the GakuNin RDM." }, "addons_url": { "box": "http://www.box.com", @@ -169,7 +171,8 @@ "onedrivebusiness": "https://onedrive.live.com", "metadata": "https://rcos.nii.ac.jp/service/rdm/", "onlyoffice": "https://onlyoffice.com/", - "workflow": "https://rcos.nii.ac.jp/service/rdm/" + "workflow": "https://rcos.nii.ac.jp/service/rdm/", + "groups": "https://rcos.nii.ac.jp/service/rdm/groups/" }, "institutional_storage_add_on_method": [ "nextcloudinstitutions", diff --git a/addons/groups/README.md b/addons/groups/README.md new file mode 100644 index 00000000000..e8aacb68a4f --- /dev/null +++ b/addons/groups/README.md @@ -0,0 +1,5 @@ +# RDM Groups Addon + +## Feature + +The RDM Groups Addon provides a way to enable/disable groups management in project diff --git a/addons/groups/__init__.py b/addons/groups/__init__.py new file mode 100644 index 00000000000..1e7194b1a69 --- /dev/null +++ b/addons/groups/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- + +DISPLAY_NAME = 'Groups' +SHORT_NAME = 'groups' +FULL_NAME = 'addons.groups' +default_app_config = 'addons.{}.apps.AddonAppConfig'.format(SHORT_NAME) diff --git a/addons/groups/apps.py b/addons/groups/apps.py new file mode 100644 index 00000000000..1f067f09d64 --- /dev/null +++ b/addons/groups/apps.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +import os +from addons.base.apps import BaseAddonAppConfig +from . import DISPLAY_NAME, SHORT_NAME + + +HERE = os.path.dirname(os.path.abspath(__file__)) +TEMPLATE_PATH = os.path.join( + HERE, + 'templates' +) + + +class AddonAppConfig(BaseAddonAppConfig): + + short_name = SHORT_NAME + name = 'addons.{}'.format(SHORT_NAME) + label = 'addons_{}'.format(SHORT_NAME) + + full_name = DISPLAY_NAME + + owners = ['user', 'node'] + + views = ['page'] + configs = ['node'] + + categories = ['other'] + + include_js = {} + + include_css = { + 'widget': [], + 'page': [], + } + + added_default = [] + + has_page_icon = False + + node_settings_template = os.path.join(TEMPLATE_PATH, 'groups_node_settings.mako') + + @property + def routes(self): + from . import routes + return [routes.api_routes, routes.page_routes] + + @property + def user_settings(self): + return self.get_model('UserSettings') + + @property + def node_settings(self): + return self.get_model('NodeSettings') diff --git a/addons/groups/migrations/0001_initial.py b/addons/groups/migrations/0001_initial.py new file mode 100644 index 00000000000..ea54de1cfe0 --- /dev/null +++ b/addons/groups/migrations/0001_initial.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.28 on 2026-03-09 07:22 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import osf.models.base +import osf.utils.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='NodeSettings', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('_id', models.CharField(db_index=True, default=osf.models.base.generate_object_id, max_length=24, unique=True)), + ('is_deleted', models.BooleanField(default=False)), + ('deleted', osf.utils.fields.NonNaiveDateTimeField(blank=True, null=True)), + ('owner', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='addons_groups_node_settings', to='osf.AbstractNode')), + ], + options={ + 'abstract': False, + }, + bases=(models.Model, osf.models.base.QuerySetExplainMixin), + ), + migrations.CreateModel( + name='UserSettings', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('_id', models.CharField(db_index=True, default=osf.models.base.generate_object_id, max_length=24, unique=True)), + ('is_deleted', models.BooleanField(default=False)), + ('deleted', osf.utils.fields.NonNaiveDateTimeField(blank=True, null=True)), + ('owner', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='addons_groups_user_settings', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + bases=(models.Model, osf.models.base.QuerySetExplainMixin), + ), + migrations.AddField( + model_name='nodesettings', + name='user_settings', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='addons_groups.UserSettings'), + ), + ] diff --git a/addons/groups/migrations/__init__.py b/addons/groups/migrations/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/addons/groups/models.py b/addons/groups/models.py new file mode 100644 index 00000000000..eb37d2143c3 --- /dev/null +++ b/addons/groups/models.py @@ -0,0 +1,10 @@ +from addons.base.models import BaseUserSettings, BaseNodeSettings +from django.db import models + + +class UserSettings(BaseUserSettings): + pass + + +class NodeSettings(BaseNodeSettings): + user_settings = models.ForeignKey(UserSettings, null=True, blank=True, on_delete=models.CASCADE) diff --git a/addons/groups/routes.py b/addons/groups/routes.py new file mode 100644 index 00000000000..1cf83957cea --- /dev/null +++ b/addons/groups/routes.py @@ -0,0 +1,24 @@ +""" +Routes associated with the groups addon +""" + +from framework.routing import Rule, json_renderer +from . import SHORT_NAME +from . import views + + +TEMPLATE_DIR = './addons/groups/templates/' + +api_routes = { + 'rules': [ + Rule([ + '/project//{}/settings/'.format(SHORT_NAME), + '/project//node//{}/settings/'.format(SHORT_NAME), + ], 'get', views.groups_get_config, json_renderer), + ], + 'prefix': '/api/v1', +} + +page_routes = { + 'rules': [] +} diff --git a/addons/groups/settings/__init__.py b/addons/groups/settings/__init__.py new file mode 100644 index 00000000000..2b2f98881f6 --- /dev/null +++ b/addons/groups/settings/__init__.py @@ -0,0 +1,10 @@ +import logging +from .defaults import * # noqa + + +logger = logging.getLogger(__name__) + +try: + from .local import * # noqa +except ImportError: + logger.warn('No local.py settings file found') diff --git a/addons/groups/settings/defaults.py b/addons/groups/settings/defaults.py new file mode 100644 index 00000000000..115021ce815 --- /dev/null +++ b/addons/groups/settings/defaults.py @@ -0,0 +1,3 @@ +""" +Groups addon default settings +""" diff --git a/addons/groups/static/comicon.png b/addons/groups/static/comicon.png new file mode 100644 index 0000000000000000000000000000000000000000..81ddf482840763dd0a071fb0b9e805f2ceae4136 GIT binary patch literal 2443 zcmb_e4OA0X77l1w&;=DvIkt9n9CX1WlbKBZX9*mdAc<=zg{(*`yG~{%WNMNLnSmty z*cPeS-GUXX)QU@Y70+t@TkYRj2-*S#3ffk!y5JVArCUI1TWhgOZC?VUy6f)t^lZ*~ znK$o!@80`;_r81Pne430=ot^p5Q#+5MuR>FJZFP3Co%&3pE;D=4jxf1Lq0DO&5R3- zFwu(*b48+v2-dtxSY=v4P@F?T(wv2ncpWZ)7Ku{Qye^Vj%LtH#DP)~m_-gYp7-DHH zoTo5hCYO$}v4&DNlUtf)rb^dR8X8VZg;Kl(FyLSW67o9iPM+{;;hChEN!WLn@6#s+7x>STclTIEG>hREi@~C4tEasS29>!N8lF zwh}q|Ws|=kH;hN$RwP*5S3~)8Wh7(97g~G;eAel^de3^VakG@;VC!k z5?Ia&1uT*lu2|5*AkrWOfwlf%*U3-n1QbI9JE&BG1xQV>b+|$i+=cX#uDgIV6~eiS z-S%LdG=(yD#=$rR9$=**qb?gKaJ-HC52i!gcR2uAFquL=?iq{25pux`86|-6q;~g2 z^JbrmL30?MD|S;%MhOTr0fqt!5D&Xq@J0so)56k#7A`{PW=Mh2gQ0~3NklLWg5zdP zNyu?Rp<0S*2n?HmnmC%Z`u-iNB5)}o$L@lH3ZhAY{GVW&BCMR-K>|{&gDhlFm$MLt zLQ)Yr&d#}kV-TI}&UK?sm+j`PtQ~CdIhhYZ#tfZOs!=KtTr$DTWFm}CULc(mW7KP5 zz*WMsG@+DRaJ9mMBWjXSAaXTLBNmL2A##OsJawK1=dC!y zU=%1(iq>F=+^Ufx7BvNAQ^-_uT285yw0i11&>P%H(*Bd}1ny~|J_rNL3!K|Gd6ROP zqRE+^g@PqPkkmw0d9s9|;mJkz9{hh?{1Zk!HU<>RomCM`wG5w1^#qLPtb&JhGwFpu ztEq00J#`r%Tq?n#Boj%o&cO9pG8G+VBb|i|=u;?+{$x5oE}j3C&JUIQ|D=Nk8q`Fh z(0|QgaPN+02G|FeK!*%WL#-LSgql0!1WLI<>nNR@bQclha~qS&wBW4fO=Oh1*}e)0KUY(Df@<+86iJJzLq8+Pqcm>dMSL{_JngZgV(Pw~EU)%_0=*Qs$oQ|L)Rw-Inp&;l`9K z;fdy7d;9|*XyZ0+%(o_l=N2ug%l@KjpY@16KE>~g9zc2ygkStN@5$4D=XW;^oi9&b z9cFuA-`V&-)0@Q=329`KZNu=4bm>s-Sww~I9Es%P_UySF)Bn~#I#r6kU5Q8kdH;dO z`_|Oy?a4iv-8oBJ-#E1QHSc(R?(M&Pv&?iUz9Z=u5u)d&8}}EC4cUkI(X_hN{(Y~e znOny2v4*jXt*^J%zO!KUV8pq$C3R>^$>MVVaPRs>#?2pdqN`orxWhZ!(x%_6eoHp3 zOmbsm@$c~MhSzp?Su3hHEnmN5tEqlN>HPN68CkWw`h^>|m^FxRPfbbZoDUajVr!cW zXO)f36|Jr@&!1{`4PAUD3AJ|h<)t;pqotju!Q-Y@x7JiQvr6}~TQ>(T^nFvl{q$4g ziA-0NqQR_a{N+e|bFJ@yWl>D#-q_x5<&Eeg<}<$T#s$mg7#GiKJX7z>m>v67Y|FGT z!55Bg|Els4w7W7Ly?dnjPxd$$CBm_WzIV=*_cq+; zNOoR-@6v}n(b&X$2zw!PR>q~#^kNNq-&PdD$V{K!t{?3lFLx6jN?(^((2b1?hp)C>Z!O^Z>&JD214kb( zyv&}{Jz-ceuV!PDS=za~`h?{a95?3$$F*0!9UXZ&%zJz0=f@IKSI#?r$a0_~GNFI$ R<-q5XF(XTV@S!!8-vj$cjyV7T literal 0 HcmV?d00001 diff --git a/addons/groups/static/groupsNodeConfig.js b/addons/groups/static/groupsNodeConfig.js new file mode 100644 index 00000000000..43ede63a811 --- /dev/null +++ b/addons/groups/static/groupsNodeConfig.js @@ -0,0 +1,185 @@ +/** + * Module that controls the Groups node settings. Includes Knockout view-model + * for syncing data. + */ + +const ko = require('knockout'); +const Raven = require('raven-js'); + +const $osf = require('js/osfHelpers'); +const ChangeMessageMixin = require('js/changeMessage'); + +const _ = require('js/rdmGettext')._; + + +const INTERVAL_NODE_SETTINGS = 500; +const MAX_RETRY_NODE_SETTINGS = 10; +const logPrefix = '[groups]'; + +const $modal = $('#groupsApplyDialog'); + +function initHooks(nodeId, addons, callback) { + if (!addons || addons.length === 0) { + return; + } + const prefixes = addons.map(function(addon) { + return '/api/v1/project/' + nodeId + '/' + addon.name + '/'; + }); + console.log(logPrefix, 'initHooks', prefixes); + (function() { + var origOpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function(method, url) { + this.addEventListener('load', function() { + if (prefixes.every(function(prefix) { + return !url.startsWith(prefix); + })) { + return; + } + const targetAddon = addons[prefixes.findIndex(function(prefix) { + return url.startsWith(prefix); + })]; + console.log(logPrefix, 'HTTP Request Completed: ', targetAddon, url); + callback(targetAddon); + }); + origOpen.apply(this, arguments); + }; + })(); +} + + +function ViewModel(nodeId, url) { + const self = this; + ChangeMessageMixin.call(self); + + self.addonName = 'Groups'; + self.nodeId = nodeId; + self.url = url; + self.urls = ko.observable(); + + self.loadedSettings = ko.observable(false); + self.importedAddonSettings = ko.observableArray([]); + + self.applicableAddonSettings = ko.computed(function() { + return self.importedAddonSettings().filter(function(setting) { + return setting.applicable && !setting.applied; + }); + }); + self.nonApplicableAddonSettings = ko.computed(function() { + return self.importedAddonSettings().filter(function(setting) { + return setting.full_name && !setting.applicable; + }); + }); + self.incompletedAddonSettings = ko.computed(function() { + return self.importedAddonSettings().filter(function(setting) { + return setting.full_name && !setting.applied; + }); + }); + + self.refresh(function() { + const addons = []; + initHooks(self.nodeId, self.importedAddonSettings(), function(targetAddon) { + console.log(logPrefix, 'initHooks callback', targetAddon); + self.waitForAddonSetting(targetAddon, function(addons) { + console.log(logPrefix, 'waitForAddonSetting callback', addons); + const nonAppliedAddons = addons.filter(function(addon) { + return !addon.applied; + }); + if (nonAppliedAddons.length === 0) { + return; + } + $modal.modal('show'); + }); + }); + }); +} + +$.extend(ViewModel.prototype, ChangeMessageMixin.prototype); + +ViewModel.prototype.refresh = function(callback) { + const self = this; + $.ajax({ + url: self.url, + type: 'GET', + dataType: 'json' + }).done(function(response) { + const importedAddonSettings = response.data.attributes.imported_addon_settings || []; + self.importedAddonSettings(importedAddonSettings); + self.loadedSettings(true); + if (!callback) { + return; + } + callback(); + }).fail(function(xhr, textStatus, error) { + self.changeMessage(_('Could not GET groups settings'), 'text-danger'); + Raven.captureMessage('Could not GET groups settings', { + extra: { + url: self.url, + textStatus: textStatus, + error: error + } + }); + }); +}; + +ViewModel.prototype.waitForAddonSetting = function(targetAddon, callback) { + const self = this; + var retry = MAX_RETRY_NODE_SETTINGS; + const interval = setInterval(function() { + self.refresh(function() { + const setting = self.importedAddonSettings().filter(function(setting) { + return setting.applicable && setting.name === targetAddon.name; + }); + if (setting.length === 0 && retry > 0) { + retry --; + return; + } + console.log(logPrefix, 'Settings updated', setting); + clearInterval(interval); + if (setting && callback) { + callback(setting); + } + }); + }, INTERVAL_NODE_SETTINGS); +}; + +ViewModel.prototype.applyAddonSettings = function() { + const self = this; + $osf.putJSON( + self.url, + { + addons: self.applicableAddonSettings().map(function(addon) { + return addon.name; + }), + } + ) + .then(function() { + self.changeMessage(_('Add-on settings configured.'), 'text-success'); + setTimeout(function() { + window.location.reload(); + }, 1000) + }) + .catch(function(xhr, textStatus, error) { + self.changeMessage(_('Failed to configure add-on settings.'), 'text-danger') + Raven.captureMessage('Failed to configure add-on settings.', { + extra: { + url: self.url, + textStatus: textStatus, + error: error + } + }); + }); + + $modal.modal('hide'); +}; + +function GroupsNodeConfig(selector, nodeId, url) { + // Initialization code + const self = this; + self.nodeId = nodeId; + self.url = url; + // On success, instantiate and bind the ViewModel + self.viewModel = new ViewModel(nodeId, url); + $osf.applyBindings(self.viewModel, selector); +} + +module.exports = GroupsNodeConfig; diff --git a/addons/groups/static/node-cfg.js b/addons/groups/static/node-cfg.js new file mode 100644 index 00000000000..931436c0105 --- /dev/null +++ b/addons/groups/static/node-cfg.js @@ -0,0 +1,5 @@ +const GroupsNodeConfig = require('./groupsNodeConfig.js'); +const SHORT_NAME = 'groups'; +const nodeId = window.contextVars.node.id; +const url = window.contextVars.node.urls.api + SHORT_NAME + '/settings/'; +new GroupsNodeConfig('#' + SHORT_NAME + 'Scope', nodeId, url); diff --git a/addons/groups/templates/groups_node_settings.mako b/addons/groups/templates/groups_node_settings.mako new file mode 100644 index 00000000000..93e77bbf1ff --- /dev/null +++ b/addons/groups/templates/groups_node_settings.mako @@ -0,0 +1,17 @@ + + +
    +

    + + ${addon_full_name} +

    + +
    +
    +
    + ${_("No configuration items.")} +
    +
    + +
    +
    diff --git a/addons/groups/tests/__init__.py b/addons/groups/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/addons/groups/tests/test_models.py b/addons/groups/tests/test_models.py new file mode 100644 index 00000000000..722b5a8cb15 --- /dev/null +++ b/addons/groups/tests/test_models.py @@ -0,0 +1,13 @@ +import pytest +from addons.groups.models import UserSettings, NodeSettings + +@pytest.mark.django_db +def test_user_settings_creation(): + user_settings = UserSettings.objects.create() + assert isinstance(user_settings, UserSettings) + +@pytest.mark.django_db +def test_node_settings_user_settings_fk(): + user_settings = UserSettings.objects.create() + node_settings = NodeSettings.objects.create(user_settings=user_settings) + assert node_settings.user_settings == user_settings diff --git a/addons/groups/tests/test_views.py b/addons/groups/tests/test_views.py new file mode 100644 index 00000000000..3f7e14db247 --- /dev/null +++ b/addons/groups/tests/test_views.py @@ -0,0 +1,30 @@ +import pytest +from unittest.mock import MagicMock, patch + +@pytest.mark.django_db +def test_groups_get_config_returns_expected_structure(): + mock_addon = MagicMock() + mock_node = MagicMock() + mock_node.get_addon.return_value = mock_addon + mock_node._id = 'abc123' + mock_node.is_deleted = False + mock_node.is_public = True + mock_node.is_collection = False + mock_node.is_quickfiles = False + + kwargs = {'node': mock_node, 'project': None} + auth = MagicMock() + + with patch('website.project.decorators.must_be_valid_project', lambda f: f), \ + patch('framework.auth.decorators.must_be_logged_in', lambda f: f), \ + patch('website.project.decorators.must_have_permission', lambda *a, **kw: lambda f: f), \ + patch('website.project.decorators.must_have_addon', lambda *a, **kw: lambda f: f): + from addons.groups import views as groups_views + import importlib + importlib.reload(groups_views) + + response = groups_views.groups_get_config(auth, **kwargs) + + assert 'data' in response + assert response['data']['type'] == 'groups-config' + assert isinstance(response['data']['attributes'], dict) diff --git a/addons/groups/views.py b/addons/groups/views.py new file mode 100644 index 00000000000..3567065f19f --- /dev/null +++ b/addons/groups/views.py @@ -0,0 +1,26 @@ +from . import SHORT_NAME +from framework.auth.decorators import must_be_logged_in +from website.project.decorators import ( + must_be_valid_project, + must_have_addon, + must_have_permission +) +from osf.utils.permissions import READ + + +def _response_config(addon): + return { + 'data': { + 'type': 'groups-config', + 'attributes': {} + } + } + +@must_be_valid_project +@must_be_logged_in +@must_have_permission(READ) +@must_have_addon(SHORT_NAME, 'node') +def groups_get_config(auth, **kwargs): + node = kwargs['node'] or kwargs['project'] + addon = node.get_addon(SHORT_NAME) + return _response_config(addon) diff --git a/admin/base/settings/defaults.py b/admin/base/settings/defaults.py index fb18e46f3ff..1c7a6dfe8be 100644 --- a/admin/base/settings/defaults.py +++ b/admin/base/settings/defaults.py @@ -140,6 +140,7 @@ 'addons.onedrivebusiness', 'addons.metadata', 'addons.workflow', + 'addons.groups', ) MIGRATION_MODULES = { @@ -186,7 +187,8 @@ 'nextcloud', 'gitlab', 'onedrive', - 'iqbrims' + 'iqbrims', + 'groups' ] USE_TZ = True diff --git a/admin/rdm_addons/views.py b/admin/rdm_addons/views.py index 5dfcfe3ad49..8e281706d05 100644 --- a/admin/rdm_addons/views.py +++ b/admin/rdm_addons/views.py @@ -96,6 +96,9 @@ def get_context_data(self, **kwargs): with app.test_request_context(): ctx['addon_settings'] = utils.get_addons_by_config_type('accounts', self.request.user) + ctx['addon_settings'].append( + utils.get_addon_template_config(utils.get_addon_config('node', 'groups'), self.request.user)) + accounts_addons = [addon for addon in website_settings.ADDONS_AVAILABLE if 'accounts' in addon.configs and not addon.for_institutions] ctx.update({ @@ -126,6 +129,8 @@ def test_func(self): def get(self, request, *args, **kwargs): addon_name = kwargs['addon_name'] addon = utils.get_addon_config('accounts', addon_name) + if addon_name == 'groups': + addon = utils.get_addon_config('node', addon_name) if addon: # get addon's icon image_path = os.path.join('addons', addon_name, 'static', addon.icon) diff --git a/admin/rdm_timestampadd/views.py b/admin/rdm_timestampadd/views.py index 6d3a48a1bc0..d279554ce1f 100644 --- a/admin/rdm_timestampadd/views.py +++ b/admin/rdm_timestampadd/views.py @@ -127,16 +127,17 @@ def get(self, request, **kwargs): response = HttpResponse(content_type='text/csv') writer = csv.writer(response, delimiter=',') writer.writerow(['Node id', 'GUID', 'Title', 'Parent', 'Root', 'Date created', 'Public', 'Withdrawn', 'Embargo', - 'Contributors']) + 'Contributors', 'Groups']) for node in node_list: parent = getattr(node, 'parent_title', None) root = getattr(node, 'root_title', None) public = getattr(node, 'is_public', None) created = getattr(node, 'created', None).strftime('%Y-%m-%d') if getattr(node, 'created', None) else None contributors = getattr(node, 'contributor_names', None) + groups = ', '.join([mapcore_group.display_name for mapcore_group in node.mapcore_groups]) writer.writerow( [node.id, node.guid, node.title, parent, root, created, public, node.retraction, node.embargo, - contributors]) + contributors, groups]) time_now = datetime.today().strftime('%Y%m%d%H%M%S') query = 'attachment; filename= export_nodes_{}.csv'.format(time_now) response['Content-Disposition'] = query diff --git a/admin/templates/rdm_timestampadd/node_list.html b/admin/templates/rdm_timestampadd/node_list.html index 5ebd82de2ae..a4f30a3b75d 100644 --- a/admin/templates/rdm_timestampadd/node_list.html +++ b/admin/templates/rdm_timestampadd/node_list.html @@ -125,6 +125,7 @@

    {% blocktrans with institutionName=institution.name %}List of Nodes for {{ i {% trans "Withdrawn" %} {% trans "Embargo" %} {% trans "Contributors" %} + {% trans "Groups" %} @@ -184,6 +185,15 @@

    {% blocktrans with institutionName=institution.name %}List of Nodes for {{ i {{ user.username }}{% if not forloop.last %}, {% endif %} {% endfor %} + + {% if not node.mapcore_groups %} + {{ None|transValue }} + {% else %} + {% for mapcore_group in node.mapcore_groups %} + {{ mapcore_group.display_name }}{% if not forloop.last %}, {% endif %} + {% endfor %} + {% endif %} + {% endfor %} diff --git a/admin_tests/rdm_timestampadd/test_views.py b/admin_tests/rdm_timestampadd/test_views.py index 2360b8fc876..c8c24c570fc 100644 --- a/admin_tests/rdm_timestampadd/test_views.py +++ b/admin_tests/rdm_timestampadd/test_views.py @@ -12,7 +12,7 @@ from django.test import RequestFactory from django.core.urlresolvers import reverse from nose import tools as nt -from osf.models import RdmUserKey, RdmFileTimestamptokenVerifyResult, Guid, BaseFileNode +from osf.models import RdmUserKey, RdmFileTimestamptokenVerifyResult, Guid, BaseFileNode, MapCoreGroup from osf_tests.factories import UserFactory, AuthUserFactory, InstitutionFactory, ProjectFactory from tests.base import AdminTestCase from tests.test_timestamp import create_test_file, create_rdmfiletimestamptokenverifyresult @@ -337,6 +337,7 @@ def mock_node(): node.embargo = None node.created = datetime(2023, 1, 1) node.contributor_names = 'user1, user2' + node.mapcore_groups = [MapCoreGroup(_id='group1'), MapCoreGroup(_id='group2')] return node @@ -433,7 +434,7 @@ def test_csv_generation_with_complete_data(self, view_instance, mock_request, mo # Check header row assert rows[0] == ['Node id', 'GUID', 'Title', 'Parent', 'Root', 'Date created', 'Public', 'Withdrawn', 'Embargo', - 'Contributors'] + 'Contributors', 'Groups'] # Check data row assert rows[1][0] == '1' # Node id @@ -442,6 +443,7 @@ def test_csv_generation_with_complete_data(self, view_instance, mock_request, mo assert rows[1][3] == 'Parent Node' # Parent assert rows[1][4] == 'Root Node' # Root assert rows[1][5] == '2023-01-01' # Date created + assert rows[1][10] == 'group1, group2' # Groups def test_csv_generation_with_minimal_data(self, view_instance, mock_request): """Test CSV generation with minimal node data""" @@ -456,6 +458,7 @@ def test_csv_generation_with_minimal_data(self, view_instance, mock_request): minimal_node.embargo = None minimal_node.created = None minimal_node.contributor_names = None + minimal_node.mapcore_groups = [] with mock.patch.object(view_instance, 'get_queryset') as mock_get_queryset: mock_get_queryset.return_value.all.return_value = [minimal_node] @@ -500,7 +503,7 @@ def test_csv_generation_with_empty_queryset(self, view_instance, mock_request): assert len(rows) == 1 assert rows[0] == ['Node id', 'GUID', 'Title', 'Parent', 'Root', 'Date created', 'Public', 'Withdrawn', 'Embargo', - 'Contributors'] + 'Contributors', 'Groups'] def test_filename_format(self, view_instance, mock_request, mock_node): """Test generated filename format""" @@ -522,7 +525,8 @@ def test_filename_format(self, view_instance, mock_request, mock_node): ('retraction', False), ('embargo', None), ('created', datetime(2023, 1, 1)), - ('contributor_names', 'user1, user2') + ('contributor_names', 'user1, user2'), + ('mapcore_groups', [MapCoreGroup(_id='group1'), MapCoreGroup(_id='group2')]), ]) def test_specific_node_attributes(self, view_instance, mock_request, mock_node, node_attribute, expected_value): @@ -542,3 +546,5 @@ def test_specific_node_attributes(self, view_instance, mock_request, mock_node, assert rows[1][5] == '2023-01-01' elif node_attribute == 'contributor_names': assert rows[1][9] == expected_value + elif node_attribute == 'mapcore_groups': + assert rows[1][10] == ', '.join([group.display_name for group in expected_value]) diff --git a/api/base/settings/defaults.py b/api/base/settings/defaults.py index 273e9a2db44..ee28a3d8e9e 100644 --- a/api/base/settings/defaults.py +++ b/api/base/settings/defaults.py @@ -128,6 +128,7 @@ 'addons.metadata', 'addons.workflow', 'addons.onlyoffice', + 'addons.groups', ) # local development using https diff --git a/api/nodes/views.py b/api/nodes/views.py index 72d55db03a1..5f2dae9486d 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -2406,6 +2406,9 @@ def get_serializer_class(self): # overrides ListBulkCreateJSON APIView, BulkUpdateJSONAPIView def get_queryset(self): node = self.get_node() + enabled_mapcore_groups = node.mapcore_groups_addon_enabled() + if not enabled_mapcore_groups: + return MapCoreNodeGroup.objects.none() qs = MapCoreNodeGroup.objects.filter(node=node, is_deleted=False) # Avoid N+1 on foreign-key relations reported by nplusone qs = qs.select_related('creator', 'mapcore_group') @@ -2427,8 +2430,6 @@ def get_queryset(self): for obj in qs: obj.permissions = perm_map.get(obj.group_id, []) - # If any related fields are reverse or many-to-many, use prefetch_related: - # qs = qs.prefetch_related('some_m2m_field') return qs # Overrides BulkDestroyJSONAPIView diff --git a/api/users/serializers.py b/api/users/serializers.py index ba390371d2c..1350f0ff71a 100644 --- a/api/users/serializers.py +++ b/api/users/serializers.py @@ -674,6 +674,9 @@ class UserNodeSerializer(NodeSerializer): def get_mapcore_groups(self, obj): if isinstance(obj, Node): + enabled_mapcore_groups = obj.mapcore_groups_addon_enabled() + if not enabled_mapcore_groups: + return [] node_groups = MapCoreNodeGroup.objects.filter(node=obj, is_deleted=False).select_related('mapcore_group') return [group.mapcore_group._id for group in node_groups] return [] diff --git a/api_tests/nodes/views/test_node_mapcore_group_views.py b/api_tests/nodes/views/test_node_mapcore_group_views.py index 23effc4d69e..7525906a029 100644 --- a/api_tests/nodes/views/test_node_mapcore_group_views.py +++ b/api_tests/nodes/views/test_node_mapcore_group_views.py @@ -7,6 +7,8 @@ from osf.models.mapcore_user_group import MapCoreUserGroup from osf_tests.factories import AuthUserFactory, NodeFactory, ProjectFactory, UserFactory from tests.base import ApiTestCase +from framework.auth import Auth + @pytest.mark.django_db class TestNodeMapCoreGroupList(ApiTestCase): @@ -21,6 +23,7 @@ def setUp(self): self.node = ProjectFactory(creator=self.admin_user, is_public=False) self.node.add_contributor(self.user, permissions='admin') self.node.add_contributor(self.read_only_user, permissions='read') + self.node.add_addon('groups', auth=Auth(self.user)) # Enable groups addon self.node.save() # Create auth groups for the node @@ -764,8 +767,9 @@ def test_has_permission_mapcore_grants_and_denies(self): from osf.models.node import NodeGroupObjectPermission node = ProjectFactory() - # user that will be "in" the mapcore group mapcore_user = UserFactory() + node.add_addon('groups', auth=Auth(mapcore_user)) # Enable groups addon + # user that will be "in" the mapcore group # other user not in group other_user = UserFactory() @@ -794,7 +798,9 @@ def test_has_permission_by_is_admin_group_parent(self): # Create a root node and a child node root = ProjectFactory() + root.add_addon('groups', auth=Auth(root.creator)) # Enable groups addon on root child = NodeFactory(creator=root.creator, parent=root) + child.add_addon('groups', auth=Auth(root.creator)) # Enable groups addon on child # Users mapcore_user = UserFactory() @@ -841,6 +847,7 @@ def test_get_permissions_mapcore_includes_and_excludes(self): from osf.models.node import NodeGroupObjectPermission node = ProjectFactory() + node.add_addon('groups', auth=Auth(node.creator)) # Enable groups addon mapcore_user = UserFactory() other_user = UserFactory() @@ -888,3 +895,190 @@ def test_is_admin_group_parent_handles_mapcore_node_group_filter_exception(self) with mock.patch('osf.models.mapcore_node_group.MapCoreNodeGroup.objects.filter', side_effect=Exception('boom')): assert is_admin_group_parent(parent, user_mapcore_group_ids) is False + + def test_has_permission_mapcore_groups_addon_disabled(self): + """When groups addon is disabled, MapCore permission logic is skipped and user is denied.""" + from django.contrib.auth.models import Permission + from osf.models.node import NodeGroupObjectPermission + + node = ProjectFactory() + mapcore_user = UserFactory() + + # Ensure groups addon is disabled + if node.has_addon('groups'): + node.delete_addon('groups', auth=Auth(mapcore_user)) + assert not node.has_addon('groups') + + mg = MapCoreGroup.objects.create(_id='mcg-addon-disabled-hasperm') + ag = AuthGroup.objects.create(name=f'node_{node._id}_read') + + MapCoreNodeGroup.objects.create(node=node, group=ag, mapcore_group=mg, creator=node.creator) + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + perm = Permission.objects.get(codename='read_node') + NodeGroupObjectPermission.objects.create(group=ag, permission=perm, content_object=node) + + # Without groups addon, MapCore permissions are not applied + assert node.has_permission(mapcore_user, 'read') is False + + def test_has_permission_mapcore_groups_addon_enabled(self): + """When groups addon is enabled, MapCore permission logic grants access.""" + from django.contrib.auth.models import Permission + from osf.models.node import NodeGroupObjectPermission + node = ProjectFactory() + mapcore_user = UserFactory() + node.add_addon('groups', auth=Auth(mapcore_user)) # Enable groups addon + + mg = MapCoreGroup.objects.create(_id='mcg-addon-enabled-hasperm') + ag = AuthGroup.objects.create(name=f'node_{node._id}_read') + + MapCoreNodeGroup.objects.create(node=node, group=ag, mapcore_group=mg, creator=node.creator) + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + perm = Permission.objects.get(codename='read_node') + NodeGroupObjectPermission.objects.create(group=ag, permission=perm, content_object=node) + + # With groups addon enabled, MapCore permissions ARE applied + assert node.has_permission(mapcore_user, 'read') is True + + def test_get_permissions_mapcore_groups_addon_disabled(self): + """When groups addon is disabled, MapCore-derived permissions are not included.""" + from django.contrib.auth.models import Permission + from osf.models.node import NodeGroupObjectPermission + + node = ProjectFactory() + mapcore_user = UserFactory() + + # groups addon is NOT enabled + # Ensure groups addon is disabled + if node.has_addon('groups'): + node.delete_addon('groups', auth=Auth(mapcore_user)) + assert not node.has_addon('groups') + + mg = MapCoreGroup.objects.create(_id='mcg-addon-disabled-getperms') + ag = AuthGroup.objects.create(name=f'node_{node._id}_read') + + MapCoreNodeGroup.objects.create(node=node, group=ag, mapcore_group=mg, creator=node.creator) + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + perm = Permission.objects.get(codename='read_node') + NodeGroupObjectPermission.objects.create(group=ag, permission=perm, content_object=node) + + # Without groups addon, MapCore-derived permissions are not included + perms = node.get_permissions(mapcore_user) + assert 'read' not in perms + + def test_get_permissions_mapcore_groups_addon_enabled(self): + """When groups addon is enabled, MapCore-derived permissions are included.""" + from django.contrib.auth.models import Permission + from osf.models.node import NodeGroupObjectPermission + + node = ProjectFactory() + mapcore_user = UserFactory() + node.add_addon('groups', auth=Auth(mapcore_user)) # Enable groups addon + + mg = MapCoreGroup.objects.create(_id='mcg-addon-enabled-getperms') + ag = AuthGroup.objects.create(name=f'node_{node._id}_read') + + MapCoreNodeGroup.objects.create(node=node, group=ag, mapcore_group=mg, creator=node.creator) + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + perm = Permission.objects.get(codename='read_node') + NodeGroupObjectPermission.objects.create(group=ag, permission=perm, content_object=node) + + # With groups addon enabled, MapCore-derived permissions ARE included + perms = node.get_permissions(mapcore_user) + assert 'read' in perms + + def test_has_permission_parent_admin_via_mapcore_groups_addon_disabled(self): + """When groups addon is disabled, parent admin via MapCore does NOT grant child read.""" + from django.contrib.auth.models import Permission + from osf.models.node import NodeGroupObjectPermission + + root = ProjectFactory() + root.add_addon('groups', auth=Auth(root.creator)) # Enable groups addon on root + child = NodeFactory(creator=root.creator, parent=root) + mapcore_user = UserFactory() + + # groups addon NOT enabled on child + if child.has_addon('groups'): + child.delete_addon('groups', auth=Auth(mapcore_user)) + assert not child.has_addon('groups') + + mg = MapCoreGroup.objects.create(_id='mcg-parent-admin-disabled') + ag = AuthGroup.objects.create(name=f'node_{root._id}_admin') + + MapCoreNodeGroup.objects.create(node=root, group=ag, mapcore_group=mg, creator=root.creator) + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + perm = Permission.objects.get(codename='admin_node') + NodeGroupObjectPermission.objects.create(group=ag, permission=perm, content_object=root) + + # Without groups addon on child, is_admin_group_parent path is not taken + assert child.has_permission(mapcore_user, 'read') is True + + def test_has_permission_parent_admin_via_mapcore_groups_addon_enabled(self): + """When groups addon is enabled, parent admin via MapCore grants child read.""" + from django.contrib.auth.models import Permission + from osf.models.node import NodeGroupObjectPermission + + root = ProjectFactory() + child = NodeFactory(creator=root.creator, parent=root) # Enable groups addon on child + mapcore_user = UserFactory() + child.add_addon('groups', auth=Auth(mapcore_user)) + + mg = MapCoreGroup.objects.create(_id='mcg-parent-admin-enabled') + ag = AuthGroup.objects.create(name=f'node_{root._id}_admin') + + MapCoreNodeGroup.objects.create(node=root, group=ag, mapcore_group=mg, creator=root.creator) + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + perm = Permission.objects.get(codename='admin_node') + NodeGroupObjectPermission.objects.create(group=ag, permission=perm, content_object=root) + + # With groups addon enabled on child, is_admin_group_parent path grants read + assert child.has_permission(mapcore_user, 'read') is True + + def test_has_permission_mapcore_groups_addon_deleted_acts_as_disabled(self): + """A deleted (soft-removed) groups addon is treated as disabled.""" + from django.contrib.auth.models import Permission + from osf.models.node import NodeGroupObjectPermission + + node = ProjectFactory() + mapcore_user = UserFactory() + node.add_addon('groups', auth=Auth(mapcore_user)) + node.delete_addon('groups', auth=Auth(mapcore_user)) # Soft-delete the addon + + mg = MapCoreGroup.objects.create(_id='mcg-addon-deleted-hasperm') + ag = AuthGroup.objects.create(name=f'node_{node._id}_read') + + MapCoreNodeGroup.objects.create(node=node, group=ag, mapcore_group=mg, creator=node.creator) + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + perm = Permission.objects.get(codename='read_node') + NodeGroupObjectPermission.objects.create(group=ag, permission=perm, content_object=node) + + # Deleted addon is not returned by get_addons, so MapCore logic is skipped + assert node.has_permission(mapcore_user, 'read') is False + + def test_get_permissions_mapcore_groups_addon_deleted_acts_as_disabled(self): + """A deleted (soft-removed) groups addon means MapCore permissions are not included.""" + from django.contrib.auth.models import Permission + from osf.models.node import NodeGroupObjectPermission + + node = ProjectFactory() + mapcore_user = UserFactory() + node.add_addon('groups', auth=Auth(mapcore_user)) + node.delete_addon('groups', auth=Auth(mapcore_user)) # Soft-delete the addon + + mg = MapCoreGroup.objects.create(_id='mcg-addon-deleted-getperms') + ag = AuthGroup.objects.create(name=f'node_{node._id}_read') + + MapCoreNodeGroup.objects.create(node=node, group=ag, mapcore_group=mg, creator=node.creator) + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + perm = Permission.objects.get(codename='read_node') + NodeGroupObjectPermission.objects.create(group=ag, permission=perm, content_object=node) + + perms = node.get_permissions(mapcore_user) + assert 'read' not in perms diff --git a/api_tests/users/serializers/test_serializers.py b/api_tests/users/serializers/test_serializers.py index 26395cc2666..27efa608f7e 100644 --- a/api_tests/users/serializers/test_serializers.py +++ b/api_tests/users/serializers/test_serializers.py @@ -17,6 +17,7 @@ from django.urls import resolve, reverse from osf.models import QuickFilesNode +from framework.auth.core import Auth @pytest.fixture() def user(): @@ -263,6 +264,7 @@ def test_get_mapcore_groups(self, user): from osf_tests.factories import ProjectFactory node = ProjectFactory(creator=user) + node.add_addon('groups', auth=Auth(node.creator)) # Enable groups addon # Create two MapCoreGroup records g1 = MapCoreGroup.objects.create(_id='group-one') diff --git a/framework/addons/data/addons.json b/framework/addons/data/addons.json index 6de9e53f3ec..b813f934e77 100644 --- a/framework/addons/data/addons.json +++ b/framework/addons/data/addons.json @@ -738,6 +738,36 @@ "status": "partial", "text": "Workflow template metadata is captured when creating a template, but live workflow tasks are not executed inside the template." } + }, + "Groups": { + "Permissions": { + "status": "none", + "text": "The GakuNin RDM does not affect the permissions of Groups." + }, + "View / download file versions": { + "status": "none", + "text": "The Groups add-on does not provide Storage Features." + }, + "Add / update files": { + "status": "none", + "text": "The Groups add-on does not provide Storage Features." + }, + "Delete files": { + "status": "none", + "text": "The Groups add-on does not provide Storage Features." + }, + "Logs": { + "status": "none", + "text": "The Groups add-on does not provide Storage Features." + }, + "Forking": { + "status": "none", + "text": "Forking a project or component does not copy Groups authorization." + }, + "Registering": { + "status": "none", + "text": "Groups information will not be registered." + } } }, "disclaimers": [ diff --git a/osf/models/__init__.py b/osf/models/__init__.py index d82b6d44124..c86fc3973ad 100644 --- a/osf/models/__init__.py +++ b/osf/models/__init__.py @@ -72,3 +72,4 @@ from osf.models.project_limit_number_template_attribute import ProjectLimitNumberTemplateAttribute # noqa from osf.models.project_limit_number_setting import ProjectLimitNumberSetting # noqa from osf.models.project_limit_number_setting_attribute import ProjectLimitNumberSettingAttribute # noqa +from osf.models.mapcore_group import MapCoreGroup # noqa diff --git a/osf/models/mapcore_group.py b/osf/models/mapcore_group.py index 0c16c48f04e..8d0cf344154 100644 --- a/osf/models/mapcore_group.py +++ b/osf/models/mapcore_group.py @@ -12,3 +12,7 @@ class Meta: @property def absolute_url(self): return f'{MAPCORE_GROUP_HOSTNAME}{MAPCORE_GROUP_API_PATH}{self._id}/' + + @property + def display_name(self): + return self._id diff --git a/osf/models/mixins.py b/osf/models/mixins.py index 1179f2c7efb..193005e8a12 100644 --- a/osf/models/mixins.py +++ b/osf/models/mixins.py @@ -628,6 +628,9 @@ def _settings_model(self, addon_model, config=None): config = apps.get_app_config('addons_{}'.format(addon_model)) return getattr(config, '{}_settings'.format(self.settings_type)) + def mapcore_groups_addon_enabled(self): + return self.has_addon('groups') + class NodeLinkMixin(models.Model): @@ -1941,8 +1944,12 @@ def has_permission(self, user, permission, check_parent=True): """ object_type = self.guardian_object_type group_perm = [] + enabled_groups = False + if hasattr(self, 'mapcore_groups_addon_enabled'): + enabled_groups = self.mapcore_groups_addon_enabled() + # Also check Auth Groups linked via MapCoreNodeGroup (by auth_group id) - if object_type == 'node': + if object_type == 'node' and enabled_groups: try: user_mapcore_group_ids = MapCoreUserGroup.objects.filter(user=user, is_deleted=False).values_list('mapcore_group_id', flat=True) # get auth group ids linked to this object @@ -1968,7 +1975,7 @@ def has_permission(self, user, permission, check_parent=True): has_permission = perm in get_group_perms(user, self) if object_type == 'node': if not has_permission and permission == READ and check_parent: - if is_admin_group_parent(self.root, user_mapcore_group_ids): + if enabled_groups and is_admin_group_parent(self.root, user_mapcore_group_ids): return True else: return self.is_admin_parent(user) @@ -1995,22 +2002,27 @@ def get_permissions(self, user): # Overrides guardian mixin - returns readable perms instead of literal perms if isinstance(user, AnonymousUser): return [] - - try: - user_mapcore_group_ids = MapCoreUserGroup.objects.filter(user=user, is_deleted=False).values_list('mapcore_group_id', flat=True) - # get auth group ids linked to this object - auth_group_ids = MapCoreNodeGroup.objects.filter(node=self, mapcore_group_id__in=user_mapcore_group_ids, is_deleted=False).values_list('group_id', flat=True) - except Exception: - auth_group_ids = [] + enabled_groups = False + if hasattr(self, 'mapcore_groups_addon_enabled'): + enabled_groups = self.mapcore_groups_addon_enabled() group_perms = [] - if auth_group_ids: - # Try OSF-specific node-group-permission model(s), then fallback to guardian's GroupObjectPermission - NodeGroupPermModel = apps.get_model('osf', 'NodeGroupObjectPermission') - for gid in auth_group_ids: - perms_qs = NodeGroupPermModel.objects.filter(group_id=gid, content_object_id=self.id) - for perm in list(perms_qs.values_list('permission__codename', flat=True)): - if perm not in group_perms: - group_perms.append(perm) + # Also check Auth Groups linked via MapCoreNodeGroup (by auth_group id) if node and groups addon enabled + if enabled_groups: + try: + user_mapcore_group_ids = MapCoreUserGroup.objects.filter(user=user, is_deleted=False).values_list('mapcore_group_id', flat=True) + # get auth group ids linked to this object + auth_group_ids = MapCoreNodeGroup.objects.filter(node=self, mapcore_group_id__in=user_mapcore_group_ids, is_deleted=False).values_list('group_id', flat=True) + except Exception: + auth_group_ids = [] + + if auth_group_ids: + # Try OSF-specific node-group-permission model(s), then fallback to guardian's GroupObjectPermission + NodeGroupPermModel = apps.get_model('osf', 'NodeGroupObjectPermission') + for gid in auth_group_ids: + perms_qs = NodeGroupPermModel.objects.filter(group_id=gid, content_object_id=self.id) + for perm in list(perms_qs.values_list('permission__codename', flat=True)): + if perm not in group_perms: + group_perms.append(perm) # If base_perms not on model, will error perms = self.base_perms diff --git a/osf/models/node.py b/osf/models/node.py index 5628284a02f..eb1321d1adb 100644 --- a/osf/models/node.py +++ b/osf/models/node.py @@ -5,6 +5,7 @@ import re from future.moves.urllib.parse import urljoin import warnings +from osf.models.mapcore_group import MapCoreGroup from osf.models.mapcore_node_group import MapCoreNodeGroup from osf.models.mapcore_user_group import MapCoreUserGroup from rest_framework import status as http_status @@ -2551,6 +2552,12 @@ def guid(self): guid = self.guids.first() return guid._id if guid else guid + @property + def mapcore_groups(self): + if not self.has_addon('groups'): + return MapCoreGroup.objects.none() + return MapCoreGroup.objects.filter(mapcore_group_nodes__node=self, mapcore_group_nodes__is_deleted=False) + def remove_addons(auth, resource_object_list): for config in AbstractNode.ADDONS_AVAILABLE: try: diff --git a/tests/test_node_groups_view.py b/tests/test_node_groups_view.py index d79f4624754..1295f077cc5 100644 --- a/tests/test_node_groups_view.py +++ b/tests/test_node_groups_view.py @@ -38,6 +38,7 @@ def test_node_groups_returns_expected_keys_when_permitted(self): """ with self.context: node = ProjectFactory(is_public=False) + node.add_addon('groups', auth=Auth(node.creator)) # Enable groups addon creator = node.creator # Create a user and grant READ permission user = UserFactory() diff --git a/website/project/views/node.py b/website/project/views/node.py index 600de7e4fce..6fa4cb87d6b 100644 --- a/website/project/views/node.py +++ b/website/project/views/node.py @@ -40,6 +40,7 @@ must_have_permission, must_not_be_registration, must_not_be_retracted_registration, + must_have_addon, ) from osf.utils.tokens import process_token_or_pass from website.util.rubeus import collect_addon_js @@ -538,11 +539,13 @@ def node_contributors(auth, node, **kwargs): @must_not_be_retracted_registration @must_have_permission(READ) @ember_flag_is_active(features.EMBER_PROJECT_CONTRIBUTORS) +@must_have_addon('groups', 'node') def node_groups(auth, node, **kwargs): ret = _view_project(node, auth, primary=True) ret['groups'] = utils.serialize_mapcore_node_groups(node) current_group = [group['mapcore_group']['id'] for group in ret['groups']] ret['adminGroups'] = utils.serialize_parent_admin_groups(node, current_group) + ret['baseUrl'] = settings.MAPCORE_GROUP_HOSTNAME return ret @must_have_permission(ADMIN) @@ -918,6 +921,9 @@ def _view_project(node, auth, primary=False, is_registration = node.is_registration timestamp_pattern = get_timestamp_pattern_division(auth, node) mapcore_groups = utils.serialize_mapcore_node_groups(node, visible_only=True) + enabled_mapcore_groups = False + if hasattr(node, 'mapcore_groups_addon_enabled'): + enabled_mapcore_groups = node.mapcore_groups_addon_enabled() data = { 'node': { 'disapproval_link': disapproval_link, @@ -989,6 +995,7 @@ def _view_project(node, auth, primary=False, 'mfr_url': node.osfstorage_region.mfr_url, 'groups': list(node.osf_groups.values_list('name', flat=True)), 'mapcore_groups': mapcore_groups, + 'enabled_mapcore_groups': enabled_mapcore_groups, }, 'parent_node': { 'exists': parent is not None, diff --git a/website/static/css/pages/contributor-page.css b/website/static/css/pages/contributor-page.css index cc08f74708f..2c1435033e6 100644 --- a/website/static/css/pages/contributor-page.css +++ b/website/static/css/pages/contributor-page.css @@ -160,3 +160,15 @@ th.remove { font-size: 1.5em; } } + +#groupsNotes { + padding-top: 20px; + padding-bottom: 20px; +} + +#groupsNotes h3 { + color: red; + padding: 0; + margin: 0; + font-weight: 500; +} diff --git a/website/static/js/groupsManager.js b/website/static/js/groupsManager.js index 08c340b0b57..93881c5f71f 100644 --- a/website/static/js/groupsManager.js +++ b/website/static/js/groupsManager.js @@ -212,7 +212,7 @@ var MessageModel = function(text, level) { }; -var GroupsViewModel = function(groups, adminGroups, user, isRegistration, table, adminTable, groupShouter, pageChangedShouter) { +var GroupsViewModel = function(groups, adminGroups, user, isRegistration, table, adminTable, groupShouter, pageChangedShouter, baseUrl) { var self = this; @@ -228,6 +228,7 @@ var GroupsViewModel = function(groups, adminGroups, user, isRegistration, table, self.permissionList = Object.keys(self.permissionMap); self.groupToRemove = ko.observable(''); + self.baseUrl = baseUrl; self.groups = ko.observableArray(); self.adminGroups = ko.observableArray(); @@ -473,7 +474,7 @@ var GroupsViewModel = function(groups, adminGroups, user, isRegistration, table, // Public API // //////////////// -function GroupManager(selector, groups, adminGroups, user, isRegistration, table, adminTable) { +function GroupManager(selector, groups, adminGroups, user, isRegistration, table, adminTable, baseUrl) { var self = this; //shouter allows communication between GroupManager and GroupsRemover, in particular which group needs to // be removed is passed to GroupsRemover @@ -483,7 +484,8 @@ function GroupManager(selector, groups, adminGroups, user, isRegistration, table self.$element = $(selector); self.groups = groups; self.adminGroups = adminGroups; - self.viewModel = new GroupsViewModel(groups, adminGroups, user, isRegistration, table, adminTable, groupShouter, pageChangedShouter); + self.baseUrl = baseUrl; + self.viewModel = new GroupsViewModel(groups, adminGroups, user, isRegistration, table, adminTable, groupShouter, pageChangedShouter, baseUrl); $('body').on('nodeLoad', function(event, data) { // If user is a group, initialize the group modal // controller diff --git a/website/static/js/pages/sharing-page.js b/website/static/js/pages/sharing-page.js index 2a88383676c..5e1b1db8389 100644 --- a/website/static/js/pages/sharing-page.js +++ b/website/static/js/pages/sharing-page.js @@ -28,7 +28,7 @@ if (isContribPage) { if (isGroupPage) { // cm = new ContribManager('#manageContributors', ctx.contributors, ctx.adminContributors, ctx.currentUser, ctx.isRegistration, '#manageContributorsTable', '#adminContributorsTable'); - gm = new GroupManager('#manageGroups', ctx.groups,ctx.adminGroups, ctx.currentUser, ctx.isRegistration, '#manageGroupsTable', '#adminGroupsTable'); + gm = new GroupManager('#manageGroups', ctx.groups, ctx.adminGroups, ctx.currentUser, ctx.isRegistration, '#manageGroupsTable', '#adminGroupsTable', ctx.baseUrl); } if (hasAccessRequests) { diff --git a/website/templates/project/groups.mako b/website/templates/project/groups.mako index 38f76bfa780..f417300aaeb 100644 --- a/website/templates/project/groups.mako +++ b/website/templates/project/groups.mako @@ -51,6 +51,10 @@
    + % if node['enabled_mapcore_groups']:
    % if user['is_contributor_or_group_member']: ${_("Groups")}: @@ -185,11 +186,16 @@ % if node['anonymous']:
      ${_("Anonymous Groups")}
    % else: + % if node['mapcore_groups'] != []:
      ${group_list.render_groups_full(groups=node['mapcore_groups'])}
    + % else: + ${_("None")} + % endif % endif
    + % endif % if node['groups']:
    Groups: diff --git a/website/templates/project/project_header.mako b/website/templates/project/project_header.mako index 7e64bacea96..c77f95498df 100644 --- a/website/templates/project/project_header.mako +++ b/website/templates/project/project_header.mako @@ -47,7 +47,7 @@ % for addon in addons_enabled: - % if addon not in ['binderhub', 'metadata', 'workflow'] and addons[addon]['has_page']: + % if addon not in ['binderhub', 'metadata', 'workflow', 'groups'] and addons[addon]['has_page']:
  • @@ -118,8 +118,7 @@ % if user['is_contributor_or_group_member']:
  • ${_("Contributors")}
  • % endif - - % if user['is_contributor_or_group_member']: + % if 'groups' in addons_enabled and addons['groups']['has_page']:
  • ${_("Groups")}
  • % endif diff --git a/website/templates/util/group_list.mako b/website/templates/util/group_list.mako index c79bbb7a82e..3d5e5a5c13e 100644 --- a/website/templates/util/group_list.mako +++ b/website/templates/util/group_list.mako @@ -11,7 +11,7 @@ ${render_group_dict(group) if isinstance(group, dict) else render_user_obj(group)} % endfor % if others_count: - ${_("%(othersCount)s more") % dict(othersCount=others_count)} + ${_("%(groupOthersCount)s more") % dict(groupOthersCount=others_count)} % endif diff --git a/website/templates/util/render_node.mako b/website/templates/util/render_node.mako index cea10f148f0..b7611043764 100644 --- a/website/templates/util/render_node.mako +++ b/website/templates/util/render_node.mako @@ -101,9 +101,11 @@
    ${contributor_list.render_contributors(contributors=summary['contributors'], others_count=summary['others_count'], node_url=summary['url'])}
    + % if summary['enabled_mapcore_groups']:
    ${group_list.render_groups(groups=summary['mapcore_groups'], others_count=summary['mapcore_groups_others_count'], node_url=summary['url'])}
    + % endif % if summary['groups']:
    ${summary['groups']} diff --git a/website/translations/en/LC_MESSAGES/js_messages.po b/website/translations/en/LC_MESSAGES/js_messages.po index 08b21592b4c..14777ec64c1 100644 --- a/website/translations/en/LC_MESSAGES/js_messages.po +++ b/website/translations/en/LC_MESSAGES/js_messages.po @@ -9358,3 +9358,117 @@ msgid "" "${user} made bibliographic group ${mapcore_groups} a " "non-bibliographic group on ${node}" msgstr "" + +msgid "" +"\n" +"\n" +"

    Groups Add-on Terms

    \n" +"\n" +"\n" +"\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"\n" +"
    FunctionStatus
    PermissionsThe GakuNin RDM does not affect the permissions of " +"Groups.
    View / download file versionsThe Groups add-on does not provide Storage " +"Features.
    Add / update filesThe Groups add-on does not provide Storage " +"Features.
    Delete filesThe Groups add-on does not provide Storage " +"Features.
    LogsThe Groups add-on does not provide Storage " +"Features.
    ForkingForking a project or component does not copy Groups " +"authorization.
    \n" +"\n" +"
      \n" +"
    • This add-on connects your GakuNin RDM project to an external " +"service. Use of this service is bound by its terms and conditions. The " +"GakuNin RDM is not responsible for the service or for your use " +"thereof.
    • \n" +"
    • This add-on allows you to store files using an external " +"service. Files added to this add-on are not stored within the GakuNin " +"RDM.
    • \n" +"
    \n" +msgstr "" +"\n" +"\n" +"

    Groups Add-on Terms

    \n" +"\n" +"\n" +"\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"\n" +"
    FunctionStatus
    PermissionsThe GakuNin RDM does not affect the permissions of " +"Groups.
    View / download file versionsThe Groups add-on does not provide Storage " +"Features.
    Add / update filesThe Groups add-on does not provide Storage " +"Features.
    Delete filesThe Groups add-on does not provide Storage " +"Features.
    LogsThe Groups add-on does not provide Storage " +"Features.
    ForkingForking a project or component does not copy Groups " +"authorization.
    \n" +"\n" +"
      \n" +"
    • This add-on connects your GakuNin RDM project to an external " +"service. Use of this service is bound by its terms and conditions. The " +"GakuNin RDM is not responsible for the service or for your use " +"thereof.
    • \n" +"
    \n" diff --git a/website/translations/en/LC_MESSAGES/messages.po b/website/translations/en/LC_MESSAGES/messages.po index 216d3050ae6..12923b8b59f 100644 --- a/website/translations/en/LC_MESSAGES/messages.po +++ b/website/translations/en/LC_MESSAGES/messages.po @@ -4160,3 +4160,12 @@ msgstr "" msgid "Bibliographic Group Information" msgstr "" + +msgid "※ Group members can be edited using the GakuNin mAP {baseUrl}." +msgstr "" + +msgid "If a group member currently logged into GakuNin RDM is deleted on the mAP, they will not be removed from the project until they log out." +msgstr "" + +msgid "%(groupOthersCount)s more" +msgstr "" diff --git a/website/translations/ja/LC_MESSAGES/js_messages.po b/website/translations/ja/LC_MESSAGES/js_messages.po index a59c330b1ca..0f6302fb378 100644 --- a/website/translations/ja/LC_MESSAGES/js_messages.po +++ b/website/translations/ja/LC_MESSAGES/js_messages.po @@ -10910,3 +10910,111 @@ msgstr "" "
  • このアドオンにより、GakuNin RDMプロジェクトは外部サービスに接続されます。このサービスを利用すると、外部サービスの利用規約に拘束されます。GakuNin RDMはサービスおよびその利用について責任を負いません。
  • \n" "
  • このアドオンを利用すると外部サービスにファイルを保存できます。このアドオンに追加したファイルはGakuNin RDM内には保存されません。
  • \n" "\n" + +msgid "" +"\n" +"\n" +"

    Groups Add-on Terms

    \n" +"\n" +"\n" +"\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"\n" +"
    FunctionStatus
    PermissionsThe GakuNin RDM does not affect the permissions of " +"Groups.
    View / download file versionsThe Groups add-on does not provide Storage " +"Features.
    Add / update filesThe Groups add-on does not provide Storage " +"Features.
    Delete filesThe Groups add-on does not provide Storage " +"Features.
    LogsThe Groups add-on does not provide Storage " +"Features.
    ForkingForking a project or component does not copy Groups " +"authorization.
    \n" +"\n" +"
      \n" +"
    • This add-on connects your GakuNin RDM project to an external " +"service. Use of this service is bound by its terms and conditions. The " +"GakuNin RDM is not responsible for the service or for your use " +"thereof.
    • \n" +"
    • This add-on allows you to store files using an external " +"service. Files added to this add-on are not stored within the GakuNin " +"RDM.
    • \n" +"
    \n" +msgstr "" +"\n" +"\n" +"

    Groups アドオン規約

    \n" +"\n" +"\n" +"\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" " +"\n" +" \n" +" \n" +"\n" +"
    機能ステータス
    権限GroupsアドオンはGRDMの権限に影響を及ぼしません。
    ファイルバージョンの閲覧/ダウンロードGroupsアドオンはストレージ機能を提供しません。
    ファイルの追加/更新Groupsアドオンはストレージ機能を提供しません。
    ファイルの削除Groupsアドオンはストレージ機能を提供しません。
    ログGroupsアドオンはストレージ機能を提供しません。
    フォークプロジェクトやコンポーネントをフォークしても、Groupsの権限はコピーされません。
    \n" +"\n" +"
      \n" +"
    • このアドオンにより、GakuNin " +"RDMプロジェクトは外部サービスに接続されます。このサービスを利用することで、それら外部サービスの利用規約に拘束されます。GakuNin " +"RDMは、それらサービスまたはユーザーによるその利用に対して責任を負いません。
    • \n" +"
    \n" diff --git a/website/translations/ja/LC_MESSAGES/messages.po b/website/translations/ja/LC_MESSAGES/messages.po index 15777d1e557..791d16e6e34 100644 --- a/website/translations/ja/LC_MESSAGES/messages.po +++ b/website/translations/ja/LC_MESSAGES/messages.po @@ -5150,3 +5150,12 @@ msgstr "ワークフローテンプレートを更新しました。" #: addons/workflow/static/workflowNodeConfig.js:876 msgid "Failed to update workflow template." msgstr "ワークフローテンプレートの更新に失敗しました。" + +msgid "※ Group members can be edited using the GakuNin mAP {baseUrl}." +msgstr "※グループのメンバー編集は、学認mAP機能 {baseUrl} で実施します。" + +msgid "If a group member currently logged into GakuNin RDM is deleted on the mAP, they will not be removed from the project until they log out." +msgstr "GakuNin RDMログイン中のグループメンバーをmAP機能上で削除する場合、当該ユーザがログアウトするまでプロジェクトからは削除されません。" + +msgid "%(groupOthersCount)s more" +msgstr "あと%(groupOthersCount)sグループ" diff --git a/website/translations/messages.pot b/website/translations/messages.pot index e358e12b68b..aad15ea22cd 100644 --- a/website/translations/messages.pot +++ b/website/translations/messages.pot @@ -4423,3 +4423,12 @@ msgstr "" msgid "Bibliographic Group Information" msgstr "" + +msgid "※ Group members can be edited using the GakuNin mAP {baseUrl}." +msgstr "" + +msgid "If a group member currently logged into GakuNin RDM is deleted on the mAP, they will not be removed from the project until they log out." +msgstr "" + +msgid "%(groupOthersCount)s more" +msgstr "" diff --git a/website/views.py b/website/views.py index e8a041d8450..dde5e5383f8 100644 --- a/website/views.py +++ b/website/views.py @@ -141,6 +141,9 @@ def serialize_node_summary(node, auth, primary=True, show_path=False): if node.can_view(auth): contributor_data = serialize_contributors_for_summary(node) mapcore_group_data = serialize_mapcore_group_for_summary(node) + enabled_mapcore_groups = False + if hasattr(node, 'mapcore_groups_addon_enabled'): + enabled_mapcore_groups = node.mapcore_groups_addon_enabled() summary.update({ 'can_view': True, 'can_edit': node.can_edit(auth), @@ -178,6 +181,7 @@ def serialize_node_summary(node, auth, primary=True, show_path=False): 'groups': serialize_groups_for_summary(node), 'mapcore_groups': mapcore_group_data['mapcore_groups'], 'mapcore_groups_others_count': mapcore_group_data['mapcore_groups_others_count'], + 'enabled_mapcore_groups': enabled_mapcore_groups, 'description': node.description if len(node.description) <= 150 else node.description[0:150] + '...', }) else: From 7fd8a79d9976e2cb0e0e3bdcfeb91bb2a05b2530 Mon Sep 17 00:00:00 2001 From: hcphat Date: Wed, 25 Mar 2026 17:05:40 +0700 Subject: [PATCH 4/7] =?UTF-8?q?ref:=202.3.=E3=82=B0=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=97=E7=AE=A1=E7=90=86=E9=80=A3=E6=90=BA=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E9=96=8B=E7=99=BA:=20Resolve=20migrations=20conflict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- osf/migrations/0265_merge_20260325_0957.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 osf/migrations/0265_merge_20260325_0957.py diff --git a/osf/migrations/0265_merge_20260325_0957.py b/osf/migrations/0265_merge_20260325_0957.py new file mode 100644 index 00000000000..bf3ab4f00f6 --- /dev/null +++ b/osf/migrations/0265_merge_20260325_0957.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.28 on 2026-03-25 09:57 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0264_merge_20260223_0712'), + ('osf', '0264_merge_20260218_0749'), + ] + + operations = [ + ] From d07cb4d1010beca4da165361956c52913db7c6a8 Mon Sep 17 00:00:00 2001 From: hcphat Date: Fri, 27 Mar 2026 14:43:43 +0700 Subject: [PATCH 5/7] =?UTF-8?q?ref:=202.3.=E3=82=B0=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=97=E7=AE=A1=E7=90=86=E9=80=A3=E6=90=BA=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E9=96=8B=E7=99=BA:=20Fix=20Webpack=20deploy=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 3bfac100f39..e0e7d56c722 100644 --- a/Dockerfile +++ b/Dockerfile @@ -178,6 +178,7 @@ COPY ./addons/binderhub/static/ ./addons/binderhub/static/ COPY ./addons/metadata/static/ ./addons/metadata/static/ COPY ./addons/onlyoffice/static/ ./addons/onlyoffice/static/ COPY ./addons/workflow/static/ ./addons/workflow/static/ +COPY ./addons/groups/static/ ./addons/groups/static/ RUN \ # OSF yarn install --frozen-lockfile \ From 4ea0abf5a2f5565dc6db4fc2d11c8fa3c8894f11 Mon Sep 17 00:00:00 2001 From: hcphat Date: Mon, 30 Mar 2026 16:20:22 +0700 Subject: [PATCH 6/7] =?UTF-8?q?ref:=202.3.=E3=82=B0=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=97=E7=AE=A1=E7=90=86=E9=80=A3=E6=90=BA=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E9=96=8B=E7=99=BA:=20Fix=20IT=20issue=20show=20project=20incor?= =?UTF-8?q?rect=20and=20add=20logic=20check=20groups=20addon=20enabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/nodes/permissions.py | 18 ++ api/nodes/views.py | 3 + .../views/test_node_mapcore_group_views.py | 230 ++++++++++++++++++ osf/models/node.py | 3 +- osf_tests/test_mapcore_group.py | 4 + 5 files changed, 257 insertions(+), 1 deletion(-) diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index c7ba374a984..3927e1e930d 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -383,3 +383,21 @@ def has_object_permission(self, request, view, obj): if request.method in permissions.SAFE_METHODS: return obj.is_public or obj.can_view(auth) return obj.has_permission(auth.user, osf_permissions.ADMIN) + + +class GroupsAddonEnabled(permissions.BasePermission): + """Checks if the groups addon is enabled for the node.""" + + acceptable_models = (AbstractNode,) + + def has_object_permission(self, request, view, obj): + # This permission is used on views that are only relevant if the groups addon is enabled. + if request.method in permissions.SAFE_METHODS: + return True + if not isinstance(obj, AbstractNode): + return False + if not obj.guardian_object_type == 'node': + return False + if not obj.has_addon('groups'): + return False + return True diff --git a/api/nodes/views.py b/api/nodes/views.py index 5f2dae9486d..eb32587c83f 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -72,6 +72,7 @@ from api.logs.serializers import NodeLogSerializer, NodeLogDownloadSerializer from api.nodes.filters import NodesFilterMixin from api.nodes.permissions import ( + GroupsAddonEnabled, IsAdmin, IsPublic, AdminOrPublic, @@ -2376,6 +2377,7 @@ class NodeMapCoreGroupList(JSONAPIBaseView, generics.ListAPIView, bulk_views.Bul permission_classes = ( AdminOrPublic, drf_permissions.IsAuthenticatedOrReadOnly, + GroupsAddonEnabled, ReadOnlyIfRegistration, base_permissions.TokenHasScope, ) @@ -2593,6 +2595,7 @@ class NodeMapCoreGroupRemove(JSONAPIBaseView, generics.DestroyAPIView, NodeMixin permission_classes = ( AdminOrPublic, drf_permissions.IsAuthenticatedOrReadOnly, + GroupsAddonEnabled, ReadOnlyIfRegistration, base_permissions.TokenHasScope, ) diff --git a/api_tests/nodes/views/test_node_mapcore_group_views.py b/api_tests/nodes/views/test_node_mapcore_group_views.py index 7525906a029..0c9cb2ce114 100644 --- a/api_tests/nodes/views/test_node_mapcore_group_views.py +++ b/api_tests/nodes/views/test_node_mapcore_group_views.py @@ -566,6 +566,7 @@ def setUp(self): self.node = ProjectFactory(creator=self.admin_user, is_public=False) self.node.add_contributor(self.user, permissions='admin') self.node.add_contributor(self.read_only_user, permissions='read') + self.node.add_addon('groups', auth=Auth(self.user)) # Enable groups addon self.node.save() # Create auth groups for the node @@ -740,6 +741,235 @@ def test_delete_mapcore_group_from_public_node(self): res = self.app.delete(url, auth=self.admin_user.auth) assert res.status_code == 204 + +@pytest.mark.django_db +class TestGroupsAddonEnabledPermission(ApiTestCase): + """ + Verify that GroupsAddonEnabled blocks when the groups addon + is disabled on the node, and allows them when the addon is enabled. + """ + + def setUp(self): + super().setUp() + self.admin_user = AuthUserFactory() + + # Node WITHOUT the groups addon enabled + self.node = ProjectFactory(creator=self.admin_user, is_public=True) + # Ensure addon is absent + if self.node.has_addon('groups'): + self.node.delete_addon('groups', auth=Auth(self.admin_user)) + self.node.save() + + # Create auth groups and data so payloads are otherwise valid + self.auth_groups = {} + for perm in ['read', 'write', 'admin']: + self.auth_groups[perm] = AuthGroup.objects.get_or_create( + name=f'node_{self.node.id}_{perm}' + )[0] + + self.mapcore_group = MapCoreGroup.objects.create(_id='addon-perm-mcg') + self.mcng = MapCoreNodeGroup.objects.create( + node=self.node, + group=self.auth_groups['admin'], + mapcore_group=self.mapcore_group, + creator=self.admin_user, + ) + + self.list_url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/' + self.detail_url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/' + + def test_get_list_addon_disabled_returns_200(self): + """GET list is allowed even when groups addon is disabled (safe methods bypass GroupsAddonEnabled).""" + assert not self.node.has_addon('groups') + res = self.app.get(self.list_url, auth=self.admin_user.auth) + assert res.status_code == 200 + + def test_get_list_addon_enabled_returns_200(self): + """GET list is allowed when groups addon is enabled.""" + self.node.add_addon('groups', auth=Auth(self.admin_user)) + self.node.save() + + res = self.app.get(self.list_url, auth=self.admin_user.auth) + assert res.status_code == 200 + + def test_post_addon_disabled_returns_403(self): + """POST (create) is blocked when groups addon is disabled.""" + assert not self.node.has_addon('groups') + mapcore_group2 = MapCoreGroup.objects.create(_id='addon-perm-mcg-2') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group2.id, + 'permission': 'write', + } + ] + }, + } + } + + res = self.app.post_json(self.list_url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 403 + + def test_post_addon_enabled_returns_201(self): + """POST (create) is allowed when groups addon is enabled.""" + self.node.add_addon('groups', auth=Auth(self.admin_user)) + self.node.save() + + mapcore_group2 = MapCoreGroup.objects.create(_id='addon-perm-mcg-2-enabled') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group2.id, + 'permission': 'write', + } + ] + }, + } + } + + res = self.app.post_json(self.list_url, payload, auth=self.admin_user.auth) + assert res.status_code == 201 + + def test_patch_addon_disabled_returns_403(self): + """PATCH (update) is blocked when groups addon is disabled.""" + assert not self.node.has_addon('groups') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'node_group_id': self.mcng.id, + 'permission': 'read', + } + ] + }, + } + } + + res = self.app.patch_json(self.list_url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 403 + + def test_patch_addon_enabled_returns_200(self): + """PATCH (update) is allowed when groups addon is enabled.""" + self.node.add_addon('groups', auth=Auth(self.admin_user)) + self.node.save() + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'node_group_id': self.mcng.id, + 'permission': 'read', + } + ] + }, + } + } + + res = self.app.patch_json(self.list_url, payload, auth=self.admin_user.auth) + assert res.status_code == 200 + + def test_delete_addon_disabled_returns_403(self): + """DELETE is blocked when groups addon is disabled.""" + assert not self.node.has_addon('groups') + res = self.app.delete(self.detail_url, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 403 + + def test_delete_addon_enabled_returns_204(self): + """DELETE is allowed when groups addon is enabled.""" + self.node.add_addon('groups', auth=Auth(self.admin_user)) + self.node.save() + + res = self.app.delete(self.detail_url, auth=self.admin_user.auth) + assert res.status_code == 204 + + self.mcng.refresh_from_db() + assert self.mcng.is_deleted is True + + def test_get_list_addon_soft_deleted_returns_200(self): + """GET list is allowed even when groups addon is soft-deleted (safe methods bypass GroupsAddonEnabled).""" + self.node.add_addon('groups', auth=Auth(self.admin_user)) + self.node.delete_addon('groups', auth=Auth(self.admin_user)) + assert not self.node.has_addon('groups') + + res = self.app.get(self.list_url, auth=self.admin_user.auth) + assert res.status_code == 200 + + def test_post_addon_soft_deleted_returns_403(self): + """POST is blocked when groups addon was added then soft-deleted.""" + self.node.add_addon('groups', auth=Auth(self.admin_user)) + self.node.delete_addon('groups', auth=Auth(self.admin_user)) + assert not self.node.has_addon('groups') + + mapcore_group2 = MapCoreGroup.objects.create(_id='addon-soft-del-mcg') + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group2.id, + 'permission': 'write', + } + ] + }, + } + } + res = self.app.post_json(self.list_url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 403 + + def test_patch_addon_soft_deleted_returns_403(self): + """PATCH is blocked when groups addon was added then soft-deleted.""" + self.node.add_addon('groups', auth=Auth(self.admin_user)) + self.node.delete_addon('groups', auth=Auth(self.admin_user)) + assert not self.node.has_addon('groups') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'node_group_id': self.mcng.id, + 'permission': 'read', + } + ] + }, + } + } + res = self.app.patch_json(self.list_url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 403 + + def test_delete_addon_soft_deleted_returns_403(self): + """DELETE is blocked when groups addon was added then soft-deleted.""" + # Create a fresh mcng so it is not already deleted + mcng2 = MapCoreNodeGroup.objects.create( + node=self.node, + group=self.auth_groups['write'], + mapcore_group=self.mapcore_group, + creator=self.admin_user, + ) + self.node.add_addon('groups', auth=Auth(self.admin_user)) + self.node.delete_addon('groups', auth=Auth(self.admin_user)) + assert not self.node.has_addon('groups') + + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{mcng2.id}/' + res = self.app.delete(url, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 403 + + @pytest.mark.django_db class TestMixinMapCorePermissions: def test_mapcore_node_group_get_permission(self): diff --git a/osf/models/node.py b/osf/models/node.py index eb1321d1adb..cff5d47a6fb 100644 --- a/osf/models/node.py +++ b/osf/models/node.py @@ -252,7 +252,8 @@ def get_nodes_for_user(self, user, permission=READ_NODE, base_queryset=None, inc query = Q(id__in=node_groups) if include_mapcore_groups and user and not isinstance(user, AnonymousUser): mapcore_user_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False).values_list('mapcore_group_id', flat=True) - node_mapcore_groups = MapCoreNodeGroup.objects.filter(mapcore_group_id__in=mapcore_user_groups, is_deleted=False).values_list('node_id', flat=True) + node_mapcore_groups = MapCoreNodeGroup.objects.filter(mapcore_group_id__in=mapcore_user_groups, is_deleted=False, + node__addons_groups_node_settings__is_deleted=False).values_list('node_id', flat=True) query = Q(id__in=node_groups) | Q(id__in=node_mapcore_groups) if include_public: query |= Q(is_public=True) diff --git a/osf_tests/test_mapcore_group.py b/osf_tests/test_mapcore_group.py index 4991f9b4254..9071064e999 100644 --- a/osf_tests/test_mapcore_group.py +++ b/osf_tests/test_mapcore_group.py @@ -6,6 +6,7 @@ from osf.models.node import Node from osf.models.node import NodeGroupObjectPermission from osf_tests.factories import PrivateLinkFactory, UserFactory, NodeFactory +from framework.auth import Auth pytestmark = pytest.mark.django_db @@ -30,6 +31,7 @@ def test_can_view_via_mapcore_group_when_included(self): # Setup: node, user, mapcore group, auth group that follows 'node__admin' naming. user = UserFactory() node = NodeFactory(is_public=False) + node.add_addon('groups', auth=Auth(user)) # Enable groups addon # Create the MapCoreGroup mc_group = MapCoreGroup.objects.create(_id='mc-1') @@ -73,6 +75,7 @@ def test_cannot_view_via_mapcore_group_when_not_included(self): # Same setup but do NOT include mapcore groups in the queryset user = UserFactory() node = NodeFactory(is_public=False) + node.add_addon('groups', auth=Auth(user)) # Enable groups addon mc_group = MapCoreGroup.objects.create(_id='mc-2') auth_group = AuthGroup.objects.create(name=f'node_{node._id}_admin') MapCoreNodeGroup.objects.create(node=node, group=auth_group, mapcore_group=mc_group, creator=user, is_deleted=False) @@ -98,6 +101,7 @@ def test_deleted_mapcore_node_group_is_ignored(self): def test_get_nodes_for_user_include_mapcore_group(self): user = UserFactory() node = NodeFactory(is_public=False) + node.add_addon('groups', auth=Auth(user)) # Enable groups addon mc_group = MapCoreGroup.objects.create(_id='mc-4') auth_group = AuthGroup.objects.create(name=f'node_{node._id}_admin') MapCoreNodeGroup.objects.create(node=node, group=auth_group, mapcore_group=mc_group, creator=user, is_deleted=False) From 65dac17b6e452e04ad6b6898c59c85956f5604db Mon Sep 17 00:00:00 2001 From: hcphat Date: Wed, 1 Apr 2026 15:33:02 +0700 Subject: [PATCH 7/7] =?UTF-8?q?ref:=202.3.=E3=82=B0=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=97=E7=AE=A1=E7=90=86=E9=80=A3=E6=90=BA=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E9=96=8B=E7=99=BA:=20Update=20sort=20order=20by=20addon=5Ffull?= =?UTF-8?q?=5Fname?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/rdm_addons/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin/rdm_addons/views.py b/admin/rdm_addons/views.py index 8e281706d05..eb4bbb7805c 100644 --- a/admin/rdm_addons/views.py +++ b/admin/rdm_addons/views.py @@ -98,7 +98,7 @@ def get_context_data(self, **kwargs): ctx['addon_settings'] = utils.get_addons_by_config_type('accounts', self.request.user) ctx['addon_settings'].append( utils.get_addon_template_config(utils.get_addon_config('node', 'groups'), self.request.user)) - + ctx['addon_settings'].sort(key=lambda x: x['addon_full_name'].lower()) accounts_addons = [addon for addon in website_settings.ADDONS_AVAILABLE if 'accounts' in addon.configs and not addon.for_institutions] ctx.update({