StrictDoc Documentation
strictdoc/export/html/form_objects/requirement_form_object.py
Source file coverage
Path:
strictdoc/export/html/form_objects/requirement_form_object.py
Lines:
1085
Non-empty lines:
986
Non-empty lines covered with requirements:
986 / 986 (100.0%)
Functions:
34
Functions covered by requirements:
34 / 34 (100.0%)
1
"""
2
@relation(SDOC-SRS-55, scope=file)
3
"""
4
 
5
from collections import defaultdict
6
from enum import Enum
7
from typing import Dict, Iterator, List, Optional, Set, Tuple, Union
8
 
9
from starlette.datastructures import FormData
10
 
11
from strictdoc.backend.markdown.markdown_to_html_fragment_writer import (
12
    MarkdownToHtmlFragmentWriter,
13
)
14
from strictdoc.backend.rst.rst_to_html_fragment_writer import (
15
    RstToHtmlFragmentWriter,
16
)
17
from strictdoc.backend.sdoc.constants import SDocMarkup
18
from strictdoc.backend.sdoc.errors.document_tree_error import DocumentTreeError
19
from strictdoc.backend.sdoc.models.document import SDocDocument
20
from strictdoc.backend.sdoc.models.document_grammar import (
21
    DocumentGrammar,
22
)
23
from strictdoc.backend.sdoc.models.grammar_element import (
24
    GrammarElement,
25
    GrammarElementField,
26
    GrammarElementFieldMultipleChoice,
27
    GrammarElementFieldSingleChoice,
28
    RequirementFieldType,
29
)
30
from strictdoc.backend.sdoc.models.inline_link import InlineLink
31
from strictdoc.backend.sdoc.models.node import SDocNode, SDocNodeField
32
from strictdoc.backend.sdoc.models.reference import (
33
    ChildReqReference,
34
    FileEntry,
35
    FileEntryFormat,
36
    FileReference,
37
    ParentReqReference,
38
    Reference,
39
)
40
from strictdoc.core.constants import GraphLinkType
41
from strictdoc.core.graph.abstract_bucket import ALL_EDGES
42
from strictdoc.core.project_config import ProjectConfig
43
from strictdoc.core.traceability_index import (
44
    TraceabilityIndex,
45
)
46
from strictdoc.core.tree_cycle_detector import SingleShotTreeCycleDetector
47
from strictdoc.helpers.auto_described import auto_described
48
from strictdoc.helpers.cast import assert_cast
49
from strictdoc.helpers.form_data import ParsedFormData, parse_form_data
50
from strictdoc.helpers.mid import MID
51
from strictdoc.helpers.string import sanitize_html_form_field
52
from strictdoc.server.error_object import ErrorObject
53
 
54
 
55
class RequirementFormFieldType(str, Enum):
56
    SINGLELINE = "SINGLELINE"
57
    MULTILINE = "MULTILINE"
58
 
59
 
60
def deduplicate_comma_separated_value(value: str) -> str:
61
    """
62
    MultipleChoice/Tag field values are a comma-separated set. Existing
63
    documents can already contain duplicate entries (hand-edited, or
64
    created before the autocomplete duplicate-prevention fix). Remove the
65
    duplicates here, case-insensitively, keeping the order and casing of
66
    the first occurrence.
67
    """
68
    seen: Set[str] = set()
69
    deduplicated_parts: List[str] = []
70
    for raw_part in value.split(","):
71
        part = raw_part.strip()
72
        if not part:
73
            continue
74
        key = part.lower()
75
        if key in seen:
76
            continue
77
        seen.add(key)
78
        deduplicated_parts.append(part)
79
    return ", ".join(deduplicated_parts)
80
 
81
 
82
@auto_described
83
class RequirementFormField:
84
    def __init__(
85
        self,
86
        *,
87
        field_mid: str,
88
        field_name: str,
89
        field_type: RequirementFormFieldType,
90
        field_value: str,
91
        field_gef_type: str = RequirementFieldType.STRING,
92
        is_editable: bool = True,
93
    ) -> None:
94
        assert isinstance(field_value, str)
95
        self.field_mid: str = field_mid
96
        self.field_name: str = field_name
97
        self.field_value: str = field_value
98
        self.field_type = field_type
99
        self.field_gef_type: str = field_gef_type
100
        self.is_editable = is_editable
101
 
102
    def is_multiline(self) -> bool:
103
        return self.field_type == RequirementFormFieldType.MULTILINE
104
 
105
    def is_autocompletable(self) -> bool:
106
        return self.field_gef_type in (
107
            RequirementFieldType.SINGLE_CHOICE,
108
            RequirementFieldType.MULTIPLE_CHOICE,
109
            RequirementFieldType.TAG,
110
        )
111
 
112
    def is_multiplechoice(self) -> bool:
113
        return self.field_gef_type in (
114
            RequirementFieldType.MULTIPLE_CHOICE,
115
            RequirementFieldType.TAG,
116
        )
117
 
118
    def get_input_field_name(self) -> str:
119
        return f"requirement[fields][{self.field_mid}][value]"
120
 
