Merge pull request #6807 from ietf-tools/main

ci: merge main to release
This commit is contained in:
Robert Sparks 2023-12-18 13:50:59 -06:00 committed by GitHub
commit f3a574c8fb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 230 additions and 71 deletions

View file

@ -28,6 +28,8 @@ from ietf.doc.factories import IndividualDraftFactory, WgDraftFactory, WgRfcFact
from ietf.group.factories import RoleFactory
from ietf.meeting.factories import MeetingFactory, SessionFactory
from ietf.meeting.models import Session
from ietf.nomcom.models import Volunteer, NomCom
from ietf.nomcom.factories import NomComFactory, nomcom_kwargs_for_year
from ietf.person.factories import PersonFactory, random_faker
from ietf.person.models import User
from ietf.person.models import PersonalApiKey
@ -630,6 +632,7 @@ class CustomApiTests(TestCase):
'reg_type': 'hackathon',
'ticket_type': '',
'checkedin': 'False',
'is_nomcom_volunteer': 'False',
}
url = urlreverse('ietf.api.views.api_new_meeting_registration')
r = self.client.post(url, reg)
@ -691,6 +694,50 @@ class CustomApiTests(TestCase):
missing_fields = [f.strip() for f in fields.split(',')]
self.assertEqual(set(missing_fields), set(drop_fields))
def test_api_new_meeting_registration_nomcom_volunteer(self):
'''Test that Volunteer is created if is_nomcom_volunteer=True
is submitted to API
'''
meeting = MeetingFactory(type_id='ietf')
reg = {
'apikey': 'invalid',
'affiliation': "Alguma Corporação",
'country_code': 'PT',
'meeting': meeting.number,
'reg_type': 'onsite',
'ticket_type': '',
'checkedin': 'False',
'is_nomcom_volunteer': 'True',
}
person = PersonFactory()
reg['email'] = person.email().address
reg['first_name'] = person.first_name()
reg['last_name'] = person.last_name()
now = datetime.datetime.now()
if now.month > 10:
year = now.year + 1
else:
year = now.year
# create appropriate group and nomcom objects
nomcom = NomComFactory.create(is_accepting_volunteers=True, **nomcom_kwargs_for_year(year))
url = urlreverse('ietf.api.views.api_new_meeting_registration')
r = self.client.post(url, reg)
self.assertContains(r, 'Invalid apikey', status_code=403)
oidcp = PersonFactory(user__is_staff=True)
# Make sure 'oidcp' has an acceptable role
RoleFactory(name_id='robot', person=oidcp, email=oidcp.email(), group__acronym='secretariat')
key = PersonalApiKey.objects.create(person=oidcp, endpoint=url)
reg['apikey'] = key.hash()
r = self.client.post(url, reg)
nomcom = NomCom.objects.last()
self.assertContains(r, "Accepted, New registration", status_code=202)
# assert Volunteer exists
self.assertEqual(Volunteer.objects.count(), 1)
volunteer = Volunteer.objects.last()
self.assertEqual(volunteer.person, person)
self.assertEqual(volunteer.nomcom, nomcom)
self.assertEqual(volunteer.origin, 'registration')
def test_api_version(self):
DumpInfo.objects.create(date=timezone.datetime(2022,8,31,7,10,1,tzinfo=datetime.timezone.utc), host='testapi.example.com',tz='UTC')
url = urlreverse('ietf.api.views.version')

View file

