async def test_trigger_event(self): emitter = EventHub(ExampleEvents) def raise_listener(argument): raise TestException("This runs!") with pytest.raises(TestException): emitter.subscribe(ExampleEvents.something_happened, raise_listener) emitter.emit(ExampleEvents.something_happened, argument="test")
class RentalManager(Rebuildable): """ Handles the lifecycle of the rental in the system. Also publishes events on its hub, so that other modules can stay up to date with the system. When the module starts, it will replay all events from midnight that night. """ def __init__(self, payment_manager: PaymentManager): self._active_rentals: Dict[int, Tuple[int, int]] = {} """Maps user ids to a tuple containing their current rental and current bike.""" self.hub = EventHub(RentalEvent) self._payment_manager = payment_manager async def create(self, user: User, bike: Bike) -> Tuple[Rental, Point]: """ Creates a new rental for a user. :raises ActiveRentalError: If the requested user currently has a rental active. :raises CurrentlyRentedError: If the requested bike is currently in use. """ if user.id in self._active_rentals: raise ActiveRentalError(self._active_rentals[user.id]) if self.is_in_use(bike): raise CurrentlyRentedError("The requested bike is in use.", []) if not await self._payment_manager.is_customer(user): raise CustomerError() rental = await Rental.create(user=user, bike=bike) rental.bike = bike rental.user = user await self._publish_event(rental, RentalUpdateType.RENT) self._active_rentals[user.id] = (rental.id, bike.id) await rental.fetch_related("updates") current_location = await LocationUpdate.filter(bike=bike).first() self.hub.emit(RentalEvent.rental_started, user, bike, current_location.location) return rental, current_location.location if current_location is not None else None async def finish(self, user: User, *, extra_cost=0.0) -> Tuple[Rental, str]: """ Completes a rental, charging the customer. :returns: A tuple with the rental and the URL of the receipt. :raises InactiveRentalError: When there is no active rental for that user, or the given rental is not active. """ rental, distance = await self._get_rental_with_distance(user) rental.price = await get_price(rental.updates[0].time, datetime.now(), extra_cost) current_location = await LocationUpdate.filter(bike_id=rental.bike_id ).first() success, receipt_url = await self._payment_manager.charge_customer( user, rental, distance) if success: del self._active_rentals[user.id] await rental.save() await self._publish_event(rental, RentalUpdateType.RETURN) self.hub.emit(RentalEvent.rental_ended, user, rental.bike, current_location.location, rental.price, distance) else: raise Exception("Couldn't charge card!") return rental, receipt_url async def cancel(self, user: User) -> Rental: """Cancels a rental, effective immediately, waiving the rental fee.""" rental, distance = await self._get_rental_with_distance(user) await self._publish_event(rental, RentalUpdateType.CANCEL) self.hub.emit(RentalEvent.rental_cancelled, user, rental.bike) del self._active_rentals[user.id] return rental async def active_rentals(self) -> List[Rental]: """Gets all the active rentals.""" active_rental_ids = [ rental_id for rental_id, bike_id in self._active_rentals.values() ] return await Rental.filter(id__in=active_rental_ids ).prefetch_related('updates', 'bike') async def active_rental( self, user: Union[User, int], *, with_locations=False) -> Optional[Union[Rental, Tuple]]: """Gets the active rental for a given user.""" if isinstance(user, int): user_id = user if isinstance(user, User): user_id = user.id rental_id, bike_id = self._active_rentals.get(user_id, (None, None)) if rental_id is None: return None rental = await Rental.filter(id=rental_id).first().prefetch_related( 'updates', 'bike') if with_locations: locations = await LocationUpdate.filter( bike_id=bike_id, time__gte=rental.updates[0].time.strftime("%Y-%m-%d %H:%M:%S")) if locations: return rental, locations[0].location, locations[-1].location else: return rental, None, None else: return rental def has_active_rental(self, user: Union[User, int]) -> bool: """Checks if the given user has an active rental.""" return (user if isinstance(user, int) else user.id) in self._active_rentals def is_active(self, rental_id): """Checks if the given rental ID is currently active.""" return rental_id in { rental for rental, bike in self._active_rentals.values() } def is_in_use(self, bike: Union[Bike, int]) -> bool: """Checks if the given bike is in use.""" bid = bike.id if isinstance(bike, Bike) else bike return any(bid == rental[1] for rental in self._active_rentals.values()) def is_available(self, bike: Union[Bike, int], reservation_manager) -> bool: """A bike is available if the bike is un-rented and it is not reserved.""" return not self.is_in_use( bike) and not reservation_manager.is_reserved(bike) def is_renting(self, user_id: int, bike_id: int) -> bool: """Checks if the given user is renting the given bike.""" return user_id in self._active_rentals and self._active_rentals[ user_id][1] == bike_id async def get_price_estimate(self, rental: Union[Rental, int]) -> float: """Gets the price of the rental so far.""" if isinstance(rental, int): rental = await Rental.filter(id=rental).first() elif isinstance(rental, Rental): rental = rental return await get_price(rental.start_time, datetime.now()) async def get_available_bikes_out_of(self, bike_ids: List[int]) -> List[Bike]: """Given a list of bike ids, checks if they are free or not and returns the ones that are free.""" used_bikes = { bike_id for rental_id, bike_id in self._active_rentals.values() } available_bikes = set(bike_ids) - used_bikes if not available_bikes: return [] query = Bike.filter(id__in=available_bikes) return await query.prefetch_related( Prefetch("location_updates", queryset=LocationUpdate.all().limit(100)), "state_updates", Prefetch("issues", queryset=Issue.filter(status__not=IssueStatus.CLOSED))) async def _rebuild(self): """ Rebuilds the currently active rentals from the database. Also replays events that happened today for use by subscribers. """ async for rental in await unfinished_rentals(): self._active_rentals[rental.user_id] = (rental.id, rental.bike_id) midnight = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) todays_updates = await RentalUpdate.filter(time__gt=midnight ).prefetch_related( "rental", "rental__user", "rental__bike") for update in todays_updates: if update.type == RentalUpdateType.RENT: self.hub.emit(RentalEvent.rental_started, update.rental.user, update.rental.bike, None) # todo location elif update.type == RentalUpdateType.RETURN: self.hub.emit(RentalEvent.rental_ended, update.rental.user, update.rental.bike, None, update.rental.price, None) elif update.type == RentalUpdateType.CANCEL: self.hub.emit(RentalEvent.rental_cancelled, update.rental.user, update.rental.bike) async def _get_rental_with_distance(self, user: User) -> Tuple[Rental, float]: """Given a rental or user, "resolves" the rental, user pair.""" if not isinstance(user, User): raise TypeError( f"Supplied target must be a Rental or User, not {type(user)}") if user.id not in self._active_rentals: raise InactiveRentalError("Given user has no active rentals!") rental_id, bike_id = self._active_rentals[user.id] rental, distance = await get_rental_with_distance(rental_id) return rental, distance @staticmethod async def _publish_event(rental: Rental, event_type: RentalUpdateType): update = await RentalUpdate.create(rental=rental, type=event_type) if rental.updates._fetched: rental.updates.related_objects.append(update)