StrictDoc Documentation
strictdoc/server/routers/main_router.py
Source file coverage
Path:
strictdoc/server/routers/main_router.py
Lines:
3436
Non-empty lines:
3144
Non-empty lines covered with requirements:
3144 / 3144 (100.0%)
Functions:
74
Functions covered by requirements:
74 / 74 (100.0%)
1
import asyncio
2
import copy
3
import datetime
4
import os
5
import re
6
import uuid
7
from collections import defaultdict
8
from mimetypes import guess_type
9
from pathlib import Path
10
from typing import Any, Dict, Iterator, List, Optional, Union
11
from urllib.parse import quote
12
 
13
from fastapi import APIRouter, Depends, FastAPI, Form, HTTPException, UploadFile
14
from reqif.models.error_handling import ReqIFXMLParsingError
15
from reqif.parser import ReqIFParser
16
from reqif.unparser import ReqIFUnparser
17
from starlette.background import BackgroundTask
18
from starlette.datastructures import FormData
19
from starlette.requests import Request
20
from starlette.responses import (
21
    FileResponse,
22
    HTMLResponse,
23
    RedirectResponse,
24
    Response,
25
)
26
from starlette.websockets import WebSocket, WebSocketDisconnect
27
 
28
from strictdoc.backend.markdown.writer import SDMarkdownWriter
29
from strictdoc.backend.reqif.p01_sdoc.reqif_to_sdoc_converter import (
30
    P01_ReqIFToSDocConverter,
31
)
32
from strictdoc.backend.reqif.p01_sdoc.sdoc_to_reqif_converter import (
33
    P01_SDocToReqIFObjectConverter,
34
)
35
from strictdoc.backend.sdoc.models.document import SDocDocument
36
from strictdoc.backend.sdoc.models.document_grammar import (
37
    DocumentGrammar,
38
)
39
from strictdoc.backend.sdoc.models.grammar_element import (
40
    GrammarElement,
41
    GrammarElementField,
42
    RequirementFieldType,
43
)
44
from strictdoc.backend.sdoc.models.model import (
45
    SDocExtendedElementIF,
46
    SDocNodeIF,
47
)
48
from strictdoc.backend.sdoc.models.node import (
49
    SDocNode,
50
)
51
from strictdoc.backend.sdoc.writer import SDWriter
52
from strictdoc.backend.sdoc_source_code.models.source_file_info import (
53
    SourceFileTraceabilityInfo,
54
)
55
from strictdoc.core.actions.export_action import ExportAction
56
from strictdoc.core.analyzers.document_stats import DocumentTreeStats
57
from strictdoc.core.analyzers.document_uid_analyzer import DocumentUIDAnalyzer
58
from strictdoc.core.document_meta import DocumentMeta
59
from strictdoc.core.document_tree import DocumentTree
60
from strictdoc.core.project_config import ProjectConfig
61
from strictdoc.core.query_engine.query_object import Query, QueryObject
62
from strictdoc.core.query_engine.query_reader import QueryReader
63
from strictdoc.core.transforms.constants import NodeCreationOrder
64
from strictdoc.core.transforms.delete_requirement import (
65
    DeleteRequirementCommand,
66
)
67
from strictdoc.core.transforms.update_document_config import (
68
    UpdateDocumentConfigTransform,
69
)
70
from strictdoc.core.transforms.update_grammar import UpdateGrammarCommand
71
from strictdoc.core.transforms.update_grammar_element import (
72
    UpdateGrammarElementCommand,
73
)
74
from strictdoc.core.transforms.update_included_document import (
75
    UpdateIncludedDocumentTransform,
76
)
77
from strictdoc.core.transforms.update_requirement import (
78
    CreateNodeInfo,
79
    CreateOrUpdateNodeCommand,
80
    CreateOrUpdateNodeResult,
81
    UpdateNodeInfo,
82
)
83
from strictdoc.core.transforms.validation_error import (
84
    MultipleValidationError,
85
    MultipleValidationErrorAsList,
86
)
87
from strictdoc.export.html.document_type import DocumentType
88
from strictdoc.export.html.form_objects.document_config_form_object import (
89
    DocumentConfigFormObject,
90
    DocumentMetadataFormField,
91
)
92
from strictdoc.export.html.form_objects.grammar_element_form_object import (
93
    GrammarElementFormObject,
94
)
95
from strictdoc.export.html.form_objects.grammar_form_object import (
96
    GrammarFormObject,
97
)
98
from strictdoc.export.html.form_objects.included_document_form_object import (
99
    IncludedDocumentFormObject,
100
)
101
from strictdoc.export.html.form_objects.requirement_form_object import (
102
    RequirementFormField,
103
    RequirementFormFieldType,
104
    RequirementFormObject,
105
    RequirementReferenceFormField,
106
)
107
from strictdoc.export.html.generators.view_objects.document_screen_view_object import (
108
    DocumentScreenViewObject,
109
)
110
from strictdoc.export.html.generators.view_objects.nestor_view_object import (
111
    NestorViewObject,
112
)
113
from strictdoc.export.html.generators.view_objects.project_tree_view_object import (
114
    ProjectTreeViewObject,
115
)
116
from strictdoc.export.html.generators.view_objects.search_screen_view_object import (
117
    SearchScreenViewObject,
118
)
119
from strictdoc.export.html.generators.view_objects.server_error_view_object import (
120
    ServerErrorViewObject,
121
)
122
from strictdoc.export.html.html_generator import HTMLGenerator
123
from strictdoc.export.html.html_templates import HTMLTemplates, JinjaEnvironment
124
from strictdoc.export.html.renderers.link_renderer import LinkRenderer
125
from strictdoc.export.html.renderers.markup_renderer import MarkupRenderer
126
from strictdoc.export.json.json_generator import JSONGenerator
127
from strictdoc.features.html2pdf.generator import (
128
    DocumentHTML2PDFGenerator,
129
)
130
from strictdoc.features.html2pdf.pdf_print_driver import (
131
    PDFPrintDriver,
132
    PDFPrintDriverException,
133
)
134
from strictdoc.helpers.cast import assert_cast
135
from strictdoc.helpers.file_modification_time import (
136
    get_file_modification_time,
137
    set_file_modification_time,
138
)
139
from strictdoc.helpers.mid import MID
140
from strictdoc.helpers.parallelizer import NullParallelizer
141
from strictdoc.helpers.path_filter import PathFilter
142
from strictdoc.helpers.paths import SDocRelativePath
143
from strictdoc.helpers.string import (
144
    create_safe_acronym,
145
    is_safe_alphanumeric_string,
146
)
147
from strictdoc.helpers.timing import measure_performance
148
from strictdoc.server.error_object import ErrorObject
149
from strictdoc.server.helpers.hierarchical_rw_lock_manager import (
150
    HierarchicalRWLockManager,
151
)
152
from strictdoc.server.helpers.http import request_is_for_non_modified_file
153
 
154
HTTP_STATUS_BAD_REQUEST = 400
155
HTTP_STATUS_NOT_FOUND = 404
156
HTTP_STATUS_PRECONDITION_FAILED = 412
157
HTTP_STATUS_INTERNAL_SERVER_ERROR = 500
158
 
159
AUTOCOMPLETE_LIMIT = 50
160
 
161
 
162
def search_query_contains_markers(query: str) -> bool:
163
    # Query mode markers are intentionally broad to keep behavior deterministic
164
    # for expression-like input.
165
    if "node." in query:
166
        return True
167
    if ("(" in query and ")" in query) or "==" in query or "!=" in query:
168
        return True
169
    if re.search(r'\[\s*"[^"]+"\s*\]', query):
170
        return True
171
    return False
172
 
173
 
174
def parse_plain_text_search_query(
175
    query: str,
176
) -> tuple[Optional[str], Optional[re.Pattern[str]]]:
177
    plain_text_query = query.lower()
178
    if (
179
        len(plain_text_query) >= 2
180
        and plain_text_query.startswith('"')
181
        and plain_text_query.endswith('"')
182
    ):
183
        return plain_text_query[1:-1], None
184
 
185
    query_parts = [part for part in plain_text_query.split() if part]
186
    if len(query_parts) == 0:
187
        return None, None
188
    wildcard_pattern = ".*".join(map(re.escape, query_parts))
189
    return None, re.compile(wildcard_pattern)
190
 
191
 
192
def search_text_matches_plain_text_query(
193
    text: str,
194
    *,
195
    phrase: Optional[str],
196
    pattern: Optional[re.Pattern[str]],
197
) -> bool:
198
    lowered_text = text.lower()
199
    if phrase is not None:
200
        return phrase in lowered_text
201
    if pattern is not None:
202
        return pattern.search(lowered_text) is not None
203
    return False
204
 
205
 
206
def search_node_matches_plain_text_query(
207
    node: SDocExtendedElementIF,
208
    *,
209
    phrase: Optional[str],
210
    pattern: Optional[re.Pattern[str]],
211
) -> bool:
212
    if isinstance(node, SDocNode):
213
        for requirement_field_ in node.enumerate_fields():
214
            field_text = requirement_field_.get_text_value()
215
            if search_text_matches_plain_text_query(
216
                field_text, phrase=phrase, pattern=pattern
217
            ):
218
                return True
219
        return False
220
    if isinstance(node, SourceFileTraceabilityInfo):
221
        if node.source_file is None:
222
            return False
223
        return search_text_matches_plain_text_query(
224
            node.source_file.in_doctree_source_file_rel_path,
225
            phrase=phrase,
226
            pattern=pattern,
227
        )
228
    return False
229
 
230
 
231
def create_main_router(
232
    project_config: ProjectConfig,
233
    *,
234
    app: FastAPI,
235
    lock_manager: HierarchicalRWLockManager,
236
) -> APIRouter:
237
    parallelizer = NullParallelizer()
238
 
239
    # This dictionary is used to track conflicts between concurrently edited
240
    # versions of the same nodes. If a saved node has a version that is older
241
    # than one tracked in this dictionary, StrictDoc raises a validation to a
242
    # user.
243
    # Type signature: [MID, version number]
244
    revisions: Dict[str, int] = defaultdict(int)
245
 
246
    project_config.is_running_on_server = True
247
 
248
    export_action = ExportAction(
249
        project_config=project_config,
250
        parallelizer=parallelizer,
251
    )
252
 
253
    is_small_project = export_action.traceability_index.is_small_project()
254
 
255
    html_templates: HTMLTemplates = HTMLTemplates.create(
256
        project_config=project_config,
257
        enable_caching=not is_small_project,
258
        strictdoc_last_update=export_action.traceability_index.strictdoc_last_update,
259
    )
260
 
261
    html_generator = HTMLGenerator(project_config, html_templates)
262
    html_generator.export_assets(
263
        traceability_index=export_action.traceability_index,
264
        project_config=project_config,
265
        export_output_html_root=project_config.export_output_html_root,
266
    )
267
 
268
    sdoc_writer = SDWriter(project_config)
269
    markdown_writer = SDMarkdownWriter()
270
 
271
    def write_document_to_file(document: SDocDocument) -> None:
272
        """
273
        FIXME: Factorize this into an OOP class.
274
        """
275
 
276
        assert isinstance(document, SDocDocument)
277
 
278
        if (
279
            document.meta is not None
280
            and document.meta.input_doc_full_path.lower().endswith(
281
                (".md", ".markdown")
282
            )
283
        ):
284
            markdown_writer.write_to_file(document)
285
            return
286
 
287
        sdoc_writer.write_to_file(document)
288
 
289
    def env() -> JinjaEnvironment:
290
        return html_templates.jinja_environment()
291
 
292
    @app.exception_handler(404)
293
    async def not_found_handler(request: Request, exc: Exception) -> Response:  # noqa: ARG001
294
        return _error_response(HTTP_STATUS_NOT_FOUND)
295
 
296
    @app.exception_handler(500)
297
    async def internal_error_handler(
298
        request: Request,  # noqa: ARG001
299
        exc: Exception,  # noqa: ARG001
300
    ) -> Response:
301
        return _error_response(HTTP_STATUS_INTERNAL_SERVER_ERROR)
302
 
303
    def read_lock() -> Iterator[None]:
304
        with lock_manager.acquire_global_read():
305
            yield
306
 
307
    def write_lock() -> Iterator[None]:
308
        with lock_manager.acquire_global_write():
309
            yield
310
 
311
    async def parse_form_data(request: Request) -> FormData:
312
        return await request.form()
313
 
314
    router = APIRouter()
315
    read_router = APIRouter(dependencies=[Depends(read_lock)])
316
    write_router = APIRouter(dependencies=[Depends(write_lock)])
317
 
318
    @router.get("/")
319
    def get_root(request: Request) -> Response:
320
        return get_incoming_request(request, "index.html")
321
 
322
    @read_router.get("/actions/show_full_node", response_class=Response)
323
    def node__show_full(reference_mid: str) -> Response:
324
        node: Union[SDocNode] = (
325
            export_action.traceability_index.get_node_by_mid(MID(reference_mid))
326
        )
327
        requirement_document: SDocDocument = assert_cast(
328
            node.get_document(), SDocDocument
329
        )
330
        assert requirement_document.meta is not None
331
        link_renderer = LinkRenderer(
332
            root_path=requirement_document.meta.get_root_path_prefix(),
333
            static_path=project_config.dir_for_sdoc_assets,
334
        )
335
        markup_renderer = MarkupRenderer.create(
336
            markup=requirement_document.config.get_markup(),
337
            traceability_index=export_action.traceability_index,
338
            link_renderer=link_renderer,
339
            html_templates=html_generator.html_templates,
340
            config=project_config,
341
            context_document=requirement_document,
342
        )
343
        view_object = DocumentScreenViewObject(
344
            document_type=DocumentType.DOCUMENT,
345
            document=requirement_document,
346
            traceability_index=export_action.traceability_index,
347
            project_config=project_config,
348
            link_renderer=link_renderer,
349
            markup_renderer=markup_renderer,
350
            jinja_environment=env(),
351
            git_client=html_generator.git_client,
352
        )
353
        output = env().render_template_as_markup(
354
            "actions/node/show_full_node/stream_show_full_node.jinja",
355
            view_object=view_object,
356
            requirement=node,
357
        )
358
        return HTMLResponse(
359
            content=output,
360
            status_code=200,
361
            headers={
362
                "Content-Type": "text/vnd.turbo-stream.html",
363
            },
364
        )
365
 
366
    @read_router.get(
367
        "/actions/document/new_requirement", response_class=Response
368
    )
369
    def get_new_requirement(
370
        reference_mid: str,
371
        whereto: str,
372
        element_type: str,
373
        context_document_mid: str,
374
    ) -> Response:
375
        assert isinstance(reference_mid, str), reference_mid
376
        assert isinstance(whereto, str), whereto
377
        assert isinstance(element_type, str), element_type
378
        assert isinstance(context_document_mid, str), context_document_mid
379
 
380
        assert NodeCreationOrder.is_valid(whereto), whereto
381
 
382
        context_document = export_action.traceability_index.get_node_by_mid(
383
            MID(context_document_mid)
384
        )
385
 
386
        reference_node = export_action.traceability_index.get_node_by_mid(
387
            MID(reference_mid)
388
        )
389
        if not export_action.traceability_index.can_create_node_at(
390
            reference_node, whereto
391
        ):
392
            raise HTTPException(
393
                status_code=403,
394
                detail="Adding nodes is disabled for autogenerated content.",
395
            )
396
 
397
        # Which document becomes the new requirement's parent is based on
398
        # whether the reference node is a root node of an included document or not.
399
        document: SDocDocument
400
        if isinstance(reference_node, SDocDocument):
401
            if whereto == "child":
402
                document = reference_node
403
            else:
404
                document = context_document
405
        else:
406
            document = reference_node.get_document()
