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. Existing63
documents can already contain duplicate entries (hand-edited, or64
created before the autocomplete duplicate-prevention fix). Remove the65
duplicates here, case-insensitively, keeping the order and casing of66
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
continue74
key = part.lower()
75
if key in seen:
76
continue77
seen.add(key)
78
deduplicated_parts.append(part)
79
return ", ".join(deduplicated_parts)
80
81
82
@auto_described83
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
@staticmethod125
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
@staticmethod156
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 autocomplete174
# duplicate-prevention fix). Deduplicate when loading the175
# value into the edit form, so saving the form as-is176
# 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_described194
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_described236
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 can242
also be the parent document if requirement's own document243
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_fields279
)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 the284
# requirement still has parent/child relations, so the template can285
# offer a button to restore existing_requirement_uid into the field.286
self.uid_rename_blocked_by_relations: bool = False
287
288
@staticmethod289
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 relation349
# → 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
continue353
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
continue375
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
@staticmethod404
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 likely415
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
@staticmethod467
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_name497
]: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
@staticmethod557
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
continue657
else:
658
if requirement_field.is_multiline():
659
continue660
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
return704
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
break714
if at_least_one_non_empty_field_present:
715
break716
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 be769
# 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
continue802
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_mid808
# 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 the816
# writer that matches the document's markup (RST by817
# 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_value830
)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
continue854
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
return913
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
continue924
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
continue930
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
continue939
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 is953
# edited in UI because the UI dropdown element whitelists954
# 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
@staticmethod965
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_lambda1010
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
return1042
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
)