Coverage for cc_modules/cc_sms.py: 71%

55 statements  

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

1""" 

2camcops_server/cc_modules/cc_sms.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**Send SMS via supported backends** 

27 

28""" 

29 

30import logging 

31from typing import Any, Dict, Type 

32 

33import requests 

34from twilio.rest import Client 

35 

36from camcops_server.cc_modules.cc_constants import SmsBackendNames 

37 

38 

39_backends = {} 

40log = logging.getLogger(__name__) 

41 

42 

43class MissingBackendException(Exception): 

44 """ 

45 SMS backend not configured. 

46 """ 

47 

48 pass 

49 

50 

51class SmsBackend: 

52 """ 

53 Base class for sending SMS (text) messages. 

54 """ 

55 

56 def __init__(self, config: Dict[str, Any]) -> None: 

57 """ 

58 Args: 

59 config: 

60 Dictionary of parameters specific to the backend in use. 

61 """ 

62 self.config = config 

63 

64 def send_sms( 

65 self, recipient: str, message: str, sender: str = None 

66 ) -> None: 

67 """ 

68 Send an SMS message. 

69 

70 Args: 

71 recipient: 

72 Recipient's phone number, as a string. 

73 message: 

74 Message contents. 

75 sender: 

76 Sender's phone number, if applicable. 

77 """ 

78 raise NotImplementedError 

79 

80 

81class ConsoleSmsBackend(SmsBackend): 

82 """ 

83 Debugging "backend" -- just prints the message to the server console. 

84 """ 

85 

86 PREFIX = "SMS debugging: would have sent message" 

87 

88 @classmethod 

89 def make_msg(cls, recipient: str, message: str) -> str: 

90 """ 

91 Returns the message sent to the console. 

92 """ 

93 return f"{cls.PREFIX} {message!r} to {recipient}" 

94 

95 def send_sms( 

96 self, recipient: str, message: str, sender: str = None 

97 ) -> None: 

98 log.info(self.make_msg(recipient, message)) 

99 

100 

101class KapowSmsBackend(SmsBackend): 

102 """ 

103 Send SMS messages via Kapow. 

104 """ 

105 

106 API_URL = "https://www.kapow.co.uk/scripts/sendsms.php" 

107 # Parameters must be in lower case; see _read_sms_config(). 

108 PARAM_USERNAME = "username" 

109 PARAM_PASSWORD = "password" 

110 

111 def __init__(self, config: Dict[str, Any]) -> None: 

112 super().__init__(config) 

113 assert ( 

114 self.PARAM_USERNAME in config 

115 ), f"Kapow SMS: missing parameter {self.PARAM_USERNAME.upper()}" 

116 assert ( 

117 self.PARAM_PASSWORD in config 

118 ), f"Kapow SMS: missing parameter {self.PARAM_PASSWORD.upper()}" 

119 

120 def send_sms( 

121 self, recipient: str, message: str, sender: str = None 

122 ) -> None: 

123 data = { 

124 "username": self.config[self.PARAM_USERNAME], 

125 "password": self.config[self.PARAM_PASSWORD], 

126 "mobile": recipient, 

127 "sms": message, 

128 } 

129 requests.post(self.API_URL, data=data) 

130 

131 

132class TwilioSmsBackend(SmsBackend): 

133 """ 

134 Send SMS messages via Twilio SMS. 

135 """ 

136 

137 # Parameters must be in lower case; see _read_sms_config(). 

138 PARAM_SID = "sid" 

139 PARAM_TOKEN = "token" 

140 PARAM_FROM_PHONE_NUMBER = "from_phone_number" 

141 

142 def __init__(self, config: Dict[str, Any]) -> None: 

143 super().__init__(config) 

144 assert ( 

145 self.PARAM_SID in config 

146 ), f"Twilio SMS: missing parameter {self.PARAM_SID.upper()}" 

147 assert ( 

148 self.PARAM_TOKEN in config 

149 ), f"Twilio SMS: missing parameter {self.PARAM_TOKEN.upper()}" 

150 assert self.PARAM_FROM_PHONE_NUMBER in config, ( 

151 f"Twilio SMS: missing parameter " 

152 f"{self.PARAM_FROM_PHONE_NUMBER.upper()}" 

153 ) 

154 self.client = Client( 

155 username=self.config[self.PARAM_SID], 

156 password=self.config[self.PARAM_TOKEN], 

157 # account_sid: defaults to username 

158 ) 

159 

160 def send_sms( 

161 self, recipient: str, message: str, sender: str = None 

162 ) -> None: 

163 # Twilio accounts are associated with a phone number so we ignore 

164 # ``sender`` 

165 self.client.messages.create( 

166 to=recipient, 

167 body=message, 

168 from_=self.config[self.PARAM_FROM_PHONE_NUMBER], 

169 ) 

170 

171 

172def register_backend(name: str, backend_class: Type[SmsBackend]) -> None: 

173 """ 

174 Internal function to register an SMS backend by name. 

175 

176 Args: 

177 name: 

178 Name of backend (e.g. as referred to in the config file). 

179 backend_class: 

180 Appropriate subclass of :class:`SmsBackend`. 

181 """ 

182 global _backends 

183 _backends[name] = backend_class 

184 

185 

186register_backend(SmsBackendNames.CONSOLE, ConsoleSmsBackend) 

187register_backend(SmsBackendNames.KAPOW, KapowSmsBackend) 

188register_backend(SmsBackendNames.TWILIO, TwilioSmsBackend) 

189 

190 

191def get_sms_backend(label: str, config: Dict[str, Any]) -> SmsBackend: 

192 """ 

193 Make an instance of an SMS backend by name, passing it appropriate 

194 backend-specific config options. 

195 """ 

196 try: 

197 backend_class = _backends[label] 

198 except KeyError: 

199 raise MissingBackendException(f"No backend {label!r} registered") 

200 

201 return backend_class(config)