Skip to content
Open
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
4 changes: 2 additions & 2 deletions docs/reservations.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Use **Previous**, the date field, or **Next** to view another day. If the page s
{% if slack_enabled %}
## Reserve Equipment in Slack

Before you start, make sure that your Slack account is linked to an active ESB user. Ask a staff member for help if Slack says that your account is not linked.
Anyone who can use the ESB Slack app can reserve equipment. An Equipment Status Board account is not required.

### Create a Reservation

Expand All @@ -44,7 +44,7 @@ If your requested time is unavailable or does not meet the equipment reservation

![Slack My Reservations window showing an upcoming reservation and its cancel action](images/reservations-cancel-form.png){ .docs-screenshot-compact }

You can cancel only an active reservation that belongs to you. Slack confirms when the reservation is canceled.
You can cancel only an active reservation that belongs to your Slack identity or linked ESB account. If staff later creates an ESB account for you, reservations you made previously through Slack still appear here. Slack confirms when the reservation is canceled.
{% endif %}

## Manage Reservations
Expand Down
6 changes: 5 additions & 1 deletion esb/forms/reservation_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ class AdminReservationCreateForm(FlaskForm):
submit = SubmitField("Review reservation")

def validate_owner_user_id(self, field):
if self.reservation_type.data == RESERVATION_TYPE_MEMBER and not field.data:
if (
self.reservation_type.data == RESERVATION_TYPE_MEMBER
and not field.data
and not getattr(self, "allow_slack_owner", False)
):
raise ValidationError("Select an active member for this reservation.")


Expand Down
23 changes: 21 additions & 2 deletions esb/models/reservation.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@ class Reservation(db.Model):
name="ck_reservations_type",
),
db.CheckConstraint(
"(reservation_type = 'member' AND user_id IS NOT NULL) "
"OR (reservation_type = 'admin_hold' AND user_id IS NULL)",
"(reservation_type = 'member' AND "
"((user_id IS NOT NULL AND slack_user_id IS NULL AND slack_display_name IS NULL) "
"OR (user_id IS NULL AND slack_user_id IS NOT NULL AND slack_display_name IS NOT NULL))) "
"OR (reservation_type = 'admin_hold' AND user_id IS NULL "
"AND slack_user_id IS NULL AND slack_display_name IS NULL)",
name="ck_reservations_type_owner",
),
db.CheckConstraint("ends_at > starts_at", name="ck_reservations_valid_interval"),
Expand All @@ -34,6 +37,8 @@ class Reservation(db.Model):
id = db.Column(db.Integer, primary_key=True)
equipment_id = db.Column(db.Integer, db.ForeignKey("equipment.id"), nullable=False, index=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True)
slack_user_id = db.Column(db.String(32), nullable=True, index=True)
slack_display_name = db.Column(db.String(80), nullable=True)
starts_at = db.Column(db.DateTime, nullable=False, index=True)
ends_at = db.Column(db.DateTime, nullable=False, index=True)
status = db.Column(db.String(20), default='active', nullable=False, index=True)
Expand Down Expand Up @@ -99,5 +104,19 @@ def is_admin_hold(self) -> bool:
"""Return whether this row blocks equipment without a member owner."""
return self.reservation_type == RESERVATION_TYPE_ADMIN_HOLD

@property
def is_slack_owned(self) -> bool:
"""Return whether this member reservation belongs directly to Slack."""
return self.slack_user_id is not None

@property
def owner_display_name(self) -> str:
"""Return the stable owner label used by administrative views."""
if self.user is not None:
return self.user.display_name
if self.slack_display_name:
return f"{self.slack_display_name} (Slack)"
return "Admin Hold"

