Repository: useblocks/sphinx-needs (python)

Issue:
Sphinx-Needs throws exception when "." is used in ID
Sphinx-Needs throws a cryptic python error 

> Extension error (sphinx_needs.directives.need):
Handler <function process_need_nodes at 0x7fc668dc98a0> for event 'doctree-resolved' threw an exception (exception: too many values to unpack (expected 2))

if "." is used in an ID after configuring a regex as follows in `conf.py`

> needs_id_regex = r'.*'

Although it is not a good idea to use "." in IDs, Sphinx-Needs should be robust with such a case.

Related Issue: https://github.com/useblocks/sphinx-needs/issues/852

Pull request description:
🐛 FIX: Centralise splitting of need ID
Ensure the splitting of a need ID, on a `.` into `main.part` IDs, cannot except (and remove duplication of the logic)

closes #1096

Patch:
diff --git a/docs/conf.py b/docs/conf.py
index ea524939..0c8d8188 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -18,9 +18,7 @@ import datetime
 # documentation root, use os.path.abspath to make it absolute, like shown here.
 #
 import os
-from typing import Any, Dict, List
-
-from sphinx.application import Sphinx
+from typing import Any, Dict
 
 from sphinx_needs import __version__
 
@@ -82,43 +80,11 @@ NOTE_TEMPLATE = """
    {% endif %}
 """
 
-EXTRA_CONTENT_TEMPLATE_COLLAPSE = """
-.. _{{id}}:
-
-{% if hide == false -%}
-.. role:: needs_tag
-.. role:: needs_status
-.. role:: needs_type
-.. role:: needs_id
-.. role:: needs_title
-
-.. rst-class:: need
-.. rst-class:: need_{{type_name}}
-
-.. dropdown::
-   :class: need
-
-            :needs_type:`{{type_name}}`: {% if title %}:needs_title:`{{title}}`{% endif %} :needs_id:`{{id}}`
-
-{% if status and  status|upper != "NONE" and not hide_status %}        | status: :needs_status:`{{status}}`{% endif %}
-{% if tags and not hide_tags %}        | tags: :needs_tag:`{{tags|join("` :needs_tag:`")}}`{% endif %}
-{% if my_extra_option != "" %}        | my_extra_option: {{ my_extra_option }}{% endif %}
-{% if another_option != "" %}        | another_option: {{ another_option }}{% endif %}
-        | links incoming: :need_incoming:`{{id}}`
-        | links outgoing: :need_outgoing:`{{id}}`
-
-    {{content|indent(4) }}
-
-{% endif -%}
-"""
-
 DEFAULT_DIAGRAM_TEMPLATE = (
     "<size:12>{{type_name}}</size>\\n**{{title|wordwrap(15, wrapstring='**\\\\n**')}}**\\n<size:10>{{id}}</size>"
 )
 
 # You can uncomment some of the following lines to override the default configuration for Sphinx-Needs.
-
-# needs_template = TITLE_TEMPLATE
 # needs_diagram_template = DEFAULT_DIAGRAM_TEMPLATE
 
 # Absolute path to the needs_report_template_file based on the conf.py directory
@@ -251,7 +217,6 @@ plantuml_output_format = "svg_img"
 needs_table_style = "datatables"
 needs_table_columns = "ID;TITLE;STATUS;OUTGOING"
 
