Path:
strictdoc/server/routers/main_router.py
Lines:
4923
Non-empty lines:
4530
Non-empty lines covered with requirements:
4530 / 4530 (100.0%)
Functions:
91
Functions covered by requirements:
91 / 91 (100.0%)
1
import asyncio
2
import copy
3
import datetime
4
import os
5
import re
6
import types
7
import uuid
8
from collections import defaultdict
9
from mimetypes import guess_type
10
from pathlib import Path
11
from typing import Any, Dict, Iterator, List, Optional, Union
12
from urllib.parse import quote
13
14
from fastapi import APIRouter, Depends, FastAPI, Form, HTTPException, UploadFile
15
from reqif.models.error_handling import ReqIFXMLParsingError
16
from reqif.parser import ReqIFParser
17
from reqif.unparser import ReqIFUnparser
18
from starlette.background import BackgroundTask
19
from starlette.datastructures import FormData
20
from starlette.requests import Request
21
from starlette.responses import (
22
FileResponse,
23
HTMLResponse,
24
RedirectResponse,
25
Response,
26
)27
from starlette.websockets import WebSocket, WebSocketDisconnect
28
29
from strictdoc.backend.json.json_generator import JSONGenerator
30
from strictdoc.backend.markdown.writer import SDMarkdownWriter
31
from strictdoc.backend.reqif.p01_sdoc.reqif_to_sdoc_converter import (
32
P01_ReqIFToSDocConverter,
33
)34
from strictdoc.backend.reqif.p01_sdoc.sdoc_to_reqif_converter import (
35
P01_SDocToReqIFObjectConverter,
36
)37
from strictdoc.backend.sdoc.errors.document_tree_error import DocumentTreeError
38
from strictdoc.backend.sdoc.models.document import SDocDocument
39
from strictdoc.backend.sdoc.models.document_grammar import (
40
DocumentGrammar,
41
)42
from strictdoc.backend.sdoc.models.grammar_element import (
43
GrammarElement,
44
GrammarElementField,
45
RequirementFieldType,
46
)47
from strictdoc.backend.sdoc.models.model import (
48
SDocExtendedElementIF,
49
SDocNodeIF,
50
)51
from strictdoc.backend.sdoc.models.node import (
52
SDocNode,
53
)54
from strictdoc.backend.sdoc.writer import SDWriter
55
from strictdoc.backend.sdoc_source_code.models.source_file_info import (
56
SourceFileTraceabilityInfo,
57
)58
from strictdoc.core.analyzers.document_stats import DocumentTreeStats
59
from strictdoc.core.analyzers.document_uid_analyzer import DocumentUIDAnalyzer
60
from strictdoc.core.document_meta import DocumentMeta
61
from strictdoc.core.document_tree import DocumentTree
62
from strictdoc.core.feature import Feature, FeatureContext
63
from strictdoc.core.project_config import ProjectConfig
64
from strictdoc.core.query_engine.query_object import Query, QueryObject
65
from strictdoc.core.query_engine.query_reader import QueryReader
66
from strictdoc.core.traceability_index_builder import TraceabilityIndexBuilder
67
from strictdoc.core.transforms.constants import NodeCreationOrder
68
from strictdoc.core.transforms.delete_requirement import (
69
DeleteRequirementCommand,
70
)71
from strictdoc.core.transforms.update_document_config import (
72
UpdateDocumentConfigTransform,
73
)74
from strictdoc.core.transforms.update_grammar import UpdateGrammarCommand
75
from strictdoc.core.transforms.update_grammar_element import (
76
UpdateGrammarElementCommand,
77
)78
from strictdoc.core.transforms.update_included_document import (
79
UpdateIncludedDocumentTransform,
80
)81
from strictdoc.core.transforms.update_requirement import (
82
CreateNodeInfo,
83
CreateOrUpdateNodeCommand,
84
CreateOrUpdateNodeResult,
85
UpdateNodeInfo,
86
)87
from strictdoc.core.transforms.validation_error import (
88
MultipleValidationError,
89
MultipleValidationErrorAsList,
90
)91
from strictdoc.export.html.document_type import DocumentType
92
from strictdoc.export.html.form_objects.document_config_form_object import (
93
DocumentConfigFormObject,
94
DocumentMetadataFormField,
95
)96
from strictdoc.export.html.form_objects.grammar_element_form_object import (
97
GrammarElementFormObject,
98
)99
from strictdoc.export.html.form_objects.grammar_form_object import (
100
GrammarFormObject,
101
)102
from strictdoc.export.html.form_objects.included_document_form_object import (
103
IncludedDocumentFormObject,
104
)105
from strictdoc.export.html.form_objects.requirement_form_object import (
106
RequirementFormField,
107
RequirementFormFieldType,
108
RequirementFormObject,
109
RequirementReferenceFormField,
110
deduplicate_comma_separated_value,
111
)112
from strictdoc.export.html.generators.view_objects.document_screen_view_object import (
113
DocumentScreenViewObject,
114
)115
from strictdoc.export.html.generators.view_objects.server_error_view_object import (
116
ServerErrorViewObject,
117
)118
from strictdoc.export.html.html_generator import HTMLGenerator
119
from strictdoc.export.html.html_templates import HTMLTemplates, JinjaEnvironment
120
from strictdoc.export.html.renderers.link_renderer import LinkRenderer
121
from strictdoc.export.html.renderers.markup_renderer import MarkupRenderer
122
from strictdoc.features.export.export_action import ExportAction
123
from strictdoc.features.html2pdf.generator import (
124
DocumentHTML2PDFGenerator,
125
)126
from strictdoc.features.html2pdf.pdf_print_driver import (
127
PDFPrintDriver,
128
PDFPrintDriverException,
129
)130
from strictdoc.features.nestor.view_object import (
131
NestorViewObject,
132
)133
from strictdoc.features.project_index.view_object import (
134
ProjectTreeViewObject,
135
)136
from strictdoc.features.search.view_object import (
137
SearchScreenViewObject,
138
)139
from strictdoc.helpers.cast import assert_cast
140
from strictdoc.helpers.file_modification_time import (
141
get_file_modification_time,
142
set_file_modification_time,
143
)144
from strictdoc.helpers.mid import MID
145
from strictdoc.helpers.parallelizer import NullParallelizer
146
from strictdoc.helpers.path_filter import PathFilter
147
from strictdoc.helpers.paths import SDocRelativePath
148
from strictdoc.helpers.string import (
149
create_safe_acronym,
150
is_safe_alphanumeric_string,
151
sanitize_html_form_field,
152
)153
from strictdoc.helpers.timing import measure_performance
154
from strictdoc.server.document_watcher import (
155
DocumentWatcher,
156
get_watched_document_extensions,
157
)158
from strictdoc.server.error_object import ErrorObject
159
from strictdoc.server.helpers.hierarchical_rw_lock_manager import (
160
HierarchicalRWLockManager,
161
)162
from strictdoc.server.helpers.http import request_is_for_non_modified_file
163
from strictdoc.server.helpers.turbo import render_turbo_stream
164
165
HTTP_STATUS_BAD_REQUEST = 400
166
HTTP_STATUS_NOT_FOUND = 404
167
HTTP_STATUS_PRECONDITION_FAILED = 412
168
HTTP_STATUS_INTERNAL_SERVER_ERROR = 500
169
170
AUTOCOMPLETE_LIMIT = 50
171
172
173
def search_query_contains_markers(query: str) -> bool:
174
# Query mode markers are intentionally broad to keep behavior deterministic175
# for expression-like input.176
if "node." in query:
177
return True
178
if ("(" in query and ")" in query) or "==" in query or "!=" in query:
179
return True
180
if re.search(r'\[\s*"[^"]+"\s*\]', query):
181
return True
182
return False
183
184
185
def parse_plain_text_search_query(
186
query: str,
187
) -> tuple[Optional[str], Optional[re.Pattern[str]]]:
188
plain_text_query = query.lower()
189
if (
190
len(plain_text_query) >= 2
191
and plain_text_query.startswith('"')
192
and plain_text_query.endswith('"')
193
):194
return plain_text_query[1:-1], None
195
196
query_parts = [part for part in plain_text_query.split() if part]
197
if len(query_parts) == 0:
198
return None, None
199
wildcard_pattern = ".*".join(map(re.escape, query_parts))
200
return None, re.compile(wildcard_pattern)
201
202
203
def search_text_matches_plain_text_query(
204
text: str,
205
*,
206
phrase: Optional[str],
207
pattern: Optional[re.Pattern[str]],
208
) -> bool:
209
lowered_text = text.lower()
210
if phrase is not None:
211
return phrase in lowered_text
212
if pattern is not None:
213
return pattern.search(lowered_text) is not None
214
return False
215
216
217
def search_node_matches_plain_text_query(
218
node: SDocExtendedElementIF,
219
*,
220
phrase: Optional[str],
221
pattern: Optional[re.Pattern[str]],
222
) -> bool:
223
if isinstance(node, SDocNode):
224
for requirement_field_ in node.enumerate_fields():
225
field_text = requirement_field_.get_text_value()
226
if search_text_matches_plain_text_query(
227
field_text, phrase=phrase, pattern=pattern
228
):229
return True
230
return False
231
if isinstance(node, SourceFileTraceabilityInfo):
232
if node.source_file is None:
233
return False
234
return search_text_matches_plain_text_query(
235
node.source_file.in_doctree_source_file_rel_path,
236
phrase=phrase,
237
pattern=pattern,
238
)239
return False
240
241
242
def create_main_router(
243
project_config: ProjectConfig,
244
*,
245
app: FastAPI,
246
lock_manager: HierarchicalRWLockManager,
247
) -> APIRouter:
248
parallelizer = NullParallelizer()
249
250
# This dictionary is used to track conflicts between concurrently edited251
# versions of the same nodes. If a saved node has a version that is older252
# than one tracked in this dictionary, StrictDoc raises a validation to a253
# user.254
# Type signature: [MID, version number]255
revisions: Dict[str, int] = defaultdict(int)
256
257
project_config.is_running_on_server = True
258
259
export_action = ExportAction(
260
project_config=project_config,
261
parallelizer=parallelizer,
262
)263
264
is_small_project = export_action.traceability_index.is_small_project()
265
266
html_templates: HTMLTemplates = HTMLTemplates.create(
267
project_config=project_config,
268
enable_caching=not is_small_project,
269
strictdoc_last_update=export_action.traceability_index.strictdoc_last_update,
270
)271
272
html_generator = HTMLGenerator(project_config, html_templates)
273
274
# Server screens contributed by built-in Features (e.g.275
# ProjectStatisticsFeature), keyed by the screen_filename() each one276
# owns. Built from *all* built-in Features regardless of activation277
# (not just project_config.get_features(), which only resolves278
# activated ones) so that a request for a known-but-not-activated279
# screen can be told apart from an unknown path: the former must280
# still return 412, the latter 404. Dispatched from within281
# generate_document() below, so every Feature-contributed screen282
# still goes through the same shared caching/locking machinery as283
# every other document.284
server_features_by_screen_filename: Dict[str, Feature] = {
285
feature_.screen_filename(): feature_
286
for feature_ in ProjectConfig._builtin_features_by_handle().values()
287
if feature_.supports_server()
288
}289
290
html_generator.export_assets(
291
traceability_index=export_action.traceability_index,
292
project_config=project_config,
293
html_templates=html_templates,
294
export_output_html_root=project_config.export_output_html_root,
295
)296
297
sdoc_writer = SDWriter(project_config)
298
299
def write_document_to_file(document: SDocDocument) -> None:
300
"""
301
FIXME: Factorize this into an OOP class.302
"""303
304
assert isinstance(document, SDocDocument)
305
306
# Inhibit before writing so the watcher's debounce always fires into307
# an already-suppressed state — no race window between write and hash.308
if document.meta is not None:
309
document_watcher = getattr(app.state, "document_watcher", None)
310
if document_watcher is not None:
311
document_watcher.inhibit_next_change(
312
document.meta.input_doc_full_path
313
)314
315
if (
316
document.meta is not None
317
and document.meta.input_doc_full_path.lower().endswith(
318
(".md", ".markdown")
319
)320
):321
SDMarkdownWriter.write_to_file(
322
document, line_width=project_config.document_line_width
323
)324
else:
325
sdoc_writer.write_to_file(document)
326
327
def env() -> JinjaEnvironment:
328
return html_templates.jinja_environment()
329
330
@app.exception_handler(404)
331
async def not_found_handler(request: Request, exc: Exception) -> Response: # noqa: ARG001
332
return _error_response(HTTP_STATUS_NOT_FOUND)
333
334
@app.exception_handler(500)
335
async def internal_error_handler(
336
request: Request, # noqa: ARG001
337
exc: Exception, # noqa: ARG001
338
) -> Response:
339
return _error_response(HTTP_STATUS_INTERNAL_SERVER_ERROR)
340
341
def read_lock() -> Iterator[None]:
342
with lock_manager.acquire_global_read():
343
yield344
345
def write_lock() -> Iterator[None]:
346
with lock_manager.acquire_global_write():
347
yield348
349
async def parse_form_data(request: Request) -> FormData:
350
return await request.form()
351
352
router = APIRouter()
353
read_router = APIRouter(dependencies=[Depends(read_lock)])
354
write_router = APIRouter(dependencies=[Depends(write_lock)])
355
356
@router.get("/")
357
def get_root(request: Request) -> Response:
358
return get_incoming_request(request, "index.html")
359
360
@read_router.get("/actions/show_full_node", response_class=Response)
361
def node__show_full(reference_mid: str) -> Response:
362
node: Union[SDocNode] = (
363
export_action.traceability_index.get_node_by_mid(MID(reference_mid))
364
)365
requirement_document: SDocDocument = assert_cast(
366
node.get_document(), SDocDocument
367
)368
assert requirement_document.meta is not None
369
link_renderer = LinkRenderer(
370
root_path=requirement_document.meta.get_root_path_prefix(),
371
static_path=project_config.dir_for_sdoc_assets,
372
)373
markup_renderer = MarkupRenderer.create(
374
markup=requirement_document.config.get_markup(),
375
traceability_index=export_action.traceability_index,
376
link_renderer=link_renderer,
377
html_templates=html_generator.html_templates,
378
config=project_config,
379
context_document=requirement_document,
380
)381
view_object = DocumentScreenViewObject(
382
document_type=DocumentType.DOCUMENT,
383
document=requirement_document,
384
traceability_index=export_action.traceability_index,
385
project_config=project_config,
386
link_renderer=link_renderer,
387
markup_renderer=markup_renderer,
388
jinja_environment=env(),
389
git_client=html_generator.git_client,
390
)391
output = env().render_template_as_markup(
392
"actions/node/show_full_node/stream_show_full_node.jinja",
393
view_object=view_object,
394
requirement=node,
395
)396
return HTMLResponse(
397
content=output,
398
status_code=200,
399
headers={
400
"Content-Type": "text/vnd.turbo-stream.html",
401
},402
)403
404
@read_router.get(
405
"/actions/document/new_requirement", response_class=Response
406
)407
def get_new_requirement(
408
reference_mid: str,
409
whereto: str,
410
element_type: str,
411
context_document_mid: str,
412
) -> Response:
413
assert isinstance(reference_mid, str), reference_mid
414
assert isinstance(whereto, str), whereto
415
assert isinstance(element_type, str), element_type
416
assert isinstance(context_document_mid, str), context_document_mid
417
418
assert NodeCreationOrder.is_valid(whereto), whereto
419
420
context_document = export_action.traceability_index.get_node_by_mid(
421
MID(context_document_mid)
422
)423
424
reference_node = export_action.traceability_index.get_node_by_mid(
425
MID(reference_mid)
426
)427
if not export_action.traceability_index.can_create_node_at(
428
reference_node, whereto
429
):430
raise HTTPException(
431
status_code=403,
432
detail="Adding nodes is disabled for autogenerated content.",
433
)434
435
# Which document becomes the new requirement's parent is based on436
# whether the reference node is a root node of an included document or not.437
document: SDocDocument
438
if isinstance(reference_node, SDocDocument):
439
if whereto == "child":
440
document = reference_node
441
else:
442
document = context_document
443
else:
444
document = reference_node.get_document()
445
446
next_uid: Optional[str] = None
447
if element_type not in ("TEXT", "SECTION"):
448
document_tree_stats: DocumentTreeStats = (
449
DocumentUIDAnalyzer.analyze_document_tree(
450
export_action.traceability_index
451
)452
)453
if (
454
node_prefix := reference_node.get_prefix_for_new_node(
455
element_type456
)457
) is not None:
458
next_uid = document_tree_stats.get_next_requirement_uid(
459
node_prefix460
)461
form_object = RequirementFormObject.create_new(
462
document=document,
463
context_document_mid=context_document_mid,
464
next_uid=next_uid,
465
element_type=element_type,
466
)467
468
target_node_mid = reference_mid
469
470
if whereto == NodeCreationOrder.CHILD:
471
replace_action = "after"
472
elif whereto == NodeCreationOrder.BEFORE:
473
replace_action = "before"
474
elif whereto == NodeCreationOrder.AFTER:
475
replace_action = "after"
476
else:
477
raise NotImplementedError
478
479
assert document.meta is not None
480
link_renderer = LinkRenderer(
481
root_path=document.meta.get_root_path_prefix(),
482
static_path=project_config.dir_for_sdoc_assets,
483
)484
markup_renderer = MarkupRenderer.create(
485
markup=document.config.get_markup(),
486
traceability_index=export_action.traceability_index,
487
link_renderer=link_renderer,
488
html_templates=html_generator.html_templates,
489
config=project_config,
490
context_document=document,
491
)492
output = env().render_template_as_markup(
493
"actions/"494
"document/"495
"create_requirement/"496
"stream_new_requirement.jinja.html",
497
is_new_requirement=True,
498
renderer=markup_renderer,
499
form_object=form_object,
500
reference_mid=reference_mid,
501
target_node_mid=target_node_mid,
502
document_type=DocumentType.DOCUMENT,
503
whereto=whereto,
504
replace_action=replace_action,
505
)506
507
return HTMLResponse(
508
content=output,
509
status_code=200,
510
headers={
511
"Content-Type": "text/vnd.turbo-stream.html",
512
},513
)514
515
@read_router.get(
516
"/actions/document/clone_requirement", response_class=Response
517
)518
def get_clone_requirement(
519
reference_mid: str, context_document_mid: str
520
) -> Response:
521
assert isinstance(reference_mid, str), reference_mid
522
523
reference_node = export_action.traceability_index.get_node_by_mid(
524
MID(reference_mid)
525
)526
reference_requirement: SDocNode = assert_cast(reference_node, SDocNode)
527
if not export_action.traceability_index.can_clone_node(
528
reference_requirement529
):530
raise HTTPException(
531
status_code=403,
532
detail="Cloning is disabled for autogenerated content.",
533
)534
535
document: Optional[SDocDocument] = (
536
reference_node537
if isinstance(reference_node, SDocDocument)
538
else reference_node.get_document()
539
)540
document_tree_stats: DocumentTreeStats = (
541
DocumentUIDAnalyzer.analyze_document_tree(
542
export_action.traceability_index
543
)544
)545
next_uid: str = ""
546
if (node_prefix := reference_node.get_prefix()) is not None:
547
next_uid = document_tree_stats.get_next_requirement_uid(node_prefix)
548
549
form_object: RequirementFormObject = (
550
RequirementFormObject.clone_from_requirement(
551
requirement=reference_requirement,
552
context_document_mid=context_document_mid,
553
clone_uid=next_uid,
554
)555
)556
557
target_node_mid = reference_mid
558
559
whereto = NodeCreationOrder.AFTER
560
replace_action = "after"
561
562
assert document is not None
563
assert document.meta is not None
564
link_renderer = LinkRenderer(
565
root_path=document.meta.get_root_path_prefix(),
566
static_path=project_config.dir_for_sdoc_assets,
567
)568
markup_renderer = MarkupRenderer.create(
569
markup=document.config.get_markup(),
570
traceability_index=export_action.traceability_index,
571
link_renderer=link_renderer,
572
html_templates=html_generator.html_templates,
573
config=project_config,
574
context_document=document,
575
)576
output = env().render_template_as_markup(
577
"actions/"578
"document/"579
"create_requirement/"580
"stream_new_requirement.jinja.html",
581
is_new_requirement=True,
582
renderer=markup_renderer,
583
form_object=form_object,
584
reference_mid=reference_mid,
585
target_node_mid=target_node_mid,
586
document_type=DocumentType.DOCUMENT,
587
whereto=whereto,
588
replace_action=replace_action,
589
)590
591
return HTMLResponse(
592
content=output,
593
status_code=200,
594
headers={
595
"Content-Type": "text/vnd.turbo-stream.html",
596
},597
)598
599
@write_router.post(
600
"/actions/document/create_requirement", response_class=Response
601
)602
def create_requirement(
603
request_form_data: FormData = Depends(parse_form_data),
604
) -> Response:
605
request_dict: Dict[str, str] = dict(request_form_data)
606
requirement_mid: str = request_dict["requirement_mid"]
607
document_mid: str = request_dict["document_mid"]
608
context_document_mid: str = request_dict["context_document_mid"]
609
reference_mid: str = request_dict["reference_mid"]
610
whereto: str = request_dict["whereto"]
611
document: SDocDocument = (
612
export_action.traceability_index.get_node_by_mid(MID(document_mid))
613
)614
editing_context_document: SDocDocument = (
615
export_action.traceability_index.get_node_by_mid(
616
MID(context_document_mid)
617
)618
)619
reference_node = export_action.traceability_index.get_node_by_mid(
620
MID(reference_mid)
621
)622
if not export_action.traceability_index.can_create_node_at(
623
reference_node, whereto
624
):625
raise HTTPException(
626
status_code=403,
627
detail="Adding nodes is disabled for autogenerated content.",
628
)629
630
form_object: RequirementFormObject = (
631
RequirementFormObject.create_from_request(
632
is_new=True,
633
requirement_mid=requirement_mid,
634
request_form_data=request_form_data,
635
document=document,
636
existing_requirement_uid=None,
637
)638
)639
form_object.validate(
640
traceability_index=export_action.traceability_index,
641
context_document=document,
642
config=project_config,
643
existing_revision=0,
644
)645
646
if not form_object.any_errors():
647
command = CreateOrUpdateNodeCommand(
648
form_object=form_object,
649
node_info=CreateNodeInfo(
650
whereto=whereto,
651
requirement_mid=requirement_mid,
652
reference_mid=reference_mid,
653
),654
traceability_index=export_action.traceability_index,
655
project_config=project_config,
656
)657
command.perform()
658
659
if form_object.any_errors():
660
assert document.meta is not None
661
link_renderer = LinkRenderer(
662
root_path=document.meta.get_root_path_prefix(),
663
static_path=project_config.dir_for_sdoc_assets,
664
)665
markup_renderer = MarkupRenderer.create(
666
markup=document.config.get_markup(),
667
traceability_index=export_action.traceability_index,
668
link_renderer=link_renderer,
669
html_templates=html_generator.html_templates,
670
config=project_config,
671
context_document=document,
672
)673
output = env().render_template_as_markup(
674
"actions/"675
"document/"676
"create_requirement/"677
"stream_new_requirement.jinja.html",
678
is_new_requirement=True,
679
renderer=markup_renderer,
680
form_object=form_object,
681
reference_mid=reference_mid,
682
target_node_mid=requirement_mid,
683
document_type=DocumentType.DOCUMENT,
684
whereto=whereto,
685
replace_action="replace",
686
)687
return HTMLResponse(
688
content=output,
689
status_code=422,
690
headers={
691
"Content-Type": "text/vnd.turbo-stream.html",
692
},693
)694
695
# Saving new content to .SDoc files.696
write_document_to_file(document)
697
if document != editing_context_document:
698
write_document_to_file(editing_context_document)
699
700
# Exporting the updated document to HTML. Note that this happens after701
# the traceability index last update marker has been updated. This way702
# the generated HTML file is newer than the traceability index.703
html_generator.export_single_document_with_performance(
704
document=document,
705
traceability_index=export_action.traceability_index,
706
specific_documents=(DocumentType.DOCUMENT,),
707
)708
709
assert document.meta is not None
710
link_renderer = LinkRenderer(
711
root_path=document.meta.get_root_path_prefix(),
712
static_path=project_config.dir_for_sdoc_assets,
713
)714
markup_renderer = MarkupRenderer.create(
715
markup=document.config.get_markup(),
716
traceability_index=export_action.traceability_index,
717
link_renderer=link_renderer,
718
html_templates=html_generator.html_templates,
719
config=project_config,
720
context_document=document,
721
)722
723
view_object = DocumentScreenViewObject(
724
document_type=DocumentType.DOCUMENT,
725
document=editing_context_document,
726
traceability_index=export_action.traceability_index,
727
project_config=project_config,
728
link_renderer=link_renderer,
729
markup_renderer=markup_renderer,
730
jinja_environment=env(),
731
git_client=html_generator.git_client,
732
)733
734
output = view_object.render_updated_screen()
735
736
return HTMLResponse(
737
content=output,
738
status_code=200,
739
headers={
740
"Content-Type": "text/vnd.turbo-stream.html",
741
},742
)743
744
@read_router.get(
745
"/actions/document/edit_requirement", response_class=Response
746
)747
def get_edit_requirement(
748
node_id: str, context_document_mid: str
749
) -> Response:
750
"""
751
@relation(SDOC-SRS-55, scope=function)752
"""753
754
requirement: SDocNode = (
755
export_action.traceability_index.get_node_by_mid(MID(node_id))
756
)757
if not export_action.traceability_index.can_edit_node(requirement):
758
raise HTTPException(
759
status_code=403,
760
detail="Editing is disabled for autogenerated content.",
761
)762
763
revision = revisions[requirement.reserved_mid.get_string_value()]
764
765
form_object: RequirementFormObject = (
766
RequirementFormObject.create_from_requirement(
767
requirement=requirement,
768
revision=revision,
769
context_document_mid=context_document_mid,
770
)771
)772
document: SDocDocument = assert_cast(
773
requirement.get_document(), SDocDocument
774
)775
assert document.meta is not None
776
link_renderer = LinkRenderer(
777
root_path=document.meta.get_root_path_prefix(),
778
static_path=project_config.dir_for_sdoc_assets,
779
)780
markup_renderer = MarkupRenderer.create(
781
markup=document.config.get_markup(),
782
traceability_index=export_action.traceability_index,
783
link_renderer=link_renderer,
784
html_templates=html_generator.html_templates,
785
config=project_config,
786
context_document=document,
787
)788
output = env().render_template_as_markup(
789
"actions/"790
"document/"791
"edit_requirement/"792
"stream_edit_requirement.jinja.html",
793
is_new_requirement=False,
794
renderer=markup_renderer,
795
form_object=form_object,
796
document_type=DocumentType.DOCUMENT,
797
)798
return HTMLResponse(
799
content=output,
800
status_code=200,
801
headers={
802
"Content-Type": "text/vnd.turbo-stream.html",
803
},804
)805
806
@read_router.get(
807
"/reset_uid",
808
response_class=Response,
809
)810
def reset_uid(reference_mid: str) -> Response:
811
document_tree_stats: DocumentTreeStats = (
812
DocumentUIDAnalyzer.analyze_document_tree(
813
export_action.traceability_index
814
)815
)816
reference_node = export_action.traceability_index.get_node_by_mid_weak(
817
MID(reference_mid)
818
)819
next_uid: str = ""
820
if (
821
isinstance(reference_node, SDocNode)
822
and reference_node.node_type == "SECTION"
823
):824
document: SDocDocument = assert_cast(
825
reference_node.get_document(), SDocDocument
826
)827
document_acronym = create_safe_acronym(document.title)
828
next_uid = document_tree_stats.get_auto_section_uid(
829
document_acronym, reference_node
830
)831
elif isinstance(reference_node, SDocNode):
832
if (node_prefix := reference_node.get_prefix()) is not None:
833
next_uid = document_tree_stats.get_next_requirement_uid(
834
node_prefix835
)836
else:
837
raise NotImplementedError(reference_node) # pragma: no cover
838
839
uid_form_field: RequirementFormField = RequirementFormField(
840
field_mid=MID.create(),
841
field_name="UID",
842
field_type=RequirementFormFieldType.SINGLELINE,
843
field_value=next_uid,
844
)845
output = env().render_template_as_markup(
846
"components/form/row/row_uid_with_reset/stream.jinja",
847
next_uid=next_uid,
848
reference_mid=reference_mid,
849
uid_form_field=uid_form_field,
850
)851
return HTMLResponse(
852
content=output,
853
status_code=200,
854
headers={
855
"Content-Type": "text/vnd.turbo-stream.html",
856
},857
)858
859
@write_router.post("/actions/document/update_requirement")
- "6.3.5. Update node" (REQUIREMENT)
860
def document__update_requirement(
861
request_form_data: FormData = Depends(parse_form_data),
862
) -> Response:
863
"""
864
@relation(SDOC-SRS-55, scope=function)865
"""866
867
request_dict = dict(request_form_data)
868
requirement_mid = request_dict["requirement_mid"]
869
requirement: SDocNode = (
870
export_action.traceability_index.get_node_by_mid(
871
MID(requirement_mid)
872
)873
)874
if not export_action.traceability_index.can_edit_node(requirement):
875
raise HTTPException(
876
status_code=403,
877
detail="Editing is disabled for autogenerated content.",
878
)879
880
document = assert_cast(requirement.get_document(), SDocDocument)
881
882
assert isinstance(requirement_mid, str) and len(requirement_mid) > 0, (
883
f"{requirement_mid}"
884
)885
886
form_object: RequirementFormObject = (
887
RequirementFormObject.create_from_request(
888
is_new=False,
889
requirement_mid=requirement_mid,
890
request_form_data=request_form_data,
891
document=document,
892
existing_requirement_uid=requirement.reserved_uid,
893
)894
)895
existing_revision = revisions[form_object.requirement_mid]
896
897
form_object.validate(
898
traceability_index=export_action.traceability_index,
899
context_document=document,
900
config=project_config,
901
existing_revision=existing_revision,
902
)903
904
update_requirement_command_result_or_none: Optional[
905
CreateOrUpdateNodeResult906
] = None
907
if not form_object.any_errors():
908
update_command = CreateOrUpdateNodeCommand(
909
form_object=form_object,
910
node_info=UpdateNodeInfo(node_to_update=requirement),
911
traceability_index=export_action.traceability_index,
912
project_config=project_config,
913
)914
915
update_requirement_command_result_or_none = update_command.perform()
916
917
link_renderer: LinkRenderer
918
markup_renderer: MarkupRenderer
919
assert document.meta is not None
920
if form_object.any_errors():
921
link_renderer = LinkRenderer(
922
root_path=document.meta.get_root_path_prefix(),
923
static_path=project_config.dir_for_sdoc_assets,
924
)925
markup_renderer = MarkupRenderer.create(
926
markup=document.config.get_markup(),
927
traceability_index=export_action.traceability_index,
928
link_renderer=link_renderer,
929
html_templates=html_generator.html_templates,
930
config=project_config,
931
context_document=document,
932
)933
output = env().render_template_as_markup(
934
"actions/"935
"document/"936
"edit_requirement/"937
"stream_edit_requirement.jinja.html",
938
is_new_requirement=False,
939
renderer=markup_renderer,
940
requirement=requirement,
941
document_type=DocumentType.DOCUMENT,
942
form_object=form_object,
943
)944
return HTMLResponse(
945
content=output,
946
status_code=422,
947
headers={
948
"Content-Type": "text/vnd.turbo-stream.html",
949
},950
)951
952
update_requirement_command_result: CreateOrUpdateNodeResult = (
953
assert_cast(
954
update_requirement_command_result_or_none,
955
CreateOrUpdateNodeResult,
956
)957
)958
959
# Saving new content to .SDoc files.960
write_document_to_file(document)
961
962
revisions[requirement_mid] += 1
963
964
# Exporting the updated document to HTML. Note that this happens after965
# the traceability index last update marker has been updated. This way966
# the generated HTML file is newer than the traceability index.967
html_generator.export_single_document_with_performance(
968
document=document,
969
traceability_index=export_action.traceability_index,
970
specific_documents=(DocumentType.DOCUMENT,),
971
)972
973
link_renderer = LinkRenderer(
974
root_path=document.meta.get_root_path_prefix(),
975
static_path=project_config.dir_for_sdoc_assets,
976
)977
markup_renderer = MarkupRenderer.create(
978
markup=document.config.get_markup(),
979
traceability_index=export_action.traceability_index,
980
link_renderer=link_renderer,
981
html_templates=html_generator.html_templates,
982
config=project_config,
983
context_document=document,
984
)985
view_object = DocumentScreenViewObject(
986
document_type=DocumentType.DOCUMENT,
987
document=document,
988
traceability_index=export_action.traceability_index,
989
project_config=project_config,
990
link_renderer=link_renderer,
991
markup_renderer=markup_renderer,
992
jinja_environment=env(),
993
git_client=html_generator.git_client,
994
)995
996
return HTMLResponse(
997
content=view_object.render_updated_nodes_and_toc(
998
update_requirement_command_result.this_document_requirements_to_update,
999
node_updated=True,
1000
),1001
status_code=200,
1002
headers={
1003
"Content-Type": "text/vnd.turbo-stream.html",
1004
},1005
)1006
1007
@write_router.delete("/actions/table/delete_node")
1008
def table__delete_node(
1009
node_id: str, context_document_mid: str, confirmed: bool = False
1010
) -> Response:
1011
node = export_action.traceability_index.get_node_by_mid(MID(node_id))
1012
if not isinstance(node, SDocNode):
1013
raise HTTPException(status_code=404, detail="Node not found.")
1014
if not export_action.traceability_index.can_delete_node(node):
1015
raise HTTPException(
1016
status_code=403,
1017
detail="Deleting is disabled for autogenerated content.",
1018
)1019
1020
if not confirmed:
1021
try:
1022
DeleteRequirementCommand(
1023
requirement=node,
1024
traceability_index=export_action.traceability_index,
1025
).validate()
1026
errors: List[str] = []
1027
except MultipleValidationErrorAsList as error_:
1028
errors = error_.errors
1029
1030
output = env().render_template_as_markup(
1031
"actions/table/delete_node/stream_confirm.jinja",
1032
node_mid=node_id,
1033
context_document_mid=context_document_mid,
1034
errors=errors,
1035
)1036
return HTMLResponse(
1037
content=output,
1038
status_code=200 if len(errors) == 0 else 422,
1039
headers={"Content-Type": "text/vnd.turbo-stream.html"},
1040
)1041
1042
document = assert_cast(node.get_document(), SDocDocument)
1043
editing_context_document = assert_cast(
1044
export_action.traceability_index.get_node_by_mid(
1045
MID(context_document_mid)
1046
),1047
SDocDocument,
1048
)1049
1050
try:
1051
DeleteRequirementCommand(
1052
requirement=node,
1053
traceability_index=export_action.traceability_index,
1054
).perform()
1055
except MultipleValidationError:
1056
return HTMLResponse(
1057
content="",
1058
status_code=422,
1059
headers={"Content-Type": "text/vnd.turbo-stream.html"},
1060
)1061
1062
write_document_to_file(document)
1063
if document != editing_context_document:
1064
write_document_to_file(editing_context_document)
1065
1066
html_generator.export_single_document_with_performance(
1067
document=document,
1068
traceability_index=export_action.traceability_index,
1069
specific_documents=(DocumentType.DOCUMENT, DocumentType.TABLE),
1070
)1071
if document != editing_context_document:
1072
html_generator.export_single_document_with_performance(
1073
document=editing_context_document,
1074
traceability_index=export_action.traceability_index,
1075
specific_documents=(DocumentType.DOCUMENT, DocumentType.TABLE),
1076
)1077
1078
table_view_object = DocumentScreenViewObject.create_for_table_screen(
1079
document=editing_context_document,
1080
traceability_index=export_action.traceability_index,
1081
project_config=project_config,
1082
html_templates=html_generator.html_templates,
1083
git_client=html_generator.git_client,
1084
jinja_environment=env(),
1085
)1086
output = render_turbo_stream(
1087
content=env().render_template_as_markup(
1088
"screens/document/table/body.jinja",
1089
view_object=table_view_object,
1090
content_entries=list(
1091
table_view_object.document_content_iterator()
1092
),1093
),1094
action="replace",
1095
target="table-content-body",
1096
)1097
output += env().render_template_as_markup(
1098
"actions/document/_shared/stream_updated_toc.jinja.html",
1099
view_object=table_view_object,
1100
)1101
output += render_turbo_stream(
1102
content="",
1103
action="update",
1104
target="confirm",
1105
)1106
return HTMLResponse(
1107
content=output,
1108
status_code=200,
1109
headers={"Content-Type": "text/vnd.turbo-stream.html"},
1110
)1111
1112
@write_router.post("/actions/table/add_node")
1113
def table__add_node(
1114
request_form_data: FormData = Depends(parse_form_data),
1115
) -> Response:
1116
request_dict = dict(request_form_data)
1117
context_document_mid = request_dict["context_document_mid"]
1118
reference_mid = request_dict["reference_mid"]
1119
element_type = request_dict["element_type"]
1120
whereto = request_dict["whereto"]
1121
1122
if not NodeCreationOrder.is_valid(whereto):
1123
return HTMLResponse(
1124
content="Unknown node placement.", status_code=400
1125
)1126
1127
reference_node = export_action.traceability_index.get_node_by_mid(
1128
MID(reference_mid)
1129
)1130
if not export_action.traceability_index.can_create_node_at(
1131
reference_node, whereto
1132
):1133
return HTMLResponse(
1134
content="Adding nodes is disabled for this location.",
1135
status_code=403,
1136
)1137
1138
editing_context_document = (
1139
export_action.traceability_index.get_node_by_mid(
1140
MID(context_document_mid)
1141
)1142
)1143
if isinstance(reference_node, SDocDocument):
1144
if whereto == NodeCreationOrder.CHILD:
1145
document = reference_node
1146
else:
1147
document = editing_context_document
1148
else:
1149
document = assert_cast(reference_node.get_document(), SDocDocument)
1150
1151
assert document.grammar is not None
1152
if element_type not in document.grammar.elements_by_type:
1153
return HTMLResponse(content="Unknown node type.", status_code=400)
1154
1155
element = document.grammar.elements_by_type[element_type]
1156
1157
next_uid: Optional[str] = None
1158
if element_type not in ("TEXT", "SECTION"):
1159
document_tree_stats: DocumentTreeStats = (
1160
DocumentUIDAnalyzer.analyze_document_tree(
1161
export_action.traceability_index
1162
)1163
)1164
if (
1165
node_prefix := reference_node.get_prefix_for_new_node(
1166
element_type1167
)1168
) is not None:
1169
next_uid = document_tree_stats.get_next_requirement_uid(
1170
node_prefix1171
)1172
1173
form_object = RequirementFormObject.create_new(
1174
document=document,
1175
context_document_mid=context_document_mid,
1176
next_uid=next_uid,
1177
element_type=element_type,
1178
)1179
1180
for field_name, fields in form_object.fields.items():
1181
if field_name in ("UID", "MID"):
1182
continue1183
grammar_field = element.fields_map[field_name]
1184
if grammar_field.required:
1185
for field in fields:
1186
if len(field.field_value) == 0:
1187
field.field_value = "TBD"
1188
1189
# Fallback: if still no field has a value (including auto-generated1190
# UID/MID), pick the first suitable field by priority so the node is1191
# never completely empty. Mirrors the "at least one non-empty" check in1192
# RequirementFormObject.validate().1193
if not any(
1194
len(f.field_value) > 0
1195
for fl in form_object.fields.values()
1196
for f in fl
1197
):1198
_PRIORITY_NAMES = ("TITLE", "STATEMENT", "RATIONALE")
1199
_PRIORITY_TYPES = (
1200
RequirementFieldType.STRING,
1201
RequirementFieldType.SINGLE_CHOICE,
1202
RequirementFieldType.MULTIPLE_CHOICE,
1203
)1204
_fallback_field = None
1205
for _name in _PRIORITY_NAMES:
1206
if _name in form_object.fields:
1207
_fallback_field = form_object.fields[_name][0]
1208
break1209
if _fallback_field is None:
1210
for _type in _PRIORITY_TYPES:
1211
for _fn, _fl in form_object.fields.items():
1212
if _fn in ("UID", "MID"):
1213
continue1214
if element.fields_map[_fn].gef_type == _type:
1215
_fallback_field = _fl[0]
1216
break1217
if _fallback_field is not None:
1218
break1219
if _fallback_field is not None:
1220
_fallback_field.field_value = "TBD"
1221
1222
form_object.validate(
1223
traceability_index=export_action.traceability_index,
1224
context_document=document,
1225
config=project_config,
1226
existing_revision=0,
1227
)1228
if form_object.any_errors():
1229
error_messages: List[str] = []
1230
for field_errors in form_object.errors.values():
1231
error_messages.extend(field_errors)
1232
for reference_field in form_object.reference_fields:
1233
error_messages.extend(reference_field.validation_messages)
1234
return HTMLResponse(
1235
content=(
1236
error_messages[0]
1237
if len(error_messages) > 0
1238
else "Unable to create this node."
1239
),1240
status_code=422,
1241
)1242
1243
create_command = CreateOrUpdateNodeCommand(
1244
form_object=form_object,
1245
node_info=CreateNodeInfo(
1246
whereto=whereto,
1247
requirement_mid=form_object.requirement_mid,
1248
reference_mid=reference_mid,
1249
),1250
traceability_index=export_action.traceability_index,
1251
project_config=project_config,
1252
)1253
create_command.perform()
1254
1255
write_document_to_file(document)
1256
if document != editing_context_document:
1257
write_document_to_file(editing_context_document)
1258
1259
html_generator.export_single_document_with_performance(
1260
document=document,
1261
traceability_index=export_action.traceability_index,
1262
specific_documents=(DocumentType.DOCUMENT, DocumentType.TABLE),
1263
)1264
if document != editing_context_document:
1265
html_generator.export_single_document_with_performance(
1266
document=editing_context_document,
1267
traceability_index=export_action.traceability_index,
1268
specific_documents=(DocumentType.DOCUMENT, DocumentType.TABLE),
1269
)1270
1271
table_view_object = DocumentScreenViewObject.create_for_table_screen(
1272
document=editing_context_document,
1273
traceability_index=export_action.traceability_index,
1274
project_config=project_config,
1275
html_templates=html_generator.html_templates,
1276
git_client=html_generator.git_client,
1277
jinja_environment=env(),
1278
)1279
output = render_turbo_stream(
1280
content=env().render_template_as_markup(
1281
"screens/document/table/body.jinja",
1282
view_object=table_view_object,
1283
content_entries=list(
1284
table_view_object.document_content_iterator()
1285
),1286
),1287
action="replace",
1288
target="table-content-body",
1289
)1290
output += env().render_template_as_markup(
1291
"actions/document/_shared/stream_updated_toc.jinja.html",
1292
view_object=table_view_object,
1293
)1294
output += render_turbo_stream(
1295
content=(
1296
'<div id="table-add-node-feedback" hidden '1297
f'data-created-node-mid="{form_object.requirement_mid}">'
1298
"</div>"1299
),1300
action="replace",
1301
target="table-add-node-feedback",
1302
)1303
return HTMLResponse(
1304
content=output,
1305
status_code=200,
1306
headers={"Content-Type": "text/vnd.turbo-stream.html"},
1307
)1308
1309
@write_router.post("/actions/table/update_node_field")
1310
def table__update_node_field(
1311
request_form_data: FormData = Depends(parse_form_data),
1312
) -> Response:
1313
request_dict = dict(request_form_data)
1314
node_mid_str: str = request_dict["node_mid"]
1315
field_name: str = request_dict["field_name"]
1316
field_value: str = request_dict.get("field_value", "")
1317
1318
node: SDocNode = export_action.traceability_index.get_node_by_mid(
1319
MID(node_mid_str)
1320
)1321
document = assert_cast(node.get_document(), SDocDocument)
1322
assert document.grammar is not None
1323
grammar: DocumentGrammar = document.grammar
1324
element: GrammarElement = grammar.elements_by_type[node.node_type]
1325
1326
if field_name not in element.fields_map:
1327
return HTMLResponse(
1328
content=f"Unknown field: {field_name}",
1329
status_code=400,
1330
)1331
if element.is_field_multiline(field_name):
1332
return HTMLResponse(
1333
content=f"Field {field_name} is multiline; use the popup editor",
1334
status_code=400,
1335
)1336
1337
sanitized_value: str = sanitize_html_form_field(
1338
field_value, multiline=False
1339
)1340
1341
old_title: Optional[str] = (
1342
node.reserved_title if field_name == "TITLE" else None
1343
)1344
1345
revision: int = revisions[node_mid_str]
1346
form_object: RequirementFormObject = (
1347
RequirementFormObject.create_from_requirement(
1348
requirement=node,
1349
revision=revision,
1350
context_document_mid=document.reserved_mid.get_string_value(),
1351
)1352
)1353
1354
if field_name in form_object.fields:
1355
form_object.fields[field_name][0].field_value = sanitized_value
1356
1357
form_object.validate(
1358
traceability_index=export_action.traceability_index,
1359
context_document=document,
1360
config=project_config,
1361
existing_revision=revision,
1362
)1363
if form_object.any_errors():
1364
first_error = next(iter(form_object.errors.values()))
1365
return HTMLResponse(
1366
content=first_error[0] if first_error else "Validation error",
1367
status_code=422,
1368
)1369
1370
update_command = CreateOrUpdateNodeCommand(
1371
form_object=form_object,
1372
node_info=UpdateNodeInfo(node_to_update=node),
1373
traceability_index=export_action.traceability_index,
1374
project_config=project_config,
1375
)1376
update_command.perform()
1377
write_document_to_file(document)
1378
revisions[node_mid_str] += 1
1379
1380
table_view_object = DocumentScreenViewObject.create_for_table_screen(
1381
document=document,
1382
traceability_index=export_action.traceability_index,
1383
project_config=project_config,
1384
html_templates=html_generator.html_templates,
1385
git_client=html_generator.git_client,
1386
jinja_environment=env(),
1387
)1388
1389
if field_name == "TITLE":
1390
title_presence_changed = bool(old_title) != bool(sanitized_value)
1391
content_entries = (
1392
list(table_view_object.document_content_iterator())
1393
if title_presence_changed
1394
else []
1395
)1396
output = env().render_template_as_markup(
1397
"actions/table/update_node_field/stream_update_title_field.jinja.html",
1398
view_object=table_view_object,
1399
node=node,
1400
field_value=sanitized_value,
1401
title_presence_changed=title_presence_changed,
1402
content_entries=content_entries,
1403
)1404
else:
1405
output = env().render_template_as_markup(
1406
"actions/table/update_node_field/stream_update_node_field.jinja.html",
1407
view_object=table_view_object,
1408
node=node,
1409
field_name=field_name,
1410
field_value=sanitized_value,
1411
)1412
return HTMLResponse(
1413
content=output,
1414
status_code=200,
1415
headers={"Content-Type": "text/vnd.turbo-stream.html"},
1416
)1417
1418
@read_router.get(
1419
"/actions/table/get_node_comments_inline", response_class=Response
1420
)1421
def table__get_node_comments_inline(node_mid: str) -> Response:
1422
node: SDocNode = export_action.traceability_index.get_node_by_mid(
1423
MID(node_mid)
1424
)1425
document = assert_cast(node.get_document(), SDocDocument)
1426
revision: int = revisions[node_mid]
1427
form_object: RequirementFormObject = (
1428
RequirementFormObject.create_from_requirement(
1429
requirement=node,
1430
revision=revision,
1431
context_document_mid=document.reserved_mid.get_string_value(),
1432
)1433
)1434
output = env().render_template_as_markup(
1435
"actions/table/get_node_comments_inline/stream_inline_form.jinja.html",
1436
form_object=form_object,
1437
)1438
return HTMLResponse(
1439
content=output,
1440
status_code=200,
1441
headers={"Content-Type": "text/vnd.turbo-stream.html"},
1442
)1443
1444
@read_router.get(
1445
"/actions/table/get_node_relations_inline", response_class=Response
1446
)1447
def table__get_node_relations_inline(node_mid: str) -> Response:
1448
node: SDocNode = export_action.traceability_index.get_node_by_mid(
1449
MID(node_mid)
1450
)1451
document = assert_cast(node.get_document(), SDocDocument)
1452
revision: int = revisions[node_mid]
1453
form_object: RequirementFormObject = (
1454
RequirementFormObject.create_from_requirement(
1455
requirement=node,
1456
revision=revision,
1457
context_document_mid=document.reserved_mid.get_string_value(),
1458
)1459
)1460
assert document.meta is not None
1461
link_renderer = LinkRenderer(
1462
root_path=document.meta.get_root_path_prefix(),
1463
static_path=project_config.dir_for_sdoc_assets,
1464
)1465
1466
# UIDs of relations explicitly declared on this node in its .sdoc data.1467
own_relation_uids = {
1468
r.ref_uid
1469
for r in node.relations
1470
if hasattr(r, "ref_uid") and r.ref_uid
1471
}1472
# All nodes linked to this node in both directions by the traceability graph.1473
traceability_linked_nodes = (
1474
export_action.traceability_index.get_parent_requirements(node)
1475
+ export_action.traceability_index.get_children_requirements(node)
1476
)1477
# Nodes present in traceability but not declared on this node —1478
# derived connections (e.g. other nodes that reference this one as parent).1479
derived_nodes = [
1480
req1481
for req in traceability_linked_nodes
1482
if req.reserved_uid not in own_relation_uids
1483
]1484
1485
view_object_stub = types.SimpleNamespace(
1486
render_node_link=lambda req: link_renderer.render_node_link(
1487
req, document, DocumentType.DOCUMENT
1488
),1489
)1490
output = env().render_template_as_markup(
1491
"actions/table/get_node_relations_inline/stream_inline_form.jinja.html",
1492
form_object=form_object,
1493
derived_nodes=derived_nodes,
1494
view_object=view_object_stub,
1495
)1496
return HTMLResponse(
1497
content=output,
1498
status_code=200,
1499
headers={"Content-Type": "text/vnd.turbo-stream.html"},
1500
)1501
1502
@read_router.get(
1503
"/actions/table/get_node_autocomplete_inline", response_class=Response
1504
)1505
def table__get_node_autocomplete_inline(
1506
node_mid: str,
1507
field_name: str,
1508
) -> Response:
1509
node: SDocNode = export_action.traceability_index.get_node_by_mid(
1510
MID(node_mid)
1511
)1512
document = assert_cast(node.get_document(), SDocDocument)
1513
assert document.grammar is not None
1514
grammar: DocumentGrammar = document.grammar
1515
element: GrammarElement = grammar.elements_by_type[node.node_type]
1516
1517
if field_name not in element.fields_map:
1518
return HTMLResponse(
1519
content=f"Unknown field: {field_name}", status_code=400
1520
)1521
1522
field: GrammarElementField = element.fields_map[field_name]
1523
is_multiple_choice: bool = field.gef_type in (
1524
RequirementFieldType.MULTIPLE_CHOICE,
1525
RequirementFieldType.TAG,
1526
)1527
1528
current_value: str = ""
1529
if field_name in node.ordered_fields_lookup:
1530
current_value = node.ordered_fields_lookup[field_name][
1531
01532
].get_text_value()
1533
if is_multiple_choice:
1534
# The document may already contain duplicate values (e.g.1535
# hand-edited, or saved before the autocomplete1536
# duplicate-prevention fix). Deduplicate when loading the1537
# value into the table cell's edit mode, same as the1538
# modal requirement-edit form does.1539
current_value = deduplicate_comma_separated_value(current_value)
1540
1541
output = env().render_template_as_markup(
1542
"actions/table/get_node_autocomplete_inline/stream_inline_form.jinja.html",
1543
node_mid=node_mid,
1544
cell_field_name=field_name,
1545
current_value=current_value,
1546
document_mid=document.reserved_mid.get_string_value(),
1547
element_type=node.node_type,
1548
is_multiple_choice=is_multiple_choice,
1549
)1550
return HTMLResponse(
1551
content=output,
1552
status_code=200,
1553
headers={"Content-Type": "text/vnd.turbo-stream.html"},
1554
)1555
1556
@read_router.get(
1557
"/actions/table/get_node_contenteditable_inline",
1558
response_class=Response,
1559
)1560
def table__get_node_contenteditable_inline(
1561
node_mid: str,
1562
field_name: str,
1563
) -> Response:
1564
node: SDocNode = export_action.traceability_index.get_node_by_mid(
1565
MID(node_mid)
1566
)1567
document = assert_cast(node.get_document(), SDocDocument)
1568
assert document.grammar is not None
1569
grammar: DocumentGrammar = document.grammar
1570
element: GrammarElement = grammar.elements_by_type[node.node_type]
1571
1572
if field_name == "TITLE":
1573
current_value = node.reserved_title or ""
1574
elif field_name in node.ordered_fields_lookup:
1575
current_value = node.ordered_fields_lookup[field_name][
1576
01577
].get_text_value()
1578
else:
1579
current_value = ""
1580
1581
if field_name in element.fields_map and element.is_field_multiline(
1582
field_name1583
):1584
field_type = "multiline"
1585
form_action = "/actions/table/update_node_field_multiline"
1586
else:
1587
field_type = "singleline"
1588
form_action = "/actions/table/update_node_field"
1589
1590
output = env().render_template_as_markup(
1591
"actions/table/get_node_contenteditable_inline/stream_inline_form.jinja.html",
1592
node_mid=node_mid,
1593
field_name=field_name,
1594
current_value=current_value,
1595
field_type=field_type,
1596
form_action=form_action,
1597
)1598
return HTMLResponse(
1599
content=output,
1600
status_code=200,
1601
headers={"Content-Type": "text/vnd.turbo-stream.html"},
1602
)1603
1604
_TABLE_DOC_CONFIG_FIELDS = frozenset(
1605
{"TITLE", "UID", "VERSION", "CLASSIFICATION", "PREFIX"}
1606
)1607
1608
@read_router.get(
1609
"/actions/table/get_document_config_field_inline",
1610
response_class=Response,
1611
)1612
def table__get_document_config_field_inline(
1613
document_mid: str,
1614
field_name: str,
1615
) -> Response:
1616
document: SDocDocument = (
1617
export_action.traceability_index.get_node_by_mid(MID(document_mid))
1618
)1619
if not export_action.traceability_index.can_edit_document(document):
1620
raise HTTPException(
1621
status_code=403,
1622
detail="Editing is disabled for autogenerated content.",
1623
)1624
if field_name not in _TABLE_DOC_CONFIG_FIELDS:
1625
raise HTTPException(
1626
status_code=400, detail=f"Unknown field: {field_name}"
1627
)1628
1629
if field_name == "TITLE":
1630
current_value = document.title or ""
1631
elif field_name == "UID":
1632
current_value = document.config.uid or ""
1633
elif field_name == "VERSION":
1634
current_value = document.config.version or ""
1635
elif field_name == "CLASSIFICATION":
1636
current_value = document.config.classification or ""
1637
else: # PREFIX
1638
current_value = document.config.requirement_prefix or ""
1639
1640
output = env().render_template_as_markup(
1641
"actions/table/get_document_config_field_inline/stream_inline_form.jinja.html",
1642
document_mid=document_mid,
1643
field_name=field_name,
1644
current_value=current_value,
1645
)1646
return HTMLResponse(
1647
content=output,
1648
status_code=200,
1649
headers={"Content-Type": "text/vnd.turbo-stream.html"},
1650
)1651
1652
@write_router.post(
1653
"/actions/table/update_document_config_field",
1654
response_class=Response,
1655
)1656
def table__update_document_config_field(
1657
request_form_data: FormData = Depends(parse_form_data),
1658
) -> Response:
1659
request_dict: Dict[str, str] = dict(request_form_data)
1660
document_mid: str = request_dict["document_mid"]
1661
field_name: str = request_dict["field_name"]
1662
field_value: str = request_dict.get("field_value", "")
1663
1664
document: SDocDocument = (
1665
export_action.traceability_index.get_node_by_mid(MID(document_mid))
1666
)1667
if not export_action.traceability_index.can_edit_document(document):
1668
raise HTTPException(
1669
status_code=403,
1670
detail="Editing is disabled for autogenerated content.",
1671
)1672
if field_name not in _TABLE_DOC_CONFIG_FIELDS:
1673
return HTMLResponse(
1674
content=f"Unknown field: {field_name}", status_code=400
1675
)1676
1677
sanitized_value: str = sanitize_html_form_field(
1678
field_value, multiline=False
1679
)1680
1681
form_object: DocumentConfigFormObject = (
1682
DocumentConfigFormObject.create_from_document(document=document)
1683
)1684
if field_name == "TITLE":
1685
form_object.document_title = sanitized_value
1686
elif field_name == "UID":
1687
form_object.document_uid = sanitized_value
1688
elif field_name == "VERSION":
1689
form_object.document_version = sanitized_value
1690
elif field_name == "CLASSIFICATION":
1691
form_object.document_classification = sanitized_value
1692
else: # PREFIX
1693
form_object.document_requirement_prefix = sanitized_value
1694
1695
try:
1696
update_command = UpdateDocumentConfigTransform(
1697
form_object=form_object,
1698
document=document,
1699
traceability_index=export_action.traceability_index,
1700
)1701
update_command.perform()
1702
except MultipleValidationError as validation_error:
1703
errors = validation_error.errors.get(field_name, [])
1704
error_text = "\n".join(errors) if errors else "Validation error"
1705
return HTMLResponse(content=error_text, status_code=422)
1706
1707
write_document_to_file(document)
1708
export_action.traceability_index.update_last_updated()
1709
1710
if field_name == "TITLE":
1711
display_value = document.title or ""
1712
elif field_name == "UID":
1713
display_value = document.config.uid or ""
1714
elif field_name == "VERSION":
1715
display_value = document.config.version or ""
1716
elif field_name == "CLASSIFICATION":
1717
display_value = document.config.classification or ""
1718
else: # PREFIX
1719
display_value = document.config.requirement_prefix or ""
1720
1721
assert document.meta is not None
1722
link_renderer = LinkRenderer(
1723
root_path=document.meta.get_root_path_prefix(),
1724
static_path=project_config.dir_for_sdoc_assets,
1725
)1726
view_object_stub = types.SimpleNamespace(
1727
document=document,
1728
render_local_anchor=link_renderer.render_local_anchor,
1729
)1730
1731
output = env().render_template_as_markup(
1732
"actions/table/update_document_config_field/stream_update.jinja.html",
1733
document=document,
1734
field_name=field_name,
1735
display_value=display_value,
1736
view_object=view_object_stub,
1737
)1738
return HTMLResponse(
1739
content=output,
1740
status_code=200,
1741
headers={"Content-Type": "text/vnd.turbo-stream.html"},
1742
)1743
1744
@read_router.get(
1745
"/actions/table/get_document_custom_meta_inline",
1746
response_class=Response,
1747
)1748
def table__get_document_custom_meta_inline(
1749
document_mid: str,
1750
form_key: str,
1751
field_name: str = "value",
1752
) -> Response:
1753
document: SDocDocument = (
1754
export_action.traceability_index.get_node_by_mid(MID(document_mid))
1755
)1756
if not export_action.traceability_index.can_edit_document(document):
1757
raise HTTPException(
1758
status_code=403,
1759
detail="Editing is disabled for autogenerated content.",
1760
)1761
1762
form_object = DocumentConfigFormObject.create_from_document(
1763
document=document
1764
)1765
# form_key is a table-form transport key, not a StrictDoc MID. Nested1766
# form keys use underscores because parse_form_data only accepts1767
# letters, digits, and underscores in bracketed field-name segments.1768
# The numeric suffix identifies the current row position for this render.1769
form_key_match = re.fullmatch(r"custom_meta_(\d+)", form_key)
1770
if form_key_match is None:
1771
return HTMLResponse(
1772
content=f"Invalid custom metadata form key: {form_key}",
1773
status_code=400,
1774
)1775
metadata_index = int(form_key_match.group(1))
1776
if metadata_index >= len(form_object.custom_metadata_fields):
1777
return HTMLResponse(
1778
content=f"Unknown custom metadata form key: {form_key}",
1779
status_code=404,
1780
)1781
metadata_field = form_object.custom_metadata_fields[metadata_index]
1782
if field_name not in ("name", "value"):
1783
return HTMLResponse(
1784
content=f"Invalid custom metadata field name: {field_name}",
1785
status_code=400,
1786
)1787
1788
output = env().render_template_as_markup(
1789
"actions/table/get_document_custom_meta_inline/stream_inline_form.jinja.html",
1790
form_key=form_key,
1791
field_name=field_name,
1792
field_label=metadata_field.field_name,
1793
field_value=metadata_field.field_value,
1794
errors=[],
1795
name_errors=[],
1796
value_errors=[],
1797
)1798
return HTMLResponse(
1799
content=output,
1800
status_code=200,
1801
headers={"Content-Type": "text/vnd.turbo-stream.html"},
1802
)1803
1804
@read_router.get(
1805
"/actions/table/get_document_custom_meta_new_inline",
1806
response_class=Response,
1807
)1808
def table__get_document_custom_meta_new_inline(
1809
document_mid: str,
1810
) -> Response:
1811
document: SDocDocument = (
1812
export_action.traceability_index.get_node_by_mid(MID(document_mid))
1813
)1814
if not export_action.traceability_index.can_edit_document(document):
1815
raise HTTPException(
1816
status_code=403,
1817
detail="Editing is disabled for autogenerated content.",
1818
)1819
1820
# The new row key is local to the current form render. A separate prefix1821
# lets the POST endpoint distinguish an unsaved Add row from an existing1822
# positional metadata row without introducing a persistent identifier.1823
form_key = (
1824
f"new_custom_meta_{len(document.config.get_custom_metadata())}"
1825
)1826
output = env().render_template_as_markup(
1827
"actions/table/get_document_custom_meta_new_inline/stream_inline_form.jinja.html",
1828
form_key=form_key,
1829
field_name="",
1830
field_value="",
1831
errors=[],
1832
name_errors=[],
1833
value_errors=[],
1834
)1835
return HTMLResponse(
1836
content=output,
1837
status_code=200,
1838
headers={"Content-Type": "text/vnd.turbo-stream.html"},
1839
)1840
1841
@write_router.post(
1842
"/actions/table/update_document_custom_meta",
1843
response_class=Response,
1844
)1845
def table__update_document_custom_meta(
1846
request_form_data: FormData = Depends(parse_form_data),
1847
) -> Response:
1848
request_dict: Dict[str, str] = dict(request_form_data)
1849
document_mid: str = request_dict["document_mid"]
1850
active_form_key: str = request_dict["active_form_key"]
1851
active_field_name: str = request_dict.get("active_field_name", "value")
1852
action: Optional[str] = request_dict.get("action")
1853
document: SDocDocument = (
1854
export_action.traceability_index.get_node_by_mid(MID(document_mid))
1855
)1856
if not export_action.traceability_index.can_edit_document(document):
1857
raise HTTPException(
1858
status_code=403,
1859
detail="Editing is disabled for autogenerated content.",
1860
)1861
1862
form_object: DocumentConfigFormObject = (
1863
DocumentConfigFormObject.create_from_request(
1864
document_mid=document_mid,
1865
request_form_data=request_form_data,
1866
)1867
)1868
is_block_action = action in ("delete", "reorder")
1869
active_metadata_field = None
1870
active_metadata_index = -1
1871
active_field_is_new = False
1872
if not is_block_action:
1873
if active_field_name not in ("name", "value", "new"):
1874
return HTMLResponse(
1875
content=(
1876
"Invalid active custom metadata field name: "1877
f"{active_field_name}"
1878
),1879
status_code=400,
1880
)1881
active_metadata_field = next(
1882
(1883
metadata_field1884
for metadata_field in form_object.custom_metadata_fields
1885
if metadata_field.field_mid == active_form_key
1886
),1887
None,
1888
)1889
if active_metadata_field is None:
1890
return HTMLResponse(
1891
content=(
1892
"Unknown active custom metadata form key: "1893
f"{active_form_key}"
1894
),1895
status_code=400,
1896
)1897
active_metadata_index = form_object.custom_metadata_fields.index(
1898
active_metadata_field1899
)1900
active_field_is_new = active_form_key.startswith("new_custom_meta_")
1901
if (
1902
active_field_is_new1903
and len(active_metadata_field.field_name) == 0
1904
and len(active_metadata_field.field_value) == 0
1905
):1906
# A fully empty Add row is not metadata. Skip it without running1907
# the transform or writing the document; partially filled rows1908
# continue through normal validation.1909
output = env().render_template_as_markup(
1910
"actions/table/update_document_custom_meta/stream_skip_empty_new.jinja.html",
1911
doc_mid=document_mid,
1912
)1913
return HTMLResponse(
1914
content=output,
1915
status_code=200,
1916
headers={"Content-Type": "text/vnd.turbo-stream.html"},
1917
)1918
try:
1919
update_command = UpdateDocumentConfigTransform(
1920
form_object=form_object,
1921
document=document,
1922
traceability_index=export_action.traceability_index,
1923
)1924
update_command.perform()
1925
except MultipleValidationError as validation_error:
1926
if is_block_action:
1927
return HTMLResponse(
1928
content="\n".join(
1929
error1930
for errors in validation_error.errors.values()
1931
for error in errors
1932
),1933
status_code=422,
1934
)1935
for error_key, errors in validation_error.errors.items():
1936
for error in errors:
1937
form_object.add_error(error_key, error)
1938
assert active_metadata_field is not None
1939
active_field_errors = form_object.get_errors(
1940
f"METADATA[{active_form_key}]"
1941
)1942
name_errors = [
1943
error1944
for error in active_field_errors
1945
if error.startswith("Key ")
1946
]1947
value_errors = [
1948
error1949
for error in active_field_errors
1950
if error.startswith("Value ")
1951
]1952
if active_field_is_new:
1953
output = env().render_template_as_markup(
1954
"actions/table/get_document_custom_meta_new_inline/stream_inline_form.jinja.html",
1955
form_key=active_form_key,
1956
field_name=active_metadata_field.field_name,
1957
field_value=active_metadata_field.field_value,
1958
errors=active_field_errors,
1959
name_errors=name_errors,
1960
value_errors=value_errors,
1961
)1962
return HTMLResponse(
1963
content=output,
1964
status_code=422,
1965
headers={"Content-Type": "text/vnd.turbo-stream.html"},
1966
)1967
output = env().render_template_as_markup(
1968
"actions/table/get_document_custom_meta_inline/stream_inline_form.jinja.html",
1969
form_key=active_form_key,
1970
field_name=active_field_name,
1971
field_label=active_metadata_field.field_name,
1972
field_value=active_metadata_field.field_value,
1973
errors=active_field_errors,
1974
name_errors=name_errors,
1975
value_errors=value_errors,
1976
)1977
return HTMLResponse(
1978
content=output,
1979
status_code=422,
1980
headers={"Content-Type": "text/vnd.turbo-stream.html"},
1981
)1982
1983
write_document_to_file(document)
1984
export_action.traceability_index.update_last_updated()
1985
1986
assert document.meta is not None
1987
link_renderer = LinkRenderer(
1988
root_path=document.meta.get_root_path_prefix(),
1989
static_path=project_config.dir_for_sdoc_assets,
1990
)1991
markup_renderer = MarkupRenderer.create(
1992
markup=document.config.get_markup(),
1993
traceability_index=export_action.traceability_index,
1994
link_renderer=link_renderer,
1995
html_templates=html_generator.html_templates,
1996
config=project_config,
1997
context_document=document,
1998
)1999
view_object = DocumentScreenViewObject(
2000
document_type=DocumentType.DOCUMENT,
2001
document=document,
2002
traceability_index=export_action.traceability_index,
2003
project_config=project_config,
2004
link_renderer=link_renderer,
2005
markup_renderer=markup_renderer,
2006
jinja_environment=env(),
2007
git_client=html_generator.git_client,
2008
)2009
if is_block_action:
2010
stream_template = (
2011
"actions/table/update_document_custom_meta/"2012
f"stream_{action}.jinja.html"
2013
)2014
output = env().render_template_as_markup(
2015
stream_template,
2016
doc_mid=document_mid,
2017
document_config=document.config,
2018
view_object=view_object,
2019
)2020
return HTMLResponse(
2021
content=output,
2022
status_code=200,
2023
headers={"Content-Type": "text/vnd.turbo-stream.html"},
2024
)2025
assert active_metadata_field is not None
2026
if active_field_is_new:
2027
# New rows use a distinct transport key while unsaved. Once saved,2028
# normalize it to the positional key used by existing display rows.2029
form_key = f"custom_meta_{active_metadata_index}"
2030
output = env().render_template_as_markup(
2031
"actions/table/update_document_custom_meta/stream_add.jinja.html",
2032
doc_mid=document_mid,
2033
field_content=view_object.render_metadata_value(
2034
active_metadata_field.field_value
2035
),2036
field_label=active_metadata_field.field_name,
2037
field_value=active_metadata_field.field_value,
2038
form_key=form_key,
2039
view_object=view_object,
2040
)2041
return HTMLResponse(
2042
content=output,
2043
status_code=200,
2044
headers={"Content-Type": "text/vnd.turbo-stream.html"},
2045
)2046
output = env().render_template_as_markup(
2047
"actions/table/update_document_custom_meta/stream_update.jinja.html",
2048
active_field_name=active_field_name,
2049
field_content=view_object.render_metadata_value(
2050
active_metadata_field.field_value
2051
),2052
field_label=active_metadata_field.field_name,
2053
field_value=active_metadata_field.field_value,
2054
form_key=active_form_key,
2055
)2056
return HTMLResponse(
2057
content=output,
2058
status_code=200,
2059
headers={"Content-Type": "text/vnd.turbo-stream.html"},
2060
)2061
2062
@write_router.post(
2063
"/actions/table/update_node_field_multiline", response_class=Response
2064
)2065
def table__update_node_field_multiline(
2066
request_form_data: FormData = Depends(parse_form_data),
2067
) -> Response:
2068
request_dict = dict(request_form_data)
2069
node_mid_str: str = request_dict["node_mid"]
2070
field_name: str = request_dict["field_name"]
2071
field_value: str = request_dict.get("field_value", "")
2072
2073
node: SDocNode = export_action.traceability_index.get_node_by_mid(
2074
MID(node_mid_str)
2075
)2076
document = assert_cast(node.get_document(), SDocDocument)
2077
assert document.grammar is not None
2078
grammar: DocumentGrammar = document.grammar
2079
element: GrammarElement = grammar.elements_by_type[node.node_type]
2080
2081
if field_name not in element.fields_map:
2082
return HTMLResponse(
2083
content=f"Unknown field: {field_name}", status_code=400
2084
)2085
if not element.is_field_multiline(field_name):
2086
return HTMLResponse(
2087
content=f"Field {field_name} is not multiline", status_code=400
2088
)2089
2090
sanitized_value: str = sanitize_html_form_field(
2091
field_value, multiline=True
2092
)2093
2094
revision: int = revisions[node_mid_str]
2095
form_object: RequirementFormObject = (
2096
RequirementFormObject.create_from_requirement(
2097
requirement=node,
2098
revision=revision,
2099
context_document_mid=document.reserved_mid.get_string_value(),
2100
)2101
)2102
2103
if field_name in form_object.fields:
2104
form_object.fields[field_name][0].field_value = sanitized_value
2105
2106
form_object.validate(
2107
traceability_index=export_action.traceability_index,
2108
context_document=document,
2109
config=project_config,
2110
existing_revision=revision,
2111
)2112
if form_object.any_errors():
2113
# WIP: error text is collected for future inline error display.2114
field_errors: List[str] = []
2115
for error_list in form_object.errors.values():
2116
field_errors.extend(error_list)
2117
return HTMLResponse(
2118
content="\n".join(field_errors),
2119
status_code=422,
2120
)2121
2122
update_command = CreateOrUpdateNodeCommand(
2123
form_object=form_object,
2124
node_info=UpdateNodeInfo(node_to_update=node),
2125
traceability_index=export_action.traceability_index,
2126
project_config=project_config,
2127
)2128
update_command.perform()
2129
write_document_to_file(document)
2130
revisions[node_mid_str] += 1
2131
2132
assert document.meta is not None
2133
link_renderer = LinkRenderer(
2134
root_path=document.meta.get_root_path_prefix(),
2135
static_path=project_config.dir_for_sdoc_assets,
2136
)2137
markup_renderer = MarkupRenderer.create(
2138
markup=document.config.get_markup(),
2139
traceability_index=export_action.traceability_index,
2140
link_renderer=link_renderer,
2141
html_templates=html_generator.html_templates,
2142
config=project_config,
2143
context_document=document,
2144
)2145
2146
if field_name == element.content_field[0]:
2147
rendered_content = (
2148
markup_renderer.render_node_statement(
2149
DocumentType.DOCUMENT, node
2150
)2151
if node.has_reserved_statement()
2152
else ""
2153
)2154
elif field_name == "RATIONALE":
2155
rendered_content = (
2156
markup_renderer.render_node_rationale(
2157
DocumentType.DOCUMENT, node
2158
)2159
if node.rationale
2160
else ""
2161
)2162
elif field_name in node.ordered_fields_lookup:
2163
node_field = node.ordered_fields_lookup[field_name][0]
2164
rendered_content = markup_renderer.render_node_field(
2165
DocumentType.DOCUMENT, node_field
2166
)2167
else:
2168
rendered_content = ""
2169
2170
output = env().render_template_as_markup(
2171
"actions/table/update_node_field_multiline/stream_update.jinja.html",
2172
node_mid=node_mid_str,
2173
field_name=field_name,
2174
rendered_content=rendered_content,
2175
)2176
return HTMLResponse(
2177
content=output,
2178
status_code=200,
2179
headers={"Content-Type": "text/vnd.turbo-stream.html"},
2180
)2181
2182
@write_router.post(
2183
"/actions/table/update_node_comments", response_class=Response
2184
)2185
def table__update_node_comments(
2186
request_form_data: FormData = Depends(parse_form_data),
2187
) -> Response:
2188
request_dict = dict(request_form_data)
2189
node_mid_str: str = request_dict["requirement_mid"]
2190
node: SDocNode = export_action.traceability_index.get_node_by_mid(
2191
MID(node_mid_str)
2192
)2193
document = assert_cast(node.get_document(), SDocDocument)
2194
2195
form_object: RequirementFormObject = (
2196
RequirementFormObject.create_from_request(
2197
is_new=False,
2198
requirement_mid=node_mid_str,
2199
request_form_data=request_form_data,
2200
document=document,
2201
existing_requirement_uid=node.reserved_uid,
2202
)2203
)2204
existing_revision: int = revisions[node_mid_str]
2205
2206
form_object.validate(
2207
traceability_index=export_action.traceability_index,
2208
context_document=document,
2209
config=project_config,
2210
existing_revision=existing_revision,
2211
)2212
if form_object.any_errors():
2213
error_output = env().render_template_as_markup(
2214
"actions/table/get_node_comments_inline/stream_inline_form.jinja.html",
2215
form_object=form_object,
2216
)2217
return HTMLResponse(
2218
content=error_output,
2219
status_code=422,
2220
headers={"Content-Type": "text/vnd.turbo-stream.html"},
2221
)2222
2223
update_command = CreateOrUpdateNodeCommand(
2224
form_object=form_object,
2225
node_info=UpdateNodeInfo(node_to_update=node),
2226
traceability_index=export_action.traceability_index,
2227
project_config=project_config,
2228
)2229
update_command.perform()
2230
write_document_to_file(document)
2231
revisions[node_mid_str] += 1
2232
2233
assert document.meta is not None
2234
link_renderer = LinkRenderer(
2235
root_path=document.meta.get_root_path_prefix(),
2236
static_path=project_config.dir_for_sdoc_assets,
2237
)2238
markup_renderer = MarkupRenderer.create(
2239
markup=document.config.get_markup(),
2240
traceability_index=export_action.traceability_index,
2241
link_renderer=link_renderer,
2242
html_templates=html_generator.html_templates,
2243
config=project_config,
2244
context_document=document,
2245
)2246
2247
rendered_comments: List[str] = []
2248
if "COMMENT" in node.ordered_fields_lookup:
2249
for comment_field_ in node.ordered_fields_lookup["COMMENT"]:
2250
rendered_comments.append(
2251
markup_renderer.render_node_field(
2252
DocumentType.DOCUMENT, comment_field_
2253
)2254
)2255
2256
output = env().render_template_as_markup(
2257
"actions/table/update_node_comments/stream_update.jinja.html",
2258
node_mid=node_mid_str,
2259
rendered_comments=rendered_comments,
2260
)2261
return HTMLResponse(
2262
content=output,
2263
status_code=200,
2264
headers={"Content-Type": "text/vnd.turbo-stream.html"},
2265
)2266
2267
@write_router.post(
2268
"/actions/table/update_node_relations", response_class=Response
2269
)2270
def table__update_node_relations(
2271
request_form_data: FormData = Depends(parse_form_data),
2272
) -> Response:
2273
request_dict = dict(request_form_data)
2274
node_mid_str: str = request_dict["requirement_mid"]
2275
node: SDocNode = export_action.traceability_index.get_node_by_mid(
2276
MID(node_mid_str)
2277
)2278
document = assert_cast(node.get_document(), SDocDocument)
2279
2280
form_object: RequirementFormObject = (
2281
RequirementFormObject.create_from_request(
2282
is_new=False,
2283
requirement_mid=node_mid_str,
2284
request_form_data=request_form_data,
2285
document=document,
2286
existing_requirement_uid=node.reserved_uid,
2287
)2288
)2289
existing_revision: int = revisions[node_mid_str]
2290
2291
context_document: SDocDocument = (
2292
export_action.traceability_index.get_node_by_mid(
2293
MID(form_object.context_document_mid)
2294
)2295
)2296
2297
form_object.validate(
2298
traceability_index=export_action.traceability_index,
2299
context_document=document,
2300
config=project_config,
2301
existing_revision=existing_revision,
2302
)2303
if form_object.any_errors():
2304
assert document.meta is not None
2305
error_link_renderer = LinkRenderer(
2306
root_path=document.meta.get_root_path_prefix(),
2307
static_path=project_config.dir_for_sdoc_assets,
2308
)2309
own_relation_uids = {
2310
r.ref_uid
2311
for r in node.relations
2312
if hasattr(r, "ref_uid") and r.ref_uid
2313
}2314
traceability_linked_nodes = (
2315
export_action.traceability_index.get_parent_requirements(node)
2316
+ export_action.traceability_index.get_children_requirements(
2317
node2318
)2319
)2320
derived_nodes = [
2321
req2322
for req in traceability_linked_nodes
2323
if req.reserved_uid not in own_relation_uids
2324
]2325
view_object_stub = types.SimpleNamespace(
2326
render_node_link=lambda req: (
2327
error_link_renderer.render_node_link(
2328
req, document, DocumentType.DOCUMENT
2329
)2330
),2331
)2332
error_output = env().render_template_as_markup(
2333
"actions/table/get_node_relations_inline/stream_inline_form.jinja.html",
2334
form_object=form_object,
2335
derived_nodes=derived_nodes,
2336
view_object=view_object_stub,
2337
)2338
return HTMLResponse(
2339
content=error_output,
2340
status_code=422,
2341
headers={"Content-Type": "text/vnd.turbo-stream.html"},
2342
)2343
2344
old_related_uids = {
2345
r.ref_uid
2346
for r in node.relations
2347
if hasattr(r, "ref_uid") and r.ref_uid
2348
}2349
2350
update_command = CreateOrUpdateNodeCommand(
2351
form_object=form_object,
2352
node_info=UpdateNodeInfo(node_to_update=node),
2353
traceability_index=export_action.traceability_index,
2354
project_config=project_config,
2355
)2356
update_command.perform()
2357
write_document_to_file(document)
2358
revisions[node_mid_str] += 1
2359
2360
new_related_uids = {
2361
r.ref_uid
2362
for r in node.relations
2363
if hasattr(r, "ref_uid") and r.ref_uid
2364
}2365
# Linking/unlinking this node also changes the computed Parent/Child2366
# relations shown on the other side of the link, so those rows need2367
# their RELATIONS cell refreshed too.2368
affected_related_nodes = [
2369
related_node2370
for uid in old_related_uids | new_related_uids
2371
if isinstance(
2372
related_node2373
:= export_action.traceability_index.get_node_by_uid_weak(uid),
2374
SDocNode,
2375
)2376
and related_node.reserved_mid != node.reserved_mid
2377
]2378
2379
assert document.meta is not None
2380
link_renderer = LinkRenderer(
2381
root_path=document.meta.get_root_path_prefix(),
2382
static_path=project_config.dir_for_sdoc_assets,
2383
)2384
2385
view_object_stub = types.SimpleNamespace(
2386
traceability_index=export_action.traceability_index,
2387
project_config=project_config,
2388
link_renderer=link_renderer,
2389
render_node_link=lambda req: link_renderer.render_node_link(
2390
req, context_document, DocumentType.DOCUMENT
2391
),2392
)2393
2394
output = env().render_template_as_markup(
2395
"actions/table/update_node_relations/stream_update.jinja.html",
2396
node_mid=node_mid_str,
2397
requirement=node,
2398
affected_related_nodes=affected_related_nodes,
2399
view_object=view_object_stub,
2400
)2401
return HTMLResponse(
2402
content=output,
2403
status_code=200,
2404
headers={"Content-Type": "text/vnd.turbo-stream.html"},
2405
)2406
2407
@read_router.get(
2408
"/actions/document/cancel_new_requirement", response_class=Response
2409
)2410
def cancel_new_requirement(requirement_mid: str) -> Response:
2411
output = env().render_template_as_markup(
2412
"actions/"2413
"document/"2414
"create_requirement/"2415
"stream_cancel_new_requirement.jinja.html",
2416
requirement_mid=requirement_mid,
2417
)2418
return HTMLResponse(
2419
content=output,
2420
status_code=200,
2421
headers={
2422
"Content-Type": "text/vnd.turbo-stream.html",
2423
},2424
)2425
2426
@read_router.get(
2427
"/actions/document/cancel_edit_requirement", response_class=Response
2428
)- "6.3.5. Update node" (REQUIREMENT)
2429
def cancel_edit_requirement(requirement_mid: str) -> Response:
2430
"""
2431
@relation(SDOC-SRS-55, scope=function)2432
"""2433
2434
assert isinstance(requirement_mid, str) and len(requirement_mid) > 0, (
2435
f"{requirement_mid}"
2436
)2437
requirement: SDocNode = (
2438
export_action.traceability_index.get_node_by_mid(
2439
MID(requirement_mid)
2440
)2441
)2442
document: SDocDocument = assert_cast(
2443
requirement.get_document(), SDocDocument
2444
)2445
assert document.meta is not None
2446
link_renderer = LinkRenderer(
2447
root_path=document.meta.get_root_path_prefix(),
2448
static_path=project_config.dir_for_sdoc_assets,
2449
)2450
markup_renderer = MarkupRenderer.create(
2451
markup=document.config.get_markup(),
2452
traceability_index=export_action.traceability_index,
2453
link_renderer=link_renderer,
2454
html_templates=html_generator.html_templates,
2455
config=project_config,
2456
context_document=document,
2457
)2458
view_object = DocumentScreenViewObject(
2459
document_type=DocumentType.DOCUMENT,
2460
document=document,
2461
traceability_index=export_action.traceability_index,
2462
project_config=project_config,
2463
link_renderer=link_renderer,
2464
markup_renderer=markup_renderer,
2465
jinja_environment=env(),
2466
git_client=html_generator.git_client,
2467
)2468
return HTMLResponse(
2469
content=view_object.render_updated_nodes_and_toc(
2470
[requirement], node_updated=False
2471
),2472
headers={
2473
"Content-Type": "text/vnd.turbo-stream.html",
2474
},2475
)2476
2477
@write_router.delete(
2478
"/actions/document/delete_requirement",
2479
response_class=Response,
2480
)2481
def delete_requirement(
2482
node_id: str, context_document_mid: str, confirmed: bool = False
2483
) -> Response:
2484
requirement: SDocNode = (
2485
export_action.traceability_index.get_node_by_mid(MID(node_id))
2486
)2487
if not export_action.traceability_index.can_delete_node(requirement):
2488
raise HTTPException(
2489
status_code=403,
2490
detail="Deleting is disabled for autogenerated content.",
2491
)2492
2493
document: SDocDocument = assert_cast(
2494
requirement.get_document(), SDocDocument
2495
)2496
if not confirmed:
2497
errors: List[str]
2498
try:
2499
delete_command = DeleteRequirementCommand(
2500
requirement=requirement,
2501
traceability_index=export_action.traceability_index,
2502
)2503
delete_command.validate()
2504
errors = []
2505
except MultipleValidationErrorAsList as error_:
2506
errors = error_.errors
2507
2508
output = env().render_template_as_markup(
2509
"actions/document/delete_requirement/"2510
"stream_confirm_delete_requirement.jinja",
2511
requirement_mid=node_id,
2512
context_document_mid=context_document_mid,
2513
errors=errors,
2514
)2515
return HTMLResponse(
2516
content=output,
2517
status_code=200 if len(errors) == 0 else 422,
2518
headers={
2519
"Content-Type": "text/vnd.turbo-stream.html",
2520
},2521
)2522
2523
try:
2524
delete_command = DeleteRequirementCommand(
2525
requirement=requirement,
2526
traceability_index=export_action.traceability_index,
2527
)2528
delete_command.perform()
2529
except MultipleValidationError:
2530
return HTMLResponse(
2531
content="",
2532
status_code=422,
2533
headers={
2534
"Content-Type": "text/vnd.turbo-stream.html",
2535
},2536
)2537
2538
# Saving new content to .SDoc file.2539
write_document_to_file(document)
2540
2541
context_document: SDocDocument = (
2542
export_action.traceability_index.get_node_by_mid(
2543
MID(context_document_mid)
2544
)2545
)2546
2547
# Rendering back the Turbo template.2548
assert document.meta is not None
2549
link_renderer = LinkRenderer(
2550
root_path=document.meta.get_root_path_prefix(),
2551
static_path=project_config.dir_for_sdoc_assets,
2552
)2553
markup_renderer = MarkupRenderer.create(
2554
markup=document.config.get_markup(),
2555
traceability_index=export_action.traceability_index,
2556
link_renderer=link_renderer,
2557
html_templates=html_generator.html_templates,
2558
config=project_config,
2559
context_document=document,
2560
)2561
view_object: DocumentScreenViewObject = DocumentScreenViewObject(
2562
document_type=DocumentType.DOCUMENT,
2563
document=context_document,
2564
traceability_index=export_action.traceability_index,
2565
project_config=project_config,
2566
link_renderer=link_renderer,
2567
markup_renderer=markup_renderer,
2568
jinja_environment=env(),
2569
git_client=html_generator.git_client,
2570
)2571
output = env().render_template_as_markup(
2572
"actions/document/delete_requirement/"2573
"stream_delete_requirement.jinja.html",
2574
view_object=view_object,
2575
)2576
2577
output += env().render_template_as_markup(
2578
"actions/document/_shared/stream_updated_toc.jinja.html",
2579
view_object=view_object,
2580
)2581
2582
output += env().render_template_as_markup(
2583
"actions/document/_shared/stream_updated_viewtype_menu.jinja.html",
2584
view_object=view_object,
2585
)2586
2587
return HTMLResponse(
2588
content=output,
2589
status_code=200,
2590
headers={
2591
"Content-Type": "text/vnd.turbo-stream.html",
2592
},2593
)2594
2595
@write_router.post("/actions/document/move_node", response_class=Response)
- "6.3.9. Move requirement / section nodes within document" (REQUIREMENT)
2596
def move_node(
2597
request_form_data: FormData = Depends(parse_form_data),
2598
) -> Response:
2599
"""
2600
@relation(SDOC-SRS-92, scope=function)2601
"""2602
2603
request_dict: Dict[str, str] = dict(request_form_data)
2604
moved_node_mid: str = request_dict["moved_node_mid"]
2605
target_mid: str = request_dict["target_mid"]
2606
whereto: str = request_dict["whereto"]
2607
2608
assert export_action.traceability_index is not None
2609
2610
moved_node = export_action.traceability_index.get_node_by_mid(
2611
MID(moved_node_mid)
2612
)2613
document: SDocDocument = assert_cast(
2614
moved_node.get_document(), SDocDocument
2615
)2616
target_node = export_action.traceability_index.get_node_by_mid(
2617
MID(target_mid)
2618
)2619
moved_sdoc_node = assert_cast(moved_node, SDocNode)
2620
if not export_action.traceability_index.can_move_node_to(
2621
moved_sdoc_node, target_node, whereto
2622
):2623
raise HTTPException(
2624
status_code=403,
2625
detail="Moving is disabled for autogenerated content.",
2626
)2627
2628
current_parent_node = moved_node.parent
2629
2630
# Currently UI allows a child-like drag-and-drop on a leaf (non-composite) node.2631
# In that case, we make it add a node **after** the target node2632
# (not as its child because that's not possible).2633
if (
2634
whereto == NodeCreationOrder.CHILD
2635
and isinstance(target_node, SDocNode)
2636
and not target_node.is_composite
2637
):2638
whereto = NodeCreationOrder.AFTER
2639
2640
if whereto == NodeCreationOrder.CHILD:
2641
# Disconnect the moved_node from its parent.2642
current_parent_node.section_contents.remove(moved_node)
2643
# Append to the end of child list.2644
target_node.section_contents.append(moved_node)
2645
moved_node.parent = target_node
2646
elif whereto == NodeCreationOrder.BEFORE:
2647
# Disconnect the moved_node from its parent.2648
current_parent_node.section_contents.remove(moved_node)
2649
# Append before.2650
insert_to_idx = target_node.parent.section_contents.index(
2651
target_node2652
)2653
target_node.parent.section_contents.insert(
2654
insert_to_idx, moved_node
2655
)2656
moved_node.parent = target_node.parent
2657
elif whereto == NodeCreationOrder.AFTER:
2658
# Disconnect the moved_node from its parent.2659
current_parent_node.section_contents.remove(moved_node)
2660
# Append after.2661
insert_to_idx = target_node.parent.section_contents.index(
2662
target_node2663
)2664
target_node.parent.section_contents.insert(
2665
insert_to_idx + 1, moved_node
2666
)2667
moved_node.parent = target_node.parent
2668
else:
2669
raise NotImplementedError
2670
2671
# Saving new content to .SDoc file.2672
write_document_to_file(document)
2673
2674
# Update the index because other documents might reference this2675
# document's sections. These documents will be regenerated on demand,2676
# when they are opened next time.2677
export_action.traceability_index.update_last_updated()
2678
2679
assert document.meta is not None
2680
link_renderer = LinkRenderer(
2681
root_path=document.meta.get_root_path_prefix(),
2682
static_path=project_config.dir_for_sdoc_assets,
2683
)2684
markup_renderer = MarkupRenderer.create(
2685
markup=document.config.get_markup(),
2686
traceability_index=export_action.traceability_index,
2687
link_renderer=link_renderer,
2688
html_templates=html_generator.html_templates,
2689
config=project_config,
2690
context_document=document,
2691
)2692
view_object = DocumentScreenViewObject(
2693
document_type=DocumentType.DOCUMENT,
2694
document=document,
2695
traceability_index=export_action.traceability_index,
2696
project_config=project_config,
2697
link_renderer=link_renderer,
2698
markup_renderer=markup_renderer,
2699
jinja_environment=env(),
2700
git_client=html_generator.git_client,
2701
)2702
return HTMLResponse(
2703
content=view_object.render_update_document_content_with_moved_node(
2704
moved_node2705
),2706
headers={
2707
"Content-Type": "text/vnd.turbo-stream.html",
2708
},2709
)2710
2711
@read_router.get(
2712
"/actions/project_index/new_document", response_class=Response
2713
)2714
def get_new_document() -> Response:
2715
"""
2716
@relation(SDOC-SRS-107, scope=function)2717
"""2718
2719
output = env().render_template_as_markup(
2720
"actions/project_index/stream_new_document.jinja.html",
2721
error_object=ErrorObject(),
2722
document_title="",
2723
document_path="",
2724
include_doc_paths=project_config.include_doc_paths,
2725
)2726
return HTMLResponse(
2727
content=output,
2728
headers={
2729
"Content-Type": "text/vnd.turbo-stream.html",
2730
},2731
)2732
2733
@read_router.get(
2734
"/actions/project_index/edit_project_title_form",
2735
response_class=Response,
2736
)2737
def get_edit_project_title_form() -> Response:
2738
error_object = ErrorObject()
2739
output = env().render_template_as_markup(
2740
"actions/project_index/edit_project_title/"2741
"stream_form_edit_project_title.jinja.html",
2742
error_object=error_object,
2743
project_config=project_config,
2744
)2745
return HTMLResponse(
2746
content=output,
2747
headers={
2748
"Content-Type": "text/vnd.turbo-stream.html",
2749
},2750
)2751
2752
@write_router.post(
2753
"/actions/project_index/save_project_title", response_class=Response
2754
)2755
def save_project_title(project_title: str = Form("")) -> Response:
2756
error_object = ErrorObject()
2757
2758
new_title = project_title.strip() if project_title is not None else ""
2759
if len(new_title) == 0:
2760
error_object.add_error(
2761
"project_title", "Project title must not be empty."
2762
)2763
2764
if error_object.any_errors():
2765
output = env().render_template_as_markup(
2766
"actions/project_index/edit_project_title/"2767
"stream_form_edit_project_title.jinja.html",
2768
error_object=error_object,
2769
project_config=project_config,
2770
new_title=new_title,
2771
)2772
return HTMLResponse(
2773
content=output,
2774
status_code=200,
2775
headers={
2776
"Content-Type": "text/vnd.turbo-stream.html",
2777
},2778
)2779
2780
# Try to persist the new title into the project configuration when available.2781
project_root = project_config.get_project_root_path()
2782
config_toml_path: Optional[str] = None
2783
config_py_path: Optional[str] = None
2784
2785
if os.path.isdir(project_root):
2786
# Prefer Python config when both exist.2787
candidate_py = os.path.join(project_root, "strictdoc_config.py")
2788
candidate_toml = os.path.join(project_root, "strictdoc.toml")
2789
if os.path.isfile(candidate_py):
2790
config_py_path = candidate_py
2791
elif os.path.isfile(candidate_toml):
2792
config_toml_path = candidate_toml
2793
else:
2794
# project_root may point directly to a config file or to an2795
# input path next to the config files.2796
if project_root.endswith("strictdoc.toml"):
2797
config_toml_path = project_root
2798
elif project_root.endswith("strictdoc_config.py"):
2799
config_py_path = project_root
2800
else:
2801
config_dir = os.path.dirname(project_root)
2802
candidate_py = os.path.join(config_dir, "strictdoc_config.py")
2803
candidate_toml = os.path.join(config_dir, "strictdoc.toml")
2804
if os.path.isfile(candidate_py):
2805
config_py_path = candidate_py
2806
elif os.path.isfile(candidate_toml):
2807
config_toml_path = candidate_toml
2808
2809
# strictdoc.toml is not supported anymore.2810
if config_toml_path is not None:
2811
error_object = ErrorObject()
2812
2813
error_object.add_error(
2814
"project_title",
2815
"Renaming project title is not supported with TOML config files. Switch from strictdoc_config.toml to strictdoc_config.py and try again.",
2816
)2817
2818
output = env().render_template_as_markup(
2819
"actions/project_index/edit_project_title/"2820
"stream_form_edit_project_title.jinja.html",
2821
error_object=error_object,
2822
project_config=project_config,
2823
new_title=new_title,
2824
)2825
return HTMLResponse(
2826
content=output,
2827
status_code=400,
2828
headers={
2829
"Content-Type": "text/vnd.turbo-stream.html",
2830
},2831
)2832
2833
# Update strictdoc_config.py by editing its title using regex.2834
# The implementation is pretty hacky but should work for now.2835
if config_py_path is not None:
2836
with open(config_py_path, encoding="utf8") as config_file:
2837
config_text = config_file.read()
2838
2839
pattern = re.compile(
2840
r"(project_title\s*=\s*)([\"'])(.*?)([\"'])",
2841
re.DOTALL,
2842
)2843
2844
def _replace_title(match: re.Match[str]) -> str:
2845
prefix = match.group(1)
2846
quote = match.group(2)
2847
escaped_title = new_title.replace(quote, "\\" + quote)
2848
return f"{prefix}{quote}{escaped_title}{quote}"
2849
2850
new_text, count = pattern.subn(_replace_title, config_text, count=1)
2851
2852
if count > 0:
2853
with open(config_py_path, "w", encoding="utf8") as config_file:
2854
config_file.write(new_text)
2855
else:
2856
error_object = ErrorObject()
2857
2858
error_object.add_error(
2859
"project_title",
2860
(2861
"Renaming project title is not supported when a title is "2862
"not already configured to a previous value in"2863
"strictdoc_config.py."2864
),2865
)2866
2867
output = env().render_template_as_markup(
2868
"actions/project_index/edit_project_title/"2869
"stream_form_edit_project_title.jinja.html",
2870
error_object=error_object,
2871
project_config=project_config,
2872
new_title=new_title,
2873
)2874
return HTMLResponse(
2875
content=output,
2876
status_code=400,
2877
headers={
2878
"Content-Type": "text/vnd.turbo-stream.html",
2879
},2880
)2881
2882
# Update in-memory project configuration after successful validation2883
# of where the title can be stored on disk.2884
project_config.project_title = new_title
2885
2886
# This ensures that the cached project index HTML page is invalidated.2887
export_action.traceability_index.update_last_updated()
2888
2889
# Return Turbo Streams to update the header title and close the modal.2890
output = env().render_template_as_markup(
2891
"actions/project_index/edit_project_title/"2892
"stream_save_project_title.jinja.html",
2893
project_config=project_config,
2894
)2895
return HTMLResponse(
2896
content=output,
2897
status_code=200,
2898
headers={
2899
"Content-Type": "text/vnd.turbo-stream.html",
2900
},2901
)2902
2903
@write_router.post(
2904
"/actions/project_index/create_document", response_class=Response
2905
)- "6.2.2. Create document" (REQUIREMENT)
2906
def document_tree__create_document(
2907
document_title: str = Form(""),
2908
document_path: str = Form(""),
2909
) -> Response:
2910
"""
2911
@relation(SDOC-SRS-107, scope=function)2912
"""2913
2914
error_object = ErrorObject()
2915
if document_title is None or len(document_title) == 0:
2916
error_object.add_error(
2917
"document_title", "Document title must not be empty."
2918
)2919
if document_path is None or len(document_path) == 0:
2920
error_object.add_error(
2921
"document_path", "Document path must not be empty."
2922
)2923
else:
2924
document_path = document_path.strip().lstrip("/")
2925
if not is_safe_alphanumeric_string(document_path):
2926
error_object.add_error(
2927
"document_path",
2928
(2929
"Document path must be relative and only contain "2930
"slashes, alphanumeric characters, "2931
"and underscore symbols."2932
),2933
)2934
2935
if project_config.include_doc_paths is not None:
2936
path_filter_includes = PathFilter(
2937
project_config.include_doc_paths, positive_or_negative=True
2938
)2939
if not path_filter_includes.match(document_path):
2940
error_object.add_error(
2941
"document_path",
2942
(2943
"Document path is not a valid path according to "2944
"the project config's setting 'include_doc_paths': "2945
f"{project_config.include_doc_paths}."
2946
),2947
)2948
if project_config.exclude_doc_paths is not None:
2949
path_filter_excludes = PathFilter(
2950
project_config.exclude_doc_paths, positive_or_negative=False
2951
)2952
if path_filter_excludes.match(document_path):
2953
error_object.add_error(
2954
"document_path",
2955
(2956
"Document path is not a valid path according to "2957
"the project config's setting 'exclude_doc_paths': "2958
f"{project_config.exclude_doc_paths}."
2959
),2960
)2961
2962
if error_object.any_errors():
2963
output = env().render_template_as_markup(
2964
"actions/project_index/stream_new_document.jinja.html",
2965
error_object=error_object,
2966
document_title=document_title
2967
if document_title is not None
2968
else "",
2969
document_path=document_path
2970
if document_path is not None
2971
else "",
2972
include_doc_paths=project_config.include_doc_paths,
2973
)2974
return HTMLResponse(
2975
content=output,
2976
status_code=200,
2977
headers={
2978
"Content-Type": "text/vnd.turbo-stream.html",
2979
},2980
)2981
2982
if not document_path.endswith(".sdoc"):
2983
document_path = document_path + ".sdoc"
2984
2985
assert isinstance(project_config.input_paths, list)
2986
full_input_path = os.path.abspath(project_config.input_paths[0])
2987
file_tree_mount_folder = os.path.basename(
2988
os.path.dirname(full_input_path)
2989
)2990
doc_full_path = os.path.join(full_input_path, document_path)
2991
doc_full_path_dir = os.path.dirname(doc_full_path)
2992
document_file_name = os.path.basename(doc_full_path)
2993
input_doc_dir_rel_path = os.path.dirname(document_path)
2994
input_doc_assets_dir_rel_path = (
2995
"/".join(
2996
(2997
file_tree_mount_folder,
2998
input_doc_dir_rel_path,
2999
"_assets",
3000
)3001
)3002
if len(input_doc_dir_rel_path) > 0
3003
else "/".join((file_tree_mount_folder, "_assets"))
3004
)3005
3006
Path(doc_full_path_dir).mkdir(parents=True, exist_ok=True)
3007
document = SDocDocument(
3008
mid=None,
3009
title=document_title,
3010
config=None,
3011
view=None,
3012
grammar=DocumentGrammar.create_default(parent=None),
3013
section_contents=[],
3014
)3015
# FIXME: Fill in the document meta correctly.3016
document.meta = DocumentMeta(
3017
level=0,
3018
file_tree_mount_folder="NOT_RELEVANT",
3019
document_filename=document_file_name,
3020
document_filename_base="NOT_RELEVANT",
3021
input_doc_full_path=doc_full_path,
3022
input_doc_rel_path=SDocRelativePath(document_path),
3023
input_doc_dir_rel_path=SDocRelativePath(input_doc_dir_rel_path),
3024
input_doc_assets_dir_rel_path=SDocRelativePath(
3025
input_doc_assets_dir_rel_path3026
),3027
output_document_dir_full_path="NOT_RELEVANT",
3028
output_document_dir_rel_path=SDocRelativePath("FIXME"),
3029
)3030
3031
write_document_to_file(document)
3032
3033
export_action.build_index()
3034
export_action.export()
3035
3036
view_object = ProjectTreeViewObject(
3037
traceability_index=export_action.traceability_index,
3038
project_config=project_config,
3039
)3040
output = env().render_template_as_markup(
3041
"actions/project_index/stream_create_document.jinja.html",
3042
view_object=view_object,
3043
)3044
return HTMLResponse(
3045
content=output,
3046
status_code=200,
3047
headers={
3048
"Content-Type": "text/vnd.turbo-stream.html",
3049
},3050
)3051
3052
@write_router.delete(
3053
"/actions/document/delete_document",
3054
response_class=Response,
3055
)3056
def delete_document(document_mid: str, confirmed: bool = False) -> Response:
3057
"""
3058
Delete an entire SDOC document from the project.3059
3060
This endpoint is intentionally simple: it removes the underlying3061
``.sdoc`` file from disk, rebuilds the index and redirects back to the3062
project index screen. For now, it is up to the user to ensure that3063
no other documents depend on this one (for example via ``INCLUDE``).3064
"""3065
3066
document: SDocDocument = assert_cast(
3067
export_action.traceability_index.get_node_by_mid(MID(document_mid)),
3068
SDocDocument,
3069
)3070
if not export_action.traceability_index.can_delete_node(document):
3071
raise HTTPException(
3072
status_code=403,
3073
detail="Deleting is disabled for autogenerated content.",
3074
)3075
3076
assert document.meta is not None
3077
3078
errors: List[str] = []
3079
try:
3080
export_action.traceability_index.validate_can_remove_document(
3081
document3082
)3083
except MultipleValidationErrorAsList as error_:
3084
errors = error_.errors
3085
3086
if not confirmed:
3087
output = env().render_template_as_markup(
3088
"actions/document/delete_document/"3089
"stream_confirm_delete_document.jinja",
3090
document_mid=document_mid,
3091
errors=errors,
3092
)3093
return HTMLResponse(
3094
content=output,
3095
status_code=200 if len(errors) == 0 else 422,
3096
headers={
3097
"Content-Type": "text/vnd.turbo-stream.html",
3098
},3099
)3100
3101
if len(errors) > 0:
3102
output = env().render_template_as_markup(
3103
"actions/document/delete_document/"3104
"stream_confirm_delete_document.jinja",
3105
document_mid=document_mid,
3106
errors=errors,
3107
)3108
return HTMLResponse(
3109
content=output,
3110
status_code=422,
3111
headers={
3112
"Content-Type": "text/vnd.turbo-stream.html",
3113
},3114
)3115
3116
# Remove the underlying SDOC file.3117
path_to_document = document.meta.input_doc_full_path
3118
try:
3119
if os.path.exists(path_to_document):
3120
os.remove(path_to_document)
3121
except OSError:
3122
# If the file cannot be removed, keep the project index intact and3123
# fall back to a normal redirect; the error can be inspected in3124
# server logs.3125
pass3126
3127
# Best-effort cleanup of generated HTML artifacts for this document.3128
# Not all of these files are guaranteed to exist (e.g. PDF export).3129
html_paths = [
3130
document.meta.get_html_doc_path(),
3131
document.meta.get_html_table_path(),
3132
document.meta.get_html_traceability_path(),
3133
document.meta.get_html_deep_traceability_path(),
3134
document.meta.get_html_pdf_path(),
3135
]3136
for html_path in html_paths:
3137
try:
3138
if os.path.exists(html_path):
3139
os.remove(html_path)
3140
except OSError:
3141
# Ignore individual file deletion errors; remaining files can3142
# be cleaned up manually if necessary.3143
continue3144
3145
# Rebuild the project index so the removed document disappears from3146
# the project tree and related views.3147
export_action.build_index()
3148
export_action.export()
3149
3150
# Redirect back to the project index page.3151
return RedirectResponse("/", status_code=303)
3152
3153
@read_router.get("/actions/document/new_comment", response_class=Response)
3154
def document__add_comment(
3155
requirement_mid: str,
3156
document_mid: str,
3157
context_document_mid: str,
3158
element_type: str,
3159
revision: str,
3160
) -> Response:
3161
document: SDocDocument = (
3162
export_action.traceability_index.get_node_by_mid(MID(document_mid))
3163
)3164
assert document.grammar is not None
3165
grammar: DocumentGrammar = document.grammar
3166
# The data of the form object is ignored. What matters is the comment3167
# form data.3168
output = env().render_template_as_markup(
3169
"actions/"3170
"document/"3171
"add_requirement_comment/"3172
"stream_add_requirement_comment.jinja.html",
3173
requirement_mid=requirement_mid,
3174
form_object=RequirementFormObject(
3175
is_new=False,
3176
element_type=element_type,
3177
revision=int(revision),
3178
requirement_mid=requirement_mid,
3179
document_mid=document.reserved_mid,
3180
context_document_mid=context_document_mid,
3181
fields=[],
3182
reference_fields=[],
3183
existing_requirement_uid=None,
3184
grammar=grammar,
3185
relation_types=[],
3186
),3187
field=RequirementFormField(
3188
field_mid=MID.create(),
3189
field_name="COMMENT",
3190
field_type=RequirementFormFieldType.MULTILINE,
3191
field_value="",
3192
),3193
)3194
return HTMLResponse(
3195
content=output,
3196
status_code=200,
3197
headers={
3198
"Content-Type": "text/vnd.turbo-stream.html",
3199
},3200
)3201
3202
@read_router.get("/actions/document/new_relation", response_class=Response)
3203
def document__add_relation(
3204
requirement_mid: str,
3205
document_mid: str,
3206
context_document_mid: str,
3207
element_type: str,
3208
revision: str,
3209
) -> Response:
3210
document: SDocDocument = (
3211
export_action.traceability_index.get_node_by_mid(MID(document_mid))
3212
)3213
assert document.grammar is not None
3214
grammar: DocumentGrammar = document.grammar
3215
3216
element: GrammarElement = grammar.elements_by_type[element_type]
3217
grammar_element_relations = element.get_relation_types()
3218
3219
# The data of the form object is ignored. What matters is the relation3220
# form data.3221
output = env().render_template_as_markup(
3222
"actions/"3223
"document/"3224
"add_requirement_relation/"3225
"stream_add_requirement_relation.jinja.html",
3226
requirement_mid=requirement_mid,
3227
form_object=RequirementFormObject(
3228
is_new=False,
3229
element_type=element_type,
3230
revision=int(revision),
3231
requirement_mid=requirement_mid,
3232
document_mid=document_mid,
3233
context_document_mid=context_document_mid,
3234
fields=[],
3235
reference_fields=[],
3236
existing_requirement_uid=None,
3237
grammar=grammar,
3238
relation_types=grammar_element_relations,
3239
),3240
field=RequirementReferenceFormField(
3241
field_mid=MID.create(),
3242
field_type=RequirementReferenceFormField.FieldType.PARENT,
3243
field_value="",
3244
field_role="",
3245
# Mark as new so that an empty UID is silently discarded on save.3246
is_new=True,
3247
),3248
relation_types=grammar_element_relations,
3249
)3250
return HTMLResponse(
3251
content=output,
3252
status_code=200,
3253
headers={
3254
"Content-Type": "text/vnd.turbo-stream.html",
3255
},3256
)3257
3258
@read_router.get("/actions/document/edit_config", response_class=Response)
- "6.3.11. Edit Document options" (REQUIREMENT)
3259
def document__edit_config(document_mid: str) -> Response:
3260
"""
3261
@relation(SDOC-SRS-57, scope=function)3262
"""3263
3264
document: SDocDocument = (
3265
export_action.traceability_index.get_node_by_mid(MID(document_mid))
3266
)3267
if not export_action.traceability_index.can_edit_document(document):
3268
raise HTTPException(
3269
status_code=403,
3270
detail="Editing is disabled for autogenerated content.",
3271
)3272
3273
form_object = DocumentConfigFormObject.create_from_document(
3274
document=document
3275
)3276
3277
output = env().render_template_as_markup(
3278
"actions/"3279
"document/"3280
"edit_document_config/"3281
"stream_edit_document_config.jinja.html",
3282
form_object=form_object,
3283
document=document,
3284
)3285
return HTMLResponse(
3286
content=output,
3287
status_code=200,
3288
headers={
3289
"Content-Type": "text/vnd.turbo-stream.html",
3290
},3291
)3292
3293
@read_router.get("/actions/document/new_metadata", response_class=Response)
3294
def document__add_metadata(
3295
document_mid: str,
3296
) -> Response:
3297
document: SDocDocument = (
3298
export_action.traceability_index.get_node_by_mid(MID(document_mid))
3299
)3300
if not export_action.traceability_index.can_edit_document(document):
3301
raise HTTPException(
3302
status_code=403,
3303
detail="Editing is disabled for autogenerated content.",
3304
)3305
3306
assert document.grammar is not None
3307
3308
form_object = DocumentConfigFormObject.create_from_document(
3309
document=document
3310
)3311
3312
output = env().render_template_as_markup(
3313
"actions/"3314
"document/"3315
"add_document_metadata/"3316
"stream_add_document_metadata.jinja.html",
3317
form_object=form_object,
3318
field=DocumentMetadataFormField(
3319
field_mid=MID.create(),
3320
field_name="",
3321
field_value="",
3322
),3323
)3324
return HTMLResponse(
3325
content=output,
3326
status_code=200,
3327
headers={
3328
"Content-Type": "text/vnd.turbo-stream.html",
3329
},3330
)3331
3332
@read_router.get(
3333
"/actions/document/edit_included_document", response_class=Response
3334
)3335
def document__edit_included_document(
3336
document_mid: str, context_document_mid: str
3337
) -> Response:
3338
document: SDocDocument = (
3339
export_action.traceability_index.get_node_by_mid(MID(document_mid))
3340
)3341
form_object = IncludedDocumentFormObject.create_from_document(
3342
document=document,
3343
context_document_mid=context_document_mid,
3344
jinja_environment=env(),
3345
)3346
return HTMLResponse(
3347
content=form_object.render_edit_form(),
3348
status_code=200,
3349
headers={
3350
"Content-Type": "text/vnd.turbo-stream.html",
3351
},3352
)3353
3354
@write_router.post("/actions/document/save_config", response_class=Response)
- "6.3.11. Edit Document options" (REQUIREMENT)
3355
def document__save_edit_config(
3356
request_form_data: FormData = Depends(parse_form_data),
3357
) -> Response:
3358
"""
3359
@relation(SDOC-SRS-57, scope=function)3360
"""3361
3362
request_dict: Dict[str, str] = dict(request_form_data)
3363
document_mid: str = request_dict["document_mid"]
3364
document: SDocDocument = (
3365
export_action.traceability_index.get_node_by_mid(MID(document_mid))
3366
)3367
if not export_action.traceability_index.can_edit_document(document):
3368
raise HTTPException(
3369
status_code=403,
3370
detail="Editing is disabled for autogenerated content.",
3371
)3372
3373
form_object: DocumentConfigFormObject = (
3374
DocumentConfigFormObject.create_from_request(
3375
document_mid=document_mid,
3376
request_form_data=request_form_data,
3377
)3378
)3379
try:
3380
update_command = UpdateDocumentConfigTransform(
3381
form_object=form_object,
3382
document=document,
3383
traceability_index=export_action.traceability_index,
3384
)3385
update_command.perform()
3386
except MultipleValidationError as validation_error:
3387
for error_key, errors in validation_error.errors.items():
3388
for error in errors:
3389
form_object.add_error(error_key, error)
3390
html_output = env().render_template_as_markup(
3391
"actions/"3392
"document/"3393
"edit_document_config/"3394
"stream_edit_document_config.jinja.html",
3395
form_object=form_object,
3396
document=document,
3397
)3398
return HTMLResponse(
3399
content=html_output,
3400
status_code=422,
3401
headers={
3402
"Content-Type": "text/vnd.turbo-stream.html",
3403
},3404
)3405
3406
# Re-generate the document's SDOC.3407
write_document_to_file(document)
3408
3409
# Update the index because other documents might be referenced by this3410
# document's free text. These documents will be regenerated on demand,3411
# when they are opened next time.3412
export_action.traceability_index.update_last_updated()
3413
3414
assert document.meta is not None
3415
link_renderer = LinkRenderer(
3416
root_path=document.meta.get_root_path_prefix(),
3417
static_path=project_config.dir_for_sdoc_assets,
3418
)3419
markup_renderer = MarkupRenderer.create(
3420
markup=document.config.get_markup(),
3421
traceability_index=export_action.traceability_index,
3422
link_renderer=link_renderer,
3423
html_templates=html_generator.html_templates,
3424
config=project_config,
3425
context_document=document,
3426
)3427
view_object = DocumentScreenViewObject(
3428
document_type=DocumentType.DOCUMENT,
3429
document=document,
3430
traceability_index=export_action.traceability_index,
3431
project_config=project_config,
3432
link_renderer=link_renderer,
3433
markup_renderer=markup_renderer,
3434
jinja_environment=env(),
3435
git_client=html_generator.git_client,
3436
)3437
html_output = env().render_template_as_markup(
3438
"actions/"3439
"document/"3440
"edit_document_config/"3441
"stream_save_document_config.jinja.html",
3442
view_object=view_object,
3443
)3444
return HTMLResponse(
3445
content=html_output,
3446
status_code=200,
3447
headers={
3448
"Content-Type": "text/vnd.turbo-stream.html",
3449
},3450
)3451
3452
@write_router.post(
3453
"/actions/document/save_included_document", response_class=Response
3454
)3455
def document__save_included_document(
3456
request_form_data: FormData = Depends(parse_form_data),
3457
) -> Response:
3458
request_dict: Dict[str, str] = dict(request_form_data)
3459
document_mid: str = request_dict["document_mid"]
3460
context_document_mid: str = request_dict["context_document_mid"]
3461
document: SDocDocument = (
3462
export_action.traceability_index.get_node_by_mid(MID(document_mid))
3463
)3464
context_document: SDocDocument = (
3465
export_action.traceability_index.get_node_by_mid(
3466
MID(context_document_mid)
3467
)3468
)3469
form_object: IncludedDocumentFormObject = (
3470
IncludedDocumentFormObject.create_from_request(
3471
request_form_data=request_form_data, jinja_environment=env()
3472
)3473
)3474
try:
3475
update_command = UpdateIncludedDocumentTransform(
3476
form_object=form_object,
3477
document=document,
3478
traceability_index=export_action.traceability_index,
3479
)3480
update_command.perform()
3481
except MultipleValidationError as validation_error:
3482
for error_key, errors in validation_error.errors.items():
3483
for error in errors:
3484
form_object.add_error(error_key, error)
3485
return HTMLResponse(
3486
content=form_object.render_edit_form(),
3487
status_code=422,
3488
headers={
3489
"Content-Type": "text/vnd.turbo-stream.html",
3490
},3491
)3492
3493
# Re-generate the document's SDOC.3494
write_document_to_file(document)
3495
3496
# Update the index because other documents might be referenced by this3497
# document's free text. These documents will be regenerated on demand,3498
# when they are opened next time.3499
export_action.traceability_index.update_last_updated()
3500
3501
assert document.meta is not None
3502
link_renderer = LinkRenderer(
3503
root_path=document.meta.get_root_path_prefix(),
3504
static_path=project_config.dir_for_sdoc_assets,
3505
)3506
markup_renderer = MarkupRenderer.create(
3507
markup=document.config.get_markup(),
3508
traceability_index=export_action.traceability_index,
3509
link_renderer=link_renderer,
3510
html_templates=html_generator.html_templates,
3511
config=project_config,
3512
context_document=document,
3513
)3514
view_object = DocumentScreenViewObject(
3515
document_type=DocumentType.DOCUMENT,
3516
document=context_document,
3517
traceability_index=export_action.traceability_index,
3518
project_config=project_config,
3519
link_renderer=link_renderer,
3520
markup_renderer=markup_renderer,
3521
jinja_environment=env(),
3522
git_client=html_generator.git_client,
3523
)3524
return HTMLResponse(
3525
content=view_object.render_updated_nodes_and_toc(
3526
nodes=[document], node_updated=True
3527
),3528
status_code=200,
3529
headers={
3530
"Content-Type": "text/vnd.turbo-stream.html",
3531
},3532
)3533
3534
@read_router.get(
3535
"/actions/document/cancel_edit_config", response_class=Response
3536
)- "6.3.11. Edit Document options" (REQUIREMENT)
3537
def document__cancel_edit_config(document_mid: str) -> Response:
3538
"""
3539
@relation(SDOC-SRS-57, scope=function)3540
"""3541
3542
document: SDocDocument = (
3543
export_action.traceability_index.get_node_by_mid(MID(document_mid))
3544
)3545
assert document.meta is not None
3546
link_renderer = LinkRenderer(
3547
root_path=document.meta.get_root_path_prefix(),
3548
static_path=project_config.dir_for_sdoc_assets,
3549
)3550
markup_renderer = MarkupRenderer.create(
3551
markup=document.config.get_markup(),
3552
traceability_index=export_action.traceability_index,
3553
link_renderer=link_renderer,
3554
html_templates=html_generator.html_templates,
3555
config=project_config,
3556
context_document=document,
3557
)3558
view_object = DocumentScreenViewObject(
3559
document_type=DocumentType.DOCUMENT,
3560
document=document,
3561
traceability_index=export_action.traceability_index,
3562
project_config=project_config,
3563
link_renderer=link_renderer,
3564
markup_renderer=markup_renderer,
3565
jinja_environment=env(),
3566
git_client=html_generator.git_client,
3567
)3568
output = env().render_template_as_markup(
3569
"actions/"3570
"document/"3571
"edit_document_config/"3572
"stream_cancel_edit_document_config.jinja.html",
3573
view_object=view_object,
3574
document=document,
3575
)3576
return HTMLResponse(
3577
content=output,
3578
status_code=200,
3579
headers={
3580
"Content-Type": "text/vnd.turbo-stream.html",
3581
},3582
)3583
3584
@read_router.get(
3585
"/actions/document/cancel_edit_included_document",
3586
response_class=Response,
3587
)3588
def document__cancel_edit_included_document(document_mid: str) -> Response:
3589
document: SDocDocument = (
3590
export_action.traceability_index.get_node_by_mid(MID(document_mid))
3591
)3592
assert document.meta is not None
3593
link_renderer = LinkRenderer(
3594
root_path=document.meta.get_root_path_prefix(),
3595
static_path=project_config.dir_for_sdoc_assets,
3596
)3597
markup_renderer = MarkupRenderer.create(
3598
markup=document.config.get_markup(),
3599
traceability_index=export_action.traceability_index,
3600
link_renderer=link_renderer,
3601
html_templates=html_generator.html_templates,
3602
config=project_config,
3603
context_document=document,
3604
)3605
view_object = DocumentScreenViewObject(
3606
document_type=DocumentType.DOCUMENT,
3607
document=document,
3608
traceability_index=export_action.traceability_index,
3609
project_config=project_config,
3610
link_renderer=link_renderer,
3611
markup_renderer=markup_renderer,
3612
jinja_environment=env(),
3613
git_client=html_generator.git_client,
3614
)3615
output = env().render_template_as_markup(
3616
"actions/document/edit_section/stream_updated_section.jinja.html",
3617
view_object=view_object,
3618
document=document,
3619
node=document,
3620
)3621
return HTMLResponse(
3622
content=output,
3623
status_code=200,
3624
headers={
3625
"Content-Type": "text/vnd.turbo-stream.html",
3626
},3627
)3628
3629
@read_router.get("/actions/document/edit_grammar", response_class=Response)
- "6.3.10. Edit Document grammar" (REQUIREMENT)
3630
def document__edit_grammar(document_mid: str) -> Response:
3631
"""
3632
@relation(SDOC-SRS-56, scope=function)3633
"""3634
3635
document: SDocDocument = (
3636
export_action.traceability_index.get_node_by_mid(MID(document_mid))
3637
)3638
form_object: GrammarFormObject = GrammarFormObject.create_from_document(
3639
document=document,
3640
project_config=project_config,
3641
jinja_environment=env(),
3642
)3643
return HTMLResponse(
3644
content=form_object.render(),
3645
status_code=200,
3646
headers={
3647
"Content-Type": "text/vnd.turbo-stream.html",
3648
},3649
)3650
3651
@write_router.post(
3652
"/actions/document/save_grammar", response_class=Response
3653
)- "6.3.10. Edit Document grammar" (REQUIREMENT)
3654
def document__save_grammar(
3655
request_form_data: FormData = Depends(parse_form_data),
3656
) -> Response:
3657
"""
3658
@relation(SDOC-SRS-56, scope=function)3659
"""3660
3661
request_dict: Dict[str, str] = dict(request_form_data)
3662
document_mid: str = request_dict["document_mid"]
3663
document: SDocDocument = (
3664
export_action.traceability_index.get_node_by_mid(MID(document_mid))
3665
)3666
form_object: GrammarFormObject = GrammarFormObject.create_from_request(
3667
document_mid=document_mid,
3668
request_form_data=request_form_data,
3669
project_config=project_config,
3670
jinja_environment=env(),
3671
)3672
if not form_object.validate():
3673
return HTMLResponse(
3674
content=form_object.render(),
3675
status_code=422,
3676
headers={
3677
"Content-Type": "text/vnd.turbo-stream.html",
3678
},3679
)3680
# Update the document with new grammar.3681
update_grammar_action = UpdateGrammarCommand(
3682
form_object=form_object,
3683
document=document,
3684
traceability_index=export_action.traceability_index,
3685
)3686
update_grammar_action.perform()
3687
3688
# Re-generate the document's SDOC.3689
write_document_to_file(document)
3690
3691
# Re-generate the document.3692
html_generator.export_single_document(
3693
document=document,
3694
traceability_index=export_action.traceability_index,
3695
)3696
3697
# Re-generate the document tree.3698
html_generator.export_project_tree_screen(
3699
traceability_index=export_action.traceability_index,
3700
)3701
3702
assert document.meta is not None
3703
link_renderer = LinkRenderer(
3704
root_path=document.meta.get_root_path_prefix(),
3705
static_path=project_config.dir_for_sdoc_assets,
3706
)3707
markup_renderer = MarkupRenderer.create(
3708
markup=document.config.get_markup(),
3709
traceability_index=export_action.traceability_index,
3710
link_renderer=link_renderer,
3711
html_templates=html_generator.html_templates,
3712
config=project_config,
3713
context_document=document,
3714
)3715
view_object = DocumentScreenViewObject(
3716
document_type=DocumentType.DOCUMENT,
3717
document=document,
3718
traceability_index=export_action.traceability_index,
3719
project_config=project_config,
3720
link_renderer=link_renderer,
3721
markup_renderer=markup_renderer,
3722
jinja_environment=env(),
3723
git_client=html_generator.git_client,
3724
)3725
output = (
3726
form_object.render_close_form()
3727
+ env().render_template_as_markup(
3728
"actions/document/_shared/stream_refresh_document.jinja.html",
3729
view_object=view_object,
3730
)3731
)3732
return HTMLResponse(
3733
content=output,
3734
status_code=200,
3735
headers={
3736
"Content-Type": "text/vnd.turbo-stream.html",
3737
},3738
)3739
3740
@read_router.get(
3741
"/actions/document/add_grammar_element", response_class=Response
3742
)- "6.3.10. Edit Document grammar" (REQUIREMENT)
3743
def document__add_grammar_element(document_mid: str) -> Response:
3744
"""
3745
@relation(SDOC-SRS-56, scope=function)3746
"""3747
3748
form_object: GrammarFormObject = GrammarFormObject(
3749
document_mid=document_mid,
3750
fields=[], # Not used in this limited partial template.
3751
project_config=project_config,
3752
jinja_environment=env(),
3753
imported_grammar_file=None,
3754
)3755
return HTMLResponse(
3756
content=form_object.render_row_with_new_grammar_element(),
3757
status_code=200,
3758
headers={
3759
"Content-Type": "text/vnd.turbo-stream.html",
3760
},3761
)3762
3763
@read_router.get(
3764
"/actions/document/edit_grammar_element", response_class=Response
3765
)- "6.3.10. Edit Document grammar" (REQUIREMENT)
3766
def document__edit_grammar_element(
3767
document_mid: str, element_mid: str
3768
) -> Response:
3769
"""
3770
@relation(SDOC-SRS-56, scope=function)3771
"""3772
3773
document: SDocDocument = (
3774
export_action.traceability_index.get_node_by_mid(MID(document_mid))
3775
)3776
form_object: GrammarElementFormObject = (
3777
GrammarElementFormObject.create_from_document(
3778
document=document,
3779
element_mid=element_mid,
3780
project_config=project_config,
3781
jinja_environment=env(),
3782
)3783
)3784
3785
return HTMLResponse(
3786
content=form_object.render(),
3787
status_code=200,
3788
headers={
3789
"Content-Type": "text/vnd.turbo-stream.html",
3790
},3791
)3792
3793
@write_router.post(
3794
"/actions/document/save_grammar_element", response_class=Response
3795
)- "6.3.10. Edit Document grammar" (REQUIREMENT)
3796
def document__save_grammar_element(
3797
request_form_data: FormData = Depends(parse_form_data),
3798
) -> Response:
3799
"""
3800
@relation(SDOC-SRS-56, scope=function)3801
"""3802
3803
request_dict: Dict[str, str] = dict(request_form_data)
3804
document_mid: str = request_dict["document_mid"]
3805
document: SDocDocument = (
3806
export_action.traceability_index.get_node_by_mid(MID(document_mid))
3807
)3808
form_object: GrammarElementFormObject = (
3809
GrammarElementFormObject.create_from_request(
3810
document=document,
3811
request_form_data=request_form_data,
3812
project_config=project_config,
3813
jinja_environment=env(),
3814
)3815
)3816
if not form_object.validate():
3817
return HTMLResponse(
3818
content=form_object.render_after_validation(),
3819
status_code=422,
3820
headers={
3821
"Content-Type": "text/vnd.turbo-stream.html",
3822
},3823
)3824
3825
# Update the document with new grammar.3826
update_grammar_action = UpdateGrammarElementCommand(
3827
form_object=form_object,
3828
document=document,
3829
traceability_index=export_action.traceability_index,
3830
)3831
update_grammar_action.perform()
3832
3833
# Re-generate the document's SDOC.3834
write_document_to_file(document)
3835
3836
# Re-generate the document.3837
html_generator.export_single_document(
3838
document=document,
3839
traceability_index=export_action.traceability_index,
3840
)3841
3842
# Re-generate the document tree.3843
html_generator.export_project_tree_screen(
3844
traceability_index=export_action.traceability_index,
3845
)3846
3847
assert document.meta is not None
3848
link_renderer = LinkRenderer(
3849
root_path=document.meta.get_root_path_prefix(),
3850
static_path=project_config.dir_for_sdoc_assets,
3851
)3852
markup_renderer = MarkupRenderer.create(
3853
markup=document.config.get_markup(),
3854
traceability_index=export_action.traceability_index,
3855
link_renderer=link_renderer,
3856
html_templates=html_generator.html_templates,
3857
config=project_config,
3858
context_document=document,
3859
)3860
view_object = DocumentScreenViewObject(
3861
document_type=DocumentType.DOCUMENT,
3862
document=document,
3863
traceability_index=export_action.traceability_index,
3864
project_config=project_config,
3865
link_renderer=link_renderer,
3866
markup_renderer=markup_renderer,
3867
jinja_environment=env(),
3868
git_client=html_generator.git_client,
3869
)3870
output = (
3871
form_object.render_close_form()
3872
+ env().render_template_as_markup(
3873
"actions/document/_shared/stream_refresh_document.jinja.html",
3874
view_object=view_object,
3875
)3876
)3877
return HTMLResponse(
3878
content=output,
3879
status_code=200,
3880
headers={
3881
"Content-Type": "text/vnd.turbo-stream.html",
3882
},3883
)3884
3885
@read_router.get(
3886
"/actions/document/add_grammar_field", response_class=Response
3887
)- "6.3.10. Edit Document grammar" (REQUIREMENT)
3888
def document__add_grammar_field(document_mid: str) -> Response:
3889
"""
3890
@relation(SDOC-SRS-56, scope=function)3891
"""3892
3893
form_object: GrammarElementFormObject = GrammarElementFormObject(
3894
document_mid=document_mid,
3895
element_mid="NOT_RELEVANT",
3896
element_name="NOT_RELEVANT",
3897
is_composite=None, # Not used in this limited partial template.
3898
prefix=None, # Not used in this limited partial template.
3899
view_style=None, # Not used in this limited partial template.
3900
fields=[], # Not used in this limited partial template.
3901
relations=[], # Not used in this limited partial template.
3902
project_config=project_config,
3903
jinja_environment=env(),
3904
)3905
return HTMLResponse(
3906
content=form_object.render_row_with_new_field(),
3907
status_code=200,
3908
headers={
3909
"Content-Type": "text/vnd.turbo-stream.html",
3910
},3911
)3912
3913
@read_router.get(
3914
"/actions/document/add_grammar_relation", response_class=Response
3915
)- "6.3.10. Edit Document grammar" (REQUIREMENT)
3916
def document__add_grammar_relation(document_mid: str) -> Response:
3917
"""
3918
@relation(SDOC-SRS-56, scope=function)3919
"""3920
3921
form_object = GrammarElementFormObject(
3922
document_mid=document_mid,
3923
element_mid="NOT_RELEVANT",
3924
element_name="NOT_RELEVANT",
3925
is_composite=None, # Not used in this limited partial template.
3926
prefix=None, # Not used in this limited partial template.
3927
view_style=None, # Not used in this limited partial template.
3928
fields=[], # Not used in this limited partial template.
3929
relations=[], # Not used in this limited partial template.
3930
project_config=project_config,
3931
jinja_environment=env(),
3932
)3933
return HTMLResponse(
3934
content=form_object.render_row_with_new_relation(),
3935
status_code=200,
3936
headers={
3937
"Content-Type": "text/vnd.turbo-stream.html",
3938
},3939
)3940
3941
@read_router.get(
3942
"/actions/project_index/import_reqif_document_form",
3943
response_class=Response,
3944
)3945
def get_import_reqif_document_form() -> Response:
3946
output = env().render_template_as_markup(
3947
"actions/project_index/import_reqif_document/"3948
"stream_form_import_reqif_document.jinja.html",
3949
error_object=ErrorObject(),
3950
)3951
return HTMLResponse(
3952
content=output,
3953
status_code=200,
3954
headers={
3955
"Content-Type": "text/vnd.turbo-stream.html",
3956
},3957
)3958
3959
@write_router.post(
3960
"/actions/project_index/import_document_reqif", response_class=Response
3961
)3962
def import_document_reqif(reqif_file: UploadFile) -> Response:
3963
contents = reqif_file.file.read().decode()
3964
3965
error_object = ErrorObject()
3966
assert isinstance(contents, str)
3967
3968
try:
3969
reqif_bundle = ReqIFParser.parse_from_string(contents)
3970
converter: P01_ReqIFToSDocConverter = P01_ReqIFToSDocConverter()
3971
documents: List[SDocDocument] = converter.convert_reqif_bundle(
3972
reqif_bundle,
3973
enable_mid=project_config.reqif_enable_mid,
3974
import_markup=project_config.reqif_import_markup,
3975
)3976
except ReqIFXMLParsingError as exception:
3977
error_object.add_error(
3978
"reqif_file", "Cannot parse ReqIF file: " + str(exception)
3979
)3980
# Catch unexpected errors but exclude from code coverage, because it is3981
# not clear yet how to write a test that triggers this.3982
except Exception as exception: # pragma: no cover
3983
error_object.add_error("reqif_file", str(exception))
3984
3985
if error_object.any_errors():
3986
output = env().render_template_as_markup(
3987
"actions/project_index/import_reqif_document/"3988
"stream_form_import_reqif_document.jinja.html",
3989
error_object=error_object,
3990
)3991
return HTMLResponse(
3992
content=output,
3993
status_code=422,
3994
headers={
3995
"Content-Type": "text/vnd.turbo-stream.html",
3996
},3997
)3998
assert documents is not None
3999
assert isinstance(project_config.input_paths, list)
4000
for document in documents:
4001
document_title = re.sub(r"[^A-Za-z0-9-]", "_", document.title)
4002
document_path = f"{document_title}.sdoc"
4003
4004
full_input_path = os.path.abspath(project_config.input_paths[0])
4005
doc_full_path = os.path.join(full_input_path, document_path)
4006
doc_full_path_dir = os.path.dirname(doc_full_path)
4007
Path(doc_full_path_dir).mkdir(parents=True, exist_ok=True)
4008
4009
file_tree_mount_folder = os.path.basename(
4010
os.path.dirname(full_input_path)
4011
)4012
4013
input_doc_assets_dir_rel_path = "/".join(
4014
(file_tree_mount_folder, "_assets")
4015
)4016
4017
# FIXME: Fill in the meta information correctly.4018
document.meta = DocumentMeta(
4019
level=0,
4020
file_tree_mount_folder="NOT_RELEVANT",
4021
document_filename=document_path,
4022
document_filename_base="NOT_RELEVANT",
4023
input_doc_full_path=doc_full_path,
4024
input_doc_rel_path=SDocRelativePath(document_path),
4025
input_doc_dir_rel_path=SDocRelativePath(""),
4026
input_doc_assets_dir_rel_path=SDocRelativePath(
4027
input_doc_assets_dir_rel_path4028
),4029
output_document_dir_full_path="NOT_RELEVANT",
4030
output_document_dir_rel_path=SDocRelativePath("FIXME"),
4031
)4032
4033
write_document_to_file(document)
4034
4035
export_action.build_index()
4036
export_action.export()
4037
4038
view_object = ProjectTreeViewObject(
4039
traceability_index=export_action.traceability_index,
4040
project_config=project_config,
4041
)4042
output = env().render_template_as_markup(
4043
"actions/project_index/import_reqif_document/"4044
"stream_refresh_with_imported_reqif_document.jinja.html",
4045
view_object=view_object,
4046
)4047
return HTMLResponse(
4048
content=output,
4049
status_code=200,
4050
headers={
4051
"Content-Type": "text/vnd.turbo-stream.html",
4052
},4053
)4054
4055
@router.get("/export_html2pdf/{document_mid}", response_class=Response)
4056
def get_export_html2pdf(document_mid: str) -> Response: # noqa: ARG001
4057
if not project_config.is_activated_html2pdf():
4058
return Response(
4059
content="The HTML2PDF feature is not activated in the project config.",
4060
status_code=HTTP_STATUS_PRECONDITION_FAILED,
4061
)4062
4063
with lock_manager.acquire_subset(
4064
read_ids={_compute_document_mid_lock_key(document_mid)}
4065
):4066
document = export_action.traceability_index.get_node_by_mid(
4067
MID(document_mid)
4068
)4069
4070
root_path = document.meta.get_root_path_prefix()
4071
relative_path = (
4072
document.meta.output_document_dir_rel_path.relative_path
4073
)4074
4075
link_renderer = LinkRenderer(
4076
root_path=root_path,
4077
static_path=project_config.dir_for_sdoc_assets,
4078
)4079
markup_renderer = MarkupRenderer.create(
4080
document.config.get_markup(),
4081
export_action.traceability_index,
4082
link_renderer,
4083
html_templates,
4084
project_config,
4085
document,
4086
)4087
4088
pdf_project_config = copy.deepcopy(project_config)
4089
pdf_project_config.is_running_on_server = False
4090
4091
with measure_performance("Generating printable HTML document"):
4092
document_content = DocumentHTML2PDFGenerator.export(
4093
pdf_project_config,
4094
document,
4095
export_action.traceability_index,
4096
markup_renderer,
4097
link_renderer,
4098
git_client=html_generator.git_client,
4099
html_templates=html_templates,
4100
)4101
4102
# Copy values needed below so the expensive filesystem and subprocess4103
# phase can run without holding the router's read lock.4104
proposed_basename = "document"
4105
if document.title is not None:
4106
proposed_basename = document.title
4107
if document.uid is not None:
4108
proposed_basename = document.uid + " " + proposed_basename
4109
4110
temp_uid = uuid.uuid4().hex
4111
path_to_output_html = os.path.join(
4112
project_config.export_output_html_root,
4113
relative_path,
4114
f"_temp_{temp_uid}.html",
4115
)4116
path_to_output_pdf = os.path.join(
4117
project_config.export_output_html_root,
4118
"html",
4119
f"_temp_{temp_uid}.pdf",
4120
)4121
4122
def cleanup_html2pdf_artifacts() -> None:
4123
for path in (path_to_output_html, path_to_output_pdf):
4124
if os.path.isfile(path):
4125
os.remove(path)
4126
4127
Path(path_to_output_html).parent.mkdir(parents=True, exist_ok=True)
4128
Path(path_to_output_pdf).parent.mkdir(parents=True, exist_ok=True)
4129
4130
# FIXME: Add this print driver to a service bus object to make it4131
# unit-testable.4132
pdf_print_driver = PDFPrintDriver()
4133
with open(path_to_output_html, mode="w", encoding="utf8") as temp_file_:
4134
temp_file_.write(document_content)
4135
4136
assert os.path.isfile(path_to_output_html), path_to_output_html
4137
try:
4138
pdf_print_driver.get_pdf_from_html(
4139
project_config,
4140
[(path_to_output_html, path_to_output_pdf)],
4141
project_config.export_output_html_root,
4142
)4143
except PDFPrintDriverException as e_: # pragma: no cover
4144
cleanup_html2pdf_artifacts()
4145
return Response(
4146
content=e_.get_server_user_message(),
4147
status_code=HTTP_STATUS_INTERNAL_SERVER_ERROR,
4148
)4149
assert os.path.isfile(path_to_output_pdf), path_to_output_pdf
4150
4151
# We sanitize the basename, Windows is the most restrictive:4152
# - many forbidden chars.4153
# - not more than 120 chars in total, including the PDF extension4154
forbidden = '<>:"/\\|?*\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f'
4155
table = str.maketrans(forbidden, "_" * len(forbidden))
4156
sanitized_basename = proposed_basename.translate(table)
4157
sanitized_basename = sanitized_basename.strip(" ")[:115]
4158
encoded_filename = quote(sanitized_basename + ".pdf")
4159
4160
return FileResponse(
4161
path=path_to_output_pdf,
4162
status_code=200,
4163
headers={
4164
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
4165
},4166
media_type="application/octet-stream",
4167
background=BackgroundTask(cleanup_html2pdf_artifacts),
4168
)4169
4170
@read_router.get(
4171
"/reqif/export_document/{document_mid}", response_class=Response
4172
)4173
def get_reqif_export_document(document_mid: str) -> Response: # noqa: ARG001
4174
# TODO: Export single document, not the whole tree.4175
return get_reqif_export_tree()
4176
4177
@read_router.get("/reqif/export_tree", response_class=Response)
4178
def get_reqif_export_tree() -> Response:
4179
reqif_bundle = P01_SDocToReqIFObjectConverter.convert_document_tree(
4180
document_tree=export_action.traceability_index.document_tree,
4181
multiline_is_xhtml=project_config.reqif_multiline_is_xhtml,
4182
enable_mid=project_config.reqif_enable_mid,
4183
)4184
reqif_content: str = ReqIFUnparser.unparse(reqif_bundle)
4185
return Response(
4186
content=reqif_content,
4187
status_code=200,
4188
media_type="application/octet-stream",
4189
headers={
4190
"Content-Disposition": 'attachment; filename="export.reqif"',
4191
},4192
)4193
4194
@read_router.get("/search", response_class=Response)
4195
def get_search(q: Optional[str] = None) -> Response:
4196
if not project_config.is_activated_search():
4197
return Response(
4198
content="The Search feature is not activated in the project config.",
4199
status_code=HTTP_STATUS_PRECONDITION_FAILED,
4200
)4201
search_results = []
4202
error = None
4203
node_query = None
4204
plain_text_query_phrase = None
4205
plain_text_query_pattern = None
4206
4207
if q is not None and len(q) > 0:
4208
normalized_query = q.strip()
4209
if len(normalized_query) > 0:
4210
if search_query_contains_markers(normalized_query):
4211
try:
4212
query: Query = QueryReader.read(normalized_query)
4213
node_query = QueryObject(
4214
query, export_action.traceability_index
4215
)4216
except Exception as e:
4217
error = f"error: {e}"
4218
else:
4219
(4220
plain_text_query_phrase,
4221
plain_text_query_pattern,
4222
) = parse_plain_text_search_query(normalized_query)
4223
4224
if (
4225
node_query is not None
4226
or plain_text_query_phrase is not None
4227
or plain_text_query_pattern is not None
4228
):4229
result: List[SDocExtendedElementIF] = []
4230
try:
4231
document_tree = assert_cast(
4232
export_action.traceability_index.document_tree, DocumentTree
4233
)4234
for document in document_tree.document_list:
4235
document_iterator = (
4236
export_action.traceability_index.get_document_iterator(
4237
document4238
)4239
)4240
for node, _ in document_iterator.all_content(
4241
print_fragments=False
4242
):4243
if (
4244
node_query is not None and node_query.evaluate(node)
4245
) or (
4246
search_node_matches_plain_text_query(
4247
node,
4248
phrase=plain_text_query_phrase,
4249
pattern=plain_text_query_pattern,
4250
)4251
):4252
result.append(node)
4253
4254
if (
4255
export_action.traceability_index.document_tree.source_tree
4256
is not None
4257
):4258
for source_file_ in export_action.traceability_index.document_tree.source_tree.source_files:
4259
source_file_info_: SourceFileTraceabilityInfo = export_action.traceability_index.get_file_traceability_index().get_coverage_info(
4260
source_file_.in_doctree_source_file_rel_path_posix
4261
)4262
if (
4263
node_query is not None
4264
and node_query.evaluate(source_file_info_)
4265
) or (
4266
search_node_matches_plain_text_query(
4267
source_file_info_,
4268
phrase=plain_text_query_phrase,
4269
pattern=plain_text_query_pattern,
4270
)4271
):4272
result.append(source_file_info_)
4273
4274
search_results = result
4275
# Catch unexpected errors but exclude from code coverage, because4276
# it is not clear yet how to write a test that triggers this.4277
except (
4278
AttributeError,
4279
NameError,
4280
TypeError,
4281
) as attribute_error_: # pragma: no cover
4282
error = attribute_error_.args[0]
4283
4284
view_object = SearchScreenViewObject(
4285
traceability_index=export_action.traceability_index,
4286
project_config=project_config,
4287
templates=html_templates,
4288
search_results=search_results,
4289
search_value=q if q is not None else "",
4290
error=error,
4291
)4292
output = view_object.render_screen(html_templates.jinja_environment())
4293
4294
return Response(
4295
content=output,
4296
status_code=200,
4297
)4298
4299
@read_router.get("/autocomplete/uid", response_class=Response)
- "6.3.13. Auto-completion for requirements UIDs" (REQUIREMENT)
4300
def get_autocomplete_uid_results(
4301
q: Optional[str] = None, exclude_requirement_mid: Optional[str] = None
4302
) -> Response:
4303
"""
4304
Returns matches of possible node UID values when creating a node relation.4305
4306
The UID of the node identified by the optional parameter "exclude_requirement_mid" is excluded,4307
so that a node cannot be linked to itself.4308
4309
@relation(SDOC-SRS-120, scope=function)4310
"""4311
output = ""
4312
if q is not None:
4313
query_words = q.lower().split()
4314
resulting_nodes = []
4315
document_tree = assert_cast(
4316
export_action.traceability_index.document_tree, DocumentTree
4317
)4318
for document in document_tree.document_list:
4319
document_iterator = (
4320
export_action.traceability_index.get_document_iterator(
4321
document4322
)4323
)4324
for node_, _ in document_iterator.all_content(
4325
print_fragments=False
4326
):4327
if not isinstance(node_, SDocNodeIF):
4328
continue4329
4330
if node_.node_type == "SECTION":
4331
continue4332
4333
if (
4334
node_.reserved_uid is not None
4335
and node_.reserved_mid != exclude_requirement_mid
4336
):4337
words_ = node_.reserved_uid.strip().lower()
4338
if node_.reserved_title is not None:
4339
words_ = (
4340
words_4341
+ " "
4342
+ node_.reserved_title.strip().lower()
4343
)4344
if all(word_ in words_ for word_ in query_words):
4345
resulting_nodes.append(node_)
4346
4347
# Excluding the following branch from code coverage4348
# because it is not practical to create a test that4349
# reproduces going above the limit. The code is4350
# simple, so it should be safe to exclude this4351
# branch from coverage.4352
if (
4353
len(resulting_nodes) >= AUTOCOMPLETE_LIMIT
4354
): # pragma: no cover
4355
break4356
4357
output = env().render_template_as_markup(
4358
"autocomplete/uid/stream_autocomplete_uid.jinja.html",
4359
nodes=resulting_nodes,
4360
)4361
4362
return Response(
4363
content=output,
4364
status_code=200,
4365
)4366
4367
@read_router.get("/autocomplete/field", response_class=Response)
4368
def get_autocomplete_field_results(
4369
q: Optional[str] = None,
4370
document_mid: Optional[str] = None,
4371
element_type: Optional[str] = None,
4372
field_name: Optional[str] = None,
4373
) -> Response:
4374
"""
4375
Returns matches of possible values of a SingleChoice, MultiChoice or Tag field.4376
4377
The field is identified by the document_mid, the element_type, and the field_name.4378
"""4379
output = ""
4380
if (
4381
q is not None
4382
and document_mid is not None
4383
and element_type is not None
4384
):4385
document: SDocDocument = (
4386
export_action.traceability_index.get_node_by_mid(
4387
MID(document_mid)
4388
)4389
)4390
if document:
4391
assert field_name is not None
4392
all_options = document.get_options_for_field(
4393
element_type, field_name
4394
)4395
field: GrammarElementField = (
4396
document.get_grammar_element_field_for(
4397
element_type, field_name
4398
)4399
)4400
4401
if field.gef_type in (
4402
RequirementFieldType.MULTIPLE_CHOICE,
4403
RequirementFieldType.TAG,
4404
):4405
# MultipleChoice/Tag: We split the query into its parts:4406
#4407
# Example User input: "Some Value, Another Value, Yet ano|".4408
# parts = ['some value', 'another value', 'yet ano'] # noqa: ERA0014409
parts = q.lower().split(",")
4410
4411
# For the lookup, we want to use the only the last, still4412
# incomplete part, not the full query:4413
#4414
# last_part = "yet ano" # noqa: ERA0014415
# query_words = ['yet', 'ano'] # noqa: ERA0014416
last_part = parts[-1].strip()
4417
query_words = last_part.split()
4418
4419
# We also filter the already selected choices from the4420
# options we are going to be send to the user,4421
# as MultipleChoices is a Set, so options shall be4422
# selectable at most once.4423
#4424
# In the example, we would remove 'some value' and 'another value'.4425
already_selected = [
4426
p.strip() for p in parts[:-1] if p.strip()
4427
]4428
filtered_options = [
4429
choice4430
for choice in all_options
4431
if choice.lower() not in already_selected
4432
]4433
else:
4434
# SingleChoice: we use the full query and all available4435
# options. There is no notion of an "already selected"4436
# segment, as a SingleChoice field holds only one value.4437
query_words = q.lower().split()
4438
filtered_options = all_options
4439
last_part = None
4440
4441
resulting_values = []
4442
4443
# Now filter the remaining options for those that match all words in query_words.4444
for option_ in filtered_options:
4445
words_ = option_.strip().lower()
4446
4447
if all(word_ in words_ for word_ in query_words):
4448
# A MultipleChoice/Tag option that exactly matches4449
# the segment currently being typed is, in fact,4450
# already a complete value of the field (typed in4451
# full, with or without a trailing comma). It is4452
# still shown so the user has visual confirmation,4453
# but marked as already selected so it can't be4454
# inserted as a duplicate.4455
is_selected = last_part is not None and (
4456
words_ == last_part
4457
)4458
resulting_values.append((option_, is_selected))
4459
if len(resulting_values) >= AUTOCOMPLETE_LIMIT:
4460
break4461
4462
output = env().render_template_as_markup(
4463
"autocomplete/field/stream_autocomplete_field.jinja.html",
4464
values=resulting_values,
4465
)4466
4467
return Response(
4468
content=output,
4469
status_code=200,
4470
)4471
4472
@read_router.get("/UID/{uid_or_mid}", response_class=RedirectResponse)
4473
def redirect_to_uid(uid_or_mid: str) -> Response:
4474
# Resolve UID or MID.4475
4476
linkable_node: Optional[Any] = (
4477
export_action.traceability_index.get_node_by_mid_weak(
4478
MID(uid_or_mid)
4479
)4480
)4481
if linkable_node is None:
4482
linkable_node = (
4483
export_action.traceability_index.get_linkable_node_by_uid_weak(
4484
uid_or_mid4485
)4486
)4487
4488
# If found, send a 302 redirect response to guide the user to the4489
# correct URL (page + #anchor)4490
if linkable_node is not None:
4491
link_renderer = LinkRenderer(
4492
root_path="", static_path=project_config.dir_for_sdoc_assets
4493
)4494
href = link_renderer.render_node_link(
4495
linkable_node, None, document_type=DocumentType.DOCUMENT
4496
)4497
return RedirectResponse(url=href, status_code=302)
4498
# The HTTPException will render our ServerErrorViewObject 404 page4499
# via @app.exception_handler(404).4500
raise HTTPException(status_code=404, detail="UID or MID was not found")
4501
4502
# Nestor is a highly experimental feature that is unlikely to make it to the4503
# stable feature set. Excluding it from code coverage.4504
@write_router.get("/__nestor", response_class=Response) # pragma: no cover
4505
def get_nestor() -> Response: # pragma: no cover
4506
output_json_root = os.path.join(project_config.output_dir, "html")
4507
Path(output_json_root).mkdir(parents=True, exist_ok=True)
4508
JSONGenerator().export_tree(
4509
export_action.traceability_index, project_config, output_json_root
4510
)4511
path_to_json = os.path.join("index.json")
4512
view_object = NestorViewObject(
4513
traceability_index=export_action.traceability_index,
4514
project_config=project_config,
4515
templates=html_templates,
4516
path_to_json=path_to_json,
4517
)4518
output = view_object.render_screen(html_templates.jinja_environment())
4519
4520
return Response(
4521
content=output,
4522
status_code=200,
4523
)4524
4525
router.include_router(read_router)
4526
router.include_router(write_router)
4527
4528
@router.get(
4529
"/{full_path:path}/static_html_search_index.js", response_class=Response
4530
)4531
def get_static_search_index(
4532
request: Request,
4533
full_path: str, # noqa: ARG001
4534
) -> Response:
4535
static_file = os.path.join(
4536
project_config.export_output_html_root,
4537
project_config.dir_for_sdoc_assets,
4538
"static_html_search_index.js",
4539
)4540
4541
def must_generate() -> bool:
4542
if not os.path.isfile(static_file):
4543
return True
4544
output_file_mtime = get_file_modification_time(static_file)
4545
return (
4546
export_action.traceability_index.index_last_updated
4547
> output_file_mtime
4548
)4549
4550
with lock_manager.acquire_global_read():
4551
if not must_generate() and request_is_for_non_modified_file(
4552
request, static_file
4553
):4554
return Response(status_code=304)
4555
4556
with lock_manager.acquire_global_write():
4557
html_generator.export_static_html_search_index(
4558
traceability_index=export_action.traceability_index
4559
)4560
4561
return FileResponse(
4562
static_file,
4563
media_type="application/javascript",
4564
headers={
4565
# We don't want the search index to be cached on the server without4566
# revalidation.4567
# The no-cache request directive asks caches to validate the4568
# response with the origin server before reuse.4569
# no-cache allows clients to request the most up-to-date4570
# response even if the cache has a fresh response.4571
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control4572
"Cache-Control": "no-cache"
4573
},4574
)4575
4576
@router.get("/{full_path:path}", response_class=Response)
4577
def get_incoming_request(request: Request, full_path: str) -> Response:
4578
# FIXME: This seems to be quite un-sanitized.4579
_, file_extension = os.path.splitext(full_path)
4580
if file_extension == ".html":
4581
return get_document(request, full_path)
4582
elif file_extension == "":
4583
# No extension: StrictDoc documents always end in .html, so no4584
# extension can ever resolve to a valid document. Return 4044585
# directly without going through get_document().4586
return _error_response(HTTP_STATUS_NOT_FOUND)
4587
else:
4588
return get_asset(request, full_path)
4589
- "14.5. On-demand loading of HTML pages" (REQUIREMENT)
4590
def get_document(request: Request, url_to_document: str) -> Response:
4591
"""
4592
@relation(SDOC-SRS-4, scope=function)4593
"""4594
4595
document_relative_path: SDocRelativePath = SDocRelativePath.from_url(
4596
url_to_document4597
)4598
full_path_to_document = os.path.join(
4599
project_config.export_output_html_root,
4600
document_relative_path.relative_path,
4601
)4602
4603
def must_generate() -> bool:
4604
if not os.path.isfile(full_path_to_document):
4605
return True
4606
output_file_mtime = get_file_modification_time(
4607
full_path_to_document4608
)4609
return (
4610
export_action.traceability_index.index_last_updated
4611
> output_file_mtime
4612
)4613
4614
with lock_manager.acquire_global_read():
4615
if not must_generate():
4616
if request_is_for_non_modified_file(
4617
request, full_path_to_document
4618
):4619
return Response(status_code=304)
4620
return FileResponse(
4621
full_path_to_document,
4622
media_type="text/html",
4623
headers={
4624
# We don't want the documents to be cached on the server without4625
# revalidation.4626
# The no-cache request directive asks caches to validate the4627
# response with the origin server before reuse.4628
# no-cache allows clients to request the most up-to-date4629
# response even if the cache has a fresh response.4630
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control4631
"Cache-Control": "no-cache"
4632
},4633
)4634
4635
lock_key = _compute_document_generation_lock_key(
4636
document_relative_path.relative_path
4637
)4638
4639
with lock_manager.acquire_subset(write_ids={lock_key}):
4640
if not must_generate():
4641
if request_is_for_non_modified_file(
4642
request, full_path_to_document
4643
):4644
return Response(status_code=304)
4645
return FileResponse(
4646
full_path_to_document,
4647
media_type="text/html",
4648
headers={"Cache-Control": "no-cache"},
4649
)4650
4651
def generate_document() -> Optional[Response]:
4652
if document_relative_path.relative_path.startswith(
4653
"_source_files"4654
):4655
if document_relative_path.relative_path.endswith(
4656
"source_coverage.html"4657
):4658
html_generator.export_source_coverage_screen(
4659
traceability_index=export_action.traceability_index,
4660
)4661
else:
4662
try:
4663
html_generator.export_single_source_file_screen(
4664
traceability_index=export_action.traceability_index,
4665
path_to_source_file=document_relative_path.relative_path,
4666
)4667
except FileNotFoundError:
4668
return _error_response(HTTP_STATUS_NOT_FOUND)
4669
elif document_relative_path.relative_path == "index.html":
4670
html_generator.export_project_tree_screen(
4671
traceability_index=export_action.traceability_index,
4672
)4673
elif (
4674
document_relative_path.relative_path
4675
== "traceability_matrix.html"
4676
):4677
if not project_config.is_activated_requirements_coverage():
4678
return Response(
4679
content="The Requirements Coverage feature is not activated in the project config.",
4680
status_code=HTTP_STATUS_PRECONDITION_FAILED,
4681
)4682
html_generator.export_requirements_coverage_screen(
4683
traceability_index=export_action.traceability_index,
4684
)4685
elif document_relative_path.relative_path == "tree_map.html":
4686
if not project_config.is_activated_tree_map():
4687
return Response(
4688
content="The Tree Map feature is not activated in the project config.",
4689
status_code=HTTP_STATUS_PRECONDITION_FAILED,
4690
)4691
html_generator.export_tree_map_screen(
4692
traceability_index=export_action.traceability_index,
4693
)4694
elif (
4695
document_relative_path.relative_path
4696
== "source_coverage.html"
4697
):4698
if not project_config.is_activated_requirements_to_source_traceability():
4699
return Response(
4700
content="The Requirements to Source Files feature is not activated in the project config.",
4701
status_code=HTTP_STATUS_PRECONDITION_FAILED,
4702
)4703
html_generator.export_source_coverage_screen(
4704
traceability_index=export_action.traceability_index,
4705
)4706
elif (
4707
feature_ := server_features_by_screen_filename.get(
4708
document_relative_path.relative_path
4709
)4710
) is not None:
4711
if project_config.get_feature(feature_.HANDLE) is None:
4712
return Response(
4713
content=(
4714
f"The {feature_.HANDLE} feature is not "
4715
f"activated in the project config."
4716
),4717
status_code=HTTP_STATUS_PRECONDITION_FAILED,
4718
)4719
feature_.render_screen(
4720
FeatureContext(
4721
project_config=project_config,
4722
traceability_index=export_action.traceability_index,
4723
html_templates=html_templates,
4724
)4725
)4726
else:
4727
document_type_to_generate: DocumentType
4728
if document_relative_path.relative_path.endswith(
4729
"-TABLE.html"4730
):4731
base_document_url = (
4732
document_relative_path.relative_path.replace(
4733
"-TABLE", ""
4734
)4735
)4736
document_type_to_generate = DocumentType.TABLE
4737
elif document_relative_path.relative_path.endswith(
4738
"-DEEP-TRACE.html"4739
):4740
base_document_url = (
4741
document_relative_path.relative_path.replace(
4742
"-DEEP-TRACE", ""
4743
)4744
)4745
document_type_to_generate = DocumentType.DEEPTRACE
4746
elif document_relative_path.relative_path.endswith(
4747
"-TRACE.html"4748
):4749
base_document_url = (
4750
document_relative_path.relative_path.replace(
4751
"-TRACE", ""
4752
)4753
)4754
document_type_to_generate = DocumentType.TRACE
4755
elif document_relative_path.relative_path.endswith(
4756
"-PDF.html"4757
):4758
if not project_config.is_activated_html2pdf():
4759
return Response(
4760
content="The HTML2PDF feature is not activated in the project config.",
4761
status_code=HTTP_STATUS_PRECONDITION_FAILED,
4762
)4763
base_document_url = (
4764
document_relative_path.relative_path.replace(
4765
"-PDF", ""
4766
)4767
)4768
document_type_to_generate = DocumentType.PDF
4769
else:
4770
# Either this is a normal document, or the path is broken.4771
base_document_url = document_relative_path.relative_path
4772
document_type_to_generate = DocumentType.DOCUMENT
4773
4774
document_tree = assert_cast(
4775
export_action.traceability_index.document_tree,
4776
DocumentTree,
4777
)4778
document = document_tree.map_docs_by_rel_paths.get(
4779
base_document_url4780
)4781
if document is None:
4782
return _error_response(HTTP_STATUS_NOT_FOUND)
4783
4784
assert document.meta is not None
4785
set_file_modification_time(
4786
document.meta.input_doc_full_path,
4787
datetime.datetime.today(),
4788
)4789
4790
html_generator.export_single_document_with_performance(
4791
document=document,
4792
traceability_index=export_action.traceability_index,
4793
specific_documents=(document_type_to_generate,),
4794
)4795
return None
4796
4797
response_or_none = generate_document()
4798
if response_or_none is not None:
4799
return response_or_none
4800
return FileResponse(
4801
full_path_to_document,
4802
media_type="text/html",
4803
headers={"Cache-Control": "no-cache"},
4804
)4805
4806
def get_asset(request: Request, url_to_asset: str) -> Response:
4807
project_output_path = project_config.export_output_html_root
4808
4809
static_file = os.path.join(project_output_path, url_to_asset)
4810
content_type, _ = guess_type(static_file)
4811
4812
with lock_manager.acquire_global_read():
4813
# We keep a global read lock here because this endpoint serves not4814
# only bundled immutable static files, but also generated assets4815
# under export_output_html_root that may be rewritten at runtime.4816
# FIXME: Revisit when asset writes are fully atomic and immutable4817
# from the reader's perspective, so this lock can potentially be4818
# narrowed or removed.4819
if not os.path.isfile(static_file):
4820
return _error_response(HTTP_STATUS_NOT_FOUND, path_type="asset")
4821
4822
if request_is_for_non_modified_file(request, static_file):
4823
return Response(status_code=304)
4824
4825
response = FileResponse(static_file, media_type=content_type)
4826
return response
4827
4828
def _compute_document_mid_lock_key(document_mid: str) -> str:
4829
return f"document:{document_mid}"
4830
4831
def _compute_document_relative_path_lock_key(relative_path: str) -> str:
4832
return f"document:{relative_path}"
4833
4834
def _compute_document_generation_lock_key(relative_path: str) -> str:
4835
return _compute_document_relative_path_lock_key(relative_path)
4836
4837
def _error_response(
4838
error_code: int, path_type: str = "document"
4839
) -> Response:
4840
view_object = ServerErrorViewObject(
4841
project_config=project_config,
4842
error_code=error_code,
4843
path_type=path_type,
4844
)4845
return Response(
4846
content=view_object.render_screen(env()),
4847
status_code=error_code,
4848
media_type="text/html",
4849
)4850
4851
# Websockets solution based on:4852
# https://fastapi.tiangolo.com/advanced/websockets/4853
class ConnectionManager:
4854
def __init__(self) -> None:
4855
self.active_connections: List[WebSocket] = []
4856
self._lock = asyncio.Lock()
4857
4858
async def connect(self, websocket: WebSocket) -> None:
4859
await websocket.accept()
4860
async with self._lock:
4861
self.active_connections.append(websocket)
4862
4863
async def disconnect(self, websocket: WebSocket) -> None:
4864
async with self._lock:
4865
if websocket in self.active_connections:
4866
self.active_connections.remove(websocket)
4867
4868
async def broadcast(self, message: str) -> None:
4869
async with self._lock:
4870
connections = list(self.active_connections)
4871
for connection in connections:
4872
await connection.send_text(message)
4873
4874
manager = ConnectionManager()
4875
4876
def rebuild_index_after_file_change() -> Optional[str]:
4877
try:
4878
with lock_manager.acquire_global_write():
4879
export_action.traceability_index = (
4880
TraceabilityIndexBuilder.create(
4881
project_config=project_config,
4882
parallelizer=parallelizer,
4883
)4884
)4885
return None
4886
except DocumentTreeError as document_tree_error:
4887
return document_tree_error.to_print_message()
4888
except Exception as build_error: # noqa: BLE001
4889
return str(build_error)
4890
4891
def notify_clients_after_file_change() -> None:
4892
build_error = rebuild_index_after_file_change()
4893
message = "reload" if build_error is None else f"error:{build_error}"
4894
if build_error is not None:
4895
print(f"WATCH: rebuild failed:\n{build_error}") # noqa: T201
4896
event_loop = getattr(app.state, "event_loop", None)
4897
if event_loop is not None:
4898
asyncio.run_coroutine_threadsafe(
4899
manager.broadcast(message), event_loop
4900
)4901
4902
if project_config.watch_enabled:
4903
app.state.document_watcher = DocumentWatcher(
4904
watch_paths=project_config.input_paths or [],
4905
output_dir_abs_path=project_config.output_dir,
4906
on_documents_changed=notify_clients_after_file_change,
4907
watched_extensions=get_watched_document_extensions(project_config),
4908
)4909
4910
@router.websocket("/ws/{client_id}")
4911
async def websocket_endpoint(websocket: WebSocket, client_id: int) -> None:
4912
await manager.connect(websocket)
4913
try:
4914
while True:
4915
_ = await websocket.receive_text()
4916
# Do nothing for now.4917
except WebSocketDisconnect:
4918
await manager.disconnect(websocket)
4919
await manager.broadcast(
4920
f"Websocket: Client #{client_id} disconnected"
4921
)4922
4923
return router