Repository: ietf-tools/datatracker

Issue:
Logic to indicate downrefs from BCPs appears broken
### Describe the issue

The datatracker is indicating that references from BCPs to Proposed Standards and Draft Standards are downrefs.

For example, see these reference lists from three example BCPs or BCPs-to-be:
https://datatracker.ietf.org/doc/draft-ietf-tsvwg-ecn-encap-guidelines/references/
https://datatracker.ietf.org/doc/draft-ietf-dnsop-avoid-fragmentation/references/
https://datatracker.ietf.org/doc/rfc7141/references/

Other systems that depend on these incorrect downref categorizations are also affected, e.g. I first notice this issue in the last call announcement for draft-ietf-tsvwg-ecn-encap-guidelines

However, 1 of the 4 downrefs listed on the references page for ecn-encap-guidelines (rfc3168), was not listed in the last call announcement, even though it had the same maturity status as the others (PS). 
See https://datatracker.ietf.org/doc/draft-ietf-tsvwg-ecn-encap-guidelines/history/
Might this indicate another bug unmasked by the first bug?


### 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:

Resolving patch:
diff --git a/ietf/doc/models.py b/ietf/doc/models.py
--- a/ietf/doc/models.py
+++ b/ietf/doc/models.py
@@ -676,39 +676,59 @@ def __str__(self):
         return u"%s %s %s" % (self.source.name, self.relationship.name.lower(), self.target.name)
 
     def is_downref(self):
-
-        if self.source.type.slug!='draft' or self.relationship.slug not in ['refnorm','refold','refunk']:
+        if self.source.type.slug != "draft" or self.relationship.slug not in [
+            "refnorm",
+            "refold",
+            "refunk",
+        ]:
             return None
 
         state = self.source.get_state()
-        if state and state.slug == 'rfc':
+        if state and state.slug == "rfc":
             source_lvl = self.source.std_level.slug if self.source.std_level else None
         elif self.source.intended_std_level:
             source_lvl = self.source.intended_std_level.slug
         else:
             source_lvl = None
 
-        if source_lvl not in ['bcp','ps','ds','std']:
+        if source_lvl not in ["bcp", "ps", "ds", "std", "unkn"]:
             return None
 
-        if self.target.document.get_state().slug == 'rfc':
+        if self.target.document.get_state().slug == "rfc":
             if not self.target.document.std_level:
-                target_lvl = 'unkn'
+                target_lvl = "unkn"
             else:
                 target_lvl = self.target.document.std_level.slug
         else:
             if not self.target.document.intended_std_level:
-                target_lvl = 'unkn'
+                target_lvl = "unkn"
             else:
                 target_lvl = self.target.document.intended_std_level.slug
 
-        rank = { 'ps':1, 'ds':2, 'std':3, 'bcp':3 }
+        if self.relationship.slug not in ["refnorm", "refunk"]:
+            return None
+
+        if source_lvl in ["inf", "exp"]:
+            return None
 
-        if ( target_lvl not in rank ) or ( rank[target_lvl] < rank[source_lvl] ):
-            if self.relationship.slug == 'refnorm' and target_lvl!='unkn':
-                return "Downref"
-            else:
-                return "Possible Downref"
+        pos_downref = (
+            "Downref" if self.relationship.slug != "refunk" else "Possible Downref"
+        )
+
+        if source_lvl in ["bcp", "ps", "ds", "std"] and target_lvl in ["inf", "exp"]:
+            return pos_downref
+
+        if source_lvl == "ds" and target_lvl == "ps":
+            return pos_downref
+
+        if source_lvl == "std" and target_lvl in ["ps", "ds"]:
+            return pos_downref
+
+        if source_lvl not in ["inf", "exp"] and target_lvl == "unkn":
+            return "Possible Downref"
+
+        if source_lvl == "unkn" and target_lvl in ["ps", "ds"]:
+            return "Possible Downref"
 
         return None
 


Test patch:
diff --git a/ietf/doc/tests_models.py b/ietf/doc/tests_models.py
new file mode 100644
--- /dev/null
+++ b/ietf/doc/tests_models.py
@@ -0,0 +1,113 @@
+# Copyright The IETF Trust 2016-2023, All Rights Reserved
+# -*- coding: utf-8 -*-
+
+import itertools
+
+from ietf.doc.factories import WgRfcFactory
+from ietf.doc.models import RelatedDocument
+from ietf.utils.test_utils import TestCase
+
+
+class RelatedDocumentTests(TestCase):
+    def test_is_downref(self):
+        rfcs = [
+            WgRfcFactory(std_level_id=lvl)
+            for lvl in ["inf", "exp", "bcp", "ps", "ds", "std", "unkn"]
+        ]
+
+        result_matrix = {
+            # source
+            "inf": {
+                "inf": None,  # target
+                "exp": None,  # target
+                "bcp": None,  # target
+                "ps": None,  # target
+                "ds": None,  # target
+                "std": None,  # target
+                "unkn": None,  # target
+            },
+            # source
+            "exp": {
+                "inf": None,  # target
+                "exp": None,  # target
+                "bcp": None,  # target
+                "ps": None,  # target
+                "ds": None,  # target
+                "std": None,  # target
+                "unkn": None,  # target
+            },
+            # source
+            "bcp": {
+                "inf": "Downref",  # target
+                "exp": "Downref",  # target
+                "bcp": None,  # target
+                "ps": None,  # target
+                "ds": None,  # target
+                "std": None,  # target
+                "unkn": "Possible Downref",  # target
+            },
+            # source
+            "ps": {
+                "inf": "Downref",  # target
+                "exp": "Downref",  # target
+                "bcp": None,  # target
+                "ps": None,  # target
+                "ds": None,  # target
+                "std": None,  # target
+                "unkn": "Possible Downref",  # target
+            },
+            # source
+            "ds": {
+                "inf": "Downref",  # target
+                "exp": "Downref",  # target
+                "bcp": None,  # target
+                "ps": "Downref",  # target
+                "ds": None,  # target
+                "std": None,  # target
+                "unkn": "Possible Downref",  # target
+            },
+            # source
+            "std": {
+                "inf": "Downref",  # target
+                "exp": "Downref",  # target
+                "bcp": None,  # target
+                "ps": "Downref",  # target
+                "ds": "Downref",  # target
+                "std": None,  # target
+                "unkn": "Possible Downref",  # target
+            },
+            # source
+            "unkn": {
+                "inf": None,  # target
+                "exp": None,  # target
+                "bcp": None,  # target
+                "ps": "Possible Downref",  # target
+                "ds": "Possible Downref",  # target
+                "std": None,  # target
+                "unkn": "Possible Downref",  # target
+            },
+        }
+
+        for rel in ["refnorm", "refinfo", "refunk", "refold"]:
+            for source, target in itertools.product(rfcs, rfcs):
+                ref = RelatedDocument.objects.create(
+                    source=source,
+                    target=target.docalias.first(),
+                    relationship_id=rel,
+                )
+
+                result = ref.is_downref()
+
+                desired_result = (
+                    result_matrix[source.std_level_id][target.std_level_id]
+                    if ref.relationship.slug in ["refnorm", "refunk"]
+                    else None
+                )
+                if (
+                    ref.relationship.slug == "refunk"
+                    and desired_result is not None
+                    and not desired_result.startswith("Possible")
+                ):
+                    desired_result = f"Possible {desired_result}"
+
+                self.assertEqual(desired_result, result)