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 deterministic164
# 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 edited240
# versions of the same nodes. If a saved node has a version that is older241
# than one tracked in this dictionary, StrictDoc raises a validation to a242
# 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
return286
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
yield306
307
def write_lock() -> Iterator[None]:
308
with lock_manager.acquire_global_write():
309
yield310
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 on398
# 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_type418
)419
) is not None:
420
next_uid = document_tree_stats.get_next_requirement_uid(
421
node_prefix422
)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_requirement491
):492
raise HTTPException(
493
status_code=403,
494
detail="Cloning is disabled for autogenerated content.",
495
)496
497
document: Optional[SDocDocument] = (
498
reference_node499
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 after664
# the traceability index last update marker has been updated. This way665
# 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_prefix798
)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")
- "6.3.5. Update node" (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
CreateOrUpdateNodeResult875
] = 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 after935
# the traceability index last update marker has been updated. This way936
# 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
)- "6.3.5. Update node" (REQUIREMENT)
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)
- "6.3.9. Move requirement / section nodes within document" (REQUIREMENT)
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 node1202
# (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_node1222
)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_node1233
)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 this1245
# 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_node1275
),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 an1365
# 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 validation1453
# 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
)- "6.2.2. Create document" (REQUIREMENT)
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_path1596
),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 underlying1631
``.sdoc`` file from disk, rebuilds the index and redirects back to the1632
project index screen. For now, it is up to the user to ensure that1633
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
document1652
)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 and1693
# fall back to a normal redirect; the error can be inspected in1694
# server logs.1695
pass1696
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 can1712
# be cleaned up manually if necessary.1713
continue1714
1715
# Rebuild the project index so the removed document disappears from1716
# 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 comment1737
# 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 relation1790
# 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)
- "6.3.11. Edit Document options" (REQUIREMENT)
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)
- "6.3.11. Edit Document options" (REQUIREMENT)
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 this1978
# 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 this2065
# 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
)- "6.3.11. Edit Document options" (REQUIREMENT)
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)
- "6.3.10. Edit Document grammar" (REQUIREMENT)
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
)- "6.3.10. Edit Document grammar" (REQUIREMENT)
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
)- "6.3.10. Edit Document grammar" (REQUIREMENT)
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
)- "6.3.10. Edit Document grammar" (REQUIREMENT)
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
)- "6.3.10. Edit Document grammar" (REQUIREMENT)
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
)- "6.3.10. Edit Document grammar" (REQUIREMENT)
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
)- "6.3.10. Edit Document grammar" (REQUIREMENT)
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 is2549
# 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_path2596
),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 subprocess2671
# 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 it2699
# 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 extension2722
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
document2806
)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, because2844
# 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)
- "6.3.13. Auto-completion for requirements UIDs" (REQUIREMENT)
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
document2890
)2891
)2892
for node_, _ in document_iterator.all_content(
2893
print_fragments=False
2894
):2895
if not isinstance(node_, SDocNodeIF):
2896
continue2897
2898
if node_.node_type == "SECTION":
2899
continue2900
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 coverage2916
# because it is not practical to create a test that2917
# reproduces going above the limit. The code is2918
# simple, so it should be safe to exclude this2919
# branch from coverage.2920
if (
2921
len(resulting_nodes) >= AUTOCOMPLETE_LIMIT
2922
): # pragma: no cover
2923
break2924
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: ERA0012977
parts = q.lower().split(",")
2978
2979
# For the lookup, we want to use the only the last, still2980
# incomplete part, not the full query:2981
#2982
# last_part = "yet ano" # noqa: ERA0012983
# query_words = ['yet', 'ano'] # noqa: ERA0012984
last_part = parts[-1].strip()
2985
query_words = last_part.split()
2986
2987
# We also filter the already selected choices from the2988
# options we are going to be send to the user,2989
# as MultipleChoices is a Set, so options shall be2990
# 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
choice2998
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
break3016
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_mid3040
)3041
)3042
3043
# If found, send a 302 redirect response to guide the user to the3044
# 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 page3054
# 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 the3058
# 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 without3121
# revalidation.3122
# The no-cache request directive asks caches to validate the3123
# response with the origin server before reuse.3124
# no-cache allows clients to request the most up-to-date3125
# response even if the cache has a fresh response.3126
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control3127
"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 no3139
# extension can ever resolve to a valid document. Return 4043140
# directly without going through get_document().3141
return _error_response(HTTP_STATUS_NOT_FOUND)
3142
else:
3143
return get_asset(request, full_path)
3144
- "14.5. On-demand loading of HTML pages" (REQUIREMENT)
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_document3152
)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_document3163
)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 without3180
# revalidation.3181
# The no-cache request directive asks caches to validate the3182
# response with the origin server before reuse.3183
# no-cache allows clients to request the most up-to-date3184
# response even if the cache has a fresh response.3185
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control3186
"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_url3327
)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 not3361
# only bundled immutable static files, but also generated assets3362
# under export_output_html_root that may be rewritten at runtime.3363
# FIXME: Revisit when asset writes are fully atomic and immutable3364
# from the reader's perspective, so this lock can potentially be3365
# 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