@ -1,7 +1,6 @@
# Copyright The IETF Trust 2017-2020, All Rights Reserved
# -*- coding: utf-8 -*-
import json
import pytz
import re
@ -38,6 +37,7 @@ from ietf.doc.utils import fuzzy_find_documents
from ietf.ietfauth.views import send_account_creation_email
from ietf.ietfauth.utils import role_required
from ietf.meeting.models import Meeting
from ietf.nomcom.models import Volunteer, NomCom
from ietf.stats.models import MeetingRegistration
from ietf.utils import log
from ietf.utils.decorators import require_api_key
@ -140,7 +140,7 @@ def api_new_meeting_registration(request):
def err(code, text):
return HttpResponse(text, status=code, content_type='text/plain')
required_fields = [ 'meeting', 'first_name', 'last_name', 'affiliation', 'country_code',
'email', 'reg_type', 'ticket_type', 'checkedin']
'email', 'reg_type', 'ticket_type', 'checkedin', 'is_nomcom_volunteer']
fields = required_fields + []
if request.method == 'POST':
# parameters:
@ -202,6 +202,19 @@ def api_new_meeting_registration(request):
else:
send_account_creation_email(request, email)
response += ", Email sent"
# handle nomcom volunteer
if data['is_nomcom_volunteer'] and object.person:
try:
nomcom = NomCom.objects.get(is_accepting_volunteers=True)
except (NomCom.DoesNotExist, NomCom.MultipleObjectsReturned):
nomcom = None
if nomcom:
Volunteer.objects.create(
nomcom=nomcom,
person=object.person,
affiliation=data['affiliation'],
origin='registration')
return HttpResponse(response, status=202, content_type='text/plain')
else:
return HttpResponse(status=405)

View file

@ -651,8 +651,8 @@ class DocumentInfo(models.Model):
| models.Q(source__type__slug="rfc")
)
def referenced_by_rfcs(self):
"""Get refs to this doc from RFCs"""
return self.relations_that(("refnorm", "refinfo", "refunk", "refold")).filter(
source__type__slug="rfc"
)
@ -675,6 +675,13 @@ class DocumentInfo(models.Model):
def part_of(self):
return self.related_that("contains")
def referenced_by_rfcs_as_rfc_or_draft(self):
"""Get refs to this doc, or a draft/rfc it came from, from an RFC"""
refs_to = self.referenced_by_rfcs()
if self.type_id == "rfc" and self.came_from_draft():
refs_to |= self.came_from_draft().referenced_by_rfcs()
return refs_to
class Meta:
abstract = True

View file

@ -2951,4 +2951,51 @@ class DocInfoMethodsTests(TestCase):
self.assertEqual(draft.revisions_by_dochistory(),[f"{i:02d}" for i in range(8,10)])
self.assertEqual(draft.revisions_by_newrevisionevent(),[f"{i:02d}" for i in [*range(0,5), *range(6,10)]])
def test_referenced_by_rfcs(self):
# n.b., no significance to the ref* values in this test
referring_draft = WgDraftFactory()
(rfc, referring_rfc) = WgRfcFactory.create_batch(2)
rfc.targets_related.create(relationship_id="refnorm", source=referring_draft)
rfc.targets_related.create(relationship_id="refnorm", source=referring_rfc)
self.assertCountEqual(
rfc.referenced_by_rfcs(),
rfc.targets_related.filter(source=referring_rfc),
)
def test_referenced_by_rfcs_as_rfc_or_draft(self):
# n.b., no significance to the ref* values in this test
draft = WgDraftFactory()
rfc = WgRfcFactory()
draft.relateddocument_set.create(relationship_id="became_rfc", target=rfc)
# Draft referring to the rfc and the draft - should not be reported at all
draft_referring_to_both = WgDraftFactory()
draft_referring_to_both.relateddocument_set.create(relationship_id="refnorm", target=draft)
draft_referring_to_both.relateddocument_set.create(relationship_id="refnorm", target=rfc)
# RFC referring only to the draft - should be reported for either the draft or the rfc
rfc_referring_to_draft = WgRfcFactory()
rfc_referring_to_draft.relateddocument_set.create(relationship_id="refinfo", target=draft)
# RFC referring only to the rfc - should be reported only for the rfc
rfc_referring_to_rfc = WgRfcFactory()
rfc_referring_to_rfc.relateddocument_set.create(relationship_id="refinfo", target=rfc)
# RFC referring only to the rfc - should be reported only for the rfc
rfc_referring_to_rfc = WgRfcFactory()
rfc_referring_to_rfc.relateddocument_set.create(relationship_id="refinfo", target=rfc)
# RFC referring to the rfc and the draft - should be reported for both
rfc_referring_to_both = WgRfcFactory()
rfc_referring_to_both.relateddocument_set.create(relationship_id="refnorm", target=draft)
rfc_referring_to_both.relateddocument_set.create(relationship_id="refnorm", target=rfc)
self.assertCountEqual(
draft.referenced_by_rfcs_as_rfc_or_draft(),
draft.targets_related.filter(source__type="rfc"),
)
self.assertCountEqual(
rfc.referenced_by_rfcs_as_rfc_or_draft(),
draft.targets_related.filter(source__type="rfc") | rfc.targets_related.filter(source__type="rfc"),
)

