Skip to content

Commit b22e025

Browse files
committed
Adds support for simple reservation blockers
1 parent 0591dd9 commit b22e025

14 files changed

Lines changed: 1279 additions & 59 deletions

HISTORY.rst

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,30 @@
11
Changelog
22
---------
33

4+
- Adds new entity `ReservationBlocker` for administrative blockers
5+
of targeted allocations for the targeted timespans, this also ends
6+
up adding a new column `source_type` to `ReservedSlot` which can be
7+
added using the following recipe using an alembic `Operations` object::
8+
9+
operations.add_column(
10+
'reserved_slots',
11+
Column(
12+
'source_type',
13+
Enum(
14+
'reservation', 'blocker',
15+
name='reserved_slot_source_type'
16+
),
17+
nullable=False,
18+
server_default='reservation'
19+
)
20+
)
21+
operations.alter_column(
22+
'reserved_slots',
23+
'source_type',
24+
server_default=None
25+
)
26+
27+
428
0.9.1 (05.08.2025)
529
~~~~~~~~~~~~~~~~~~~
630

src/libres/db/models/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from libres.db.models.base import ORMBase
44
from libres.db.models.allocation import Allocation
5+
from libres.db.models.blocker import ReservationBlocker
56
from libres.db.models.reserved_slot import ReservedSlot
67
from libres.db.models.reservation import Reservation
78

@@ -10,5 +11,6 @@
1011
'ORMBase',
1112
'Allocation',
1213
'ReservedSlot',
13-
'Reservation'
14+
'Reservation',
15+
'ReservationBlocker',
1416
)

src/libres/db/models/allocation.py

Lines changed: 69 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
from sqlalchemy.schema import Index
1111
from sqlalchemy.schema import UniqueConstraint
1212
from sqlalchemy.orm import object_session
13+
from sqlalchemy.orm import relationship
1314
from sqlalchemy.orm.util import has_identity
1415

15-
from libres.db.models import ORMBase
16+
from libres.db.models.base import ORMBase
1617
from libres.db.models.types import UUID, UTCDateTime, JSON
1718
from libres.db.models.other import OtherModels
1819
from libres.db.models.timestamp import TimestampMixin
@@ -136,9 +137,15 @@ class Allocation(TimestampMixin, ORMBase, OtherModels):
136137
nullable=False
137138
)
138139

139-
if TYPE_CHECKING:
140-
# forward declare backref
141-
reserved_slots: list[ReservedSlot]
140+
# Reserved_slots are eagerly joined since we usually want both
141+
# allocation and reserved_slots. There's barely a function which does
142+
# not need to know about reserved slots when working with allocations.
143+
reserved_slots: relationship[list[ReservedSlot]] = relationship(
144+
'ReservedSlot',
145+
lazy='joined',
146+
cascade='all, delete-orphan',
147+
back_populates='allocation'
148+
)
142149

