Coverage for cc_modules/cc_filename.py: 33%

96 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-15 14:23 +0100

1""" 

2camcops_server/cc_modules/cc_filename.py 

3 

4=============================================================================== 

5 

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

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

8 

9 This file is part of CamCOPS. 

10 

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

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

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

14 (at your option) any later version. 

15 

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

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

18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

19 GNU General Public License for more details. 

20 

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

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

23 

24=============================================================================== 

25 

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

27 

28""" 

29 

30import logging 

31import os 

32from typing import List, TYPE_CHECKING 

33 

34from cardinal_pythonlib.datetimefunc import ( 

35 format_datetime, 

36 get_now_localtz_pendulum, 

37) 

38from cardinal_pythonlib.logs import BraceStyleAdapter 

39from cardinal_pythonlib.stringfunc import mangle_unicode_to_ascii 

40from pendulum import Date, DateTime as Pendulum 

41 

42from camcops_server.cc_modules.cc_constants import DateFormat 

43from camcops_server.cc_modules.cc_exception import STR_FORMAT_EXCEPTIONS 

44 

45if TYPE_CHECKING: 

46 from camcops_server.cc_modules.cc_patientidnum import ( 

47 PatientIdNum, 

48 ) 

49 from camcops_server.cc_modules.cc_request import ( 

50 CamcopsRequest, 

51 ) 

52 

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

54 

55 

56# ============================================================================= 

57# Ancillary functions for export filenames 

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

59 

60 

61class PatientSpecElementForFilename(object): 

62 """ 

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

64 the "patient" part of a filename specification. 

65 """ 

66 

67 SURNAME = "surname" 

68 FORENAME = "forename" 

69 DOB = "dob" 

70 SEX = "sex" 

71 ALLIDNUMS = "allidnums" 

72 IDSHORTDESC_PREFIX = "idshortdesc" # special 

73 IDNUM_PREFIX = "idnum" # special 

74 

75 

76class FilenameSpecElement(object): 

77 """ 

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

79 """ 

80 

81 PATIENT = "patient" 

82 CREATED = "created" 

83 NOW = "now" 

84 TASKTYPE = "tasktype" 

85 SERVERPK = "serverpk" 

86 FILETYPE = "filetype" 

87 ANONYMOUS = "anonymous" 

88 # ... plus all those from PatientSpecElementForFilename 

89 

90 

91def patient_spec_for_filename_is_valid( 

92 patient_spec: str, valid_which_idnums: List[int] 

93) -> bool: 

94 """ 

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

96 ``False``. 

97 """ 

98 pse = PatientSpecElementForFilename 

99 testdict = { 

100 pse.SURNAME: "surname", 

101 pse.FORENAME: "forename", 

102 pse.DOB: "dob", 

103 pse.SEX: "sex", 

104 pse.ALLIDNUMS: "allidnums", 

105 } 

106 for n in valid_which_idnums: 

107 nstr = str(n) 

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

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

110 try: 

111 # Legal substitutions only? 

112 patient_spec.format(**testdict) 

113 return True 

114 except STR_FORMAT_EXCEPTIONS: # duff patient_spec; details unimportant 

115 return False 

116 

117 

118def filename_spec_is_valid( 

119 filename_spec: str, valid_which_idnums: List[int] 

120) -> bool: 

121 """ 

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

123 ``False``. 

124 """ 

125 pse = PatientSpecElementForFilename 

126 fse = FilenameSpecElement 

127 testdict = { 

128 # As above: 

129 pse.SURNAME: "surname", 

130 pse.FORENAME: "forename", 

131 pse.DOB: "dob", 

132 pse.SEX: "sex", 

133 pse.ALLIDNUMS: "allidnums", 

134 # Plus: 

135 fse.PATIENT: "patient", 

136 fse.CREATED: "created", 

137 fse.NOW: "now", 

138 fse.TASKTYPE: "tasktype", 

139 fse.SERVERPK: "serverpk", 

140 fse.FILETYPE: "filetype", 

141 fse.ANONYMOUS: "anonymous", 

142 } 

143 for n in valid_which_idnums: 

144 nstr = str(n) 

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

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

147 try: 

148 # Legal substitutions only? 

149 filename_spec.format(**testdict) 

150 return True 

151 except STR_FORMAT_EXCEPTIONS: # duff filename_spec; details unimportant 

152 return False 

153 

154 

155def get_export_filename( 

156 req: "CamcopsRequest", 

157 patient_spec_if_anonymous: str, 

158 patient_spec: str, 

159 filename_spec: str, 

160 filetype: str, 

161 is_anonymous: bool = False, 

162 surname: str = None, 

163 forename: str = None, 

164 dob: Date = None, 

165 sex: str = None, 

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

167 creation_datetime: Pendulum = None, 

168 basetable: str = None, 

169 serverpk: int = None, 

170 skip_conversion_to_safe_filename: bool = False, 

171) -> str: 

172 """ 

173 Get filename, for file exports/transfers. 

174 Also used for e-mail headers and bodies. 

175 

176 Args: 

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

178 patient_spec_if_anonymous: 

179 patient specification to be used for anonymous tasks 

180 patient_spec: 

181 patient specification to be used for patient-identifiable tasks 

182 filename_spec: 

183 specification to use to create the filename (may include 

184 patient information from the patient specification) 

185 filetype: 

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

187 is_anonymous: is it an anonymous task? 

188 surname: patient's surname 

189 forename: patient's forename 

190 dob: patient's date of birth 

191 sex: patient's sex 

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

193 creation_datetime: date/time the task was created 

194 basetable: name of the task's base table 

195 serverpk: server PK of the task 

196 skip_conversion_to_safe_filename: don't bother converting the result 

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

198 an e-mail subject) 

199 

200 Returns: 

201 the generated filename 

202 

203 """ 

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

205 pse = PatientSpecElementForFilename 

206 fse = FilenameSpecElement 

207 d = { 

208 pse.SURNAME: surname or "", 

209 pse.FORENAME: forename or "", 

210 pse.DOB: ( 

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

212 if dob 

213 else "" 

214 ), 

215 pse.SEX: sex or "", 

216 } 

217 all_id_components = [] 

218 for idobj in idnum_objects: 

219 if idobj.which_idnum is not None: 

220 nstr = str(idobj.which_idnum) 

221 has_num = idobj.idnum_value is not None 

222 d[pse.IDNUM_PREFIX + nstr] = ( 

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

224 ) 

225 d[pse.IDSHORTDESC_PREFIX + nstr] = ( 

226 idobj.short_description(req) or "" 

227 ) 

228 if has_num and idobj.short_description(req): 

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

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

231 if is_anonymous: 

232 patient = patient_spec_if_anonymous 

233 else: 

234 try: 

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

236 except STR_FORMAT_EXCEPTIONS: 

237 log.warning( 

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

239 ) 

240 patient = "invalid_patient_spec" 

241 d.update( 

242 { 

243 fse.PATIENT: patient, 

244 fse.CREATED: format_datetime( 

245 creation_datetime, DateFormat.FILENAME, "" 

246 ), 

247 fse.NOW: format_datetime( 

248 get_now_localtz_pendulum(), DateFormat.FILENAME 

249 ), 

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

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

252 fse.FILETYPE: filetype.lower(), 

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

254 } 

255 ) 

256 try: 

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

258 except STR_FORMAT_EXCEPTIONS: 

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

260 formatted = "invalid_filename_spec" 

261 if skip_conversion_to_safe_filename: 

262 return formatted 

263 return convert_string_for_filename(formatted, allow_paths=True) 

264 

265 

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

267 """ 

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

269 operating systems. 

270 """ 

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

272 # ... modified 

273 s = mangle_unicode_to_ascii(s) 

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

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

276 if allow_paths: 

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

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

279 return s 

280 

281 

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

283 """ 

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

285 """ 

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

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

288 return root + new_extension_with_dot