Coverage for cc_modules/cc_filename.py: 33%

1"""

2 camcops_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

30 import logging

31 import os

32 from typing import List, TYPE_CHECKING

33

34 from cardinal_pythonlib.datetimefunc import (

35 format_datetime,

36 get_now_localtz_pendulum,

37)

38 from cardinal_pythonlib.logs import BraceStyleAdapter

39 from cardinal_pythonlib.stringfunc import mangle_unicode_to_ascii

40 from pendulum import Date, DateTime as Pendulum

41

42 from camcops_server.cc_modules.cc_constants import DateFormat

43 from camcops_server.cc_modules.cc_exception import STR_FORMAT_EXCEPTIONS

44

45 if 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

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

54

55

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

57# Ancillary functions for export filenames

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

59

60

61 class 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

76 class 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

91 def 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

118 def 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

155 def 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

266 def 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

282 def 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