143150
__table_args__ = (
144151
Index('mirror_resource_ix', 'mirror_of', 'resource'),
@@ -502,15 +509,32 @@ def availability(self) -> float:
502509
"""Returns the availability in percent."""
503510

504511
total = self.count_slots()
505-
used = len(self.reserved_slots)
512+
blocked = sum(
513+
1
514+
for s in self.reserved_slots
515+
if s.source_type == 'blocker'
516+
)
517+
reserved = sum(
518+
1
519+
for s in self.reserved_slots
520+
if s.source_type == 'reservation'
521+
)
506522

507-
if total == used:
523+
if total == blocked:
524+
# if everything is blocked this allocation is unavailable
508525
return 0.0
509526

510-
if used == 0:
527+
# blockers detract from the total slots
528+
# they're not part of the availability
529+
total -= blocked
530+
531+
if total == reserved:
532+
return 0.0
533+
534+
if reserved == 0:
511535
return 100.0
512536

513-
return 100.0 - (float(used) / float(total) * 100.0)
537+
return 100.0 - 100.0 * (reserved / total)
514538

515539
@property
516540
def normalized_availability(self) -> float:
@@ -542,27 +566,53 @@ def normalized_availability(self) -> float:
542566
# the normalized total slots correspond to the naive delta
543567
total = naive_delta.total_seconds() // (self.raster * 60)
544568
if real_delta > naive_delta:
545-
# this is the most complicated case since we need to
546-
# reduce the set of reserved slots by the hour we skipped
569+
# this is the most complicated case since we need to reduce the
570+
# set of reserved slots by the hour we removed from the total
547571
ambiguous_start = start.replace(
548572
hour=2, minute=0, second=0, microsecond=0)
549573
ambiguous_end = ambiguous_start.replace(hour=3)
550-
used = sum(
551-
1 for r in self.reserved_slots
574+
blocked = sum(
575+
1
576+
for r in self.reserved_slots
577+
if r.source_type == 'blocker'
578+
if not ambiguous_start <= r.start < ambiguous_end
579+
)
580+
reserved = sum(
581+
1
582+
for r in self.reserved_slots
583+
if r.source_type == 'reservation'
552584
if not ambiguous_start <= r.start < ambiguous_end
553585
)
554586
else:
555-
used = len(self.reserved_slots)
556-
# add one hour's worth of reserved slots
557-
used += 60 // self.raster
587+
blocked = sum(
588+
1
589+
for s in self.reserved_slots
590+
if s.source_type == 'blocker'
591+
)
592+
reserved = sum(
593+
1
594+
for s in self.reserved_slots
595+
if s.source_type == 'reservation'
596+
)
597+
# add one hour's worth of slots to compensate for the extra
598+
# hour we added to the total.
599+
reserved += 60 // self.raster
558600

559-
if used == 0:
560-
return 100.0
601+
if total == blocked:
602+
# if everything is blocked this allocation is unavailable
603+
return 0.0
604+
605+
# blockers detract from the total slots
606+
# they're not part of the availability
607+
total -= blocked
561608

562-
if total == used:
609+
if total == reserved:
563610
return 0.0
564611

565-
return 100.0 - (float(used) / float(total) * 100.0)
612+
if reserved == 0:
613+
return 100.0
614+
615+
return 100.0 - 100.0 * (reserved / total)
566616

567617
@property
568618
def in_group(self) -> int:

src/libres/db/models/blocker.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
from __future__ import annotations
2+
3+
import sedate
4+
5+
from datetime import datetime, timedelta
6+
7+
from sqlalchemy import types
8+
from sqlalchemy.orm import object_session
9+
from sqlalchemy.schema import Column
10+
from sqlalchemy.schema import Index
11+
12+
from libres.db.models.base import ORMBase
13+
from libres.db.models.types import UUID, UTCDateTime
14+
from libres.db.models.other import OtherModels
15+
from libres.db.models.timespan import Timespan
16+
from libres.db.models.timestamp import TimestampMixin
17+
18+
19+
from typing import Literal
20+
from typing import TYPE_CHECKING
21+
if TYPE_CHECKING:
22+
import uuid
23+
from sedate.types import TzInfoOrName
24+
from sqlalchemy.orm import Query
25+
26+
from libres.db.models import Allocation
27+
28+
29+
class ReservationBlocker(TimestampMixin, ORMBase, OtherModels):
30+
"""Describes a reservation blocker.
31+
32+
Blockers can be used to signify that an allocation will be blocked for
33+
the specified time span, in order to e.g. perform cleaning duties on
34+
the relevant resource.
35+
36+
"""
37+
38+
__tablename__ = 'reservation_blockers'
39+
40+
id: Column[int] = Column(
41+
types.Integer(),
42+
primary_key=True,
43+
autoincrement=True
44+
)
45+
46+
token: Column[uuid.UUID] = Column(
47+
UUID(),
48+
nullable=False,
49+
)
50+
51+
target: Column[uuid.UUID] = Column(
52+
UUID(),
53+
nullable=False,
54+
)
55+
56+
target_type: Column[Literal['group', 'allocation']] = Column(
57+
types.Enum( # type:ignore[arg-type]
58+
'group', 'allocation',
59+
name='reservation_blocker_target_type'
60+
),
61+
nullable=False
62+
)
63+
64+
resource: Column[uuid.UUID] = Column(
65+
UUID(),
66+
nullable=False
67+
)
68+
69+
start: Column[datetime | None] = Column(
70+
UTCDateTime(timezone=False),
71+
nullable=True
72+
)
73+
74+
end: Column[datetime | None] = Column(
75+
UTCDateTime(timezone=False),
76+
nullable=True
77+
)
78+
79+
timezone: Column[str | None] = Column(
80+
types.String(),
81+
nullable=True
82+
)
83+
84+
reason: Column[str | None] = Column(
85+
types.String(),
86+
nullable=True
87+
)
88+
89+
__table_args__ = (
90+
Index('blocker_target_ix', 'target', 'id'),
91+
)
92+
93+
def target_allocations(
94+
self,
95+
masters_only: bool = True
96+
) -> Query[Allocation]:
97+
""" Returns the allocations this blocker is targeting.
98+
99+
"""
100+
Allocation = self.models.Allocation # noqa: N806
101+
query = object_session(self).query(Allocation)
102+
query = query.filter(Allocation.group == self.target)
103+
104+
if masters_only:
105+
query = query.filter(Allocation.resource == Allocation.mirror_of)
106+
107+
# order by date
108+
query = query.order_by(Allocation._start)
109+
110+
return query # type: ignore[no-any-return]
111+
112+
def display_start(
113+
self,
114+
timezone: TzInfoOrName | None = None
115+
) -> datetime:
116+
"""Does nothing but to form a nice pair to display_end."""
117+
assert self.start is not None
118+
if timezone is None:
119+
assert self.timezone is not None
120+
timezone = self.timezone
121+
return sedate.to_timezone(self.start, timezone)
122+
123+
def display_end(
124+
self,
125+
timezone: TzInfoOrName | None = None
126+
) -> datetime:
127+
"""Returns the end plus one microsecond (nicer display)."""
128+
assert self.end is not None
129+
if timezone is None:
130+
assert self.timezone is not None
131+
timezone = self.timezone
132+
133+
end = self.end + timedelta(microseconds=1)
134+
return sedate.to_timezone(end, timezone)
135+
136+
def timespans(self) -> list[Timespan]:
137+
""" Returns the timespans targeted by this blocker.
138+
139+
The result is a list of :class:`~libres.db.models.timespan.Timespan`
140+
timespans. The start and end are the start and end dates of the
141+
referenced allocations.
142+
143+
The timespans are ordered by start.
144+
145+
"""
146+
147+
if self.target_type == 'allocation':
148+
# we don't need to hit the database in this case
149+
assert self.start is not None
150+
assert self.end is not None
151+
return [
152+
Timespan(self.start, self.end + timedelta(microseconds=1))
153+
]
154+
elif self.target_type == 'group':
155+
return [
156+
Timespan(allocation.start, allocation.end)
157+
for allocation in self.target_allocations()
158+
]
159+
else:
160+
raise NotImplementedError
161+
162+
@property
163+
def title(self) -> str | None:
164+
return self.reason

src/libres/db/models/other.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class _Models(Protocol):
1111
Allocation: type[_models.Allocation]
1212
ReservedSlot: type[_models.ReservedSlot]
1313
Reservation: type[_models.Reservation]
14+
ReservationBlocker: type[_models.ReservationBlocker]
1415

1516

1617
models = None
@@ -25,7 +26,7 @@ def models(self) -> _Models:
2526
global models
2627
if not models:
2728
# FIXME: libres.db exports ORMBase, do we really
28-
# want to makes this accesible?
29+
# want to makes this accessible?
2930
from libres.db import models as m_
3031
models = m_
3132

src/libres/db/models/reservation.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@
99
from sqlalchemy.schema import Column
1010
from sqlalchemy.schema import Index
1111

12-
from libres.db.models import ORMBase
12+
from libres.db.models.base import ORMBase
1313
from libres.db.models.types import UUID, UTCDateTime, JSON
1414
from libres.db.models.other import OtherModels
15+
from libres.db.models.timespan import Timespan
1516
from libres.db.models.timestamp import TimestampMixin
1617

1718

1819
from typing import Any
1920
from typing import Literal
20-
from typing import NamedTuple
2121
from typing import TYPE_CHECKING
2222
if TYPE_CHECKING:
2323
import uuid
@@ -27,11 +27,6 @@
2727
from libres.db.models import Allocation
2828

2929

30-
class Timespan(NamedTuple):
31-
start: datetime
32-
end: datetime
33-
34-
3530
class Reservation(TimestampMixin, ORMBase, OtherModels):
3631
"""Describes a pending or approved reservation.
3732

0 commit comments

Comments
 (0)