From cd368148c47698c3de364cb74ee5deb0c7ec1a16 Mon Sep 17 00:00:00 2001 From: hcphat Date: Tue, 16 Dec 2025 11:01:50 +0700 Subject: [PATCH] =?UTF-8?q?ref:=202.3.=E3=82=B0=E3=83=AB=E3=83=BC=E3=83=97?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=80=A3=E6=90=BA=E6=A9=9F=E8=83=BD=E9=96=8B?= =?UTF-8?q?=E7=99=BA:=20commit=20source=20code=20and=20UT=20implement=20ma?= =?UTF-8?q?pcore=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: