Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
6b88721
Add Google Calendar Client
ahmedxgouda Sep 3, 2025
fa4d306
Parse events
ahmedxgouda Sep 3, 2025
813f956
Add event to reminder
ahmedxgouda Sep 3, 2025
81739f1
Add tests for client and parsing events
ahmedxgouda Sep 4, 2025
132daa8
Add viewing events button
ahmedxgouda Sep 4, 2025
65dd9ef
Apply check spelling
ahmedxgouda Sep 4, 2025
a2be0b9
Add reminder blocks and setting a reminder
ahmedxgouda Sep 6, 2025
eb65838
Refactor
ahmedxgouda Sep 7, 2025
cfa7c70
Apply sonar
ahmedxgouda Sep 7, 2025
b472521
Add command to manifest and fix bugs
ahmedxgouda Sep 7, 2025
92b36f4
Add constraint and remove null possibility from event
ahmedxgouda Sep 7, 2025
7df13b9
Add RQ
ahmedxgouda Sep 8, 2025
1b4a4c4
Refactor
ahmedxgouda Sep 8, 2025
419d915
Merge migrations
ahmedxgouda Sep 8, 2025
7d9cee1
Refactor
ahmedxgouda Sep 8, 2025
a68f08f
Move parse_args to nest app
ahmedxgouda Sep 8, 2025
368b4bc
Update calendar events blocks
ahmedxgouda Sep 8, 2025
3ad20fc
Use user_id as an argument name instead of slack_user_id
ahmedxgouda Sep 8, 2025
3793188
Add rq to settings
ahmedxgouda Sep 8, 2025
98e938e
Update event tests
ahmedxgouda Sep 8, 2025
26cb33c
Add rq scheduler and add base scheduler
ahmedxgouda Sep 8, 2025
e56f225
Fix scheduling
ahmedxgouda Sep 9, 2025
728e434
Add worker and scheduler to docker compose
ahmedxgouda Sep 9, 2025
b629dcc
Refactor
ahmedxgouda Sep 10, 2025
a5c9f46
Apply check spelling
ahmedxgouda Sep 10, 2025
fc6b908
Apply rabbit's suggestions
ahmedxgouda Sep 10, 2025
b1dc6c0
Add tests
ahmedxgouda Sep 10, 2025
3f83f9f
Apply sonar suggestions
ahmedxgouda Sep 10, 2025
44c101f
Apply check spelling
ahmedxgouda Sep 10, 2025
a54a559
Update event number
ahmedxgouda Sep 10, 2025
711db4c
Optimize docker compose
ahmedxgouda Sep 11, 2025
61f76dd
Add job id to reminder schedule model and cancel method to the base s…
ahmedxgouda Sep 12, 2025
65d1d4c
Update poetry lock
ahmedxgouda Sep 15, 2025
870ee45
Update tests
ahmedxgouda Sep 15, 2025
a35d73f
Merge migrations
ahmedxgouda Sep 15, 2025
a8443b5
Update tests
ahmedxgouda Sep 16, 2025
2f2f1e8
Clean up reminder schedule after completing the job if the recurrence…
ahmedxgouda Sep 17, 2025
5f8d7da
Update tests
ahmedxgouda Sep 17, 2025
8d5b0eb
Apply check-spelling
ahmedxgouda Sep 17, 2025
e53d256
Add cancel reminder command and update update_reminder_schedule_date …
ahmedxgouda Sep 17, 2025
0d6b30e
Fix schedule month update
ahmedxgouda Sep 17, 2025
f09d0af
Fix tests
ahmedxgouda Sep 17, 2025
8c8fdcd
Add tests
ahmedxgouda Sep 17, 2025
7d9256c
Apply rabbit's suggestions
ahmedxgouda Sep 17, 2025
7b85dd3
Apply suggestions
ahmedxgouda Sep 18, 2025
874667b
Update tests
ahmedxgouda Sep 18, 2025
f030677
Clean up migrations
ahmedxgouda Sep 18, 2025
1764fa9
Add atomic transaction to set_reminder handler
ahmedxgouda Sep 19, 2025
d90f611
Update poetry.lock
ahmedxgouda Sep 23, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions backend/apps/common/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
from datetime import UTC, datetime
from urllib.parse import urlparse

from dateutil import parser
from django.conf import settings
from django.template.defaultfilters import pluralize
from django.utils import timezone
from django.utils.text import Truncator
from django.utils.text import slugify as django_slugify
from humanize import intword, naturaltime
Expand All @@ -33,6 +35,42 @@ def convert_to_camel_case(text: str) -> str:
return "".join(segments)


def parse_date(date_string: str | None) -> datetime | None:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one should be on its place in this file

"""Parse a date string into a datetime object.

Args:
date_string (str | None): The date string to parse.

Returns:
datetime | None: The parsed datetime object, or None if input is None or invalid.

"""
if not date_string:
return None
try:
return parser.parse(date_string)
except (ValueError, TypeError):
return None


