Skip to content

Commit

Permalink
Merge branch 'master' into feat/product-intents-recored-activation
Browse files Browse the repository at this point in the history
  • Loading branch information
raquelmsmith committed Nov 1, 2024
2 parents e0d07b0 + 003ac8a commit b9a18d0
Show file tree
Hide file tree
Showing 67 changed files with 2,200 additions and 762 deletions.
14 changes: 14 additions & 0 deletions ee/api/billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,20 @@ def purchase_credits(self, request: Request, *args: Any, **kwargs: Any) -> HttpR
res = billing_manager.purchase_credits(organization, request.data)
return Response(res, status=status.HTTP_200_OK)

@action(methods=["POST"], detail=False, url_path="trials/activate")
def activate_trial(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse:
organization = self._get_org_required()
billing_manager = self.get_billing_manager()
res = billing_manager.activate_trial(organization, request.data)
return Response(res, status=status.HTTP_200_OK)

@action(methods=["POST"], detail=False, url_path="trials/cancel")
def cancel_trial(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse:
organization = self._get_org_required()
billing_manager = self.get_billing_manager()
res = billing_manager.cancel_trial(organization, request.data)
return Response(res, status=status.HTTP_200_OK)

@action(methods=["POST"], detail=False, url_path="activate/authorize")
def authorize(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse:
license = get_cached_instance_license()
Expand Down
20 changes: 20 additions & 0 deletions ee/billing/billing_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,26 @@ def purchase_credits(self, organization: Organization, data: dict[str, Any]):

return res.json()

def activate_trial(self, organization: Organization, data: dict[str, Any]):
res = requests.post(
f"{BILLING_SERVICE_URL}/api/trials/activate",
headers=self.get_auth_headers(organization),
json=data,
)

handle_billing_service_error(res)

return res.json()

def cancel_trial(self, organization: Organization, data: dict[str, Any]):
res = requests.post(
f"{BILLING_SERVICE_URL}/api/trials/cancel",
headers=self.get_auth_headers(organization),
json=data,
)

handle_billing_service_error(res)

def authorize(self, organization: Organization):
res = requests.post(
f"{BILLING_SERVICE_URL}/api/activate/authorize",
Expand Down
17 changes: 17 additions & 0 deletions ee/clickhouse/views/experiment_saved_metrics.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import pydantic
from rest_framework import serializers, viewsets
from rest_framework.exceptions import ValidationError


from posthog.api.routing import TeamAndOrgViewSetMixin
from posthog.api.shared import UserBasicSerializer
from posthog.models.experiment import ExperimentSavedMetric, ExperimentToSavedMetric
from posthog.schema import FunnelsQuery, TrendsQuery


class ExperimentToSavedMetricSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -47,6 +49,21 @@ class Meta:
def validate_query(self, value):
if not value:
raise ValidationError("Query is required to create a saved metric")

metric_query = value

if metric_query.get("kind") not in ["TrendsQuery", "FunnelsQuery"]:
raise ValidationError("Metric query kind must be 'TrendsQuery' or 'FunnelsQuery'")

# pydantic models are used to validate the query
try:
if metric_query["kind"] == "TrendsQuery":
TrendsQuery(**metric_query)
else:
FunnelsQuery(**metric_query)
except pydantic.ValidationError as e:
raise ValidationError(str(e.errors())) from e

return value

def create(self, validated_data):
Expand Down
35 changes: 35 additions & 0 deletions ee/clickhouse/views/experiments.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from posthog.constants import INSIGHT_TRENDS
from posthog.models.experiment import Experiment, ExperimentHoldout, ExperimentSavedMetric
from posthog.models.filters.filter import Filter
from posthog.schema import ExperimentFunnelsQuery, ExperimentTrendsQuery
from posthog.utils import generate_cache_key, get_safe_cache

EXPERIMENT_RESULTS_CACHE_DEFAULT_TTL = 60 * 60 # 1 hour
Expand Down Expand Up @@ -232,6 +233,40 @@ def validate_saved_metrics_ids(self, value):

return value

def validate_metrics(self, value):
# TODO: This isn't correct most probably, we wouldn't have experiment_id inside ExperimentTrendsQuery
# on creation. Not sure how this is supposed to work yet.
if not value:
return value

if not isinstance(value, list):
raise ValidationError("Metrics must be a list")

if len(value) > 10:
raise ValidationError("Experiments can have a maximum of 10 metrics")

for metric in value:
if not isinstance(metric, dict):
raise ValidationError("Metrics must be objects")
if not metric.get("query"):
raise ValidationError("Metric query is required")

if metric.get("type") not in ["primary", "secondary"]:
raise ValidationError("Metric type must be 'primary' or 'secondary'")

metric_query = metric["query"]

if metric_query.get("kind") not in ["ExperimentTrendsQuery", "ExperimentFunnelsQuery"]:
raise ValidationError("Metric query kind must be 'ExperimentTrendsQuery' or 'ExperimentFunnelsQuery'")

# pydantic models are used to validate the query
if metric_query["kind"] == "ExperimentTrendsQuery":
ExperimentTrendsQuery(**metric_query)
else:
ExperimentFunnelsQuery(**metric_query)

return value

def validate_parameters(self, value):
if not value:
return value
Expand Down
16 changes: 7 additions & 9 deletions ee/clickhouse/views/test/test_clickhouse_experiments.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ def test_saved_metrics(self):
{
"name": "Test Experiment saved metric",
"description": "Test description",
"query": {"events": [{"id": "$pageview"}]},
"query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]},
},
)

Expand All @@ -325,9 +325,7 @@ def test_saved_metrics(self):
self.assertEqual(response.json()["description"], "Test description")
self.assertEqual(
response.json()["query"],
{
"events": [{"id": "$pageview"}],
},
{"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]},
)
self.assertEqual(response.json()["created_by"]["id"], self.user.pk)

Expand Down Expand Up @@ -364,17 +362,17 @@ def test_saved_metrics(self):
self.assertEqual(experiment_to_saved_metric.metadata, {"type": "secondary"})
saved_metric = Experiment.objects.get(pk=exp_id).saved_metrics.first()
self.assertEqual(saved_metric.id, saved_metric_id)
self.assertEqual(saved_metric.query, {"events": [{"id": "$pageview"}]})
self.assertEqual(
saved_metric.query, {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}
)

# Now try updating experiment with new saved metric
response = self.client.post(
f"/api/projects/{self.team.id}/experiment_saved_metrics/",
{
"name": "Test Experiment saved metric 2",
"description": "Test description 2",
"query": {
"events": [{"id": "$pageleave"}],
},
"query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]},
},
)

Expand Down Expand Up @@ -460,7 +458,7 @@ def test_validate_saved_metrics_payload(self):
{
"name": "Test Experiment saved metric",
"description": "Test description",
"query": {"events": [{"id": "$pageview"}]},
"query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]},
},
)

Expand Down
98 changes: 87 additions & 11 deletions ee/clickhouse/views/test/test_experiment_saved_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,91 @@ def test_can_list_experiment_saved_metrics(self):
response = self.client.get(f"/api/projects/{self.team.id}/experiment_saved_metrics/")
self.assertEqual(response.status_code, status.HTTP_200_OK)

def test_validation_of_query_metric(self):
response = self.client.post(
f"/api/projects/{self.team.id}/experiment_saved_metrics/",
data={
"name": "Test Experiment saved metric",
"description": "Test description",
"query": {},
},
format="json",
)

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.json()["detail"], "Query is required to create a saved metric")

response = self.client.post(
f"/api/projects/{self.team.id}/experiment_saved_metrics/",
data={
"name": "Test Experiment saved metric",
"description": "Test description",
"query": {"not-kind": "ExperimentTrendsQuery"},
},
format="json",
)

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.json()["detail"], "Metric query kind must be 'TrendsQuery' or 'FunnelsQuery'")

response = self.client.post(
f"/api/projects/{self.team.id}/experiment_saved_metrics/",
data={
"name": "Test Experiment saved metric",
"description": "Test description",
"query": {"kind": "not-ExperimentTrendsQuery"},
},
format="json",
)

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.json()["detail"], "Metric query kind must be 'TrendsQuery' or 'FunnelsQuery'")

