Skip to content
This repository was archived by the owner on May 5, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fbbb396
shared stuff should be migrated with this, first try with available_p…
ajay-sentry Jan 16, 2025
52fdc2d
Merge branch 'main' into Ajay/milestone-3-migration
ajay-sentry Jan 17, 2025
33d98f6
boilerplate updates, better way to do the plan searches
ajay-sentry Jan 17, 2025
ba68a8b
update logic a bit to work with new plan, and types as well, start fi…
ajay-sentry Jan 17, 2025
3b1deb6
43 tests failing locally
ajay-sentry Jan 18, 2025
7eb00e4
fix some tests:
RulaKhaled Jan 20, 2025
4fd5090
uncomment stuff
RulaKhaled Jan 20, 2025
7473f20
more tests fixing
RulaKhaled Jan 21, 2025
ccb28ab
5 more to go
RulaKhaled Jan 21, 2025
208eb10
wrap up tests in test_plan
RulaKhaled Jan 21, 2025
a9a5eeb
resolve all tests
RulaKhaled Jan 21, 2025
3dcd8ef
update test imports and helper, remove trial_days from DTO, start off…
ajay-sentry Jan 21, 2025
6b1fabc
lint
ajay-sentry Jan 21, 2025
906e7bc
Update with pretty plan changes
RulaKhaled Jan 22, 2025
2cd7436
setupClass from setUp and clean up a bunch of the available plan tests
ajay-sentry Jan 23, 2025
8277f6d
remove ttestcase if not needed
ajay-sentry Jan 23, 2025
5c8398a
print
ajay-sentry Jan 23, 2025
d2c0af3
Merge branch 'main' into Ajay/milestone-3-migration
ajay-sentry Jan 24, 2025
7804571
use cached property, remove setter
ajay-sentry Jan 24, 2025
0e2207e
Merge branch 'main' into Ajay/milestone-3-migration
ajay-sentry Jan 27, 2025
6e1418a
missed a couple prefetch references
ajay-sentry Jan 27, 2025
a4bd8f7
Merge branch 'main' into Ajay/milestone-3-migration
ajay-sentry Jan 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 10 additions & 12 deletions shared/django_apps/codecov_auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import logging
import os
import uuid
from dataclasses import asdict
from datetime import datetime
from hashlib import md5
from typing import Optional, Self
Expand Down Expand Up @@ -30,7 +29,7 @@
from shared.django_apps.codecov_auth.managers import OwnerManager
from shared.django_apps.core.managers import RepositoryManager
from shared.django_apps.core.models import DateTimeWithoutTZField, Repository
from shared.plan.constants import USER_PLAN_REPRESENTATIONS, PlanName
from shared.plan.constants import PlanName

# Added to avoid 'doesn't declare an explicit app_label and isn't in an application in INSTALLED_APPS' error\
# Needs to be called the same as the API app
Expand Down Expand Up @@ -221,10 +220,9 @@ def pretty_plan(self) -> dict | None:
This is how we represent the details of a plan to a user, see plan.constants.py
We inject quantity to make plan management easier on api, see PlanSerializer
"""
if self.plan in USER_PLAN_REPRESENTATIONS:
plan_details = asdict(USER_PLAN_REPRESENTATIONS[self.plan])
plan_details.update({"quantity": self.plan_seat_count})
return plan_details
plan_details = Plan.objects.get(name=self.plan)
plan_details.update({"quantity": self.plan_seat_count})
return plan_details

def can_activate_user(self, user: User | None = None) -> bool:
"""
Expand Down Expand Up @@ -593,14 +591,14 @@ def avatar_url(self, size=DEFAULT_AVATAR_SIZE):
def pretty_plan(self):
if self.account:
return self.account.pretty_plan
if self.plan in USER_PLAN_REPRESENTATIONS:
plan_details = asdict(USER_PLAN_REPRESENTATIONS[self.plan])

# update with quantity they've purchased
# allows api users to update the quantity
# by modifying the "plan", sidestepping
# some iffy data modeling
plan_details = Plan.objects.get(name=self.plan)
# update with quantity they've purchased
# allows api users to update the quantity
# by modifying the "plan", sidestepping
# some iffy data modeling

if plan_details:
plan_details.update({"quantity": self.plan_user_count})
return plan_details

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
from django.dispatch import receiver
from django.forms import ValidationError