407
 
408
        next_uid: Optional[str] = None
409
        if element_type not in ("TEXT", "SECTION"):
410
            document_tree_stats: DocumentTreeStats = (
411
                DocumentUIDAnalyzer.analyze_document_tree(
412
                    export_action.traceability_index
413
                )
414
            )
415
            if (
416
                node_prefix := reference_node.get_prefix_for_new_node(
417
                    element_type
418
                )
419
            ) is not None:
420
                next_uid = document_tree_stats.get_next_requirement_uid(
421
                    node_prefix
422
                )
423
        form_object = RequirementFormObject.create_new(
424
            document=document,
425
            context_document_mid=context_document_mid,
426
            next_uid=next_uid,
427
            element_type=element_type,
428
        )
429
 
430
        target_node_mid = reference_mid
431
 
432
        if whereto == NodeCreationOrder.CHILD:
433
            replace_action = "after"
434
        elif whereto == NodeCreationOrder.BEFORE:
435
            replace_action = "before"
436
        elif whereto == NodeCreationOrder.AFTER:
437
            replace_action = "after"
438
        else:
439
            raise NotImplementedError
440
 
441
        assert document.meta is not None
442
        link_renderer = LinkRenderer(
443
            root_path=document.meta.get_root_path_prefix(),
444
            static_path=project_config.dir_for_sdoc_assets,
445
        )
446
        markup_renderer = MarkupRenderer.create(
447
            markup=document.config.get_markup(),
448
            traceability_index=export_action.traceability_index,
449
            link_renderer=link_renderer,
450
            html_templates=html_generator.html_templates,
451
            config=project_config,
452
            context_document=document,
453
        )
454
        output = env().render_template_as_markup(
455
            "actions/"
456
            "document/"
457
            "create_requirement/"
458
            "stream_new_requirement.jinja.html",
459
            is_new_requirement=True,
460
            renderer=markup_renderer,
461
            form_object=form_object,
462
            reference_mid=reference_mid,
463
            target_node_mid=target_node_mid,
464
            document_type=DocumentType.DOCUMENT,
465
            whereto=whereto,
466
            replace_action=replace_action,
467
        )
468
 
469
        return HTMLResponse(
470
            content=output,
471
            status_code=200,
472
            headers={
473
                "Content-Type": "text/vnd.turbo-stream.html",
474
            },
475
        )
476
 
477
    @read_router.get(
478
        "/actions/document/clone_requirement", response_class=Response
479
    )
480
    def get_clone_requirement(
481
        reference_mid: str, context_document_mid: str
482
    ) -> Response:
483
        assert isinstance(reference_mid, str), reference_mid
484
 
485
        reference_node = export_action.traceability_index.get_node_by_mid(
486
            MID(reference_mid)
487
        )
488
        reference_requirement: SDocNode = assert_cast(reference_node, SDocNode)
489
        if not export_action.traceability_index.can_clone_node(
490
            reference_requirement
491
        ):
492
            raise HTTPException(
493
                status_code=403,
494
                detail="Cloning is disabled for autogenerated content.",
495
            )
496
 
497
        document: Optional[SDocDocument] = (
498
            reference_node
499
            if isinstance(reference_node, SDocDocument)
500
            else reference_node.get_document()
501
        )
502
        document_tree_stats: DocumentTreeStats = (
503
            DocumentUIDAnalyzer.analyze_document_tree(
504
                export_action.traceability_index
505
            )
506
        )
507
        next_uid: str = ""
508
        if (node_prefix := reference_node.get_prefix()) is not None:
509
            next_uid = document_tree_stats.get_next_requirement_uid(node_prefix)
510
 
511
        form_object: RequirementFormObject = (
512
            RequirementFormObject.clone_from_requirement(
513
                requirement=reference_requirement,
514
                context_document_mid=context_document_mid,
515
                clone_uid=next_uid,
516
            )
517
        )
518
 
519
        target_node_mid = reference_mid
520
 
521
        whereto = NodeCreationOrder.AFTER
522
        replace_action = "after"
523
 
524
        assert document is not None
525
        assert document.meta is not None
526
        link_renderer = LinkRenderer(
527
            root_path=document.meta.get_root_path_prefix(),
528
            static_path=project_config.dir_for_sdoc_assets,
529
        )
530
        markup_renderer = MarkupRenderer.create(
531
            markup=document.config.get_markup(),
532
            traceability_index=export_action.traceability_index,
533
            link_renderer=link_renderer,
534
            html_templates=html_generator.html_templates,
535
            config=project_config,
536
            context_document=document,
537
        )
538
        output = env().render_template_as_markup(
539
            "actions/"
540
            "document/"
541
            "create_requirement/"
542
            "stream_new_requirement.jinja.html",
543
            is_new_requirement=True,
544
            renderer=markup_renderer,
545
            form_object=form_object,
546
            reference_mid=reference_mid,
547
            target_node_mid=target_node_mid,
548
            document_type=DocumentType.DOCUMENT,
549
            whereto=whereto,
550
            replace_action=replace_action,
551
        )
552
 
553
        return HTMLResponse(
554
            content=output,
555
            status_code=200,
556
            headers={
557
                "Content-Type": "text/vnd.turbo-stream.html",
558
            },
559
        )
560
 
561
    @write_router.post(
562
        "/actions/document/create_requirement", response_class=Response
563
    )
564
    def create_requirement(
565
        request_form_data: FormData = Depends(parse_form_data),
566
    ) -> Response:
567
        request_dict: Dict[str, str] = dict(request_form_data)
568
        requirement_mid: str = request_dict["requirement_mid"]
569
        document_mid: str = request_dict["document_mid"]
570
        context_document_mid: str = request_dict["context_document_mid"]
571
        reference_mid: str = request_dict["reference_mid"]
572
        whereto: str = request_dict["whereto"]
573
        document: SDocDocument = (
574
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
575
        )
576
        context_document: SDocDocument = (
577
            export_action.traceability_index.get_node_by_mid(
578
                MID(context_document_mid)
579
            )
580
        )
581
        reference_node = export_action.traceability_index.get_node_by_mid(
582
            MID(reference_mid)
583
        )
584
        if not export_action.traceability_index.can_create_node_at(
585
            reference_node, whereto
586
        ):
587
            raise HTTPException(
588
                status_code=403,
589
                detail="Adding nodes is disabled for autogenerated content.",
590
            )
591
 
592
        form_object: RequirementFormObject = (
593
            RequirementFormObject.create_from_request(
594
                is_new=True,
595
                requirement_mid=requirement_mid,
596
                request_form_data=request_form_data,
597
                document=document,
598
                existing_requirement_uid=None,
599
            )
600
        )
601
        form_object.validate(
602
            traceability_index=export_action.traceability_index,
603
            context_document=document,
604
            config=project_config,
605
            existing_revision=0,
606
        )
607
 
608
        if not form_object.any_errors():
609
            command = CreateOrUpdateNodeCommand(
610
                form_object=form_object,
611
                node_info=CreateNodeInfo(
612
                    whereto=whereto,
613
                    requirement_mid=requirement_mid,
614
                    reference_mid=reference_mid,
615
                ),
616
                context_document=context_document,
617
                traceability_index=export_action.traceability_index,
618
                project_config=project_config,
619
            )
620
            command.perform()
621
 
622
        if form_object.any_errors():
623
            assert document.meta is not None
624
            link_renderer = LinkRenderer(
625
                root_path=document.meta.get_root_path_prefix(),
626
                static_path=project_config.dir_for_sdoc_assets,
627
            )
628
            markup_renderer = MarkupRenderer.create(
629
                markup=document.config.get_markup(),
630
                traceability_index=export_action.traceability_index,
631
                link_renderer=link_renderer,
632
                html_templates=html_generator.html_templates,
633
                config=project_config,
634
                context_document=document,
635
            )
636
            output = env().render_template_as_markup(
637
                "actions/"
638
                "document/"
639
                "create_requirement/"
640
                "stream_new_requirement.jinja.html",
641
                is_new_requirement=True,
642
                renderer=markup_renderer,
643
                form_object=form_object,
644
                reference_mid=reference_mid,
645
                target_node_mid=requirement_mid,
646
                document_type=DocumentType.DOCUMENT,
647
                whereto=whereto,
648
                replace_action="replace",
649
            )
650
            return HTMLResponse(
651
                content=output,
652
                status_code=422,
653
                headers={
654
                    "Content-Type": "text/vnd.turbo-stream.html",
655
                },
656
            )
657
 
658
        # Saving new content to .SDoc files.
659
        write_document_to_file(document)
660
        if document != context_document:
661
            write_document_to_file(context_document)
662
 
663
        # Exporting the updated document to HTML. Note that this happens after
664
        # the traceability index last update marker has been updated. This way
665
        # the generated HTML file is newer than the traceability index.
666
        html_generator.export_single_document_with_performance(
667
            document=document,
668
            traceability_index=export_action.traceability_index,
669
            specific_documents=(DocumentType.DOCUMENT,),
670
        )
671
 
672
        assert document.meta is not None
673
        link_renderer = LinkRenderer(
674
            root_path=document.meta.get_root_path_prefix(),
675
            static_path=project_config.dir_for_sdoc_assets,
676
        )
677
        markup_renderer = MarkupRenderer.create(
678
            markup=document.config.get_markup(),
679
            traceability_index=export_action.traceability_index,
680
            link_renderer=link_renderer,
681
            html_templates=html_generator.html_templates,
682
            config=project_config,
683
            context_document=document,
684
        )
685
 
686
        view_object = DocumentScreenViewObject(
687
            document_type=DocumentType.DOCUMENT,
688
            document=context_document,
689
            traceability_index=export_action.traceability_index,
690
            project_config=project_config,
691
            link_renderer=link_renderer,
692
            markup_renderer=markup_renderer,
693
            jinja_environment=env(),
694
            git_client=html_generator.git_client,
695
        )
696
 
697
        output = view_object.render_updated_screen()
698
 
699
        return HTMLResponse(
700
            content=output,
701
            status_code=200,
702
            headers={
703
                "Content-Type": "text/vnd.turbo-stream.html",
704
            },
705
        )
706
 
707
    @read_router.get(
708
        "/actions/document/edit_requirement", response_class=Response
709
    )
710
    def get_edit_requirement(
711
        node_id: str, context_document_mid: str
712
    ) -> Response:
713
        """
714
        @relation(SDOC-SRS-55, scope=function)
715
        """
716
 
717
        requirement: SDocNode = (
718
            export_action.traceability_index.get_node_by_mid(MID(node_id))
719
        )
720
        if not export_action.traceability_index.can_edit_node(requirement):
721
            raise HTTPException(
722
                status_code=403,
723
                detail="Editing is disabled for autogenerated content.",
724
            )
725
 
726
        revision = revisions[requirement.reserved_mid.get_string_value()]
727
 
728
        form_object: RequirementFormObject = (
729
            RequirementFormObject.create_from_requirement(
730
                requirement=requirement,
731
                revision=revision,
732
                context_document_mid=context_document_mid,
733
            )
734
        )
735
        document: SDocDocument = assert_cast(
736
            requirement.get_document(), SDocDocument
737
        )
738
        assert document.meta is not None
739
        link_renderer = LinkRenderer(
740
            root_path=document.meta.get_root_path_prefix(),
741
            static_path=project_config.dir_for_sdoc_assets,
742
        )
743
        markup_renderer = MarkupRenderer.create(
744
            markup=document.config.get_markup(),
745
            traceability_index=export_action.traceability_index,
746
            link_renderer=link_renderer,
747
            html_templates=html_generator.html_templates,
748
            config=project_config,
749
            context_document=document,
750
        )
751
        output = env().render_template_as_markup(
752
            "actions/"
753
            "document/"
754
            "edit_requirement/"
755
            "stream_edit_requirement.jinja.html",
756
            is_new_requirement=False,
757
            renderer=markup_renderer,
758
            form_object=form_object,
759
            document_type=DocumentType.DOCUMENT,
760
        )
761
        return HTMLResponse(
762
            content=output,
763
            status_code=200,
764
            headers={
765
                "Content-Type": "text/vnd.turbo-stream.html",
766
            },
767
        )
768
 
769
    @read_router.get(
770
        "/reset_uid",
771
        response_class=Response,
772
    )
773
    def reset_uid(reference_mid: str) -> Response:
774
        document_tree_stats: DocumentTreeStats = (
775
            DocumentUIDAnalyzer.analyze_document_tree(
776
                export_action.traceability_index
777
            )
778
        )
779
        reference_node = export_action.traceability_index.get_node_by_mid_weak(
780
            MID(reference_mid)
781
        )
782
        next_uid: str = ""
783
        if (
784
            isinstance(reference_node, SDocNode)
785
            and reference_node.node_type == "SECTION"
786
        ):
787
            document: SDocDocument = assert_cast(
788
                reference_node.get_document(), SDocDocument
789
            )
790
            document_acronym = create_safe_acronym(document.title)
791
            next_uid = document_tree_stats.get_auto_section_uid(
792
                document_acronym, reference_node
793
            )
794
        elif isinstance(reference_node, SDocNode):
795
            if (node_prefix := reference_node.get_prefix()) is not None:
796
                next_uid = document_tree_stats.get_next_requirement_uid(
797
                    node_prefix
798
                )
799
        else:
800
            raise NotImplementedError(reference_node)  # pragma: no cover
801
 
802
        uid_form_field: RequirementFormField = RequirementFormField(
803
            field_mid=MID.create(),
804
            field_name="UID",
805
            field_type=RequirementFormFieldType.SINGLELINE,
806
            field_value=next_uid,
807
        )
808
        output = env().render_template_as_markup(
809
            "components/form/row/row_uid_with_reset/stream.jinja",
810
            next_uid=next_uid,
811
            reference_mid=reference_mid,
812
            uid_form_field=uid_form_field,
813
        )
814
        return HTMLResponse(
815
            content=output,
816
            status_code=200,
817
            headers={
818
                "Content-Type": "text/vnd.turbo-stream.html",
819
            },
820
        )
821
 
822
    @write_router.post("/actions/document/update_requirement")
823
    def document__update_requirement(
824
        request_form_data: FormData = Depends(parse_form_data),
825
    ) -> Response:
826
        """
827
        @relation(SDOC-SRS-55, scope=function)
828
        """
829
 
830
        request_dict = dict(request_form_data)
831
        requirement_mid = request_dict["requirement_mid"]
832
        requirement: SDocNode = (
833
            export_action.traceability_index.get_node_by_mid(
834
                MID(requirement_mid)
835
            )
836
        )
837
        if not export_action.traceability_index.can_edit_node(requirement):
838
            raise HTTPException(
839
                status_code=403,
840
                detail="Editing is disabled for autogenerated content.",
841
            )
842
 
843
        document = assert_cast(requirement.get_document(), SDocDocument)
844
 
845
        assert isinstance(requirement_mid, str) and len(requirement_mid) > 0, (
846
            f"{requirement_mid}"
847
        )
848
 
849
        form_object: RequirementFormObject = (
850
            RequirementFormObject.create_from_request(
851
                is_new=False,
852
                requirement_mid=requirement_mid,
853
                request_form_data=request_form_data,
854
                document=document,
855
                existing_requirement_uid=requirement.reserved_uid,
856
            )
857
        )
858
        existing_revision = revisions[form_object.requirement_mid]
859
 
860
        context_document: SDocDocument = (
861
            export_action.traceability_index.get_node_by_mid(
862
                MID(form_object.context_document_mid)
863
            )
864
        )
865
 
866
        form_object.validate(
867
            traceability_index=export_action.traceability_index,
868
            context_document=document,
869
            config=project_config,
870
            existing_revision=existing_revision,
871
        )
872
 