121
    def get_input_field_type_name(self) -> str:
122
        return f"requirement[fields][{self.field_mid}][name]"
123
 
124
    @staticmethod
125
    def create_from_grammar_field(
126
        *,
127
        grammar_field: GrammarElementField,
128
        multiline: bool,
129
        value: str,
130
    ) -> "RequirementFormField":
131
        assert isinstance(value, str), (
132
            grammar_field,
133
            multiline,
134
            value,
135
        )
136
        if grammar_field.gef_type in (
137
            RequirementFieldType.STRING,
138
            RequirementFieldType.SINGLE_CHOICE,
139
            RequirementFieldType.MULTIPLE_CHOICE,
140
            RequirementFieldType.TAG,
141
        ):
142
            return RequirementFormField(
143
                field_mid=MID.create(),
144
                field_name=grammar_field.title,
145
                field_type=(
146
                    RequirementFormFieldType.MULTILINE
147
                    if multiline
148
                    else RequirementFormFieldType.SINGLELINE
149
                ),
150
                field_value=value,
151
                field_gef_type=grammar_field.gef_type,
152
            )
153
        raise NotImplementedError(grammar_field)
154
 
155
    @staticmethod
156
    def create_existing_from_grammar_field(
157
        grammar_field: GrammarElementField,
158
        multiline: bool,
159
        requirement_field: SDocNodeField,
160
    ) -> "RequirementFormField":
161
        if grammar_field.gef_type in (
162
            RequirementFieldType.STRING,
163
            RequirementFieldType.SINGLE_CHOICE,
164
            RequirementFieldType.MULTIPLE_CHOICE,
165
            RequirementFieldType.TAG,
166
        ):
167
            field_value = requirement_field.get_text_value()
168
            if grammar_field.gef_type in (
169
                RequirementFieldType.MULTIPLE_CHOICE,
170
                RequirementFieldType.TAG,
171
            ):
172
                # The document may already contain duplicate values (e.g.
173
                # hand-edited, or saved before the autocomplete
174
                # duplicate-prevention fix). Deduplicate when loading the
175
                # value into the edit form, so saving the form as-is
176
                # cleans up the document.
177
                field_value = deduplicate_comma_separated_value(field_value)
178
            return RequirementFormField(
179
                field_mid=MID.create(),
180
                field_name=grammar_field.title,
181
                field_type=(
182
                    RequirementFormFieldType.MULTILINE
183
                    if multiline
184
                    else RequirementFormFieldType.SINGLELINE
185
                ),
186
                field_value=field_value,
187
                field_gef_type=grammar_field.gef_type,
188
                is_editable=requirement_field.is_document_origin(),
189
            )
190
        raise NotImplementedError(grammar_field)
191
 
192
 
193
@auto_described
194
class RequirementReferenceFormField:
195
    class FieldType(str, Enum):
196
        PARENT = "Parent"
197
        CHILD = "Child"
198
        FILE = "File"
199
 
200
    def __init__(
201
        self,
202
        field_mid: str,
203
        field_type: FieldType,
204
        field_value: str,
205
        field_role: Optional[str],
206
        # True for relation rows added client-side (never saved to the document).
207
        # False (default) for rows that were already saved and loaded into the form.
208
        # Used to distinguish "silently discard if empty" vs. "raise error if empty".
209
        is_new: bool = False,
210
    ) -> None:
211
        assert isinstance(field_mid, str), field_mid
212
        assert isinstance(field_value, str), field_value
213
        self.field_mid: str = field_mid
214
        self.field_type = field_type
215
        self.field_value: str = field_value
216
        self.field_role: str = (
217
            field_role if field_role is not None and len(field_role) > 0 else ""
218
        )
219
        self.is_new: bool = is_new
220
        self.validation_messages: List[str] = []
221
 
222
    def get_input_field_name(self) -> str:
223
        return "requirement_relation"
224
 
225
    def get_value_field_name(self) -> str:
226
        return f"requirement[relations][{self.field_mid}][value]"
227
 
228
    def get_type_field_name(self) -> str:
229
        return f"requirement[relations][{self.field_mid}][typerole]"
230
 
231
    def get_is_new_field_name(self) -> str:
232
        return f"requirement[relations][{self.field_mid}][is_new]"
233
 
234
 
235
@auto_described
236
class RequirementFormObject(ErrorObject):
237
    """
238
    Class for managing node fields in the StrictDoc web editor.
239
 
240
    context_document_mid: The MID of the document where the requirement is edited.
241
                          Normally, this is the requirement's own document but can
242
                          also be the parent document if requirement's own document
243
                          is included to it.
244
    """
245
 
246
    def __init__(
247
        self,
248
        *,
249
        is_new: bool,
250
        element_type: str,
251
        revision: int,
252
        requirement_mid: str,
253
        document_mid: str,
254
        context_document_mid: str,
255
        fields: List[RequirementFormField],
256
        reference_fields: List[RequirementReferenceFormField],
257
        existing_requirement_uid: Optional[str],
258
        grammar: DocumentGrammar,
259
        # FIXME: Better name.
260
        relation_types: List[str],
261
    ) -> None:
