StrictDoc Documentation
strictdoc/core/transforms/update_requirement.py
Source file coverage
Path:
strictdoc/core/transforms/update_requirement.py
Lines:
523
Non-empty lines:
474
Non-empty lines covered with requirements:
474 / 474 (100.0%)
Functions:
10
Functions covered by requirements:
10 / 10 (100.0%)
1
"""
2
@relation(SDOC-SRS-55, scope=file)
3
"""
4
 
5
import datetime
6
from copy import copy
7
from dataclasses import dataclass
8
from typing import Dict, List, Optional, Set, Tuple, Union
9
 
10
from textx import TextXSyntaxError
11
 
12
from strictdoc.backend.sdoc.document_reference import DocumentReference
13
from strictdoc.backend.sdoc.error_handling import get_textx_syntax_error_message
14
from strictdoc.backend.sdoc.free_text_reader import SDFreeTextReader
15
from strictdoc.backend.sdoc.models.anchor import Anchor
16
from strictdoc.backend.sdoc.models.document import SDocDocument
17
from strictdoc.backend.sdoc.models.free_text import FreeTextContainer
18
from strictdoc.backend.sdoc.models.grammar_element import GrammarElement
19
from strictdoc.backend.sdoc.models.inline_link import InlineLink
20
from strictdoc.backend.sdoc.models.model import SDocDocumentIF, SDocNodeIF
21
from strictdoc.backend.sdoc.models.node import SDocNode, SDocNodeField
22
from strictdoc.backend.sdoc.models.object_factory import SDocObjectFactory
23
from strictdoc.backend.sdoc.models.reference import (
24
    Reference,
25
)
26
from strictdoc.core.constants import (
27
    GraphLinkType,
28
)
29
from strictdoc.core.project_config import ProjectConfig
30
from strictdoc.core.traceability_index import (
31
    TraceabilityIndex,
32
)
33
from strictdoc.core.transforms.constants import NodeCreationOrder
34
from strictdoc.core.transforms.validation_error import (
35
    SingleValidationError,
36
)
37
from strictdoc.export.html.form_objects.requirement_form_object import (
38
    RequirementFormField,
39
    RequirementFormFieldType,
40
    RequirementFormObject,
41
)
42
from strictdoc.export.rst.rst_to_html_fragment_writer import (
43
    RstToHtmlFragmentWriter,
44
)
45
from strictdoc.helpers.cast import assert_cast
46
from strictdoc.helpers.file_modification_time import set_file_modification_time
47
from strictdoc.helpers.mid import MID
48
 
49
 
50
@dataclass
51
class CreateNodeInfo:
52
    whereto: str
53
    requirement_mid: str
54
    reference_mid: str
55
 
56
 
57
@dataclass
58
class UpdateNodeInfo:
59
    node_to_update: SDocNode
60
 
61
 
62
class UpdateRequirementActionObject:
63
    def __init__(self) -> None:
64
        self.existing_references_uids: Set[Tuple[str, str, Optional[str]]] = (
65
            set()
66
        )
67
        self.reference_ids_to_remove: Set[Tuple[str, str, Optional[str]]] = (
68
            set()
69
        )
70
        self.removed_uid_parent_documents_to_update: Set[SDocDocument] = set()
71
        # All requirements that have to be updated. This set includes
72
        # the requirement itself, all links it was linking to
73
        # (for deleted links) and all links it is linking to now
74
        # (including new links).
75
        self.this_document_requirements_to_update: Set[SDocNode] = set()
76
 
77
 
78
@dataclass
79
class CreateOrUpdateNodeResult:
80
    this_document_requirements_to_update: List[SDocNode]
81
 
82
 
83
class CreateOrUpdateNodeCommand:
84
    def __init__(
85
        self,
86
        *,
87
        form_object: RequirementFormObject,
88
        node_info: Union[CreateNodeInfo, UpdateNodeInfo],
89
        context_document: SDocDocument,
90
        traceability_index: TraceabilityIndex,
91
        project_config: ProjectConfig,
92
    ) -> None:
