Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
67 changes: 41 additions & 26 deletions admin/nodes/views.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,30 @@
import pytz
from enum import Enum
from datetime import datetime
from framework import status
from enum import Enum

from django.utils import timezone
from django.core.exceptions import PermissionDenied, ValidationError
from django.urls import NoReverseMatch
from django.db.models import F, Case, When, IntegerField
import pytz
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import PermissionDenied, ValidationError
from django.db.models import F, Case, When, IntegerField
from django.http import HttpResponse
from django.shortcuts import redirect, reverse, get_object_or_404
from django.urls import NoReverseMatch
from django.urls import reverse_lazy
from django.utils import timezone
from django.views.generic import (
View,
FormView,
ListView,
)
from django.shortcuts import redirect, reverse, get_object_or_404
from django.urls import reverse_lazy

from admin.base.forms import GuidForm
from admin.base.utils import change_embargo_date
from admin.base.views import GuidView
from admin.base.forms import GuidForm
from admin.notifications.views import detect_duplicate_notifications, delete_selected_notifications
from admin.nodes.forms import AddSystemTagForm, RegistrationDateForm

from api.share.utils import update_share
from admin.notifications.views import detect_duplicate_notifications, delete_selected_notifications
from api.caching.tasks import update_storage_usage_cache

from api.share.utils import update_share
from framework import status
from osf.exceptions import NodeStateError, RegistrationStuckError
from osf.models import (
OSFUser,
Expand All @@ -49,9 +47,7 @@
REINDEX_ELASTIC,
)
from osf.utils.permissions import ADMIN, API_CONTRIBUTOR_PERMISSIONS

from scripts.approve_registrations import approve_past_pendings

from website import settings, search
from website.archiver.tasks import force_archive

Expand Down Expand Up @@ -146,7 +142,8 @@ def get_context_data(self, **kwargs):
'STORAGE_LIMITS': settings.StorageLimits,
'node': node,
# to edit contributors we should have guid as django prohibits _id usage as it starts with an underscore
'annotated_contributors': node.contributor_set.prefetch_related('user__guids').annotate(guid=F('user__guids___id')),
'annotated_contributors': node.contributor_set.prefetch_related('user__guids').annotate(
guid=F('user__guids___id')),
'children': children,
'permissions': API_CONTRIBUTOR_PERMISSIONS,
'has_update_permission': self.request.user.has_perm('osf.change_node'),
Expand Down Expand Up @@ -207,7 +204,9 @@ class NodeRemoveContributorView(NodeMixin, View):
def post(self, request, *args, **kwargs):
node = self.get_object()
user = OSFUser.objects.get(id=self.kwargs.get('user_id'))
if node.has_permission(user, ADMIN) and not node._get_admin_contributors_query(node._contributors.all(), require_active=False).exclude(user=user).exists():
if node.has_permission(user, ADMIN) and not node._get_admin_contributors_query(node._contributors.all(),
require_active=False).exclude(
user=user).exists():
messages.error(self.request, 'Must be at least one admin on this node.')
return redirect(self.get_success_url())

Expand Down Expand Up @@ -867,6 +866,7 @@ class ForceArchiveRegistrationsView(NodeMixin, View):
def post(self, request, *args, **kwargs):
# Prevents circular imports that cause admin app to hang at startup
from osf.management.commands.force_archive import verify, DEFAULT_PERMISSIBLE_ADDONS
from osf.models.admin_log_entry import update_admin_log, MANUAL_ARCHIVE_RESTART

registration = self.get_object()
force_archive_params = request.POST
Expand All @@ -891,14 +891,29 @@ def post(self, request, *args, **kwargs):
if dry_mode:
messages.success(request, f"Registration {registration._id} can be archived.")
else:
force_archive_task = force_archive.delay(
str(registration._id),
permissible_addons=list(addons),
allow_unconfigured=allow_unconfigured,
skip_collisions=skip_collision,
delete_collisions=delete_collision,
)
messages.success(request, f'Registration archive process has started. Task id: {force_archive_task.id}.')
try:
update_admin_log(
user_id=request.user.id,
object_id=registration.pk,
object_repr=str(registration),
message=f'Manual archive restart initiated for registration {registration._id}',
action_flag=MANUAL_ARCHIVE_RESTART
)
force_archive_task = force_archive.delay(
str(registration._id),
permissible_addons=list(addons),
allow_unconfigured=allow_unconfigured,
skip_collisions=skip_collision,
delete_collisions=delete_collision,
)
messages.success(
request,
f'Registration archive process has started. Task id: {force_archive_task.id}.'
)
except Exception as exc:
messages.error(request,
f'This registration cannot be archived due to {exc.__class__.__name__}: {str(exc)}. '
f'If the problem persists get a developer to fix it.')

