Skip to content

Commit 665ed8b

Browse files
authored
[ENG-8515] - Add management command to manual archive (#11361)
* Add management command to manual archive * fix test * Fix lints * Fix tests
1 parent 3145cf2 commit 665ed8b

File tree

9 files changed

+441
-37
lines changed

9 files changed

+441
-37
lines changed

admin/nodes/views.py

Lines changed: 43 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,31 @@
1-
import pytz
2-
from enum import Enum
31
from datetime import datetime
4-
from framework import status
2+
from enum import Enum
53

6-
from django.utils import timezone
7-
from django.core.exceptions import PermissionDenied, ValidationError
8-
from django.urls import NoReverseMatch
4+
import pytz
95
from django.db import transaction
10-
from django.db.models import F, Case, When, IntegerField
116
from django.contrib import messages
127
from django.contrib.auth.mixins import PermissionRequiredMixin
8+
from django.core.exceptions import PermissionDenied, ValidationError
9+
from django.db.models import F, Case, When, IntegerField
1310
from django.http import HttpResponse
11+
from django.shortcuts import redirect, reverse, get_object_or_404
12+
from django.urls import NoReverseMatch
13+
from django.urls import reverse_lazy
14+
from django.utils import timezone
1415
from django.views.generic import (
1516
View,
1617
FormView,
1718
ListView,
1819
)
19-
from django.shortcuts import redirect, reverse, get_object_or_404
20-
from django.urls import reverse_lazy
2120

21+
from admin.base.forms import GuidForm
2222
from admin.base.utils import change_embargo_date
2323
from admin.base.views import GuidView
24-
from admin.base.forms import GuidForm
25-
from admin.notifications.views import delete_selected_notifications
2624
from admin.nodes.forms import AddSystemTagForm, RegistrationDateForm
27-
28-
from api.share.utils import update_share
25+
from admin.notifications.views import delete_selected_notifications
2926
from api.caching.tasks import update_storage_usage_cache
30-
27+
from api.share.utils import update_share
28+
from framework import status
3129
from osf.exceptions import NodeStateError, RegistrationStuckError
3230
from osf.management.commands.change_node_region import _update_schema_meta
3331
from osf.models import (
@@ -53,9 +51,7 @@
5351
REINDEX_ELASTIC,
5452
)
5553
from osf.utils.permissions import ADMIN, API_CONTRIBUTOR_PERMISSIONS
56-
5754
from scripts.approve_registrations import approve_past_pendings
58-
5955
from website import settings, search
6056
from website.archiver.tasks import force_archive
6157

@@ -149,7 +145,8 @@ def get_context_data(self, **kwargs):
149145
'STORAGE_LIMITS': settings.StorageLimits,
150146
'node': node,
151147
# to edit contributors we should have guid as django prohibits _id usage as it starts with an underscore
152-
'annotated_contributors': node.contributor_set.prefetch_related('user__guids').annotate(guid=F('user__guids___id')),
148+
'annotated_contributors': node.contributor_set.prefetch_related('user__guids').annotate(
149+
guid=F('user__guids___id')),
153150
'children': children,
154151
'permissions': API_CONTRIBUTOR_PERMISSIONS,
155152
'has_update_permission': self.request.user.has_perm('osf.change_node'),
@@ -209,7 +206,9 @@ class NodeRemoveContributorView(NodeMixin, View):
209206
def post(self, request, *args, **kwargs):
210207
node = self.get_object()
211208
user = OSFUser.objects.get(id=self.kwargs.get('user_id'))
212-
if node.has_permission(user, ADMIN) and not node._get_admin_contributors_query(node._contributors.all(), require_active=False).exclude(user=user).exists():
209+
if node.has_permission(user, ADMIN) and not node._get_admin_contributors_query(node._contributors.all(),
210+
require_active=False).exclude(
211+
user=user).exists():
213212
messages.error(self.request, 'Must be at least one admin on this node.')
214213
return redirect(self.get_success_url())
215214

@@ -906,6 +905,7 @@ class ForceArchiveRegistrationsView(NodeMixin, View):
906905
def post(self, request, *args, **kwargs):
907906
# Prevents circular imports that cause admin app to hang at startup
908907
from osf.management.commands.force_archive import verify, DEFAULT_PERMISSIBLE_ADDONS
908+
from osf.models.admin_log_entry import update_admin_log, MANUAL_ARCHIVE_RESTART
909909

910910
registration = self.get_object()
911911
force_archive_params = request.POST
@@ -933,16 +933,31 @@ def post(self, request, *args, **kwargs):
933933
messages.error(request, str(exc))
934934
return redirect(self.get_success_url())
935935
else:
936-
# For actual archiving, skip synchronous verification to avoid 502 timeouts
937-
# Verification will be performed asynchronously in the task
938-
force_archive_task = force_archive.delay(
939-
str(registration._id),
940-
permissible_addons=list(addons),
941-
allow_unconfigured=allow_unconfigured,
942-
skip_collisions=skip_collision,
943-
delete_collisions=delete_collision,
944-
)
945-
messages.success(request, f'Registration archive process has started. Task id: {force_archive_task.id}.')
936+
try:
937+
update_admin_log(
938+
user_id=request.user.id,
939+
object_id=registration.pk,
940+
object_repr=str(registration),
941+
message=f'Manual archive restart initiated for registration {registration._id}',
942+
action_flag=MANUAL_ARCHIVE_RESTART
943+
)
944+
# For actual archiving, skip synchronous verification to avoid 502 timeouts
945+
# Verification will be performed asynchronously in the task
946+
force_archive_task = force_archive.delay(
947+
str(registration._id),
948+
permissible_addons=list(addons),
949+
allow_unconfigured=allow_unconfigured,
950+
skip_collisions=skip_collision,
951+
delete_collisions=delete_collision,
952+
)
953+
messages.success(
954+
request,
955+
f'Registration archive process has started. Task id: {force_archive_task.id}.'
956+
)
957+
except Exception as exc:
958+
messages.error(request,
959+
f'This registration cannot be archived due to {exc.__class__.__name__}: {str(exc)}. '
960+
f'If the problem persists get a developer to fix it.')
946961

947962
return redirect(self.get_success_url())
948963

api/institutions/serializers.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,9 @@ class Meta:
231231
number_of_users = ser.IntegerField(read_only=True)
232232

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

236238

237239
class InstitutionUserMetricsSerializer(JSONAPISerializer):
@@ -289,7 +291,8 @@ def get_contacts(self, obj):
289291
return list(results)
290292

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

294297

295298
class InstitutionSummaryMetricsSerializer(JSONAPISerializer):
@@ -323,7 +326,8 @@ class Meta:
323326
)
324327

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