93
        self.form_object: RequirementFormObject = form_object
94
        self.node_info: Union[CreateNodeInfo, UpdateNodeInfo] = node_info
95
        self.context_document: SDocDocument = context_document
96
        self.traceability_index: TraceabilityIndex = traceability_index
97
        self.project_config: ProjectConfig = project_config
98
 
99
    def perform(self) -> Optional[CreateOrUpdateNodeResult]:
100
        form_object: RequirementFormObject = self.form_object
101
        node_to_update_or_none: Optional[SDocNode] = (
102
            self.node_info.node_to_update
103
            if isinstance(self.node_info, UpdateNodeInfo)
104
            else None
105
        )
106
 
107
        traceability_index: TraceabilityIndex = self.traceability_index
108
 
109
        map_form_to_requirement_fields: Dict[
110
            RequirementFormField, Optional[FreeTextContainer]
111
        ] = {}
112
        this_node_unique_anchors: Set[str] = set()
113
        for field_bucket_ in form_object.fields.values():
114
            field_: RequirementFormField
115
            for field_ in field_bucket_:
116
                if field_.field_type != RequirementFormFieldType.MULTILINE:
117
                    continue
118
                (
119
                    parsed_html,
120
                    rst_error,
121
                ) = RstToHtmlFragmentWriter(
122
                    project_config=self.project_config,
123
                    context_document=self.context_document,
124
                ).write_with_validation(field_.field_value)
125
                if parsed_html is None:
126
                    assert rst_error is not None
127
                    form_object.add_error(field_.field_name, rst_error)
128
                else:
129
                    try:
130
                        free_text_container: Optional[FreeTextContainer] = (
131
                            SDFreeTextReader.read(field_.field_value)
132
                            if len(field_.field_value) > 0
133
                            else None
134
                        )
135
                        map_form_to_requirement_fields[field_] = (
136
                            free_text_container
137
                        )
138
                        if free_text_container is None:
139
                            continue
140
                        anchors: List[Anchor] = []
141
                        for part in free_text_container.parts:
142
                            if isinstance(part, InlineLink):
143
                                linked_to_node = traceability_index.get_linkable_node_by_uid_weak(
144
                                    part.link
145
                                )
146
                                if linked_to_node is None:
147
                                    form_object.add_error(
148
                                        field_.field_name,
149
                                        "A LINK points to a node that does "
150
                                        f"not exist: '{part.link}'.",
151
                                    )
152
                            elif isinstance(part, Anchor):
153
                                if part.value in this_node_unique_anchors:
154
                                    form_object.add_error(
155
                                        field_.field_name,
156
                                        "The node fields contain duplicate anchor: "
157
                                        f"'{part.value}'.",
158
                                    )
159
                                    break
160
                                this_node_unique_anchors.add(part.value)
161
                                anchors.append(part)
162
                            else:
163
                                pass
164
                        if anchors is not None:
165
                            try:
166
                                traceability_index.validate_node_against_anchors(
167
                                    node=node_to_update_or_none,
168
                                    new_anchors=anchors,
169
                                )
170
                            except (
171
                                SingleValidationError
172
                            ) as anchors_validation_error:
173
                                form_object.add_error(
174
                                    field_.field_name,
175
                                    anchors_validation_error.args[0],
176
                                )
177
                    except TextXSyntaxError as exception:
178
                        form_object.add_error(
179
                            field_.field_name,
180
                            get_textx_syntax_error_message(exception),
181
                        )
182
 
183
        if form_object.any_errors():
184
            return None
185
 
186
        requirement: SDocNode
187
        document: SDocDocument
188
        existing_uid: Optional[str]
189
        existing_node_fields: List[SDocNodeField] = []
190
        if isinstance(self.node_info, UpdateNodeInfo):
191
            requirement = self.node_info.node_to_update
192
            document = assert_cast(requirement.get_document(), SDocDocument)
193
 
194
            existing_uid = requirement.reserved_uid
195
 
196
            existing_node_fields = list(requirement.enumerate_fields())
197
 