873
        update_requirement_command_result_or_none: Optional[
874
            CreateOrUpdateNodeResult
875
        ] = None
876
        if not form_object.any_errors():
877
            update_command = CreateOrUpdateNodeCommand(
878
                form_object=form_object,
879
                node_info=UpdateNodeInfo(node_to_update=requirement),
880
                context_document=context_document,
881
                traceability_index=export_action.traceability_index,
882
                project_config=project_config,
883
            )
884
 
885
            update_requirement_command_result_or_none = update_command.perform()
886
 
887
        link_renderer: LinkRenderer
888
        markup_renderer: MarkupRenderer
889
        assert document.meta is not None
890
        if form_object.any_errors():
891
            link_renderer = LinkRenderer(
892
                root_path=document.meta.get_root_path_prefix(),
893
                static_path=project_config.dir_for_sdoc_assets,
894
            )
895
            markup_renderer = MarkupRenderer.create(
896
                markup=document.config.get_markup(),
897
                traceability_index=export_action.traceability_index,
898
                link_renderer=link_renderer,
899
                html_templates=html_generator.html_templates,
900
                config=project_config,
901
                context_document=document,
902
            )
903
            output = env().render_template_as_markup(
904
                "actions/"
905
                "document/"
906
                "edit_requirement/"
907
                "stream_edit_requirement.jinja.html",
908
                is_new_requirement=False,
909
                renderer=markup_renderer,
910
                requirement=requirement,
911
                document_type=DocumentType.DOCUMENT,
912
                form_object=form_object,
913
            )
914
            return HTMLResponse(
915
                content=output,
916
                status_code=422,
917
                headers={
918
                    "Content-Type": "text/vnd.turbo-stream.html",
919
                },
920
            )
921
 
922
        update_requirement_command_result: CreateOrUpdateNodeResult = (
923
            assert_cast(
924
                update_requirement_command_result_or_none,
925
                CreateOrUpdateNodeResult,
926
            )
927
        )
928
 
929
        # Saving new content to .SDoc files.
930
        write_document_to_file(document)
931
 
932
        revisions[requirement_mid] += 1
933
 
934
        # Exporting the updated document to HTML. Note that this happens after
935
        # the traceability index last update marker has been updated. This way
936
        # the generated HTML file is newer than the traceability index.
937
        html_generator.export_single_document_with_performance(
938
            document=document,
939
            traceability_index=export_action.traceability_index,
940
            specific_documents=(DocumentType.DOCUMENT,),
941
        )
942
 
943
        link_renderer = LinkRenderer(
944
            root_path=document.meta.get_root_path_prefix(),
945
            static_path=project_config.dir_for_sdoc_assets,
946
        )
947
        markup_renderer = MarkupRenderer.create(
948
            markup=document.config.get_markup(),
949
            traceability_index=export_action.traceability_index,
950
            link_renderer=link_renderer,
951
            html_templates=html_generator.html_templates,
952
            config=project_config,
953
            context_document=document,
954
        )
955
        view_object = DocumentScreenViewObject(
956
            document_type=DocumentType.DOCUMENT,
957
            document=document,
958
            traceability_index=export_action.traceability_index,
959
            project_config=project_config,
960
            link_renderer=link_renderer,
961
            markup_renderer=markup_renderer,
962
            jinja_environment=env(),
963
            git_client=html_generator.git_client,
964
        )
965
 
966
        return HTMLResponse(
967
            content=view_object.render_updated_nodes_and_toc(
968
                update_requirement_command_result.this_document_requirements_to_update,
969
                node_updated=True,
970
            ),
971
            status_code=200,
972
            headers={
973
                "Content-Type": "text/vnd.turbo-stream.html",
974
            },
975
        )
976
 
977
    @read_router.get(
978
        "/actions/document/cancel_new_requirement", response_class=Response
979
    )
980
    def cancel_new_requirement(requirement_mid: str) -> Response:
981
        output = env().render_template_as_markup(
982
            "actions/"
983
            "document/"
984
            "create_requirement/"
985
            "stream_cancel_new_requirement.jinja.html",
986
            requirement_mid=requirement_mid,
987
        )
988
        return HTMLResponse(
989
            content=output,
990
            status_code=200,
991
            headers={
992
                "Content-Type": "text/vnd.turbo-stream.html",
993
            },
994
        )
995
 
996
    @read_router.get(
997
        "/actions/document/cancel_edit_requirement", response_class=Response
998
    )
999
    def cancel_edit_requirement(requirement_mid: str) -> Response:
1000
        """
1001
        @relation(SDOC-SRS-55, scope=function)
1002
        """
1003
 
1004
        assert isinstance(requirement_mid, str) and len(requirement_mid) > 0, (
1005
            f"{requirement_mid}"
1006
        )
1007
        requirement: SDocNode = (
1008
            export_action.traceability_index.get_node_by_mid(
1009
                MID(requirement_mid)
1010
            )
1011
        )
1012
        document: SDocDocument = assert_cast(
1013
            requirement.get_document(), SDocDocument
1014
        )
1015
        assert document.meta is not None
1016
        link_renderer = LinkRenderer(
1017
            root_path=document.meta.get_root_path_prefix(),
1018
            static_path=project_config.dir_for_sdoc_assets,
1019
        )
1020
        markup_renderer = MarkupRenderer.create(
1021
            markup=document.config.get_markup(),
1022
            traceability_index=export_action.traceability_index,
1023
            link_renderer=link_renderer,
1024
            html_templates=html_generator.html_templates,
1025
            config=project_config,
1026
            context_document=document,
1027
        )
1028
        view_object = DocumentScreenViewObject(
1029
            document_type=DocumentType.DOCUMENT,
1030
            document=document,
1031
            traceability_index=export_action.traceability_index,
1032
            project_config=project_config,
1033
            link_renderer=link_renderer,
1034
            markup_renderer=markup_renderer,
1035
            jinja_environment=env(),
1036
            git_client=html_generator.git_client,
1037
        )
1038
        return HTMLResponse(
1039
            content=view_object.render_updated_nodes_and_toc(
1040
                [requirement], node_updated=False
1041
            ),
1042
            headers={
1043
                "Content-Type": "text/vnd.turbo-stream.html",
1044
            },
1045
        )
1046
 
1047
    @write_router.delete(
1048
        "/actions/document/delete_requirement",
1049
        response_class=Response,
1050
    )
1051
    def delete_requirement(
1052
        node_id: str, context_document_mid: str, confirmed: bool = False
1053
    ) -> Response:
1054
        requirement: SDocNode = (
1055
            export_action.traceability_index.get_node_by_mid(MID(node_id))
1056
        )
1057
        if not export_action.traceability_index.can_delete_node(requirement):
1058
            raise HTTPException(
1059
                status_code=403,
1060
                detail="Deleting is disabled for autogenerated content.",
1061
            )
1062
 
1063
        document: SDocDocument = assert_cast(
1064
            requirement.get_document(), SDocDocument
1065
        )
1066
        if not confirmed:
1067
            errors: List[str]
1068
            try:
1069
                delete_command = DeleteRequirementCommand(
1070
                    requirement=requirement,
1071
                    traceability_index=export_action.traceability_index,
1072
                )
1073
                delete_command.validate()
1074
                errors = []
1075
            except MultipleValidationErrorAsList as error_:
1076
                errors = error_.errors
1077
 
1078
            output = env().render_template_as_markup(
1079
                "actions/document/delete_requirement/"
1080
                "stream_confirm_delete_requirement.jinja",
1081
                requirement_mid=node_id,
1082
                context_document_mid=context_document_mid,
1083
                errors=errors,
1084
            )
1085
            return HTMLResponse(
1086
                content=output,
1087
                status_code=200 if len(errors) == 0 else 422,
1088
                headers={
1089
                    "Content-Type": "text/vnd.turbo-stream.html",
1090
                },
1091
            )
1092
 
1093
        try:
1094
            delete_command = DeleteRequirementCommand(
1095
                requirement=requirement,
1096
                traceability_index=export_action.traceability_index,
1097
            )
1098
            delete_command.perform()
1099
        except MultipleValidationError:
1100
            return HTMLResponse(
1101
                content="",
1102
                status_code=422,
1103
                headers={
1104
                    "Content-Type": "text/vnd.turbo-stream.html",
1105
                },
1106
            )
1107
 
1108
        # Saving new content to .SDoc file.
1109
        write_document_to_file(document)
1110
 
1111
        context_document: SDocDocument = (
1112
            export_action.traceability_index.get_node_by_mid(
1113
                MID(context_document_mid)
1114
            )
1115
        )
1116
 
1117
        # Rendering back the Turbo template.
1118
        assert document.meta is not None
1119
        link_renderer = LinkRenderer(
1120
            root_path=document.meta.get_root_path_prefix(),
1121
            static_path=project_config.dir_for_sdoc_assets,
1122
        )
1123
        markup_renderer = MarkupRenderer.create(
1124
            markup=document.config.get_markup(),
1125
            traceability_index=export_action.traceability_index,
1126
            link_renderer=link_renderer,
1127
            html_templates=html_generator.html_templates,
1128
            config=project_config,
1129
            context_document=document,
1130
        )
1131
        view_object: DocumentScreenViewObject = DocumentScreenViewObject(
1132
            document_type=DocumentType.DOCUMENT,
1133
            document=context_document,
1134
            traceability_index=export_action.traceability_index,
1135
            project_config=project_config,
1136
            link_renderer=link_renderer,
1137
            markup_renderer=markup_renderer,
1138
            jinja_environment=env(),
1139
            git_client=html_generator.git_client,
1140
        )
1141
        output = env().render_template_as_markup(
1142
            "actions/document/delete_requirement/"
1143
            "stream_delete_requirement.jinja.html",
1144
            view_object=view_object,
1145
        )
1146
 
1147
        output += env().render_template_as_markup(
1148
            "actions/document/_shared/stream_updated_toc.jinja.html",
1149
            view_object=view_object,
1150
        )
1151
 
1152
        output += env().render_template_as_markup(
1153
            "actions/document/_shared/stream_updated_viewtype_menu.jinja.html",
1154
            view_object=view_object,
1155
        )
1156
 
1157
        return HTMLResponse(
1158
            content=output,
1159
            status_code=200,
1160
            headers={
1161
                "Content-Type": "text/vnd.turbo-stream.html",
1162
            },
1163
        )
1164
 
1165
    @write_router.post("/actions/document/move_node", response_class=Response)
1166
    def move_node(
1167
        request_form_data: FormData = Depends(parse_form_data),
1168
    ) -> Response:
1169
        """
1170
        @relation(SDOC-SRS-92, scope=function)
1171
        """
1172
 
1173
        request_dict: Dict[str, str] = dict(request_form_data)
1174
        moved_node_mid: str = request_dict["moved_node_mid"]
1175
        target_mid: str = request_dict["target_mid"]
1176
        whereto: str = request_dict["whereto"]
1177
 
1178
        assert export_action.traceability_index is not None
1179
 
1180
        moved_node = export_action.traceability_index.get_node_by_mid(
1181
            MID(moved_node_mid)
1182
        )
1183
        document: SDocDocument = assert_cast(
1184
            moved_node.get_document(), SDocDocument
1185
        )
1186
        target_node = export_action.traceability_index.get_node_by_mid(
1187
            MID(target_mid)
1188
        )
1189
        moved_sdoc_node = assert_cast(moved_node, SDocNode)
1190
        if not export_action.traceability_index.can_move_node_to(
1191
            moved_sdoc_node, target_node, whereto
1192
        ):
1193
            raise HTTPException(
1194
                status_code=403,
1195
                detail="Moving is disabled for autogenerated content.",
1196
            )
1197
 
1198
        current_parent_node = moved_node.parent
1199
 
1200
        # Currently UI allows a child-like drag-and-drop on a leaf (non-composite) node.
1201
        # In that case, we make it add a node **after** the target node
1202
        # (not as its child because that's not possible).
1203
        if (
1204
            whereto == NodeCreationOrder.CHILD
1205
            and isinstance(target_node, SDocNode)
1206
            and not target_node.is_composite
1207
        ):
1208
            whereto = NodeCreationOrder.AFTER
1209
 
1210
        if whereto == NodeCreationOrder.CHILD:
1211
            # Disconnect the moved_node from its parent.
1212
            current_parent_node.section_contents.remove(moved_node)
1213
            # Append to the end of child list.
1214
            target_node.section_contents.append(moved_node)
1215
            moved_node.parent = target_node
1216
        elif whereto == NodeCreationOrder.BEFORE:
1217
            # Disconnect the moved_node from its parent.
1218
            current_parent_node.section_contents.remove(moved_node)
1219
            # Append before.
1220
            insert_to_idx = target_node.parent.section_contents.index(
1221
                target_node
1222
            )
1223
            target_node.parent.section_contents.insert(
1224
                insert_to_idx, moved_node
1225
            )
1226
            moved_node.parent = target_node.parent
1227
        elif whereto == NodeCreationOrder.AFTER:
1228
            # Disconnect the moved_node from its parent.
1229
            current_parent_node.section_contents.remove(moved_node)
1230
            # Append after.
1231
            insert_to_idx = target_node.parent.section_contents.index(
1232
                target_node
1233
            )
1234
            target_node.parent.section_contents.insert(
1235
                insert_to_idx + 1, moved_node
1236
            )
1237
            moved_node.parent = target_node.parent
1238
        else:
1239
            raise NotImplementedError
1240
 
1241
        # Saving new content to .SDoc file.
1242
        write_document_to_file(document)
1243
 
1244
        # Update the index because other documents might reference this
1245
        # document's sections. These documents will be regenerated on demand,
1246
        # when they are opened next time.
1247
        export_action.traceability_index.update_last_updated()
1248
 
1249
        assert document.meta is not None
1250
        link_renderer = LinkRenderer(
1251
            root_path=document.meta.get_root_path_prefix(),
1252
            static_path=project_config.dir_for_sdoc_assets,
1253
        )
1254
        markup_renderer = MarkupRenderer.create(
1255
            markup=document.config.get_markup(),
1256
            traceability_index=export_action.traceability_index,
1257
            link_renderer=link_renderer,
1258
            html_templates=html_generator.html_templates,
1259
            config=project_config,
1260
            context_document=document,
1261
        )
1262
        view_object = DocumentScreenViewObject(
1263
            document_type=DocumentType.DOCUMENT,
1264
            document=document,
1265
            traceability_index=export_action.traceability_index,
1266
            project_config=project_config,
1267
            link_renderer=link_renderer,
1268
            markup_renderer=markup_renderer,
1269
            jinja_environment=env(),
1270
            git_client=html_generator.git_client,
1271
        )
1272
        return HTMLResponse(
1273
            content=view_object.render_update_document_content_with_moved_node(
1274
                moved_node
1275
            ),
1276
            headers={
1277
                "Content-Type": "text/vnd.turbo-stream.html",
1278
            },
1279
        )
1280
 
1281
    @read_router.get(
1282
        "/actions/project_index/new_document", response_class=Response
1283
    )
1284
    def get_new_document() -> Response:
1285
        """
1286
        @relation(SDOC-SRS-107, scope=function)
1287
        """
1288
 
1289
        output = env().render_template_as_markup(
1290
            "actions/project_index/stream_new_document.jinja.html",
1291
            error_object=ErrorObject(),
1292
            document_title="",
1293
            document_path="",
1294
            include_doc_paths=project_config.include_doc_paths,
1295
        )
1296
        return HTMLResponse(
1297
            content=output,
1298
            headers={
1299
                "Content-Type": "text/vnd.turbo-stream.html",
1300
            },
1301
        )
1302
 
1303
    @read_router.get(
1304
        "/actions/project_index/edit_project_title_form",
1305
        response_class=Response,
1306
    )
1307
    def get_edit_project_title_form() -> Response:
