Skip to content

Commit 913095c

Browse files
authored
Merge pull request #147 from City-of-Helsinki/release/1.5.0
Release 1.5.0
2 parents c73b1ab + c739045 commit 913095c

33 files changed

Lines changed: 750 additions & 70 deletions

.prod/uwsgi.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ processes = 2
1111
threads = 2
1212
cron = -1 -1 -1 -1 -1 /app/manage.py send_mail
1313
cron = -20 -1 -1 -1 -1 /app/manage.py retry_deferred
14-
cron = 0 0 -1 -1 -1 /app/manage.py purge_mail_log 7
14+
cron = 0 12 -1 -1 -1 /app/manage.py send_reminder_notifications

CHANGELOG.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
<!-- REMINDER: While updating changelog, also remember to update
22
the version in kukkuu/__init.py__ -->
33

4+
## [1.5.0] - 30 Sep 2020
5+
### Added
6+
- Add upcoming occurrence reminder notification
7+
- Add `upcoming_with_leeway` occurrence filter
8+
- Add enrolled events past enough (default 30 mins from the start) to a child's past events
9+
- Add initial API for subscribing and viewing free spot subscriptions (N.B. the functionality itself has NOT been implemented yet, just the API)
10+
### Changed
11+
- Do not purge email logs by default in CI/CD config
12+
413
## [1.4.0] - 15 Sep 2020
514
### Added
615
- Add nullable field `capacityOverride` and API for it which allows setting capacity per occurrence
@@ -101,7 +110,8 @@ the version in kukkuu/__init.py__ -->
101110

102111

103112

104-
[Unreleased]: https://github.com/City-of-Helsinki/kukkuu/compare/v1.4.0...HEAD
113+
[Unreleased]: https://github.com/City-of-Helsinki/kukkuu/compare/v1.5.0...HEAD
114+
[1.4.0]: https://github.com/City-of-Helsinki/kukkuu/compare/v1.4.0...v1.5.0
105115
[1.4.0]: https://github.com/City-of-Helsinki/kukkuu/compare/v1.3.0...v1.4.0
106116
[1.3.0]: https://github.com/City-of-Helsinki/kukkuu/compare/v1.2.0...v1.3.0
107117
[1.2.0]: https://github.com/City-of-Helsinki/kukkuu/compare/v1.1.0...v1.2.0

README.md

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,19 +50,29 @@ Add default languages (optional)
5050
python manage.py add_languages --default
5151

5252
### Cron jobs
53-
By default email sending won't be queued. In case you want to queue emails:
54-
- In `settings.py` configure `ILMOITIN_QUEUE_NOTIFICATIONS` to `True`
55-
- Install `cron` in your host machine
56-
- Add a crontab to execute the email delivery, here is an example:
53+
`cron` is required for sending reminder notifications, and for sending emails queued (optional).
54+
55+
#### Reminder notifications
56+
57+
To send reminder notifications on time, `send_reminder_notifications` management command needs to be executed (at least) daily.
58+
59+
Example crontab for sending reminder notifications every day at 12am:
60+
61+
0 12 * * * (/path/to/your/python path/to/your/app/manage.py send_reminder_notifications > /var/log/cron.log 2>&1)
62+
# An empty line is required at the end of this file for a valid cron file.
63+
64+
#### Queued email sending
65+
66+
By default email sending won't be queued. To enable queued emails, configure setting `ILMOITIN_QUEUE_NOTIFICATIONS` to `True`, and set `send_mail` and `retry_deferred` to be executed periodically.
67+
68+
Example crontab for queued emails (includes reminder notification sending as well):
5769

58-
```
59-
* * * * * (/path/to/your/python path/to/your/app/manage.py send_mail > /var/log/cron.log 2>&1)
70+
* * * * * (/path/to/your/python path/to/your/app/manage.py send_mail > /var/log/cron.log 2>&1)
6071
0,20,40 * * * * (/path/to/your/python path/to/your/app/manage.py retry_deferred > /var/log/cron.log 2>&1)
6172
0 0 * * * (/path/to/your/python path/to/your/app/manage.py purge_mail_log 7 > /var/log/cron.log 2>&1)
73+
0 12 * * * (/path/to/your/python path/to/your/app/manage.py send_reminder_notifications > /var/log/cron.log 2>&1)
6274
# An empty line is required at the end of this file for a valid cron file.
6375

64-
```
65-
6676
### Daily running, Debugging
6777

