Skip to content

Commit e1f0917

Browse files
committed
Summary: Add new document saving API, Document.save_with_history(events).
The new API requires at least one event and will automatically save a snapshot of the document and related state. Document.save() will now throw an exception if called directly, as the new API is intended to ensure that documents are saved with both an appropriate snapsnot and relevant history log, both of which are easily defeated by just calling .save() directly. To simplify things, the snapshot is generated after the changes to a document have been made (in anticipation of coming changes), instead of before as was usual. While revising the existing code to work with this API, a couple of missing events was discovered: - In draft expiry, a "Document has expired" event was only generated in case an IESG process had started on the document - now it's always generated, as the document changes its state in any case - Synchronization updates like title and abstract amendmends from the RFC Editor were silently (except for RFC publication) applied and not accompanied by a descriptive event - they now are - do_replace in the Secretariat tools now adds an event - Proceedings post_process in the Secretariat tools now adds an event - do_withdraw in the Secretariat tools now adds an event A migration is needed for snapshotting all documents, takes a while to run. It turns out that a single document had a bad foreign key so the migration fixes that too. - Legacy-Id: 10101
1 parent 99a9cb5 commit e1f0917

36 files changed

Lines changed: 921 additions & 745 deletions

ietf/bin/rfc-editor-index-updates

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env python
22

3-
import os, sys, re, json, datetime
3+
import os, sys, datetime
44
import syslog
55
import traceback
66

@@ -29,22 +29,28 @@ if options.skip_date:
2929
skip_date = datetime.datetime.strptime(options.skip_date, "%Y-%m-%d").date()
3030

3131
from ietf.utils.pipe import pipe
32-
from ietf.sync.rfceditor import *
3332
from ietf.doc.utils import rebuild_reference_relations
33+
import ietf.sync.rfceditor
3434

3535
syslog.syslog("Updating document metadata from RFC index from %s" % settings.RFC_EDITOR_QUEUE_URL)
3636

37-
response = fetch_index_xml(settings.RFC_EDITOR_INDEX_URL)
38-
data = parse_index(response)
37+
response = ietf.sync.rfceditor.fetch_index_xml(settings.RFC_EDITOR_INDEX_URL)
38+
data = ietf.sync.rfceditor.parse_index(response)
3939

40-
if len(data) < MIN_INDEX_RESULTS:
40+
if len(data) < ietf.sync.rfceditor.MIN_INDEX_RESULTS:
4141
syslog.syslog("Not enough results, only %s" % len(data))
4242
sys.exit(1)
4343

44-
changed, new_rfcs = update_docs_from_rfc_index(data, skip_older_than_date=skip_date)
44+
new_rfcs = []
45+
for changes, doc, rfc_published in ietf.sync.rfceditor.update_docs_from_rfc_index(data, skip_older_than_date=skip_date):
46+
if rfc_published:
47+
new_rfcs.append(doc)
4548

46-
for c in changed:
47-
syslog.syslog(c)
49+
for c in changes:
50+
syslog.syslog("%s: %s" % (doc.name, c))
51+
print "%s: %s" % (doc.name, c)
52+
53+
sys.exit(0)
4854

4955
# This can be called while processing a notifying POST from the RFC Editor
5056
# Spawn a child to sync the rfcs and calculate new reference relationships

ietf/doc/expire.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pathlib import Path
77

88
from ietf.utils.mail import send_mail
9-
from ietf.doc.models import Document, DocEvent, State, save_document_in_history, IESG_SUBSTATE_TAGS
9+
from ietf.doc.models import Document, DocEvent, State, IESG_SUBSTATE_TAGS
1010
from ietf.person.models import Person, Email
1111
from ietf.meeting.models import Meeting
1212
from ietf.doc.utils import add_state_change_event
@@ -131,8 +131,9 @@ def expire_draft(doc):
131131

132132
system = Person.objects.get(name="(System)")
133133

134+
events = []
135+
134136
# change the state
135-
save_document_in_history(doc)
136137
if doc.latest_event(type='started_iesg_process'):
137138
new_state = State.objects.get(used=True, type="draft-iesg", slug="dead")
138139
prev_state = doc.get_state(new_state.type_id)
@@ -141,15 +142,13 @@ def expire_draft(doc):
141142
doc.set_state(new_state)
142143
doc.tags.remove(*prev_tags)
143144
e = add_state_change_event(doc, system, prev_state, new_state, prev_tags=prev_tags, new_tags=[])
145+
if e:
146+
events.append(e)
144147