1308
        error_object = ErrorObject()
1309
        output = env().render_template_as_markup(
1310
            "actions/project_index/edit_project_title/"
1311
            "stream_form_edit_project_title.jinja.html",
1312
            error_object=error_object,
1313
            project_config=project_config,
1314
        )
1315
        return HTMLResponse(
1316
            content=output,
1317
            headers={
1318
                "Content-Type": "text/vnd.turbo-stream.html",
1319
            },
1320
        )
1321
 
1322
    @write_router.post(
1323
        "/actions/project_index/save_project_title", response_class=Response
1324
    )
1325
    def save_project_title(project_title: str = Form("")) -> Response:
1326
        error_object = ErrorObject()
1327
 
1328
        new_title = project_title.strip() if project_title is not None else ""
1329
        if len(new_title) == 0:
1330
            error_object.add_error(
1331
                "project_title", "Project title must not be empty."
1332
            )
1333
 
1334
        if error_object.any_errors():
1335
            output = env().render_template_as_markup(
1336
                "actions/project_index/edit_project_title/"
1337
                "stream_form_edit_project_title.jinja.html",
1338
                error_object=error_object,
1339
                project_config=project_config,
1340
                new_title=new_title,
1341
            )
1342
            return HTMLResponse(
1343
                content=output,
1344
                status_code=200,
1345
                headers={
1346
                    "Content-Type": "text/vnd.turbo-stream.html",
1347
                },
1348
            )
1349
 
1350
        # Try to persist the new title into the project configuration when available.
1351
        project_root = project_config.get_project_root_path()
1352
        config_toml_path: Optional[str] = None
1353
        config_py_path: Optional[str] = None
1354
 
1355
        if os.path.isdir(project_root):
1356
            # Prefer Python config when both exist.
1357
            candidate_py = os.path.join(project_root, "strictdoc_config.py")
1358
            candidate_toml = os.path.join(project_root, "strictdoc.toml")
1359
            if os.path.isfile(candidate_py):
1360
                config_py_path = candidate_py
1361
            elif os.path.isfile(candidate_toml):
1362
                config_toml_path = candidate_toml
1363
        else:
1364
            # project_root may point directly to a config file or to an
1365
            # input path next to the config files.
1366
            if project_root.endswith("strictdoc.toml"):
1367
                config_toml_path = project_root
1368
            elif project_root.endswith("strictdoc_config.py"):
1369
                config_py_path = project_root
1370
            else:
1371
                config_dir = os.path.dirname(project_root)
1372
                candidate_py = os.path.join(config_dir, "strictdoc_config.py")
1373
                candidate_toml = os.path.join(config_dir, "strictdoc.toml")
1374
                if os.path.isfile(candidate_py):
1375
                    config_py_path = candidate_py
1376
                elif os.path.isfile(candidate_toml):
1377
                    config_toml_path = candidate_toml
1378
 
1379
        # strictdoc.toml is not supported anymore.
1380
        if config_toml_path is not None:
1381
            error_object = ErrorObject()
1382
 
1383
            error_object.add_error(
1384
                "project_title",
1385
                "Renaming project title is not supported with TOML config files. Switch from strictdoc_config.toml to strictdoc_config.py and try again.",
1386
            )
1387
 
1388
            output = env().render_template_as_markup(
1389
                "actions/project_index/edit_project_title/"
1390
                "stream_form_edit_project_title.jinja.html",
1391
                error_object=error_object,
1392
                project_config=project_config,
1393
                new_title=new_title,
1394
            )
1395
            return HTMLResponse(
1396
                content=output,
1397
                status_code=400,
1398
                headers={
1399
                    "Content-Type": "text/vnd.turbo-stream.html",
1400
                },
1401
            )
1402
 
1403
        # Update strictdoc_config.py by editing its title using regex.
1404
        # The implementation is pretty hacky but should work for now.
1405
        if config_py_path is not None:
1406
            with open(config_py_path, encoding="utf8") as config_file:
1407
                config_text = config_file.read()
1408
 
1409
            pattern = re.compile(
1410
                r"(project_title\s*=\s*)([\"'])(.*?)([\"'])",
1411
                re.DOTALL,
1412
            )
1413
 
1414
            def _replace_title(match: re.Match[str]) -> str:
1415
                prefix = match.group(1)
1416
                quote = match.group(2)
1417
                escaped_title = new_title.replace(quote, "\\" + quote)
1418
                return f"{prefix}{quote}{escaped_title}{quote}"
1419
 
1420
            new_text, count = pattern.subn(_replace_title, config_text, count=1)
1421
 
1422
            if count > 0:
1423
                with open(config_py_path, "w", encoding="utf8") as config_file:
1424
                    config_file.write(new_text)
1425
            else:
1426
                error_object = ErrorObject()
1427
 
1428
                error_object.add_error(
1429
                    "project_title",
1430
                    (
1431
                        "Renaming project title is not supported when a title is "
1432
                        "not already configured to a previous value in"
1433
                        "strictdoc_config.py."
1434
                    ),
1435
                )
1436
 
1437
                output = env().render_template_as_markup(
1438
                    "actions/project_index/edit_project_title/"
1439
                    "stream_form_edit_project_title.jinja.html",
1440
                    error_object=error_object,
1441
                    project_config=project_config,
1442
                    new_title=new_title,
1443
                )
1444
                return HTMLResponse(
1445
                    content=output,
1446
                    status_code=400,
1447
                    headers={
1448
                        "Content-Type": "text/vnd.turbo-stream.html",
1449
                    },
1450
                )
1451
 
1452
        # Update in-memory project configuration after successful validation
1453
        # of where the title can be stored on disk.
1454
        project_config.project_title = new_title
1455
 
1456
        # This ensures that the cached project index HTML page is invalidated.
1457
        export_action.traceability_index.update_last_updated()
1458
 
1459
        # Return Turbo Streams to update the header title and close the modal.
1460
        output = env().render_template_as_markup(
1461
            "actions/project_index/edit_project_title/"
1462
            "stream_save_project_title.jinja.html",
1463
            project_config=project_config,
1464
        )
1465
        return HTMLResponse(
1466
            content=output,
1467
            status_code=200,
1468
            headers={
1469
                "Content-Type": "text/vnd.turbo-stream.html",
1470
            },
1471
        )
1472
 
1473
    @write_router.post(
1474
        "/actions/project_index/create_document", response_class=Response
1475
    )
1476
    def document_tree__create_document(
1477
        document_title: str = Form(""),
1478
        document_path: str = Form(""),
1479
    ) -> Response:
1480
        """
1481
        @relation(SDOC-SRS-107, scope=function)
1482
        """
1483
 
1484
        error_object = ErrorObject()
1485
        if document_title is None or len(document_title) == 0:
1486
            error_object.add_error(
1487
                "document_title", "Document title must not be empty."
1488
            )
1489
        if document_path is None or len(document_path) == 0:
1490
            error_object.add_error(
1491
                "document_path", "Document path must not be empty."
1492
            )
1493
        else:
1494
            document_path = document_path.strip().lstrip("/")
1495
            if not is_safe_alphanumeric_string(document_path):
1496
                error_object.add_error(
1497
                    "document_path",
1498
                    (
1499
                        "Document path must be relative and only contain "
1500
                        "slashes, alphanumeric characters, "
1501
                        "and underscore symbols."
1502
                    ),
1503
                )
1504
 
1505
        if project_config.include_doc_paths is not None:
1506
            path_filter_includes = PathFilter(
1507
                project_config.include_doc_paths, positive_or_negative=True
1508
            )
1509
            if not path_filter_includes.match(document_path):
1510
                error_object.add_error(
1511
                    "document_path",
1512
                    (
1513
                        "Document path is not a valid path according to "
1514
                        "the project config's setting 'include_doc_paths': "
1515
                        f"{project_config.include_doc_paths}."
1516
                    ),
1517
                )
1518
        if project_config.exclude_doc_paths is not None:
1519
            path_filter_excludes = PathFilter(
1520
                project_config.exclude_doc_paths, positive_or_negative=False
1521
            )
1522
            if path_filter_excludes.match(document_path):
1523
                error_object.add_error(
1524
                    "document_path",
1525
                    (
1526
                        "Document path is not a valid path according to "
1527
                        "the project config's setting 'exclude_doc_paths': "
1528
                        f"{project_config.exclude_doc_paths}."
1529
                    ),
1530
                )
1531
 
1532
        if error_object.any_errors():
1533
            output = env().render_template_as_markup(
1534
                "actions/project_index/stream_new_document.jinja.html",
1535
                error_object=error_object,
1536
                document_title=document_title
1537
                if document_title is not None
1538
                else "",
1539
                document_path=document_path
1540
                if document_path is not None
1541
                else "",
1542
                include_doc_paths=project_config.include_doc_paths,
1543
            )
1544
            return HTMLResponse(
1545
                content=output,
1546
                status_code=200,
1547
                headers={
1548
                    "Content-Type": "text/vnd.turbo-stream.html",
1549
                },
1550
            )
1551
 
1552
        if not document_path.endswith(".sdoc"):
1553
            document_path = document_path + ".sdoc"
1554
 
1555
        assert isinstance(project_config.input_paths, list)
1556
        full_input_path = os.path.abspath(project_config.input_paths[0])
1557
        file_tree_mount_folder = os.path.basename(
1558
            os.path.dirname(full_input_path)
1559
        )
1560
        doc_full_path = os.path.join(full_input_path, document_path)
1561
        doc_full_path_dir = os.path.dirname(doc_full_path)
1562
        document_file_name = os.path.basename(doc_full_path)
1563
        input_doc_dir_rel_path = os.path.dirname(document_path)
1564
        input_doc_assets_dir_rel_path = (
1565
            "/".join(
1566
                (
1567
                    file_tree_mount_folder,
1568
                    input_doc_dir_rel_path,
1569
                    "_assets",
1570
                )
1571
            )
1572
            if len(input_doc_dir_rel_path) > 0
1573
            else "/".join((file_tree_mount_folder, "_assets"))
1574
        )
1575
 
1576
        Path(doc_full_path_dir).mkdir(parents=True, exist_ok=True)
1577
        document = SDocDocument(
1578
            mid=None,
1579
            title=document_title,
1580
            config=None,
1581
            view=None,
1582
            grammar=DocumentGrammar.create_default(parent=None),
1583
            section_contents=[],
1584
        )
1585
        # FIXME: Fill in the document meta correctly.
1586
        document.meta = DocumentMeta(
1587
            level=0,
1588
            file_tree_mount_folder="NOT_RELEVANT",
1589
            document_filename=document_file_name,
1590
            document_filename_base="NOT_RELEVANT",
1591
            input_doc_full_path=doc_full_path,
1592
            input_doc_rel_path=SDocRelativePath(document_path),
1593
            input_doc_dir_rel_path=SDocRelativePath(input_doc_dir_rel_path),
1594
            input_doc_assets_dir_rel_path=SDocRelativePath(
1595
                input_doc_assets_dir_rel_path
1596
            ),
1597
            output_document_dir_full_path="NOT_RELEVANT",
1598
            output_document_dir_rel_path=SDocRelativePath("FIXME"),
1599
        )
1600
 
1601
        write_document_to_file(document)
1602
 
1603
        export_action.build_index()
1604
        export_action.export()
1605
 
1606
        view_object = ProjectTreeViewObject(
1607
            traceability_index=export_action.traceability_index,
1608
            project_config=project_config,
1609
        )
1610
        output = env().render_template_as_markup(
1611
            "actions/project_index/stream_create_document.jinja.html",
1612
            view_object=view_object,
1613
        )
1614
        return HTMLResponse(
1615
            content=output,
1616
            status_code=200,
1617
            headers={
1618
                "Content-Type": "text/vnd.turbo-stream.html",
1619
            },
1620
        )
1621
 
1622
    @write_router.delete(
1623
        "/actions/document/delete_document",
1624
        response_class=Response,
1625
    )
1626
    def delete_document(document_mid: str, confirmed: bool = False) -> Response:
1627
        """
1628
        Delete an entire SDOC document from the project.
1629
 
1630
        This endpoint is intentionally simple: it removes the underlying
1631
        ``.sdoc`` file from disk, rebuilds the index and redirects back to the
1632
        project index screen. For now, it is up to the user to ensure that
1633
        no other documents depend on this one (for example via ``INCLUDE``).
1634
        """
1635
 
1636
        document: SDocDocument = assert_cast(
1637
            export_action.traceability_index.get_node_by_mid(MID(document_mid)),
1638
            SDocDocument,
1639
        )
1640
        if not export_action.traceability_index.can_delete_node(document):
1641
            raise HTTPException(
1642
                status_code=403,
1643
                detail="Deleting is disabled for autogenerated content.",
1644
            )
1645
 
1646
        assert document.meta is not None
1647
 
1648
        errors: List[str] = []
1649
        try:
1650
            export_action.traceability_index.validate_can_remove_document(
1651
                document
1652
            )
1653
        except MultipleValidationErrorAsList as error_:
1654
            errors = error_.errors
1655
 
1656
        if not confirmed:
1657
            output = env().render_template_as_markup(
1658
                "actions/document/delete_document/"
1659
                "stream_confirm_delete_document.jinja",
1660
                document_mid=document_mid,
1661
                errors=errors,
1662
            )
1663
            return HTMLResponse(
1664
                content=output,
1665
                status_code=200 if len(errors) == 0 else 422,
1666
                headers={
1667
                    "Content-Type": "text/vnd.turbo-stream.html",
1668
                },
1669
            )
1670
 
1671
        if len(errors) > 0:
1672
            output = env().render_template_as_markup(
1673
                "actions/document/delete_document/"
1674
                "stream_confirm_delete_document.jinja",
1675
                document_mid=document_mid,
1676
                errors=errors,
1677
            )
1678
            return HTMLResponse(
1679
                content=output,
1680
                status_code=422,
1681
                headers={
1682
                    "Content-Type": "text/vnd.turbo-stream.html",
1683
                },
1684
            )
1685
 
1686
        # Remove the underlying SDOC file.
1687
        path_to_document = document.meta.input_doc_full_path
1688
        try:
1689
            if os.path.exists(path_to_document):
1690
                os.remove(path_to_document)
1691
        except OSError:
1692
            # If the file cannot be removed, keep the project index intact and
1693
            # fall back to a normal redirect; the error can be inspected in
1694
            # server logs.
1695
            pass
1696
 
1697
        # Best-effort cleanup of generated HTML artifacts for this document.
1698
        # Not all of these files are guaranteed to exist (e.g. PDF export).
1699
        html_paths = [
1700
            document.meta.get_html_doc_path(),
1701
            document.meta.get_html_table_path(),
1702
            document.meta.get_html_traceability_path(),
1703
            document.meta.get_html_deep_traceability_path(),
1704
            document.meta.get_html_pdf_path(),
1705
        ]
1706
        for html_path in html_paths:
1707
            try:
1708
                if os.path.exists(html_path):
1709
                    os.remove(html_path)
1710
            except OSError:
1711
                # Ignore individual file deletion errors; remaining files can
1712
                # be cleaned up manually if necessary.
1713
                continue
1714
 
1715
        # Rebuild the project index so the removed document disappears from
1716
        # the project tree and related views.
1717
        export_action.build_index()
1718
        export_action.export()
1719
 
1720
        # Redirect back to the project index page.
1721
        return RedirectResponse("/", status_code=303)
1722
 
1723
    @read_router.get("/actions/document/new_comment", response_class=Response)
1724
    def document__add_comment(
1725
        requirement_mid: str,
1726
        document_mid: str,
1727
        context_document_mid: str,
1728
        element_type: str,
1729
        revision: str,
1730
    ) -> Response:
1731
        document: SDocDocument = (
1732
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
1733
        )
