Skip to content

Commit 9710778

Browse files
ahmedxgoudaarkid15r
authored andcommitted
Implement calendar events scheduling (#2211)
* Add Google Calendar Client * Parse events * Add event to reminder * Add tests for client and parsing events * Add viewing events button * Apply check spelling * Add reminder blocks and setting a reminder * Refactor * Apply sonar * Add command to manifest and fix bugs * Add constraint and remove null possibility from event * Add RQ * Refactor * Merge migrations * Refactor * Move parse_args to nest app * Update calendar events blocks * Use user_id as an argument name instead of slack_user_id * Add rq to settings * Update event tests * Add rq scheduler and add base scheduler * Fix scheduling * Add worker and scheduler to docker compose * Refactor * Apply check spelling * Apply rabbit's suggestions * Add tests * Apply sonar suggestions * Apply check spelling * Update event number * Optimize docker compose * Add job id to reminder schedule model and cancel method to the base scheduler * Update poetry lock * Update tests * Merge migrations * Update tests * Clean up reminder schedule after completing the job if the recurrence is once and update the object to the next scheduled date if recurrence is otherwise and add list_reminders button * Update tests * Apply check-spelling * Add cancel reminder command and update update_reminder_schedule_date function * Fix schedule month update * Fix tests * Add tests * Apply rabbit's suggestions * Apply suggestions * Update tests * Clean up migrations * Add atomic transaction to set_reminder handler * Update poetry.lock
1 parent f63955b commit 9710778

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+2323
-52
lines changed

backend/apps/common/utils.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
from datetime import UTC, datetime
77
from urllib.parse import urlparse
88

9+
from dateutil import parser
910
from django.conf import settings
1011
from django.template.defaultfilters import pluralize
12+
from django.utils import timezone
1113
from django.utils.text import Truncator
1214
from django.utils.text import slugify as django_slugify
1315
from humanize import intword, naturaltime
@@ -33,6 +35,42 @@ def convert_to_camel_case(text: str) -> str:
3335
return "".join(segments)
3436

3537

38+
def parse_date(date_string: str | None) -> datetime | None:
39+
"""Parse a date string into a datetime object.
40+
41+
Args:
42+
date_string (str | None): The date string to parse.
43+
44+
Returns:
45+
datetime | None: The parsed datetime object, or None if input is None or invalid.
46+
47+
"""
48+
if not date_string:
49+
return None
50+
try:
51+
return parser.parse(date_string)
52+
except (ValueError, TypeError):
53+
return None
54+
55+
56+
def convert_to_local(dt: datetime | None) -> datetime | None:
57+
"""Convert a datetime object to the local timezone.
58+
59+
Args:
60+
dt (datetime | None): The datetime object to convert.
61+
62+
Returns:
63+
datetime | None: The converted datetime object in the local timezone,
64+
or None if the input is None.
65+
66+
"""
67+
if not dt:
68+
return None
69+
if timezone.is_naive(dt):
70+
dt = timezone.make_aware(dt, timezone=UTC)
71+
return timezone.localtime(dt)
72+
73+
3674
def convert_to_snake_case(text: str) -> str:
3775
"""Convert a string to snake_case.
3876
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Google Calendar API Client."""
2+
3+
from django.utils import timezone
4+
from googleapiclient.discovery import build
5+
6+
from apps.nest.models.google_account_authorization import GoogleAccountAuthorization
7+
8+
9+
class GoogleCalendarClient:
10+
"""Google Calendar API Client Class."""
11+
12+
def __init__(self, google_account_authorization: GoogleAccountAuthorization):
13+
"""Initialize the Google Calendar API Client."""
14+
self.google_account_authorization = google_account_authorization
15+
self.service = build(
16+
"calendar", "v3", credentials=google_account_authorization.credentials
17+
)
18+
19+
def get_events(self, min_time=None, max_time=None) -> list[dict]:
20+
"""Retrieve events from Google Calendar."""
21+
if not min_time:
22+
min_time = timezone.now()
23+
if not max_time:
24+
max_time = min_time + timezone.timedelta(days=1)
25+
events_result = (
26+
self.service.events()
27+
.list(
28+
calendarId="primary",
29+
timeMin=min_time.isoformat(),
30+
timeMax=max_time.isoformat(),
31+
singleEvents=True,
32+
orderBy="startTime",
33+
)
34+
.execute()
35+
)
36+
return events_result.get("items", [])
37+
38+
def get_event(self, google_calendar_id: str) -> dict:
39+
"""Retrieve a specific event from Google Calendar."""
40+
return (
41+
self.service.events().get(calendarId="primary", eventId=google_calendar_id).execute()
42+
)

backend/apps/nest/handlers/__init__.py

Whitespace-only changes.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Handlers for Calendar Events."""
2+
3+
from django.core.cache import cache
4+
from django.core.exceptions import ValidationError
5+
from django.db import transaction
6+
from django.utils import timezone
7+
8+
from apps.nest.clients.google_calendar import GoogleCalendarClient
9+
from apps.nest.models.google_account_authorization import GoogleAccountAuthorization
10+
from apps.nest.models.reminder import Reminder
11+
from apps.nest.models.reminder_schedule import ReminderSchedule
12+
from apps.owasp.models.event import Event
13+
from apps.slack.models.member import Member
14+
15+
16+
def schedule_reminder(
17+
reminder: Reminder,
18+
scheduled_time: timezone.datetime,
19+
recurrence=ReminderSchedule.Recurrence.ONCE,
20+
) -> ReminderSchedule:
21+
"""Schedule a reminder."""
22+
if scheduled_time < timezone.now():
23+
message = "Scheduled time must be in the future."
24+
raise ValidationError(message)
25+
if recurrence not in ReminderSchedule.Recurrence.values:
26+
message = "Invalid recurrence value."
27+
raise ValidationError(message)
28+
return ReminderSchedule.objects.create(
29+
reminder=reminder,
30+
scheduled_time=scheduled_time,
31+
recurrence=recurrence,
32+
)
33+
34+
35+
def set_reminder(
36+
channel: str,
37+
event_number: str,
38+
user_id: str,
39+
minutes_before: int,
40+
recurrence: str | None = None,
41+
message: str = "",
42+
) -> ReminderSchedule:
43+
"""Set a reminder for a user."""
44+
if minutes_before <= 0:
45+
message = "Minutes before must be a positive integer."
46+
raise ValidationError(message)
47+
auth = GoogleAccountAuthorization.authorize(user_id)
48+
if not isinstance(auth, GoogleAccountAuthorization):
49+
message = "User is not authorized with Google. Please sign in first."
50+
raise ValidationError(message)
51+
google_calendar_id = cache.get(f"{user_id}_{event_number}")
52+
if not google_calendar_id:
53+
message = (
54+
"Invalid or expired event number. Please get a new event number from the events list."
55+
)
56+
raise ValidationError(message)
57+
58+
client = GoogleCalendarClient(auth)
59+
event = Event.parse_google_calendar_event(client.get_event(google_calendar_id))
60+
if not event:
61+
message = "Could not retrieve the event details. Please try again later."
62+
raise ValidationError(message)
63+
64+
reminder_time = event.start_date - timezone.timedelta(minutes=minutes_before)
65+
if reminder_time < timezone.now():
66+
message = "Reminder time must be in the future. Please adjust the minutes before."
67+
raise ValidationError(message)
68+
69+
if recurrence and recurrence not in ReminderSchedule.Recurrence.values:
70+
message = "Invalid recurrence value."
71+
raise ValidationError(message)
72+
73+
with transaction.atomic():
74+
# Saving event to the database after validation
75+
event.save()
76+
77+
member = Member.objects.get(slack_user_id=user_id)
78+
reminder, _ = Reminder.objects.get_or_create(
79+
channel_id=channel,
80+
event=event,
81+
member=member,
82+
defaults={"message": f"{event.name} - {message}" if message else event.name},
83+
)
84+
return schedule_reminder(
85+
reminder=reminder,
86+
scheduled_time=reminder_time,
87+
recurrence=recurrence or ReminderSchedule.Recurrence.ONCE,
88+
)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Generated by Django 5.2.4 on 2025-09-03 14:26
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
("nest", "0008_reminder_reminderschedule"),
10+
("owasp", "0052_remove_event_calendar_id_event_google_calendar_id"),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name="reminder",
16+
name="event",
17+
field=models.ForeignKey(
18+
null=True,
19+
on_delete=django.db.models.deletion.SET_NULL,
20+
related_name="reminders",
21+
to="owasp.event",
22+
verbose_name="Event",
23+
),
24+
),
25+
]
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.2.4 on 2025-09-07 21:43
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("nest", "0009_reminder_event"),
9+
]
10+
11+
operations = [
12+
migrations.AddConstraint(
13+
model_name="reminderschedule",
14+
constraint=models.UniqueConstraint(
15+
fields=("reminder", "scheduled_time"), name="unique_reminder_schedule"
16+
),
17+
),
18+
]
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Generated by Django 5.2.4 on 2025-09-08 05:48
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("nest", "0005_alter_userbadge_user"),
9+
("nest", "0010_reminderschedule_unique_reminder_schedule"),
10+
]
11+
12+
operations = []
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Generated by Django 5.2.6 on 2025-09-12 04:39
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("nest", "0011_merge_20250908_0548"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="reminderschedule",
14+
name="job_id",
15+
field=models.CharField(
16+
blank=True,
17+
help_text="ID of the scheduled job in the task queue.",
18+
max_length=255,
19+
verbose_name="Job ID",
20+
),
21+
),
22+
]
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Generated by Django 5.2.6 on 2025-09-15 04:11
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("nest", "0006_delete_apikey"),
9+
("nest", "0012_reminderschedule_job_id"),
10+
]
11+
12+
operations = []

backend/apps/nest/models/google_account_authorization.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,13 +155,34 @@ def get_flow():
155155
raise ValueError(AUTH_ERROR_MESSAGE)
156156
return get_google_auth_client()
157157

158+
@property
159+
def credentials(self):
160+
"""The Google API credentials."""
161+
if not settings.IS_GOOGLE_AUTH_ENABLED:
162+
raise ValueError(AUTH_ERROR_MESSAGE)
163+
return Credentials(
164+
token=self.access_token,
165+
refresh_token=self.refresh_token,
166+
scopes=self.scope,
167+
# Google expects naive date in the request
168+
expiry=self.naive_expires_at,
169+
token_uri=settings.GOOGLE_AUTH_TOKEN_URI,
170+
client_id=settings.GOOGLE_AUTH_CLIENT_ID,
171+
client_secret=settings.GOOGLE_AUTH_CLIENT_SECRET,
172+
)
173+
158174
@property
159175
def is_token_expired(self):
160176
"""Check if the access token is expired."""
161177
return self.expires_at is None or self.expires_at <= timezone.now() + timezone.timedelta(
162178
seconds=60
163179
)
164180

181+
@property
182+
def naive_expires_at(self):
183+
"""Get the naive datetime of token expiry."""
184+
return timezone.make_naive(self.expires_at)
185+
165186
@staticmethod
166187
def refresh_access_token(auth):
167188
"""Refresh the access token using the refresh token."""

0 commit comments

Comments
 (0)