262
        super().__init__()
263
        assert isinstance(element_type, str), element_type
264
        assert isinstance(revision, int), revision
265
 
266
        self.is_new: bool = is_new
267
        self.element_type: str = element_type
268
        self.revision: int = revision
269
        self.requirement_mid: str = requirement_mid
270
        self.document_mid: str = document_mid
271
        self.context_document_mid: str = context_document_mid
272
        fields_dict: Dict[str, List[RequirementFormField]] = {}
273
        for field in fields:
274
            fields_dict.setdefault(field.field_name, []).append(field)
275
 
276
        self.fields: Dict[str, List[RequirementFormField]] = fields_dict
277
        self.reference_fields: List[RequirementReferenceFormField] = (
278
            reference_fields
279
        )
280
        self.existing_requirement_uid: Optional[str] = existing_requirement_uid
281
        self.grammar: DocumentGrammar = grammar
282
        self.relation_types: List[str] = relation_types
283
        # Set by validate() when a UID rename is rejected because the
284
        # requirement still has parent/child relations, so the template can
285
        # offer a button to restore existing_requirement_uid into the field.
286
        self.uid_rename_blocked_by_relations: bool = False
287
 
288
    @staticmethod
289
    def create_from_request(
290
        *,
291
        is_new: bool,
292
        requirement_mid: str,
293
        request_form_data: FormData,
294
        document: SDocDocument,
295
        existing_requirement_uid: Optional[str],
296
    ) -> "RequirementFormObject":
297
        request_form_data_as_list = [
298
            (field_name, field_value)
299
            for field_name, field_value in request_form_data.multi_items()
300
        ]
301
        request_form_dict: ParsedFormData = assert_cast(
302
            parse_form_data(request_form_data_as_list), dict
303
        )
304
        requirement_fields = defaultdict(list)
305
        form_ref_fields: List[RequirementReferenceFormField] = []
306
 
307
        context_document_mid = assert_cast(
308
            request_form_dict["context_document_mid"], str
309
        )
310
        revision = assert_cast(request_form_dict["revision"], str)
311
        requirement_dict = assert_cast(request_form_dict["requirement"], dict)
312
 
313
        element_type = assert_cast(request_form_dict["element_type"], str)
314
        requirement_fields_dict = assert_cast(requirement_dict["fields"], dict)
315
        for _, field_dict in requirement_fields_dict.items():
316
            assert isinstance(field_dict, dict), type(field_dict)
317
 
318
            field_name = field_dict["name"]
319
            field_value = field_dict["value"]
320
            requirement_fields[field_name].append(field_value)
321
 
322
        # FIXME: defaulting to {}
323
        requirement_relations_dict = requirement_dict.get("relations", {})
324
        for relation_mid, relation_dict in requirement_relations_dict.items():
325
            # FIXME: Editing files is not supported. Fix this hack ASAP.
326
            relation_typerole = relation_dict.get("typerole", "File")
327
            relation_typerole_parts = relation_typerole.split(",")
328
            if len(relation_typerole_parts) == 2:
329
                relation_type = relation_typerole_parts[0]
330
                relation_role = relation_typerole_parts[1]
331
            elif len(relation_typerole_parts) == 1:
332
                relation_type = relation_typerole_parts[0]
333
                relation_role = None
334
            else:
335
                raise AssertionError("Must not reach here")  # pragma: no cover
336
 
337
            field_type = {
338
                "Parent": RequirementReferenceFormField.FieldType.PARENT,
339
                "Child": RequirementReferenceFormField.FieldType.CHILD,
340
                "File": RequirementReferenceFormField.FieldType.FILE,
341
            }[relation_type]
342
 
343
            relation_value = relation_dict["value"].strip()
344
 
345
            if len(relation_value) == 0 and relation_type != "File":
346
                # is_new distinguishes two empty-UID cases:
347
                # - True: user added a row but left it blank → silently discard.
348
                # - False: user cleared the UID of an existing saved relation
349
                #          → keep the field so validate() can raise the error.
350
                is_new_relation = relation_dict.get("is_new", "false") == "true"
351
                if is_new_relation:
352
                    continue
353
 
354
            form_ref_fields.append(
355
                RequirementReferenceFormField(
356
                    field_mid=relation_mid,
357
                    field_type=field_type,
358
                    field_value=relation_value,
359
                    field_role=relation_role,
360
                )
361
            )
362
 
363
        assert document.grammar is not None
364
        grammar: DocumentGrammar = document.grammar
365
        element: GrammarElement = grammar.elements_by_type[element_type]
366
        form_fields: List[RequirementFormField] = []
367
 
368
        for field_idx, field_name in enumerate(element.field_titles):
369
            multiline = element.is_field_idx_multiline(field_idx)
370
 
371
            field = element.fields_map[field_name]
372
 
373
            if field_name not in requirement_fields:
374
                continue
375
 
376
            requirement_field_values = requirement_fields.get(field_name, [])
377
            for requirement_field_value in requirement_field_values:
