Coverage for cc_modules/cc_filename.py: 34%

97 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/cc_modules/cc_filename.py 

5 

6=============================================================================== 

7 

8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

10 

11 This file is part of CamCOPS. 

12 

13 CamCOPS is free software: you can redistribute it and/or modify 

14 it under the terms of the GNU General Public License as published by 

15 the Free Software Foundation, either version 3 of the License, or 

16 (at your option) any later version. 

17 

18 CamCOPS is distributed in the hope that it will be useful, 

19 but WITHOUT ANY WARRANTY; without even the implied warranty of 

20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

21 GNU General Public License for more details. 

22 

23 You should have received a copy of the GNU General Public License 

24 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>. 

25 

26=============================================================================== 

27 

28**Functions for handling filenames, and some associated constants.** 

29 

30""" 

31 

32import logging 

33import os 

34from typing import List, TYPE_CHECKING 

35 

36from cardinal_pythonlib.datetimefunc import ( 

37 format_datetime, 

38 get_now_localtz_pendulum, 

39) 

40from cardinal_pythonlib.logs import BraceStyleAdapter 

41from cardinal_pythonlib.stringfunc import mangle_unicode_to_ascii 

42from pendulum import Date, DateTime as Pendulum 

43 

44from camcops_server.cc_modules.cc_constants import DateFormat 

45from camcops_server.cc_modules.cc_exception import STR_FORMAT_EXCEPTIONS 

46 

47if TYPE_CHECKING: 

48 from camcops_server.cc_modules.cc_patientidnum import ( 

49 PatientIdNum, # noqa: F401 

50 ) 

51 from camcops_server.cc_modules.cc_request import ( 

52 CamcopsRequest, # noqa: F401 

53 ) 

54 

55log = BraceStyleAdapter(logging.getLogger(__name__)) 

56 

57 

58# ============================================================================= 

59# Ancillary functions for export filenames 

60# ============================================================================= 

61 

62 

63class PatientSpecElementForFilename(object): 

64 """ 

65 Parts of the patient information that can be used to autogenerate 

66 the "patient" part of a filename specification. 

67 """ 

68 

69 SURNAME = "surname" 

70 FORENAME = "forename" 

71 DOB = "dob" 

72 SEX = "sex" 

73 ALLIDNUMS = "allidnums" 

74 IDSHORTDESC_PREFIX = "idshortdesc" # special 

75 IDNUM_PREFIX = "idnum" # special 

76 

77 

78class FilenameSpecElement(object): 

79 """ 

80 Types of informatino that can be used to autogenerate a filename. 

81 """ 

82 

83 PATIENT = "patient" 

84 CREATED = "created" 

85 NOW = "now" 

86 TASKTYPE = "tasktype" 

87 SERVERPK = "serverpk" 

88 FILETYPE = "filetype" 

89 ANONYMOUS = "anonymous" 

90 # ... plus all those from PatientSpecElementForFilename 

91 

92 

93def patient_spec_for_filename_is_valid( 

94 patient_spec: str, valid_which_idnums: List[int] 

95) -> bool: 

96 """ 

97 Returns ``True`` if the ``patient_spec`` appears valid; otherwise 

98 ``False``. 

99 """ 

100 pse = PatientSpecElementForFilename 

101 testdict = { 

102 pse.SURNAME: "surname", 

103 pse.FORENAME: "forename", 

104 pse.DOB: "dob", 

105 pse.SEX: "sex", 

106 pse.ALLIDNUMS: "allidnums", 

107 } 

108 for n in valid_which_idnums: 

109 nstr = str(n) 

110 testdict[pse.IDSHORTDESC_PREFIX + nstr] = pse.IDSHORTDESC_PREFIX + nstr 

111 testdict[pse.IDNUM_PREFIX + nstr] = pse.IDNUM_PREFIX + nstr 

112 try: 

113 # Legal substitutions only? 

114 patient_spec.format(**testdict) 

115 return True 

116 except STR_FORMAT_EXCEPTIONS: # duff patient_spec; details unimportant 

117 return False 

118 

119 

120def filename_spec_is_valid( 

121 filename_spec: str, valid_which_idnums: List[int] 

122) -> bool: 

123 """ 

124 Returns ``True`` if the ``filename_spec`` appears valid; otherwise 

125 ``False``. 

126 """ 

127 pse = PatientSpecElementForFilename 

128 fse = FilenameSpecElement 

129 testdict = { 

130 # As above: 

131 pse.SURNAME: "surname", 

132 pse.FORENAME: "forename", 

133 pse.DOB: "dob", 

134 pse.SEX: "sex", 

135 pse.ALLIDNUMS: "allidnums", 

136 # Plus: 

137 fse.PATIENT: "patient", 

138 fse.CREATED: "created", 

139 fse.NOW: "now", 

140 fse.TASKTYPE: "tasktype", 

141 fse.SERVERPK: "serverpk", 

142 fse.FILETYPE: "filetype", 

143 fse.ANONYMOUS: "anonymous", 

144 } 

145 for n in valid_which_idnums: 

146 nstr = str(n) 

147 testdict[pse.IDSHORTDESC_PREFIX + nstr] = pse.IDSHORTDESC_PREFIX + nstr 

148 testdict[pse.IDNUM_PREFIX + nstr] = pse.IDNUM_PREFIX + nstr 

149 try: 

150 # Legal substitutions only? 

151 filename_spec.format(**testdict) 

152 return True 

153 except STR_FORMAT_EXCEPTIONS: # duff filename_spec; details unimportant 

154 return False 

155 

156 

157def get_export_filename( 

158 req: "CamcopsRequest", 

159 patient_spec_if_anonymous: str, 

160 patient_spec: str, 

161 filename_spec: str, 

162 filetype: str, 

163 is_anonymous: bool = False, 

164 surname: str = None, 

165 forename: str = None, 

166 dob: Date = None, 

167 sex: str = None, 

168 idnum_objects: List["PatientIdNum"] = None, 

169 creation_datetime: Pendulum = None, 

170 basetable: str = None, 

171 serverpk: int = None, 

172 skip_conversion_to_safe_filename: bool = False, 

173) -> str: 

174 """ 

175 Get filename, for file exports/transfers. 

176 Also used for e-mail headers and bodies. 

177 

178 Args: 

179 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

180 patient_spec_if_anonymous: 

181 patient specification to be used for anonymous tasks 

182 patient_spec: 

183 patient specification to be used for patient-identifiable tasks 

184 filename_spec: 

185 specification to use to create the filename (may include 

186 patient information from the patient specification) 

187 filetype: 

188 task output format and therefore file type (e.g. HTML, PDF, XML) 

189 is_anonymous: is it an anonymous task? 

190 surname: patient's surname 

191 forename: patient's forename 

192 dob: patient's date of birth 

193 sex: patient's sex 

194 idnum_objects: list of :class:`PatientIdNum` objects for the patient 

195 creation_datetime: date/time the task was created 

196 basetable: name of the task's base table 

197 serverpk: server PK of the task 

198 skip_conversion_to_safe_filename: don't bother converting the result 

199 to a safe filename (because it'll be used for something else, like 

200 an e-mail subject) 

201 

202 Returns: 

203 the generated filename 

204 

205 """ 

206 idnum_objects = idnum_objects or [] # type: List['PatientIdNum'] 

207 pse = PatientSpecElementForFilename 

208 fse = FilenameSpecElement 

209 d = { 

210 pse.SURNAME: surname or "", 

211 pse.FORENAME: forename or "", 

212 pse.DOB: ( 

213 format_datetime(dob, DateFormat.FILENAME_DATE_ONLY, "") 

214 if dob 

215 else "" 

216 ), 

217 pse.SEX: sex or "", 

218 } 

219 all_id_components = [] 

220 for idobj in idnum_objects: 

221 if idobj.which_idnum is not None: 

222 nstr = str(idobj.which_idnum) 

223 has_num = idobj.idnum_value is not None 

224 d[pse.IDNUM_PREFIX + nstr] = ( 

225 str(idobj.idnum_value) if has_num else "" 

226 ) 

227 d[pse.IDSHORTDESC_PREFIX + nstr] = ( 

228 idobj.short_description(req) or "" 

229 ) 

230 if has_num and idobj.short_description(req): 

231 all_id_components.append(idobj.get_filename_component(req)) 

232 d[pse.ALLIDNUMS] = "_".join(all_id_components) 

233 if is_anonymous: 

234 patient = patient_spec_if_anonymous 

235 else: 

236 try: 

237 patient = str(patient_spec).format(**d) 

238 except STR_FORMAT_EXCEPTIONS: 

239 log.warning( 

240 "Bad patient_spec: {!r}; dictionary was {!r}", patient_spec, d 

241 ) 

242 patient = "invalid_patient_spec" 

243 d.update( 

244 { 

245 fse.PATIENT: patient, 

246 fse.CREATED: format_datetime( 

247 creation_datetime, DateFormat.FILENAME, "" 

248 ), 

249 fse.NOW: format_datetime( 

250 get_now_localtz_pendulum(), DateFormat.FILENAME 

251 ), 

252 fse.TASKTYPE: str(basetable or ""), 

253 fse.SERVERPK: str(serverpk or ""), 

254 fse.FILETYPE: filetype.lower(), 

255 fse.ANONYMOUS: patient_spec_if_anonymous if is_anonymous else "", 

256 } 

257 ) 

258 try: 

259 formatted = str(filename_spec).format(**d) 

260 except STR_FORMAT_EXCEPTIONS: 

261 log.warning("Bad filename_spec: {!r}", filename_spec) 

262 formatted = "invalid_filename_spec" 

263 if skip_conversion_to_safe_filename: 

264 return formatted 

265 return convert_string_for_filename(formatted, allow_paths=True) 

266 

267 

268def convert_string_for_filename(s: str, allow_paths: bool = False) -> str: 

269 """ 

270 Remove characters that don't play nicely in filenames across multiple 

271 operating systems. 

272 """ 

273 # http://stackoverflow.com/questions/7406102 

274 # ... modified 

275 s = mangle_unicode_to_ascii(s) 

276 s = s.replace(" ", "_") 

277 keepcharacters = [".", "_", "-"] 

278 if allow_paths: 

279 keepcharacters.extend([os.sep]) # '/' under UNIX; '\' under Windows 

280 s = "".join(c for c in s if c.isalnum() or c in keepcharacters) 

281 return s 

282 

283 

284def change_filename_ext(filename: str, new_extension_with_dot: str) -> str: 

285 """ 

286 Replaces the extension, i.e. the part of the filename after its last '.'. 

287 """ 

288 (root, ext) = os.path.splitext(filename) 

289 # ... converts "blah.blah.txt" to ("blah.blah", ".txt") 

290 return root + new_extension_with_dot