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