145-
e = DocEvent(doc=doc, by=system)
146-
e.type = "expired_document"
147-
e.desc = "Document has expired"
148-
e.save()
148+
events.append(DocEvent.objects.create(doc=doc, by=system, type="expired_document", desc="Document has expired"))
149149

150150
doc.set_state(State.objects.get(used=True, type="draft", slug="expired"))
151-
doc.time = datetime.datetime.now()
152-
doc.save()
151+
doc.save_with_history(events)
153152

154153
def clean_up_draft_files():
155154
"""Move unidentified and old files out of the Internet Draft directory."""

ietf/doc/lastcall.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from django.db.models import Q
66

77
from ietf.doc.models import Document, State, DocEvent, LastCallDocEvent, WriteupDocEvent
8-
from ietf.doc.models import save_document_in_history
98
from ietf.doc.models import IESG_SUBSTATE_TAGS
109
from ietf.person.models import Person
1110
from ietf.doc.utils import add_state_change_event
@@ -50,8 +49,6 @@ def expire_last_call(doc):
5049
else:
5150
raise ValueError("Unexpected document type to expire_last_call(): %s" % doc.type)
5251

53-
save_document_in_history(doc)
54-
5552
prev_state = doc.get_state(new_state.type_id)
5653
doc.set_state(new_state)
5754

@@ -60,8 +57,7 @@ def expire_last_call(doc):
6057

6158
system = Person.objects.get(name="(System)")
6259
e = add_state_change_event(doc, system, prev_state, new_state, prev_tags=prev_tags, new_tags=[])
63-
64-
doc.time = (e and e.time) or datetime.datetime.now()
65-
doc.save()
60+
if e:
61+
doc.save_with_history([e])
6662

6763
email_last_call_expired(doc)

ietf/doc/mails.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,20 @@ def email_ad(request, doc, ad, changed_by, text, subject=None):
8787
doc=doc,
8888
url=settings.IDTRACKER_BASE_URL + doc.get_absolute_url()))
8989

90+
def email_update_telechat(request, doc, text):
91+
to = set(['iesg@ietf.org','iesg-secretary@ietf.org'])
92+
to.update(set([x.strip() for x in doc.notify.replace(';', ',').split(',')]))
93+
94+
if not to:
95+
return
96+
97+
text = strip_tags(text)
98+
send_mail(request, list(to), None,
99+
"Telechat update notice: %s" % doc.file_tag(),
100+
"doc/mail/update_telechat.txt",
101+
dict(text=text,
102+
url=settings.IDTRACKER_BASE_URL + doc.get_absolute_url()))
103+
90104

91105
def generate_ballot_writeup(request, doc):
92106
e = doc.latest_event(type="iana_review")
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# -*- coding: utf-8 -*-
2+
from __future__ import unicode_literals
3+
4+
from django.db import migrations
5+
6+
def fix_buggy_author_foreignkey(apps, schema_editor):
7+
DocumentAuthor = apps.get_model("doc", "DocumentAuthor")
8+
# apparently, we have a buggy key in the DB, fix it
9+
DocumentAuthor.objects.filter(author="[<Email: d3e3e3@gmail.com>]").update(author="d3e3e3@gmail.com")
10+
11+
def save_all_documents_in_history(apps, schema_editor):
12+
State = apps.get_model("doc", "State")
13+
Document = apps.get_model("doc", "Document")
14+
DocHistory = apps.get_model("doc", "DocHistory")
15+
RelatedDocument = apps.get_model("doc", "RelatedDocument")
16+
RelatedDocHistory = apps.get_model("doc", "RelatedDocHistory")
17+
DocumentAuthor = apps.get_model("doc", "DocumentAuthor")
18+
DocHistoryAuthor = apps.get_model("doc", "DocHistoryAuthor")
19+
20+
def canonical_name(self):
21+
name = self.name
22+
state = State.objects.filter(document=self, type_id=self.type_id).first()
23+
if self.type_id == "draft" and state.slug == "rfc":
24+
a = self.docalias_set.filter(name__startswith="rfc")
25+
if a:
26+
name = a[0].name
27+
elif self.type_id == "charter":
28+
return charter_name_for_group(self.chartered_group)
29+
return name
30+
31+
def charter_name_for_group(group):
32+
if group.type_id == "rg":
33+
top_org = "irtf"
34+
else:
35+
top_org = "ietf"
36+
37+
return "charter-%s-%s" % (top_org, group.acronym)
38+
39+
def save_document_in_history(doc):
40+
"""Save a snapshot of document and related objects in the database."""
41+
def get_model_fields_as_dict(obj):
42+
return dict((field.name, getattr(obj, field.name))
43+
for field in obj._meta.fields
44+
if field is not obj._meta.pk)
45+
46+
# copy fields
47+
fields = get_model_fields_as_dict(doc)
48+
fields["doc"] = doc
49+
fields["name"] = canonical_name(doc)
50+
51+
dochist = DocHistory(**fields)
52+
dochist.save()
53+
54+
# copy many to many
55+
for field in doc._meta.many_to_many:
56+
if field.rel.through and field.rel.through._meta.auto_created:
57+
setattr(dochist, field.name, getattr(doc, field.name).all())
58+
59+
# copy remaining tricky many to many
60+
def transfer_fields(obj, HistModel):
61+
mfields = get_model_fields_as_dict(item)
62+
# map doc -> dochist
63+
for k, v in mfields.iteritems():
64+
if v == doc:
65+
mfields[k] = dochist
66+
HistModel.objects.create(**mfields)
67+
68+
for item in RelatedDocument.objects.filter(source=doc):
69+
transfer_fields(item, RelatedDocHistory)
70+
71+
for item in DocumentAuthor.objects.filter(document=doc):
72+
transfer_fields(item, DocHistoryAuthor)
73+
74+
return dochist
75+
76+
from django.conf import settings
77+
settings.DEBUG = False # prevent out-of-memory problems
78+
79+
for d in Document.objects.iterator():
80+
save_document_in_history(d)
81+
82+
class Migration(migrations.Migration):
83+
84+
dependencies = [
85+
('doc', '0005_auto_20150721_0230'),
86+
]
87+
88+
operations = [
89+
migrations.RunPython(fix_buggy_author_foreignkey),
90+
migrations.RunPython(save_all_documents_in_history)
91+
]

