diff --git a/server/polar/advertisement/endpoints.py b/server/polar/advertisement/endpoints.py index 4a73be79a9..fb73eeb0f1 100644 --- a/server/polar/advertisement/endpoints.py +++ b/server/polar/advertisement/endpoints.py @@ -4,7 +4,7 @@ from pydantic import UUID4 from polar.benefit.schemas import BenefitID -from polar.benefit.service.benefit import benefit as benefit_service +from polar.benefit.service import benefit as benefit_service from polar.exceptions import PolarRequestValidationError, ResourceNotFound from polar.kit.pagination import ListResource, PaginationParamsQuery from polar.kit.sorting import Sorting, SortingGetter diff --git a/server/polar/benefit/benefits/__init__.py b/server/polar/benefit/benefits/__init__.py deleted file mode 100644 index 06e8e057c7..0000000000 --- a/server/polar/benefit/benefits/__init__.py +++ /dev/null @@ -1,52 +0,0 @@ -from typing import Any - -from polar.models import Benefit -from polar.models.benefit import ( - BenefitProperties, - BenefitType, -) -from polar.models.benefit_grant import BenefitGrantPropertiesBase -from polar.postgres import AsyncSession -from polar.redis import Redis - -from .ads import BenefitAdsService -from .base import ( - BenefitActionRequiredError, - BenefitPropertiesValidationError, - BenefitRetriableError, - BenefitServiceError, - BenefitServiceProtocol, -) -from .custom import BenefitCustomService -from .discord import BenefitDiscordService -from .downloadables import BenefitDownloadablesService -from .github_repository import BenefitGitHubRepositoryService -from .license_keys import BenefitLicenseKeysService - -_SERVICE_CLASS_MAP: dict[ - BenefitType, - type[BenefitServiceProtocol[Any, Any, Any]], -] = { - BenefitType.custom: BenefitCustomService, - BenefitType.ads: BenefitAdsService, - BenefitType.discord: BenefitDiscordService, - BenefitType.github_repository: BenefitGitHubRepositoryService, - BenefitType.downloadables: BenefitDownloadablesService, - BenefitType.license_keys: BenefitLicenseKeysService, -} - - -def get_benefit_service( - type: BenefitType, session: AsyncSession, redis: Redis -) -> BenefitServiceProtocol[Benefit, BenefitProperties, BenefitGrantPropertiesBase]: - return _SERVICE_CLASS_MAP[type](session, redis) - - -__all__ = [ - "BenefitActionRequiredError", - "BenefitServiceProtocol", - "BenefitPropertiesValidationError", - "BenefitRetriableError", - "BenefitServiceError", - "get_benefit_service", -] diff --git a/server/polar/benefit/endpoints.py b/server/polar/benefit/endpoints.py index 409fcda7a2..16f59fd381 100644 --- a/server/polar/benefit/endpoints.py +++ b/server/polar/benefit/endpoints.py @@ -15,6 +15,7 @@ from polar.routing import APIRouter from . import auth +from .grant.service import benefit_grant as benefit_grant_service from .schemas import Benefit as BenefitSchema from .schemas import ( BenefitCreate, @@ -23,8 +24,7 @@ BenefitUpdate, benefit_schema_map, ) -from .service.benefit import benefit as benefit_service -from .service.benefit_grant import benefit_grant as benefit_grant_service +from .service import benefit as benefit_service router = APIRouter(prefix="/benefits", tags=["benefits", APITag.documented]) diff --git a/server/polar/benefit/service/__init__.py b/server/polar/benefit/grant/__init__.py similarity index 100% rename from server/polar/benefit/service/__init__.py rename to server/polar/benefit/grant/__init__.py diff --git a/server/tests/benefit/benefits/__init__.py b/server/polar/benefit/grant/schemas.py similarity index 100% rename from server/tests/benefit/benefits/__init__.py rename to server/polar/benefit/grant/schemas.py diff --git a/server/polar/benefit/service/benefit_grant_scope.py b/server/polar/benefit/grant/scope.py similarity index 100% rename from server/polar/benefit/service/benefit_grant_scope.py rename to server/polar/benefit/grant/scope.py diff --git a/server/polar/benefit/service/benefit_grant.py b/server/polar/benefit/grant/service.py similarity index 94% rename from server/polar/benefit/service/benefit_grant.py rename to server/polar/benefit/grant/service.py index a881e7e871..ad3c6ae69c 100644 --- a/server/polar/benefit/service/benefit_grant.py +++ b/server/polar/benefit/grant/service.py @@ -6,9 +6,9 @@ from sqlalchemy import select from sqlalchemy.orm import joinedload -from polar.benefit.benefits import get_benefit_service -from polar.benefit.benefits.base import BenefitActionRequiredError +from polar.benefit.registry import get_benefit_strategy from polar.benefit.schemas import BenefitGrantWebhook +from polar.benefit.strategies.base import BenefitActionRequiredError from polar.customer.service import customer as customer_service from polar.eventstream.service import publish as eventstream_publish from polar.exceptions import PolarError @@ -25,7 +25,7 @@ from polar.webhook.webhooks import WebhookPayloadTypeAdapter from polar.worker import enqueue_job -from .benefit_grant_scope import scope_to_args +from .scope import scope_to_args log: Logger = structlog.get_logger() @@ -149,9 +149,9 @@ async def grant_benefit( return grant previous_properties = grant.properties - benefit_service = get_benefit_service(benefit.type, session, redis) + benefit_strategy = get_benefit_strategy(benefit.type, session, redis) try: - properties = await benefit_service.grant( + properties = await benefit_strategy.grant( benefit, customer, grant.properties, @@ -214,7 +214,7 @@ async def revoke_benefit( previous_properties = grant.properties - benefit_service = get_benefit_service(benefit.type, session, redis) + benefit_strategy = get_benefit_strategy(benefit.type, session, redis) # Call the revoke logic in two cases: # * If the service requires grants to be revoked individually # * If there is only one grant remaining for this benefit, @@ -222,9 +222,9 @@ async def revoke_benefit( other_grants = await self._get_granted_by_benefit_and_customer( session, benefit, customer ) - if benefit_service.should_revoke_individually or len(other_grants) < 2: + if benefit_strategy.should_revoke_individually or len(other_grants) < 2: try: - properties = await benefit_service.revoke( + properties = await benefit_strategy.revoke( benefit, customer, grant.properties, @@ -296,8 +296,8 @@ async def enqueue_benefit_grant_updates( benefit: Benefit, previous_properties: BenefitProperties, ) -> None: - benefit_service = get_benefit_service(benefit.type, session, redis) - if not await benefit_service.requires_update(benefit, previous_properties): + benefit_strategy = get_benefit_strategy(benefit.type, session, redis) + if not await benefit_strategy.requires_update(benefit, previous_properties): return grants = await self._get_granted_by_benefit(session, benefit) @@ -322,9 +322,9 @@ async def update_benefit_grant( assert customer is not None previous_properties = grant.properties - benefit_service = get_benefit_service(benefit.type, session, redis) + benefit_strategy = get_benefit_strategy(benefit.type, session, redis) try: - properties = await benefit_service.grant( + properties = await benefit_strategy.grant( benefit, customer, grant.properties, @@ -374,8 +374,8 @@ async def delete_benefit_grant( assert customer is not None previous_properties = grant.properties - benefit_service = get_benefit_service(benefit.type, session, redis) - properties = await benefit_service.revoke( + benefit_strategy = get_benefit_strategy(benefit.type, session, redis) + properties = await benefit_strategy.revoke( benefit, customer, grant.properties, diff --git a/server/polar/benefit/registry.py b/server/polar/benefit/registry.py new file mode 100644 index 0000000000..738249965c --- /dev/null +++ b/server/polar/benefit/registry.py @@ -0,0 +1,87 @@ +from typing import Any + +from polar.models import Benefit +from polar.models.benefit import ( + BenefitProperties, + BenefitType, +) +from polar.models.benefit_grant import BenefitGrantPropertiesBase +from polar.postgres import AsyncSession +from polar.redis import Redis + +from .strategies.ads.service import BenefitAdsService +from .strategies.base import ( + BenefitActionRequiredError, + BenefitPropertiesValidationError, + BenefitRetriableError, + BenefitServiceError, + BenefitServiceProtocol, +) +from .strategies.custom.service import BenefitCustomService +from .strategies.discord.service import BenefitDiscordService +from .strategies.downloadables.service import BenefitDownloadablesService +from .strategies.github_repository.service import BenefitGitHubRepositoryService +from .strategies.license_keys.service import BenefitLicenseKeysService + +# class Config(NamedTuple): +# service: type[BenefitServiceProtocol[Any, Any, Any]] +# tax: bool +# +# +# class BenefitType(StrEnum): +# custom = "custom" +# ads = "ads" +# discord = "discord" +# github_repository = "github_repository" +# downloadables = "downloadables" +# license_keys = "license_keys" +# +# @classmethod +# @functools.cache +# def mapping(cls) -> dict["BenefitType", Config]: +# return { +# cls.custom: Config(BenefitCustomService, tax=True), +# cls.ads: Config(BenefitAdsService, tax=True), +# cls.discord: Config(BenefitDiscordService, tax=True), +# cls.github_repository: Config(BenefitGitHubRepositoryService, tax=True), +# cls.downloadables: Config(BenefitDownloadablesService, tax=True), +# cls.license_keys: Config(BenefitLicenseKeysService, tax=True), +# } +# +# def is_tax_applicable(self) -> bool: +# return self.mapping()[self].tax +# +# @classmethod +# def get_service( +# cls, type: "BenefitType", session: AsyncSession, redis: Redis +# ) -> BenefitServiceProtocol[Benefit, BenefitProperties, BenefitGrantPropertiesBase]: +# return cls.mapping()[type].service(session, redis) +# + +_STRATEGY_CLASS_MAP: dict[ + BenefitType, + type[BenefitServiceProtocol[Any, Any, Any]], +] = { + BenefitType.custom: BenefitCustomService, + BenefitType.ads: BenefitAdsService, + BenefitType.discord: BenefitDiscordService, + BenefitType.github_repository: BenefitGitHubRepositoryService, + BenefitType.downloadables: BenefitDownloadablesService, + BenefitType.license_keys: BenefitLicenseKeysService, +} + + +def get_benefit_strategy( + type: BenefitType, session: AsyncSession, redis: Redis +) -> BenefitServiceProtocol[Benefit, BenefitProperties, BenefitGrantPropertiesBase]: + return _STRATEGY_CLASS_MAP[type](session, redis) + + +__all__ = [ + "BenefitActionRequiredError", + "BenefitServiceProtocol", + "BenefitPropertiesValidationError", + "BenefitRetriableError", + "BenefitServiceError", + "get_benefit_strategy", +] diff --git a/server/polar/benefit/schemas.py b/server/polar/benefit/schemas.py index a9e8813c8c..274ce68f4f 100644 --- a/server/polar/benefit/schemas.py +++ b/server/polar/benefit/schemas.py @@ -1,34 +1,63 @@ -from datetime import datetime -from typing import Annotated, Any, Literal +from typing import Annotated -from annotated_types import Len from pydantic import ( UUID4, Discriminator, - Field, TypeAdapter, - computed_field, - field_validator, - model_validator, ) -from polar.config import settings from polar.customer.schemas import Customer -from polar.kit import jwt from polar.kit.schemas import ( ClassName, - IDSchema, MergeJSONSchema, - Schema, SelectorWidget, SetSchemaReference, - TimestampedSchema, ) from polar.models.benefit import BenefitType from polar.models.benefit_grant import ( BenefitGrantProperties, ) -from polar.organization.schemas import Organization, OrganizationID + +from .strategies.ads.schemas import ( + BenefitAds, + BenefitAdsCreate, + BenefitAdsSubscriber, + BenefitAdsUpdate, +) +from .strategies.base.schemas import ( + BenefitBase, + BenefitGrantBase, +) +from .strategies.custom.schemas import ( + BenefitCustom, + BenefitCustomCreate, + BenefitCustomSubscriber, + BenefitCustomUpdate, +) +from .strategies.discord.schemas import ( + BenefitDiscord, + BenefitDiscordCreate, + BenefitDiscordSubscriber, + BenefitDiscordUpdate, +) +from .strategies.downloadables.schemas import ( + BenefitDownloadables, + BenefitDownloadablesCreate, + BenefitDownloadablesSubscriber, + BenefitDownloadablesUpdate, +) +from .strategies.github_repository.schemas import ( + BenefitGitHubRepository, + BenefitGitHubRepositoryCreate, + BenefitGitHubRepositorySubscriber, + BenefitGitHubRepositoryUpdate, +) +from .strategies.license_keys.schemas import ( + BenefitLicenseKeys, + BenefitLicenseKeysCreate, + BenefitLicenseKeysSubscriber, + BenefitLicenseKeysUpdate, +) BENEFIT_DESCRIPTION_MIN_LENGTH = 3 BENEFIT_DESCRIPTION_MAX_LENGTH = 42 @@ -39,295 +68,6 @@ SelectorWidget("/v1/benefits", "Benefit", "description"), ] -# BenefitProperties - - -class BenefitProperties(Schema): ... - - -## Custom - -Note = Annotated[ - str | None, - Field( - description=( - "Private note to be shared with customers who have this benefit granted." - ), - ), -] - - -class BenefitCustomProperties(Schema): - """ - Properties for a benefit of type `custom`. - """ - - note: Note | None - - -class BenefitCustomCreateProperties(Schema): - """ - Properties for creating a benefit of type `custom`. - """ - - note: Note | None = None - - -class BenefitCustomSubscriberProperties(Schema): - """ - Properties available to subscribers for a benefit of type `custom`. - """ - - note: Note | None - - -## Ads - - -class BenefitAdsProperties(Schema): - """ - Properties for a benefit of type `ads`. - """ - - image_height: int = Field(400, description="The height of the displayed ad.") - image_width: int = Field(400, description="The width of the displayed ad.") - - -## Discord - - -class BenefitDiscordProperties(Schema): - """ - Properties for a benefit of type `discord`. - """ - - guild_id: str = Field(..., description="The ID of the Discord server.") - role_id: str = Field(..., description="The ID of the Discord role to grant.") - - @computed_field # type: ignore[prop-decorator] - @property - def guild_token(self) -> str: - return jwt.encode( - data={"guild_id": self.guild_id}, - secret=settings.SECRET, - type="discord_guild_token", - ) - - -class BenefitDiscordCreateProperties(Schema): - """ - Properties to create a benefit of type `discord`. - """ - - guild_token: str = Field(serialization_alias="guild_id") - role_id: str = Field(..., description="The ID of the Discord role to grant.") - - @field_validator("guild_token") - @classmethod - def validate_guild_token(cls, v: str) -> str: - try: - guild_token_data = jwt.decode( - token=v, secret=settings.SECRET, type="discord_guild_token" - ) - return guild_token_data["guild_id"] - except (KeyError, jwt.DecodeError, jwt.ExpiredSignatureError) as e: - raise ValueError( - "Invalid token. Please authenticate your Discord server again." - ) from e - - -class BenefitDiscordSubscriberProperties(Schema): - """ - Properties available to subscribers for a benefit of type `discord`. - """ - - guild_id: str = Field(..., description="The ID of the Discord server.") - - -## GitHub Repository - -Permission = Annotated[ - Literal["pull", "triage", "push", "maintain", "admin"], - Field( - description=( - "The permission level to grant. " - "Read more about roles and their permissions on " - "[GitHub documentation](https://docs.github.com/en/organizations/managing-user-access-to-your-organizations-repositories/managing-repository-roles/repository-roles-for-an-organization#permissions-for-each-role)." - ) - ), -] -RepositoryOwner = Annotated[ - str, - Field(description="The owner of the repository.", examples=["polarsource"]), -] -RepositoryName = Annotated[ - str, - Field(description="The name of the repository.", examples=["private_repo"]), -] - - -class BenefitGitHubRepositoryCreateProperties(Schema): - """ - Properties to create a benefit of type `github_repository`. - """ - - repository_owner: str = Field( - description="The owner of the repository.", examples=["polarsource"] - ) - repository_name: str = Field( - description="The name of the repository.", examples=["private_repo"] - ) - permission: Permission - - -class BenefitGitHubRepositoryProperties(Schema): - """ - Properties for a benefit of type `github_repository`. - """ - - repository_owner: RepositoryOwner - repository_name: RepositoryName - permission: Permission - repository_id: UUID4 | None = Field(None, deprecated=True) - - -class BenefitGitHubRepositorySubscriberProperties(Schema): - """ - Properties available to subscribers for a benefit of type `github_repository`. - """ - - repository_owner: RepositoryOwner - repository_name: RepositoryName - - -## Downloads - - -class BenefitDownloadablesCreateProperties(Schema): - archived: dict[UUID4, bool] = {} - files: Annotated[list[UUID4], Len(min_length=1)] - - -class BenefitDownloadablesProperties(Schema): - archived: dict[UUID4, bool] - files: list[UUID4] - - -def get_active_file_ids(properties: BenefitDownloadablesProperties) -> list[UUID4]: - active = [] - archived_files = properties.archived - for file_id in properties.files: - archived = archived_files.get(file_id, False) - if not archived: - active.append(file_id) - - return active - - -class BenefitDownloadablesSubscriberProperties(Schema): - active_files: list[UUID4] - - @model_validator(mode="before") - @classmethod - def assign_active_files(cls, data: dict[str, Any]) -> dict[str, Any]: - if "files" not in data: - return data - - schema = BenefitDownloadablesProperties(**data) - actives = get_active_file_ids(schema) - return dict(active_files=actives) - - -## License Keys - - -class BenefitLicenseKeyExpirationProperties(Schema): - ttl: int = Field(gt=0) - timeframe: Literal["year", "month", "day"] - - -class BenefitLicenseKeyActivationProperties(Schema): - limit: int = Field(gt=0, le=50) - enable_customer_admin: bool - - -class BenefitLicenseKeysCreateProperties(Schema): - prefix: str | None = None - expires: BenefitLicenseKeyExpirationProperties | None = None - activations: BenefitLicenseKeyActivationProperties | None = None - limit_usage: int | None = Field(gt=0, default=None) - - -class BenefitLicenseKeysProperties(Schema): - prefix: str | None - expires: BenefitLicenseKeyExpirationProperties | None - activations: BenefitLicenseKeyActivationProperties | None - limit_usage: int | None - - -class BenefitLicenseKeysSubscriberProperties(Schema): - prefix: str | None - expires: BenefitLicenseKeyExpirationProperties | None - activations: BenefitLicenseKeyActivationProperties | None - limit_usage: int | None - - -# BenefitCreate - - -class BenefitCreateBase(Schema): - type: BenefitType - description: str = Field( - ..., - min_length=BENEFIT_DESCRIPTION_MIN_LENGTH, - max_length=BENEFIT_DESCRIPTION_MAX_LENGTH, - description=( - "The description of the benefit. " - "Will be displayed on products having this benefit." - ), - ) - organization_id: OrganizationID | None = Field( - None, - description=( - "The ID of the organization owning the benefit. " - "**Required unless you use an organization token.**" - ), - ) - - -class BenefitCustomCreate(BenefitCreateBase): - """ - Schema to create a benefit of type `custom`. - """ - - type: Literal[BenefitType.custom] - properties: BenefitCustomCreateProperties - - -class BenefitAdsCreate(BenefitCreateBase): - type: Literal[BenefitType.ads] - properties: BenefitAdsProperties - - -class BenefitDiscordCreate(BenefitCreateBase): - type: Literal[BenefitType.discord] - properties: BenefitDiscordCreateProperties - - -class BenefitGitHubRepositoryCreate(BenefitCreateBase): - type: Literal[BenefitType.github_repository] - properties: BenefitGitHubRepositoryCreateProperties - - -class BenefitDownloadablesCreate(BenefitCreateBase): - type: Literal[BenefitType.downloadables] - properties: BenefitDownloadablesCreateProperties - - -class BenefitLicenseKeysCreate(BenefitCreateBase): - type: Literal[BenefitType.license_keys] - properties: BenefitLicenseKeysCreateProperties - BenefitCreate = Annotated[ BenefitCustomCreate @@ -341,51 +81,6 @@ class BenefitLicenseKeysCreate(BenefitCreateBase): ] -# BenefitUpdate - - -class BenefitUpdateBase(Schema): - description: str | None = Field( - None, - min_length=BENEFIT_DESCRIPTION_MIN_LENGTH, - max_length=BENEFIT_DESCRIPTION_MAX_LENGTH, - description=( - "The description of the benefit. " - "Will be displayed on products having this benefit." - ), - ) - - -class BenefitAdsUpdate(BenefitUpdateBase): - type: Literal[BenefitType.ads] - properties: BenefitAdsProperties | None = None - - -class BenefitCustomUpdate(BenefitUpdateBase): - type: Literal[BenefitType.custom] - properties: BenefitCustomProperties | None = None - - -class BenefitDiscordUpdate(BenefitUpdateBase): - type: Literal[BenefitType.discord] - properties: BenefitDiscordCreateProperties | None = None - - -class BenefitGitHubRepositoryUpdate(BenefitUpdateBase): - type: Literal[BenefitType.github_repository] - properties: BenefitGitHubRepositoryCreateProperties | None = None - - -class BenefitDownloadablesUpdate(BenefitUpdateBase): - type: Literal[BenefitType.downloadables] - properties: BenefitDownloadablesCreateProperties | None = None - - -class BenefitLicenseKeysUpdate(BenefitUpdateBase): - type: Literal[BenefitType.license_keys] - properties: BenefitLicenseKeysCreateProperties | None = None - - BenefitUpdate = ( BenefitAdsUpdate | BenefitCustomUpdate @@ -396,77 +91,6 @@ class BenefitLicenseKeysUpdate(BenefitUpdateBase): ) -# Benefit - - -class BenefitBase(IDSchema, TimestampedSchema): - id: UUID4 = Field(..., description="The ID of the benefit.") - type: BenefitType = Field(..., description="The type of the benefit.") - description: str = Field(..., description="The description of the benefit.") - selectable: bool = Field( - ..., description="Whether the benefit is selectable when creating a product." - ) - deletable: bool = Field(..., description="Whether the benefit is deletable.") - organization_id: UUID4 = Field( - ..., description="The ID of the organization owning the benefit." - ) - - -class BenefitCustom(BenefitBase): - """ - A benefit of type `custom`. - - Use it to grant any kind of benefit that doesn't fit in the other types. - """ - - type: Literal[BenefitType.custom] - properties: BenefitCustomProperties - is_tax_applicable: bool = Field(deprecated=True) - - -class BenefitAds(BenefitBase): - """ - A benefit of type `ads`. - - Use it so your backers can display ads on your README, website, etc. - """ - - type: Literal[BenefitType.ads] - properties: BenefitAdsProperties - - -class BenefitDiscord(BenefitBase): - """ - A benefit of type `discord`. - - Use it to automatically invite your backers to a Discord server. - """ - - type: Literal[BenefitType.discord] - properties: BenefitDiscordProperties - - -class BenefitGitHubRepository(BenefitBase): - """ - A benefit of type `github_repository`. - - Use it to automatically invite your backers to a private GitHub repository. - """ - - type: Literal[BenefitType.github_repository] - properties: BenefitGitHubRepositoryProperties - - -class BenefitDownloadables(BenefitBase): - type: Literal[BenefitType.downloadables] - properties: BenefitDownloadablesProperties - - -class BenefitLicenseKeys(BenefitBase): - type: Literal[BenefitType.license_keys] - properties: BenefitLicenseKeysProperties - - Benefit = Annotated[ BenefitAds | BenefitCustom @@ -489,45 +113,6 @@ class BenefitLicenseKeys(BenefitBase): } -class BenefitGrantBase(IDSchema, TimestampedSchema): - """ - A grant of a benefit to a customer. - """ - - id: UUID4 = Field(description="The ID of the grant.") - granted_at: datetime | None = Field( - None, - description=( - "The timestamp when the benefit was granted. " - "If `None`, the benefit is not granted." - ), - ) - is_granted: bool = Field(description="Whether the benefit is granted.") - revoked_at: datetime | None = Field( - None, - description=( - "The timestamp when the benefit was revoked. " - "If `None`, the benefit is not revoked." - ), - ) - is_revoked: bool = Field(description="Whether the benefit is revoked.") - subscription_id: UUID4 | None = Field( - description="The ID of the subscription that granted this benefit.", - ) - order_id: UUID4 | None = Field( - description="The ID of the order that granted this benefit." - ) - customer_id: UUID4 = Field( - description="The ID of the customer concerned by this grant." - ) - user_id: UUID4 = Field( - validation_alias="customer_id", deprecated="Use `customer_id`." - ) - benefit_id: UUID4 = Field( - description="The ID of the benefit concerned by this grant." - ) - - class BenefitGrant(BenefitGrantBase): customer: Customer properties: BenefitGrantProperties @@ -538,50 +123,6 @@ class BenefitGrantWebhook(BenefitGrant): previous_properties: BenefitGrantProperties | None = None -# BenefitSubscriber - - -class BenefitSubscriberBase(BenefitBase): - organization: Organization - - -class BenefitCustomSubscriber(BenefitSubscriberBase): - type: Literal[BenefitType.custom] - properties: BenefitCustomSubscriberProperties - - -class BenefitGrantAdsSubscriberProperties(Schema): - advertisement_campaign_id: UUID4 | None = Field( - None, - description="The ID of the enabled advertisement campaign for this benefit grant.", - ) - - -class BenefitAdsSubscriber(BenefitSubscriberBase): - type: Literal[BenefitType.ads] - properties: BenefitAdsProperties - - -class BenefitDiscordSubscriber(BenefitSubscriberBase): - type: Literal[BenefitType.discord] - properties: BenefitDiscordSubscriberProperties - - -class BenefitGitHubRepositorySubscriber(BenefitSubscriberBase): - type: Literal[BenefitType.github_repository] - properties: BenefitGitHubRepositorySubscriberProperties - - -class BenefitDownloadablesSubscriber(BenefitSubscriberBase): - type: Literal[BenefitType.downloadables] - properties: BenefitDownloadablesSubscriberProperties - - -class BenefitLicenseKeysSubscriber(BenefitSubscriberBase): - type: Literal[BenefitType.license_keys] - properties: BenefitLicenseKeysSubscriberProperties - - # Properties that are available to subscribers only BenefitSubscriber = Annotated[ BenefitAdsSubscriber diff --git a/server/polar/benefit/service/benefit.py b/server/polar/benefit/service.py similarity index 95% rename from server/polar/benefit/service/benefit.py rename to server/polar/benefit/service.py index ee51641e0f..801b266850 100644 --- a/server/polar/benefit/service/benefit.py +++ b/server/polar/benefit/service.py @@ -28,9 +28,9 @@ from polar.redis import Redis from polar.webhook.service import webhook as webhook_service -from ..benefits import get_benefit_service -from ..schemas import BenefitCreate, BenefitUpdate -from .benefit_grant import benefit_grant as benefit_grant_service +from .grant.service import benefit_grant as benefit_grant_service +from .registry import get_benefit_strategy +from .schemas import BenefitCreate, BenefitUpdate B = TypeVar("B", bound=Benefit) @@ -146,8 +146,8 @@ async def user_create( except AttributeError: is_tax_applicable = create_schema.type.is_tax_applicable() - benefit_service = get_benefit_service(create_schema.type, session, redis) - properties = await benefit_service.validate_properties( + benefit_strategy = get_benefit_strategy(create_schema.type, session, redis) + properties = await benefit_strategy.validate_properties( auth_subject, create_schema.properties.model_dump(mode="json", by_alias=True), ) @@ -197,8 +197,8 @@ async def user_update( properties_update: BaseModel | None = getattr(update_schema, "properties", None) if properties_update is not None: - benefit_service = get_benefit_service(benefit.type, session, redis) - update_dict["properties"] = await benefit_service.validate_properties( + benefit_strategy = get_benefit_strategy(benefit.type, session, redis) + update_dict["properties"] = await benefit_strategy.validate_properties( auth_subject, properties_update.model_dump(mode="json", by_alias=True), ) diff --git a/server/polar/benefit/strategies/__init__.py b/server/polar/benefit/strategies/__init__.py new file mode 100644 index 0000000000..008a2233de --- /dev/null +++ b/server/polar/benefit/strategies/__init__.py @@ -0,0 +1,31 @@ +from .ads.properties import BenefitGrantAdsProperties +from .base import ( + BenefitActionRequiredError, + BenefitPropertiesValidationError, + BenefitRetriableError, + BenefitServiceError, + BenefitServiceProtocol, +) +from .custom.properties import BenefitGrantCustomProperties +from .discord.properties import BenefitGrantDiscordProperties +from .downloadables.properties import BenefitGrantDownloadablesProperties +from .github_repository.properties import BenefitGrantGitHubRepositoryProperties +from .license_keys.properties import BenefitGrantLicenseKeysProperties + +BenefitGrantProperties = ( + BenefitGrantDiscordProperties + | BenefitGrantGitHubRepositoryProperties + | BenefitGrantDownloadablesProperties + | BenefitGrantLicenseKeysProperties + | BenefitGrantAdsProperties + | BenefitGrantCustomProperties +) + +__all__ = [ + "BenefitActionRequiredError", + "BenefitServiceProtocol", + "BenefitPropertiesValidationError", + "BenefitRetriableError", + "BenefitServiceError", + "BenefitGrantProperties", +] diff --git a/server/tests/benefit/service/__init__.py b/server/polar/benefit/strategies/ads/__init__.py similarity index 100% rename from server/tests/benefit/service/__init__.py rename to server/polar/benefit/strategies/ads/__init__.py diff --git a/server/polar/benefit/strategies/ads/properties.py b/server/polar/benefit/strategies/ads/properties.py new file mode 100644 index 0000000000..a50801c971 --- /dev/null +++ b/server/polar/benefit/strategies/ads/properties.py @@ -0,0 +1,10 @@ +from ..base.properties import BenefitGrantProperties, BenefitProperties + + +class BenefitAdsProperties(BenefitProperties): + image_height: int + image_width: int + + +class BenefitGrantAdsProperties(BenefitGrantProperties): + advertisement_campaign_id: str diff --git a/server/polar/benefit/strategies/ads/schemas.py b/server/polar/benefit/strategies/ads/schemas.py new file mode 100644 index 0000000000..627dd98040 --- /dev/null +++ b/server/polar/benefit/strategies/ads/schemas.py @@ -0,0 +1,55 @@ +from typing import Literal + +from pydantic import UUID4, Field + +from polar.kit.schemas import Schema +from polar.models.benefit import BenefitType + +from ..base.schemas import ( + BenefitBase, + BenefitCreateBase, + BenefitSubscriberBase, + BenefitUpdateBase, +) + + +class BenefitAdsProperties(Schema): + """ + Properties for a benefit of type `ads`. + """ + + image_height: int = Field(400, description="The height of the displayed ad.") + image_width: int = Field(400, description="The width of the displayed ad.") + + +class BenefitAdsCreate(BenefitCreateBase): + type: Literal[BenefitType.ads] + properties: BenefitAdsProperties + + +class BenefitAdsUpdate(BenefitUpdateBase): + type: Literal[BenefitType.ads] + properties: BenefitAdsProperties | None = None + + +class BenefitAds(BenefitBase): + """ + A benefit of type `ads`. + + Use it so your backers can display ads on your README, website, etc. + """ + + type: Literal[BenefitType.ads] + properties: BenefitAdsProperties + + +class BenefitGrantAdsSubscriberProperties(Schema): + advertisement_campaign_id: UUID4 | None = Field( + None, + description="The ID of the enabled advertisement campaign for this benefit grant.", + ) + + +class BenefitAdsSubscriber(BenefitSubscriberBase): + type: Literal[BenefitType.ads] + properties: BenefitAdsProperties diff --git a/server/polar/benefit/benefits/ads.py b/server/polar/benefit/strategies/ads/service.py similarity index 97% rename from server/polar/benefit/benefits/ads.py rename to server/polar/benefit/strategies/ads/service.py index 53c57a45ca..72c8e33716 100644 --- a/server/polar/benefit/benefits/ads.py +++ b/server/polar/benefit/strategies/ads/service.py @@ -5,7 +5,7 @@ from polar.models.benefit import BenefitAds, BenefitAdsProperties from polar.models.benefit_grant import BenefitGrantAdsProperties -from .base import BenefitServiceProtocol +from ..base.service import BenefitServiceProtocol class BenefitAdsService( diff --git a/server/polar/benefit/strategies/base/__init__.py b/server/polar/benefit/strategies/base/__init__.py new file mode 100644 index 0000000000..8b234b07af --- /dev/null +++ b/server/polar/benefit/strategies/base/__init__.py @@ -0,0 +1,18 @@ +from .properties import BenefitGrantProperties, BenefitProperties +from .service import ( + BenefitActionRequiredError, + BenefitPropertiesValidationError, + BenefitRetriableError, + BenefitServiceError, + BenefitServiceProtocol, +) + +__all__ = [ + "BenefitActionRequiredError", + "BenefitServiceProtocol", + "BenefitPropertiesValidationError", + "BenefitRetriableError", + "BenefitServiceError", + "BenefitProperties", + "BenefitGrantProperties", +] diff --git a/server/polar/benefit/strategies/base/properties.py b/server/polar/benefit/strategies/base/properties.py new file mode 100644 index 0000000000..9f6da23aed --- /dev/null +++ b/server/polar/benefit/strategies/base/properties.py @@ -0,0 +1,9 @@ +from typing import TypedDict + + +class BenefitProperties(TypedDict): + """Configurable properties for this benefit.""" + + +class BenefitGrantProperties(TypedDict): + """Benefit grant properties.""" diff --git a/server/polar/benefit/strategies/base/schemas.py b/server/polar/benefit/strategies/base/schemas.py new file mode 100644 index 0000000000..f9ec39aa3a --- /dev/null +++ b/server/polar/benefit/strategies/base/schemas.py @@ -0,0 +1,108 @@ +from datetime import datetime + +from pydantic import ( + UUID4, + Field, +) + +from polar.kit.schemas import ( + IDSchema, + Schema, + TimestampedSchema, +) +from polar.models.benefit import BenefitType +from polar.organization.schemas import Organization, OrganizationID + +BENEFIT_DESCRIPTION_MIN_LENGTH = 3 +BENEFIT_DESCRIPTION_MAX_LENGTH = 42 + + +class BenefitProperties(Schema): ... + + +class BenefitCreateBase(Schema): + type: BenefitType + description: str = Field( + ..., + min_length=BENEFIT_DESCRIPTION_MIN_LENGTH, + max_length=BENEFIT_DESCRIPTION_MAX_LENGTH, + description=( + "The description of the benefit. " + "Will be displayed on products having this benefit." + ), + ) + organization_id: OrganizationID | None = Field( + None, + description=( + "The ID of the organization owning the benefit. " + "**Required unless you use an organization token.**" + ), + ) + + +class BenefitUpdateBase(Schema): + description: str | None = Field( + None, + min_length=BENEFIT_DESCRIPTION_MIN_LENGTH, + max_length=BENEFIT_DESCRIPTION_MAX_LENGTH, + description=( + "The description of the benefit. " + "Will be displayed on products having this benefit." + ), + ) + + +class BenefitBase(IDSchema, TimestampedSchema): + id: UUID4 = Field(..., description="The ID of the benefit.") + type: BenefitType = Field(..., description="The type of the benefit.") + description: str = Field(..., description="The description of the benefit.") + selectable: bool = Field( + ..., description="Whether the benefit is selectable when creating a product." + ) + deletable: bool = Field(..., description="Whether the benefit is deletable.") + organization_id: UUID4 = Field( + ..., description="The ID of the organization owning the benefit." + ) + + +class BenefitGrantBase(IDSchema, TimestampedSchema): + """ + A grant of a benefit to a customer. + """ + + id: UUID4 = Field(description="The ID of the grant.") + granted_at: datetime | None = Field( + None, + description=( + "The timestamp when the benefit was granted. " + "If `None`, the benefit is not granted." + ), + ) + is_granted: bool = Field(description="Whether the benefit is granted.") + revoked_at: datetime | None = Field( + None, + description=( + "The timestamp when the benefit was revoked. " + "If `None`, the benefit is not revoked." + ), + ) + is_revoked: bool = Field(description="Whether the benefit is revoked.") + subscription_id: UUID4 | None = Field( + description="The ID of the subscription that granted this benefit.", + ) + order_id: UUID4 | None = Field( + description="The ID of the order that granted this benefit." + ) + customer_id: UUID4 = Field( + description="The ID of the customer concerned by this grant." + ) + user_id: UUID4 = Field( + validation_alias="customer_id", deprecated="Use `customer_id`." + ) + benefit_id: UUID4 = Field( + description="The ID of the benefit concerned by this grant." + ) + + +class BenefitSubscriberBase(BenefitBase): + organization: Organization diff --git a/server/polar/benefit/benefits/base.py b/server/polar/benefit/strategies/base/service.py similarity index 100% rename from server/polar/benefit/benefits/base.py rename to server/polar/benefit/strategies/base/service.py diff --git a/server/polar/benefit/strategies/custom/__init__.py b/server/polar/benefit/strategies/custom/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/server/polar/benefit/strategies/custom/properties.py b/server/polar/benefit/strategies/custom/properties.py new file mode 100644 index 0000000000..814a94285f --- /dev/null +++ b/server/polar/benefit/strategies/custom/properties.py @@ -0,0 +1,8 @@ +from ..base.properties import BenefitGrantProperties, BenefitProperties + + +class BenefitCustomProperties(BenefitProperties): + note: str | None + + +class BenefitGrantCustomProperties(BenefitGrantProperties): ... diff --git a/server/polar/benefit/strategies/custom/schemas.py b/server/polar/benefit/strategies/custom/schemas.py new file mode 100644 index 0000000000..00e2b4813a --- /dev/null +++ b/server/polar/benefit/strategies/custom/schemas.py @@ -0,0 +1,77 @@ +from typing import Annotated, Literal + +from pydantic import Field + +from polar.kit.schemas import Schema +from polar.models.benefit import BenefitType + +from ..base.schemas import ( + BenefitBase, + BenefitCreateBase, + BenefitSubscriberBase, + BenefitUpdateBase, +) + +Note = Annotated[ + str | None, + Field( + description=( + "Private note to be shared with customers who have this benefit granted." + ), + ), +] + + +class BenefitCustomProperties(Schema): + """ + Properties for a benefit of type `custom`. + """ + + note: Note | None + + +class BenefitCustomCreateProperties(Schema): + """ + Properties for creating a benefit of type `custom`. + """ + + note: Note | None = None + + +class BenefitCustomSubscriberProperties(Schema): + """ + Properties available to subscribers for a benefit of type `custom`. + """ + + note: Note | None + + +class BenefitCustomCreate(BenefitCreateBase): + """ + Schema to create a benefit of type `custom`. + """ + + type: Literal[BenefitType.custom] + properties: BenefitCustomCreateProperties + + +class BenefitCustomUpdate(BenefitUpdateBase): + type: Literal[BenefitType.custom] + properties: BenefitCustomProperties | None = None + + +class BenefitCustom(BenefitBase): + """ + A benefit of type `custom`. + + Use it to grant any kind of benefit that doesn't fit in the other types. + """ + + type: Literal[BenefitType.custom] + properties: BenefitCustomProperties + is_tax_applicable: bool = Field(deprecated=True) + + +class BenefitCustomSubscriber(BenefitSubscriberBase): + type: Literal[BenefitType.custom] + properties: BenefitCustomSubscriberProperties diff --git a/server/polar/benefit/benefits/custom.py b/server/polar/benefit/strategies/custom/service.py similarity index 96% rename from server/polar/benefit/benefits/custom.py rename to server/polar/benefit/strategies/custom/service.py index 70e8b09db8..8065bf995c 100644 --- a/server/polar/benefit/benefits/custom.py +++ b/server/polar/benefit/strategies/custom/service.py @@ -5,7 +5,7 @@ from polar.models.benefit import BenefitCustom, BenefitCustomProperties from polar.models.benefit_grant import BenefitGrantCustomProperties -from .base import BenefitServiceProtocol +from ..base.service import BenefitServiceProtocol class BenefitCustomService( diff --git a/server/polar/benefit/strategies/discord/__init__.py b/server/polar/benefit/strategies/discord/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/server/polar/benefit/strategies/discord/properties.py b/server/polar/benefit/strategies/discord/properties.py new file mode 100644 index 0000000000..8415e18fd7 --- /dev/null +++ b/server/polar/benefit/strategies/discord/properties.py @@ -0,0 +1,12 @@ +from ..base.properties import BenefitGrantProperties, BenefitProperties + + +class BenefitDiscordProperties(BenefitProperties): + guild_id: str + role_id: str + + +class BenefitGrantDiscordProperties(BenefitGrantProperties, total=False): + account_id: str + guild_id: str + role_id: str diff --git a/server/polar/benefit/strategies/discord/schemas.py b/server/polar/benefit/strategies/discord/schemas.py new file mode 100644 index 0000000000..eae398337a --- /dev/null +++ b/server/polar/benefit/strategies/discord/schemas.py @@ -0,0 +1,89 @@ +from typing import Literal + +from pydantic import Field, computed_field, field_validator + +from polar.config import settings +from polar.kit import jwt +from polar.kit.schemas import Schema +from polar.models.benefit import BenefitType + +from ..base.schemas import ( + BenefitBase, + BenefitCreateBase, + BenefitSubscriberBase, + BenefitUpdateBase, +) + + +class BenefitDiscordProperties(Schema): + """ + Properties for a benefit of type `discord`. + """ + + guild_id: str = Field(..., description="The ID of the Discord server.") + role_id: str = Field(..., description="The ID of the Discord role to grant.") + + @computed_field # type: ignore[prop-decorator] + @property + def guild_token(self) -> str: + return jwt.encode( + data={"guild_id": self.guild_id}, + secret=settings.SECRET, + type="discord_guild_token", + ) + + +class BenefitDiscordCreateProperties(Schema): + """ + Properties to create a benefit of type `discord`. + """ + + guild_token: str = Field(serialization_alias="guild_id") + role_id: str = Field(..., description="The ID of the Discord role to grant.") + + @field_validator("guild_token") + @classmethod + def validate_guild_token(cls, v: str) -> str: + try: + guild_token_data = jwt.decode( + token=v, secret=settings.SECRET, type="discord_guild_token" + ) + return guild_token_data["guild_id"] + except (KeyError, jwt.DecodeError, jwt.ExpiredSignatureError) as e: + raise ValueError( + "Invalid token. Please authenticate your Discord server again." + ) from e + + +class BenefitDiscordSubscriberProperties(Schema): + """ + Properties available to subscribers for a benefit of type `discord`. + """ + + guild_id: str = Field(..., description="The ID of the Discord server.") + + +class BenefitDiscordCreate(BenefitCreateBase): + type: Literal[BenefitType.discord] + properties: BenefitDiscordCreateProperties + + +class BenefitDiscordUpdate(BenefitUpdateBase): + type: Literal[BenefitType.discord] + properties: BenefitDiscordCreateProperties | None = None + + +class BenefitDiscord(BenefitBase): + """ + A benefit of type `discord`. + + Use it to automatically invite your backers to a Discord server. + """ + + type: Literal[BenefitType.discord] + properties: BenefitDiscordProperties + + +class BenefitDiscordSubscriber(BenefitSubscriberBase): + type: Literal[BenefitType.discord] + properties: BenefitDiscordSubscriberProperties diff --git a/server/polar/benefit/benefits/discord.py b/server/polar/benefit/strategies/discord/service.py similarity index 99% rename from server/polar/benefit/benefits/discord.py rename to server/polar/benefit/strategies/discord/service.py index 77320cabf5..4a54e12bdf 100644 --- a/server/polar/benefit/benefits/discord.py +++ b/server/polar/benefit/strategies/discord/service.py @@ -14,7 +14,7 @@ from polar.models.benefit_grant import BenefitGrantDiscordProperties from polar.models.customer import CustomerOAuthAccount, CustomerOAuthPlatform -from .base import ( +from ..base.service import ( BenefitActionRequiredError, BenefitPropertiesValidationError, BenefitRetriableError, diff --git a/server/polar/benefit/strategies/downloadables/__init__.py b/server/polar/benefit/strategies/downloadables/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/server/polar/benefit/strategies/downloadables/properties.py b/server/polar/benefit/strategies/downloadables/properties.py new file mode 100644 index 0000000000..2d77693929 --- /dev/null +++ b/server/polar/benefit/strategies/downloadables/properties.py @@ -0,0 +1,12 @@ +from uuid import UUID + +from ..base.properties import BenefitGrantProperties, BenefitProperties + + +class BenefitDownloadablesProperties(BenefitProperties): + archived: dict[UUID, bool] + files: list[UUID] + + +class BenefitGrantDownloadablesProperties(BenefitGrantProperties, total=False): + files: list[str] diff --git a/server/polar/benefit/strategies/downloadables/schemas.py b/server/polar/benefit/strategies/downloadables/schemas.py new file mode 100644 index 0000000000..15db6b0efa --- /dev/null +++ b/server/polar/benefit/strategies/downloadables/schemas.py @@ -0,0 +1,69 @@ +from typing import Annotated, Any, Literal + +from annotated_types import Len +from pydantic import UUID4, model_validator + +from polar.kit.schemas import Schema +from polar.models.benefit import BenefitType + +from ..base.schemas import ( + BenefitBase, + BenefitCreateBase, + BenefitSubscriberBase, + BenefitUpdateBase, +) + + +class BenefitDownloadablesCreateProperties(Schema): + archived: dict[UUID4, bool] = {} + files: Annotated[list[UUID4], Len(min_length=1)] + + +class BenefitDownloadablesProperties(Schema): + archived: dict[UUID4, bool] + files: list[UUID4] + + +def get_active_file_ids(properties: BenefitDownloadablesProperties) -> list[UUID4]: + active = [] + archived_files = properties.archived + for file_id in properties.files: + archived = archived_files.get(file_id, False) + if not archived: + active.append(file_id) + + return active + + +class BenefitDownloadablesSubscriberProperties(Schema): + active_files: list[UUID4] + + @model_validator(mode="before") + @classmethod + def assign_active_files(cls, data: dict[str, Any]) -> dict[str, Any]: + if "files" not in data: + return data + + schema = BenefitDownloadablesProperties(**data) + actives = get_active_file_ids(schema) + return dict(active_files=actives) + + +class BenefitDownloadablesCreate(BenefitCreateBase): + type: Literal[BenefitType.downloadables] + properties: BenefitDownloadablesCreateProperties + + +class BenefitDownloadablesUpdate(BenefitUpdateBase): + type: Literal[BenefitType.downloadables] + properties: BenefitDownloadablesCreateProperties | None = None + + +class BenefitDownloadables(BenefitBase): + type: Literal[BenefitType.downloadables] + properties: BenefitDownloadablesProperties + + +class BenefitDownloadablesSubscriber(BenefitSubscriberBase): + type: Literal[BenefitType.downloadables] + properties: BenefitDownloadablesSubscriberProperties diff --git a/server/polar/benefit/benefits/downloadables.py b/server/polar/benefit/strategies/downloadables/service.py similarity index 92% rename from server/polar/benefit/benefits/downloadables.py rename to server/polar/benefit/strategies/downloadables/service.py index 0f1d318ad1..29588f4cb0 100644 --- a/server/polar/benefit/benefits/downloadables.py +++ b/server/polar/benefit/strategies/downloadables/service.py @@ -6,7 +6,6 @@ import structlog from polar.auth.models import AuthSubject -from polar.benefit import schemas as benefit_schemas from polar.customer_portal.service.downloadables import ( downloadable as downloadable_service, ) @@ -15,16 +14,17 @@ from polar.models.benefit import BenefitDownloadables, BenefitDownloadablesProperties from polar.models.benefit_grant import BenefitGrantDownloadablesProperties -from .base import ( +from ..base.service import ( BenefitServiceProtocol, ) +from . import schemas log: Logger = structlog.get_logger() def get_active_file_ids(properties: BenefitDownloadablesProperties) -> list[UUID]: - schema = benefit_schemas.BenefitDownloadablesProperties(**properties) - return benefit_schemas.get_active_file_ids(schema) + schema = schemas.BenefitDownloadablesProperties(**properties) + return schemas.get_active_file_ids(schema) class BenefitDownloadablesService( diff --git a/server/polar/benefit/strategies/github_repository/__init__.py b/server/polar/benefit/strategies/github_repository/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/server/polar/benefit/strategies/github_repository/properties.py b/server/polar/benefit/strategies/github_repository/properties.py new file mode 100644 index 0000000000..b67aa706ca --- /dev/null +++ b/server/polar/benefit/strategies/github_repository/properties.py @@ -0,0 +1,16 @@ +from typing import Literal + +from ..base.properties import BenefitGrantProperties, BenefitProperties + + +class BenefitGitHubRepositoryProperties(BenefitProperties): + repository_owner: str + repository_name: str + permission: Literal["pull", "triage", "push", "maintain", "admin"] + + +class BenefitGrantGitHubRepositoryProperties(BenefitGrantProperties, total=False): + account_id: str + repository_owner: str + repository_name: str + permission: Literal["pull", "triage", "push", "maintain", "admin"] diff --git a/server/polar/benefit/strategies/github_repository/schemas.py b/server/polar/benefit/strategies/github_repository/schemas.py new file mode 100644 index 0000000000..1767d34411 --- /dev/null +++ b/server/polar/benefit/strategies/github_repository/schemas.py @@ -0,0 +1,94 @@ +from typing import Annotated, Literal + +from pydantic import UUID4, Field + +from polar.kit.schemas import Schema +from polar.models.benefit import BenefitType + +from ..base.schemas import ( + BenefitBase, + BenefitCreateBase, + BenefitSubscriberBase, + BenefitUpdateBase, +) + +## GitHub Repository + +Permission = Annotated[ + Literal["pull", "triage", "push", "maintain", "admin"], + Field( + description=( + "The permission level to grant. " + "Read more about roles and their permissions on " + "[GitHub documentation](https://docs.github.com/en/organizations/managing-user-access-to-your-organizations-repositories/managing-repository-roles/repository-roles-for-an-organization#permissions-for-each-role)." + ) + ), +] +RepositoryOwner = Annotated[ + str, + Field(description="The owner of the repository.", examples=["polarsource"]), +] +RepositoryName = Annotated[ + str, + Field(description="The name of the repository.", examples=["private_repo"]), +] + + +class BenefitGitHubRepositoryCreateProperties(Schema): + """ + Properties to create a benefit of type `github_repository`. + """ + + repository_owner: str = Field( + description="The owner of the repository.", examples=["polarsource"] + ) + repository_name: str = Field( + description="The name of the repository.", examples=["private_repo"] + ) + permission: Permission + + +class BenefitGitHubRepositoryProperties(Schema): + """ + Properties for a benefit of type `github_repository`. + """ + + repository_owner: RepositoryOwner + repository_name: RepositoryName + permission: Permission + repository_id: UUID4 | None = Field(None, deprecated=True) + + +class BenefitGitHubRepositorySubscriberProperties(Schema): + """ + Properties available to subscribers for a benefit of type `github_repository`. + """ + + repository_owner: RepositoryOwner + repository_name: RepositoryName + + +class BenefitGitHubRepositoryCreate(BenefitCreateBase): + type: Literal[BenefitType.github_repository] + properties: BenefitGitHubRepositoryCreateProperties + + +class BenefitGitHubRepositoryUpdate(BenefitUpdateBase): + type: Literal[BenefitType.github_repository] + properties: BenefitGitHubRepositoryCreateProperties | None = None + + +class BenefitGitHubRepository(BenefitBase): + """ + A benefit of type `github_repository`. + + Use it to automatically invite your backers to a private GitHub repository. + """ + + type: Literal[BenefitType.github_repository] + properties: BenefitGitHubRepositoryProperties + + +class BenefitGitHubRepositorySubscriber(BenefitSubscriberBase): + type: Literal[BenefitType.github_repository] + properties: BenefitGitHubRepositorySubscriberProperties diff --git a/server/polar/benefit/benefits/github_repository.py b/server/polar/benefit/strategies/github_repository/service.py similarity index 99% rename from server/polar/benefit/benefits/github_repository.py rename to server/polar/benefit/strategies/github_repository/service.py index 4e72c461d5..46973482db 100644 --- a/server/polar/benefit/benefits/github_repository.py +++ b/server/polar/benefit/strategies/github_repository/service.py @@ -20,7 +20,7 @@ from polar.models.customer import CustomerOAuthPlatform from polar.posthog import posthog -from .base import ( +from ..base.service import ( BenefitActionRequiredError, BenefitPropertiesValidationError, BenefitRetriableError, diff --git a/server/polar/benefit/strategies/license_keys/__init__.py b/server/polar/benefit/strategies/license_keys/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/server/polar/benefit/strategies/license_keys/properties.py b/server/polar/benefit/strategies/license_keys/properties.py new file mode 100644 index 0000000000..a3a41a7644 --- /dev/null +++ b/server/polar/benefit/strategies/license_keys/properties.py @@ -0,0 +1,25 @@ +from typing import Literal, TypedDict + +from ..base.properties import BenefitGrantProperties, BenefitProperties + + +class BenefitLicenseKeyExpirationProperties(TypedDict): + ttl: int + timeframe: Literal["year", "month", "day"] + + +class BenefitLicenseKeyActivationProperties(TypedDict): + limit: int + enable_customer_admin: bool + + +class BenefitLicenseKeysProperties(BenefitProperties): + prefix: str | None + expires: BenefitLicenseKeyExpirationProperties | None + activations: BenefitLicenseKeyActivationProperties | None + limit_usage: int | None + + +class BenefitGrantLicenseKeysProperties(BenefitGrantProperties, total=False): + license_key_id: str + display_key: str diff --git a/server/polar/benefit/strategies/license_keys/schemas.py b/server/polar/benefit/strategies/license_keys/schemas.py new file mode 100644 index 0000000000..7398d3cadc --- /dev/null +++ b/server/polar/benefit/strategies/license_keys/schemas.py @@ -0,0 +1,64 @@ +from typing import Literal + +from pydantic import Field + +from polar.kit.schemas import Schema +from polar.models.benefit import BenefitType + +from ..base.schemas import ( + BenefitBase, + BenefitCreateBase, + BenefitSubscriberBase, + BenefitUpdateBase, +) + + +class BenefitLicenseKeyExpirationProperties(Schema): + ttl: int = Field(gt=0) + timeframe: Literal["year", "month", "day"] + + +class BenefitLicenseKeyActivationProperties(Schema): + limit: int = Field(gt=0, le=50) + enable_customer_admin: bool + + +class BenefitLicenseKeysCreateProperties(Schema): + prefix: str | None = None + expires: BenefitLicenseKeyExpirationProperties | None = None + activations: BenefitLicenseKeyActivationProperties | None = None + limit_usage: int | None = Field(gt=0, default=None) + + +class BenefitLicenseKeysProperties(Schema): + prefix: str | None + expires: BenefitLicenseKeyExpirationProperties | None + activations: BenefitLicenseKeyActivationProperties | None + limit_usage: int | None + + +class BenefitLicenseKeysSubscriberProperties(Schema): + prefix: str | None + expires: BenefitLicenseKeyExpirationProperties | None + activations: BenefitLicenseKeyActivationProperties | None + limit_usage: int | None + + +class BenefitLicenseKeysCreate(BenefitCreateBase): + type: Literal[BenefitType.license_keys] + properties: BenefitLicenseKeysCreateProperties + + +class BenefitLicenseKeysUpdate(BenefitUpdateBase): + type: Literal[BenefitType.license_keys] + properties: BenefitLicenseKeysCreateProperties | None = None + + +class BenefitLicenseKeys(BenefitBase): + type: Literal[BenefitType.license_keys] + properties: BenefitLicenseKeysProperties + + +class BenefitLicenseKeysSubscriber(BenefitSubscriberBase): + type: Literal[BenefitType.license_keys] + properties: BenefitLicenseKeysSubscriberProperties diff --git a/server/polar/benefit/benefits/license_keys.py b/server/polar/benefit/strategies/license_keys/service.py similarity index 98% rename from server/polar/benefit/benefits/license_keys.py rename to server/polar/benefit/strategies/license_keys/service.py index a4e59351ce..65e556cc48 100644 --- a/server/polar/benefit/benefits/license_keys.py +++ b/server/polar/benefit/strategies/license_keys/service.py @@ -12,9 +12,7 @@ from polar.models.benefit import BenefitLicenseKeys, BenefitLicenseKeysProperties from polar.models.benefit_grant import BenefitGrantLicenseKeysProperties -from .base import ( - BenefitServiceProtocol, -) +from ..base.service import BenefitServiceProtocol log: Logger = structlog.get_logger() diff --git a/server/polar/benefit/tasks.py b/server/polar/benefit/tasks.py index ccd3f5d757..3ba046d177 100644 --- a/server/polar/benefit/tasks.py +++ b/server/polar/benefit/tasks.py @@ -17,10 +17,10 @@ task, ) -from .benefits import BenefitRetriableError -from .service.benefit import benefit as benefit_service -from .service.benefit_grant import benefit_grant as benefit_grant_service -from .service.benefit_grant_scope import resolve_scope +from .grant.scope import resolve_scope +from .grant.service import benefit_grant as benefit_grant_service +from .service import benefit as benefit_service +from .strategies import BenefitRetriableError log: Logger = structlog.get_logger() diff --git a/server/polar/models/benefit.py b/server/polar/models/benefit.py index 6050faa586..e383f3c846 100644 --- a/server/polar/models/benefit.py +++ b/server/polar/models/benefit.py @@ -1,5 +1,5 @@ from enum import StrEnum -from typing import TYPE_CHECKING, Literal, TypedDict +from typing import TYPE_CHECKING from uuid import UUID from sqlalchemy import Boolean, ForeignKey, String, Text, Uuid @@ -43,52 +43,6 @@ def is_tax_applicable(self) -> bool: raise TaxApplicationMustBeSpecified(self) from e -class BenefitProperties(TypedDict): - """Configurable properties for this benefit.""" - - -class BenefitCustomProperties(BenefitProperties): - note: str | None - - -class BenefitDiscordProperties(BenefitProperties): - guild_id: str - role_id: str - - -class BenefitAdsProperties(BenefitProperties): - image_height: int - image_width: int - - -class BenefitGitHubRepositoryProperties(BenefitProperties): - repository_owner: str - repository_name: str - permission: Literal["pull", "triage", "push", "maintain", "admin"] - - -class BenefitDownloadablesProperties(BenefitProperties): - archived: dict[UUID, bool] - files: list[UUID] - - -class BenefitLicenseKeyExpirationProperties(TypedDict): - ttl: int - timeframe: Literal["year", "month", "day"] - - -class BenefitLicenseKeyActivationProperties(TypedDict): - limit: int - enable_customer_admin: bool - - -class BenefitLicenseKeysProperties(BenefitProperties): - prefix: str | None - expires: BenefitLicenseKeyExpirationProperties | None - activations: BenefitLicenseKeyActivationProperties | None - limit_usage: int | None - - class Benefit(RecordModel): __tablename__ = "benefits" diff --git a/server/polar/models/benefit_grant.py b/server/polar/models/benefit_grant.py index ac0e76951d..81e4ec621f 100644 --- a/server/polar/models/benefit_grant.py +++ b/server/polar/models/benefit_grant.py @@ -1,5 +1,5 @@ from datetime import UTC, datetime -from typing import TYPE_CHECKING, Any, Literal, TypedDict +from typing import TYPE_CHECKING, Any, TypedDict from uuid import UUID from sqlalchemy import ( @@ -23,6 +23,7 @@ relationship, ) +from polar.benefit.strategies import BenefitGrantProperties from polar.kit.db.models import RecordModel if TYPE_CHECKING: @@ -68,49 +69,6 @@ def __eq__(self, other: Any) -> ColumnElement[bool]: # type: ignore[override] return and_(*clauses) -class BenefitGrantPropertiesBase(TypedDict): - """Benefit grant properties.""" - - -class BenefitGrantCustomProperties(BenefitGrantPropertiesBase): ... - - -class BenefitGrantAdsProperties(BenefitGrantPropertiesBase): - advertisement_campaign_id: str - - -class BenefitGrantDiscordProperties(BenefitGrantPropertiesBase, total=False): - account_id: str - guild_id: str - role_id: str - - -class BenefitGrantGitHubRepositoryProperties(BenefitGrantPropertiesBase, total=False): - account_id: str - repository_owner: str - repository_name: str - permission: Literal["pull", "triage", "push", "maintain", "admin"] - - -class BenefitGrantDownloadablesProperties(BenefitGrantPropertiesBase, total=False): - files: list[str] - - -class BenefitGrantLicenseKeysProperties(BenefitGrantPropertiesBase, total=False): - license_key_id: str - display_key: str - - -BenefitGrantProperties = ( - BenefitGrantDiscordProperties - | BenefitGrantGitHubRepositoryProperties - | BenefitGrantDownloadablesProperties - | BenefitGrantLicenseKeysProperties - | BenefitGrantAdsProperties - | BenefitGrantCustomProperties -) - - class BenefitGrant(RecordModel): __tablename__ = "benefit_grants" __table_args__ = ( diff --git a/server/polar/product/service/product.py b/server/polar/product/service/product.py index 3d39f30815..923035b0b0 100644 --- a/server/polar/product/service/product.py +++ b/server/polar/product/service/product.py @@ -8,7 +8,7 @@ from polar.auth.models import AuthSubject, is_organization, is_user from polar.authz.service import AccessType, Authz -from polar.benefit.service.benefit import benefit as benefit_service +from polar.benefit.service import benefit as benefit_service from polar.custom_field.service import custom_field as custom_field_service from polar.exceptions import NotPermitted, PolarError, PolarRequestValidationError from polar.file.service import file as file_service @@ -664,8 +664,10 @@ async def _send_webhook( self, session: AsyncSession, product: Product, - event_type: Literal[WebhookEventType.product_created] - | Literal[WebhookEventType.product_updated], + event_type: ( + Literal[WebhookEventType.product_created] + | Literal[WebhookEventType.product_updated] + ), ) -> None: # mypy 1.9 is does not allow us to do # event = (event_type, subscription) diff --git a/server/polar/refund/service.py b/server/polar/refund/service.py index 7dc68d50d9..2b2e173ebc 100644 --- a/server/polar/refund/service.py +++ b/server/polar/refund/service.py @@ -8,7 +8,7 @@ from sqlalchemy.dialects import postgresql from polar.auth.models import AuthSubject, is_organization, is_user -from polar.benefit.service.benefit_grant import benefit_grant as benefit_grant_service +from polar.benefit.grant.service import benefit_grant as benefit_grant_service from polar.customer.service import customer as customer_service from polar.exceptions import PolarError, PolarRequestValidationError, ResourceNotFound from polar.integrations.stripe.service import stripe as stripe_service diff --git a/server/tests/benefit/grant/__init__.py b/server/tests/benefit/grant/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/server/tests/benefit/service/test_benefit_grant.py b/server/tests/benefit/grant/test_service.py similarity index 84% rename from server/tests/benefit/service/test_benefit_grant.py rename to server/tests/benefit/grant/test_service.py index 74f9f358ec..58bf5afcf5 100644 --- a/server/tests/benefit/service/test_benefit_grant.py +++ b/server/tests/benefit/grant/test_service.py @@ -4,8 +4,8 @@ import pytest from pytest_mock import MockerFixture -from polar.benefit.benefits import BenefitActionRequiredError, BenefitServiceProtocol -from polar.benefit.service.benefit_grant import benefit_grant as benefit_grant_service +from polar.benefit.grant.service import benefit_grant as benefit_grant_service +from polar.benefit.strategies import BenefitActionRequiredError, BenefitServiceProtocol from polar.models import Benefit, BenefitGrant, Customer, Product, Subscription from polar.postgres import AsyncSession from polar.redis import Redis @@ -19,14 +19,14 @@ @pytest.fixture(autouse=True) -def benefit_service_mock(mocker: MockerFixture) -> MagicMock: - service_mock = MagicMock(spec=BenefitServiceProtocol) - service_mock.should_revoke_individually = False - service_mock.grant.return_value = {} - service_mock.revoke.return_value = {} - mock = mocker.patch("polar.benefit.service.benefit_grant.get_benefit_service") - mock.return_value = service_mock - return service_mock +def benefit_strategy_mock(mocker: MockerFixture) -> MagicMock: + strategy_mock = MagicMock(spec=BenefitServiceProtocol) + strategy_mock.should_revoke_individually = False + strategy_mock.grant.return_value = {} + strategy_mock.revoke.return_value = {} + mock = mocker.patch("polar.benefit.grant.service.get_benefit_strategy") + mock.return_value = strategy_mock + return strategy_mock @pytest.mark.asyncio @@ -38,9 +38,9 @@ async def test_not_existing_grant( subscription: Subscription, customer: Customer, benefit_organization: Benefit, - benefit_service_mock: MagicMock, + benefit_strategy_mock: MagicMock, ) -> None: - benefit_service_mock.grant.return_value = {"external_id": "abc"} + benefit_strategy_mock.grant.return_value = {"external_id": "abc"} grant = await benefit_grant_service.grant_benefit( session, redis, customer, benefit_organization, subscription=subscription @@ -51,7 +51,7 @@ async def test_not_existing_grant( assert grant.benefit_id == benefit_organization.id assert grant.is_granted assert grant.properties == {"external_id": "abc"} - benefit_service_mock.grant.assert_called_once() + benefit_strategy_mock.grant.assert_called_once() async def test_existing_grant_not_granted( self, @@ -61,7 +61,7 @@ async def test_existing_grant_not_granted( subscription: Subscription, customer: Customer, benefit_organization: Benefit, - benefit_service_mock: MagicMock, + benefit_strategy_mock: MagicMock, ) -> None: grant = BenefitGrant( subscription=subscription, customer=customer, benefit=benefit_organization @@ -74,7 +74,7 @@ async def test_existing_grant_not_granted( assert updated_grant.id == grant.id assert updated_grant.is_granted - benefit_service_mock.grant.assert_called_once() + benefit_strategy_mock.grant.assert_called_once() async def test_existing_grant_already_granted( self, @@ -84,7 +84,7 @@ async def test_existing_grant_already_granted( subscription: Subscription, customer: Customer, benefit_organization: Benefit, - benefit_service_mock: MagicMock, + benefit_strategy_mock: MagicMock, ) -> None: grant = BenefitGrant( subscription=subscription, customer=customer, benefit=benefit_organization @@ -98,7 +98,7 @@ async def test_existing_grant_already_granted( assert updated_grant.id == grant.id assert updated_grant.is_granted - benefit_service_mock.grant.assert_not_called() + benefit_strategy_mock.grant.assert_not_called() async def test_action_required_error( self, @@ -107,9 +107,9 @@ async def test_action_required_error( subscription: Subscription, customer: Customer, benefit_organization: Benefit, - benefit_service_mock: MagicMock, + benefit_strategy_mock: MagicMock, ) -> None: - benefit_service_mock.grant.side_effect = BenefitActionRequiredError("Error") + benefit_strategy_mock.grant.side_effect = BenefitActionRequiredError("Error") grant = await benefit_grant_service.grant_benefit( session, redis, customer, benefit_organization, subscription=subscription @@ -124,9 +124,9 @@ async def test_default_properties_value( subscription: Subscription, customer: Customer, benefit_organization: Benefit, - benefit_service_mock: MagicMock, + benefit_strategy_mock: MagicMock, ) -> None: - benefit_service_mock.grant.side_effect = ( + benefit_strategy_mock.grant.side_effect = ( lambda customer, benefit, properties, **kwargs: properties ) @@ -146,7 +146,7 @@ async def test_not_existing_grant( subscription: Subscription, customer: Customer, benefit_organization: Benefit, - benefit_service_mock: MagicMock, + benefit_strategy_mock: MagicMock, ) -> None: grant = await benefit_grant_service.revoke_benefit( session, redis, customer, benefit_organization, subscription=subscription @@ -155,7 +155,7 @@ async def test_not_existing_grant( assert grant.subscription_id == subscription.id assert grant.benefit_id == benefit_organization.id assert grant.is_revoked - benefit_service_mock.revoke.assert_called_once() + benefit_strategy_mock.revoke.assert_called_once() async def test_existing_grant_not_revoked( self, @@ -165,9 +165,9 @@ async def test_existing_grant_not_revoked( subscription: Subscription, customer: Customer, benefit_organization: Benefit, - benefit_service_mock: MagicMock, + benefit_strategy_mock: MagicMock, ) -> None: - benefit_service_mock.revoke.return_value = {"message": "ok"} + benefit_strategy_mock.revoke.return_value = {"message": "ok"} grant = BenefitGrant( subscription=subscription, @@ -184,7 +184,7 @@ async def test_existing_grant_not_revoked( assert updated_grant.id == grant.id assert updated_grant.is_revoked assert updated_grant.properties == {"message": "ok"} - benefit_service_mock.revoke.assert_called_once() + benefit_strategy_mock.revoke.assert_called_once() async def test_existing_grant_already_revoked( self, @@ -194,7 +194,7 @@ async def test_existing_grant_already_revoked( subscription: Subscription, customer: Customer, benefit_organization: Benefit, - benefit_service_mock: MagicMock, + benefit_strategy_mock: MagicMock, ) -> None: grant = BenefitGrant( subscription=subscription, @@ -210,7 +210,7 @@ async def test_existing_grant_already_revoked( assert updated_grant.id == grant.id assert updated_grant.is_revoked - benefit_service_mock.revoke.assert_not_called() + benefit_strategy_mock.revoke.assert_not_called() async def test_several_benefit_grants( self, @@ -220,7 +220,7 @@ async def test_several_benefit_grants( subscription: Subscription, customer: Customer, benefit_organization: Benefit, - benefit_service_mock: MagicMock, + benefit_strategy_mock: MagicMock, product: Product, ) -> None: first_grant = await create_benefit_grant( @@ -247,7 +247,7 @@ async def test_several_benefit_grants( assert updated_grant.id == first_grant.id assert updated_grant.is_revoked - benefit_service_mock.revoke.assert_not_called() + benefit_strategy_mock.revoke.assert_not_called() async def test_several_benefit_grants_should_individual_revoke( self, @@ -257,11 +257,11 @@ async def test_several_benefit_grants_should_individual_revoke( subscription: Subscription, customer: Customer, benefit_organization: Benefit, - benefit_service_mock: MagicMock, + benefit_strategy_mock: MagicMock, product: Product, ) -> None: - benefit_service_mock.should_revoke_individually = True - benefit_service_mock.revoke.return_value = {"message": "ok"} + benefit_strategy_mock.should_revoke_individually = True + benefit_strategy_mock.revoke.return_value = {"message": "ok"} first_grant = await create_benefit_grant( save_fixture, customer, benefit_organization, subscription=subscription @@ -288,7 +288,7 @@ async def test_several_benefit_grants_should_individual_revoke( assert updated_grant.id == first_grant.id assert updated_grant.is_revoked assert updated_grant.properties == {"message": "ok"} - benefit_service_mock.revoke.assert_called_once() + benefit_strategy_mock.revoke.assert_called_once() async def test_action_required_error( self, @@ -298,9 +298,9 @@ async def test_action_required_error( subscription: Subscription, customer: Customer, benefit_organization: Benefit, - benefit_service_mock: MagicMock, + benefit_strategy_mock: MagicMock, ) -> None: - benefit_service_mock.revoke.side_effect = BenefitActionRequiredError("Error") + benefit_strategy_mock.revoke.side_effect = BenefitActionRequiredError("Error") grant = BenefitGrant( subscription=subscription, @@ -316,7 +316,7 @@ async def test_action_required_error( assert updated_grant.id == grant.id assert updated_grant.is_revoked - benefit_service_mock.revoke.assert_called_once() + benefit_strategy_mock.revoke.assert_called_once() @pytest.mark.asyncio @@ -333,9 +333,7 @@ async def test_subscription_scope( customer: Customer, subscription: Subscription, ) -> None: - enqueue_job_mock = mocker.patch( - "polar.benefit.service.benefit_grant.enqueue_job" - ) + enqueue_job_mock = mocker.patch("polar.benefit.grant.service.enqueue_job") product = await set_product_benefits( save_fixture, product=product, benefits=benefits @@ -367,9 +365,7 @@ async def test_outdated_grants( subscription: Subscription, customer: Customer, ) -> None: - enqueue_job_mock = mocker.patch( - "polar.benefit.service.benefit_grant.enqueue_job" - ) + enqueue_job_mock = mocker.patch("polar.benefit.grant.service.enqueue_job") grant = BenefitGrant( subscription=subscription, customer=customer, benefit=benefits[0] @@ -401,12 +397,10 @@ async def test_not_required_update( session: AsyncSession, redis: Redis, benefit_organization: Benefit, - benefit_service_mock: MagicMock, + benefit_strategy_mock: MagicMock, ) -> None: - enqueue_job_mock = mocker.patch( - "polar.benefit.service.benefit_grant.enqueue_job" - ) - benefit_service_mock.requires_update.return_value = False + enqueue_job_mock = mocker.patch("polar.benefit.grant.service.enqueue_job") + benefit_strategy_mock.requires_update.return_value = False await benefit_grant_service.enqueue_benefit_grant_updates( session, redis, benefit_organization, {} @@ -424,7 +418,7 @@ async def test_required_update_granted( customer: Customer, benefit_organization: Benefit, benefit_organization_second: Benefit, - benefit_service_mock: MagicMock, + benefit_strategy_mock: MagicMock, ) -> None: granted_grant = BenefitGrant( subscription=subscription, @@ -442,10 +436,8 @@ async def test_required_update_granted( other_benefit_grant.set_granted() await save_fixture(other_benefit_grant) - enqueue_job_mock = mocker.patch( - "polar.benefit.service.benefit_grant.enqueue_job" - ) - benefit_service_mock.requires_update.return_value = True + enqueue_job_mock = mocker.patch("polar.benefit.grant.service.enqueue_job") + benefit_strategy_mock.requires_update.return_value = True await benefit_grant_service.enqueue_benefit_grant_updates( session, redis, benefit_organization, {} @@ -466,7 +458,7 @@ async def test_required_update_revoked( customer: Customer, benefit_organization: Benefit, benefit_organization_second: Benefit, - benefit_service_mock: MagicMock, + benefit_strategy_mock: MagicMock, ) -> None: revoked_grant = BenefitGrant( subscription=subscription, customer=customer, benefit=benefit_organization @@ -482,10 +474,8 @@ async def test_required_update_revoked( other_benefit_grant.set_granted() await save_fixture(other_benefit_grant) - enqueue_job_mock = mocker.patch( - "polar.benefit.service.benefit_grant.enqueue_job" - ) - benefit_service_mock.requires_update.return_value = True + enqueue_job_mock = mocker.patch("polar.benefit.grant.service.enqueue_job") + benefit_strategy_mock.requires_update.return_value = True await benefit_grant_service.enqueue_benefit_grant_updates( session, redis, benefit_organization, {} @@ -504,7 +494,7 @@ async def test_revoked_grant( subscription: Subscription, customer: Customer, benefit_organization: Benefit, - benefit_service_mock: MagicMock, + benefit_strategy_mock: MagicMock, ) -> None: grant = BenefitGrant( subscription=subscription, customer=customer, benefit=benefit_organization @@ -517,7 +507,7 @@ async def test_revoked_grant( ) assert updated_grant.id == grant.id - benefit_service_mock.grant.assert_not_called() + benefit_strategy_mock.grant.assert_not_called() async def test_granted_grant( self, @@ -527,9 +517,9 @@ async def test_granted_grant( subscription: Subscription, customer: Customer, benefit_organization: Benefit, - benefit_service_mock: MagicMock, + benefit_strategy_mock: MagicMock, ) -> None: - benefit_service_mock.grant.return_value = {"external_id": "xyz"} + benefit_strategy_mock.grant.return_value = {"external_id": "xyz"} grant = BenefitGrant( subscription=subscription, @@ -551,8 +541,8 @@ async def test_granted_grant( assert updated_grant.id == grant.id assert updated_grant.is_granted assert updated_grant.properties == {"external_id": "xyz"} - benefit_service_mock.grant.assert_called_once() - assert benefit_service_mock.grant.call_args[1]["update"] is True + benefit_strategy_mock.grant.assert_called_once() + assert benefit_strategy_mock.grant.call_args[1]["update"] is True async def test_TODO_error( self, @@ -562,7 +552,7 @@ async def test_TODO_error( subscription: Subscription, customer: Customer, benefit_organization: Benefit, - benefit_service_mock: MagicMock, + benefit_strategy_mock: MagicMock, ) -> None: grant = BenefitGrant( subscription=subscription, customer=customer, benefit=benefit_organization @@ -570,7 +560,7 @@ async def test_TODO_error( grant.set_granted() await save_fixture(grant) - benefit_service_mock.grant.side_effect = BenefitActionRequiredError("Error") + benefit_strategy_mock.grant.side_effect = BenefitActionRequiredError("Error") # load grant_loaded = await benefit_grant_service.get(session, grant.id, loaded=True) @@ -609,9 +599,7 @@ async def test_valid( other_benefit_grant.set_granted() await save_fixture(other_benefit_grant) - enqueue_job_mock = mocker.patch( - "polar.benefit.service.benefit_grant.enqueue_job" - ) + enqueue_job_mock = mocker.patch("polar.benefit.grant.service.enqueue_job") await benefit_grant_service.enqueue_benefit_grant_deletions( session, benefit_organization @@ -632,7 +620,7 @@ async def test_revoked_grant( subscription: Subscription, customer: Customer, benefit_organization: Benefit, - benefit_service_mock: MagicMock, + benefit_strategy_mock: MagicMock, ) -> None: grant = BenefitGrant( subscription=subscription, customer=customer, benefit=benefit_organization @@ -645,7 +633,7 @@ async def test_revoked_grant( ) assert updated_grant.id == grant.id - benefit_service_mock.revoke.assert_not_called() + benefit_strategy_mock.revoke.assert_not_called() async def test_granted_grant( self, @@ -655,7 +643,7 @@ async def test_granted_grant( subscription: Subscription, customer: Customer, benefit_organization: Benefit, - benefit_service_mock: MagicMock, + benefit_strategy_mock: MagicMock, ) -> None: grant = BenefitGrant( subscription=subscription, customer=customer, benefit=benefit_organization @@ -673,7 +661,7 @@ async def test_granted_grant( assert updated_grant.id == grant.id assert updated_grant.is_revoked - benefit_service_mock.revoke.assert_called_once() + benefit_strategy_mock.revoke.assert_called_once() @pytest.mark.asyncio diff --git a/server/tests/benefit/strategies/__init__.py b/server/tests/benefit/strategies/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/server/tests/benefit/benefits/test_ads.py b/server/tests/benefit/strategies/test_ads.py similarity index 94% rename from server/tests/benefit/benefits/test_ads.py rename to server/tests/benefit/strategies/test_ads.py index 044a8f1d12..796b4eb73f 100644 --- a/server/tests/benefit/benefits/test_ads.py +++ b/server/tests/benefit/strategies/test_ads.py @@ -2,7 +2,7 @@ import pytest -from polar.benefit.benefits.ads import BenefitAdsService +from polar.benefit.strategies.ads.service import BenefitAdsService from polar.models import BenefitGrant, Customer, Organization from polar.models.benefit import BenefitAds, BenefitType from polar.models.benefit_grant import BenefitGrantAdsProperties diff --git a/server/tests/benefit/benefits/test_downloadables.py b/server/tests/benefit/strategies/test_downloadables.py similarity index 99% rename from server/tests/benefit/benefits/test_downloadables.py rename to server/tests/benefit/strategies/test_downloadables.py index a042a074e7..06c65dc463 100644 --- a/server/tests/benefit/benefits/test_downloadables.py +++ b/server/tests/benefit/strategies/test_downloadables.py @@ -2,7 +2,9 @@ import pytest -from polar.benefit.schemas import BenefitDownloadablesCreateProperties +from polar.benefit.strategies.downloadables.schemas import ( + BenefitDownloadablesCreateProperties, +) from polar.file.schemas import FileRead from polar.models import Customer, Downloadable, Organization, Product from polar.models.downloadable import DownloadableStatus diff --git a/server/tests/benefit/service/test_benefit.py b/server/tests/benefit/test_service.py similarity index 97% rename from server/tests/benefit/service/test_benefit.py rename to server/tests/benefit/test_service.py index 79e4f2ff16..ff9f3e4f6e 100644 --- a/server/tests/benefit/service/test_benefit.py +++ b/server/tests/benefit/test_service.py @@ -6,20 +6,20 @@ from polar.auth.models import AuthSubject from polar.authz.service import Authz -from polar.benefit.benefits import ( +from polar.benefit.grant.service import BenefitGrantService +from polar.benefit.service import benefit as benefit_service +from polar.benefit.service import ( # type: ignore[attr-defined] + benefit_grant_service, +) +from polar.benefit.strategies import ( BenefitPropertiesValidationError, BenefitServiceProtocol, ) -from polar.benefit.schemas import ( +from polar.benefit.strategies.custom.schemas import ( BenefitCustomCreate, BenefitCustomCreateProperties, BenefitCustomUpdate, ) -from polar.benefit.service.benefit import benefit as benefit_service -from polar.benefit.service.benefit import ( # type: ignore[attr-defined] - benefit_grant_service, -) -from polar.benefit.service.benefit_grant import BenefitGrantService from polar.exceptions import NotPermitted, PolarRequestValidationError from polar.kit.pagination import PaginationParams from polar.models import Benefit, Organization, User, UserOrganization @@ -342,7 +342,7 @@ async def test_invalid_properties( } ] ) - mock = mocker.patch("polar.benefit.service.benefit.get_benefit_service") + mock = mocker.patch("polar.benefit.service.get_benefit_strategy") mock.return_value = service_mock create_schema = BenefitCustomCreate( diff --git a/server/tests/benefit/test_tasks.py b/server/tests/benefit/test_tasks.py index a063c66c69..2fc93b50fe 100644 --- a/server/tests/benefit/test_tasks.py +++ b/server/tests/benefit/test_tasks.py @@ -4,10 +4,8 @@ from arq import Retry from pytest_mock import MockerFixture -from polar.benefit.benefits import BenefitRetriableError -from polar.benefit.service.benefit_grant import ( - BenefitGrantService, -) +from polar.benefit.grant.service import BenefitGrantService +from polar.benefit.strategies import BenefitRetriableError from polar.benefit.tasks import ( # type: ignore[attr-defined] BenefitDoesNotExist, BenefitGrantDoesNotExist, diff --git a/server/tests/customer_portal/endpoints/test_downloadables.py b/server/tests/customer_portal/endpoints/test_downloadables.py index 0785ca06bf..b60d62cbc5 100644 --- a/server/tests/customer_portal/endpoints/test_downloadables.py +++ b/server/tests/customer_portal/endpoints/test_downloadables.py @@ -7,7 +7,9 @@ from freezegun import freeze_time from httpx import AsyncClient -from polar.benefit.schemas import BenefitDownloadablesCreateProperties +from polar.benefit.strategies.downloadables.schemas import ( + BenefitDownloadablesCreateProperties, +) from polar.customer_portal.schemas.downloadables import DownloadableRead from polar.models import Customer, File, Organization, Product from polar.postgres import AsyncSession, sql diff --git a/server/tests/customer_portal/endpoints/test_license_keys.py b/server/tests/customer_portal/endpoints/test_license_keys.py index fc2ecb20d3..93fc5f25ad 100644 --- a/server/tests/customer_portal/endpoints/test_license_keys.py +++ b/server/tests/customer_portal/endpoints/test_license_keys.py @@ -5,7 +5,7 @@ from freezegun import freeze_time from httpx import AsyncClient -from polar.benefit.schemas import ( +from polar.benefit.strategies.license_keys.schemas import ( BenefitLicenseKeyActivationProperties, BenefitLicenseKeyExpirationProperties, BenefitLicenseKeysCreateProperties, diff --git a/server/tests/fixtures/downloadable.py b/server/tests/fixtures/downloadable.py index 9e2fce90fe..d27b7eb374 100644 --- a/server/tests/fixtures/downloadable.py +++ b/server/tests/fixtures/downloadable.py @@ -3,8 +3,10 @@ from sqlalchemy.orm import contains_eager -from polar.benefit.benefits.downloadables import BenefitDownloadablesService -from polar.benefit.schemas import BenefitDownloadablesCreateProperties +from polar.benefit.strategies.downloadables.schemas import ( + BenefitDownloadablesCreateProperties, +) +from polar.benefit.strategies.downloadables.service import BenefitDownloadablesService from polar.models import Benefit, Customer, Downloadable, File, Organization, Product from polar.models.benefit import BenefitDownloadables, BenefitType from polar.models.benefit_grant import BenefitGrantDownloadablesProperties diff --git a/server/tests/fixtures/license_key.py b/server/tests/fixtures/license_key.py index cb0139296b..e2481b4061 100644 --- a/server/tests/fixtures/license_key.py +++ b/server/tests/fixtures/license_key.py @@ -1,8 +1,10 @@ from collections.abc import Sequence from typing import cast -from polar.benefit.benefits.license_keys import BenefitLicenseKeysService -from polar.benefit.schemas import BenefitLicenseKeysCreateProperties +from polar.benefit.strategies.license_keys.schemas import ( + BenefitLicenseKeysCreateProperties, +) +from polar.benefit.strategies.license_keys.service import BenefitLicenseKeysService from polar.models import Benefit, Customer, LicenseKey, Organization, Product from polar.models.benefit import BenefitLicenseKeys, BenefitType from polar.models.benefit_grant import BenefitGrantLicenseKeysProperties diff --git a/server/tests/license_key/test_endpoints.py b/server/tests/license_key/test_endpoints.py index 88004c436d..4cd951deaf 100644 --- a/server/tests/license_key/test_endpoints.py +++ b/server/tests/license_key/test_endpoints.py @@ -5,7 +5,7 @@ from httpx import AsyncClient from polar.auth.models import AuthSubject -from polar.benefit.schemas import ( +from polar.benefit.strategies.license_keys.schemas import ( BenefitLicenseKeyActivationProperties, BenefitLicenseKeysCreateProperties, ) diff --git a/server/tests/refunds/test_endpoints.py b/server/tests/refunds/test_endpoints.py index f02a4874ce..af7f38e2d6 100644 --- a/server/tests/refunds/test_endpoints.py +++ b/server/tests/refunds/test_endpoints.py @@ -5,7 +5,7 @@ from pytest_mock import MockerFixture from polar.auth.scope import Scope -from polar.benefit.service.benefit_grant import benefit_grant as benefit_grant_service +from polar.benefit.grant.service import benefit_grant as benefit_grant_service from polar.integrations.stripe.service import StripeService from polar.kit.utils import generate_uuid from polar.models import (