View file

@ -1437,8 +1437,26 @@ def document_references(request, name):
return render(request, "doc/document_references.html",dict(doc=doc,refs=sorted(refs,key=lambda x:x.target.name),))
def document_referenced_by(request, name):
"""View documents that reference the named document
The view lists both direct references to a the named document, plus references to
related other documents. For a draft that became an RFC, this will include references
to the RFC. For an RFC, this will include references to the draft it came from, if any.
For a subseries document, this will include references to any of the RFC documents it
contains.
In the rendered output, a badge is applied to indicate the name of the document the
reference actually targeted. E.g., on the display for a draft that became RFC NNN,
references included because they point to that RFC would be shown with a tag "As RFC NNN".
The intention is to make the "Referenced By" page useful for finding related work while
accurately reflecting the actual reference relationships.
"""
doc = get_object_or_404(Document,name=name)
refs = doc.referenced_by()
if doc.came_from_draft():
refs |= doc.came_from_draft().referenced_by()
if doc.became_rfc():
refs |= doc.became_rfc().referenced_by()
if doc.type_id in ["bcp","std","fyi"]:
for rfc in doc.contains():
refs |= rfc.referenced_by()

View file

@ -418,13 +418,19 @@ STATE_SLUGS = {
for dt in AD_WORKLOAD
}
def state_to_doc_type(state):
for dt in STATE_SLUGS:
if state in STATE_SLUGS[dt]:
return dt
return None
IESG_STATES = State.objects.filter(type="draft-iesg").values_list("name", flat=True)
def date_to_bucket(date, now, num_buckets):
return num_buckets - min(
num_buckets, int((now.date() - date.date()).total_seconds() / 60 / 60 / 24)
)
return num_buckets - int((now.date() - date.date()).total_seconds() / 60 / 60 / 24)
def ad_workload(request):
@ -477,6 +483,7 @@ def ad_workload(request):
to_state = state_name(dt, state, shorten=False)
elif e.desc.endswith("has been replaced"):
# stop tracking
last = e.time
break
if not to_state:
@ -501,26 +508,30 @@ def ad_workload(request):
elif to_state == "RFC Published":
to_state = "RFC"
if dt == "rfc":
new_dt = state_to_doc_type(to_state)
if new_dt is not None and new_dt != dt:
dt = new_dt
if to_state not in STATE_SLUGS[dt].keys() or to_state == "Replaced":
# change into a state the AD dashboard doesn't display
if to_state in IESG_STATES or to_state == "Replaced":
# if it's an IESG state we don't display, we're done with this doc
# if it's an IESG state we don't display, record it's time
last = e.time
break
# if it's not an IESG state, keep going with next event
# keep going with next event
continue
sn = STATE_SLUGS[dt][to_state]
buckets_start = date_to_bucket(e.time, now, days)
buckets_end = date_to_bucket(last, now, days)
if buckets_end >= days:
# this event is older than we record in the history
if last == now:
# but since we didn't record any state yet,
# this is the state the doc was in for the
# entire history
for b in range(buckets_start, days):
if dt == "charter" and to_state == "Approved" and buckets_start < 0:
# don't count old charter approvals
break
if buckets_start <= 0:
if buckets_end >= 0:
for b in range(0, buckets_end):
ad.buckets[dt][sn][b].append(doc.name)
sums[dt][sn][b].append(doc.name)
last = e.time
@ -532,15 +543,6 @@ def ad_workload(request):
sums[dt][sn][b].append(doc.name)
last = e.time
if last == now:
s = state_name(dt, state, shorten=False)
if s in STATE_SLUGS[dt].keys():
# we didn't have a single event for this doc, assume
# the current state applied throughput the history
for b in range(days):
ad.buckets[dt][state][b].append(doc.name)
sums[dt][state][b].append(doc.name)
metadata = [
{
"type": (dt, doc_type_name(dt)),
@ -564,8 +566,11 @@ def ad_workload(request):
def docs_for_ad(request, name):
def sort_key(doc):
key = list(AD_WORKLOAD.keys()).index(doc_type(doc))
return key
dt = doc_type(doc)
dt_key = list(AD_WORKLOAD.keys()).index(dt)
ds = doc_state(doc)
ds_key = AD_WORKLOAD[dt].index(ds) if ds in AD_WORKLOAD[dt] else 99
return dt_key * 100 + ds_key
ad = None
responsible = Document.objects.values_list("ad", flat=True).distinct()

View file

@ -1296,7 +1296,7 @@ def stream_documents(request, acronym):
qs = Document.objects.filter(stream=acronym).filter(
Q(type_id="draft", states__type="draft", states__slug="active")
| Q(type_id="rfc")
)
).distinct()
docs, meta = prepare_document_table(request, qs, max_results=1000)
return render(request, 'group/stream_documents.html', {'stream':stream, 'docs':docs, 'meta':meta, 'editable':editable } )

View file

@ -1,4 +1,4 @@
# Copyright The IETF Trust 2016-2020, All Rights Reserved
# Copyright The IETF Trust 2016-2023, All Rights Reserved
# -*- coding: utf-8 -*-
@ -360,7 +360,13 @@ class InterimSessionModelForm(forms.ModelForm):
class InterimAnnounceForm(forms.ModelForm):
class Meta:
model = Message
fields = ('to', 'frm', 'cc', 'bcc', 'reply_to', 'subject', 'body')
fields = ('to', 'cc', 'frm', 'subject', 'body')
def __init__(self, *args, **kwargs):
super(InterimAnnounceForm, self).__init__(*args, **kwargs)
self.fields['frm'].label='From'
self.fields['frm'].widget.attrs['readonly'] = True
self.fields['to'].widget.attrs['readonly'] = True
def save(self, *args, **kwargs):
user = kwargs.pop('user')

View file

@ -0,0 +1,27 @@
# Generated by Django 4.2.7 on 2023-11-05 09:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("nomcom", "0003_alter_nomination_share_nominator"),
]
operations = [
migrations.AddField(
model_name="volunteer",
name="origin",
field=models.CharField(default="datatracker", max_length=32),
),
migrations.AddField(
model_name="volunteer",
name="time",
field=models.DateTimeField(auto_now_add=True, null=True, blank=True),
),
migrations.AddField(
model_name="volunteer",
name="withdrawn",
field=models.DateTimeField(blank=True, null=True),
),
]