def convert_to_local(dt: datetime | None) -> datetime | None:
"""Convert a datetime object to the local timezone.

Args:
dt (datetime | None): The datetime object to convert.

Returns:
datetime | None: The converted datetime object in the local timezone,
or None if the input is None.

"""
if not dt:
return None
if timezone.is_naive(dt):
dt = timezone.make_aware(dt, timezone=UTC)
return timezone.localtime(dt)


def convert_to_snake_case(text: str) -> str:
"""Convert a string to snake_case.

Expand Down
42 changes: 42 additions & 0 deletions backend/apps/nest/clients/google_calendar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Google Calendar API Client."""

from django.utils import timezone
from googleapiclient.discovery import build

from apps.nest.models.google_account_authorization import GoogleAccountAuthorization


class GoogleCalendarClient:
"""Google Calendar API Client Class."""

def __init__(self, google_account_authorization: GoogleAccountAuthorization):
"""Initialize the Google Calendar API Client."""
self.google_account_authorization = google_account_authorization
self.service = build(
"calendar", "v3", credentials=google_account_authorization.credentials
)

def get_events(self, min_time=None, max_time=None) -> list[dict]:
"""Retrieve events from Google Calendar."""
if not min_time:
min_time = timezone.now()
if not max_time:
max_time = min_time + timezone.timedelta(days=1)
events_result = (
self.service.events()
.list(
calendarId="primary",
timeMin=min_time.isoformat(),
timeMax=max_time.isoformat(),
singleEvents=True,
orderBy="startTime",
)
.execute()
)
return events_result.get("items", [])

