Coverage for liitos/approvals.py: 96.32%

154 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-28 20:14:46 +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) -> list[str]: 

180 """Derive the column model from the given path.""" 

181 return COLUMNS_EXPECTED 

182 

183 

184def columns_are_present(columns_present: list[str], columns_expected: list[str]) -> bool: 

185 """Ensure the needed columns are present.""" 

186 return all(column in columns_expected for column in columns_present) 

187 

188 

189@no_type_check 

190def normalize(signatures: object, columns_expected: list[str]) -> list[dict[str, str]]: 

191 """Normalize the channel specific topology of the model into a logical model. 

192 

193 On error an empty logical model is returned. 

194 """ 

195 for slot, approval in enumerate(signatures[0]['approvals'], start=1): 

196 log.debug(f'{slot=}, {approval=}') 

197 if not columns_are_present(approval, columns_expected): 

198 log.error('unexpected column model!') 

199 log.error(f'- expected: ({columns_expected})') 

200 log.error(f'- but found: ({sorted(approval)}) in slot #{slot}') 

201 return [] 

202 

203 default_orga = r'\theApprovalsDepartmentValue' 

204 

205 return [ 

206 { 

207 'orga': approval.get('orga', ''), 

208 'role': approval['role'], 

209 'name': approval['name'], 

210 'orga_x_name': f"{approval.get('orga', default_orga)} / {approval['name']}", 

211 } 

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

213 ] 

214 

215 

216def inject_southwards(lines: list[str], rows: list[str], pushdown: float) -> None: 

217 """Deploy approvals data per southern layout strategy per updating the lines list in place.""" 

218 for n, line in enumerate(lines): 

219 if TOKEN_EXTRA_PUSHDOWN in line: 

220 lines[n] = line.replace(TOKEN_EXTRA_PUSHDOWN, f'{pushdown}em') 

221 continue 

222 if line == TOKEN: 

223 lines[n] = GLUE.join(rows) 

224 break 

225 if lines[-1]: # Need separating empty line? 

226 lines.append(NL) 

227 

228 

229def inject_eastwards(lines: list[str], normalized: list[dict[str, str]], pushdown: float) -> None: 

230 """Deploy approvals data per eastern layout strategy per updating the lines list in place.""" 

231 for n, line in enumerate(lines): 

232 if TOKEN_EXTRA_PUSHDOWN in line: 

233 lines[n] = line.replace(TOKEN_EXTRA_PUSHDOWN, f'{pushdown}em') 

234 break 

235 hack = eastern_scaffold(normalized) 

236 log.info('logical model for approvals table is:') 

237 for slot, entry in enumerate(normalized): 

238 orga = entry['orga'] if entry['orga'] else r'\theApprovalsDepartmentValue' 

239 log.info(f'- {entry["role"]} <-- {entry["name"]} (from {orga})') 

240 hack = ( 

241 hack.replace(f'THE.ORGA{slot}.SLOT', orga) 

242 .replace(f'THE.ROLE{slot}.SLOT', entry['role']) 

243 .replace(f'THE.NAME{slot}.SLOT', entry['name']) 

244 ) 

245 lines.extend(hack.split(NL)) 

246 if lines[-1]: # pragma: no cover 

247 lines.append(NL) 

248 

249 

250def weave( 

251 doc_root: Union[str, pathlib.Path], 

252 structure_name: str, 

253 target_key: str, 

254 facet_key: str, 

255 options: OptionsType, 

256 externals: ExternalsType, 

257) -> int: 

258 """Map the approvals data to a table on the titlepage.""" 

259 log.info(LOG_SEPARATOR) 

260 log.info('entered signatures weave function ...') 

261 structure, asset_map = gat.prelude( 

262 doc_root=doc_root, 

263 structure_name=structure_name, 

264 target_key=target_key, 

265 facet_key=facet_key, 

266 command='approvals', 

267 ) 

268 

269 layout_path = asset_map[target_key][facet_key].get(gat.KEY_LAYOUT, '') 

270 layout = get_layout(layout_path, target_key=target_key, facet_key=facet_key) 

271 log.info(f'{layout=}') 

272 

273 log.info(LOG_SEPARATOR) 

274 signatures_path = asset_map[target_key][facet_key][gat.KEY_APPROVALS] 

275 columns_expected = derive_model(signatures_path) 

276 log.info(f'weaving approvals in from ({signatures_path})') 

277 

278 log.info(f'loading signatures from {signatures_path=}') 

279 signatures = gat.load_approvals(facet_key, target_key, signatures_path) 

280 if not signatures[0] and ' json ' in signatures[1]: 

281 log.error(signatures[1]) 

282 return 2 

283 log.info(f'{signatures=}') 

284 

285 log.info(LOG_SEPARATOR) 

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

287 

288 logical_model = normalize(signatures, columns_expected=columns_expected) 

289 

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

291 

292 pushdown = EXTRA_OFFSET_EM - 2 * len(rows) 

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

294 

295 bookmatter_template_is_custom = bool(externals['bookmatter']['is_custom']) 

296 bookmatter_template = str(externals['bookmatter']['id']) 

297 bookmatter_path = pathlib.Path('render/pdf/bookmatter.tex') 

298 

299 bookmatter_template = tpl.load_resource(bookmatter_template, bookmatter_template_is_custom) 

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

301 

302 if not layout['layout']['global']['has_approvals']: 302 ↛ 303line 302 didn't jump to line 303 because the condition on line 302 was never true

303 log.info('removing approvals from document layout') 

304 lines = list(too.remove_target_region_gen(lines, APPROVALS_CUT_MARKER_TOP, APPROVALS_CUT_MARKER_BOTTOM)) 

305 

306 log.info(LOG_SEPARATOR) 

307 log.info(f'weaving in the approvals from {signatures_path}...') 

308 approvals_strategy = options.get('approvals_strategy', KNOWN_APPROVALS_STRATEGIES[0]) 

309 log.info(f'selected approvals layout strategy is ({approvals_strategy})') 

310 if approvals_strategy == 'south': 

311 rows_patch = [ 

312 ROW_TEMPLATE.replace('role', kv['role']).replace('name', kv['orga_x_name']) for kv in logical_model 

313 ] 

314 inject_southwards(lines, rows_patch, pushdown) 

315 else: # default is east 

316 lines = list(too.remove_target_region_gen(lines, LAYOUT_SOUTH_CUT_MARKER_TOP, LAYOUT_SOUTH_CUT_MARKER_BOTTOM)) 

317 inject_eastwards(lines, logical_model, pushdown) 

318 

319 effective_path = pathlib.Path(layout_path).parent / bookmatter_path 

320 log.info(f'Writing effective bookmatter file to ({effective_path})') 

321 with open(effective_path, 'wt', encoding=ENCODING) as handle: 

322 handle.write('\n'.join(lines)) 

323 log.info(LOG_SEPARATOR) 

324 

325 return 0