Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
857c030
Fix embargo completion script
Vlad0n20 Sep 14, 2026
3bb7bad
Fix pagination results in duplicated
Vlad0n20 Sep 21, 2026
fea60d9
[ENG-12189] Update unused API endpoint with GDPR/ORCiD changes (#11943)
Ostap-Zherebetskyi Sep 28, 2026
e6e76c5
[ENG-11865] - Fix pagination results in duplicated (#11933)
brianjgeiger Sep 29, 2026
5d80bf9
[ENG-12078] - Fix embargo completion script (#11918)
brianjgeiger Sep 29, 2026
eeb98b0
Add session deletion after GDPR delete (#11936)
Vlad0n20 Sep 29, 2026
e7d73d9
[ENG-12157] - Wrap merge_user in a transaction (#11937)
Vlad0n20 Sep 29, 2026
7fc0446
[ENG-11841] implement Configurable ToS date drives re-acceptance (#1…
mkovalua Sep 29, 2026
26e3cc5
[ENG-12309] Exclude non-contributors from campaign emails (#11949)
mkovalua Sep 29, 2026
dc2a06f
[ENG-12159] Fix "unusable" password and invalid user status bug relat…
Ostap-Zherebetskyi Sep 29, 2026
49cd378
fixed pre-existing date_last_indexed_issue (#11946)
ihorsokhanexoft Sep 29, 2026
a6afe4e
[ENG-9994][ENG-12168] Add API endpoint/view for resend confirmation U…
bodintsov Sep 29, 2026
a9164b0
[ENG-12351] Expand unconfirmed users filter to include deactivation r…
Ostap-Zherebetskyi Oct 5, 2026
4578824
[ENG-12159] Follow-up: Fix "unusable" password and invalid user statu…
Ostap-Zherebetskyi Oct 5, 2026
bbc474e
[ENG-12142] Block CAS login for disabled and spam (confirmed) users (…
Vlad0n20 Oct 5, 2026
c0be182
[ENG-12333] Re-activate API3_and_OSF tests (#11959)
Ostap-Zherebetskyi Oct 6, 2026
3aa3705
[ENG-11880] Fixed missed email sends (#11912)
ihorsokhanexoft Oct 6, 2026
9df45ca
Revert "[ENG-12157] - Wrap merge_user in a transaction (#11937)" (#11…
brianjgeiger Oct 6, 2026
c9b1d04
Merge branch 'develop' into upstream/pbs-26-19
brianjgeiger Oct 6, 2026
9050e91
[ENG-12399] Expand filter option to exclude unconfirmed, disabled, an…
Ostap-Zherebetskyi Oct 6, 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
6 changes: 6 additions & 0 deletions admin/nodes/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@ class EmbargoReportView(PermissionRequiredMixin, TemplateView):
- pending embargoes that should have been activated
- active embargoes that are past their end date
- upcoming active embargoes
- embargoes marked completed whose registration never actually went public
"""

template_name = 'nodes/embargo_report.html'
Expand Down Expand Up @@ -562,6 +563,11 @@ def get_context_data(self, **kwargs):
self._embargo_report_queryset(Embargo.objects.active_past_end_date()),
'overdue_page',
),
'stuck_completed_page': self.paginate_embargo_report(
request,
self._embargo_report_queryset(Embargo.objects.stuck_completed()),
'stuck_completed_page',
),
})
return context

Expand Down
48 changes: 48 additions & 0 deletions admin/templates/nodes/embargo_report.html
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,52 @@ <h2>Active Embargoes Past Pending Window</h2>
</tbody>
</table>

<h2>Completed Embargoes Stuck Private</h2>
<p>These embargoes are marked completed, but the registration never actually went public. This usually means an error occurred while making the registration public (e.g. a spam flag) after the embargo had already been marked complete.</p>
<table class="table table-striped table-bordered table-hover">
<thead>
<tr>
<th>Registration</th>
<th>Embargo ID</th>
<th>State</th>
<th>Embargo End</th>
<th>Initiated By</th>
</tr>
</thead>
{% if stuck_completed_page.paginator.num_pages > 1 %}
{% include "util/pagination.html" with items=stuck_completed_page page_param="stuck_completed_page" status='' pagin=False order='' %}
{% endif %}
<tbody>
{% for embargo in stuck_completed_page %}
{% with registration=embargo.registrations.all.0 %}
{% if registration %}
<tr>
<td>
<a href="{{ registration | reverse_node }}">
{{ registration.title | truncatechars:30 }}
</a>
</td>
<td>{{ embargo.id }}</td>
<td>{{ embargo.state }}</td>
<td>{{ embargo.end_date|date:"F j, Y P" }}</td>
<td>
{% if embargo.initiated_by %}
<a href="{{ embargo.initiated_by | reverse_user }}">
{{ embargo.initiated_by.fullname }}
</a>
{% else %}
&mdash;
{% endif %}
</td>
</tr>
{% endif %}
{% endwith %}
{% empty %}
<tr>
<td colspan="5">No completed embargoes stuck private.</td>
</tr>
{% endfor %}
</tbody>
</table>

{% endblock %}
11 changes: 10 additions & 1 deletion admin/templates/notifications/notification_campaigns_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -299,9 +299,18 @@ <h4>Recipient Filters</h4>
</table>
{% endif %}

{% if metadata.filters.exclude_non_contributors %}
<table class="table table-bordered">
<tr>
<th style="width:250px;">Exclude non-contributors</th>
<td>Only contributors on at least one project or component</td>
</tr>
</table>
{% endif %}

{% if metadata.filters.manual %}
{% include "notifications/campaign_filter_group.html" with group=metadata.filters.manual is_root=True %}
{% elif not "predefined" in metadata.filters %}
{% elif not "predefined" in metadata.filters and not metadata.filters.exclude_non_contributors %}
<p class="text-muted">No filters configured.</p>
{% endif %}

Expand Down
41 changes: 35 additions & 6 deletions admin/templates/notifications/notification_campaing_create.html
Original file line number Diff line number Diff line change
Expand Up @@ -167,17 +167,32 @@ <h4>Recipient Filters</h4>
<label>
<input
type="checkbox"
id="exclude-unconfirmed"
id="exclude-unconfirmed-and-disabled-and-deactivation-requested"
checked
>
Exclude unconfirmed accounts
Exclude unconfirmed, disabled, and deactivation requested users
</label>
<p class="help-block">
When enabled, users who have not confirmed their accounts
When enabled, users who have not confirmed their accounts or are disabled or have requested deactivation
are excluded from the recipient list.
</p>
</div>

<div class="form-group" style="margin-top:15px;">
<label>
<input
type="checkbox"
id="exclude-non-contributors"
>
Exclude non-contributors
</label>
<p class="help-block">
When enabled, only users who are contributors on at least one
project or component (at any nesting level) are kept in the
recipient list. Registrations and preprints do not count.
</p>
</div>

<input
type="hidden"
id="filters-input"
Expand Down Expand Up @@ -460,25 +475,39 @@ <h4>Execution</h4>
filters = {"manual": serializeGroup(root)};
}

if (document.getElementById("exclude-unconfirmed").checked) {
if (document.getElementById("exclude-unconfirmed-and-disabled-and-deactivation-requested").checked) {
const confirmed = {
field: "date_confirmed",
lookup: "isnull",
value: false,
};
const not_disabled = {
field: "date_disabled",
lookup: "isnull",
value: true,
};
const has_not_requested_deactivation = {
field: "requested_deactivation",
lookup: "exact",
value: false,
};
if (filters.manual) {
filters.manual = {
operator: "AND",
children: [filters.manual, confirmed],
children: [filters.manual, confirmed, not_disabled, has_not_requested_deactivation],
};
} else {
filters.manual = {
operator: "AND",
children: [confirmed],
children: [confirmed, not_disabled, has_not_requested_deactivation],
};
}
}

if (document.getElementById("exclude-non-contributors").checked) {
filters.exclude_non_contributors = true;
}

return filters;
}

Expand Down
29 changes: 29 additions & 0 deletions admin_tests/nodes/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1307,3 +1307,32 @@ def test_deleted_registration_embargo_excluded(self):

context = self.view.get_context_data()
assert embargo not in context['overdue_page']

def test_stuck_completed_in_report(self):
embargo = EmbargoFactory(
approve=True,
end_date=timezone.now() - timezone.timedelta(days=1),
)
embargo.state = Sanction.COMPLETED
embargo.save()
registration = embargo.registrations.first()
registration.is_public = False
registration.save()

context = self.view.get_context_data()
assert embargo in context['stuck_completed_page']
assert embargo not in context['overdue_page']

def test_public_completed_embargo_excluded_from_stuck_completed(self):
embargo = EmbargoFactory(
approve=True,
end_date=timezone.now() - timezone.timedelta(days=1),
)
embargo.state = Sanction.COMPLETED
embargo.save()
registration = embargo.registrations.first()
registration.is_public = True
registration.save()

context = self.view.get_context_data()
assert embargo not in context['stuck_completed_page']
63 changes: 59 additions & 4 deletions admin_tests/notifications/test_campaigns.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
NotificationCampaignCreateView,
NotificationCampaignDetail,
NotificationCampaignsList,
NotificationCampaignsRecipientsPreview,
StartNotificationCampaign,
DeleteNotificationCampaign,
)
Expand All @@ -23,7 +24,7 @@
from osf.models.notification_campaign import (
NotificationCampaign,
)
from osf_tests.factories import AuthUserFactory
from osf_tests.factories import AuthUserFactory, ProjectFactory
from tests.base import AdminTestCase
from website import settings

Expand Down Expand Up @@ -270,17 +271,14 @@ def test_form_valid_persists_execution_metadata(self):
)
request.user = self.user
patch_messages(request)

form = NotificationCampaignCreateForm(data=request.POST)
assert form.is_valid()

view = setup_form_view(
NotificationCampaignCreateView(),
request,
form,
)
view.form_valid(form)

campaign = NotificationCampaign.objects.get(name='My Campaign')
assert campaign.created_by == self.user
assert campaign.metadata['execution'] == {
Expand All @@ -295,6 +293,29 @@ def test_form_valid_persists_execution_metadata(self):
assert campaign.metadata['filters'] == {'predefined': 'active'}
assert campaign.metadata['context'] == {'greeting': 'hi'}

def test_form_valid_persists_exclude_non_contributors_in_filters(self):
request = RequestFactory().post(
reverse('notifications:notification_campaigns_create'),
data=_valid_form_data(
self.notification_type,
filters=json.dumps({
'predefined': 'active',
'exclude_non_contributors': True,
}),
),
)
request.user = self.user
patch_messages(request)
form = NotificationCampaignCreateForm(data=request.POST)
assert form.is_valid()
view = setup_form_view(NotificationCampaignCreateView(), request, form)
view.form_valid(form)
campaign = NotificationCampaign.objects.get(name='My Campaign')
assert campaign.metadata['filters'] == {
'predefined': 'active',
'exclude_non_contributors': True,
}

@mock.patch('admin.notifications.views._render_email_html', side_effect=Exception('bad template'))
def test_form_valid_rejects_unrenderable_context(self, mock_render):
request = RequestFactory().post(
Expand All @@ -321,6 +342,40 @@ def test_form_valid_rejects_unrenderable_context(self, mock_render):
assert not NotificationCampaign.objects.filter(name='My Campaign').exists()


class TestNotificationCampaignsRecipientsPreview(AdminTestCase):

def setUp(self):
super().setUp()
self.user = AuthUserFactory()
grant_permission(self.user, 'view_osfuser')
self.contributor = AuthUserFactory()
self.non_contributor = AuthUserFactory()
ProjectFactory(creator=self.contributor)

def test_preview_excludes_non_contributors_when_the_option_is_on(self):
filters = {
'manual': {
'operator': 'AND',
'children': [
{
'field': 'id',
'lookup': 'in',
'value': f'{self.contributor.id},{self.non_contributor.id}',
},
],
},
'exclude_non_contributors': True,
}
request = RequestFactory().get(
reverse('notifications:notification_campaigns_recipients_preview'),
data={'filters': json.dumps(filters)},
)
request.user = self.user
view = NotificationCampaignsRecipientsPreview()
view.setup(request)
assert list(view.get_queryset().values_list('id', flat=True)) == [self.contributor.id]


class TestNotificationCampaignAdminPermissions(AdminTestCase):

def setUp(self):
Expand Down
2 changes: 1 addition & 1 deletion api/nodes/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1198,7 +1198,7 @@ class NodeFilesList(JSONAPIBaseView, generics.ListAPIView, WaterButlerMixin, Lis
ExcludeWithdrawals,
)

ordering = ('_materialized_path',) # default ordering
ordering = ('_materialized_path', 'id') # default ordering

required_read_scopes = [CoreScopes.NODE_FILE_READ]
required_write_scopes = [CoreScopes.NODE_FILE_WRITE]
Expand Down
9 changes: 7 additions & 2 deletions api/users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ def get_default_region_id(self, obj):
return region_id

def get_accepted_terms_of_service(self, obj):
return bool(obj.accepted_terms_of_service)
return obj.has_accepted_current_terms_of_service

def get_merged_by_absolute_url(self, obj):
if obj.merged_by:
Expand Down Expand Up @@ -292,7 +292,7 @@ def update(self, instance, validated_data):
for key, val in value.items():
instance.social[key] = val
elif 'accepted_terms_of_service' == attr:
if value and not instance.accepted_terms_of_service:
if value and not instance.has_accepted_current_terms_of_service:
instance.accepted_terms_of_service = timezone.now()
elif 'default_region' == attr:
user_settings = instance._settings_model('osfstorage').objects.get(owner=instance)
Expand Down Expand Up @@ -458,6 +458,11 @@ class UserResetPasswordSerializer(BaseAPISerializer):
class Meta:
type_ = 'user_reset_password'

class UserResendConfirmationSerializer(BaseAPISerializer):
email = ser.CharField(write_only=True, required=True)

class Meta:
type_ = 'user_resend_confirmation'

class ConfirmEmailTokenSerializer(BaseAPISerializer):
uid = ser.CharField(write_only=True, required=True)
Expand Down
1 change: 1 addition & 0 deletions api/users/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

urlpatterns = [
re_path(r'^reset_password/$', views.ResetPassword.as_view(), name=views.ResetPassword.view_name),
re_path(r'^resend_confirmation/$', views.ResendConfirmation.as_view(), name=views.ResendConfirmation.view_name),
re_path(r'^external_login_comfirm_email/$', views.ExternalLoginConfirmEmailView.as_view(), name=views.ExternalLoginConfirmEmailView.view_name),
re_path(r'^external_login/$', views.ExternalLogin.as_view(), name=views.ExternalLogin.view_name),
re_path(r'^$', views.UserList.as_view(), name=views.UserList.view_name),
Expand Down
Loading
Loading