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
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
1"""
2camcops_server/cc_modules/cc_filename.py
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
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.
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.
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/>.
24===============================================================================
26**Functions for handling filenames, and some associated constants.**
28"""
30import logging
31import os
32from typing import List, TYPE_CHECKING
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
42from camcops_server.cc_modules.cc_constants import DateFormat
43from camcops_server.cc_modules.cc_exception import STR_FORMAT_EXCEPTIONS
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 )
53log = BraceStyleAdapter(logging.getLogger(__name__))
56# =============================================================================
57# Ancillary functions for export filenames
58# =============================================================================
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 """
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
76class FilenameSpecElement(object):
77 """
78 Types of informatino that can be used to autogenerate a filename.
79 """
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
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
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
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.
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)
200 Returns:
201 the generated filename
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)
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
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