def __repr__(self):
return f'<Reservation {self.equipment_id} {self.starts_at!r}>'
18 changes: 11 additions & 7 deletions esb/services/notification_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,18 +170,20 @@ def queue_notification(

def queue_member_reservation_notification(reservation: Reservation, event_type: str) -> str | None:
"""Queue a member reservation DM; return a non-fatal warning on failure."""
if reservation.reservation_type == RESERVATION_TYPE_ADMIN_HOLD or reservation.user is None:
if reservation.reservation_type == RESERVATION_TYPE_ADMIN_HOLD:
return None
if not current_app.config.get('SLACK_BOT_TOKEN', ''):
return 'Reservation was saved, but Slack notifications are not configured.'

equipment = reservation.equipment
base_url = current_app.config.get('ESB_BASE_URL', '').rstrip('/')
recipient = reservation.user.email if reservation.user else reservation.slack_user_id
payload = {
'event_type': event_type,
'reservation_id': reservation.id,
'recipient_email': reservation.user.email,
'recipient_username': reservation.user.username,
'recipient_email': reservation.user.email if reservation.user else None,
'recipient_slack_user_id': reservation.slack_user_id,
'recipient_username': reservation.user.username if reservation.user else reservation.slack_display_name,
'equipment_name': equipment.name if equipment else f'Equipment {reservation.equipment_id}',
'area_name': equipment.area.name if equipment and equipment.area else 'Unknown Area',
'starts_at_label': utc_naive_to_local(reservation.starts_at).strftime('%Y-%m-%d %I:%M %p %Z'),
Expand All @@ -192,7 +194,7 @@ def queue_member_reservation_notification(reservation: Reservation, event_type:
try:
queue_notification(
notification_type='slack_dm',
target=reservation.user.email,
target=recipient,
payload=payload,
)
except Exception:
Expand Down Expand Up @@ -402,16 +404,18 @@ def _deliver_slack_message(notification: PendingNotification) -> None:


def _deliver_slack_dm(notification: PendingNotification) -> None:
"""Resolve a member by email and deliver a reservation DM through Slack."""
"""Deliver a reservation DM by stored Slack ID or member email."""
payload = dict(notification.payload or {})
recipient_email = payload.get('recipient_email')
if not recipient_email:
raise RuntimeError('Reservation notification has no recipient email')
recipient_slack_user_id = payload.get('recipient_slack_user_id')
if not recipient_email and not recipient_slack_user_id:
raise RuntimeError('Reservation notification has no recipient')
text, _blocks = _format_slack_message(payload)
from esb.services import slack_dm_service

slack_dm_service.deliver_direct_message(
recipient_email=recipient_email,
recipient_slack_user_id=recipient_slack_user_id,
text=text,
timeout=15,
)
Expand Down
49 changes: 40 additions & 9 deletions esb/services/reservation_read_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from datetime import UTC, date, datetime, timedelta
from typing import TypedDict

from sqlalchemy import or_
from sqlalchemy.orm import joinedload

from esb.extensions import db
Expand Down Expand Up @@ -138,20 +139,33 @@ def get_admin_reservation_creation_options() -> dict[str, list]:
}


def get_user_reservation(reservation_id: int, user_id: int) -> Reservation | None:
"""Return a reservation only when it belongs to the given user."""
def get_user_reservation(
reservation_id: int,
user_id: int | None,
slack_user_id: str | None = None,
) -> Reservation | None:
"""Return a reservation only when either supplied identity owns it."""
owner_filters = _owner_filters(user_id, slack_user_id)
if not owner_filters:
return None
return db.session.execute(
db.select(Reservation).filter_by(id=reservation_id, user_id=user_id)
db.select(Reservation).filter(Reservation.id == reservation_id, or_(*owner_filters))
).scalar_one_or_none()


def list_user_upcoming_reservations(user_id: int) -> list[Reservation]:
"""Return a user's active reservations that have not ended yet."""
def list_user_upcoming_reservations(
user_id: int | None,
slack_user_id: str | None = None,
) -> list[Reservation]:
"""Return active future reservations owned by either supplied identity."""
owner_filters = _owner_filters(user_id, slack_user_id)
if not owner_filters:
return []
return list(
db.session.execute(
db.select(Reservation)
.filter(
Reservation.user_id == user_id,
or_(*owner_filters),
Reservation.status == ACTIVE_STATUS,
Reservation.ends_at > _utc_now(),
)
Expand All @@ -162,6 +176,15 @@ def list_user_upcoming_reservations(user_id: int) -> list[Reservation]:
)


def _owner_filters(user_id: int | None, slack_user_id: str | None) -> list:
filters = []
if user_id is not None:
filters.append(Reservation.user_id == user_id)
if slack_user_id:
filters.append(Reservation.slack_user_id == slack_user_id)
return filters


def get_admin_reservation(reservation_id: int) -> Reservation:
"""Return a reservation and its edit/display relationships for admin actions."""
reservation = db.session.execute(
Expand Down Expand Up @@ -557,7 +580,7 @@ def _serialize_admin_reservation(reservation: Reservation) -> AdminReservationRo
ends_local = utc_naive_to_local(reservation.ends_at)
equipment = reservation.equipment
settings = equipment.reservation_settings if equipment else None
owner = reservation.user.display_name if reservation.user else "Admin Hold"
owner = reservation.owner_display_name
equipment_name = equipment.name if equipment else f"Equipment {reservation.equipment_id}"
note = reservation.notes or ""
calendar_label = f"{equipment_name}: {owner}"
Expand All @@ -578,8 +601,16 @@ def _serialize_admin_reservation(reservation: Reservation) -> AdminReservationRo
"ends_at_label": _format_local_datetime_label(ends_local),
"status": reservation.status,
"created_via": reservation.created_via,
"created_by": reservation.created_by_user.display_name if reservation.created_by_user else "",
"canceled_by": reservation.canceled_by_user.display_name if reservation.canceled_by_user else "",
"created_by": (
reservation.created_by_user.display_name
if reservation.created_by_user
else owner if reservation.created_via == "slack" else ""
),
"canceled_by": (
reservation.canceled_by_user.display_name
if reservation.canceled_by_user
else owner if reservation.status == CANCELED_STATUS and reservation.is_slack_owned else ""
),
"replaces_reservation_id": reservation.replaces_reservation_id,
"replaces_label": (
f"Reservation #{reservation.replaces_reservation_id}" if reservation.replaces_reservation_id else ""
Expand Down
Loading
Loading