Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[project] Repeatable Events Implementation #315

Merged
merged 1 commit into from
Sep 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion backend/init_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
News, # noqa: F401
NewsTranslation, # noqa: F401
Item, # noqa: F401
RepeatableEvents, # noqa: F401
RepeatableEvent, # noqa: F401
Tag, # noqa: F401
TagTranslation, # noqa: F401
)
Expand Down
2 changes: 2 additions & 0 deletions backend/models/content/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ def to_ics(self, events: List[Event], language: str):
calendar = dedent(f"""\
BEGIN:VCALENDAR
VERSION:2.0
X-WR-CALNAME:{self.name}
X-WR-TIMEZONE:Europe/Stockholm
PRODID:-//medieteknik//Calendar 1.0//{language[0:2].upper()}
CALSCALE:GREGORIAN""")

Expand Down
37 changes: 29 additions & 8 deletions backend/models/content/event.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
from datetime import timedelta
import enum
from textwrap import dedent
from typing import List
import uuid
from sqlalchemy import (
Boolean,
Column,
Enum,
ForeignKey,
Integer,
String,
inspect,
)
Expand All @@ -16,6 +19,13 @@
from models.content.base import Item


class Frequency(enum.Enum):
DAILY = "daily"
WEEKLY = "weekly"
MONTHLY = "monthly"
YEARLY = "yearly"


