Scrape SunkwiBOT/twitch-drops-api for data
All checks were successful
Deploy to Server / deploy (push) Successful in 29s
All checks were successful
Deploy to Server / deploy (push) Successful in 29s
This commit is contained in:
parent
b06dd6b1ac
commit
3535d7d2dd
28 changed files with 4272 additions and 576 deletions
|
|
@ -27,7 +27,7 @@ repos:
|
||||||
args: [--target-version, "6.0"]
|
args: [--target-version, "6.0"]
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.15.12
|
rev: v0.15.17
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
args: ["--fix", "--exit-non-zero-on-fix"]
|
args: ["--fix", "--exit-non-zero-on-fix"]
|
||||||
|
|
@ -38,8 +38,3 @@ repos:
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyupgrade
|
- id: pyupgrade
|
||||||
args: ["--py311-plus"]
|
args: ["--py311-plus"]
|
||||||
|
|
||||||
- repo: https://github.com/rhysd/actionlint
|
|
||||||
rev: v1.7.12
|
|
||||||
hooks:
|
|
||||||
- id: actionlint
|
|
||||||
|
|
|
||||||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -71,6 +71,7 @@
|
||||||
"sitewide",
|
"sitewide",
|
||||||
"speculationrules",
|
"speculationrules",
|
||||||
"staticfiles",
|
"staticfiles",
|
||||||
|
"Sunkwi",
|
||||||
"testchannel",
|
"testchannel",
|
||||||
"testpass",
|
"testpass",
|
||||||
"thelovinator",
|
"thelovinator",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ if TYPE_CHECKING:
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True) # noqa: RUF076 — intentional: project-wide N+1 detection with @pytest.mark.no_zeal escape hatch
|
||||||
def use_zeal(request: pytest.FixtureRequest) -> Generator[None]:
|
def use_zeal(request: pytest.FixtureRequest) -> Generator[None]:
|
||||||
"""Enable Zeal N+1 detection context for each pytest test.
|
"""Enable Zeal N+1 detection context for each pytest test.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -533,7 +533,7 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
|
||||||
|
|
||||||
|
|
||||||
# MARK: /debug/
|
# MARK: /debug/
|
||||||
def debug_view(request: HttpRequest) -> HttpResponse:
|
def debug_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0914
|
||||||
"""Debug view showing potentially broken or inconsistent data.
|
"""Debug view showing potentially broken or inconsistent data.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -651,6 +651,60 @@ def debug_view(request: HttpRequest) -> HttpResponse:
|
||||||
.order_by("game__display_name", "name"),
|
.order_by("game__display_name", "name"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── Data source diff ────────────────────────────────────────────────
|
||||||
|
# Compare campaigns imported from different data sources
|
||||||
|
sunkibot_drop_ids: set[str] = set(
|
||||||
|
DropCampaign.objects.filter(
|
||||||
|
data_source="SunkwiBOT/twitch-drops-api",
|
||||||
|
).values_list("twitch_id", flat=True),
|
||||||
|
)
|
||||||
|
miner_drop_ids: set[str] = set(
|
||||||
|
DropCampaign.objects.filter(data_source="TwitchDropsMiner").values_list(
|
||||||
|
"twitch_id",
|
||||||
|
flat=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drops only in SunkwiBOT (not in TwitchDropsMiner)
|
||||||
|
sunkibot_only_drop_ids: set[str] = sunkibot_drop_ids - miner_drop_ids
|
||||||
|
# Drops only in TwitchDropsMiner (not in SunkwiBOT)
|
||||||
|
miner_only_drop_ids: set[str] = miner_drop_ids - sunkibot_drop_ids
|
||||||
|
|
||||||
|
sunkibot_only_drops: QuerySet[DropCampaign] = (
|
||||||
|
DropCampaign.objects
|
||||||
|
.filter(twitch_id__in=sunkibot_only_drop_ids)
|
||||||
|
.select_related("game")
|
||||||
|
.order_by("name")[:200]
|
||||||
|
)
|
||||||
|
miner_only_drops: QuerySet[DropCampaign] = (
|
||||||
|
DropCampaign.objects
|
||||||
|
.filter(twitch_id__in=miner_only_drop_ids)
|
||||||
|
.select_related("game")
|
||||||
|
.order_by("name")[:200]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Same for reward campaigns
|
||||||
|
sunkibot_reward_ids: set[str] = set(
|
||||||
|
RewardCampaign.objects.filter(
|
||||||
|
data_source="SunkwiBOT/twitch-drops-api",
|
||||||
|
).values_list("twitch_id", flat=True),
|
||||||
|
)
|
||||||
|
miner_reward_ids: set[str] = set(
|
||||||
|
RewardCampaign.objects.filter(data_source="TwitchDropsMiner").values_list(
|
||||||
|
"twitch_id",
|
||||||
|
flat=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
sunkibot_only_reward_ids: set[str] = sunkibot_reward_ids - miner_reward_ids
|
||||||
|
miner_only_reward_ids: set[str] = miner_reward_ids - sunkibot_reward_ids
|
||||||
|
|
||||||
|
sunkibot_only_rewards: QuerySet[RewardCampaign] = RewardCampaign.objects.filter(
|
||||||
|
twitch_id__in=sunkibot_only_reward_ids,
|
||||||
|
).order_by("name")[:200]
|
||||||
|
miner_only_rewards: QuerySet[RewardCampaign] = RewardCampaign.objects.filter(
|
||||||
|
twitch_id__in=miner_only_reward_ids,
|
||||||
|
).order_by("name")[:200]
|
||||||
context: dict[str, Any] = {
|
context: dict[str, Any] = {
|
||||||
"now": now,
|
"now": now,
|
||||||
"games_without_owner": games_without_owner,
|
"games_without_owner": games_without_owner,
|
||||||
|
|
@ -662,6 +716,15 @@ def debug_view(request: HttpRequest) -> HttpResponse:
|
||||||
"active_missing_image": active_missing_image,
|
"active_missing_image": active_missing_image,
|
||||||
"operation_names_with_counts": operation_names_with_counts,
|
"operation_names_with_counts": operation_names_with_counts,
|
||||||
"campaigns_missing_dropcampaigndetails": campaigns_missing_dropcampaigndetails,
|
"campaigns_missing_dropcampaigndetails": campaigns_missing_dropcampaigndetails,
|
||||||
|
# Data source diff
|
||||||
|
"sunkibot_only_drops_count": len(sunkibot_only_drop_ids),
|
||||||
|
"miner_only_drops_count": len(miner_only_drop_ids),
|
||||||
|
"sunkibot_only_drops": sunkibot_only_drops,
|
||||||
|
"miner_only_drops": miner_only_drops,
|
||||||
|
"sunkibot_only_rewards_count": len(sunkibot_only_reward_ids),
|
||||||
|
"miner_only_rewards_count": len(miner_only_reward_ids),
|
||||||
|
"sunkibot_only_rewards": sunkibot_only_rewards,
|
||||||
|
"miner_only_rewards": miner_only_rewards,
|
||||||
}
|
}
|
||||||
|
|
||||||
seo_context: dict[str, Any] = _build_seo_context(
|
seo_context: dict[str, Any] = _build_seo_context(
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ if TYPE_CHECKING:
|
||||||
from django.test.client import _MonkeyPatchedWSGIResponse
|
from django.test.client import _MonkeyPatchedWSGIResponse
|
||||||
from pytest_django.asserts import QuerySet
|
from pytest_django.asserts import QuerySet
|
||||||
|
|
||||||
from kick.schemas import KickDropCampaignSchema
|
|
||||||
from kick.schemas import KickRewardSchema
|
from kick.schemas import KickRewardSchema
|
||||||
|
|
||||||
# Minimal valid campaign fixture (single campaign, active status)
|
# Minimal valid campaign fixture (single campaign, active status)
|
||||||
|
|
|
||||||
|
|
@ -170,4 +170,73 @@
|
||||||
<p>None ✅</p>
|
<p>None ✅</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Data Source Diff — Drop Campaigns</h2>
|
||||||
|
<p>Comparing campaigns by <code>data_source</code> field in the DB.</p>
|
||||||
|
<h3>Only in SunkwiBOT/twitch-drops-api ({{ sunkibot_only_drops_count }})</h3>
|
||||||
|
{% if sunkibot_only_drops %}
|
||||||
|
<ul>
|
||||||
|
{% for c in sunkibot_only_drops %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'twitch:campaign_detail' c.twitch_id %}">{{ c.name }}</a>
|
||||||
|
(Game: <a href="{% url 'twitch:game_detail' c.game.twitch_id %}">{{ c.game.display_name }}</a>)
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% if sunkibot_only_drops_count > 200 %}
|
||||||
|
<p><em>… and {{ sunkibot_only_drops_count|add:"-200" }} more (showing first 200)</em></p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p>None ✅</p>
|
||||||
|
{% endif %}
|
||||||
|
<h3>Only in TwitchDropsMiner ({{ miner_only_drops_count }})</h3>
|
||||||
|
{% if miner_only_drops %}
|
||||||
|
<ul>
|
||||||
|
{% for c in miner_only_drops %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'twitch:campaign_detail' c.twitch_id %}">{{ c.name }}</a>
|
||||||
|
(Game: <a href="{% url 'twitch:game_detail' c.game.twitch_id %}">{{ c.game.display_name }}</a>)
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% if miner_only_drops_count > 200 %}
|
||||||
|
<p><em>… and {{ miner_only_drops_count|add:"-200" }} more (showing first 200)</em></p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p>None ✅</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Data Source Diff — Reward Campaigns</h2>
|
||||||
|
<h3>Only in SunkwiBOT/twitch-drops-api ({{ sunkibot_only_rewards_count }})</h3>
|
||||||
|
{% if sunkibot_only_rewards %}
|
||||||
|
<ul>
|
||||||
|
{% for c in sunkibot_only_rewards %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'twitch:reward_campaign_detail' c.twitch_id %}">{{ c.name }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% if sunkibot_only_rewards_count > 200 %}
|
||||||
|
<p><em>… and {{ sunkibot_only_rewards_count|add:"-200" }} more (showing first 200)</em></p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p>None ✅</p>
|
||||||
|
{% endif %}
|
||||||
|
<h3>Only in TwitchDropsMiner ({{ miner_only_rewards_count }})</h3>
|
||||||
|
{% if miner_only_rewards %}
|
||||||
|
<ul>
|
||||||
|
{% for c in miner_only_rewards %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'twitch:reward_campaign_detail' c.twitch_id %}">{{ c.name }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% if miner_only_rewards_count > 200 %}
|
||||||
|
<p><em>… and {{ miner_only_rewards_count|add:"-200" }} more (showing first 200)</em></p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p>None ✅</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
|
||||||
|
|
@ -47,48 +47,65 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<!-- Campaign description -->
|
<!-- Campaign description -->
|
||||||
<p>{{ campaign.description|linebreaksbr }}</p>
|
<p>{{ campaign.description|linebreaksbr }}</p>
|
||||||
<div>
|
<dl>
|
||||||
Published
|
<dt>Published</dt>
|
||||||
<time datetime="{{ campaign.added_at|date:'c' }}"
|
<dd>
|
||||||
title="{{ campaign.added_at|date:'DATETIME_FORMAT' }}">{{ campaign.added_at|date:"M d, Y H:i" }}</time> ({{ campaign.added_at|timesince }} ago)
|
<time datetime="{{ campaign.added_at|date:'c' }}"
|
||||||
</div>
|
title="{{ campaign.added_at|date:'DATETIME_FORMAT' }}">{{ campaign.added_at|date:"M d, Y H:i" }}</time> ({{ campaign.added_at|timesince }} ago)
|
||||||
<div>
|
</dd>
|
||||||
Last updated
|
<dt>Last updated</dt>
|
||||||
<time datetime="{{ campaign.updated_at|date:'c' }}"
|
<dd>
|
||||||
title="{{ campaign.updated_at|date:'DATETIME_FORMAT' }}">{{ campaign.updated_at|date:"M d, Y H:i" }}</time> ({{ campaign.updated_at|timesince }} ago)
|
<time datetime="{{ campaign.updated_at|date:'c' }}"
|
||||||
</div>
|
title="{{ campaign.updated_at|date:'DATETIME_FORMAT' }}">{{ campaign.updated_at|date:"M d, Y H:i" }}</time> ({{ campaign.updated_at|timesince }} ago)
|
||||||
<!-- Campaign end times -->
|
</dd>
|
||||||
<div>
|
<!-- Campaign end times -->
|
||||||
{% if campaign.end_at < now %}
|
<dt>
|
||||||
Ended
|
{% if campaign.end_at < now %}
|
||||||
|
Ended
|
||||||
|
{% else %}
|
||||||
|
Ends in
|
||||||
|
{% endif %}
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
<time datetime="{{ campaign.end_at|date:'c' }}"
|
<time datetime="{{ campaign.end_at|date:'c' }}"
|
||||||
title="{{ campaign.end_at|date:'DATETIME_FORMAT' }}">{{ campaign.end_at|date:"M d, Y H:i" }}</time> ({{ campaign.end_at|timesince }} ago)
|
title="{{ campaign.end_at|date:'DATETIME_FORMAT' }}">{{ campaign.end_at|date:"M d, Y H:i" }}</time>
|
||||||
{% else %}
|
{% if campaign.end_at < now %}
|
||||||
Ends in
|
({{ campaign.end_at|timesince }} ago)
|
||||||
<time datetime="{{ campaign.end_at|date:'c' }}"
|
{% else %}
|
||||||
title="{{ campaign.end_at|date:'DATETIME_FORMAT' }}">{{ campaign.end_at|date:"M d, Y H:i" }}</time> (in {{ campaign.end_at|timeuntil }})
|
(in {{ campaign.end_at|timeuntil }})
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</dd>
|
||||||
<!-- Campaign start times -->
|
<!-- Campaign start times -->
|
||||||
<div>
|
<dt>
|
||||||
{% if campaign.start_at > now %}
|
{% if campaign.start_at > now %}
|
||||||
Starts in
|
Starts in
|
||||||
|
{% else %}
|
||||||
|
Started
|
||||||
|
{% endif %}
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
<time datetime="{{ campaign.start_at|date:'c' }}"
|
<time datetime="{{ campaign.start_at|date:'c' }}"
|
||||||
title="{{ campaign.start_at|date:'DATETIME_FORMAT' }}">{{ campaign.start_at|date:"M d, Y H:i" }}</time> (in {{ campaign.start_at|timeuntil }})
|
title="{{ campaign.start_at|date:'DATETIME_FORMAT' }}">{{ campaign.start_at|date:"M d, Y H:i" }}</time>
|
||||||
{% else %}
|
{% if campaign.start_at > now %}
|
||||||
Started
|
(in {{ campaign.start_at|timeuntil }})
|
||||||
<time datetime="{{ campaign.start_at|date:'c' }}"
|
{% else %}
|
||||||
title="{{ campaign.start_at|date:'DATETIME_FORMAT' }}">{{ campaign.start_at|date:"M d, Y H:i" }}</time> ({{ campaign.start_at|timesince }} ago)
|
({{ campaign.start_at|timesince }} ago)
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</dd>
|
||||||
<!-- Campaign duration -->
|
<!-- Campaign duration -->
|
||||||
<div>
|
<dt>Duration</dt>
|
||||||
Duration is
|
<dd>
|
||||||
<time datetime="{{ campaign.duration_iso }}"
|
<time datetime="{{ campaign.duration_iso }}"
|
||||||
title="{{ campaign.start_at|date:'DATETIME_FORMAT' }} to {{ campaign.end_at|date:'DATETIME_FORMAT' }}">
|
title="{{ campaign.start_at|date:'DATETIME_FORMAT' }} to {{ campaign.end_at|date:'DATETIME_FORMAT' }}">
|
||||||
{{ campaign.end_at|timeuntil:campaign.start_at }}
|
{{ campaign.end_at|timeuntil:campaign.start_at }}
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</dd>
|
||||||
|
<!-- Data source -->
|
||||||
|
<dt>Data source</dt>
|
||||||
|
<dd>
|
||||||
|
{{ campaign.data_source }}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
<!-- Buttons -->
|
<!-- Buttons -->
|
||||||
<div>
|
<div>
|
||||||
<!-- Campaign Detail URL -->
|
<!-- Campaign Detail URL -->
|
||||||
|
|
@ -103,9 +120,9 @@
|
||||||
title="Atom feed for {{ campaign.game.display_name }} campaigns">[atom]</a>
|
title="Atom feed for {{ campaign.game.display_name }} campaigns">[atom]</a>
|
||||||
<a href="{% url 'core:game_campaign_feed_discord' campaign.game.twitch_id %}"
|
<a href="{% url 'core:game_campaign_feed_discord' campaign.game.twitch_id %}"
|
||||||
title="Discord feed for {{ campaign.game.display_name }} campaigns">[discord]</a>
|
title="Discord feed for {{ campaign.game.display_name }} campaigns">[discord]</a>
|
||||||
|
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
|
||||||
<a href="{% url 'twitch:twitch-api-v1:get_campaign' campaign.twitch_id %}"
|
<a href="{% url 'twitch:twitch-api-v1:get_campaign' campaign.twitch_id %}"
|
||||||
title="Twitch campaign API">[api]</a>
|
title="Twitch campaign API">[api]</a>
|
||||||
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -50,58 +50,71 @@
|
||||||
</div>
|
</div>
|
||||||
<!-- Campaign Summary -->
|
<!-- Campaign Summary -->
|
||||||
{% if reward_campaign.summary %}<p id="campaign-summary">{{ reward_campaign.summary|linebreaksbr }}</p>{% endif %}
|
{% if reward_campaign.summary %}<p id="campaign-summary">{{ reward_campaign.summary|linebreaksbr }}</p>{% endif %}
|
||||||
<!-- Campaign Status -->
|
<dl>
|
||||||
<div>
|
<!-- Campaign Status -->
|
||||||
<strong>Status:</strong>
|
<dt>Status</dt>
|
||||||
{% if is_active %}
|
<dd>
|
||||||
Active
|
{% if is_active %}
|
||||||
{% elif reward_campaign.starts_at > now %}
|
Active
|
||||||
Upcoming
|
{% elif reward_campaign.starts_at > now %}
|
||||||
{% else %}
|
Upcoming
|
||||||
Expired
|
{% else %}
|
||||||
{% endif %}
|
Expired
|
||||||
</div>
|
{% endif %}
|
||||||
<div>Added at {{ reward_campaign.added_at|date:"M d, Y H:i" }} ({{ reward_campaign.added_at|timesince }} ago)</div>
|
</dd>
|
||||||
<div>
|
<dt>Added</dt>
|
||||||
Last updated at {{ reward_campaign.updated_at|date:"M d, Y H:i" }} ({{ reward_campaign.updated_at|timesince }} ago)
|
<dd>
|
||||||
</div>
|
{{ reward_campaign.added_at|date:"M d, Y H:i" }} ({{ reward_campaign.added_at|timesince }} ago)
|
||||||
<!-- Reward Start Time -->
|
</dd>
|
||||||
<div>
|
<dt>Last updated</dt>
|
||||||
{% if reward_campaign.starts_at > now %}
|
<dd>
|
||||||
Starts
|
{{ reward_campaign.updated_at|date:"M d, Y H:i" }} ({{ reward_campaign.updated_at|timesince }} ago)
|
||||||
{% else %}
|
</dd>
|
||||||
Started at
|
<!-- Reward Start Time -->
|
||||||
{% endif %}
|
<dt>
|
||||||
<time datetime="{{ reward_campaign.starts_at|date:'c' }}"
|
{% if reward_campaign.starts_at > now %}
|
||||||
title="{{ reward_campaign.starts_at|date:'DATETIME_FORMAT' }}">
|
Starts
|
||||||
{{ reward_campaign.starts_at|date:"M d, Y H:i" }}
|
{% else %}
|
||||||
|
Started at
|
||||||
|
{% endif %}
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<time datetime="{{ reward_campaign.starts_at|date:'c' }}"
|
||||||
|
title="{{ reward_campaign.starts_at|date:'DATETIME_FORMAT' }}">
|
||||||
|
{{ reward_campaign.starts_at|date:"M d, Y H:i" }}
|
||||||
|
</time>
|
||||||
{% if reward_campaign.starts_at > now %}
|
{% if reward_campaign.starts_at > now %}
|
||||||
(in {{ reward_campaign.starts_at|timeuntil }})
|
(in {{ reward_campaign.starts_at|timeuntil }})
|
||||||
{% else %}
|
{% else %}
|
||||||
({{ reward_campaign.starts_at|timesince }} ago)
|
({{ reward_campaign.starts_at|timesince }} ago)
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</time>
|
</dd>
|
||||||
</div>
|
<!-- Reward End Time -->
|
||||||
<!-- Reward End Time -->
|
<dt>
|
||||||
<div>
|
{% if reward_campaign.ends_at > now %}
|
||||||
{% if reward_campaign.ends_at > now %}
|
Ends
|
||||||
Ends
|
{% else %}
|
||||||
{% else %}
|
Ended at
|
||||||
Ended at
|
{% endif %}
|
||||||
{% endif %}
|
</dt>
|
||||||
<time datetime="{{ reward_campaign.ends_at|date:'c' }}"
|
<dd>
|
||||||
title="{{ reward_campaign.ends_at|date:'DATETIME_FORMAT' }}">
|
<time datetime="{{ reward_campaign.ends_at|date:'c' }}"
|
||||||
{{ reward_campaign.ends_at|date:"M d, Y H:i" }}
|
title="{{ reward_campaign.ends_at|date:'DATETIME_FORMAT' }}">
|
||||||
|
{{ reward_campaign.ends_at|date:"M d, Y H:i" }}
|
||||||
|
</time>
|
||||||
{% if reward_campaign.ends_at > now %}
|
{% if reward_campaign.ends_at > now %}
|
||||||
(in {{ reward_campaign.ends_at|timeuntil }})
|
(in {{ reward_campaign.ends_at|timeuntil }})
|
||||||
{% else %}
|
{% else %}
|
||||||
({{ reward_campaign.ends_at|timesince }} ago)
|
({{ reward_campaign.ends_at|timesince }} ago)
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</time>
|
</dd>
|
||||||
</div>
|
<!-- Data source -->
|
||||||
<div>
|
<dt>Data source</dt>
|
||||||
{% if reward_campaign.instructions %}{{ reward_campaign.instructions|linebreaksbr }}{% endif %}
|
<dd>
|
||||||
</div>
|
{{ reward_campaign.data_source }}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
<div>{% if reward_campaign.instructions %}{{ reward_campaign.instructions|linebreaksbr }}{% endif %}</div>
|
||||||
<!-- RSS Feeds -->
|
<!-- RSS Feeds -->
|
||||||
<div style="margin-bottom: 1rem;">
|
<div style="margin-bottom: 1rem;">
|
||||||
<a href="{% url 'core:reward_campaign_feed' %}"
|
<a href="{% url 'core:reward_campaign_feed' %}"
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,10 @@
|
||||||
<div>
|
<div>
|
||||||
<a href="{% url 'twitch:dashboard' %}">Twitch</a> > Reward Campaigns
|
<a href="{% url 'twitch:dashboard' %}">Twitch</a> > Reward Campaigns
|
||||||
</div>
|
</div>
|
||||||
<p>This is an archive of old Twitch reward campaigns because we currently do not scrape them.</p>
|
|
||||||
<p>
|
<p>
|
||||||
Feel free to submit a pull request on <a href="https://github.com/TheLovinator1/ttvdrops">GitHub</a>
|
Data is scraped from <a href="https://github.com/SunkwiBOT/twitch-drops-api"
|
||||||
with a working implementation :-).
|
target="_blank"
|
||||||
|
rel="noopener">SunkwiBOT/twitch-drops-api</a> every 15 minutes.
|
||||||
</p>
|
</p>
|
||||||
<!-- RSS Feeds -->
|
<!-- RSS Feeds -->
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -36,84 +36,81 @@
|
||||||
title="Atom feed for all reward campaigns">[atom]</a>
|
title="Atom feed for all reward campaigns">[atom]</a>
|
||||||
<a href="{% url 'core:reward_campaign_feed_discord' %}"
|
<a href="{% url 'core:reward_campaign_feed_discord' %}"
|
||||||
title="Discord feed for all reward campaigns">[discord]</a>
|
title="Discord feed for all reward campaigns">[discord]</a>
|
||||||
|
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
|
||||||
<a href="{% url 'twitch:twitch-api-v1:list_reward_campaigns' %}"
|
<a href="{% url 'twitch:twitch-api-v1:list_reward_campaigns' %}"
|
||||||
title="Twitch reward campaigns API">[api]</a>
|
title="Twitch reward campaigns API">[api]</a>
|
||||||
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
|
|
||||||
</div>
|
</div>
|
||||||
{% if reward_campaigns %}
|
{% if reward_campaigns %}
|
||||||
{% comment %}
|
{% with active_campaigns=reward_campaigns|dictsortreversed:"ends_at" %}
|
||||||
<h5>Active Reward Campaigns</h5>
|
<h5>Active Reward Campaigns</h5>
|
||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for campaign in reward_campaigns %}
|
{% for campaign in active_campaigns %}
|
||||||
{% if campaign.starts_at <= now and campaign.ends_at >= now %}
|
{% if campaign.starts_at <= now and campaign.ends_at >= now %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a href="{% url 'twitch:reward_campaign_detail' campaign.twitch_id %}">
|
<a href="{% url 'twitch:reward_campaign_detail' campaign.twitch_id %}">
|
||||||
{% if campaign.brand %}
|
{% if campaign.brand %}
|
||||||
{{ campaign.brand }}: {{ campaign.name }}
|
{{ campaign.brand }}: {{ campaign.name }}
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ campaign.name }}
|
{{ campaign.name }}
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% if campaign.summary %}
|
||||||
|
<br />
|
||||||
|
<small>{{ campaign.summary }}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</td>
|
||||||
{% if campaign.summary %}
|
<td>
|
||||||
<br />
|
{% if campaign.game %}
|
||||||
<small>{{ campaign.summary }}</small>
|
<a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">{{ campaign.game.display_name }}</a>
|
||||||
{% endif %}
|
{% elif campaign.is_sitewide %}
|
||||||
</td>
|
Site-wide
|
||||||
<td>
|
|
||||||
{% if campaign.game %}
|
|
||||||
<a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">{{ campaign.game.display_name }}</a>
|
|
||||||
{% elif campaign.is_sitewide %}
|
|
||||||
Site-wide
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span title="{{ campaign.ends_at|date:'M d, Y H:i' }}">Ends in {{ campaign.ends_at|timeuntil }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% endcomment %}
|
|
||||||
{% comment %}
|
|
||||||
<h5>Upcoming Reward Campaigns</h5>
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
{% for campaign in reward_campaigns %}
|
|
||||||
|
|
||||||
{% if campaign.starts_at > now %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<a href="{% url 'twitch:reward_campaign_detail' campaign.twitch_id %}">
|
|
||||||
{% if campaign.brand %}
|
|
||||||
{{ campaign.brand }}: {{ campaign.name }}
|
|
||||||
{% else %}
|
|
||||||
{{ campaign.name }}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</td>
|
||||||
{% if campaign.summary %}
|
<td>
|
||||||
<br />
|
<span title="{{ campaign.ends_at|date:'M d, Y H:i' }}">Ends in {{ campaign.ends_at|timeuntil }}</span>
|
||||||
<small>{{ campaign.summary }}</small>
|
</td>
|
||||||
{% endif %}
|
</tr>
|
||||||
</td>
|
{% endif %}
|
||||||
<td>
|
{% endfor %}
|
||||||
{% if campaign.game %}
|
</tbody>
|
||||||
<a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">{{ campaign.game.display_name }}</a>
|
</table>
|
||||||
{% elif campaign.is_sitewide %}
|
<h5>Upcoming Reward Campaigns</h5>
|
||||||
Site-wide
|
<table>
|
||||||
{% endif %}
|
<tbody>
|
||||||
</td>
|
{% for campaign in active_campaigns %}
|
||||||
<td>
|
{% if campaign.starts_at > now %}
|
||||||
<span title="Starts on {{ campaign.starts_at|date:'M d, Y H:i' }}">Starts in {{ campaign.starts_at|timeuntil }}</span>
|
<tr>
|
||||||
</td>
|
<td>
|
||||||
</tr>
|
<a href="{% url 'twitch:reward_campaign_detail' campaign.twitch_id %}">
|
||||||
{% endif %}
|
{% if campaign.brand %}
|
||||||
{% endfor %}
|
{{ campaign.brand }}: {{ campaign.name }}
|
||||||
</tbody>
|
{% else %}
|
||||||
</table>
|
{{ campaign.name }}
|
||||||
{% endcomment %}
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% if campaign.summary %}
|
||||||
|
<br />
|
||||||
|
<small>{{ campaign.summary }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if campaign.game %}
|
||||||
|
<a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">{{ campaign.game.display_name }}</a>
|
||||||
|
{% elif campaign.is_sitewide %}
|
||||||
|
Site-wide
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span title="Starts on {{ campaign.starts_at|date:'M d, Y H:i' }}">Starts in {{ campaign.starts_at|timeuntil }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endwith %}
|
||||||
<h5>Past Reward Campaigns</h5>
|
<h5>Past Reward Campaigns</h5>
|
||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
|
||||||
364
tools/extract_historical_drops.py
Executable file
364
tools/extract_historical_drops.py
Executable file
|
|
@ -0,0 +1,364 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Extract all unique historical versions of drops.json/rewards.json from the SunkwiBOT/twitch-drops-api repo and merge them into a single JSON file.
|
||||||
|
|
||||||
|
For each campaign (identified by its ``id``), channels from ALL commits are
|
||||||
|
accumulated and deduplicated. The output file can be imported in a single
|
||||||
|
ORM pass via ``--from-file``, which is much faster than iterating 14k+
|
||||||
|
git commits through Django.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python tools/extract_historical_drops.py [--rewards] [--git-dir DIR] [--output FILE]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess # noqa: S404
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
REPO_URL = "https://github.com/SunkwiBOT/twitch-drops-api"
|
||||||
|
|
||||||
|
# Fields that should be taken from the *first* occurrence of a campaign
|
||||||
|
# (game metadata, names, descriptions, timestamps, etc.)
|
||||||
|
CAMPAIGN_FIRST_FIELDS = {
|
||||||
|
"gameId",
|
||||||
|
"gameDisplayName",
|
||||||
|
"gameBoxArtURL",
|
||||||
|
"startAt",
|
||||||
|
"endAt",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fields within individual rewards that should be first-occurrence only
|
||||||
|
REWARD_FIRST_FIELDS: set[str] = set()
|
||||||
|
|
||||||
|
# Fields that should be *merged* across all occurrences (channels, time-based drops)
|
||||||
|
REWARD_MERGE_FIELDS = {"channels"} # mapped from allow.channels
|
||||||
|
|
||||||
|
|
||||||
|
def run_git(git_dir: str, *args: str, check: bool = True) -> str:
|
||||||
|
"""Run a git command and return stdout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
git_dir: Path to the git repository.
|
||||||
|
*args: Git command arguments.
|
||||||
|
check: If True, raise on non-zero exit.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The stdout output of the git command.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
subprocess.CalledProcessError: If the command fails and check is True.
|
||||||
|
FileNotFoundError: If the git executable is not found.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
git: str | None = shutil.which("git")
|
||||||
|
if not git:
|
||||||
|
msg = "Git executable not found in PATH."
|
||||||
|
raise FileNotFoundError(msg)
|
||||||
|
result: subprocess.CompletedProcess[str] = subprocess.run( # noqa: S603
|
||||||
|
[git, "--git-dir", git_dir, *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=check,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
if check:
|
||||||
|
raise
|
||||||
|
return ""
|
||||||
|
else:
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def extract(
|
||||||
|
git_dir: str,
|
||||||
|
file_path: str,
|
||||||
|
*,
|
||||||
|
skip_latest: bool = True,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Extract and merge all historical versions of *file_path* from the repo.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of top-level entries (drop groups or reward campaigns)
|
||||||
|
with channels accumulated across all commits.
|
||||||
|
"""
|
||||||
|
log_output = run_git(
|
||||||
|
git_dir,
|
||||||
|
"log",
|
||||||
|
"--all",
|
||||||
|
"--format=%H %ct",
|
||||||
|
"--follow",
|
||||||
|
"--reverse",
|
||||||
|
"--",
|
||||||
|
file_path,
|
||||||
|
)
|
||||||
|
lines = [ln.strip() for ln in log_output.strip().split("\n") if ln.strip()]
|
||||||
|
|
||||||
|
if skip_latest and lines:
|
||||||
|
lines = lines[:-1]
|
||||||
|
|
||||||
|
# Per-campaign-id accumulators
|
||||||
|
# Structure: {twitch_id: {"_first_data": {...}, "_channels": {channel_id: channel_data}}}
|
||||||
|
campaigns: dict[str, dict[str, Any]] = {}
|
||||||
|
# For drops.json, track groups separately
|
||||||
|
groups: dict[str, dict[str, Any]] = {}
|
||||||
|
total_processed = 0
|
||||||
|
seen_hashes: set[str] = set()
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
sha = line.split(None, 1)[0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = run_git(git_dir, "show", f"{sha}:{file_path}", check=True)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
content_hash = hashlib.sha256(content.encode()).hexdigest()
|
||||||
|
if content_hash in seen_hashes:
|
||||||
|
continue
|
||||||
|
seen_hashes.add(content_hash)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(content)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if file_path == "drops.json":
|
||||||
|
_merge_drops_version(data, groups, campaigns)
|
||||||
|
else:
|
||||||
|
_merge_rewards_version(data, campaigns)
|
||||||
|
|
||||||
|
total_processed += 1
|
||||||
|
|
||||||
|
# Build final output from accumulated data
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
if file_path == "drops.json":
|
||||||
|
# Sort groups by their startAt for stable output
|
||||||
|
sorted_groups = sorted(groups.values(), key=lambda g: g.get("startAt", ""))
|
||||||
|
for group in sorted_groups:
|
||||||
|
group_rewards = [
|
||||||
|
_build_reward_output(campaigns[cid])
|
||||||
|
for cid in group.get("_reward_ids", [])
|
||||||
|
if cid in campaigns
|
||||||
|
]
|
||||||
|
if group_rewards:
|
||||||
|
entry = {k: v for k, v in group.items() if not k.startswith("_")}
|
||||||
|
entry["rewards"] = group_rewards
|
||||||
|
result.append(entry)
|
||||||
|
else:
|
||||||
|
sorted_campaigns = sorted(
|
||||||
|
campaigns.values(),
|
||||||
|
key=lambda c: c.get("_first_data", {}).get("startAt", ""),
|
||||||
|
)
|
||||||
|
result.extend(_build_reward_output(camp) for camp in sorted_campaigns)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_drops_version(
|
||||||
|
data: list[dict[str, Any]],
|
||||||
|
groups: dict[str, dict[str, Any]],
|
||||||
|
campaigns: dict[str, dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
"""Merge a single commit's drops.json data into the accumulators."""
|
||||||
|
for item in data:
|
||||||
|
game_id = item.get("gameId", "")
|
||||||
|
if game_id not in groups:
|
||||||
|
groups[game_id] = {k: v for k, v in item.items() if k != "rewards"}
|
||||||
|
groups[game_id]["_reward_ids"] = []
|
||||||
|
|
||||||
|
for reward in item.get("rewards", []):
|
||||||
|
rid = reward.get("id", "")
|
||||||
|
if not rid:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if rid not in campaigns:
|
||||||
|
# First occurrence: store all fields
|
||||||
|
campaigns[rid] = {
|
||||||
|
"_first_data": dict(reward),
|
||||||
|
"_channels": {},
|
||||||
|
}
|
||||||
|
groups[game_id]["_reward_ids"].append(rid)
|
||||||
|
else:
|
||||||
|
# Subsequent occurrence: merge metadata + channels
|
||||||
|
_merge_metadata(campaigns[rid], reward)
|
||||||
|
_merge_channels(campaigns[rid], reward)
|
||||||
|
|
||||||
|
# Always merge channels from every commit
|
||||||
|
_merge_channels(campaigns[rid], reward)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_rewards_version(
|
||||||
|
data: list[dict[str, Any]],
|
||||||
|
campaigns: dict[str, dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
"""Merge a single commit's rewards.json data into the accumulators."""
|
||||||
|
for reward in data:
|
||||||
|
rid = reward.get("id", "")
|
||||||
|
if not rid:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if rid not in campaigns:
|
||||||
|
campaigns[rid] = {
|
||||||
|
"_first_data": dict(reward),
|
||||||
|
"_channels": {},
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Subsequent occurrence: merge metadata
|
||||||
|
_merge_metadata(campaigns[rid], reward)
|
||||||
|
|
||||||
|
_merge_channels(campaigns[rid], reward)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_metadata(
|
||||||
|
campaign: dict[str, Any],
|
||||||
|
reward_data: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Fill empty/missing fields in ``_first_data`` with values from later commits.
|
||||||
|
|
||||||
|
For each top-level field in *reward_data*, if the stored value in
|
||||||
|
``_first_data`` is empty/None and the new value is not, update it.
|
||||||
|
This ensures campaigns get the best available description, dates,
|
||||||
|
image URL, etc. across all historical versions.
|
||||||
|
"""
|
||||||
|
first = campaign["_first_data"]
|
||||||
|
|
||||||
|
# Top-level string/primitive fields to merge (first non-empty wins)
|
||||||
|
for key in (
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"imageURL",
|
||||||
|
"detailsURL",
|
||||||
|
"accountLinkURL",
|
||||||
|
"startAt",
|
||||||
|
"endAt",
|
||||||
|
"status",
|
||||||
|
):
|
||||||
|
old_val = first.get(key)
|
||||||
|
new_val = reward_data.get(key)
|
||||||
|
if not old_val and new_val:
|
||||||
|
first[key] = new_val
|
||||||
|
|
||||||
|
# Nested fields: allow.isEnabled
|
||||||
|
old_allow = first.get("allow")
|
||||||
|
new_allow = reward_data.get("allow")
|
||||||
|
if (
|
||||||
|
isinstance(old_allow, dict)
|
||||||
|
and isinstance(new_allow, dict)
|
||||||
|
and not old_allow.get("isEnabled")
|
||||||
|
and new_allow.get("isEnabled")
|
||||||
|
):
|
||||||
|
old_allow["isEnabled"] = new_allow["isEnabled"]
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_channels(
|
||||||
|
campaign: dict[str, Any],
|
||||||
|
reward_data: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Merge channels from *reward_data* into the accumulated *campaign*."""
|
||||||
|
allow = reward_data.get("allow")
|
||||||
|
if not allow or not isinstance(allow, dict):
|
||||||
|
return
|
||||||
|
channels = allow.get("channels")
|
||||||
|
if not channels or not isinstance(channels, list):
|
||||||
|
return
|
||||||
|
|
||||||
|
for ch in channels:
|
||||||
|
ch_id = ch.get("id", "")
|
||||||
|
if ch_id and ch_id not in campaign["_channels"]:
|
||||||
|
campaign["_channels"][ch_id] = dict(ch)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_reward_output(campaign: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Build the final reward dict from accumulated data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The merged reward dict with all accumulated channels.
|
||||||
|
"""
|
||||||
|
first = campaign["_first_data"]
|
||||||
|
|
||||||
|
# Start with all first-occurrence data
|
||||||
|
result = dict(first)
|
||||||
|
|
||||||
|
# Build the allow field with merged channels
|
||||||
|
merged_channels = list(campaign["_channels"].values())
|
||||||
|
if merged_channels or "allow" in first:
|
||||||
|
allow = (
|
||||||
|
dict(first.get("allow", {})) if isinstance(first.get("allow"), dict) else {}
|
||||||
|
)
|
||||||
|
allow["channels"] = merged_channels
|
||||||
|
allow["isEnabled"] = (
|
||||||
|
first.get("allow", {}).get("isEnabled", True)
|
||||||
|
if isinstance(first.get("allow"), dict)
|
||||||
|
else True
|
||||||
|
)
|
||||||
|
result["allow"] = allow
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Entry point: clone repo, extract historical data, write output JSON."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Extract historical drops data from SunkwiBOT/twitch-drops-api",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--rewards",
|
||||||
|
action="store_true",
|
||||||
|
help="Extract rewards.json instead of drops.json",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--git-dir",
|
||||||
|
type=str,
|
||||||
|
default="",
|
||||||
|
help="Path to an existing bare clone (avoids re-cloning)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=str,
|
||||||
|
default="",
|
||||||
|
help="Output file path (default: drops_merged.json or rewards_merged.json)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
file_path = "rewards.json" if args.rewards else "drops.json"
|
||||||
|
default_output = "rewards_merged.json" if args.rewards else "drops_merged.json"
|
||||||
|
output_path = args.output or default_output
|
||||||
|
|
||||||
|
git_dir = args.git_dir
|
||||||
|
cleanup = False
|
||||||
|
tmp: Path | None = None
|
||||||
|
|
||||||
|
if not git_dir:
|
||||||
|
tmp = Path(tempfile.mkdtemp())
|
||||||
|
run_git(
|
||||||
|
str(tmp),
|
||||||
|
"clone",
|
||||||
|
"--filter=blob:none",
|
||||||
|
"--bare",
|
||||||
|
REPO_URL,
|
||||||
|
str(tmp / "repo.git"),
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
git_dir = str(tmp / "repo.git")
|
||||||
|
cleanup = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = extract(git_dir, file_path)
|
||||||
|
with Path(output_path).open("w", encoding="utf-8") as f:
|
||||||
|
json.dump(result, f, indent=2)
|
||||||
|
finally:
|
||||||
|
if cleanup and tmp is not None:
|
||||||
|
shutil.rmtree(str(tmp), ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -409,7 +409,8 @@ def _serialize_game_summary(
|
||||||
display_name=game.display_name,
|
display_name=game.display_name,
|
||||||
box_art_url=_game_box_art_url(game),
|
box_art_url=_game_box_art_url(game),
|
||||||
organizations=[
|
organizations=[
|
||||||
_serialize_organization_summary(org) for org in game.owners.all()
|
_serialize_organization_summary(org)
|
||||||
|
for org in getattr(game, "owners_for_detail", game.owners.all())
|
||||||
],
|
],
|
||||||
campaign_count=_game_campaign_count(game),
|
campaign_count=_game_campaign_count(game),
|
||||||
active_campaign_count=_game_active_campaign_count(game, now),
|
active_campaign_count=_game_active_campaign_count(game, now),
|
||||||
|
|
@ -424,7 +425,8 @@ def _serialize_game(game: Game, now: datetime.datetime) -> V1GameSchema:
|
||||||
display_name=game.display_name,
|
display_name=game.display_name,
|
||||||
box_art_url=_game_box_art_url(game),
|
box_art_url=_game_box_art_url(game),
|
||||||
organizations=[
|
organizations=[
|
||||||
_serialize_organization_summary(org) for org in game.owners.all()
|
_serialize_organization_summary(org)
|
||||||
|
for org in getattr(game, "owners_for_detail", game.owners.all())
|
||||||
],
|
],
|
||||||
campaign_count=_game_campaign_count(game),
|
campaign_count=_game_campaign_count(game),
|
||||||
active_campaign_count=_game_active_campaign_count(game, now),
|
active_campaign_count=_game_active_campaign_count(game, now),
|
||||||
|
|
@ -520,7 +522,11 @@ def _serialize_campaign_detail(
|
||||||
operation_names=campaign.operation_names,
|
operation_names=campaign.operation_names,
|
||||||
allowed_channels=[
|
allowed_channels=[
|
||||||
_serialize_channel_summary(channel)
|
_serialize_channel_summary(channel)
|
||||||
for channel in campaign.allow_channels.all()
|
for channel in getattr(
|
||||||
|
campaign,
|
||||||
|
"channels_ordered",
|
||||||
|
campaign.allow_channels.all(),
|
||||||
|
)
|
||||||
],
|
],
|
||||||
drops=[_serialize_drop(drop) for drop in campaign.time_based_drops.all()], # pyright: ignore[reportAttributeAccessIssue]
|
drops=[_serialize_drop(drop) for drop in campaign.time_based_drops.all()], # pyright: ignore[reportAttributeAccessIssue]
|
||||||
)
|
)
|
||||||
|
|
@ -643,7 +649,8 @@ def get_game(request: HttpRequest, twitch_id: str) -> V1GameDetailSchema:
|
||||||
display_name=game.display_name,
|
display_name=game.display_name,
|
||||||
box_art_url=_game_box_art_url(game),
|
box_art_url=_game_box_art_url(game),
|
||||||
organizations=[
|
organizations=[
|
||||||
_serialize_organization_summary(org) for org in game.owners.all()
|
_serialize_organization_summary(org)
|
||||||
|
for org in getattr(game, "owners_for_detail", game.owners.all())
|
||||||
],
|
],
|
||||||
campaign_count=_game_campaign_count(game),
|
campaign_count=_game_campaign_count(game),
|
||||||
active_campaign_count=_game_active_campaign_count(game, now),
|
active_campaign_count=_game_active_campaign_count(game, now),
|
||||||
|
|
@ -700,7 +707,10 @@ def get_organization(
|
||||||
name=org.name,
|
name=org.name,
|
||||||
added_at=org.added_at,
|
added_at=org.added_at,
|
||||||
updated_at=org.updated_at,
|
updated_at=org.updated_at,
|
||||||
games=[_serialize_game_summary(game, now) for game in org.games.all()], # pyright: ignore[reportAttributeAccessIssue]
|
games=[
|
||||||
|
_serialize_game_summary(game, now)
|
||||||
|
for game in getattr(org, "games_for_detail", org.games.all()) # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
],
|
||||||
campaigns=[_serialize_campaign_detail(campaign, now) for campaign in campaigns],
|
campaigns=[_serialize_campaign_detail(campaign, now) for campaign in campaigns],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ class TwitchConfig(AppConfig):
|
||||||
# Patch FieldFile.open to swallow FileNotFoundError and provide
|
# Patch FieldFile.open to swallow FileNotFoundError and provide
|
||||||
# an empty in-memory file-like object so image dimension
|
# an empty in-memory file-like object so image dimension
|
||||||
# calculations don't crash when the on-disk file was removed.
|
# calculations don't crash when the on-disk file was removed.
|
||||||
try:
|
if hasattr(FieldFile, "open"):
|
||||||
orig_open: Callable[..., FieldFile] = FieldFile.open
|
orig_open: Callable[..., FieldFile] = FieldFile.open
|
||||||
|
|
||||||
def _safe_open(self: FieldFile, mode: str = "rb") -> FieldFile:
|
def _safe_open(self: FieldFile, mode: str = "rb") -> FieldFile:
|
||||||
|
|
@ -34,8 +34,8 @@ class TwitchConfig(AppConfig):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
FieldFile.open = _safe_open
|
FieldFile.open = _safe_open
|
||||||
except (AttributeError, TypeError) as exc:
|
else:
|
||||||
logger.debug("Failed to patch FieldFile.open: %s", exc)
|
logger.debug("FieldFile has no 'open' attribute; skipping patch")
|
||||||
|
|
||||||
# Register post_save signal handlers that dispatch image download tasks
|
# Register post_save signal handlers that dispatch image download tasks
|
||||||
# when new Twitch records are created.
|
# when new Twitch records are created.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,23 +26,25 @@ class Command(BaseCommand):
|
||||||
or not game.box_art_mime_type
|
or not game.box_art_mime_type
|
||||||
or not game.box_art_width
|
or not game.box_art_width
|
||||||
)
|
)
|
||||||
if game.box_art_file and needs_update:
|
if not (game.box_art_file and needs_update):
|
||||||
try:
|
continue
|
||||||
if not game.box_art_width:
|
|
||||||
game.box_art_file.open()
|
|
||||||
|
|
||||||
# populate size and mime if available
|
try:
|
||||||
with contextlib.suppress(Exception):
|
if not game.box_art_width:
|
||||||
game.box_art_size_bytes = game.box_art_file.size
|
game.box_art_file.open()
|
||||||
mime, _ = mimetypes.guess_type(game.box_art_file.name or "")
|
with contextlib.suppress(Exception):
|
||||||
if mime:
|
game.box_art_size_bytes = game.box_art_file.size
|
||||||
game.box_art_mime_type = mime
|
except (OSError, ValueError, AttributeError) as exc:
|
||||||
|
self.stdout.write(self.style.ERROR(f" Failed {game}: {exc}"))
|
||||||
|
continue
|
||||||
|
|
||||||
game.save()
|
mime, _ = mimetypes.guess_type(game.box_art_file.name or "")
|
||||||
total_updated += 1
|
if mime:
|
||||||
self.stdout.write(self.style.SUCCESS(f" Updated {game}"))
|
game.box_art_mime_type = mime
|
||||||
except (OSError, ValueError, AttributeError) as exc:
|
|
||||||
self.stdout.write(self.style.ERROR(f" Failed {game}: {exc}"))
|
game.save()
|
||||||
|
total_updated += 1
|
||||||
|
self.stdout.write(self.style.SUCCESS(f" Updated {game}"))
|
||||||
|
|
||||||
# Update DropCampaign images
|
# Update DropCampaign images
|
||||||
self.stdout.write("Processing DropCampaign image_file...")
|
self.stdout.write("Processing DropCampaign image_file...")
|
||||||
|
|
@ -52,22 +54,25 @@ class Command(BaseCommand):
|
||||||
or not campaign.image_mime_type
|
or not campaign.image_mime_type
|
||||||
or not campaign.image_width
|
or not campaign.image_width
|
||||||
)
|
)
|
||||||
if campaign.image_file and needs_update:
|
if not (campaign.image_file and needs_update):
|
||||||
try:
|
continue
|
||||||
if not campaign.image_width:
|
|
||||||
campaign.image_file.open()
|
|
||||||
|
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
campaign.image_size_bytes = campaign.image_file.size
|
if not campaign.image_width:
|
||||||
mime, _ = mimetypes.guess_type(campaign.image_file.name or "")
|
campaign.image_file.open()
|
||||||
if mime:
|
with contextlib.suppress(Exception):
|
||||||
campaign.image_mime_type = mime
|
campaign.image_size_bytes = campaign.image_file.size
|
||||||
|
except (OSError, ValueError, AttributeError) as exc:
|
||||||
|
self.stdout.write(self.style.ERROR(f" Failed {campaign}: {exc}"))
|
||||||
|
continue
|
||||||
|
|
||||||
campaign.save()
|
mime, _ = mimetypes.guess_type(campaign.image_file.name or "")
|
||||||
total_updated += 1
|
if mime:
|
||||||
self.stdout.write(self.style.SUCCESS(f" Updated {campaign}"))
|
campaign.image_mime_type = mime
|
||||||
except (OSError, ValueError, AttributeError) as exc:
|
|
||||||
self.stdout.write(self.style.ERROR(f" Failed {campaign}: {exc}"))
|
campaign.save()
|
||||||
|
total_updated += 1
|
||||||
|
self.stdout.write(self.style.SUCCESS(f" Updated {campaign}"))
|
||||||
|
|
||||||
# Update DropBenefit images
|
# Update DropBenefit images
|
||||||
self.stdout.write("Processing DropBenefit image_file...")
|
self.stdout.write("Processing DropBenefit image_file...")
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from collections.abc import Mapping
|
|
||||||
from datetime import UTC
|
from datetime import UTC
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -340,7 +339,41 @@ def extract_operation_name_from_parsed(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def repair_partially_broken_json(raw_text: str) -> str: # noqa: PLR0915
|
def _validate_repair_result(
|
||||||
|
fixed: str,
|
||||||
|
parsed_data: dict | list | str,
|
||||||
|
) -> str | None:
|
||||||
|
"""Validate that a repaired JSON result contains valid GraphQL responses.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fixed: The repaired JSON string.
|
||||||
|
parsed_data: The parsed JSON data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The string to return if valid, or None to continue trying other strategies.
|
||||||
|
"""
|
||||||
|
# If it's a list, validate all items are GraphQL responses
|
||||||
|
if isinstance(parsed_data, list):
|
||||||
|
# Filter to only keep GraphQL responses
|
||||||
|
filtered = [
|
||||||
|
item
|
||||||
|
for item in parsed_data
|
||||||
|
if isinstance(item, dict) and ("data" in item or "extensions" in item)
|
||||||
|
]
|
||||||
|
if filtered:
|
||||||
|
# If we filtered anything out, return the filtered version
|
||||||
|
if len(filtered) < len(parsed_data):
|
||||||
|
return json.dumps(filtered)
|
||||||
|
# Otherwise return as-is
|
||||||
|
return fixed
|
||||||
|
# Single dict - check if it's a GraphQL response
|
||||||
|
elif isinstance(parsed_data, dict):
|
||||||
|
if "data" in parsed_data or "extensions" in parsed_data:
|
||||||
|
return fixed
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def repair_partially_broken_json(raw_text: str) -> str:
|
||||||
"""Attempt to repair partially broken JSON with multiple fallback strategies.
|
"""Attempt to repair partially broken JSON with multiple fallback strategies.
|
||||||
|
|
||||||
Handles "half-bad" JSON by:
|
Handles "half-bad" JSON by:
|
||||||
|
|
@ -357,27 +390,10 @@ def repair_partially_broken_json(raw_text: str) -> str: # noqa: PLR0915
|
||||||
# Strategy 1: Direct repair attempt
|
# Strategy 1: Direct repair attempt
|
||||||
try:
|
try:
|
||||||
fixed: str = json_repair.repair_json(raw_text, logging=False)
|
fixed: str = json_repair.repair_json(raw_text, logging=False)
|
||||||
# Validate it produces valid JSON
|
|
||||||
parsed_data = json.loads(fixed)
|
parsed_data = json.loads(fixed)
|
||||||
|
result: str | None = _validate_repair_result(fixed, parsed_data)
|
||||||
# If it's a list, validate all items are GraphQL responses
|
if result is not None:
|
||||||
if isinstance(parsed_data, list):
|
return result
|
||||||
# Filter to only keep GraphQL responses
|
|
||||||
filtered = [
|
|
||||||
item
|
|
||||||
for item in parsed_data
|
|
||||||
if isinstance(item, dict) and ("data" in item or "extensions" in item)
|
|
||||||
]
|
|
||||||
if filtered:
|
|
||||||
# If we filtered anything out, return the filtered version
|
|
||||||
if len(filtered) < len(parsed_data):
|
|
||||||
return json.dumps(filtered)
|
|
||||||
# Otherwise return as-is
|
|
||||||
return fixed
|
|
||||||
# Single dict - check if it's a GraphQL response
|
|
||||||
elif isinstance(parsed_data, dict):
|
|
||||||
if "data" in parsed_data or "extensions" in parsed_data:
|
|
||||||
return fixed
|
|
||||||
except ValueError, TypeError, json.JSONDecodeError:
|
except ValueError, TypeError, json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -1142,30 +1158,92 @@ class Command(BaseCommand):
|
||||||
f"{Fore.GREEN}✓{Style.RESET_ALL} {action} reward campaign: {display_name}",
|
f"{Fore.GREEN}✓{Style.RESET_ALL} {action} reward campaign: {display_name}",
|
||||||
)
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options) -> None: # noqa: ARG002
|
def _dispatch_path_processing(
|
||||||
"""Main entry point for the command.
|
self,
|
||||||
|
input_path: Path,
|
||||||
|
options: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Dispatch processing based on whether the path is a file or directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_path: Resolved path to process.
|
||||||
|
options: Command options.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
CommandError: If the provided path does not exist.
|
CommandError: If the path does not exist.
|
||||||
"""
|
"""
|
||||||
|
if input_path.is_file():
|
||||||
|
self.process_file(file_path=input_path, options=options)
|
||||||
|
elif input_path.is_dir():
|
||||||
|
self.process_json_files(input_path=input_path, options=options)
|
||||||
|
else:
|
||||||
|
msg: str = f"Path does not exist: {input_path}"
|
||||||
|
raise CommandError(msg)
|
||||||
|
|
||||||
|
def handle(self, *args, **options) -> None: # noqa: ARG002
|
||||||
|
"""Main entry point for the command."""
|
||||||
colorama_init(autoreset=True)
|
colorama_init(autoreset=True)
|
||||||
|
|
||||||
input_path: Path = Path(options["path"]).resolve()
|
input_path: Path = Path(options["path"]).resolve()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if input_path.is_file():
|
self._dispatch_path_processing(input_path, options)
|
||||||
self.process_file(file_path=input_path, options=options)
|
|
||||||
elif input_path.is_dir():
|
|
||||||
self.process_json_files(input_path=input_path, options=options)
|
|
||||||
else:
|
|
||||||
msg: str = f"Path does not exist: {input_path}"
|
|
||||||
raise CommandError(msg)
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
tqdm.write(self.style.WARNING("\n\nInterrupted by user!"))
|
tqdm.write(self.style.WARNING("\n\nInterrupted by user!"))
|
||||||
tqdm.write(self.style.WARNING("Shutting down gracefully..."))
|
tqdm.write(self.style.WARNING("Shutting down gracefully..."))
|
||||||
sys.exit(130) # 128 + 2 (Keyboard Interrupt)
|
sys.exit(130) # 128 + 2 (Keyboard Interrupt)
|
||||||
|
|
||||||
|
def _process_single_file_result(
|
||||||
|
self,
|
||||||
|
file_path: Path,
|
||||||
|
options: dict,
|
||||||
|
progress_bar: tqdm,
|
||||||
|
) -> tuple[int, int, int]:
|
||||||
|
"""Process a single file result and update progress output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the JSON file.
|
||||||
|
options: Command options.
|
||||||
|
progress_bar: tqdm progress bar instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success_increment, failed_increment, error_increment).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result: dict[str, bool | str] = self.process_file_worker(
|
||||||
|
file_path=file_path,
|
||||||
|
options=options,
|
||||||
|
)
|
||||||
|
except (OSError, ValueError, KeyError) as e:
|
||||||
|
progress_bar.write(
|
||||||
|
f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} (error: {e})",
|
||||||
|
)
|
||||||
|
return 0, 0, 1
|
||||||
|
|
||||||
|
if result["success"]:
|
||||||
|
if options.get("verbose"):
|
||||||
|
progress_bar.write(
|
||||||
|
f"{Fore.GREEN}✓{Style.RESET_ALL} {file_path.name}",
|
||||||
|
)
|
||||||
|
return 1, 0, 0
|
||||||
|
|
||||||
|
reason: bool | str | None = (
|
||||||
|
result.get("reason") if isinstance(result, dict) else None
|
||||||
|
)
|
||||||
|
if reason:
|
||||||
|
progress_bar.write(
|
||||||
|
f"{Fore.RED}✗{Style.RESET_ALL} "
|
||||||
|
f"{file_path.name} → {result['broken_dir']}/"
|
||||||
|
f"{file_path.name} ({reason})",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
progress_bar.write(
|
||||||
|
f"{Fore.RED}✗{Style.RESET_ALL} "
|
||||||
|
f"{file_path.name} → {result['broken_dir']}/"
|
||||||
|
f"{file_path.name}",
|
||||||
|
)
|
||||||
|
return 0, 1, 0
|
||||||
|
|
||||||
def process_json_files(self, input_path: Path, options: dict) -> None:
|
def process_json_files(self, input_path: Path, options: dict) -> None:
|
||||||
"""Process multiple JSON files in a directory.
|
"""Process multiple JSON files in a directory.
|
||||||
|
|
||||||
|
|
@ -1191,39 +1269,14 @@ class Command(BaseCommand):
|
||||||
dynamic_ncols=True,
|
dynamic_ncols=True,
|
||||||
) as progress_bar:
|
) as progress_bar:
|
||||||
for file_path in json_files:
|
for file_path in json_files:
|
||||||
try:
|
s_inc, f_inc, e_inc = self._process_single_file_result(
|
||||||
result: dict[str, bool | str] = self.process_file_worker(
|
file_path=file_path,
|
||||||
file_path=file_path,
|
options=options,
|
||||||
options=options,
|
progress_bar=progress_bar,
|
||||||
)
|
)
|
||||||
if result["success"]:
|
success_count += s_inc
|
||||||
success_count += 1
|
failed_count += f_inc
|
||||||
if options.get("verbose"):
|
error_count += e_inc
|
||||||
progress_bar.write(
|
|
||||||
f"{Fore.GREEN}✓{Style.RESET_ALL} {file_path.name}",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
failed_count += 1
|
|
||||||
reason: bool | str | None = (
|
|
||||||
result.get("reason") if isinstance(result, dict) else None
|
|
||||||
)
|
|
||||||
if reason:
|
|
||||||
progress_bar.write(
|
|
||||||
f"{Fore.RED}✗{Style.RESET_ALL} "
|
|
||||||
f"{file_path.name} → {result['broken_dir']}/"
|
|
||||||
f"{file_path.name} ({reason})",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
progress_bar.write(
|
|
||||||
f"{Fore.RED}✗{Style.RESET_ALL} "
|
|
||||||
f"{file_path.name} → {result['broken_dir']}/"
|
|
||||||
f"{file_path.name}",
|
|
||||||
)
|
|
||||||
except (OSError, ValueError, KeyError) as e:
|
|
||||||
error_count += 1
|
|
||||||
progress_bar.write(
|
|
||||||
f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} (error: {e})",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update postfix with statistics
|
# Update postfix with statistics
|
||||||
progress_bar.set_postfix_str(
|
progress_bar.set_postfix_str(
|
||||||
|
|
@ -1385,6 +1438,111 @@ class Command(BaseCommand):
|
||||||
return []
|
return []
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def _run_worker_inner(
|
||||||
|
self,
|
||||||
|
file_path: Path,
|
||||||
|
options: dict,
|
||||||
|
) -> dict[str, bool | str]:
|
||||||
|
"""Inner processing logic for the file worker.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the JSON file to process.
|
||||||
|
options: Command options.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with success status and optional broken_dir path.
|
||||||
|
"""
|
||||||
|
raw_text: str = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
|
||||||
|
# Repair potentially broken JSON with multiple fallback strategies
|
||||||
|
fixed_json_str: str = repair_partially_broken_json(raw_text)
|
||||||
|
parsed_json: (
|
||||||
|
JSONReturnType | tuple[JSONReturnType, list[dict[str, str]]] | str
|
||||||
|
) = json.loads(fixed_json_str)
|
||||||
|
operation_name: str | None = extract_operation_name_from_parsed(parsed_json)
|
||||||
|
|
||||||
|
# Check for error-only responses first
|
||||||
|
error_description: str | None = detect_error_only_response(parsed_json)
|
||||||
|
if error_description:
|
||||||
|
if not options.get("skip_broken_moves"):
|
||||||
|
broken_dir: Path | None = move_file_to_broken_subdir(
|
||||||
|
file_path,
|
||||||
|
error_description,
|
||||||
|
operation_name=operation_name,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"broken_dir": str(broken_dir),
|
||||||
|
"reason": error_description,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"broken_dir": "(skipped)",
|
||||||
|
"reason": error_description,
|
||||||
|
}
|
||||||
|
|
||||||
|
matched: str | None = detect_non_campaign_keyword(raw_text)
|
||||||
|
if matched:
|
||||||
|
if not options.get("skip_broken_moves"):
|
||||||
|
broken_dir: Path | None = move_file_to_broken_subdir(
|
||||||
|
file_path,
|
||||||
|
matched,
|
||||||
|
operation_name=operation_name,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"broken_dir": str(broken_dir),
|
||||||
|
"reason": f"matched '{matched}'",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"broken_dir": "(skipped)",
|
||||||
|
"reason": f"matched '{matched}'",
|
||||||
|
}
|
||||||
|
if "dropCampaign" not in raw_text:
|
||||||
|
if not options.get("skip_broken_moves"):
|
||||||
|
broken_dir: Path | None = move_file_to_broken_subdir(
|
||||||
|
file_path,
|
||||||
|
"no_dropCampaign",
|
||||||
|
operation_name=operation_name,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"broken_dir": str(broken_dir),
|
||||||
|
"reason": "no dropCampaign present",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"broken_dir": "(skipped)",
|
||||||
|
"reason": "no dropCampaign present",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Normalize and filter to dict responses only
|
||||||
|
responses: list[dict[str, Any]] = self._normalize_responses(parsed_json)
|
||||||
|
processed, broken_dir = self.process_responses(
|
||||||
|
responses=responses,
|
||||||
|
file_path=file_path,
|
||||||
|
options=options,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not processed:
|
||||||
|
# File was already moved to broken during validation
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"broken_dir": str(broken_dir) if broken_dir else "(unknown)",
|
||||||
|
"reason": "validation failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
campaign_structure: str | None = self._detect_campaign_structure(
|
||||||
|
responses[0] if responses else {},
|
||||||
|
)
|
||||||
|
move_completed_file(
|
||||||
|
file_path=file_path,
|
||||||
|
operation_name=operation_name,
|
||||||
|
campaign_structure=campaign_structure,
|
||||||
|
)
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
def process_file_worker(
|
def process_file_worker(
|
||||||
self,
|
self,
|
||||||
file_path: Path,
|
file_path: Path,
|
||||||
|
|
@ -1404,96 +1562,7 @@ class Command(BaseCommand):
|
||||||
json.JSONDecodeError: If the JSON file cannot be parsed
|
json.JSONDecodeError: If the JSON file cannot be parsed
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
raw_text: str = file_path.read_text(encoding="utf-8", errors="ignore")
|
return self._run_worker_inner(file_path, options)
|
||||||
|
|
||||||
# Repair potentially broken JSON with multiple fallback strategies
|
|
||||||
fixed_json_str: str = repair_partially_broken_json(raw_text)
|
|
||||||
parsed_json: (
|
|
||||||
JSONReturnType | tuple[JSONReturnType, list[dict[str, str]]] | str
|
|
||||||
) = json.loads(fixed_json_str)
|
|
||||||
operation_name: str | None = extract_operation_name_from_parsed(parsed_json)
|
|
||||||
|
|
||||||
# Check for error-only responses first
|
|
||||||
error_description: str | None = detect_error_only_response(parsed_json)
|
|
||||||
if error_description:
|
|
||||||
if not options.get("skip_broken_moves"):
|
|
||||||
broken_dir: Path | None = move_file_to_broken_subdir(
|
|
||||||
file_path,
|
|
||||||
error_description,
|
|
||||||
operation_name=operation_name,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"broken_dir": str(broken_dir),
|
|
||||||
"reason": error_description,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"broken_dir": "(skipped)",
|
|
||||||
"reason": error_description,
|
|
||||||
}
|
|
||||||
|
|
||||||
matched: str | None = detect_non_campaign_keyword(raw_text)
|
|
||||||
if matched:
|
|
||||||
if not options.get("skip_broken_moves"):
|
|
||||||
broken_dir: Path | None = move_file_to_broken_subdir(
|
|
||||||
file_path,
|
|
||||||
matched,
|
|
||||||
operation_name=operation_name,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"broken_dir": str(broken_dir),
|
|
||||||
"reason": f"matched '{matched}'",
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"broken_dir": "(skipped)",
|
|
||||||
"reason": f"matched '{matched}'",
|
|
||||||
}
|
|
||||||
if "dropCampaign" not in raw_text:
|
|
||||||
if not options.get("skip_broken_moves"):
|
|
||||||
broken_dir: Path | None = move_file_to_broken_subdir(
|
|
||||||
file_path,
|
|
||||||
"no_dropCampaign",
|
|
||||||
operation_name=operation_name,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"broken_dir": str(broken_dir),
|
|
||||||
"reason": "no dropCampaign present",
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"broken_dir": "(skipped)",
|
|
||||||
"reason": "no dropCampaign present",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Normalize and filter to dict responses only
|
|
||||||
responses: list[dict[str, Any]] = self._normalize_responses(parsed_json)
|
|
||||||
processed, broken_dir = self.process_responses(
|
|
||||||
responses=responses,
|
|
||||||
file_path=file_path,
|
|
||||||
options=options,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not processed:
|
|
||||||
# File was already moved to broken during validation
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"broken_dir": str(broken_dir) if broken_dir else "(unknown)",
|
|
||||||
"reason": "validation failed",
|
|
||||||
}
|
|
||||||
|
|
||||||
campaign_structure: str | None = self._detect_campaign_structure(
|
|
||||||
responses[0] if responses else {},
|
|
||||||
)
|
|
||||||
move_completed_file(
|
|
||||||
file_path=file_path,
|
|
||||||
operation_name=operation_name,
|
|
||||||
campaign_structure=campaign_structure,
|
|
||||||
)
|
|
||||||
|
|
||||||
except ValidationError, json.JSONDecodeError:
|
except ValidationError, json.JSONDecodeError:
|
||||||
if options["crash_on_error"]:
|
if options["crash_on_error"]:
|
||||||
raise
|
raise
|
||||||
|
|
@ -1511,8 +1580,116 @@ class Command(BaseCommand):
|
||||||
)
|
)
|
||||||
return {"success": False, "broken_dir": str(broken_dir)}
|
return {"success": False, "broken_dir": str(broken_dir)}
|
||||||
return {"success": False, "broken_dir": "(skipped)"}
|
return {"success": False, "broken_dir": "(skipped)"}
|
||||||
else:
|
|
||||||
return {"success": True}
|
def _run_file_processing_inner(
|
||||||
|
self,
|
||||||
|
file_path: Path,
|
||||||
|
options: dict,
|
||||||
|
progress_bar: tqdm,
|
||||||
|
) -> None:
|
||||||
|
"""Inner file processing logic with progress reporting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the JSON file.
|
||||||
|
options: Command options.
|
||||||
|
progress_bar: tqdm progress bar instance.
|
||||||
|
"""
|
||||||
|
raw_text: str = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
|
||||||
|
# Repair potentially broken JSON with multiple fallback strategies
|
||||||
|
fixed_json_str: str = repair_partially_broken_json(raw_text)
|
||||||
|
parsed_json: (
|
||||||
|
JSONReturnType | tuple[JSONReturnType, list[dict[str, str]]] | str
|
||||||
|
) = json.loads(fixed_json_str)
|
||||||
|
operation_name: str | None = extract_operation_name_from_parsed(
|
||||||
|
parsed_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for error-only responses first
|
||||||
|
error_description: str | None = detect_error_only_response(parsed_json)
|
||||||
|
if error_description:
|
||||||
|
if not options.get("skip_broken_moves"):
|
||||||
|
broken_dir: Path | None = move_file_to_broken_subdir(
|
||||||
|
file_path,
|
||||||
|
error_description,
|
||||||
|
operation_name=operation_name,
|
||||||
|
)
|
||||||
|
progress_bar.write(
|
||||||
|
f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} → "
|
||||||
|
f"{broken_dir}/{file_path.name} "
|
||||||
|
f"({error_description})",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
progress_bar.write(
|
||||||
|
f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} ({error_description}, move skipped)",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
matched: str | None = detect_non_campaign_keyword(raw_text)
|
||||||
|
if matched:
|
||||||
|
if not options.get("skip_broken_moves"):
|
||||||
|
broken_dir: Path | None = move_file_to_broken_subdir(
|
||||||
|
file_path,
|
||||||
|
matched,
|
||||||
|
operation_name=operation_name,
|
||||||
|
)
|
||||||
|
progress_bar.write(
|
||||||
|
f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} → "
|
||||||
|
f"{broken_dir}/{file_path.name} "
|
||||||
|
f"(matched '{matched}')",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
progress_bar.write(
|
||||||
|
f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} (matched '{matched}', move skipped)",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if "dropCampaign" not in raw_text:
|
||||||
|
if not options.get("skip_broken_moves"):
|
||||||
|
broken_dir = move_file_to_broken_subdir(
|
||||||
|
file_path,
|
||||||
|
"no_dropCampaign",
|
||||||
|
operation_name=operation_name,
|
||||||
|
)
|
||||||
|
progress_bar.write(
|
||||||
|
f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} → "
|
||||||
|
f"{broken_dir}/{file_path.name} "
|
||||||
|
f"(no dropCampaign present)",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
progress_bar.write(
|
||||||
|
f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} (no dropCampaign present, move skipped)",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Normalize and filter to dict responses only
|
||||||
|
responses: list[dict[str, Any]] = self._normalize_responses(parsed_json)
|
||||||
|
|
||||||
|
processed, broken_dir = self.process_responses(
|
||||||
|
responses=responses,
|
||||||
|
file_path=file_path,
|
||||||
|
options=options,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not processed:
|
||||||
|
# File already moved during validation; nothing more to do here.
|
||||||
|
progress_bar.write(
|
||||||
|
f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} → "
|
||||||
|
f"{broken_dir}/{file_path.name} (validation failed)",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
campaign_structure: str | None = self._detect_campaign_structure(
|
||||||
|
responses[0] if responses else {},
|
||||||
|
)
|
||||||
|
move_completed_file(
|
||||||
|
file_path=file_path,
|
||||||
|
operation_name=operation_name,
|
||||||
|
campaign_structure=campaign_structure,
|
||||||
|
)
|
||||||
|
|
||||||
|
progress_bar.update(1)
|
||||||
|
progress_bar.write(f"{Fore.GREEN}✓{Style.RESET_ALL} {file_path.name}")
|
||||||
|
|
||||||
def process_file(self, file_path: Path, options: dict) -> None:
|
def process_file(self, file_path: Path, options: dict) -> None:
|
||||||
"""Reads a JSON file and processes the campaign data.
|
"""Reads a JSON file and processes the campaign data.
|
||||||
|
|
@ -1533,102 +1710,7 @@ class Command(BaseCommand):
|
||||||
dynamic_ncols=True,
|
dynamic_ncols=True,
|
||||||
) as progress_bar:
|
) as progress_bar:
|
||||||
try:
|
try:
|
||||||
raw_text: str = file_path.read_text(encoding="utf-8", errors="ignore")
|
self._run_file_processing_inner(file_path, options, progress_bar)
|
||||||
|
|
||||||
# Repair potentially broken JSON with multiple fallback strategies
|
|
||||||
fixed_json_str: str = repair_partially_broken_json(raw_text)
|
|
||||||
parsed_json: (
|
|
||||||
JSONReturnType | tuple[JSONReturnType, list[dict[str, str]]] | str
|
|
||||||
) = json.loads(fixed_json_str)
|
|
||||||
operation_name: str | None = extract_operation_name_from_parsed(
|
|
||||||
parsed_json,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for error-only responses first
|
|
||||||
error_description: str | None = detect_error_only_response(parsed_json)
|
|
||||||
if error_description:
|
|
||||||
if not options.get("skip_broken_moves"):
|
|
||||||
broken_dir: Path | None = move_file_to_broken_subdir(
|
|
||||||
file_path,
|
|
||||||
error_description,
|
|
||||||
operation_name=operation_name,
|
|
||||||
)
|
|
||||||
progress_bar.write(
|
|
||||||
f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} → "
|
|
||||||
f"{broken_dir}/{file_path.name} "
|
|
||||||
f"({error_description})",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
progress_bar.write(
|
|
||||||
f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} ({error_description}, move skipped)",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
matched: str | None = detect_non_campaign_keyword(raw_text)
|
|
||||||
if matched:
|
|
||||||
if not options.get("skip_broken_moves"):
|
|
||||||
broken_dir: Path | None = move_file_to_broken_subdir(
|
|
||||||
file_path,
|
|
||||||
matched,
|
|
||||||
operation_name=operation_name,
|
|
||||||
)
|
|
||||||
progress_bar.write(
|
|
||||||
f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} → "
|
|
||||||
f"{broken_dir}/{file_path.name} "
|
|
||||||
f"(matched '{matched}')",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
progress_bar.write(
|
|
||||||
f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} (matched '{matched}', move skipped)",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if "dropCampaign" not in raw_text:
|
|
||||||
if not options.get("skip_broken_moves"):
|
|
||||||
broken_dir = move_file_to_broken_subdir(
|
|
||||||
file_path,
|
|
||||||
"no_dropCampaign",
|
|
||||||
operation_name=operation_name,
|
|
||||||
)
|
|
||||||
progress_bar.write(
|
|
||||||
f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} → "
|
|
||||||
f"{broken_dir}/{file_path.name} "
|
|
||||||
f"(no dropCampaign present)",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
progress_bar.write(
|
|
||||||
f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} (no dropCampaign present, move skipped)",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Normalize and filter to dict responses only
|
|
||||||
responses: list[dict[str, Any]] = self._normalize_responses(parsed_json)
|
|
||||||
|
|
||||||
processed, broken_dir = self.process_responses(
|
|
||||||
responses=responses,
|
|
||||||
file_path=file_path,
|
|
||||||
options=options,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not processed:
|
|
||||||
# File already moved during validation; nothing more to do here.
|
|
||||||
progress_bar.write(
|
|
||||||
f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} → "
|
|
||||||
f"{broken_dir}/{file_path.name} (validation failed)",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
campaign_structure: str | None = self._detect_campaign_structure(
|
|
||||||
responses[0] if responses else {},
|
|
||||||
)
|
|
||||||
move_completed_file(
|
|
||||||
file_path=file_path,
|
|
||||||
operation_name=operation_name,
|
|
||||||
campaign_structure=campaign_structure,
|
|
||||||
)
|
|
||||||
|
|
||||||
progress_bar.update(1)
|
|
||||||
progress_bar.write(f"{Fore.GREEN}✓{Style.RESET_ALL} {file_path.name}")
|
|
||||||
except ValidationError, json.JSONDecodeError:
|
except ValidationError, json.JSONDecodeError:
|
||||||
if options["crash_on_error"]:
|
if options["crash_on_error"]:
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -139,45 +139,48 @@ class Command(BaseCommand):
|
||||||
image_path: Absolute path to the downloaded image file
|
image_path: Absolute path to the downloaded image file
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
source_path = Path(image_path)
|
self._perform_image_conversion(image_path)
|
||||||
if not source_path.exists() or source_path.suffix.lower() not in {
|
|
||||||
".jpg",
|
|
||||||
".jpeg",
|
|
||||||
".png",
|
|
||||||
}:
|
|
||||||
return
|
|
||||||
|
|
||||||
base_path = source_path.with_suffix("")
|
|
||||||
webp_path = base_path.with_suffix(".webp")
|
|
||||||
avif_path = base_path.with_suffix(".avif")
|
|
||||||
|
|
||||||
with Image.open(source_path) as img:
|
|
||||||
# Convert to RGB if needed
|
|
||||||
if img.mode in {"RGBA", "LA"} or (
|
|
||||||
img.mode == "P" and "transparency" in img.info
|
|
||||||
):
|
|
||||||
background = Image.new("RGB", img.size, (255, 255, 255))
|
|
||||||
rgba_img = img.convert("RGBA") if img.mode == "P" else img
|
|
||||||
background.paste(
|
|
||||||
rgba_img,
|
|
||||||
mask=rgba_img.split()[-1]
|
|
||||||
if rgba_img.mode in {"RGBA", "LA"}
|
|
||||||
else None,
|
|
||||||
)
|
|
||||||
rgb_img = background
|
|
||||||
elif img.mode != "RGB":
|
|
||||||
rgb_img = img.convert("RGB")
|
|
||||||
else:
|
|
||||||
rgb_img = img
|
|
||||||
|
|
||||||
# Save WebP
|
|
||||||
rgb_img.save(webp_path, "WEBP", quality=85, method=6)
|
|
||||||
|
|
||||||
# Save AVIF
|
|
||||||
rgb_img.save(avif_path, "AVIF", quality=85, speed=4)
|
|
||||||
|
|
||||||
except (OSError, ValueError) as e:
|
except (OSError, ValueError) as e:
|
||||||
# Don't fail the download if conversion fails
|
# Don't fail the download if conversion fails
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.WARNING(f"Failed to convert {image_path}: {e}"),
|
self.style.WARNING(f"Failed to convert {image_path}: {e}"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _perform_image_conversion(self, image_path: str) -> None:
|
||||||
|
"""Perform the actual image conversion to WebP and AVIF."""
|
||||||
|
source_path = Path(image_path)
|
||||||
|
if not source_path.exists() or source_path.suffix.lower() not in {
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".png",
|
||||||
|
}:
|
||||||
|
return
|
||||||
|
|
||||||
|
base_path = source_path.with_suffix("")
|
||||||
|
webp_path = base_path.with_suffix(".webp")
|
||||||
|
avif_path = base_path.with_suffix(".avif")
|
||||||
|
|
||||||
|
with Image.open(source_path) as img:
|
||||||
|
# Convert to RGB if needed
|
||||||
|
if img.mode in {"RGBA", "LA"} or (
|
||||||
|
img.mode == "P" and "transparency" in img.info
|
||||||
|
):
|
||||||
|
background = Image.new("RGB", img.size, (255, 255, 255))
|
||||||
|
rgba_img = img.convert("RGBA") if img.mode == "P" else img
|
||||||
|
background.paste(
|
||||||
|
rgba_img,
|
||||||
|
mask=rgba_img.split()[-1]
|
||||||
|
if rgba_img.mode in {"RGBA", "LA"}
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
rgb_img = background
|
||||||
|
elif img.mode != "RGB":
|
||||||
|
rgb_img = img.convert("RGB")
|
||||||
|
else:
|
||||||
|
rgb_img = img
|
||||||
|
|
||||||
|
# Save WebP
|
||||||
|
rgb_img.save(webp_path, "WEBP", quality=85, method=6)
|
||||||
|
|
||||||
|
# Save AVIF
|
||||||
|
rgb_img.save(avif_path, "AVIF", quality=85, speed=4)
|
||||||
|
|
|
||||||
|
|
@ -325,56 +325,66 @@ class Command(BaseCommand):
|
||||||
|
|
||||||
return "failed"
|
return "failed"
|
||||||
|
|
||||||
|
def _convert_image_to_formats(
|
||||||
|
self,
|
||||||
|
source_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Load an image and save it as WebP and AVIF.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_path: Path to the source image file.
|
||||||
|
"""
|
||||||
|
base_path: Path = source_path.with_suffix("")
|
||||||
|
webp_path: Path = base_path.with_suffix(".webp")
|
||||||
|
avif_path: Path = base_path.with_suffix(".avif")
|
||||||
|
|
||||||
|
with Image.open(source_path) as img:
|
||||||
|
# Convert to RGB if needed
|
||||||
|
if img.mode in {"RGBA", "LA"} or (
|
||||||
|
img.mode == "P" and "transparency" in img.info
|
||||||
|
):
|
||||||
|
background: Image.Image = Image.new(
|
||||||
|
"RGB",
|
||||||
|
img.size,
|
||||||
|
(255, 255, 255),
|
||||||
|
)
|
||||||
|
rgba_img: Image.Image | ImageFile = (
|
||||||
|
img.convert("RGBA") if img.mode == "P" else img
|
||||||
|
)
|
||||||
|
background.paste(
|
||||||
|
rgba_img,
|
||||||
|
mask=rgba_img.split()[-1]
|
||||||
|
if rgba_img.mode in {"RGBA", "LA"}
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
rgb_img = background
|
||||||
|
elif img.mode != "RGB":
|
||||||
|
rgb_img: Image.Image = img.convert("RGB")
|
||||||
|
else:
|
||||||
|
rgb_img: Image.Image = img
|
||||||
|
|
||||||
|
# Save WebP
|
||||||
|
rgb_img.save(webp_path, "WEBP", quality=85, method=6)
|
||||||
|
|
||||||
|
# Save AVIF
|
||||||
|
rgb_img.save(avif_path, "AVIF", quality=85, speed=4)
|
||||||
|
|
||||||
def _convert_to_modern_formats(self, image_path: str) -> None:
|
def _convert_to_modern_formats(self, image_path: str) -> None:
|
||||||
"""Convert downloaded image to WebP and AVIF formats.
|
"""Convert downloaded image to WebP and AVIF formats.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
image_path: Absolute path to the downloaded image file
|
image_path: Absolute path to the downloaded image file
|
||||||
"""
|
"""
|
||||||
|
source_path = Path(image_path)
|
||||||
|
if not source_path.exists() or source_path.suffix.lower() not in {
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".png",
|
||||||
|
}:
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
source_path = Path(image_path)
|
self._convert_image_to_formats(source_path)
|
||||||
if not source_path.exists() or source_path.suffix.lower() not in {
|
|
||||||
".jpg",
|
|
||||||
".jpeg",
|
|
||||||
".png",
|
|
||||||
}:
|
|
||||||
return
|
|
||||||
|
|
||||||
base_path: Path = source_path.with_suffix("")
|
|
||||||
webp_path: Path = base_path.with_suffix(".webp")
|
|
||||||
avif_path: Path = base_path.with_suffix(".avif")
|
|
||||||
|
|
||||||
with Image.open(source_path) as img:
|
|
||||||
# Convert to RGB if needed
|
|
||||||
if img.mode in {"RGBA", "LA"} or (
|
|
||||||
img.mode == "P" and "transparency" in img.info
|
|
||||||
):
|
|
||||||
background: Image.Image = Image.new(
|
|
||||||
"RGB",
|
|
||||||
img.size,
|
|
||||||
(255, 255, 255),
|
|
||||||
)
|
|
||||||
rgba_img: Image.Image | ImageFile = (
|
|
||||||
img.convert("RGBA") if img.mode == "P" else img
|
|
||||||
)
|
|
||||||
background.paste(
|
|
||||||
rgba_img,
|
|
||||||
mask=rgba_img.split()[-1]
|
|
||||||
if rgba_img.mode in {"RGBA", "LA"}
|
|
||||||
else None,
|
|
||||||
)
|
|
||||||
rgb_img = background
|
|
||||||
elif img.mode != "RGB":
|
|
||||||
rgb_img: Image.Image = img.convert("RGB")
|
|
||||||
else:
|
|
||||||
rgb_img: Image.Image = img
|
|
||||||
|
|
||||||
# Save WebP
|
|
||||||
rgb_img.save(webp_path, "WEBP", quality=85, method=6)
|
|
||||||
|
|
||||||
# Save AVIF
|
|
||||||
rgb_img.save(avif_path, "AVIF", quality=85, speed=4)
|
|
||||||
|
|
||||||
except (OSError, ValueError) as e:
|
except (OSError, ValueError) as e:
|
||||||
# Don't fail the download if conversion fails
|
# Don't fail the download if conversion fails
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
|
|
|
||||||
1637
twitch/management/commands/import_twitch_drops_api.py
Normal file
1637
twitch/management/commands/import_twitch_drops_api.py
Normal file
|
|
@ -0,0 +1,1637 @@
|
||||||
|
"""Import drop and reward campaign data from the SunkwiBOT/twitch-drops-api repo.
|
||||||
|
|
||||||
|
This command fetches ``drops.json`` and ``rewards.json`` from the
|
||||||
|
`SunkwiBOT/twitch-drops-api <https://github.com/SunkwiBOT/twitch-drops-api>`_
|
||||||
|
GitHub repository and imports the data into the local database.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
# Current data (latest commit)
|
||||||
|
uv run python manage.py import_twitch_drops_api
|
||||||
|
uv run python manage.py import_twitch_drops_api --source "SunkwiBOT/twitch-drops-api"
|
||||||
|
|
||||||
|
# Historical data (all commits, oldest-first — captures expired campaigns)
|
||||||
|
uv run python manage.py import_twitch_drops_api --historical
|
||||||
|
uv run python manage.py import_twitch_drops_api --historical --max-commits 50
|
||||||
|
uv run python manage.py import_twitch_drops_api --historical --git-dir /tmp/mirror
|
||||||
|
|
||||||
|
The ``--source`` argument controls the value stored in the
|
||||||
|
``data_source`` field on imported records (defaults to
|
||||||
|
``"SunkwiBOT/twitch-drops-api"``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import subprocess # noqa: S404
|
||||||
|
import tempfile
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from colorama import Fore
|
||||||
|
from colorama import Style
|
||||||
|
from colorama import init as colorama_init
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.core.management.base import CommandError
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from twitch.models import Channel
|
||||||
|
from twitch.models import DropBenefit
|
||||||
|
from twitch.models import DropBenefitEdge
|
||||||
|
from twitch.models import DropCampaign
|
||||||
|
from twitch.models import Game
|
||||||
|
from twitch.models import Organization
|
||||||
|
from twitch.models import RewardCampaign
|
||||||
|
from twitch.models import TimeBasedDrop
|
||||||
|
from twitch.schemas import SunkwiBotDropGroupSchema
|
||||||
|
from twitch.schemas import SunkwiBotRewardCampaignSchema
|
||||||
|
from twitch.utils import parse_date
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from django.core.management.base import CommandParser
|
||||||
|
from django.db.models import Model
|
||||||
|
|
||||||
|
from twitch.schemas import SunkwiBotBenefitEdgeSchema
|
||||||
|
from twitch.schemas import SunkwiBotDropCampaignACLSchema
|
||||||
|
from twitch.schemas import SunkwiBotRewardSchema
|
||||||
|
from twitch.schemas import SunkwiBotTimeBasedDropSchema
|
||||||
|
|
||||||
|
# Default raw GitHub URLs for the SunkwiBOT/twitch-drops-api repo
|
||||||
|
DEFAULT_DROPS_URL = (
|
||||||
|
"https://raw.githubusercontent.com/SunkwiBOT/twitch-drops-api/main/drops.json"
|
||||||
|
)
|
||||||
|
DEFAULT_REWARDS_URL = (
|
||||||
|
"https://raw.githubusercontent.com/SunkwiBOT/twitch-drops-api/main/rewards.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
GIT_CLONE_URL = "https://github.com/SunkwiBOT/twitch-drops-api.git"
|
||||||
|
|
||||||
|
HTTP_TIMEOUT = 60 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""Import drop and reward campaigns from the SunkwiBOT twitch-drops-api repo."""
|
||||||
|
|
||||||
|
help = (
|
||||||
|
"Import drop and reward campaigns from "
|
||||||
|
"SunkwiBOT/twitch-drops-api (GitHub raw JSON)."
|
||||||
|
)
|
||||||
|
requires_migrations_checks = True
|
||||||
|
|
||||||
|
def add_arguments(self, parser: CommandParser) -> None:
|
||||||
|
"""Populate the command with arguments."""
|
||||||
|
parser.add_argument(
|
||||||
|
"--source",
|
||||||
|
type=str,
|
||||||
|
default="SunkwiBOT/twitch-drops-api",
|
||||||
|
help=(
|
||||||
|
"Value to store in the data_source field. "
|
||||||
|
"Default: SunkwiBOT/twitch-drops-api"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--drops-url",
|
||||||
|
type=str,
|
||||||
|
default=DEFAULT_DROPS_URL,
|
||||||
|
help=f"URL to fetch drops.json from. Default: {DEFAULT_DROPS_URL}",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--rewards-url",
|
||||||
|
type=str,
|
||||||
|
default=DEFAULT_REWARDS_URL,
|
||||||
|
help=f"URL to fetch rewards.json from. Default: {DEFAULT_REWARDS_URL}",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--drops-only",
|
||||||
|
action="store_true",
|
||||||
|
help="Only import drops.json (skip rewards.json).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--rewards-only",
|
||||||
|
action="store_true",
|
||||||
|
help="Only import rewards.json (skip drops.json).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--crash-on-error",
|
||||||
|
action="store_true",
|
||||||
|
help="Crash on first validation/import error instead of continuing.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose",
|
||||||
|
action="store_true",
|
||||||
|
help="Print per-record success messages.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--report",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Compare SunkwiBOT data against the database without importing. "
|
||||||
|
"Prints a summary of what's new, what exists, and field-level "
|
||||||
|
"differences for overlapping campaigns."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--merge",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"For existing campaigns, fill in empty/blank fields with "
|
||||||
|
"SunkwiBOT data without overwriting populated ones. The "
|
||||||
|
"original data_source is preserved."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--update-existing",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"By default, campaigns already in the database are skipped "
|
||||||
|
"(existing data_source is preserved). Use this flag to "
|
||||||
|
"update existing campaigns with the incoming data instead."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--historical",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Iterate through all git commits (oldest first) to capture "
|
||||||
|
"expired campaigns that are no longer in the latest JSON. "
|
||||||
|
"Implies --skip-latest unless --no-skip-latest is given."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-skip-latest",
|
||||||
|
action="store_true",
|
||||||
|
help="When used with --historical, also process the latest commit.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-commits",
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
help="Maximum number of *unique-content* commits to process (0 = all).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--git-dir",
|
||||||
|
type=str,
|
||||||
|
default="",
|
||||||
|
help=(
|
||||||
|
"Path to a local bare clone of the repo. If not set, a temp "
|
||||||
|
"clone is created and cleaned up."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--from-file",
|
||||||
|
type=str,
|
||||||
|
default="",
|
||||||
|
help=(
|
||||||
|
"Path to a pre-extracted JSON file (e.g. drops_merged.json "
|
||||||
|
"from tools/extract_historical_drops.py). Import this file "
|
||||||
|
"directly instead of fetching from git history or URLs. "
|
||||||
|
"Much faster than --historical for repeated imports."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args: Any, **options: Any) -> None: # noqa: ANN401, ARG002, PLR0914, PLR0915
|
||||||
|
"""Main entry point for the command.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CommandError: If the --from-file path does not exist or is invalid.
|
||||||
|
"""
|
||||||
|
colorama_init(autoreset=True)
|
||||||
|
source: str = options["source"]
|
||||||
|
verbose: bool = options["verbose"]
|
||||||
|
crash_on_error: bool = options["crash_on_error"]
|
||||||
|
report_mode: bool = options.get("report", False)
|
||||||
|
merge_mode: bool = options.get("merge", False)
|
||||||
|
skip_existing: bool = not options.get("update_existing")
|
||||||
|
historical: bool = options.get("historical", False)
|
||||||
|
|
||||||
|
# Caches for the duration of the command to avoid redundant DB lookups
|
||||||
|
self._org_cache: dict[str, Organization] = {}
|
||||||
|
self._game_cache: dict[str, Game] = {}
|
||||||
|
self._channel_cache: dict[str, Channel] = {}
|
||||||
|
self._benefit_cache: dict[str, DropBenefit] = {}
|
||||||
|
|
||||||
|
# Merge mode: fill empty fields on existing campaigns instead of skipping
|
||||||
|
self._merge_mode: bool = merge_mode
|
||||||
|
if merge_mode:
|
||||||
|
skip_existing = False
|
||||||
|
|
||||||
|
drops_count = 0
|
||||||
|
rewards_count = 0
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
if report_mode:
|
||||||
|
self._generate_report(
|
||||||
|
drops_url=options["drops_url"],
|
||||||
|
rewards_url=options["rewards_url"],
|
||||||
|
crash_on_error=crash_on_error,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
from_file: str = options.get("from_file", "") or ""
|
||||||
|
|
||||||
|
if from_file:
|
||||||
|
# Import from a pre-extracted merged JSON file (fast path).
|
||||||
|
# The file is produced by tools/extract_historical_drops.py.
|
||||||
|
from_file_path = pathlib.Path(from_file)
|
||||||
|
if not from_file_path.exists():
|
||||||
|
msg = f"File not found: {from_file}"
|
||||||
|
raise CommandError(msg)
|
||||||
|
|
||||||
|
with from_file_path.open(encoding="utf-8") as f:
|
||||||
|
raw_data = json.load(f)
|
||||||
|
|
||||||
|
if not isinstance(raw_data, list):
|
||||||
|
msg = f"Expected a JSON array in {from_file}, got {type(raw_data).__name__}"
|
||||||
|
raise CommandError(msg)
|
||||||
|
|
||||||
|
# Detect file type by content structure
|
||||||
|
is_drops = any("gameId" in item for item in raw_data)
|
||||||
|
|
||||||
|
if is_drops:
|
||||||
|
self.stdout.write(
|
||||||
|
f"Importing drops from {from_file} ({len(raw_data)} groups) …",
|
||||||
|
)
|
||||||
|
drops_count = self._parse_and_import_drops(
|
||||||
|
raw_data=raw_data,
|
||||||
|
source=source,
|
||||||
|
verbose=verbose,
|
||||||
|
crash_on_error=crash_on_error,
|
||||||
|
label=from_file_path.name,
|
||||||
|
skip_existing=skip_existing,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
f"Importing rewards from {from_file} ({len(raw_data)} campaigns) …",
|
||||||
|
)
|
||||||
|
rewards_count = self._parse_and_import_rewards(
|
||||||
|
raw_data=raw_data,
|
||||||
|
source=source,
|
||||||
|
verbose=verbose,
|
||||||
|
crash_on_error=crash_on_error,
|
||||||
|
label=from_file_path.name,
|
||||||
|
skip_existing=skip_existing,
|
||||||
|
)
|
||||||
|
elif historical:
|
||||||
|
# Historical mode: clone repo and iterate commits
|
||||||
|
hist_drops, hist_rewards, hist_errors = self._process_historical(
|
||||||
|
source=source,
|
||||||
|
verbose=verbose,
|
||||||
|
crash_on_error=crash_on_error,
|
||||||
|
skip_latest=not options["no_skip_latest"],
|
||||||
|
max_commits=options["max_commits"],
|
||||||
|
skip_existing=skip_existing,
|
||||||
|
git_dir=options["git_dir"],
|
||||||
|
)
|
||||||
|
drops_count += hist_drops
|
||||||
|
rewards_count += hist_rewards
|
||||||
|
errors.extend(hist_errors)
|
||||||
|
else:
|
||||||
|
# Normal mode: fetch latest from URLs
|
||||||
|
if not options["rewards_only"]:
|
||||||
|
try:
|
||||||
|
drops_count += self._import_drops(
|
||||||
|
url=options["drops_url"],
|
||||||
|
source=source,
|
||||||
|
verbose=verbose,
|
||||||
|
crash_on_error=crash_on_error,
|
||||||
|
skip_existing=skip_existing,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
msg = f"Failed to import drops.json: {exc}"
|
||||||
|
self.stderr.write(self.style.ERROR(msg))
|
||||||
|
errors.append(msg)
|
||||||
|
|
||||||
|
if not options["drops_only"]:
|
||||||
|
try:
|
||||||
|
rewards_count += self._import_rewards(
|
||||||
|
url=options["rewards_url"],
|
||||||
|
source=source,
|
||||||
|
verbose=verbose,
|
||||||
|
crash_on_error=crash_on_error,
|
||||||
|
skip_existing=skip_existing,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
msg = f"Failed to import rewards.json: {exc}"
|
||||||
|
self.stderr.write(self.style.ERROR(msg))
|
||||||
|
errors.append(msg)
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
self.stdout.write("\n" + "=" * 50)
|
||||||
|
if historical:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"✓ Historical drops imported/updated: {drops_count}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"✓ Historical rewards imported/updated: {rewards_count}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"✓ Drop campaigns imported/updated: {drops_count}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"✓ Reward campaigns imported/updated: {rewards_count}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if errors:
|
||||||
|
for err in errors:
|
||||||
|
self.stdout.write(self.style.ERROR(f"✗ {err}"))
|
||||||
|
self.stdout.write("=" * 50)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Historical import via git clone
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _process_historical( # noqa: PLR0913
|
||||||
|
self,
|
||||||
|
source: str,
|
||||||
|
*,
|
||||||
|
verbose: bool,
|
||||||
|
crash_on_error: bool,
|
||||||
|
skip_latest: bool,
|
||||||
|
max_commits: int,
|
||||||
|
git_dir: str,
|
||||||
|
skip_existing: bool = False,
|
||||||
|
) -> tuple[int, int, list[str]]:
|
||||||
|
"""Clone the repo and iterate commits oldest-first to import historical data.
|
||||||
|
|
||||||
|
Skips commits whose file content is byte-identical to the previously
|
||||||
|
processed commit (deduplicates bot spam).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source: data_source label.
|
||||||
|
verbose: Print per-record messages.
|
||||||
|
crash_on_error: Raise on first error.
|
||||||
|
skip_latest: If True, skip the most recent commit (already imported
|
||||||
|
by the non-historical path).
|
||||||
|
max_commits: Max unique-content commits to process (0 = all).
|
||||||
|
git_dir: Path to existing bare clone, or '' to create a temp one.
|
||||||
|
skip_existing: If True, skip campaigns already in the database.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (drops_count, rewards_count, error_messages).
|
||||||
|
"""
|
||||||
|
own_clone = not git_dir
|
||||||
|
if own_clone:
|
||||||
|
git_dir_obj = tempfile.mkdtemp(prefix="twitch-drops-api-")
|
||||||
|
git_dir = git_dir_obj
|
||||||
|
self.stdout.write(f"Cloning {GIT_CLONE_URL} …")
|
||||||
|
self._git(
|
||||||
|
git_dir,
|
||||||
|
"clone",
|
||||||
|
"--filter=blob:none",
|
||||||
|
"--bare",
|
||||||
|
GIT_CLONE_URL,
|
||||||
|
git_dir,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(f"Using existing clone at {git_dir} …")
|
||||||
|
|
||||||
|
drops_count = 0
|
||||||
|
rewards_count = 0
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
drops_count += self._import_historical_file(
|
||||||
|
git_dir=git_dir,
|
||||||
|
file_path="drops.json",
|
||||||
|
source=source,
|
||||||
|
verbose=verbose,
|
||||||
|
crash_on_error=crash_on_error,
|
||||||
|
skip_latest=skip_latest,
|
||||||
|
max_commits=max_commits,
|
||||||
|
skip_existing=skip_existing,
|
||||||
|
)
|
||||||
|
rewards_count += self._import_historical_file(
|
||||||
|
git_dir=git_dir,
|
||||||
|
file_path="rewards.json",
|
||||||
|
source=source,
|
||||||
|
verbose=verbose,
|
||||||
|
crash_on_error=crash_on_error,
|
||||||
|
skip_latest=skip_latest,
|
||||||
|
max_commits=max_commits,
|
||||||
|
skip_existing=skip_existing,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
errors.append(str(exc))
|
||||||
|
finally:
|
||||||
|
if own_clone:
|
||||||
|
self._rmtree(git_dir)
|
||||||
|
|
||||||
|
return drops_count, rewards_count, errors
|
||||||
|
|
||||||
|
def _import_historical_file( # noqa: PLR0913
|
||||||
|
self,
|
||||||
|
git_dir: str,
|
||||||
|
file_path: str,
|
||||||
|
source: str,
|
||||||
|
*,
|
||||||
|
verbose: bool,
|
||||||
|
crash_on_error: bool,
|
||||||
|
skip_latest: bool,
|
||||||
|
max_commits: int,
|
||||||
|
skip_existing: bool,
|
||||||
|
) -> int:
|
||||||
|
"""Import all historical versions of a single file from git history.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
git_dir: Path to bare clone.
|
||||||
|
file_path: Path within repo (e.g. "drops.json").
|
||||||
|
source: data_source label.
|
||||||
|
verbose: Print per-record messages.
|
||||||
|
crash_on_error: Raise on first error.
|
||||||
|
skip_latest: If True, skip the newest commit.
|
||||||
|
max_commits: Max unique-content commits (0 = all).
|
||||||
|
skip_existing: If True, skip campaigns already in the database.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of campaigns imported/updated.
|
||||||
|
"""
|
||||||
|
label = file_path.replace(".json", "")
|
||||||
|
self.stdout.write(f"\nCollecting commits for {file_path} …")
|
||||||
|
|
||||||
|
# Get all commits that touched this file, oldest first
|
||||||
|
log_output = self._git(
|
||||||
|
git_dir,
|
||||||
|
"log",
|
||||||
|
"--all",
|
||||||
|
"--format=%H %ct",
|
||||||
|
"--follow",
|
||||||
|
"--reverse",
|
||||||
|
"--",
|
||||||
|
file_path,
|
||||||
|
)
|
||||||
|
lines = [ln.strip() for ln in log_output.strip().split("\n") if ln.strip()]
|
||||||
|
total = len(lines)
|
||||||
|
self.stdout.write(f" Found {total} total commits for {file_path}")
|
||||||
|
|
||||||
|
if skip_latest and total > 0:
|
||||||
|
lines = lines[:-1]
|
||||||
|
self.stdout.write(f" Skipping latest commit, processing {len(lines)}")
|
||||||
|
|
||||||
|
if max_commits and len(lines) > max_commits:
|
||||||
|
lines = lines[:max_commits]
|
||||||
|
self.stdout.write(f" Limited to {max_commits} commits")
|
||||||
|
|
||||||
|
if not lines:
|
||||||
|
self.stdout.write(f" No commits to process for {file_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
total_campaigns = 0
|
||||||
|
seen_hashes: set[str] = set()
|
||||||
|
processed = 0
|
||||||
|
skipped_identical = 0
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
parts = line.split(None, 1)
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
sha = parts[0]
|
||||||
|
|
||||||
|
# Get file content at this commit
|
||||||
|
try:
|
||||||
|
content = self._git(
|
||||||
|
git_dir,
|
||||||
|
"show",
|
||||||
|
f"{sha}:{file_path}",
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
# File didn't exist at this commit yet
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip if content is byte-identical to last processed
|
||||||
|
content_hash = hashlib.sha256(content.encode()).hexdigest()
|
||||||
|
if content_hash in seen_hashes:
|
||||||
|
skipped_identical += 1
|
||||||
|
continue
|
||||||
|
seen_hashes.add(content_hash)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(content)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
if verbose:
|
||||||
|
self.stdout.write(
|
||||||
|
f"{Fore.YELLOW}→{Style.RESET_ALL} {sha[:7]}: "
|
||||||
|
f"invalid JSON, skipping",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
continue
|
||||||
|
|
||||||
|
short_sha = sha[:7]
|
||||||
|
self.stdout.write(
|
||||||
|
f" [{processed + 1}/{len(lines)}] Importing {label} @ {short_sha} …",
|
||||||
|
)
|
||||||
|
|
||||||
|
if file_path == "drops.json":
|
||||||
|
count = self._parse_and_import_drops(
|
||||||
|
raw_data=data,
|
||||||
|
source=source,
|
||||||
|
verbose=verbose,
|
||||||
|
crash_on_error=crash_on_error,
|
||||||
|
label=f"{label}@{short_sha}",
|
||||||
|
skip_existing=skip_existing,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
count = self._parse_and_import_rewards(
|
||||||
|
raw_data=data,
|
||||||
|
source=source,
|
||||||
|
verbose=verbose,
|
||||||
|
crash_on_error=crash_on_error,
|
||||||
|
label=f"{label}@{short_sha}",
|
||||||
|
skip_existing=skip_existing,
|
||||||
|
)
|
||||||
|
|
||||||
|
total_campaigns += count
|
||||||
|
processed += 1
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f" Done: {processed} versions processed, "
|
||||||
|
f"{skipped_identical} skipped (identical content), "
|
||||||
|
f"{total_campaigns} total campaigns imported for {file_path}",
|
||||||
|
)
|
||||||
|
return total_campaigns
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Git / filesystem helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _git(self, git_dir: str, *args: str, check: bool = True) -> str:
|
||||||
|
"""Run a git command in the bare clone directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
git_dir: Path to the bare clone.
|
||||||
|
*args: Git sub-command and arguments.
|
||||||
|
check: If True, raise on non-zero exit.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Stdout of the git command.
|
||||||
|
"""
|
||||||
|
cmd = ["git", "--git-dir", git_dir, *args]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=check) # noqa: S603
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
def _rmtree(self, path: str) -> None:
|
||||||
|
"""Recursively remove a directory tree.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to remove.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
for root_str, dirs, files in os.walk(path, topdown=False):
|
||||||
|
root_path = pathlib.Path(root_str)
|
||||||
|
for name in files:
|
||||||
|
(root_path / name).unlink()
|
||||||
|
for name in dirs:
|
||||||
|
(root_path / name).rmdir()
|
||||||
|
pathlib.Path(path).rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# drops.json import
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _fetch_json(self, url: str, label: str) -> list[dict[str, Any]]:
|
||||||
|
"""Fetch a JSON file from a URL and parse it into a list of dicts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The URL to fetch.
|
||||||
|
label: Human-readable label for error messages.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed JSON data as a list of dicts.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: If the response is not a JSON array.
|
||||||
|
"""
|
||||||
|
self.stdout.write(f"Fetching {label} from {url} …")
|
||||||
|
with httpx.Client(timeout=HTTP_TIMEOUT, follow_redirects=True) as client:
|
||||||
|
response = client.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
data: Any = response.json()
|
||||||
|
if not isinstance(data, list):
|
||||||
|
msg = f"Expected a JSON array from {url}, got {type(data).__name__}"
|
||||||
|
raise TypeError(msg)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _import_drops(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
source: str,
|
||||||
|
*,
|
||||||
|
verbose: bool,
|
||||||
|
crash_on_error: bool,
|
||||||
|
skip_existing: bool = False,
|
||||||
|
) -> int:
|
||||||
|
"""Import drop campaigns from a drops.json URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Raw GitHub URL for drops.json.
|
||||||
|
source: Data source label to store on records.
|
||||||
|
verbose: Print per-record messages.
|
||||||
|
crash_on_error: Raise on first error instead of continuing.
|
||||||
|
skip_existing: If True, skip campaigns already in the database.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of drop campaigns imported/updated.
|
||||||
|
"""
|
||||||
|
raw_data = self._fetch_json(url, "drops.json")
|
||||||
|
return self._parse_and_import_drops(
|
||||||
|
raw_data=raw_data,
|
||||||
|
source=source,
|
||||||
|
verbose=verbose,
|
||||||
|
crash_on_error=crash_on_error,
|
||||||
|
label="drops.json",
|
||||||
|
skip_existing=skip_existing,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _strip_typename(data: Any) -> Any: # noqa: ANN401
|
||||||
|
"""Recursively remove ``__typename`` keys from parsed JSON data.
|
||||||
|
|
||||||
|
Old commits in the repo included GraphQL ``__typename`` metadata which
|
||||||
|
the SunkwiBot schemas reject with ``extra="forbid"``. Stripping them
|
||||||
|
before validation keeps the schemas strict while still accepting
|
||||||
|
historical payloads.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Raw parsed JSON (dict, list, or scalar).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Data with all ``__typename`` keys removed.
|
||||||
|
"""
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return {
|
||||||
|
k: Command._strip_typename(v)
|
||||||
|
for k, v in data.items()
|
||||||
|
if k != "__typename"
|
||||||
|
}
|
||||||
|
if isinstance(data, list):
|
||||||
|
return [Command._strip_typename(item) for item in data]
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _generate_report( # noqa: PLR0914, PLR0915
|
||||||
|
self,
|
||||||
|
drops_url: str,
|
||||||
|
rewards_url: str,
|
||||||
|
*,
|
||||||
|
crash_on_error: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Compare SunkwiBOT data against the database without importing.
|
||||||
|
|
||||||
|
Fetches drops.json and rewards.json, validates with schemas, then
|
||||||
|
compares against the database to show:
|
||||||
|
- How many campaigns are new
|
||||||
|
- How many already exist
|
||||||
|
- Field-level differences for overlapping campaigns
|
||||||
|
- Total counts per source
|
||||||
|
|
||||||
|
Args:
|
||||||
|
drops_url: URL for drops.json.
|
||||||
|
rewards_url: URL for rewards.json.
|
||||||
|
crash_on_error: Raise on first error instead of continuing.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If any item fails schema validation and crash_on_error is True.
|
||||||
|
"""
|
||||||
|
from collections import Counter # noqa: PLC0415
|
||||||
|
|
||||||
|
self.stdout.write("=" * 60)
|
||||||
|
self.stdout.write(self.style.SUCCESS("COMPARISON REPORT"))
|
||||||
|
self.stdout.write(
|
||||||
|
"Comparing SunkwiBOT data against local database…",
|
||||||
|
)
|
||||||
|
self.stdout.write("=" * 60)
|
||||||
|
|
||||||
|
# Track differences for field-level comparison
|
||||||
|
sample_limit = 5
|
||||||
|
|
||||||
|
for label, raw_url, model_cls, id_field in [ # noqa: PLR1702
|
||||||
|
("drops", drops_url, DropCampaign, "twitch_id"),
|
||||||
|
("rewards", rewards_url, RewardCampaign, "twitch_id"),
|
||||||
|
]:
|
||||||
|
self.stdout.write(f"\n--- {label.upper()} ---")
|
||||||
|
try:
|
||||||
|
raw_data = self._fetch_json(raw_url, f"{label}.json")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self.stderr.write(
|
||||||
|
self.style.ERROR(f" Failed to fetch {label}.json: {exc}"),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
validated_ids: set[str] = set()
|
||||||
|
validated: list[Any] = []
|
||||||
|
groups: list[SunkwiBotDropGroupSchema] = []
|
||||||
|
if label == "drops":
|
||||||
|
for i, raw_item in enumerate(raw_data):
|
||||||
|
try:
|
||||||
|
stripped_item = self._strip_typename(raw_item)
|
||||||
|
groups.append(
|
||||||
|
SunkwiBotDropGroupSchema.model_validate(stripped_item),
|
||||||
|
)
|
||||||
|
except ValidationError:
|
||||||
|
if crash_on_error:
|
||||||
|
raise
|
||||||
|
self.stdout.write(
|
||||||
|
f" {Fore.YELLOW}⚠{Style.RESET_ALL} "
|
||||||
|
f"item [{i}] failed validation, skipping",
|
||||||
|
)
|
||||||
|
for group in groups:
|
||||||
|
validated_ids.update(reward.twitch_id for reward in group.rewards)
|
||||||
|
else:
|
||||||
|
for i, raw_item in enumerate(raw_data):
|
||||||
|
try:
|
||||||
|
stripped_item = self._strip_typename(raw_item)
|
||||||
|
validated.append(
|
||||||
|
SunkwiBotRewardCampaignSchema.model_validate(stripped_item),
|
||||||
|
)
|
||||||
|
except ValidationError:
|
||||||
|
if crash_on_error:
|
||||||
|
raise
|
||||||
|
self.stdout.write(
|
||||||
|
f" {Fore.YELLOW}⚠{Style.RESET_ALL} "
|
||||||
|
f"item [{i}] failed validation, skipping",
|
||||||
|
)
|
||||||
|
validated_ids.update(c.twitch_id for c in validated)
|
||||||
|
|
||||||
|
# Compare against DB
|
||||||
|
existing_ids: set[str] = set(
|
||||||
|
model_cls.objects.values_list(id_field, flat=True),
|
||||||
|
)
|
||||||
|
new_ids = validated_ids - existing_ids
|
||||||
|
overlap_ids = validated_ids & existing_ids
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f" Total in SunkwiBOT data: {len(validated_ids)}",
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
f" Total in local database: {len(existing_ids)}",
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
f" New (SunkwiBOT only): {len(new_ids)}",
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
f" Overlap (both): {len(overlap_ids)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Field-level comparison for overlapping campaigns
|
||||||
|
if overlap_ids and label == "drops":
|
||||||
|
field_diffs: list[tuple[str, str, str]] = []
|
||||||
|
fields_to_check = [
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"image_url",
|
||||||
|
"start_at",
|
||||||
|
"end_at",
|
||||||
|
]
|
||||||
|
for group in groups:
|
||||||
|
for reward in group.rewards:
|
||||||
|
if reward.twitch_id not in overlap_ids:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
db_obj = DropCampaign.objects.get(
|
||||||
|
twitch_id=reward.twitch_id,
|
||||||
|
)
|
||||||
|
except DropCampaign.DoesNotExist:
|
||||||
|
continue
|
||||||
|
for field in fields_to_check:
|
||||||
|
ours = getattr(db_obj, field, None)
|
||||||
|
theirs = getattr(reward, field, None)
|
||||||
|
# Compare string forms for dates
|
||||||
|
str_ours = str(ours) if ours is not None else ""
|
||||||
|
str_theirs = str(theirs) if theirs is not None else ""
|
||||||
|
if str_ours.strip() != str_theirs.strip():
|
||||||
|
field_diffs.append(
|
||||||
|
(field, str_ours, str_theirs),
|
||||||
|
)
|
||||||
|
|
||||||
|
if field_diffs:
|
||||||
|
diff_counter: Counter = Counter()
|
||||||
|
for diff_field, _ours_val, _theirs_val in field_diffs:
|
||||||
|
diff_counter[diff_field] += 1
|
||||||
|
self.stdout.write(
|
||||||
|
"\n Field differences (overlapping campaigns):",
|
||||||
|
)
|
||||||
|
for field, count in diff_counter.most_common():
|
||||||
|
ours_empty = sum(
|
||||||
|
1 for f, o, _ in field_diffs if f == field and not o.strip()
|
||||||
|
)
|
||||||
|
theirs_empty = sum(
|
||||||
|
1
|
||||||
|
for f, _o, t in field_diffs
|
||||||
|
if f == field and not t.strip()
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
f" {field}: {count} campaigns differ "
|
||||||
|
f"(we have empty: {ours_empty}, "
|
||||||
|
f"they have empty: {theirs_empty})",
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
" Use --merge to fill empty fields from SunkwiBOT data.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
" ✓ No field differences in overlapping campaigns.",
|
||||||
|
)
|
||||||
|
|
||||||
|
elif overlap_ids and label == "rewards":
|
||||||
|
fields_to_check = [
|
||||||
|
"name",
|
||||||
|
"brand",
|
||||||
|
"summary",
|
||||||
|
"status",
|
||||||
|
]
|
||||||
|
reward_diffs = 0
|
||||||
|
for c_id in overlap_ids:
|
||||||
|
try:
|
||||||
|
db_obj = RewardCampaign.objects.get(twitch_id=c_id)
|
||||||
|
except RewardCampaign.DoesNotExist:
|
||||||
|
continue
|
||||||
|
# Re-find the schema entry
|
||||||
|
for c_entry in validated:
|
||||||
|
if c_entry.twitch_id == c_id:
|
||||||
|
for field in fields_to_check:
|
||||||
|
ours = getattr(db_obj, field, None)
|
||||||
|
theirs = getattr(c_entry, field, None)
|
||||||
|
if str(ours or "") != str(theirs or ""):
|
||||||
|
reward_diffs += 1
|
||||||
|
break
|
||||||
|
if reward_diffs:
|
||||||
|
self.stdout.write(
|
||||||
|
f" {reward_diffs} field differences found in "
|
||||||
|
f"overlapping reward campaigns.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
" ✓ No field differences in overlapping reward campaigns.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_ids:
|
||||||
|
self.stdout.write(
|
||||||
|
f"\n {Fore.GREEN}Sample new campaigns:{Style.RESET_ALL}",
|
||||||
|
)
|
||||||
|
sample_new = list(new_ids)[:5]
|
||||||
|
for sid in sample_new:
|
||||||
|
self.stdout.write(f" - {sid}")
|
||||||
|
if len(new_ids) > sample_limit:
|
||||||
|
self.stdout.write(
|
||||||
|
f" … and {len(new_ids) - sample_limit} more",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write("\n" + "=" * 60)
|
||||||
|
|
||||||
|
def _parse_and_import_drops( # noqa: PLR0913
|
||||||
|
self,
|
||||||
|
raw_data: list[dict[str, Any]],
|
||||||
|
source: str,
|
||||||
|
*,
|
||||||
|
verbose: bool,
|
||||||
|
crash_on_error: bool,
|
||||||
|
label: str = "drops.json",
|
||||||
|
skip_existing: bool = False,
|
||||||
|
) -> int:
|
||||||
|
"""Validate and import raw drops.json data (from URL or git history).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_data: Parsed drops.json array.
|
||||||
|
source: Data source label.
|
||||||
|
verbose: Print per-record messages.
|
||||||
|
crash_on_error: Raise on first error.
|
||||||
|
label: Human label for error messages (e.g. "drops.json@abc1234").
|
||||||
|
skip_existing: If True, skip campaigns already in the database.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of drop campaigns imported/updated.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If crash_on_error is True and validation fails.
|
||||||
|
"""
|
||||||
|
raw_data = self._strip_typename(raw_data)
|
||||||
|
drop_groups: list[SunkwiBotDropGroupSchema] = []
|
||||||
|
for i, item in enumerate(raw_data):
|
||||||
|
try:
|
||||||
|
group = SunkwiBotDropGroupSchema.model_validate(item)
|
||||||
|
drop_groups.append(group)
|
||||||
|
except ValidationError as e:
|
||||||
|
msg = f"{label} item [{i}] failed validation: {e}"
|
||||||
|
if crash_on_error:
|
||||||
|
raise
|
||||||
|
self.stderr.write(self.style.ERROR(msg))
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_campaigns = 0
|
||||||
|
for group in drop_groups:
|
||||||
|
# Get or create the shared Game for this group
|
||||||
|
# Use the first reward's owner as the game's organization
|
||||||
|
first_owner = group.rewards[0].owner if group.rewards else None
|
||||||
|
org_obj = None
|
||||||
|
if first_owner:
|
||||||
|
org_obj = self._get_or_create_organization(
|
||||||
|
twitch_id=first_owner.twitch_id,
|
||||||
|
name=first_owner.name,
|
||||||
|
verbose=verbose,
|
||||||
|
)
|
||||||
|
|
||||||
|
game_obj = self._get_or_create_game(
|
||||||
|
twitch_id=group.game_id,
|
||||||
|
display_name=group.game_display_name,
|
||||||
|
box_art_url=group.game_box_art_url,
|
||||||
|
source=source,
|
||||||
|
verbose=verbose,
|
||||||
|
org_obj=org_obj,
|
||||||
|
)
|
||||||
|
|
||||||
|
for reward in group.rewards:
|
||||||
|
try:
|
||||||
|
self._process_sunkwibot_reward(
|
||||||
|
reward=reward,
|
||||||
|
game_obj=game_obj,
|
||||||
|
source=source,
|
||||||
|
verbose=verbose,
|
||||||
|
skip_existing=skip_existing,
|
||||||
|
)
|
||||||
|
total_campaigns += 1
|
||||||
|
except Exception as exc:
|
||||||
|
msg = (
|
||||||
|
f"Failed to import reward {reward.twitch_id} "
|
||||||
|
f"({reward.name}): {exc}"
|
||||||
|
)
|
||||||
|
if crash_on_error:
|
||||||
|
raise
|
||||||
|
self.stderr.write(self.style.ERROR(msg))
|
||||||
|
|
||||||
|
return total_campaigns
|
||||||
|
|
||||||
|
def _process_sunkwibot_reward( # noqa: PLR0915
|
||||||
|
self,
|
||||||
|
reward: SunkwiBotRewardSchema,
|
||||||
|
game_obj: Game,
|
||||||
|
source: str,
|
||||||
|
*,
|
||||||
|
verbose: bool,
|
||||||
|
skip_existing: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Process a single reward item from drops.json into a DropCampaign.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reward: The validated reward schema from drops.json.
|
||||||
|
game_obj: The Game instance for this campaign.
|
||||||
|
source: Data source label.
|
||||||
|
verbose: Print per-record messages.
|
||||||
|
skip_existing: If True, skip if campaign already exists.
|
||||||
|
"""
|
||||||
|
# Owner organization
|
||||||
|
self._get_or_create_organization(
|
||||||
|
twitch_id=reward.owner.twitch_id,
|
||||||
|
name=reward.owner.name,
|
||||||
|
verbose=verbose,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse dates
|
||||||
|
start_at_dt = parse_date(reward.start_at)
|
||||||
|
end_at_dt = parse_date(reward.end_at)
|
||||||
|
|
||||||
|
defaults: dict[str, Any] = {
|
||||||
|
"name": reward.name,
|
||||||
|
"description": reward.description,
|
||||||
|
"image_url": reward.image_url,
|
||||||
|
"game": game_obj,
|
||||||
|
"start_at": start_at_dt,
|
||||||
|
"end_at": end_at_dt,
|
||||||
|
"details_url": reward.details_url,
|
||||||
|
"account_link_url": reward.account_link_url,
|
||||||
|
"data_source": source,
|
||||||
|
}
|
||||||
|
|
||||||
|
existing: DropCampaign | None = DropCampaign.objects.filter(
|
||||||
|
twitch_id=reward.twitch_id,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
# Merge mode: fill empty fields, never overwrite populated ones
|
||||||
|
if existing is not None and getattr(self, "_merge_mode", False):
|
||||||
|
changed = False
|
||||||
|
merge_fields: dict[str, str | object | None] = {
|
||||||
|
"description": reward.description,
|
||||||
|
"image_url": reward.image_url,
|
||||||
|
"details_url": reward.details_url,
|
||||||
|
}
|
||||||
|
for field, new_value in merge_fields.items():
|
||||||
|
old_value = getattr(existing, field, None)
|
||||||
|
if not old_value and new_value:
|
||||||
|
setattr(existing, field, new_value)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
existing.save(update_fields=list(merge_fields.keys()))
|
||||||
|
# Track enrichment
|
||||||
|
if source not in existing.operation_names:
|
||||||
|
existing.operation_names.append(source)
|
||||||
|
existing.save(update_fields=["operation_names"])
|
||||||
|
if verbose:
|
||||||
|
action = "Merged" if changed else "Skipped (merged)"
|
||||||
|
self.stdout.write(
|
||||||
|
f"{Fore.CYAN}→{Style.RESET_ALL} {action} campaign: {reward.name}",
|
||||||
|
)
|
||||||
|
# Still process time-based drops and channels for existing
|
||||||
|
if reward.time_based_drops:
|
||||||
|
for tbd_schema in reward.time_based_drops:
|
||||||
|
self._process_time_based_drop(
|
||||||
|
tbd_schema=tbd_schema,
|
||||||
|
campaign_obj=existing,
|
||||||
|
source=source,
|
||||||
|
verbose=verbose,
|
||||||
|
)
|
||||||
|
if reward.allow:
|
||||||
|
self._process_allow_channels(
|
||||||
|
allow_schema=reward.allow,
|
||||||
|
campaign_obj=existing,
|
||||||
|
verbose=verbose,
|
||||||
|
)
|
||||||
|
if not existing.is_fully_imported:
|
||||||
|
existing.is_fully_imported = True
|
||||||
|
existing.save(update_fields=["is_fully_imported"])
|
||||||
|
return
|
||||||
|
|
||||||
|
if skip_existing and existing is not None:
|
||||||
|
if verbose:
|
||||||
|
self.stdout.write(
|
||||||
|
f"{Fore.YELLOW}→{Style.RESET_ALL} Skipped campaign: "
|
||||||
|
f"{reward.name} (already exists)",
|
||||||
|
)
|
||||||
|
# Still accumulate channels and time-based drops for skipped
|
||||||
|
# campaigns so historical channels are never lost.
|
||||||
|
if reward.time_based_drops:
|
||||||
|
for tbd_schema in reward.time_based_drops:
|
||||||
|
self._process_time_based_drop(
|
||||||
|
tbd_schema=tbd_schema,
|
||||||
|
campaign_obj=existing,
|
||||||
|
source=source,
|
||||||
|
verbose=verbose,
|
||||||
|
)
|
||||||
|
if reward.allow:
|
||||||
|
self._process_allow_channels(
|
||||||
|
allow_schema=reward.allow,
|
||||||
|
campaign_obj=existing,
|
||||||
|
verbose=verbose,
|
||||||
|
)
|
||||||
|
if not existing.is_fully_imported:
|
||||||
|
existing.is_fully_imported = True
|
||||||
|
existing.save(update_fields=["is_fully_imported"])
|
||||||
|
return
|
||||||
|
|
||||||
|
campaign_obj, created = DropCampaign.objects.get_or_create(
|
||||||
|
twitch_id=reward.twitch_id,
|
||||||
|
defaults=defaults,
|
||||||
|
)
|
||||||
|
if not created:
|
||||||
|
self._save_if_changed(campaign_obj, defaults)
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
action = "Created" if created else "Updated"
|
||||||
|
self.stdout.write(
|
||||||
|
f"{Fore.GREEN}✓{Style.RESET_ALL} {action} campaign: "
|
||||||
|
f"{reward.name} (source: {source})",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process time-based drops
|
||||||
|
if reward.time_based_drops:
|
||||||
|
for tbd_schema in reward.time_based_drops:
|
||||||
|
self._process_time_based_drop(
|
||||||
|
tbd_schema=tbd_schema,
|
||||||
|
campaign_obj=campaign_obj,
|
||||||
|
source=source,
|
||||||
|
verbose=verbose,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process allowed channels
|
||||||
|
if reward.allow:
|
||||||
|
self._process_allow_channels(
|
||||||
|
allow_schema=reward.allow,
|
||||||
|
campaign_obj=campaign_obj,
|
||||||
|
verbose=verbose,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark as fully imported
|
||||||
|
if not campaign_obj.is_fully_imported:
|
||||||
|
campaign_obj.is_fully_imported = True
|
||||||
|
campaign_obj.save(update_fields=["is_fully_imported"])
|
||||||
|
|
||||||
|
def _process_time_based_drop(
|
||||||
|
self,
|
||||||
|
tbd_schema: SunkwiBotTimeBasedDropSchema,
|
||||||
|
campaign_obj: DropCampaign,
|
||||||
|
source: str, # noqa: ARG002
|
||||||
|
*,
|
||||||
|
verbose: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Process a single TimeBasedDrop from the SunkwiBOT schema.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tbd_schema: A SunkwiBotTimeBasedDropSchema instance.
|
||||||
|
campaign_obj: The parent DropCampaign.
|
||||||
|
source: Data source label.
|
||||||
|
verbose: Print per-record messages.
|
||||||
|
"""
|
||||||
|
start_at_dt = parse_date(tbd_schema.start_at)
|
||||||
|
end_at_dt = parse_date(tbd_schema.end_at)
|
||||||
|
|
||||||
|
drop_defaults: dict[str, Any] = {
|
||||||
|
"campaign": campaign_obj,
|
||||||
|
"name": tbd_schema.name,
|
||||||
|
"required_subs": tbd_schema.required_subs,
|
||||||
|
}
|
||||||
|
if tbd_schema.required_minutes_watched is not None:
|
||||||
|
drop_defaults["required_minutes_watched"] = (
|
||||||
|
tbd_schema.required_minutes_watched
|
||||||
|
)
|
||||||
|
if start_at_dt is not None:
|
||||||
|
drop_defaults["start_at"] = start_at_dt
|
||||||
|
if end_at_dt is not None:
|
||||||
|
drop_defaults["end_at"] = end_at_dt
|
||||||
|
|
||||||
|
drop_obj, created = TimeBasedDrop.objects.get_or_create(
|
||||||
|
twitch_id=tbd_schema.twitch_id,
|
||||||
|
defaults=drop_defaults,
|
||||||
|
)
|
||||||
|
if not created:
|
||||||
|
self._save_if_changed(drop_obj, drop_defaults)
|
||||||
|
|
||||||
|
if verbose and created:
|
||||||
|
self.stdout.write(
|
||||||
|
f"{Fore.GREEN}✓{Style.RESET_ALL} Created TimeBasedDrop: "
|
||||||
|
f"{tbd_schema.name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process benefit edges
|
||||||
|
for edge_schema in tbd_schema.benefit_edges:
|
||||||
|
self._process_benefit_edge(
|
||||||
|
edge_schema=edge_schema,
|
||||||
|
drop_obj=drop_obj,
|
||||||
|
verbose=verbose,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _process_benefit_edge(
|
||||||
|
self,
|
||||||
|
edge_schema: SunkwiBotBenefitEdgeSchema,
|
||||||
|
drop_obj: TimeBasedDrop,
|
||||||
|
*,
|
||||||
|
verbose: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Process a benefit edge, creating/updating the benefit and edge.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
edge_schema: A SunkwiBotBenefitEdgeSchema instance.
|
||||||
|
drop_obj: The parent TimeBasedDrop.
|
||||||
|
verbose: Print per-record messages.
|
||||||
|
"""
|
||||||
|
benefit_schema = edge_schema.benefit
|
||||||
|
|
||||||
|
# Get or create benefit
|
||||||
|
cache_key = benefit_schema.twitch_id
|
||||||
|
cached = self._benefit_cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
benefit_obj = cached
|
||||||
|
else:
|
||||||
|
benefit_obj = DropBenefit.objects.filter(
|
||||||
|
twitch_id=benefit_schema.twitch_id,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if benefit_obj is None:
|
||||||
|
created_at_dt = (
|
||||||
|
parse_date(benefit_schema.created_at)
|
||||||
|
if benefit_schema.created_at
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
benefit_obj = DropBenefit.objects.create(
|
||||||
|
twitch_id=benefit_schema.twitch_id,
|
||||||
|
name=benefit_schema.name,
|
||||||
|
image_asset_url=benefit_schema.image_asset_url,
|
||||||
|
entitlement_limit=benefit_schema.entitlement_limit,
|
||||||
|
is_ios_available=benefit_schema.is_ios_available,
|
||||||
|
distribution_type=benefit_schema.distribution_type or "",
|
||||||
|
created_at=created_at_dt,
|
||||||
|
)
|
||||||
|
if verbose:
|
||||||
|
self.stdout.write(
|
||||||
|
f"{Fore.GREEN}✓{Style.RESET_ALL} Created DropBenefit: "
|
||||||
|
f"{benefit_schema.name}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
defaults: dict[str, Any] = {
|
||||||
|
"name": benefit_schema.name,
|
||||||
|
"image_asset_url": benefit_schema.image_asset_url,
|
||||||
|
"entitlement_limit": benefit_schema.entitlement_limit,
|
||||||
|
"is_ios_available": benefit_schema.is_ios_available,
|
||||||
|
"distribution_type": benefit_schema.distribution_type or "",
|
||||||
|
}
|
||||||
|
if benefit_schema.created_at:
|
||||||
|
created_at_dt = parse_date(benefit_schema.created_at)
|
||||||
|
if created_at_dt:
|
||||||
|
defaults["created_at"] = created_at_dt
|
||||||
|
self._save_if_changed(benefit_obj, defaults)
|
||||||
|
|
||||||
|
self._benefit_cache[cache_key] = benefit_obj
|
||||||
|
|
||||||
|
# Create or update the edge
|
||||||
|
edge_defaults = {"entitlement_limit": edge_schema.entitlement_limit}
|
||||||
|
edge_obj, edge_created = DropBenefitEdge.objects.get_or_create(
|
||||||
|
drop=drop_obj,
|
||||||
|
benefit=benefit_obj,
|
||||||
|
defaults=edge_defaults,
|
||||||
|
)
|
||||||
|
if not edge_created:
|
||||||
|
self._save_if_changed(edge_obj, edge_defaults)
|
||||||
|
|
||||||
|
def _process_allow_channels(
|
||||||
|
self,
|
||||||
|
allow_schema: SunkwiBotDropCampaignACLSchema,
|
||||||
|
campaign_obj: DropCampaign,
|
||||||
|
*,
|
||||||
|
verbose: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Process the allow.channels list and set on the campaign.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
allow_schema: A SunkwiBotDropCampaignACLSchema instance.
|
||||||
|
campaign_obj: The DropCampaign to update.
|
||||||
|
verbose: Print per-record messages.
|
||||||
|
"""
|
||||||
|
# Update allow_is_enabled
|
||||||
|
if campaign_obj.allow_is_enabled != allow_schema.is_enabled:
|
||||||
|
campaign_obj.allow_is_enabled = allow_schema.is_enabled
|
||||||
|
campaign_obj.save(update_fields=["allow_is_enabled"])
|
||||||
|
|
||||||
|
if not allow_schema.channels:
|
||||||
|
return
|
||||||
|
|
||||||
|
channel_objects: list[Channel] = []
|
||||||
|
for ch in allow_schema.channels:
|
||||||
|
display_name: str = ch.display_name or ch.name
|
||||||
|
channel_obj, created = Channel.objects.get_or_create(
|
||||||
|
twitch_id=ch.twitch_id,
|
||||||
|
defaults={
|
||||||
|
"name": ch.name,
|
||||||
|
"display_name": display_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not created:
|
||||||
|
self._save_if_changed(
|
||||||
|
channel_obj,
|
||||||
|
{"name": ch.name, "display_name": display_name},
|
||||||
|
)
|
||||||
|
if verbose and created:
|
||||||
|
self.stdout.write(
|
||||||
|
f"{Fore.GREEN}✓{Style.RESET_ALL} Created channel: {display_name}",
|
||||||
|
)
|
||||||
|
channel_objects.append(channel_obj)
|
||||||
|
|
||||||
|
campaign_obj.allow_channels.add(*channel_objects)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# rewards.json import
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _import_rewards(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
source: str,
|
||||||
|
*,
|
||||||
|
verbose: bool,
|
||||||
|
crash_on_error: bool,
|
||||||
|
skip_existing: bool = False,
|
||||||
|
) -> int:
|
||||||
|
"""Import reward campaigns from a rewards.json URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Raw GitHub URL for rewards.json.
|
||||||
|
source: Data source label to store on records.
|
||||||
|
verbose: Print per-record messages.
|
||||||
|
crash_on_error: Raise on first error instead of continuing.
|
||||||
|
skip_existing: If True, skip campaigns already in the database.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of reward campaigns imported/updated.
|
||||||
|
"""
|
||||||
|
raw_data = self._fetch_json(url, "rewards.json")
|
||||||
|
return self._parse_and_import_rewards(
|
||||||
|
raw_data=raw_data,
|
||||||
|
source=source,
|
||||||
|
verbose=verbose,
|
||||||
|
crash_on_error=crash_on_error,
|
||||||
|
label="rewards.json",
|
||||||
|
skip_existing=skip_existing,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_and_import_rewards( # noqa: PLR0913
|
||||||
|
self,
|
||||||
|
raw_data: list[dict[str, Any]],
|
||||||
|
source: str,
|
||||||
|
*,
|
||||||
|
verbose: bool,
|
||||||
|
crash_on_error: bool,
|
||||||
|
label: str = "rewards.json",
|
||||||
|
skip_existing: bool = False,
|
||||||
|
) -> int:
|
||||||
|
"""Validate and import raw rewards.json data (from URL or git history).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_data: Parsed rewards.json array.
|
||||||
|
source: Data source label.
|
||||||
|
verbose: Print per-record messages.
|
||||||
|
crash_on_error: Raise on first error.
|
||||||
|
label: Human label for error messages.
|
||||||
|
skip_existing: If True, skip campaigns already in the database.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of reward campaigns imported/updated.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If crash_on_error is True and validation fails.
|
||||||
|
"""
|
||||||
|
raw_data = self._strip_typename(raw_data)
|
||||||
|
validated: list[SunkwiBotRewardCampaignSchema] = []
|
||||||
|
for i, item in enumerate(raw_data):
|
||||||
|
try:
|
||||||
|
campaign = SunkwiBotRewardCampaignSchema.model_validate(item)
|
||||||
|
validated.append(campaign)
|
||||||
|
except ValidationError as e:
|
||||||
|
msg = f"{label} item [{i}] failed validation: {e}"
|
||||||
|
if crash_on_error:
|
||||||
|
raise
|
||||||
|
self.stderr.write(self.style.ERROR(msg))
|
||||||
|
continue
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for campaign in validated:
|
||||||
|
try:
|
||||||
|
self._process_reward_campaign(
|
||||||
|
campaign=campaign,
|
||||||
|
source=source,
|
||||||
|
verbose=verbose,
|
||||||
|
skip_existing=skip_existing,
|
||||||
|
)
|
||||||
|
total += 1
|
||||||
|
except Exception as exc:
|
||||||
|
msg = (
|
||||||
|
f"Failed to import reward campaign {campaign.twitch_id} "
|
||||||
|
f"({campaign.name}): {exc}"
|
||||||
|
)
|
||||||
|
if crash_on_error:
|
||||||
|
raise
|
||||||
|
self.stderr.write(self.style.ERROR(msg))
|
||||||
|
|
||||||
|
return total
|
||||||
|
|
||||||
|
def _process_reward_campaign(
|
||||||
|
self,
|
||||||
|
campaign: SunkwiBotRewardCampaignSchema,
|
||||||
|
source: str,
|
||||||
|
*,
|
||||||
|
verbose: bool,
|
||||||
|
skip_existing: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Process a single reward campaign from rewards.json.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
campaign: Validated reward campaign schema.
|
||||||
|
source: Data source label.
|
||||||
|
verbose: Print per-record messages.
|
||||||
|
skip_existing: If True, skip if campaign already exists.
|
||||||
|
"""
|
||||||
|
starts_at_dt = parse_date(campaign.starts_at)
|
||||||
|
ends_at_dt = parse_date(campaign.ends_at)
|
||||||
|
|
||||||
|
# Resolve game if present
|
||||||
|
game_obj: Game | None = None
|
||||||
|
if campaign.game and isinstance(campaign.game, dict):
|
||||||
|
game_id = campaign.game.get("id")
|
||||||
|
if game_id:
|
||||||
|
with contextlib.suppress(Game.DoesNotExist):
|
||||||
|
game_obj = Game.objects.get(twitch_id=game_id)
|
||||||
|
|
||||||
|
defaults: dict[str, Any] = {
|
||||||
|
"name": campaign.name,
|
||||||
|
"brand": campaign.brand,
|
||||||
|
"starts_at": starts_at_dt,
|
||||||
|
"ends_at": ends_at_dt,
|
||||||
|
"status": campaign.status,
|
||||||
|
"summary": campaign.summary,
|
||||||
|
"instructions": campaign.instructions,
|
||||||
|
"external_url": campaign.external_url,
|
||||||
|
"reward_value_url_param": campaign.reward_value_url_param,
|
||||||
|
"about_url": campaign.about_url,
|
||||||
|
"is_sitewide": campaign.is_sitewide,
|
||||||
|
"game": game_obj,
|
||||||
|
"image_url": campaign.image.image1x_url if campaign.image else "",
|
||||||
|
"data_source": source,
|
||||||
|
}
|
||||||
|
|
||||||
|
existing_reward: RewardCampaign | None = RewardCampaign.objects.filter(
|
||||||
|
twitch_id=campaign.twitch_id,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
# Merge mode: fill empty fields, never overwrite populated ones
|
||||||
|
if existing_reward is not None and getattr(self, "_merge_mode", False):
|
||||||
|
changed = False
|
||||||
|
merge_fields = {
|
||||||
|
"summary": campaign.summary,
|
||||||
|
"instructions": campaign.instructions,
|
||||||
|
"image_url": campaign.image.image1x_url if campaign.image else "",
|
||||||
|
"about_url": campaign.about_url,
|
||||||
|
"external_url": campaign.external_url,
|
||||||
|
}
|
||||||
|
for field, new_value in merge_fields.items():
|
||||||
|
old_value = getattr(existing_reward, field, None)
|
||||||
|
if not old_value and new_value:
|
||||||
|
setattr(existing_reward, field, new_value)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
existing_reward.save(
|
||||||
|
update_fields=list(merge_fields.keys()),
|
||||||
|
)
|
||||||
|
if verbose:
|
||||||
|
action_str = "Merged" if changed else "Skipped (merged)"
|
||||||
|
display_name = (
|
||||||
|
f"{campaign.brand}: {campaign.name}"
|
||||||
|
if campaign.brand
|
||||||
|
else campaign.name
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
f"{Fore.CYAN}→{Style.RESET_ALL} {action_str} reward "
|
||||||
|
f"campaign: {display_name}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if skip_existing and existing_reward is not None:
|
||||||
|
if verbose:
|
||||||
|
self.stdout.write(
|
||||||
|
f"{Fore.YELLOW}→{Style.RESET_ALL} Skipped reward campaign: "
|
||||||
|
f"{campaign.name} (already exists)",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
reward_obj, created = RewardCampaign.objects.get_or_create(
|
||||||
|
twitch_id=campaign.twitch_id,
|
||||||
|
defaults=defaults,
|
||||||
|
)
|
||||||
|
updated = self._save_if_changed(reward_obj, defaults) if not created else True
|
||||||
|
|
||||||
|
if verbose and (created or updated):
|
||||||
|
action = "Created" if created else "Updated"
|
||||||
|
display_name = (
|
||||||
|
f"{campaign.brand}: {campaign.name}"
|
||||||
|
if campaign.brand
|
||||||
|
else campaign.name
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
f"{Fore.GREEN}✓{Style.RESET_ALL} {action} reward campaign: "
|
||||||
|
f"{display_name} (source: {source})",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Shared helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _save_if_changed(self, obj: Model, defaults: dict[str, Any]) -> bool: # type: ignore[valid-type]
|
||||||
|
"""Save the model instance only when data actually changed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
obj: The model instance to potentially update.
|
||||||
|
defaults: Field values to apply.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the object was saved, False if no changes were detected.
|
||||||
|
"""
|
||||||
|
changed_fields: list[str] = []
|
||||||
|
for field, new_value in defaults.items():
|
||||||
|
if getattr(obj, field, None) != new_value:
|
||||||
|
setattr(obj, field, new_value)
|
||||||
|
changed_fields.append(field)
|
||||||
|
|
||||||
|
if not changed_fields:
|
||||||
|
return False
|
||||||
|
|
||||||
|
obj.save(update_fields=changed_fields)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_or_create_organization(
|
||||||
|
self,
|
||||||
|
twitch_id: str,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
verbose: bool,
|
||||||
|
) -> Organization:
|
||||||
|
"""Get or create an organization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
twitch_id: Twitch organization ID.
|
||||||
|
name: Organization name.
|
||||||
|
verbose: Print per-record messages.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Organization instance.
|
||||||
|
"""
|
||||||
|
cached = self._org_cache.get(twitch_id)
|
||||||
|
if cached is not None:
|
||||||
|
self._save_if_changed(cached, {"name": name})
|
||||||
|
return cached
|
||||||
|
|
||||||
|
org_obj, created = Organization.objects.get_or_create(
|
||||||
|
twitch_id=twitch_id,
|
||||||
|
defaults={"name": name},
|
||||||
|
)
|
||||||
|
if not created:
|
||||||
|
self._save_if_changed(org_obj, {"name": name})
|
||||||
|
if verbose and created:
|
||||||
|
self.stdout.write(
|
||||||
|
f"{Fore.GREEN}✓{Style.RESET_ALL} Created organization: {name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._org_cache[twitch_id] = org_obj
|
||||||
|
return org_obj
|
||||||
|
|
||||||
|
def _get_or_create_game( # noqa: PLR0913
|
||||||
|
self,
|
||||||
|
twitch_id: str,
|
||||||
|
display_name: str,
|
||||||
|
box_art_url: str,
|
||||||
|
source: str, # noqa: ARG002
|
||||||
|
*,
|
||||||
|
verbose: bool,
|
||||||
|
org_obj: Organization | None = None,
|
||||||
|
) -> Game:
|
||||||
|
"""Get or create a game.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
twitch_id: Twitch game ID.
|
||||||
|
display_name: Game display name.
|
||||||
|
box_art_url: Box art URL.
|
||||||
|
source: Data source label.
|
||||||
|
verbose: Print per-record messages.
|
||||||
|
org_obj: Optional organization to assign as owner of the game.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Game instance.
|
||||||
|
"""
|
||||||
|
cached = self._game_cache.get(twitch_id)
|
||||||
|
if cached is not None:
|
||||||
|
self._save_if_changed(
|
||||||
|
cached,
|
||||||
|
{
|
||||||
|
"display_name": display_name,
|
||||||
|
"box_art": box_art_url,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return cached
|
||||||
|
|
||||||
|
defaults: dict[str, Any] = {
|
||||||
|
"display_name": display_name,
|
||||||
|
"name": display_name,
|
||||||
|
"box_art": box_art_url,
|
||||||
|
}
|
||||||
|
game_obj, created = Game.objects.get_or_create(
|
||||||
|
twitch_id=twitch_id,
|
||||||
|
defaults=defaults,
|
||||||
|
)
|
||||||
|
if not created:
|
||||||
|
self._save_if_changed(
|
||||||
|
game_obj,
|
||||||
|
{
|
||||||
|
"display_name": display_name,
|
||||||
|
"name": display_name,
|
||||||
|
"box_art": box_art_url,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Assign owner organization if provided
|
||||||
|
if (
|
||||||
|
org_obj is not None
|
||||||
|
and not game_obj.owners.filter(
|
||||||
|
pk=org_obj.pk,
|
||||||
|
).exists()
|
||||||
|
):
|
||||||
|
game_obj.owners.add(org_obj)
|
||||||
|
if verbose:
|
||||||
|
self.stdout.write(
|
||||||
|
f"{Fore.GREEN}✓{Style.RESET_ALL} Assigned owner "
|
||||||
|
f"{org_obj.name} to game {display_name}",
|
||||||
|
)
|
||||||
|
if verbose and created:
|
||||||
|
self.stdout.write(
|
||||||
|
f"{Fore.GREEN}✓{Style.RESET_ALL} Created game: {display_name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._game_cache[twitch_id] = game_obj
|
||||||
|
return game_obj
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Generated by Django 6.0.6 on 2026-06-14 13:39
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
"""Adds a `data_source` field to DropCampaign and RewardCampaign to track the origin of imported campaign data.
|
||||||
|
|
||||||
|
This allows us to distinguish between campaigns imported by different tools (e.g. TwitchDropsMiner vs. SunkwiBOT/twitch-drops-api) and can be useful for debugging, analytics, or future migrations that may need to handle data differently based on its source.
|
||||||
|
"""
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("twitch", "0023_alter_dropcampaign_url_lengths"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="dropcampaign",
|
||||||
|
name="data_source",
|
||||||
|
field=models.TextField(
|
||||||
|
choices=[
|
||||||
|
("TwitchDropsMiner", "TwitchDropsMiner"),
|
||||||
|
("SunkwiBOT/twitch-drops-api", "SunkwiBOT/twitch-drops-api"),
|
||||||
|
],
|
||||||
|
default="TwitchDropsMiner",
|
||||||
|
help_text="Identifies the origin tool/script that imported this campaign data.",
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="rewardcampaign",
|
||||||
|
name="data_source",
|
||||||
|
field=models.TextField(
|
||||||
|
choices=[
|
||||||
|
("TwitchDropsMiner", "TwitchDropsMiner"),
|
||||||
|
("SunkwiBOT/twitch-drops-api", "SunkwiBOT/twitch-drops-api"),
|
||||||
|
],
|
||||||
|
default="TwitchDropsMiner",
|
||||||
|
help_text="Identifies the origin tool/script that imported this campaign data.",
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -705,6 +705,16 @@ class DropCampaign(auto_prefetch.Model): # noqa: PLR0904
|
||||||
auto_now=True,
|
auto_now=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data_source = models.TextField(
|
||||||
|
help_text="Identifies the origin tool/script that imported this campaign data.",
|
||||||
|
max_length=50,
|
||||||
|
choices=[
|
||||||
|
("TwitchDropsMiner", "TwitchDropsMiner"),
|
||||||
|
("SunkwiBOT/twitch-drops-api", "SunkwiBOT/twitch-drops-api"),
|
||||||
|
],
|
||||||
|
default="TwitchDropsMiner",
|
||||||
|
)
|
||||||
|
|
||||||
is_fully_imported = models.BooleanField(
|
is_fully_imported = models.BooleanField(
|
||||||
help_text="True if all images and formats are imported and ready for display.",
|
help_text="True if all images and formats are imported and ready for display.",
|
||||||
default=False,
|
default=False,
|
||||||
|
|
@ -912,6 +922,9 @@ class DropCampaign(auto_prefetch.Model): # noqa: PLR0904
|
||||||
"twitch_id",
|
"twitch_id",
|
||||||
"name",
|
"name",
|
||||||
"distribution_type",
|
"distribution_type",
|
||||||
|
"created_at",
|
||||||
|
"entitlement_limit",
|
||||||
|
"is_ios_available",
|
||||||
"image_asset_url",
|
"image_asset_url",
|
||||||
"image_file",
|
"image_file",
|
||||||
"image_width",
|
"image_width",
|
||||||
|
|
@ -1806,6 +1819,16 @@ class RewardCampaign(auto_prefetch.Model):
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data_source = models.TextField(
|
||||||
|
help_text="Identifies the origin tool/script that imported this campaign data.",
|
||||||
|
max_length=50,
|
||||||
|
choices=[
|
||||||
|
("TwitchDropsMiner", "TwitchDropsMiner"),
|
||||||
|
("SunkwiBOT/twitch-drops-api", "SunkwiBOT/twitch-drops-api"),
|
||||||
|
],
|
||||||
|
default="TwitchDropsMiner",
|
||||||
|
)
|
||||||
|
|
||||||
game = auto_prefetch.ForeignKey(
|
game = auto_prefetch.ForeignKey(
|
||||||
help_text="Game associated with this reward campaign (if any).",
|
help_text="Game associated with this reward campaign (if any).",
|
||||||
related_name="reward_campaigns",
|
related_name="reward_campaigns",
|
||||||
|
|
|
||||||
|
|
@ -649,9 +649,267 @@ class GlobalChatBadgesResponse(BaseModel):
|
||||||
|
|
||||||
data: list[ChatBadgeSetSchema]
|
data: list[ChatBadgeSetSchema]
|
||||||
|
|
||||||
|
|
||||||
|
# MARK: SunkwiBOT /twitch-drops-api Schemas
|
||||||
|
#
|
||||||
|
# The SunkwiBOT API serves simplified JSON without GraphQL metadata (no
|
||||||
|
# __typename, etc.). These schemas are relaxed to match that format.
|
||||||
|
|
||||||
|
|
||||||
|
class SunkwiBotChannelInfoSchema(BaseModel):
|
||||||
|
"""Schema for a channel inside a drop's allow.channels from the SunkwiBOT API."""
|
||||||
|
|
||||||
|
twitch_id: str = Field(alias="id")
|
||||||
|
display_name: str | None = Field(default=None, alias="displayName")
|
||||||
|
name: str
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"extra": "forbid",
|
||||||
|
"validate_assignment": True,
|
||||||
|
"populate_by_name": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SunkwiBotDropCampaignACLSchema(BaseModel):
|
||||||
|
"""Schema for the allow field within a SunkwiBOT reward (drop campaign)."""
|
||||||
|
|
||||||
|
channels: list[SunkwiBotChannelInfoSchema] | None = None
|
||||||
|
is_enabled: bool = Field(alias="isEnabled")
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"extra": "forbid",
|
||||||
|
"validate_assignment": True,
|
||||||
|
"populate_by_name": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SunkwiBotGameRefSchema(BaseModel):
|
||||||
|
"""Schema for a game object inside a SunkwiBOT reward."""
|
||||||
|
|
||||||
|
twitch_id: str = Field(alias="id")
|
||||||
|
slug: str
|
||||||
|
display_name: str = Field(alias="displayName")
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"extra": "forbid",
|
||||||
|
"validate_assignment": True,
|
||||||
|
"populate_by_name": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SunkwiBotOrganizationSchema(BaseModel):
|
||||||
|
"""Schema for an owner organization inside a SunkwiBOT reward."""
|
||||||
|
|
||||||
|
twitch_id: str = Field(alias="id")
|
||||||
|
name: str
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"extra": "forbid",
|
||||||
|
"validate_assignment": True,
|
||||||
|
"populate_by_name": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SunkwiBotBenefitSchema(BaseModel):
|
||||||
|
"""Schema for a benefit inside a benefitEdge from the SunkwiBOT API."""
|
||||||
|
|
||||||
|
twitch_id: str = Field(alias="id")
|
||||||
|
created_at: str | None = Field(default=None, alias="createdAt")
|
||||||
|
entitlement_limit: int = Field(alias="entitlementLimit")
|
||||||
|
game: dict | None = None
|
||||||
|
image_asset_url: str = Field(alias="imageAssetURL")
|
||||||
|
is_ios_available: bool = Field(alias="isIosAvailable")
|
||||||
|
name: str
|
||||||
|
owner_organization: dict | None = Field(default=None, alias="ownerOrganization")
|
||||||
|
distribution_type: str | None = Field(default=None, alias="distributionType")
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"extra": "forbid",
|
||||||
|
"validate_assignment": True,
|
||||||
|
"populate_by_name": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SunkwiBotBenefitEdgeSchema(BaseModel):
|
||||||
|
"""Schema for a benefitEdge within a timeBasedDrop from the SunkwiBOT API."""
|
||||||
|
|
||||||
|
benefit: SunkwiBotBenefitSchema
|
||||||
|
entitlement_limit: int = Field(alias="entitlementLimit")
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"extra": "forbid",
|
||||||
|
"validate_assignment": True,
|
||||||
|
"populate_by_name": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SunkwiBotTimeBasedDropSchema(BaseModel):
|
||||||
|
"""Schema for a timeBasedDrop within a SunkwiBOT reward (drop campaign)."""
|
||||||
|
|
||||||
|
twitch_id: str = Field(alias="id")
|
||||||
|
required_subs: int = Field(alias="requiredSubs")
|
||||||
|
benefit_edges: list[SunkwiBotBenefitEdgeSchema] = Field(
|
||||||
|
default=[],
|
||||||
|
alias="benefitEdges",
|
||||||
|
)
|
||||||
|
end_at: str = Field(alias="endAt")
|
||||||
|
name: str
|
||||||
|
precondition_drops: None = Field(default=None, alias="preconditionDrops")
|
||||||
|
required_minutes_watched: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
alias="requiredMinutesWatched",
|
||||||
|
)
|
||||||
|
start_at: str = Field(alias="startAt")
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"extra": "forbid",
|
||||||
|
"validate_assignment": True,
|
||||||
|
"populate_by_name": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
@field_validator("benefit_edges", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def handle_null_benefit_edges(cls, v: list | None) -> list:
|
||||||
|
"""Convert null benefitEdges to empty list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
v: The raw benefit_edges value (list or None).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Empty list if None, otherwise the list itself.
|
||||||
|
"""
|
||||||
|
return v or []
|
||||||
|
|
||||||
|
|
||||||
|
class SunkwiBotRewardSchema(BaseModel):
|
||||||
|
"""Schema for a single reward item inside the drops.json rewards array.
|
||||||
|
|
||||||
|
This maps to a DropCampaign in our database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
twitch_id: str = Field(alias="id")
|
||||||
|
self_field: dict | None = Field(default=None, alias="self")
|
||||||
|
allow: SunkwiBotDropCampaignACLSchema | None = None
|
||||||
|
account_link_url: str = Field(alias="accountLinkURL")
|
||||||
|
description: str
|
||||||
|
details_url: str = Field(alias="detailsURL")
|
||||||
|
end_at: str = Field(alias="endAt")
|
||||||
|
event_based_drops: list | None = Field(default=None, alias="eventBasedDrops")
|
||||||
|
game: SunkwiBotGameRefSchema
|
||||||
|
image_url: str = Field(alias="imageURL")
|
||||||
|
name: str
|
||||||
|
owner: SunkwiBotOrganizationSchema
|
||||||
|
start_at: str = Field(alias="startAt")
|
||||||
|
status: str
|
||||||
|
time_based_drops: list[SunkwiBotTimeBasedDropSchema] = Field(
|
||||||
|
default=[],
|
||||||
|
alias="timeBasedDrops",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"extra": "forbid",
|
||||||
|
"validate_assignment": True,
|
||||||
|
"populate_by_name": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SunkwiBotDropGroupSchema(BaseModel):
|
||||||
|
"""Schema for a single top-level item in the drops.json array.
|
||||||
|
|
||||||
|
Each item groups multiple drop campaigns under a shared game.
|
||||||
|
"""
|
||||||
|
|
||||||
|
end_at: str = Field(alias="endAt")
|
||||||
|
game_box_art_url: str = Field(alias="gameBoxArtURL")
|
||||||
|
game_display_name: str = Field(alias="gameDisplayName")
|
||||||
|
game_id: str = Field(alias="gameId")
|
||||||
|
rewards: list[SunkwiBotRewardSchema]
|
||||||
|
start_at: str = Field(alias="startAt")
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"extra": "forbid",
|
||||||
|
"validate_assignment": True,
|
||||||
|
"populate_by_name": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SunkwiBotRewardImageSetSchema(BaseModel):
|
||||||
|
"""Schema for an image set inside a rewards.json reward."""
|
||||||
|
|
||||||
|
image1x_url: str = Field(alias="image1xURL")
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"extra": "forbid",
|
||||||
|
"validate_assignment": True,
|
||||||
|
"populate_by_name": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SunkwiBotIndividualRewardSchema(BaseModel):
|
||||||
|
"""Schema for a single reward inside a rewards.json reward campaign."""
|
||||||
|
|
||||||
|
twitch_id: str = Field(alias="id")
|
||||||
|
name: str
|
||||||
|
banner_image: SunkwiBotRewardImageSetSchema | None = Field(
|
||||||
|
default=None,
|
||||||
|
alias="bannerImage",
|
||||||
|
)
|
||||||
|
thumbnail_image: SunkwiBotRewardImageSetSchema | None = Field(
|
||||||
|
default=None,
|
||||||
|
alias="thumbnailImage",
|
||||||
|
)
|
||||||
|
earnable_until: str | None = Field(default=None, alias="earnableUntil")
|
||||||
|
redemption_instructions: str = Field(default="", alias="redemptionInstructions")
|
||||||
|
redemption_url: str = Field(default="", alias="redemptionURL")
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"extra": "forbid",
|
||||||
|
"validate_assignment": True,
|
||||||
|
"populate_by_name": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SunkwiBotRewardUnlockRequirementsSchema(BaseModel):
|
||||||
|
"""Schema for the unlockRequirements inside a rewards.json campaign."""
|
||||||
|
|
||||||
|
minute_watched_goal: int = Field(alias="minuteWatchedGoal")
|
||||||
|
subs_goal: int = Field(alias="subsGoal")
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"extra": "forbid",
|
||||||
|
"validate_assignment": True,
|
||||||
|
"populate_by_name": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SunkwiBotRewardCampaignSchema(BaseModel):
|
||||||
|
"""Schema for a single top-level item in the rewards.json array.
|
||||||
|
|
||||||
|
This maps to a RewardCampaign in our database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
about_url: str = Field(alias="aboutURL")
|
||||||
|
brand: str
|
||||||
|
ends_at: str = Field(alias="endsAt")
|
||||||
|
external_url: str = Field(alias="externalURL")
|
||||||
|
game: dict | None = None
|
||||||
|
twitch_id: str = Field(alias="id")
|
||||||
|
image: SunkwiBotRewardImageSetSchema | None = None
|
||||||
|
instructions: str
|
||||||
|
is_sitewide: bool = Field(alias="isSitewide")
|
||||||
|
name: str
|
||||||
|
rewards: list[SunkwiBotIndividualRewardSchema] = Field(default=[])
|
||||||
|
reward_value_url_param: str = Field(default="", alias="rewardValueURLParam")
|
||||||
|
starts_at: str = Field(alias="startsAt")
|
||||||
|
status: str
|
||||||
|
summary: str
|
||||||
|
unlock_requirements: SunkwiBotRewardUnlockRequirementsSchema | None = Field(
|
||||||
|
default=None,
|
||||||
|
alias="unlockRequirements",
|
||||||
|
)
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"extra": "forbid",
|
"extra": "forbid",
|
||||||
"validate_assignment": True,
|
"validate_assignment": True,
|
||||||
"strict": True,
|
|
||||||
"populate_by_name": True,
|
"populate_by_name": True,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,12 @@ import httpx
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from PIL.Image import Image
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from urllib.parse import ParseResult
|
from urllib.parse import ParseResult
|
||||||
|
|
||||||
from django.db.models.fields.files import ImageFieldFile
|
from django.db.models.fields.files import ImageFieldFile
|
||||||
from PIL.Image import Image
|
from PIL.Image import Image
|
||||||
from PIL.ImageFile import ImageFile
|
|
||||||
|
|
||||||
logger: logging.Logger = logging.getLogger("ttvdrops.tasks")
|
logger: logging.Logger = logging.getLogger("ttvdrops.tasks")
|
||||||
|
|
||||||
|
|
@ -104,37 +102,46 @@ def _convert_to_modern_formats(source: Path) -> None:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from PIL import Image # noqa: PLC0415
|
img = _open_and_prepare_image(source)
|
||||||
except ImportError:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
with Image.open(source) as raw:
|
|
||||||
if raw.mode in {"RGBA", "LA"} or (
|
|
||||||
raw.mode == "P" and "transparency" in raw.info
|
|
||||||
):
|
|
||||||
background: Image = Image.new("RGB", raw.size, (255, 255, 255))
|
|
||||||
rgba: Image | ImageFile = (
|
|
||||||
raw.convert("RGBA") if raw.mode == "P" else raw
|
|
||||||
)
|
|
||||||
mask: Image | None = (
|
|
||||||
rgba.split()[-1] if rgba.mode in {"RGBA", "LA"} else None
|
|
||||||
)
|
|
||||||
background.paste(rgba, mask=mask)
|
|
||||||
img: Image = background
|
|
||||||
elif raw.mode != "RGB":
|
|
||||||
img = raw.convert("RGB")
|
|
||||||
else:
|
|
||||||
img: Image = raw.copy()
|
|
||||||
|
|
||||||
for fmt, ext in (("WEBP", ".webp"), ("AVIF", ".avif")):
|
|
||||||
out: Path = source.with_suffix(ext)
|
|
||||||
try:
|
|
||||||
img.save(out, fmt, quality=80)
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
logger.debug("Could not convert %s to %s.", source, fmt)
|
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
logger.debug("Format conversion failed for %s.", source)
|
logger.debug("Format conversion failed for %s.", source)
|
||||||
|
return
|
||||||
|
|
||||||
|
_save_modern_formats(img, source)
|
||||||
|
|
||||||
|
|
||||||
|
def _open_and_prepare_image(source: Path) -> Image:
|
||||||
|
"""Open an image and convert it to an RGB-mode PIL Image.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An RGB-mode PIL Image ready for saving in modern formats.
|
||||||
|
"""
|
||||||
|
from PIL import Image # noqa: PLC0415
|
||||||
|
|
||||||
|
with Image.open(source) as raw:
|
||||||
|
if raw.mode in {"RGBA", "LA"} or (
|
||||||
|
raw.mode == "P" and "transparency" in raw.info
|
||||||
|
):
|
||||||
|
background: Image = Image.new("RGB", raw.size, (255, 255, 255))
|
||||||
|
rgba: Image = raw.convert("RGBA") if raw.mode == "P" else raw
|
||||||
|
mask: Image | None = (
|
||||||
|
rgba.split()[-1] if rgba.mode in {"RGBA", "LA"} else None
|
||||||
|
)
|
||||||
|
background.paste(rgba, mask=mask)
|
||||||
|
return background
|
||||||
|
if raw.mode != "RGB":
|
||||||
|
return raw.convert("RGB")
|
||||||
|
return raw.copy()
|
||||||
|
|
||||||
|
|
||||||
|
def _save_modern_formats(img: Image, source: Path) -> None:
|
||||||
|
"""Save *img* as WebP and AVIF alongside the original *source*."""
|
||||||
|
for fmt, ext in (("WEBP", ".webp"), ("AVIF", ".avif")):
|
||||||
|
out: Path = source.with_suffix(ext)
|
||||||
|
try:
|
||||||
|
img.save(out, fmt, quality=80)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.debug("Could not convert %s to %s.", source, fmt)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,106 @@ class TwitchApiV1TestCase(TestCase):
|
||||||
assert data["items"][0]["status"] == "active"
|
assert data["items"][0]["status"] == "active"
|
||||||
assert data["items"][0]["game"]["twitch_id"] == "game123"
|
assert data["items"][0]["game"]["twitch_id"] == "game123"
|
||||||
|
|
||||||
|
def test_v1_campaign_list_filters_by_game(self) -> None:
|
||||||
|
"""Filter campaigns by game twitch_id."""
|
||||||
|
response = self.client.get(
|
||||||
|
f"/twitch/api/v1/campaigns/?game={self.game.twitch_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 1
|
||||||
|
assert data["items"][0]["twitch_id"] == "campaign123"
|
||||||
|
|
||||||
|
def test_v1_campaign_list_game_filter_with_no_matches(self) -> None:
|
||||||
|
"""Return empty list when game filter matches no campaigns."""
|
||||||
|
response = self.client.get("/twitch/api/v1/campaigns/?game=nonexistent")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 0
|
||||||
|
assert data["items"] == []
|
||||||
|
|
||||||
|
def test_v1_campaign_list_status_upcoming(self) -> None:
|
||||||
|
"""Filter campaigns by status=upcoming."""
|
||||||
|
now = timezone.now()
|
||||||
|
DropCampaign.objects.create(
|
||||||
|
twitch_id="upcoming_campaign",
|
||||||
|
name="Upcoming Campaign",
|
||||||
|
game=self.game,
|
||||||
|
start_at=now + timedelta(days=1),
|
||||||
|
end_at=now + timedelta(days=2),
|
||||||
|
operation_names=["DropCampaignDetails"],
|
||||||
|
is_fully_imported=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get("/twitch/api/v1/campaigns/?status=upcoming")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 1
|
||||||
|
assert data["items"][0]["status"] == "upcoming"
|
||||||
|
|
||||||
|
def test_v1_campaign_list_status_expired(self) -> None:
|
||||||
|
"""Filter campaigns by status=expired."""
|
||||||
|
now = timezone.now()
|
||||||
|
DropCampaign.objects.create(
|
||||||
|
twitch_id="expired_campaign",
|
||||||
|
name="Expired Campaign",
|
||||||
|
game=self.game,
|
||||||
|
start_at=now - timedelta(days=3),
|
||||||
|
end_at=now - timedelta(days=1),
|
||||||
|
operation_names=["DropCampaignDetails"],
|
||||||
|
is_fully_imported=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get("/twitch/api/v1/campaigns/?status=expired")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 1
|
||||||
|
assert data["items"][0]["status"] == "expired"
|
||||||
|
|
||||||
|
def test_v1_campaign_list_default_page_size(self) -> None:
|
||||||
|
"""Return all campaigns when no page_size is specified."""
|
||||||
|
response = self.client.get("/twitch/api/v1/campaigns/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] >= 1
|
||||||
|
assert data["page"] == 1
|
||||||
|
|
||||||
|
def test_v1_campaign_list_page_size_param(self) -> None:
|
||||||
|
"""Respect custom page_size parameter."""
|
||||||
|
response = self.client.get("/twitch/api/v1/campaigns/?page_size=1")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["page_size"] == 1
|
||||||
|
assert len(data["items"]) == 1
|
||||||
|
|
||||||
|
def test_v1_campaign_list_summary_fields(self) -> None:
|
||||||
|
"""Return correct field shape for campaign summary items."""
|
||||||
|
response = self.client.get("/twitch/api/v1/campaigns/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
item = data["items"][0]
|
||||||
|
assert item["twitch_id"] == "campaign123"
|
||||||
|
assert item["name"] == "Test Campaign"
|
||||||
|
assert item["description"] == "A test campaign"
|
||||||
|
assert item["status"] == "active"
|
||||||
|
assert item["image_url"] == "https://example.com/campaign.png"
|
||||||
|
assert item["details_url"] == "https://example.com/details"
|
||||||
|
assert item["account_link_url"] == "https://example.com/link"
|
||||||
|
assert item["allow_is_enabled"] is True
|
||||||
|
assert item["is_fully_imported"] is True
|
||||||
|
assert "start_at" in item
|
||||||
|
assert "end_at" in item
|
||||||
|
assert "added_at" in item
|
||||||
|
assert "updated_at" in item
|
||||||
|
assert item["game"]["twitch_id"] == "game123"
|
||||||
|
|
||||||
def test_v1_campaign_detail(self) -> None:
|
def test_v1_campaign_detail(self) -> None:
|
||||||
"""Return nested campaign detail data from the v1 endpoint."""
|
"""Return nested campaign detail data from the v1 endpoint."""
|
||||||
response = self.client.get("/twitch/api/v1/campaigns/campaign123/")
|
response = self.client.get("/twitch/api/v1/campaigns/campaign123/")
|
||||||
|
|
@ -228,6 +328,35 @@ class TwitchApiV1TestCase(TestCase):
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["game"]["box_art_url"] == self.game.box_art_file.url
|
assert data["game"]["box_art_url"] == self.game.box_art_file.url
|
||||||
|
|
||||||
|
def test_v1_campaign_detail_avoids_n_plus_one_on_benefits(self) -> None:
|
||||||
|
"""Fetch campaign detail without N+1 queries on DropBenefit fields.
|
||||||
|
|
||||||
|
Regression test: _serialize_benefit accesses created_at,
|
||||||
|
entitlement_limit, and is_ios_available; these must be included
|
||||||
|
in the Prefetch .only() in DropCampaign.for_detail_view to avoid
|
||||||
|
one extra query per benefit.
|
||||||
|
"""
|
||||||
|
with CaptureQueriesContext(connection) as capture:
|
||||||
|
response = self.client.get("/twitch/api/v1/campaigns/campaign123/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
benefits = data["drops"][0]["benefits"]
|
||||||
|
assert len(benefits) == 1
|
||||||
|
assert benefits[0]["created_at"] is None
|
||||||
|
assert benefits[0]["entitlement_limit"] == 1
|
||||||
|
assert benefits[0]["is_ios_available"] is False
|
||||||
|
|
||||||
|
# Expect 7 queries (5 core prefetches + 2 campaign COUNTs):
|
||||||
|
# 1. campaign + game (select_related)
|
||||||
|
# 2. game owners (Prefetch → owners_for_detail)
|
||||||
|
# 3. allow_channels (Prefetch → channels_ordered)
|
||||||
|
# 4. time_based_drops (Prefetch)
|
||||||
|
# 5. benefits through DropBenefitEdge (Prefetch)
|
||||||
|
# 6. campaign count for game
|
||||||
|
# 7. active campaign count for game
|
||||||
|
assert len(capture) <= 7
|
||||||
|
|
||||||
def test_v1_all_endpoints_handle_multiple_rows(self) -> None:
|
def test_v1_all_endpoints_handle_multiple_rows(self) -> None:
|
||||||
"""Exercise all v1 routes with enough rows to catch deferred loads."""
|
"""Exercise all v1 routes with enough rows to catch deferred loads."""
|
||||||
self._create_secondary_api_fixture()
|
self._create_secondary_api_fixture()
|
||||||
|
|
@ -436,3 +565,263 @@ class TwitchApiV1TestCase(TestCase):
|
||||||
)
|
)
|
||||||
assert f'href="{campaign_api_url}"' in content
|
assert f'href="{campaign_api_url}"' in content
|
||||||
assert 'title="Twitch campaign API">[api]</a>' in content
|
assert 'title="Twitch campaign API">[api]</a>' in content
|
||||||
|
|
||||||
|
# ── Games ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_v1_game_list_fields(self) -> None:
|
||||||
|
"""Return correct field shape for game list items."""
|
||||||
|
response = self.client.get("/twitch/api/v1/games/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] >= 1
|
||||||
|
item = data["items"][0]
|
||||||
|
assert item["twitch_id"] == "game123"
|
||||||
|
assert item["slug"] == "test-game"
|
||||||
|
assert item["name"] == "Test Game"
|
||||||
|
assert item["display_name"] == "Test Game"
|
||||||
|
assert isinstance(item["box_art_url"], str)
|
||||||
|
assert isinstance(item["organizations"], list)
|
||||||
|
assert item["organizations"][0]["twitch_id"] == "org123"
|
||||||
|
assert item["campaign_count"] >= 1
|
||||||
|
assert item["active_campaign_count"] >= 1
|
||||||
|
assert "added_at" in item
|
||||||
|
assert "updated_at" in item
|
||||||
|
|
||||||
|
def test_v1_game_list_pagination(self) -> None:
|
||||||
|
"""Paginate game list results."""
|
||||||
|
response = self.client.get("/twitch/api/v1/games/?page_size=1")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["page_size"] == 1
|
||||||
|
assert len(data["items"]) == 1
|
||||||
|
|
||||||
|
def test_v1_game_detail_campaigns_and_owners(self) -> None:
|
||||||
|
"""Return campaigns and organizations in game detail."""
|
||||||
|
response = self.client.get("/twitch/api/v1/games/game123/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["campaigns"][0]["twitch_id"] == "campaign123"
|
||||||
|
assert data["campaigns"][0]["status"] == "active"
|
||||||
|
assert data["organizations"][0]["twitch_id"] == "org123"
|
||||||
|
|
||||||
|
def test_v1_game_detail_not_found(self) -> None:
|
||||||
|
"""Return 404 for missing game."""
|
||||||
|
response = self.client.get("/twitch/api/v1/games/nonexistent/")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
# ── Organizations ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_v1_organization_list_fields(self) -> None:
|
||||||
|
"""Return correct field shape for organization list items."""
|
||||||
|
response = self.client.get("/twitch/api/v1/organizations/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] >= 1
|
||||||
|
item = data["items"][0]
|
||||||
|
assert item["twitch_id"] == "org123"
|
||||||
|
assert item["name"] == "Test Organization"
|
||||||
|
assert "added_at" in item
|
||||||
|
assert "updated_at" in item
|
||||||
|
|
||||||
|
def test_v1_organization_detail_not_found(self) -> None:
|
||||||
|
"""Return 404 for missing organization."""
|
||||||
|
response = self.client.get("/twitch/api/v1/organizations/nonexistent/")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
# ── Channels ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_v1_channel_list_search(self) -> None:
|
||||||
|
"""Filter channels by search query."""
|
||||||
|
response = self.client.get("/twitch/api/v1/channels/?search=testchannel")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 1
|
||||||
|
assert data["items"][0]["twitch_id"] == "channel123"
|
||||||
|
|
||||||
|
def test_v1_channel_list_search_no_match(self) -> None:
|
||||||
|
"""Return empty list when search matches no channels."""
|
||||||
|
response = self.client.get("/twitch/api/v1/channels/?search=zzzzz")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 0
|
||||||
|
assert data["items"] == []
|
||||||
|
|
||||||
|
def test_v1_channel_list_fields(self) -> None:
|
||||||
|
"""Return correct field shape for channel list items."""
|
||||||
|
response = self.client.get("/twitch/api/v1/channels/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
item = data["items"][0]
|
||||||
|
assert item["twitch_id"] == "channel123"
|
||||||
|
assert item["name"] == "testchannel"
|
||||||
|
assert item["display_name"] == "TestChannel"
|
||||||
|
assert item["allowed_campaign_count"] == 1
|
||||||
|
assert "added_at" in item
|
||||||
|
assert "updated_at" in item
|
||||||
|
|
||||||
|
def test_v1_channel_detail_campaigns(self) -> None:
|
||||||
|
"""Return campaign summaries in channel detail."""
|
||||||
|
response = self.client.get("/twitch/api/v1/channels/channel123/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["campaigns"]) == 1
|
||||||
|
assert data["campaigns"][0]["twitch_id"] == "campaign123"
|
||||||
|
assert data["campaigns"][0]["game"]["twitch_id"] == "game123"
|
||||||
|
|
||||||
|
def test_v1_channel_detail_not_found(self) -> None:
|
||||||
|
"""Return 404 for missing channel."""
|
||||||
|
response = self.client.get("/twitch/api/v1/channels/nonexistent/")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
# ── Reward Campaigns ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_v1_reward_campaign_list_filters_by_game(self) -> None:
|
||||||
|
"""Filter reward campaigns by game twitch_id."""
|
||||||
|
response = self.client.get(
|
||||||
|
f"/twitch/api/v1/reward-campaigns/?game={self.game.twitch_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 1
|
||||||
|
assert data["items"][0]["twitch_id"] == "reward123"
|
||||||
|
|
||||||
|
def test_v1_reward_campaign_list_status_active(self) -> None:
|
||||||
|
"""Filter reward campaigns by status=active."""
|
||||||
|
response = self.client.get("/twitch/api/v1/reward-campaigns/?status=active")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 1
|
||||||
|
assert data["items"][0]["computed_status"] == "active"
|
||||||
|
|
||||||
|
def test_v1_reward_campaign_list_status_expired(self) -> None:
|
||||||
|
"""Filter reward campaigns by status=expired."""
|
||||||
|
now = timezone.now()
|
||||||
|
RewardCampaign.objects.create(
|
||||||
|
twitch_id="expired_reward",
|
||||||
|
name="Expired Reward",
|
||||||
|
brand="Expired Brand",
|
||||||
|
starts_at=now - timedelta(days=3),
|
||||||
|
ends_at=now - timedelta(days=1),
|
||||||
|
status="ACTIVE",
|
||||||
|
summary="Expired summary",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get("/twitch/api/v1/reward-campaigns/?status=expired")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 1
|
||||||
|
assert data["items"][0]["computed_status"] == "expired"
|
||||||
|
|
||||||
|
def test_v1_reward_campaign_list_fields(self) -> None:
|
||||||
|
"""Return correct field shape for reward campaign list items."""
|
||||||
|
response = self.client.get("/twitch/api/v1/reward-campaigns/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
item = data["items"][0]
|
||||||
|
assert item["twitch_id"] == "reward123"
|
||||||
|
assert item["name"] == "Test Reward"
|
||||||
|
assert item["brand"] == "Test Brand"
|
||||||
|
assert item["status"] == "ACTIVE"
|
||||||
|
assert item["computed_status"] == "active"
|
||||||
|
assert item["summary"] == "Reward summary"
|
||||||
|
assert item["is_sitewide"] is False
|
||||||
|
assert item["game"]["twitch_id"] == "game123"
|
||||||
|
assert "starts_at" in item
|
||||||
|
assert "ends_at" in item
|
||||||
|
assert "added_at" in item
|
||||||
|
assert "updated_at" in item
|
||||||
|
|
||||||
|
def test_v1_reward_campaign_detail_fields(self) -> None:
|
||||||
|
"""Return correct field shape for reward campaign detail."""
|
||||||
|
response = self.client.get("/twitch/api/v1/reward-campaigns/reward123/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["twitch_id"] == "reward123"
|
||||||
|
assert data["name"] == "Test Reward"
|
||||||
|
assert data["brand"] == "Test Brand"
|
||||||
|
assert isinstance(data["instructions"], str)
|
||||||
|
assert isinstance(data["external_url"], str)
|
||||||
|
assert isinstance(data["about_url"], str)
|
||||||
|
assert data["game"]["twitch_id"] == "game123"
|
||||||
|
|
||||||
|
def test_v1_reward_campaign_detail_no_game(self) -> None:
|
||||||
|
"""Return reward campaign detail with null game."""
|
||||||
|
now = timezone.now()
|
||||||
|
RewardCampaign.objects.create(
|
||||||
|
twitch_id="no_game_reward",
|
||||||
|
name="No Game Reward",
|
||||||
|
brand="Standalone Brand",
|
||||||
|
starts_at=now - timedelta(days=1),
|
||||||
|
ends_at=now + timedelta(days=1),
|
||||||
|
status="ACTIVE",
|
||||||
|
summary="No game attached",
|
||||||
|
is_sitewide=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get("/twitch/api/v1/reward-campaigns/no_game_reward/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["game"] is None
|
||||||
|
assert data["is_sitewide"] is True
|
||||||
|
|
||||||
|
def test_v1_reward_campaign_detail_not_found(self) -> None:
|
||||||
|
"""Return 404 for missing reward campaign."""
|
||||||
|
response = self.client.get("/twitch/api/v1/reward-campaigns/nonexistent/")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
# ── Badges ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_v1_badge_list_fields(self) -> None:
|
||||||
|
"""Return correct field shape for badge set list items."""
|
||||||
|
response = self.client.get("/twitch/api/v1/badges/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] >= 1
|
||||||
|
item = data["items"][0]
|
||||||
|
assert item["set_id"] == "test-badge-set"
|
||||||
|
assert len(item["badges"]) == 1
|
||||||
|
assert "added_at" in item
|
||||||
|
assert "updated_at" in item
|
||||||
|
|
||||||
|
def test_v1_badge_detail_fields(self) -> None:
|
||||||
|
"""Return correct field shape for badge set detail."""
|
||||||
|
response = self.client.get("/twitch/api/v1/badges/test-badge-set/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["set_id"] == "test-badge-set"
|
||||||
|
assert len(data["badges"]) == 1
|
||||||
|
badge = data["badges"][0]
|
||||||
|
assert badge["badge_id"] == "1"
|
||||||
|
assert badge["title"] == "Test Badge"
|
||||||
|
assert badge["description"] == "Test badge description"
|
||||||
|
assert "image_url_1x" in badge
|
||||||
|
assert "image_url_2x" in badge
|
||||||
|
assert "image_url_4x" in badge
|
||||||
|
assert "click_action" in badge
|
||||||
|
assert "click_url" in badge
|
||||||
|
|
||||||
|
def test_v1_badge_detail_not_found(self) -> None:
|
||||||
|
"""Return 404 for missing badge set."""
|
||||||
|
response = self.client.get("/twitch/api/v1/badges/nonexistent/")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
|
||||||
714
twitch/tests/test_import_twitch_drops_api.py
Normal file
714
twitch/tests/test_import_twitch_drops_api.py
Normal file
|
|
@ -0,0 +1,714 @@
|
||||||
|
"""Tests for the import_twitch_drops_api management command."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Self
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from twitch.management.commands.import_twitch_drops_api import Command
|
||||||
|
from twitch.models import DropBenefit
|
||||||
|
from twitch.models import DropBenefitEdge
|
||||||
|
from twitch.models import DropCampaign
|
||||||
|
from twitch.models import Game
|
||||||
|
from twitch.models import Organization
|
||||||
|
from twitch.models import RewardCampaign
|
||||||
|
from twitch.models import TimeBasedDrop
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sample data matching the SunkwiBOT/twitch-drops-api JSON formats
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SAMPLE_DROPS_JSON: list[dict] = [
|
||||||
|
{
|
||||||
|
"endAt": "2026-06-21T10:59:00Z",
|
||||||
|
"gameBoxArtURL": "https://static-cdn.jtvnw.net/ttv-boxart/515025_IGDB-285x380.jpg",
|
||||||
|
"gameDisplayName": "Overwatch 2",
|
||||||
|
"gameId": "515025",
|
||||||
|
"rewards": [
|
||||||
|
{
|
||||||
|
"id": "reward-campaign-1",
|
||||||
|
"self": None,
|
||||||
|
"allow": {
|
||||||
|
"channels": [
|
||||||
|
{
|
||||||
|
"id": "123456789",
|
||||||
|
"displayName": "SomeStreamer",
|
||||||
|
"name": "somestreamer",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"isEnabled": True,
|
||||||
|
},
|
||||||
|
"accountLinkURL": "https://www.twitch.tv/drops/campaigns",
|
||||||
|
"description": "Earn Overwatch 2 drops by watching!",
|
||||||
|
"detailsURL": "https://www.twitch.tv/drops/campaigns/reward-campaign-1",
|
||||||
|
"endAt": "2026-06-21T10:59:00Z",
|
||||||
|
"eventBasedDrops": [],
|
||||||
|
"game": {
|
||||||
|
"id": "515025",
|
||||||
|
"slug": "overwatch-2",
|
||||||
|
"displayName": "Overwatch 2",
|
||||||
|
},
|
||||||
|
"imageURL": "https://example.com/campaign.png",
|
||||||
|
"name": "Overwatch 2 x Twitch Drops",
|
||||||
|
"owner": {
|
||||||
|
"id": "org-blizzard",
|
||||||
|
"name": "Blizzard Entertainment",
|
||||||
|
},
|
||||||
|
"startAt": "2026-06-12T15:00:00Z",
|
||||||
|
"status": "ACTIVE",
|
||||||
|
"timeBasedDrops": [
|
||||||
|
{
|
||||||
|
"id": "time-drop-1",
|
||||||
|
"requiredSubs": 0,
|
||||||
|
"benefitEdges": [
|
||||||
|
{
|
||||||
|
"benefit": {
|
||||||
|
"id": "benefit-1",
|
||||||
|
"createdAt": "2026-06-10T12:00:00Z",
|
||||||
|
"entitlementLimit": 1,
|
||||||
|
"game": {
|
||||||
|
"id": "515025",
|
||||||
|
"name": "Overwatch 2",
|
||||||
|
},
|
||||||
|
"imageAssetURL": "https://example.com/reward.png",
|
||||||
|
"isIosAvailable": True,
|
||||||
|
"name": "Ana's Cybermedic Skin",
|
||||||
|
"ownerOrganization": {
|
||||||
|
"id": "org-blizzard",
|
||||||
|
"name": "Blizzard Entertainment",
|
||||||
|
},
|
||||||
|
"distributionType": "MANUAL",
|
||||||
|
},
|
||||||
|
"entitlementLimit": 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"endAt": "2026-06-21T10:59:00Z",
|
||||||
|
"name": "Watch 2 hours",
|
||||||
|
"preconditionDrops": None,
|
||||||
|
"requiredMinutesWatched": 120,
|
||||||
|
"startAt": "2026-06-12T15:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"startAt": "2026-06-12T15:00:00Z",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
SAMPLE_REWARDS_JSON: list[dict] = [
|
||||||
|
{
|
||||||
|
"aboutURL": "https://example.com/redeem",
|
||||||
|
"brand": "Mojang",
|
||||||
|
"endsAt": "2026-06-15T05:59:59.999Z",
|
||||||
|
"externalURL": "https://example.com/redeem",
|
||||||
|
"game": {
|
||||||
|
"displayName": "Minecraft",
|
||||||
|
"id": "27471",
|
||||||
|
"slug": "minecraft",
|
||||||
|
},
|
||||||
|
"id": "reward-campaign-2",
|
||||||
|
"image": {
|
||||||
|
"image1xURL": "https://example.com/reward-campaign.png",
|
||||||
|
},
|
||||||
|
"instructions": "",
|
||||||
|
"isSitewide": False,
|
||||||
|
"name": "Tubbo's WatchTime",
|
||||||
|
"rewards": [],
|
||||||
|
"rewardValueURLParam": "",
|
||||||
|
"startsAt": "2026-05-31T16:00:00Z",
|
||||||
|
"status": "UNKNOWN",
|
||||||
|
"summary": "Watch 5 minutes of Minecraft gameplay!",
|
||||||
|
"unlockRequirements": {
|
||||||
|
"minuteWatchedGoal": 5,
|
||||||
|
"subsGoal": 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def mock_httpx_get(data: list[dict]) -> MagicMock:
|
||||||
|
"""Return a mock httpx.Response that returns the given JSON data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: The JSON data to return from response.json().
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A MagicMock configured as an httpx response.
|
||||||
|
"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = data
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
return mock_response
|
||||||
|
|
||||||
|
|
||||||
|
class MockResponse:
|
||||||
|
"""Minimal mock for httpx.Response used as a context-manager return."""
|
||||||
|
|
||||||
|
def __init__(self, json_data: list[dict]) -> None:
|
||||||
|
"""Initialise with JSON data to return from .json().
|
||||||
|
|
||||||
|
Args:
|
||||||
|
json_data: The data to return.
|
||||||
|
"""
|
||||||
|
self._json_data = json_data
|
||||||
|
|
||||||
|
def json(self) -> list[dict]:
|
||||||
|
"""Return the mocked JSON data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The JSON data.
|
||||||
|
"""
|
||||||
|
return self._json_data
|
||||||
|
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
"""No-op for mock."""
|
||||||
|
|
||||||
|
|
||||||
|
class MockHttpxClient:
|
||||||
|
"""Mock for httpx.Client that returns canned data from its get() method."""
|
||||||
|
|
||||||
|
def __init__(self, drops_data: list[dict], rewards_data: list[dict]) -> None:
|
||||||
|
"""Initialise with data for each endpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
drops_data: Data to return for the drops URL.
|
||||||
|
rewards_data: Data to return for the rewards URL.
|
||||||
|
"""
|
||||||
|
self.drops_data = drops_data
|
||||||
|
self.rewards_data = rewards_data
|
||||||
|
|
||||||
|
def get(self, url: str, **kwargs: object) -> MockResponse:
|
||||||
|
"""Return canned data based on the URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The request URL.
|
||||||
|
**kwargs: Ignored.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A MockResponse with the appropriate data.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the URL is not recognised.
|
||||||
|
"""
|
||||||
|
if "drops.json" in url:
|
||||||
|
return MockResponse(self.drops_data)
|
||||||
|
if "rewards.json" in url:
|
||||||
|
return MockResponse(self.rewards_data)
|
||||||
|
msg = f"Unexpected URL: {url}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
def __enter__(self) -> Self:
|
||||||
|
"""Support context manager usage.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self.
|
||||||
|
"""
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args: object) -> None:
|
||||||
|
"""No-op for context manager."""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.no_zeal
|
||||||
|
class ImportTwitchDropsApiTests(TestCase):
|
||||||
|
"""Tests for the import_twitch_drops_api management command."""
|
||||||
|
|
||||||
|
drops_url = (
|
||||||
|
"https://raw.githubusercontent.com/SunkwiBOT/twitch-drops-api/main/drops.json"
|
||||||
|
)
|
||||||
|
rewards_url = (
|
||||||
|
"https://raw.githubusercontent.com/SunkwiBOT/twitch-drops-api/main/rewards.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
"""Set up the command instance for each test."""
|
||||||
|
self.command = Command()
|
||||||
|
self.command._org_cache = {}
|
||||||
|
self.command._game_cache = {}
|
||||||
|
self.command._channel_cache = {}
|
||||||
|
self.command._benefit_cache = {}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# drops.json import tests
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@patch("httpx.Client")
|
||||||
|
def test_import_drops_creates_campaign(self, mock_client: MagicMock) -> None:
|
||||||
|
"""Verify a basic drops.json import creates a DropCampaign with correct data."""
|
||||||
|
mock_client.return_value = MockHttpxClient(
|
||||||
|
drops_data=SAMPLE_DROPS_JSON,
|
||||||
|
rewards_data=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.command.handle(
|
||||||
|
drops_url=self.drops_url,
|
||||||
|
rewards_url=self.rewards_url,
|
||||||
|
drops_only=True,
|
||||||
|
rewards_only=False,
|
||||||
|
source="SunkwiBOT/twitch-drops-api",
|
||||||
|
verbose=False,
|
||||||
|
crash_on_error=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Game
|
||||||
|
game = Game.objects.get(twitch_id="515025")
|
||||||
|
assert game.display_name == "Overwatch 2"
|
||||||
|
|
||||||
|
# Organization
|
||||||
|
org = Organization.objects.get(twitch_id="org-blizzard")
|
||||||
|
assert org.name == "Blizzard Entertainment"
|
||||||
|
|
||||||
|
# DropCampaign
|
||||||
|
campaign = DropCampaign.objects.get(twitch_id="reward-campaign-1")
|
||||||
|
assert campaign.name == "Overwatch 2 x Twitch Drops"
|
||||||
|
assert campaign.data_source == "SunkwiBOT/twitch-drops-api"
|
||||||
|
assert campaign.is_fully_imported is True
|
||||||
|
|
||||||
|
# TimeBasedDrop
|
||||||
|
tbd = TimeBasedDrop.objects.get(twitch_id="time-drop-1")
|
||||||
|
assert tbd.name == "Watch 2 hours"
|
||||||
|
assert tbd.required_minutes_watched == 120
|
||||||
|
|
||||||
|
# DropBenefit
|
||||||
|
benefit = DropBenefit.objects.get(twitch_id="benefit-1")
|
||||||
|
assert benefit.name == "Ana's Cybermedic Skin"
|
||||||
|
assert benefit.distribution_type == "MANUAL"
|
||||||
|
|
||||||
|
# DropBenefitEdge
|
||||||
|
edge = DropBenefitEdge.objects.get(drop=tbd, benefit=benefit)
|
||||||
|
assert edge.entitlement_limit == 1
|
||||||
|
|
||||||
|
@patch("httpx.Client")
|
||||||
|
def test_import_drops_idempotent(self, mock_client: MagicMock) -> None:
|
||||||
|
"""Verify running the import twice doesn't create duplicates."""
|
||||||
|
mock_client.return_value = MockHttpxClient(
|
||||||
|
drops_data=SAMPLE_DROPS_JSON,
|
||||||
|
rewards_data=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
# First run
|
||||||
|
self.command.handle(
|
||||||
|
drops_url=self.drops_url,
|
||||||
|
rewards_url=self.rewards_url,
|
||||||
|
drops_only=True,
|
||||||
|
rewards_only=False,
|
||||||
|
source="SunkwiBOT/twitch-drops-api",
|
||||||
|
verbose=False,
|
||||||
|
crash_on_error=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Second run
|
||||||
|
self.command.handle(
|
||||||
|
drops_url=self.drops_url,
|
||||||
|
rewards_url=self.rewards_url,
|
||||||
|
drops_only=True,
|
||||||
|
rewards_only=False,
|
||||||
|
source="SunkwiBOT/twitch-drops-api",
|
||||||
|
verbose=False,
|
||||||
|
crash_on_error=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert DropCampaign.objects.count() == 1
|
||||||
|
assert Game.objects.count() == 1
|
||||||
|
assert Organization.objects.count() == 1
|
||||||
|
assert TimeBasedDrop.objects.count() == 1
|
||||||
|
assert DropBenefit.objects.count() == 1
|
||||||
|
assert DropBenefitEdge.objects.count() == 1
|
||||||
|
|
||||||
|
@patch("httpx.Client")
|
||||||
|
def test_import_drops_sets_data_source(self, mock_client: MagicMock) -> None:
|
||||||
|
"""Verify the --source argument is stored on the DropCampaign."""
|
||||||
|
mock_client.return_value = MockHttpxClient(
|
||||||
|
drops_data=SAMPLE_DROPS_JSON,
|
||||||
|
rewards_data=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.command.handle(
|
||||||
|
drops_url=self.drops_url,
|
||||||
|
rewards_url=self.rewards_url,
|
||||||
|
drops_only=True,
|
||||||
|
rewards_only=False,
|
||||||
|
source="CustomSource",
|
||||||
|
verbose=False,
|
||||||
|
crash_on_error=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
campaign = DropCampaign.objects.get(twitch_id="reward-campaign-1")
|
||||||
|
assert campaign.data_source == "CustomSource"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# rewards.json import tests
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@patch("httpx.Client")
|
||||||
|
def test_import_rewards_creates_reward_campaign(
|
||||||
|
self,
|
||||||
|
mock_client: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Verify a basic rewards.json import creates a RewardCampaign."""
|
||||||
|
# Create the game first since rewards.json references existing games
|
||||||
|
Game.objects.create(
|
||||||
|
twitch_id="27471",
|
||||||
|
display_name="Minecraft",
|
||||||
|
name="Minecraft",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_client.return_value = MockHttpxClient(
|
||||||
|
drops_data=[],
|
||||||
|
rewards_data=SAMPLE_REWARDS_JSON,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.command.handle(
|
||||||
|
drops_url=self.drops_url,
|
||||||
|
rewards_url=self.rewards_url,
|
||||||
|
drops_only=False,
|
||||||
|
rewards_only=True,
|
||||||
|
source="SunkwiBOT/twitch-drops-api",
|
||||||
|
verbose=False,
|
||||||
|
crash_on_error=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
reward = RewardCampaign.objects.get(twitch_id="reward-campaign-2")
|
||||||
|
assert reward.name == "Tubbo's WatchTime"
|
||||||
|
assert reward.brand == "Mojang"
|
||||||
|
assert reward.data_source == "SunkwiBOT/twitch-drops-api"
|
||||||
|
assert reward.is_sitewide is False
|
||||||
|
assert reward.game is not None
|
||||||
|
assert reward.game.twitch_id == "27471"
|
||||||
|
|
||||||
|
@patch("httpx.Client")
|
||||||
|
def test_import_rewards_sets_data_source(self, mock_client: MagicMock) -> None:
|
||||||
|
"""Verify the --source argument is stored on the RewardCampaign."""
|
||||||
|
mock_client.return_value = MockHttpxClient(
|
||||||
|
drops_data=[],
|
||||||
|
rewards_data=SAMPLE_REWARDS_JSON,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.command.handle(
|
||||||
|
drops_url=self.drops_url,
|
||||||
|
rewards_url=self.rewards_url,
|
||||||
|
drops_only=False,
|
||||||
|
rewards_only=True,
|
||||||
|
source="CustomSource",
|
||||||
|
verbose=False,
|
||||||
|
crash_on_error=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
reward = RewardCampaign.objects.get(twitch_id="reward-campaign-2")
|
||||||
|
assert reward.data_source == "CustomSource"
|
||||||
|
|
||||||
|
@patch("httpx.Client")
|
||||||
|
def test_import_rewards_idempotent(self, mock_client: MagicMock) -> None:
|
||||||
|
"""Verify running the rewards import twice doesn't create duplicates."""
|
||||||
|
Game.objects.create(
|
||||||
|
twitch_id="27471",
|
||||||
|
display_name="Minecraft",
|
||||||
|
name="Minecraft",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_client.return_value = MockHttpxClient(
|
||||||
|
drops_data=[],
|
||||||
|
rewards_data=SAMPLE_REWARDS_JSON,
|
||||||
|
)
|
||||||
|
|
||||||
|
# First run
|
||||||
|
self.command.handle(
|
||||||
|
drops_url=self.drops_url,
|
||||||
|
rewards_url=self.rewards_url,
|
||||||
|
drops_only=False,
|
||||||
|
rewards_only=True,
|
||||||
|
source="SunkwiBOT/twitch-drops-api",
|
||||||
|
verbose=False,
|
||||||
|
crash_on_error=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Second run
|
||||||
|
self.command.handle(
|
||||||
|
drops_url=self.drops_url,
|
||||||
|
rewards_url=self.rewards_url,
|
||||||
|
drops_only=False,
|
||||||
|
rewards_only=True,
|
||||||
|
source="SunkwiBOT/twitch-drops-api",
|
||||||
|
verbose=False,
|
||||||
|
crash_on_error=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert RewardCampaign.objects.count() == 1
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Combined import tests
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@patch("httpx.Client")
|
||||||
|
def test_import_both(self, mock_client: MagicMock) -> None:
|
||||||
|
"""Verify importing both drops.json and rewards.json works together."""
|
||||||
|
Game.objects.create(
|
||||||
|
twitch_id="27471",
|
||||||
|
display_name="Minecraft",
|
||||||
|
name="Minecraft",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_client.return_value = MockHttpxClient(
|
||||||
|
drops_data=SAMPLE_DROPS_JSON,
|
||||||
|
rewards_data=SAMPLE_REWARDS_JSON,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.command.handle(
|
||||||
|
drops_url=self.drops_url,
|
||||||
|
rewards_url=self.rewards_url,
|
||||||
|
drops_only=False,
|
||||||
|
rewards_only=False,
|
||||||
|
source="SunkwiBOT/twitch-drops-api",
|
||||||
|
verbose=False,
|
||||||
|
crash_on_error=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert DropCampaign.objects.count() == 1
|
||||||
|
assert RewardCampaign.objects.count() == 1
|
||||||
|
assert Game.objects.count() == 2 # 515025 + 27471
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Error handling tests
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@patch("httpx.Client")
|
||||||
|
def test_handles_invalid_json(self, mock_client: MagicMock) -> None:
|
||||||
|
"""Verify the command handles a non-list response gracefully."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {"not": "a list"}
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_client.return_value.__enter__.return_value.get.return_value = mock_response
|
||||||
|
|
||||||
|
# The error is caught internally and logged; no data should be imported
|
||||||
|
self.command.handle(
|
||||||
|
drops_url=self.drops_url,
|
||||||
|
rewards_url=self.rewards_url,
|
||||||
|
drops_only=True,
|
||||||
|
rewards_only=False,
|
||||||
|
source="SunkwiBOT/twitch-drops-api",
|
||||||
|
verbose=False,
|
||||||
|
crash_on_error=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert DropCampaign.objects.count() == 0
|
||||||
|
|
||||||
|
@patch("httpx.Client")
|
||||||
|
def test_handles_http_error(self, mock_client: MagicMock) -> None:
|
||||||
|
"""Verify the command handles HTTP errors gracefully."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||||
|
"404 Not Found",
|
||||||
|
request=MagicMock(),
|
||||||
|
response=MagicMock(),
|
||||||
|
)
|
||||||
|
mock_client.return_value.__enter__.return_value.get.return_value = mock_response
|
||||||
|
|
||||||
|
# Should not crash when crash_on_error is False
|
||||||
|
self.command.handle(
|
||||||
|
drops_url=self.drops_url,
|
||||||
|
rewards_url=self.rewards_url,
|
||||||
|
drops_only=False,
|
||||||
|
rewards_only=False,
|
||||||
|
source="SunkwiBOT/twitch-drops-api",
|
||||||
|
verbose=False,
|
||||||
|
crash_on_error=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# No data should have been imported
|
||||||
|
assert DropCampaign.objects.count() == 0
|
||||||
|
assert RewardCampaign.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _strip_typename tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class StripTypenameTests(TestCase):
|
||||||
|
"""Tests for Command._strip_typename."""
|
||||||
|
|
||||||
|
def test_removes_typename_from_dict(self) -> None:
|
||||||
|
"""Verify __typename is removed from a flat dict."""
|
||||||
|
result = Command._strip_typename({
|
||||||
|
"id": "123",
|
||||||
|
"__typename": "DropCampaign",
|
||||||
|
"name": "Test",
|
||||||
|
})
|
||||||
|
assert result == {"id": "123", "name": "Test"}
|
||||||
|
|
||||||
|
def test_removes_typename_nested(self) -> None:
|
||||||
|
"""Verify __typename is removed from nested dicts."""
|
||||||
|
result = Command._strip_typename({
|
||||||
|
"game": {
|
||||||
|
"id": "g1",
|
||||||
|
"__typename": "Game",
|
||||||
|
},
|
||||||
|
"__typename": "DropCampaign",
|
||||||
|
})
|
||||||
|
assert result == {"game": {"id": "g1"}}
|
||||||
|
|
||||||
|
def test_removes_typename_in_lists(self) -> None:
|
||||||
|
"""Verify __typename is removed from items inside lists."""
|
||||||
|
result = Command._strip_typename({
|
||||||
|
"rewards": [
|
||||||
|
{"id": "r1", "__typename": "Reward"},
|
||||||
|
{"id": "r2", "__typename": "Reward"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert result == {"rewards": [{"id": "r1"}, {"id": "r2"}]}
|
||||||
|
|
||||||
|
def test_handles_scalars(self) -> None:
|
||||||
|
"""Verify scalars and None pass through unchanged."""
|
||||||
|
assert Command._strip_typename("hello") == "hello"
|
||||||
|
assert Command._strip_typename(42) == 42
|
||||||
|
assert Command._strip_typename(None) is None
|
||||||
|
assert Command._strip_typename([1, 2, 3]) == [1, 2, 3]
|
||||||
|
|
||||||
|
def test_removes_typename_deeply_nested(self) -> None:
|
||||||
|
"""Verify deeply nested __typename is removed."""
|
||||||
|
data = {
|
||||||
|
"data": {
|
||||||
|
"user": {
|
||||||
|
"id": "u1",
|
||||||
|
"__typename": "User",
|
||||||
|
"campaigns": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"__typename": "DropCampaign",
|
||||||
|
"game": {
|
||||||
|
"id": "g1",
|
||||||
|
"__typename": "Game",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"__typename": "Response",
|
||||||
|
}
|
||||||
|
result = Command._strip_typename(data)
|
||||||
|
# Should have no __typename anywhere
|
||||||
|
raw = str(result)
|
||||||
|
assert "__typename" not in raw
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Historical import tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.no_zeal
|
||||||
|
class HistoricalImportTests(TestCase):
|
||||||
|
"""Tests for the --historical mode of import_twitch_drops_api."""
|
||||||
|
|
||||||
|
def _init_caches(self, command: Command) -> None:
|
||||||
|
"""Initialise instance caches needed by import helpers."""
|
||||||
|
command._org_cache = {}
|
||||||
|
command._game_cache = {}
|
||||||
|
command._channel_cache = {}
|
||||||
|
command._benefit_cache = {}
|
||||||
|
|
||||||
|
def test_parse_and_import_drops_from_raw(self) -> None:
|
||||||
|
"""Verify _parse_and_import_drops works with pre-loaded data."""
|
||||||
|
command = Command()
|
||||||
|
self._init_caches(command)
|
||||||
|
count = command._parse_and_import_drops(
|
||||||
|
raw_data=SAMPLE_DROPS_JSON,
|
||||||
|
source="SunkwiBOT/twitch-drops-api",
|
||||||
|
verbose=False,
|
||||||
|
crash_on_error=True,
|
||||||
|
)
|
||||||
|
assert count == 1
|
||||||
|
assert DropCampaign.objects.count() == 1
|
||||||
|
campaign = DropCampaign.objects.get(twitch_id="reward-campaign-1")
|
||||||
|
assert campaign.data_source == "SunkwiBOT/twitch-drops-api"
|
||||||
|
|
||||||
|
def test_parse_and_import_rewards_from_raw(self) -> None:
|
||||||
|
"""Verify _parse_and_import_rewards works with pre-loaded data."""
|
||||||
|
Game.objects.create(twitch_id="27471", display_name="Minecraft")
|
||||||
|
command = Command()
|
||||||
|
self._init_caches(command)
|
||||||
|
count = command._parse_and_import_rewards(
|
||||||
|
raw_data=SAMPLE_REWARDS_JSON,
|
||||||
|
source="SunkwiBOT/twitch-drops-api",
|
||||||
|
verbose=False,
|
||||||
|
crash_on_error=True,
|
||||||
|
)
|
||||||
|
assert count == 1
|
||||||
|
assert RewardCampaign.objects.count() == 1
|
||||||
|
reward = RewardCampaign.objects.get(twitch_id="reward-campaign-2")
|
||||||
|
assert reward.data_source == "SunkwiBOT/twitch-drops-api"
|
||||||
|
|
||||||
|
def test_parse_and_import_drops_handles_typename(self) -> None:
|
||||||
|
"""Verify _parse_and_import_drops strips __typename before validation."""
|
||||||
|
raw = [
|
||||||
|
{
|
||||||
|
"endAt": "2026-06-21T10:59:00Z",
|
||||||
|
"gameBoxArtURL": "https://example.com/art.png",
|
||||||
|
"gameDisplayName": "Test Game",
|
||||||
|
"gameId": "test-game-1",
|
||||||
|
"rewards": [
|
||||||
|
{
|
||||||
|
"id": "hist-campaign-1",
|
||||||
|
"self": None,
|
||||||
|
"allow": {"isEnabled": True},
|
||||||
|
"accountLinkURL": "https://example.com",
|
||||||
|
"description": "Historical",
|
||||||
|
"detailsURL": "https://example.com",
|
||||||
|
"endAt": "2026-06-21T10:59:00Z",
|
||||||
|
"eventBasedDrops": [],
|
||||||
|
"game": {
|
||||||
|
"id": "test-game-1",
|
||||||
|
"slug": "test",
|
||||||
|
"displayName": "Test Game",
|
||||||
|
},
|
||||||
|
"imageURL": "",
|
||||||
|
"name": "Historical Campaign",
|
||||||
|
"owner": {"id": "org-1", "name": "Test Org"},
|
||||||
|
"startAt": "2026-06-12T15:00:00Z",
|
||||||
|
"status": "ACTIVE",
|
||||||
|
"timeBasedDrops": [],
|
||||||
|
"__typename": "DropCampaign",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"startAt": "2026-06-12T15:00:00Z",
|
||||||
|
"__typename": "DropGroup",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
command = Command()
|
||||||
|
self._init_caches(command)
|
||||||
|
count = command._parse_and_import_drops(
|
||||||
|
raw_data=raw,
|
||||||
|
source="SunkwiBOT/twitch-drops-api",
|
||||||
|
verbose=False,
|
||||||
|
crash_on_error=True,
|
||||||
|
)
|
||||||
|
assert count == 1
|
||||||
|
assert DropCampaign.objects.filter(twitch_id="hist-campaign-1").exists()
|
||||||
|
|
||||||
|
@patch.object(Command, "_process_historical")
|
||||||
|
def test_historical_flag_calls_process_historical(
|
||||||
|
self,
|
||||||
|
mock_process: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Verify --historical calls _process_historical instead of fetching URLs."""
|
||||||
|
mock_process.return_value = (5, 2, [])
|
||||||
|
command = Command()
|
||||||
|
command.handle(
|
||||||
|
historical=True,
|
||||||
|
source="SunkwiBOT/twitch-drops-api",
|
||||||
|
verbose=False,
|
||||||
|
crash_on_error=False,
|
||||||
|
drops_url="",
|
||||||
|
rewards_url="",
|
||||||
|
drops_only=False,
|
||||||
|
rewards_only=False,
|
||||||
|
no_skip_latest=False,
|
||||||
|
max_commits=0,
|
||||||
|
git_dir="",
|
||||||
|
)
|
||||||
|
mock_process.assert_called_once()
|
||||||
|
|
@ -5,7 +5,6 @@ from typing import TYPE_CHECKING
|
||||||
import pytest
|
import pytest
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.db.migrations.executor import MigrationExecutor
|
from django.db.migrations.executor import MigrationExecutor
|
||||||
from django.db.migrations.state import StateApps
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.db.migrations.state import StateApps
|
from django.db.migrations.state import StateApps
|
||||||
|
|
@ -84,3 +83,12 @@ def test_0021_backfills_allowed_campaign_count() -> None: # noqa: PLR0914
|
||||||
"migration_backfill_channel_2": 1,
|
"migration_backfill_channel_2": 1,
|
||||||
"migration_backfill_channel_3": 0,
|
"migration_backfill_channel_3": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Restore database to the latest migration so subsequent tests don't
|
||||||
|
# encounter missing columns (SQLite DDL persists across transaction
|
||||||
|
# rollbacks, so the schema changes made by this test are permanent).
|
||||||
|
latest: list[tuple[str, str]] = [
|
||||||
|
("twitch", "0024_dropcampaign_data_source_rewardcampaign_data_source"),
|
||||||
|
]
|
||||||
|
executor = MigrationExecutor(connection)
|
||||||
|
executor.migrate(latest)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ from typing import Literal
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.handlers.wsgi import WSGIRequest
|
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.db.models import Max
|
from django.db.models import Max
|
||||||
|
|
@ -43,17 +42,10 @@ if TYPE_CHECKING:
|
||||||
from django.test import Client
|
from django.test import Client
|
||||||
from django.test.client import _MonkeyPatchedWSGIResponse
|
from django.test.client import _MonkeyPatchedWSGIResponse
|
||||||
from django.test.utils import ContextList
|
from django.test.utils import ContextList
|
||||||
from pytest_django.fixtures import SettingsWrapper
|
|
||||||
|
|
||||||
from twitch.views import Page
|
from twitch.views import Page
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def apply_base_url_override(settings: SettingsWrapper) -> None:
|
|
||||||
"""Ensure BASE_URL is globally overridden for all tests."""
|
|
||||||
settings.BASE_URL = "https://ttvdrops.lovinator.space" # pyright: ignore[reportAttributeAccessIssue]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestSearchView:
|
class TestSearchView:
|
||||||
"""Tests for the search_view function."""
|
"""Tests for the search_view function."""
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from _pytest.capture import CaptureResult
|
|
||||||
from django.core.management.base import CommandError
|
from django.core.management.base import CommandError
|
||||||
|
|
||||||
from twitch.management.commands.watch_imports import Command
|
from twitch.management.commands.watch_imports import Command
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue