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

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 pathlib 

35from typing import Union, no_type_check 

36 

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 

41 

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

53 

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

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

56 

57EASTERN_TABLE_MAX_MEMBERS = 4 

58EASTERN_TOTAL_MAX_MEMBERS = EASTERN_TABLE_MAX_MEMBERS * 2 

59 

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 

84 

85\end{longtable} 

86\end{small} 

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

88""" 

89 

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

95 

96 

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 ) 

107 

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 ) 

125 

126 for thing in upper_table.split(NL): 

127 log.debug(thing) 

128 

129 if not lowers: 

130 return upper_table 

131 

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 

138 

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 ) 

146 

147 for thing in lower_table.split(NL): 

148 log.debug(thing) 

149 

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

151 

152 

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. 

155 

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 

162 

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

164 return layout 

165 

166 

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 

171 

172 return channel, columns_expected 

173 

174 

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) 

178 

179 

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. 

183 

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

192 

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

201 

202 default_orga = r'\theApprovalsDepartmentValue' 

203 

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 ] 

214 

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 ] 

224 

225 

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) 

237 

238 

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) 

258 

259 

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 ) 

278 

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

282 

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

287 

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

291 

292 log.info(LOG_SEPARATOR) 

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

294 

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

296 

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

298 

299 pushdown = EXTRA_OFFSET_EM - 2 * len(rows) 

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

301 

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

305 

306 bookmatter_template = tpl.load_resource(bookmatter_template, bookmatter_template_is_custom) 

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

308 

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

312 

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) 

325 

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) 

331 

332 return 0