feat: Revamp AD dashboard (#6534)

* fix: Reorder conflict review columns

Fixes #6528

Also remove some redundant computation while I'm here.

* Remove some more stuff that isn't needed

* Progress

* Delivers current functionality

* Add some comments

* Handle expired docs

* Interim commit

* Fix tests

* Cleanup

* More cleanup

* Reduce differences to current view

* Interim commit

* More progress

* Getting close

* Make page functional again

* Remove unused variable

* Suppress mypy warning

* Fix #6553

* Log in as secretary to execute new code, and remove redundant check

* Remove unneeded code

* Fix #6608 by adding link to state description to state heading

* Missed part of this change in last commit.

Also fix an unrelated template nit while I'm here.
This commit is contained in:
Lars Eggert 2023-11-15 19:25:30 +02:00 committed by GitHub
parent 5eb2f568a0
commit c36f63baf9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 484 additions and 410 deletions

View file

@ -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

View file

@ -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

View file

@ -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'})

View file

@ -1 +1,6 @@
@import "npm:highcharts/css/highcharts.css";
@import "custom-bs-import";
.highcharts-container {
font-family: $font-family-sans-serif;
}

View file

@ -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");

View file

@ -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);

View file

@ -1,33 +0,0 @@
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load ietf_filters %}
{% if prev or count %}
<span{% if count == 0 %} class="text-body-secondary"{% endif %}>{{ count }}</span>
{% if user|has_role:"Area Director,Secretariat" %}
<i data-bs-toggle="popover"
{% if count != prev %}
data-bs-content="
<div class='mb-2 fw-bold'>
{{ delta.days }} days ago, the count was {{ prev }}.
</div>
{% if docs_delta %}
{{ group.group_type }}s in the delta are:
<ul>
{% for d in docs_delta %}
<li><a href='{% url "ietf.doc.views_doc.document_main" d.name %}'>{{ d.name }}</a></li>
{% endfor %}
</ul>
{% 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 %}
></i>
{% endwith %}
{% endif %}
{% endif %}

View file