198
            # Clearing all existing fields because they will be recreated from
199
            # scratch from the form data.
200
            requirement.ordered_fields_lookup.clear()
201
 
202
            self.populate_node_fields_from_form_object(
203
                requirement, form_object, map_form_to_requirement_fields
204
            )
205
 
206
            # Updating Traceability Index: UID.
207
            traceability_index.update_requirement_uid(
208
                requirement=requirement, old_uid=existing_uid
209
            )
210
        else:
211
            reference_node: Union[SDocDocument, SDocNode] = (
212
                traceability_index.get_node_by_mid(
213
                    MID(self.node_info.reference_mid)
214
                )
215
            )
216
            document = traceability_index.get_node_by_mid(
217
                MID(form_object.document_mid)
218
            )
219
            parent: Union[SDocDocumentIF, SDocNodeIF]
220
            if self.node_info.whereto == NodeCreationOrder.CHILD:
221
                parent = reference_node
222
                insert_to_idx = len(parent.section_contents)
223
            elif self.node_info.whereto == NodeCreationOrder.BEFORE:
224
                # Be aware of an edge case besides all normal cases:
225
                # A reference node can be a root node of an included document.
226
                if not isinstance(reference_node, SDocDocument):
227
                    parent = reference_node.parent
228
                    insert_to_idx = parent.section_contents.index(
229
                        reference_node
230
                    )
231
                else:
232
                    assert (
233
                        reference_node.ng_including_document_from_file
234
                        is not None
235
                    )
236
                    parent = (
237
                        reference_node.ng_including_document_from_file.parent
238
                    )
239
                    assert (
240
                        reference_node.ng_including_document_from_file
241
                        is not None
242
                    )
243
                    insert_to_idx = parent.section_contents.index(
244
                        reference_node.ng_including_document_from_file
245
                    )
246
            elif self.node_info.whereto == NodeCreationOrder.AFTER:
247
                if isinstance(reference_node, SDocDocument):
248
                    assert reference_node.document_is_included()
249
                    assert reference_node.ng_including_document_from_file
250
                    parent = (
251
                        reference_node.ng_including_document_from_file.parent
252
                    )
253
                    insert_to_idx = (
254
                        parent.section_contents.index(
255
                            reference_node.ng_including_document_from_file
256
                        )
257
                        + 1
258
                    )
259
                else:
260
                    parent = reference_node.parent
261
                    insert_to_idx = (
262
                        parent.section_contents.index(reference_node) + 1
263
                    )
264
            else:
265
                raise NotImplementedError
266
 
267
            # Reset the 'needs generation' flag on all documents.
268
            for document_ in traceability_index.document_tree.document_list:
269
                assert document_.meta is not None
270
                set_file_modification_time(
271
                    document_.meta.input_doc_full_path,
272
                    datetime.datetime.today(),
273
                )
274
 
275
            # FIXME: It is better to have a general create_node method because
276
            #        we are dealing with arbitrary nodes, not only Requirement.
277
            requirement = SDocObjectFactory.create_requirement(
278
                parent=parent, node_type=form_object.element_type
279
            )
280
            requirement.reserved_mid = MID(form_object.requirement_mid)
281
            if document.config.enable_mid:
282
                requirement.mid_permanent = True
283
 
284
            assert document.grammar is not None
285
            grammar_element: GrammarElement = document.grammar.elements_by_type[
286
                form_object.element_type
287
            ]
288
 
289
            requirement.is_composite = (
290
                grammar_element.property_is_composite is True
291
            )
292
            requirement.ng_document_reference = DocumentReference()
293
            requirement.ng_document_reference.set_document(document)
294
            requirement.ng_including_document_reference = (
295
                document.ng_including_document_reference
296
            )
297
 
298
            parent.section_contents.insert(insert_to_idx, requirement)
299
 
300
            self.populate_node_fields_from_form_object(
301
                requirement, form_object, map_form_to_requirement_fields
302
            )
303
            traceability_index.create_requirement(requirement=requirement)
304
 
305
        action_object = UpdateRequirementActionObject()