1734
        assert document.grammar is not None
1735
        grammar: DocumentGrammar = document.grammar
1736
        # The data of the form object is ignored. What matters is the comment
1737
        # form data.
1738
        output = env().render_template_as_markup(
1739
            "actions/"
1740
            "document/"
1741
            "add_requirement_comment/"
1742
            "stream_add_requirement_comment.jinja.html",
1743
            requirement_mid=requirement_mid,
1744
            form_object=RequirementFormObject(
1745
                is_new=False,
1746
                element_type=element_type,
1747
                revision=int(revision),
1748
                requirement_mid=requirement_mid,
1749
                document_mid=document.reserved_mid,
1750
                context_document_mid=context_document_mid,
1751
                fields=[],
1752
                reference_fields=[],
1753
                existing_requirement_uid=None,
1754
                grammar=grammar,
1755
                relation_types=[],
1756
            ),
1757
            field=RequirementFormField(
1758
                field_mid=MID.create(),
1759
                field_name="COMMENT",
1760
                field_type=RequirementFormFieldType.MULTILINE,
1761
                field_value="",
1762
            ),
1763
        )
1764
        return HTMLResponse(
1765
            content=output,
1766
            status_code=200,
1767
            headers={
1768
                "Content-Type": "text/vnd.turbo-stream.html",
1769
            },
1770
        )
1771
 
1772
    @read_router.get("/actions/document/new_relation", response_class=Response)
1773
    def document__add_relation(
1774
        requirement_mid: str,
1775
        document_mid: str,
1776
        context_document_mid: str,
1777
        element_type: str,
1778
        revision: str,
1779
    ) -> Response:
1780
        document: SDocDocument = (
1781
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
1782
        )
1783
        assert document.grammar is not None
1784
        grammar: DocumentGrammar = document.grammar
1785
 
1786
        element: GrammarElement = grammar.elements_by_type[element_type]
1787
        grammar_element_relations = element.get_relation_types()
1788
 
1789
        # The data of the form object is ignored. What matters is the relation
1790
        # form data.
1791
        output = env().render_template_as_markup(
1792
            "actions/"
1793
            "document/"
1794
            "add_requirement_relation/"
1795
            "stream_add_requirement_relation.jinja.html",
1796
            requirement_mid=requirement_mid,
1797
            form_object=RequirementFormObject(
1798
                is_new=False,
1799
                element_type=element_type,
1800
                revision=int(revision),
1801
                requirement_mid=requirement_mid,
1802
                document_mid=document_mid,
1803
                context_document_mid=context_document_mid,
1804
                fields=[],
1805
                reference_fields=[],
1806
                existing_requirement_uid=None,
1807
                grammar=grammar,
1808
                relation_types=grammar_element_relations,
1809
            ),
1810
            field=RequirementReferenceFormField(
1811
                field_mid=MID.create(),
1812
                field_type=RequirementReferenceFormField.FieldType.PARENT,
1813
                field_value="",
1814
                field_role="",
1815
            ),
1816
            relation_types=grammar_element_relations,
1817
        )
1818
        return HTMLResponse(
1819
            content=output,
1820
            status_code=200,
1821
            headers={
1822
                "Content-Type": "text/vnd.turbo-stream.html",
1823
            },
1824
        )
1825
 
1826
    @read_router.get("/actions/document/edit_config", response_class=Response)
1827
    def document__edit_config(document_mid: str) -> Response:
1828
        """
1829
        @relation(SDOC-SRS-57, scope=function)
1830
        """
1831
 
1832
        document: SDocDocument = (
1833
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
1834
        )
1835
        if not export_action.traceability_index.can_edit_document(document):
1836
            raise HTTPException(
1837
                status_code=403,
1838
                detail="Editing is disabled for autogenerated content.",
1839
            )
1840
 
1841
        form_object = DocumentConfigFormObject.create_from_document(
1842
            document=document
1843
        )
1844
 
1845
        output = env().render_template_as_markup(
1846
            "actions/"
1847
            "document/"
1848
            "edit_document_config/"
1849
            "stream_edit_document_config.jinja.html",
1850
            form_object=form_object,
1851
            document=document,
1852
        )
1853
        return HTMLResponse(
1854
            content=output,
1855
            status_code=200,
1856
            headers={
1857
                "Content-Type": "text/vnd.turbo-stream.html",
1858
            },
1859
        )
1860
 
1861
    @read_router.get("/actions/document/new_metadata", response_class=Response)
1862
    def document__add_metadata(
1863
        document_mid: str,
1864
    ) -> Response:
1865
        document: SDocDocument = (
1866
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
1867
        )
1868
        if not export_action.traceability_index.can_edit_document(document):
1869
            raise HTTPException(
1870
                status_code=403,
1871
                detail="Editing is disabled for autogenerated content.",
1872
            )
1873
 
1874
        assert document.grammar is not None
1875
 
1876
        form_object = DocumentConfigFormObject.create_from_document(
1877
            document=document
1878
        )
1879
 
1880
        output = env().render_template_as_markup(
1881
            "actions/"
1882
            "document/"
1883
            "add_document_metadata/"
1884
            "stream_add_document_metadata.jinja.html",
1885
            form_object=form_object,
1886
            field=DocumentMetadataFormField(
1887
                field_mid=MID.create(),
1888
                field_name="",
1889
                field_value="",
1890
            ),
1891
        )
1892
        return HTMLResponse(
1893
            content=output,
1894
            status_code=200,
1895
            headers={
1896
                "Content-Type": "text/vnd.turbo-stream.html",
1897
            },
1898
        )
1899
 
1900
    @read_router.get(
1901
        "/actions/document/edit_included_document", response_class=Response
1902
    )
1903
    def document__edit_included_document(
1904
        document_mid: str, context_document_mid: str
1905
    ) -> Response:
1906
        document: SDocDocument = (
1907
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
1908
        )
1909
        form_object = IncludedDocumentFormObject.create_from_document(
1910
            document=document,
1911
            context_document_mid=context_document_mid,
1912
            jinja_environment=env(),
1913
        )
1914
        return HTMLResponse(
1915
            content=form_object.render_edit_form(),
1916
            status_code=200,
1917
            headers={
1918
                "Content-Type": "text/vnd.turbo-stream.html",
1919
            },
1920
        )
1921
 
1922
    @write_router.post("/actions/document/save_config", response_class=Response)
1923
    def document__save_edit_config(
1924
        request_form_data: FormData = Depends(parse_form_data),
1925
    ) -> Response:
1926
        """
1927
        @relation(SDOC-SRS-57, scope=function)
1928
        """
1929
 
1930
        request_dict: Dict[str, str] = dict(request_form_data)
1931
        document_mid: str = request_dict["document_mid"]
1932
        document: SDocDocument = (
1933
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
1934
        )
1935
        if not export_action.traceability_index.can_edit_document(document):
1936
            raise HTTPException(
1937
                status_code=403,
1938
                detail="Editing is disabled for autogenerated content.",
1939
            )
1940
 
1941
        form_object: DocumentConfigFormObject = (
1942
            DocumentConfigFormObject.create_from_request(
1943
                document_mid=document_mid,
1944
                request_form_data=request_form_data,
1945
            )
1946
        )
1947
        try:
1948
            update_command = UpdateDocumentConfigTransform(
1949
                form_object=form_object,
1950
                document=document,
1951
                traceability_index=export_action.traceability_index,
1952
            )
1953
            update_command.perform()
1954
        except MultipleValidationError as validation_error:
1955
            for error_key, errors in validation_error.errors.items():
1956
                for error in errors:
1957
                    form_object.add_error(error_key, error)
1958
            html_output = env().render_template_as_markup(
1959
                "actions/"
1960
                "document/"
1961
                "edit_document_config/"
1962
                "stream_edit_document_config.jinja.html",
1963
                form_object=form_object,
1964
                document=document,
1965
            )
1966
            return HTMLResponse(
1967
                content=html_output,
1968
                status_code=422,
1969
                headers={
1970
                    "Content-Type": "text/vnd.turbo-stream.html",
1971
                },
1972
            )
1973
 
1974
        # Re-generate the document's SDOC.
1975
        write_document_to_file(document)
1976
 
1977
        # Update the index because other documents might be referenced by this
1978
        # document's free text. These documents will be regenerated on demand,
1979
        # when they are opened next time.
1980
        export_action.traceability_index.update_last_updated()
1981
 
1982
        assert document.meta is not None
1983
        link_renderer = LinkRenderer(
1984
            root_path=document.meta.get_root_path_prefix(),
1985
            static_path=project_config.dir_for_sdoc_assets,
1986
        )
1987
        markup_renderer = MarkupRenderer.create(
1988
            markup=document.config.get_markup(),
1989
            traceability_index=export_action.traceability_index,
1990
            link_renderer=link_renderer,
1991
            html_templates=html_generator.html_templates,
1992
            config=project_config,
1993
            context_document=document,
1994
        )
1995
        view_object = DocumentScreenViewObject(
1996
            document_type=DocumentType.DOCUMENT,
1997
            document=document,
1998
            traceability_index=export_action.traceability_index,
1999
            project_config=project_config,
2000
            link_renderer=link_renderer,
2001
            markup_renderer=markup_renderer,
2002
            jinja_environment=env(),
2003
            git_client=html_generator.git_client,
2004
        )
2005
        html_output = env().render_template_as_markup(
2006
            "actions/"
2007
            "document/"
2008
            "edit_document_config/"
2009
            "stream_save_document_config.jinja.html",
2010
            view_object=view_object,
2011
        )
2012
        return HTMLResponse(
2013
            content=html_output,
2014
            status_code=200,
2015
            headers={
2016
                "Content-Type": "text/vnd.turbo-stream.html",
2017
            },
2018
        )
2019
 
2020
    @write_router.post(
2021
        "/actions/document/save_included_document", response_class=Response
2022
    )
2023
    def document__save_included_document(
2024
        request_form_data: FormData = Depends(parse_form_data),
2025
    ) -> Response:
2026
        request_dict: Dict[str, str] = dict(request_form_data)
2027
        document_mid: str = request_dict["document_mid"]
2028
        context_document_mid: str = request_dict["context_document_mid"]
2029
        document: SDocDocument = (
2030
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
2031
        )
2032
        context_document: SDocDocument = (
2033
            export_action.traceability_index.get_node_by_mid(
2034
                MID(context_document_mid)
2035
            )
2036
        )
2037
        form_object: IncludedDocumentFormObject = (
2038
            IncludedDocumentFormObject.create_from_request(
2039
                request_form_data=request_form_data, jinja_environment=env()
2040
            )
2041
        )
2042
        try:
2043
            update_command = UpdateIncludedDocumentTransform(
2044
                form_object=form_object,
2045
                document=document,
2046
                traceability_index=export_action.traceability_index,
2047
            )
2048
            update_command.perform()
2049
        except MultipleValidationError as validation_error:
2050
            for error_key, errors in validation_error.errors.items():
2051
                for error in errors:
2052
                    form_object.add_error(error_key, error)
2053
            return HTMLResponse(
2054
                content=form_object.render_edit_form(),
2055
                status_code=422,
2056
                headers={
2057
                    "Content-Type": "text/vnd.turbo-stream.html",
2058
                },
2059
            )
2060
 
2061
        # Re-generate the document's SDOC.
2062
        write_document_to_file(document)
2063
 
2064
        # Update the index because other documents might be referenced by this
2065
        # document's free text. These documents will be regenerated on demand,
2066
        # when they are opened next time.
2067
        export_action.traceability_index.update_last_updated()
2068
 
2069
        assert document.meta is not None
2070
        link_renderer = LinkRenderer(
2071
            root_path=document.meta.get_root_path_prefix(),
2072
            static_path=project_config.dir_for_sdoc_assets,
2073
        )
2074
        markup_renderer = MarkupRenderer.create(
2075
            markup=document.config.get_markup(),
2076
            traceability_index=export_action.traceability_index,
2077
            link_renderer=link_renderer,
2078
            html_templates=html_generator.html_templates,
2079
            config=project_config,
2080
            context_document=document,
2081
        )
2082
        view_object = DocumentScreenViewObject(
2083
            document_type=DocumentType.DOCUMENT,
2084
            document=context_document,
2085
            traceability_index=export_action.traceability_index,
2086
            project_config=project_config,
2087
            link_renderer=link_renderer,
2088
            markup_renderer=markup_renderer,
2089
            jinja_environment=env(),
2090
            git_client=html_generator.git_client,
2091
        )
2092
        return HTMLResponse(
2093
            content=view_object.render_updated_nodes_and_toc(
2094
                nodes=[document], node_updated=True
2095
            ),
2096
            status_code=200,
2097
            headers={
2098
                "Content-Type": "text/vnd.turbo-stream.html",
2099
            },
2100
        )
2101
 
2102
    @read_router.get(
2103
        "/actions/document/cancel_edit_config", response_class=Response
2104
    )
2105
    def document__cancel_edit_config(document_mid: str) -> Response:
2106
        """
2107
        @relation(SDOC-SRS-57, scope=function)
2108
        """
2109
 
2110
        document: SDocDocument = (
2111
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
2112
        )
2113
        assert document.meta is not None
2114
        link_renderer = LinkRenderer(
2115
            root_path=document.meta.get_root_path_prefix(),
2116
            static_path=project_config.dir_for_sdoc_assets,
2117
        )
2118
        markup_renderer = MarkupRenderer.create(
2119
            markup=document.config.get_markup(),
2120
            traceability_index=export_action.traceability_index,
2121
            link_renderer=link_renderer,
2122
            html_templates=html_generator.html_templates,
2123
            config=project_config,
2124
            context_document=document,
2125
        )
2126
        view_object = DocumentScreenViewObject(
2127
            document_type=DocumentType.DOCUMENT,
2128
            document=document,
2129
            traceability_index=export_action.traceability_index,
2130
            project_config=project_config,
2131
            link_renderer=link_renderer,
2132
            markup_renderer=markup_renderer,
2133
            jinja_environment=env(),
2134
            git_client=html_generator.git_client,
2135
        )
2136
        output = env().render_template_as_markup(
2137
            "actions/"
2138
            "document/"
2139
            "edit_document_config/"
2140
            "stream_cancel_edit_document_config.jinja.html",
2141
            view_object=view_object,
2142
            document=document,
2143
        )
2144
        return HTMLResponse(
2145
            content=output,
2146
            status_code=200,
2147
            headers={
2148
                "Content-Type": "text/vnd.turbo-stream.html",
2149
            },
2150
        )
2151
 
2152
    @read_router.get(
2153
        "/actions/document/cancel_edit_included_document",
2154
        response_class=Response,
2155
    )
2156
    def document__cancel_edit_included_document(document_mid: str) -> Response:
2157
        document: SDocDocument = (
2158
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
2159
        )
2160
        assert document.meta is not None
2161
        link_renderer = LinkRenderer(
2162
            root_path=document.meta.get_root_path_prefix(),
2163
            static_path=project_config.dir_for_sdoc_assets,
2164
        )
2165
        markup_renderer = MarkupRenderer.create(
2166
            markup=document.config.get_markup(),
2167
            traceability_index=export_action.traceability_index,
2168
            link_renderer=link_renderer,
2169
            html_templates=html_generator.html_templates,
2170
            config=project_config,
2171
            context_document=document,
2172
        )
2173
        view_object = DocumentScreenViewObject(
2174
            document_type=DocumentType.DOCUMENT,
2175
            document=document,
2176
            traceability_index=export_action.traceability_index,
2177
            project_config=project_config,
2178
            link_renderer=link_renderer,
2179
            markup_renderer=markup_renderer,
2180
            jinja_environment=env(),
2181
            git_client=html_generator.git_client,
2182
        )
