Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

API: Migrate to Plan / Tier Tables #1099

Merged
merged 22 commits into from
Jan 30, 2025
Merged

Conversation

ajay-sentry
Copy link
Contributor

@ajay-sentry ajay-sentry commented Jan 15, 2025

Purpose/Motivation

This PR relies on this version of shared: codecov/shared#479.

The main objective of this PR is to migrate all Backend Service logic to use the new plan and tiers tables instead of the Plan constants. You can read more about milestone 3 here.

A core change introduced in this PR is the creation of mocks for plans and tiers, which are used in most test setup functions. To ensure efficient testing, we use setUpClass to set up these mocks once per test class, avoiding the need to recreate them for each test.

Links to relevant tickets

Closes codecov/engineering-team#3252

What does this PR do?

Include a brief description of the changes in this PR. Bullet points are your friend.

Notes to Reviewer

Anything to note to the team? Any tips on how to review, or where to start?

Legal Boilerplate

Look, I get it. The entity doing business as "Sentry" was incorporated in the State of Delaware in 2015 as Functional Software, Inc. In 2022 this entity acquired Codecov and as result Sentry is going to need some rights from me in order to utilize my contributions in this PR. So here's the deal: I retain all rights, title and interest in and to my contributions, and by keeping this boilerplate intact I confirm that Sentry can use, modify, copy, and redistribute my contributions, under Sentry's choice of terms.

@ajay-sentry ajay-sentry requested a review from a team as a code owner January 15, 2025 17:40
Copy link

codecov bot commented Jan 15, 2025

Codecov Report

Attention: Patch coverage is 96.77419% with 2 lines in your changes missing coverage. Please review.

Project coverage is 96.07%. Comparing base (e06c5d8) to head (e9662a2).
Report is 4 commits behind head on main.

✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
services/billing.py 87.50% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1099      +/-   ##
==========================================
- Coverage   96.11%   96.07%   -0.04%     
==========================================
  Files         835      836       +1     
  Lines       19572    19611      +39     
