Coverage for liitos/approvals.py: 98.45%
155 statements
« prev ^ index » next coverage.py v7.6.4, created at 2024-11-10 18:56:07 +00:00
« prev ^ index » next coverage.py v7.6.4, created at 2024-11-10 18:56:07 +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 pathlib
35from typing import Union, no_type_check
37import liitos.gather as gat
38import liitos.template as tpl
39import liitos.tools as too
40from liitos import ENCODING, ExternalsType, KNOWN_APPROVALS_STRATEGIES, LOG_SEPARATOR, PathLike, OptionsType, log
42TOKEN_EXTRA_PUSHDOWN = r'\ExtraPushdown' # nosec B105
43EXTRA_OFFSET_EM = 24
44TOKEN = r'\ \mbox{THE.ROLE.SLOT} & \mbox{THE.NAME.SLOT} & \mbox{} \\[0.5ex]' # nosec B105
45ROW_TEMPLATE = r'\ \mbox{role} & \mbox{name} & \mbox{} \\[0.5ex]'
46GLUE = '\n\\hline\n'
47FORMAT_DATE = '%d %b %Y'
48JSON_CHANNEL = 'json'
49YAML_CHANNEL = 'yaml'
50COLUMNS_EXPECTED = ['name', 'role', 'orga']
51APPROVALS_CUT_MARKER_TOP = '% |-- approvals - cut - marker - top -->'
52APPROVALS_CUT_MARKER_BOTTOM = '% <-- approvals - cut - marker - bottom --|'
54LAYOUT_SOUTH_CUT_MARKER_TOP = '% |-- layout south - cut - marker - top -->'
55LAYOUT_SOUTH_CUT_MARKER_BOTTOM = '% <-- layout south - cut - marker - bottom --|'
57EASTERN_TABLE_MAX_MEMBERS = 4
58EASTERN_TOTAL_MAX_MEMBERS = EASTERN_TABLE_MAX_MEMBERS * 2
60NL = '\n'
61BASE_TABLE = r"""% |-- layout east - cut - marker - top -->
62\begin{small}
63\addtolength\aboverulesep{0.15ex} % extra spacing above and below rules
64\addtolength\belowrulesep{0.35ex}
65\begin{longtable}[]{|
66 >{\raggedright\arraybackslash}m{(\columnwidth - 12\tabcolsep) * \real{0.2000}}|% <- fixed
67$HEAD.BLOCK$}
68\hline
69\begin{minipage}[b]{\linewidth}\raggedright\ \centering \textbf{\theApprovalsDepartmentLabel}\end{minipage}%
70$ORGA.BLOCK$ \\[0.5ex]
71\hline
72\ \mbox{\textbf{\theApprovalsRoleLabel}}%
73$ROLE.BLOCK$
74 \\[0.5ex]
75\hline
76\ \mbox{\textbf{\theApprovalsNameLabel}}%
77$NAME.BLOCK$
78 \\[0.5ex]
79\hline
80\ \mbox{\textbf{Date}} \mbox{\textbf{\ \ \ \ \ \ }} \mbox{\textbf{\ Signature}}%
81$SIGN.BLOCK$
82 \\[0.5ex]
83\hline
85\end{longtable}
86\end{small}
87% <-- layout east - cut - marker - bottom --|
88"""
90HEAD_CELL = r' >{\raggedright\arraybackslash}m{(\columnwidth - 12\tabcolsep) * \real{0.2000}}|'
91ORGA_CELL = r' & \begin{minipage}[b]{\linewidth}\centering\arraybackslash \textbf{THE.ORGA$RANK$.SLOT}\end{minipage}'
92ROLE_CELL = r' & \centering\arraybackslash \textbf{THE.ROLE$RANK$.SLOT}'
93NAME_CELL = r' & \centering\arraybackslash THE.NAME$RANK$.SLOT'
94SIGN_CELL = r' & \mbox{}'
97def eastern_scaffold(normalized: list[dict[str, str]]) -> str:
98 """Inject the blocks derived from the approvals data to yield the fill-in scaffold."""
99 bearers = len(normalized)
100 table_max_members = EASTERN_TABLE_MAX_MEMBERS
101 total_max_members = EASTERN_TOTAL_MAX_MEMBERS
102 if bearers > total_max_members:
103 raise NotImplementedError(
104 f'Please use southwards layout for more than {total_max_members} role bearers;'
105 f' found ({bearers}) entries in approvals data source.'
106 )
108 # First up to 4 entries got into upper table and final upt to 4 entries (if any) to lower table
109 upper, lower = normalized[:table_max_members], normalized[table_max_members:]
110 uppers, lowers = len(upper), len(lower)
111 log.info(f'SPLIT {uppers}, {lowers}, {bearers}')
112 log.info(f'UPPER: {list(range(uppers))}')
113 head_block = (f'{HEAD_CELL}{NL}' * uppers).rstrip(NL)
114 orga_block = NL.join(ORGA_CELL.replace('$RANK$', str(slot)) for slot in range(uppers))
115 role_block = NL.join(ROLE_CELL.replace('$RANK$', str(slot)) for slot in range(uppers))
116 name_block = NL.join(NAME_CELL.replace('$RANK$', str(slot)) for slot in range(uppers))
117 sign_block = f'{SIGN_CELL}{NL}' * uppers
118 upper_table = (
119 BASE_TABLE.replace('$HEAD.BLOCK$', head_block)
120 .replace('$ORGA.BLOCK$', orga_block)
121 .replace('$ROLE.BLOCK$', role_block)
122 .replace('$NAME.BLOCK$', name_block)
123 .replace('$SIGN.BLOCK$', sign_block)
124 )
126 for thing in upper_table.split(NL):
127 log.debug(thing)
129 if not lowers:
130 return upper_table
132 log.info(f'LOWER: {list(range(uppers, bearers))}')
133 head_block = (f'{HEAD_CELL}{NL}' * lowers).rstrip(NL)
134 orga_block = NL.join(ORGA_CELL.replace('$RANK$', str(slot)) for slot in range(uppers, bearers))
135 role_block = NL.join(ROLE_CELL.replace('$RANK$', str(slot)) for slot in range(uppers, bearers))
136 name_block = NL.join(NAME_CELL.replace('$RANK$', str(slot)) for slot in range(uppers, bearers))
137 sign_block = f'{SIGN_CELL}{NL}' * lowers
139 lower_table = (
140 BASE_TABLE.replace('$HEAD.BLOCK$', head_block)
141 .replace('$ORGA.BLOCK$', orga_block)
142 .replace('$ROLE.BLOCK$', role_block)
143 .replace('$NAME.BLOCK$', name_block)
144 .replace('$SIGN.BLOCK$', sign_block)
145 )
147 for thing in lower_table.split(NL):
148 log.debug(thing)
150 return f'{upper_table}{NL}{lower_table}'
153def get_layout(layout_path: PathLike, target_key: str, facet_key: str) -> dict[str, dict[str, dict[str, bool]]]:
154 """Boolean layout decisions on bookmatter and publisher page conten.
156 Deprecated as the known use cases evolved into a different direction ...
157 """
158 layout = {'layout': {'global': {'has_approvals': True, 'has_changes': True, 'has_notices': True}}}
159 if layout_path:
160 log.info(f'loading layout from {layout_path=} for approvals')
161 return gat.load_layout(facet_key, target_key, layout_path)[0] # type: ignore
163 log.info('using default layout for approvals')
164 return layout
167def derive_model(model_path: PathLike) -> tuple[str, list[str]]:
168 """Derive the model as channel type and column model from the given path."""
169 channel = JSON_CHANNEL if str(model_path).endswith('.json') else YAML_CHANNEL
170 columns_expected = ['Approvals', 'Name'] if channel == JSON_CHANNEL else COLUMNS_EXPECTED
172 return channel, columns_expected
175def columns_are_present(columns_present: list[str], columns_expected: list[str]) -> bool:
176 """Ensure the needed columns are present."""
177 return all(column in columns_expected for column in columns_present)
180@no_type_check
181def normalize(signatures: object, channel: str, columns_expected: list[str]) -> list[dict[str, str]]:
182 """Normalize the channel specific topology of the model into a logical model.
184 On error an empty logical model is returned.
185 """
186 if channel == JSON_CHANNEL:
187 if not columns_are_present(signatures[0]['columns'], columns_expected):
188 log.error('unexpected column model!')
189 log.error(f'- expected: ({columns_expected})')
190 log.error(f'- but found: ({signatures[0]["columns"]})')
191 return []
193 if channel == YAML_CHANNEL:
194 for slot, approval in enumerate(signatures[0]['approvals'], start=1):
195 log.debug(f'{slot=}, {approval=}')
196 if not columns_are_present(approval, columns_expected):
197 log.error('unexpected column model!')
198 log.error(f'- expected: ({columns_expected})')
199 log.error(f'- but found: ({sorted(approval)}) in slot #{slot}')
200 return []
202 default_orga = r'\theApprovalsDepartmentValue'
204 if channel == JSON_CHANNEL:
205 return [
206 {
207 'orga': default_orga,
208 'role': role,
209 'name': name,
210 'orga_x_name': f'{default_orga} / {name}',
211 }
212 for role, name in signatures[0]['rows']
213 ]
215 return [
216 {
217 'orga': approval.get('orga', ''),
218 'role': approval['role'],
219 'name': approval['name'],
220 'orga_x_name': f"{approval.get('orga', default_orga)} / {approval['name']}",
221 }
222 for approval in signatures[0]['approvals']
223 ]
226def inject_southwards(lines: list[str], rows: list[str], pushdown: float) -> None:
227 """Deploy approvals data per southern layout strategy per updating the lines list in place."""
228 for n, line in enumerate(lines):
229 if TOKEN_EXTRA_PUSHDOWN in line:
230 lines[n] = line.replace(TOKEN_EXTRA_PUSHDOWN, f'{pushdown}em')
231 continue
232 if line == TOKEN:
233 lines[n] = GLUE.join(rows)
234 break
235 if lines[-1]: # Need separating empty line?
236 lines.append(NL)
239def inject_eastwards(lines: list[str], normalized: list[dict[str, str]], pushdown: float) -> None:
240 """Deploy approvals data per eastern layout strategy per updating the lines list in place."""
241 for n, line in enumerate(lines):
242 if TOKEN_EXTRA_PUSHDOWN in line:
243 lines[n] = line.replace(TOKEN_EXTRA_PUSHDOWN, f'{pushdown}em')
244 break
245 hack = eastern_scaffold(normalized)
246 log.info('logical model for approvals table is:')
247 for slot, entry in enumerate(normalized):
248 orga = entry['orga'] if entry['orga'] else r'\theApprovalsDepartmentValue'
249 log.info(f'- {entry["role"]} <-- {entry["name"]} (from {orga})')
250 hack = (
251 hack.replace(f'THE.ORGA{slot}.SLOT', orga)
252 .replace(f'THE.ROLE{slot}.SLOT', entry['role'])
253 .replace(f'THE.NAME{slot}.SLOT', entry['name'])
254 )
255 lines.extend(hack.split(NL))
256 if lines[-1]: # pragma: no cover
257 lines.append(NL)
260def weave(
261 doc_root: Union[str, pathlib.Path],
262 structure_name: str,
263 target_key: str,
264 facet_key: str,
265 options: OptionsType,
266 externals: ExternalsType,
267) -> int:
268 """Map the approvals data to a table on the titlepage."""
269 log.info(LOG_SEPARATOR)
270 log.info('entered signatures weave function ...')
271 structure, asset_map = gat.prelude(
272 doc_root=doc_root,
273 structure_name=structure_name,
274 target_key=target_key,
275 facet_key=facet_key,
276 command='approvals',
277 )
279 layout_path = asset_map[target_key][facet_key].get(gat.KEY_LAYOUT, '')
280 layout = get_layout(layout_path, target_key=target_key, facet_key=facet_key)
281 log.info(f'{layout=}')
283 log.info(LOG_SEPARATOR)
284 signatures_path = asset_map[target_key][facet_key][gat.KEY_APPROVALS]
285 channel, columns_expected = derive_model(signatures_path)
286 log.info(f'detected approvals channel ({channel}) weaving in from ({signatures_path})')
288 log.info(f'loading signatures from {signatures_path=}')
289 signatures = gat.load_approvals(facet_key, target_key, signatures_path)
290 log.info(f'{signatures=}')
292 log.info(LOG_SEPARATOR)
293 log.info('plausibility tests for approvals ...')
295 logical_model = normalize(signatures, channel=channel, columns_expected=columns_expected)
297 rows = [ROW_TEMPLATE.replace('role', kv['role']).replace('name', kv['name']) for kv in logical_model]
299 pushdown = EXTRA_OFFSET_EM - 2 * len(rows)
300 log.info(f'calculated extra pushdown to be {pushdown}em')
302 bookmatter_template_is_custom = bool(externals['bookmatter']['is_custom'])
303 bookmatter_template = str(externals['bookmatter']['id'])
304 bookmatter_path = pathlib.Path('render/pdf/bookmatter.tex')
306 bookmatter_template = tpl.load_resource(bookmatter_template, bookmatter_template_is_custom)
307 lines = [line.rstrip() for line in bookmatter_template.split('\n')]
309 if not layout['layout']['global']['has_approvals']: 309 ↛ 310line 309 didn't jump to line 310 because the condition on line 309 was never true
310 log.info('removing approvals from document layout')
311 lines = list(too.remove_target_region_gen(lines, APPROVALS_CUT_MARKER_TOP, APPROVALS_CUT_MARKER_BOTTOM))
313 log.info(LOG_SEPARATOR)
314 log.info(f'weaving in the approvals from {signatures_path}...')
315 approvals_strategy = options.get('approvals_strategy', KNOWN_APPROVALS_STRATEGIES[0])
316 log.info(f'selected approvals layout strategy is ({approvals_strategy})')
317 if approvals_strategy == 'south':
318 rows_patch = [
319 ROW_TEMPLATE.replace('role', kv['role']).replace('name', kv['orga_x_name']) for kv in logical_model
320 ]
321 inject_southwards(lines, rows_patch, pushdown)
322 else: # default is east
323 lines = list(too.remove_target_region_gen(lines, LAYOUT_SOUTH_CUT_MARKER_TOP, LAYOUT_SOUTH_CUT_MARKER_BOTTOM))
324 inject_eastwards(lines, logical_model, pushdown)
326 effective_path = pathlib.Path(layout_path).parent / bookmatter_path
327 log.info(f'Writing effective bookmatter file to ({effective_path})')
328 with open(effective_path, 'wt', encoding=ENCODING) as handle:
329 handle.write('\n'.join(lines))
330 log.info(LOG_SEPARATOR)
332 return 0