libres 0.9.0__tar.gz → 0.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. {libres-0.9.0 → libres-0.10.0}/HISTORY.rst +34 -0
  2. {libres-0.9.0/src/libres.egg-info → libres-0.10.0}/PKG-INFO +35 -1
  3. {libres-0.9.0 → libres-0.10.0}/pyproject.toml +70 -55
  4. {libres-0.9.0 → libres-0.10.0}/src/libres/__init__.py +2 -2
  5. {libres-0.9.0 → libres-0.10.0}/src/libres/context/registry.py +2 -0
  6. {libres-0.9.0 → libres-0.10.0}/src/libres/db/models/__init__.py +3 -1
  7. {libres-0.9.0 → libres-0.10.0}/src/libres/db/models/allocation.py +69 -19
  8. libres-0.10.0/src/libres/db/models/blocker.py +164 -0
  9. {libres-0.9.0 → libres-0.10.0}/src/libres/db/models/other.py +2 -1
  10. {libres-0.9.0 → libres-0.10.0}/src/libres/db/models/reservation.py +2 -7
  11. {libres-0.9.0 → libres-0.10.0}/src/libres/db/models/reserved_slot.py +15 -11
  12. libres-0.10.0/src/libres/db/models/timespan.py +12 -0
  13. {libres-0.9.0 → libres-0.10.0}/src/libres/db/queries.py +2 -1
  14. {libres-0.9.0 → libres-0.10.0}/src/libres/db/scheduler.py +420 -24
  15. {libres-0.9.0 → libres-0.10.0/src/libres.egg-info}/PKG-INFO +35 -1
  16. {libres-0.9.0 → libres-0.10.0}/src/libres.egg-info/SOURCES.txt +3 -0
  17. {libres-0.9.0 → libres-0.10.0}/tests/test_allocation.py +105 -29
  18. {libres-0.9.0 → libres-0.10.0}/tests/test_registry.py +19 -13
  19. {libres-0.9.0 → libres-0.10.0}/tests/test_reservation.py +13 -6
  20. {libres-0.9.0 → libres-0.10.0}/tests/test_reserved_slot.py +15 -5
  21. {libres-0.9.0 → libres-0.10.0}/tests/test_scheduler.py +154 -111
  22. libres-0.10.0/tests/test_scheduler_blockers.py +496 -0
  23. {libres-0.9.0 → libres-0.10.0}/tests/test_session.py +30 -19
  24. {libres-0.9.0 → libres-0.10.0}/tests/test_test.py +12 -2
  25. {libres-0.9.0 → libres-0.10.0}/tests/test_types.py +4 -2
  26. {libres-0.9.0 → libres-0.10.0}/tests/test_utils.py +3 -1
  27. {libres-0.9.0 → libres-0.10.0}/LICENSE +0 -0
  28. {libres-0.9.0 → libres-0.10.0}/MANIFEST.in +0 -0
  29. {libres-0.9.0 → libres-0.10.0}/README.rst +0 -0
  30. {libres-0.9.0 → libres-0.10.0}/docs/Makefile +0 -0
  31. {libres-0.9.0 → libres-0.10.0}/docs/_static/custom.css +0 -0
  32. {libres-0.9.0 → libres-0.10.0}/docs/_static/favicon.ico +0 -0
  33. {libres-0.9.0 → libres-0.10.0}/docs/_static/logo.svg +0 -0
  34. {libres-0.9.0 → libres-0.10.0}/docs/api.rst +0 -0
  35. {libres-0.9.0 → libres-0.10.0}/docs/concepts.rst +0 -0
  36. {libres-0.9.0 → libres-0.10.0}/docs/conf.py +0 -0
  37. {libres-0.9.0 → libres-0.10.0}/docs/customizations.rst +0 -0
  38. {libres-0.9.0 → libres-0.10.0}/docs/faq.rst +0 -0
  39. {libres-0.9.0 → libres-0.10.0}/docs/index.rst +0 -0
  40. {libres-0.9.0 → libres-0.10.0}/docs/requirements.txt +0 -0
  41. {libres-0.9.0 → libres-0.10.0}/docs/under_the_hood.rst +0 -0
  42. {libres-0.9.0 → libres-0.10.0}/setup.cfg +0 -0
  43. {libres-0.9.0 → libres-0.10.0}/src/libres/.gitignore +0 -0
  44. {libres-0.9.0 → libres-0.10.0}/src/libres/context/__init__.py +0 -0
  45. {libres-0.9.0 → libres-0.10.0}/src/libres/context/core.py +0 -0
  46. {libres-0.9.0 → libres-0.10.0}/src/libres/context/exposure.py +0 -0
  47. {libres-0.9.0 → libres-0.10.0}/src/libres/context/session.py +0 -0
  48. {libres-0.9.0 → libres-0.10.0}/src/libres/context/settings.py +0 -0
  49. {libres-0.9.0 → libres-0.10.0}/src/libres/db/__init__.py +0 -0
  50. {libres-0.9.0 → libres-0.10.0}/src/libres/db/models/base.py +0 -0
  51. {libres-0.9.0 → libres-0.10.0}/src/libres/db/models/timestamp.py +0 -0
  52. {libres-0.9.0 → libres-0.10.0}/src/libres/db/models/types/__init__.py +0 -0
  53. {libres-0.9.0 → libres-0.10.0}/src/libres/db/models/types/json_type.py +0 -0
  54. {libres-0.9.0 → libres-0.10.0}/src/libres/db/models/types/utcdatetime.py +0 -0
  55. {libres-0.9.0 → libres-0.10.0}/src/libres/db/models/types/uuid_type.py +0 -0
  56. {libres-0.9.0 → libres-0.10.0}/src/libres/modules/__init__.py +0 -0
  57. {libres-0.9.0 → libres-0.10.0}/src/libres/modules/errors.py +0 -0
  58. {libres-0.9.0 → libres-0.10.0}/src/libres/modules/events.py +0 -0
  59. {libres-0.9.0 → libres-0.10.0}/src/libres/modules/rasterizer.py +0 -0
  60. {libres-0.9.0 → libres-0.10.0}/src/libres/modules/utils.py +0 -0
  61. {libres-0.9.0 → libres-0.10.0}/src/libres/py.typed +0 -0
  62. {libres-0.9.0 → libres-0.10.0}/src/libres.egg-info/dependency_links.txt +0 -0
  63. {libres-0.9.0 → libres-0.10.0}/src/libres.egg-info/not-zip-safe +0 -0
  64. {libres-0.9.0 → libres-0.10.0}/src/libres.egg-info/requires.txt +0 -0
  65. {libres-0.9.0 → libres-0.10.0}/src/libres.egg-info/top_level.txt +0 -0