2183
        output = env().render_template_as_markup(
2184
            "actions/document/edit_section/stream_updated_section.jinja.html",
2185
            view_object=view_object,
2186
            document=document,
2187
            node=document,
2188
        )
2189
        return HTMLResponse(
2190
            content=output,
2191
            status_code=200,
2192
            headers={
2193
                "Content-Type": "text/vnd.turbo-stream.html",
2194
            },
2195
        )
2196
 
2197
    @read_router.get("/actions/document/edit_grammar", response_class=Response)
2198
    def document__edit_grammar(document_mid: str) -> Response:
2199
        """
2200
        @relation(SDOC-SRS-56, scope=function)
2201
        """
2202
 
2203
        document: SDocDocument = (
2204
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
2205
        )
2206
        form_object: GrammarFormObject = GrammarFormObject.create_from_document(
2207
            document=document,
2208
            project_config=project_config,
2209
            jinja_environment=env(),
2210
        )
2211
        return HTMLResponse(
2212
            content=form_object.render(),
2213
            status_code=200,
2214
            headers={
2215
                "Content-Type": "text/vnd.turbo-stream.html",
2216
            },
2217
        )
2218
 
2219
    @write_router.post(
2220
        "/actions/document/save_grammar", response_class=Response
2221
    )
2222
    def document__save_grammar(
2223
        request_form_data: FormData = Depends(parse_form_data),
2224
    ) -> Response:
2225
        """
2226
        @relation(SDOC-SRS-56, scope=function)
2227
        """
2228
 
2229
        request_dict: Dict[str, str] = dict(request_form_data)
2230
        document_mid: str = request_dict["document_mid"]
2231
        document: SDocDocument = (
2232
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
2233
        )
2234
        form_object: GrammarFormObject = GrammarFormObject.create_from_request(
2235
            document_mid=document_mid,
2236
            request_form_data=request_form_data,
2237
            project_config=project_config,
2238
            jinja_environment=env(),
2239
        )
2240
        if not form_object.validate():
2241
            return HTMLResponse(
2242
                content=form_object.render(),
2243
                status_code=422,
2244
                headers={
2245
                    "Content-Type": "text/vnd.turbo-stream.html",
2246
                },
2247
            )
2248
        # Update the document with new grammar.
2249
        update_grammar_action = UpdateGrammarCommand(
2250
            form_object=form_object,
2251
            document=document,
2252
            traceability_index=export_action.traceability_index,
2253
        )
2254
        update_grammar_action.perform()
2255
 
2256
        # Re-generate the document's SDOC.
2257
        write_document_to_file(document)
2258
 
2259
        # Re-generate the document.
2260
        html_generator.export_single_document(
2261
            document=document,
2262
            traceability_index=export_action.traceability_index,
2263
        )
2264
 
2265
        # Re-generate the document tree.
2266
        html_generator.export_project_tree_screen(
2267
            traceability_index=export_action.traceability_index,
2268
        )
2269
 
2270
        assert document.meta is not None
2271
        link_renderer = LinkRenderer(
2272
            root_path=document.meta.get_root_path_prefix(),
2273
            static_path=project_config.dir_for_sdoc_assets,
2274
        )
2275
        markup_renderer = MarkupRenderer.create(
2276
            markup=document.config.get_markup(),
2277
            traceability_index=export_action.traceability_index,
2278
            link_renderer=link_renderer,
2279
            html_templates=html_generator.html_templates,
2280
            config=project_config,
2281
            context_document=document,
2282
        )
2283
        view_object = DocumentScreenViewObject(
2284
            document_type=DocumentType.DOCUMENT,
2285
            document=document,
2286
            traceability_index=export_action.traceability_index,
2287
            project_config=project_config,
2288
            link_renderer=link_renderer,
2289
            markup_renderer=markup_renderer,
2290
            jinja_environment=env(),
2291
            git_client=html_generator.git_client,
2292
        )
2293
        output = (
2294
            form_object.render_close_form()
2295
            + env().render_template_as_markup(
2296
                "actions/document/_shared/stream_refresh_document.jinja.html",
2297
                view_object=view_object,
2298
            )
2299
        )
2300
        return HTMLResponse(
2301
            content=output,
2302
            status_code=200,
2303
            headers={
2304
                "Content-Type": "text/vnd.turbo-stream.html",
2305
            },
2306
        )
2307
 
2308
    @read_router.get(
2309
        "/actions/document/add_grammar_element", response_class=Response
2310
    )
2311
    def document__add_grammar_element(document_mid: str) -> Response:
2312
        """
2313
        @relation(SDOC-SRS-56, scope=function)
2314
        """
2315
 
2316
        form_object: GrammarFormObject = GrammarFormObject(
2317
            document_mid=document_mid,
2318
            fields=[],  # Not used in this limited partial template.
2319
            project_config=project_config,
2320
            jinja_environment=env(),
2321
            imported_grammar_file=None,
2322
        )
2323
        return HTMLResponse(
2324
            content=form_object.render_row_with_new_grammar_element(),
2325
            status_code=200,
2326
            headers={
2327
                "Content-Type": "text/vnd.turbo-stream.html",
2328
            },
2329
        )
2330
 
2331
    @read_router.get(
2332
        "/actions/document/edit_grammar_element", response_class=Response
2333
    )
2334
    def document__edit_grammar_element(
2335
        document_mid: str, element_mid: str
2336
    ) -> Response:
2337
        """
2338
        @relation(SDOC-SRS-56, scope=function)
2339
        """
2340
 
2341
        document: SDocDocument = (
2342
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
2343
        )
2344
        form_object: GrammarElementFormObject = (
2345
            GrammarElementFormObject.create_from_document(
2346
                document=document,
2347
                element_mid=element_mid,
2348
                project_config=project_config,
2349
                jinja_environment=env(),
2350
            )
2351
        )
2352
 
2353
        return HTMLResponse(
2354
            content=form_object.render(),
2355
            status_code=200,
2356
            headers={
2357
                "Content-Type": "text/vnd.turbo-stream.html",
2358
            },
2359
        )
2360
 
2361
    @write_router.post(
2362
        "/actions/document/save_grammar_element", response_class=Response
2363
    )
2364
    def document__save_grammar_element(
2365
        request_form_data: FormData = Depends(parse_form_data),
2366
    ) -> Response:
2367
        """
2368
        @relation(SDOC-SRS-56, scope=function)
2369
        """
2370
 
2371
        request_dict: Dict[str, str] = dict(request_form_data)
2372
        document_mid: str = request_dict["document_mid"]
2373
        document: SDocDocument = (
2374
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
2375
        )
2376
        form_object: GrammarElementFormObject = (
2377
            GrammarElementFormObject.create_from_request(
2378
                document=document,
2379
                request_form_data=request_form_data,
2380
                project_config=project_config,
2381
                jinja_environment=env(),
2382
            )
2383
        )
2384
        if not form_object.validate():
2385
            return HTMLResponse(
2386
                content=form_object.render_after_validation(),
2387
                status_code=422,
2388
                headers={
2389
                    "Content-Type": "text/vnd.turbo-stream.html",
2390
                },
2391
            )
2392
 
2393
        # Update the document with new grammar.
2394
        update_grammar_action = UpdateGrammarElementCommand(
2395
            form_object=form_object,
2396
            document=document,
2397
            traceability_index=export_action.traceability_index,
2398
        )
2399
        update_grammar_action.perform()
2400
 
2401
        # Re-generate the document's SDOC.
2402
        write_document_to_file(document)
2403
 
2404
        # Re-generate the document.
2405
        html_generator.export_single_document(
2406
            document=document,
2407
            traceability_index=export_action.traceability_index,
2408
        )
2409
 
2410
        # Re-generate the document tree.
2411
        html_generator.export_project_tree_screen(
2412
            traceability_index=export_action.traceability_index,
2413
        )
2414
 
2415
        assert document.meta is not None
2416
        link_renderer = LinkRenderer(
2417
            root_path=document.meta.get_root_path_prefix(),
2418
            static_path=project_config.dir_for_sdoc_assets,
2419
        )
2420
        markup_renderer = MarkupRenderer.create(
2421
            markup=document.config.get_markup(),
2422
            traceability_index=export_action.traceability_index,
2423
            link_renderer=link_renderer,
2424
            html_templates=html_generator.html_templates,
2425
            config=project_config,
2426
            context_document=document,
2427
        )
2428
        view_object = DocumentScreenViewObject(
2429
            document_type=DocumentType.DOCUMENT,
2430
            document=document,
2431
            traceability_index=export_action.traceability_index,
2432
            project_config=project_config,
2433
            link_renderer=link_renderer,
2434
            markup_renderer=markup_renderer,
2435
            jinja_environment=env(),
2436
            git_client=html_generator.git_client,
2437
        )
2438
        output = (
2439
            form_object.render_close_form()
2440
            + env().render_template_as_markup(
2441
                "actions/document/_shared/stream_refresh_document.jinja.html",
2442
                view_object=view_object,
2443
            )
2444
        )
2445
        return HTMLResponse(
2446
            content=output,
2447
            status_code=200,
2448
            headers={
2449
                "Content-Type": "text/vnd.turbo-stream.html",
2450
            },
2451
        )
2452
 
2453
    @read_router.get(
2454
        "/actions/document/add_grammar_field", response_class=Response
2455
    )
2456
    def document__add_grammar_field(document_mid: str) -> Response:
2457
        """
2458
        @relation(SDOC-SRS-56, scope=function)
2459
        """
2460
 
2461
        form_object: GrammarElementFormObject = GrammarElementFormObject(
2462
            document_mid=document_mid,
2463
            element_mid="NOT_RELEVANT",
2464
            element_name="NOT_RELEVANT",
2465
            is_composite=None,  # Not used in this limited partial template.
2466
            prefix=None,  # Not used in this limited partial template.
2467
            view_style=None,  # Not used in this limited partial template.
2468
            fields=[],  # Not used in this limited partial template.
2469
            relations=[],  # Not used in this limited partial template.
2470
            project_config=project_config,
2471
            jinja_environment=env(),
2472
        )
2473
        return HTMLResponse(
2474
            content=form_object.render_row_with_new_field(),
2475
            status_code=200,
2476
            headers={
2477
                "Content-Type": "text/vnd.turbo-stream.html",
2478
            },
2479
        )
2480
 
2481
    @read_router.get(
2482
        "/actions/document/add_grammar_relation", response_class=Response
2483
    )
2484
    def document__add_grammar_relation(document_mid: str) -> Response:
2485
        """
2486
        @relation(SDOC-SRS-56, scope=function)
2487
        """
2488
 
2489
        form_object = GrammarElementFormObject(
2490
            document_mid=document_mid,
2491
            element_mid="NOT_RELEVANT",
2492
            element_name="NOT_RELEVANT",
2493
            is_composite=None,  # Not used in this limited partial template.
2494
            prefix=None,  # Not used in this limited partial template.
2495
            view_style=None,  # Not used in this limited partial template.
2496
            fields=[],  # Not used in this limited partial template.
2497
            relations=[],  # Not used in this limited partial template.
2498
            project_config=project_config,
2499
            jinja_environment=env(),
2500
        )
2501
        return HTMLResponse(
2502
            content=form_object.render_row_with_new_relation(),
2503
            status_code=200,
2504
            headers={
2505
                "Content-Type": "text/vnd.turbo-stream.html",
2506
            },
2507
        )
2508
 
2509
    @read_router.get(
2510
        "/actions/project_index/import_reqif_document_form",
2511
        response_class=Response,
2512
    )
2513
    def get_import_reqif_document_form() -> Response:
2514
        output = env().render_template_as_markup(
2515
            "actions/project_index/import_reqif_document/"
2516
            "stream_form_import_reqif_document.jinja.html",
2517
            error_object=ErrorObject(),
2518
        )
2519
        return HTMLResponse(
2520
            content=output,
2521
            status_code=200,
2522
            headers={
2523
                "Content-Type": "text/vnd.turbo-stream.html",
2524
            },
2525
        )
2526
 
2527
    @write_router.post(
2528
        "/actions/project_index/import_document_reqif", response_class=Response
2529
    )
2530
    def import_document_reqif(reqif_file: UploadFile) -> Response:
2531
        contents = reqif_file.file.read().decode()
2532
 
2533
        error_object = ErrorObject()
2534
        assert isinstance(contents, str)
2535
 
2536
        try:
2537
            reqif_bundle = ReqIFParser.parse_from_string(contents)
2538
            converter: P01_ReqIFToSDocConverter = P01_ReqIFToSDocConverter()
2539
            documents: List[SDocDocument] = converter.convert_reqif_bundle(
2540
                reqif_bundle,
2541
                enable_mid=project_config.reqif_enable_mid,
2542
                import_markup=project_config.reqif_import_markup,
2543
            )
2544
        except ReqIFXMLParsingError as exception:
2545
            error_object.add_error(
2546
                "reqif_file", "Cannot parse ReqIF file: " + str(exception)
2547
            )
2548
        # Catch unexpected errors but exclude from code coverage, because it is
2549
        # not clear yet how to write a test that triggers this.
2550
        except Exception as exception:  # pragma: no cover
2551
            error_object.add_error("reqif_file", str(exception))
2552
 
2553
        if error_object.any_errors():
2554
            output = env().render_template_as_markup(
2555
                "actions/project_index/import_reqif_document/"
2556
                "stream_form_import_reqif_document.jinja.html",
2557
                error_object=error_object,
2558
            )
2559
            return HTMLResponse(
2560
                content=output,
2561
                status_code=422,
2562
                headers={
2563
                    "Content-Type": "text/vnd.turbo-stream.html",
2564
                },
2565
            )
2566
        assert documents is not None
2567
        assert isinstance(project_config.input_paths, list)
2568
        for document in documents:
2569
            document_title = re.sub(r"[^A-Za-z0-9-]", "_", document.title)
2570
            document_path = f"{document_title}.sdoc"
2571
 
2572
            full_input_path = os.path.abspath(project_config.input_paths[0])
2573
            doc_full_path = os.path.join(full_input_path, document_path)
2574
            doc_full_path_dir = os.path.dirname(doc_full_path)
2575
            Path(doc_full_path_dir).mkdir(parents=True, exist_ok=True)
2576
 
2577
            file_tree_mount_folder = os.path.basename(
2578
                os.path.dirname(full_input_path)
2579
            )
2580
 
2581
            input_doc_assets_dir_rel_path = "/".join(
2582
                (file_tree_mount_folder, "_assets")
2583
            )
2584
 
2585
            # FIXME: Fill in the meta information correctly.
2586
            document.meta = DocumentMeta(
2587
                level=0,
2588
                file_tree_mount_folder="NOT_RELEVANT",
2589
                document_filename=document_path,
2590
                document_filename_base="NOT_RELEVANT",
2591
                input_doc_full_path=doc_full_path,
2592
                input_doc_rel_path=SDocRelativePath(document_path),
2593
                input_doc_dir_rel_path=SDocRelativePath(""),
2594
                input_doc_assets_dir_rel_path=SDocRelativePath(
2595
                    input_doc_assets_dir_rel_path
2596
                ),
2597
                output_document_dir_full_path="NOT_RELEVANT",
2598
                output_document_dir_rel_path=SDocRelativePath("FIXME"),
2599
            )
2600
 
2601
            write_document_to_file(document)
2602
 
2603
        export_action.build_index()
2604
        export_action.export()
2605
 
2606
        view_object = ProjectTreeViewObject(
2607
            traceability_index=export_action.traceability_index,
2608
            project_config=project_config,
2609
        )
2610
        output = env().render_template_as_markup(
2611
            "actions/project_index/import_reqif_document/"
2612
            "stream_refresh_with_imported_reqif_document.jinja.html",
2613
            view_object=view_object,
2614
        )