return redirect(self.get_success_url())

Expand Down
10 changes: 7 additions & 3 deletions api/institutions/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,9 @@ class Meta:
number_of_users = ser.IntegerField(read_only=True)

def get_absolute_url(self, obj):
raise NotImplementedError()
institution_id = self.context['request'].parser_context['kwargs']['institution_id']
dept_id = obj['name'].replace(' ', '-')
return f'/institutions/{institution_id}/metrics/departments/{dept_id}/'


class InstitutionUserMetricsSerializer(JSONAPISerializer):
Expand Down Expand Up @@ -289,7 +291,8 @@ def get_contacts(self, obj):
return list(results)

def get_absolute_url(self, obj):
raise NotImplementedError()
institution_id = self.context['request'].parser_context['kwargs']['institution_id']
return f'/institutions/{institution_id}/metrics/users/'


class InstitutionSummaryMetricsSerializer(JSONAPISerializer):
Expand Down Expand Up @@ -323,7 +326,8 @@ class Meta:
)

def get_absolute_url(self, obj):
raise NotImplementedError()
institution_id = self.context['request'].parser_context['kwargs']['institution_id']
return f'/institutions/{institution_id}/metrics/summary/'


class InstitutionRelated(JSONAPIRelationshipSerializer):
Expand Down
145 changes: 145 additions & 0 deletions osf/management/commands/process_manual_restart_approvals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import logging
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils import timezone
from osf.models import Registration
from osf.models.admin_log_entry import AdminLogEntry, MANUAL_ARCHIVE_RESTART
from website import settings
from scripts.approve_registrations import approve_past_pendings

logger = logging.getLogger(__name__)


class Command(BaseCommand):
help = 'Process registrations that were manually restarted and may need approval'

def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be done without actually doing it',
)
parser.add_argument(
'--hours-back',
type=int,
default=72,
help='How many hours back to look for manual restarts (default: 72)',
)
parser.add_argument(
'--registration-id',
type=str,
help='Process a specific registration ID only',
)

def handle(self, *args, **options):
dry_run = options['dry_run']
hours_back = options['hours_back']
specific_registration = options.get('registration_id')

if dry_run:
self.stdout.write(self.style.WARNING('Running in DRY RUN mode - no changes will be made'))

since = timezone.now() - timedelta(hours=hours_back)

query = AdminLogEntry.objects.filter(
action_flag=MANUAL_ARCHIVE_RESTART,
action_time__gte=since
)

if specific_registration:
try:
reg = Registration.objects.get(_id=specific_registration)
query = query.filter(object_id=reg.pk)
self.stdout.write(f"Processing specific registration: {specific_registration}")
except Registration.DoesNotExist:
self.stdout.write(self.style.ERROR(f"Registration {specific_registration} not found"))
return

manual_restart_logs = query.values_list('object_id', flat=True).distinct()

registrations_to_check = Registration.objects.filter(
pk__in=manual_restart_logs,
)

self.stdout.write(f"Found {registrations_to_check.count()} manually restarted registrations to check")

approvals_ready = []
skipped_registrations = []

for registration in registrations_to_check:
status = self.should_auto_approve(registration)

if status == 'ready':
approval = registration.registration_approval
if approval:
approvals_ready.append(approval)
self.stdout.write(
self.style.SUCCESS(f"✓ Queuing registration {registration._id} for approval")
)
else:
skipped_registrations.append((registration._id, status))
self.stdout.write(
self.style.WARNING(f"⚠ Skipping registration {registration._id}: {status}")
)

