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

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, ExternalsType, KNOWN_APPROVALS_STRATEGIES, LOG_SEPARATOR, PathLike, OptionsType, log 

42 

43NO_DIG_SIG_FIELDS = bool(os.getenv('LIITOS_NO_DIG_SIG_FIELDS', '')) 

44 

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

59 

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

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

62 

63EASTERN_TABLE_MAX_MEMBERS = 4 

64EASTERN_TOTAL_MAX_MEMBERS = EASTERN_TABLE_MAX_MEMBERS * 2 

65 

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 

90 

91\end{longtable} 

92\end{small} 

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

94""" 

95 

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

105 

106 

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 ) 

117 

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 ) 

136 

137 for thing in upper_table.split(NL): 

138 log.debug(thing) 

139 

140 if not lowers: 

141 return upper_table 

142 

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 

150 

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 ) 

158 

159 for thing in lower_table.split(NL): 

160 log.debug(thing) 

161 

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

163 

164 

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. 

167 

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 

174 

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

176 return layout 

177 

178 

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 

183 

184 return channel, columns_expected 

185 

186 

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) 

190 

191 

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. 

195 

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

204 

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

213 

214 default_orga = r'\theApprovalsDepartmentValue' 

215 

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 ] 

226 

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 ] 

236 

237 

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) 

249 

250 

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) 

270 

271 

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 ) 

290 

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

294 

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

299 

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

303 

304 log.info(LOG_SEPARATOR) 

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

306 

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

308 

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

310 

311 pushdown = EXTRA_OFFSET_EM - 2 * len(rows) 

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

313 

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

317 

318 bookmatter_template = tpl.load_resource(bookmatter_template, bookmatter_template_is_custom) 

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

320 

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

324 

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) 

337 

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) 

343 

344 return 0