diff --git a/ietf/api/tests.py b/ietf/api/tests.py index e61069b3f5..3d3e3ac121 100644 --- a/ietf/api/tests.py +++ b/ietf/api/tests.py @@ -28,6 +28,8 @@ 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 @@ def test_api_new_meeting_registration(self): '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 @@ def test_api_new_meeting_registration(self): 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') diff --git a/ietf/api/views.py b/ietf/api/views.py index 9d832f6fa5..e587b37121 100644 --- a/ietf/api/views.py +++ b/ietf/api/views.py @@ -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.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 err(code, text): 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) diff --git a/ietf/doc/models.py b/ietf/doc/models.py index fdda23ecd1..9275b54101 100644 --- a/ietf/doc/models.py +++ b/ietf/doc/models.py @@ -651,8 +651,8 @@ def referenced_by(self): | 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 @@ def contains(self): 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 diff --git a/ietf/doc/tests.py b/ietf/doc/tests.py index f14b5b1af0..8ae588ad1d 100644 --- a/ietf/doc/tests.py +++ b/ietf/doc/tests.py @@ -2951,4 +2951,51 @@ def test_revisions(self): 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"), + ) diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index 8408f5a0cd..665393c24e 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -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() diff --git a/ietf/doc/views_search.py b/ietf/doc/views_search.py index eef57a29c1..2e4231c5ac 100644 --- a/ietf/doc/views_search.py +++ b/ietf/doc/views_search.py @@ -418,13 +418,19 @@ def state_name(doc_type, state, shorten=True): 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() diff --git a/ietf/group/views.py b/ietf/group/views.py index 991a1b8d87..698963678a 100644 --- a/ietf/group/views.py +++ b/ietf/group/views.py @@ -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 } ) diff --git a/ietf/meeting/forms.py b/ietf/meeting/forms.py index 84853b83e9..ef6a2721e9 100644 --- a/ietf/meeting/forms.py +++ b/ietf/meeting/forms.py @@ -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 @@ def save_agenda(self): 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') diff --git a/ietf/nomcom/migrations/0004_volunteer_origin_volunteer_time_volunteer_withdrawn.py b/ietf/nomcom/migrations/0004_volunteer_origin_volunteer_time_volunteer_withdrawn.py new file mode 100644 index 0000000000..9eaebf2069 --- /dev/null +++ b/ietf/nomcom/migrations/0004_volunteer_origin_volunteer_time_volunteer_withdrawn.py @@ -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), + ), + ] diff --git a/ietf/nomcom/models.py b/ietf/nomcom/models.py index ee2eea2cca..51006a227e 100644 --- a/ietf/nomcom/models.py +++ b/ietf/nomcom/models.py @@ -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}' diff --git a/ietf/person/management/commands/purge_old_personal_api_key_events.py b/ietf/person/management/commands/purge_old_personal_api_key_events.py index a32edf866c..66b9d2c33e 100644 --- a/ietf/person/management/commands/purge_old_personal_api_key_events.py +++ b/ietf/person/management/commands/purge_old_personal_api_key_events.py @@ -18,9 +18,11 @@ def add_arguments(self, parser): 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 @@ def _format_count(count, unit='day'): 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 @@ def _format_count(count, unit='day'): 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 @@ def _format_count(count, unit='day'): 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() diff --git a/ietf/person/management/commands/tests.py b/ietf/person/management/commands/tests.py index 291a6ace5f..38d770a588 100644 --- a/ietf/person/management/commands/tests.py +++ b/ietf/person/management/commands/tests.py @@ -76,26 +76,26 @@ def test_purge_old_personal_api_key_events(self): 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') diff --git a/ietf/templates/doc/document_referenced_by.html b/ietf/templates/doc/document_referenced_by.html index 2c1729f99c..e1137768b7 100644 --- a/ietf/templates/doc/document_referenced_by.html +++ b/ietf/templates/doc/document_referenced_by.html @@ -38,10 +38,10 @@