Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
a45770f
[ENG-8769] Update waitress (#11290)
Vlad0n20 Oct 16, 2025
b796c25
[ENG-8177] Institution dashboard not showing user affiliations for Pr…
antkryt Oct 16, 2025
a04d2c6
[ENG-9015] Redesign legacy pages (#11353)
futa-ikeda Oct 17, 2025
a95c48c
ENG-9127 add contributors from parent project to component
mkovalua Oct 17, 2025
bbc065e
implement unit test for component project contributors from parent pr…
mkovalua Oct 20, 2025
05484eb
Merge pull request #11376 from mkovalua/feature/ENG-9127
brianjgeiger Oct 21, 2025
5413b85
[ENG-9128] [Post-Release] P57 - [Contributors] Unregistered user’s “S…
Ostap-Zherebetskyi Oct 22, 2025
30e0c3d
[ENG-8655] Fix/eng 8655 (#11375)
Vlad0n20 Oct 23, 2025
3f8b884
[ENG-9122]Update create_at field for versioned preprint (#11383)
Vlad0n20 Oct 23, 2025
0a92941
[ENG-9002] allow gdpr delete for sole contribs (#11334)
antkryt Oct 23, 2025
62812c7
[ENG-8479] Update user template for admin app (#11346)
Vlad0n20 Oct 23, 2025
30b96ab
Merge pull request #11390 from CenterForOpenScience/develop
adlius Oct 23, 2025
c24abb2
[ENG-6223] chore: remove 'old' institution dashboard stuff (#11365)
aaxelb Oct 23, 2025
4277e44
fix test (#11394)
adlius Oct 27, 2025
8b09bd4
[ENG-9622] add legacy prefix to /claim/verify (#11386)
antkryt Oct 28, 2025
77b8622
[ENG-8832] Most recent numbered version of a preprint fails to show f…
opaduchak Oct 28, 2025
4f18fa4
fixed institution group lookup in admin (#11293)
opaduchak Oct 29, 2025
1965f4a
[ENG-8516] Add contributor and update permissions functionality on ad…
ihorsokhanexoft Oct 29, 2025
9337848
[ENG-9122] Update fix (#11403)
Vlad0n20 Nov 3, 2025
9b15446
[ENG-9060] Osf admin can recreate preprint version 1 with unregistere…
ihorsokhanexoft Nov 3, 2025
c3fb320
[ENG-8960] 2 Add registration date updated to activity logs (#11401)
Vlad0n20 Nov 4, 2025
478d2af
Copy global_reviews subscription changes to new_pending_submissions s…
Ostap-Zherebetskyi Nov 4, 2025
f56e0a7
[ENG-9025] Add the ability to remove moderators/admins from products …
Ostap-Zherebetskyi Nov 4, 2025
d006605
[ENG-8828] Allow for the complete removal of a preprint (in initial …
antkryt Nov 4, 2025
f6349e5
Accessible links (#11369)
jsoref Nov 6, 2025
5bc34b0
Spelling (#11379)
jsoref Nov 6, 2025
966f8e7
[ENG-8516] osf admin can update contributor permission for any resour…
ihorsokhanexoft Nov 6, 2025
7d6c24c
Remove duplicate import (#11427)
futa-ikeda Nov 6, 2025
2cbcfc8
[ENG-8481] added systems tags management in admin for nodes and prepr…
ihorsokhanexoft Nov 10, 2025
1cefee3
[ENG-8630] Feature/azure blob storage (#11307)
futa-ikeda Nov 11, 2025
ffc7a7a
Update license year
futa-ikeda Nov 11, 2025
f69a323
osf admin can remove contributors from any resource (#11436)
ihorsokhanexoft Nov 11, 2025
89e3499
enable bulk request for registration contributors POST/PATCH
antkryt Nov 19, 2025
88d3287
[ENG-9748] Update license year (#11437)
brianjgeiger Nov 19, 2025
7c67707
[ENG-9789] Enable Registration contributors API endpoint to use bulk …
brianjgeiger Nov 19, 2025
ef49be6
[ENG-9122] Fix/eng 9122 (#11435)
Vlad0n20 Nov 20, 2025
64e9f9b
Add affiliation management for institutions and users
Ostap-Zherebetskyi Oct 9, 2025
693fa28
Merge remote-tracking branch 'upstream/feature/pbs-25-25' into featur…
Ostap-Zherebetskyi Jan 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions admin/institutions/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,7 @@
name='list_and_add_admin'),
re_path(r'^(?P<institution_id>[0-9]+)/remove_admins/$', views.InstitutionRemoveAdmin.as_view(),
name='remove_admins'),
re_path(r'^(?P<institution_id>[0-9]+)/affiliations/$', views.InstitutionListAndAddAffiliation.as_view(), name='affiliations'),
re_path(r'^(?P<institution_id>[0-9]+)/remove_affiliations/$', views.InstitutionRemoveAffiliation.as_view(), name='remove_affiliations'),

]
60 changes: 60 additions & 0 deletions admin/institutions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,3 +373,63 @@ def form_valid(self, form):

def get_success_url(self):
return reverse('institutions:register_metrics_admin', kwargs={'institution_id': self.kwargs['institution_id']})


class InstitutionAffiliationBaseView(PermissionRequiredMixin, ListView):
permission_required = 'osf.change_institution'
template_name = 'institutions/edit_affiliations.html'
raise_exception = True

def get_queryset(self):
return Institution.objects.get(id=self.kwargs['institution_id'])

def get_context_data(self, **kwargs):
institution = Institution.objects.get(id=self.kwargs['institution_id'])
context = super().get_context_data(**kwargs)
context['institution'] = institution
context['affiliations'] = institution.get_institution_users()
return context


class InstitutionListAndAddAffiliation(InstitutionAffiliationBaseView):

def get_permission_required(self):
if self.request.method == 'GET':
return ('osf.view_institution',)
return (self.permission_required,)

def post(self, request, *args, **kwargs):
institution = Institution.objects.get(id=self.kwargs['institution_id'])
data = dict(request.POST)
del data['csrfmiddlewaretoken'] # just to remove the key from the form dict

target_user = OSFUser.load(data['add-affiliation-form'][0])
if target_user is None:
messages.error(request, f'User for guid: {data["add-affiliation-form"][0]} could not be found')
return redirect('institutions:affiliations', institution_id=institution.id)

target_user.add_or_update_affiliated_institution(institution)

messages.success(request, f'The following user was successfully added: {target_user.fullname} ({target_user.username})')

return redirect('institutions:affiliations', institution_id=institution.id)


class InstitutionRemoveAffiliation(InstitutionAffiliationBaseView):

def post(self, request, *args, **kwargs):
institution = Institution.objects.get(id=self.kwargs['institution_id'])
data = dict(request.POST)
del data['csrfmiddlewaretoken'] # just to remove the key from the form dict

to_be_removed = list(data.keys())
removed_affiliations = [user.replace('User-', '') for user in to_be_removed if 'User-' in user]
affiliated_users = OSFUser.objects.filter(id__in=removed_affiliations)
for user in affiliated_users:
user.remove_affiliated_institution(institution._id)

if affiliated_users:
users_names = ' ,'.join(affiliated_users.values_list('fullname', flat=True))
messages.success(request, f'The following users were successfully removed: {users_names}')

return redirect('institutions:affiliations', institution_id=institution.id)
1 change: 1 addition & 0 deletions admin/templates/institutions/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
{% else %}
<a class="btn btn-danger" href={% url 'institutions:reactivate' institution.id %}>Reactivate institution</a>
{% endif %}
<a class="btn btn-primary" href={% url 'institutions:affiliations' institution.id %}>Affiliations</a>
{% endif %}
{% if perms.osf.change_institution %}
<a class="btn btn-primary" href={% url 'institutions:list_and_add_admin' institution.id %}>Manage Admins</a>
Expand Down
55 changes: 55 additions & 0 deletions admin/templates/institutions/edit_affiliations.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% load static %}
{% load render_bundle from webpack_loader %}
{% block title %}
<title>Institution Affiliations</title>
{% endblock title %}
{% block content %}
<div class="container-fluid">
<div class="row">
{% if messages %}
<ul>
{% for message in messages %}
<li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
<div class="row">
<div class="col-md-12 text-center">
<h2>{{ institution.name }}</h2>
</div>
</div>
<div class="row">
<div class="col-md-12">
<form id="add-affiliation-form" action="{% url 'institutions:affiliations' institution.id %}" method="post">
{% csrf_token %}
<label>Add user by guid: </label>
<input type="text" name="add-affiliation-form">
<input type="submit" name="user" value="Add User" class="form-button btn btn-success">
</form>
</div>
</div>
<hr>
<div class="row">
<div class="col-md-12">
<form id="remove-affiliation-form" action="{% url 'institutions:remove_affiliations' institution.id %}" method="post">
{% csrf_token %}
<table class="table table-striped">
<th></th>
<th>Name</th>
<th>Username</th>
{% for user in affiliations %}
<tr>
<td><input type='checkbox' name="User-{{user.id}}"></td>
<td>{{ user.fullname }}</td>
<td>{{ user.username }}</td>
</tr>
{% endfor %}
</table>
<input class="form-button btn btn-danger" type="submit" value="Remove affiliation" />
</form>
</div>
</div>
</div>
{% endblock content %}
55 changes: 55 additions & 0 deletions admin/templates/users/affiliated_institutions.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% load static %}
{% load render_bundle from webpack_loader %}
{% block title %}
<title>Affiliated Institutions</title>
{% endblock title %}
{% block content %}
<div class="container-fluid">
<div class="row">
{% if messages %}
<ul>
{% for message in messages %}
<li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
<div class="row">
<div class="col-md-12 text-center">
<h2>{{ institution.name }}</h2>
</div>
</div>
<div class="row">
<div class="col-md-12">
<form id="add-affiliation-form" action="{% url 'users:affiliations' guid=user.guid %}" method="post">
{% csrf_token %}
<label>Add Institution by guid: </label>
<input type="text" name="add-affiliation-form">
<input type="submit" name="user" value="Add Institution" class="form-button btn btn-success">
</form>
</div>
</div>
<hr>
<div class="row">
<div class="col-md-12">
<form id="remove-affiliation-form" action="{% url 'users:remove_affiliations' guid=user.guid %}" method="post">
{% csrf_token %}
<table class="table table-striped">
<th></th>
<th>Name</th>
<th>Guid</th>
{% for institution in institutions %}
<tr>
<td><input type='checkbox' name="institution-{{institution.id}}"></td>
<td>{{ institution.name }}</td>
<td>{{ institution.guid }}</td>
</tr>
{% endfor %}
</table>
<input class="form-button btn btn-danger" type="submit" value="Remove affiliation" />
</form>
</div>
</div>
</div>
{% endblock content %}
3 changes: 3 additions & 0 deletions admin/templates/users/user.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
{% include "users/mark_spam.html" with user=user %}
{% include "users/reindex_user_elastic.html" with user=user %}
{% include "users/reindex_user_share.html" with user=user %}
{% if perms.osf.change_institution %}
<a class="btn btn-primary" href="{% url 'users:affiliations' guid=user.guid %}">Affiliations</a>
{% endif %}
</div>
</div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions admin/users/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,6 @@
name='reindex-share-user'),
re_path(r'^(?P<guid>[a-z0-9]+)/merge_accounts/$', views.UserMergeAccounts.as_view(), name='merge-accounts'),
re_path(r'^(?P<guid>[a-z0-9]+)/draft_registrations/$', views.UserDraftRegistrationsList.as_view(), name='draft-registrations'),
re_path(r'^(?P<guid>[a-z0-9]+)/affiliations/$', views.UserListAndAddAffiliations.as_view(), name='affiliations'),
re_path(r'^(?P<guid>[a-z0-9]+)/remove_affiliations/$', views.UserRemoveAffiliations.as_view(), name='remove_affiliations'),
]
58 changes: 58 additions & 0 deletions admin/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from osf.models.spam import SpamStatus
from framework.auth import get_user
from framework.auth.core import generate_verification_key
from osf.models.institution import Institution

from website import search
from website.settings import EXTERNAL_IDENTITY_PROFILE
Expand Down Expand Up @@ -614,3 +615,60 @@ def get_context_data(self, **kwargs):
'draft_registrations': query_set
}
)


class UserAffiliationBaseView(UserMixin, ListView):
permission_required = 'osf.change_institution'
template_name = 'users/affiliated_institutions.html'
raise_exception = True

def get_queryset(self):
# Django template does not like attributes with underscores for some reason, so we annotate.
return self.get_object().get_affiliated_institutions().annotate(
guid=F('_id')
)

def get_context_data(self, **kwargs):
institutions = self.get_queryset()
context = super().get_context_data(**kwargs)
context['institutions'] = institutions
context['user'] = self.get_object()
return context


class UserRemoveAffiliations(UserAffiliationBaseView):

def post(self, request, *args, **kwargs):
user = self.get_object()
data = dict(request.POST)

to_be_removed = list(data.keys())
removed_affiliations = [institution.replace('institution-', '') for institution in to_be_removed if 'institution-' in institution]
institutions_qs = Institution.objects.filter(id__in=removed_affiliations)
for institution in institutions_qs:
user.remove_affiliated_institution(institution._id)

if institutions_qs:
institutions_names = ' ,'.join(institutions_qs.values_list('name', flat=True))
messages.success(request, f'The following users were successfully removed: {institutions_names}')

return redirect('users:affiliations', guid=user.guid)


class UserListAndAddAffiliations(UserAffiliationBaseView):

def post(self, request, *args, **kwargs):
user = self.get_object()
data = dict(request.POST)
del data['csrfmiddlewaretoken'] # just to remove the key from the form dict

institution = Institution.load(data['add-affiliation-form'][0])
if institution is None:
messages.error(request, f'Institution for guid: {data["add-affiliation-form"][0]} could not be found')
return redirect('users:affiliations', guid=user.guid)

user.add_or_update_affiliated_institution(institution)

messages.success(request, f'The following institution was successfully added: {institution.name}')

return redirect('users:affiliations', guid=user.guid)
2 changes: 1 addition & 1 deletion api_tests/preprints/views/test_preprint_versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -807,7 +807,7 @@ def test_list_versions_pre_mod(self):

# Pre moderation V4 (Pending)
pre_mod_preprint_v4 = PreprintFactory.create_version(
create_from=pre_mod_preprint_v2,
create_from=pre_mod_preprint_v3,
creator=self.creator,
final_machine_state='initial',
is_published=False,
Expand Down
73 changes: 73 additions & 0 deletions osf/management/commands/fix_versioned_guids.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import logging

from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Prefetch

from osf.models import GuidVersionsThrough, Guid, Preprint
from osf.utils.workflows import ReviewStates

logger = logging.getLogger(__name__)


class Command(BaseCommand):
help = 'Fix'

def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
dest='dry_run',
help='Run the command without saving changes',
)

@transaction.atomic
def handle(self, *args, **options):
dry_run = options.get('dry_run', False)
fix_versioned_guids(dry_run=dry_run)
if dry_run:
transaction.set_rollback(True)


def fix_versioned_guids(dry_run: bool):
content_type = ContentType.objects.get_for_model(Preprint)
versions_queryset = GuidVersionsThrough.objects.order_by('-version')
processed_count = 0
updated_count = 0
skipped_count = 0
errors_count = 0
for guid in (
Guid.objects.filter(content_type=content_type)
.prefetch_related(Prefetch('versions', queryset=versions_queryset))
.iterator(chunk_size=500)
):
processed_count += 1
if not guid.versions:
skipped_count += 1
continue
for version in guid.versions.all():
last_version_object_id = version.object_id
if guid.object_id == last_version_object_id:
skipped_count += 1
break
if version.referent.machine_state not in (ReviewStates.INITIAL.value, ReviewStates.REJECTED.value, ReviewStates.WITHDRAWN.value):
continue
try:
guid.object_id = last_version_object_id
guid.referent = version.referent
if not dry_run:
guid.save()
updated_count += 1
except Exception as e:
logger.error(f"Error occurred during patching {guid._id=}", exc_info=e)
errors_count += 1

if dry_run:
logger.error(
f"Processed: {processed_count}, Would update: {updated_count}, Skipped: {skipped_count}, Errors: {errors_count}"
)
else:
logger.error(
f"Processed: {processed_count}, Updated: {updated_count}, Skipped: {skipped_count}, Errors: {errors_count}"
)
Loading
Loading