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

Add annual software plans as default product plans #34584

Merged
merged 12 commits into from May 21, 2024

Conversation

nospame
Copy link
Contributor

@nospame nospame commented May 8, 2024

Product Description

Adds "Pay Annually" software plans if they do not already exist, using the naming convention "CommCare {edition} Edition - Pay Annually".

Technical Summary

SaaS-15493
Review by commit recommended.

Feature Flag

This change is not feature flagged.

Safety Assurance

Safety story

Tested migration directly in local environment and on staging, confirming new plans have been created or modified as expected and not duplicated.
Because ensure_plans is always run via migrations, these changes are tested implicitly every time db test setup runs.

Automated test coverage

Added config for annual plans and new asserts in test_ensure_plans.

QA Plan

Given complexity I ran into locally in constructing these changes, I would consider QA here, but I am not sure how to do so since it is primarily a data migration. Any thoughts?

Migrations

  • The migrations in this code can be safely applied first independently of the code

The migration here relies on code changes to ensure_plans included in this PR to run correctly.

Rollback instructions

The migration should be reversed first. This would remove DefaultProductPlan objects added here but not any SoftwarePlans, because on some environments those annual plans would have already existed.

Labels & Review

  • Risk label is set correctly
  • The set of people pinged as reviewers is appropriate for the level of risk of the change

- Break down into more model-specific _ensure functions
- namedtuple for plan_key parameters
- get_or_create for SoftwarePlan
- helpers for plan name and SoftwareProduct
- kwargs dict for SoftwarePlan and DefaultProductPlan
Adding is_annual_plan to unique_together constraint means the model can support a monthly and an annual version of what otherwise looks like the same plan
@dimagimon dimagimon added the reindex/migration Reindex or migration will be required during or before deploy label May 8, 2024
@nospame nospame requested a review from jingcheng16 May 8, 2024 17:44
@nospame nospame marked this pull request as ready for review May 8, 2024 22:07
@nospame nospame added the product/all-users-all-environments Change impacts all users on all environments label May 9, 2024
Copy link
Contributor

@jingcheng16 jingcheng16 left a comment

Choose a reason for hiding this comment

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

Looks good! Thank for the refactoring. Just a few questions.

corehq/apps/accounting/bootstrap/utils.py Outdated Show resolved Hide resolved
corehq/apps/accounting/bootstrap/utils.py Outdated Show resolved Hide resolved
@nospame nospame requested review from millerdev and biyeun and removed request for biyeun May 14, 2024 15:39
No need to pass in all params when name is sufficient. Reverts to previous behavior where visibility and edition are silently ignored if they mismatch the existing plan.

def _software_plan_name(plan_key, product, product_rate):
plan_name = (
(('%s Trial' % product_rate.name) if plan_key.is_trial else ('%s Edition' % product_rate.name))
Copy link
Member

Choose a reason for hiding this comment

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

I know this is copied from very old code, but can we use f-strings to make this more readable?

Suggested change
(('%s Trial' % product_rate.name) if plan_key.is_trial else ('%s Edition' % product_rate.name))
(f'{product_rate.name} Trial' if plan_key.is_trial else f'{product_rate.name} Edition')

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 need to be translated, or is this always used for internal representations like log_accounting_info? If it may be translated then f-strings cannot be used, although string.format() is still better than the old % syntax in most cases.

Copy link
Contributor Author

@nospame nospame May 15, 2024

Choose a reason for hiding this comment

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

As far as I can see it's only ever used internally, since we're using it to look up and/or generate model instances here, or in other places for log_accounting_info. Since the instance name does get shown to a user in limited cases like renewing their plan, it could be that it's translated later on (I don't think so, but it could be), but I would think that would be based on the name saved on the instance, not this string construction.