378
                sanitized_field_value: str = sanitize_html_form_field(
379
                    requirement_field_value, multiline=multiline
380
                )
381
                form_field = RequirementFormField.create_from_grammar_field(
382
                    grammar_field=field,
383
                    multiline=multiline,
384
                    value=sanitized_field_value,
385
                )
386
                form_fields.append(form_field)
387
 
388
        form_object = RequirementFormObject(
389
            is_new=is_new,
390
            element_type=element_type,
391
            revision=int(revision),
392
            requirement_mid=requirement_mid,
393
            document_mid=document.reserved_mid,
394
            context_document_mid=context_document_mid,
395
            fields=form_fields,
396
            reference_fields=form_ref_fields,
397
            existing_requirement_uid=existing_requirement_uid,
398
            grammar=grammar,
399
            relation_types=element.get_relation_types(),
400
        )
401
        return form_object
402
 
403
    @staticmethod
404
    def create_new(
405
        *,
406
        document: SDocDocument,
407
        context_document_mid: str,
408
        next_uid: Optional[str],
409
        element_type: str,
410
    ) -> "RequirementFormObject":
411
        """
412
        Create a new node form object.
413
 
414
        For now, the next_uid cannot be non-None for TEXT nodes. This will likely
415
        change in the future.
416
        """
417
 
418
        assert document.grammar is not None
419
 
420
        new_requirement_mid: MID = MID.create()
421
 
422
        grammar: DocumentGrammar = document.grammar
423
        element: GrammarElement = grammar.elements_by_type[element_type]
424
 
425
        form_fields: List[RequirementFormField] = []
426
 
427
        for field_idx, field_name in enumerate(element.field_titles):
428
            field = element.fields_map[field_name]
429
            multiline = element.is_field_idx_multiline(field_idx)
430
            form_field: RequirementFormField = (
431
                RequirementFormField.create_from_grammar_field(
432
                    grammar_field=field,
433
                    multiline=multiline,
434
                    value="",
435
                )
436
            )
437
            form_fields.append(form_field)
438
            if form_field.field_name == "UID" and next_uid is not None:
439
                form_field.field_value = next_uid
440
            elif form_field.field_name == "MID" and (
441
                document.config.enable_mid
442
                or (
443
                    "MID" in element.fields_map
444
                    and document.meta is not None
445
                    and document.meta.input_doc_full_path.lower().endswith(
446
                        (".md", ".markdown")
447
                    )
448
                )
449
            ):
450
                form_field.field_value = new_requirement_mid.get_string_value()
451
 
452
        return RequirementFormObject(
453
            is_new=True,
454
            element_type=element_type,
455
            revision=0,
456
            requirement_mid=new_requirement_mid,
457
            document_mid=document.reserved_mid,
458
            context_document_mid=context_document_mid,
459
            fields=form_fields,
460
            reference_fields=[],
461
            existing_requirement_uid=None,
462
            grammar=grammar,
463
            relation_types=element.get_relation_types(),
464
        )
465
 
466
    @staticmethod
467
    def create_from_requirement(
468
        *,
469
        requirement: SDocNode,
470
        revision: int,
471
        context_document_mid: str,
472
    ) -> "RequirementFormObject":
473
        assert isinstance(requirement, SDocNode)
474
        document: SDocDocument = assert_cast(
475
            requirement.get_document(), SDocDocument
476
        )
477
        assert document.grammar is not None
478
        grammar: DocumentGrammar = document.grammar
479
        element: GrammarElement = grammar.elements_by_type[
480
            requirement.node_type
481
        ]
482
 
483
        grammar_element_relations = element.get_relation_types()
484
 
485
        form_fields: List[RequirementFormField] = []
486
        form_refs_fields: List[RequirementReferenceFormField] = []
487
 
488
        for field_idx, field_name in enumerate(element.field_titles):
489
            multiline = element.is_field_idx_multiline(field_idx)
490
 
491
            # Handle all other fields in a general way.
492
            field = element.fields_map[field_name]
493
 
494
            if field_name in requirement.ordered_fields_lookup:
495
                for requirement_field in requirement.ordered_fields_lookup[
496
                    field_name
497
                ]:
498
                    form_field = (
499
                        RequirementFormField.create_existing_from_grammar_field(
500
                            field,
501
                            multiline=multiline,
502
                            requirement_field=requirement_field,
503
                        )
504
                    )
505
                    form_fields.append(form_field)
506
            else:
507
                form_field = RequirementFormField.create_from_grammar_field(
508
                    grammar_field=field,
509
                    multiline=multiline,
510
                    value="",
511
                )
512
                form_fields.append(form_field)
513
 
514
        for reference_value in requirement.relations:
515
            if isinstance(reference_value, ParentReqReference):
516
                parent_reference: ParentReqReference = reference_value
517
                form_ref_field = RequirementReferenceFormField(
518
                    field_mid=parent_reference.mid,
519
                    field_type=RequirementReferenceFormField.FieldType.PARENT,
520
                    field_value=parent_reference.ref_uid,
521
                    field_role=parent_reference.role,
522
                )
523
                form_refs_fields.append(form_ref_field)
524
            elif isinstance(reference_value, ChildReqReference):
525
                child_req_reference: ChildReqReference = reference_value
526
                form_ref_field = RequirementReferenceFormField(
527
                    field_mid=child_req_reference.mid,
528
                    field_type=RequirementReferenceFormField.FieldType.CHILD,
529
                    field_value=child_req_reference.ref_uid,
530
                    field_role=child_req_reference.role,
531
                )
532
                form_refs_fields.append(form_ref_field)
533
            elif isinstance(reference_value, FileReference):
534
                child_file_reference: FileReference = reference_value
535
                form_ref_field = RequirementReferenceFormField(
536
                    field_mid=child_file_reference.mid,
537
                    field_type=RequirementReferenceFormField.FieldType.FILE,
538
                    field_value=child_file_reference.get_posix_path(),
539
                    field_role=child_file_reference.role,
540
                )
541
                form_refs_fields.append(form_ref_field)
542
        return RequirementFormObject(
543
            is_new=False,
544
            element_type=requirement.node_type,
545
            revision=revision,
546
            requirement_mid=requirement.reserved_mid,
547
            document_mid=document.reserved_mid,
548
            context_document_mid=context_document_mid,
549
            fields=form_fields,
550
            reference_fields=form_refs_fields,
551
            existing_requirement_uid=requirement.reserved_uid,
552
            grammar=grammar,
553
            relation_types=grammar_element_relations,
554
        )
555
 
556
    @staticmethod
557
    def clone_from_requirement(
558
        *, requirement: SDocNode, context_document_mid: str, clone_uid: str
559
    ) -> "RequirementFormObject":
560
        assert isinstance(requirement, SDocNode), requirement
561
 
562
        document = assert_cast(requirement.get_document(), SDocDocument)
563
 
564
        form_object: RequirementFormObject = (
565
            RequirementFormObject.create_from_requirement(
566
                requirement=requirement,
567
                revision=0,
568
                context_document_mid=context_document_mid,
569
            )
570
        )
571
        grammar = document.grammar
572
        assert grammar is not None
573
        grammar_element = grammar.elements_by_type[requirement.node_type]
574
        form_object.requirement_mid = MID.create()
575
        for field_name, fields_ in form_object.fields.items():
576
            field: RequirementFormField
577
            if field_name == "UID":
578
                field = fields_[0]
579
                field.field_value = clone_uid
580
            elif field_name == "MID" and (
581
                document.config.enable_mid
582
                or (
583
                    "MID" in grammar_element.fields_map
584
                    and document.meta is not None
585
                    and document.meta.input_doc_full_path.lower().endswith(
586
                        (".md", ".markdown")
587
                    )
588
                )
589
            ):
590
                field = fields_[0]
591
                field.field_value = (
592
                    form_object.requirement_mid.get_string_value()
593
                )
594
 
595
        return form_object
596
 
597
    def any_errors(self) -> bool:
598
        if super().any_errors():
599
            return True
600
        for reference_field in self.reference_fields:
601
            if len(reference_field.validation_messages) > 0:
602
                return True
603
        return False
604
 
605
    def get_requirement_relations(
606
        self, requirement: SDocNode
607
    ) -> List[Reference]:
608
        references: List[Reference] = []
609
        reference_field: RequirementReferenceFormField
610
        for reference_field in self.reference_fields:
611
            ref_uid = reference_field.field_value
612
            ref_type = reference_field.field_type
613
            ref_role = reference_field.field_role
614
            if ref_type == RequirementReferenceFormField.FieldType.PARENT:
615
                references.append(
616
                    ParentReqReference(
617
                        parent=requirement, ref_uid=ref_uid, role=ref_role
618
                    )
619
                )
620
            elif ref_type == RequirementReferenceFormField.FieldType.CHILD:
621
                references.append(
622
                    ChildReqReference(
623
                        parent=requirement, ref_uid=ref_uid, role=ref_role
624
                    )
625
                )
626
            elif ref_type == RequirementReferenceFormField.FieldType.FILE:
627
                file_entry = FileEntry(
628
                    parent=requirement,
629
                    g_file_format=FileEntryFormat.SOURCECODE,
630
                    g_file_path=reference_field.field_value,
631
                    g_line_range="",
632
                )
633
                references.append(
634
                    FileReference(
635
                        parent=requirement,
636
                        g_file_entry=file_entry,
637
                    )
638
                )
639
            else:
640
                raise NotImplementedError(ref_type)
641
        return references
642
 
643
    def enumerate_fields(
644
        self, multiline: bool
645
    ) -> Iterator[List[RequirementFormField]]:
646
        for field_name_, field in self.fields.items():
647
            try:
648
                requirement_field: RequirementFormField = field[0]
649
            except IndexError as index_error_:
650
                raise AssertionError(
651
                    f"Expected field name to correspond to an existing field: {field_name_}."
652
                ) from index_error_
653
 
654
            if multiline:
655
                if not requirement_field.is_multiline():
656
                    continue
657
            else:
658
                if requirement_field.is_multiline():
659
                    continue
660
            yield field