2615
        return HTMLResponse(
2616
            content=output,
2617
            status_code=200,
2618
            headers={
2619
                "Content-Type": "text/vnd.turbo-stream.html",
2620
            },
2621
        )
2622
 
2623
    @router.get("/export_html2pdf/{document_mid}", response_class=Response)
2624
    def get_export_html2pdf(document_mid: str) -> Response:  # noqa: ARG001
2625
        if not project_config.is_activated_html2pdf():
2626
            return Response(
2627
                content="The HTML2PDF feature is not activated in the project config.",
2628
                status_code=HTTP_STATUS_PRECONDITION_FAILED,
2629
            )
2630
 
2631
        with lock_manager.acquire_subset(
2632
            read_ids={_compute_document_mid_lock_key(document_mid)}
2633
        ):
2634
            document = export_action.traceability_index.get_node_by_mid(
2635
                MID(document_mid)
2636
            )
2637
 
2638
            root_path = document.meta.get_root_path_prefix()
2639
            relative_path = (
2640
                document.meta.output_document_dir_rel_path.relative_path
2641
            )
2642
 
2643
            link_renderer = LinkRenderer(
2644
                root_path=root_path,
2645
                static_path=project_config.dir_for_sdoc_assets,
2646
            )
2647
            markup_renderer = MarkupRenderer.create(
2648
                document.config.get_markup(),
2649
                export_action.traceability_index,
2650
                link_renderer,
2651
                html_templates,
2652
                project_config,
2653
                document,
2654
            )
2655
 
2656
            pdf_project_config = copy.deepcopy(project_config)
2657
            pdf_project_config.is_running_on_server = False
2658
 
2659
            with measure_performance("Generating printable HTML document"):
2660
                document_content = DocumentHTML2PDFGenerator.export(
2661
                    pdf_project_config,
2662
                    document,
2663
                    export_action.traceability_index,
2664
                    markup_renderer,
2665
                    link_renderer,
2666
                    git_client=html_generator.git_client,
2667
                    html_templates=html_templates,
2668
                )
2669
 
2670
            # Copy values needed below so the expensive filesystem and subprocess
2671
            # phase can run without holding the router's read lock.
2672
            proposed_basename = "document"
2673
            if document.title is not None:
2674
                proposed_basename = document.title
2675
            if document.uid is not None:
2676
                proposed_basename = document.uid + " " + proposed_basename
2677
 
2678
        temp_uid = uuid.uuid4().hex
2679
        path_to_output_html = os.path.join(
2680
            project_config.export_output_html_root,
2681
            relative_path,
2682
            f"_temp_{temp_uid}.html",
2683
        )
2684
        path_to_output_pdf = os.path.join(
2685
            project_config.export_output_html_root,
2686
            "html",
2687
            f"_temp_{temp_uid}.pdf",
2688
        )
2689
 
2690
        def cleanup_html2pdf_artifacts() -> None:
2691
            for path in (path_to_output_html, path_to_output_pdf):
2692
                if os.path.isfile(path):
2693
                    os.remove(path)
2694
 
2695
        Path(path_to_output_html).parent.mkdir(parents=True, exist_ok=True)
2696
        Path(path_to_output_pdf).parent.mkdir(parents=True, exist_ok=True)
2697
 
2698
        # FIXME: Add this print driver to a service bus object to make it
2699
        #        unit-testable.
2700
        pdf_print_driver = PDFPrintDriver()
2701
        with open(path_to_output_html, mode="w", encoding="utf8") as temp_file_:
2702
            temp_file_.write(document_content)
2703
 
2704
        assert os.path.isfile(path_to_output_html), path_to_output_html
2705
        try:
2706
            pdf_print_driver.get_pdf_from_html(
2707
                project_config,
2708
                [(path_to_output_html, path_to_output_pdf)],
2709
                project_config.export_output_html_root,
2710
            )
2711
        except PDFPrintDriverException as e_:  # pragma: no cover
2712
            cleanup_html2pdf_artifacts()
2713
            return Response(
2714
                content=e_.get_server_user_message(),
2715
                status_code=HTTP_STATUS_INTERNAL_SERVER_ERROR,
2716
            )
2717
        assert os.path.isfile(path_to_output_pdf), path_to_output_pdf
2718
 
2719
        # We sanitize the basename, Windows is the most restrictive:
2720
        # - many forbidden chars.
2721
        # - not more than 120 chars in total, including the PDF extension
2722
        forbidden = '<>:"/\\|?*\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f'
2723
        table = str.maketrans(forbidden, "_" * len(forbidden))
2724
        sanitized_basename = proposed_basename.translate(table)
2725
        sanitized_basename = sanitized_basename.strip(" ")[:115]
2726
        encoded_filename = quote(sanitized_basename + ".pdf")
2727
 
2728
        return FileResponse(
2729
            path=path_to_output_pdf,
2730
            status_code=200,
2731
            headers={
2732
                "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
2733
            },
2734
            media_type="application/octet-stream",
2735
            background=BackgroundTask(cleanup_html2pdf_artifacts),
2736
        )
2737
 
2738
    @read_router.get(
2739
        "/reqif/export_document/{document_mid}", response_class=Response
2740
    )
2741
    def get_reqif_export_document(document_mid: str) -> Response:  # noqa: ARG001
2742
        # TODO: Export single document, not the whole tree.
2743
        return get_reqif_export_tree()
2744
 
2745
    @read_router.get("/reqif/export_tree", response_class=Response)
2746
    def get_reqif_export_tree() -> Response:
2747
        reqif_bundle = P01_SDocToReqIFObjectConverter.convert_document_tree(
2748
            document_tree=export_action.traceability_index.document_tree,
2749
            multiline_is_xhtml=project_config.reqif_multiline_is_xhtml,
2750
            enable_mid=project_config.reqif_enable_mid,
2751
        )
2752
        reqif_content: str = ReqIFUnparser.unparse(reqif_bundle)
2753
        return Response(
2754
            content=reqif_content,
2755
            status_code=200,
2756
            media_type="application/octet-stream",
2757
            headers={
2758
                "Content-Disposition": 'attachment; filename="export.reqif"',
2759
            },
2760
        )
2761
 
2762
    @read_router.get("/search", response_class=Response)
2763
    def get_search(q: Optional[str] = None) -> Response:
2764
        if not project_config.is_activated_search():
2765
            return Response(
2766
                content="The Search feature is not activated in the project config.",
2767
                status_code=HTTP_STATUS_PRECONDITION_FAILED,
2768
            )
2769
        search_results = []
2770
        error = None
2771
        node_query = None
2772
        plain_text_query_phrase = None
2773
        plain_text_query_pattern = None
2774
 
2775
        if q is not None and len(q) > 0:
2776
            normalized_query = q.strip()
2777
            if len(normalized_query) > 0:
2778
                if search_query_contains_markers(normalized_query):
2779
                    try:
2780
                        query: Query = QueryReader.read(normalized_query)
2781
                        node_query = QueryObject(
2782
                            query, export_action.traceability_index
2783
                        )
2784
                    except Exception as e:
2785
                        error = f"error: {e}"
2786
                else:
2787
                    (
2788
                        plain_text_query_phrase,
2789
                        plain_text_query_pattern,
2790
                    ) = parse_plain_text_search_query(normalized_query)
2791
 
2792
        if (
2793
            node_query is not None
2794
            or plain_text_query_phrase is not None
2795
            or plain_text_query_pattern is not None
2796
        ):
2797
            result: List[SDocExtendedElementIF] = []
2798
            try:
2799
                document_tree = assert_cast(
2800
                    export_action.traceability_index.document_tree, DocumentTree
2801
                )
2802
                for document in document_tree.document_list:
2803
                    document_iterator = (
2804
                        export_action.traceability_index.get_document_iterator(
2805
                            document
2806
                        )
2807
                    )
2808
                    for node, _ in document_iterator.all_content(
2809
                        print_fragments=False
2810
                    ):
2811
                        if (
2812
                            node_query is not None and node_query.evaluate(node)
2813
                        ) or (
2814
                            search_node_matches_plain_text_query(
2815
                                node,
2816
                                phrase=plain_text_query_phrase,
2817
                                pattern=plain_text_query_pattern,
2818
                            )
2819
                        ):
2820
                            result.append(node)
2821
 
2822
                if (
2823
                    export_action.traceability_index.document_tree.source_tree
2824
                    is not None
2825
                ):
2826
                    for source_file_ in export_action.traceability_index.document_tree.source_tree.source_files:
2827
                        source_file_info_: SourceFileTraceabilityInfo = export_action.traceability_index.get_file_traceability_index().get_coverage_info(
2828
                            source_file_.in_doctree_source_file_rel_path_posix
2829
                        )
2830
                        if (
2831
                            node_query is not None
2832
                            and node_query.evaluate(source_file_info_)
2833
                        ) or (
2834
                            search_node_matches_plain_text_query(
2835
                                source_file_info_,
2836
                                phrase=plain_text_query_phrase,
2837
                                pattern=plain_text_query_pattern,
2838
                            )
2839
                        ):
2840
                            result.append(source_file_info_)
2841
 
2842
                search_results = result
2843
            # Catch unexpected errors but exclude from code coverage, because
2844
            # it is not clear yet how to write a test that triggers this.
2845
            except (
2846
                AttributeError,
2847
                NameError,
2848
                TypeError,
2849
            ) as attribute_error_:  # pragma: no cover
2850
                error = attribute_error_.args[0]
2851
 
2852
        view_object = SearchScreenViewObject(
2853
            traceability_index=export_action.traceability_index,
2854
            project_config=project_config,
2855
            templates=html_templates,
2856
            search_results=search_results,
2857
            search_value=q if q is not None else "",
2858
            error=error,
2859
        )
2860
        output = view_object.render_screen(html_templates.jinja_environment())
2861
 
2862
        return Response(
2863
            content=output,
2864
            status_code=200,
2865
        )
2866
 
2867
    @read_router.get("/autocomplete/uid", response_class=Response)
2868
    def get_autocomplete_uid_results(
2869
        q: Optional[str] = None, exclude_requirement_mid: Optional[str] = None
2870
    ) -> Response:
2871
        """
2872
        Returns matches of possible node UID values when creating a node relation.
2873
 
2874
        The UID of the node identified by the optional parameter "exclude_requirement_mid" is excluded,
2875
        so that a node cannot be linked to itself.
2876
 
2877
        @relation(SDOC-SRS-120, scope=function)
2878
        """
2879
        output = ""
2880
        if q is not None:
2881
            query_words = q.lower().split()
2882
            resulting_nodes = []
2883
            document_tree = assert_cast(
2884
                export_action.traceability_index.document_tree, DocumentTree
2885
            )
2886
            for document in document_tree.document_list:
2887
                document_iterator = (
2888
                    export_action.traceability_index.get_document_iterator(
2889
                        document
2890
                    )
2891
                )
2892
                for node_, _ in document_iterator.all_content(
2893
                    print_fragments=False
2894
                ):
2895
                    if not isinstance(node_, SDocNodeIF):
2896
                        continue
2897
 
2898
                    if node_.node_type == "SECTION":
2899
                        continue
2900
 
2901
                    if (
2902
                        node_.reserved_uid is not None
2903
                        and node_.reserved_mid != exclude_requirement_mid
2904
                    ):
2905
                        words_ = node_.reserved_uid.strip().lower()
2906
                        if node_.reserved_title is not None:
2907
                            words_ = (
2908
                                words_
2909
                                + " "
2910
                                + node_.reserved_title.strip().lower()
2911
                            )
2912
                        if all(word_ in words_ for word_ in query_words):
2913
                            resulting_nodes.append(node_)
2914
 
2915
                            # Excluding the following branch from code coverage
2916
                            # because it is not practical to create a test that
2917
                            # reproduces going above the limit. The code is
2918
                            # simple, so it should be safe to exclude this
2919
                            # branch from coverage.
2920
                            if (
2921
                                len(resulting_nodes) >= AUTOCOMPLETE_LIMIT
2922
                            ):  # pragma: no cover
2923
                                break
2924
 
2925
            output = env().render_template_as_markup(
2926
                "autocomplete/uid/stream_autocomplete_uid.jinja.html",
2927
                nodes=resulting_nodes,
2928
            )
2929
 
2930
        return Response(
2931
            content=output,
2932
            status_code=200,
2933
        )
2934
 
2935
    @read_router.get("/autocomplete/field", response_class=Response)
2936
    def get_autocomplete_field_results(
2937
        q: Optional[str] = None,
2938
        document_mid: Optional[str] = None,
2939
        element_type: Optional[str] = None,
2940
        field_name: Optional[str] = None,
2941
    ) -> Response:
2942
        """
2943
        Returns matches of possible values of a SingleChoice, MultiChoice or Tag field.
2944
 
2945
        The field is identified by the document_mid, the element_type, and the field_name.
2946
        """
2947
        output = ""
2948
        if (
2949
            q is not None
2950
            and document_mid is not None
2951
            and element_type is not None
2952
        ):
2953
            document: SDocDocument = (
2954
                export_action.traceability_index.get_node_by_mid(
2955
                    MID(document_mid)
2956
                )
2957
            )
2958
            if document:
2959
                assert field_name is not None
2960
                all_options = document.get_options_for_field(
2961
                    element_type, field_name
2962
                )
2963
                field: GrammarElementField = (
2964
                    document.get_grammar_element_field_for(
2965
                        element_type, field_name
2966
                    )
2967
                )
2968
 
2969
                if field.gef_type in (
2970
                    RequirementFieldType.MULTIPLE_CHOICE,
2971
                    RequirementFieldType.TAG,
2972
                ):
2973
                    # MultipleChoice/Tag: We split the query into its parts:
2974
                    #
2975
                    # Example User input: "Some Value, Another Value, Yet ano|".
2976
                    # parts = ['some value', 'another value', 'yet ano']                # noqa: ERA001
2977
                    parts = q.lower().split(",")
2978
 
2979
                    # For the lookup, we want to use the only the last, still
2980
                    # incomplete part, not the full query:
2981
                    #
2982
                    # last_part = "yet ano"                                             # noqa: ERA001
2983
                    # query_words = ['yet', 'ano']                                      # noqa: ERA001
2984
                    last_part = parts[-1].strip()
2985
                    query_words = last_part.split()
2986
 
2987
                    # We also filter the already selected choices from the
2988
                    # options we are going to be send to the user,
2989
                    # as MultipleChoices is a Set, so options shall be
2990
                    # selectable at most once.
2991
                    #
2992
                    # In the example, we would remove 'some value' and 'another value'.
2993
                    already_selected = [
2994
                        p.strip() for p in parts[:-1] if p.strip()
2995
                    ]
2996
                    filtered_options = [
2997
                        choice
2998
                        for choice in all_options
2999
                        if choice.lower() not in already_selected
3000
                    ]
3001
                else:
3002
                    # SingleChoice: we use the full query and all available options.
3003
                    query_words = q.lower().split()
3004
                    filtered_options = all_options
3005
 
3006
                resulting_values = []
3007
 
3008
                # Now filter the remaining options for those that match all words in query_words.
3009
                for option_ in filtered_options:
3010
                    words_ = option_.strip().lower()
3011
 
3012
                    if all(word_ in words_ for word_ in query_words):
3013
                        resulting_values.append(option_)
3014
                    if len(resulting_values) >= AUTOCOMPLETE_LIMIT:
3015
                        break
3016
 
3017
            output = env().render_template_as_markup(
3018
                "autocomplete/field/stream_autocomplete_field.jinja.html",
3019
                values=resulting_values,
3020
            )
3021
 
3022
        return Response(
3023
            content=output,
3024
            status_code=200,
3025
        )
3026
 
3027
    @read_router.get("/UID/{uid_or_mid}", response_class=RedirectResponse)