306
        action_object.existing_references_uids.update(
307
            requirement.get_requirement_reference_uids()
308
        )
309
        action_object.reference_ids_to_remove = copy(
310
            action_object.existing_references_uids
311
        )
312
        action_object.this_document_requirements_to_update = {requirement}
313
 
314
        references: List[Reference] = form_object.get_requirement_relations(
315
            requirement
316
        )
317
        if len(references) > 0:
318
            requirement.relations = references
319
        else:
320
            requirement.relations = []
321
 
322
        for document_ in traceability_index.document_tree.document_list:
323
            assert document_.meta is not None
324
            set_file_modification_time(
325
                document_.meta.input_doc_full_path, datetime.datetime.today()
326
            )
327
 
328
        # Updating Traceability Index: Links.
329
        for reference_field in form_object.reference_fields:
330
            ref_uid = reference_field.field_value
331
            ref_role: Optional[str] = (
332
                reference_field.field_role
333
                if reference_field.field_role is not None
334
                and len(reference_field.field_role) > 0
335
                else None
336
            )
337
            # If a link is in the form, we don't want to remove it.
338
            if (
339
                reference_field.field_type,
340
                ref_uid,
341
                ref_role,
342
            ) in action_object.reference_ids_to_remove:
343
                action_object.reference_ids_to_remove.remove(
344
                    (reference_field.field_type, ref_uid, ref_role)
345
                )
346
            # If a link is already in the requirement and traceability index,
347
            # there is nothing to do.
348
            if (
349
                reference_field.field_type,
350
                ref_uid,
351
                ref_role,
352
            ) in action_object.existing_references_uids:
353
                continue
354
            if reference_field.field_type == "Parent":
355
                traceability_index.update_requirement_parent_uid(
356
                    requirement=requirement,
357
                    parent_uid=ref_uid,
358
                    role=reference_field.field_role
359
                    if len(reference_field.field_role) > 0
360
                    else None,
361
                )
362
            elif reference_field.field_type == "Child":
363
                traceability_index.update_requirement_child_uid(
364
                    requirement=requirement,
365
                    child_uid=ref_uid,
366
                    role=reference_field.field_role
367
                    if len(reference_field.field_role) > 0
368
                    else None,
369
                )
370
            elif reference_field.field_type == "File":
371
                pass
372
            else:
373
                raise AssertionError(f"Must not reach here: {reference_field}")
374
 
375
        # Calculate which documents and requirements have to be regenerated.
376
        for (
377
            _,
378
            reference_id_to_remove,
379
            _,
380
        ) in action_object.reference_ids_to_remove:
381
            removed_uid_parent_requirement: SDocNode = (
382
                traceability_index.graph_database.get_link_value(
383
                    link_type=GraphLinkType.UID_TO_NODE,
384
                    lhs_node=reference_id_to_remove,
385
                )
386
            )
387
            removed_uid_parent_requirement_document = assert_cast(
388
                removed_uid_parent_requirement.get_document(), SDocDocument
389
            )
390
            action_object.removed_uid_parent_documents_to_update.add(
391
                removed_uid_parent_requirement_document
392
            )
393
            # If a link was pointing towards a parent requirement in this
394
            # document, we will have to re-render it now.
395
            if removed_uid_parent_requirement_document == document:
396
                action_object.this_document_requirements_to_update.add(
397
                    removed_uid_parent_requirement
398
                )
399
 
400
        for (
401
            relation_type_,
402
            reference_id_to_remove,
403
            reference_id_to_remove_role,
404
        ) in action_object.reference_ids_to_remove:
405
            if relation_type_ == "Parent":
406
                traceability_index.remove_requirement_parent_uid(
407
                    requirement=requirement,
408
                    parent_uid=reference_id_to_remove,
409
                    role=reference_id_to_remove_role,
410
                )
411
            elif relation_type_ == "Child":
412
                traceability_index.remove_requirement_child_uid(
413
                    requirement=requirement,
414
                    child_uid=reference_id_to_remove,
415
                    role=reference_id_to_remove_role,
416
                )
