Skip to content

Commit 23c705c

Browse files
committed
✨ Add campsite search by exact date windows
Use with the `--exact-windows` flag to `camply campsites`
1 parent 881586a commit 23c705c

9 files changed

+486
-12
lines changed

camply/cli.py

+25
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,14 @@ def campgrounds(
494494
metavar="TEXT",
495495
help="Day(s) of the Week to search.",
496496
)
497+
exact_windows_argument = click.option(
498+
"--exact-windows",
499+
is_flag=True,
500+
show_default=True,
501+
default=False,
502+
help="Search only for bookings which exactly match one of the ranges "
503+
"specified with the --start-date and --end-date arguments.",
504+
)
497505

498506

499507
def _get_equipment(equipment: Optional[List[str]]) -> List[Tuple[str, Optional[int]]]:
@@ -526,7 +534,10 @@ def _validate_campsites(
526534
notify_first_try: bool,
527535
search_forever: bool,
528536
search_once: bool,
537+
weekends: bool,
538+
nights: int,
529539
day: Optional[Tuple[str]],
540+
exact_windows: bool,
530541
**kwargs: Dict[str, Any],
531542
) -> Tuple[bool, List[SearchWindow], Set[int]]:
532543
"""
@@ -548,7 +559,10 @@ def _validate_campsites(
548559
notifications: List[str]
549560
notify_first_try: bool
550561
search_forever: bool
562+
weekends: bool
563+
nights: int
551564
day: Optional[Tuple[str]]
565+
exact_windows: bool
552566
**kwargs: Dict[str, Any]
553567
554568
Returns
@@ -583,6 +597,11 @@ def _validate_campsites(
583597
"You cannot specify `--search-once` alongside `--continuous` or `--search-forever`"
584598
)
585599
sys.exit(1)
600+
if exact_windows and any([day, weekends, nights != 1]):
601+
logger.error(
602+
"You cannot specify `--exact-windows` alongside `--nights`, `--day`, or `--weekends`"
603+
)
604+
sys.exit(1)
586605

587606
if any(
588607
[
@@ -618,6 +637,7 @@ def _get_provider_kwargs_from_cli(
618637
equipment: Tuple[Union[str, int]],
619638
equipment_id: Tuple[Union[str, int]],
620639
day: Optional[Tuple[str]],
640+
exact_windows: bool,
621641
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
622642
"""
623643
Get Provider kwargs from CLI
@@ -640,6 +660,7 @@ def _get_provider_kwargs_from_cli(
640660
search_forever=search_forever,
641661
search_once=search_once,
642662
day=day,
663+
exact_windows=exact_windows,
643664
)
644665
if len(notifications) == 0:
645666
notifications = ["silent"]
@@ -665,6 +686,7 @@ def _get_provider_kwargs_from_cli(
665686
"equipment": equipment,
666687
"equipment_id": equipment_id,
667688
"days_of_the_week": days_of_the_week,
689+
"exact_windows": exact_windows,
668690
}
669691
search_kwargs = {
670692
"log": True,
@@ -688,6 +710,7 @@ def _get_provider_kwargs_from_cli(
688710
@nights_argument
689711
@weekends_argument
690712
@day_of_the_week_argument
713+
@exact_windows_argument
691714
@notifications_argument
692715
@continuous_argument
693716
@search_forever_argument
@@ -725,6 +748,7 @@ def campsites(
725748
equipment: Tuple[Union[str, int]],
726749
equipment_id: Tuple[Union[str, int]],
727750
day: Optional[Tuple[str]],
751+
exact_windows: bool,
728752
) -> None:
729753
"""
730754
Find Available Campsites with Custom Search Criteria
@@ -766,6 +790,7 @@ def campsites(
766790
equipment=equipment,
767791
equipment_id=equipment_id,
768792
day=day,
793+
exact_windows=exact_windows,
769794
yaml_config=yaml_config,
770795
)
771796
provider_class: Type[BaseCampingSearch] = CAMPSITE_SEARCH_PROVIDER[provider]

camply/containers/search_model.py

+1
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ class YamlSearchFile(CamplyModel):
4747
days: Optional[List[str]] = None
4848
weekends: bool = False
4949
nights: int = 1
50+
exact_windows: bool = False
5051
continuous: bool = True
5152
polling_interval: int = SearchConfig.RECOMMENDED_POLLING_INTERVAL
5253
notifications: ArrayOrSingleStr = "silent"

camply/search/base_search.py

+166-11
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def __init__(
5050
offline_search: bool = False,
5151
offline_search_path: Optional[str] = None,
5252
days_of_the_week: Optional[Sequence[int]] = None,
53+
exact_windows: bool = False,
5354
**kwargs,
5455
) -> None:
5556
"""
@@ -73,10 +74,18 @@ def __init__(
7374
When not specified, the filename will default to `camply_campsites.json`
7475
days_of_the_week: Optional[Sequence[int]]
7576
Days of the week (by weekday integer) to search for.
77+
exact_windows: bool
78+
When set to True, only availabilities exactly matching a passed
79+
search_window will be returned. Useful when you have multiple search
80+
windows with different numbers of nights, but only want to book a
81+
given window if all days are available.
7682
"""
7783
self._verbose = kwargs.get("verbose", True)
7884
self.campsite_finder: ProviderType = self.provider_class()
79-
self.search_window: List[SearchWindow] = make_list(search_window)
85+
self.exact_windows: bool = exact_windows
86+
self._original_search_windows: List[SearchWindow] = self._valid_search_windows(
87+
make_list(search_window)
88+
)
8089
self.days_of_the_week = set(
8190
days_of_the_week if days_of_the_week is not None else ()
8291
)
@@ -126,6 +135,13 @@ def search_days(self) -> List[datetime]:
126135
current_date = datetime.now().date()
127136
return [day for day in self._original_search_days if day >= current_date]
128137

138+
@property
139+
def search_windows(self) -> List[SearchWindow]:
140+
"""
141+
Get the list of search windows that need to be searched
142+
"""
143+
return self._valid_search_windows(self._original_search_windows)
144+
129145
@property
130146
def search_months(self) -> List[datetime]:
131147
"""
@@ -181,6 +197,31 @@ def _get_intersection_date_overlap(
181197
else:
182198
return False
183199

200+
def _has_matching_window(
201+
self,
202+
date: datetime,
203+
periods: int,
204+
) -> bool:
205+
"""
206+
Determine if there is a matching search window when using exact_windows
207+
208+
Parameters
209+
----------
210+
date: datetime
211+
Start date of window to search for
212+
periods: int
213+
Number of days of window to search for
214+
215+
Returns
216+
-------
217+
bool
218+
"""
219+
return any(
220+
window.start_date == date.date()
221+
and (window.end_date - window.start_date).days == periods
222+
for window in self.search_windows
223+
)
224+
184225
def _compare_date_overlap(self, campsite: AvailableCampsite) -> bool:
185226
"""
186227
See whether a campsite should be returned as found
@@ -193,11 +234,17 @@ def _compare_date_overlap(self, campsite: AvailableCampsite) -> bool:
193234
-------
194235
bool
195236
"""
196-
intersection = self._get_intersection_date_overlap(
197-
date=campsite.booking_date,
198-
periods=campsite.booking_nights,
199-
search_days=self.search_days,
200-
)
237+
if self.exact_windows:
238+
intersection = self._has_matching_window(
239+
date=campsite.booking_date,
240+
periods=campsite.booking_nights,
241+
)
242+
else:
243+
intersection = self._get_intersection_date_overlap(
244+
date=campsite.booking_date,
245+
periods=campsite.booking_nights,
246+
search_days=self.search_days,
247+
)
201248
return intersection
202249

203250
def _filter_date_overlap(self, campsites: DataFrame) -> pd.DataFrame:
@@ -605,7 +652,7 @@ def _get_search_days(self) -> List[datetime]:
605652
"""
606653
current_date = datetime.now().date()
607654
search_nights = set()
608-
for window in self.search_window:
655+
for window in self.search_windows:
609656
generated_dates = {
610657
date for date in window.get_date_range() if date >= current_date
611658
}
@@ -639,9 +686,30 @@ def _get_search_days(self) -> List[datetime]:
639686
raise RuntimeError(SearchConfig.ERROR_MESSAGE)
640687
return sorted(search_nights)
641688

642-
@classmethod
689+
def _valid_search_windows(self, windows: List[SearchWindow]) -> List[SearchWindow]:
690+
"""
691+
Return the subset of windows which have not yet expired
692+
693+
Parameters
694+
----------
695+
windows: List[SearchWindow]
696+
697+
Returns
698+
-------
699+
List[SearchWindow]
700+
"""
701+
current_date = datetime.now().date()
702+
if self.exact_windows:
703+
# In this case we are only interested if no days of the window have
704+
# yet elapsed.
705+
return [w for w in windows if w.start_date >= current_date]
706+
else:
707+
# In this case we are interested as long as there is still at least
708+
# one day that has not yet elapsed.
709+
return [w for w in windows if w.end_date >= current_date]
710+
643711
def _consolidate_campsites(
644-
cls, campsite_df: DataFrame, nights: int
712+
self, campsite_df: DataFrame, nights: int
645713
) -> pd.DataFrame:
646714
"""
647715
Consolidate Single Night Campsites into Multiple Night Campsites
@@ -679,14 +747,101 @@ def _consolidate_campsites(
679747
composed_grouping.drop(
680748
columns=[CampsiteContainerFields.CAMPSITE_GROUP], inplace=True
681749
)
682-
nightly_breakouts = cls._find_consecutive_nights(
683-
dataframe=composed_grouping, nights=nights
750+
nightly_breakouts = self._find_night_groupings(
751+
dataframe=composed_grouping
684752
)
685753
composed_groupings.append(nightly_breakouts)
686754
if len(composed_groupings) == 0:
687755
composed_groupings = [DataFrame()]
688756
return concat(composed_groupings, ignore_index=True)
689757

758+
def _find_night_groupings(self, dataframe: DataFrame) -> DataFrame:
759+
"""
760+
Find all matching night groupings in dataframe
761+
762+
Matching criteria depends on the value of self.exact_windows.
763+
764+
Parameters
765+
----------
766+
dataframe: DataFrame
767+
768+
Returns
769+
-------
770+
DataFrame
771+
"""
772+
if self.exact_windows:
773+
return self._find_matching_windows(dataframe)
774+
else:
775+
return self._find_consecutive_nights(dataframe, self.nights)
776+
777+
@staticmethod
778+
def _booking_in_window(booking: Series, window: SearchWindow) -> bool:
779+
"""
780+
Return true only if the dates of booking are completely inside window
781+
782+
Parameters
783+
----------
784+
booking: Series
785+
AvailableCampsite converted to a Series
786+
window: SearchWindow
787+
788+
Returns
789+
-------
790+
bool
791+
"""
792+
return (
793+
window.start_date <= booking["booking_date"].date()
794+
and booking["booking_end_date"].date() <= window.end_date
795+
)
796+
797+
def _find_matching_windows(self, dataframe: DataFrame) -> DataFrame:
798+
"""
799+
Find all sub sequences of dataframe that exactly match a search window
800+
801+
Parameters
802+
----------
803+
dataframe: DataFrame
804+
Each row contains a consecutive available night for the same
805+
campsite
806+
807+
Returns
808+
-------
809+
DataFrame
810+
"""
811+
duplicate_subset = set(dataframe.columns) - AvailableCampsite.__unhashable__
812+
matching_windows = []
813+
for window in self.search_windows:
814+
if (
815+
dataframe.booking_date.min().date() <= window.start_date
816+
and window.end_date <= dataframe.booking_end_date.max().date()
817+
):
818+
intersect_criteria = dataframe.apply(
819+
self._booking_in_window, axis=1, window=window
820+
)
821+
window_intersection = dataframe[intersect_criteria].copy()
822+
823+
window_intersection.booking_date = (
824+
window_intersection.booking_date.min()
825+
)
826+
window_intersection.booking_end_date = (
827+
window_intersection.booking_end_date.max()
828+
)
829+
window_intersection.booking_url = window_intersection.booking_url.iloc[
830+
0
831+
]
832+
window_intersection.booking_nights = (
833+
window_intersection.booking_end_date
834+
- window_intersection.booking_date
835+
).dt.days
836+
window_intersection.drop_duplicates(
837+
inplace=True, subset=duplicate_subset
838+
)
839+
matching_windows.append(window_intersection)
840+
841+
if len(matching_windows) == 0:
842+
matching_windows = [DataFrame()]
843+
return concat(matching_windows, ignore_index=True)
844+
690845
@classmethod
691846
def _consecutive_subseq(cls, iterable: Iterable, length: int) -> Generator:
692847
"""

camply/search/search_going_to_camp.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ def get_all_campsites(self) -> List[AvailableCampsite]:
138138
List[AvailableCampsite]
139139
"""
140140
available_sites = []
141-
for search_window in self.search_window:
141+
for search_window in self.search_windows:
142142
current_start_date = search_window.get_current_start_date()
143143
for campground in self.campgrounds:
144144
sites = self.campsite_finder.list_site_availability(

camply/utils/yaml_utils.py

+1
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ def yaml_file_to_arguments(
125125
"equipment": equipment,
126126
"offline_search": yaml_model.offline_search,
127127
"offline_search_path": yaml_model.offline_search_path,
128+
"exact_windows": yaml_model.exact_windows,
128129
}
129130
search_kwargs = {
130131
"log": True,

0 commit comments

Comments
 (0)