661
 
662
    def enumerate_reference_fields(
663
        self,
664
    ) -> Iterator[RequirementReferenceFormField]:
665
        yield from self.reference_fields
666
 
667
    def enumerate_relation_roles(
668
        self, relation_field: RequirementReferenceFormField
669
    ) -> Iterator[Tuple[str, Optional[str], bool]]:
670
        requirement_element = self.grammar.elements_by_type[self.element_type]
671
        for relation_ in requirement_element.relations:
672
            is_current = (
673
                relation_field.field_type == relation_.relation_type
674
                and (
675
                    relation_field.field_role == relation_.relation_role
676
                    or (
677
                        relation_field.field_role == ""
678
                        and relation_.relation_role is None
679
                    )
680
                )
681
            )
682
            yield relation_.relation_type, relation_.relation_role, is_current
683
 
684
    def validate(
685
        self,
686
        *,
687
        traceability_index: TraceabilityIndex,
688
        context_document: SDocDocument,
689
        config: ProjectConfig,
690
        existing_revision: int,
691
    ) -> None:
692
        assert isinstance(traceability_index, TraceabilityIndex)
693
        assert isinstance(context_document, SDocDocument)
694
 
695
        if self.revision != existing_revision:
696
            self.add_error(
697
                "_GENERAL_",
698
                (
699
                    "Cannot update the node because it has already been "
700
                    "modified by another update action."
701
                ),
702
            )
703
            return
704
 
705
        #
706
        # Ensure that at least one field must be non-empty.
707
        #
708
        at_least_one_non_empty_field_present = False
709
        for field_fields_ in self.fields.values():
710
            for field_ in field_fields_:
711
                if len(field_.field_value) > 0:
712
                    at_least_one_non_empty_field_present = True
713
                    break
714
            if at_least_one_non_empty_field_present:
715
                break
716
        if not at_least_one_non_empty_field_present:
717
            self.add_error(
718
                "_GENERAL_",
719
                "At least one node field must be non-empty.",
720
            )
721
 
722
        #
723
        # MID uniqueness check.
724
        # FIXME: MID uniqueness if a node is updated.
725
        # """
726
        if self.is_new and "MID" in self.fields:
727
            new_node_mid = self.fields["MID"][0].field_value
728
            if len(new_node_mid) > 0:
729
                existing_node_with_this_mid = (
730
                    traceability_index.get_node_by_mid_weak(MID(new_node_mid))
731
                )
732
                if existing_node_with_this_mid is not None:
733
                    self.add_error(
734
                        "MID",
735
                        (
736
                            f"A node with this MID already exists, "
737
                            "please select another MID: "
738
                            f"{new_node_mid}."
739
                        ),
740
                    )
741
 
742
        #
743
        # UID uniqueness check.
744
        #
745
        new_node_uid_or_none: Optional[str] = None
746
        if "UID" in self.fields:
747
            new_node_uid = self.fields["UID"][0].field_value
748
            if len(new_node_uid) > 0:
749
                new_node_uid_or_none = new_node_uid
750
 
751
        if new_node_uid_or_none is not None and (
752
            self.is_new or self.existing_requirement_uid != new_node_uid_or_none
753
        ):
754
            existing_node_with_this_uid = (
755
                traceability_index.get_node_by_uid_weak(new_node_uid_or_none)
756
            )
757
            if existing_node_with_this_uid is not None:
758
                self.add_error(
759
                    "UID",
760
                    (
761
                        "The chosen UID must be unique. "
762
                        "Another node with this UID already exists: "
763
                        f"'{new_node_uid_or_none}'."
764
                    ),
765
                )
766
 
767
        #
768
        # Ensure that UID doesn't have any incoming links if it is going to be
769
        # renamed or removed.
770
        #
771
        if self.existing_requirement_uid is not None:
772
            if (
773
                new_node_uid_or_none is None
774
                or self.existing_requirement_uid != new_node_uid_or_none
775
            ):
776
                existing_node: SDocNode = traceability_index.get_node_by_mid(
777
                    MID(self.requirement_mid)
778
                )
779
 
780
                existing_incoming_links: Optional[List[InlineLink]] = (
781
                    traceability_index.get_incoming_links(existing_node)
782
                )
783
                if (
784
                    existing_incoming_links is not None
785
                    and len(existing_incoming_links) > 0
786
                ):
787
                    self.add_error(
788
                        "UID",
789
                        (
790
                            "Renaming a node UID when the node has "
791
                            "incoming links is not supported yet. "
792
                            "Please delete all incoming links first."
793
                        ),
794
                    )
795
 
796
        requirement_element = self.grammar.elements_by_type[self.element_type]
797
 
798
        for grammar_element_field_ in requirement_element.fields:
799
            if grammar_element_field_.gef_type == RequirementFieldType.STRING:
800
                if grammar_element_field_.title not in self.fields:
801
                    continue
802
 
803
                field_instances = self.fields[grammar_element_field_.title]
804
                is_multi_instance = len(field_instances) > 1
805
                for form_field_ in field_instances:
806
                    field_value = form_field_.field_value
807
                    # Multi-instance fields (e.g. COMMENT) key errors by field_mid
808
                    # so each row only shows its own errors.
809
                    error_key = (
810
                        form_field_.field_mid
811
                        if is_multi_instance
812
                        else grammar_element_field_.title
813
                    )
814
 
815
                    # If field not empty, validate its markup syntax using the
816
                    # writer that matches the document's markup (RST by
817
                    # default, Markdown for Markdown documents).
818
                    if len(field_value) > 0:
819
                        markup = (
820
                            context_document.config.markup
821
                            if context_document is not None
822
                            else None
823
                        )
824
                        if markup == SDocMarkup.MARKDOWN:
825
                            (
826
                                parsed_html,
827
                                markup_error,
828
                            ) = MarkdownToHtmlFragmentWriter.write_with_validation(
829
                                field_value
830
                            )
831
                        else:
832
                            (
833
                                parsed_html,
834
                                markup_error,
835
                            ) = RstToHtmlFragmentWriter(
836
                                project_config=config,
837
                                context_document=context_document,
838
                            ).write_with_validation(field_value)
839
                        if parsed_html is None:
840
                            assert markup_error is not None
841
                            self.add_error(error_key, markup_error)
842
                    # If field is empty, check if required and validate for emptiness.
843
                    else:
844
                        if grammar_element_field_.required:
845
                            self.add_error(
846
                                error_key,
847
                                (
848
                                    f"Node's {grammar_element_field_.title} must not be empty. "
849
                                    f"If there is no appropriate value for this field yet, "
850
                                    f"enter TBD (to be done) or TBC (to be confirmed)."
851
                                ),
852
                            )
853
                        continue
854
 
855
            elif grammar_element_field_.gef_type in (
856
                RequirementFieldType.SINGLE_CHOICE,
857
                RequirementFieldType.MULTIPLE_CHOICE,
858
            ):
859
                self._validate_choice(grammar_element_field_)
860
 
861
        requirement_uid: Optional[str] = (
862
            self.fields["UID"][0].field_value if "UID" in self.fields else None
863
        )
864
        has_requirement_relations = any(
865
            reference_field.field_type
866
            in (
867
                RequirementReferenceFormField.FieldType.PARENT,
868
                RequirementReferenceFormField.FieldType.CHILD,
869
            )
870
            for reference_field in self.reference_fields
871
        )
872
        if has_requirement_relations and (
873
            requirement_uid is None or len(requirement_uid) == 0
874
        ):
875
            self.add_error(
876
                "UID",
877
                "Requirement with parent relations must have an UID. "
878
                "Either provide a parent UID, or "
879
                "delete the parent requirement relations.",
880
            )
881
 
882
        if (
883
            self.existing_requirement_uid is not None
884
            and self.existing_requirement_uid != requirement_uid
885
        ):
886
            if has_requirement_relations:
887
                self.add_error(
888
                    "UID",
889
                    "Not supported yet: "
890
                    "Renaming a requirement UID when the requirement has "
891
                    "parent requirement relations. For now, manually delete the "
892
                    "relations, rename the UID, recreate the relations.",
893
                )
894
                self.uid_rename_blocked_by_relations = True
895
 
896
            existing_node = assert_cast(
897
                traceability_index.get_node_by_uid_weak(
898
                    self.existing_requirement_uid
899
                ),
900
                SDocNode,
901
            )
902
 
903
            if traceability_index.has_children_requirements(existing_node):
904
                self.add_error(
905
                    "UID",
906
                    "Not supported yet: "
907
                    "Renaming a requirement UID when the requirement has "
908
                    "child requirement relations. For now, manually delete the "
909
                    "relations, rename the UID, recreate the relations.",
910
                )
911
                self.uid_rename_blocked_by_relations = True
912
                return
913
 
914
        if requirement_uid is not None:
915
            relation_target_uids_so_far: Set[str] = set()
916
            for reference_field in self.reference_fields:
917
                if reference_field.field_type in ("Parent", "Child"):
918
                    link_uid = reference_field.field_value
919
                    if len(link_uid) == 0:
920
                        reference_field.validation_messages.append(
921
                            "Requirement relation UID must not be empty."
922
                        )
923
                        continue
924
                    elif not traceability_index.has_node_connections(link_uid):
925
                        reference_field.validation_messages.append(
926
                            f'Parent requirement with an UID "{link_uid}" '
927
                            f"does not exist."
928
                        )
929
                        continue
930
 
931
                    # Validate that every UID can be only referenced once.
932
                    if link_uid in relation_target_uids_so_far:
933
                        reference_field.validation_messages.append(
934
                            f'A target requirement with a UID "{link_uid}" '
935
                            "is referenced more than once. Multiple relations "
936
                            "to the same target requirement are not allowed."
937
                        )
938
                        continue
939
                    relation_target_uids_so_far.add(link_uid)
940
 
941
                    # Check if the target document supports a given relation.
942
                    node_grammar_element: GrammarElement = (
943
                        self.grammar.elements_by_type[self.element_type]
944
                    )