328332

329333
class InstitutionRelated(JSONAPIRelationshipSerializer):
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import logging
2+
from datetime import timedelta
3+
from django.core.management.base import BaseCommand
4+
from django.utils import timezone
5+
from osf.models import Registration
6+
from osf.models.admin_log_entry import AdminLogEntry, MANUAL_ARCHIVE_RESTART
7+
from website import settings
8+
from scripts.approve_registrations import approve_past_pendings
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
class Command(BaseCommand):
14+
help = 'Process registrations that were manually restarted and may need approval'
15+
16+
def add_arguments(self, parser):
17+
parser.add_argument(
18+
'--dry-run',
19+
action='store_true',
20+
help='Show what would be done without actually doing it',
21+
)
22+
parser.add_argument(
23+
'--hours-back',
24+
type=int,
25+
default=72,
26+
help='How many hours back to look for manual restarts (default: 72)',
27+
)
28+
parser.add_argument(
29+
'--registration-id',
30+
type=str,
31+
help='Process a specific registration ID only',
32+
)
33+
34+
def handle(self, *args, **options):
35+
dry_run = options['dry_run']
36+
hours_back = options['hours_back']
37+
specific_registration = options.get('registration_id')
38+
39+
if dry_run:
40+
self.stdout.write(self.style.WARNING('Running in DRY RUN mode - no changes will be made'))
41+
42+
since = timezone.now() - timedelta(hours=hours_back)
43+
44+
query = AdminLogEntry.objects.filter(
45+
action_flag=MANUAL_ARCHIVE_RESTART,
46+
action_time__gte=since
47+
)
48+
49+
if specific_registration:
50+
try:
51+
reg = Registration.objects.get(_id=specific_registration)
52+
query = query.filter(object_id=reg.pk)
53+
self.stdout.write(f"Processing specific registration: {specific_registration}")
54+
except Registration.DoesNotExist:
55+
self.stdout.write(self.style.ERROR(f"Registration {specific_registration} not found"))
56+
return
57+
58+
manual_restart_logs = query.values_list('object_id', flat=True).distinct()
59+
60+
registrations_to_check = Registration.objects.filter(
61+
pk__in=manual_restart_logs,
62+
)
63+
64+
self.stdout.write(f"Found {registrations_to_check.count()} manually restarted registrations to check")
65+
66+
approvals_ready = []
67+
skipped_registrations = []
68+
69+
for registration in registrations_to_check:
70+
status = self.should_auto_approve(registration)
71+
72+
if status == 'ready':
73+
approval = registration.registration_approval
74+
if approval:
75+
approvals_ready.append(approval)
76+
self.stdout.write(
77+
self.style.SUCCESS(f"✓ Queuing registration {registration._id} for approval")
78+
)
79+
else:
80+
skipped_registrations.append((registration._id, status))
81+
self.stdout.write(
82+
self.style.WARNING(f"⚠ Skipping registration {registration._id}: {status}")
83+
)
84+
85+
if approvals_ready:
86+
if dry_run:
87+
self.stdout.write(
88+
self.style.WARNING(f"DRY RUN: Would approve {len(approvals_ready)} registrations")
89+
)
90+
else:
91+
try:
92+
approve_past_pendings(approvals_ready, dry_run=False)
93+
self.stdout.write(
94+
self.style.SUCCESS(f"✓ Successfully approved {len(approvals_ready)} manually restarted registrations")
95+
)
96+
except Exception as e:
97+
self.stdout.write(
98+
self.style.ERROR(f"✗ Error approving registrations: {e}")
99+
)
100+
else:
101+
self.stdout.write('No registrations ready for approval')
102+
103+
self.stdout.write(f"Total checked: {registrations_to_check.count()}")
104+
self.stdout.write(f"Ready for approval: {len(approvals_ready)}")
105+
self.stdout.write(f"Skipped: {len(skipped_registrations)}")
106+
107+
if skipped_registrations:
108+
self.stdout.write('\nSkipped registrations:')
109+
for reg_id, reason in skipped_registrations:
110+
self.stdout.write(f" - {reg_id}: {reason}")
111+
112+
def should_auto_approve(self, registration):
113+
if registration.is_public:
114+
return 'already public'
115+
116+
if registration.is_registration_approved:
117+
return 'already approved'
118+
119+
if registration.archiving:
120+
return 'still archiving'
121+
122+
archive_job = registration.archive_job
123+
if archive_job and hasattr(archive_job, 'status'):
124+
if archive_job.status not in ['SUCCESS', None]:
125+
return f'archive status: {archive_job.status}'
126+
127+
approval = registration.registration_approval
128+
if not approval:
129+
return 'no approval object'
130+
131+
if approval.is_approved:
132+
return 'approval already approved'
133+
134+
if approval.is_rejected:
135+
return 'approval was rejected'
136+
137+
time_since_initiation = timezone.now() - approval.initiation_date
138+
if time_since_initiation < settings.REGISTRATION_APPROVAL_TIME:
139+
remaining = settings.REGISTRATION_APPROVAL_TIME - time_since_initiation
140+
return f'not ready yet ({remaining} remaining)'
141+
142+
if registration.is_stuck_registration:
143+
return 'registration still stuck'
144+
145+
return 'ready'