@@ -1,6 +1,40 @@
1
1
  Changelog
2
2
  ---------
3
3
 
4
+ 0.10.0 (15.01.2026)
5
+ ~~~~~~~~~~~~~~~~~~~
6
+
7
+ - Adds new entity `ReservationBlocker` for administrative blockers
8
+ of targeted allocations for the targeted timespans, this also ends
9
+ up adding a new column `source_type` to `ReservedSlot` which can be
10
+ added using the following recipe using an alembic `Operations` object::
11
+
12
+ operations.add_column(
13
+ 'reserved_slots',
14
+ Column(
15
+ 'source_type',
16
+ Enum(
17
+ 'reservation', 'blocker',
18
+ name='reserved_slot_source_type'
19
+ ),
20
+ nullable=False,
21
+ server_default='reservation'
22
+ )
23
+ )
24
+ operations.alter_column(
25
+ 'reserved_slots',
26
+ 'source_type',
27
+ server_default=None
28
+ )
29
+
30
+
31
+ 0.9.1 (05.08.2025)
32
+ ~~~~~~~~~~~~~~~~~~~
33
+
34
+ - Fixes bug in `Scheduler.search_allocations` when the searched
35
+ time range contains multiple DST <-> ST transitions.
36
+ [Daverball]
37
+
4
38
  0.9.0 (23.05.2025)
5
39
  ~~~~~~~~~~~~~~~~~~~
6
40
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: libres
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: A library to reserve things
5
5
  Home-page: http://github.com/seantis/libres/
6
6
  Author: Denis Krienbühl
@@ -142,6 +142,40 @@ After this, create a new release on Github.
142
142
  Changelog
143
143
  ---------
144
144
 
145
+ 0.10.0 (15.01.2026)
146
+ ~~~~~~~~~~~~~~~~~~~
147
+
148
+ - Adds new entity `ReservationBlocker` for administrative blockers
149
+ of targeted allocations for the targeted timespans, this also ends
150
+ up adding a new column `source_type` to `ReservedSlot` which can be
151
+ added using the following recipe using an alembic `Operations` object::
152
+
153
+ operations.add_column(
154
+ 'reserved_slots',
155
+ Column(
156
+ 'source_type',
157
+ Enum(
158
+ 'reservation', 'blocker',
159
+ name='reserved_slot_source_type'
160
+ ),
161
+ nullable=False,
162
+ server_default='reservation'
163
+ )
164
+ )
165
+ operations.alter_column(
166
+ 'reserved_slots',
167
+ 'source_type',
168
+ server_default=None
169
+ )
170
+
171
+
172
+ 0.9.1 (05.08.2025)
173
+ ~~~~~~~~~~~~~~~~~~~
174
+
175
+ - Fixes bug in `Scheduler.search_allocations` when the searched
176
+ time range contains multiple DST <-> ST transitions.
177
+ [Daverball]
178
+
145
179
  0.9.0 (23.05.2025)
146
180
  ~~~~~~~~~~~~~~~~~~~
147
181
 
@@ -11,7 +11,7 @@ branch = true
11
11
  source = ["src"]
12
12
 
13
13
  [tool.bumpversion]
14
- current_version = "0.9.0"
14
+ current_version = "0.10.0"
15
15
  commit = true
16
16
  message = "Release {new_version}"
17
17
  tag = true
