Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions server/polar/benefit/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,21 @@ async def update(
]
)

if (
"description" in benefit_update.model_fields_set
and benefit_update.description is None
):
raise PolarRequestValidationError(
[
{
"type": "value_error",
"loc": ("body", "description"),
"msg": "Description cannot be null.",
"input": benefit_update.description,
}
]
)

update_dict = benefit_update.model_dump(
by_alias=True, exclude_unset=True, exclude={"type", "properties"}
)
Expand Down
12 changes: 12 additions & 0 deletions server/polar/checkout/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2396,9 +2396,21 @@ async def _update_checkout(
if checkout.customer_id is not None:
exclude.add("customer_email")

# These columns are NOT NULL with defaults, but the update schema types them
# Optional so they can be omitted. Pydantic keeps an explicitly-sent `null`
# in the `exclude_unset` output, so skip it here to preserve the current
# value instead of raising a NOT NULL IntegrityError.
non_nullable_fields = {
"is_business_customer",
"allow_discount_codes",
"require_billing_address",
"customer_metadata",
}
for attr, value in checkout_update.model_dump(
exclude_unset=True, exclude=exclude, by_alias=True
).items():
if value is None and attr in non_nullable_fields:
continue
setattr(checkout, attr, value)

if disabling_business_customer:
Expand Down
6 changes: 6 additions & 0 deletions server/polar/custom_field/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@ async def update(
for attr, value in custom_field_update.model_dump(
exclude_unset=True, by_alias=True
).items():
# `name`, `slug` and `properties` map to NOT NULL columns. Pydantic
# keeps an explicitly-sent `null` in the `exclude_unset` output, so
# ignore it and keep the current value instead of raising a NOT NULL
# IntegrityError, mirroring the `slug is not None` guard above.
if value is None:
continue
setattr(custom_field, attr, value)

# Update the slug from all custom_field_data JSONB
Expand Down
17 changes: 16 additions & 1 deletion server/polar/discount/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,10 @@ async def update(
]
)

if discount_update.type is not None and discount_update.type != discount.type:
if (
"type" in discount_update.model_fields_set
and discount_update.type != discount.type
):
raise PolarRequestValidationError(
[
{
Expand All @@ -244,6 +247,18 @@ async def update(
]
)

if "name" in discount_update.model_fields_set and discount_update.name is None:
raise PolarRequestValidationError(
[
{
"type": "value_error",
"loc": ("body", "name"),
"msg": "Name cannot be null.",
"input": discount_update.name,
}
]
)

if discount_update.code is not None:
existing_discount = await self.get_by_code_and_organization(
session, discount_update.code, discount.organization, redeemable=False
Expand Down
5 changes: 5 additions & 0 deletions server/polar/license_key/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,11 @@ async def update(
await self._enqueue_grant_lifecycle(session, license_key, status)

for key, value in update_dict.items():
# `status` is NOT NULL; an explicitly-sent `null` would violate the
# constraint. Ignore it and keep the current status, mirroring the
# `status is not None` guard used for the lifecycle job above.
if key == "status" and value is None:
continue
setattr(license_key, key, value)

session.add(license_key)
Expand Down
11 changes: 10 additions & 1 deletion server/polar/meter/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,15 +205,24 @@ async def update(
if errors:
raise PolarRequestValidationError(errors)

# `name` and `unit` map to NOT NULL columns. Like `filter` and
# `aggregation`, exclude them from the raw dump and re-add only when a
# non-null value was provided, so an explicitly-sent `null` (which
# `exclude_unset` keeps) preserves the current value instead of raising a
# NOT NULL violation.
update_dict = meter_update.model_dump(
by_alias=True,
exclude_unset=True,
exclude={"filter", "aggregation", "is_archived"},
exclude={"filter", "aggregation", "is_archived", "name", "unit"},
)
if meter_update.filter is not None:
update_dict["filter"] = meter_update.filter
if meter_update.aggregation is not None:
update_dict["aggregation"] = meter_update.aggregation
if meter_update.name is not None:
update_dict["name"] = meter_update.name
if meter_update.unit is not None:
update_dict["unit"] = meter_update.unit

# Handle archiving/unarchiving
if meter_update.is_archived is not None:
Expand Down
18 changes: 18 additions & 0 deletions server/polar/organization/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,24 @@ async def update(
},
)

# These map to NOT NULL columns. Pydantic keeps an explicitly-sent `null`
# in the `exclude_unset` output (it counts as set), so drop it here to
# preserve the current value instead of hitting a NOT NULL violation,
# mirroring how feature_settings / subscription_settings / dispute_settings
# above ignore a null value.
for non_nullable_field in (
"name",
"socials",
"embed_hosts",
"default_presentment_currency",
"default_tax_behavior",
"sso_enforced",
"customer_email_settings",
"customer_portal_settings",
):
if update_dict.get(non_nullable_field) is None:
update_dict.pop(non_nullable_field, None)

if update_schema.details:
organization.details = cast(
OrganizationDetails, update_schema.details.model_dump()
Expand Down
16 changes: 15 additions & 1 deletion server/polar/product/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,9 @@ async def update(
):
product.description = update_schema.description

if update_schema.visibility is not None:
product.visibility = update_schema.visibility

if update_schema.recurring_interval is not None:
product.recurring_interval = update_schema.recurring_interval

Expand All @@ -447,9 +450,20 @@ async def update(
if update_schema.is_archived:
product = await self._archive(session, product)

# `name`, `visibility` and `is_archived` map to NOT NULL columns and are
# already handled above with `is not None` guards. Exclude them here so an
# explicitly-sent `null` (which `exclude_unset` keeps) can't overwrite the
# earlier handling and trigger a NOT NULL violation.
for attr, value in update_schema.model_dump(
exclude_unset=True,
exclude={"prices", "medias", "attached_custom_fields"},
exclude={
"prices",
"medias",
"attached_custom_fields",
"name",
"visibility",
"is_archived",
},
by_alias=True,
).items():
setattr(product, attr, value)
Expand Down
25 changes: 25 additions & 0 deletions server/tests/benefit/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,31 @@ async def test_valid_description_change(

enqueue_benefit_grant_updates_mock.assert_awaited_once()

@pytest.mark.auth(
AuthSubjectFixture(subject="user"),
AuthSubjectFixture(subject="organization"),
)
async def test_description_explicit_null(
self,
session: AsyncSession,
redis: Redis,
benefit_organization: Benefit,
auth_subject: AuthSubject[User | Organization],
user_organization: UserOrganization,
) -> None:
update_schema = BenefitCustomUpdate.model_validate(
{"type": BenefitType.custom, "description": None}
)

with pytest.raises(PolarRequestValidationError):
await benefit_service.update(
session,
redis,
benefit_organization,
update_schema,
auth_subject,
)

@pytest.mark.auth
async def test_slack_shared_channel_update_keeps_integration(
self,
Expand Down
28 changes: 28 additions & 0 deletions server/tests/checkout/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3749,6 +3749,34 @@ async def test_valid_customer_metadata(

assert checkout.customer_metadata == {"key": "value"}

async def test_not_null_fields_explicit_null_ignored(
self,
session: AsyncSession,
checkout_one_time_free: Checkout,
) -> None:
checkout_one_time_free.is_business_customer = True
checkout_one_time_free.allow_discount_codes = False
checkout_one_time_free.require_billing_address = True
checkout_one_time_free.customer_metadata = {"key": "value"}

checkout = await checkout_service.update(
session,
checkout_one_time_free,
CheckoutUpdate.model_validate(
{
"is_business_customer": None,
"allow_discount_codes": None,
"require_billing_address": None,
"customer_metadata": None,
}
),
)

assert checkout.is_business_customer is True
assert checkout.allow_discount_codes is False
assert checkout.require_billing_address is True
assert checkout.customer_metadata == {"key": "value"}

@pytest.mark.parametrize(
"custom_field_data",
[pytest.param({"text": "abc", "select": "c"}, id="invalid select")],
Expand Down
24 changes: 24 additions & 0 deletions server/tests/custom_field/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,27 @@ async def test_slug_of_soft_deleted_field(
await session.flush()

assert updated_field.slug == deleted_field.slug

@pytest.mark.auth
async def test_name_slug_explicit_null_ignored(
self,
session: AsyncSession,
auth_subject: AuthSubject[User],
user_organization: UserOrganization,
text_field: CustomFieldText,
) -> None:
original_name = text_field.name
original_slug = text_field.slug

updated_field = await custom_field_service.update(
session,
text_field,
CustomFieldUpdateText.model_validate(
{"type": text_field.type, "name": None, "slug": None}
),
auth_subject,
)
await session.flush()

assert updated_field.name == original_name
assert updated_field.slug == original_slug
50 changes: 50 additions & 0 deletions server/tests/discount/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,56 @@ async def test_type_change(
auth_subject=auth_subject,
)

@pytest.mark.auth
async def test_type_explicit_null(
self,
auth_subject: AuthSubject[User],
user_organization: UserOrganization,
save_fixture: SaveFixture,
session: AsyncSession,
organization: Organization,
) -> None:
discount = await create_discount(
save_fixture,
type=DiscountType.percentage,
basis_points=1000,
duration=DiscountDuration.once,
organization=organization,
)

with pytest.raises(PolarRequestValidationError):
await discount_service.update(
session,
discount,
discount_update=DiscountUpdate.model_validate({"type": None}),
auth_subject=auth_subject,
)

@pytest.mark.auth
async def test_name_explicit_null(
self,
auth_subject: AuthSubject[User],
user_organization: UserOrganization,
save_fixture: SaveFixture,
session: AsyncSession,
organization: Organization,
) -> None:
discount = await create_discount(
save_fixture,
type=DiscountType.percentage,
basis_points=1000,
duration=DiscountDuration.once,
organization=organization,
)

with pytest.raises(PolarRequestValidationError):
await discount_service.update(
session,
discount,
discount_update=DiscountUpdate.model_validate({"name": None}),
auth_subject=auth_subject,
)

@pytest.mark.parametrize(
("field", "value"),
[
Expand Down
25 changes: 25 additions & 0 deletions server/tests/license_key/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,31 @@ async def test_update_without_status_does_not_enqueue(
assert license_key.limit_activations == 5
enqueue_job_mock.assert_not_called()

async def test_status_explicit_null_ignored(
self,
mocker: MockerFixture,
session: AsyncSession,
redis: Redis,
save_fixture: SaveFixture,
organization: Organization,
product: Product,
customer: Customer,
) -> None:
enqueue_job_mock = mocker.patch("polar.license_key.service.enqueue_job")
license_key, _ = await _license_key_and_grant(
session, redis, save_fixture, customer, organization, product
)
original_status = license_key.status

await license_key_service.update(
session,
license_key=license_key,
updates=LicenseKeyUpdate.model_validate({"status": None}),
)

assert license_key.status == original_status
enqueue_job_mock.assert_not_called()


@pytest.mark.asyncio
class TestRotate:
Expand Down
26 changes: 26 additions & 0 deletions server/tests/meter/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,32 @@ async def test_update_unit(

assert updated_meter.unit == unit

@pytest.mark.auth
async def test_name_unit_explicit_null_ignored(
self,
auth_subject: AuthSubject[User],
user_organization: UserOrganization,
save_fixture: SaveFixture,
session: AsyncSession,
organization: Organization,
) -> None:
meter = await create_meter(
save_fixture,
name="Original Name",
organization=organization,
)
original_unit = meter.unit

updated_meter = await meter_service.update(
session,
meter,
MeterUpdate.model_validate({"name": None, "unit": None}),
auth_subject=auth_subject,
)

assert updated_meter.name == "Original Name"
assert updated_meter.unit == original_unit


@pytest.mark.asyncio
class TestGetQuantities:
Expand Down
Loading
Loading