Skip to content

Commit

Permalink
[project] Repeatable Events Implementation
Browse files Browse the repository at this point in the history
* Fully works with ICS
* Added new options for the form, also got rid of preview.
  • Loading branch information
BeastlyMC956 committed Sep 8, 2024
1 parent 95fce1d commit bda6fd6
Show file tree
Hide file tree
Showing 17 changed files with 675 additions and 270 deletions.
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

0 comments on commit bda6fd6

Please sign in to comment.