ietf/doc/models.py

Lines changed: 23 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
from ietf.person.models import Email, Person
1919
from ietf.utils.admin import admin_link
2020

21-
2221
class StateType(models.Model):
2322
slug = models.CharField(primary_key=True, max_length=30) # draft, draft-iesg, charter, ...
2423
label = models.CharField(max_length=255, help_text="Label that should be used (e.g. in admin) for state drop-down for this type of state") # State, IESG state, WG state, ...
@@ -433,6 +432,27 @@ def display_name(self):
433432
name = name.upper()
434433
return name
435434

435+
def save_with_history(self, events):
436+
"""Save document and put a snapshot in the history models where they
437+
can be retrieved later. You must pass in at least one event
438+
with a description of what happened."""
439+
440+
assert events, "You must always add at least one event to describe the changes in the history log"
441+
self.time = max(self.time, events[0].time)
442+
443+
self._has_an_event_so_saving_is_allowed = True
444+
self.save()
445+
del self._has_an_event_so_saving_is_allowed
446+
447+
from ietf.doc.utils import save_document_in_history
448+
save_document_in_history(self)
449+
450+
def save(self, *args, **kwargs):
451+
# if there's no primary key yet, we can allow the save to go
452+
# through to break the cycle between the document and any
453+
# events
454+
assert kwargs.get("force_insert", False) or getattr(self, "_has_an_event_so_saving_is_allowed", None), "Use .save_with_history to save documents"
455+
super(Document, self).save(*args, **kwargs)
436456

437457
def telechat_date(self, e=None):
438458
if not e:
@@ -570,50 +590,6 @@ class Meta:
570590
verbose_name = "document history"
571591
verbose_name_plural = "document histories"
572592