response = self.client.post(
f"/api/projects/{self.team.id}/experiment_saved_metrics/",
data={
"name": "Test Experiment saved metric",
"description": "Test description",
"query": {"kind": "ExperimentTrendsQuery"},
},
format="json",
)

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.json()["detail"], "Metric query kind must be 'TrendsQuery' or 'FunnelsQuery'")

response = self.client.post(
f"/api/projects/{self.team.id}/experiment_saved_metrics/",
data={
"name": "Test Experiment saved metric",
"description": "Test description",
"query": {"kind": "TrendsQuery"},
},
format="json",
)

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertTrue("'loc': ('series',), 'msg': 'Field required'" in response.json()["detail"])

response = self.client.post(
f"/api/projects/{self.team.id}/experiment_saved_metrics/",
data={
"name": "Test Experiment saved metric",
"description": "Test description",
"query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]},
},
format="json",
)

self.assertEqual(response.status_code, status.HTTP_201_CREATED)

def test_create_update_experiment_saved_metrics(self) -> None:
response = self.client.post(
f"/api/projects/{self.team.id}/experiment_saved_metrics/",
data={
"name": "Test Experiment saved metric",
"description": "Test description",
"query": {"events": [{"id": "$pageview"}]},
"query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]},
},
format="json",
)
Expand All @@ -26,7 +104,7 @@ def test_create_update_experiment_saved_metrics(self) -> None:
self.assertEqual(response.json()["description"], "Test description")
self.assertEqual(
response.json()["query"],
{"events": [{"id": "$pageview"}]},
{"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]},
)
self.assertEqual(response.json()["created_by"]["id"], self.user.pk)

Expand Down Expand Up @@ -63,23 +141,25 @@ def test_create_update_experiment_saved_metrics(self) -> None:
self.assertEqual(experiment_to_saved_metric.metadata, {"type": "secondary"})
saved_metric = Experiment.objects.get(pk=exp_id).saved_metrics.first()
self.assertEqual(saved_metric.id, saved_metric_id)
self.assertEqual(saved_metric.query, {"events": [{"id": "$pageview"}]})
self.assertEqual(
saved_metric.query, {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}
)

# Now try updating saved metric
response = self.client.patch(
f"/api/projects/{self.team.id}/experiment_saved_metrics/{saved_metric_id}",
{
"name": "Test Experiment saved metric 2",
"description": "Test description 2",
"query": {"events": [{"id": "$pageleave"}]},
"query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]},
},
)

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["name"], "Test Experiment saved metric 2")
self.assertEqual(
response.json()["query"],
{"events": [{"id": "$pageleave"}]},
{"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]},
)

# make sure experiment in question was updated as well
Expand All @@ -88,11 +168,7 @@ def test_create_update_experiment_saved_metrics(self) -> None:
self.assertEqual(saved_metric.id, saved_metric_id)
self.assertEqual(
saved_metric.query,
{
"events": [
{"id": "$pageleave"},
]
},
{"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]},
)
self.assertEqual(saved_metric.name, "Test Experiment saved metric 2")
self.assertEqual(saved_metric.description, "Test description 2")
Expand All @@ -110,7 +186,7 @@ def test_invalid_create(self):
f"/api/projects/{self.team.id}/experiment_saved_metrics/",
data={
"name": None, # invalid
"query": {"events": [{"id": "$pageview"}]},
"query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]},
},
format="json",
)
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified frontend/__snapshots__/scenes-other-billing--billing--dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified frontend/__snapshots__/scenes-other-billing--billing--light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions frontend/src/lib/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ export const FEATURE_FLAGS = {
EXPERIMENTS_HOLDOUTS: 'experiments-holdouts', // owner: @jurajmajerik #team-experiments
MESSAGING: 'messaging', // owner @mariusandra #team-cdp
SESSION_REPLAY_URL_BLOCKLIST: 'session-replay-url-blocklist', // owner: @richard-better #team-replay
BILLING_TRIAL_FLOW: 'billing-trial-flow', // owner: @zach
DEAD_CLICKS_AUTOCAPTURE: 'dead-clicks-autocapture', // owner: @pauldambra #team-replay
} as const
export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS]
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/lib/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ export const humanizeBytes = (fileSizeInBytes: number | null): string => {
return convertedBytes.toFixed(2) + ' ' + byteUnits[i]
}

