1from __future__ import annotations
2
3import datetime
4from calendar import monthrange
5from collections.abc import Iterator
6from enum import Enum
7
8from plain.utils import timezone
9
10
11class DatetimeRangeAliases(Enum):
12 TODAY = "Today"
13 THIS_WEEK = "This Week"
14 THIS_WEEK_TO_DATE = "This Week-to-date"
15 THIS_MONTH = "This Month"
16 THIS_MONTH_TO_DATE = "This Month-to-date"
17 THIS_QUARTER = "This Quarter"
18 THIS_QUARTER_TO_DATE = "This Quarter-to-date"
19 THIS_YEAR = "This Year"
20 THIS_YEAR_TO_DATE = "This Year-to-date"
21 LAST_WEEK = "Last Week"
22 LAST_WEEK_TO_DATE = "Last Week-to-date"
23 LAST_MONTH = "Last Month"
24 LAST_MONTH_TO_DATE = "Last Month-to-date"
25 LAST_QUARTER = "Last Quarter"
26 LAST_QUARTER_TO_DATE = "Last Quarter-to-date"
27 LAST_YEAR = "Last Year"
28 LAST_YEAR_TO_DATE = "Last Year-to-date"
29 SINCE_30_DAYS_AGO = "Since 30 Days Ago"
30 SINCE_60_DAYS_AGO = "Since 60 Days Ago"
31 SINCE_90_DAYS_AGO = "Since 90 Days Ago"
32 SINCE_365_DAYS_AGO = "Since 365 Days Ago"
33 NEXT_WEEK = "Next Week"
34 NEXT_4_WEEKS = "Next 4 Weeks"
35 NEXT_MONTH = "Next Month"
36 NEXT_QUARTER = "Next Quarter"
37 NEXT_YEAR = "Next Year"
38
39 # TODO doesn't include anything less than a day...
40 # ex. SINCE_1_HOUR_AGO = "Since 1 Hour Ago"
41
42 def __str__(self) -> str:
43 return self.value
44
45 @classmethod
46 def from_value(cls, value: str) -> DatetimeRangeAliases:
47 for member in cls:
48 if member.value == value:
49 return member
50 raise ValueError(f"{value} is not a valid value for {cls.__name__}")
51
52 @classmethod
53 def to_range(cls, value: str) -> DatetimeRange:
54 now = timezone.localtime()
55 start_of_today = now.replace(hour=0, minute=0, second=0, microsecond=0)
56 start_of_week = start_of_today - datetime.timedelta(
57 days=start_of_today.weekday()
58 )
59 start_of_month = start_of_today.replace(day=1)
60 start_of_quarter = start_of_today.replace(
61 month=((start_of_today.month - 1) // 3) * 3 + 1, day=1
62 )
63 start_of_year = start_of_today.replace(month=1, day=1)
64
65 def end_of_day(dt: datetime.datetime) -> datetime.datetime:
66 return dt.replace(hour=23, minute=59, second=59, microsecond=999999)
67
68 def end_of_month(dt: datetime.datetime) -> datetime.datetime:
69 last_day = monthrange(dt.year, dt.month)[1]
70 return end_of_day(dt.replace(day=last_day))
71
72 def end_of_quarter(dt: datetime.datetime) -> datetime.datetime:
73 end_month = ((dt.month - 1) // 3 + 1) * 3
74 return end_of_month(dt.replace(month=end_month))
75
76 def end_of_year(dt: datetime.datetime) -> datetime.datetime:
77 return end_of_month(dt.replace(month=12))
78
79 if value == cls.TODAY:
80 return DatetimeRange(start_of_today, end_of_day(now))
81 if value == cls.THIS_WEEK:
82 return DatetimeRange(
83 start_of_week, end_of_day(start_of_week + datetime.timedelta(days=6))
84 )
85 if value == cls.THIS_WEEK_TO_DATE:
86 return DatetimeRange(start_of_week, now)
87 if value == cls.THIS_MONTH:
88 return DatetimeRange(start_of_month, end_of_month(start_of_month))
89 if value == cls.THIS_MONTH_TO_DATE:
90 return DatetimeRange(start_of_month, now)
91 if value == cls.THIS_QUARTER:
92 return DatetimeRange(start_of_quarter, end_of_quarter(start_of_quarter))
93 if value == cls.THIS_QUARTER_TO_DATE:
94 return DatetimeRange(start_of_quarter, now)
95 if value == cls.THIS_YEAR:
96 return DatetimeRange(start_of_year, end_of_year(start_of_year))
97 if value == cls.THIS_YEAR_TO_DATE:
98 return DatetimeRange(start_of_year, now)
99 if value == cls.LAST_WEEK:
100 last_week_start = start_of_week - datetime.timedelta(days=7)
101 return DatetimeRange(
102 last_week_start,
103 end_of_day(last_week_start + datetime.timedelta(days=6)),
104 )
105 if value == cls.LAST_WEEK_TO_DATE:
106 return DatetimeRange(start_of_week - datetime.timedelta(days=7), now)
107 if value == cls.LAST_MONTH:
108 last_month = (start_of_month - datetime.timedelta(days=1)).replace(day=1)
109 return DatetimeRange(last_month, end_of_month(last_month))
110 if value == cls.LAST_MONTH_TO_DATE:
111 last_month = (start_of_month - datetime.timedelta(days=1)).replace(day=1)
112 return DatetimeRange(last_month, now)
113 if value == cls.LAST_QUARTER:
114 last_quarter = (start_of_quarter - datetime.timedelta(days=1)).replace(
115 day=1
116 )
117 return DatetimeRange(last_quarter, end_of_quarter(last_quarter))
118 if value == cls.LAST_QUARTER_TO_DATE:
119 last_quarter = (start_of_quarter - datetime.timedelta(days=1)).replace(
120 day=1
121 )
122 return DatetimeRange(last_quarter, now)
123 if value == cls.LAST_YEAR:
124 last_year = start_of_year.replace(year=start_of_year.year - 1)
125 return DatetimeRange(last_year, end_of_year(last_year))
126 if value == cls.LAST_YEAR_TO_DATE:
127 last_year = start_of_year.replace(year=start_of_year.year - 1)
128 return DatetimeRange(last_year, now)
129 if value == cls.SINCE_30_DAYS_AGO:
130 return DatetimeRange(now - datetime.timedelta(days=30), now)
131 if value == cls.SINCE_60_DAYS_AGO:
132 return DatetimeRange(now - datetime.timedelta(days=60), now)
133 if value == cls.SINCE_90_DAYS_AGO:
134 return DatetimeRange(now - datetime.timedelta(days=90), now)
135 if value == cls.SINCE_365_DAYS_AGO:
136 return DatetimeRange(now - datetime.timedelta(days=365), now)
137 if value == cls.NEXT_WEEK:
138 next_week_start = start_of_week + datetime.timedelta(days=7)
139 return DatetimeRange(
140 next_week_start,
141 end_of_day(next_week_start + datetime.timedelta(days=6)),
142 )
143 if value == cls.NEXT_4_WEEKS:
144 return DatetimeRange(now, end_of_day(now + datetime.timedelta(days=28)))
145 if value == cls.NEXT_MONTH:
146 next_month = (start_of_month + datetime.timedelta(days=31)).replace(day=1)
147 return DatetimeRange(next_month, end_of_month(next_month))
148 if value == cls.NEXT_QUARTER:
149 next_quarter = (start_of_quarter + datetime.timedelta(days=90)).replace(
150 day=1
151 )
152 return DatetimeRange(next_quarter, end_of_quarter(next_quarter))
153 if value == cls.NEXT_YEAR:
154 next_year = start_of_year.replace(year=start_of_year.year + 1)
155 return DatetimeRange(next_year, end_of_year(next_year))
156 raise ValueError(f"Invalid range: {value}")
157
158
159class DatetimeRange:
160 def __init__(
161 self,
162 start: datetime.datetime | datetime.date | str,
163 end: datetime.datetime | datetime.date | str,
164 ):
165 self.start = start
166 self.end = end
167
168 if isinstance(self.start, str) and self.start:
169 self.start = datetime.datetime.fromisoformat(self.start)
170
171 if isinstance(self.end, str) and self.end:
172 self.end = datetime.datetime.fromisoformat(self.end)
173
174 if isinstance(self.start, datetime.date) and not isinstance(
175 self.start, datetime.datetime
176 ):
177 self.start = timezone.localtime().replace(
178 year=self.start.year, month=self.start.month, day=self.start.day
179 )
180
181 if isinstance(self.end, datetime.date) and not isinstance(
182 self.start, datetime.datetime
183 ):
184 self.end = timezone.localtime().replace(
185 year=self.end.year, month=self.end.month, day=self.end.day
186 )
187
188 def as_tuple(self) -> tuple[datetime.datetime, datetime.datetime]:
189 return (self.start, self.end)
190
191 def total_days(self) -> int:
192 return (self.end - self.start).days
193
194 def iter_days(self) -> Iterator[datetime.date]:
195 """Yields each day in the range."""
196 return iter(
197 self.start.date() + datetime.timedelta(days=i)
198 for i in range(0, self.total_days())
199 )
200
201 def iter_weeks(self) -> Iterator[datetime.datetime]:
202 """Yields the start of each week in the range."""
203 current = self.start - datetime.timedelta(days=self.start.weekday())
204 current = current.replace(hour=0, minute=0, second=0, microsecond=0)
205 while current <= self.end:
206 next_week = current + datetime.timedelta(weeks=1)
207 yield current
208 current = next_week
209
210 def iter_months(self) -> Iterator[datetime.datetime]:
211 """Yields the start of each month in the range."""
212 current = self.start.replace(day=1)
213 current = current.replace(hour=0, minute=0, second=0, microsecond=0)
214 while current <= self.end:
215 if current.month == 12:
216 next_month = current.replace(year=current.year + 1, month=1)
217 else:
218 next_month = current.replace(month=current.month + 1)
219 yield current
220 current = next_month
221
222 def iter_quarters(self) -> Iterator[datetime.datetime]:
223 """Yields the start of each quarter in the range."""
224 current = self.start.replace(month=((self.start.month - 1) // 3) * 3 + 1, day=1)
225 current = current.replace(hour=0, minute=0, second=0, microsecond=0)
226 while current <= self.end:
227 next_quarter_month = ((current.month - 1) // 3 + 1) * 3 + 1
228 if next_quarter_month > 12:
229 next_quarter_month -= 12
230 next_year = current.year + 1
231 else:
232 next_year = current.year
233 next_quarter = datetime.datetime(
234 next_year, next_quarter_month, 1, tzinfo=current.tzinfo
235 )
236 yield current
237 current = next_quarter
238
239 def iter_years(self) -> Iterator[datetime.datetime]:
240 """Yields the start of each year in the range."""
241 current = self.start.replace(month=1, day=1)
242 current = current.replace(hour=0, minute=0, second=0, microsecond=0)
243 while current <= self.end:
244 next_year = current.replace(year=current.year + 1)
245 yield current
246 current = next_year
247
248 def __repr__(self) -> str:
249 return f"DatetimeRange({self.start}, {self.end})"
250
251 def __str__(self) -> str:
252 return f"{self.start} to {self.end}"
253
254 def __eq__(self, other: object) -> bool:
255 if not isinstance(other, DatetimeRange):
256 return False
257 return self.start == other.start and self.end == other.end
258
259 def __hash__(self) -> int:
260 return hash((self.start, self.end))
261
262 def __contains__(self, item: datetime.datetime) -> bool:
263 return self.start <= item <= self.end