View file

@ -327,7 +327,10 @@ class Volunteer(models.Model):
nomcom = ForeignKey('NomCom')
person = ForeignKey(Person)
affiliation = models.CharField(blank=True, max_length=255)
time = models.DateTimeField(auto_now_add=True, null=True, blank=True)
origin = models.CharField(max_length=32, default='datatracker')
withdrawn = models.DateTimeField(blank=True, null=True)
def __str__(self):
return f'{self.person} for {self.nomcom}'

View file

@ -18,9 +18,11 @@ class Command(BaseCommand):
parser.add_argument('-n', '--dry-run', action='store_true', default=False,
help="Don't delete events, just show what would be done")
def handle(self, *args, **options):
keep_days = options['keep_days']
dry_run = options['dry_run']
verbosity = options.get("verbosity", 1)
def _format_count(count, unit='day'):
return '{} {}{}'.format(count, unit, ('' if count == 1 else 's'))
@ -28,10 +30,11 @@ class Command(BaseCommand):
if keep_days < 0:
raise CommandError('Negative keep_days not allowed ({} was specified)'.format(keep_days))
self.stdout.write('purge_old_personal_api_key_events: Finding events older than {}\n'.format(_format_count(keep_days)))
if dry_run:
self.stdout.write('Dry run requested, records will not be deleted\n')
self.stdout.flush()
if verbosity > 1:
self.stdout.write('purge_old_personal_api_key_events: Finding events older than {}\n'.format(_format_count(keep_days)))
if dry_run:
self.stdout.write('Dry run requested, records will not be deleted\n')
self.stdout.flush()
now = timezone.now()
old_events = PersonApiKeyEvent.objects.filter(
@ -41,7 +44,8 @@ class Command(BaseCommand):
stats = old_events.aggregate(Min('time'), Max('time'))
old_count = old_events.count()
if old_count == 0:
self.stdout.write('No events older than {} found\n'.format(_format_count(keep_days)))
if verbosity > 1:
self.stdout.write('No events older than {} found\n'.format(_format_count(keep_days)))
return
oldest_date = stats['time__min']
@ -50,10 +54,11 @@ class Command(BaseCommand):
newest_ago = now - newest_date
action_fmt = 'Would delete {}\n' if dry_run else 'Deleting {}\n'
self.stdout.write(action_fmt.format(_format_count(old_count, 'event')))
self.stdout.write(' Oldest at {} ({} ago)\n'.format(oldest_date, _format_count(oldest_ago.days)))
self.stdout.write(' Most recent at {} ({} ago)\n'.format(newest_date, _format_count(newest_ago.days)))
self.stdout.flush()
if verbosity > 1:
self.stdout.write(action_fmt.format(_format_count(old_count, 'event')))
self.stdout.write(' Oldest at {} ({} ago)\n'.format(oldest_date, _format_count(oldest_ago.days)))
self.stdout.write(' Most recent at {} ({} ago)\n'.format(newest_date, _format_count(newest_ago.days)))
self.stdout.flush()
if not dry_run:
old_events.delete()

View file

@ -76,26 +76,26 @@ class CommandTests(TestCase):
num_recent_events = len(recent_events)
# call with dry run
output = self._call_command('purge_old_personal_api_key_events', str(keep_days), '--dry-run')
output = self._call_command('purge_old_personal_api_key_events', str(keep_days), '--dry-run', '-v2')
self._assert_purge_dry_run_results(output, num_old_events, old_events + recent_events)
# call for real
output = self._call_command('purge_old_personal_api_key_events', str(keep_days))
output = self._call_command('purge_old_personal_api_key_events', str(keep_days), '-v2')
self._assert_purge_results(output, num_old_events, recent_events)
self.assertEqual(PersonEvent.objects.count(), personevents_before + num_recent_events,
'PersonEvents were not cleaned up properly')
# repeat - there should be nothing left to delete
output = self._call_command('purge_old_personal_api_key_events', '--dry-run', str(keep_days))
output = self._call_command('purge_old_personal_api_key_events', '--dry-run', str(keep_days), '-v2')
self._assert_purge_dry_run_results(output, 0, recent_events)
output = self._call_command('purge_old_personal_api_key_events', str(keep_days))
output = self._call_command('purge_old_personal_api_key_events', str(keep_days), '-v2')
self._assert_purge_results(output, 0, recent_events)
self.assertEqual(PersonEvent.objects.count(), personevents_before + num_recent_events,
'PersonEvents were not cleaned up properly')
# and now delete the remaining events
output = self._call_command('purge_old_personal_api_key_events', '0')
output = self._call_command('purge_old_personal_api_key_events', '0', '-v2')
self._assert_purge_results(output, num_recent_events, [])
self.assertEqual(PersonEvent.objects.count(), personevents_before,
'PersonEvents were not cleaned up properly')

View file

@ -38,10 +38,10 @@
</thead>
<tbody>
{% for ref in refs %}
{% with ref.source.name as name %}
{% with ref.source.name as src_name %}
<tr>
<td>
<a href="{% url 'ietf.doc.views_doc.document_main' name=name %}">{{ name|prettystdname }}</a>
<a href="{% url 'ietf.doc.views_doc.document_main' name=src_name %}">{{ src_name|prettystdname }}</a>
{% if ref.target.name != name %}
<br>
<span class="badge rounded-pill text-bg-info">As {{ ref.target.name }}</span>
@ -51,13 +51,13 @@
<b>{{ ref.source.title }}</b>
<br>
<a class="btn btn-primary btn-sm"
href="{% url 'ietf.doc.views_doc.document_references' name %}"
href="{% url 'ietf.doc.views_doc.document_references' src_name %}"
rel="nofollow">
<i class="bi bi-arrow-left"></i>
References
</a>
<a class="btn btn-primary btn-sm"
href="{% url 'ietf.doc.views_doc.document_referenced_by' name %}"
href="{% url 'ietf.doc.views_doc.document_referenced_by' src_name %}"
rel="nofollow">
<i class="bi bi-arrow-right"></i>
Referenced by

View file

@ -1,5 +1,5 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{# Copyright The IETF Trust 2015-2023, All Rights Reserved #}
{% load origin %}
{% load static django_bootstrap5 widget_tweaks %}
{% block title %}Announce Interim Meeting{% endblock %}
@ -11,26 +11,7 @@
<h1>Announce Interim Meeting</h1>
<form method="post" class="my-3">
{% csrf_token %}
<div class="row mb-3">
<label for="{{ form.to.id_for_label }}" class="col-md-2 fw-bold col-form-label">To</label>
<div class="col-md-10">{% render_field form.to class="form-control" readonly="readonly" %}</div>
</div>
<div class="row mb-3">
<label for="{{ form.cc.id_for_label }}" class="col-md-2 fw-bold col-form-label">Cc</label>
<div class="col-md-10">{% render_field form.cc class="form-control" %}</div>
</div>
<div class="row mb-3">
<label for="{{ form.frm.id_for_label }}" class="col-md-2 fw-bold col-form-label">From</label>
<div class="col-md-10">{% render_field form.frm class="form-control" readonly="readonly" %}</div>
</div>
<div class="row mb-3">
<label for="{{ form.subject.id_for_label }}" class="col-md-2 fw-bold col-form-label">Subject</label>
<div class="col-md-10">{% render_field form.subject class="form-control" %}</div>
</div>
<div class="row mb-3">
<label for="{{ form.body.id_for_label }}" class="col-md-2 fw-bold col-form-label">Body</label>
<div class="col-md-10">{% render_field form.body class="form-control" %}</div>
</div>
{% bootstrap_form form layout="horizontal" %}
<button class="btn btn-primary" type="submit" name="send">Send</button>
<a class="btn btn-secondary float-end"
href="{% url 'ietf.meeting.views.interim_request_details' number=meeting.number %}">

View file

@ -109,7 +109,7 @@
<td>{{ doc.pub_date|date:"b Y"|title }}</td>
<td>{{ doc.title|urlize_ietf_docs }}</td>
<td class="text-end">
{% with doc.referenced_by_rfcs.count as refbycount %}
{% with doc.referenced_by_rfcs_as_rfc_or_draft.count as refbycount %}
{% if refbycount %}
<a class="text-end"
href="{% url 'ietf.doc.views_doc.document_referenced_by' doc.name %}"