export function toSentenceCase(str: string): string {
return str.replace(/\b\w/g, (c) => c.toUpperCase())
}

export function toParams(obj: Record<string, any>, explodeArrays: boolean = false): string {
if (!obj) {
return ''
Expand Down
12 changes: 8 additions & 4 deletions frontend/src/scenes/billing/Billing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { useResizeBreakpoints } from 'lib/hooks/useResizeObserver'
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
import { LemonLabel } from 'lib/lemon-ui/LemonLabel/LemonLabel'
import { SpinnerOverlay } from 'lib/lemon-ui/Spinner/Spinner'
import { humanFriendlyCurrency } from 'lib/utils'
import { humanFriendlyCurrency, toSentenceCase } from 'lib/utils'
import { useEffect } from 'react'
import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
import { SceneExport } from 'scenes/sceneTypes'
Expand Down Expand Up @@ -108,9 +108,13 @@ export function Billing(): JSX.Element {
</LemonBanner>
)}

{billing?.free_trial_until ? (
<LemonBanner type="success" className="mb-2">
You are currently on a free trial until <b>{billing.free_trial_until.format('LL')}</b>
{billing?.trial ? (
<LemonBanner type="info" className="mb-2">
You are currently on a free trial for <b>{toSentenceCase(billing.trial.target)} plan</b> until{' '}
<b>{dayjs(billing.trial.expires_at).format('LL')}</b>. At the end of the trial{' '}
{billing.trial.type === 'autosubscribe'
? 'you will be automatically subscribed to the plan.'
: 'you will be asked to subscribe. If you choose not to, you will lose access to the features.'}
</LemonBanner>
) : null}

Expand Down
Loading

0 comments on commit b9a18d0

Please sign in to comment.