if approvals_ready:
if dry_run:
self.stdout.write(
self.style.WARNING(f"DRY RUN: Would approve {len(approvals_ready)} registrations")
)
else:
try:
approve_past_pendings(approvals_ready, dry_run=False)
self.stdout.write(
self.style.SUCCESS(f"✓ Successfully approved {len(approvals_ready)} manually restarted registrations")
)
except Exception as e:
self.stdout.write(
self.style.ERROR(f"✗ Error approving registrations: {e}")
)
else:
self.stdout.write('No registrations ready for approval')

self.stdout.write(f"Total checked: {registrations_to_check.count()}")
self.stdout.write(f"Ready for approval: {len(approvals_ready)}")
self.stdout.write(f"Skipped: {len(skipped_registrations)}")

if skipped_registrations:
self.stdout.write('\nSkipped registrations:')
for reg_id, reason in skipped_registrations:
self.stdout.write(f" - {reg_id}: {reason}")

def should_auto_approve(self, registration):
if registration.is_public:
return 'already public'

if registration.is_registration_approved:
return 'already approved'

if registration.archiving:
return 'still archiving'

archive_job = registration.archive_job
if archive_job and hasattr(archive_job, 'status'):
if archive_job.status not in ['SUCCESS', None]:
return f'archive status: {archive_job.status}'

approval = registration.registration_approval
if not approval:
return 'no approval object'

if approval.is_approved:
return 'approval already approved'

if approval.is_rejected:
return 'approval was rejected'

time_since_initiation = timezone.now() - approval.initiation_date
if time_since_initiation < settings.REGISTRATION_APPROVAL_TIME:
remaining = settings.REGISTRATION_APPROVAL_TIME - time_since_initiation
return f'not ready yet ({remaining} remaining)'

if registration.is_stuck_registration:
return 'registration still stuck'

return 'ready'
2 changes: 2 additions & 0 deletions osf/models/admin_log_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
DOI_CREATION_FAILED = 80
DOI_UPDATE_FAILED = 81

MANUAL_ARCHIVE_RESTART = 90

def update_admin_log(user_id, object_id, object_repr, message, action_flag=UNKNOWN):
AdminLogEntry.objects.log_action(
user_id=user_id,
Expand Down
74 changes: 74 additions & 0 deletions scripts/check_manual_restart_approval.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import logging
from framework.celery_tasks import app as celery_app
from django.core.management import call_command
from osf.models import Registration

logger = logging.getLogger(__name__)


@celery_app.task(name='scripts.check_manual_restart_approval')
def check_manual_restart_approval(registration_id):
try:
try:
registration = Registration.objects.get(_id=registration_id)
except Registration.DoesNotExist:
logger.error(f"Registration {registration_id} not found")
return f"Registration {registration_id} not found"

if registration.is_public or registration.is_registration_approved:
return f"Registration {registration_id} already approved/public"

if registration.archiving:
logger.info(f"Registration {registration_id} still archiving, retrying in 10 minutes")
check_manual_restart_approval.apply_async(
args=[registration_id],
countdown=600
)
return f"Registration {registration_id} still archiving, scheduled retry"

logger.info(f"Processing manual restart approval for registration {registration_id}")

call_command(
'process_manual_restart_approvals',
registration_id=registration_id,
dry_run=False,
hours_back=24,
verbosity=1
)

return f"Processed manual restart approval check for registration {registration_id}"

except Exception as e:
logger.error(f"Error processing manual restart approval for {registration_id}: {e}")
raise


@celery_app.task(name='scripts.check_manual_restart_approvals_batch')
def check_manual_restart_approvals_batch(hours_back=24):
try:
logger.info(f"Running batch check for manual restart approvals (last {hours_back} hours)")

call_command(
'process_manual_restart_approvals',
dry_run=False,
hours_back=hours_back,
verbosity=1
)

return f"Completed batch manual restart approval check for last {hours_back} hours"

except Exception as e:
logger.error(f"Error in batch manual restart approval check: {e}")
raise


@celery_app.task(name='scripts.delayed_manual_restart_approval')
def delayed_manual_restart_approval(registration_id, delay_minutes=30):
logger.info(f"Scheduling delayed manual restart approval check for {registration_id} in {delay_minutes} minutes")

check_manual_restart_approval.apply_async(
args=[registration_id],
countdown=delay_minutes * 60
)

return f"Scheduled manual restart approval check for {registration_id} in {delay_minutes} minutes"
Loading
Loading