Coverage for website/utils/snippets.py: 76.19%

92 statements  

« prev     ^ index     » next       coverage.py v7.6.7, created at 2025-08-14 10:31 +0000

1import datetime 

2import hmac 

3from base64 import urlsafe_b64decode, urlsafe_b64encode 

4from collections import namedtuple 

5from hashlib import sha1 

6 

7from django.conf import settings 

8from django.contrib.admin.models import LogEntry 

9from django.core.mail import EmailMultiAlternatives 

10from django.template import loader 

11from django.template.defaultfilters import urlencode 

12from django.templatetags.static import static 

13from django.utils import dateparse, timezone 

14 

15from rest_framework.exceptions import ParseError 

16 

17 

18def dict2obj(d, name="Object"): 

19 return namedtuple(name, d.keys())(*d.values()) 

20 

21 

22def strtobool(val): 

23 """Convert a string representation of truth to true (1) or false (0). 

24 

25 True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values 

26 are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if 

27 'val' is anything else. 

28 """ 

29 val = val.lower() 

30 if val in ("y", "yes", "t", "true", "on", "1"): 

31 return 1 

32 if val in ("n", "no", "f", "false", "off", "0"): 32 ↛ 34line 32 didn't jump to line 34 because the condition on line 32 was always true

33 return 0 

34 raise ValueError(f"invalid truth value {val}") 

35 

36 

37def datetime_to_lectureyear(date): 

38 """Convert a :class:`~datetime.date` to the start of the lectureyear. 

39 

40 >>> from datetime import date, datetime, timezone 

41 >>> nov_23 = date(1990, 11, 7) 

42 >>> datetime_to_lectureyear(nov_23) 

43 1990 

44 >>> mar_2 = date(1993, 3, 2) 

45 >>> datetime_to_lectureyear(mar_2) 

46 1992 

47 

48 Also works on :class:`~datetime.datetime`, but they need to be tz-aware: 

49 

50 >>> new_year = datetime(2000, 1, 1, tzinfo=timezone.utc) 

51 >>> datetime_to_lectureyear(new_year) 

52 1999 

53 """ 

54 if isinstance(date, timezone.datetime): 

55 date = timezone.localtime(date).date() 

56 sept_1 = timezone.make_aware(timezone.datetime(date.year, 9, 1)) 

57 if date < sept_1.date(): 

58 return date.year - 1 

59 return date.year 

60 

61 

62def create_google_maps_url(location, zoom, size): 

63 """Return a Google Maps URL for a given location. 

64 

65 The URL can be relative if it's a static file, so you may need to use 

66 request.build_absolute_uri() to get the full URL suitable for an API. 

67 """ 

68 if location.lower().strip() == "online": 68 ↛ 69line 68 didn't jump to line 69 because the condition on line 68 was never true

69 return static("img/locations/online.png") 

70 if location.lower().strip() == "discord": 70 ↛ 71line 70 didn't jump to line 71 because the condition on line 70 was never true

71 return static("img/locations/discord.png") 

72 

73 maps_url = ( 

74 f"/maps/api/staticmap?" 

75 f"center={ urlencode(location) }&" 

76 f"zoom={ zoom }&size={ size }&" 

77 f"markers={ urlencode(location) }&" 

78 f"key={ settings.GOOGLE_MAPS_API_KEY }" 

79 ) 

80 

81 decoded_key = urlsafe_b64decode(settings.GOOGLE_MAPS_API_SECRET) 

82 

83 signature = hmac.new(decoded_key, maps_url.encode(), sha1) 

84 

85 encoded_signature = urlsafe_b64encode(signature.digest()) 

86 

87 maps_url += f"&signature={encoded_signature.decode('utf-8')}" 

88 

89 return "https://maps.googleapis.com" + maps_url 

90 

91 

92def _extract_date(param): 

93 """Extract the date from an arbitrary string.""" 

94 if param is None: 

95 return None 

96 try: 

97 return dateparse.parse_datetime(param) 

98 except ValueError: 

99 return dateparse.parse_date(param) 

100 

101 

102def extract_date_range(request, allow_empty=False): 

103 """Extract a date range from an arbitrary string.""" 

104 default_value = None 

105 

106 start = request.query_params.get("start", default_value) 

107 if start or not allow_empty: 

108 try: 

109 start = dateparse.parse_datetime(start) 

110 if not timezone.is_aware(start): 110 ↛ 111line 110 didn't jump to line 111 because the condition on line 110 was never true

111 start = timezone.make_aware(start) 

112 except (ValueError, AttributeError, TypeError) as e: 

113 raise ParseError(detail="start query parameter invalid") from e 

114 

115 end = request.query_params.get("end", default_value) 