945
                    field_role_or_none = (
946
                        reference_field.field_role
947
                        if reference_field.field_role is not None
948
                        and len(reference_field.field_role) > 0
949
                        else None
950
                    )
951
 
952
                    # This is not a realistic case to happen when a node is
953
                    # edited in UI because the UI dropdown element whitelists
954
                    # the available relation types.
955
                    # Using an assert anyway just to make sure.
956
                    assert node_grammar_element.has_relation_type_role(
957
                        reference_field.field_type, field_role_or_none
958
                    )
959
 
960
                    self._validate_no_cycle_by_new_node(
961
                        traceability_index, reference_field, requirement_uid
962
                    )
963
 
964
    @staticmethod
965
    def _validate_no_cycle_by_new_node(
966
        traceability_index: TraceabilityIndex,
967
        reference_field: RequirementReferenceFormField,
968
        requirement_uid: str,
969
    ) -> None:
970
        """
971
        Check if a relation being added by a new node would form a cycle.
972
        """
973
 
974
        ref_uid = reference_field.field_value
975
 
976
        def parent_lambda(node_id: str) -> List[str]:
977
            node = traceability_index.graph_database.get_link_value(
978
                link_type=GraphLinkType.UID_TO_NODE,
979
                lhs_node=node_id,
980
            )
981
            return list(
982
                map(
983
                    lambda node_: node_.reserved_uid,
984
                    traceability_index.graph_database.get_link_values(
985
                        link_type=GraphLinkType.NODE_TO_PARENT_NODES,
986
                        lhs_node=node,
987
                        edge=ALL_EDGES,
988
                    ),
989
                )
990
            )
991
 
992
        def child_lambda(node_id: str) -> List[str]:
993
            node = traceability_index.graph_database.get_link_value(
994
                link_type=GraphLinkType.UID_TO_NODE,
995
                lhs_node=node_id,
996
            )
997
            return list(
998
                map(
999
                    lambda node_: node_.reserved_uid,
1000
                    traceability_index.graph_database.get_link_values(
1001
                        link_type=GraphLinkType.NODE_TO_CHILD_NODES,
1002
                        lhs_node=node,
1003
                        edge=ALL_EDGES,
1004
                    ),
1005
                )
1006
            )
1007
 
1008
        relations_lambda = (
1009
            parent_lambda
1010
            if reference_field.field_type == "Parent"
1011
            else child_lambda
1012
        )
1013
 
1014
        cycle_detector = SingleShotTreeCycleDetector()
1015
        try:
1016
            cycle_detector.check_node(
1017
                requirement_uid,
1018
                ref_uid,
1019
                relations_lambda,
1020
            )
1021
        except DocumentTreeError as error_:
1022
            reference_field.validation_messages.append(
1023
                error_.to_validation_message()
1024
            )
1025
 
1026
    def _validate_choice(
1027
        self, grammar_element_field: GrammarElementField
1028
    ) -> None:
1029
        field_0 = self.fields[grammar_element_field.title][0]
1030
        if len(field_0.field_value) == 0:
1031
            if grammar_element_field.required:
1032
                self.add_error(
1033
                    grammar_element_field.title,
1034
                    (
1035
                        f"Node's {grammar_element_field.title} must not be empty. "
1036
                        f"If there is no appropriate value for this field yet, "
1037
                        f"enter TBD (to be done)."
1038
                    ),
1039
                )
1040
            # Empty non-required fields are valid.
1041
            return
1042
 
1043
        choice_grammar_element_field: Union[
1044
            GrammarElementFieldSingleChoice,
1045
            GrammarElementFieldMultipleChoice,
1046
        ] = assert_cast(
1047
            grammar_element_field,
1048
            (
1049
                GrammarElementFieldSingleChoice,
1050
                GrammarElementFieldMultipleChoice,
1051
            ),
1052
        )
1053
        if (
1054
            grammar_element_field.gef_type == RequirementFieldType.SINGLE_CHOICE
1055
            and field_0.field_value not in choice_grammar_element_field.options
1056
            and field_0.field_value not in ("TBD", "TBC")
1057
        ):
1058
            self.add_error(
1059
                grammar_element_field.title,
1060
                (
1061
                    f"Node's {grammar_element_field.title} must be a value one of "
1062
                    f"{', '.join(choice_grammar_element_field.options)}."
1063
                ),
1064
            )
1065
        elif (
1066
            grammar_element_field.gef_type
1067
            == RequirementFieldType.MULTIPLE_CHOICE
1068
        ):
1069
            choices = [
1070
                choice.strip() for choice in field_0.field_value.split(",")
1071
            ]
1072
            if all(
1073
                choice in choice_grammar_element_field.options
1074
                or choice in ("TBD", "TBC")
1075
                for choice in choices
1076
            ):
1077
                field_0.field_value = ", ".join(choices)
1078
            else:
1079
                self.add_error(
1080
                    grammar_element_field.title,
1081
                    (
1082
                        f"Node's {grammar_element_field.title} must not contain"
1083
                        f" values other than {', '.join(choice_grammar_element_field.options)}."
1084
                    ),
1085
                )