Skip to content
Draft
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
9 changes: 8 additions & 1 deletion apps/users/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from wtforms import BooleanField, StringField, SubmitField
from wtforms.validators import DataRequired

from apps.users.calendar import fetch_events
from main import db
from models.payment import Payment
from models.purchase import Purchase
Expand Down Expand Up @@ -82,8 +83,14 @@ def account() -> ResponseReturnValue:
except Exception:
app.logger.exception("Error fetching blog posts")

calendar = fetch_events(current_user, datetime.now(), config.event_end + timedelta(days=14))

return render_template(
"account/main.html", blog_posts=blog_posts, now=datetime.now(), event_start=config.event_start
"account/main.html",
blog_posts=blog_posts,
calendar=calendar,
now=datetime.now(),
event_start=config.event_start,
)


Expand Down
127 changes: 127 additions & 0 deletions apps/users/calendar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Literal

import pytz
from sqlalchemy import cast, select
from sqlalchemy.dialects.postgresql import INTERVAL
from sqlalchemy.orm import InstrumentedAttribute, with_parent

from main import db
from models.content.schedule import Occurrence, ScheduleItem
from models.user import User
from models.volunteer.shift import Shift, ShiftEntry


@dataclass
class CalendarEntry:
type: Literal["volunteer_shift", "favourited_content", "owned_content"]
start_time: datetime
end_time: datetime
venue_name: str | None
venue_mapref: str | None
human_type: str = "Event"
conflict_priority: int = 99

def overlaps_with(self, start_time: datetime, end_time: datetime) -> bool:
"""Does any part of this calendar entry occur between start_time and end_time?"""
return self.start_time < end_time and self.end_time > start_time

def to_dict(self) -> dict[str, str | int | None]:
return {
"type": self.type,
"human_type": self.human_type,
"conflict_priority": self.conflict_priority,
"start_time": pytz.timezone("Europe/London")
.localize(self.start_time)
.strftime("%Y-%m-%dT%H:%M:00"),
"end_time": pytz.timezone("Europe/London").localize(self.end_time).strftime("%Y-%m-%dT%H:%M:00"),
"venue_name": self.venue_name,
"venue_mapref": self.venue_mapref,
}


class VolunteerShiftCalendarEntry(CalendarEntry):
type: Literal["volunteer_shift"] = "volunteer_shift"
human_type = "Volunteer Shift"
conflict_priority: int = 0
shift: Shift
shift_entry: ShiftEntry

def __init__(self, shift_entry: ShiftEntry):
self.shit_entry = shift_entry
self.shift = shift_entry.shift
self.title = self.shift.role.name
self.start_time = self.shift.start
self.end_time = self.shift.end
self.venue_name = self.shift.venue.name
self.venue_mapref = self.shift.venue.mapref


class OccurrenceCalendarEntry(CalendarEntry):
occurrence: Occurrence

def __init__(self, occurrence: Occurrence):
# This is mostly here so that mypy knows scheduled_venue is set.
if not occurrence.scheduled or occurrence.scheduled_venue is None:
raise ValueError("Only scheduled content can result in calendar entries.")

self.occurrence = occurrence
self.title = occurrence.schedule_item.title
self.start_time = occurrence.scheduled_time # type: ignore
self.end_time = occurrence.scheduled_end_time # type: ignore
self.venue_name = occurrence.scheduled_venue.name
self.venue_mapref = occurrence.scheduled_venue.map_link


class FavouritedScheduleItemCalendarEntry(OccurrenceCalendarEntry):
type: Literal["favourited_content"] = "favourited_content"
human_type = "Favourite"
conflict_priority: int = 2


class OwnedScheduleItemCalendarEntry(OccurrenceCalendarEntry):
type: Literal["owned_content"] = "owned_content"
human_type = "Your Content"
conflict_priority: int = 1


def _occurrences_with_parent(
user: User, start: datetime, end: datetime, join_via: InstrumentedAttribute[list[ScheduleItem]]
) -> Sequence[Occurrence]:
occurrence_end = Occurrence.scheduled_time + cast("1 minute", INTERVAL) * Occurrence.scheduled_duration
return db.session.scalars(
select(Occurrence)
.join(Occurrence.schedule_item)
.where(with_parent(user, join_via))
.where(Occurrence.scheduled_time < end)
.where(occurrence_end > start)
).all()