def get_event(self, google_calendar_id: str) -> dict:
"""Retrieve a specific event from Google Calendar."""
return (
self.service.events().get(calendarId="primary", eventId=google_calendar_id).execute()
)
Empty file.
88 changes: 88 additions & 0 deletions backend/apps/nest/handlers/calendar_events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Handlers for Calendar Events."""

from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone

from apps.nest.clients.google_calendar import GoogleCalendarClient
from apps.nest.models.google_account_authorization import GoogleAccountAuthorization
from apps.nest.models.reminder import Reminder
from apps.nest.models.reminder_schedule import ReminderSchedule
from apps.owasp.models.event import Event
from apps.slack.models.member import Member


def schedule_reminder(
reminder: Reminder,
scheduled_time: timezone.datetime,
recurrence=ReminderSchedule.Recurrence.ONCE,
) -> ReminderSchedule:
"""Schedule a reminder."""
if scheduled_time < timezone.now():
message = "Scheduled time must be in the future."
raise ValidationError(message)
if recurrence not in ReminderSchedule.Recurrence.values:
message = "Invalid recurrence value."
raise ValidationError(message)
return ReminderSchedule.objects.create(
reminder=reminder,
scheduled_time=scheduled_time,
recurrence=recurrence,
)
Comment on lines +16 to +32
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your single piece of text functions are still hard to read.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe adding comments will improve this?



def set_reminder(
channel: str,
event_number: str,
user_id: str,
minutes_before: int,
recurrence: str | None = None,
message: str = "",
) -> ReminderSchedule:
"""Set a reminder for a user."""
if minutes_before <= 0:
message = "Minutes before must be a positive integer."
raise ValidationError(message)
auth = GoogleAccountAuthorization.authorize(user_id)
if not isinstance(auth, GoogleAccountAuthorization):
message = "User is not authorized with Google. Please sign in first."
raise ValidationError(message)
google_calendar_id = cache.get(f"{user_id}_{event_number}")
if not google_calendar_id:
message = (
"Invalid or expired event number. Please get a new event number from the events list."
)
raise ValidationError(message)
Comment on lines +44 to +56
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one has some splitting but still could use better partitioning.


client = GoogleCalendarClient(auth)
event = Event.parse_google_calendar_event(client.get_event(google_calendar_id))
if not event:
message = "Could not retrieve the event details. Please try again later."
raise ValidationError(message)

reminder_time = event.start_date - timezone.timedelta(minutes=minutes_before)
if reminder_time < timezone.now():
message = "Reminder time must be in the future. Please adjust the minutes before."
raise ValidationError(message)

if recurrence and recurrence not in ReminderSchedule.Recurrence.values:
message = "Invalid recurrence value."
raise ValidationError(message)

with transaction.atomic():
# Saving event to the database after validation
event.save()

member = Member.objects.get(slack_user_id=user_id)
reminder, _ = Reminder.objects.get_or_create(
channel_id=channel,
event=event,
member=member,
defaults={"message": f"{event.name} - {message}" if message else event.name},
)
return schedule_reminder(
reminder=reminder,
scheduled_time=reminder_time,
recurrence=recurrence or ReminderSchedule.Recurrence.ONCE,
)
25 changes: 25 additions & 0 deletions backend/apps/nest/migrations/0009_reminder_event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Generated by Django 5.2.4 on 2025-09-03 14:26

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("nest", "0008_reminder_reminderschedule"),
("owasp", "0052_remove_event_calendar_id_event_google_calendar_id"),
]

operations = [
migrations.AddField(
model_name="reminder",
name="event",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="reminders",
to="owasp.event",
verbose_name="Event",
),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-07 21:43

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("nest", "0009_reminder_event"),
]

operations = [
migrations.AddConstraint(
model_name="reminderschedule",
constraint=models.UniqueConstraint(
fields=("reminder", "scheduled_time"), name="unique_reminder_schedule"
),
),
]
12 changes: 12 additions & 0 deletions backend/apps/nest/migrations/0011_merge_20250908_0548.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Generated by Django 5.2.4 on 2025-09-08 05:48

from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("nest", "0005_alter_userbadge_user"),
("nest", "0010_reminderschedule_unique_reminder_schedule"),
]

operations = []
22 changes: 22 additions & 0 deletions backend/apps/nest/migrations/0012_reminderschedule_job_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 5.2.6 on 2025-09-12 04:39

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("nest", "0011_merge_20250908_0548"),
]

operations = [
migrations.AddField(
model_name="reminderschedule",
name="job_id",
field=models.CharField(
blank=True,
help_text="ID of the scheduled job in the task queue.",
max_length=255,
verbose_name="Job ID",
),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Generated by Django 5.2.6 on 2025-09-15 04:11

from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("nest", "0006_delete_apikey"),
("nest", "0012_reminderschedule_job_id"),
]

operations = []
21 changes: 21 additions & 0 deletions backend/apps/nest/models/google_account_authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,13 +155,34 @@ def get_flow():
raise ValueError(AUTH_ERROR_MESSAGE)
return get_google_auth_client()

@property
def credentials(self):
"""The Google API credentials."""
if not settings.IS_GOOGLE_AUTH_ENABLED:
raise ValueError(AUTH_ERROR_MESSAGE)
return Credentials(
token=self.access_token,
refresh_token=self.refresh_token,
scopes=self.scope,
# Google expects naive date in the request
expiry=self.naive_expires_at,
token_uri=settings.GOOGLE_AUTH_TOKEN_URI,
client_id=settings.GOOGLE_AUTH_CLIENT_ID,
client_secret=settings.GOOGLE_AUTH_CLIENT_SECRET,
)

@property
def is_token_expired(self):
"""Check if the access token is expired."""
return self.expires_at is None or self.expires_at <= timezone.now() + timezone.timedelta(
seconds=60
)

@property
def naive_expires_at(self):
"""Get the naive datetime of token expiry."""
return timezone.make_naive(self.expires_at)

@staticmethod
def refresh_access_token(auth):
"""Refresh the access token using the refresh token."""
Expand Down
9 changes: 8 additions & 1 deletion backend/apps/nest/models/reminder.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,21 @@ class Meta:
verbose_name = "Nest Reminder"
verbose_name_plural = "Nest Reminders"

channel_id = models.CharField(verbose_name="Channel ID", max_length=15, default="")
event = models.ForeignKey(
"owasp.Event",
verbose_name="Event",
on_delete=models.SET_NULL,
related_name="reminders",
null=True,
)
member = models.ForeignKey(
"slack.Member",
verbose_name="Slack Member",
on_delete=models.SET_NULL,
related_name="reminders",
null=True,
)
channel_id = models.CharField(verbose_name="Channel ID", max_length=15, default="")
message = models.TextField(verbose_name="Reminder Message")

def __str__(self) -> str:
Expand Down
30 changes: 30 additions & 0 deletions backend/apps/nest/models/reminder_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ class ReminderSchedule(models.Model):
"""Model representing a reminder schedule."""

class Meta:
constraints = [
models.UniqueConstraint(
fields=["reminder", "scheduled_time"],
name="unique_reminder_schedule",
)
]
db_table = "nest_reminder_schedules"
verbose_name = "Nest Reminder Schedule"
verbose_name_plural = "Nest Reminder Schedules"
Expand All @@ -31,6 +37,30 @@ class Recurrence(models.TextChoices):
default=Recurrence.ONCE,
)

job_id = models.CharField(
verbose_name="Job ID",
max_length=255,
blank=True,
help_text="ID of the scheduled job in the task queue.",
)

@property
def cron_expression(self) -> str | None:
"""Get cron expression for the scheduled time."""
time_str = f"{self.scheduled_time.minute} {self.scheduled_time.hour}"
match self.recurrence:
case self.Recurrence.DAILY:
return f"{time_str} * * *"
case self.Recurrence.WEEKLY:
# Mapping Python's weekday (0=Monday) to cron's (0=Sunday)
dow = (self.scheduled_time.weekday() + 1) % 7
return f"{time_str} * * {dow}"
case self.Recurrence.MONTHLY:
return f"{time_str} {self.scheduled_time.day} * *"
# For 'once' or any other case, return None
case _:
return None

def __str__(self) -> str:
"""Reminder Schedule human readable representation."""
return f"Schedule for {self.reminder} at {self.scheduled_time} ({self.recurrence})"
Empty file.
Empty file.
Loading