Source code for libres.db.scheduler

import sedate

from datetime import datetime, timedelta

from libres.context.core import ContextServicesMixin
from libres.db.models import ORMBase, Allocation, ReservedSlot, Reservation
from libres.db.queries import Queries
from libres.modules import compat
from libres.modules import errors
from libres.modules import events
from libres.modules import rasterizer
from libres.modules import utils

from sqlalchemy import func
from sqlalchemy.orm import exc
from sqlalchemy.sql import and_, not_

from uuid import uuid4 as new_uuid


missing = object()


[docs]class Scheduler(ContextServicesMixin): """ The Scheduler is responsible for talking to the backend of the given context to create reservations. It is the main part of the API. """
[docs] def __init__(self, context, name, timezone, allocation_cls=Allocation, reservation_cls=Reservation): """ Initializeds a new Scheduler instance. :context: The :class:`libres.context.core.Context` this scheduler should operate on. Acquire a context by using :func:`libres.context.registry.Registry.register_context`. :name: The name of the Scheduler. The context name and name of the scheduler are used to generate the resource uuid in the database. To access the data you generated with a scheduler use the same context name and scheduler name together. :timezone: A single scheduler always operates on the same timezone. This is used to determine what a whole day means for example (given that a whole day starts at 0:00 and ends at 23:59:59). Dates passed to the scheduler that are not timezone-aware are assumed to be of this timezone! This timezone cannot change after allocations have been created! If it does, a migration has to be written (as of yet no such migration exists). """ assert isinstance(timezone, compat.string_types) self.context = context self.queries = Queries(context) self.name = name self.timezone = timezone self.allocation_cls = allocation_cls self.reservation_cls = reservation_cls
[docs] def clone(self): """ Clones the scheduler. The result will be a new scheduler using the same context, name, settings and attributes. """ return Scheduler(self.context, self.name, self.timezone)
@property def resource(self): """ The resource that belongs to this scheduler. The resource is a uuid created from the name and context of this scheduler, based on the namespace uuid defined in :ref:`settings.uuid_namespace` """ return self.generate_uuid(self.name)
[docs] def setup_database(self): """ Creates the tables and indices required for libres. This needs to be called once per database. Multiple invocations won't hurt but they are unnecessary. """ ORMBase.metadata.create_all(self.session.bind)
def _prepare_dates(self, dates): return [ ( sedate.standardize_date(s, self.timezone), sedate.standardize_date(e, self.timezone) ) for s, e in utils.pairs(dates) ] def _prepare_range(self, start, end): return ( sedate.standardize_date(start, self.timezone), sedate.standardize_date(end, self.timezone) )
[docs] def managed_allocations(self): """ The allocations managed by this scheduler / resource. """ query = self.session.query(Allocation) query = query.filter(Allocation.mirror_of == self.resource) return query
[docs] def managed_reserved_slots(self): """ The reserved_slots managed by this scheduler / resource. """ uuids = self.managed_allocations().with_entities(Allocation.resource) query = self.session.query(ReservedSlot) query = query.filter(ReservedSlot.resource.in_(uuids)) return query
[docs] def managed_reservations(self): """ The reservations managed by this scheduler / resource. """ query = self.session.query(Reservation) query = query.filter(Reservation.resource == self.resource) return query
[docs] def extinguish_managed_records(self): """ WARNING: Completely removes any trace of the records managed by this scheduler. That means all reservations, reserved slots and allocations! """ self.managed_reservations().delete('fetch') self.managed_reserved_slots().delete('fetch') self.managed_allocations().delete('fetch')
def allocation_by_id(self, id): query = self.managed_allocations() query = query.filter(Allocation.mirror_of == self.resource) query = query.filter(Allocation.id == id) return query.one() def allocations_by_ids(self, ids): query = self.managed_allocations() query = query.filter(Allocation.id.in_(ids)) query = query.order_by(Allocation._start) return query def allocations_by_group(self, group, masters_only=True): return self.allocations_by_groups([group], masters_only=masters_only) def allocations_by_groups(self, groups, masters_only=True): query = self.managed_allocations() query = query.filter(Allocation.group.in_(groups)) if masters_only: query = query.filter(Allocation.resource == self.resource) return query
[docs] def allocations_by_reservation(self, token, id=None): """ Returns the allocations for the reservation if it was *approved*, pending reservations return nothing. If you need to get the allocation a pending reservation might be targeting, use _target_allocations in model.reservation. """ # TODO -> this is much too joiny, it was easier when we assumed # that there would be one reservation per token, now that there # is more than one reservation per token we should denormalize # this a little by adding the reservation_id to the reserved slot groups = self.managed_reservations() groups = groups.with_entities(Reservation.target) groups = groups.filter(Reservation.token == token) if id is not None: groups = groups.filter(Reservation.id == id) allocations = self.managed_allocations() allocations = allocations.with_entities(Allocation.id) allocations = allocations.filter(Allocation.group.in_( groups.subquery() )) query = self.managed_allocations() query = query.join(ReservedSlot) query = query.filter( ReservedSlot.reservation_token == token ) query = query.filter( ReservedSlot.allocation_id.in_( allocations.subquery() ) ) return query
def allocations_in_range(self, start, end, masters_only=True): start, end = self._prepare_range(start, end) query = self.managed_allocations() query = self.queries.allocations_in_range(query, start, end) if masters_only: query = query.filter(Allocation.resource == self.resource) return query def allocation_by_date(self, start, end): query = self.allocations_in_range(start, end) return query.one() def allocation_dates_by_group(self, group): query = self.allocations_by_group(group) query = query.with_entities(Allocation._start, Allocation._end) return query.all() def allocation_mirrors_by_master(self, master): return [s for s in master.siblings() if not s.is_master] def allocation_dates_by_ids(self, ids, start_time=None, end_time=None): for allocation in self.allocations_by_ids(ids).all(): s = start_time or allocation.display_start().time() e = end_time or allocation.display_end().time() s, e = allocation.limit_timespan(s, e) yield s, e - timedelta(microseconds=1)
[docs] def manual_approval_required(self, ids): """ Returns True if any of the allocations require manual approval. """ query = self.allocations_by_ids(ids) query = query.filter(Allocation.approve_manually == True) return query.first() and True or False
[docs] def allocate( self, dates, partly_available=False, raster=rasterizer.MIN_RASTER, whole_day=False, quota=None, quota_limit=0, grouped=False, data=None, approve_manually=False, ): """ Allocates a spot in the sedate. An allocation defines a timerange which can be reserved. No reservations can exist outside of existing allocations. In fact any reserved slot will link to an allocation. :dates: The datetimes to allocate. This can be a tuple with start datetime and an end datetime object, or a list of tuples with start and end datetime objects. If the datetime objects are timezone naive they are assumed to be of the same timezone as the scheduler itself. :partly_available: If an allocation is partly available, parts of its daterange may be reserved. So if the allocation lats from 01:00 to 03:00, a reservation may be made from 01:00 to 02:00. if partly_available if False, it may only be reserved as a whole (so from 01:00 to 03:00 in the aforementioned example). If partly_available is True, a raster may be specified. See ``raster``. :raster: If an allocation is partly available a raster defines the granularity with which a reservation can be made. For example: a raster of 15min will ensure that reservations are at least 15 minutes long and start either at :00, :15, :30 or :45). By default, we use a raster of 5, which means that reservations may not be shorter than 5 minutes and will snap to 00:05, 00:10, 00:15 and so on. For performance reasons it is not possible to create reservations shorter than 5 minutes. If you need that, this library is not for you. :whole_day: If true, the hours/minutes of the given dates are ignored and they are made to span a whole day (relative to the scheduler's timezone). :quota: The number of times this allocation may be 'over-reserved'. Say you have a concert and you are selling 20 tickets. The concert is on saturday night, so there's only one start and end date. But there are 20 reservations/tickets that can be made on that allocation. By default, an allocation has a quota of one and may therefore only be reserved once. :quota_limit: The number of times a reservation may 'over-reserve' this allocation. If you are selling tickets for a concert and set the quota_limit to 2, then you are saying that each customer may only acquire 2 tickets at once. If the quota_limit is 0, there is no limit, which is the default. :grouped: Creates a grouped allocation. A grouped allocation is an allocation spanning multiple date-ranges that may only be reserved as a whole. An example for this is a college class which is scheduled to be given every tuesday afternoon. A student may either reserve a spot for the class as a whole (including all tuesday afternoons), or not at all. If the allocation has only one start and one end date, the grouped parameter has no effect. If allocate is called with multiple dates, without grouping, then every created allocation is completely independent. By default, allocations are not grouped. :data: A dictionary of your own chosing that will be attached to the allocation. Use this for your own data. Note that the dictionary needs to be json serializable. For more information see :ref:`custom-json`. :approve_manually: If true, reservations must be approved before they generate reserved slots. This allows for a kind fo waitinglist/queue that forms around an allocation, giving an admin the possiblity to pick the reservations he or she approves of. If false, reservations trigger a reserved slots immediatly, which results in a first-come-first-serve kind of thing. Manual approval is a bit of an anachronism in Libres which **might be removed in the future**. We strongly encourage you to not use this feature and to just keep the default (which is False). """ dates = self._prepare_dates(dates) group = new_uuid() quota = quota or 1 # This is mostly for historic reasons - it's unclear if the current # code could really handle it.. if partly_available and grouped: raise errors.InvalidAllocationError # the whole day option results in the dates being aligned to # the beginning of the day / end of it -> not timezone aware! if whole_day: for ix, (start, end) in enumerate(dates): dates[ix] = sedate.align_range_to_day( start, end, self.timezone ) # Ensure that the list of dates contains no overlaps inside rasterized_dates = [ rasterizer.rasterize_span(s, e, raster) for s, e in dates ] for start, end in rasterized_dates: if sedate.count_overlaps(rasterized_dates, start, end) > 1: raise errors.InvalidAllocationError if end < start: raise errors.InvalidAllocationError # Make sure that this span does not overlap another master for start, end in rasterized_dates: existing = self.allocations_in_range(start, end).first() if existing: raise errors.OverlappingAllocationError(start, end, existing) # Write the master allocations allocations = [] for start, end in dates: allocation = self.allocation_cls() allocation.raster = raster allocation.start = start allocation.end = end allocation.timezone = self.timezone allocation.resource = self.resource allocation.mirror_of = self.resource allocation.quota = quota allocation.quota_limit = quota_limit allocation.partly_available = partly_available allocation.approve_manually = approve_manually allocation.data = data if grouped: allocation.group = group else: allocation.group = new_uuid() allocations.append(allocation) self.session.add_all(allocations) self.session.flush() events.on_allocations_added(self.context, allocations) return allocations
[docs] def change_quota(self, master, new_quota): """ Changes the quota of a master allocation. Fails if the quota is already exhausted. When the quota is decreased a reorganization of the mirrors is triggered. Reorganizing means eliminating gaps in the chain of mirrors that emerge when reservations are removed: Initial State: 1 (master) Free 2 (mirror) Free 3 (mirror) Free Reservations are made: 1 (master) Reserved 2 (mirror) Reserved 3 (mirror) Reserved A reservation is deleted: 1 (master) Reserved 2 (mirror) Free <-- !! 3 (mirror) Reserved Reorganization is performed: 1 (master) Reserved 2 (mirror) Reserved <-- !! 3 (mirror) Free <-- !! The quota is decreased: 1 (master) Reserved 2 (mirror) Reserved In other words, the reserved allocations are moved to the beginning, the free allocations moved at the end. This is done to ensure that the sequence of generated uuids for the mirrors always represent all possible keys. Without the reorganization we would see the following after decreasing the quota: The quota is decreased: 1 (master) Reserved 3 (mirror) Reserved This would make it impossible to calculate the mirror keys. Instead the existing keys would have to queried from the database. """ assert new_quota > 0, "Quota must be greater than 0" if new_quota == master.quota: return if new_quota > master.quota: master.quota = new_quota return # Make sure that the quota can be decreased mirrors = self.allocation_mirrors_by_master(master) allocations = [master] + mirrors free_allocations = [a for a in allocations if a.is_available()] required = master.quota - new_quota if len(free_allocations) < required: raise errors.AffectedReservationError(None) # get a map pointing from the existing uuid to the newly assigned uuid reordered = self.reordered_keylist(allocations, new_quota) # unused keys are the ones not present in the newly assignd uuid list unused = set(reordered.keys()) - set(reordered.values()) - set((None,)) # get a map for resource_uuid -> allocation.id ids = dict(((a.resource, a.id) for a in allocations)) for allocation in allocations: # change the quota for all allocations allocation.quota = new_quota # the value is None if the allocation is not mapped to a new uuid new_resource = reordered[allocation.resource] if not new_resource: continue # move all slots to the mapped allocation id new_id = ids[new_resource] for slot in allocation.reserved_slots: # build a query here as the manipulation of mapped objects in # combination with the delete query below seems a bit # unpredictable given the cascading of changes query = self.session.query(ReservedSlot) query = query.filter(and_( ReservedSlot.resource == slot.resource, ReservedSlot.allocation_id == slot.allocation_id, ReservedSlot.start == slot.start )) query.update( { ReservedSlot.resource: new_resource, ReservedSlot.allocation_id: new_id } ) # get rid of the unused allocations (always preserving the master) if unused: query = self.session.query(Allocation) query = query.filter(Allocation.resource.in_(unused)) query = query.filter(Allocation.id != master.id) query = query.filter(Allocation._start == master._start) query.delete('fetch')
[docs] def reordered_keylist(self, allocations, new_quota): """ Creates the map for the keylist reorganzation. Each key of the returned dictionary is a resource uuid pointing to the resource uuid it should be moved to. If the allocation should not be moved they key-value is None. """ masters = [a for a in allocations if a.is_master] assert(len(masters) == 1) master = masters[0] allocations = dict(((a.resource, a) for a in allocations)) # generate the keylist (the allocation resources may be unordered) keylist = [master.resource] keylist.extend(utils.generate_uuids(master.resource, master.quota)) # prefill the map reordered = dict(((k, None) for k in keylist)) # each free allocation increases the offset by which the next key # for a non-free allocation is acquired offset = 0 for ix, key in enumerate(keylist): if allocations[key].is_available(): offset += 1 else: reordered[key] = keylist[ix - offset] return reordered
[docs] def availability(self, start=None, end=None): """Goes through all allocations and sums up the availability.""" start = start if start else sedate.mindatetime end = end if end else sedate.maxdatetime start, end = self._prepare_range(start, end) return self.queries.availability_by_range(start, end, [self.resource])
def move_allocation( self, master_id, new_start=None, new_end=None, group=None, new_quota=None, approve_manually=None, quota_limit=0, whole_day=None, data=missing): assert master_id assert any([new_start and new_end, group, new_quota]) new_start, new_end = self._prepare_range(new_start, new_end) # Find allocation master = self.allocation_by_id(master_id) mirrors = self.allocation_mirrors_by_master(master) changing = [master] + mirrors ids = [c.id for c in changing] assert master.timezone == self.timezone, """ You are trying to move an allocation that was created with a different timezone. This is currently unsupported. See Scheduler.__init__ -> timezone """ assert(group or master.group) # Simulate the new allocation new_start = new_start or master.start new_end = new_end or master.end if whole_day: new_start, new_end = sedate.align_range_to_day( new_start, new_end, self.timezone ) if new_end < new_start: raise errors.InvalidAllocationError new = self.allocation_cls( start=new_start, end=new_end, raster=master.raster, timezone=self.timezone ) # Ensure that the new span does not overlap an existing one existing_allocations = self.allocations_in_range(new.start, new.end) for existing in existing_allocations: if existing.id not in ids: raise errors.OverlappingAllocationError( new.start, new.end, existing ) for change in changing: if change.partly_available: # confirmed reservations for reservation in change.reserved_slots: if not new.contains(reservation.start, reservation.end): raise errors.AffectedReservationError(reservation) # pending reservations if change.is_master: # (mirrors return the same values) for pending in change.pending_reservations.with_entities( Reservation.start, Reservation.end): if not new.contains(*pending): raise errors.AffectedPendingReservationError( pending ) else: # confirmed reservations if change.start != new.start or change.end != new.end: if len(change.reserved_slots): raise errors.AffectedReservationError( change.reserved_slots[0] ) if change.is_master and \ change.pending_reservations.count(): raise errors.AffectedPendingReservationError( change.pending_reservations[0] ) # the following attributes must be equal over all group members # (this still allows to use move_allocation to remove an allocation # from an existing group by specifiying the new group) for allocation in self.allocations_by_group(group or master.group): if approve_manually is not None: allocation.approve_manually = approve_manually if quota_limit is not None: allocation.quota_limit = quota_limit if new_quota is not None and allocation.is_master: self.change_quota(allocation, new_quota) for change in changing: change.start = new.start change.end = new.end change.group = group or master.group if data is not missing: change.data = data def remove_allocation(self, id=None, groups=None): if id: master = self.allocation_by_id(id) allocations = [master] allocations.extend(self.allocation_mirrors_by_master(master)) elif groups: allocations = self.allocations_by_groups( groups, masters_only=False ) else: raise NotImplementedError for allocation in allocations: assert allocation.mirror_of == self.resource, """ Trying to delete an allocation from a different resource than the scheduler and context. This is a serious error or someone trying to something funny with the POST parameters. """ if allocation.is_transient: # the allocation doesn't exist yet, so we can't delete it continue if len(allocation.reserved_slots) > 0: raise errors.AffectedReservationError( allocation.reserved_slots[0] ) if allocation.pending_reservations.count(): raise errors.AffectedPendingReservationError( allocation.pending_reservations[0] ) for allocation in allocations: if not allocation.is_transient: self.session.delete(allocation)
[docs] def remove_unused_allocations(self, start, end): """ Removes all allocations without reservations between start and end and returns the number of allocations that were deleted. Groups which are partially inside the daterange are not included. """ start, end = self._prepare_range( sedate.as_datetime(start), sedate.as_datetime(end) ) # all the slots slots = self.managed_reserved_slots() slots = slots.with_entities(ReservedSlot.allocation_id) # all the reservations reservations = self.managed_reservations() reservations = reservations.with_entities(Reservation.target) # all the groups which are fully inside the required scope groups = self.managed_allocations().with_entities(Allocation.group) groups = groups.group_by(Allocation.group) groups = groups.having( and_( start <= func.min(Allocation._start), func.max(Allocation._end) <= end ) ) # all allocations candidates = self.managed_allocations() candidates = candidates.filter(start <= Allocation._start) candidates = candidates.filter(Allocation._end <= end) # .. without the ones with slots candidates = candidates.filter( not_(Allocation.id.in_(slots.subquery()))) # .. without the ones with reservations candidates = candidates.filter( not_(Allocation.group.in_(reservations.subquery()))) # .. including only the groups fully inside the required scope allocations = candidates.filter( Allocation.group.in_(groups.subquery())) return allocations.delete('fetch')
[docs] def reserve( self, email, dates=None, group=None, data=None, session_id=None, quota=1, single_token_per_session=False ): """ Reserves one or many allocations. Returns a token that needs to be passed to :meth:`approve_reservations` to complete the reservation. That is to say, Libres uses a two-step reservation process. The first step is reserving what is either an open spot or a place on the waiting list (see ``approve_manually`` of :meth:`~libres.db.scheduler.Scheduler.allocate`). The second step is to actually write out the reserved slots, which is done by approving an existing reservation. Most checks are done in the reserve functions. The approval step only fails if there's no open spot. This function returns a reservation token which can be used to approve the reservation in approve_reservation. Usually you want to just short-circuit those two steps:: scheduler.approve_reservations( scheduler.reserve(dates) ) :email: Each reservation *must* be associated with an email. That is, a user. :dates: The dates to reserve. May either be a tuple of start/end datetimes or a list of such tuples. :group: The allocation group to reserve. ``dates``and ``group`` are mutually exclusive. :data: A dictionary of your own chosing that will be attached to the reservation. Use this for your own data. Note that the dictionary needs to be json serializable. For more information see :ref:`custom-json`. :session_id: An uuid that connects the reservation to a browser session. Together with :meth:`libres.db.queries.Queries.confirm_reservations_for_session` this can be used to create a reservation shopping card. By default the session_id is None, meaning that no browser session is associated with the reservation. :quota: The number of allocations that should be reserved at once. See ``quota`` in :meth:`~libres.db.scheduler.Scheduler.allocate`. :single_token_per_session: If True, all reservations of the same session shared the same token, though that token will differ from the session id itself. This only applies if the reserve function is called multiple times with the same session id. In this case, subsequent reserve calls will re-use whatever token they can find in the table. If there's no existing reservations, a new token will be created. That also applies if a reservation is created, deleted and then another is created. Because the last reserve call won't find any reservations it will create a new token. So the shared token is always the last token returned by the reserve function. Note that this only works reliably if you set this parameter to true for *all* your reserve calls that use a session. """ assert (dates or group) and not (dates and group) email = email.strip() if not self.validate_email(email): raise errors.InvalidEmailAddress if group: dates = self.allocation_dates_by_group(group) dates = self._prepare_dates(dates) timezone = self.timezone # First, the request is checked for saneness. If any requested # date cannot be reserved the request as a whole fails. for start, end in dates: # are the parameters valid? if not utils.is_valid_reservation_length(start, end, timezone): raise errors.ReservationTooLong if start > end or (end - start).seconds < 5 * 60: raise errors.ReservationTooShort # can all allocations be reserved? for allocation in self.allocations_in_range(start, end): # start and end are not rasterized, so we need this check if not allocation.overlaps(start, end): continue assert allocation.is_master # with manual approval the reservation ends up on the # waitinglist and does not yet need a spot if not allocation.approve_manually: if not allocation.find_spot(start, end): raise errors.AlreadyReservedError free = self.free_allocations_count(allocation, start, end) if free < quota: raise errors.AlreadyReservedError if not allocation.contains(start, end): raise errors.TimerangeTooLong() if allocation.quota_limit > 0: if allocation.quota_limit < quota: raise errors.QuotaOverLimit if allocation.quota < quota: raise errors.QuotaImpossible if quota < 1: raise errors.InvalidQuota # ok, we're good to go if single_token_per_session and session_id: existing = self.queries.reservations_by_session(session_id).first() token = existing and existing.token or new_uuid() else: token = new_uuid() reservations = [] # groups are reserved by group-identifier - so all members of a group # or none of them. As such there's no start / end date which is defined # implicitly by the allocation def new_reservations_by_group(group): if group: reservation = self.reservation_cls() reservation.token = token reservation.target = group reservation.status = u'pending' reservation.target_type = u'group' reservation.resource = self.resource reservation.data = data reservation.session_id = session_id reservation.email = email.strip() reservation.quota = quota yield reservation # all other reservations are reserved by start/end date def new_reservations_by_dates(dates): already_reserved_groups = set() for start, end in dates: for allocation in self.allocations_in_range(start, end): if allocation.group in already_reserved_groups: continue if not allocation.overlaps(start, end): continue # automatically reserve the whole group if the allocation # is part of a group if allocation.in_group: already_reserved_groups.add(allocation.group) # I really want to use 'yield from'. Python 3 ftw! for r in new_reservations_by_group(allocation.group): yield r else: reservation = self.reservation_cls() reservation.token = token reservation.start, reservation.end\ = rasterizer.rasterize_span( start, end, allocation.raster ) reservation.timezone = allocation.timezone reservation.target = allocation.group reservation.status = u'pending' reservation.target_type = u'allocation' reservation.resource = self.resource reservation.data = data reservation.session_id = session_id reservation.email = email.strip() reservation.quota = quota yield reservation # create the reservations if group: reservations = tuple(new_reservations_by_group(group)) else: reservations = tuple(new_reservations_by_dates(dates)) if not reservations: raise errors.InvalidReservationError # have a very simple overlap check for reservations, it's not important # that this catches *all* possible problems - that's being handled # by the reservation slots - but it should stop us from adding the same # reservation twice on a single session if session_id: found = self.queries.reservations_by_session(session_id) found = found.with_entities(Reservation.target, Reservation.start) found = set(found.all()) for reservation in reservations: if (reservation.target, reservation.start) in found: raise errors.OverlappingReservationError for reservation in reservations: self.session.add(reservation) events.on_reservations_made(self.context, reservations) return token
def _approve_reservation_record(self, reservation): # write out the slots slots_to_reserve = [] if reservation.target_type == u'group': dates = self.allocation_dates_by_group(reservation.target) else: dates = ((reservation.start, reservation.end),) # the reservation quota is simply implemented by multiplying the # dates which are approved dates = dates * reservation.quota for start, end in dates: for allocation in self.reservation_targets(start, end): allocation_slots = allocation.all_slots(start, end) for slot_start, slot_end in allocation_slots: slot = ReservedSlot() slot.start = slot_start slot.end = slot_end slot.resource = allocation.resource slot.reservation_token = reservation.token # the slots are written with the allocation allocation.reserved_slots.append(slot) slots_to_reserve.append(slot) # the allocation may be a fake one, in which case we # must make it realz yo if allocation.is_transient: self.session.add(allocation) reservation.status = u'approved' if not slots_to_reserve: raise errors.NotReservableError return slots_to_reserve
[docs] def approve_reservations(self, token): """ This function approves an existing reservation and writes the reserved slots accordingly. Returns a list with the reserved slots. """ slots_to_reserve = [] reservations = self.reservations_by_token(token).all() for reservation in reservations: try: slots_to_reserve.extend( self._approve_reservation_record(reservation) ) except errors.LibresError as e: e.reservation = reservation raise e events.on_reservations_approved(self.context, reservations) return slots_to_reserve
[docs] def deny_reservation(self, token): """ Denies a pending reservation, removing it from the records and sending an email to the reservee. """ query = self.reservations_by_token(token) query = query.filter(Reservation.status == u'pending') reservations = query.all() query.delete() events.on_reservations_denied(self.context, reservations)
[docs] def remove_reservation(self, token, id=None): """ Removes all reserved slots of the given reservation token. Note that removing a reservation does not let the reservee know that his reservation has been removed. If you want to let the reservee know what happened, use revoke_reservation. The id is optional. If given, only the reservation with the given token AND id is removed. """ slots = self.reserved_slots_by_reservation(token, id).all() for slot in slots: self.session.delete(slot) reservations = self.reservations_by_token(token, id).all() for reservation in reservations: self.session.delete(reservation) # some allocations still reference reserved_slots if not for this self.session.expire_all() events.on_reservations_removed(self.context, reservations)
def change_email(self, token, new_email): for reservation in self.reservations_by_token(token).all(): reservation.email = new_email def change_reservation_data(self, token, data): for reservation in self.reservations_by_token(token).all(): reservation.data = data
[docs] def change_reservation_time_candidates(self, tokens=None): """ Returns the reservations that fullfill the restrictions imposed by change_reservation_time. Pass a list of reservation tokens to further limit the results. """ query = self.managed_reservations() query = query.filter(Reservation.status == 'approved') query = query.filter(Reservation.target_type == 'allocation') groups = self.managed_allocations().with_entities(Allocation.group) groups = groups.filter(Allocation.partly_available == True) query = query.filter(Reservation.target.in_(groups.subquery())) if tokens: query = query.filter(Reservation.token.in_(tokens)) return query
[docs] def change_reservation_time(self, token, id, new_start, new_end): """ Kept for backwards compatibility, use :meth:`change_reservation` instead. """ return self.change_reservation(token, id, new_start, new_end)
[docs] def change_reservation(self, token, id, new_start, new_end, quota=None): """ Allows to change the timespan of a reservation under certain conditions: - The new timespan must be reservable inside the existing allocation. (So you cannot use this method to reserve another allocation) - The referenced allocation must not be in a group. Returns True if a change was made. Just like revoke_reservation, this function raises an event which includes a send_email flag and a reason which may be used to inform the user of the changes to his reservation. """ # check for the reservation first as the allocation won't exist # if the reservation has not been approved yet assert new_start and new_end new_start, new_end = self._prepare_range(new_start, new_end) existing_reservation = self.reservations_by_token(token, id).one() # if there's nothing to change, do not change if quota is None or existing_reservation.quota == quota: if existing_reservation.start == new_start: ends = (new_end, new_end - timedelta(microseconds=1)) if existing_reservation.end in ends: return False # will return raise a MultipleResultsFound exception if this is a group if existing_reservation.status == 'approved': allocation = self.allocations_by_reservation(token, id).one() else: allocation = existing_reservation._target_allocations().first() if not allocation.contains(new_start, new_end): raise errors.TimerangeTooLong() reservation_arguments = dict( email=existing_reservation.email, dates=(new_start, new_end), data=existing_reservation.data, quota=quota or existing_reservation.quota ) old_start = existing_reservation.display_start() old_end = existing_reservation.display_end() with self.begin_nested(): self.remove_reservation(token, id) new_token = self.reserve(**reservation_arguments) new_reservation = self.reservations_by_token(new_token).one() new_reservation.id = id new_reservation.token = token new_reservation.session_id = existing_reservation.session_id if existing_reservation.status == 'approved': self._approve_reservation_record(new_reservation) events.on_reservation_time_changed( self.context, new_reservation, old_time=(old_start, old_end), new_time=( new_reservation.display_start(), new_reservation.display_end() ), ) return new_reservation
[docs] def search_allocations( self, start, end, days=None, minspots=0, available_only=False, whole_day='any', groups='any', strict=False ): """ Search allocations using a number of options. The date is split into date/time. All allocations between start and end date within the given time (on each day) are included. For example, start=01.01.2012 12:00 end=31.01.2012 14:00 will include all allocations in January 2012 which OVERLAP the given times. So an allocation starting at 11:00 and ending at 12:00 will be included! WARNING allocations not matching the start/end date may be included if they belong to a group from which a member *is* included! If that behavior is not wanted set 'strict' to True or set 'include_groups' to 'no' (though you won't get any groups then). Allocations which are included in this way will return True in the following expression: getattr(allocation, 'is_extra_result', False) :start: Include allocations starting on or after this date. :end: Include allocations ending on or before this date. :days: List of days which should be considered, a subset of: (['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su']) If left out, all days are included. :minspots: Minimum number of spots reservable. :available_only: If True, unavailable allocations are left out (0% availability). Default is False. :whole_day: May have one of the following values: 'yes', 'no', 'any' If yes, only whole_day allocations are returned. If no, whole_day allocations are filtered out. If any (default), all allocations are included. Any is the same as leaving the option out. :include_groups: 'any' if all allocations should be included. 'yes' if only group-allocations should be included. 'no' if no group-allocations should be included. See allocation.in_group to see what constitutes a group :strict: Set to True if you don't want groups included as a whole if a groupmember is found. See comment above. """ assert start assert end start, end = self._prepare_range(start, end) assert whole_day in ('yes', 'no', 'any') assert groups in ('yes', 'no', 'any') if days: days_map = { 'mo': 0, 'tu': 1, 'we': 2, 'th': 3, 'fr': 4, 'sa': 5, 'su': 6 } # get the day from the map - if impossible take the verbatim value # this allows for using strings or integers days = set(days_map.get(day, day) for day in days) query = self.allocations_in_range(start, end) query = query.order_by(Allocation._start) allocations = [] known_groups = set() known_ids = set() for allocation in query.all(): if not self.is_allocation_exposed(allocation): continue s = datetime.combine(allocation.start.date(), start.time()) e = datetime.combine(allocation.end.date(), end.time()) s = sedate.replace_timezone(s, allocation.start.tzname()) e = sedate.replace_timezone(e, allocation.start.tzname()) if not allocation.overlaps(s, e): continue if days: if allocation.start.weekday() not in days: continue if whole_day != 'any': if whole_day == 'yes' and not allocation.whole_day: continue if whole_day == 'no' and allocation.whole_day: continue # minspots means that we don't show allocations which cannot # be reserved with the required spots in one reservation # so we can disregard all allocations with a lower quota limit. # # the spots are later checked again for actual availability, but # that is a heavier check, so it doesn't belong here. if minspots: if allocation.quota_limit > 0: if allocation.quota_limit < minspots: continue if available_only: if not allocation.find_spot(s, e): continue if minspots: availability = self.availability( allocation.start, allocation.end ) if (minspots / float(allocation.quota) * 100.0) > availability: continue # keep track of allocations in groups as those need to be added # to the result, even though they don't match the search in_group = ( allocation.group in known_groups or allocation.in_group ) if in_group: known_groups.add(allocation.group) known_ids.add(allocation.id) if groups != 'any': if groups == 'yes' and not in_group: continue if groups == 'no' and in_group: continue allocations.append(allocation) if not strict and groups != 'no' and known_ids and known_groups: query = self.managed_allocations() query = query.filter(not_(Allocation.id.in_(known_ids))) query = query.filter(Allocation.group.in_(known_groups)) for allocation in query.all(): allocation.is_extra_result = True allocations.append(allocation) allocations.sort(key=lambda a: a._start) return allocations
[docs] def free_allocations_count(self, master_allocation, start, end): """ Returns the number of free allocations between master_allocation and it's mirrors. """ free_allocations = 0 if master_allocation.is_available(start, end): free_allocations += 1 if master_allocation.quota == 1: return free_allocations for mirror in self.allocation_mirrors_by_master(master_allocation): if mirror.is_available(start, end): free_allocations += 1 return free_allocations
[docs] def reservation_targets(self, start, end): """ Returns a list of allocations that are free within start and end. These allocations may come from the master or any of the mirrors. """ targets = [] query = self.queries.all_allocations_in_range(start, end) query = query.filter(Allocation.resource == self.resource) for master_allocation in query: if not master_allocation.overlaps(start, end): continue # may happen because start and end are not rasterized found = master_allocation.find_spot(start, end) if not found: raise errors.AlreadyReservedError targets.append(found) return targets
[docs] def reserved_slots_by_reservation(self, token, id=None): """ Returns all reserved slots of the given reservation. The id is optional and may be used only return the slots from a specific reservation matching token and id. """ assert token query = self.managed_reserved_slots() query = query.filter(ReservedSlot.reservation_token == token) if id is None: return query else: ids = self.allocations_by_reservation(token, id) ids = ids.with_entities(Allocation.id) return query.filter( ReservedSlot.allocation_id.in_(ids.subquery()) )
def reservations_by_group(self, group): tokens = self.managed_reservations().with_entities(Reservation.token) tokens = tokens.filter(Reservation.target == group) return self.managed_reservations().filter( Reservation.token.in_( tokens.subquery() ) ) def reservations_by_allocation(self, allocation_id): master = self.allocation_by_id(allocation_id) return self.reservations_by_group(master.group) def reservations_by_token(self, token, id=None): query = self.managed_reservations() query = query.filter(Reservation.token == token) if id: query = query.filter(Reservation.id == id) try: query.first() except exc.NoResultFound: raise errors.InvalidReservationToken return query