What you pay for, what Discord drops, and pixiv switched off #251
@@ -49,7 +49,7 @@ from .membership_roster import (
|
|||||||
get_sync_state,
|
get_sync_state,
|
||||||
has_paid_access,
|
has_paid_access,
|
||||||
identity_keys_for_source,
|
identity_keys_for_source,
|
||||||
match_kind,
|
pair_sources_with_memberships,
|
||||||
roster_is_fresh,
|
roster_is_fresh,
|
||||||
url_tail,
|
url_tail,
|
||||||
)
|
)
|
||||||
@@ -113,20 +113,12 @@ async def reconcile(
|
|||||||
.where(Source.platform == platform)
|
.where(Source.platform == platform)
|
||||||
)).all()
|
)).all()
|
||||||
|
|
||||||
# Nested loop rather than a SQL join: the match is a predicate over a JSON
|
# The join itself lives in `membership_roster` beside `match_kind`, so C5's
|
||||||
# blob and a derived URL handle, neither of which is indexable, and both
|
# gated-reason annotation pairs sources with memberships by exactly the same
|
||||||
# sides are tens of rows on any real library. Keeping it in Python means ONE
|
# rule this card sorts them by. Two copies would let the Subscriptions row
|
||||||
# definition of identity (`match_kind`) instead of a second one in SQL that
|
# and this card disagree about which creator a source IS.
|
||||||
# could drift from it.
|
pairs = pair_sources_with_memberships([s for s, _a in rows], memberships)
|
||||||
pairs: dict[int, tuple[PlatformMembership, str]] = {}
|
matched_membership_ids = {m.id for m, _kind in pairs.values()}
|
||||||
matched_membership_ids: set[int] = set()
|
|
||||||
for source, _artist in rows:
|
|
||||||
for m in memberships:
|
|
||||||
kind = match_kind(source, m)
|
|
||||||
if kind:
|
|
||||||
pairs[source.id] = (m, kind)
|
|
||||||
matched_membership_ids.add(m.id)
|
|
||||||
break
|
|
||||||
|
|
||||||
subscribed_not_tracked = []
|
subscribed_not_tracked = []
|
||||||
for m in memberships:
|
for m in memberships:
|
||||||
|
|||||||
@@ -352,6 +352,127 @@ def match_kind(source: Source, membership: PlatformMembership) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def pair_sources_with_memberships(
|
||||||
|
sources: list[Source], memberships: list[PlatformMembership],
|
||||||
|
) -> dict[int, tuple[PlatformMembership, str]]:
|
||||||
|
"""Source id -> the membership it is the same creator as, and how we know.
|
||||||
|
|
||||||
|
Extracted from C4's reconcile loop when C5 became its second caller. It is
|
||||||
|
a nested loop rather than a SQL join because the match is a predicate over
|
||||||
|
a JSON blob and a derived URL handle, neither of which is indexable, and
|
||||||
|
both sides are tens of rows on any real library. Keeping it in Python means
|
||||||
|
ONE definition of identity (`match_kind`) instead of a second one in SQL
|
||||||
|
that could drift from it.
|
||||||
|
|
||||||
|
First match wins, which is `match_kind`'s ordering doing its job: a source
|
||||||
|
with a cached campaign id can only pair with the membership holding that
|
||||||
|
id, so an ambiguous handle never outvotes it.
|
||||||
|
"""
|
||||||
|
pairs: dict[int, tuple[PlatformMembership, str]] = {}
|
||||||
|
for source in sources:
|
||||||
|
for m in memberships:
|
||||||
|
kind = match_kind(source, m)
|
||||||
|
if kind:
|
||||||
|
pairs[source.id] = (m, kind)
|
||||||
|
break
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Why the posts are invisible (#387 C5)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# A3 made a tier-gated source say "47 posts you can't see". These are the words
|
||||||
|
# the roster is allowed to add to that count — and ONLY to that count.
|
||||||
|
#
|
||||||
|
# THE LINE: the roster ANNOTATES the gated flag, it never produces it.
|
||||||
|
# `current_user_can_view` (read per post by `patreon_client.post_is_gated`) is
|
||||||
|
# the authoritative per-post signal, and entitled-tier data cannot stand in for
|
||||||
|
# it — a creator can gate a post behind an access rule that maps onto no tier
|
||||||
|
# name at all. So nothing here may suppress a download, skip a walk, or decide
|
||||||
|
# a post is inaccessible. It explains a skip that ALREADY happened. Getting
|
||||||
|
# that backwards would make FC silently stop fetching content the operator is
|
||||||
|
# paying for, which is the worst failure available in this milestone.
|
||||||
|
# `test_no_fetch_path_can_read_the_roster` pins that structurally.
|
||||||
|
GATED_LAPSED = "lapsed" # the membership ended — resubscribe, or disable
|
||||||
|
GATED_TIER = "tier" # paying, but this tier doesn't reach these posts
|
||||||
|
GATED_FREE = "free" # a current FREE follow — nobody is paying for access
|
||||||
|
|
||||||
|
|
||||||
|
def gated_reason(
|
||||||
|
platform: str, status: str | None, *, is_free_member: bool = False,
|
||||||
|
) -> str | None:
|
||||||
|
"""Why a tier-gated source's posts are out of reach, if the roster knows.
|
||||||
|
|
||||||
|
None means "no words beyond the count" and is the answer for every case
|
||||||
|
where the roster is not evidence: a status this code has not been taught,
|
||||||
|
and (at the call site) a campaign absent from the roster or a roster too
|
||||||
|
stale to trust. Absence is not evidence — the same discipline as
|
||||||
|
`test_post_is_gated_only_on_explicit_false`.
|
||||||
|
|
||||||
|
`is_free_member` is read AFTER the status axis, not folded into it, which
|
||||||
|
is why `has_paid_access` is called here with it forced off. The two axes
|
||||||
|
are independent in Patreon's payload, and collapsing them loses a real
|
||||||
|
distinction: a current free follower has not lost anything, so telling them
|
||||||
|
"you're not a patron any more" would be a false sentence about a state they
|
||||||
|
were never in.
|
||||||
|
"""
|
||||||
|
by_status = has_paid_access(platform, status, is_free_member=False)
|
||||||
|
if by_status is None:
|
||||||
|
return None
|
||||||
|
if not by_status:
|
||||||
|
return GATED_LAPSED
|
||||||
|
return GATED_FREE if is_free_member else GATED_TIER
|
||||||
|
|
||||||
|
|
||||||
|
async def gated_reasons_for_sources(
|
||||||
|
session: AsyncSession, sources: list[Source], *, now: datetime | None = None,
|
||||||
|
) -> dict[int, str]:
|
||||||
|
"""The reason word for each of these sources, where the roster has one.
|
||||||
|
|
||||||
|
Callers pass ONLY the sources already known to be tier-gated: the question
|
||||||
|
"why can't I see these posts" is meaningless for a source whose posts are
|
||||||
|
all visible, and asking it anyway would put roster data on rows that have
|
||||||
|
no gated state for it to annotate.
|
||||||
|
|
||||||
|
Sources with no entry in the result get A3's bare count, which is the
|
||||||
|
correct degraded rendering for all three of: platform never swept, roster
|
||||||
|
stale, campaign not in the roster.
|
||||||
|
"""
|
||||||
|
if not sources:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
platforms = {s.platform for s in sources}
|
||||||
|
# Per platform, because freshness is per platform: a working Patreon sweep
|
||||||
|
# must not lend its credibility to a SubscribeStar roster that has never
|
||||||
|
# run. Same gate as C4's `tracked_not_subscribed`, for the same reason.
|
||||||
|
fresh = {
|
||||||
|
p for p in platforms
|
||||||
|
if roster_is_fresh(await get_sync_state(session, p), now=now)
|
||||||
|
}
|
||||||
|
if not fresh:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
memberships = (await session.execute(
|
||||||
|
select(PlatformMembership).where(
|
||||||
|
PlatformMembership.platform.in_(sorted(fresh))
|
||||||
|
)
|
||||||
|
)).scalars().all()
|
||||||
|
pairs = pair_sources_with_memberships(
|
||||||
|
[s for s in sources if s.platform in fresh], memberships,
|
||||||
|
)
|
||||||
|
|
||||||
|
reasons: dict[int, str] = {}
|
||||||
|
for source_id, (m, _kind) in pairs.items():
|
||||||
|
reason = gated_reason(
|
||||||
|
m.platform, m.status,
|
||||||
|
is_free_member=bool((m.details or {}).get("is_free_member")),
|
||||||
|
)
|
||||||
|
if reason is not None:
|
||||||
|
reasons[source_id] = reason
|
||||||
|
return reasons
|
||||||
|
|
||||||
|
|
||||||
async def source_for_membership(
|
async def source_for_membership(
|
||||||
session: AsyncSession, membership: PlatformMembership,
|
session: AsyncSession, membership: PlatformMembership,
|
||||||
) -> Source | None:
|
) -> Source | None:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from ..models import (
|
|||||||
)
|
)
|
||||||
from .db_helpers import failing_sources_clause
|
from .db_helpers import failing_sources_clause
|
||||||
from .gallery_dl import ErrorType
|
from .gallery_dl import ErrorType
|
||||||
|
from .membership_roster import gated_reasons_for_sources
|
||||||
from .platforms import known_platform_keys
|
from .platforms import known_platform_keys
|
||||||
from .scheduler_service import compute_next_check_at
|
from .scheduler_service import compute_next_check_at
|
||||||
|
|
||||||
@@ -92,6 +93,12 @@ class SourceRecord:
|
|||||||
# source, so it is joined in by `list()` only — None everywhere else, which
|
# source, so it is joined in by `list()` only — None everywhere else, which
|
||||||
# the UI renders as the bare no-access state with no fabricated number.
|
# the UI renders as the bare no-access state with no fabricated number.
|
||||||
tier_gated_count: int | None = None
|
tier_gated_count: int | None = None
|
||||||
|
# Milestone #387 C5: WHY those posts are out of reach, when the learned
|
||||||
|
# roster can say — "lapsed" / "tier" / "free", or None for "no words beyond
|
||||||
|
# the count". Joined in by `list()` beside the count, and for the same
|
||||||
|
# reason: it annotates a gated state rather than producing one. Nothing on
|
||||||
|
# a fetch path may read it (see `membership_roster.gated_reason`).
|
||||||
|
gated_reason: str | None = None
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -116,6 +123,7 @@ class SourceRecord:
|
|||||||
"backfill_recapture": self.backfill_recapture,
|
"backfill_recapture": self.backfill_recapture,
|
||||||
"backfill_posts": self.backfill_posts,
|
"backfill_posts": self.backfill_posts,
|
||||||
"tier_gated_count": self.tier_gated_count,
|
"tier_gated_count": self.tier_gated_count,
|
||||||
|
"gated_reason": self.gated_reason,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -243,6 +251,7 @@ class SourceService:
|
|||||||
def _build_record(
|
def _build_record(
|
||||||
self, source: Source, artist: Artist, settings: ImportSettings,
|
self, source: Source, artist: Artist, settings: ImportSettings,
|
||||||
gated_counts: dict[int, int] | None = None,
|
gated_counts: dict[int, int] | None = None,
|
||||||
|
gated_reasons: dict[int, str] | None = None,
|
||||||
) -> SourceRecord:
|
) -> SourceRecord:
|
||||||
nxt = compute_next_check_at(source, artist, settings)
|
nxt = compute_next_check_at(source, artist, settings)
|
||||||
co = source.config_overrides or {}
|
co = source.config_overrides or {}
|
||||||
@@ -268,6 +277,7 @@ class SourceService:
|
|||||||
backfill_recapture=bool(co.get("_backfill_recapture")),
|
backfill_recapture=bool(co.get("_backfill_recapture")),
|
||||||
backfill_posts=int(co.get("_backfill_posts", 0)),
|
backfill_posts=int(co.get("_backfill_posts", 0)),
|
||||||
tier_gated_count=(gated_counts or {}).get(source.id),
|
tier_gated_count=(gated_counts or {}).get(source.id),
|
||||||
|
gated_reason=(gated_reasons or {}).get(source.id),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _row_to_record(self, source: Source) -> SourceRecord:
|
async def _row_to_record(self, source: Source) -> SourceRecord:
|
||||||
@@ -302,12 +312,15 @@ class SourceService:
|
|||||||
stmt = stmt.order_by(Artist.name.asc(), Source.id.asc())
|
stmt = stmt.order_by(Artist.name.asc(), Source.id.asc())
|
||||||
rows = (await self.session.execute(stmt)).all()
|
rows = (await self.session.execute(stmt)).all()
|
||||||
settings = await self._load_settings()
|
settings = await self._load_settings()
|
||||||
# Only tier-gated rows need the join — on a healthy library that is an
|
# Only tier-gated rows need either join — on a healthy library that is
|
||||||
# empty list and _tier_gated_counts short-circuits without a query.
|
# an empty list and both helpers short-circuit without a query.
|
||||||
gated_counts = await self._tier_gated_counts(
|
gated = [s for s, _a in rows if s.error_type == ErrorType.TIER_LIMITED]
|
||||||
[s.id for s, _a in rows if s.error_type == ErrorType.TIER_LIMITED]
|
gated_counts = await self._tier_gated_counts([s.id for s in gated])
|
||||||
)
|
gated_reasons = await gated_reasons_for_sources(self.session, gated)
|
||||||
return [self._build_record(s, a, settings, gated_counts) for s, a in rows]
|
return [
|
||||||
|
self._build_record(s, a, settings, gated_counts, gated_reasons)
|
||||||
|
for s, a in rows
|
||||||
|
]
|
||||||
|
|
||||||
async def get(self, source_id: int) -> SourceRecord | None:
|
async def get(self, source_id: int) -> SourceRecord | None:
|
||||||
source = (await self.session.execute(
|
source = (await self.session.execute(
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
<div>Last checked: {{ lastCheckedText }}</div>
|
<div>Last checked: {{ lastCheckedText }}</div>
|
||||||
<div v-if="nextCheckText">Next check: {{ nextCheckText }}</div>
|
<div v-if="nextCheckText">Next check: {{ nextCheckText }}</div>
|
||||||
<div v-if="noAccess" class="fc-health-tip__gated">{{ noAccessText }}</div>
|
<div v-if="noAccess" class="fc-health-tip__gated">{{ noAccessText }}</div>
|
||||||
|
<div v-if="noAccess && noAccessReason" class="fc-health-tip__why">
|
||||||
|
{{ noAccessReason }}
|
||||||
|
</div>
|
||||||
<div v-if="(source.consecutive_failures || 0) > 0">
|
<div v-if="(source.consecutive_failures || 0) > 0">
|
||||||
Failures: {{ source.consecutive_failures }}
|
Failures: {{ source.consecutive_failures }}
|
||||||
</div>
|
</div>
|
||||||
@@ -53,6 +56,19 @@ const noAccessText = computed(() => {
|
|||||||
: "Some posts are behind a tier you don't hold"
|
: "Some posts are behind a tier you don't hold"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// #387 C5: the learned roster's explanation for the line above, when it has
|
||||||
|
// one. The backend sends null for every case where the roster is not evidence —
|
||||||
|
// campaign absent, roster stale, never swept, status not yet characterised — so
|
||||||
|
// there is deliberately NO fallback sentence here. A default would turn "we
|
||||||
|
// don't know why" into a reason, which is the one thing this step must not do.
|
||||||
|
const GATED_REASONS = {
|
||||||
|
lapsed: "You're not a patron any more — resubscribe, or disable this source.",
|
||||||
|
tier: "Your tier doesn't cover these posts — upgrade, or leave them be.",
|
||||||
|
free: "You follow this creator for free — these posts are for paying patrons.",
|
||||||
|
}
|
||||||
|
|
||||||
|
const noAccessReason = computed(() => GATED_REASONS[props.source.gated_reason] || null)
|
||||||
|
|
||||||
const ariaLabel = computed(() => `source health: ${level.value}`)
|
const ariaLabel = computed(() => `source health: ${level.value}`)
|
||||||
|
|
||||||
const lastCheckedText = computed(() => formatRelative(props.source.last_checked_at))
|
const lastCheckedText = computed(() => formatRelative(props.source.last_checked_at))
|
||||||
@@ -92,6 +108,12 @@ const truncatedError = computed(() => {
|
|||||||
.fc-health-tip__gated {
|
.fc-health-tip__gated {
|
||||||
color: rgb(var(--v-theme-info, 33 150 243));
|
color: rgb(var(--v-theme-info, 33 150 243));
|
||||||
}
|
}
|
||||||
|
/* The reason is subordinate to the count it explains: same block, quieter, so
|
||||||
|
a tooltip that has one does not read as two separate findings. */
|
||||||
|
.fc-health-tip__why {
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
max-width: 24rem;
|
||||||
|
}
|
||||||
.fc-health-tip__err {
|
.fc-health-tip__err {
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
color: rgb(var(--v-theme-error, 244 67 54));
|
color: rgb(var(--v-theme-error, 244 67 54));
|
||||||
|
|||||||
@@ -56,6 +56,69 @@ describe('SourceHealthDot', () => {
|
|||||||
expect(w.text()).toContain("tier you don't hold")
|
expect(w.text()).toContain("tier you don't hold")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Milestone #387 C5. The count says WHAT; these say WHY — a much stronger
|
||||||
|
// claim, so the cases that must stay silent are pinned alongside the ones
|
||||||
|
// that speak.
|
||||||
|
|
||||||
|
function gated (extra) {
|
||||||
|
return mountComponent(SourceHealthDot, {
|
||||||
|
stubs: { VTooltip: VTooltipStub },
|
||||||
|
props: {
|
||||||
|
source: {
|
||||||
|
...checked, consecutive_failures: 0,
|
||||||
|
error_type: 'tier_limited', tier_gated_count: 47, ...extra,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('a lapsed membership says the subscription ended', () => {
|
||||||
|
expect(gated({ gated_reason: 'lapsed' }).text()).toContain('not a patron any more')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('an active membership says the tier is the limit, not the subscription', () => {
|
||||||
|
const text = gated({ gated_reason: 'tier' }).text()
|
||||||
|
expect(text).toContain("tier doesn't cover these posts")
|
||||||
|
expect(text).not.toContain('not a patron any more')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a free follow is not described as a lapsed subscription', () => {
|
||||||
|
const text = gated({ gated_reason: 'free' }).text()
|
||||||
|
expect(text).toContain('follow this creator for free')
|
||||||
|
expect(text).not.toContain('not a patron any more')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('no reason means the count stands alone, with no invented explanation', () => {
|
||||||
|
// null covers all four not-evidence cases the backend collapses into it:
|
||||||
|
// campaign absent, roster stale, never swept, status uncharacterised.
|
||||||
|
const text = gated({ gated_reason: null }).text()
|
||||||
|
expect(text).toContain('47 posts')
|
||||||
|
expect(text).not.toContain('patron')
|
||||||
|
expect(text).not.toContain('tier doesn')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a reason the frontend has not been taught renders nothing', () => {
|
||||||
|
// The backend's status vocabulary grows per platform (D1). An unknown word
|
||||||
|
// must degrade to the bare count, never to `undefined` in the tooltip.
|
||||||
|
const text = gated({ gated_reason: 'some_future_word' }).text()
|
||||||
|
expect(text).toContain('47 posts')
|
||||||
|
expect(text).not.toContain('undefined')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a reason never appears on a source that is not gated', () => {
|
||||||
|
const w = mountComponent(SourceHealthDot, {
|
||||||
|
stubs: { VTooltip: VTooltipStub },
|
||||||
|
props: {
|
||||||
|
source: {
|
||||||
|
...checked, consecutive_failures: 0,
|
||||||
|
error_type: null, gated_reason: 'lapsed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// The roster annotates a gated state; it never asserts one on its own.
|
||||||
|
expect(w.text()).not.toContain('not a patron any more')
|
||||||
|
})
|
||||||
|
|
||||||
it('a genuinely failing source still grades as a failure, gated or not', () => {
|
it('a genuinely failing source still grades as a failure, gated or not', () => {
|
||||||
const w = mountComponent(SourceHealthDot, {
|
const w = mountComponent(SourceHealthDot, {
|
||||||
stubs: { VTooltip: VTooltipStub },
|
stubs: { VTooltip: VTooltipStub },
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Row builders for the learned membership roster (#387 phase C).
|
||||||
|
|
||||||
|
Three test files were each constructing `PlatformMembership` and
|
||||||
|
`MembershipSync` rows with their own private helper — C4's reconcile tests,
|
||||||
|
E4's suggestion tests, and C5's gated-reason tests — and the three had already
|
||||||
|
started to drift apart in which fields they defaulted. That matters more here
|
||||||
|
than for ordinary test plumbing: every one of these tests turns on the exact
|
||||||
|
shape of a membership row (a `details["campaign"]["vanity"]` that the identity
|
||||||
|
join reads, an `is_free_member` flag that changes what the operator is told),
|
||||||
|
so three builders means three slightly different ideas of what a membership
|
||||||
|
looks like, and a test that passes against a row the sweep would never write.
|
||||||
|
|
||||||
|
`campaign=` rather than the column's own `external_campaign_id=`: it is what
|
||||||
|
the majority of call sites already say, and the full name earns nothing in a
|
||||||
|
builder whose only subject is memberships.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from backend.app.models import MembershipSync, PlatformMembership
|
||||||
|
|
||||||
|
# The shape a real Patreon sweep writes, per the C0 capture (Scribe note
|
||||||
|
# #3886): a vanity nested under `details.campaign`, which is where
|
||||||
|
# `PlatformMembership.vanity_or_none` reads it from.
|
||||||
|
DEFAULT_VANITY = "maewix"
|
||||||
|
DEFAULT_URL = f"https://www.patreon.com/{DEFAULT_VANITY}"
|
||||||
|
|
||||||
|
|
||||||
|
async def membership(
|
||||||
|
db, *, campaign="c1", platform="patreon", status="active_patron",
|
||||||
|
display_name="Maewix", url=DEFAULT_URL, details=None, **kw,
|
||||||
|
) -> PlatformMembership:
|
||||||
|
"""One observed membership.
|
||||||
|
|
||||||
|
`details` defaults to the vanity-bearing shape rather than to `{}`, because
|
||||||
|
a row with no vanity cannot be matched by handle and would quietly make
|
||||||
|
every identity test a campaign-id test.
|
||||||
|
"""
|
||||||
|
m = PlatformMembership(
|
||||||
|
platform=platform,
|
||||||
|
external_campaign_id=campaign,
|
||||||
|
status=status,
|
||||||
|
display_name=display_name,
|
||||||
|
url=url,
|
||||||
|
details={"campaign": {"vanity": DEFAULT_VANITY}} if details is None else details,
|
||||||
|
**kw,
|
||||||
|
)
|
||||||
|
db.add(m)
|
||||||
|
await db.flush()
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
async def synced(db, *, platform="patreon", ago=timedelta(hours=1)) -> MembershipSync:
|
||||||
|
"""A successful sweep this recently — what makes a roster FRESH.
|
||||||
|
|
||||||
|
Pass `ago` past `ROSTER_STALE_AFTER` to build the stale case; omit the call
|
||||||
|
entirely for never-synced. Those are three different states and every
|
||||||
|
consumer of the roster has to tell them apart.
|
||||||
|
"""
|
||||||
|
state = MembershipSync(platform=platform, last_success_at=datetime.now(UTC) - ago)
|
||||||
|
db.add(state)
|
||||||
|
await db.flush()
|
||||||
|
return state
|
||||||
@@ -31,6 +31,7 @@ from backend.app.services.artist_membership_service import (
|
|||||||
name_signal,
|
name_signal,
|
||||||
weighted_score,
|
weighted_score,
|
||||||
)
|
)
|
||||||
|
from tests.roster_builders import membership as _membership
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
@@ -126,17 +127,6 @@ async def _artist_with_discord(db, name, slug):
|
|||||||
return a
|
return a
|
||||||
|
|
||||||
|
|
||||||
async def _membership(db, **kw):
|
|
||||||
kw.setdefault("platform", "patreon")
|
|
||||||
kw.setdefault("external_campaign_id", "c1")
|
|
||||||
kw.setdefault("url", "https://www.patreon.com/maewix")
|
|
||||||
kw.setdefault("details", {"campaign": {"vanity": "maewix"}})
|
|
||||||
m = PlatformMembership(**kw)
|
|
||||||
db.add(m)
|
|
||||||
await db.flush()
|
|
||||||
return m
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_a_matching_name_proposes_the_link(db):
|
async def test_a_matching_name_proposes_the_link(db):
|
||||||
artist = await _artist_with_discord(db, "Maewix", "maewix")
|
artist = await _artist_with_discord(db, "Maewix", "maewix")
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
"""Milestone 387 C5: why the posts are invisible, in the roster's own words.
|
||||||
|
|
||||||
|
A3 gave a tier-gated source a count — "47 posts you can't see". The roster can
|
||||||
|
turn that into a reason, and the whole risk of this step is that a reason is a
|
||||||
|
much stronger claim than a count. So most of what follows pins refusals:
|
||||||
|
|
||||||
|
* an unrecognised status says NOTHING rather than guessing lapsed;
|
||||||
|
* a campaign absent from the roster says nothing, because absence is not
|
||||||
|
evidence (the same discipline as `test_post_is_gated_only_on_explicit_false`);
|
||||||
|
* a stale roster degrades to the bare count rather than asserting last week's
|
||||||
|
reason as today's;
|
||||||
|
* a current FREE follower is not told they used to be a patron.
|
||||||
|
|
||||||
|
And the one that matters most: no fetch path can read the roster at all. That
|
||||||
|
is asserted structurally, not by inspection — the roster explains a skip that
|
||||||
|
already happened, and must never be able to cause one.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.app.models import Artist, Source
|
||||||
|
from backend.app.services.gallery_dl import ErrorType
|
||||||
|
from backend.app.services.membership_roster import (
|
||||||
|
GATED_FREE,
|
||||||
|
GATED_LAPSED,
|
||||||
|
GATED_TIER,
|
||||||
|
ROSTER_STALE_AFTER,
|
||||||
|
gated_reason,
|
||||||
|
gated_reasons_for_sources,
|
||||||
|
)
|
||||||
|
from tests.roster_builders import membership as _membership
|
||||||
|
from tests.roster_builders import synced as _synced
|
||||||
|
|
||||||
|
# --- the pure mapping: status word -> the words we are entitled to say -------
|
||||||
|
#
|
||||||
|
# No database, so these run in the unit lane. `gated_reason` is where the claim
|
||||||
|
# is actually decided; everything below it is plumbing.
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_former_patron_is_told_the_membership_ended():
|
||||||
|
assert gated_reason("patreon", "former_patron") == GATED_LAPSED
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_active_patron_is_told_the_tier_does_not_reach_these_posts():
|
||||||
|
assert gated_reason("patreon", "active_patron") == GATED_TIER
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_current_free_follower_is_not_told_they_used_to_be_a_patron():
|
||||||
|
"""`free` and `lapsed` are different states and must not share a sentence.
|
||||||
|
|
||||||
|
Both make `has_paid_access` False, which is why the reason is derived from
|
||||||
|
the status axis first rather than from that boolean: "you're not a patron
|
||||||
|
any more" is a false statement about someone who never was one.
|
||||||
|
"""
|
||||||
|
assert gated_reason("patreon", "active_patron", is_free_member=True) == GATED_FREE
|
||||||
|
assert gated_reason("patreon", "former_patron", is_free_member=True) == GATED_LAPSED
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", [None, "declined_patron", "", "wat"])
|
||||||
|
def test_an_unrecognised_status_says_nothing(status):
|
||||||
|
"""Unknown is not lapsed.
|
||||||
|
|
||||||
|
`declined_patron` is in here on purpose: it is the plausible-looking word
|
||||||
|
the C0 capture proved is NOT in the `patron_status` vocabulary. If someone
|
||||||
|
adds it to MEMBERSHIP_STATUS on the strength of the request filter, this
|
||||||
|
test is where that shows up.
|
||||||
|
"""
|
||||||
|
assert gated_reason("patreon", status) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_platform_that_has_never_been_characterised_says_nothing():
|
||||||
|
"""SubscribeStar and FANBOX (D1) inherit silence, not a Patreon guess."""
|
||||||
|
assert gated_reason("subscribestar", "active_patron") is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- the join, against the database ----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _artist(db, name="Maewix"):
|
||||||
|
a = Artist(name=name, slug=name.lower().replace(" ", ""))
|
||||||
|
db.add(a)
|
||||||
|
await db.flush()
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
async def _gated_source(db, artist, *, url="https://www.patreon.com/maewix",
|
||||||
|
platform="patreon", overrides=None):
|
||||||
|
s = Source(
|
||||||
|
artist_id=artist.id, platform=platform, url=url, enabled=True,
|
||||||
|
error_type=ErrorType.TIER_LIMITED, config_overrides=overrides,
|
||||||
|
)
|
||||||
|
db.add(s)
|
||||||
|
await db.flush()
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_matched_active_membership_explains_the_gate(db):
|
||||||
|
artist = await _artist(db)
|
||||||
|
source = await _gated_source(db, artist)
|
||||||
|
await _membership(db)
|
||||||
|
await _synced(db)
|
||||||
|
|
||||||
|
assert await gated_reasons_for_sources(db, [source]) == {source.id: GATED_TIER}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_lapsed_membership_explains_the_gate(db):
|
||||||
|
artist = await _artist(db)
|
||||||
|
source = await _gated_source(db, artist)
|
||||||
|
await _membership(db, status="former_patron")
|
||||||
|
await _synced(db)
|
||||||
|
|
||||||
|
assert await gated_reasons_for_sources(db, [source]) == {source.id: GATED_LAPSED}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_campaign_absent_from_the_roster_adds_no_words(db):
|
||||||
|
"""The third case in the step: absence is not evidence.
|
||||||
|
|
||||||
|
The sweep succeeded an hour ago and this creator is simply not in it. That
|
||||||
|
could mean the subscription lapsed — or that the sweep's pagination is
|
||||||
|
incomplete, or that the creator renamed. The count stands alone.
|
||||||
|
"""
|
||||||
|
artist = await _artist(db)
|
||||||
|
source = await _gated_source(db, artist)
|
||||||
|
await _membership(db, campaign="somebody-else",
|
||||||
|
url="https://www.patreon.com/someoneelse",
|
||||||
|
details={"campaign": {"vanity": "someoneelse"}})
|
||||||
|
await _synced(db)
|
||||||
|
|
||||||
|
assert await gated_reasons_for_sources(db, [source]) == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_stale_roster_degrades_to_the_bare_count(db):
|
||||||
|
"""Same data as the passing case, one stale sync state.
|
||||||
|
|
||||||
|
A reason is a claim about NOW. Last week's roster cannot make it.
|
||||||
|
"""
|
||||||
|
artist = await _artist(db)
|
||||||
|
source = await _gated_source(db, artist)
|
||||||
|
await _membership(db, status="former_patron")
|
||||||
|
await _synced(db, ago=ROSTER_STALE_AFTER + timedelta(hours=1))
|
||||||
|
|
||||||
|
assert await gated_reasons_for_sources(db, [source]) == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_never_synced_platform_degrades_to_the_bare_count(db):
|
||||||
|
artist = await _artist(db)
|
||||||
|
source = await _gated_source(db, artist)
|
||||||
|
await _membership(db, status="former_patron")
|
||||||
|
# No MembershipSync row at all.
|
||||||
|
|
||||||
|
assert await gated_reasons_for_sources(db, [source]) == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_one_platforms_fresh_sweep_does_not_vouch_for_anothers(db):
|
||||||
|
"""Freshness is per platform, so a working Patreon sweep cannot lend its
|
||||||
|
credibility to a SubscribeStar roster that has never run (D1's future)."""
|
||||||
|
artist = await _artist(db)
|
||||||
|
patreon = await _gated_source(db, artist)
|
||||||
|
other = await _gated_source(
|
||||||
|
db, artist, platform="subscribestar",
|
||||||
|
url="https://subscribestar.adult/maewix",
|
||||||
|
)
|
||||||
|
await _membership(db, status="former_patron")
|
||||||
|
await _membership(db, platform="subscribestar", campaign="s1",
|
||||||
|
status="former_patron",
|
||||||
|
url="https://subscribestar.adult/maewix",
|
||||||
|
details={"campaign": {"vanity": "maewix"}})
|
||||||
|
await _synced(db) # patreon only
|
||||||
|
|
||||||
|
reasons = await gated_reasons_for_sources(db, [patreon, other])
|
||||||
|
assert reasons == {patreon.id: GATED_LAPSED}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_cached_campaign_id_wins_over_a_stale_url(db):
|
||||||
|
"""Inherited from `match_kind` via the shared join — asserted here so C5
|
||||||
|
keeps identity in step with C4 rather than quietly growing its own."""
|
||||||
|
artist = await _artist(db)
|
||||||
|
source = await _gated_source(
|
||||||
|
db, artist, url="https://www.patreon.com/old-handle",
|
||||||
|
overrides={"patreon_campaign_id": "c1"},
|
||||||
|
)
|
||||||
|
await _membership(db, campaign="c1", url="https://www.patreon.com/new-handle",
|
||||||
|
details={"campaign": {"vanity": "new-handle"}},
|
||||||
|
status="former_patron")
|
||||||
|
await _synced(db)
|
||||||
|
|
||||||
|
assert await gated_reasons_for_sources(db, [source]) == {source.id: GATED_LAPSED}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_sources_asks_the_database_nothing(db):
|
||||||
|
assert await gated_reasons_for_sources(db, []) == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_source_list_carries_the_reason_to_the_ui(db):
|
||||||
|
"""End to end through `SourceService.list`, because the field being on the
|
||||||
|
record is not the same as the field reaching the payload."""
|
||||||
|
from backend.app.services.source_service import SourceService
|
||||||
|
|
||||||
|
artist = await _artist(db)
|
||||||
|
source = await _gated_source(db, artist)
|
||||||
|
await _membership(db, status="former_patron")
|
||||||
|
await _synced(db)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
records = await SourceService(db).list()
|
||||||
|
row = next(r.to_dict() for r in records if r.id == source.id)
|
||||||
|
assert row["gated_reason"] == GATED_LAPSED
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_source_that_is_not_gated_gets_no_reason(db):
|
||||||
|
"""The roster annotates a gated state; it never puts one on a healthy row.
|
||||||
|
|
||||||
|
A source walking fine whose creator the operator stopped paying for has
|
||||||
|
nothing to explain — and saying "you're not a patron any more" beside a
|
||||||
|
healthy row would be the roster asserting inaccessibility on its own.
|
||||||
|
"""
|
||||||
|
from backend.app.services.source_service import SourceService
|
||||||
|
|
||||||
|
artist = await _artist(db)
|
||||||
|
source = Source(
|
||||||
|
artist_id=artist.id, platform="patreon", enabled=True,
|
||||||
|
url="https://www.patreon.com/maewix", error_type=None,
|
||||||
|
)
|
||||||
|
db.add(source)
|
||||||
|
await db.flush()
|
||||||
|
await _membership(db, status="former_patron")
|
||||||
|
await _synced(db)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
records = await SourceService(db).list()
|
||||||
|
row = next(r.to_dict() for r in records if r.id == source.id)
|
||||||
|
assert row["gated_reason"] is None
|
||||||
|
assert row["tier_gated_count"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- the line that must not be crossed --------------------------------------
|
||||||
|
|
||||||
|
_APP = Path(__file__).resolve().parents[1] / "backend" / "app"
|
||||||
|
|
||||||
|
# The modules that FETCH: the walk, the downloaders, the per-platform clients
|
||||||
|
# and ingesters. Named as ROOTS of an import walk rather than as the place the
|
||||||
|
# assertion looks — the check follows their transitive first-party imports, so
|
||||||
|
# it still holds when the logic inside them moves (rule #167).
|
||||||
|
_FETCH_ROOTS = [
|
||||||
|
"services/download_service.py",
|
||||||
|
"services/ingest_core.py",
|
||||||
|
"services/native_ingest_common.py",
|
||||||
|
"services/gallery_dl.py",
|
||||||
|
"services/refetch_service.py",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Reading ANY of these is reading the roster.
|
||||||
|
_ROSTER_MODULES = {"services.membership_roster", "services.membership_reconcile"}
|
||||||
|
|
||||||
|
|
||||||
|
def _first_party_imports(path: Path) -> set[str]:
|
||||||
|
"""Every `backend.app.*` module this file imports, as a dotted path
|
||||||
|
relative to `backend/app` — absolute and relative forms both."""
|
||||||
|
tree = ast.parse(path.read_text())
|
||||||
|
here = path.relative_to(_APP).with_suffix("").parts
|
||||||
|
if here and here[-1] == "__init__":
|
||||||
|
# A package's `__init__` IS the package, so `from .x import y` inside it
|
||||||
|
# resolves one level shallower than the file path suggests. Getting this
|
||||||
|
# wrong silently under-resolves every relative import in every package
|
||||||
|
# and would make the guard below unable to fail.
|
||||||
|
here = here[:-1]
|
||||||
|
out: set[str] = set()
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.ImportFrom):
|
||||||
|
if node.level:
|
||||||
|
# `from ..models import X` -> walk up from this module's package
|
||||||
|
base = list(here[: len(here) - node.level])
|
||||||
|
mod = base + (node.module.split(".") if node.module else [])
|
||||||
|
elif node.module and node.module.startswith("backend.app."):
|
||||||
|
mod = node.module[len("backend.app."):].split(".")
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
out.add(".".join(mod))
|
||||||
|
# `from .membership_roster import x` and
|
||||||
|
# `from . import membership_roster` must both resolve to the module.
|
||||||
|
for alias in node.names:
|
||||||
|
out.add(".".join([*mod, alias.name]))
|
||||||
|
elif isinstance(node, ast.Import):
|
||||||
|
for alias in node.names:
|
||||||
|
if alias.name.startswith("backend.app."):
|
||||||
|
out.add(alias.name[len("backend.app."):])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _reachable_from(roots: list[str]) -> set[str]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
queue = [r[:-3].replace("/", ".") for r in roots]
|
||||||
|
while queue:
|
||||||
|
mod = queue.pop()
|
||||||
|
if mod in seen:
|
||||||
|
continue
|
||||||
|
seen.add(mod)
|
||||||
|
for candidate in (_APP / Path(*mod.split(".")) / "__init__.py",
|
||||||
|
_APP / Path(*mod.split(".")).with_suffix(".py")):
|
||||||
|
if candidate.is_file():
|
||||||
|
queue.extend(_first_party_imports(candidate) - seen)
|
||||||
|
break
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_fetch_roots_all_exist():
|
||||||
|
"""Falsifiability guard for the guard below.
|
||||||
|
|
||||||
|
A reachability check passes trivially if its roots resolve to nothing, so a
|
||||||
|
renamed module would silently turn the real assertion into a no-op that
|
||||||
|
still reads as coverage.
|
||||||
|
"""
|
||||||
|
missing = [r for r in _FETCH_ROOTS if not (_APP / r).is_file()]
|
||||||
|
assert not missing, f"fetch roots moved or were renamed: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
# The falsifiability controls. FC runs no local verification (rule #85), so a
|
||||||
|
# guard cannot be falsified by hand before it is committed — it has to carry its
|
||||||
|
# own proof that it is capable of failing, and re-prove it on every run. These
|
||||||
|
# two assert the walker finds roster imports that ARE there, one direct and one
|
||||||
|
# through a hop, so the real assertion below can never pass merely because the
|
||||||
|
# walk resolved nothing.
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_walk_sees_a_direct_roster_import():
|
||||||
|
"""`source_service` legitimately reads the roster — it is what annotates the
|
||||||
|
Subscriptions rows. If this stops holding the walker has gone blind, not the
|
||||||
|
dependency gone away."""
|
||||||
|
assert "services.membership_roster" in _reachable_from(["services/source_service.py"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_walk_follows_more_than_one_hop():
|
||||||
|
"""api/sources.py -> services/source_service.py -> services/membership_roster.py.
|
||||||
|
|
||||||
|
A one-hop walker would pass the guard below on a fetch path that reaches the
|
||||||
|
roster through any intermediate module, which is the likeliest way this ever
|
||||||
|
actually regresses.
|
||||||
|
"""
|
||||||
|
reachable = _reachable_from(["api/sources.py"])
|
||||||
|
assert "services.source_service" in reachable
|
||||||
|
assert "services.membership_roster" in reachable
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_fetch_path_can_read_the_roster():
|
||||||
|
"""The roster explains a skip. It must never be able to cause one.
|
||||||
|
|
||||||
|
Entitled-tier data says which tiers the account holds, not which posts
|
||||||
|
those tiers unlock — `current_user_can_view` is the only per-post truth. A
|
||||||
|
fetch path that could consult the roster could decide not to fetch
|
||||||
|
something the operator is paying for, silently, and that is the worst
|
||||||
|
failure available in this milestone.
|
||||||
|
|
||||||
|
Import reachability rather than a call-site scan: a module that cannot
|
||||||
|
reach the roster cannot consult it, and unlike a grep for a function name
|
||||||
|
this keeps holding when the functions are renamed.
|
||||||
|
"""
|
||||||
|
reachable = _reachable_from(_FETCH_ROOTS)
|
||||||
|
assert not (reachable & _ROSTER_MODULES), (
|
||||||
|
"a fetch path can now reach the membership roster: "
|
||||||
|
f"{sorted(reachable & _ROSTER_MODULES)}. The roster may annotate a "
|
||||||
|
"gated state, never produce one — see membership_roster.gated_reason."
|
||||||
|
)
|
||||||
@@ -10,11 +10,11 @@ So most of what follows pins refusals: the gate that empties the bucket when the
|
|||||||
roster cannot be trusted, the tri-state that keeps "unknown" out of "lapsed",
|
roster cannot be trusted, the tri-state that keeps "unknown" out of "lapsed",
|
||||||
and the per-row basis that stops the weakest claim sounding like the strongest.
|
and the per-row basis that stops the weakest claim sounding like the strongest.
|
||||||
"""
|
"""
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from backend.app.models import Artist, MembershipSync, PlatformMembership, Source
|
from backend.app.models import Artist, MembershipSync, Source
|
||||||
from backend.app.services.membership_reconcile import (
|
from backend.app.services.membership_reconcile import (
|
||||||
BASIS_ABSENT_EXACT,
|
BASIS_ABSENT_EXACT,
|
||||||
BASIS_ABSENT_HANDLE,
|
BASIS_ABSENT_HANDLE,
|
||||||
@@ -23,6 +23,8 @@ from backend.app.services.membership_reconcile import (
|
|||||||
reconcile_all,
|
reconcile_all,
|
||||||
)
|
)
|
||||||
from backend.app.services.membership_roster import ROSTER_STALE_AFTER
|
from backend.app.services.membership_roster import ROSTER_STALE_AFTER
|
||||||
|
from tests.roster_builders import membership as _membership
|
||||||
|
from tests.roster_builders import synced as _synced
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
@@ -44,25 +46,6 @@ async def _source(db, artist, *, url, platform="patreon", enabled=True, override
|
|||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
async def _membership(
|
|
||||||
db, *, campaign="c1", platform="patreon", status="active_patron",
|
|
||||||
display_name="Maewix", url="https://www.patreon.com/maewix", details=None,
|
|
||||||
):
|
|
||||||
m = PlatformMembership(
|
|
||||||
platform=platform, external_campaign_id=campaign, status=status,
|
|
||||||
display_name=display_name, url=url,
|
|
||||||
details={"campaign": {"vanity": "maewix"}} if details is None else details,
|
|
||||||
)
|
|
||||||
db.add(m)
|
|
||||||
await db.flush()
|
|
||||||
return m
|
|
||||||
|
|
||||||
|
|
||||||
async def _synced(db, *, platform="patreon", ago=timedelta(hours=1)):
|
|
||||||
db.add(MembershipSync(platform=platform, last_success_at=datetime.now(UTC) - ago))
|
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
|
|
||||||
# --- the join --------------------------------------------------------------
|
# --- the join --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user