6878
* Create `.env` file: `touch .env`

children/admin.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.contrib import admin
22
from django.utils.translation import ugettext_lazy as _
33
from languages.models import Language
4+
from subscriptions.models import FreeSpotNotificationSubscription
45

56
from events.models import Enrolment
67

@@ -30,6 +31,12 @@ class LanguagesSpokenAtHomeInline(admin.TabularInline):
3031
verbose_name_plural = _("Languages spoken at home")
3132

3233

34+
class SubscriptionInline(admin.TabularInline):
35+
model = FreeSpotNotificationSubscription
36+
extra = 0
37+
readonly_fields = ("created_at",)
38+
39+
3340
@admin.register(Child)
3441
class ChildAdmin(admin.ModelAdmin):
3542
list_display = (
@@ -45,7 +52,12 @@ class ChildAdmin(admin.ModelAdmin):
4552
list_select_related = ("project",)
4653
fields = ("project", "first_name", "last_name", "birthdate", "postal_code")
4754
search_fields = ("first_name", "last_name")
48-
inlines = (RelationshipInline, EnrolmentInline, LanguagesSpokenAtHomeInline)
55+
inlines = (
56+
RelationshipInline,
57+
EnrolmentInline,
58+
LanguagesSpokenAtHomeInline,
59+
SubscriptionInline,
60+
)
4961

5062
def get_project_year(self, obj):
5163
return obj.project.year

children/schema.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from datetime import timedelta
23

34
import graphene
45
from django.conf import settings
@@ -56,6 +57,24 @@ class Meta:
5657
model = Child
5758
interfaces = (relay.Node,)
5859
connection_class = ChildrenConnection
60+
fields = (
61+
"id",
62+
"created_at",
63+
"updated_at",
64+
"first_name",
65+
"last_name",
66+
"postal_code",
67+
"birthdate",
68+
"guardians",
69+
"project",
70+
"languages_spoken_at_home",
71+
"relationships",
72+
"occurrences",
73+
"enrolments",
74+
"available_events",
75+
"past_events",
76+
"free_spot_notification_subscriptions",
77+
)
5978
filter_fields = ("project_id",)
6079

6180
@classmethod
@@ -74,13 +93,30 @@ def get_node(cls, info, id):
7493
return None
7594

7695
def resolve_past_events(self, info, **kwargs):
77-
return (
78-
self.project.events.user_can_view(info.context.user)
79-
.published()
80-
.exclude(occurrences__time__gte=timezone.now())
81-
.distinct()
96+
"""
97+
Past events include:
98+
99+
1) Events the user has not enrolled AND are completely (all occurrences) in the
100+
past
101+
2) Events the user has enrolled AND the occurrence of the enrolment is more than
102+
KUKKUU_ENROLLED_OCCURRENCE_IN_PAST_LEEWAY mins in the past.
103+
"""
104+
events = self.project.events.user_can_view(info.context.user).published()
105+
106+
past_unparticipated_events = events.exclude(
107+
occurrences__in=self.occurrences.all()
108+
).exclude(occurrences__time__gte=timezone.now())
109+
110+
past_enough_enrolled_occurrences = self.occurrences.filter(
111+
time__lt=timezone.now()
112+
- timedelta(minutes=settings.KUKKUU_ENROLLED_OCCURRENCE_IN_PAST_LEEWAY)
113+
)
114+
past_enough_participated_events = events.filter(
115+
occurrences__in=past_enough_enrolled_occurrences
82116
)
83117

118+
return past_unparticipated_events | past_enough_participated_events
119+
84120
def resolve_available_events(self, info, **kwargs):
85121
return (
86122
self.project.events.user_can_view(info.context.user)

children/tests/snapshots/snap_test_api.py

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -378,27 +378,6 @@
378378
}
379379
}
380380

381-
snapshots["test_get_past_events 1"] = {
382-
"data": {
383-
"child": {
384-
"availableEvents": {"edges": []},
385-
"occurrences": {"edges": [{"node": {"time": "1969-12-31T22:20:00+00:00"}}]},
386-
"pastEvents": {
387-
"edges": [
388-
{
389-
"node": {
390-
"createdAt": "2020-12-12T00:00:00+00:00",
391-
"occurrences": {
392-
"edges": [{"node": {"remainingCapacity": 0}}]
393-
},
394-
}
395-
}
396-
]
397-
},
398-
}
399-
}
400-
}
401-
402381
snapshots["test_children_project_filter 1"] = {
403382
"data": {
404383
"children": {
@@ -539,3 +518,59 @@
539518
}
540519
}
541520
}
521+
522+
snapshots["test_get_past_events 1"] = {
523+
"data": {
524+
"child": {
525+
"availableEvents": {
526+
"edges": [
527+
{
528+
"node": {
529+
"createdAt": "2020-12-12T00:00:00+00:00",
530+
"occurrences": {
531+
"edges": [
532+
{"node": {"remainingCapacity": 0}},
533+
{"node": {"remainingCapacity": 0}},
534+
]
535+
},
536+
}
537+
}
538+
]
539+
},
540+
"occurrences": {
541+
"edges": [
542+
{"node": {"time": "2020-12-11T23:29:00+00:00"}},
543+
{"node": {"time": "2020-12-11T23:31:00+00:00"}},
544+
]
545+
},
546+
"pastEvents": {
547+
"edges": [
548+
{
549+
"node": {
550+
"createdAt": "2020-12-12T00:00:00+00:00",
551+
"name": "enrolled occurrence in the past",
552+
"occurrences": {
553+
"edges": [
554+
{"node": {"remainingCapacity": 23}},
555+
{"node": {"remainingCapacity": 24}},
556+
]
557+
},
558+
}
559+
},
560+
{
561+
"node": {
562+
"createdAt": "2020-12-12T00:00:00+00:00",
563+
"name": "event in the past",
564+
"occurrences": {
565+
"edges": [
566+
{"node": {"remainingCapacity": 32}},
567+
{"node": {"remainingCapacity": 32}},
568+
]
569+
},
570+
}
571+
},
572+
]
573+
},
574+
}
575+
}
576+
}

children/tests/test_api.py

Lines changed: 47 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,7 @@ def test_children_project_filter(
431431
edges{
432432
node{
433433
createdAt
434+
name
434435
occurrences{
435436
edges{
436437
node{
@@ -776,50 +777,71 @@ def test_get_past_events(
776777

777778
next_project = ProjectFactory(year=2021)
778779

779-
# Unpublished occurrences
780-
OccurrenceFactory.create_batch(
781-
2, time=timezone.now(), event=EventFactory(project=project), venue=venue
780+
past = timezone.now() - timedelta(
781+
minutes=settings.KUKKUU_ENROLLED_OCCURRENCE_IN_PAST_LEEWAY + 1
782+
)
783+
not_enough_past = timezone.now() - timedelta(
784+
minutes=settings.KUKKUU_ENROLLED_OCCURRENCE_IN_PAST_LEEWAY - 1
782785
)
786+
future = timezone.now() + timedelta(minutes=1)
783787

784-
# Published occurrences in the past
785-
event = EventFactory(published_at=timezone.now(), project=project)
786-
past_occurrence_1 = OccurrenceFactory(
787-
time=datetime(1970, 1, 1, 0, 0, 0, tzinfo=pytz.timezone(settings.TIME_ZONE)),
788-
event=event,
789-
venue=venue,
788+
# Enrolled occurrence in the past, event should be visible
789+
event = EventFactory(
790+
published_at=timezone.now(),
791+
project=project,
792+
name="enrolled occurrence in the past",
790793
)
794+
OccurrenceFactory(time=future, event=event, venue=venue)
795+
the_past_occurrence = OccurrenceFactory(time=past, event=event, venue=venue)
796+
EnrolmentFactory(child=child_with_user_guardian, occurrence=the_past_occurrence)
791797

792-
OccurrenceFactory(
793-
time=datetime(1970, 1, 1, 0, 0, 0, tzinfo=pytz.timezone(settings.TIME_ZONE)),
794-
event__published_at=timezone.now(),
795-
event__project=project,
796-
venue=venue,
798+
# Enrolled occurrence in the past but not enough, event should be NOT visible
799+
event_2 = EventFactory(
800+
published_at=timezone.now(),
801+
project=project,
802+
name="enrolled occurrence in the past but not enough",
803+
)
804+
OccurrenceFactory(time=future, event=event_2, venue=venue)
805+
the_not_so_past_occurrence = OccurrenceFactory(
806+
time=not_enough_past, event=event_2, venue=venue
807+
)
808+
EnrolmentFactory(
809+
child=child_with_user_guardian, occurrence=the_not_so_past_occurrence
797810
)
798-
# Past occurrence but from another project
811+
812+
# Event without an enrolment in the past, should be visible
813+
event_3 = EventFactory(
814+
published_at=timezone.now(), project=project, name="event in the past"
815+
)
816+
OccurrenceFactory(time=past, event=event_3, venue=venue)
817+
OccurrenceFactory(time=not_enough_past, event=event_3, venue=venue)
818+
819+
# Event without an enrolment in the future, should NOT be visible
820+
event_4 = EventFactory(
821+
published_at=timezone.now(), project=project, name="event in the future"
822+
)
823+
OccurrenceFactory(time=not_enough_past, event=event_4, venue=venue)
824+
OccurrenceFactory(time=future, event=event_4, venue=venue)
825+
826+
# Past occurrence but from another project, should NOT be visible
799827
OccurrenceFactory(
800-
time=datetime(1970, 1, 1, 0, 0, 0, tzinfo=pytz.timezone(settings.TIME_ZONE)),
828+
time=past,
801829
event__published_at=timezone.now(),
802830
event__project=next_project,
831+
event__name="another project event",
803832
venue=venue,
804833
)
805834

806-
# Recent published occurrence
807-
OccurrenceFactory(time=timezone.now(), event=event, venue=venue)
808-
809-
# Unpublished occurrences in the past
835+
# Unpublished occurrences in the past, should NOT be visible
810836
OccurrenceFactory.create_batch(
811837
3,
812838
time=datetime(1970, 1, 1, 0, 0, 0, tzinfo=pytz.timezone(settings.TIME_ZONE)),
813839
event__project=project,
840+
event__name="unpublished event in the past",
814841
venue=venue,
815842
)
816843

817-
EnrolmentFactory(child=child_with_user_guardian, occurrence=past_occurrence_1)
818-
819844
executed = guardian_api_client.execute(CHILD_EVENTS_QUERY, variables=variables)
820-
# Still return enroled events if they are past events
821-
# Should only return past events from current project
822-
assert len(executed["data"]["child"]["pastEvents"]["edges"]) == 1
823845
snapshot.assert_match(executed)
824846

825847
guardian_api_client.user.projects.add(project)

common/tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def setup_test_environment(settings):
2626
settings.DEFAULT_FROM_EMAIL = "kukkuu@example.com"
2727
settings.ILMOITIN_TRANSLATED_FROM_EMAIL = {}
2828
settings.MEDIA_ROOT = "test_media"
29+
settings.KUKKUU_REMINDER_DAYS_IN_ADVANCE = 7
2930
with translation.override("fi"), freeze_time("2020-12-12"):
3031
yield
3132
shutil.rmtree("test_media", ignore_errors=True)

common/utils.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import binascii
12
from copy import deepcopy
23

34
from django.db import transaction
@@ -39,7 +40,13 @@ def get_global_id(obj):
3940

4041

4142
def get_node_id_from_global_id(global_id, expected_node_name):
42-
name, id = from_global_id(global_id)
43+
try:
44+
name, id = from_global_id(global_id)
45+
except (
46+
binascii.Error,
47+
UnicodeDecodeError,
48+
): # invalid global ID
49+
return None
4350
return id if name == expected_node_name else None
4451

4552

0 commit comments

Comments
 (0)