@@ -25,47 +26,47 @@ def tearDown(self):
def test_ensure_plans(self):
self._test_plan_versions_ensured(BOOTSTRAP_CONFIG_TESTING)
self._test_plan_versions_ensured({
(SoftwarePlanEdition.COMMUNITY, False, False): {
(SoftwarePlanEdition.COMMUNITY, False, False, False): {
Copy link
Member

Choose a reason for hiding this comment

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

were any tests added for the annual plans?

Copy link
Member

Choose a reason for hiding this comment

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

nm I see in BOOTSTRAP_CONFIG_TESTING those annual plans are added

Copy link
Member

@biyeun biyeun left a comment

Choose a reason for hiding this comment

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

thanks for the refactor as well!
left some comments on moving to f-strings to improve readability, but those aren't blocking

Also remove duplicate logging for software product rate, and note whether default product plan is an annual plan.
@nospame
Copy link
Contributor Author

nospame commented May 16, 2024

Want to confirm there are no particular concerns with this. Don't know that I've ever not been able to check that box before.

The migration here relies on code changes to ensure_plans to run correctly. Happy to split up this PR if the code changes are required to come first.

@jingcheng16
Copy link
Contributor

@millerdev Hi Daniel, any thoughts on this?
#34584 (comment)
Migration related questions always stumped me.

Copy link
Contributor

@millerdev millerdev left a comment

Choose a reason for hiding this comment

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

The migration here relies on code changes to ensure_plans to run correctly.

Thanks for bringing this to my attention. I didn't notice it and did not plan to do a thorough review of this PR since Biyeun had already reviewed it.

With regard to:

The migrations in this code can be safely applied first independently of the code

Another way to say this is, some currently-deployed code would be broken and cause errors if the migrations in this PR were to be applied before the PR is deployed. (This is what happens, for a usually short time, during every deploy.)

It's not clear to me (yet) if that is true for this PR, but it would be true, for example, in a PR that deleted a table and also removed code that referenced that same table. Those two things (remove code that references to-be-deleted table and migration that deletes a table) need to be done in separate PRs (and deployed probably 6 weeks apart) so that there is no live code that references the table by the time the migration is applied. The 6-week constraint is so self-hosted environments have a chance to apply the migration before the breaking change is deployed.

On the other hand, it's fine for a single PR to include a migration that adds a new table and also include code that references that new table. This is safe since there is no deployed code that references the table.

Does this explanation help you form a better answer? Hopefully it was helpful and not a bunch of things you already knew.

@@ -857,7 +857,7 @@ class DefaultProductPlan(models.Model):

class Meta(object):
app_label = 'accounting'
unique_together = ('edition', 'is_trial', 'is_report_builder_enabled')
unique_together = ('edition', 'is_trial', 'is_report_builder_enabled', 'is_annual_plan')
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure this can be done safely in a single operation. Looking at the output of sqlmigrate, it will do the following inside a transaction:

ALTER TABLE "accounting_defaultproductplan" DROP CONSTRAINT "accounting_defaultproduc_edition_is_trial_is_repo_294e5a62_uniq";

ALTER TABLE "accounting_defaultproductplan" ADD CONSTRAINT "accounting_defaultproduc_edition_is_trial_is_repo_46637956_uniq" UNIQUE ("edition", "is_trial", "is_report_builder_enabled", "is_annual_plan");

I'm not sure how much data is in that table, but if there is a lot and it takes a long time, it will lock the table while the new index is being built. This means that all other queries will be blocked during that time.

An alternate and safer approach would be to have the migration create the new index first CONCURRENTLY, and then after that is finished, drop the old index. Note that creating an index concurrently cannot be done in a transaction, so the migration will need to specify atomic = False. Search for other migrations where we have run CREATE UNIQUE INDEX CONCURRENTLY for examples.

This question and answer seem relevant since you are altering a constraint, but I think only indexes (unique or not) can be created concurrently.

Finally, are there any potential uniqueness violations in the existing data? If so, this will fail if they are not removed before this is run.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Another way to say this is, some currently-deployed code would be broken and cause errors if the migrations in this PR were to be applied before the PR is deployed.
...
Does this explanation help you form a better answer?

Yes, I think so. I believe this is closer to "include a migration that adds a new table and also include code that references that new table", in that the migration first alters a table, and then relies on that altered table state to add new software plans.

I'm not sure how much data is in that table

Quite little - essentially one object for every uniquely named Software Plan, in this case a quick query returns a total of 12 DefaultProductPlans on production, 10 on staging, and 15 (after running this migration) in my local environment.

I'm not really familiar with indices. Would creating the new index CONCURRENTLY first mean we're generating a new set of data with the change applied, waiting for that to complete, and then pointing the db to the new data? It seems like this might not be necessary given the small volume of data, but is it better practice to just go this route anyway?

Finally, are there any potential uniqueness violations in the existing data?

There are not. DefaultProductPlan is only created in ensure_plans, which itself is only run in migrations. The is_annual_plan property was only recently added, with a default of False that is now applied to all instances existing prior to this PR, and because of the current constraint could not violate the new one.

Copy link
Contributor

@millerdev millerdev May 18, 2024

Choose a reason for hiding this comment

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

If there are so few (i.e., less than thousands) it will be no problem to do it this way, no need to do it CONCURRENTLY. This assumes that there is not high contention for locks on that table, which I assume there are not since it's probably rarely written, given the small number of rows.

@@ -9,6 +9,15 @@ def _bootstrap_new_annual_pricing(apps, schema_editor):
ensure_plans(BOOTSTRAP_CONFIG, verbose=True, apps=apps)


def _remove_annual_default_product_plans(apps, schema_editor):
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 don't know if this is strictly necessary but was easy enough to add, so that in case it needs to be reverted for any reason we won't be stuck doing so manually on every environment.

Copy link
Contributor

Choose a reason for hiding this comment

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

👍 It's unlikely that we would use this in production environments once it's been merged into master because it's difficult to make sure all affected environments have been properly reversed. The approach we would likely take there would be to add a new migration that undoes whatever was done here that didn't work out. However, it can be very handy to be able to reverse migrations for development purposes.

@nospame nospame added product/admin Change affects admin pages only visible to super users / staff and removed product/all-users-all-environments Change impacts all users on all environments labels May 20, 2024
product_rate.name = product_name

def _get_software_product(product_name, verbose, apps):
# SoftwareProduct no longer exists but is retained here to avoid breaking old migrations
Copy link
Contributor

Choose a reason for hiding this comment

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

Not sure I follow what's going on here, but if this is just to support very old migrations (like much older than 6 weeks) I would suggest to remove it and squash the migrations that depend on it so traces of SoftwareProduct can be removed. Fine if this is done in a separate PR.

Copy link
Contributor Author

@nospame nospame May 21, 2024

Choose a reason for hiding this comment

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

I fully agree, but I'm not actually sure how to do that. SoftwareProduct is currently created in accounting migration 0001_squashed_0052, modified in 0010 and finally removed in 0011, all of which are ~7 years old. In the meantime, ensure_plans gets run in accounting migrations 0001_squashed and 0008 so it has to account for the SoftwareProduct model existing.

The similarly aged comment that stood here previously noted that this could be removed "after squashing migrations" -- I'm not sure if there was more squashing intended or if it was just not quite correct.

@@ -9,6 +9,15 @@ def _bootstrap_new_annual_pricing(apps, schema_editor):
ensure_plans(BOOTSTRAP_CONFIG, verbose=True, apps=apps)


def _remove_annual_default_product_plans(apps, schema_editor):
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 It's unlikely that we would use this in production environments once it's been merged into master because it's difficult to make sure all affected environments have been properly reversed. The approach we would likely take there would be to add a new migration that undoes whatever was done here that didn't work out. However, it can be very handy to be able to reverse migrations for development purposes.

@nospame nospame merged commit 82a4499 into master May 21, 2024
13 checks passed
@nospame nospame deleted the em/defaultproductplan-add-annual-plans branch May 21, 2024 17:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
product/admin Change affects admin pages only visible to super users / staff reindex/migration Reindex or migration will be required during or before deploy
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants