Coverage for liitos/approvals.py: 96.57%
162 statements
« prev ^ index » next coverage.py v7.6.8, created at 2024-11-25 15:36:16 +00:00
« prev ^ index » next coverage.py v7.6.8, created at 2024-11-25 15:36:16 +00:00
1"""Weave the content of the approvals data file into the output structure (for now LaTeX).
3# Supported Table Layouts
5# Layout `south` (the old default)
7| Approvals | Name | Date and Signature |
8|:-----------|:-------------|:-------------------|
9| Author | Au. Thor. | |
10| Reviewer | Re. Viewer. | |
11| Approver | Ap. Prover. | |
12| Authorizer | Au. Thorizer | |
14Table: The table is simple to grow by appending rows.
16The southern layout relies on the skeleton in the bookmatter.tex.in template.
18# Layout `east` (the new default)
20| Department | AB | AB | AB | AB. |
21|:--------------------|:--------:|:----------:|:----------:|:------------:|
22| Approvals | Author | Reviewer | Approver | Authorizer |
23| Name | Au. Thor | Re. Viewer | Ap. Prover | Au. Thorizer |
24| Date <br> Signature | | | | |
26Table: This table can only grow towards the right margin and a limit of only 4 role bearers is reasonable
28The eastern layout requires more dynamic LaTeX generation and thus generates the construct
29from the data inside this module.
31For more than 4 role bearers a second table should be placed below the first, to keep the cell content readable.
32"""
34import os
35import pathlib
36from typing import Union, no_type_check
38import liitos.gather as gat
39import liitos.template as tpl
40import liitos.tools as too
41from liitos import ENCODING, ExternalsType, KNOWN_APPROVALS_STRATEGIES, LOG_SEPARATOR, PathLike, OptionsType, log
43NO_DIG_SIG_FIELDS = bool(os.getenv('LIITOS_NO_DIG_SIG_FIELDS', ''))
45TOKEN_EXTRA_PUSHDOWN = r'\ExtraPushdown' # nosec B105
46EXTRA_OFFSET_EM = 24
47TOKEN = r'\ \mbox{THE.ROLE.SLOT} & \mbox{THE.NAME.SLOT} & \mbox{} \\[0.5ex]' # nosec B105
48DSF = r'\begin{Form}\hspace*{-2mm}\fbox{\digitalsignaturefield{71.10mm}{8.00mm}{name}}\end{Form}'
49if NO_DIG_SIG_FIELDS: 49 ↛ 50line 49 didn't jump to line 50 because the condition on line 49 was never true
50 DSF = r'\mbox{}'
51ROW_TEMPLATE = r'\ \mbox{role} & \mbox{name} & ' + DSF + r' \\[0.5ex]'
52GLUE = '\n\\hline\n'
53FORMAT_DATE = '%d %b %Y'
54JSON_CHANNEL = 'json'
55YAML_CHANNEL = 'yaml'
56COLUMNS_EXPECTED = ['name', 'role', 'orga']
57APPROVALS_CUT_MARKER_TOP = '% |-- approvals - cut - marker - top -->'
58APPROVALS_CUT_MARKER_BOTTOM = '% <-- approvals - cut - marker - bottom --|'
60LAYOUT_SOUTH_CUT_MARKER_TOP = '% |-- layout south - cut - marker - top -->'
61LAYOUT_SOUTH_CUT_MARKER_BOTTOM = '% <-- layout south - cut - marker - bottom --|'
63EASTERN_TABLE_MAX_MEMBERS = 4
64EASTERN_TOTAL_MAX_MEMBERS = EASTERN_TABLE_MAX_MEMBERS * 2
66NL = '\n'
67BASE_TABLE = r"""% |-- layout east - cut - marker - top -->
68\begin{small}
69\addtolength\aboverulesep{0.15ex} % extra spacing above and below rules
70\addtolength\belowrulesep{0.35ex}
71\begin{longtable}[]{|
72 >{\raggedright\arraybackslash}m{(\columnwidth - 12\tabcolsep) * \real{0.2000}}|% <- fixed
73$HEAD.BLOCK$}
74\hline
75\begin{minipage}[b]{\linewidth}\raggedright\ \centering \textbf{\theApprovalsDepartmentLabel}\end{minipage}%
76$ORGA.BLOCK$ \\[0.5ex]
77\hline
78\ \mbox{\textbf{\theApprovalsRoleLabel}}%
79$ROLE.BLOCK$
80 \\[0.5ex]
81\hline
82\ \mbox{\textbf{\theApprovalsNameLabel}}%
83$NAME.BLOCK$
84 \\[0.5ex]
85\hline
86\ \mbox{\textbf{Date}} \mbox{\textbf{\ \ \ \ \ \ }} \mbox{\textbf{\ Signature}}%
87$SIGN.BLOCK$
88 \\[0.5ex]
89\hline
91\end{longtable}
92\end{small}
93% <-- layout east - cut - marker - bottom --|
94"""
96HEAD_CELL = r' >{\raggedright\arraybackslash}m{(\columnwidth - 12\tabcolsep) * \real{0.2000}}|'
97ORGA_CELL = r' & \begin{minipage}[b]{\linewidth}\centering\arraybackslash \textbf{THE.ORGA$RANK$.SLOT}\end{minipage}'
98ROLE_CELL = r' & \centering\arraybackslash \textbf{THE.ROLE$RANK$.SLOT}'
99NAME_CELL = r' & \centering\arraybackslash THE.NAME$RANK$.SLOT'
100SIGN_CELL = (
101 r' & \begin{Form}\hspace*{-2mm}\fbox{\digitalsignaturefield{30.55mm}{6.05mm}{THE.NAME$RANK$.SLOT}}\end{Form}'
102)
103if NO_DIG_SIG_FIELDS: 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true
104 SIGN_CELL = r' & \mbox{}'
107def eastern_scaffold(normalized: list[dict[str, str]]) -> str:
108 """Inject the blocks derived from the approvals data to yield the fill-in scaffold."""
109 bearers = len(normalized)
110 table_max_members = EASTERN_TABLE_MAX_MEMBERS
111 total_max_members = EASTERN_TOTAL_MAX_MEMBERS
112 if bearers > total_max_members:
113 raise NotImplementedError(
114 f'Please use southwards layout for more than {total_max_members} role bearers;'
115 f' found ({bearers}) entries in approvals data source.'
116 )
118 # First up to 4 entries got into upper table and final upt to 4 entries (if any) to lower table
119 upper, lower = normalized[:table_max_members], normalized[table_max_members:]
120 uppers, lowers = len(upper), len(lower)
121 log.info(f'SPLIT {uppers}, {lowers}, {bearers}')
122 log.info(f'UPPER: {list(range(uppers))}')
123 head_block = (f'{HEAD_CELL}{NL}' * uppers).rstrip(NL)
124 orga_block = NL.join(ORGA_CELL.replace('$RANK$', str(slot)) for slot in range(uppers))
125 role_block = NL.join(ROLE_CELL.replace('$RANK$', str(slot)) for slot in range(uppers))
126 name_block = NL.join(NAME_CELL.replace('$RANK$', str(slot)) for slot in range(uppers))
127 sign_block = NL.join(SIGN_CELL.replace('$RANK$', str(slot)) for slot in range(uppers))
128 # sign_block = f'{SIGN_CELL}{NL}' * uppers
129 upper_table = (
130 BASE_TABLE.replace('$HEAD.BLOCK$', head_block)
131 .replace('$ORGA.BLOCK$', orga_block)
132 .replace('$ROLE.BLOCK$', role_block)
133 .replace('$NAME.BLOCK$', name_block)
134 .replace('$SIGN.BLOCK$', sign_block)
135 )
137 for thing in upper_table.split(NL):
138 log.debug(thing)
140 if not lowers:
141 return upper_table
143 log.info(f'LOWER: {list(range(uppers, bearers))}')
144 head_block = (f'{HEAD_CELL}{NL}' * lowers).rstrip(NL)
145 orga_block = NL.join(ORGA_CELL.replace('$RANK$', str(slot)) for slot in range(uppers, bearers))
146 role_block = NL.join(ROLE_CELL.replace('$RANK$', str(slot)) for slot in range(uppers, bearers))
147 name_block = NL.join(NAME_CELL.replace('$RANK$', str(slot)) for slot in range(uppers, bearers))
148 sign_block = NL.join(SIGN_CELL.replace('$RANK$', str(slot)) for slot in range(uppers, bearers))
149 # sign_block = f'{SIGN_CELL}{NL}' * lowers
151 lower_table = (
152 BASE_TABLE.replace('$HEAD.BLOCK$', head_block)
153 .replace('$ORGA.BLOCK$', orga_block)
154 .replace('$ROLE.BLOCK$', role_block)
155 .replace('$NAME.BLOCK$', name_block)
156 .replace('$SIGN.BLOCK$', sign_block)
157 )
159 for thing in lower_table.split(NL):
160 log.debug(thing)
162 return f'{upper_table}{NL}{lower_table}'
165def get_layout(layout_path: PathLike, target_key: str, facet_key: str) -> dict[str, dict[str, dict[str, bool]]]:
166 """Boolean layout decisions on bookmatter and publisher page conten.
168 Deprecated as the known use cases evolved into a different direction ...
169 """
170 layout = {'layout': {'global': {'has_approvals': True, 'has_changes': True, 'has_notices': True}}}
171 if layout_path:
172 log.info(f'loading layout from {layout_path=} for approvals')
173 return gat.load_layout(facet_key, target_key, layout_path)[0] # type: ignore
175 log.info('using default layout for approvals')
176 return layout
179def derive_model(model_path: PathLike) -> tuple[str, list[str]]:
180 """Derive the model as channel type and column model from the given path."""
181 channel = JSON_CHANNEL if str(model_path).endswith('.json') else YAML_CHANNEL
182 columns_expected = ['Approvals', 'Name'] if channel == JSON_CHANNEL else COLUMNS_EXPECTED
184 return channel, columns_expected
187def columns_are_present(columns_present: list[str], columns_expected: list[str]) -> bool:
188 """Ensure the needed columns are present."""
189 return all(column in columns_expected for column in columns_present)
192@no_type_check
193def normalize(signatures: object, channel: str, columns_expected: list[str]) -> list[dict[str, str]]:
194 """Normalize the channel specific topology of the model into a logical model.
196 On error an empty logical model is returned.
197 """
198 if channel == JSON_CHANNEL:
199 if not columns_are_present(signatures[0]['columns'], columns_expected):
200 log.error('unexpected column model!')
201 log.error(f'- expected: ({columns_expected})')
202 log.error(f'- but found: ({signatures[0]["columns"]})')
203 return []
205 if channel == YAML_CHANNEL:
206 for slot, approval in enumerate(signatures[0]['approvals'], start=1):
207 log.debug(f'{slot=}, {approval=}')
208 if not columns_are_present(approval, columns_expected):
209 log.error('unexpected column model!')
210 log.error(f'- expected: ({columns_expected})')
211 log.error(f'- but found: ({sorted(approval)}) in slot #{slot}')
212 return []
214 default_orga = r'\theApprovalsDepartmentValue'
216 if channel == JSON_CHANNEL:
217 return [
218 {
219 'orga': default_orga,
220 'role': role,
221 'name': name,
222 'orga_x_name': f'{default_orga} / {name}',
223 }
224 for role, name in signatures[0]['rows']
225 ]
227 return [
228 {
229 'orga': approval.get('orga', ''),
230 'role': approval['role'],
231 'name': approval['name'],
232 'orga_x_name': f"{approval.get('orga', default_orga)} / {approval['name']}",
233 }
234 for approval in signatures[0]['approvals']
235 ]
238def inject_southwards(lines: list[str], rows: list[str], pushdown: float) -> None:
239 """Deploy approvals data per southern layout strategy per updating the lines list in place."""
240 for n, line in enumerate(lines):
241 if TOKEN_EXTRA_PUSHDOWN in line:
242 lines[n] = line.replace(TOKEN_EXTRA_PUSHDOWN, f'{pushdown}em')
243 continue
244 if line == TOKEN:
245 lines[n] = GLUE.join(rows)
246 break
247 if lines[-1]: # Need separating empty line?
248 lines.append(NL)
251def inject_eastwards(lines: list[str], normalized: list[dict[str, str]], pushdown: float) -> None:
252 """Deploy approvals data per eastern layout strategy per updating the lines list in place."""
253 for n, line in enumerate(lines):
254 if TOKEN_EXTRA_PUSHDOWN in line:
255 lines[n] = line.replace(TOKEN_EXTRA_PUSHDOWN, f'{pushdown}em')
256 break
257 hack = eastern_scaffold(normalized)
258 log.info('logical model for approvals table is:')
259 for slot, entry in enumerate(normalized):
260 orga = entry['orga'] if entry['orga'] else r'\theApprovalsDepartmentValue'
261 log.info(f'- {entry["role"]} <-- {entry["name"]} (from {orga})')
262 hack = (
263 hack.replace(f'THE.ORGA{slot}.SLOT', orga)
264 .replace(f'THE.ROLE{slot}.SLOT', entry['role'])
265 .replace(f'THE.NAME{slot}.SLOT', entry['name'])
266 )
267 lines.extend(hack.split(NL))
268 if lines[-1]: # pragma: no cover
269 lines.append(NL)
272def weave(
273 doc_root: Union[str, pathlib.Path],
274 structure_name: str,
275 target_key: str,
276 facet_key: str,
277 options: OptionsType,
278 externals: ExternalsType,
279) -> int:
280 """Map the approvals data to a table on the titlepage."""
281 log.info(LOG_SEPARATOR)
282 log.info('entered signatures weave function ...')
283 structure, asset_map = gat.prelude(
284 doc_root=doc_root,
285 structure_name=structure_name,
286 target_key=target_key,
287 facet_key=facet_key,
288 command='approvals',
289 )
291 layout_path = asset_map[target_key][facet_key].get(gat.KEY_LAYOUT, '')
292 layout = get_layout(layout_path, target_key=target_key, facet_key=facet_key)
293 log.info(f'{layout=}')
295 log.info(LOG_SEPARATOR)
296 signatures_path = asset_map[target_key][facet_key][gat.KEY_APPROVALS]
297 channel, columns_expected = derive_model(signatures_path)
298 log.info(f'detected approvals channel ({channel}) weaving in from ({signatures_path})')
300 log.info(f'loading signatures from {signatures_path=}')
301 signatures = gat.load_approvals(facet_key, target_key, signatures_path)
302 log.info(f'{signatures=}')
304 log.info(LOG_SEPARATOR)
305 log.info('plausibility tests for approvals ...')
307 logical_model = normalize(signatures, channel=channel, columns_expected=columns_expected)
309 rows = [ROW_TEMPLATE.replace('role', kv['role']).replace('name', kv['name']) for kv in logical_model]
311 pushdown = EXTRA_OFFSET_EM - 2 * len(rows)
312 log.info(f'calculated extra pushdown to be {pushdown}em')
314 bookmatter_template_is_custom = bool(externals['bookmatter']['is_custom'])
315 bookmatter_template = str(externals['bookmatter']['id'])
316 bookmatter_path = pathlib.Path('render/pdf/bookmatter.tex')
318 bookmatter_template = tpl.load_resource(bookmatter_template, bookmatter_template_is_custom)
319 lines = [line.rstrip() for line in bookmatter_template.split('\n')]
321 if not layout['layout']['global']['has_approvals']: 321 ↛ 322line 321 didn't jump to line 322 because the condition on line 321 was never true
322 log.info('removing approvals from document layout')
323 lines = list(too.remove_target_region_gen(lines, APPROVALS_CUT_MARKER_TOP, APPROVALS_CUT_MARKER_BOTTOM))
325 log.info(LOG_SEPARATOR)
326 log.info(f'weaving in the approvals from {signatures_path}...')
327 approvals_strategy = options.get('approvals_strategy', KNOWN_APPROVALS_STRATEGIES[0])
328 log.info(f'selected approvals layout strategy is ({approvals_strategy})')
329 if approvals_strategy == 'south':
330 rows_patch = [
331 ROW_TEMPLATE.replace('role', kv['role']).replace('name', kv['orga_x_name']) for kv in logical_model
332 ]
333 inject_southwards(lines, rows_patch, pushdown)
334 else: # default is east
335 lines = list(too.remove_target_region_gen(lines, LAYOUT_SOUTH_CUT_MARKER_TOP, LAYOUT_SOUTH_CUT_MARKER_BOTTOM))
336 inject_eastwards(lines, logical_model, pushdown)
338 effective_path = pathlib.Path(layout_path).parent / bookmatter_path
339 log.info(f'Writing effective bookmatter file to ({effective_path})')
340 with open(effective_path, 'wt', encoding=ENCODING) as handle:
341 handle.write('\n'.join(lines))
342 log.info(LOG_SEPARATOR)
344 return 0