116 if end or not allow_empty: 

117 try: 

118 end = dateparse.parse_datetime(end) 

119 if not timezone.is_aware(end): 119 ↛ 120line 119 didn't jump to line 120 because the condition on line 119 was never true

120 end = timezone.make_aware(end) 

121 except (ValueError, AttributeError, TypeError) as e: 

122 raise ParseError(detail="end query parameter invalid") from e 

123 

124 return start, end 

125 

126 

127def overlaps(check, others, can_equal=True): 

128 """Check for overlapping date ranges. 

129 

130 This works by checking the maximum of the two `since` times, and the minimum of 

131 the two `until` times. Because there are no infinite dates, the value date_max 

132 is created for when the `until` value is None; this signifies a timespan that 

133 has not ended yet and is the maximum possible date in Python's datetime. 

134 

135 The ranges overlap when the maximum start time is smaller than the minimum 

136 end time, as can be seen in this example of two integer ranges: 

137 

138 check: . . . .[4]. . . . 9 

139 other: . . 2 . .[5]. . . . 

140 

141 check: . . . .[4]. . . . 9 

142 other: . . 2 . . . . . . . [date_max] 

143 

144 And when non overlapping: 

145 check: . . . . . .[6] . . 9 

146 other: . . 2 . .[5]. . . . 

147 

148 4 < 5 == True so these intervals overlap, while 6 < 5 == False so these intervals 

149 don't overlap 

150 

151 The can_equal argument is used for boards, where the end date can't be the same 

152 as the start date. 

153 

154 >>> overlaps( \ 

155 dict2obj({ \ 

156 'pk': 1 \ 

157 , 'since': datetime.date(2018, 12, 1) \ 

158 , 'until': datetime.date(2019, 1, 1) \ 

159 }) \ 

160 , [dict2obj({ \ 

161 'pk': 2 \ 

162 , 'since': datetime.date(2019, 1, 1) \ 

163 , 'until': datetime.date(2019, 1, 31) \ 

164 })]) 

165 False 

166 

167 >>> overlaps( \ 

168 dict2obj({ \ 

169 'pk': 1 \ 

170 , 'since': datetime.date(2018, 12, 1) \ 

171 , 'until': datetime.date(2019, 1, 1) \ 

172 }) \ 

173 , [dict2obj({ \ 

174 'pk': 2 \ 

175 , 'since': datetime.date(2019, 1, 1) \ 

176 , 'until': datetime.date(2019, 1, 31) \ 

177 })], False) 

178 True 

179 

180 >>> overlaps( \ 

181 dict2obj({ \ 

182 'pk': 1 \ 

183 , 'since': datetime.date(2018, 12, 1) \ 

184 , 'until': datetime.date(2019, 1, 2) \ 

185 }) \ 

186 , [dict2obj({ \ 

187 'pk': 2 \ 

188 , 'since': datetime.date(2019, 1, 1) \ 

189 , 'until': datetime.date(2019, 1, 31) \ 

190 })]) 

191 True 

192 """ 

193 date_max = datetime.date(datetime.MAXYEAR, 12, 31) 

194 for other in others: 

195 if check.pk == other.pk: 

196 # No checks for the object we're validating 

197 continue 

198 

199 max_start = max(check.since, other.since) 

200 min_end = min(check.until or date_max, other.until or date_max) 

201 

202 if max_start == min_end and not can_equal: 

203 return True 

204 if max_start < min_end: 

205 return True 

206 

207 return False 

208 

209 

210def send_email( 

211 to: list[str], 

212 subject: str, 

213 txt_template: str, 

214 context: dict, 

215 html_template: str | None = None, 

216 bcc: list[str] | None = None, 

217 from_email: str | None = None, 

218 connection=None, 

219) -> None: 

220 txt_message = loader.render_to_string(txt_template, context) 

221 

222 mail = EmailMultiAlternatives( 

223 subject=f"[THALIA] {subject}", 

224 body=txt_message, 

225 to=to, 

226 bcc=bcc, 

227 from_email=from_email, 

228 connection=connection, 

229 ) 

230 

231 if html_template is not None: 

232 html_message = loader.render_to_string(html_template, context) 

233 mail.attach_alternative(html_message, "text/html") 

234 

235 mail.send() 

236 

237 

238def minimise_logentries_data(dry_run=False): 

239 # Sometimes years are 366 days of course, but better delete 1 or 2 days early than late 

240 deletion_period = timezone.now().date() - timezone.timedelta(days=365 * 7) 

241 

242 qs = LogEntry.objects.filter(action_time__lte=deletion_period) 

243 if not dry_run: 

244 count, _ = qs.delete() 

245 else: 

246 count = qs.count() 

247 return count