@ -3,42 +3,58 @@
{% load origin static %}
{% load ietf_filters %}
{% block pagehead %}
<link rel="stylesheet" href="{% static "ietf/css/list.css" %}">
<link rel="stylesheet" href="{% static 'ietf/css/list.css' %}">
<link rel="stylesheet" href="{% static 'ietf/css/highcharts.css' %}">
{% 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 %}
<h1>Area Director Workload</h1>
<h1>IESG Dashboard</h1>
{% if user|has_role:"Area Director,Secretariat" %}
<div class="alert alert-info my-3">
{{ delta.days }}-day trend indicators
{{ delta }}-day trend graphs
are only shown to logged-in Area Directors.
</div>
{% endif %}
{% for group in workload %}
<h2 class="mt-5" id="{{ group.group_type|slugify }}">{{ group.group_type }} State Counts</h2>
<table class="table table-sm table-striped table-bordered tablesorter">
{% for dt in metadata %}
<h2 class="mt-5" id="{{ dt.type.0 }}">{{ dt.type.1 }} State Counts</h2>
<table class="table table-sm table-striped table-bordered tablesorter navskip">
<thead class="wrap-anywhere">
<tr>
<th scope="col" data-sort="name">Area Director</th>
{% for g, desc, up_is_good in group.group_names %}
<th scope="col" class="col-1" title="{{ desc }}"
data-sort="{{ g|slugify }}-num">
{{ g|split:'/'|join:'/<wbr>' }}
{% for state, state_name in dt.states %}
<th scope="col" class="col-1" title=""
data-sort="{{ state }}-num">
<a href="{% url 'ietf.doc.views_help.state_help' type='draft-iesg' %}#{{ state }}">
{{ state_name|split:'/'|join:'/<wbr>' }}
</a>
</th>
{% endfor %}
</tr>
</thead>
<tbody class="table-group-divider">
{% for ad, ad_data in group.counts %}
{% for ad in dt.ads %}
<tr>
<td>
<a href="{{ ad.dashboard }}">{{ ad.name }}</a>
</td>
{% for label, count, prev, docs_delta in ad_data %}
<td class="col-1"
id="{{ group.group_type|slugify }}-{{ ad.full_name_as_key|slugify }}-{{ label.0|slugify }}">
{% include 'doc/ad_count.html' %}
{% for state, state_name in dt.states %}
<td class="col-1 align-bottom"
id="{{ dt.type.0 }}-{{ ad|slugify }}-{{ state }}">
<div id="chart-{{ dt.type.0 }}-{{ ad|slugify }}-{{ state }}"></div>
</td>
{% endfor %}
</tr>
@ -47,9 +63,9 @@
<tfoot class="table-group-divider">
<tr>
<th scope="row">Sum</th>
{% for label, count, prev in group.sums %}
<td>
{% include 'doc/ad_count.html' %}
{% for state, state_name in dt.states %}
<td class="align-bottom">
<div id="chart-{{ dt.type.0 }}-sum-{{ state }}"></div>
</td>
{% endfor %}
</tr>
@ -69,4 +85,114 @@
});
});
</script>
<script src="{% static "ietf/js/highcharts.js" %}"></script>
{{ data|json_script:"data" }}
<script>
const data = JSON.parse(document.getElementById("data").textContent);
Object.entries(data).forEach(([dt, ads]) => {
max = {};
Object.entries(ads).forEach(([ad, states]) => {
Object.entries(states).forEach(([state, buckets]) => {
buckets.series = buckets.map((x) => x.length);
if (ad != "sum") {
max[state] = Math.max(...buckets.series,
max[state] ? max[state] : 0);
}
});
});
Object.entries(ads).forEach(([ad, states]) => {
Object.entries(states).forEach(([state, buckets]) => {
const cell = `chart-${dt}-${ad}-${state}`;
// if there is only a single datapoint in the
// bucket, display it without a graph
if (buckets.series.length == 1) {
document.getElementById(cell).innerHTML =
buckets.series[0];
return;
}
// if a bucket has all zeroes, fake a Highcharts
// plot with HTML, to reduce the number of plot
// objects on the page
if (buckets.series.every((x) => x == 0)) {
// document.getElementById(cell).innerHTML = `
// <div class="position-relative">
// <div class="position-absolute bottom-0 start-0">
// <div style="font-size: .7rem;" class="ms-1">0</div>
// </div>
// <div class="position-absolute bottom-0 end-0 w-100 ps-1">
// <div class="border-bottom mb-1 ms-3">0</div>
// </div>
// </div>
// `;
return;
}
// else actually create a graph
const ymax = Math.max(1, ad != "sum" ? max[state] : Math.max(...buckets.series));
Highcharts.chart({
title: { text: undefined },
chart: {
type: "line",
animation: false,
renderTo: cell,
panning: { enabled: false },
spacing: [4, 0, 5, 0],
height: "45%",
},
scrollbar: { enabled: false },
tooltip: { enabled: false },
navigator: { enabled: false },
exporting: { enabled: false },
legend: { enabled: false },
credits: { enabled: false },
xAxis: {
title: { text: undefined},
labels: { enabled: false },
zoomEnabled: false,
tickLength: 0,
},
yAxis: {
title: { text: undefined},
zoomEnabled: false,
tickLength: 0,
labels: { x: -3 },
min: 0,
max: ymax,
tickInterval: ymax,
},
plotOptions: {
series: {
animation: false,
dataLabels: {
enabled: true,
inside: true,
padding: 0,
formatter: function() {
// only label the last
// (= current-day) point in
// the curve with today's
// value
if (this.point.index + 1 ==
this.series.points.length) {
return this.y;
}
return undefined;
}
}
}
},
series: [{
name: undefined,
data: buckets.series,
enableMouseTracking: false,
}],
});
});
});
});
</script>
{% endblock %}

View file

@ -1,7 +1,6 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin static ietf_filters textfilters %}
%}
{% block pagehead %}
<link rel="stylesheet" href="{% static "ietf/css/list.css" %}">
{% endblock %}