==========================================
+ Hits        18812    18842      +30     
- Misses        760      769       +9     
Flag Coverage Δ
unit 95.98% <96.77%> (-0.05%) ⬇️
unit-latest-uploader 95.98% <96.77%> (-0.05%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@codecov-notifications
Copy link

codecov-notifications bot commented Jan 15, 2025

Codecov Report

Attention: Patch coverage is 96.77419% with 2 lines in your changes missing coverage. Please review.

✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
services/billing.py 87.50% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codecov-qa
Copy link

codecov-qa bot commented Jan 15, 2025

Codecov Report

Attention: Patch coverage is 96.82540% with 2 lines in your changes missing coverage. Please review.

Project coverage is 95.98%. Comparing base (ae83c2f) to head (b6ad275).
Report is 2 commits behind head on main.

✅ All tests successful. No failed tests found.

Copy link

codecov-public-qa bot commented Jan 15, 2025

❌ 5 Tests Failed:

Tests completed Failed Passed Skipped
2711 5 2706 6
View the top 3 failed tests by shortest run time
graphql_api/tests/test_owner.py::TestOwnerType::test_fetch_available_plans_is_enterprise_plan
Stack Traces | 0.442s run time
self = <graphql_api.tests.test_owner.TestOwnerType testMethod=test_fetch_available_plans_is_enterprise_plan>

    def test_fetch_available_plans_is_enterprise_plan(self):
        current_org = OwnerFactory(
            username="random-plan-user",
            service="github",
            plan=PlanName.FREE_PLAN_NAME.value,
        )
    
        query = """{
            owner(username: "%s") {
                availablePlans {
                    value
                    isEnterprisePlan
                    isProPlan
                    isTeamPlan
                    isSentryPlan
                    isFreePlan
                    isTrialPlan
                }
            }
        }
        """ % (current_org.username)
        data = self.gql_request(query, owner=current_org)
>       assert data == {
            "owner": {
                "availablePlans": [
                    {
                        "value": "users-basic",
                        "isEnterprisePlan": False,
                        "isProPlan": False,
                        "isTeamPlan": False,
                        "isSentryPlan": False,
                        "isFreePlan": True,
                        "isTrialPlan": False,
                    },
                    {
                        "value": "users-free",
                        "isEnterprisePlan": False,
                        "isProPlan": False,
                        "isTeamPlan": False,
                        "isSentryPlan": False,
                        "isFreePlan": True,
                        "isTrialPlan": False,
                    },
                    {
                        "value": "users-pr-inappm",
                        "isEnterprisePlan": False,
                        "isProPlan": True,
                        "isTeamPlan": False,
                        "isSentryPlan": False,
                        "isFreePlan": False,
                        "isTrialPlan": False,
                    },
                    {
                        "value": "users-pr-inappy",
                        "isEnterprisePlan": False,
                        "isProPlan": True,
                        "isTeamPlan": False,
                        "isSentryPlan": False,
                        "isFreePlan": False,
                        "isTrialPlan": False,
                    },
                    {
                        "value": "users-teamm",
                        "isEnterprisePlan": False,
                        "isProPlan": False,
                        "isTeamPlan": True,
                        "isSentryPlan": False,
                        "isFreePlan": False,
                        "isTrialPlan": False,
                    },
                    {
                        "value": "users-teamy",
                        "isEnterprisePlan": False,
                        "isProPlan": False,
                        "isTeamPlan": True,
                        "isSentryPlan": False,
                        "isFreePlan": False,
                        "isTrialPlan": False,
                    },
                ]
            }
        }
E       AssertionError: assert {'owner': {'a...Plans': None}} == {'owner': {'a...False, ...}]}}
E         
E         Differing items:
E         {'owner': {'availablePlans': None}} != {'owner': {'availablePlans': [{'isEnterprisePlan': False, 'isFreePlan': True, 'isProPlan': False, 'isSentryPlan': Fals...Plan': False, ...}, {'isEnterprisePlan': False, 'isFreePlan': False, 'isProPlan': False, 'isSentryPlan': False, ...}]}}
E         Use -v to get more diff

graphql_api/tests/test_owner.py:1151: AssertionError
graphql_api/tests/test_owner.py::TestOwnerType::test_resolve_number_of_uploads_per_user
Stack Traces | 0.625s run time
self = <graphql_api.tests.test_owner.TestOwnerType testMethod=test_resolve_number_of_uploads_per_user>

    def test_resolve_number_of_uploads_per_user(self):
        query_uploads_number = """{
            owner(username: "%s") {
               numberOfUploads
            }
        }
        """
        repository = RepositoryFactory.create(
            author__plan=PlanName.BASIC_PLAN_NAME.value, author=self.owner
        )
        first_commit = CommitFactory.create(repository=repository)
        first_report = CommitReportFactory.create(
            commit=first_commit, report_type=ReportType.COVERAGE.value
        )
        for i in range(150):
            upload = UploadFactory.create(report=first_report)
            insert_coverage_measurement(
                owner_id=self.owner.ownerid,
                repo_id=repository.repoid,
                commit_id=first_commit.id,
                upload_id=upload.id,
                uploader_used=UploaderType.CLI.value,
                private_repo=repository.private,
                report_type=first_report.report_type,
            )
        query = query_uploads_number % (repository.author.username)
        data = self.gql_request(query, owner=self.owner)
>       assert data["owner"]["numberOfUploads"] == 150
E       assert None == 150

graphql_api/tests/test_owner.py:383: AssertionError
graphql_api/tests/test_owner.py::TestOwnerType::test_owner_plan_status
Stack Traces | 1.22s run time
self = <graphql_api.tests.test_owner.TestOwnerType testMethod=test_owner_plan_status>

    @freeze_time("2023-06-19")
    def test_owner_plan_status(self):
        current_org = OwnerFactory(
            username="random-plan-user",
            service="github",
            trial_start_date=timezone.now(),
            trial_end_date=timezone.now() + timedelta(days=14),
            trial_status=TrialStatus.ONGOING.value,
        )
        query = """{
            owner(username: "%s") {
                plan {
                    trialStatus
                }
            }
        }
        """ % (current_org.username)
        data = self.gql_request(query, owner=current_org)
>       assert data["owner"]["plan"] == {
            "trialStatus": "ONGOING",
        }
E       AssertionError: assert None == {'trialStatus': 'ONGOING'}

graphql_api/tests/test_owner.py:644: AssertionError

To view more test analytics, go to the Test Analytics Dashboard
📢 Thoughts on this report? Let us know!

Copy link
Contributor

github-actions bot commented Jan 15, 2025

✅ All tests successful. No failed tests were found.

📣 Thoughts on this report? Let Codecov know! | Powered by Codecov

@ajay-sentry ajay-sentry requested a review from a team as a code owner January 15, 2025 20:02
@@ -131,11 +130,6 @@ def validate_value(self, value: str) -> str:
plan["value"] for plan in plan_service.available_plans(current_owner)
]
if value not in plan_values:
if value in SENTRY_PAID_USER_PLAN_REPRESENTATIONS:
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed this because it would now just be an extra DB call for a log lol

billing/helpers.py Outdated Show resolved Hide resolved
@RulaKhaled RulaKhaled force-pushed the Ajay/milestone-3-migration branch from bef1fa7 to 3a5b3af Compare January 20, 2025 11:51
Copy link
Contributor

github-actions bot commented Jan 21, 2025

This PR includes changes to shared. Please review them here: codecov/shared@fe16480...0e2207e

@ajay-sentry ajay-sentry changed the title [WIP] Plan Milestone 3 Transitions API: Migrate to Plan / Tier Tables Jan 23, 2025
@@ -219,7 +222,7 @@ def get_plan(self, phase: Dict[str, Any]) -> str:
plan_name = list(stripe_plan_dict.keys())[
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This can probably be further cleaned up by doing a get on the stripe_id 🤔

@@ -637,7 +637,10 @@ def validate_upload(
owner = _determine_responsible_owner(repository)

# If author is on per repo billing, check their repo credits
if owner.plan not in USER_PLAN_REPRESENTATIONS and owner.repo_credits <= 0:
if (
owner.plan not in Plan.objects.values_list("name", flat=True)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is some mega legacy code, I don't think anyone is on per repo billing anymore and can probably be deleted tbh

@@ -69,17 +71,24 @@ def __getitem__(self, key):


class StripeWebhookHandlerTests(APITestCase):
@classmethod
def setUpClass(cls):
Copy link
Contributor

Choose a reason for hiding this comment

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

beauty

def resolve_plan_representation(owner: Owner, info: GraphQLResolveInfo) -> PlanData:
info.context["plan_service"] = PlanService(current_org=owner)
free_plan = FREE_PLAN_REPRESENTATIONS[PlanName.BASIC_PLAN_NAME.value]
return free_plan.convert_to_DTO()
free_plan = Plan.objects.select_related("tier").get(
Copy link
Contributor Author

@ajay-sentry ajay-sentry Jan 27, 2025

Choose a reason for hiding this comment

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

direct fetch of the basic plan info, will swap to new dev plan when we do that integration

@@ -794,22 +795,32 @@ def update_plan(self, owner, desired_plan):
on current state, might create a stripe checkout session and return
the checkout session's ID, which is a string. Otherwise returns None.
"""
if desired_plan["value"] in FREE_PLAN_REPRESENTATIONS:
try:
plan = Plan.objects.get(name=desired_plan["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.

This is a bit of a logic update for what I interpreted the other const to be doing; check if the plan exists AND check if the plan is active; otherwise its an old plan we don't want users to transition to anymore

)
return None

if plan.paid_plan is False:
Copy link
Contributor Author

Choose a reason for hiding this comment

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

a logic "reversal" here because we've already ruled out the non-existent/old plan case. So we can check if the plan is a free plan and set that plan data, otherwise do the paid plan logic

@@ -26,7 +26,7 @@ freezegun
google-cloud-pubsub
gunicorn>=22.0.0
https://github.com/codecov/opentelem-python/archive/refs/tags/v0.0.4a1.tar.gz#egg=codecovopentelem
https://github.com/codecov/shared/archive/fe16480b3646a616ff412d5c0a28cafd2c7104c1.tar.gz#egg=shared
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't see this commit on the branch in shared - can you update to the most recent commit on your branch?

Copy link
Contributor

Choose a reason for hiding this comment

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

You can find the commit at codecov/shared#479. We need the changes from that version and will update to the new shared version once it's merged. Feel free to review the rest of the files—we did the same for the worker PR

Copy link
Contributor

github-actions bot commented Jan 29, 2025

This PR includes changes to shared. Please review them here: codecov/shared@fe16480...a4bd8f7

Copy link
Contributor

This PR includes changes to shared. Please review them here: codecov/shared@fe16480...74c0888

@RulaKhaled RulaKhaled enabled auto-merge January 30, 2025 17:03
@RulaKhaled RulaKhaled disabled auto-merge January 30, 2025 17:05
active_plans = list(
Plan.objects.select_related("tier").filter(paid_plan=True, is_active=True)
)
active_plan_names = {plan.name for plan in active_plans}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
active_plan_names = {plan.name for plan in active_plans}
active_plan_names = set(active_plans.values_list("name", flat=True))

written from memory so there may be typos, but instead of iterating through, just return the values you want

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's cool that django can keep those sets in memory and do additional filters if needed

plan.name
for plan in active_plans
if plan.tier.tier_name == TierName.TEAM.value
}
Copy link
Contributor

Choose a reason for hiding this comment

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

do a filter on the qset to achieve plan.tier.tier_name == TierName.TEAM.value rather than iterating and checking. then do a values_list to get the names

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated ✔️ Thanks for the suggestion

@@ -184,7 +187,7 @@ def validate(self, plan: Dict[str, Any]) -> Dict[str, Any]:
"Quantity or plan for paid plan must be different from the existing one"
)
if (
plan["value"] in TEAM_PLAN_REPRESENTATIONS
plan["value"] in team_tier_plans
Copy link
Contributor

Choose a reason for hiding this comment

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

I thought that with the new structuring of plans, instead of doing a comparison like this, you would get the Plan object that plan["value"] references, and then you could do, if plan.tier.tier_name == TierName.TEAM.value

the plan should have these attributes on it, so you don't need to compile a list of other plans and ask is this one in that list - just check the attribute.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep totally on board! Unfortunately the frontend is the thing that sends us these values right now and it's super naive in how it's being done. We could fetch the plan based off the name here maybe and do a similar check, but ultimately its about the same

Copy link
Contributor Author

Choose a reason for hiding this comment

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

return False
# If from TEAM to PRO, then considered a similar plan but really is an upgrade
elif owner.plan in TEAM_PLANS and desired_plan["value"] not in TEAM_PLANS:
elif owner.plan in team_plans and desired_plan["value"] not in team_plans:
Copy link
Contributor

Choose a reason for hiding this comment

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

same question on these checks - could you not do current_plan_info.tier_name?

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 fair point, makes sense

elif (
current_plan_info.tier.tier_name == TierName.TEAM.value
and desired_plan_info.tier.tier_name != TierName.TEAM.value
):
Copy link
Contributor

Choose a reason for hiding this comment

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

_is_extending_term and _is_similar_plan - they are both getting Plans from the db, but they are both only called once, from the same place (_get_proration_params)

could do the db calls in _get_proration_params, then pass the Plan objs or the relevant fields to _is_extending_term and _is_similar_plan to cut db hits in half

Copy link
Contributor

@suejung-sentry suejung-sentry left a comment

Choose a reason for hiding this comment

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

approving on behalf of @nora-codecov who is awaiting getting added to this repo

@ajay-sentry ajay-sentry added this pull request to the merge queue Jan 30, 2025
Merged via the queue into main with commit 354b640 Jan 30, 2025
17 of 19 checks passed
@ajay-sentry ajay-sentry deleted the Ajay/milestone-3-migration branch January 30, 2025 19:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Migrate plan service logic in API
4 participants