From fdab580c68a3fdb8a1f0ba1e14ea971bba607d6e Mon Sep 17 00:00:00 2001 From: ncongthang Date: Fri, 17 Jul 2026 18:03:58 +0700 Subject: [PATCH 1/6] =?UTF-8?q?Ref=202.2.=E3=83=A6=E3=83=BC=E3=82=B6?= =?UTF-8?q?=E3=83=BC=E7=94=BB=E9=9D=A2=E3=83=97=E3=83=AD=E3=82=B8=E3=82=A7?= =?UTF-8?q?=E3=82=AF=E3=83=88=E5=86=85=E3=81=B8=E3=81=AE=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E3=83=A1=E3=83=B3=E3=83=90?= =?UTF-8?q?=E3=83=BC=E6=83=85=E5=A0=B1=E8=A1=A8=E7=A4=BA=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=81=AE=E6=94=B9=E4=BF=AE:=20Add=20email,=20affiliation,=20an?= =?UTF-8?q?d=20invite=20date=20to=20contributors=20list=20and=20shorten=20?= =?UTF-8?q?Bibliographic=20Contributor=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- website/profile/utils.py | 19 ++++++---- website/project/views/node.py | 35 ++++++++++++++++--- website/templates/project/contributors.mako | 12 +++++-- .../project/modal_add_contributor.mako | 2 +- .../translations/en/LC_MESSAGES/messages.po | 4 +++ .../translations/ja/LC_MESSAGES/messages.po | 4 +++ 6 files changed, 62 insertions(+), 14 deletions(-) diff --git a/website/profile/utils.py b/website/profile/utils.py index 7b80d5c9509..9ecbaf6cde3 100644 --- a/website/profile/utils.py +++ b/website/profile/utils.py @@ -18,7 +18,7 @@ def get_profile_image_url(user, size=settings.PROFILE_IMAGE_MEDIUM): use_ssl=True, size=size) -def serialize_user(user, node=None, admin=False, full=False, is_profile=False, include_node_counts=False): +def serialize_user(user, node=None, admin=False, full=False, is_profile=False, include_node_counts=False, invite_date=None): """ Return a dictionary representation of a registered user. @@ -31,6 +31,8 @@ def serialize_user(user, node=None, admin=False, full=False, is_profile=False, i user = contrib.user fullname = user.display_full_name(node=node) idp_attrs = user.get_idp_attr() + affiliated = list(user.affiliated_institutions.all()) + institution = affiliated[0] if affiliated else None ret = { 'id': str(user._id), 'primary_key': user.id, @@ -42,6 +44,9 @@ def serialize_user(user, node=None, admin=False, full=False, is_profile=False, i 'active': user.is_active, 'have_email': user.have_email, 'idp_email': idp_attrs.get('email'), + 'email': user.username if user.have_email else '', + 'affiliation': institution.name if institution else '', + 'invite_date': invite_date, } if node is not None: if admin: @@ -151,11 +156,13 @@ def serialize_user(user, node=None, admin=False, full=False, is_profile=False, i return ret -def serialize_contributors(contribs, node, **kwargs): - return [ - serialize_user(contrib, node, **kwargs) - for contrib in contribs.iterator() - ] +def serialize_contributors(contribs, node, invite_dates=None, **kwargs): + result = [] + for contrib in contribs: + user_obj = contrib.user if isinstance(contrib, Contributor) else contrib + date = invite_dates.get(user_obj._id) if invite_dates else None + result.append(serialize_user(contrib, node, invite_date=date, **kwargs)) + return result def serialize_visible_contributors(node): diff --git a/website/project/views/node.py b/website/project/views/node.py index 40093842809..48394e3a0e8 100644 --- a/website/project/views/node.py +++ b/website/project/views/node.py @@ -520,17 +520,44 @@ def node_choose_addons(auth, node, **kwargs): node.config_addons(request.json, auth) +def _get_contributor_invite_dates(node, contributor_guids): + invite_dates = {} + + # Include admin_contributor_added to cover proxy-registration additions (代理登録) + for log in NodeLog.objects.filter( + node=node, + action__in=[NodeLog.CONTRIB_ADDED, NodeLog.ADMIN_CONTRIB_ADDED], + ).order_by('date').values('date', 'params'): + for user_guid in log['params'].get('contributors', []): + if isinstance(user_guid, str): + invite_dates[user_guid] = log['date'].strftime('%Y-%m-%d') + + node_created = node.created.strftime('%Y-%m-%d') + + # Fallback for contributors with no log entry (e.g. project creator, contributors inherited + # from a parent when creating a component). Uses the node creation date as the invite date. + for guid in contributor_guids: + if guid not in invite_dates: + invite_dates[guid] = node_created + + return invite_dates + + @must_be_valid_project @must_not_be_retracted_registration @must_have_permission(READ) @ember_flag_is_active(features.EMBER_PROJECT_CONTRIBUTORS) def node_contributors(auth, node, **kwargs): ret = _view_project(node, auth, primary=True) - contribs = node.contributor_set.include('user__groups', 'user__guids', 'user__ext') - ret['contributors'] = utils.serialize_contributors(contribs, node) + contribs = list(node.contributor_set.include('user__groups', 'user__guids', 'user__ext', 'user__affiliated_institutions')) + contributor_guids = [c.user._id for c in contribs] + invite_dates = _get_contributor_invite_dates(node, contributor_guids) + ret['contributors'] = utils.serialize_contributors(contribs, node, invite_dates=invite_dates) ret['access_requests'] = utils.serialize_access_requests(node) - admin_contribs = node.parent_admin_contributors.include('groups', 'guids', 'ext') - ret['adminContributors'] = utils.serialize_contributors(admin_contribs, node, admin=True) + admin_contribs = list(node.parent_admin_contributors.include('groups', 'guids', 'ext', 'affiliated_institutions')) + node_created = node.created.strftime('%Y-%m-%d') + admin_invite_dates = {u._id: node_created for u in admin_contribs} + ret['adminContributors'] = utils.serialize_contributors(admin_contribs, node, admin=True, invite_dates=admin_invite_dates) return ret diff --git a/website/templates/project/contributors.mako b/website/templates/project/contributors.mako index 3c727e756f3..1fcb6c3ff39 100644 --- a/website/templates/project/contributors.mako +++ b/website/templates/project/contributors.mako @@ -182,6 +182,9 @@ data-bind="css: {sortable: ($data === 'contrib' && $root.isSortable())}" style="min-width: 100px;white-space: nowrap;">${_("Name")} + ${_("E-mail")} + ${_("Affiliation")} + ${_("Invite Date")} ${_("Permissions")} - - ${_("Bibliographic Contributor")} + + ${_("Bibliographic Title")} - ${_("Bibliographic Contributor")} + ${_("Bibliographic Title")} + + +
diff --git a/website/templates/project/modal_add_contributor.mako b/website/templates/project/modal_add_contributor.mako index 6ab0f818f7c..7600929e9ac 100644 --- a/website/templates/project/modal_add_contributor.mako +++ b/website/templates/project/modal_add_contributor.mako @@ -215,7 +215,7 @@ ${_("Name")} - ${_("Bibliographic Contributor")} + ${_("Bibliographic Title")} Date: Tue, 21 Jul 2026 17:12:39 +0700 Subject: [PATCH 2/6] =?UTF-8?q?Ref=202.2.=E3=83=A6=E3=83=BC=E3=82=B6?= =?UTF-8?q?=E3=83=BC=E7=94=BB=E9=9D=A2=E3=83=97=E3=83=AD=E3=82=B8=E3=82=A7?= =?UTF-8?q?=E3=82=AF=E3=83=88=E5=86=85=E3=81=B8=E3=81=AE=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E3=83=A1=E3=83=B3=E3=83=90?= =?UTF-8?q?=E3=83=BC=E6=83=85=E5=A0=B1=E8=A1=A8=E7=A4=BA=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=81=AE=E6=94=B9=E4=BF=AE:=20Add=20UT=20for=20logic=20add=20e?= =?UTF-8?q?mail,=20affiliation,=20and=20invite=20date=20to=20contributors?= =?UTF-8?q?=20list=20and=20shorten=20Bibliographic=20Contributor=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_contributors_views.py | 277 ++++++++++++++++++++++++++++++- 1 file changed, 276 insertions(+), 1 deletion(-) diff --git a/tests/test_contributors_views.py b/tests/test_contributors_views.py index d68c09d076d..850445b0bc6 100644 --- a/tests/test_contributors_views.py +++ b/tests/test_contributors_views.py @@ -2,9 +2,10 @@ import time import datetime +import mock from nose.tools import * # noqa; PEP8 asserts -from osf_tests.factories import ProjectFactory, NodeFactory, AuthUserFactory, NodeRequestFactory +from osf_tests.factories import ProjectFactory, NodeFactory, AuthUserFactory, NodeRequestFactory, InstitutionFactory from osf.utils import workflows from osf.utils import permissions from tests.base import OsfTestCase @@ -12,6 +13,8 @@ from framework.auth.decorators import Auth from website.profile import utils +from website.project.views.node import _get_contributor_invite_dates, node_contributors +from osf.models.nodelog import NodeLog class TestContributorUtils(OsfTestCase): @@ -43,6 +46,48 @@ def test_serialize_user_admin(self): assert_false(serialized['visible']) assert_equal(serialized['permission'], permissions.READ) + def test_serialize_user_includes_invite_date(self): + invite_date = '2024-01-15' + serialized = utils.serialize_user(self.project.creator, self.project, invite_date=invite_date) + assert_equal(serialized['invite_date'], invite_date) + + def test_serialize_user_invite_date_defaults_to_none(self): + serialized = utils.serialize_user(self.project.creator, self.project) + assert serialized['invite_date'] is None + + def test_serialize_user_email_field_present(self): + serialized = utils.serialize_user(self.project.creator, self.project) + assert_in('email', serialized) + + def test_serialize_user_affiliation_no_institution(self): + serialized = utils.serialize_user(self.project.creator, self.project) + assert_equal(serialized['affiliation'], '') + + def test_serialize_user_affiliation_with_institution(self): + institution = InstitutionFactory() + self.project.creator.affiliated_institutions.add(institution) + serialized = utils.serialize_user(self.project.creator, self.project) + assert_equal(serialized['affiliation'], institution.name) + + def test_serialize_contributors_passes_invite_dates(self): + contribs = list(self.project.contributor_set.all()) + invite_dates = {self.project.creator._id: '2023-05-01'} + result = utils.serialize_contributors(contribs, self.project, invite_dates=invite_dates) + assert_equal(len(result), 1) + assert_equal(result[0]['invite_date'], '2023-05-01') + + def test_serialize_contributors_invite_date_none_when_not_in_dict(self): + contribs = list(self.project.contributor_set.all()) + result = utils.serialize_contributors(contribs, self.project, invite_dates={}) + assert_equal(len(result), 1) + assert result[0]['invite_date'] is None + + def test_serialize_contributors_no_invite_dates(self): + contribs = list(self.project.contributor_set.all()) + result = utils.serialize_contributors(contribs, self.project) + assert_equal(len(result), 1) + assert result[0]['invite_date'] is None + def test_serialize_access_requests(self): new_user = AuthUserFactory() node_request = NodeRequestFactory( @@ -169,3 +214,233 @@ def test_get_contributors_from_parent(self): len(res.json['contributors']), 2, ) + + +class TestGetContributorInviteDates(OsfTestCase): + + def setUp(self): + super(TestGetContributorInviteDates, self).setUp() + self.user = AuthUserFactory() + self.auth = Auth(user=self.user) + self.project = ProjectFactory(creator=self.user) + + def _get_dates(self, project=None): + node = project or self.project + contribs = list(node.contributor_set.all()) + guids = [c.user._id for c in contribs] + return _get_contributor_invite_dates(node, guids) + + def test_creator_gets_node_created_date(self): + dates = self._get_dates() + expected = self.project.created.strftime('%Y-%m-%d') + assert_equal(dates[self.user._id], expected) + + def test_added_contributor_gets_log_date(self): + new_user = AuthUserFactory() + self.project.add_contributor(new_user, auth=self.auth, save=True) + + log = NodeLog.objects.filter( + node=self.project, + action=NodeLog.CONTRIB_ADDED, + ).order_by('-date').first() + + dates = self._get_dates() + expected = log.date.strftime('%Y-%m-%d') + assert_equal(dates[new_user._id], expected) + + def test_admin_contrib_added_log_is_included(self): + new_user = AuthUserFactory() + # Simulate admin_contributor_added log (ユーザー登録代理機能) + self.project.add_contributor(new_user, auth=self.auth, log=False, save=True) + log_date = self.project.created + datetime.timedelta(days=1) + NodeLog.objects.create( + node=self.project, + action=NodeLog.ADMIN_CONTRIB_ADDED, + params={'contributors': [new_user._id]}, + user=self.user, + date=log_date, + ) + + dates = self._get_dates() + assert_in(new_user._id, dates) + assert_not_equal(dates[new_user._id], self.project.created.strftime('%Y-%m-%d')) + + def test_contributor_with_no_log_gets_node_created_date(self): + new_user = AuthUserFactory() + # Add without log to simulate inherited contributor (component case) + self.project.add_contributor(new_user, auth=self.auth, log=False, save=True) + + dates = self._get_dates() + expected = self.project.created.strftime('%Y-%m-%d') + assert_equal(dates[new_user._id], expected) + + def test_contributor_added_multiple_times_gets_most_recent_date(self): + new_user = AuthUserFactory() + self.project.add_contributor(new_user, auth=self.auth, save=True) + + # Simulate re-add by injecting a later log entry + later_date = self.project.created + datetime.timedelta(days=30) + NodeLog.objects.create( + node=self.project, + action=NodeLog.CONTRIB_ADDED, + params={'contributors': [new_user._id]}, + user=self.user, + date=later_date, + ) + + dates = self._get_dates() + assert_equal(dates[new_user._id], later_date.strftime('%Y-%m-%d')) + + def test_creator_readded_gets_readd_date(self): + # Creator removed then re-added — should show re-add date, not node_created + self.project.remove_contributor(self.user, auth=self.auth) + later_date = self.project.created + datetime.timedelta(days=10) + NodeLog.objects.create( + node=self.project, + action=NodeLog.CONTRIB_ADDED, + params={'contributors': [self.user._id]}, + user=self.user, + date=later_date, + ) + self.project.add_contributor(self.user, auth=self.auth, log=False, save=True) + + dates = self._get_dates() + assert_equal(dates[self.user._id], later_date.strftime('%Y-%m-%d')) + + def test_all_current_contributors_have_dates(self): + # _get_dates() passes only current-contributor guids; every one of them + # must appear in the result regardless of whether a removed contributor's + # log entry also leaks through. + removed_user = AuthUserFactory() + self.project.add_contributor(removed_user, auth=self.auth, save=True) + self.project.remove_contributor(removed_user, auth=self.auth) + + dates = self._get_dates() + + contribs = list(self.project.contributor_set.all()) + current_guids = [c.user._id for c in contribs] + assert_not_in(removed_user._id, current_guids) # verify removal succeeded + for guid in current_guids: + assert guid in dates + + +class TestNodeContributorsView(OsfTestCase): + """Tests for node_contributors view — verifies contributors/adminContributors + serialization including invite_date, email, and affiliation fields.""" + + def setUp(self): + super(TestNodeContributorsView, self).setUp() + self.user = AuthUserFactory() + self.auth = Auth(user=self.user) + self.project = ProjectFactory(creator=self.user) + # ember_flag_is_active calls waffle.flag_is_active(request, ...) — patch + # to False so decorator falls through to the real view. + self._waffle_patcher = mock.patch('waffle.flag_is_active', return_value=False) + self._waffle_patcher.start() + # must_have_permission rebuilds auth via Auth.from_kwargs which calls + # _get_current_user(). Patch it to return our test user. + self._current_user_patcher = mock.patch( + 'framework.auth.core._get_current_user', + return_value=self.user, + ) + self._current_user_patcher.start() + + def tearDown(self): + self._current_user_patcher.stop() + self._waffle_patcher.stop() + super(TestNodeContributorsView, self).tearDown() + + def _call_view(self, project=None): + node = project or self.project + return node_contributors(auth=self.auth, node=node) + + def test_contributors_key_present(self): + ret = self._call_view() + assert_in('contributors', ret) + + def test_admin_contributors_key_present(self): + ret = self._call_view() + assert_in('adminContributors', ret) + + def test_access_requests_key_present(self): + ret = self._call_view() + assert_in('access_requests', ret) + + def test_contributor_has_invite_date_field(self): + ret = self._call_view() + assert len(ret['contributors']) >= 1 + for contrib in ret['contributors']: + assert_in('invite_date', contrib) + + def test_contributor_has_email_field(self): + ret = self._call_view() + for contrib in ret['contributors']: + assert_in('email', contrib) + + def test_contributor_has_affiliation_field(self): + ret = self._call_view() + for contrib in ret['contributors']: + assert_in('affiliation', contrib) + + def test_creator_invite_date_equals_node_created(self): + # Creator has no CONTRIB_ADDED log, so fallback to node creation date. + ret = self._call_view() + creator_serialized = next( + c for c in ret['contributors'] if c['id'] == self.user._id + ) + expected = self.project.created.strftime('%Y-%m-%d') + assert_equal(creator_serialized['invite_date'], expected) + + def test_added_contributor_invite_date_from_log(self): + new_user = AuthUserFactory() + self.project.add_contributor(new_user, auth=self.auth, save=True) + + log = NodeLog.objects.filter( + node=self.project, + action=NodeLog.CONTRIB_ADDED, + ).order_by('-date').first() + expected = log.date.strftime('%Y-%m-%d') + + ret = self._call_view() + new_serialized = next( + c for c in ret['contributors'] if c['id'] == new_user._id + ) + assert_equal(new_serialized['invite_date'], expected) + + def test_contributor_affiliation_with_institution(self): + institution = InstitutionFactory() + self.user.affiliated_institutions.add(institution) + ret = self._call_view() + creator_serialized = next( + c for c in ret['contributors'] if c['id'] == self.user._id + ) + assert_equal(creator_serialized['affiliation'], institution.name) + + def test_admin_contributor_invite_date_equals_node_created(self): + # Admin contributors (from parent) always get node_created as invite_date. + parent = ProjectFactory(creator=self.user) + component = NodeFactory(parent=parent, creator=self.user) + + ret = node_contributors(auth=self.auth, node=component) + + node_created = component.created.strftime('%Y-%m-%d') + for admin_contrib in ret['adminContributors']: + assert_equal(admin_contrib['invite_date'], node_created) + + def test_admin_contributor_has_email_and_affiliation_fields(self): + parent = ProjectFactory(creator=self.user) + component = NodeFactory(parent=parent, creator=self.user) + + ret = node_contributors(auth=self.auth, node=component) + + for admin_contrib in ret['adminContributors']: + assert_in('email', admin_contrib) + assert_in('affiliation', admin_contrib) + + def test_contributor_count_matches_project_contributors(self): + new_user = AuthUserFactory() + self.project.add_contributor(new_user, auth=self.auth, save=True) + + ret = self._call_view() + expected_count = self.project.contributor_set.count() + assert_equal(len(ret['contributors']), expected_count) From afacffc2330c2125791539a059e8a9b22dcf0f07 Mon Sep 17 00:00:00 2001 From: ncongthang Date: Thu, 23 Jul 2026 09:05:12 +0700 Subject: [PATCH 3/6] =?UTF-8?q?Ref=202.2.=E3=83=A6=E3=83=BC=E3=82=B6?= =?UTF-8?q?=E3=83=BC=E7=94=BB=E9=9D=A2=E3=83=97=E3=83=AD=E3=82=B8=E3=82=A7?= =?UTF-8?q?=E3=82=AF=E3=83=88=E5=86=85=E3=81=B8=E3=81=AE=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E3=83=A1=E3=83=B3=E3=83=90?= =?UTF-8?q?=E3=83=BC=E6=83=85=E5=A0=B1=E8=A1=A8=E7=A4=BA=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=81=AE=E6=94=B9=E4=BF=AE:=20Fix=20N+1=20query=20and=20non-de?= =?UTF-8?q?terministic=20institution=20selection=20in=20serialize=5Fuser?= =?UTF-8?q?=20then=20UT=20after=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_contributors_views.py | 122 ++++++++++++++++++++++++++++--- website/profile/utils.py | 7 +- 2 files changed, 118 insertions(+), 11 deletions(-) diff --git a/tests/test_contributors_views.py b/tests/test_contributors_views.py index 850445b0bc6..6d59d93dc83 100644 --- a/tests/test_contributors_views.py +++ b/tests/test_contributors_views.py @@ -55,9 +55,21 @@ def test_serialize_user_invite_date_defaults_to_none(self): serialized = utils.serialize_user(self.project.creator, self.project) assert serialized['invite_date'] is None - def test_serialize_user_email_field_present(self): - serialized = utils.serialize_user(self.project.creator, self.project) + def test_serialize_user_email_field_present_when_have_email(self): + user = self.project.creator + user.have_email = True + user.save() + serialized = utils.serialize_user(user, self.project) + assert_in('email', serialized) + assert_equal(serialized['email'], user.username) + + def test_serialize_user_email_field_empty_when_no_email(self): + user = self.project.creator + user.have_email = False + user.save() + serialized = utils.serialize_user(user, self.project) assert_in('email', serialized) + assert_equal(serialized['email'], '') def test_serialize_user_affiliation_no_institution(self): serialized = utils.serialize_user(self.project.creator, self.project) @@ -66,9 +78,24 @@ def test_serialize_user_affiliation_no_institution(self): def test_serialize_user_affiliation_with_institution(self): institution = InstitutionFactory() self.project.creator.affiliated_institutions.add(institution) + from django.db.models import prefetch_related_objects + prefetch_related_objects([self.project.creator], 'affiliated_institutions') serialized = utils.serialize_user(self.project.creator, self.project) assert_equal(serialized['affiliation'], institution.name) + def test_serialize_user_affiliation_selects_lowest_pk_institution(self): + # With multiple institutions, the one with the lowest pk must be selected + # (sorted ascending by pk, matching representative_affiliated_institution behaviour). + inst1 = InstitutionFactory() + inst2 = InstitutionFactory() + low_pk_inst, high_pk_inst = (inst1, inst2) if inst1.pk < inst2.pk else (inst2, inst1) + user = self.project.creator + user.affiliated_institutions.add(low_pk_inst, high_pk_inst) + from django.db.models import prefetch_related_objects + prefetch_related_objects([user], 'affiliated_institutions') + serialized = utils.serialize_user(user, self.project) + assert_equal(serialized['affiliation'], low_pk_inst.name) + def test_serialize_contributors_passes_invite_dates(self): contribs = list(self.project.contributor_set.all()) invite_dates = {self.project.creator._id: '2023-05-01'} @@ -263,7 +290,7 @@ def test_admin_contrib_added_log_is_included(self): dates = self._get_dates() assert_in(new_user._id, dates) - assert_not_equal(dates[new_user._id], self.project.created.strftime('%Y-%m-%d')) + assert_equal(dates[new_user._id], log_date.strftime('%Y-%m-%d')) def test_contributor_with_no_log_gets_node_created_date(self): new_user = AuthUserFactory() @@ -291,21 +318,27 @@ def test_contributor_added_multiple_times_gets_most_recent_date(self): dates = self._get_dates() assert_equal(dates[new_user._id], later_date.strftime('%Y-%m-%d')) - def test_creator_readded_gets_readd_date(self): - # Creator removed then re-added — should show re-add date, not node_created - self.project.remove_contributor(self.user, auth=self.auth) + def test_contributor_readded_gets_readd_date(self): + # Contributor removed then re-added — should show re-add date, not original add date. + # Use a non-creator user so remove_contributor succeeds (creator cannot be removed + # when they are the only visible admin contributor). + other_user = AuthUserFactory() + self.project.add_contributor(other_user, auth=self.auth, save=True) + self.project.remove_contributor(other_user, auth=self.auth) + assert not self.project.is_contributor(other_user) + later_date = self.project.created + datetime.timedelta(days=10) NodeLog.objects.create( node=self.project, action=NodeLog.CONTRIB_ADDED, - params={'contributors': [self.user._id]}, + params={'contributors': [other_user._id]}, user=self.user, date=later_date, ) - self.project.add_contributor(self.user, auth=self.auth, log=False, save=True) + self.project.add_contributor(other_user, auth=self.auth, log=False, save=True) dates = self._get_dates() - assert_equal(dates[self.user._id], later_date.strftime('%Y-%m-%d')) + assert_equal(dates[other_user._id], later_date.strftime('%Y-%m-%d')) def test_all_current_contributors_have_dates(self): # _get_dates() passes only current-contributor guids; every one of them @@ -418,25 +451,96 @@ def test_contributor_affiliation_with_institution(self): def test_admin_contributor_invite_date_equals_node_created(self): # Admin contributors (from parent) always get node_created as invite_date. + # Use a separate parent_admin user who is NOT a contributor of the component, + # so that parent_admin_contributors returns a non-empty list. parent = ProjectFactory(creator=self.user) + parent_admin = AuthUserFactory() + parent.add_contributor(parent_admin, permissions=permissions.ADMIN, auth=self.auth, save=True) component = NodeFactory(parent=parent, creator=self.user) ret = node_contributors(auth=self.auth, node=component) + assert len(ret['adminContributors']) >= 1, 'adminContributors must be non-empty for this test to be meaningful' node_created = component.created.strftime('%Y-%m-%d') for admin_contrib in ret['adminContributors']: assert_equal(admin_contrib['invite_date'], node_created) def test_admin_contributor_has_email_and_affiliation_fields(self): + # Use a separate parent_admin user who is NOT a contributor of the component, + # so that parent_admin_contributors returns a non-empty list. parent = ProjectFactory(creator=self.user) + parent_admin = AuthUserFactory() + parent.add_contributor(parent_admin, permissions=permissions.ADMIN, auth=self.auth, save=True) component = NodeFactory(parent=parent, creator=self.user) ret = node_contributors(auth=self.auth, node=component) + assert len(ret['adminContributors']) >= 1, 'adminContributors must be non-empty for this test to be meaningful' for admin_contrib in ret['adminContributors']: assert_in('email', admin_contrib) assert_in('affiliation', admin_contrib) + def test_admin_contributor_email_value_when_have_email(self): + # Verify email value (not just key presence) for admin contributors. + parent = ProjectFactory(creator=self.user) + parent_admin = AuthUserFactory() + parent_admin.have_email = True + parent_admin.save() + parent.add_contributor(parent_admin, permissions=permissions.ADMIN, auth=self.auth, save=True) + component = NodeFactory(parent=parent, creator=self.user) + + ret = node_contributors(auth=self.auth, node=component) + + assert len(ret['adminContributors']) >= 1, 'adminContributors must be non-empty for this test to be meaningful' + admin_serialized = next(c for c in ret['adminContributors'] if c['id'] == parent_admin._id) + assert_equal(admin_serialized['email'], parent_admin.username) + + def test_admin_contributor_email_empty_when_no_email(self): + parent = ProjectFactory(creator=self.user) + parent_admin = AuthUserFactory() + parent_admin.have_email = False + parent_admin.save() + parent.add_contributor(parent_admin, permissions=permissions.ADMIN, auth=self.auth, save=True) + component = NodeFactory(parent=parent, creator=self.user) + + ret = node_contributors(auth=self.auth, node=component) + + assert len(ret['adminContributors']) >= 1, 'adminContributors must be non-empty for this test to be meaningful' + admin_serialized = next(c for c in ret['adminContributors'] if c['id'] == parent_admin._id) + assert_equal(admin_serialized['email'], '') + + def test_admin_contributor_affiliation_with_institution(self): + parent = ProjectFactory(creator=self.user) + parent_admin = AuthUserFactory() + institution = InstitutionFactory() + parent_admin.affiliated_institutions.add(institution) + parent.add_contributor(parent_admin, permissions=permissions.ADMIN, auth=self.auth, save=True) + component = NodeFactory(parent=parent, creator=self.user) + + ret = node_contributors(auth=self.auth, node=component) + + assert len(ret['adminContributors']) >= 1, 'adminContributors must be non-empty for this test to be meaningful' + admin_serialized = next(c for c in ret['adminContributors'] if c['id'] == parent_admin._id) + assert_equal(admin_serialized['affiliation'], institution.name) + + def test_admin_contrib_added_log_invite_date_via_view(self): + # Integration: ADMIN_CONTRIB_ADDED log (代理登録) must surface as invite_date + # when the contributors list is fetched through the node_contributors view. + new_user = AuthUserFactory() + self.project.add_contributor(new_user, auth=self.auth, log=False, save=True) + log_date = self.project.created + datetime.timedelta(days=2) + NodeLog.objects.create( + node=self.project, + action=NodeLog.ADMIN_CONTRIB_ADDED, + params={'contributors': [new_user._id]}, + user=self.user, + date=log_date, + ) + + ret = self._call_view() + new_serialized = next(c for c in ret['contributors'] if c['id'] == new_user._id) + assert_equal(new_serialized['invite_date'], log_date.strftime('%Y-%m-%d')) + def test_contributor_count_matches_project_contributors(self): new_user = AuthUserFactory() self.project.add_contributor(new_user, auth=self.auth, save=True) diff --git a/website/profile/utils.py b/website/profile/utils.py index 9ecbaf6cde3..7a95d471eb8 100644 --- a/website/profile/utils.py +++ b/website/profile/utils.py @@ -31,8 +31,11 @@ def serialize_user(user, node=None, admin=False, full=False, is_profile=False, i user = contrib.user fullname = user.display_full_name(node=node) idp_attrs = user.get_idp_attr() - affiliated = list(user.affiliated_institutions.all()) - institution = affiliated[0] if affiliated else None + institution = None + prefetch_cache = getattr(user, '_prefetched_objects_cache', {}) + if 'affiliated_institutions' in prefetch_cache: + affiliated = list(user.affiliated_institutions.all()) + institution = sorted(affiliated, key=lambda i: i.pk)[0] if affiliated else None ret = { 'id': str(user._id), 'primary_key': user.id, From 1b384e4268911a273c17117bd7d1505801520757 Mon Sep 17 00:00:00 2001 From: ncongthang Date: Fri, 31 Jul 2026 14:21:48 +0700 Subject: [PATCH 4/6] =?UTF-8?q?Ref=202.2.=E3=83=A6=E3=83=BC=E3=82=B6?= =?UTF-8?q?=E3=83=BC=E7=94=BB=E9=9D=A2=E3=83=97=E3=83=AD=E3=82=B8=E3=82=A7?= =?UTF-8?q?=E3=82=AF=E3=83=88=E5=86=85=E3=81=B8=E3=81=AE=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E3=83=A1=E3=83=B3=E3=83=90?= =?UTF-8?q?=E3=83=BC=E6=83=85=E5=A0=B1=E8=A1=A8=E7=A4=BA=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=81=AE=E6=94=B9=E4=BF=AE:=20Restrict=20email,=20affiliated?= =?UTF-8?q?=20institution,=20and=20invite=20date=20to=20admin=20users=20on?= =?UTF-8?q?ly=20then=20UT=20after=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_contributors_views.py | 112 +++++++++++++++++++- website/profile/utils.py | 9 +- website/project/views/node.py | 36 +++++-- website/templates/project/contributors.mako | 4 + 4 files changed, 145 insertions(+), 16 deletions(-) diff --git a/tests/test_contributors_views.py b/tests/test_contributors_views.py index 6d59d93dc83..a1ba5246dd6 100644 --- a/tests/test_contributors_views.py +++ b/tests/test_contributors_views.py @@ -32,6 +32,14 @@ def test_serialize_user_full_does_not_include_emails_by_default(self): serialized = utils.serialize_user(self.project.creator, self.project, full=True) assert_not_in('emails', serialized) + def test_serialize_user_full_does_not_include_idp_email_without_is_profile(self): + serialized = utils.serialize_user(self.project.creator, self.project, full=True, is_profile=False) + assert_not_in('idp_email', serialized) + + def test_serialize_user_full_includes_idp_email_when_is_profile(self): + serialized = utils.serialize_user(self.project.creator, self.project, full=True, is_profile=True) + assert_in('idp_email', serialized) + def test_serialize_user_full_includes_email_if_is_profile(self): serialized = utils.serialize_user( self.project.creator, @@ -55,19 +63,24 @@ def test_serialize_user_invite_date_defaults_to_none(self): serialized = utils.serialize_user(self.project.creator, self.project) assert serialized['invite_date'] is None - def test_serialize_user_email_field_present_when_have_email(self): + def test_serialize_user_email_not_included_by_default(self): + user = self.project.creator + serialized = utils.serialize_user(user, self.project) + assert_not_in('email', serialized) + + def test_serialize_user_email_field_present_when_include_email_and_have_email(self): user = self.project.creator user.have_email = True user.save() - serialized = utils.serialize_user(user, self.project) + serialized = utils.serialize_user(user, self.project, include_email=True) assert_in('email', serialized) assert_equal(serialized['email'], user.username) - def test_serialize_user_email_field_empty_when_no_email(self): + def test_serialize_user_email_field_empty_when_include_email_and_no_email(self): user = self.project.creator user.have_email = False user.save() - serialized = utils.serialize_user(user, self.project) + serialized = utils.serialize_user(user, self.project, include_email=True) assert_in('email', serialized) assert_equal(serialized['email'], '') @@ -548,3 +561,94 @@ def test_contributor_count_matches_project_contributors(self): ret = self._call_view() expected_count = self.project.contributor_set.count() assert_equal(len(ret['contributors']), expected_count) + + def test_non_admin_contributor_has_no_email_field(self): + # A read-only contributor calls node_contributors; their response must + # NOT contain the 'email' key because include_email is False for + # non-admin callers. + read_user = AuthUserFactory() + self.project.add_contributor(read_user, permissions=permissions.READ, auth=self.auth, save=True) + read_auth = Auth(user=read_user) + self._current_user_patcher.stop() + patcher = mock.patch('framework.auth.core._get_current_user', return_value=read_user) + patcher.start() + try: + ret = node_contributors(auth=read_auth, node=self.project) + finally: + patcher.stop() + # Restart the original patcher so tearDown can stop it cleanly. + self._current_user_patcher = mock.patch( + 'framework.auth.core._get_current_user', + return_value=self.user, + ) + self._current_user_patcher.start() + for contrib in ret['contributors']: + assert_not_in('email', contrib) + + def test_non_admin_contributor_invite_date_is_none(self): + # Non-admin path passes invite_dates=None, so every contributor's + # invite_date must be None (not a date string). + read_user = AuthUserFactory() + self.project.add_contributor(read_user, permissions=permissions.READ, auth=self.auth, save=True) + read_auth = Auth(user=read_user) + self._current_user_patcher.stop() + patcher = mock.patch('framework.auth.core._get_current_user', return_value=read_user) + patcher.start() + try: + ret = node_contributors(auth=read_auth, node=self.project) + finally: + patcher.stop() + self._current_user_patcher = mock.patch( + 'framework.auth.core._get_current_user', + return_value=self.user, + ) + self._current_user_patcher.start() + for contrib in ret['contributors']: + assert_in('invite_date', contrib) + assert_is_none(contrib['invite_date']) + + def test_non_admin_contributor_affiliation_is_empty(self): + institution = InstitutionFactory() + self.user.affiliated_institutions.add(institution) + read_user = AuthUserFactory() + self.project.add_contributor(read_user, permissions=permissions.READ, auth=self.auth, save=True) + read_auth = Auth(user=read_user) + self._current_user_patcher.stop() + patcher = mock.patch('framework.auth.core._get_current_user', return_value=read_user) + patcher.start() + try: + ret = node_contributors(auth=read_auth, node=self.project) + finally: + patcher.stop() + self._current_user_patcher = mock.patch( + 'framework.auth.core._get_current_user', + return_value=self.user, + ) + self._current_user_patcher.start() + for contrib in ret['contributors']: + assert_equal(contrib['affiliation'], '') + + def test_non_admin_contributor_admin_contributors_have_no_email(self): + parent = ProjectFactory(creator=self.user) + parent_admin = AuthUserFactory() + parent.add_contributor(parent_admin, permissions=permissions.ADMIN, auth=self.auth, save=True) + read_user = AuthUserFactory() + parent.add_contributor(read_user, permissions=permissions.READ, auth=self.auth, save=True) + component = NodeFactory(parent=parent, creator=self.user) + component.add_contributor(read_user, permissions=permissions.READ, auth=self.auth, save=True) + read_auth = Auth(user=read_user) + self._current_user_patcher.stop() + patcher = mock.patch('framework.auth.core._get_current_user', return_value=read_user) + patcher.start() + try: + ret = node_contributors(auth=read_auth, node=component) + finally: + patcher.stop() + self._current_user_patcher = mock.patch( + 'framework.auth.core._get_current_user', + return_value=self.user, + ) + self._current_user_patcher.start() + assert len(ret['adminContributors']) >= 1, 'adminContributors must be non-empty for this test to be meaningful' + for admin_contrib in ret['adminContributors']: + assert_not_in('email', admin_contrib) diff --git a/website/profile/utils.py b/website/profile/utils.py index 7a95d471eb8..18b5084bba5 100644 --- a/website/profile/utils.py +++ b/website/profile/utils.py @@ -18,7 +18,7 @@ def get_profile_image_url(user, size=settings.PROFILE_IMAGE_MEDIUM): use_ssl=True, size=size) -def serialize_user(user, node=None, admin=False, full=False, is_profile=False, include_node_counts=False, invite_date=None): +def serialize_user(user, node=None, admin=False, full=False, is_profile=False, include_node_counts=False, invite_date=None, include_email=False): """ Return a dictionary representation of a registered user. @@ -30,7 +30,6 @@ def serialize_user(user, node=None, admin=False, full=False, is_profile=False, i contrib = user user = contrib.user fullname = user.display_full_name(node=node) - idp_attrs = user.get_idp_attr() institution = None prefetch_cache = getattr(user, '_prefetched_objects_cache', {}) if 'affiliated_institutions' in prefetch_cache: @@ -46,11 +45,11 @@ def serialize_user(user, node=None, admin=False, full=False, is_profile=False, i 'profile_image_url': user.profile_image_url(size=settings.PROFILE_IMAGE_MEDIUM), 'active': user.is_active, 'have_email': user.have_email, - 'idp_email': idp_attrs.get('email'), - 'email': user.username if user.have_email else '', 'affiliation': institution.name if institution else '', 'invite_date': invite_date, } + if include_email: + ret['email'] = user.username if user.have_email else '' if node is not None: if admin: flags = { @@ -80,6 +79,8 @@ def serialize_user(user, node=None, admin=False, full=False, is_profile=False, i if full: # Add emails if is_profile: + idp_attrs = user.get_idp_attr() + ret['idp_email'] = idp_attrs.get('email') ret['emails'] = [ { 'address': each, diff --git a/website/project/views/node.py b/website/project/views/node.py index 48394e3a0e8..25cff6a3b51 100644 --- a/website/project/views/node.py +++ b/website/project/views/node.py @@ -549,15 +549,35 @@ def _get_contributor_invite_dates(node, contributor_guids): @ember_flag_is_active(features.EMBER_PROJECT_CONTRIBUTORS) def node_contributors(auth, node, **kwargs): ret = _view_project(node, auth, primary=True) - contribs = list(node.contributor_set.include('user__groups', 'user__guids', 'user__ext', 'user__affiliated_institutions')) - contributor_guids = [c.user._id for c in contribs] - invite_dates = _get_contributor_invite_dates(node, contributor_guids) - ret['contributors'] = utils.serialize_contributors(contribs, node, invite_dates=invite_dates) + is_admin = node.has_permission(auth.user, ADMIN) + + contrib_includes = ['user__groups', 'user__guids', 'user__ext'] + admin_includes = ['groups', 'guids', 'ext'] + if is_admin: + contrib_includes.append('user__affiliated_institutions') + admin_includes.append('affiliated_institutions') + + contribs = node.contributor_set.include(*contrib_includes) + admin_contribs = node.parent_admin_contributors.include(*admin_includes) + + invite_dates = None + admin_invite_dates = None + if is_admin: + contribs = list(contribs) + admin_contribs = list(admin_contribs) + invite_dates = _get_contributor_invite_dates(node, [c.user._id for c in contribs]) + node_created = node.created.strftime('%Y-%m-%d') + admin_invite_dates = {u._id: node_created for u in admin_contribs} + + ret['contributors'] = utils.serialize_contributors( + contribs, node, invite_dates=invite_dates, include_email=is_admin + ) ret['access_requests'] = utils.serialize_access_requests(node) - admin_contribs = list(node.parent_admin_contributors.include('groups', 'guids', 'ext', 'affiliated_institutions')) - node_created = node.created.strftime('%Y-%m-%d') - admin_invite_dates = {u._id: node_created for u in admin_contribs} - ret['adminContributors'] = utils.serialize_contributors(admin_contribs, node, admin=True, invite_dates=admin_invite_dates) + ret['adminContributors'] = utils.serialize_contributors( + admin_contribs, node, admin=True, + invite_dates=admin_invite_dates, include_email=is_admin + ) + return ret diff --git a/website/templates/project/contributors.mako b/website/templates/project/contributors.mako index 1fcb6c3ff39..a9af57b2a39 100644 --- a/website/templates/project/contributors.mako +++ b/website/templates/project/contributors.mako @@ -182,9 +182,11 @@ data-bind="css: {sortable: ($data === 'contrib' && $root.isSortable())}" style="min-width: 100px;white-space: nowrap;">${_("Name")} + % if permissions.ADMIN in user['permissions']: ${_("E-mail")} ${_("Affiliation")} ${_("Invite Date")} + % endif ${_("Permissions")} + % if permissions.ADMIN in user['permissions']: + % endif
From 7e8c414b8c55d40b803cfae0efd3368209456c1f Mon Sep 17 00:00:00 2001 From: ncongthang Date: Fri, 21 Aug 2026 09:34:00 +0700 Subject: [PATCH 5/6] =?UTF-8?q?Ref=202.2.=E3=83=A6=E3=83=BC=E3=82=B6?= =?UTF-8?q?=E3=83=BC=E7=94=BB=E9=9D=A2=E3=83=97=E3=83=AD=E3=82=B8=E3=82=A7?= =?UTF-8?q?=E3=82=AF=E3=83=88=E5=86=85=E3=81=B8=E3=81=AE=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E3=83=A1=E3=83=B3=E3=83=90?= =?UTF-8?q?=E3=83=BC=E6=83=85=E5=A0=B1=E8=A1=A8=E7=A4=BA=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=81=AE=E6=94=B9=E4=BF=AE:=20Revert=20Bibliographic=20label?= =?UTF-8?q?=20to=20"Bibliographic=20Contributor"=20and=20update=20Japanese?= =?UTF-8?q?=20translation=20to=20"=E7=9B=AE=E9=8C=B2=E8=A1=A8=E7=A4=BA"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- website/templates/project/contributors.mako | 4 ++-- website/templates/project/modal_add_contributor.mako | 2 +- website/translations/en/LC_MESSAGES/messages.po | 4 ---- website/translations/ja/LC_MESSAGES/messages.po | 6 +----- 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/website/templates/project/contributors.mako b/website/templates/project/contributors.mako index a9af57b2a39..e4faa4cb7b4 100644 --- a/website/templates/project/contributors.mako +++ b/website/templates/project/contributors.mako @@ -198,7 +198,7 @@ > - ${_("Bibliographic Title")} + ${_("Bibliographic Contributor")} - ${_("Bibliographic Title")} + ${_("Bibliographic Contributor")} ${_("Name")} - ${_("Bibliographic Title")} + ${_("Bibliographic Contributor")} Date: Fri, 21 Aug 2026 14:15:55 +0700 Subject: [PATCH 6/6] =?UTF-8?q?Ref=202.2.=E3=83=A6=E3=83=BC=E3=82=B6?= =?UTF-8?q?=E3=83=BC=E7=94=BB=E9=9D=A2=E3=83=97=E3=83=AD=E3=82=B8=E3=82=A7?= =?UTF-8?q?=E3=82=AF=E3=83=88=E5=86=85=E3=81=B8=E3=81=AE=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E3=83=A1=E3=83=B3=E3=83=90?= =?UTF-8?q?=E3=83=BC=E6=83=85=E5=A0=B1=E8=A1=A8=E7=A4=BA=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=81=AE=E6=94=B9=E4=BF=AE:=20Rename=20Affiliation=20column=20?= =?UTF-8?q?to=20Affiliated=20Institutions=20and=20add=20translations=20for?= =?UTF-8?q?=20Invite=20Date?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- website/templates/project/contributors.mako | 2 +- website/translations/en/LC_MESSAGES/messages.po | 8 ++++++++ website/translations/ja/LC_MESSAGES/messages.po | 8 ++++++++ website/translations/messages.pot | 8 ++++++++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/website/templates/project/contributors.mako b/website/templates/project/contributors.mako index e4faa4cb7b4..d409d31bea5 100644 --- a/website/templates/project/contributors.mako +++ b/website/templates/project/contributors.mako @@ -184,7 +184,7 @@ % if permissions.ADMIN in user['permissions']: ${_("E-mail")} - ${_("Affiliation")} + ${_("Affiliated Institutions")} ${_("Invite Date")} % endif diff --git a/website/translations/en/LC_MESSAGES/messages.po b/website/translations/en/LC_MESSAGES/messages.po index 6d84a486354..88063883b68 100644 --- a/website/translations/en/LC_MESSAGES/messages.po +++ b/website/translations/en/LC_MESSAGES/messages.po @@ -2871,6 +2871,14 @@ msgstr "" msgid "Non-Bibliographic" msgstr "" +#: website/templates/project/contributors.mako:187 +msgid "Affiliated Institutions" +msgstr "" + +#: website/templates/project/contributors.mako:188 +msgid "Invite Date" +msgstr "" + #: website/templates/project/contributors.mako:63 msgid "Drag and drop contributors to change listing order." msgstr "" diff --git a/website/translations/ja/LC_MESSAGES/messages.po b/website/translations/ja/LC_MESSAGES/messages.po index 0fd90ed2222..62d573a2a6b 100644 --- a/website/translations/ja/LC_MESSAGES/messages.po +++ b/website/translations/ja/LC_MESSAGES/messages.po @@ -3312,6 +3312,14 @@ msgstr "目録で表示" msgid "Non-Bibliographic" msgstr "目録で非表示" +#: website/templates/project/contributors.mako:187 +msgid "Affiliated Institutions" +msgstr "所属機関" + +#: website/templates/project/contributors.mako:188 +msgid "Invite Date" +msgstr "招待日" + #: website/templates/project/contributors.mako:63 msgid "Drag and drop contributors to change listing order." msgstr "メンバーをドラッグ&ドロップして、リストの順序を変更します。" diff --git a/website/translations/messages.pot b/website/translations/messages.pot index b1d25f76719..efaae9cbd49 100644 --- a/website/translations/messages.pot +++ b/website/translations/messages.pot @@ -3238,6 +3238,14 @@ msgstr "" msgid "Non-Bibliographic" msgstr "" +#: website/templates/project/contributors.mako:187 +msgid "Affiliated Institutions" +msgstr "" + +#: website/templates/project/contributors.mako:188 +msgid "Invite Date" +msgstr "" + #: website/templates/project/contributors.mako:63 msgid "Drag and drop contributors to change listing order." msgstr ""