@@ -58,14 +58,40 @@ class PeriodFilter(StatisticsFilter):
5858
5959 @staticmethod
6060 def list (bot : DCSServerBot ) -> list [str ]:
61- return ['all' , 'day' , 'week' , 'month' , 'year' , 'today' , 'yesterday' ]
61+ """
62+ All period names that can be passed directly to the filter.
63+ """
64+ return [
65+ 'all' ,
66+ 'today' ,
67+ 'yesterday' ,
68+ 'day' ,
69+ 'week' ,
70+ 'month' ,
71+ 'quarter' ,
72+ 'halfyear' ,
73+ 'year' ,
74+ ]
6275
6376 @staticmethod
6477 def supports (bot : DCSServerBot , period : str ) -> bool :
65- return (period and period .startswith ('period:' )) or period in PeriodFilter .list (bot ) or '-' in period
78+ """
79+ A period is supported if:
80+ • it starts with 'period:' (custom range syntax)
81+ • it is one of the names in .list()
82+ • it contains a hyphen and the hyphen syntax is valid
83+ """
84+ return (
85+ (period and period .startswith ('period:' )) or
86+ period in PeriodFilter .list (bot ) or
87+ '-' in period
88+ )
6689
6790 @staticmethod
68- def parse_date (date_str ):
91+ def parse_date (date_str : str ) -> datetime :
92+ """
93+ Accepts YYYYMMDD, YYYYMMDD HH, etc. The format list is unchanged.
94+ """
6995 formats = [
7096 "%Y%m%d %H:%M:%S" ,
7197 "%Y%m%d %H:%M" ,
@@ -74,63 +100,93 @@ def parse_date(date_str):
74100 "%Y%m" ,
75101 "%Y"
76102 ]
77-
78103 for fmt in formats :
79104 try :
80105 return datetime .strptime (date_str , fmt )
81106 except ValueError :
82107 continue
83-
84- # If none of the formats match, raise an error
85108 raise ValueError (f"Date format { date_str } is not supported" )
86109
110+ @staticmethod
111+ def _interval_from_period (period : str ) -> str :
112+ """
113+ Convert a public period name into a Postgres interval literal.
114+ Unsupported names fall back to the literal itself (e.g. '1 week').
115+ """
116+ mapping = {
117+ 'quarter' : '3 months' ,
118+ 'halfyear' : '6 months'
119+ }
120+ return mapping .get (period , f"1 { period } " )
121+
87122 def filter (self , bot : DCSServerBot ) -> str :
88- if self .period and self .period .startswith ('period:' ):
89- period = self .period [7 :].strip ()
90- else :
91- period = self .period
92- if period in [None , 'all' ]:
123+ # Normalize the period string
124+ period = self .period [7 :].strip () if self .period and self .period .startswith ('period:' ) else self .period
125+
126+ # ------------------------------------------------------------------
127+ # 1 Handle the “all” case – no filtering
128+ # ------------------------------------------------------------------
129+ if period in (None , 'all' ):
93130 return '1 = 1'
94- elif period == 'yesterday' :
131+
132+ # ------------------------------------------------------------------
133+ # 2 Special dates (today / yesterday)
134+ # ------------------------------------------------------------------
135+ if period == 'yesterday' :
95136 return "DATE_TRUNC('day', s.hop_on) = current_date - 1"
96137 elif period == 'today' :
97138 return "DATE_TRUNC('day', s.hop_on) = current_date"
98- elif period in PeriodFilter .list (bot ):
99- return f"s.hop_on > ((now() AT TIME ZONE 'utc') - interval '1 { period } ')"
100- elif '-' in period :
101- start , end = period .split ('-' )
102- start = start .strip ()
103- end = end .strip ()
104- # avoid SQL injection
105- pattern = re .compile (r'^\d+\s+(year|month|week|day|hour|minute)s?$' )
139+
140+ # ------------------------------------------------------------------
141+ # 3 One‑step intervals: day, week, month, quarter, year
142+ # ------------------------------------------------------------------
143+ if period in PeriodFilter .list (bot ):
144+ # Translate friendly name → Postgres intervals
145+ interval_lit = PeriodFilter ._interval_from_period (period )
146+ return f"s.hop_on > ((now() AT TIME ZONE 'utc') - interval '{ interval_lit } ')"
147+
148+ # ------------------------------------------------------------------
149+ # 4 Custom “start‑end” syntax
150+ # ------------------------------------------------------------------
151+ if '-' in period :
152+ start , end = [p .strip () for p in period .split ('-' , 1 )]
153+
154+ # Pattern for “X unit” (e.g. “2 week”, “5 days”)
155+ pattern = re .compile (r'^\d+\s+(year|month|week|day|hour|minute|quarter|halfyear)s?$' )
106156 if pattern .match (end ):
107157 return f"s.hop_on > ((now() AT TIME ZONE 'utc') - interval '{ end } ')"
108- else :
109- start = self .parse_date (start ) if start else datetime (year = 1970 , month = 1 , day = 1 )
110- end = self .parse_date (end ) if end else datetime .now (tz = timezone .utc )
111- return (f"s.hop_on >= '{ start .strftime ('%Y-%m-%d %H:%M:%S' )} '::TIMESTAMP AND "
112- f"COALESCE(s.hop_off, (now() AT TIME ZONE 'utc')) <= '{ end .strftime ('%Y-%m-%d %H:%M:%S' )} '" )
113- else :
114- return "1 = 1"
115158
159+ # Otherwise treat both sides as dates
160+ start_dt = self .parse_date (start ) if start else datetime (year = 1970 , month = 1 , day = 1 )
161+ end_dt = self .parse_date (end ) if end else datetime .now (tz = timezone .utc )
162+
163+ return (
164+ f"s.hop_on >= '{ start_dt .strftime ('%Y-%m-%d %H:%M:%S' )} '::TIMESTAMP "
165+ f"AND COALESCE(s.hop_off, (now() AT TIME ZONE 'utc')) <= "
166+ f"'{ end_dt .strftime ('%Y-%m-%d %H:%M:%S' )} '"
167+ )
168+
169+ # ------------------------------------------------------------------
170+ # 5 Fallback – no filtering
171+ # ------------------------------------------------------------------
172+ return "1 = 1"
116173
117174 def format (self , bot : DCSServerBot ) -> str :
118- if self .period and self .period .startswith ('period:' ):
119- period = self .period [7 :]
120- else :
121- period = self .period
122- if period in [None , 'all' ]:
175+ period = self .period [7 :] if self .period and self .period .startswith ('period:' ) else self .period
176+
177+ if period in (None , 'all' ):
123178 return 'Overall '
124- elif period == 'day' :
125- return 'Daily '
126- elif period in ['today' , 'yesterday' ]:
179+ elif period in ('today' , 'yesterday' ):
127180 return period .capitalize () + 's '
128- elif period in ['day' , 'week' , 'month' , 'year' ]:
181+ elif period in ('day' , 'week' , 'month' , 'year' , 'quarter' ):
182+ # The last part of every name is turned into an adjective
129183 return period .capitalize () + 'ly '
184+ elif period == 'halfyear' :
185+ return period .capitalize () + ' '
130186 elif '-' in period :
131187 return period + '\n '
132- else :
133- return period
188+
189+ return period
134190
135191
136192class CampaignFilter (StatisticsFilter ):
@@ -282,11 +338,57 @@ def format(self, bot: DCSServerBot) -> str:
282338class MissionStatisticsFilter (PeriodFilter ):
283339
284340 def filter (self , bot : DCSServerBot ) -> str :
285- if self .period in self .list (bot ) and self .period .lower () != 'all' :
286- return f"time > ((now() AT TIME ZONE 'utc') - interval '1 { self .period } ')"
287- else :
341+ # Normalize the period string
342+ period = self .period [7 :].strip () if self .period and self .period .startswith ('period:' ) else self .period
343+
344+ # ------------------------------------------------------------------
345+ # 1 Handle the “all” case – no filtering
346+ # ------------------------------------------------------------------
347+ if period in (None , 'all' ):
288348 return '1 = 1'
289349
350+ # ------------------------------------------------------------------
351+ # 2 Special dates (today / yesterday)
352+ # ------------------------------------------------------------------
353+ if period == 'yesterday' :
354+ return "DATE_TRUNC('day', time) = current_date - 1"
355+ elif period == 'today' :
356+ return "DATE_TRUNC('day', time) = current_date"
357+
358+ # ------------------------------------------------------------------
359+ # 3 One‑step intervals: day, week, month, quarter, year
360+ # ------------------------------------------------------------------
361+ if period in PeriodFilter .list (bot ):
362+ # Translate friendly name → Postgres intervals
363+ interval_lit = PeriodFilter ._interval_from_period (period )
364+ return f"time > ((now() AT TIME ZONE 'utc') - interval '{ interval_lit } ')"
365+
366+ # ------------------------------------------------------------------
367+ # 4 Custom “start‑end” syntax
368+ # ------------------------------------------------------------------
369+ if '-' in period :
370+ start , end = [p .strip () for p in period .split ('-' , 1 )]
371+
372+ # Pattern for “X unit” (e.g. “2 week”, “5 days”)
373+ pattern = re .compile (r'^\d+\s+(year|month|week|day|hour|minute|quarter)s?$' )
374+ if pattern .match (end ):
375+ return f"time > ((now() AT TIME ZONE 'utc') - interval '{ end } ')"
376+
377+ # Otherwise treat both sides as dates
378+ start_dt = self .parse_date (start ) if start else datetime (year = 1970 , month = 1 , day = 1 )
379+ end_dt = self .parse_date (end ) if end else datetime .now (tz = timezone .utc )
380+
381+ return (
382+ f"time >= '{ start_dt .strftime ('%Y-%m-%d %H:%M:%S' )} '::TIMESTAMP "
383+ f"AND time <= '{ end_dt .strftime ('%Y-%m-%d %H:%M:%S' )} '"
384+ )
385+
386+ # ------------------------------------------------------------------
387+ # 5 Fallback – no filtering
388+ # ------------------------------------------------------------------
389+ return "1 = 1"
390+
391+
290392class StatsPagination (Pagination ):
291393 def __init__ (self , env : ReportEnv ):
292394 super ().__init__ (env )
0 commit comments