-needs_template_collapse = EXTRA_CONTENT_TEMPLATE_COLLAPSE
 needs_extra_options = [
     "my_extra_option",
     "another_option",
@@ -543,25 +508,6 @@ rst_epilog = """
 html_context = {}
 
 
-def rstjinja(app: Sphinx, _docname: str, source: List[str]) -> None:
-    """
-    Render our pages as a jinja template for fancy templating goodness.
-    """
-    # Make sure we're outputting HTML
-    if app.builder.format != "html" and app.builder.name != "linkcheck":
-        return
-    src = source[0]
-    from jinja2 import Template
-
-    template = Template(src, autoescape=True)
-    rendered = template.render(**app.config.html_context)
-    source[0] = rendered
-
-
-def setup(app: Sphinx) -> None:
-    app.connect("source-read", rstjinja)
-
-
 # LINKCHECK config
 # https://www.sphinx-doc.org/en/master/usage/configuration.html?highlight=linkcheck#options-for-the-linkcheck-builder
 linkcheck_ignore = [
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 7cdd4e61..6d59ab25 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -661,8 +661,6 @@ If you do not set ``needs_report_template``, the default template used is:
 
 .. code-block:: jinja
 
-   {% raw -%}
-
    {# Output for needs_types #}
    {% if types|length != 0 %}
    .. dropdown:: Need Types
@@ -739,7 +737,7 @@ If you do not set ``needs_report_template``, the default template used is:
    {% endif %}
    {# Output for needs metrics #}
 
-   {% endraw %}
+   
 
 The plugin provides the following variables which you can use in your custom Jinja template:
 
@@ -770,13 +768,12 @@ By default the following template is used:
 
 .. code-block:: jinja
 
-    {% raw -%}
     {%- if is_need -%}
     <size:12>{{type_name}}</size>\\n**{{title|wordwrap(15, wrapstring='**\\\\n**')}}**\\n<size:10>{{id}}</size>
     {%- else -%}
     <size:12>{{type_name}} (part)</size>\\n**{{content|wordwrap(15, wrapstring='**\\\\n**')}}**\\n<size:10>{{id_parent}}.**{{id}}**</size>
     {%- endif -%}
-    {% endraw %}
+    
 
 
 .. _needs_id_required:
@@ -1550,7 +1547,7 @@ Default: ``False``.
     needs_service_all_data = True
 
 
-{% raw %}
+
 
 .. _needs_external_needs:
 
@@ -1626,7 +1623,7 @@ keys:
             The related CSS class definition must be done by the user, e.g. by :ref:`own_css`.
             (*optional*) (*default*: ``external_link``)
 
-{% endraw %}
+
 
 .. _needs_needextend_strict:
 
@@ -1718,7 +1715,7 @@ All named capture group values get injected, so that parts of the option-value c
 link name and url.
 
 **Example**:
-{% raw %}
+
 
 .. code-block:: python
 
@@ -1740,7 +1737,7 @@ link name and url.
             'options': ['github']
         }
     }
-{% endraw %}
+
 
 |ex|
 
@@ -1955,7 +1952,7 @@ constraints_results is a dictionary similar in structure to needs_constraints ab
             "critical": {
                 "check_0": "'critical' in tags",
                 "severity": "CRITICAL",
-                "error_message": "need {% raw %}{{id}}{% endraw %} does not fulfill CRITICAL constraint, because tags are {% raw %}{{tags}}{% endraw %}"
+                "error_message": "need {{id}} does not fulfill CRITICAL constraint, because tags are {{tags}}"
             }
         
         }
@@ -2137,8 +2134,6 @@ You can use the data passed via needs_render_context as shown below:
 
 .. code-block:: jinja
 
-    {% raw -%}
-
     .. req:: Need with jinja_content enabled
        :id: JINJA1D8913
        :jinja_content: true
@@ -2153,9 +2148,7 @@ You can use the data passed via needs_render_context as shown below:
             + author[1]
        {% endfor %}
 
-    {% endraw %}
-
-{% raw -%}
+    
 
 .. req:: Need with jinja_content enabled
    :id: JINJA1D8913
@@ -2168,7 +2161,7 @@ You can use the data passed via needs_render_context as shown below:
    * {{ author[0] }} --> ID-{{ author[1] }}
    {% endfor %}
 
-{% endraw %}
+
 
 
 .. _needs_debug_measurement:
@@ -2205,8 +2198,6 @@ If nothing is set, the following default template is used:
 
 .. code-block:: jinja
 
-   {% raw -%}
-
    .. _{{id}}:
 
    {% if hide == false -%}
@@ -2236,7 +2227,7 @@ If nothing is set, the following default template is used:
 
    {% endif -%}
 
-   {% endraw %}
+   
 
 Available jinja variables are:
 
@@ -2254,7 +2245,7 @@ Available jinja variables are:
 
 .. warning::
 
-   You must add a reference like `.. _{{ '{{id}}' }}:` to the template. Otherwise linking will not work!
+   You must add a reference like `.. _{{id}}:` to the template. Otherwise linking will not work!
 
 .. _needs_template_collapse:
 
@@ -2268,8 +2259,6 @@ Default value:
 
 .. code-block:: jinja
 
-    {% raw -%}
-
     .. _{{id}}:
 
     {% if hide == false -%}
@@ -2298,7 +2287,7 @@ Default value:
        {{content|indent(4) }}
 
    {% endif -%}
-   {% endraw %}
+   
 
 For more details please see :ref:`needs_template`.
 
diff --git a/docs/directives/need.rst b/docs/directives/need.rst
index c671e712..f3a4222a 100644
--- a/docs/directives/need.rst
+++ b/docs/directives/need.rst
@@ -197,7 +197,7 @@ and stores its PlantUML code under given key from :ref:`needuml` directive under
 
 This diagram data can then be used in other :ref:`needuml` calls to combine and reuse PlantUML elements.
 
-{% raw %}
+
 
 |ex|
 
@@ -251,7 +251,7 @@ Reuse of :need:`SP_INT` inside a :ref:`needuml`:
 
    system => int_a
 
-{% endraw %}
+
 
 This simple mechanism is really powerful to design reusable and configurable SW architecture diagrams.
 For more examples and details, please read :ref:`needuml`.
@@ -553,7 +553,6 @@ Default: False
 
 .. code-block:: jinja
 
-    {% raw -%}
     .. req:: First Req Need
        :id: JINJAID123
        :jinja_content: false
@@ -584,12 +583,10 @@ Default: False
        Need with ``:jinja_content:`` equal to ``true``.
        This requirement has status: **{{ status }}**.
 
-    {% endraw %}
+    
 
 |out|
 
-{% raw -%}
-
 .. req:: First Req Need
    :id: JINJAID123
    :jinja_content: false
@@ -620,7 +617,7 @@ Default: False
    Need with ``:jinja_content:`` equal to ``true``.
    This requirement has status: **{{ status }}**.
 
-{% endraw %}
+
 
 .. _title_from_content:
 
diff --git a/docs/directives/needarch.rst b/docs/directives/needarch.rst
index 82fc74e4..383bd1e6 100644
--- a/docs/directives/needarch.rst
+++ b/docs/directives/needarch.rst
@@ -1,4 +1,4 @@
-{% raw %}
+
 
 .. _needarch:
 
@@ -256,4 +256,4 @@ does detect different parameter sets and does import `uml()` calls with differen
       {{uml('COMP_T_002')}}
       {% endif %}
 
-{% endraw %}
+
diff --git a/docs/directives/needreport.rst b/docs/directives/needreport.rst
index 97c5c433..a5e02e21 100644
--- a/docs/directives/needreport.rst
+++ b/docs/directives/needreport.rst
@@ -21,6 +21,16 @@ The :ref:`needs_report_template` value is a path to the
 `jinja2 <https://jinja.palletsprojects.com/en/2.11.x/templates/>`_ template file.
 You can use the template file to customise the content generated  by ``needreport``.
 
+.. note::
+
+   The default needs report template is set to use ``dropdown`` directives for containing each configuration type, which requires the ``dropdown`` directive to be available in your Sphinx environment. If you do not have the ``dropdown`` directive available, you can use the following configuration to set the default needs report template to use ``admonition`` directives instead:
+
+   .. code-block:: python
+
+      needs_render_context = {
+         "report_directive": "admonition",
+      }
+
 |ex|
 
 .. code-block:: rst
diff --git a/docs/directives/needuml.rst b/docs/directives/needuml.rst
index 7670839b..d5683b2c 100644
--- a/docs/directives/needuml.rst
+++ b/docs/directives/needuml.rst
@@ -1,4 +1,4 @@
-{% raw %}
+
 
 .. _needuml:
 
@@ -741,4 +741,4 @@ NeedUml Examples
 
       class_x o-- class_y
 
-{% endraw %}
+
diff --git a/docs/layout_styles.rst b/docs/layout_styles.rst
index 61fb39b3..0d0b613c 100644
--- a/docs/layout_styles.rst
+++ b/docs/layout_styles.rst
@@ -287,13 +287,13 @@ The head-line for the default Sphinx-Needs layout ``clean`` looks like this::
 You are free to surround a layout function with a rst role. Like ``**<<meta("title")>>**`` to get a bold printed title.
 
 Sometimes an argument for a layout function shall be based on a given need option. In this cases the option name
-can be surrounded by ``{%raw%}{{ .. }}{%endraw%}``.
+can be surrounded by ``{{ .. }}``.
 As example, there may be an ``author`` option in a bug-need and you want to show a picture of the author in the grid
 ``simple_side_right_partial``.
 
 The line for the ``side`` area could look like::
 
-   '<<image("_images/{%raw%}{{author}}{%endraw%}.png", align="center")>>'
+   '<<image("_images/{{author}}.png", align="center")>>'
 
 .. spec:: My test spec
    :author: daniel
@@ -326,7 +326,7 @@ Here is the complete used code::
                'head': ['**<<meta("title")>>** for *<<meta("author")>>*'],
                'meta': ['**status**: <<meta("status")>>',
                         '**author**: <<meta("author")>>'],
-               'side': ['<<image("_images/{%raw%}{{author}}{%endraw%}.png", align="center")>>']
+               'side': ['<<image("_images/{{author}}.png", align="center")>>']
            }
        }
    }
diff --git a/docs/roles.rst b/docs/roles.rst
index 5b7915c3..9bb4c749 100644
--- a/docs/roles.rst
+++ b/docs/roles.rst
@@ -202,21 +202,9 @@ They are also getting the part_id as link description.
 
 |out|
 
-{% if READTHEDOCS %}
-..
-
-.. image:: _static/need_part_as_flow.png
-   :align: center
-
-{% else %}
-
 .. needflow::
    :filter: id in ["my_car_1","impl_my_car_1"]
 
-{% endif %}
-
-
-
 **Presentation in needtable**
 
 Please see :ref:`needtable_show_parts` of :ref:`needtable` configuration documentation.
diff --git a/docs/services/open_needs.rst b/docs/services/open_needs.rst
index 5b5f7297..f79a0e3c 100644
--- a/docs/services/open_needs.rst
+++ b/docs/services/open_needs.rst
@@ -1,6 +1,6 @@
 .. _open_needs_service:
 
-{% raw %}
+
 
 Open-Needs services
 ===================
@@ -199,7 +199,7 @@ The logic and syntax is the same as used by `mapping <#mapping>`_.
    :language: python
    :lines: 329-346
 
-{% endraw %}
+
 
 Examples
 --------
@@ -226,4 +226,4 @@ Examples
 
 .. image:: /_images/ons_table.png
    :align: center
-   :width: 60%
\ No newline at end of file
+   :width: 60%
diff --git a/sphinx_needs/directives/need.py b/sphinx_needs/directives/need.py
index b5b8a3f6..202b0f07 100644
--- a/sphinx_needs/directives/need.py
+++ b/sphinx_needs/directives/need.py
@@ -28,7 +28,7 @@ from sphinx_needs.layout import build_need
 from sphinx_needs.logging import get_logger
 from sphinx_needs.need_constraints import process_constraints
 from sphinx_needs.nodes import Need
-from sphinx_needs.utils import add_doc, profile, remove_node_from_tree
+from sphinx_needs.utils import add_doc, profile, remove_node_from_tree, split_need_id
 
 logger = get_logger(__name__)
 
@@ -440,17 +440,14 @@ def check_links(needs: Dict[str, NeedsInfoType], config: NeedsSphinxConfig) -> N
     for need in needs.values():
         for link_type in extra_links:
             dead_links_allowed = link_type.get("allow_dead_links", False)
-            need_link_value = (
+            need_link_value: List[str] = (
                 [need[link_type["option"]]] if isinstance(need[link_type["option"]], str) else need[link_type["option"]]  # type: ignore
             )
-            for link in need_link_value:
-                if "." in link:
-                    need_id, need_part_id = link.split(".")
-                else:
-                    need_id = link
-                    need_part_id = None
-                if need_id not in needs or (
-                    need_id in needs and need_part_id and need_part_id not in needs[need_id]["parts"]
+            for need_id_full in need_link_value:
+                need_id_main, need_id_part = split_need_id(need_id_full)
+
+                if need_id_main not in needs or (
+                    need_id_main in needs and need_id_part and need_id_part not in needs[need_id_main]["parts"]
                 ):
                     need["has_dead_links"] = True
                     if not dead_links_allowed:
@@ -469,23 +466,19 @@ def create_back_links(needs: Dict[str, NeedsInfoType], config: NeedsSphinxConfig
         option_back = f"{option}_back"
 
         for key, need in needs.items():
-            need_link_value = [need[option]] if isinstance(need[option], str) else need[option]  # type: ignore[literal-required]
-            for link in need_link_value:
-                link_main = link.split(".")[0]
-                try:
-                    link_part = link.split(".")[1]
-                except IndexError:
-                    link_part = None
-
-                if link_main in needs:
-                    if key not in needs[link_main][option_back]:  # type: ignore[literal-required]
-                        needs[link_main][option_back].append(key)  # type: ignore[literal-required]
+            need_link_value: List[str] = [need[option]] if isinstance(need[option], str) else need[option]  # type: ignore[literal-required]
+            for need_id_full in need_link_value:
+                need_id_main, need_id_part = split_need_id(need_id_full)
+
+                if need_id_main in needs:
+                    if key not in needs[need_id_main][option_back]:  # type: ignore[literal-required]
+                        needs[need_id_main][option_back].append(key)  # type: ignore[literal-required]
 
                     # Handling of links to need_parts inside a need
-                    if link_part and link_part in needs[link_main]["parts"]:
-                        if option_back not in needs[link_main]["parts"][link_part].keys():
-                            needs[link_main]["parts"][link_part][option_back] = []  # type: ignore[literal-required]
-                        needs[link_main]["parts"][link_part][option_back].append(key)  # type: ignore[literal-required]
+                    if need_id_part and need_id_part in needs[need_id_main]["parts"]:
+                        if option_back not in needs[need_id_main]["parts"][need_id_part].keys():
+                            needs[need_id_main]["parts"][need_id_part][option_back] = []  # type: ignore[literal-required]
+                        needs[need_id_main]["parts"][need_id_part][option_back].append(key)  # type: ignore[literal-required]
 
 
 def _fix_list_dyn_func(list: List[str]) -> List[str]:
diff --git a/sphinx_needs/directives/needreport.py b/sphinx_needs/directives/needreport.py
index 1f0532ff..b72f7523 100644
--- a/sphinx_needs/directives/needreport.py
+++ b/sphinx_needs/directives/needreport.py
@@ -1,84 +1,71 @@
-import os
+from pathlib import Path
 from typing import Sequence
 
 from docutils import nodes
 from docutils.parsers.rst import directives
 from jinja2 import Template
-from sphinx.errors import SphinxError
+from sphinx.util import logging
 from sphinx.util.docutils import SphinxDirective
 
 from sphinx_needs.config import NeedsSphinxConfig
 from sphinx_needs.directives.utils import analyse_needs_metrics
 from sphinx_needs.utils import add_doc
 
-
-class NeedsReportException(SphinxError):
-    pass
+LOGGER = logging.getLogger(__name__)
 
 
 class NeedReportDirective(SphinxDirective):
     final_argument_whitespace = True
     option_spec = {
-        "types": directives.unchanged,
-        "links": directives.unchanged,
-        "options": directives.unchanged,
-        "usage": directives.unchanged,
+        "types": directives.flag,
+        "links": directives.flag,
+        "options": directives.flag,
+        "usage": directives.flag,
+        "template": directives.unchanged,
     }
 
     def run(self) -> Sequence[nodes.raw]:
         env = self.env
         needs_config = NeedsSphinxConfig(env.config)
 
-        if len(self.options.keys()) == 0:  # Check if options is empty
-            error_file, error_line = self.state_machine.input_lines.items[0]
-            error_msg = "{}:{}: NeedReportError: No options specified to generate need report.".format(
-                error_file, error_line + self.state_machine.input_lines.data.index(".. needreport::") + 1
+        if not set(self.options).intersection({"types", "links", "options", "usage"}):
+            LOGGER.warning(
+                "No options specified to generate need report [needs.report]",
+                location=self.get_location(),
+                type="needs",
+                subtype="report",
             )
-            raise NeedsReportException(error_msg)
-
-        types = self.options.get("types")
-        extra_links = self.options.get("links")
-        extra_options = self.options.get("options")
-        usage = self.options.get("usage")
-
-        needs_types = []
-        needs_extra_links = []
-        needs_extra_options = []
-        needs_metrics = {}
-
-        if types is not None and isinstance(types, str):
-            needs_types = needs_config.types
-        if extra_links is not None and isinstance(extra_links, str):
-            needs_extra_links = needs_config.extra_links
-        if extra_options is not None and isinstance(extra_options, str):
-            needs_extra_options = needs_config.extra_options
-        if usage is not None and isinstance(usage, str):
-            needs_metrics = analyse_needs_metrics(env)
+            return []
 
         report_info = {
-            "types": needs_types,
-            "options": needs_extra_options,
-            "links": needs_extra_links,
-            "usage": needs_metrics,
+            "types": needs_config.types if "types" in self.options else [],
+            "options": needs_config.extra_options if "options" in self.options else [],
+            "links": needs_config.extra_links if "links" in self.options else [],
+            "usage": analyse_needs_metrics(env) if "usage" in self.options else {},
+            "report_directive": "dropdown",
         }
         report_info.update(**needs_config.render_context)
 
-        need_report_template_path: str = needs_config.report_template
-        # Absolute path starts with /, based on the conf.py directory. The / need to be striped
-        correct_need_report_template_path = os.path.join(env.app.srcdir, need_report_template_path.lstrip("/"))
-
-        if len(need_report_template_path) == 0:
-            default_template_path = "needreport_template.rst"
-            correct_need_report_template_path = os.path.join(os.path.dirname(__file__), default_template_path)
-
-        if not os.path.exists(correct_need_report_template_path):
-            raise ReferenceError(f"Could not load needs report template file {correct_need_report_template_path}")
+        if "template" in self.options:
+            need_report_template_path = Path(self.env.relfn2path(self.options["template"], self.env.docname)[1])
+        elif needs_config.report_template:
+            # Absolute path starts with /, based on the conf.py directory. The / need to be striped
+            need_report_template_path = Path(str(env.app.srcdir)) / needs_config.report_template.lstrip("/")
+        else:
+            need_report_template_path = Path(__file__).parent / "needreport_template.rst"
+
+        if not need_report_template_path.is_file():
+            LOGGER.warning(
+                f"Could not load needs report template file {need_report_template_path} [needs.report]",
+                location=self.get_location(),
+                type="needs",
+                subtype="report",
+            )
+            return []
 
-        with open(correct_need_report_template_path) as needs_report_template_file:
-            needs_report_template_file_content = needs_report_template_file.read()
+        needs_report_template_file_content = need_report_template_path.read_text(encoding="utf8")
 
         template = Template(needs_report_template_file_content, autoescape=True)
-
         text = template.render(**report_info)
         self.state_machine.insert_input(text.split("\n"), self.state_machine.document.attributes["source"])
 
diff --git a/sphinx_needs/directives/needreport_template.rst b/sphinx_needs/directives/needreport_template.rst
index 2b34eccf..38b14140 100644
--- a/sphinx_needs/directives/needreport_template.rst
+++ b/sphinx_needs/directives/needreport_template.rst
@@ -1,7 +1,7 @@
 {# Output for needs_types #}
 {% if types|length != 0 %}
 
-.. dropdown:: Need Types
+.. {{ report_directive }}:: Need Types
 
    .. list-table::
       :widths: 40 20 20 20
@@ -23,7 +23,7 @@
 {# Output for needs_extra_links #}
 {% if links|length != 0 %}
 
-.. dropdown:: Need Extra Links
+.. {{ report_directive }}:: Need Extra Links
 
    .. list-table::
       :widths: 10 30 30 5 20
@@ -47,7 +47,7 @@
 {# Output for needs_options #}
 {% if options|length != 0 %}
 
-.. dropdown:: Need Extra Options
+.. {{ report_directive }}:: Need Extra Options
 
    {% for option in options %}
    * {{ option }}
@@ -58,7 +58,7 @@
 {# Output for needs metrics #}
 {% if usage|length != 0 %}
 
-.. dropdown:: Need Metrics
+.. {{ report_directive }}:: Need Metrics
 
    .. list-table::
       :widths: 40 40
diff --git a/sphinx_needs/roles/need_outgoing.py b/sphinx_needs/roles/need_outgoing.py
index 3a7ba1c9..092d89a9 100644
--- a/sphinx_needs/roles/need_outgoing.py
+++ b/sphinx_needs/roles/need_outgoing.py
@@ -8,7 +8,7 @@ from sphinx_needs.config import NeedsSphinxConfig
 from sphinx_needs.data import SphinxNeedsData
 from sphinx_needs.errors import NoUri
 from sphinx_needs.logging import get_logger
-from sphinx_needs.utils import check_and_calc_base_url_rel_path
+from sphinx_needs.utils import check_and_calc_base_url_rel_path, split_need_id
 
 log = get_logger(__name__)
 
@@ -41,24 +41,21 @@ def process_need_outgoing(
 
         link_list = [links] if isinstance(links, str) else links
 
-        for index, link in enumerate(link_list):
-            link_split = link.split(".")
-            link = link_split[0]
-            try:
-                link_part = link_split[1]
-            except IndexError:
-                link_part = None
+        for index, need_id_full in enumerate(link_list):
+            need_id_main, need_id_part = split_need_id(need_id_full)
 
             # If the need target exists, let's create the reference
-            if (link in needs_all_needs and not link_part) or (
-                link_part and link in needs_all_needs and link_part in needs_all_needs[link]["parts"]
+            if (need_id_main in needs_all_needs and not need_id_part) or (
+                need_id_part
+                and need_id_main in needs_all_needs
+                and need_id_part in needs_all_needs[need_id_main]["parts"]
             ):
                 try:
-                    target_need = needs_all_needs[link]
-                    if link_part and link_part in target_need["parts"]:
-                        part_content = target_need["parts"][link_part]["content"]
+                    target_need = needs_all_needs[need_id_main]
+                    if need_id_part and need_id_part in target_need["parts"]:
+                        part_content = target_need["parts"][need_id_part]["content"]
                         target_title = part_content if len(part_content) < 30 else part_content[:27] + "..."
-                        target_id = ".".join([link, link_part])
+                        target_id = ".".join([need_id_main, need_id_part])
                     else:
                         target_title = target_need["title"]
                         target_id = target_need["id"]
@@ -102,9 +99,9 @@ def process_need_outgoing(
             else:
                 # Let's add a normal text here instead of a link.
                 # So really each link set by the user gets shown.
-                link_text = f"{link}"
-                if link_part:
-                    link_text += f".{link_part}"
+                link_text = f"{need_id_main}"
+                if need_id_part:
+                    link_text += f".{need_id_part}"
                 dead_link_text = nodes.Text(link_text)
                 dead_link_para = nodes.inline(classes=["needs_dead_link"])
                 dead_link_para.append(dead_link_text)
@@ -130,7 +127,7 @@ def process_need_outgoing(
                     if node_need_ref and node_need_ref.line:
                         log.log(
                             log_level,
-                            f"linked need {link} not found "
+                            f"linked need {need_id_main} not found "
                             f"(Line {node_need_ref.line} of file {node_need_ref.source}) [needs]",
                             **kwargs,
                         )
@@ -139,7 +136,7 @@ def process_need_outgoing(
                             log_level,
                             "outgoing linked need {} not found (document: {}, "
                             "source need {} on line {} ) [needs]".format(
-                                link, ref_need["docname"], ref_need["id"], ref_need["lineno"]
+                                need_id_main, ref_need["docname"], ref_need["id"], ref_need["lineno"]
                             ),
                             **kwargs,
                         )
diff --git a/sphinx_needs/roles/need_ref.py b/sphinx_needs/roles/need_ref.py
index d054766e..23f9a5b1 100644
--- a/sphinx_needs/roles/need_ref.py
+++ b/sphinx_needs/roles/need_ref.py
@@ -10,7 +10,7 @@ from sphinx_needs.config import NeedsSphinxConfig
 from sphinx_needs.data import NeedsInfoType, SphinxNeedsData
 from sphinx_needs.errors import NoUri
 from sphinx_needs.logging import get_logger
-from sphinx_needs.utils import check_and_calc_base_url_rel_path
+from sphinx_needs.utils import check_and_calc_base_url_rel_path, split_need_id
 
 log = get_logger(__name__)
 
@@ -71,25 +71,20 @@ def process_need_ref(app: Sphinx, doctree: nodes.document, fromdocname: str, fou
         prefix = "[["
         postfix = "]]"
 
-        ref_id_complete = node_need_ref["reftarget"]
+        need_id_full = node_need_ref["reftarget"]
+        need_id_main, need_id_part = split_need_id(need_id_full)
 
-        if "." in ref_id_complete:
-            ref_id, part_id = ref_id_complete.split(".")
-        else:
-            ref_id = ref_id_complete
-            part_id = None
-
-        if ref_id in all_needs:
-            target_need = all_needs[ref_id]
+        if need_id_main in all_needs:
+            target_need = all_needs[need_id_main]
 
             dict_need = transform_need_to_dict(target_need)  # Transform a dict in a dict of {str, str}
 
             # We set the id to the complete id maintained in node_need_ref["reftarget"]
-            dict_need["id"] = ref_id_complete
+            dict_need["id"] = need_id_full
 
-            if part_id:
+            if need_id_part:
                 # If part_id, we have to fetch the title from the content.
-                dict_need["title"] = target_need["parts"][part_id]["content"]
+                dict_need["title"] = target_need["parts"][need_id_part]["content"]
 
             # Shorten title, if necessary
             max_length = needs_config.role_need_max_title_length
@@ -100,7 +95,7 @@ def process_need_ref(app: Sphinx, doctree: nodes.document, fromdocname: str, fou
 
             ref_name: Union[None, str, nodes.Text] = node_need_ref.children[0].children[0]  # type: ignore[assignment]
             # Only use ref_name, if it differs from ref_id
-            if str(ref_id_complete) == str(ref_name):
+            if str(need_id_full) == str(ref_name):
                 ref_name = None
 
             if ref_name and prefix in ref_name and postfix in ref_name:
diff --git a/sphinx_needs/utils.py b/sphinx_needs/utils.py
index d90ddceb..4bbddf12 100644
--- a/sphinx_needs/utils.py
+++ b/sphinx_needs/utils.py
@@ -109,6 +109,22 @@ MONTH_NAMES = [
 ]
 
 
+def split_need_id(need_id_full: str) -> Tuple[str, Optional[str]]:
+    """A need id can be a combination of a main id and a part id,
+    split by a dot.
+    This function splits them:
+    If there is no dot, the part id is None,
+    otherwise everything before the first dot is the main id,
+    and everything after the first dot is the part id.
+    """
+    if "." in need_id_full:
+        need_id, need_part_id = need_id_full.split(".", maxsplit=1)
+    else:
+        need_id = need_id_full
+        need_part_id = None
+    return need_id, need_part_id
+
+
 def row_col_maker(
     app: Sphinx,
     fromdocname: str,


Test patch:
diff --git a/tests/doc_test/doc_needreport/conf.py b/tests/doc_test/doc_needreport/conf.py
new file mode 100644
index 00000000..b5a51194
--- /dev/null
+++ b/tests/doc_test/doc_needreport/conf.py
@@ -0,0 +1,5 @@
+extensions = ["sphinx_needs"]
+needs_extra_options = ["other"]
+needs_render_context = {
+    "report_directive": "admonition",
+}
diff --git a/tests/doc_test/doc_needreport/index.rst b/tests/doc_test/doc_needreport/index.rst
new file mode 100644
index 00000000..6c5cdeab
--- /dev/null
+++ b/tests/doc_test/doc_needreport/index.rst
@@ -0,0 +1,17 @@
+Needs Report
+============
+
+.. req:: Requirement 1
+
+.. needreport::
+
+.. needreport::
+      :types:
+      :template: unknown.rst
+
+.. needreport::
+      :types:
+      :links:
+      :options:
+      :usage:
+
diff --git a/tests/test_needreport.py b/tests/test_needreport.py
new file mode 100644
index 00000000..73c474fe
--- /dev/null
+++ b/tests/test_needreport.py
@@ -0,0 +1,18 @@
+from pathlib import Path
+
+import pytest
+
+
+@pytest.mark.parametrize("test_app", [{"buildername": "html", "srcdir": "doc_test/doc_needreport"}], indirect=True)
+def test_doc_needarch(test_app):
+    app = test_app
+    app.build()
+    # check for warning about missing options
+    warnings = app._warning.getvalue()
+    assert "index.rst:6: WARNING: No options specified to generate need report [needs.report]" in warnings
+    assert "index.rst:8: WARNING: Could not load needs report template file" in warnings
+    html = Path(app.outdir, "index.html").read_text(encoding="utf8")
+    assert "Need Types" in html
+    assert "Need Extra Links" in html
+    assert "Need Extra Options" in html
+    assert "Need Metrics" in html