def fetch_events(user: User, start: datetime, end: datetime) -> Sequence[CalendarEntry]:
"""Return a list of calendar entries for a user."""
calendar: list[CalendarEntry] = []
calendar += [
VolunteerShiftCalendarEntry(shift)
for shift in db.session.scalars(
select(ShiftEntry)
.where(with_parent(user, User.shift_entries))
.join(ShiftEntry.shift)
.where(Shift.start < end)
.where(Shift.end > start)
)
]

calendar += [
FavouritedScheduleItemCalendarEntry(occurrence)
for occurrence in _occurrences_with_parent(user, start, end, User.favourites)
]

calendar += [
OwnedScheduleItemCalendarEntry(occurrence)
for occurrence in _occurrences_with_parent(user, start, end, User.schedule_items)
]

return sorted(calendar, key=lambda e: e.start_time)
27 changes: 26 additions & 1 deletion apps/volunteer/schedule.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from collections import defaultdict
from collections.abc import Sequence
from datetime import datetime, timedelta

from flask import (
Expand All @@ -17,6 +18,7 @@
from icalendar import Calendar, Event
from sqlalchemy.orm import joinedload

from apps.users.calendar import CalendarEntry, fetch_events
from main import db, get_or_404
from models import naive_utcnow
from models.user import User, generate_api_token
Expand Down Expand Up @@ -46,6 +48,24 @@ def _get_roles_with_user_data(user):
return res


def _get_conflicts(shift: Shift, calendar: Sequence[CalendarEntry]) -> tuple[str, list[dict]]:
"""Return (primary_conflict_type, conflict_details) for a shift.

primary_conflict_type is the highest-priority conflict type (for CSS), or ""
if there are no conflicts. conflict_details is a list of dicts describing
each conflicting event.
"""
conflicts = sorted(
[event for event in calendar if event.overlaps_with(shift.start, shift.end)],
key=lambda c: c.conflict_priority,
)
if not conflicts:
return "", []

details = [c.to_dict() for c in conflicts]
return conflicts[0].type, details


def redirect_next_or_schedule(message: str | None = None) -> ResponseReturnValue:
"""
Set the flash if `message` is set, then redirect either to the URL in the
Expand Down Expand Up @@ -83,13 +103,18 @@ def schedule():
active_day = default_day

shifts = Shift.get_all_for_day(active_day)
if len(shifts) == 0:
# If there's no shifts nothing can conflict, so don't bother looking.
user_calendar = []
else:
user_calendar = fetch_events(current_user, shifts[0].start, shifts[-1].end)

by_time = defaultdict(lambda: [])

for s in shifts:
hour_key = s.start.strftime("%H:%M")

to_add = s.to_localtime_dict()
to_add["conflicts_with"], to_add["conflicts_detail"] = _get_conflicts(s, user_calendar)
to_add["sign_up_url"] = url_for(".shift", shift_id=to_add["id"])
to_add["is_user_shift"] = current_user in s.volunteers
by_time[hour_key].append(to_add)
Expand Down
29 changes: 29 additions & 0 deletions css/_account.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
.calendar {
width: 100%;

th,
td {
padding: 4px 8px;

&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}

th.time,
td.time,
th.type,
td.type,
th.location,
td.location {
white-space: nowrap;
}

th.event,
td.event {
width: 100%;
}
}
4 changes: 4 additions & 0 deletions css/_panel_grid.scss
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@
}
}

.panel-full-width {
grid-column: 1 / -1;
}

.panel-footer {
background-color: $highlight-background;
border-top: 1px solid rgba(0, 0, 0, 0.12);
Expand Down
1 change: 1 addition & 0 deletions css/_variables.scss
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ $form-element-text: #d5d6da;
/* Alert/button element colours */

$success-background: $brand-2026-green;
$success-border: color.adjust($success-background, $lightness: -15%);
$success-text: #000;

$info-background: $brand-2026-mid-blue;
Expand Down
1 change: 1 addition & 0 deletions css/main.scss
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
@use "./_bar_training.scss";
@use "./_about.scss";
@use "./_wiki.scss";
@use "./_account.scss";

@font-face {
font-family: "Raleway";
Expand Down
Loading
Loading