Skip to content

Commit

Permalink
✨ Add campsite search by exact date windows
Browse files Browse the repository at this point in the history
Use with the `--exact-windows` flag to `camply campsites`
  • Loading branch information
sethgirvan committed Sep 14, 2023
1 parent 881586a commit 23c705c
Show file tree
Hide file tree
Showing 9 changed files with 486 additions and 12 deletions.
25 changes: 25 additions & 0 deletions camply/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,14 @@ def campgrounds(
metavar="TEXT",
help="Day(s) of the Week to search.",
)
exact_windows_argument = click.option(
"--exact-windows",
is_flag=True,
show_default=True,
default=False,
help="Search only for bookings which exactly match one of the ranges "
"specified with the --start-date and --end-date arguments.",
)


def _get_equipment(equipment: Optional[List[str]]) -> List[Tuple[str, Optional[int]]]:
Expand Down Expand Up @@ -526,7 +534,10 @@ def _validate_campsites(
notify_first_try: bool,
search_forever: bool,
search_once: bool,
weekends: bool,
nights: int,
day: Optional[Tuple[str]],
exact_windows: bool,
**kwargs: Dict[str, Any],
) -> Tuple[bool, List[SearchWindow], Set[int]]:
"""
Expand All @@ -548,7 +559,10 @@ def _validate_campsites(
notifications: List[str]
notify_first_try: bool
search_forever: bool
weekends: bool
nights: int
day: Optional[Tuple[str]]
exact_windows: bool
**kwargs: Dict[str, Any]
Returns
Expand Down Expand Up @@ -583,6 +597,11 @@ def _validate_campsites(
"You cannot specify `--search-once` alongside `--continuous` or `--search-forever`"
)
sys.exit(1)
if exact_windows and any([day, weekends, nights != 1]):
logger.error(
"You cannot specify `--exact-windows` alongside `--nights`, `--day`, or `--weekends`"
)
sys.exit(1)

if any(
[
Expand Down Expand Up @@ -618,6 +637,7 @@ def _get_provider_kwargs_from_cli(
equipment: Tuple[Union[str, int]],
equipment_id: Tuple[Union[str, int]],
day: Optional[Tuple[str]],
exact_windows: bool,
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
"""
Get Provider kwargs from CLI
Expand All @@ -640,6 +660,7 @@ def _get_provider_kwargs_from_cli(
search_forever=search_forever,
search_once=search_once,
day=day,
exact_windows=exact_windows,
)
if len(notifications) == 0:
notifications = ["silent"]
Expand All @@ -665,6 +686,7 @@ def _get_provider_kwargs_from_cli(
"equipment": equipment,
"equipment_id": equipment_id,
"days_of_the_week": days_of_the_week,
"exact_windows": exact_windows,
}
search_kwargs = {
"log": True,
Expand All @@ -688,6 +710,7 @@ def _get_provider_kwargs_from_cli(
@nights_argument
@weekends_argument
@day_of_the_week_argument
@exact_windows_argument
@notifications_argument
@continuous_argument
@search_forever_argument
Expand Down Expand Up @@ -725,6 +748,7 @@ def campsites(
equipment: Tuple[Union[str, int]],
equipment_id: Tuple[Union[str, int]],
day: Optional[Tuple[str]],
exact_windows: bool,
) -> None:
"""
Find Available Campsites with Custom Search Criteria
Expand Down Expand Up @@ -766,6 +790,7 @@ def campsites(
equipment=equipment,
equipment_id=equipment_id,
day=day,
exact_windows=exact_windows,
yaml_config=yaml_config,
)
provider_class: Type[BaseCampingSearch] = CAMPSITE_SEARCH_PROVIDER[provider]
Expand Down
1 change: 1 addition & 0 deletions camply/containers/search_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class YamlSearchFile(CamplyModel):
days: Optional[List[str]] = None
weekends: bool = False
nights: int = 1
exact_windows: bool = False
continuous: bool = True
polling_interval: int = SearchConfig.RECOMMENDED_POLLING_INTERVAL
notifications: ArrayOrSingleStr = "silent"
Expand Down
177 changes: 166 additions & 11 deletions camply/search/base_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def __init__(
offline_search: bool = False,
offline_search_path: Optional[str] = None,
days_of_the_week: Optional[Sequence[int]] = None,
exact_windows: bool = False,
**kwargs,
) -> None:
"""
Expand All @@ -73,10 +74,18 @@ def __init__(
When not specified, the filename will default to `camply_campsites.json`
days_of_the_week: Optional[Sequence[int]]
Days of the week (by weekday integer) to search for.
exact_windows: bool
When set to True, only availabilities exactly matching a passed
search_window will be returned. Useful when you have multiple search
windows with different numbers of nights, but only want to book a
given window if all days are available.
"""
self._verbose = kwargs.get("verbose", True)
self.campsite_finder: ProviderType = self.provider_class()
self.search_window: List[SearchWindow] = make_list(search_window)
self.exact_windows: bool = exact_windows
self._original_search_windows: List[SearchWindow] = self._valid_search_windows(
make_list(search_window)
)
self.days_of_the_week = set(
days_of_the_week if days_of_the_week is not None else ()
)
Expand Down Expand Up @@ -126,6 +135,13 @@ def search_days(self) -> List[datetime]:
current_date = datetime.now().date()
return [day for day in self._original_search_days if day >= current_date]

@property
def search_windows(self) -> List[SearchWindow]:
"""
Get the list of search windows that need to be searched
"""
return self._valid_search_windows(self._original_search_windows)

@property
def search_months(self) -> List[datetime]:
"""
Expand Down Expand Up @@ -181,6 +197,31 @@ def _get_intersection_date_overlap(
else:
return False

def _has_matching_window(
self,
date: datetime,
periods: int,
) -> bool:
"""
Determine if there is a matching search window when using exact_windows
Parameters
----------
date: datetime
Start date of window to search for
periods: int
Number of days of window to search for
Returns
-------
bool
"""
return any(
window.start_date == date.date()
and (window.end_date - window.start_date).days == periods
for window in self.search_windows
)

def _compare_date_overlap(self, campsite: AvailableCampsite) -> bool:
"""
See whether a campsite should be returned as found
Expand All @@ -193,11 +234,17 @@ def _compare_date_overlap(self, campsite: AvailableCampsite) -> bool:
-------
bool
"""
intersection = self._get_intersection_date_overlap(
date=campsite.booking_date,
periods=campsite.booking_nights,
search_days=self.search_days,
)
if self.exact_windows:
intersection = self._has_matching_window(
date=campsite.booking_date,
periods=campsite.booking_nights,
)
else:
intersection = self._get_intersection_date_overlap(
date=campsite.booking_date,
periods=campsite.booking_nights,
search_days=self.search_days,
)
return intersection

def _filter_date_overlap(self, campsites: DataFrame) -> pd.DataFrame:
Expand Down Expand Up @@ -605,7 +652,7 @@ def _get_search_days(self) -> List[datetime]:
"""
current_date = datetime.now().date()
search_nights = set()
for window in self.search_window:
for window in self.search_windows:
generated_dates = {
date for date in window.get_date_range() if date >= current_date
}
Expand Down Expand Up @@ -639,9 +686,30 @@ def _get_search_days(self) -> List[datetime]:
raise RuntimeError(SearchConfig.ERROR_MESSAGE)
return sorted(search_nights)

@classmethod
def _valid_search_windows(self, windows: List[SearchWindow]) -> List[SearchWindow]:
"""
Return the subset of windows which have not yet expired
Parameters
----------
windows: List[SearchWindow]
Returns
-------
List[SearchWindow]
"""
current_date = datetime.now().date()
if self.exact_windows:
# In this case we are only interested if no days of the window have
# yet elapsed.
return [w for w in windows if w.start_date >= current_date]
else:
# In this case we are interested as long as there is still at least
# one day that has not yet elapsed.
return [w for w in windows if w.end_date >= current_date]

def _consolidate_campsites(
cls, campsite_df: DataFrame, nights: int
self, campsite_df: DataFrame, nights: int
) -> pd.DataFrame:
"""
Consolidate Single Night Campsites into Multiple Night Campsites
Expand Down Expand Up @@ -679,14 +747,101 @@ def _consolidate_campsites(
composed_grouping.drop(
columns=[CampsiteContainerFields.CAMPSITE_GROUP], inplace=True
)
nightly_breakouts = cls._find_consecutive_nights(
dataframe=composed_grouping, nights=nights
nightly_breakouts = self._find_night_groupings(
dataframe=composed_grouping
)
composed_groupings.append(nightly_breakouts)
if len(composed_groupings) == 0:
composed_groupings = [DataFrame()]
return concat(composed_groupings, ignore_index=True)

def _find_night_groupings(self, dataframe: DataFrame) -> DataFrame:
"""
Find all matching night groupings in dataframe
Matching criteria depends on the value of self.exact_windows.
Parameters
----------
dataframe: DataFrame
Returns
-------
DataFrame
"""
if self.exact_windows:
return self._find_matching_windows(dataframe)
else:
return self._find_consecutive_nights(dataframe, self.nights)

@staticmethod
def _booking_in_window(booking: Series, window: SearchWindow) -> bool:
"""
Return true only if the dates of booking are completely inside window
Parameters
----------
booking: Series
AvailableCampsite converted to a Series
window: SearchWindow
Returns
-------
bool
"""
return (
window.start_date <= booking["booking_date"].date()
and booking["booking_end_date"].date() <= window.end_date
)

def _find_matching_windows(self, dataframe: DataFrame) -> DataFrame:
"""
Find all sub sequences of dataframe that exactly match a search window
Parameters
----------
dataframe: DataFrame
Each row contains a consecutive available night for the same
campsite
Returns
-------
DataFrame
"""
duplicate_subset = set(dataframe.columns) - AvailableCampsite.__unhashable__
matching_windows = []
for window in self.search_windows:
if (
dataframe.booking_date.min().date() <= window.start_date
and window.end_date <= dataframe.booking_end_date.max().date()
):
intersect_criteria = dataframe.apply(
self._booking_in_window, axis=1, window=window
)
window_intersection = dataframe[intersect_criteria].copy()

window_intersection.booking_date = (
window_intersection.booking_date.min()
)
window_intersection.booking_end_date = (
window_intersection.booking_end_date.max()
)
window_intersection.booking_url = window_intersection.booking_url.iloc[
0
]
window_intersection.booking_nights = (
window_intersection.booking_end_date
- window_intersection.booking_date
).dt.days
window_intersection.drop_duplicates(
inplace=True, subset=duplicate_subset
)
matching_windows.append(window_intersection)

if len(matching_windows) == 0:
matching_windows = [DataFrame()]
return concat(matching_windows, ignore_index=True)

@classmethod
def _consecutive_subseq(cls, iterable: Iterable, length: int) -> Generator:
"""
Expand Down
2 changes: 1 addition & 1 deletion camply/search/search_going_to_camp.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ def get_all_campsites(self) -> List[AvailableCampsite]:
List[AvailableCampsite]
"""
available_sites = []
for search_window in self.search_window:
for search_window in self.search_windows:
current_start_date = search_window.get_current_start_date()
for campground in self.campgrounds:
sites = self.campsite_finder.list_site_availability(
Expand Down
1 change: 1 addition & 0 deletions camply/utils/yaml_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ def yaml_file_to_arguments(
"equipment": equipment,
"offline_search": yaml_model.offline_search,
"offline_search_path": yaml_model.offline_search_path,
"exact_windows": yaml_model.exact_windows,
}
search_kwargs = {
"log": True,
Expand Down
Loading

0 comments on commit 23c705c

Please sign in to comment.