Skip to content
This repository was archived by the owner on Jun 13, 2025. It is now read-only.

Commit 261ea42

Browse files
authored
feat: Django admin portal changes for plans and tiers (#1097)
1 parent 020e7cf commit 261ea42

File tree

5 files changed

+279
-8
lines changed

5 files changed

+279
-8
lines changed

codecov_auth/admin.py

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from django.contrib.admin.models import LogEntry
99
from django.db.models import OuterRef, Subquery
1010
from django.db.models.fields import BLANK_CHOICE_DASH
11-
from django.forms import CheckboxInput, Select
11+
from django.forms import CheckboxInput, Select, Textarea
1212
from django.http import HttpRequest
1313
from django.shortcuts import redirect, render
1414
from django.utils import timezone
@@ -17,7 +17,9 @@
1717
Account,
1818
AccountsUsers,
1919
InvoiceBilling,
20+
Plan,
2021
StripeBilling,
22+
Tier,
2123
)
2224
from shared.plan.constants import USER_PLAN_REPRESENTATIONS
2325
from shared.plan.service import PlanService
@@ -708,3 +710,109 @@ class AccountsUsersAdmin(AdminMixin, admin.ModelAdmin):
708710
]
709711

710712
fields = readonly_fields + ["account", "user"]
713+
714+
715+
class PlansInline(admin.TabularInline):
716+
model = Plan
717+
extra = 1
718+
verbose_name_plural = "Plans (click save to commit changes)"
719+
verbose_name = "Plan"
720+
fields = [
721+
"name",
722+
"marketing_name",
723+
"base_unit_price",
724+
"billing_rate",
725+
"max_seats",
726+
"monthly_uploads_limit",
727+
"paid_plan",
728+
"is_active",
729+
]
730+
formfield_overrides = {
731+
Plan._meta.get_field("benefits"): {"widget": Textarea(attrs={"rows": 3})},
732+
}
733+
734+
735+
@admin.register(Tier)
736+
class TierAdmin(admin.ModelAdmin):
737+
list_display = (
738+
"tier_name",
739+
"bundle_analysis",
740+
"test_analytics",
741+
"flaky_test_detection",
742+
"project_coverage",
743+
"private_repo_support",
744+
)
745+
list_editable = (
746+
"bundle_analysis",
747+
"test_analytics",
748+
"flaky_test_detection",
749+
"project_coverage",
750+
"private_repo_support",
751+
)
752+
search_fields = ("tier_name__iregex",)
753+
inlines = [PlansInline]
754+
fields = [
755+
"tier_name",
756+
"bundle_analysis",
757+
"test_analytics",
758+
"flaky_test_detection",
759+
"project_coverage",
760+
"private_repo_support",
761+
]
762+
763+
764+
class PlanAdminForm(forms.ModelForm):
765+
class Meta:
766+
model = Plan
767+
fields = "__all__"
768+
769+
def clean_base_unit_price(self) -> int | None:
770+
base_unit_price = self.cleaned_data.get("base_unit_price")
771+
if base_unit_price is not None and base_unit_price < 0:
772+
raise forms.ValidationError("Base unit price cannot be negative.")
773+
return base_unit_price
774+
775+
def clean_max_seats(self) -> int | None:
776+
max_seats = self.cleaned_data.get("max_seats")
777+
if max_seats is not None and max_seats < 0:
778+
raise forms.ValidationError("Max seats cannot be negative.")
779+
return max_seats
780+
781+
def clean_monthly_uploads_limit(self) -> int | None:
782+
monthly_uploads_limit = self.cleaned_data.get("monthly_uploads_limit")
783+
if monthly_uploads_limit is not None and monthly_uploads_limit < 0:
784+
raise forms.ValidationError("Monthly uploads limit cannot be negative.")
785+
return monthly_uploads_limit
786+
787+
788+
@admin.register(Plan)
789+
class PlanAdmin(admin.ModelAdmin):
790+
form = PlanAdminForm
791+
list_display = (
792+
"name",
793+
"marketing_name",
794+
"base_unit_price",
795+
"is_active",
796+
"paid_plan",
797+
"max_seats",
798+
"monthly_uploads_limit",
799+
"billing_rate",
800+
)
801+
list_filter = ("is_active", "paid_plan", "billing_rate", "tier")
802+
search_fields = ("name__iregex", "marketing_name__iregex")
803+
fields = [
804+
"tier",
805+
"name",
806+
"marketing_name",
807+
"base_unit_price",
808+
"benefits",
809+
"billing_rate",
810+
"is_active",
811+
"max_seats",
812+
"monthly_uploads_limit",
813+
"paid_plan",
814+
]
815+
formfield_overrides = {
816+
Plan._meta.get_field("benefits"): {"widget": Textarea(attrs={"rows": 3})},
817+
}
818+
autocomplete_fields = ["tier"] # a dropdown for selecting related Tiers

