11from datetime import date , datetime , timedelta
2- from typing import TYPE_CHECKING , List
32
3+ import structlog
44from dateutil .relativedelta import relativedelta
55from django .conf import settings
6- from django .db .models import Sum
6+ from django .db .models import Q , Sum
77from django .utils import timezone
88from rest_framework .exceptions import NotFound
99
10+ from app_analytics import constants
1011from app_analytics .dataclasses import FeatureEvaluationData , UsageData
1112from app_analytics .influxdb_wrapper import (
1213 get_events_for_organisation ,
1718from app_analytics .influxdb_wrapper import (
1819 get_usage_data as get_usage_data_from_influxdb ,
1920)
21+ from app_analytics .mappers import map_annotated_api_usage_buckets_to_usage_data
2022from app_analytics .models import (
2123 APIUsageBucket ,
2224 FeatureEvaluationBucket ,
23- Resource ,
2425)
26+ from app_analytics .types import Labels , PeriodType
2527from environments .models import Environment
2628from features .models import Feature
27- from organisations .models import Organisation
29+ from organisations .models import Organisation , OrganisationSubscriptionInformationCache
2830
29- from . import constants
30- from .types import PERIOD_TYPE
31+ logger = structlog .get_logger ("app_analytics" )
3132
3233
3334def get_usage_data (
3435 organisation : Organisation ,
3536 environment_id : int | None = None ,
3637 project_id : int | None = None ,
37- period : PERIOD_TYPE | None = None ,
38+ period : PeriodType | None = None ,
39+ labels_filter : Labels | None = None ,
3840) -> list [UsageData ]:
39- now = timezone . now ()
40- date_start = date_stop = None
41- sub_cache = getattr ( organisation , "subscription_information_cache" , None )
41+ sub_cache = OrganisationSubscriptionInformationCache . objects . filter (
42+ organisation = organisation
43+ ). first ( )
4244
43- is_subscription_valid = (
44- sub_cache is not None and sub_cache .is_billing_terms_dates_set ()
45+ date_start , date_stop = _get_start_date_and_stop_date_for_subscribed_organisation (
46+ sub_cache = sub_cache ,
47+ period = period ,
4548 )
4649
47- if period in (constants .CURRENT_BILLING_PERIOD , constants .PREVIOUS_BILLING_PERIOD ):
48- if not is_subscription_valid :
49- raise NotFound ("No billing periods found for this organisation." )
50-
51- if TYPE_CHECKING :
52- assert sub_cache is not None
53-
54- match period :
55- case constants .CURRENT_BILLING_PERIOD :
56- starts_at = sub_cache .current_billing_term_starts_at or now - timedelta (
57- days = 30
58- )
59- month_delta = relativedelta (now , starts_at ).months
60- date_start = relativedelta (months = month_delta ) + starts_at
61- date_stop = now
62-
63- case constants .PREVIOUS_BILLING_PERIOD :
64- starts_at = sub_cache .current_billing_term_starts_at or now - timedelta (
65- days = 30
66- )
67- month_delta = relativedelta (now , starts_at ).months - 1
68- month_delta += relativedelta (now , starts_at ).years * 12
69- date_start = relativedelta (months = month_delta ) + starts_at
70- date_stop = relativedelta (months = month_delta + 1 ) + starts_at
71- case constants .NINETY_DAY_PERIOD :
72- date_start = now - relativedelta (days = 90 )
73- date_stop = now
74-
7550 if settings .USE_POSTGRES_FOR_ANALYTICS :
76- kwargs = {
77- "organisation" : organisation ,
78- "environment_id" : environment_id ,
79- "project_id" : project_id ,
80- }
81- if date_start :
82- assert date_stop
83- kwargs ["date_start" ] = date_start # type: ignore[assignment]
84- kwargs ["date_stop" ] = date_stop # type: ignore[assignment]
85-
86- return get_usage_data_from_local_db (** kwargs ) # type: ignore[arg-type]
87-
88- kwargs = {
89- "organisation_id" : organisation .id ,
90- "environment_id" : environment_id ,
91- "project_id" : project_id ,
92- }
51+ return get_usage_data_from_local_db (
52+ organisation = organisation ,
53+ environment_id = environment_id ,
54+ project_id = project_id ,
55+ date_start = date_start ,
56+ date_stop = date_stop ,
57+ labels_filter = labels_filter ,
58+ )
9359
94- if date_start :
95- assert date_stop
96- kwargs ["date_start" ] = date_start # type: ignore[assignment]
97- kwargs ["date_stop" ] = date_stop # type: ignore[assignment]
60+ if settings .INFLUXDB_TOKEN :
61+ return get_usage_data_from_influxdb (
62+ organisation_id = organisation .id ,
63+ environment_id = environment_id ,
64+ project_id = project_id ,
65+ date_start = date_start ,
66+ date_stop = date_stop ,
67+ labels_filter = labels_filter ,
68+ )
9869
99- return get_usage_data_from_influxdb (** kwargs ) # type: ignore[arg-type]
70+ logger .warning (
71+ "no-analytics-database-configured" ,
72+ details = constants .NO_ANALYTICS_DATABASE_CONFIGURED_WARNING ,
73+ )
74+ return []
10075
10176
10277def get_usage_data_from_local_db (
@@ -105,7 +80,8 @@ def get_usage_data_from_local_db(
10580 project_id : int | None = None ,
10681 date_start : datetime | None = None ,
10782 date_stop : datetime | None = None ,
108- ) -> List [UsageData ]:
83+ labels_filter : Labels | None = None ,
84+ ) -> list [UsageData ]:
10985 if date_start is None :
11086 date_start = timezone .now () - timedelta (days = 30 )
11187 if date_stop is None :
@@ -127,28 +103,20 @@ def get_usage_data_from_local_db(
127103 if environment_id :
128104 qs = qs .filter (environment_id = environment_id )
129105
106+ if labels_filter :
107+ qs = qs .filter (labels__contains = labels_filter )
108+
130109 qs = (
131110 qs .filter ( # type: ignore[assignment]
132111 created_at__date__lte = date_stop ,
133112 created_at__date__gt = date_start ,
134113 )
135114 .order_by ("created_at__date" )
136- .values ("created_at__date" , "resource" )
115+ .values ("created_at__date" , "resource" , "labels" )
137116 .annotate (count = Sum ("total_count" ))
138117 )
139- data_by_day = {}
140- for row in qs : # TODO Write proper mappers for this?
141- day = row ["created_at__date" ]
142- if day not in data_by_day :
143- data_by_day [day ] = UsageData (day = day )
144- if column_name := Resource (row ["resource" ]).column_name :
145- setattr (
146- data_by_day [day ],
147- column_name ,
148- row ["count" ],
149- )
150118
151- return data_by_day . values () # type: ignore[return-value]
119+ return map_annotated_api_usage_buckets_to_usage_data ( qs )
152120
153121
154122def get_total_events_count (organisation ) -> int : # type: ignore[no-untyped-def]
@@ -168,30 +136,53 @@ def get_total_events_count(organisation) -> int: # type: ignore[no-untyped-def]
168136
169137
170138def get_feature_evaluation_data (
171- feature : Feature , environment_id : int , period : int = 30
172- ) -> List [FeatureEvaluationData ]:
139+ feature : Feature ,
140+ environment_id : int ,
141+ period_days : int = 30 ,
142+ labels_filter : Labels | None = None ,
143+ ) -> list [FeatureEvaluationData ]:
173144 if settings .USE_POSTGRES_FOR_ANALYTICS :
174145 return get_feature_evaluation_data_from_local_db (
175- feature , environment_id , period
146+ feature = feature ,
147+ environment_id = environment_id ,
148+ period_days = period_days ,
149+ labels_filter = labels_filter ,
176150 )
177- return get_feature_evaluation_data_from_influxdb (
178- feature_name = feature .name , environment_id = environment_id , period = f"{ period } d"
151+
152+ if settings .INFLUXDB_TOKEN :
153+ return get_feature_evaluation_data_from_influxdb (
154+ feature_name = feature .name ,
155+ environment_id = environment_id ,
156+ period_days = period_days ,
157+ labels_filter = labels_filter ,
158+ )
159+
160+ logger .warning (
161+ "no-analytics-database-configured" ,
162+ details = constants .NO_ANALYTICS_DATABASE_CONFIGURED_WARNING ,
179163 )
164+ return []
180165
181166
182167def get_feature_evaluation_data_from_local_db (
183- feature : Feature , environment_id : int , period : int = 30
184- ) -> List [FeatureEvaluationData ]:
168+ feature : Feature ,
169+ environment_id : int ,
170+ period_days : int = 30 ,
171+ labels_filter : Labels | None = None ,
172+ ) -> list [FeatureEvaluationData ]:
173+ filter = Q (
174+ environment_id = environment_id ,
175+ bucket_size = constants .ANALYTICS_READ_BUCKET_SIZE ,
176+ feature_name = feature .name ,
177+ created_at__date__lte = timezone .now (),
178+ created_at__date__gt = timezone .now () - timedelta (days = period_days ),
179+ )
180+ if labels_filter :
181+ filter &= Q (labels__contains = labels_filter )
185182 feature_evaluation_data = (
186- FeatureEvaluationBucket .objects .filter (
187- environment_id = environment_id ,
188- bucket_size = constants .ANALYTICS_READ_BUCKET_SIZE ,
189- feature_name = feature .name ,
190- created_at__date__lte = timezone .now (),
191- created_at__date__gt = timezone .now () - timedelta (days = period ),
192- )
183+ FeatureEvaluationBucket .objects .filter (filter )
193184 .order_by ("created_at__date" )
194- .values ("created_at__date" , "feature_name" , "environment_id" )
185+ .values ("created_at__date" , "feature_name" , "environment_id" , "labels" )
195186 .annotate (count = Sum ("total_count" ))
196187 )
197188 usage_list = []
@@ -200,15 +191,61 @@ def get_feature_evaluation_data_from_local_db(
200191 FeatureEvaluationData (
201192 day = data ["created_at__date" ],
202193 count = data ["count" ],
194+ labels = data ["labels" ],
203195 )
204196 )
205197 return usage_list
206198
207199
208- def _get_environment_ids_for_org (organisation ) -> List [int ]: # type: ignore[no-untyped-def]
200+ def _get_environment_ids_for_org (organisation : Organisation ) -> list [int ]:
209201 # We need to do this to prevent Django from generating a query that
210202 # references the environments and projects tables,
211203 # as they do not exist in the analytics database.
212204 return [
213- e .id for e in Environment .objects .filter (project__organisation = organisation )
205+ * Environment .objects .filter (
206+ project__organisation = organisation ,
207+ ).values_list (
208+ "id" ,
209+ flat = True ,
210+ )
214211 ]
212+
213+
214+ def _get_start_date_and_stop_date_for_subscribed_organisation (
215+ sub_cache : OrganisationSubscriptionInformationCache | None ,
216+ period : PeriodType | None = None ,
217+ ) -> tuple [datetime | None , datetime | None ]:
218+ """
219+ Populate start and stop date for the given period
220+ from the organisation's subscription information.
221+ """
222+ now = timezone .now ()
223+
224+ match period :
225+ case constants .CURRENT_BILLING_PERIOD :
226+ if sub_cache and sub_cache .current_billing_term_starts_at :
227+ starts_at = sub_cache .current_billing_term_starts_at
228+ else :
229+ raise NotFound ("No billing periods found for this organisation." )
230+
231+ month_delta = relativedelta (now , starts_at ).months
232+ date_start = relativedelta (months = month_delta ) + starts_at
233+ return date_start , now
234+
235+ case constants .PREVIOUS_BILLING_PERIOD :
236+ if sub_cache and sub_cache .current_billing_term_starts_at :
237+ starts_at = sub_cache .current_billing_term_starts_at
238+ else :
239+ raise NotFound ("No billing periods found for this organisation." )
240+
241+ month_delta = relativedelta (now , starts_at ).months - 1
242+ month_delta += relativedelta (now , starts_at ).years * 12
243+ date_start = relativedelta (months = month_delta ) + starts_at
244+ date_stop = relativedelta (months = month_delta + 1 ) + starts_at
245+ return date_start , date_stop
246+
247+ case constants .NINETY_DAY_PERIOD :
248+ date_start = now - relativedelta (days = 90 )
249+ return date_start , now
250+
251+ return None , None
0 commit comments