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