codecov_auth/tests/test_admin.py

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@
1818
InvoiceBillingFactory,
1919
OrganizationLevelTokenFactory,
2020
OwnerFactory,
21+
PlanFactory,
2122
SentryUserFactory,
2223
SessionFactory,
2324
StripeBillingFactory,
25+
TierFactory,
2426
UserFactory,
2527
)
2628
from shared.django_apps.core.tests.factories import PullFactory, RepositoryFactory
@@ -39,7 +41,14 @@
3941
UserAdmin,
4042
find_and_remove_stale_users,
4143
)
42-
from codecov_auth.models import OrganizationLevelToken, Owner, SentryUser, User
44+
from codecov_auth.models import (
45+
OrganizationLevelToken,
46+
Owner,
47+
Plan,
48+
SentryUser,
49+
Tier,
50+
User,
51+
)
4352
from core.models import Pull
4453

4554

@@ -881,3 +890,157 @@ def test_account_widget(self):
881890
self.assertFalse(form.base_fields["account"].widget.can_add_related)
882891
self.assertFalse(form.base_fields["account"].widget.can_change_related)
883892
self.assertFalse(form.base_fields["account"].widget.can_delete_related)
893+
894+
895+
class PlanAdminTest(TestCase):
896+
def setUp(self):
897+
self.staff_user = UserFactory(is_staff=True)
898+
self.client.force_login(user=self.staff_user)
899+
admin_site = AdminSite()
900+
admin_site.register(Plan)
901+
902+
def test_plan_admin_modal_display(self):
903+
plan = PlanFactory()
904+
response = self.client.get(
905+
reverse("admin:codecov_auth_plan_change", args=[plan.pk])
906+
)
907+
self.assertEqual(response.status_code, 200)
908+
self.assertContains(response, plan.name)
909+
910+
def test_plan_modal_tiers_display(self):
911+
tier = TierFactory()
912+
plan = PlanFactory(tier=tier)
913+
response = self.client.get(
914+
reverse("admin:codecov_auth_plan_change", args=[plan.pk])
915+
)
916+
self.assertEqual(response.status_code, 200)
917+
self.assertContains(response, tier.tier_name)
918+
919+
def test_add_plans_modal_action(self):
920+
plan = PlanFactory(base_unit_price=10, max_seats=5)
921+
tier = TierFactory()
922+
data = {
923+
"action": "add_plans",
924+
ACTION_CHECKBOX_NAME: [plan.pk],
925+
"tier_id": tier.pk,
926+
}
927+
response = self.client.post(
928+
reverse("admin:codecov_auth_plan_changelist"), data=data
929+
)
930+
self.assertEqual(response.status_code, 302)
931+
self.assertEqual(response.url, "/admin/codecov_auth/plan/")
932+
933+
def test_plan_change_form(self):
934+
plan = PlanFactory()
935+
response = self.client.get(
936+
reverse("admin:codecov_auth_plan_change", args=[plan.pk])
937+
)
938+
self.assertEqual(response.status_code, 200)
939+
for field in [
940+
"tier",
941+
"name",
942+
"marketing_name",
943+
"base_unit_price",
944+
"benefits",
945+
"billing_rate",
946+
"is_active",
947+
"max_seats",
948+
"monthly_uploads_limit",
949+
"paid_plan",
950+
]:
951+
self.assertContains(response, f"id_{field}")
952+
953+
def test_plan_change_form_validation(self):
954+
plan = PlanFactory(base_unit_price=-10)
955+
956+
response = self.client.post(
957+
reverse("admin:codecov_auth_plan_change", args=[plan.pk]),
958+
{
959+
"tier": plan.tier_id,
960+
"name": plan.name,
961+
"marketing_name": plan.marketing_name,
962+
"base_unit_price": -10,
963+
"benefits": plan.benefits,
964+
"is_active": plan.is_active,
965+
"paid_plan": plan.paid_plan,
966+
},
967+
)
968+
self.assertEqual(response.status_code, 200)
969+
self.assertContains(response, "Base unit price cannot be negative.")
970+
971+
response = self.client.post(
972+
reverse("admin:codecov_auth_plan_change", args=[plan.pk]),
973+
{
974+
"tier": plan.tier_id,
975+
"name": plan.name,
976+
"marketing_name": plan.marketing_name,
977+
"base_unit_price": plan.base_unit_price,
978+
"benefits": plan.benefits,
979+
"is_active": plan.is_active,
980+
"max_seats": -5,
981+
"paid_plan": plan.paid_plan,
982+
},
983+
)
984+
self.assertEqual(response.status_code, 200)
985+
self.assertContains(response, "Max seats cannot be negative.")
986+
987+
response = self.client.post(
988+
reverse("admin:codecov_auth_plan_change", args=[plan.pk]),
989+
{
990+
"tier": plan.tier_id,
991+
"name": plan.name,
992+
"marketing_name": plan.marketing_name,
993+
"benefits": plan.benefits,
994+
"is_active": plan.is_active,
995+
"monthly_uploads_limit": -5,
996+
"paid_plan": plan.paid_plan,
997+
},
998+
)
999+
self.assertEqual(response.status_code, 200)
1000+
self.assertContains(response, "Monthly uploads limit cannot be negative.")
1001+
1002+
1003+
class TierAdminTest(TestCase):
1004+
def setUp(self):
1005+
self.staff_user = UserFactory(is_staff=True)
1006+
self.client.force_login(user=self.staff_user)
1007+
admin_site = AdminSite()
1008+
admin_site.register(Tier)
1009+
1010+
def test_tier_modal_plans_display(self):
1011+
tier = TierFactory()
1012+
response = self.client.get(
1013+
reverse("admin:codecov_auth_tier_change", args=[tier.pk])
1014+
)
1015+
self.assertEqual(response.status_code, 200)
1016+
self.assertContains(response, tier.tier_name)
1017+
1018+
def test_add_plans_modal_action(self):
1019+
tier = TierFactory()
1020+
plan = PlanFactory()
1021+
data = {
1022+
"action": "add_plans",
1023+
ACTION_CHECKBOX_NAME: [plan.pk],
1024+
"tier_id": tier.pk,
1025+
}
1026+
response = self.client.post(
1027+
reverse("admin:codecov_auth_tier_changelist"), data=data
1028+
)
1029+
self.assertEqual(response.status_code, 302)
1030+
self.assertEqual(response.url, "/admin/codecov_auth/tier/")
1031+
1032+
def test_tier_change_form(self):
1033+
tier = TierFactory()
1034+
response = self.client.get(
1035+
reverse("admin:codecov_auth_tier_change", args=[tier.pk])
1036+
)
1037+
self.assertEqual(response.status_code, 200)
1038+
for field in [
1039+
"tier_name",
1040+
"bundle_analysis",
1041+
"test_analytics",
1042+
"flaky_test_detection",
1043+
"project_coverage",
1044+
"private_repo_support",
1045+
]:
1046+
self.assertContains(response, f"id_{field}")

