Skip to content

Commit c747e97

Browse files
authored
fix: keep draft-iesg state on expiration. Update action holders. (ietf-tools#8321)
* fix: keep draft-iesg state on expiration. Update action holders * feat: task to repair docs in dead because expiry * fix: restore all to-date flows through update_action_holders * fix: Fetch the System user following more regular conventions * fix: better signal test
1 parent 70ab711 commit c747e97

6 files changed

Lines changed: 186 additions & 60 deletions

File tree

ietf/doc/expire.py

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
# expiry of Internet-Drafts
44

55

6+
import debug # pyflakes:ignore
7+
68
from django.conf import settings
79
from django.utils import timezone
810

@@ -11,12 +13,12 @@
1113

1214
from typing import List, Optional # pyflakes:ignore
1315

16+
from ietf.doc.utils import new_state_change_event, update_action_holders
1417
from ietf.utils import log
1518
from ietf.utils.mail import send_mail
16-
from ietf.doc.models import Document, DocEvent, State, IESG_SUBSTATE_TAGS
19+
from ietf.doc.models import Document, DocEvent, State, StateDocEvent
1720
from ietf.person.models import Person
1821
from ietf.meeting.models import Meeting
19-
from ietf.doc.utils import add_state_change_event, update_action_holders
2022
from ietf.mailtrigger.utils import gather_address_lists
2123
from ietf.utils.timezone import date_today, datetime_today, DEADLINE_TZINFO
2224

@@ -161,24 +163,11 @@ def expire_draft(doc):
161163

162164
events = []
163165

164-
# change the state
165-
if doc.latest_event(type='started_iesg_process'):
166-
new_state = State.objects.get(used=True, type="draft-iesg", slug="dead")
167-
prev_state = doc.get_state(new_state.type_id)
168-
prev_tags = doc.tags.filter(slug__in=IESG_SUBSTATE_TAGS)
169-
if new_state != prev_state:
170-
doc.set_state(new_state)
171-
doc.tags.remove(*prev_tags)
172-
e = add_state_change_event(doc, system, prev_state, new_state, prev_tags=prev_tags, new_tags=[])
173-
if e:
174-
events.append(e)
175-
e = update_action_holders(doc, prev_state, new_state, prev_tags=prev_tags, new_tags=[])
176-
if e:
177-
events.append(e)
178-
179166
events.append(DocEvent.objects.create(doc=doc, rev=doc.rev, by=system, type="expired_document", desc="Document has expired"))
180167

168+
prev_draft_state=doc.get_state("draft")
181169
doc.set_state(State.objects.get(used=True, type="draft", slug="expired"))
170+
events.append(update_action_holders(doc, prev_draft_state, doc.get_state("draft"),[],[]))
182171
doc.save_with_history(events)
183172

184173
def clean_up_draft_files():
@@ -238,3 +227,42 @@ def move_file_to(subdir):
238227
except Document.DoesNotExist:
239228
# All uses of this past 2014 seem related to major system failures.
240229
move_file_to("unknown_ids")
230+
231+
232+
def repair_dead_on_expire():
233+
by = Person.objects.get(name="(System)")
234+
id_exists = State.objects.get(type="draft-iesg", slug="idexists")
235+
dead = State.objects.get(type="draft-iesg", slug="dead")
236+
dead_drafts = Document.objects.filter(
237+
states__type="draft-iesg", states__slug="dead", type_id="draft"
238+
)
239+
for d in dead_drafts:
240+
dead_event = d.latest_event(
241+
StateDocEvent, state_type="draft-iesg", state__slug="dead"
242+
)
243+
if dead_event is not None:
244+
if d.docevent_set.filter(type="expired_document").exists():
245+
closest_expiry = min(
246+
[
247+
abs(e.time - dead_event.time)
248+
for e in d.docevent_set.filter(type="expired_document")
249+
]
250+
)
251+
if closest_expiry.total_seconds() < 60:
252+
d.set_state(id_exists)
253+
events = []
254+
e = DocEvent(
255+
doc=d,
256+
rev=d.rev,
257+
type="added_comment",
258+
by=by,
259+
desc="IESG Dead state was set due only to document expiry - changing IESG state to ID-Exists",
260+
)
261+
e.skip_community_list_notification = True
262+
e.save()
263+
events.append(e)
264+
e = new_state_change_event(d, by, dead, id_exists)
265+
e.skip_community_list_notification = True
266+
e.save()
267+
events.append(e)
268+
d.save_with_history(events)

ietf/doc/tasks.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
in_draft_expire_freeze,
1919
get_expired_drafts,
2020
expirable_drafts,
21+
repair_dead_on_expire,
2122
send_expire_notice_for_draft,
2223
expire_draft,
2324
clean_up_draft_files,
@@ -61,6 +62,11 @@ def expire_ids_task():
6162
raise
6263

6364

65+
@shared_task
66+
def repair_dead_on_expire_task():
67+
repair_dead_on_expire()
68+
69+
6470
@shared_task
6571
def notify_expirations_task(notify_days=14):
6672
for doc in get_soon_to_expire_drafts(notify_days):

ietf/doc/tests_draft.py

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@
1919

2020
import debug # pyflakes:ignore
2121

22-
from ietf.doc.expire import get_expired_drafts, send_expire_notice_for_draft, expire_draft
23-
from ietf.doc.factories import EditorialDraftFactory, IndividualDraftFactory, WgDraftFactory, RgDraftFactory, DocEventFactory
22+
from ietf.doc.expire import expirable_drafts, get_expired_drafts, repair_dead_on_expire, send_expire_notice_for_draft, expire_draft
23+
from ietf.doc.factories import EditorialDraftFactory, IndividualDraftFactory, StateDocEventFactory, WgDraftFactory, RgDraftFactory, DocEventFactory
2424
from ietf.doc.models import ( Document, DocReminder, DocEvent,
25-
ConsensusDocEvent, LastCallDocEvent, RelatedDocument, State, TelechatDocEvent,
25+
ConsensusDocEvent, LastCallDocEvent, RelatedDocument, State, StateDocEvent, TelechatDocEvent,
2626
WriteupDocEvent, DocRelationshipName, IanaExpertDocEvent )
2727
from ietf.doc.utils import get_tags_for_stream_id, create_ballot_if_not_open
2828
from ietf.doc.views_draft import AdoptDraftForm
@@ -36,7 +36,7 @@
3636
from ietf.utils.test_utils import login_testing_unauthorized
3737
from ietf.utils.mail import outbox, empty_outbox, get_payload_text
3838
from ietf.utils.test_utils import TestCase
39-
from ietf.utils.timezone import date_today, datetime_from_date, DEADLINE_TZINFO
39+
from ietf.utils.timezone import date_today, datetime_today, datetime_from_date, DEADLINE_TZINFO
4040

4141

4242
class ChangeStateTests(TestCase):
@@ -763,13 +763,16 @@ def test_expire_drafts(self):
763763
txt = "%s-%s.txt" % (draft.name, draft.rev)
764764
self.write_draft_file(txt, 5000)
765765

766+
self.assertFalse(expirable_drafts(Document.objects.filter(pk=draft.pk)).exists())
767+
draft.set_state(State.objects.get(used=True, type="draft-iesg", slug="idexists"))
768+
self.assertTrue(expirable_drafts(Document.objects.filter(pk=draft.pk)).exists())
766769
expire_draft(draft)
767770

768771
draft = Document.objects.get(name=draft.name)
769772
self.assertEqual(draft.get_state_slug(), "expired")
770-
self.assertEqual(draft.get_state_slug("draft-iesg"), "dead")
773+
self.assertEqual(draft.get_state_slug("draft-iesg"), "idexists")
771774
self.assertTrue(draft.latest_event(type="expired_document"))
772-
self.assertCountEqual(draft.action_holders.all(), [])
775+
self.assertEqual(draft.action_holders.count(), 0)
773776
self.assertIn('Removed all action holders', draft.latest_event(type='changed_action_holders').desc)
774777
self.assertTrue(not os.path.exists(os.path.join(settings.INTERNET_DRAFT_PATH, txt)))
775778
self.assertTrue(os.path.exists(os.path.join(settings.INTERNET_DRAFT_ARCHIVE_DIR, txt)))
@@ -842,6 +845,77 @@ def test_clean_up_draft_files(self):
842845
self.assertTrue(not os.path.exists(os.path.join(settings.INTERNET_DRAFT_PATH, txt)))
843846
self.assertTrue(os.path.exists(os.path.join(settings.INTERNET_DRAFT_ARCHIVE_DIR, txt)))
844847

848+
@mock.patch("ietf.community.signals.notify_of_event")
849+
def test_repair_dead_on_expire(self, mock_notify):
850+
851+
# Create a draft in iesg idexists - ensure it doesn't get new docevents.
852+
# Create a draft in iesg dead with no expires within the window - ensure it doesn't get new docevents and its state doesn't change.
853+
# Create a draft in iesg dead with an expiry in the window - ensure it gets the right doc events, iesg state changes, draft state doesn't change.
854+
last_year = datetime_today() - datetime.timedelta(days=365)
855+
856+
not_dead = WgDraftFactory(name="draft-not-dead")
857+
not_dead_event_count = not_dead.docevent_set.count()
858+
859+
dead_not_from_expires = WgDraftFactory(name="draft-dead-not-from-expiring")
860+
dead_not_from_expires.set_state(
861+
State.objects.get(type="draft-iesg", slug="dead")
862+
)
863+
StateDocEventFactory(
864+
doc=dead_not_from_expires, state=("draft-iesg", "dead"), time=last_year
865+
)
866+
DocEventFactory(
867+
doc=dead_not_from_expires,
868+
type="expired_document",
869+
time=last_year + datetime.timedelta(days=1),
870+
)
871+
dead_not_from_expires_event_count = dead_not_from_expires.docevent_set.count()
872+
873+
dead_from_expires = []
874+
dead_from_expires_event_count = dict()
875+
for delta in [-5, 5]:
876+
d = WgDraftFactory(
877+
name=f"draft-dead-from-expiring-just-{'before' if delta<0 else 'after'}"
878+
)
879+
d.set_state(State.objects.get(type="draft-iesg", slug="dead"))
880+
StateDocEventFactory(doc=d, state=("draft-iesg", "dead"), time=last_year)
881+
DocEventFactory(
882+
doc=d,
883+
type="expired_document",
884+
time=last_year + datetime.timedelta(seconds=delta),
885+
)
886+
dead_from_expires.append(d)
887+
dead_from_expires_event_count[d] = d.docevent_set.count()
888+
889+
notified_during_factory_work = mock_notify.call_count
890+
for call_args in mock_notify.call_args_list:
891+
e = call_args.args[0]
892+
self.assertTrue(isinstance(e,DocEvent))
893+
self.assertFalse(hasattr(e,"skip_community_list_notification"))
894+
895+
repair_dead_on_expire()
896+
897+
self.assertEqual(not_dead.docevent_set.count(), not_dead_event_count)
898+
self.assertEqual(
899+
dead_not_from_expires.docevent_set.count(),
900+
dead_not_from_expires_event_count,
901+
)
902+
for d in dead_from_expires:
903+
self.assertEqual(
904+
d.docevent_set.count(), dead_from_expires_event_count[d] + 2
905+
)
906+
self.assertIn(
907+
"due only to document expiry", d.latest_event(type="added_comment").desc
908+
)
909+
self.assertEqual(
910+
d.latest_event(StateDocEvent).desc,
911+
"IESG state changed to <b>I-D Exists</b> from Dead",
912+
)
913+
self.assertEqual(mock_notify.call_count, 4+notified_during_factory_work)
914+
for call_args in mock_notify.call_args_list[-4:]:
915+
e = call_args.args[0]
916+
self.assertTrue(isinstance(e,DocEvent))
917+
self.assertTrue(hasattr(e,"skip_community_list_notification"))
918+
self.assertTrue(e.skip_community_list_notification)
845919

846920
class ExpireLastCallTests(TestCase):
847921
def test_expire_last_call(self):

ietf/doc/tests_tasks.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
# Copyright The IETF Trust 2024, All Rights Reserved
2+
3+
import debug # pyflakes:ignore
24
import datetime
35
import mock
46

@@ -19,6 +21,7 @@
1921
generate_idnits2_rfcs_obsoleted_task,
2022
generate_idnits2_rfc_status_task,
2123
notify_expirations_task,
24+
repair_dead_on_expire_task,
2225
)
2326