@@ -108,6 +108,7 @@ select = [
108
108
  "PERF",
109
109
  "PGH004",
110
110
  "PIE",
111
+ "PT",
111
112
  "PYI",
112
113
  "Q",
113
114
  "RUF",
@@ -156,7 +157,6 @@ ignore = [
156
157
  "UP009",
157
158
  "UP012",
158
159
  "UP032",
159
- "UP038",
160
160
  ]
161
161
  unfixable = []
162
162
  external = ["TC"]
@@ -164,11 +164,11 @@ allowed-confusables = ["×"]
164
164
  preview = true
165
165
 
166
166
  [tool.ruff.lint.extend-per-file-ignores]
167
+ "src/**/*.py" = ["PT"]
167
168
  "tests/**/*.py" = [
168
169
  "C4",
169
170
  "D",
170
171
  "FLY002",
171
- "I002",
172
172
  "ISC",
173
173
  "N",
174
174
  "Q",
@@ -219,65 +219,80 @@ docstring-code-format = true
219
219
  docstring-code-line-length = "dynamic"
220
220
 
221
221
  [tool.tox]
222
- legacy_tox_ini = """
223
- [tox]
224
- envlist = py39,py310,py311,flake8,bandit,mypy,report
225
-
226
- [gh-actions]
227
- python =
228
- 3.9: py39
229
- 3.10: py310
230
- 3.11: py311,flake8,bandit,mypy
231
- 3.12: py312
232
- 3.13: py313
222
+ requires = ["tox>=4.21"]
223
+ envlist = [
224
+ "3.9",
225
+ "3.10",
226
+ "3.11",
227
+ "3.12",
228
+ "3.13",
229
+ "flake8",
230
+ "ruff",
231
+ "bandit",
232
+ "mypy",
233
+ "report"
234
+ ]
233
235
 
234
- [testenv]
235
- usedevelop = true
236
- setenv =
237
- py{39,310,311,312,313}: COVERAGE_FILE = .coverage.{envname}
238
- deps =
239
- -e{toxinidir}[test]
240
- commands = pytest --cov --cov-report= {posargs}
236
+ [tool.tox.gh.python]
237
+ "3.9" = ["3.9"]
238
+ "3.10" = ["3.10"]
239
+ "3.11" = ["3.11", "flake8", "bandit", "ruff", "mypy"]
240
+ "3.12" = ["3.12"]
241
+ "3.13" = ["3.13"]
241
242
 
242
- [testenv:ruff]
243
- basepython = python3.11
243
+ [tool.tox.env_run_base]
244
+ use_develop = true
245
+ set_env = { COVERAGE_FILE = ".coverage.{envname}" }
246
+ deps = ["-e{toxinidir}[test]"]
247
+ commands = [
248
+ [
249
+ "pytest",
250
+ "--cov",
251
+ "--cov-report=",
252
+ { replace = "posargs", default = [], extend = true }
253
+ ],
254
+ ]
255
+ [tool.tox.env.ruff]
256
+ base_python = ["python3.11"]
244
257
  skip_install = true
245
- deps =
246
- ruff
247
- commands = ruff check
258
+ deps = ["ruff"]
259
+ commands = [["ruff", "check"]]
248
260
 
249
- [testenv:flake8]
250
- basepython = python3.11
261
+ [tool.tox.env.flake8]
262
+ base_python = ["python3.11"]
251
263
  skip_install = true
252
- deps =
253
- flake8
254
- flake8-type-checking
255
- commands = flake8 src/ tests/
264
+ deps = [
265
+ "flake8",
266
+ "flake8-type-checking",
267
+ ]
268
+ commands = [["flake8", "src{/}", "tests{/}"]]
256
269
 
257
- [testenv:bandit]
258
- basepython = python3.11
270
+ [tool.tox.env.bandit]
271
+ base_python = ["python3.11"]
259
272
  skip_install = true
260
- deps =
261
- bandit[toml]
262
- commands = bandit -q -c pyproject.toml -r src
273
+ deps = ["bandit[toml]"]
274
+ commands = [["bandit", "-q", "-c", "pyproject.toml", "-r", "src"]]
263
275
 
264
- [testenv:mypy]
265
- basepython = python3.11
266
- deps =
267
- -e{toxinidir}[mypy]
268
- commands =
269
- mypy -p libres --python-version 3.9
270
- mypy -p libres --python-version 3.10
271
- mypy -p libres --python-version 3.11
272
- mypy -p libres --python-version 3.12
273
- mypy -p libres --python-version 3.13
274
-
275
- [testenv:report]
276
- deps =
277
- coverage
276
+ [tool.tox.env.mypy]
277
+ base_python = ["python3.11"]
278
278
  skip_install = true
279
- commands =
280
- coverage combine
281
- coverage report -m
279
+ deps = ["-e{toxinidir}[mypy]"]
280
+ commands = [
281
+ ["mypy", "-p", "libres", "--python-version", "3.9"],
282
+ ["mypy", "-p", "libres", "--python-version", "3.10"],
283
+ ["mypy", "-p", "libres", "--python-version", "3.11"],
284
+ ["mypy", "-p", "libres", "--python-version", "3.12"],
285
+ ["mypy", "-p", "libres", "--python-version", "3.13"],
286
+ ]
282
287
 
283
- """
288
+ [tool.tox.env.report]
289
+ base_python = ["python3.11"]
290
+ skip_install = true
291
+ ignore_errors = true
292
+ ignore_outcome = true
293
+ set_env = { COVERAGE_FILE = ".coverage" }
294
+ deps = ["coverage"]
295
+ commands = [
296
+ ["coverage", "combine", "{toxinidir}"],
297
+ ["coverage", "report", "-m"],
298
+ ]
@@ -3,9 +3,9 @@ from __future__ import annotations
3
3
  from libres.context.registry import create_default_registry
4
4
  from libres.db import new_scheduler
5
5
 
6
- registry = create_default_registry()
6
+ registry = create_default_registry() # noqa: RUF067
7
7
 
8
- __version__ = '0.9.0'
8
+ __version__ = '0.10.0'
9
9
  __all__ = (
10
10
  'new_scheduler',
11
11
  'registry'
@@ -82,6 +82,8 @@ class Registry:
82
82
  """
83
83
 
84
84
  contexts: dict[str, Context]
85
+ # FIXME: Why do we allow this to be None? Do we make use
86
+ # of this anywhere?
85
87
  master_context: Context | None = None
86
88
 
87
89
  def __init__(self) -> None:
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from libres.db.models.base import ORMBase
4
4
  from libres.db.models.allocation import Allocation
5
+ from libres.db.models.blocker import ReservationBlocker
5
6
  from libres.db.models.reserved_slot import ReservedSlot
6
7
  from libres.db.models.reservation import Reservation
7
8
 
@@ -10,5 +11,6 @@ __all__ = (
10
11
  'ORMBase',
11
12
  'Allocation',
12
13
  'ReservedSlot',
13
- 'Reservation'
14
+ 'Reservation',
15
+ 'ReservationBlocker',
14
16
  )
@@ -10,9 +10,10 @@ from sqlalchemy.schema import Column
10
10
  from sqlalchemy.schema import Index
11
11
  from sqlalchemy.schema import UniqueConstraint
12
12
  from sqlalchemy.orm import object_session
13
+ from sqlalchemy.orm import relationship
13
14
  from sqlalchemy.orm.util import has_identity
14
15
 
15
- from libres.db.models import ORMBase
16
+ from libres.db.models.base import ORMBase
16
17
  from libres.db.models.types import UUID, UTCDateTime, JSON
17
18
  from libres.db.models.other import OtherModels
18
19
  from libres.db.models.timestamp import TimestampMixin
@@ -136,9 +137,15 @@ class Allocation(TimestampMixin, ORMBase, OtherModels):
136
137
  nullable=False
137
138
  )
138
139
 
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
+ )
142
149
 
143
150
  __table_args__ = (
144
151
  Index('mirror_resource_ix', 'mirror_of', 'resource'),
@@ -502,15 +509,32 @@ class Allocation(TimestampMixin, ORMBase, OtherModels):
502
509
  """Returns the availability in percent."""
503
510
 
504
511
  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
+ )
506
522
 
507
- if total == used:
523
+ if total == blocked:
524
+ # if everything is blocked this allocation is unavailable
508
525
  return 0.0
509
526
 
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:
511
535
  return 100.0
512
536
 
513
- return 100.0 - (float(used) / float(total) * 100.0)
537
+ return 100.0 - 100.0 * (reserved / total)
514
538
 
515
539
  @property
516
540
  def normalized_availability(self) -> float:
@@ -542,27 +566,53 @@ class Allocation(TimestampMixin, ORMBase, OtherModels):
542
566
  # the normalized total slots correspond to the naive delta
543
567
  total = naive_delta.total_seconds() // (self.raster * 60)
544
568
  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
547
571
  ambiguous_start = start.replace(
548
572
  hour=2, minute=0, second=0, microsecond=0)
549
573
  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'
552
584
  if not ambiguous_start <= r.start < ambiguous_end
553
585
  )
554
586
  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
558
600
 
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
561
608
 
562
- if total == used:
609
+ if total == reserved:
563
610
  return 0.0
564
611
 
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)
566
616
 
567
617
  @property
568
618
  def in_group(self) -> int:
@@ -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
@@ -11,6 +11,7 @@ if TYPE_CHECKING:
11
11
  Allocation: type[_models.Allocation]
12
12
  ReservedSlot: type[_models.ReservedSlot]
13
13
  Reservation: type[_models.Reservation]
14
+ ReservationBlocker: type[_models.ReservationBlocker]
14
15
 
15
16
 
16
17
  models = None
@@ -25,7 +26,7 @@ class OtherModels:
25
26
  global models
26
27
  if not models:
27
28
  # FIXME: libres.db exports ORMBase, do we really
28
- # want to makes this accesible?
29
+ # want to makes this accessible?
29
30
  from libres.db import models as m_
30
31
  models = m_
31
32
 
@@ -9,15 +9,15 @@ from sqlalchemy.orm import object_session, deferred
9
9
  from sqlalchemy.schema import Column
10
10
  from sqlalchemy.schema import Index
11
11
 
12
- from libres.db.models import ORMBase
12
+ from libres.db.models.base import ORMBase
13
13
  from libres.db.models.types import UUID, UTCDateTime, JSON
14
14
  from libres.db.models.other import OtherModels
15
+ from libres.db.models.timespan import Timespan
15
16
  from libres.db.models.timestamp import TimestampMixin
16
17
 
17
18
 
18
19
  from typing import Any
19
20
  from typing import Literal
20
- from typing import NamedTuple
21
21
  from typing import TYPE_CHECKING
22
22
  if TYPE_CHECKING:
23
23
  import uuid
@@ -27,11 +27,6 @@ if TYPE_CHECKING:
27
27
  from libres.db.models import Allocation
28
28
 
29
29
 
30
- class Timespan(NamedTuple):
31
- start: datetime
32
- end: datetime
33
-
34
-
35
30
  class Reservation(TimestampMixin, ORMBase, OtherModels):
36
31
  """Describes a pending or approved reservation.
37
32