diff --git a/backend/app/services/native_ingest_common.py b/backend/app/services/native_ingest_common.py index e0c9bda..7731163 100644 --- a/backend/app/services/native_ingest_common.py +++ b/backend/app/services/native_ingest_common.py @@ -211,6 +211,59 @@ class PostRecordOutcome: body_chars: int +# -- membership roster seam (shared dataclass, #387 C2/C7) ----------------- + +@dataclass +class Membership: + """One membership the ACCOUNT holds, as the roster needs it (#387 C2). + + Lives HERE rather than in the platform module that first produced it, for + the same reason `PostRecordOutcome` does: it is the seam's contract, not + Patreon's. C7 moved it — while it sat in `patreon_client` a second platform + would have had to import its contract from the first platform's module, + which inverts the dependency and is how a "portable" seam quietly becomes + Patreon-shaped. + + Deliberately not a raw upstream row: the sweep should not have to know that + a tier lives behind a JSON:API `reward` relationship, and + `platform_membership` should not gain columns because one platform shapes + things a certain way. + + `status` carries the PLATFORM's own word, verbatim and unmapped + (`active_patron`, `former_patron`, ...). Deciding what it means is the read + site's job — `membership_roster.has_paid_access` — precisely so an + unrecognised word records as evidence rather than as a decision. + + `is_free_member` is SEPARATE from status and must stay that way. Patreon + expresses a free follow as this boolean rather than as a status value, so + "does the account pay for this" is `status == "active_patron" and not + is_free_member` — a question the status string alone cannot answer. NOTE: + the C0 capture contains no ACTIVE free member, so the two fields are + perfectly correlated in that sample; the separation is what the schema + says, not something the sample proves. + + A platform that lacks a field supplies the empty answer, never a guess: + no tiers -> `[]`, no pledge -> `amount_cents=None` (absent stays + distinguishable from zero — "free" and "we don't know" are different + answers), no vanity -> None and identity falls back to the URL tail. + """ + + campaign_id: str + display_name: str | None + url: str | None + vanity: str | None + status: str | None + is_free_member: bool + tier_names: list[str] + amount_cents: int | None + currency: str | None + # Everything the roster did not model, kept so a later question can be + # answered without another authenticated round-trip. Scoped to the + # membership's own attributes plus the creator's — never the raw page, + # which is where the card/address resources live. + details: dict + + # -- base downloader (shared fetch/validate plumbing) ---------------------- class BaseNativeDownloader: diff --git a/backend/app/services/patreon_client.py b/backend/app/services/patreon_client.py index 7d291d9..11bb7da 100644 --- a/backend/app/services/patreon_client.py +++ b/backend/app/services/patreon_client.py @@ -53,6 +53,7 @@ from ..utils.paths import filehash_from_url from ..utils.prosemirror import post_body_html from .native_ingest_common import ( _MAX_429_RETRIES, + Membership, NativeAuthError, NativeDriftError, NativeIngestError, @@ -163,44 +164,6 @@ class MediaItem: post_id: str -@dataclass -class Membership: - """One membership the ACCOUNT holds, as the roster needs it (#387 C2). - - Deliberately not a raw JSON:API row: the sweep (C3) should not have to know - that a tier lives behind a `reward` relationship, and `platform_membership` - should not gain columns because Patreon shapes things a certain way. - - `status` carries the PLATFORM's own word, verbatim and unmapped - (`active_patron`, `former_patron`, ...). Deciding what it means is the read - site's job — `membership_roster.has_paid_access` — precisely so an - unrecognised word records as evidence rather than as a decision. - - `is_free_member` is SEPARATE from status and must stay that way. The - capture shows Patreon expressing a free follow as this boolean rather than - as a status value, so "does the account pay for this" is - `status == "active_patron" and not is_free_member` — a question the status - string alone cannot answer. NOTE: the capture contains no ACTIVE free - member, so the two fields are perfectly correlated in that sample; the - separation is what the schema says, not something the sample proves. - """ - - campaign_id: str - display_name: str | None - url: str | None - vanity: str | None - status: str | None - is_free_member: bool - tier_names: list[str] - amount_cents: int | None - currency: str | None - # Everything the roster did not model, kept so a later question can be - # answered without another authenticated round-trip. Scoped to the member's - # own attributes plus the campaign's — never the raw page, which is where - # the card/address resources live. - details: dict - - def _filehash(url: str) -> str | None: # Delegate to the shared extractor (utils.paths) so capture-time persistence # and render-time inline-image matching use the EXACT same identity. diff --git a/tests/test_patreon_memberships.py b/tests/test_patreon_memberships.py index be2e44f..9612c5f 100644 --- a/tests/test_patreon_memberships.py +++ b/tests/test_patreon_memberships.py @@ -18,8 +18,8 @@ from pathlib import Path import pytest +from backend.app.services.native_ingest_common import Membership from backend.app.services.patreon_client import ( - Membership, PatreonClient, PatreonDriftError, )