417
 
418
        # Rendering back the Turbo template for each changed requirement.
419
        for reference_field in form_object.reference_fields:
420
            if reference_field.field_type not in ("Parent", "Child"):
421
                continue
422
            ref_uid = reference_field.field_value
423
 
424
            node: SDocNode = traceability_index.graph_database.get_link_value(
425
                link_type=GraphLinkType.UID_TO_NODE,
426
                lhs_node=ref_uid,
427
            )
428
 
429
            if node.get_document() == document:
430
                action_object.this_document_requirements_to_update.add(node)
431
 
432
        self._update_traceability_index_with_links_and_anchors(
433
            requirement, existing_node_fields
434
        )
435
 
436
        document.build_search_index()
437
 
438
        traceability_index.update_last_updated()
439
 
440
        return CreateOrUpdateNodeResult(
441
            this_document_requirements_to_update=list(
442
                action_object.this_document_requirements_to_update
443
            )
444
        )
445
 
446
    def _update_traceability_index_with_links_and_anchors(
447
        self, updated_node: SDocNode, existing_node_fields: List[SDocNodeField]
448
    ) -> None:
449
        traceability_index = self.traceability_index
450
 
451
        existing_anchor_uids: Set[str] = set()
452
        existing_links: List[InlineLink] = []
453
        for node_field_ in existing_node_fields:
454
            for part_ in node_field_.parts:
455
                if isinstance(part_, Anchor):
456
                    existing_anchor_uids.add(part_.value)
457
                elif isinstance(part_, InlineLink):
458
                    existing_links.append(part_)
459
 
460
        for existing_link_ in existing_links:
461
            traceability_index.remove_inline_link(existing_link_)
462
 
463
        for existing_anchor_uid in existing_anchor_uids:
464
            traceability_index.remove_anchor_by_uid(existing_anchor_uid)
465
 
466
        for node_field_ in updated_node.enumerate_fields():
467
            for part_ in node_field_.parts:
468
                if isinstance(part_, Anchor):
469
                    # Since this is a new section, we just need to register the
470
                    # new anchor. By this time, we know that there is no
471
                    # existing anchor with this name.
472
                    traceability_index.update_with_anchor(part_)
473
                elif isinstance(part_, InlineLink):
474
                    traceability_index.create_inline_link(part_)
475
 
476
    def populate_node_fields_from_form_object(
477
        self,
478
        node: SDocNode,
479
        form_object: RequirementFormObject,
480
        map_form_to_requirement_fields: Dict[
481
            RequirementFormField, Optional[FreeTextContainer]
482
        ],
483
    ) -> None:
484
        # FIXME: Leave only one method based on set_field_value().
485
        for form_field_name, form_fields in form_object.fields.items():
486
            for form_field_index, form_field in enumerate(form_fields):
487
                if form_field.field_type != RequirementFormFieldType.MULTILINE:
488
                    node.set_field_value(
489
                        field_name=form_field_name,
490
                        form_field_index=form_field_index,
491
                        value=form_field.field_value,
492
                    )
493
                    continue
494
 
495
                free_text_content: Optional[FreeTextContainer] = (
496
                    map_form_to_requirement_fields[form_field]
497
                )
498
                requirement_field: Optional[SDocNodeField] = (
499
                    SDocNodeField(
500
                        node,
501
                        field_name=form_field_name,
502
                        parts=free_text_content.parts,
503
                        multiline__="true"
504
                        if form_field.field_type
505
                        == RequirementFormFieldType.MULTILINE
506
                        else None,
507
                    )
508
                    if free_text_content is not None
509
                    else None
510
                )
511
                node.set_field_value(
512
                    field_name=form_field_name,
513
                    form_field_index=form_field_index,
514
                    value=requirement_field,
515
                )
516
                if (
517
                    requirement_field is not None
518
                    and free_text_content is not None
519
                ):
520
                    for part_ in requirement_field.parts:
521
                        if isinstance(part_, str):
522
                            continue
523
                        part_.parent = requirement_field