Coverage for liitos/approvals.py: 98.13%

156 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2024-02-13 17:41:25 +00:00

1"""Weave the content of the approvals data file into the output structure (for now LaTeX). 

2 

3# Supported Table Layouts 

4 

5# Layout `south` (the old default) 

6 

7| Approvals | Name | Date and Signature | 

8|:-----------|:-------------|:-------------------| 

9| Author | Au. Thor. | | 

10| Reviewer | Re. Viewer. | | 

11| Approver | Ap. Prover. | | 

12| Authorizer | Au. Thorizer | | 

13 

14Table: The table is simple to grow by appending rows. 

15 

16The southern layout relies on the skeleton in the bookmatter.tex.in template. 

17 

18# Layout `east` (the new default) 

19 

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 | | | | | 

25 

26Table: This table can only grow towards the right margin and a limit of only 4 role bearers is reasonable 

27 

28The eastern layout requires more dynamic LaTeX generation and thus generates the construct 

29from the data inside this module. 

30 

31For more than 4 role bearers a second table should be placed below the first, to keep the cell content readable. 

32""" 

33 

34import os 

35import pathlib 

36from typing import Union, no_type_check 

37 

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 

42 

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 never false

46 BOOKMATTER_TEMPLATE = 'templates/bookmatter.tex.in' 

47 

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 --|' 

60 

61LAYOUT_SOUTH_CUT_MARKER_TOP = '% |-- layout south - cut - marker - top -->' 

62LAYOUT_SOUTH_CUT_MARKER_BOTTOM = '% <-- layout south - cut - marker - bottom --|' 

63 

64EASTERN_TABLE_MAX_MEMBERS = 4 

65EASTERN_TOTAL_MAX_MEMBERS = EASTERN_TABLE_MAX_MEMBERS * 2 

66 

67NL = '\n' 

68BASE_TABLE = r"""% |-- layout east - cut - marker - top --> 

69\begin{large} 

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\ \mbox{\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 

91 

92\end{longtable} 

93\end{large} 

94% <-- layout east - cut - marker - bottom --| 

95""" 

96 

97HEAD_CELL = r' >{\raggedright\arraybackslash}m{(\columnwidth - 12\tabcolsep) * \real{0.2000}}|' 

98ORGA_CELL = r' & \begin{minipage}[b]{\linewidth}\raggedright\mbox{\textbf{THE.ORGA$RANK$.SLOT}}\end{minipage}' 

99ROLE_CELL = r' & \mbox{THE.ROLE$RANK$.SLOT}' 

100NAME_CELL = r' & \mbox{THE.NAME$RANK$.SLOT}' 

101SIGN_CELL = r' & \mbox{}' 

102 

103 

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 ) 

114 

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 ) 

132 

133 for thing in upper_table.split(NL): 

134 log.debug(thing) 

135 

136 if not lowers: 

137 return upper_table 

138 

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 

145 

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 ) 

153 

154 for thing in lower_table.split(NL): 

155 log.debug(thing) 

156 

157 return f'{upper_table}{NL}{lower_table}' 

158 

159 

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. 

162 

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 

169 

170 log.info('using default layout for approvals') 

171 return layout 

172 

173 

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 

178 

179 return channel, columns_expected 

180 

181 

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) 

185 

186 

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. 

190 

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 [] 

199 

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 [] 

208 

209 if channel == JSON_CHANNEL: 

210 return [{'role': role, 'name': name} for role, name in signatures[0]['rows']] 

211 

212 return [ 

213 {'orga': approval.get('orga', ''), 'role': approval['role'], 'name': approval['name']} 

214 for approval in signatures[0]['approvals'] 

215 ] 

216 

217 

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) 

229 

230 

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) 

250 

251 

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 ) 

269 

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=}') 

273 

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})') 

278 

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=}') 

282 

283 log.info(LOG_SEPARATOR) 

284 log.info('plausibility tests for approvals ...') 

285 

286 logical_model = normalize(signatures, channel=channel, columns_expected=columns_expected) 

287 

288 rows = [ROW_TEMPLATE.replace('role', kv['role']).replace('name', kv['name']) for kv in logical_model] 

289 

290 pushdown = EXTRA_OFFSET_EM - 2 * len(rows) 

291 log.info(f'calculated extra pushdown to be {pushdown}em') 

292 

293 bookmatter_template = tpl.load_resource(BOOKMATTER_TEMPLATE, BOOKMATTER_TEMPLATE_IS_EXTERNAL) 

294 lines = [line.rstrip() for line in bookmatter_template.split('\n')] 

295 

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)) 

299 

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) 

309 

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) 

315 

316 return 0