3028
    def redirect_to_uid(uid_or_mid: str) -> Response:
3029
        # Resolve UID or MID.
3030
 
3031
        linkable_node: Optional[Any] = (
3032
            export_action.traceability_index.get_node_by_mid_weak(
3033
                MID(uid_or_mid)
3034
            )
3035
        )
3036
        if linkable_node is None:
3037
            linkable_node = (
3038
                export_action.traceability_index.get_linkable_node_by_uid_weak(
3039
                    uid_or_mid
3040
                )
3041
            )
3042
 
3043
        # If found, send a 302 redirect response to guide the user to the
3044
        # correct URL (page + #anchor)
3045
        if linkable_node is not None:
3046
            link_renderer = LinkRenderer(
3047
                root_path="", static_path=project_config.dir_for_sdoc_assets
3048
            )
3049
            href = link_renderer.render_node_link(
3050
                linkable_node, None, document_type=DocumentType.DOCUMENT
3051
            )
3052
            return RedirectResponse(url=href, status_code=302)
3053
        # The HTTPException will render our ServerErrorViewObject 404 page
3054
        # via @app.exception_handler(404).
3055
        raise HTTPException(status_code=404, detail="UID or MID was not found")
3056
 
3057
    # Nestor is a highly experimental feature that is unlikely to make it to the
3058
    # stable feature set. Excluding it from code coverage.
3059
    @write_router.get("/__nestor", response_class=Response)  # pragma: no cover
3060
    def get_nestor() -> Response:  # pragma: no cover
3061
        output_json_root = os.path.join(project_config.output_dir, "html")
3062
        Path(output_json_root).mkdir(parents=True, exist_ok=True)
3063
        JSONGenerator().export_tree(
3064
            export_action.traceability_index, project_config, output_json_root
3065
        )
3066
        path_to_json = os.path.join("index.json")
3067
        view_object = NestorViewObject(
3068
            traceability_index=export_action.traceability_index,
3069
            project_config=project_config,
3070
            templates=html_templates,
3071
            path_to_json=path_to_json,
3072
        )
3073
        output = view_object.render_screen(html_templates.jinja_environment())
3074
 
3075
        return Response(
3076
            content=output,
3077
            status_code=200,
3078
        )
3079
 
3080
    router.include_router(read_router)
3081
    router.include_router(write_router)
3082
 
3083
    @router.get(
3084
        "/{full_path:path}/static_html_search_index.js", response_class=Response
3085
    )
3086
    def get_static_search_index(
3087
        request: Request,
3088
        full_path: str,  # noqa: ARG001
3089
    ) -> Response:
3090
        static_file = os.path.join(
3091
            project_config.export_output_html_root,
3092
            project_config.dir_for_sdoc_assets,
3093
            "static_html_search_index.js",
3094
        )
3095
 
3096
        def must_generate() -> bool:
3097
            if not os.path.isfile(static_file):
3098
                return True
3099
            output_file_mtime = get_file_modification_time(static_file)
3100
            return (
3101
                export_action.traceability_index.index_last_updated
3102
                > output_file_mtime
3103
            )
3104
 
3105
        with lock_manager.acquire_global_read():
3106
            if not must_generate() and request_is_for_non_modified_file(
3107
                request, static_file
3108
            ):
3109
                return Response(status_code=304)
3110
 
3111
        with lock_manager.acquire_global_write():
3112
            html_generator.export_static_html_search_index(
3113
                traceability_index=export_action.traceability_index
3114
            )
3115
 
3116
        return FileResponse(
3117
            static_file,
3118
            media_type="application/javascript",
3119
            headers={
3120
                # We don't want the search index to be cached on the server without
3121
                # revalidation.
3122
                # The no-cache request directive asks caches to validate the
3123
                # response with the origin server before reuse.
3124
                # no-cache allows clients to request the most up-to-date
3125
                # response even if the cache has a fresh response.
3126
                # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
3127
                "Cache-Control": "no-cache"
3128
            },
3129
        )
3130
 
3131
    @router.get("/{full_path:path}", response_class=Response)
3132
    def get_incoming_request(request: Request, full_path: str) -> Response:
3133
        # FIXME: This seems to be quite un-sanitized.
3134
        _, file_extension = os.path.splitext(full_path)
3135
        if file_extension == ".html":
3136
            return get_document(request, full_path)
3137
        elif file_extension == "":
3138
            # No extension: StrictDoc documents always end in .html, so no
3139
            # extension can ever resolve to a valid document. Return 404
3140
            # directly without going through get_document().
3141
            return _error_response(HTTP_STATUS_NOT_FOUND)
3142
        else:
3143
            return get_asset(request, full_path)
3144
 
3145
    def get_document(request: Request, url_to_document: str) -> Response:
3146
        """
3147
        @relation(SDOC-SRS-4, scope=function)
3148
        """
3149
 
3150
        document_relative_path: SDocRelativePath = SDocRelativePath.from_url(
3151
            url_to_document
3152
        )
3153
        full_path_to_document = os.path.join(
3154
            project_config.export_output_html_root,
3155
            document_relative_path.relative_path,
3156
        )
3157
 
3158
        def must_generate() -> bool:
3159
            if not os.path.isfile(full_path_to_document):
3160
                return True
3161
            output_file_mtime = get_file_modification_time(
3162
                full_path_to_document
3163
            )
3164
            return (
3165
                export_action.traceability_index.index_last_updated
3166
                > output_file_mtime
3167
            )
3168
 
3169
        with lock_manager.acquire_global_read():
3170
            if not must_generate():
3171
                if request_is_for_non_modified_file(
3172
                    request, full_path_to_document
3173
                ):
3174
                    return Response(status_code=304)
3175
                return FileResponse(
3176
                    full_path_to_document,
3177
                    media_type="text/html",
3178
                    headers={
3179
                        # We don't want the documents to be cached on the server without
3180
                        # revalidation.
3181
                        # The no-cache request directive asks caches to validate the
3182
                        # response with the origin server before reuse.
3183
                        # no-cache allows clients to request the most up-to-date
3184
                        # response even if the cache has a fresh response.
3185
                        # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
3186
                        "Cache-Control": "no-cache"
3187
                    },
3188
                )
3189
 
3190
        lock_key = _compute_document_generation_lock_key(
3191
            document_relative_path.relative_path
3192
        )
3193
 
3194
        with lock_manager.acquire_subset(write_ids={lock_key}):
3195
            if not must_generate():
3196
                if request_is_for_non_modified_file(
3197
                    request, full_path_to_document
3198
                ):
3199
                    return Response(status_code=304)
3200
                return FileResponse(
3201
                    full_path_to_document,
3202
                    media_type="text/html",
3203
                    headers={"Cache-Control": "no-cache"},
3204
                )
3205
 
3206
            def generate_document() -> Optional[Response]:
3207
                if document_relative_path.relative_path.startswith(
3208
                    "_source_files"
3209
                ):
3210
                    if document_relative_path.relative_path.endswith(
3211
                        "source_coverage.html"
3212
                    ):
3213
                        html_generator.export_source_coverage_screen(
3214
                            traceability_index=export_action.traceability_index,
3215
                        )
3216
                    else:
3217
                        try:
3218
                            html_generator.export_single_source_file_screen(
3219
                                traceability_index=export_action.traceability_index,
3220
                                path_to_source_file=document_relative_path.relative_path,
3221
                            )
3222
                        except FileNotFoundError:
3223
                            return _error_response(HTTP_STATUS_NOT_FOUND)
3224
                elif document_relative_path.relative_path == "index.html":
3225
                    html_generator.export_project_tree_screen(
3226
                        traceability_index=export_action.traceability_index,
3227
                    )
3228
                elif (
3229
                    document_relative_path.relative_path
3230
                    == "traceability_matrix.html"
3231
                ):
3232
                    if not project_config.is_activated_requirements_coverage():
3233
                        return Response(
3234
                            content="The Requirements Coverage feature is not activated in the project config.",
3235
                            status_code=HTTP_STATUS_PRECONDITION_FAILED,
3236
                        )
3237
                    html_generator.export_requirements_coverage_screen(
3238
                        traceability_index=export_action.traceability_index,
3239
                    )
3240
                elif document_relative_path.relative_path == "tree_map.html":
3241
                    if not project_config.is_activated_tree_map():
3242
                        return Response(
3243
                            content="The Tree Map feature is not activated in the project config.",
3244
                            status_code=HTTP_STATUS_PRECONDITION_FAILED,
3245
                        )
3246
                    html_generator.export_tree_map_screen(
3247
                        traceability_index=export_action.traceability_index,
3248
                    )
3249
                elif (
3250
                    document_relative_path.relative_path
3251
                    == "source_coverage.html"
3252
                ):
3253
                    if not project_config.is_activated_requirements_to_source_traceability():
3254
                        return Response(
3255
                            content="The Requirements to Source Files feature is not activated in the project config.",
3256
                            status_code=HTTP_STATUS_PRECONDITION_FAILED,
3257
                        )
3258
                    html_generator.export_source_coverage_screen(
3259
                        traceability_index=export_action.traceability_index,
3260
                    )
3261
                elif (
3262
                    document_relative_path.relative_path
3263
                    == "project_statistics.html"
3264
                ):
3265
                    if not project_config.is_activated_project_statistics():
3266
                        return Response(
3267
                            content="The Project Statistics feature is not activated in the project config.",
3268
                            status_code=HTTP_STATUS_PRECONDITION_FAILED,
3269
                        )
3270
                    html_generator.export_project_statistics(
3271
                        traceability_index=export_action.traceability_index,
3272
                    )
3273
                else:
3274
                    document_type_to_generate: DocumentType
3275
                    if document_relative_path.relative_path.endswith(
3276
                        "-TABLE.html"
3277
                    ):
3278
                        base_document_url = (
3279
                            document_relative_path.relative_path.replace(
3280
                                "-TABLE", ""
3281
                            )
3282
                        )
3283
                        document_type_to_generate = DocumentType.TABLE
3284
                    elif document_relative_path.relative_path.endswith(
3285
                        "-DEEP-TRACE.html"
3286
                    ):
3287
                        base_document_url = (
3288
                            document_relative_path.relative_path.replace(
3289
                                "-DEEP-TRACE", ""
3290
                            )
3291
                        )
3292
                        document_type_to_generate = DocumentType.DEEPTRACE
3293
                    elif document_relative_path.relative_path.endswith(
3294
                        "-TRACE.html"
3295
                    ):
3296
                        base_document_url = (
3297
                            document_relative_path.relative_path.replace(
3298
                                "-TRACE", ""
3299
                            )
3300
                        )
3301
                        document_type_to_generate = DocumentType.TRACE
3302
                    elif document_relative_path.relative_path.endswith(
3303
                        "-PDF.html"
3304
                    ):
3305
                        if not project_config.is_activated_html2pdf():
3306
                            return Response(
3307
                                content="The HTML2PDF feature is not activated in the project config.",
3308
                                status_code=HTTP_STATUS_PRECONDITION_FAILED,
3309
                            )
3310
                        base_document_url = (
3311
                            document_relative_path.relative_path.replace(
3312
                                "-PDF", ""
3313
                            )
3314
                        )
3315
                        document_type_to_generate = DocumentType.PDF
3316
                    else:
3317
                        # Either this is a normal document, or the path is broken.
3318
                        base_document_url = document_relative_path.relative_path
3319
                        document_type_to_generate = DocumentType.DOCUMENT
3320
 
3321
                    document_tree = assert_cast(
3322
                        export_action.traceability_index.document_tree,
3323
                        DocumentTree,
3324
                    )
3325
                    document = document_tree.map_docs_by_rel_paths.get(
3326
                        base_document_url
3327
                    )
3328
                    if document is None:
3329
                        return _error_response(HTTP_STATUS_NOT_FOUND)
3330
 
3331
                    assert document.meta is not None
3332
                    set_file_modification_time(
3333
                        document.meta.input_doc_full_path,
3334
                        datetime.datetime.today(),
3335
                    )
3336
 
3337
                    html_generator.export_single_document_with_performance(
3338
                        document=document,
3339
                        traceability_index=export_action.traceability_index,
3340
                        specific_documents=(document_type_to_generate,),
3341
                    )
3342
                return None
3343
 
3344
            response_or_none = generate_document()
3345
            if response_or_none is not None:
3346
                return response_or_none
3347
            return FileResponse(
3348
                full_path_to_document,
3349
                media_type="text/html",
3350
                headers={"Cache-Control": "no-cache"},
3351
            )
3352
 
3353
    def get_asset(request: Request, url_to_asset: str) -> Response:
3354
        project_output_path = project_config.export_output_html_root
3355
 
3356
        static_file = os.path.join(project_output_path, url_to_asset)
3357
        content_type, _ = guess_type(static_file)
3358
 
3359
        with lock_manager.acquire_global_read():
3360
            # We keep a global read lock here because this endpoint serves not
3361
            # only bundled immutable static files, but also generated assets
3362
            # under export_output_html_root that may be rewritten at runtime.
3363
            # FIXME: Revisit when asset writes are fully atomic and immutable
3364
            # from the reader's perspective, so this lock can potentially be
3365
            # narrowed or removed.
3366
            if not os.path.isfile(static_file):
3367
                return _error_response(HTTP_STATUS_NOT_FOUND, path_type="asset")
3368
 
3369
            if request_is_for_non_modified_file(request, static_file):
3370
                return Response(status_code=304)
3371
 
3372
            response = FileResponse(static_file, media_type=content_type)
3373
            return response
3374
 
3375
    def _compute_document_mid_lock_key(document_mid: str) -> str:
3376
        return f"document:{document_mid}"
3377
 
3378
    def _compute_document_relative_path_lock_key(relative_path: str) -> str:
3379
        return f"document:{relative_path}"
3380
 
3381
    def _compute_document_generation_lock_key(relative_path: str) -> str:
3382
        return _compute_document_relative_path_lock_key(relative_path)
3383
 
3384
    def _error_response(
3385
        error_code: int, path_type: str = "document"
3386
    ) -> Response:
3387
        view_object = ServerErrorViewObject(
3388
            project_config=project_config,
3389
            error_code=error_code,
3390
            path_type=path_type,
3391
        )
3392
        return Response(
3393
            content=view_object.render_screen(env()),
3394
            status_code=error_code,
3395
            media_type="text/html",
3396
        )
3397
 
3398
    # Websockets solution based on:
3399
    # https://fastapi.tiangolo.com/advanced/websockets/
3400
    class ConnectionManager:
3401
        def __init__(self) -> None:
3402
            self.active_connections: List[WebSocket] = []
3403
            self._lock = asyncio.Lock()
3404
 
3405
        async def connect(self, websocket: WebSocket) -> None:
3406
            await websocket.accept()
3407
            async with self._lock:
3408
                self.active_connections.append(websocket)
3409
 
3410
        async def disconnect(self, websocket: WebSocket) -> None:
3411
            async with self._lock:
3412
                if websocket in self.active_connections:
3413
                    self.active_connections.remove(websocket)
3414
 
3415
        async def broadcast(self, message: str) -> None:
3416
            async with self._lock:
3417
                connections = list(self.active_connections)
3418
            for connection in connections:
3419
                await connection.send_text(message)
3420
 
3421
    manager = ConnectionManager()
3422
 
3423
    @router.websocket("/ws/{client_id}")
3424
    async def websocket_endpoint(websocket: WebSocket, client_id: int) -> None:
3425
        await manager.connect(websocket)
3426
        try:
3427
            while True:
3428
                _ = await websocket.receive_text()
3429
                # Do nothing for now.
3430
        except WebSocketDisconnect:
3431
            await manager.disconnect(websocket)
3432
            await manager.broadcast(
3433
                f"Websocket: Client #{client_id} disconnected"
3434
            )
3435
 
3436
    return router