requirements.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ freezegun
2626
google-cloud-pubsub
2727
gunicorn>=22.0.0
2828
https://github.com/codecov/opentelem-python/archive/refs/tags/v0.0.4a1.tar.gz#egg=codecovopentelem
29-
https://github.com/codecov/shared/archive/de4b37bc5a736317c6e7c93f9c58e9ae07f8c96b.tar.gz#egg=shared
29+
https://github.com/codecov/shared/archive/191837f5e35f5efc410e670aac7e50e0d09e43e1.tar.gz#egg=shared
3030
https://github.com/photocrowd/django-cursor-pagination/archive/f560902696b0c8509e4d95c10ba0d62700181d84.tar.gz
3131
idna>=3.7
3232
minio

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,7 @@ sentry-sdk[celery]==2.13.0
416416
# shared
417417
setproctitle==1.1.10
418418
# via -r requirements.in
419-
shared @ https://github.com/codecov/shared/archive/de4b37bc5a736317c6e7c93f9c58e9ae07f8c96b.tar.gz
419+
shared @ https://github.com/codecov/shared/archive/191837f5e35f5efc410e670aac7e50e0d09e43e1.tar.gz
420420
# via -r requirements.in
421421
simplejson==3.17.2
422422
# via -r requirements.in

utils/test_utils.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,10 @@ def app(self) -> str:
4545
migrate_to = None
4646

4747
def setUp(self) -> None:
48-
assert (
49-
self.migrate_from and self.migrate_to
50-
), "TestCase '{}' must define migrate_from and migrate_to properties".format(
51-
type(self).__name__
48+
assert self.migrate_from and self.migrate_to, (
49+
"TestCase '{}' must define migrate_from and migrate_to properties".format(
50+
type(self).__name__
51+
)
5252
)
5353
self.migrate_from = [(self.app, self.migrate_from)]
5454
self.migrate_to = [(self.app, self.migrate_to)]

0 commit comments

Comments
 (0)