diff --git a/ietf/doc/tests.py b/ietf/doc/tests.py index 106ac1782..ace55a0d7 100644 --- a/ietf/doc/tests.py +++ b/ietf/doc/tests.py @@ -36,15 +36,14 @@ import debug # pyflakes:ignore from ietf.doc.models import ( Document, DocAlias, DocRelationshipName, RelatedDocument, State, DocEvent, BallotPositionDocEvent, LastCallDocEvent, WriteupDocEvent, NewRevisionDocEvent, BallotType, EditedAuthorsDocEvent ) -from ietf.doc.factories import ( DocumentFactory, DocEventFactory, CharterFactory, +from ietf.doc.factories import ( DocumentFactory, DocEventFactory, CharterFactory, ConflictReviewFactory, WgDraftFactory, IndividualDraftFactory, WgRfcFactory, IndividualRfcFactory, StateDocEventFactory, BallotPositionDocEventFactory, BallotDocEventFactory, DocumentAuthorFactory, NewRevisionDocEventFactory, - StatusChangeFactory, BofreqFactory, DocExtResourceFactory, RgDraftFactory) + StatusChangeFactory, DocExtResourceFactory, RgDraftFactory) from ietf.doc.forms import NotifyForm from ietf.doc.fields import SearchableDocumentsField from ietf.doc.utils import create_ballot_if_not_open, uppercase_std_abbreviated_name -from ietf.doc.views_search import ad_dashboard_group, ad_dashboard_group_type, shorten_group_name # TODO: red flag that we're importing from views in tests. Move these to utils. from ietf.group.models import Group, Role from ietf.group.factories import GroupFactory, RoleFactory from ietf.ipr.factories import HolderIprDisclosureFactory @@ -60,6 +59,7 @@ from ietf.utils.test_utils import login_testing_unauthorized, unicontent from ietf.utils.test_utils import TestCase from ietf.utils.text import normalize_text from ietf.utils.timezone import date_today, datetime_today, DEADLINE_TZINFO, RPC_TZINFO +from ietf.doc.utils_search import AD_WORKLOAD class SearchTests(TestCase): @@ -279,43 +279,61 @@ class SearchTests(TestCase): self.assertContains(r, "Document Search") def test_ad_workload(self): - Role.objects.filter(name_id='ad').delete() - ad = RoleFactory(name_id='ad',group__type_id='area',group__state_id='active',person__name='Example Areadirector').person - doc_type_names = ['bofreq', 'charter', 'conflrev', 'draft', 'statchg'] - expected = defaultdict(lambda :0) - for doc_type_name in doc_type_names: - if doc_type_name=='draft': - states = State.objects.filter(type='draft-iesg', used=True).values_list('slug', flat=True) - else: - states = State.objects.filter(type=doc_type_name, used=True).values_list('slug', flat=True) - - for state in states: - target_num = random.randint(0,2) + Role.objects.filter(name_id="ad").delete() + ad = RoleFactory( + name_id="ad", + group__type_id="area", + group__state_id="active", + person__name="Example Areadirector", + ).person + expected = defaultdict(lambda: 0) + for doc_type_slug in AD_WORKLOAD: + for state in AD_WORKLOAD[doc_type_slug]: + target_num = random.randint(0, 2) for _ in range(target_num): - if doc_type_name == 'draft': - doc = IndividualDraftFactory(ad=ad,states=[('draft-iesg', state),('draft','rfc' if state=='pub' else 'active')]) - elif doc_type_name == 'charter': - doc = CharterFactory(ad=ad, states=[(doc_type_name, state)]) - elif doc_type_name == 'bofreq': - # Note that the view currently doesn't handle bofreqs - doc = BofreqFactory(states=[(doc_type_name, state)], bofreqresponsibledocevent__responsible=[ad]) - elif doc_type_name == 'conflrev': - doc = ConflictReviewFactory(ad=ad, states=State.objects.filter(type_id=doc_type_name, slug=state)) - elif doc_type_name == 'statchg': - doc = StatusChangeFactory(ad=ad, states=State.objects.filter(type_id=doc_type_name, slug=state)) - else: - # Currently unreachable - doc = DocumentFactory(type_id=doc_type_name, ad=ad, states=[(doc_type_name, state)]) + if ( + doc_type_slug == "draft" + or doc_type_slug == "rfc" + and state == "rfcqueue" + ): + IndividualDraftFactory( + ad=ad, + states=[ + ("draft-iesg", state), + ("draft", "rfc" if state == "pub" else "active"), + ], + ) + elif doc_type_slug == "rfc": + WgRfcFactory.create( + states=[("draft", "rfc"), ("draft-iesg", "pub")] + ) - if not slugify(ad_dashboard_group_type(doc)) in ('document', 'none'): - expected[(slugify(ad_dashboard_group_type(doc)), slugify(ad.full_name_as_key()), slugify(shorten_group_name(ad_dashboard_group(doc))))] += 1 - - url = urlreverse('ietf.doc.views_search.ad_workload') + elif doc_type_slug == "charter": + CharterFactory(ad=ad, states=[(doc_type_slug, state)]) + elif doc_type_slug == "conflrev": + ConflictReviewFactory( + ad=ad, + states=State.objects.filter( + type_id=doc_type_slug, slug=state + ), + ) + elif doc_type_slug == "statchg": + StatusChangeFactory( + ad=ad, + states=State.objects.filter( + type_id=doc_type_slug, slug=state + ), + ) + self.client.login(username="ad", password="ad+password") + url = urlreverse("ietf.doc.views_search.ad_workload") r = self.client.get(url) self.assertEqual(r.status_code, 200) q = PyQuery(r.content) for group_type, ad, group in expected: - self.assertEqual(int(q(f'#{group_type}-{ad}-{group}').text()),expected[(group_type, ad, group)]) + self.assertEqual( + int(q(f"#{group_type}-{ad}-{group}").text()), + expected[(group_type, ad, group)], + ) def test_docs_for_ad(self): ad = RoleFactory(name_id='ad',group__type_id='area',group__state_id='active').person diff --git a/ietf/doc/utils_search.py b/ietf/doc/utils_search.py index 31aedda0d..0c2dafd16 100644 --- a/ietf/doc/utils_search.py +++ b/ietf/doc/utils_search.py @@ -9,7 +9,7 @@ from zoneinfo import ZoneInfo from django.conf import settings -from ietf.doc.models import Document, DocAlias, RelatedDocument, DocEvent, TelechatDocEvent, BallotDocEvent +from ietf.doc.models import Document, DocAlias, RelatedDocument, DocEvent, TelechatDocEvent, BallotDocEvent, DocTypeName from ietf.doc.expire import expirable_drafts from ietf.doc.utils import augment_docs_and_user_with_user_info from ietf.meeting.models import SessionPresentation, Meeting, Session @@ -26,7 +26,7 @@ def fill_in_telechat_date(docs, doc_dict=None, doc_ids=None): doc_dict = dict((d.pk, d) for d in docs) doc_ids = list(doc_dict.keys()) if doc_ids is None: - doc_ids = list(doc_dict.keys()) + doc_ids = list(doc_dict.keys()) seen = set() for e in TelechatDocEvent.objects.filter(doc__id__in=doc_ids, type="scheduled_for_telechat").order_by('-time'): @@ -181,7 +181,7 @@ def augment_docs_with_related_docs_info(docs): continue originalDoc = d.related_that_doc('conflrev')[0].document d.pages = originalDoc.pages - + def prepare_document_table(request, docs, query=None, max_results=200): """Take a queryset of documents and a QueryDict with sorting info and return list of documents with attributes filled in for @@ -283,3 +283,86 @@ def prepare_document_table(request, docs, query=None, max_results=200): h["sort_url"] = "?" + d.urlencode() return (docs, meta) + + +# The document types and state slugs to include in the AD dashboard +# and AD doc list, in the order they should be shown. +# +# "rfc" is a custom subset of "draft" that we special-case in the code +# to break out these docs into a separate table. +# +AD_WORKLOAD = { + "draft": [ + "pub-req", + "ad-eval", + "lc-req", + "lc", + "writeupw", + # "defer", # probably not a useful state to show, since it's rare + "iesg-eva", + "goaheadw", + "approved", + "ann", + ], + "rfc": [ + "rfcqueue", + "rfc", + ], + "conflrev": [ + "needshep", + "adrev", + "iesgeval", + "approved", # synthesized state for all the "appr-" states + # "withdraw", # probably not a useful state to show + ], + "statchg": [ + "needshep", + "adrev", + "lc-req", + "in-lc", + "iesgeval", + "goahead", + "appr-sent", + # "dead", # probably not a useful state to show + ], + "charter": [ + "notrev", + "infrev", + "intrev", + "extrev", + "iesgrev", + "approved", + # "replaced", # probably not a useful state to show + ], +} + + +def doc_type(doc): + dt = doc.type.slug + if ( + doc.get_state_slug("draft") == "rfc" + or doc.get_state_slug("draft-iesg") == "rfcqueue" + ): + dt = "rfc" + return dt + + +def doc_state(doc): + dt = doc.type.slug + ds = doc.get_state(dt) + if dt == "draft": + dis = doc.get_state("draft-iesg") + if ds.slug == "active" and dis: + return dis.slug + elif dt == "conflrev": + if ds.slug.startswith("appr"): + return "approved" + return ds.slug + + +def doc_type_name(doc_type): + if doc_type == "rfc": + return "RFC" + if doc_type == "draft": + return "Internet-Draft" + return DocTypeName.objects.get(slug=doc_type).name diff --git a/ietf/doc/views_search.py b/ietf/doc/views_search.py index 4e791aea7..6ba8abe1e 100644 --- a/ietf/doc/views_search.py +++ b/ietf/doc/views_search.py @@ -36,8 +36,7 @@ import re import datetime - -from collections import defaultdict +import copy from django import forms from django.conf import settings @@ -47,8 +46,9 @@ from django.db.models import Q from django.http import Http404, HttpResponseBadRequest, HttpResponse, HttpResponseRedirect, QueryDict from django.shortcuts import render from django.utils import timezone +from django.utils.html import strip_tags from django.utils.cache import _generate_cache_key # type: ignore - +from django.utils.text import slugify import debug # pyflakes:ignore @@ -66,7 +66,8 @@ from ietf.person.models import Person from ietf.person.utils import get_active_ads from ietf.utils.draft_search import normalize_draftname from ietf.utils.log import log -from ietf.doc.utils_search import prepare_document_table +from ietf.doc.utils_search import prepare_document_table, doc_type, doc_state, doc_type_name, AD_WORKLOAD +from ietf.ietfauth.utils import has_role class SearchForm(forms.Form): @@ -313,76 +314,23 @@ def search_for_name(request, name): return cached_redirect(cache_key, urlreverse('ietf.doc.views_search.search') + search_args) -def ad_dashboard_group_type(doc): - # Return group type for document for dashboard. - # If doc is not defined return list of all possible - # group types - if not doc: - return ('I-D', 'RFC', 'Conflict Review', 'Status Change', 'Charter') - if doc.type.slug=='draft': - if doc.get_state_slug('draft') == 'rfc': - return 'RFC' - elif doc.get_state_slug('draft') == 'active' and doc.get_state_slug('draft-iesg') and doc.get_state('draft-iesg').name =='RFC Ed Queue': - return 'RFC' - elif doc.get_state_slug('draft') == 'active' and doc.get_state_slug('draft-iesg') and doc.get_state('draft-iesg').name in ('Dead', 'I-D Exists', 'AD is watching'): - return None - elif doc.get_state('draft').name in ('Expired', 'Replaced'): - return None - else: - return 'I-D' - elif doc.type.slug=='conflrev': - return 'Conflict Review' - elif doc.type.slug=='statchg': - return 'Status Change' - elif doc.type.slug=='charter': - return "Charter" + +def state_name(doc_type, state, shorten=True): + name = "" + if doc_type in ["draft", "rfc"] and state not in ["rfc", "expired"]: + name = State.objects.get(type__in=["draft", "draft-iesg"], slug=state).name + elif state == "rfc": + name = "RFC" + elif doc_type == "conflrev" and state.startswith("appr"): + name = "Approved" else: - return "Document" + name = State.objects.get(type=doc_type, slug=state).name -def ad_dashboard_group(doc): - - if doc.type.slug=='draft': - if doc.get_state_slug('draft') == 'rfc': - return 'RFC' - elif doc.get_state_slug('draft') == 'active' and doc.get_state_slug('draft-iesg'): - return '%s Internet-Draft' % doc.get_state('draft-iesg').name - else: - return '%s Internet-Draft' % doc.get_state('draft').name - elif doc.type.slug=='conflrev': - if doc.get_state_slug('conflrev') in ('appr-reqnopub-sent','appr-noprob-sent'): - return 'Approved Conflict Review' - elif doc.get_state_slug('conflrev') in ('appr-reqnopub-pend','appr-noprob-pend','appr-reqnopub-pr','appr-noprob-pr'): - return "%s Conflict Review" % State.objects.get(type__slug='draft-iesg',slug='approved') - else: - return '%s Conflict Review' % doc.get_state('conflrev') - elif doc.type.slug=='statchg': - if doc.get_state_slug('statchg') in ('appr-sent',): - return 'Approved Status Change' - if doc.get_state_slug('statchg') in ('appr-pend','appr-pr'): - return '%s Status Change' % State.objects.get(type__slug='draft-iesg',slug='approved') - else: - return '%s Status Change' % doc.get_state('statchg') - elif doc.type.slug=='charter': - if doc.get_state_slug('charter') == 'approved': - return "Approved Charter" - else: - return '%s Charter' % doc.get_state('charter') - else: - return "Document" - - -def shorten_group_name(name): - for s in [ - " Internet-Draft", - " Conflict Review", - " Status Change", - " (Internal Steering Group/IAB Review) Charter", - "Charter", - ]: - if name.endswith(s): - name = name[: -len(s)] + if not shorten: + return name for pat, sub in [ + (r" \(Internal Steering Group/IAB Review\)", ""), ("Writeup", "Write-up"), ("Requested", "Req"), ("Evaluation", "Eval"), @@ -390,82 +338,45 @@ def shorten_group_name(name): ("Waiting", "Wait"), ("Go-Ahead", "OK"), ("Approved-", "App, "), + ("Approved No Problem", "App."), ("announcement", "ann."), ("IESG Eval - ", ""), ("Not currently under review", "Not under review"), ("External Review", "Ext. Review"), - (r"IESG Review \(Charter for Approval, Selected by Secretariat\)", "IESG Review"), + ( + r"IESG Review \(Charter for Approval, Selected by Secretariat\)", + "IESG Review", + ), ("Needs Shepherd", "Needs Shep."), ("Approved", "App."), ("Replaced", "Repl."), ("Withdrawn", "Withd."), ("Chartering/Rechartering", "Charter"), - (r"\(Message to Community, Selected by Secretariat\)", "") + (r"\(Message to Community, Selected by Secretariat\)", ""), ]: name = re.sub(pat, sub, name) return name.strip() -def ad_dashboard_sort_key(doc): +STATE_SLUGS = { + dt: {state_name(dt, ds, shorten=False): ds for ds in AD_WORKLOAD[dt]} # type: ignore + for dt in AD_WORKLOAD +} - if doc.type.slug=='draft' and doc.get_state_slug('draft') == 'rfc': - return "21%04d" % int(doc.rfc_number()) - if doc.type.slug=='statchg' and doc.get_state_slug('statchg') == 'appr-sent': - return "22%d" % 0 # TODO - get the date of the transition into this state here - if doc.type.slug=='conflrev' and doc.get_state_slug('conflrev') in ('appr-reqnopub-sent','appr-noprob-sent'): - return "23%d" % 0 # TODO - get the date of the transition into this state here - if doc.type.slug=='charter' and doc.get_state_slug('charter') == 'approved': - return "24%d" % 0 # TODO - get the date of the transition into this state here +IESG_STATES = State.objects.filter(type="draft-iesg").values_list("name", flat=True) - seed = ad_dashboard_group(doc) - if doc.type.slug=='conflrev' and doc.get_state_slug('conflrev') == 'adrev': - state = State.objects.get(type__slug='draft-iesg',slug='ad-eval') - return "1%d%s" % (state.order,seed) - - if doc.type.slug=='charter' and doc.get_state_slug('charter') != 'replaced': - if doc.get_state_slug('charter') in ('notrev','infrev'): - return "100%s" % seed - elif doc.get_state_slug('charter') == 'intrev': - state = State.objects.get(type__slug='draft-iesg',slug='ad-eval') - return "1%d%s" % (state.order,seed) - elif doc.get_state_slug('charter') == 'extrev': - state = State.objects.get(type__slug='draft-iesg',slug='lc') - return "1%d%s" % (state.order,seed) - elif doc.get_state_slug('charter') == 'iesgrev': - state = State.objects.get(type__slug='draft-iesg',slug='iesg-eva') - return "1%d%s" % (state.order,seed) - - if doc.type.slug=='statchg' and doc.get_state_slug('statchg') == 'adrev': - state = State.objects.get(type__slug='draft-iesg',slug='ad-eval') - return "1%d%s" % (state.order,seed) - - if seed.startswith('Needs Shepherd'): - return "100%s" % seed - if seed.endswith(' Document'): - seed = seed[:-9] - elif seed.endswith(' Internet-Draft'): - seed = seed[:-15] - elif seed.endswith(' Conflict Review'): - seed = seed[:-16] - elif seed.endswith(' Status Change'): - seed = seed[:-14] - state = State.objects.filter(type__slug='draft-iesg',name=seed) - if state: - ageseconds = 0 - changetime= doc.latest_event(type='changed_document') - if changetime: - ad = (timezone.now()-doc.latest_event(type='changed_document').time) - ageseconds = (ad.microseconds + (ad.seconds + ad.days * 24 * 3600) * 10**6) / 10**6 - return "1%d%s%s%010d" % (state[0].order,seed,doc.type.slug,ageseconds) - - return "3%s" % seed +def date_to_bucket(date, now, num_buckets): + return num_buckets - min( + num_buckets, int((now.date() - date.date()).total_seconds() / 60 / 60 / 24) + ) def ad_workload(request): - delta = datetime.timedelta(days=120) - right_now = timezone.now() + # number of days (= buckets) to show in the graphs + days = 120 if has_role(request.user, ["Area Director", "Secretariat"]) else 1 + now = timezone.now() ads = [] responsible = Document.objects.values_list("ad", flat=True).distinct() @@ -480,209 +391,146 @@ def ad_workload(request): if p in get_active_ads(): ads.append(p) - doctypes = list( - DocTypeName.objects.filter(used=True) - .exclude(slug__in=("draft", "liai-att")) - .values_list("pk", flat=True) - ) - - up_is_good = {} - group_types = ad_dashboard_group_type(None) - groups = {g: {} for g in group_types} - group_names = {g: [] for g in group_types} - - # Prefill groups in preferred sort order - # FIXME: This should really use the database states instead of replicating the logic - for id, (g, uig) in enumerate( - [ - ("Publication Requested Internet-Draft", False), - ("AD Evaluation Internet-Draft", False), - ("Last Call Requested Internet-Draft", True), - ("In Last Call Internet-Draft", True), - ("Waiting for Writeup Internet-Draft", False), - ("IESG Evaluation - Defer Internet-Draft", False), - ("IESG Evaluation Internet-Draft", True), - ("Waiting for AD Go-Ahead Internet-Draft", False), - ("Approved-announcement to be sent Internet-Draft", True), - ("Approved-announcement sent Internet-Draft", True), - ] - ): - groups["I-D"][g] = id - group_names["I-D"].append(g) - up_is_good[g] = uig - - for id, g in enumerate(["RFC Ed Queue Internet-Draft", "RFC"]): - groups["RFC"][g] = id - group_names["RFC"].append(g) - up_is_good[g] = True - - for id, (g, uig) in enumerate( - [ - ("AD Review Conflict Review", False), - ("Needs Shepherd Conflict Review", False), - ("IESG Evaluation Conflict Review", True), - ("Approved Conflict Review", True), - ("Withdrawn Conflict Review", None), - ] - ): - groups["Conflict Review"][g] = id - group_names["Conflict Review"].append(g) - up_is_good[g] = uig - - for id, (g, uig) in enumerate( - [ - ("Publication Requested Status Change", False), - ("AD Evaluation Status Change", False), - ("Last Call Requested Status Change", True), - ("In Last Call Status Change", True), - ("Waiting for Writeup Status Change", False), - ("IESG Evaluation Status Change", True), - ("Waiting for AD Go-Ahead Status Change", False), - ] - ): - groups["Status Change"][g] = id - group_names["Status Change"].append(g) - up_is_good[g] = uig - - for id, (g, uig) in enumerate( - [ - ("Not currently under review Charter", None), - ("Draft Charter Charter", None), - ("Start Chartering/Rechartering (Internal Steering Group/IAB Review) Charter", False), - ("External Review (Message to Community, Selected by Secretariat) Charter", True), - ("IESG Review (Charter for Approval, Selected by Secretariat) Charter", True), - ("Approved Charter", True), - ("Replaced Charter", None), - ] - ): - groups["Charter"][g] = id - group_names["Charter"].append(g) - up_is_good[g] = uig + bucket_template = { + dt: {state: [[] for _ in range(days)] for state in STATE_SLUGS[dt].values()} + for dt in STATE_SLUGS + } + sums = copy.deepcopy(bucket_template) for ad in ads: - form = SearchForm( - { - "by": "ad", - "ad": ad.id, - "rfcs": "on", - "activedrafts": "on", - "olddrafts": "on", - "doctypes": doctypes, - } - ) - ad.dashboard = urlreverse( "ietf.doc.views_search.docs_for_ad", kwargs=dict(name=ad.full_name_as_key()) ) - ad.counts = defaultdict(list) - ad.prev = defaultdict(list) - ad.doc_now = defaultdict(list) - ad.doc_prev = defaultdict(list) + ad.buckets = copy.deepcopy(bucket_template) - for doc in retrieve_search_results(form): - group_type = ad_dashboard_group_type(doc) - if group_type and group_type in groups: - # Right now, anything with group_type "Document", such as a bofreq is not handled. - group = ad_dashboard_group(doc) - if group not in groups[group_type]: - groups[group_type][group] = len(groups[group_type]) - group_names[group_type].append(group) + for doc in Document.objects.filter(ad=ad): + dt = doc_type(doc) + state = doc_state(doc) - inc = len(groups[group_type]) - len(ad.counts[group_type]) - if inc > 0: - ad.counts[group_type].extend([0] * inc) - ad.prev[group_type].extend([0] * inc) - ad.doc_now[group_type].extend(set() for _ in range(inc)) - ad.doc_prev[group_type].extend(set() for _ in range(inc)) + state_events = doc.docevent_set.filter( + Q(type="started_iesg_process") + | Q(type="changed_state") + | Q(type="published_rfc") + | Q(type="closed_ballot"), + ).order_by("-time") - ad.counts[group_type][groups[group_type][group]] += 1 - ad.doc_now[group_type][groups[group_type][group]].add(doc) + # compute state history for drafts + last = now + for e in state_events: + to_state = None + if dt == "charter": + if e.type == "closed_ballot": + to_state = state_name(dt, state, shorten=False) + elif e.desc.endswith("has been replaced"): + # stop tracking + break - last_state_event = ( - doc.docevent_set.filter( - Q(type="started_iesg_process") | Q(type="changed_state") + if not to_state: + # get the state name this event changed the doc into + match = re.search( + r"(RFC) published|[Ss]tate changed to (.*?)(?:::.*)? from (.*?)(?=::|$)", + strip_tags(e.desc), + flags=re.MULTILINE, ) - .order_by("-time") - .first() - ) - if (last_state_event is not None) and (right_now - last_state_event.time) > delta: - ad.prev[group_type][groups[group_type][group]] += 1 - ad.doc_prev[group_type][groups[group_type][group]].add(doc) + if not match: + # some irrelevant state change for the AD dashboard, ignore it + continue + to_state = match.group(1) or match.group(2) - for ad in ads: - ad.doc_diff = defaultdict(list) - for gt in group_types: - inc = len(groups[gt]) - len(ad.counts[gt]) - if inc > 0: - ad.counts[gt].extend([0] * inc) - ad.prev[gt].extend([0] * inc) - ad.doc_now[gt].extend([set()] * inc) - ad.doc_prev[gt].extend([set()] * inc) + # fix up some states that have been renamed + if dt == "conflrev" and to_state.startswith("Approved"): + to_state = "Approved" + elif dt == "charter" and to_state.startswith( + "Start Chartering/Rechartering" + ): + to_state = "Start Chartering/Rechartering (Internal Steering Group/IAB Review)" + elif to_state == "RFC Published": + to_state = "RFC" - ad.doc_diff[gt].extend([set()] * len(groups[gt])) - for idx, g in enumerate(group_names[gt]): - ad.doc_diff[gt][idx] = ad.doc_prev[gt][idx] ^ ad.doc_now[gt][idx] + 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 + last = e.time + break + # if it's not an IESG state, keep going with next event + continue - # Shorten the names of groups - for gt in group_types: - for idx, g in enumerate(group_names[gt]): - group_names[gt][idx] = ( - shorten_group_name(g), - g, - up_is_good[g] if g in up_is_good else None, - ) + sn = STATE_SLUGS[dt][to_state] + buckets_start = date_to_bucket(e.time, now, days) + buckets_end = date_to_bucket(last, now, days) - workload = [ - dict( - group_type=gt, - group_names=group_names[gt], - counts=[ - ( - ad, - [ - ( - group_names[gt][index], - ad.counts[gt][index], - ad.prev[gt][index], - ad.doc_diff[gt][index], - ) - for index in range(len(group_names[gt])) - ], - ) - for ad in ads - ], - sums=[ - ( - group_names[gt][index], - sum([ad.counts[gt][index] for ad in ads]), - sum([ad.prev[gt][index] for ad in ads]), - ) - for index in range(len(group_names[gt])) - ], - ) - for gt in group_types + 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): + ad.buckets[dt][sn][b].append(doc.name) + sums[dt][sn][b].append(doc.name) + last = e.time + break + + # record doc state in the indicated buckets + for b in range(buckets_start, buckets_end): + ad.buckets[dt][sn][b].append(doc.name) + 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)), + "states": [(state, state_name(dt, state)) for state in ad.buckets[dt]], + "ads": ads, + } + for dt in AD_WORKLOAD ] - return render(request, "doc/ad_list.html", {"workload": workload, "delta": delta}) + data = { + dt: {slugify(ad): ad.buckets[dt] for ad in ads} | {"sum": sums[dt]} + for dt in AD_WORKLOAD + } + + return render( + request, + "doc/ad_list.html", + {"metadata": metadata, "data": data, "delta": days}, + ) + def docs_for_ad(request, name): + def sort_key(doc): + key = list(AD_WORKLOAD.keys()).index(doc_type(doc)) + return key + ad = None - responsible = Document.objects.values_list('ad', flat=True).distinct() - for p in Person.objects.filter(Q(role__name__in=("pre-ad", "ad"), - role__group__type="area", - role__group__state="active") - | Q(pk__in=responsible)).distinct(): + responsible = Document.objects.values_list("ad", flat=True).distinct() + for p in Person.objects.filter( + Q( + role__name__in=("pre-ad", "ad"), + role__group__type="area", + role__group__state="active", + ) + | Q(pk__in=responsible) + ).distinct(): if name == p.full_name_as_key(): ad = p break if not ad: raise Http404 - form = SearchForm({'by':'ad','ad': ad.id, - 'rfcs':'on', 'activedrafts':'on', 'olddrafts':'on', - 'sort': 'status', - 'doctypes': list(DocTypeName.objects.filter(used=True).exclude(slug__in=('draft','liai-att')).values_list("pk", flat=True))}) - results, meta = prepare_document_table(request, retrieve_search_results(form), form.data, max_results=500) - results.sort(key=ad_dashboard_sort_key) + + results, meta = prepare_document_table(request, Document.objects.filter(ad=ad)) + results.sort(key=lambda d: sort_key(d)) del meta["headers"][-1] # filter out some results @@ -706,28 +554,37 @@ def docs_for_ad(request, name): ] for d in results: - d.search_heading = ad_dashboard_group(d) + dt = d.type.slug + d.search_heading = state_name(dt, doc_state(d), shorten=False) + if d.search_heading != "RFC": + d.search_heading += f" {doc_type_name(dt)}" # Additional content showing docs with blocking positions by this AD, # and docs that the AD hasn't balloted on that are lacking ballot positions to progress blocked_docs = [] not_balloted_docs = [] if ad in get_active_ads(): - iesg_docs = Document.objects.filter(Q(states__type="draft-iesg", - states__slug__in=IESG_BALLOT_ACTIVE_STATES) | - Q(states__type="charter", - states__slug__in=IESG_CHARTER_ACTIVE_STATES) | - Q(states__type__in=("statchg", "conflrev"), - states__slug__in=IESG_STATCHG_CONFLREV_ACTIVE_STATES)).distinct() - possible_docs = iesg_docs.filter(docevent__ballotpositiondocevent__pos__blocking=True, - docevent__ballotpositiondocevent__balloter=ad) + iesg_docs = Document.objects.filter( + Q(states__type="draft-iesg", states__slug__in=IESG_BALLOT_ACTIVE_STATES) + | Q(states__type="charter", states__slug__in=IESG_CHARTER_ACTIVE_STATES) + | Q( + states__type__in=("statchg", "conflrev"), + states__slug__in=IESG_STATCHG_CONFLREV_ACTIVE_STATES, + ) + ).distinct() + possible_docs = iesg_docs.filter( + docevent__ballotpositiondocevent__pos__blocking=True, + docevent__ballotpositiondocevent__balloter=ad, + ) for doc in possible_docs: ballot = doc.active_ballot() if not ballot: continue blocking_positions = [p for p in ballot.all_positions() if p.pos.blocking] - if not blocking_positions or not any( p.balloter==ad for p in blocking_positions ): + if not blocking_positions or not any( + p.balloter == ad for p in blocking_positions + ): continue augment_events_with_revision(doc, blocking_positions) @@ -739,7 +596,12 @@ def docs_for_ad(request, name): # latest first if blocked_docs: - blocked_docs.sort(key=lambda d: min(p.time for p in d.blocking_positions if p.balloter==ad), reverse=True) + blocked_docs.sort( + key=lambda d: min( + p.time for p in d.blocking_positions if p.balloter == ad + ), + reverse=True, + ) possible_docs = iesg_docs.exclude( Q(docevent__ballotpositiondocevent__balloter=ad) @@ -750,7 +612,7 @@ def docs_for_ad(request, name): not ballot or doc.get_state_slug("draft") == "repl" or doc.get_state_slug("draft-iesg") == "defer" - or (doc.telechat_date() and doc.telechat_date() > timezone.now().date()) + or not doc.previous_telechat_date() ): continue @@ -760,9 +622,19 @@ def docs_for_ad(request, name): if re.search(r"\bNeeds\s+\d+", iesg_ballot_summary): not_balloted_docs.append(doc) - return render(request, 'doc/drafts_for_ad.html', { - 'form':form, 'docs':results, 'meta':meta, 'ad_name': ad.plain_name(), 'blocked_docs': blocked_docs, 'not_balloted_docs': not_balloted_docs - }) + return render( + request, + "doc/drafts_for_ad.html", + { + "docs": results, + "meta": meta, + "ad_name": ad.name, + "blocked_docs": blocked_docs, + "not_balloted_docs": not_balloted_docs, + }, + ) + + def drafts_in_last_call(request): lc_state = State.objects.get(type="draft-iesg", slug="lc").pk form = SearchForm({'by':'state','state': lc_state, 'rfcs':'on', 'activedrafts':'on'}) diff --git a/ietf/static/css/highcharts.scss b/ietf/static/css/highcharts.scss index c556f4e11..d2f5d5e0e 100644 --- a/ietf/static/css/highcharts.scss +++ b/ietf/static/css/highcharts.scss @@ -1 +1,6 @@ @import "npm:highcharts/css/highcharts.css"; +@import "custom-bs-import"; + +.highcharts-container { + font-family: $font-family-sans-serif; +} diff --git a/ietf/static/js/highcharts.js b/ietf/static/js/highcharts.js index 268f96e50..6c3b68051 100644 --- a/ietf/static/js/highcharts.js +++ b/ietf/static/js/highcharts.js @@ -6,6 +6,8 @@ import Highcharts_Export_Data from "highcharts/modules/export-data"; import Highcharts_Accessibility from "highcharts/modules/accessibility"; import Highcharts_Sunburst from "highcharts/modules/sunburst"; +document.documentElement.style.setProperty("--highcharts-background-color", "transparent"); + Highcharts_Exporting(Highcharts); Highcharts_Offline_Exporting(Highcharts); Highcharts_Export_Data(Highcharts); @@ -27,7 +29,7 @@ window.Highcharts = Highcharts; window.group_stats = function (url, chart_selector) { $.getJSON(url, function (data) { $(chart_selector) - .each(function (i, e) { + .each(function (_, e) { const dataset = e.dataset.dataset; if (!dataset) { console.log("dataset data attribute not set"); diff --git a/ietf/static/js/highstock.js b/ietf/static/js/highstock.js index 15ddb5ef9..05b1250ed 100644 --- a/ietf/static/js/highstock.js +++ b/ietf/static/js/highstock.js @@ -5,6 +5,8 @@ import Highcharts_Offline_Exporting from "highcharts/modules/offline-exporting"; import Highcharts_Export_Data from "highcharts/modules/export-data"; import Highcharts_Accessibility from"highcharts/modules/accessibility"; +document.documentElement.style.setProperty("--highcharts-background-color", "transparent"); + Highcharts_Exporting(Highcharts); Highcharts_Offline_Exporting(Highcharts); Highcharts_Export_Data(Highcharts); diff --git a/ietf/templates/doc/ad_count.html b/ietf/templates/doc/ad_count.html deleted file mode 100644 index f69b0cc83..000000000 --- a/ietf/templates/doc/ad_count.html +++ /dev/null @@ -1,33 +0,0 @@ -{# Copyright The IETF Trust 2015, All Rights Reserved #} -{% load ietf_filters %} - -{% if prev or count %} - {{ count }} - {% if user|has_role:"Area Director,Secretariat" %} - {{ d.name }} - {% endfor %} - - {% endif %}" - {% endif %} - {% with label.2 as up_is_good %} - {% if prev < count %} - class="bi bi-arrow-up-right-circle{% if count %}-fill{% endif %} {{ up_is_good|yesno:'text-success,text-danger,text-body-secondary' }}" - {% elif prev > count %} - class="bi bi-arrow-down-right-circle{% if count %}-fill{% endif %} {{ up_is_good|yesno:'text-danger,text-success,text-body-secondary' }}" - {% else %} - class="bi bi-arrow-right-circle text-body-secondary" - {% endif %} - > - {% endwith %} - {% endif %} -{% endif %} \ No newline at end of file diff --git a/ietf/templates/doc/ad_list.html b/ietf/templates/doc/ad_list.html index 189754e8a..cfc8830e5 100644 --- a/ietf/templates/doc/ad_list.html +++ b/ietf/templates/doc/ad_list.html @@ -3,42 +3,58 @@ {% load origin static %} {% load ietf_filters %} {% block pagehead %} - + + {% endblock %} -{% block title %}Area directors{% endblock %} +{% block morecss %} + table .border-bottom { border-bottom-color: var(--highcharts-neutral-color-80) !important; } + .highcharts-container .highcharts-axis-labels { font-size: .7rem; } + .highcharts-container .highcharts-graph { stroke-width: 2.5; } + .highcharts-container .highcharts-color-0 { + fill: var(--bs-primary); + stroke: var(--bs-primary); + } + .highcharts-container .highcharts-data-label text { + font-size: 1rem; + font-weight: inherit; + } +{% endblock %} +{% block title %}IESG Dashboard{% endblock %} {% block content %} {% origin %} -
Area Director | - {% for g, desc, up_is_good in group.group_names %} -
- {{ g|split:'/'|join:'/
+
+ {{ state_name|split:'/'|join:'/ |
{% endfor %}
|
---|---|---|
{{ ad.name }} | - {% for label, count, prev, docs_delta in ad_data %} -- {% include 'doc/ad_count.html' %} + {% for state, state_name in dt.states %} + | + | {% endfor %}
Sum | - {% for label, count, prev in group.sums %} -- {% include 'doc/ad_count.html' %} + {% for state, state_name in dt.states %} + | + | {% endfor %}