Path:
strictdoc/export/html/generators/view_objects/document_screen_view_object.py
Lines:
950
Non-empty lines:
842
Non-empty lines covered with requirements:
842 / 842 (100.0%)
Functions:
65
Functions covered by requirements:
65 / 65 (100.0%)
1
"""2
@relation(SDOC-SRS-54, scope=file)3
"""4
5
from dataclasses import dataclass
6
from datetime import datetime
7
from enum import Enum
8
from typing import (
9
Any,
10
Dict,
11
Generator,
12
Iterator,
13
List,
14
Optional,
15
Sequence,
16
Set,
17
Tuple,
18
Union,
19
)20
21
from jinja2 import Template
22
from markupsafe import Markup, escape
23
24
from strictdoc import __version__
25
from strictdoc.backend.sdoc.free_text_reader import SDFreeTextReader
26
from strictdoc.backend.sdoc.models.anchor import Anchor
27
from strictdoc.backend.sdoc.models.document import SDocDocument
28
from strictdoc.backend.sdoc.models.document_view import ViewElement
29
from strictdoc.backend.sdoc.models.grammar_element import (
30
GrammarElement,
31
GrammarElementFieldMultipleChoice,
32
GrammarElementFieldSingleChoice,
33
GrammarElementFieldTag,
34
)35
from strictdoc.backend.sdoc.models.inline_link import InlineLink
36
from strictdoc.backend.sdoc.models.model import (
37
RequirementFieldName,
38
SDocDocumentIF,
39
SDocElementIF,
40
SDocNodeIF,
41
)42
from strictdoc.backend.sdoc.models.node import SDocNode, SDocNodeField
43
from strictdoc.core.document_iterator import DocumentIterationContext
44
from strictdoc.core.document_tree import DocumentTree
45
from strictdoc.core.document_tree_iterator import DocumentTreeIterator
46
from strictdoc.core.file_system.file_tree import File, FileOrFolderEntry, Folder
47
from strictdoc.core.project_config import ProjectConfig
48
from strictdoc.core.traceability_index import TraceabilityIndex
49
from strictdoc.export.html.document_type import DocumentType
50
from strictdoc.export.html.generators.view_objects.document_chunks import (
51
CHUNK_SIZE,
52
DocumentChunk,
53
slice_chunks,
54
)55
from strictdoc.export.html.generators.view_objects.helpers import (
56
screen_should_display_file,
57
screen_should_display_folder,
58
)59
from strictdoc.export.html.html_templates import HTMLTemplates, JinjaEnvironment
60
from strictdoc.export.html.renderers.html_fragment_writer import (
61
HTMLFragmentWriter,
62
)63
from strictdoc.export.html.renderers.link_renderer import LinkRenderer
64
from strictdoc.export.html.renderers.markup_renderer import MarkupRenderer
65
from strictdoc.helpers.cast import assert_cast
66
from strictdoc.helpers.file_system import file_open_read_utf8
67
from strictdoc.helpers.git_client import GitClient
68
from strictdoc.helpers.string import interpolate_at_pattern_lazy
69
from strictdoc.server.helpers.turbo import render_turbo_stream
70
71
72
class TableCellEditMode(str, Enum):
73
AUTOCOMPLETE = "autocomplete"
74
SINGLELINE = "singleline"
75
MULTILINE = "multiline"
76
READONLY = "readonly"
77
78
79
@dataclass80
class DocumentScreenViewObject:
81
def __init__(
82
self,
83
*,
84
document_type: DocumentType,
85
document: SDocDocument,
86
traceability_index: TraceabilityIndex,
87
project_config: ProjectConfig,
88
link_renderer: LinkRenderer,
89
markup_renderer: MarkupRenderer,
90
jinja_environment: JinjaEnvironment,
91
git_client: GitClient,
92
):93
self.document_type: DocumentType = document_type
94
self.link_document_type: DocumentType = DocumentType.DOCUMENT
95
self.document: SDocDocument = document
96
self.traceability_index: TraceabilityIndex = traceability_index
97
self.project_config: ProjectConfig = project_config
98
self.link_renderer: LinkRenderer = link_renderer
99
self.markup_renderer: MarkupRenderer = markup_renderer
100
self.jinja_environment: JinjaEnvironment = jinja_environment
101
self.git_client: GitClient = git_client
102
self.document_iterator = self.traceability_index.get_document_iterator(
103
self.document
104
)105
self.document_tree_iterator: DocumentTreeIterator = (
106
DocumentTreeIterator(
107
assert_cast(traceability_index.document_tree, DocumentTree)
108
)109
)110
self.current_view: ViewElement = document.view.get_current_view(
111
project_config.view
112
)113
self.is_running_on_server: bool = project_config.is_running_on_server
114
self.strictdoc_version = __version__
115
self._chunked_rendering: Optional[bool] = None
116
self._chunk_index_by_mid: Optional[Dict[str, int]] = None
117
118
self.custom_html2pdf_template: Optional[Template] = None
119
if project_config.html2pdf_template is not None:
120
with file_open_read_utf8(project_config.html2pdf_template) as f_:
121
self.custom_html2pdf_template = Template(f_.read())
122
123
def has_included_document(self) -> bool:
124
return len(self.document.included_documents) > 0
125
126
def render_screen(self) -> Markup:
127
if self.document_type.is_document():
128
if self.document.config.layout == "Website":
129
return self.jinja_environment.render_template_as_markup(
130
"website/document/index.jinja", view_object=self
131
)132
return self.jinja_environment.render_template_as_markup(
133
"screens/document/document/index.jinja", view_object=self
134
)135
elif self.document_type.is_table():
136
return self.jinja_environment.render_template_as_markup(
137
"screens/document/table/index.jinja", view_object=self
138
)139
elif self.document_type.is_trace():
140
return self.jinja_environment.render_template_as_markup(
141
"features/trace/index.jinja", view_object=self
142
)143
elif self.document_type.is_deeptrace():
144
return self.jinja_environment.render_template_as_markup(
145
"features/deep_trace/index.jinja",
146
view_object=self,
147
)148
elif self.document_type.is_pdf():
149
return self.jinja_environment.render_template_as_markup(
150
"features/html2pdf/index.jinja", view_object=self
151
)152
else:
153
raise NotImplementedError(self.document_type) # pragma: no cover
154
155
def render_updated_screen(self) -> Markup:
156
output = self.jinja_environment.render_template_as_markup(
157
"actions/"158
"document/"159
"create_requirement/"160
"stream_created_requirement.jinja.html",
161
view_object=self,
162
)163
164
output += self.jinja_environment.render_template_as_markup(
165
"actions/document/_shared/stream_updated_toc.jinja.html",
166
view_object=self,
167
)168
169
output += self.jinja_environment.render_template_as_markup(
170
"actions/document/_shared/stream_updated_viewtype_menu.jinja.html",
171
view_object=self,
172
)173
174
return output
175
176
def render_updated_nodes_and_toc(
177
self,
178
nodes: Sequence[Union[SDocDocument, SDocNode]],
179
node_updated: bool = False,
180
) -> str:
181
output: str = ""
182
183
if node_updated:
184
# The TOC is rendered before the individual nodes intentionally:185
# toc.jinja calls table_of_contents() -> all_content(), which186
# iterates every node and writes the correct title_number_string187
# into each node's context as a side effect. The node templates188
# rendered below then read those values and display the right189
# section numbers. Reversing this order would cause nodes whose190
# level changed (e.g. a title was added or removed) to render191
# with stale numbers.192
#193
# FIXME: This is a bit hacky. A cleaner solution would be to194
# separate the calculation of title_number_string from the rendering195
# of the TOC, so that the side effect is explicit and not tied to196
# the TOC template.197
toc_content = self.jinja_environment.render_template_as_markup(
198
"screens/document/_shared/toc.jinja", view_object=self
199
)200
output += render_turbo_stream(
201
content=toc_content,
202
action="update",
203
target="frame-toc",
204
)205
206
viewtype_menu_content = (
207
self.jinja_environment.render_template_as_markup(
208
"screens/document/_shared/viewtype_menu.jinja",
209
view_object=self,
210
)211
)212
output += render_turbo_stream(
213
content=viewtype_menu_content,
214
action="update",
215
target="frame-viewtype-menu",
216
)217
218
for node_ in nodes:
219
template_folder: str
220
if isinstance(node_, SDocDocument):
221
template_folder = "section"
222
elif isinstance(node_, SDocNode):
223
if node_.is_text_node():
224
template_folder = "text_node"
225
else:
226
template_folder = "node_content"
227
else:
228
raise NotImplementedError
229
content = self.jinja_environment.render_template_as_markup(
230
f"components/{template_folder}/index_extends_node.jinja",
231
view_object=self,
232
node=node_,
233
)234
output += render_turbo_stream(
235
content=content,
236
action="replace",
237
target=f"article-{node_.reserved_mid}",
238
)239
240
return output
241
242
def render_update_document_content_with_moved_node(
243
self, moved_node: Any
244
) -> Markup:
245
content = self.jinja_environment.render_template_as_markup(
246
"screens/document/document/frame_document_content.jinja.html",
247
view_object=self,
248
)249
output = render_turbo_stream(
250
content=content,
251
action="replace",
252
target="frame_document_content",
253
)254
toc_content = self.jinja_environment.render_template_as_markup(
255
"actions/document/_shared/stream_updated_toc.jinja.html",
256
view_object=self,
257
last_moved_node_id=moved_node.reserved_mid,
258
)259
output += render_turbo_stream(
260
toc_content,
261
action="update",
262
target="frame-toc",
263
)264
265
output += self.jinja_environment.render_template_as_markup(
266
"actions/document/_shared/stream_updated_viewtype_menu.jinja.html",
267
view_object=self,
268
)269
270
return output
271
272
def render_document_version(
273
self, included_document: Optional[SDocDocument] = None
274
) -> Optional[str]:
275
# 'document' is the main view document or an included document276
# (e.g., a bundle PDF member); defaults to the main view document.277
document_ = (
278
included_document279
if included_document is not None
280
else self.document
281
)282
if document_.config.version is None:
283
return None
284
285
def resolver(variable_name: str) -> str:
286
if variable_name == "GIT_VERSION":
287
return self.git_client.get_commit_hash()
288
elif variable_name == "GIT_BRANCH":
289
return self.git_client.get_branch()
290
return variable_name
291
292
return interpolate_at_pattern_lazy(document_.config.version, resolver)
293
294
def render_document_date(
295
self, included_document: Optional[SDocDocument] = None
296
) -> Optional[str]:
297
# 'document' is the main view document or an included document298
# (e.g., a bundle PDF member); defaults to the main view document.299
document_ = (
300
included_document301
if included_document is not None
302
else self.document
303
)304
if document_.config.date is None:
305
return None
306
307
def resolver(variable_name: str) -> str:
308
if variable_name == "GIT_COMMIT_DATE":
309
return self.git_client.get_commit_date()
310
elif variable_name == "GIT_COMMIT_DATETIME":
311
return self.git_client.get_commit_datetime()
312
return variable_name
313
314
return interpolate_at_pattern_lazy(document_.config.date, resolver)
315
316
def render_metadata_value(self, metadata_value: str) -> Markup:
317
"""
318
FIXME: Remove duplication of Git-resolvers in this class.319
"""320
321
def resolver(variable_name: str) -> str:
322
if variable_name == "GIT_VERSION":
323
return self.git_client.get_commit_hash()
324
elif variable_name == "GIT_BRANCH":
325
return self.git_client.get_branch()
326
elif variable_name == "GIT_COMMIT_DATE":
327
return self.git_client.get_commit_date()
328
elif variable_name == "GIT_COMMIT_DATETIME":
329
return self.git_client.get_commit_datetime()
330
return variable_name
331
332
free_text_container = SDFreeTextReader.read(metadata_value)
333
output_parts: List[str] = []
334
for part in free_text_container.parts:
335
if isinstance(part, str):
336
interpolated = interpolate_at_pattern_lazy(part, resolver)
337
output_parts.append(str(escape(interpolated)))
338
elif isinstance(part, InlineLink):
339
linkable_node = (
340
self.traceability_index.get_linkable_node_by_uid_weak(
341
part.link
342
)343
)344
if linkable_node is None:
345
# Dangling or not-yet-complete while the user is still346
# typing: degrade gracefully instead of crashing this347
# ad hoc, possibly unsaved render.348
output_parts.append(str(escape(f"[LINK: {part.link}]")))
349
else:
350
href = self.link_renderer.render_node_link(
351
linkable_node, self.document, self.document_type
352
)353
output_parts.append(
354
HTMLFragmentWriter.write_anchor_link(
355
linkable_node.get_display_title(), href
356
)357
)358
else:
359
# An Anchor can only be produced here by SDFreeTextReader360
# parsing a raw, not-yet-saved value in isolation (where361
# "[ANCHOR: ...]" happens to sit at the start of the362
# string). Persisted METADATA can never contain a real363
# Anchor: the main grammar embeds the value right after364
# "KEY: ", never at a line start, so it always parses as365
# plain text there. Render defensively as literal text.366
assert isinstance(part, Anchor)
367
output_parts.append(str(escape(f"[ANCHOR: {part.value}]")))
368
return Markup("".join(output_parts))
369
370
def is_empty_tree(self) -> bool:
371
return self.document_tree_iterator.is_empty_tree()
372
373
def is_deeptrace(self) -> bool:
374
return self.document_type.is_deeptrace()
375
376
def has_any_nodes(self) -> bool:
377
return self.document.has_any_nodes()
378
379
def iterator_files_first(self) -> Iterator[FileOrFolderEntry]:
380
yield from self.document_tree_iterator.iterator_files_first()
381
382
def render_url(self, url: str) -> Markup:
383
return Markup(self.link_renderer.render_url(url))
384
385
def render_node_link(
386
self, node: Union[SDocDocument, SDocNode, Anchor]
387
) -> str:
388
assert isinstance(node, (SDocDocument, SDocNode, Anchor)), node
389
return self.link_renderer.render_node_link(
390
node, self.document, self.document_type
391
)392
393
def render_document_link(
394
self,
395
document: SDocDocument,
396
context_document: SDocDocument,
397
document_type_string: str,
398
) -> str:
399
assert document is not None, document
400
return self.link_renderer.render_node_link(
401
document, context_document, DocumentType(document_type_string)
402
)403
404
def render_current_view_document_link(self, document: SDocDocument) -> str:
405
assert isinstance(document, SDocDocument), document
406
assert document.meta is not None
407
assert self.document.meta is not None
408
return document.meta.get_html_link(
409
self.document_type,
410
self.document.meta.level,
411
)412
413
def render_static_url(self, url: str) -> Markup:
414
return Markup(self.link_renderer.render_static_url(url))
415
416
def render_local_anchor(
417
self, node: Union[Anchor, SDocNode, SDocDocument]
418
) -> str:
419
return self.link_renderer.render_local_anchor(node)
420
421
def render_node_statement(self, node: SDocNode) -> Markup:
422
return self.markup_renderer.render_node_statement(
423
self.document_type, node
424
)425
426
def render_truncated_node_statement(self, node: SDocNode) -> Markup:
427
return self.markup_renderer.render_truncated_node_statement(
428
self.document_type, node
429
)430
431
def render_node_rationale(self, node: SDocNode) -> Markup:
432
return self.markup_renderer.render_node_rationale(
433
self.document_type, node
434
)435
436
def render_node_field(self, node_field: SDocNodeField) -> Markup:
437
assert isinstance(node_field, SDocNodeField), node_field
438
return self.markup_renderer.render_node_field(
439
self.document_type, node_field
440
)441
442
def render_issues(
443
self,
444
node: Union[SDocNodeIF, SDocDocumentIF],
445
field: Optional[str] = None,
446
) -> str:
447
issues = self.traceability_index.validation_index.get_issues(
448
node, field=field
449
)450
if issues is None:
451
return ""
452
issues_html = ""
453
for issue_ in issues:
454
issue_html = self.jinja_environment.render_template_as_markup(
455
"components/issue/index.jinja",
456
issue=issue_,
457
view_object=self,
458
)459
issues_html += issue_html
460
return issues_html
461
462
def get_page_title(self) -> str:
463
return self.document_type.get_page_title()
464
465
def get_document_level(self) -> int:
466
assert self.document.meta is not None
467
return self.document.meta.level
468
469
def date_today(self) -> str:
470
return datetime.today().strftime("%Y-%m-%d")
471
472
def get_document_by_path(self, full_path: str) -> SDocDocument:
473
return self.traceability_index.document_tree.get_document_by_path(
474
full_path475
)476
477
def get_grammar_elements(self) -> List[GrammarElement]:
478
assert self.document.grammar is not None
479
return self.document.grammar.elements
480
481
def table_of_contents(
482
self,
483
) -> Iterator[Tuple[SDocElementIF, DocumentIterationContext]]:
484
yield from self.document_iterator.table_of_contents()
485
486
def document_has_any_toc_nodes(self) -> bool:
487
return any(self.table_of_contents())
488
489
def document_content_iterator(
490
self,
491
) -> Iterator[Tuple[SDocElementIF, DocumentIterationContext]]:
492
yield from self.document_iterator.all_content(
493
print_fragments=True,
494
)495
496
def is_chunked_rendering(self) -> bool:
497
"""
498
Chunked mode: DOCUMENT screens for large documents only (both the499
FastAPI server and static HTML export - the two differ only in how a500
chunk's HTML reaches the browser, see chunk_frame_id_for() /501
static_chunk_relative_path()).502
503
Activates only when the document contains strictly more content nodes504
than the lazy_document_loading_threshold option value.505
506
The result is computed once and memoized because the node counting507
requires a full document walk.508
"""509
if self._chunked_rendering is not None:
510
return self._chunked_rendering
511
512
threshold = self.project_config.lazy_document_loading_threshold
513
if threshold == 0 or not self.document_type.is_document():
514
self._chunked_rendering = False
515
else:
516
self._chunked_rendering = self._count_content_nodes() > threshold
517
return self._chunked_rendering
518
519
def document_chunk_size(self) -> int:
520
"""
521
Effective chunk size used by the chunked rendering templates.522
523
The chunking threshold caps the chunk size: with a threshold lower524
than CHUNK_SIZE, a document that activates chunked rendering must525
still be split into more than one chunk.526
"""527
threshold = self.project_config.lazy_document_loading_threshold
528
if 0 < threshold < CHUNK_SIZE:
529
return threshold
530
return CHUNK_SIZE
531
532
def document_content_chunks(
533
self, chunk_size: Optional[int] = None
534
) -> List[DocumentChunk]:
535
"""
536
Recomputed per request; chunk cursors are node MIDs — see537
DocumentChunk.538
539
When chunk_size is not provided, the effective document chunk size540
is used, see document_chunk_size().541
"""542
if chunk_size is None:
543
chunk_size = self.document_chunk_size()
544
node_mids = [
545
assert_cast(node_, (SDocNodeIF, SDocDocumentIF)).reserved_mid
546
for node_, _ in self.document_content_iterator()
547
]548
return slice_chunks(node_mids, chunk_size)
549
550
def chunk_frame_id_for(
551
self, node: Union[SDocNodeIF, SDocDocumentIF]
552
) -> str:
553
"""
554
Frame id of the lazy chunk that contains node, e.g.555
"document-chunk-3", or "" when chunked rendering is inactive or the556
node is not part of the chunked content (the screen's own document557
root renders outside the chunk loop).558
559
Used by toc.jinja to stamp each TOC link so the deep-link script560
(toc_chunk_navigation.js) can force-load the chunk holding a target561
before scrolling to it.562
"""563
if not self.is_chunked_rendering():
564
return ""
565
index = self._chunk_index_by_node_mid().get(node.reserved_mid)
566
if index is None:
567
return ""
568
return f"document-chunk-{index}"
569
570
def _chunk_index_by_node_mid(self) -> Dict[str, int]:
571
# Maps each content node's reserved MID to its chunk index using the572
# same ordering and effective chunk size as document_content_chunks().573
# Built once per request and memoized.574
if self._chunk_index_by_mid is None:
575
chunk_size = self.document_chunk_size()
576
mapping: Dict[str, int] = {}
577
for position, (node_, _) in enumerate(
578
self.document_content_iterator()
579
):580
node_mid = assert_cast(
581
node_, (SDocNodeIF, SDocDocumentIF)
582
).reserved_mid
583
mapping[node_mid] = position // chunk_size
584
self._chunk_index_by_mid = mapping
585
return self._chunk_index_by_mid
586
587
def document_chunk_content_iterator(
588
self, from_node_mid: str, count: int
589
) -> Iterator[Tuple[SDocElementIF, DocumentIterationContext]]:
590
"""
591
Yield up to count (node, context) pairs, starting from the node whose592
reserved MID equals from_node_mid.593
594
The iteration always walks the document from its start because the595
per-node title numbering context must build up during the traversal.596
The nodes before the cursor node are skipped. If the cursor MID is not597
found, e.g., the node was deleted by a concurrent edit, nothing is598
yielded, and the caller is expected to render an empty fragment.599
Resolving a successor node for a stale cursor is a documented600
follow-up.601
602
``count`` must be positive; HTTP callers must validate/clamp603
untrusted input before calling (the fragment route owns that604
validation).605
"""606
assert count > 0, count
607
cursor_found = False
608
yielded = 0
609
for node_, context_ in self.document_content_iterator():
610
if not cursor_found:
611
node_mid = assert_cast(
612
node_, (SDocNodeIF, SDocDocumentIF)
613
).reserved_mid
614
if node_mid != from_node_mid:
615
continue616
cursor_found = True
617
yield node_, context_
618
yielded += 1
619
if yielded == count:
620
return621
622
def static_chunk_relative_path(self, chunk: DocumentChunk) -> str:
623
"""
624
Filename of the generated .js file that delivers this chunk's HTML625
for static export, e.g. "document-chunk-3.js". Written by626
DocumentHTMLGenerator.export next to the document's own HTML output,627
and referenced client-side by toc_chunk_navigation.js's static628
delivery path (no FastAPI server to fetch a fragment route from).629
"""630
assert self.document.meta is not None
631
return (
632
f"{self.document.meta.document_filename_base}"
633
f"-chunk-{chunk.index}.js"
634
)635
636
def static_chunk_key(self, chunk: DocumentChunk) -> str:
637
"""
638
Key under window.StrictDoc.chunks that the generated .js file for639
this chunk assigns its rendered HTML to.640
"""641
assert self.document.meta is not None
642
return (
643
f"{self.document.meta.document_filename_base}-chunk-{chunk.index}"
644
)645
646
def _count_content_nodes(self) -> int:
647
# This is a full document walk: no precomputed node count exists on648
# the traceability index. It is called once per request thanks to the649
# is_chunked_rendering() memoization. Note that the underlying650
# iterator re-patches node.context title numbering during the walk:651
# an idempotent mutation, the same one the render loop performs.652
return sum(1 for _ in self.document_content_iterator())
653
654
def should_display_folder(self, folder: Folder) -> bool:
655
return screen_should_display_folder(
656
folder,
657
self.traceability_index,
658
self.project_config,
659
must_only_include_non_included_sdoc=True,
660
)661
662
def should_display_file(self, file: File) -> bool:
663
return screen_should_display_file(
664
file,
665
self.traceability_index,
666
self.project_config,
667
must_only_include_non_included_sdoc=True,
668
)669
670
def should_display_included_documents_for_document(
671
self, document: SDocDocument
672
) -> bool:
673
return (
674
self.project_config.export_included_documents
675
and len(document.included_documents) > 0
676
)677
678
def should_display_stable_link(
679
self, node: Union[SDocDocument, SDocNode]
680
) -> bool:
681
assert isinstance(node, (SDocDocument, SDocNode)), node
682
return node.reserved_uid is not None
683
684
def get_stable_link(self, node: Union[SDocDocument, SDocNode]) -> str:
685
"""
686
Get a stable link for a given node.687
688
An example of a link produced: ../../#SDOC_UG_CONTACT689
The copy_to_clipboard.js script consumes this link and690
transforms it into a link like: http://127.0.0.1:5111/?a=SDOC_UG_CONTACT.691
"""692
693
assert isinstance(node, (SDocDocument, SDocNode)), node
694
base_url = self.link_renderer.render_url("")
695
if node.reserved_uid is not None:
696
return base_url + "#" + node.reserved_uid
697
if node.reserved_mid is not None and node.mid_permanent:
698
return base_url + "#" + node.reserved_mid
699
return base_url
700
701
def get_html2pdf_classes(self, node: SDocNode) -> str:
702
"""
703
Get CSS classes for html2pdf4doc for a given node.704
705
html2pdf4doc rules for `Narrative` requirement style:706
* If no multi-line content:707
the node is not split at all (with or without a title)708
-> add .html2pdf4doc-no-break to node.709
* ASSUMPTION:710
We assume that the number of single-line strings is no greater711
than the height of the page712
"""713
714
assert isinstance(node, SDocNode), node
715
716
html2pdf4doc_classes = []
717
718
if (
719
node.node_type
720
in self.project_config.html2pdf_forced_page_break_nodes
721
):722
html2pdf4doc_classes.append("sdoc-html2pdf4doc-break-before")
723
724
if not node.has_multiline_fields():
725
if node.get_requirement_style_mode() == "narrative":
726
html2pdf4doc_classes.append("html2pdf4doc-no-break")
727
728
# The section that does not break away from its children.729
if node.has_child_nodes():
730
html2pdf4doc_classes.append("html2pdf4doc-no-hanging")
731
732
return " ".join(html2pdf4doc_classes)
733
734
#735
# Document Level Actions736
#737
738
def can_edit_document(self, document: SDocDocument) -> bool:
739
"""
740
Determines if the document's root configuration (title, metadata) can be edited.741
"""742
return self.traceability_index.can_edit_document(document)
743
744
#745
# Node Level Actions746
#747
748
def can_edit_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
749
return self.traceability_index.can_edit_node(node)
750
751
def can_delete_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
752
return self.traceability_index.can_delete_node(node)
753
754
def can_clone_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
755
return self.traceability_index.can_clone_node(node)
756
757
def can_add_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
758
return self.traceability_index.can_add_node(node)
759
760
def can_insert_next_to_node(
761
self, node: Union[SDocDocument, SDocNode]
762
) -> bool:
763
return self.traceability_index.can_insert_next_to_node(node)
764
765
def get_table_first_editable_field_name(
766
self, element_type: str
767
) -> Optional[str]:
768
grammar = self.document.grammar
769
if grammar is None:
770
return None
771
element = grammar.elements_by_type.get(element_type)
772
if element is None:
773
return None
774
preferred_fields = (
775
"TITLE",
776
"STATEMENT",
777
"RATIONALE",
778
"DESCRIPTION",
779
"CONTENT",
780
"COMMENT",
781
)782
for field_name in preferred_fields:
783
if (
784
field_name in element.field_titles
785
and self.is_table_cell_editable(element_type, field_name)
786
):787
return field_name
788
for field_name in element.field_titles:
789
if field_name in ("UID", "MID", "LEVEL", "STATUS", "TAGS"):
790
continue791
if self.is_table_cell_editable(element_type, field_name):
792
return field_name
793
for field_name in element.field_titles:
794
if self.is_table_cell_editable(element_type, field_name):
795
return field_name
796
return None
797
798
def can_move_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
799
return self.traceability_index.can_move_node(node)
800
801
# Table editing802
#803
804
def get_table_cell_edit_mode(
805
self, element_type: str, field_name: str
806
) -> TableCellEditMode:
807
"""
808
Returns the editing mode for a table cell:809
AUTOCOMPLETE — SingleChoice / MultipleChoice / Tag field810
SINGLELINE — single-line STRING field (meta fields)811
MULTILINE — multi-line STRING field (STATEMENT, RATIONALE, COMMENT, custom content)812
READONLY — field not declared in grammar for this element type813
"""814
grammar = self.document.grammar
815
if grammar is None:
816
return TableCellEditMode.READONLY
817
element = grammar.elements_by_type.get(element_type)
818
if element is None:
819
return TableCellEditMode.READONLY
820
field = element.fields_map.get(field_name)
821
if field is None:
822
return TableCellEditMode.READONLY
823
if isinstance(
824
field,
825
(826
GrammarElementFieldSingleChoice,
827
GrammarElementFieldMultipleChoice,
828
GrammarElementFieldTag,
829
),830
):831
return TableCellEditMode.AUTOCOMPLETE
832
if element.is_field_multiline(field_name):
833
return TableCellEditMode.MULTILINE
834
return TableCellEditMode.SINGLELINE
835
836
def is_table_cell_editable(
837
self, element_type: str, field_name: str
838
) -> bool:
839
"""Returns True if the field is declared in grammar and can be edited on TABLE screen."""
840
if field_name == "RELATIONS":
841
grammar = self.document.grammar
842
if grammar is None:
843
return False
844
element = grammar.elements_by_type.get(element_type)
845
return element is not None and len(element.relations) > 0
846
return (
847
self.get_table_cell_edit_mode(element_type, field_name)
848
!= TableCellEditMode.READONLY
849
)850
851
def is_table_cell_multiple_choice(
852
self, element_type: str, field_name: str
853
) -> bool:
854
grammar = self.document.grammar
855
if grammar is None:
856
return False
857
element = grammar.elements_by_type.get(element_type)
858
if element is None:
859
return False
860
field = element.fields_map.get(field_name)
861
if field is None:
862
return False
863
return isinstance(
864
field,
865
(GrammarElementFieldMultipleChoice, GrammarElementFieldTag),
866
)867
868
def enumerate_table_columns(self) -> Generator[str, None, None]:
869
"""
870
Yields column identifiers for the TABLE screen in display order.871
872
Only yields columns that exist in at least one grammar element.873
Column order:874
1. Non-reserved meta fields (before TITLE/STATEMENT)875
2. RELATIONS — if any grammar element has relations876
3. TITLE — if any grammar element has TITLE877
4. STATEMENT — if any grammar element has STATEMENT878
5. RATIONALE — if any grammar element has RATIONALE879
6. COMMENT — if any grammar element has COMMENT880
7. Non-reserved content fields (after TITLE/STATEMENT)881
882
TYPE and LEVEL are not yielded — they are always present and883
rendered as fixed first columns in the template.884
"""885
assert self.document.grammar is not None
886
assert self.document.grammar.elements is not None
887
seen: Set[str] = set()
888
889
for element in self.document.grammar.elements:
890
for title in element.enumerate_table_meta_field_titles():
891
if title not in seen:
892
seen.add(title)
893
yield title
894
895
if any(element.relations for element in self.document.grammar.elements):
896
yield "RELATIONS"
897
898
for name in (
899
RequirementFieldName.TITLE,
900
RequirementFieldName.STATEMENT,
901
RequirementFieldName.RATIONALE,
902
RequirementFieldName.COMMENT,
903
):904
if any(
905
name in element.fields_map
906
for element in self.document.grammar.elements
907
):908
yield name
909
910
for element in self.document.grammar.elements:
911
for (
912
title913
) in element.enumerate_table_non_reserved_content_field_titles():
914
if title not in seen:
915
seen.add(title)
916
yield title
917
918
@staticmethod919
def create_for_table_screen(
920
*,
921
document: SDocDocument,
922
traceability_index: TraceabilityIndex,
923
project_config: ProjectConfig,
924
html_templates: HTMLTemplates,
925
git_client: GitClient,
926
jinja_environment: JinjaEnvironment,
927
) -> "DocumentScreenViewObject":
928
assert document.meta is not None
929
link_renderer = LinkRenderer(
930
root_path=document.meta.get_root_path_prefix(),
931
static_path=project_config.dir_for_sdoc_assets,
932
)933
markup_renderer = MarkupRenderer.create(
934
markup=document.config.get_markup(),
935
traceability_index=traceability_index,
936
link_renderer=link_renderer,
937
html_templates=html_templates,
938
config=project_config,
939
context_document=document,
940
)941
return DocumentScreenViewObject(
942
document_type=DocumentType.TABLE,
943
document=document,
944
traceability_index=traceability_index,
945
project_config=project_config,
946
link_renderer=link_renderer,
947
markup_renderer=markup_renderer,
948
jinja_environment=jinja_environment,
949
git_client=git_client,
950
)