Repository: ietf-tools/datatracker

Issue:
AD milestones page crashes if any milestones have no due date
### Describe the issue

The milestones view at `/iesg/milestones/` throws a server error if there are `GroupMilestone` instances with null `due` fields in the display list. The error is
```
TypeError at /iesg/milestones/

'<' not supported between instances of 'NoneType' and 'NoneType'

Request Method: 	GET
Request URL: 	https://datatracker.ietf.org/iesg/milestones/
Django Version: 	4.2.6
Exception Type: 	TypeError
Exception Value: 	

'<' not supported between instances of 'NoneType' and 'NoneType'

Exception Location: 	/a/www/ietf-datatracker/11.14.0/ietf/iesg/views.py, line 529, in milestones_needing_review
```

The specific problem is that the sorting code for milestones assumes `due` is a valid sorting key. Filling in that field for the two milestones currently without it avoids this bug. The obvious fix is to apply the standard ordering on milestones (`order`, `due`, `id`) for the model rather than only `due`.

### 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:
cc: @rdanyliw


Resolving patch:
diff --git a/ietf/iesg/views.py b/ietf/iesg/views.py
--- a/ietf/iesg/views.py
+++ b/ietf/iesg/views.py
@@ -526,7 +526,9 @@ def milestones_needing_review(request):
         ad_list.append(ad)
         ad.groups_needing_review = sorted(groups, key=lambda g: g.acronym)
         for g, milestones in groups.items():
-            g.milestones_needing_review = sorted(milestones, key=lambda m: m.due)
+            g.milestones_needing_review = sorted(
+                milestones, key=lambda m: m.due if m.group.uses_milestone_dates else m.order
+            )
 
     return render(request, 'iesg/milestones_needing_review.html',
                   dict(ads=sorted(ad_list, key=lambda ad: ad.plain_name()),))


Test patch:
diff --git a/ietf/iesg/tests.py b/ietf/iesg/tests.py
--- a/ietf/iesg/tests.py
+++ b/ietf/iesg/tests.py
@@ -20,7 +20,7 @@
 from ietf.doc.models import Document, DocAlias, State, RelatedDocument
 from ietf.doc.factories import WgDraftFactory, IndividualDraftFactory, ConflictReviewFactory, BaseDocumentFactory, CharterFactory, WgRfcFactory, IndividualRfcFactory
 from ietf.doc.utils import create_ballot_if_not_open
-from ietf.group.factories import RoleFactory, GroupFactory
+from ietf.group.factories import RoleFactory, GroupFactory, DatedGroupMilestoneFactory, DatelessGroupMilestoneFactory
 from ietf.group.models import Group, GroupMilestone, Role
 from ietf.iesg.agenda import get_agenda_date, agenda_data, fill_in_agenda_administrivia, agenda_sections
 from ietf.iesg.models import TelechatDate, TelechatAgendaContent
@@ -71,7 +71,50 @@ def test_milestones_needing_review(self):
         r = self.client.get(url)
         self.assertEqual(r.status_code, 200)
         self.assertNotContains(r, m.desc)
+
+    def test_milestones_needing_review_ordering(self):
+        dated_group = GroupFactory(uses_milestone_dates=True)
+        RoleFactory(
+            name_id='ad',
+            group=dated_group,
+            person=Person.objects.get(user__username='ad'),
+        )
+        dated_milestones = DatedGroupMilestoneFactory.create_batch(
+            2, group=dated_group, state_id="review"
+        )
+        dated_milestones[0].due -= datetime.timedelta(days=1)  # make this one earlier
+        dated_milestones[0].save()
+
+        dateless_group = GroupFactory(uses_milestone_dates=False)
+        RoleFactory(
+            name_id='ad',
+            group=dateless_group,
+            person=Person.objects.get(user__username='ad'),
+        )
+        dateless_milestones = DatelessGroupMilestoneFactory.create_batch(
+            2, group=dateless_group, state_id="review"
+        )
+
+        url = urlreverse("ietf.iesg.views.milestones_needing_review")
+        self.client.login(username="ad", password="ad+password")
+        r = self.client.get(url)
+        self.assertEqual(r.status_code, 200)
+        pq = PyQuery(r.content)
         
+        # check order-by-date
+        dated_tbody = pq(f'td:contains("{dated_milestones[0].desc}")').closest("tbody")
+        next_td = dated_tbody.find('td:contains("Next")')
+        self.assertEqual(next_td.siblings()[0].text.strip(), dated_milestones[0].desc)
+        last_td = dated_tbody.find('td:contains("Last")')
+        self.assertEqual(last_td.siblings()[0].text.strip(), dated_milestones[1].desc)
+
+        # check order-by-order
+        dateless_tbody = pq(f'td:contains("{dateless_milestones[0].desc}")').closest("tbody")
+        next_td = dateless_tbody.find('td:contains("Next")')
+        self.assertEqual(next_td.siblings()[0].text.strip(), dateless_milestones[0].desc)
+        last_td = dateless_tbody.find('td:contains("Last")')
+        self.assertEqual(last_td.siblings()[0].text.strip(), dateless_milestones[1].desc)
+
 
     def test_review_decisions(self):
         draft = WgDraftFactory()