from shared.django_apps.codecov_auth.models import OrganizationLevelToken, Owner
from shared.plan.constants import USER_PLAN_REPRESENTATIONS
from shared.django_apps.codecov_auth.models import OrganizationLevelToken, Owner, Plan

log = logging.getLogger(__name__)

Expand All @@ -18,9 +17,10 @@ class OrgLevelTokenService(object):
-- only 1 token per Owner
"""

# MIGHT BE ABLE TO REMOVE THIS AND SUBSEQUENT DOWNSTREAM STUFF
@classmethod
def org_can_have_upload_token(cls, org: Owner):
return org.plan in USER_PLAN_REPRESENTATIONS
return Plan.objects.filter(name=org.plan, is_active=True).exists()

@classmethod
def get_or_create_org_token(cls, org: Owner):
Expand Down
24 changes: 15 additions & 9 deletions shared/django_apps/codecov_auth/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
UserToken,
)
from shared.encryption.oauth import get_encryptor_from_configuration
from shared.plan.constants import TrialStatus
from shared.plan.constants import PlanName, TierName, TrialStatus

encryptor = get_encryptor_from_configuration()

Expand Down Expand Up @@ -177,20 +177,26 @@ class TierFactory(DjangoModelFactory):
class Meta:
model = Tier

tier_name = factory.Faker("name")
tier_name = TierName.BASIC.value
bundle_analysis = False
test_analytics = False
flaky_test_detection = False
project_coverage = False
private_repo_support = False


class PlanFactory(DjangoModelFactory):
class Meta:
model = Plan

base_unit_price = factory.Faker("pyint")
benefits = []
tier = factory.SubFactory(TierFactory)
base_unit_price = 0
benefits = factory.LazyFunction(lambda: ["Benefit 1", "Benefit 2", "Benefit 3"])
billing_rate = None
is_active = True
marketing_name = factory.Faker("name")
max_seats = None
marketing_name = factory.Faker("catch_phrase")
max_seats = 1
monthly_uploads_limit = None
paid_plan = True
name = factory.Faker("name")
tier = factory.SubFactory(TierFactory)
name = PlanName.BASIC_PLAN_NAME.value
paid_plan = False
stripe_id = None
6 changes: 3 additions & 3 deletions shared/django_apps/utils/model_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ def __init__(

def __set_name__(self, owner, name):
# Validate that the owner class has the methods we need
assert issubclass(owner, ArchiveFieldInterface), (
"Missing some required methods to use AchiveField"
)
assert issubclass(
owner, ArchiveFieldInterface
), "Missing some required methods to use AchiveField"
self.public_name = name
self.db_field_name = "_" + name
self.archive_field_name = "_" + name + "_storage_path"
Expand Down
29 changes: 25 additions & 4 deletions shared/plan/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ class TierName(enum.Enum):
TEAM = "team"
PRO = "pro"
ENTERPRISE = "enterprise"
SENTRY = "sentry"
TRIAL = "trial"


@dataclass(repr=False)
Expand All @@ -99,7 +101,7 @@ def convert_to_DTO(self) -> dict:
"benefits": self.benefits,
"tier_name": self.tier_name,
"monthly_uploads_limit": self.monthly_uploads_limit,
"trial_days": self.trial_days,
"trial_days": self.trial_days or TrialDaysAmount.CODECOV_SENTRY.value,
"is_free_plan": self.tier_name == TierName.BASIC.value,
"is_pro_plan": self.tier_name == TierName.PRO.value,
"is_team_plan": self.tier_name == TierName.TEAM.value,
Expand All @@ -109,6 +111,25 @@ def convert_to_DTO(self) -> dict:
}


def convert_to_DTO(plan) -> dict:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thoughts on typing this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was getting a circular dependency trying to import the Plan class and type it that way, is there another way we can do it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So why is this a circular dependency? I suppose cause you'd import Plan, and plan imports from constants.

So, this being the only thing that imports from Plan, could this belong in plan instead? You'd have to make an instance of Plan to use this wherever you do though

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah exactly, I kinda wanted to avoid having to bind this function to a class when it's really just a "transformer" from our plan object to the plan object that the planService and subsequently GQL expects

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. I'll leave the implementation up to you, I think though that not typing a function because of circular imports feels a bit odd, but you're getting of the fn soon right? If so I wouldn't say it's a big deal

return {
"marketing_name": plan.marketing_name,
"value": plan.name,
"billing_rate": plan.billing_rate,
"base_unit_price": plan.base_unit_price,
"benefits": plan.benefits,
"tier_name": plan.tier.tier_name,
"monthly_uploads_limit": plan.monthly_uploads_limit,
"trial_days": TrialDaysAmount.CODECOV_SENTRY.value,
"is_free_plan": not plan.paid_plan,
"is_pro_plan": plan.tier.tier_name == TierName.PRO.value,
"is_team_plan": plan.tier.tier_name == TierName.TEAM.value,
"is_enterprise_plan": plan.tier.tier_name == TierName.ENTERPRISE.value,
"is_trial_plan": plan.tier.tier_name == TierName.TRIAL.value,
"is_sentry_plan": plan.tier.tier_name == TierName.SENTRY.value,
}


NON_PR_AUTHOR_PAID_USER_PLAN_REPRESENTATIONS = {
PlanName.CODECOV_PRO_MONTHLY_LEGACY.value: PlanData(
marketing_name=PlanMarketingName.CODECOV_PRO.value,
Expand Down Expand Up @@ -189,7 +210,7 @@ def convert_to_DTO(self) -> dict:
"Unlimited private repositories",
"Priority Support",
],
tier_name=TierName.PRO.value,
tier_name=TierName.SENTRY.value,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: These consts are all disappearing soon and aren't / shouldn't be a SOT for anything anymore

trial_days=TrialDaysAmount.CODECOV_SENTRY.value,
monthly_uploads_limit=None,
),
Expand All @@ -205,7 +226,7 @@ def convert_to_DTO(self) -> dict:
"Unlimited private repositories",
"Priority Support",
],
tier_name=TierName.PRO.value,
tier_name=TierName.SENTRY.value,
trial_days=TrialDaysAmount.CODECOV_SENTRY.value,
monthly_uploads_limit=None,
),
Expand Down Expand Up @@ -342,7 +363,7 @@ def convert_to_DTO(self) -> dict:
"Unlimited private repositories",
"Priority Support",
],
tier_name=TierName.PRO.value,
tier_name=TierName.TRIAL.value,
trial_days=None,
monthly_uploads_limit=None,
),
Expand Down
81 changes: 41 additions & 40 deletions shared/plan/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,16 @@
from shared.billing import is_pr_billing_plan
from shared.config import get_config
from shared.django_apps.codecov.commands.exceptions import ValidationError
from shared.django_apps.codecov_auth.models import Owner, Service
from shared.django_apps.codecov_auth.models import Owner, Plan, Service
from shared.plan.constants import (
BASIC_PLAN,
ENTERPRISE_CLOUD_USER_PLAN_REPRESENTATIONS,
FREE_PLAN,
FREE_PLAN_REPRESENTATIONS,
PR_AUTHOR_PAID_USER_PLAN_REPRESENTATIONS,
SENTRY_PAID_USER_PLAN_REPRESENTATIONS,
TEAM_PLAN_MAX_USERS,
TEAM_PLAN_REPRESENTATIONS,
TRIAL_PLAN_REPRESENTATION,
TRIAL_PLAN_SEATS,
USER_PLAN_REPRESENTATIONS,
PlanBillingRate,
PlanData,
PlanName,
TierName,
TrialDaysAmount,
TrialStatus,
convert_to_DTO,
)
from shared.self_hosted.service import enterprise_has_seats_left, license_seats

Expand Down Expand Up @@ -55,19 +47,20 @@ def __init__(self, current_org: Owner):
self.current_org = current_org.root_organization
else:
self.current_org = current_org
if self.current_org.plan not in USER_PLAN_REPRESENTATIONS:

if not Plan.objects.filter(name=self.current_org.plan).exists():
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remind me, are we porting all plans, including "obsolete" plans like the super super old ones that we don't use? If so, changing those "if name not in USER_PLAN_REPRESENTATION" to this wouldn't be 100% equivalent

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The full plan list we're porting over can be found here: https://www.notion.so/sentry/Plan-and-Tier-Table-Mapping-1758b10e4b5d801aa2d7e30198bcfc68

It's basically all the plans that were in USER_PLAN_REPRESENTATION, and seemingly the check should hold true even if the plans are no longer active in the future too, which is why I didn't add the is_active=True filter

raise ValueError("Unsupported plan")
self._plan_data = None

def update_plan(self, name: str, user_count: Optional[int]) -> None:
"""Updates the organization's plan and user count."""
if name not in USER_PLAN_REPRESENTATIONS:
if not Plan.objects.filter(name=name).exists():
raise ValueError("Unsupported plan")
if not user_count:
raise ValueError("Quantity Needed")
self.current_org.plan = name
self.current_org.plan_user_count = user_count
self._plan_data = USER_PLAN_REPRESENTATIONS[self.current_org.plan]
self._plan_data = Plan.objects.get(name=name)
self.current_org.delinquent = False
self.current_org.save()

