Skip to content

Commit 3085053

Browse files
author
Stephen Cefali
authored
feat(onboarding): adds an onboarding backend (#44141)
This PR sets up an onboarding backend that can swap in a different model for `OrganizationOnboardingTask`. The reason we want this is so the sandbox can use the onboarding tasks on the organization serializer which have a different model that's unique per user instead of per org (so each user can do their own onboarding).
1 parent 82c41bc commit 3085053

File tree

10 files changed

+135
-45
lines changed

10 files changed

+135
-45
lines changed

src/sentry/api/endpoints/organization_onboarding_tasks.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
from rest_framework.request import Request
33
from rest_framework.response import Response
44

5+
from sentry import onboarding_tasks
56
from sentry.api.base import region_silo_endpoint
67
from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission
7-
from sentry.models import OnboardingTaskStatus, OrganizationOnboardingTask
8-
from sentry.receivers import try_mark_onboarding_complete
8+
from sentry.models import OnboardingTaskStatus
99

1010

1111
class OnboardingTaskPermission(OrganizationPermission):
@@ -17,9 +17,9 @@ class OrganizationOnboardingTaskEndpoint(OrganizationEndpoint):
1717
permission_classes = (OnboardingTaskPermission,)
1818

1919
def post(self, request: Request, organization) -> Response:
20-
try:
21-
task_id = OrganizationOnboardingTask.TASK_LOOKUP_BY_KEY[request.data["task"]]
22-
except KeyError:
20+
21+
task_id = onboarding_tasks.get_task_lookup_by_key(request.data["task"])
22+
if task_id is None:
2323
return Response({"detail": "Invalid task key"}, status=422)
2424

2525
status_value = request.data.get("status")
@@ -28,15 +28,15 @@ def post(self, request: Request, organization) -> Response:
2828
if status_value is None and completion_seen is None:
2929
return Response({"detail": "completionSeen or status must be provided"}, status=422)
3030

31-
status = OrganizationOnboardingTask.STATUS_LOOKUP_BY_KEY.get(status_value)
31+
status = onboarding_tasks.get_status_lookup_by_key(status_value)
3232

3333
if status_value and status is None:
3434
return Response({"detail": "Invalid status key"}, status=422)
3535

3636
# Cannot skip unskippable tasks
3737
if (
3838
status == OnboardingTaskStatus.SKIPPED
39-
and task_id not in OrganizationOnboardingTask.SKIPPABLE_TASKS
39+
and task_id not in onboarding_tasks.get_skippable_tasks()
4040
):
4141
return Response(status=422)
4242

@@ -48,14 +48,14 @@ def post(self, request: Request, organization) -> Response:
4848
if completion_seen:
4949
values["completion_seen"] = timezone.now()
5050

51-
rows_affected, created = OrganizationOnboardingTask.objects.create_or_update(
51+
rows_affected, created = onboarding_tasks.create_or_update_onboarding_task(
5252
organization=organization,
5353
task=task_id,
54+
user=request.user,
5455
values=values,
55-
defaults={"user_id": request.user.id},
5656
)
5757

5858
if rows_affected or created:
59-
try_mark_onboarding_complete(organization.id)
59+
onboarding_tasks.try_mark_onboarding_complete(organization.id)
6060

6161
return Response(status=204)

src/sentry/api/serializers/models/organization.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,26 @@
11
from __future__ import annotations
22

3-
from collections.abc import Mapping, MutableMapping, Sequence
43
from datetime import datetime
5-
from typing import TYPE_CHECKING, Any, Callable, List, Optional, Tuple, Union, cast
4+
from typing import (
5+
TYPE_CHECKING,
6+
Any,
7+
Callable,
8+
List,
9+
Mapping,
10+
MutableMapping,
11+
Optional,
12+
Sequence,
13+
Tuple,
14+
Union,
15+
cast,
16+
)
617

718
from rest_framework import serializers
819
from sentry_relay.auth import PublicKey
920
from sentry_relay.exceptions import RelayError
1021
from typing_extensions import TypedDict
1122

12-
from sentry import features, quotas, roles
23+
from sentry import features, onboarding_tasks, quotas, roles
1324
from sentry.api.serializers import Serializer, register, serialize
1425
from sentry.api.serializers.models.project import ProjectSerializerResponse
1526
from sentry.api.serializers.models.role import (
@@ -339,6 +350,7 @@ class OnboardingTasksSerializerResponse(TypedDict):
339350
data: Any # JSON object
340351

341352

353+
@register(OrganizationOnboardingTask)
342354
class OnboardingTasksSerializer(Serializer): # type: ignore
343355
def get_attrs(
344356
self, item_list: OrganizationOnboardingTask, user: User, **kwargs: Any
@@ -415,9 +427,7 @@ def serialize( # type: ignore
415427

416428
from sentry import experiments
417429

418-
onboarding_tasks = list(
419-
OrganizationOnboardingTask.objects.filter(organization=obj).select_related("user")
420-
)
430+
tasks_to_serialize = list(onboarding_tasks.fetch_onboarding_tasks(obj, user))
421431

422432
experiment_assignments = experiments.all(org=obj, actor=user)
423433

@@ -517,7 +527,7 @@ def serialize( # type: ignore
517527
context["pendingAccessRequests"] = OrganizationAccessRequest.objects.filter(
518528
team__organization=obj
519529
).count()
520-
context["onboardingTasks"] = serialize(onboarding_tasks, user, OnboardingTasksSerializer())
530+
context["onboardingTasks"] = serialize(tasks_to_serialize, user)
521531
return context
522532

523533

src/sentry/assistant/guides.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
# demo mode has different guides
3131
if settings.DEMO_MODE:
32+
# TODO: remove old guides
3233
GUIDES = {
3334
"sidebar": 20,
3435
"issue_stream_v2": 21,

src/sentry/conf/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3118,6 +3118,8 @@ def build_cdc_postgres_init_db_volume(settings):
31183118
# Set the URL for signup page that we redirect to for the setup wizard if signup=1 is in the query params
31193119
SENTRY_SIGNUP_URL = None
31203120

3121+
SENTRY_ORGANIZATION_ONBOARDING_TASK = "sentry.onboarding_tasks.backends.organization_onboarding_task.OrganizationOnboardingTaskBackend"
3122+
31213123
# Temporary allowlist for specially configured organizations to use the direct-storage
31223124
# driver.
31233125
SENTRY_REPLAYS_STORAGE_ALLOWLIST = []

src/sentry/models/organizationonboardingtask.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
from django.conf import settings
24
from django.core.cache import cache
35
from django.db import IntegrityError, models, transaction
@@ -99,6 +101,10 @@ class AbstractOnboardingTask(Model):
99101
project = FlexibleForeignKey("sentry.Project", db_constraint=False, null=True)
100102
data = JSONField() # INVITE_MEMBER { invited_member: user.id }
101103

104+
# fields for typing
105+
TASK_LOOKUP_BY_KEY: dict[str, int]
106+
SKIPPABLE_TASKS: frozenset[int]
107+
102108
class Meta:
103109
abstract = True
104110

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from typing import TYPE_CHECKING
2+
3+
from django.conf import settings
4+
5+
from sentry.onboarding_tasks.base import OnboardingTaskBackend
6+
from sentry.utils.services import LazyServiceWrapper
7+
8+
backend = LazyServiceWrapper(
9+
OnboardingTaskBackend, settings.SENTRY_ORGANIZATION_ONBOARDING_TASK, {}
10+
)
11+
backend.expose(locals())
12+
13+
if TYPE_CHECKING:
14+
__onboarding_task_backend = OnboardingTaskBackend()
15+
get_task_lookup_by_key = __onboarding_task_backend.get_task_lookup_by_key
16+
get_status_lookup_by_key = __onboarding_task_backend.get_status_lookup_by_key
17+
get_skippable_tasks = __onboarding_task_backend.get_skippable_tasks
18+
fetch_onboarding_tasks = __onboarding_task_backend.fetch_onboarding_tasks
19+
create_or_update_onboarding_task = __onboarding_task_backend.create_or_update_onboarding_task
20+
try_mark_onboarding_complete = __onboarding_task_backend.try_mark_onboarding_complete

src/sentry/onboarding_tasks/backends/__init__.py

Whitespace-only changes.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from django.db import IntegrityError, transaction
2+
from django.db.models import Q
3+
from django.utils import timezone
4+
5+
from sentry.models import OnboardingTaskStatus, OrganizationOnboardingTask, OrganizationOption
6+
from sentry.onboarding_tasks.base import OnboardingTaskBackend
7+
from sentry.utils import json
8+
9+
10+
class OrganizationOnboardingTaskBackend(OnboardingTaskBackend):
11+
Model = OrganizationOnboardingTask
12+
13+
def fetch_onboarding_tasks(self, organization, user):
14+
return self.Model.objects.filter(organization=organization).select_related("user")
15+
16+
def create_or_update_onboarding_task(self, organization, user, task, values):
17+
return self.Model.objects.create_or_update(
18+
organization=organization,
19+
task=task,
20+
values=values,
21+
defaults={"user_id": user.id},
22+
)
23+
24+
def try_mark_onboarding_complete(self, organization_id):
25+
if OrganizationOption.objects.filter(
26+
organization_id=organization_id, key="onboarding:complete"
27+
).exists():
28+
return
29+
30+
completed = set(
31+
OrganizationOnboardingTask.objects.filter(
32+
Q(organization_id=organization_id)
33+
& (Q(status=OnboardingTaskStatus.COMPLETE) | Q(status=OnboardingTaskStatus.SKIPPED))
34+
).values_list("task", flat=True)
35+
)
36+
if completed >= OrganizationOnboardingTask.REQUIRED_ONBOARDING_TASKS:
37+
try:
38+
with transaction.atomic():
39+
OrganizationOption.objects.create(
40+
organization_id=organization_id,
41+
key="onboarding:complete",
42+
value={"updated": json.datetime_to_str(timezone.now())},
43+
)
44+
except IntegrityError:
45+
pass
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from sentry.models.organizationonboardingtask import AbstractOnboardingTask
2+
from sentry.utils.services import Service
3+
4+
5+
class OnboardingTaskBackend(Service):
6+
__all__ = (
7+
"get_task_lookup_by_key",
8+
"get_status_lookup_by_key",
9+
"get_skippable_tasks",
10+
"fetch_onboarding_tasks",
11+
"create_or_update_onboarding_task",
12+
"try_mark_onboarding_complete",
13+
)
14+
Model: AbstractOnboardingTask = AbstractOnboardingTask
15+
16+
def get_task_lookup_by_key(self, key):
17+
return self.Model.TASK_LOOKUP_BY_KEY.get(key)
18+
19+
def get_status_lookup_by_key(self, key):
20+
return self.Model.STATUS_LOOKUP_BY_KEY.get(key)
21+
22+
def get_skippable_tasks(self):
23+
return self.Model.SKIPPABLE_TASKS
24+
25+
def fetch_onboarding_tasks(self, organization, user):
26+
raise NotImplementedError
27+
28+
def create_or_update_onboarding_task(self, organization, user, task, values):
29+
raise NotImplementedError
30+
31+
def try_mark_onboarding_complete(self, organization_id):
32+
raise NotImplementedError

src/sentry/receivers/onboarding.py

Lines changed: 2 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
from datetime import datetime
33

44
import pytz
5-
from django.db import IntegrityError, transaction
6-
from django.db.models import F, Q
5+
from django.db.models import F
76
from django.utils import timezone
87

98
from sentry import analytics
@@ -12,9 +11,9 @@
1211
OnboardingTaskStatus,
1312
Organization,
1413
OrganizationOnboardingTask,
15-
OrganizationOption,
1614
Project,
1715
)
16+
from sentry.onboarding_tasks import try_mark_onboarding_complete
1817
from sentry.plugins.bases import IssueTrackingPlugin, IssueTrackingPlugin2
1918
from sentry.services.hybrid_cloud.user import RpcUser
2019
from sentry.signals import (
@@ -36,7 +35,6 @@
3635
project_created,
3736
transaction_processed,
3837
)
39-
from sentry.utils import json
4038
from sentry.utils.event import has_event_minified_stack_trace
4139
from sentry.utils.javascript import has_sourcemap
4240

@@ -49,30 +47,6 @@
4947
)
5048

5149

52-
def try_mark_onboarding_complete(organization_id):
53-
if OrganizationOption.objects.filter(
54-
organization_id=organization_id, key="onboarding:complete"
55-
).exists():
56-
return
57-
58-
completed = set(
59-
OrganizationOnboardingTask.objects.filter(
60-
Q(organization_id=organization_id)
61-
& (Q(status=OnboardingTaskStatus.COMPLETE) | Q(status=OnboardingTaskStatus.SKIPPED))
62-
).values_list("task", flat=True)
63-
)
64-
if completed >= OrganizationOnboardingTask.REQUIRED_ONBOARDING_TASKS:
65-
try:
66-
with transaction.atomic():
67-
OrganizationOption.objects.create(
68-
organization_id=organization_id,
69-
key="onboarding:complete",
70-
value={"updated": json.datetime_to_str(timezone.now())},
71-
)
72-
except IntegrityError:
73-
pass
74-
75-
7650
@project_created.connect(weak=False)
7751
def record_new_project(project, user, **kwargs):
7852
if user.is_authenticated:

0 commit comments

Comments
 (0)