Repository: ietf-tools/datatracker

Issue:
States in Conflict Review listing of AD Dashboard are not in the right order
### Describe the issue

In the "Conflict Review State Counts" table of the AD Dashboard (https://datatracker.ietf.org/doc/ad), the counts of conflict per state are listed.  The columns, each representing a state, should be in the progression by which the states are used.  A conflict starts with "Needs Shepherd" --> "AD Review" --> "IESG Evaluation", etc.  To that end, "Needs Shepherd" should come before "AD Review" (i.e., swap the order of these two columns).

### Code of Conduct

- [X] I agree to follow the [IETF's Code of Conduct](https://github.com/ietf-tools/.github/blob/main/CODE_OF_CONDUCT.md)
https://datatracker.ietf.org/doc/ad/ needs help text
### Description

The AD status page is probably useful to ADs.  It is opaque to those who haven't been on the IESG.  I suggest that each table title (I-D State Counts, etc) be an anchored link into a file explaining what the columns mean.

### Code of Conduct

- [X] I agree to follow the [IETF's Code of Conduct](https://github.com/ietf-tools/.github/blob/main/CODE_OF_CONDUCT.md)


Discussion:
code should be leveraging this:
```
>>> State.objects.filter(type="conflrev").order_by("order").values_list("slug","order")
<QuerySet [('needshep', 1), ('adrev', 2), ('iesgeval', 3), ('defer', 4), ('appr-reqnopub-pr', 5), ('appr-noprob-pr', 6), ('appr-reqnopub-pend', 7), ('appr-noprob-pend', 8), ('appr-reqnopub-sent', 9), ('appr-noprob-sent', 10), ('withdraw', 11), ('dead', 12)]>
```



Resolving patch:
diff --git a/ietf/doc/utils_search.py b/ietf/doc/utils_search.py
--- a/ietf/doc/utils_search.py
+++ b/ietf/doc/utils_search.py
@@ -9,7 +9,7 @@
 
 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 num(i):
             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
--- 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.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.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 cached_redirect(cache_key, url):
 
     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"
-    else:
-        return "Document"
-
-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')
+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 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):
-
-    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
-
-    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
+STATE_SLUGS = {
+    dt: {state_name(dt, ds, shorten=False): ds for ds in AD_WORKLOAD[dt]}  # type: ignore
+    for dt in AD_WORKLOAD
+}
+
+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)
+    )
 
 
 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)
-
-        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)
-
-                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))
-
-                ad.counts[group_type][groups[group_type][group]] += 1
-                ad.doc_now[group_type][groups[group_type][group]].add(doc)
-
-                last_state_event = (
-                    doc.docevent_set.filter(
-                        Q(type="started_iesg_process") | Q(type="changed_state")
+        ad.buckets = copy.deepcopy(bucket_template)
+
+        for doc in Document.objects.filter(ad=ad):
+            dt = doc_type(doc)
+            state = doc_state(doc)
+
+            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")
+
+            # 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
+
+                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)
+
+                # 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"
+
+                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
+
+                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):
+                            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
+    ]
 
-    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)
-
-            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]
-
-    # 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,
-            )
+    data = {
+        dt: {slugify(ad): ad.buckets[dt] for ad in ads} | {"sum": sums[dt]}
+        for dt in AD_WORKLOAD
+    }
 
-    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
-    ]
+    return render(
+        request,
+        "doc/ad_list.html",
+        {"metadata": metadata, "data": data, "delta": days},
+    )
 
-    return render(request, "doc/ad_list.html", {"workload": workload, "delta": delta})
 
 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'})


Test patch:
diff --git a/ietf/doc/tests.py b/ietf/doc/tests.py
--- a/ietf/doc/tests.py
+++ b/ietf/doc/tests.py
@@ -36,15 +36,14 @@
 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 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 @@ def test_frontpage(self):
         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 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')
+                    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")]
+                        )
+
+                    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