276 lines
10 KiB
Python
276 lines
10 KiB
Python
# Copyright The IETF Trust 2010-2020, All Rights Reserved
|
|
# -*- coding: utf-8 -*-
|
|
# expiry of Internet-Drafts
|
|
|
|
|
|
import debug # pyflakes:ignore
|
|
|
|
from django.conf import settings
|
|
from django.utils import timezone
|
|
|
|
import datetime, os, shutil, glob, re
|
|
from pathlib import Path
|
|
|
|
from typing import List, Optional # pyflakes:ignore
|
|
|
|
from ietf.doc.utils import new_state_change_event, update_action_holders
|
|
from ietf.utils import log
|
|
from ietf.utils.mail import send_mail
|
|
from ietf.doc.models import Document, DocEvent, State, StateDocEvent
|
|
from ietf.person.models import Person
|
|
from ietf.meeting.models import Meeting
|
|
from ietf.mailtrigger.utils import gather_address_lists
|
|
from ietf.utils.timezone import date_today, datetime_today, DEADLINE_TZINFO
|
|
|
|
|
|
nonexpirable_states: Optional[List[State]] = None
|
|
|
|
def expirable_drafts(queryset=None):
|
|
"""Return a queryset with expirable drafts."""
|
|
global nonexpirable_states
|
|
|
|
# the general rule is that each active draft is expirable, unless
|
|
# it's in a state where we shouldn't touch it
|
|
if not queryset:
|
|
queryset = Document.objects.all()
|
|
|
|
# Populate this first time through (but after django has been set up)
|
|
if nonexpirable_states is None:
|
|
# all IESG states except I-D Exists and Dead block expiry
|
|
nonexpirable_states = list(State.objects.filter(used=True, type="draft-iesg").exclude(slug__in=("idexists", "dead")))
|
|
# sent to RFC Editor and RFC Published block expiry (the latter
|
|
# shouldn't be possible for an active draft, though)
|
|
nonexpirable_states += list(State.objects.filter(used=True, type__in=("draft-stream-iab", "draft-stream-irtf", "draft-stream-ise"), slug__in=("rfc-edit", "pub")))
|
|
# other IRTF states that block expiration
|
|
nonexpirable_states += list(State.objects.filter(used=True, type_id="draft-stream-irtf", slug__in=("irsgpoll", "iesg-rev",)))
|
|
|
|
return queryset.filter(
|
|
states__type="draft", states__slug="active"
|
|
).exclude(
|
|
expires=None
|
|
).exclude(
|
|
states__in=nonexpirable_states
|
|
).exclude(
|
|
tags="rfc-rev" # under review by the RFC Editor blocks expiry
|
|
).distinct()
|
|
|
|
|
|
def get_soon_to_expire_drafts(days_of_warning):
|
|
start_date = datetime_today(DEADLINE_TZINFO) - datetime.timedelta(1)
|
|
end_date = start_date + datetime.timedelta(days_of_warning)
|
|
|
|
return expirable_drafts().filter(expires__gte=start_date, expires__lt=end_date)
|
|
|
|
def get_expired_drafts():
|
|
return expirable_drafts().filter(expires__lt=datetime_today(DEADLINE_TZINFO) + datetime.timedelta(1))
|
|
|
|
def in_draft_expire_freeze(when=None):
|
|
if when == None:
|
|
when = timezone.now()
|
|
|
|
meeting = Meeting.objects.filter(type='ietf', date__gte=when-datetime.timedelta(days=7)).order_by('date').first()
|
|
|
|
if not meeting:
|
|
return False
|
|
|
|
d = meeting.get_second_cut_off()
|
|
# for some reason, the old Perl code started at 9 am
|
|
second_cut_off = d.replace(hour=9, minute=0, second=0, microsecond=0)
|
|
|
|
d = meeting.get_ietf_monday()
|
|
ietf_monday = datetime.datetime.combine(d, datetime.time(0, 0), tzinfo=meeting.tz())
|
|
|
|
return second_cut_off <= when < ietf_monday
|
|
|
|
def send_expire_warning_for_draft(doc):
|
|
|
|
if ((doc.get_state_slug("draft-iesg") == "dead") or
|
|
(doc.get_state_slug("draft") != "active")):
|
|
return # don't warn about dead or inactive documents
|
|
|
|
expiration = doc.expires.astimezone(DEADLINE_TZINFO).date()
|
|
now_plus_12hours = timezone.now() + datetime.timedelta(hours=12)
|
|
soon = now_plus_12hours.date()
|
|
if expiration <= soon:
|
|
# The document will expire very soon, which will send email to the
|
|
# same people, so do not send the warning at this point in time
|
|
return
|
|
|
|
|
|
(to,cc) = gather_address_lists('doc_expires_soon',doc=doc)
|
|
|
|
s = doc.get_state("draft-iesg")
|
|
log.assertion('s')
|
|
state = s.name if s else "I-D Exists" # TODO remove the if clause after some runtime shows no assertions
|
|
|
|
frm = None
|
|
request = None
|
|
if to or cc:
|
|
send_mail(request, to, frm,
|
|
"Expiration impending: %s" % doc.file_tag(),
|
|
"doc/draft/expire_warning_email.txt",
|
|
dict(doc=doc,
|
|
state=state,
|
|
expiration=expiration
|
|
),
|
|
cc=cc)
|
|
|
|
def send_expire_notice_for_draft(doc):
|
|
if doc.get_state_slug("draft-iesg") == "dead":
|
|
return
|
|
|
|
s = doc.get_state("draft-iesg")
|
|
log.assertion('s')
|
|
state = s.name if s else "I-D Exists" # TODO remove the if clause after some rintime shows no assertions
|
|
|
|
request = None
|
|
(to,cc) = gather_address_lists('doc_expired',doc=doc)
|
|
send_mail(request, to,
|
|
"I-D Expiring System <ietf-secretariat-reply@ietf.org>",
|
|
"I-D was expired %s" % doc.file_tag(),
|
|
"doc/draft/id_expired_email.txt",
|
|
dict(doc=doc,
|
|
state=state,
|
|
),
|
|
cc=cc)
|
|
|
|
def move_draft_files_to_archive(doc, rev):
|
|
def move_file(f):
|
|
src = os.path.join(settings.INTERNET_DRAFT_PATH, f)
|
|
dst = os.path.join(settings.INTERNET_DRAFT_ARCHIVE_DIR, f)
|
|
|
|
if os.path.exists(src):
|
|
try:
|
|
# ghostlinkd would keep this in the combined all archive since it would
|
|
# be sourced from a different place. But when ghostlinkd is removed, nothing
|
|
# new is needed here - the file will already exist in the combined archive
|
|
shutil.move(src, dst)
|
|
except IOError as e:
|
|
if "No such file or directory" in str(e):
|
|
pass
|
|
else:
|
|
raise
|
|
|
|
def remove_ftp_copy(f):
|
|
mark = Path(settings.FTP_DIR) / "internet-drafts" / f
|
|
if mark.exists():
|
|
mark.unlink()
|
|
|
|
|
|
src_dir = Path(settings.INTERNET_DRAFT_PATH)
|
|
for file in src_dir.glob("%s-%s.*" % (doc.name, rev)):
|
|
move_file(str(file.name))
|
|
remove_ftp_copy(str(file.name))
|
|
|
|
def expire_draft(doc):
|
|
# clean up files
|
|
move_draft_files_to_archive(doc, doc.rev)
|
|
|
|
system = Person.objects.get(name="(System)")
|
|
|
|
events = []
|
|
|
|
events.append(DocEvent.objects.create(doc=doc, rev=doc.rev, by=system, type="expired_document", desc="Document has expired"))
|
|
|
|
prev_draft_state=doc.get_state("draft")
|
|
doc.set_state(State.objects.get(used=True, type="draft", slug="expired"))
|
|
events.append(update_action_holders(doc, prev_draft_state, doc.get_state("draft"),[],[]))
|
|
doc.save_with_history(events)
|
|
|
|
def clean_up_draft_files():
|
|
"""Move unidentified and old files out of the Internet-Draft directory."""
|
|
cut_off = date_today(DEADLINE_TZINFO)
|
|
|
|
pattern = os.path.join(settings.INTERNET_DRAFT_PATH, "draft-*.*")
|
|
filename_re = re.compile(r'^(.*)-(\d\d)$')
|
|
|
|
def splitext(fn):
|
|
"""
|
|
Split the pathname path into a pair (root, ext) such that root + ext
|
|
== path, and ext is empty or begins with a period and contains all
|
|
periods in the last path component.
|
|
|
|
This differs from os.path.splitext in the number of periods in the ext
|
|
parts when the final path component contains more than one period.
|
|
"""
|
|
s = fn.rfind("/")
|
|
if s == -1:
|
|
s = 0
|
|
i = fn[s:].find(".")
|
|
if i == -1:
|
|
return fn, ''
|
|
else:
|
|
return fn[:s+i], fn[s+i:]
|
|
|
|
for path in glob.glob(pattern):
|
|
basename = os.path.basename(path)
|
|
stem, ext = splitext(basename)
|
|
match = filename_re.search(stem)
|
|
if not match:
|
|
filename, revision = ("UNKNOWN", "00")
|
|
else:
|
|
filename, revision = match.groups()
|
|
|
|
def move_file_to(subdir):
|
|
# Similar to move_draft_files_to_archive
|
|
# ghostlinkd would keep this in the combined all archive since it would
|
|
# be sourced from a different place. But when ghostlinkd is removed, nothing
|
|
# new is needed here - the file will already exist in the combined archive
|
|
shutil.move(path,
|
|
os.path.join(settings.INTERNET_DRAFT_ARCHIVE_DIR, subdir, basename))
|
|
|
|
try:
|
|
doc = Document.objects.get(name=filename, rev=revision)
|
|
|
|
state = doc.get_state_slug()
|
|
|
|
if state in ("rfc","repl"):
|
|
move_file_to("")
|
|
elif (state in ("expired", "auth-rm", "ietf-rm")
|
|
and doc.expires
|
|
and doc.expires.astimezone(DEADLINE_TZINFO).date() < cut_off):
|
|
move_file_to("")
|
|
|
|
except Document.DoesNotExist:
|
|
# All uses of this past 2014 seem related to major system failures.
|
|
move_file_to("unknown_ids")
|
|
|
|
|
|
def repair_dead_on_expire():
|
|
by = Person.objects.get(name="(System)")
|
|
id_exists = State.objects.get(type="draft-iesg", slug="idexists")
|
|
dead = State.objects.get(type="draft-iesg", slug="dead")
|
|
dead_drafts = Document.objects.filter(
|
|
states__type="draft-iesg", states__slug="dead", type_id="draft"
|
|
)
|
|
for d in dead_drafts:
|
|
dead_event = d.latest_event(
|
|
StateDocEvent, state_type="draft-iesg", state__slug="dead"
|
|
)
|
|
if dead_event is not None:
|
|
if d.docevent_set.filter(type="expired_document").exists():
|
|
closest_expiry = min(
|
|
[
|
|
abs(e.time - dead_event.time)
|
|
for e in d.docevent_set.filter(type="expired_document")
|
|
]
|
|
)
|
|
if closest_expiry.total_seconds() < 60:
|
|
d.set_state(id_exists)
|
|
events = []
|
|
e = DocEvent(
|
|
doc=d,
|
|
rev=d.rev,
|
|
type="added_comment",
|
|
by=by,
|
|
desc="IESG Dead state was set due only to document expiry - changing IESG state to ID-Exists",
|
|
)
|
|
e.skip_community_list_notification = True
|
|
e.save()
|
|
events.append(e)
|
|
e = new_state_change_event(d, by, dead, id_exists)
|
|
e.skip_community_list_notification = True
|
|
e.save()
|
|
events.append(e)
|
|
d.save_with_history(events)
|