osf/models/admin_log_entry.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
DOI_CREATION_FAILED = 80
3838
DOI_UPDATE_FAILED = 81
3939

40+
MANUAL_ARCHIVE_RESTART = 90
41+
4042
def update_admin_log(user_id, object_id, object_repr, message, action_flag=UNKNOWN):
4143
AdminLogEntry.objects.log_action(
4244
user_id=user_id,
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import logging
2+
from framework.celery_tasks import app as celery_app
3+
from django.core.management import call_command
4+
from osf.models import Registration
5+
6+
logger = logging.getLogger(__name__)
7+
8+
9+
@celery_app.task(name='scripts.check_manual_restart_approval')
10+
def check_manual_restart_approval(registration_id):
11+
try:
12+
try:
13+
registration = Registration.objects.get(_id=registration_id)
14+
except Registration.DoesNotExist:
15+
logger.error(f"Registration {registration_id} not found")
16+
return f"Registration {registration_id} not found"
17+
18+
if registration.is_public or registration.is_registration_approved:
19+
return f"Registration {registration_id} already approved/public"
20+
21+
if registration.archiving:
22+
logger.info(f"Registration {registration_id} still archiving, retrying in 10 minutes")
23+
check_manual_restart_approval.apply_async(
24+
args=[registration_id],
25+
countdown=600
26+
)
27+
return f"Registration {registration_id} still archiving, scheduled retry"
28+
29+
logger.info(f"Processing manual restart approval for registration {registration_id}")
30+
31+
call_command(
32+
'process_manual_restart_approvals',
33+
registration_id=registration_id,
34+
dry_run=False,
35+
hours_back=24,
36+
verbosity=1
37+
)
38+
39+
return f"Processed manual restart approval check for registration {registration_id}"
40+
41+
except Exception as e:
42+
logger.error(f"Error processing manual restart approval for {registration_id}: {e}")
43+
raise
44+
45+
46+
@celery_app.task(name='scripts.check_manual_restart_approvals_batch')
47+
def check_manual_restart_approvals_batch(hours_back=24):
48+
try:
49+
logger.info(f"Running batch check for manual restart approvals (last {hours_back} hours)")
50+
51+
call_command(
52+
'process_manual_restart_approvals',
53+
dry_run=False,
54+
hours_back=hours_back,
55+
verbosity=1
56+
)
57+
58+
return f"Completed batch manual restart approval check for last {hours_back} hours"
59+
60+
except Exception as e:
61+
logger.error(f"Error in batch manual restart approval check: {e}")
62+
raise
63+
64+
65+
@celery_app.task(name='scripts.delayed_manual_restart_approval')
66+
def delayed_manual_restart_approval(registration_id, delay_minutes=30):
67+
logger.info(f"Scheduling delayed manual restart approval check for {registration_id} in {delay_minutes} minutes")
68+
69+
check_manual_restart_approval.apply_async(
70+
args=[registration_id],
71+
countdown=delay_minutes * 60
72+
)
73+
74+
return f"Scheduled manual restart approval check for {registration_id} in {delay_minutes} minutes"

0 commit comments

Comments
 (0)