2427
class TaskTests(TestCase):
@@ -96,6 +99,10 @@ def test_expire_last_calls_task(self, mock_get_expired, mock_expire):
9699
self.assertEqual(mock_expire.call_args_list[1], mock.call(docs[1]))
97100
self.assertEqual(mock_expire.call_args_list[2], mock.call(docs[2]))
98101

102+
@mock.patch("ietf.doc.tasks.repair_dead_on_expire")
103+
def test_repair_dead_on_expire_task(self, mock_repair):
104+
repair_dead_on_expire_task()
105+
self.assertEqual(mock_repair.call_count, 1)
99106

100107
class Idnits2SupportTests(TestCase):
101108
settings_temp_path_overrides = TestCase.settings_temp_path_overrides + ['DERIVED_DIR']

ietf/doc/utils.py

Lines changed: 46 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -491,8 +491,9 @@ def update_action_holders(doc, prev_state=None, new_state=None, prev_tags=None,
491491
492492
Returns an event describing the change which should be passed to doc.save_with_history()
493493
494-
Only cares about draft-iesg state changes. Places where other state types are updated
495-
may not call this method. If you add rules for updating action holders on other state
494+
Only cares about draft-iesg state changes and draft expiration.
495+
Places where other state types are updated may not call this method.
496+
If you add rules for updating action holders on other state
496497
types, be sure this is called in the places that change that state.
497498
"""
498499
# Should not call this with different state types
@@ -511,41 +512,50 @@ def update_action_holders(doc, prev_state=None, new_state=None, prev_tags=None,
511512

512513
# Remember original list of action holders to later check if it changed
513514
prev_set = list(doc.action_holders.all())
514-
515-
# Update the action holders. To get this right for people with more
516-
# than one relationship to the document, do removals first, then adds.
517-
# Remove outdated action holders
518-
iesg_state_changed = (prev_state != new_state) and (getattr(new_state, "type_id", None) == "draft-iesg")
519-
if iesg_state_changed:
520-
# Clear the action_holders list on a state change. This will reset the age of any that get added back.
515+
516+
if new_state and new_state.type_id=="draft" and new_state.slug=="expired":
521517
doc.action_holders.clear()
522-
if tags.removed("need-rev"):
523-
# Removed the 'need-rev' tag - drop authors from the action holders list
524-
DocumentActionHolder.objects.filter(document=doc, person__in=doc.authors()).delete()
525-
elif tags.added("need-rev"):
526-
# Remove the AD if we're asking for a new revision
527-
DocumentActionHolder.objects.filter(document=doc, person=doc.ad).delete()
528-
529-
# Add new action holders
530-
if doc.ad:
531-
# AD is an action holder unless specified otherwise for the new state
532-
if iesg_state_changed and new_state.slug not in DocumentActionHolder.CLEAR_ACTION_HOLDERS_STATES:
533-
doc.action_holders.add(doc.ad)
534-
# If AD follow-up is needed, make sure they are an action holder
535-
if tags.added("ad-f-up"):
536-
doc.action_holders.add(doc.ad)
537-
# Authors get the action if a revision is needed
538-
if tags.added("need-rev"):
539-
for auth in doc.authors():
540-
doc.action_holders.add(auth)
541-
542-
# Now create an event if we changed the set
543-
return add_action_holder_change_event(
544-
doc,
545-
Person.objects.get(name='(System)'),
546-
prev_set,
547-
reason='IESG state changed',
548-
)
518+
return add_action_holder_change_event(
519+
doc,
520+
Person.objects.get(name='(System)'),
521+
prev_set,
522+
reason='draft expired',
523+
)
524+
else:
525+
# Update the action holders. To get this right for people with more
526+
# than one relationship to the document, do removals first, then adds.
527+
# Remove outdated action holders
528+
iesg_state_changed = (prev_state != new_state) and (getattr(new_state, "type_id", None) == "draft-iesg")
529+
if iesg_state_changed:
530+
# Clear the action_holders list on a state change. This will reset the age of any that get added back.
531+
doc.action_holders.clear()
532+
if tags.removed("need-rev"):
533+
# Removed the 'need-rev' tag - drop authors from the action holders list
534+
DocumentActionHolder.objects.filter(document=doc, person__in=doc.authors()).delete()
535+
elif tags.added("need-rev"):
536+
# Remove the AD if we're asking for a new revision
537+
DocumentActionHolder.objects.filter(document=doc, person=doc.ad).delete()
538+
539+
# Add new action holders
540+
if doc.ad:
541+
# AD is an action holder unless specified otherwise for the new state
542+
if iesg_state_changed and new_state.slug not in DocumentActionHolder.CLEAR_ACTION_HOLDERS_STATES:
543+
doc.action_holders.add(doc.ad)
544+
# If AD follow-up is needed, make sure they are an action holder
545+
if tags.added("ad-f-up"):
546+
doc.action_holders.add(doc.ad)
547+
# Authors get the action if a revision is needed
548+
if tags.added("need-rev"):
549+
for auth in doc.authors():
550+
doc.action_holders.add(auth)
551+
552+
# Now create an event if we changed the set
553+
return add_action_holder_change_event(
554+
doc,
555+
Person.objects.get(name='(System)'),
556+
prev_set,
557+
reason='IESG state changed',
558+
)
549559

550560

551561
def update_documentauthors(doc, new_docauthors, by=None, basis=None):

ietf/doc/views_draft.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ def change_state(request, name):
9595
and logging the change as a comment."""
9696
doc = get_object_or_404(Document, name=name)
9797

98-
if (not doc.latest_event(type="started_iesg_process")) or doc.get_state_slug() == "expired":
98+
# Steer ADs towards "Begin IESG Processing"
99+
if doc.get_state_slug("draft-iesg")=="idexists" and not has_role(request.user,"Secretariat"):
99100
raise Http404
100101

101102
login = request.user.person

0 commit comments

Comments
 (0)