class Event(Item):
"""
Event model which inherits from the base Item model.
Expand All @@ -30,8 +40,8 @@ class Event(Item):

event_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)

start_date = Column(TIMESTAMP)
end_date = Column(TIMESTAMP)
start_date = Column(TIMESTAMP, nullable=False)
duration = Column(Integer, nullable=False) # Duration in minutes
location = Column(String(255))
is_inherited = Column(Boolean, default=False, nullable=False)
background_color = Column(String(7))
Expand Down Expand Up @@ -62,15 +72,18 @@ class Event(Item):
item = db.relationship("Item", back_populates="event", passive_deletes=True)
calendar = db.relationship("Calendar", back_populates="events")
repeatable_event = db.relationship(
"RepeatableEvents", back_populates="event", uselist=False
"RepeatableEvent", back_populates="event", uselist=False
)

translations = db.relationship("EventTranslation", back_populates="event")

__mapper_args__ = {"polymorphic_identity": "event"}

def to_dict(
self, provided_languages: List[str] = AVAILABLE_LANGUAGES, is_public_route=True
self,
provided_languages: List[str] = AVAILABLE_LANGUAGES,
is_public_route=True,
custom_start_date: str = None,
):
data = super().to_dict(
provided_languages=provided_languages, is_public_route=is_public_route
Expand Down Expand Up @@ -115,6 +128,8 @@ def to_dict(
del data["parent_event_id"]
del data["type"]
del data["published_status"]
if custom_start_date:
data["start_date"] = custom_start_date

return data

Expand All @@ -133,6 +148,7 @@ def to_ics(self, language: str):
SUMMARY:{translation.title}
DESCRIPTION:{translation.description}
LOCATION:{self.location}
LAST-MODIFIED:{self.last_updated.strftime("%Y%m%dT%H%M%S" + "Z")}
DTSTART:{self.start_date.strftime("%Y%m%dT%H%M%S" + "Z")}
DTEND:{self.end_date.strftime("%Y%m%dT%H%M%S") + "Z"}
END:VEVENT
Expand Down Expand Up @@ -178,16 +194,21 @@ def to_dict(self):
return data


class RepeatableEvents(db.Model):
__tablename__ = "repeatable_events"
class RepeatableEvent(db.Model):
__tablename__ = "repeatable_event"

repeatable_event_id = Column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)

# Metadata
reapeting_interval = Column(String(255))
day = Column(TIMESTAMP)
frequency = Column(
Enum(Frequency), default=Frequency.WEEKLY, nullable=False
) # Daily, Weekly, Monthly, Yearly
interval = Column(Integer, default=1) # Every x days, weeks, months, years
end_date = Column(TIMESTAMP) # End of the repeatable
max_occurrences = Column(Integer) # Number of times to repeat
repeat_forever = Column(Boolean, default=False) # Repeat forever

# Foreign keys
event_id = Column(UUID(as_uuid=True), ForeignKey("event.event_id"), unique=True)
Expand Down
30 changes: 22 additions & 8 deletions backend/routes/calendar_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from models.content.event import Event
from models.core.student import Student
from services.content.event import generate_events, generate_ics
from services.content.public.calendar import get_main_calendar
from utility.translation import retrieve_languages

Expand All @@ -38,21 +39,34 @@ def get_calendar_ics():
date = datetime.now()

start_date = (date - timedelta(days=1)).replace(day=1)
_, next_month_end_day = monthrange(date.year, date.month + 1)
end_date = date.replace(day=next_month_end_day) # Make end_date inclusive

# Handle year and month transition for the next month
if date.month == 12:
next_month = 1
next_month_year = date.year + 1
else:
next_month = date.month + 1
next_month_year = date.year

_, next_month_end_day = monthrange(next_month_year, next_month)
end_date = date.replace(
year=next_month_year, month=next_month, day=next_month_end_day
) # Make end_date inclusive

# Adjusted filter conditions for overlapping events and inclusivity
events: List[Event] = Event.query.filter(
events = Event.query.filter(
Event.calendar_id == main_calendar.calendar_id,
or_(
Event.start_date.between(start_date, end_date), # Starts within range
Event.end_date.between(start_date, end_date), # Ends within range
(Event.start_date < start_date)
& (Event.end_date > end_date), # Spans the range
Event.start_date <= end_date, # Starts before or on the end date
Event.start_date >= start_date, # Starts after or on the start date
),
).all()

return Response(
get_main_calendar().to_ics(events, provided_langauges[0]),
response=generate_ics(
calendar=main_calendar,
events=events,
language=provided_langauges[0],
),
mimetype="text/calendar",
)
17 changes: 12 additions & 5 deletions backend/routes/event_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from flask_jwt_extended import jwt_required
from models.committees.committee import Committee
from models.committees.committee_position import CommitteePosition
from models.content.event import Event, RepeatableEvents
from models.content.event import Event, RepeatableEvent
from models.core.student import Student
from services.content.item import (
create_item,
Expand Down Expand Up @@ -63,12 +63,19 @@ def create_event():
public=True,
)

repeatable = data.get("repeatable")
repeatable = data.get("repeats")

if repeatable:
repeatable_event = RepeatableEvents(
event_id=id,
reapeting_interval="weekly",
event: Event = Event.query.filter(Event.item_id == id).first_or_404()
end_date = data.get("end_date")
max_occurrences = data.get("max_occurrences")
repeatable_event = RepeatableEvent(
event_id=event.event_id,
frequency=data.get("frequency"),
interval=data.get("interval"),
end_date=end_date,
max_occurrences=max_occurrences,
repeat_forever=end_date is None and max_occurrences is None,
)

db.session.add(repeatable_event)
Expand Down
5 changes: 4 additions & 1 deletion backend/services/content/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

from .author import get_author_from_email
from .event import generate_ics, generate_events
from .item import (
get_items,
get_items_from_author,
Expand All @@ -16,6 +17,9 @@


__all__ = [
"get_author_from_email",
"generate_ics",
"generate_events",
"get_items",
"get_items_from_author",
"get_item_by_url",
Expand All @@ -24,5 +28,4 @@
"delete_item",
"publish",
"create_item",
"get_author_from_email",
]
122 changes: 122 additions & 0 deletions backend/services/content/event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
from calendar import Calendar
from datetime import timedelta
from textwrap import dedent
from typing import Any, Dict, List
from models.content.event import Event, EventTranslation, Frequency
from utility.translation import get_translation


def generate_ics(calendar: Calendar, events: List[Event], language: str) -> str:
ics = dedent(f"""\
BEGIN:VCALENDAR
VERSION:2.0
X-WR-CALNAME:{calendar.name}
X-WR-TIMEZONE:Europe/Stockholm
PRODID:-//medieteknik//Calendar 1.0//{language[0:2].upper()}
CALSCALE:GREGORIAN""")

for event in events:
if not event:
continue
translation = get_translation(
EventTranslation, ["event_id"], {"event_id": event.event_id}, language
)

if not isinstance(translation, EventTranslation):
return None

rrule = ""
if event.repeatable_event:
rrule = f"FREQ={event.repeatable_event.frequency.value};".upper()

if event.repeatable_event.interval:
rrule += f"INTERVAL={event.repeatable_event.interval};"

if event.repeatable_event.end_date:
rrule += f"UNTIL={event.repeatable_event.end_date.strftime('%Y%m%dT%H%M%S')};"

if event.repeatable_event.max_occurrences:
rrule += f"COUNT={event.repeatable_event.max_occurrences};"

ics += dedent(f"""
BEGIN:VEVENT
UID:{str(event.event_id) + '@medieteknik.com'}
DTSTAMP:{event.created_at.strftime("%Y%m%dT%H%M%S" + "Z")}
SUMMARY:{translation.title}
LOCATION:{event.location}
LAST-MODIFIED:{event.last_updated.strftime("%Y%m%dT%H%M%S" + "Z")}
DTSTART:{event.start_date.strftime("%Y%m%dT%H%M%S") + "Z"}
DTEND:{(event.start_date + timedelta(minutes=event.duration)).strftime("%Y%m%dT%H%M%S") + "Z"}""")
if translation.description or rrule:
ics += "\n"
if translation.description:
ics += f"DESCRIPTION:{translation.description}"

if translation.description and rrule:
ics += "\n"

if rrule:
ics += f"RRULE:{rrule}"

ics += "\nEND:VEVENT"

ics += "\nEND:VCALENDAR"
ics = dedent(ics)

return ics


def generate_events(event: Event, start_date, end_date) -> List:
"""
Generate a list of occurrences for a repeatable event.

Args:
event: The event to generate occurrences for
start_date: The start date of the range
end_date: The end date of the range

Returns:
List: A list of occurrences for the event
"""
occurrences = []
current_occurrence = event.start_date
occurrence_count = 0

if not event.repeatable_event:
return occurrences

repeat = event.repeatable_event

while current_occurrence < end_date:
event_end_time = current_occurrence + timedelta(minutes=event.duration)

if (repeat.end_date and current_occurrence > repeat.end_date) or (
repeat.max_occurrences and occurrence_count >= repeat.max_occurrences
):
break

if current_occurrence >= start_date:
occurrences.append(
{
"event": event,
"start_date": current_occurrence,
"end_date": event_end_time,
}
)
occurrence_count += 1

match event.repeatable_event.frequency:
case Frequency.DAILY:
current_occurrence += timedelta(days=event.repeatable_event.interval)
case Frequency.WEEKLY:
current_occurrence += timedelta(weeks=event.repeatable_event.interval)
case Frequency.MONTHLY:
current_occurrence = current_occurrence.replace(
month=current_occurrence.month + event.repeatable_event.interval
)
case Frequency.YEARLY:
current_occurrence = current_occurrence.replace(
year=current_occurrence.year + event.repeatable_event.interval
)

return occurrences
Loading
Loading