573-
def save_document_in_history(doc):
574-
"""This should be called before saving changes to a Document instance,
575-
so that the DocHistory entries contain all previous states, while
576-
the Group entry contain the current state. XXX TODO: Call this
577-
directly from Document.save(), and add event listeners for save()
578-
on related objects so we can save as needed when they change, too.
579-
"""
580-
def get_model_fields_as_dict(obj):
581-
return dict((field.name, getattr(obj, field.name))
582-
for field in obj._meta.fields
583-
if field is not obj._meta.pk)
584-
585-
# copy fields
586-
fields = get_model_fields_as_dict(doc)
587-
fields["doc"] = doc
588-
fields["name"] = doc.canonical_name()
589-
590-
dochist = DocHistory(**fields)
591-
dochist.save()
592-
593-
# copy many to many
594-
for field in doc._meta.many_to_many:
595-
if field.rel.through and field.rel.through._meta.auto_created:
596-
setattr(dochist, field.name, getattr(doc, field.name).all())
597-
598-
# copy remaining tricky many to many
599-
def transfer_fields(obj, HistModel):
600-
mfields = get_model_fields_as_dict(item)
601-
# map doc -> dochist
602-
for k, v in mfields.iteritems():
603-
if v == doc:
604-
mfields[k] = dochist
605-
HistModel.objects.create(**mfields)
606-
607-
for item in RelatedDocument.objects.filter(source=doc):
608-
transfer_fields(item, RelatedDocHistory)
609-
610-
for item in DocumentAuthor.objects.filter(document=doc):
611-
transfer_fields(item, DocHistoryAuthor)
612-
613-
return dochist
614-
615-
616-
617593
class DocAlias(models.Model):
618594
"""This is used for documents that may appear under multiple names,
619595
and in particular for RFCs, which for continuity still keep the
@@ -695,7 +671,8 @@ class DocReminder(models.Model):
695671

696672
# RFC Editor
697673
("rfc_editor_received_announcement", "Announcement was received by RFC Editor"),
698-
("requested_publication", "Publication at RFC Editor requested")
674+
("requested_publication", "Publication at RFC Editor requested"),
675+
("sync_from_rfc_editor", "Received updated information from RFC Editor"),
699676
]
700677

701678
class DocEvent(models.Model):

ietf/doc/tests.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@
1717
import debug # pyflakes:ignore
1818

1919
from ietf.doc.models import ( Document, DocAlias, DocRelationshipName, RelatedDocument, State,
20-
DocEvent, BallotPositionDocEvent, LastCallDocEvent, WriteupDocEvent, NewRevisionDocEvent,
21-
save_document_in_history )
20+
DocEvent, BallotPositionDocEvent, LastCallDocEvent, WriteupDocEvent, NewRevisionDocEvent )
2221
from ietf.group.models import Group
2322
from ietf.meeting.models import Meeting, Session, SessionPresentation
2423
from ietf.name.models import SessionStatusName
@@ -432,9 +431,8 @@ def test_document_draft(self):
432431
# draft published as RFC
433432
draft.set_state(State.objects.get(type="draft", slug="rfc"))
434433
draft.std_level_id = "bcp"
435-
draft.save()
434+
draft.save_with_history([DocEvent.objects.create(doc=draft, type="published_rfc", by=Person.objects.get(name="(System)"))])
436435

437-
DocEvent.objects.create(doc=draft, type="published_rfc", by=Person.objects.get(name="(System)"))
438436

439437
rfc_alias = DocAlias.objects.create(name="rfc123456", document=draft)
440438
bcp_alias = DocAlias.objects.create(name="bcp123456", document=draft)
@@ -480,9 +478,10 @@ def test_document_primary_and_history_views(self):
480478
]:
481479
doc = Document.objects.get(name=docname)
482480
# give it some history
483-
save_document_in_history(doc)
481+
doc.save_with_history([DocEvent(doc=doc)])
482+
484483
doc.rev="01"
485-
doc.save()
484+
doc.save_with_history([DocEvent(doc=doc)])
486485

487486
r = self.client.get(urlreverse("doc_view", kwargs=dict(name=doc.name)))
488487
self.assertEqual(r.status_code, 200)
@@ -539,7 +538,8 @@ def test_document_ballot(self):
539538
doc = make_test_data()
540539
ballot = doc.active_ballot()
541540

542-
save_document_in_history(doc)
541+
# make sure we have some history
542+
doc.save_with_history([DocEvent.objects.create(doc=doc, type="added_comment", by=Person.objects.get(user__username="secretary"), desc="Test")])
543543

544544
pos = BallotPositionDocEvent.objects.create(
545545
doc=doc,
@@ -567,9 +567,8 @@ def test_document_ballot(self):
567567
# Now simulate a new revision and make sure positions on older revisions are marked as such
568568
oldrev = doc.rev
569569
e = NewRevisionDocEvent.objects.create(doc=doc,rev='%02d'%(int(doc.rev)+1),type='new_revision',by=Person.objects.get(name="(System)"))
570-
save_document_in_history(doc)
571570
doc.rev = e.rev
572-
doc.save()
571+
doc.save_with_history([e])
573572
r = self.client.get(urlreverse("ietf.doc.views_doc.document_ballot", kwargs=dict(name=doc.name)))
574573
self.assertEqual(r.status_code, 200)
575574
self.assertTrue( '(%s for -%s)' % (pos.comment_time.strftime('%Y-%m-%d'), oldrev) in r.content)

0 commit comments

Comments
 (0)