Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Add campsite search by exact date windows #297

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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