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
« 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
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
15from rest_framework.exceptions import ParseError
18def dict2obj(d, name="Object"):
19 return namedtuple(name, d.keys())(*d.values())
22def strtobool(val):
23 """Convert a string representation of truth to true (1) or false (0).
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}")
37def datetime_to_lectureyear(date):
38 """Convert a :class:`~datetime.date` to the start of the lectureyear.
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
48 Also works on :class:`~datetime.datetime`, but they need to be tz-aware:
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
62def create_google_maps_url(location, zoom, size):
63 """Return a Google Maps URL for a given location.
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")
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 )
81 decoded_key = urlsafe_b64decode(settings.GOOGLE_MAPS_API_SECRET)
83 signature = hmac.new(decoded_key, maps_url.encode(), sha1)
85 encoded_signature = urlsafe_b64encode(signature.digest())
87 maps_url += f"&signature={encoded_signature.decode('utf-8')}"
89 return "https://maps.googleapis.com" + maps_url
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)
102def extract_date_range(request, allow_empty=False):
103 """Extract a date range from an arbitrary string."""
104 default_value = None
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
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
124 return start, end
127def overlaps(check, others, can_equal=True):
128 """Check for overlapping date ranges.
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.
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:
138 check: . . . .[4]. . . . 9
139 other: . . 2 . .[5]. . . .
141 check: . . . .[4]. . . . 9
142 other: . . 2 . . . . . . . [date_max]
144 And when non overlapping:
145 check: . . . . . .[6] . . 9
146 other: . . 2 . .[5]. . . .
148 4 < 5 == True so these intervals overlap, while 6 < 5 == False so these intervals
149 don't overlap
151 The can_equal argument is used for boards, where the end date can't be the same
152 as the start date.
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
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
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
199 max_start = max(check.since, other.since)
200 min_end = min(check.until or date_max, other.until or date_max)
202 if max_start == min_end and not can_equal:
203 return True
204 if max_start < min_end:
205 return True
207 return False
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)
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 )
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")
235 mail.send()
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)
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