Expand All @@ -89,25 +82,25 @@ def has_account(self) -> bool:
return self.current_org.account is not None

@property
def plan_data(self) -> PlanData:
def plan_data(self) -> Plan:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you confirmed this implementation effectively acts as cacheing the field? Since this is now a db query, we'd want this to be cached - perhaps look into cached_property decorator

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How can I confirm if its cached or not? Is there a way to check locally / on stage if we do a DB hit vs. use the cache with the new property? In any case I was able to update using that property in 7804571

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting the decorator should suffice, there's probably a way to check it programatically as well but can't think off the top of my head

"""Returns the plan data for the organization, either from account or default."""
if self._plan_data is None:
self._plan_data = USER_PLAN_REPRESENTATIONS.get(
self.current_org.account.plan
self._plan_data = Plan.objects.get(
name=self.current_org.account.plan
if self.has_account
else self.current_org.plan
)
return self._plan_data

@plan_data.setter
def plan_data(self, plan_data: Optional[PlanData]) -> None:
def plan_data(self, plan_data: Optional[Plan]) -> None:
"""Sets the plan data directly."""
self._plan_data = plan_data

@property
def plan_name(self) -> str:
"""Returns the name of the organization's current plan."""
return self.plan_data.value
return self.plan_data.name

@property
def plan_user_count(self) -> int:
Expand Down Expand Up @@ -159,29 +152,35 @@ def monthly_uploads_limit(self) -> Optional[int]:
return self.plan_data.monthly_uploads_limit

@property
def tier_name(self) -> str:
def tier_name(self) -> TierName:
"""Returns the tier name of the plan."""
return self.plan_data.tier_name
return self.plan_data.tier.tier_name
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this has a relationship to tier, might be worth in the query of plan_data to prefetch this relationship, otherwise you'll have an n+1

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added to all relevant touchpoints! Good call


def available_plans(self, owner: Owner) -> List[PlanData]:
def available_plans(self, owner: Owner) -> List[Plan]:
"""Returns the available plans for the owner and organization."""
available_plans = [BASIC_PLAN]
available_plans = {Plan.objects.get(name=PlanName.BASIC_PLAN_NAME.value)}

if self.plan_name == FREE_PLAN.value:
available_plans.append(FREE_PLAN)
curr_plan = Plan.objects.get(name=self.plan_name)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be the same as plan_data? If not, how does the has_account affect this logic?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh yeah nice catch, saves a query too

if not curr_plan.paid_plan:
available_plans.add(curr_plan)

available_plans += PR_AUTHOR_PAID_USER_PLAN_REPRESENTATIONS.values()
# Build list of available tiers based on conditions
available_tiers = [TierName.PRO.value]

if is_sentry_user(owner):
available_plans += SENTRY_PAID_USER_PLAN_REPRESENTATIONS.values()
available_tiers.append(TierName.SENTRY.value)

if (
self.plan_activated_users is None
not self.plan_activated_users
or len(self.plan_activated_users) <= TEAM_PLAN_MAX_USERS
):
available_plans += TEAM_PLAN_REPRESENTATIONS.values()
available_tiers.append(TierName.TEAM.value)

return [plan.convert_to_DTO() for plan in available_plans]
available_plans.update(
Plan.objects.filter(tier__tier_name__in=available_tiers, is_active=True)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're going to always do this at least for the PRO value, would it make sense for the first available_plans to include that parameter, and if available_tiers doesn't change, you save yourself 1 query every time

I'd also look into cacheing this one for perf

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looked into this one a bit, @cached_property is unavailable to use because we have an additional input parameter (owner), and while LRU_cache does work, if we don't end up evicting the value for an owner when other aspects of that owner change we could end up with some stale / incorrect values

)

return [convert_to_DTO(plan) for plan in available_plans]

def _start_trial_helper(
self,
Expand Down Expand Up @@ -222,7 +221,7 @@ def start_trial(self, current_owner: Owner) -> None:
"""
if self.trial_status != TrialStatus.NOT_STARTED.value:
raise ValidationError("Cannot start an existing trial")
if self.plan_name not in FREE_PLAN_REPRESENTATIONS:
if not Plan.objects.filter(name=self.plan_name, paid_plan=False).exists():
raise ValidationError("Cannot trial from a paid plan")

self._start_trial_helper(current_owner)
Expand All @@ -236,10 +235,12 @@ def start_trial_manually(self, current_owner: Owner, end_date: datetime) -> None
No value
"""
# Start a new trial plan for free users currently not on trial
if self.plan_name in FREE_PLAN_REPRESENTATIONS:

plan = Plan.objects.get(name=self.plan_name)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be self.plan_data?

I'm seeing a lot of Plan.objects.get(name=self.plan_name), so this comment applies to all the instances that behave the same way

  1. Could we use plan_data instead? Not sure how the account part of this works, but I'm not a fan of doing many DB queries in different properties when you could have made one. If we can't use plan_data, I'd make another field called "plan_db_representation" or something (please something shorter 😂), make that a cache property, and do that instead (this is effectively plan_data but idk how the accounts play a role).
  2. Prefetch other relationships you have (tier below). When you do plan.tier, if you haven't prefetched that, you basically do another DB call to tier cause it's a fk, so prefetching here means "ask for it when you do the initial fetch cause it will be 1 query"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah those are great points, I was rushing through on the original implementation just to get us started on fixing tests but going back we can totally use plan_data instead, I think the account stuff will be needed as well so the stuff previously was wrong anyway.

2 birds with one stone: optimizations and bug fixing 😂

I also fixed all the instances of plan.tier to now prefetch tier if we are gonna need it too

if plan.paid_plan is False:
self._start_trial_helper(current_owner, end_date, is_extension=False)
# Extend an existing trial plan for users currently on trial
elif self.plan_name in TRIAL_PLAN_REPRESENTATION:
elif plan.tier.tier_name == TierName.TRIAL.value:
self._start_trial_helper(current_owner, end_date, is_extension=True)
# Paying users cannot start a trial
else:
Expand Down Expand Up @@ -324,30 +325,30 @@ def has_seats_left(self) -> bool:

@property
def is_enterprise_plan(self) -> bool:
return self.plan_name in ENTERPRISE_CLOUD_USER_PLAN_REPRESENTATIONS
return self.plan_data.tier.tier_name == TierName.ENTERPRISE.value

@property
def is_free_plan(self) -> bool:
return self.plan_name in FREE_PLAN_REPRESENTATIONS
return self.plan_data.paid_plan is False and not self.is_org_trialing

@property
def is_pro_plan(self) -> bool:
return (
self.plan_name in PR_AUTHOR_PAID_USER_PLAN_REPRESENTATIONS
or self.plan_name in SENTRY_PAID_USER_PLAN_REPRESENTATIONS
self.plan_data.tier.tier_name == TierName.PRO.value
or self.plan_data.tier.tier_name == TierName.SENTRY.value
)

@property
def is_sentry_plan(self) -> bool:
return self.plan_name in SENTRY_PAID_USER_PLAN_REPRESENTATIONS
return self.plan_data.tier.tier_name == TierName.SENTRY.value

@property
def is_team_plan(self) -> bool:
return self.plan_name in TEAM_PLAN_REPRESENTATIONS
return self.plan_data.tier.tier_name == TierName.TEAM.value

@property
def is_trial_plan(self) -> bool:
return self.plan_name in TRIAL_PLAN_REPRESENTATION
return self.plan_data.tier.tier_name == TierName.TRIAL.value

@property
def is_pr_billing_plan(self) -> bool:
Expand Down
Loading
Loading