diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 88bcdb6..0000000 --- a/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -chzzk/tests/*.json linguist-generated=true -twitch/tests/*.json linguist-generated=true diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 7d4abd9..97dbb0a 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -40,7 +40,6 @@ jobs: sudo -u ttvdrops uv run python /home/ttvdrops/ttvdrops/manage.py collectstatic --noinput sudo systemctl restart ttvdrops - name: Check if server is up - if : ${{ success() && github.ref == 'refs/heads/master' }} run: | sleep 2 curl -f https://ttvdrops.lovinator.space/ || exit 1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2c5fb94..aa176fd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,7 +27,7 @@ repos: args: [--target-version, "6.0"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.8 + rev: v0.15.5 hooks: - id: ruff-check args: ["--fix", "--exit-non-zero-on-fix"] @@ -40,6 +40,6 @@ repos: args: ["--py311-plus"] - repo: https://github.com/rhysd/actionlint - rev: v1.7.12 + rev: v1.7.11 hooks: - id: actionlint diff --git a/.vscode/settings.json b/.vscode/settings.json index 9260d8b..6e557e2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,7 +10,6 @@ "buildx", "chatbadge", "chatbadgeset", - "chzzk", "collectstatic", "colorama", "createsuperuser", diff --git a/chzzk/__init__.py b/chzzk/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/chzzk/apps.py b/chzzk/apps.py deleted file mode 100644 index cfc34a1..0000000 --- a/chzzk/apps.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.apps import AppConfig - - -class ChzzkConfig(AppConfig): - """Config for chzzk app.""" - - name = "chzzk" diff --git a/chzzk/management/__init__.py b/chzzk/management/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/chzzk/management/commands/__init__.py b/chzzk/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/chzzk/management/commands/import_chzzk_campaign.py b/chzzk/management/commands/import_chzzk_campaign.py deleted file mode 100644 index 484dd66..0000000 --- a/chzzk/management/commands/import_chzzk_campaign.py +++ /dev/null @@ -1,128 +0,0 @@ -from typing import TYPE_CHECKING -from typing import Any - -if TYPE_CHECKING: - import argparse - - from chzzk.schemas import ChzzkCampaignV2 - - -from typing import TYPE_CHECKING - -import requests -from django.core.management.base import BaseCommand -from django.utils import timezone - -from chzzk.models import ChzzkCampaign -from chzzk.models import ChzzkReward -from chzzk.schemas import ChzzkApiResponseV2 - -if TYPE_CHECKING: - import argparse - -CHZZK_API_URLS: list[tuple[str, str]] = [ - ("v1", "https://api.chzzk.naver.com/service/v1/drops/campaigns/{campaign_no}"), - ("v2", "https://api.chzzk.naver.com/service/v2/drops/campaigns/{campaign_no}"), -] - -USER_AGENT = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:151.0) Gecko/20100101 Firefox/151.0" -) - - -class Command(BaseCommand): - """Django management command to scrape Chzzk drops campaigns from both v1 and v2 APIs and store them in the database.""" - - help = "Scrape Chzzk drops campaigns from both v1 and v2 APIs and store them." - - def add_arguments(self, parser: argparse.ArgumentParser) -> None: - """Add command-line arguments for the management command.""" - parser.add_argument("campaign_no", type=int, help="Campaign number to fetch") - - def handle(self, **options) -> None: - """Main handler for the management command. Fetches campaign data from both API versions, validates, and stores them.""" - campaign_no: int = int(options["campaign_no"]) - api_version: str = "v2" # TODO(TheLovinator): Add support for v1 API # noqa: TD003 - url: str = f"https://api.chzzk.naver.com/service/{api_version}/drops/campaigns/{campaign_no}" - resp: requests.Response = requests.get( - url, - timeout=2, - headers={ - "Accept": "application/json", - "User-Agent": USER_AGENT, - }, - ) - resp.raise_for_status() - data: dict[str, Any] = resp.json() - - campaign_data: ChzzkCampaignV2 - campaign_data = ChzzkApiResponseV2.model_validate(data).content - - # Prepare raw JSON defaults for both API versions so DB inserts won't fail - raw_json_v1_val = data if api_version == "v1" else {} - raw_json_v2_val = data if api_version == "v2" else {} - - # Save campaign - campaign_obj, created = ChzzkCampaign.objects.update_or_create( - campaign_no=campaign_data.campaign_no, - defaults={ - "title": campaign_data.title, - "image_url": campaign_data.image_url, - "description": campaign_data.description, - "category_type": campaign_data.category_type, - "category_id": campaign_data.category_id, - "category_value": campaign_data.category_value, - "pc_link_url": campaign_data.pc_link_url, - "mobile_link_url": campaign_data.mobile_link_url, - "service_id": campaign_data.service_id, - "state": campaign_data.state, - "start_date": campaign_data.start_date, - "end_date": campaign_data.end_date, - "has_ios_based_reward": campaign_data.has_ios_based_reward, - "drops_campaign_not_started": campaign_data.drops_campaign_not_started, - "campaign_reward_type": getattr( - campaign_data, - "campaign_reward_type", - "", - ), - "reward_type": getattr(campaign_data, "reward_type", ""), - "account_link_url": campaign_data.account_link_url, - "scraped_at": timezone.now(), - "scrape_status": "success", - "raw_json_v1": raw_json_v1_val, - "raw_json_v2": raw_json_v2_val, - }, - ) - if created: - self.stdout.write( - self.style.SUCCESS(f"Created campaign {campaign_no}"), - ) - for reward in campaign_data.reward_list: - reward_, created = ChzzkReward.objects.update_or_create( - campaign=campaign_obj, - reward_no=reward.reward_no, - defaults={ - "image_url": reward.image_url, - "title": reward.title, - "reward_type": reward.reward_type, - "campaign_reward_type": getattr( - reward, - "campaign_reward_type", - "", - ), - "condition_type": reward.condition_type, - "condition_for_minutes": reward.condition_for_minutes, - "ios_based_reward": reward.ios_based_reward, - "code_remaining_count": reward.code_remaining_count, - }, - ) - if created: - self.stdout.write( - self.style.SUCCESS( - f" Created reward {reward_.reward_no} for campaign {campaign_no}", - ), - ) - - self.stdout.write( - self.style.SUCCESS(f"Imported campaign {campaign_no}"), - ) diff --git a/chzzk/migrations/0001_initial.py b/chzzk/migrations/0001_initial.py deleted file mode 100644 index 7208889..0000000 --- a/chzzk/migrations/0001_initial.py +++ /dev/null @@ -1,100 +0,0 @@ -# Generated by Django 6.0.3 on 2026-04-01 01:57 - -import django.db.models.deletion -import django.utils.timezone -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - """Initial migration for chzzk app, creating ChzzkCampaign and ChzzkReward models.""" - - initial = True - - dependencies = [] - - operations = [ - migrations.CreateModel( - name="ChzzkCampaign", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("campaign_no", models.BigIntegerField()), - ("title", models.CharField(max_length=255)), - ("image_url", models.URLField()), - ("description", models.TextField()), - ("category_type", models.CharField(max_length=64)), - ("category_id", models.CharField(max_length=128)), - ("category_value", models.CharField(max_length=128)), - ("pc_link_url", models.URLField()), - ("mobile_link_url", models.URLField()), - ("service_id", models.CharField(max_length=128)), - ("state", models.CharField(max_length=64)), - ("start_date", models.DateTimeField()), - ("end_date", models.DateTimeField()), - ("has_ios_based_reward", models.BooleanField()), - ("drops_campaign_not_started", models.BooleanField()), - ( - "campaign_reward_type", - models.CharField(blank=True, default="", max_length=64), - ), - ( - "reward_type", - models.CharField(blank=True, default="", max_length=64), - ), - ("account_link_url", models.URLField()), - ("scraped_at", models.DateTimeField(default=django.utils.timezone.now)), - ("source_api", models.CharField(max_length=16)), - ("scrape_status", models.CharField(default="success", max_length=32)), - ("raw_json_v1", models.JSONField(blank=True, null=True)), - ("raw_json_v2", models.JSONField(blank=True, null=True)), - ], - options={ - "ordering": ["-start_date"], - }, - ), - migrations.CreateModel( - name="ChzzkReward", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("reward_no", models.BigIntegerField()), - ("image_url", models.URLField()), - ("title", models.CharField(max_length=255)), - ("reward_type", models.CharField(max_length=64)), - ( - "campaign_reward_type", - models.CharField(blank=True, default="", max_length=64), - ), - ("condition_type", models.CharField(max_length=64)), - ("condition_for_minutes", models.IntegerField()), - ("ios_based_reward", models.BooleanField()), - ("code_remaining_count", models.IntegerField()), - ( - "campaign", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="rewards", - to="chzzk.chzzkcampaign", - ), - ), - ], - options={ - "unique_together": {("campaign", "reward_no")}, - }, - ), - ] diff --git a/chzzk/migrations/__init__.py b/chzzk/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/chzzk/models.py b/chzzk/models.py deleted file mode 100644 index c1c5840..0000000 --- a/chzzk/models.py +++ /dev/null @@ -1,63 +0,0 @@ -from django.db import models -from django.utils import timezone - - -class ChzzkCampaign(models.Model): - """Chzzk campaign, including scraping metadata.""" - - campaign_no = models.BigIntegerField() - title = models.CharField(max_length=255) - image_url = models.URLField() - description = models.TextField() - category_type = models.CharField(max_length=64) - category_id = models.CharField(max_length=128) - category_value = models.CharField(max_length=128) - pc_link_url = models.URLField() - mobile_link_url = models.URLField() - service_id = models.CharField(max_length=128) - state = models.CharField(max_length=64) - start_date = models.DateTimeField() - end_date = models.DateTimeField() - has_ios_based_reward = models.BooleanField() - drops_campaign_not_started = models.BooleanField() - campaign_reward_type = models.CharField(max_length=64, blank=True, default="") - reward_type = models.CharField(max_length=64, blank=True, default="") - account_link_url = models.URLField() - - # Scraping metadata - scraped_at = models.DateTimeField(default=timezone.now) - source_api = models.CharField(max_length=16) - scrape_status = models.CharField(max_length=32, default="success") - raw_json_v1 = models.JSONField(null=True, blank=True) - raw_json_v2 = models.JSONField(null=True, blank=True) - - class Meta: - ordering = ["-start_date"] - - def __str__(self) -> str: - return f"{self.title} (#{self.campaign_no})" - - -class ChzzkReward(models.Model): - """Chzzk reward belonging to a campaign.""" - - campaign = models.ForeignKey( - ChzzkCampaign, - related_name="rewards", - on_delete=models.CASCADE, - ) - reward_no = models.BigIntegerField() - image_url = models.URLField() - title = models.CharField(max_length=255) - reward_type = models.CharField(max_length=64) - campaign_reward_type = models.CharField(max_length=64, blank=True, default="") - condition_type = models.CharField(max_length=64) - condition_for_minutes = models.IntegerField() - ios_based_reward = models.BooleanField() - code_remaining_count = models.IntegerField() - - class Meta: - unique_together = ("campaign", "reward_no") - - def __str__(self) -> str: - return f"{self.title} (#{self.reward_no})" diff --git a/chzzk/schemas.py b/chzzk/schemas.py deleted file mode 100644 index dc0df2b..0000000 --- a/chzzk/schemas.py +++ /dev/null @@ -1,54 +0,0 @@ -from typing import Any - -from pydantic import BaseModel -from pydantic import Field - - -class ChzzkRewardV2(BaseModel): - """Pydantic schema for api v2 reward object.""" - - title: str - reward_no: int = Field(..., alias="rewardNo") - image_url: str = Field(..., alias="imageUrl") - reward_type: str = Field(..., alias="rewardType") - condition_type: str = Field(..., alias="conditionType") - condition_for_minutes: int = Field(..., alias="conditionForMinutes") - ios_based_reward: bool = Field(..., alias="iosBasedReward") - code_remaining_count: int = Field(..., alias="codeRemainingCount") - - model_config = {"extra": "forbid"} - - -class ChzzkCampaignV2(BaseModel): - """Pydantic schema for api v2 campaign object.""" - - title: str - state: str - description: str - campaign_no: int = Field(..., alias="campaignNo") - image_url: str = Field(..., alias="imageUrl") - category_type: str = Field(..., alias="categoryType") - category_id: str = Field(..., alias="categoryId") - category_value: str = Field(..., alias="categoryValue") - pc_link_url: str = Field(..., alias="pcLinkUrl") - mobile_link_url: str = Field(..., alias="mobileLinkUrl") - service_id: str = Field(..., alias="serviceId") - start_date: str = Field(..., alias="startDate") - end_date: str = Field(..., alias="endDate") - reward_list: list[ChzzkRewardV2] = Field(..., alias="rewardList") - has_ios_based_reward: bool = Field(..., alias="hasIosBasedReward") - drops_campaign_not_started: bool = Field(..., alias="dropsCampaignNotStarted") - reward_type: str | None = Field(None, alias="rewardType") - account_link_url: str = Field(..., alias="accountLinkUrl") - - model_config = {"extra": "forbid"} - - -class ChzzkApiResponseV2(BaseModel): - """Pydantic schema for api v2 API response.""" - - code: int - message: Any | None - content: ChzzkCampaignV2 - - model_config = {"extra": "forbid"} diff --git a/chzzk/tests/__init__.py b/chzzk/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/chzzk/tests/test_schemas.py b/chzzk/tests/test_schemas.py deleted file mode 100644 index 0350d45..0000000 --- a/chzzk/tests/test_schemas.py +++ /dev/null @@ -1,37 +0,0 @@ -import json -from pathlib import Path - -import pydantic -import pytest - -from chzzk.schemas import ChzzkApiResponseV2 - -TESTS_DIR = Path(__file__).parent - - -@pytest.mark.parametrize( - ("fname", "data_model"), - [ - ("v2_905.json", ChzzkApiResponseV2), - ], -) -def test_chzzk_schema_strict(fname: str, data_model: type) -> None: - """Test that the schema strictly validates the given JSON file against the provided Pydantic model.""" - with Path(TESTS_DIR / fname).open(encoding="utf-8") as f: - data = json.load(f) - # Should not raise - data_model.model_validate(data) - - -@pytest.mark.parametrize( - ("fname", "data_model"), - [ - ("v1_905.json", ChzzkApiResponseV2), - ], -) -def test_chzzk_schema_cross_fail(fname: str, data_model: type) -> None: - """Test that the schema fails when validating the wrong JSON file/model combination.""" - with Path(TESTS_DIR / fname).open(encoding="utf-8") as f: - data = json.load(f) - with pytest.raises(pydantic.ValidationError): - data_model.model_validate(data) diff --git a/chzzk/tests/v1_905.json b/chzzk/tests/v1_905.json deleted file mode 100644 index 52b7479..0000000 --- a/chzzk/tests/v1_905.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "code": 200, - "message": null, - "content": { - "campaignNo": 905, - "title": "붉은사막 드롭스 #2", - "imageUrl": "https://nng-phinf.pstatic.net/MjAyNjAzMjRfMzcg/MDAxNzc0MzU4Mzk3MjM0.JQAXkEYe2ntJ5gvzr5U5egn78DalY24mi1hodGyPYcog.tko3iBzm7dOqDKdjZAZX2ozWrj-tKjVcN3v3ieQaNzQg.JPEG/KR_260319_sony_masterimage_2160x2160.jpg", - "description": "치지직에서 붉은사막 방송을 시청하고, 특별한 보상을 획득해 보세요.", - "categoryType": "GAME", - "categoryId": "CrimsonDesert", - "categoryValue": "붉은사막", - "pcLinkUrl": "https://event.pearlabyss.com/CrimsonDesert/Drops", - "mobileLinkUrl": "https://event.pearlabyss.com/CrimsonDesert/Drops", - "serviceId": "Pearl_Abyss_Event", - "state": "PROMOTED", - "startDate": "2026-03-26 09:00:00", - "endDate": "2026-04-02 08:59:00", - "rewardList": [ - { - "rewardNo": 2521, - "imageUrl": "https://nng-phinf.pstatic.net/MjAyNjAzMjRfMTIg/MDAxNzc0MzU4NTgxMTY5.WFRtLuWw0qciEmMlnm79culfGW1lj-KwvXTaZLkbzMIg.PxoTwZ6v_i1N7Vkyj--OxmeuEJsRvQDWgjT1sOB6kfog.PNG/Week-2-60-min.png", - "title": "푸른 정찰대 등자", - "rewardType": "TIME_BASED", - "campaignRewardType": "IN_APP", - "conditionType": "TIME_BASED", - "conditionForMinutes": 60, - "iosBasedReward": false, - "codeRemainingCount": 0 - }, - { - "rewardNo": 2522, - "imageUrl": "https://nng-phinf.pstatic.net/MjAyNjAzMjRfMTYw/MDAxNzc0MzU4NjEwODkz.6SMLoYofJ6A2a9Nd1IlKn_CC3JLyR8ecxkt8cO-rzcAg.kQSP_H08VoS1qXGiB_3BBMYT_0IT07YPz09n0lNmD1cg.PNG/Week-2-120-min.png", - "title": "푸른 정찰대 마면", - "rewardType": "TIME_BASED", - "campaignRewardType": "IN_APP", - "conditionType": "TIME_BASED", - "conditionForMinutes": 120, - "iosBasedReward": false, - "codeRemainingCount": 0 - }, - { - "rewardNo": 2523, - "imageUrl": "https://nng-phinf.pstatic.net/MjAyNjAzMjRfMjU3/MDAxNzc0MzU4NjM5MDgw.7u6E8ybLp7x9MNESI3od1Sf3AE_6AobSMFfb18MhsNog.M5ZvA1SSjaOObB2TB-P9Rw2rtMcV8zXipL0i7adILdwg.PNG/Week-2-180-min.png", - "title": "푸른 정찰대 안장", - "rewardType": "TIME_BASED", - "campaignRewardType": "IN_APP", - "conditionType": "TIME_BASED", - "conditionForMinutes": 180, - "iosBasedReward": false, - "codeRemainingCount": 0 - } - ], - "hasIosBasedReward": false, - "dropsCampaignNotStarted": false, - "campaignRewardType": "IN_APP", - "accountLinkUrl": "https://event.pearlabyss.com/CrimsonDesert/Drops" - } -} diff --git a/chzzk/tests/v2_905.json b/chzzk/tests/v2_905.json deleted file mode 100644 index 55dd1e4..0000000 --- a/chzzk/tests/v2_905.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "code": 200, - "message": null, - "content": { - "campaignNo": 905, - "title": "붉은사막 드롭스 #2", - "imageUrl": "https://nng-phinf.pstatic.net/MjAyNjAzMjRfMzcg/MDAxNzc0MzU4Mzk3MjM0.JQAXkEYe2ntJ5gvzr5U5egn78DalY24mi1hodGyPYcog.tko3iBzm7dOqDKdjZAZX2ozWrj-tKjVcN3v3ieQaNzQg.JPEG/KR_260319_sony_masterimage_2160x2160.jpg", - "description": "치지직에서 붉은사막 방송을 시청하고, 특별한 보상을 획득해 보세요.", - "categoryType": "GAME", - "categoryId": "CrimsonDesert", - "categoryValue": "붉은사막", - "pcLinkUrl": "https://event.pearlabyss.com/CrimsonDesert/Drops", - "mobileLinkUrl": "https://event.pearlabyss.com/CrimsonDesert/Drops", - "serviceId": "Pearl_Abyss_Event", - "state": "PROMOTED", - "startDate": "2026-03-26 09:00:00", - "endDate": "2026-04-02 08:59:00", - "rewardList": [ - { - "rewardNo": 2521, - "imageUrl": "https://nng-phinf.pstatic.net/MjAyNjAzMjRfMTIg/MDAxNzc0MzU4NTgxMTY5.WFRtLuWw0qciEmMlnm79culfGW1lj-KwvXTaZLkbzMIg.PxoTwZ6v_i1N7Vkyj--OxmeuEJsRvQDWgjT1sOB6kfog.PNG/Week-2-60-min.png", - "title": "푸른 정찰대 등자", - "rewardType": "IN_APP", - "conditionType": "TIME_BASED", - "conditionForMinutes": 60, - "iosBasedReward": false, - "codeRemainingCount": 0 - }, - { - "rewardNo": 2522, - "imageUrl": "https://nng-phinf.pstatic.net/MjAyNjAzMjRfMTYw/MDAxNzc0MzU4NjEwODkz.6SMLoYofJ6A2a9Nd1IlKn_CC3JLyR8ecxkt8cO-rzcAg.kQSP_H08VoS1qXGiB_3BBMYT_0IT07YPz09n0lNmD1cg.PNG/Week-2-120-min.png", - "title": "푸른 정찰대 마면", - "rewardType": "IN_APP", - "conditionType": "TIME_BASED", - "conditionForMinutes": 120, - "iosBasedReward": false, - "codeRemainingCount": 0 - }, - { - "rewardNo": 2523, - "imageUrl": "https://nng-phinf.pstatic.net/MjAyNjAzMjRfMjU3/MDAxNzc0MzU4NjM5MDgw.7u6E8ybLp7x9MNESI3od1Sf3AE_6AobSMFfb18MhsNog.M5ZvA1SSjaOObB2TB-P9Rw2rtMcV8zXipL0i7adILdwg.PNG/Week-2-180-min.png", - "title": "푸른 정찰대 안장", - "rewardType": "IN_APP", - "conditionType": "TIME_BASED", - "conditionForMinutes": 180, - "iosBasedReward": false, - "codeRemainingCount": 0 - } - ], - "hasIosBasedReward": false, - "dropsCampaignNotStarted": false, - "rewardType": "IN_APP", - "accountLinkUrl": "https://event.pearlabyss.com/CrimsonDesert/Drops" - } -} diff --git a/chzzk/urls.py b/chzzk/urls.py deleted file mode 100644 index 7b6209f..0000000 --- a/chzzk/urls.py +++ /dev/null @@ -1,49 +0,0 @@ -from typing import TYPE_CHECKING - -from django.urls import path - -from chzzk import views - -if TYPE_CHECKING: - from django.urls.resolvers import URLPattern - -app_name = "chzzk" - -urlpatterns: list[URLPattern] = [ - # /chzzk/ - path( - "", - views.dashboard_view, - name="dashboard", - ), - # /chzzk/campaigns/ - path( - "campaigns/", - views.CampaignListView.as_view(), - name="campaign_list", - ), - # /chzzk/campaigns// - path( - "campaigns//", - views.campaign_detail_view, - name="campaign_detail", - ), - # /chzzk/rss/campaigns - path( - "rss/campaigns", - views.ChzzkCampaignFeed(), - name="campaign_feed", - ), - # /chzzk/atom/campaigns - path( - "atom/campaigns", - views.ChzzkCampaignFeed(), - name="campaign_feed_atom", - ), - # /chzzk/discord/campaigns - path( - "discord/campaigns", - views.ChzzkCampaignFeed(), - name="campaign_feed_discord", - ), -] diff --git a/chzzk/views.py b/chzzk/views.py deleted file mode 100644 index bc87bfc..0000000 --- a/chzzk/views.py +++ /dev/null @@ -1,206 +0,0 @@ -from typing import TYPE_CHECKING - -from django.db.models.query import QuerySet -from django.shortcuts import render -from django.urls import reverse -from django.utils import timezone -from django.utils.html import format_html -from django.utils.safestring import SafeText -from django.views import generic - -from chzzk import models -from twitch.feeds import TTVDropsBaseFeed - -if TYPE_CHECKING: - import datetime - - from django.http import HttpResponse - from django.http.request import HttpRequest - from pytest_django.asserts import QuerySet - - -def dashboard_view(request: HttpRequest) -> HttpResponse: - """View function for the dashboard page showing all the active chzzk campaigns. - - Args: - request (HttpRequest): The incoming HTTP request. - - Returns: - HttpResponse: The HTTP response containing the rendered dashboard page. - """ - active_campaigns: QuerySet[models.ChzzkCampaign, models.ChzzkCampaign] = ( - models.ChzzkCampaign.objects.filter(end_date__gte=timezone.now()).order_by( - "-start_date", - ) - ) - return render( - request=request, - template_name="chzzk/dashboard.html", - context={ - "active_campaigns": active_campaigns, - }, - ) - - -class CampaignListView(generic.ListView): - """List view showing all chzzk campaigns.""" - - model = models.ChzzkCampaign - template_name = "chzzk/campaign_list.html" - context_object_name = "campaigns" - paginate_by = 25 - - -def campaign_detail_view(request: HttpRequest, campaign_no: int) -> HttpResponse: - """View function for the campaign detail page showing information about a single chzzk campaign. - - Args: - request (HttpRequest): The incoming HTTP request. - campaign_no (int): The campaign number of the campaign to display. - - Returns: - HttpResponse: The HTTP response containing the rendered campaign detail page. - """ - campaign: models.ChzzkCampaign = models.ChzzkCampaign.objects.get( - campaign_no=campaign_no, - ) - rewards: QuerySet[models.ChzzkReward, models.ChzzkReward] = campaign.rewards.all() # pyright: ignore[reportAttributeAccessIssue] - return render( - request=request, - template_name="chzzk/campaign_detail.html", - context={ - "campaign": campaign, - "rewards": rewards, - }, - ) - - -class ChzzkCampaignFeed(TTVDropsBaseFeed): - """RSS feed for the latest chzzk campaigns.""" - - title: str = "chzzk campaigns" - link: str = "/chzzk/campaigns/" - description: str = "Latest chzzk campaigns" - - _limit: int | None = None - - def __call__(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - """Allow an optional 'limit' query parameter to specify the number of items in the feed. - - Args: - request (HttpRequest): The incoming HTTP request. - *args: Additional positional arguments. - **kwargs: Additional keyword arguments. - - Returns: - HttpResponse: The HTTP response containing the feed. - """ - if request.GET.get("limit"): - try: - self._limit = int(request.GET.get("limit", 50)) - except ValueError, TypeError: - self._limit = None - return super().__call__(request, *args, **kwargs) - - def items(self) -> QuerySet[models.ChzzkCampaign, models.ChzzkCampaign]: - """Return the latest chzzk campaigns with raw_json data, ordered by start date. - - Returns: - QuerySet: A queryset of ChzzkCampaign objects. - """ - limit: int = self._limit if self._limit is not None else 50 - return models.ChzzkCampaign.objects.filter(raw_json__isnull=False).order_by( - "-start_date", - )[:limit] - - def item_title(self, item: models.ChzzkCampaign) -> str: - """Return the title of the feed item, which is the campaign title. - - Args: - item (ChzzkCampaign): The campaign object for the feed item. - - Returns: - str: The title of the feed item. - """ - return item.title - - def item_description(self, item: models.ChzzkCampaign) -> SafeText: - """Return the description of the feed item, which includes the campaign image and description. - - Args: - item (ChzzkCampaign): The campaign object for the feed item. - - Returns: - SafeText: The HTML description of the feed item. - """ - parts: list[SafeText] = [] - if getattr(item, "image_url", ""): - parts.append( - format_html( - '{}', - item.image_url, - item.title, - ), - ) - if getattr(item, "description", ""): - parts.append(format_html("

{}

", item.description)) - - # Link back to the PC detail URL when available - if getattr(item, "pc_link_url", ""): - parts.append( - format_html('

Details

', item.pc_link_url), - ) - - return SafeText("".join(str(p) for p in parts)) - - def item_link(self, item: models.ChzzkCampaign) -> str: - """Return the URL for the feed item, which is the campaign detail page. - - Args: - item (ChzzkCampaign): The campaign object for the feed item. - - Returns: - str: The URL for the feed item. - """ - return reverse("chzzk:campaign_detail", args=[item.pk]) - - def item_pubdate(self, item: models.ChzzkCampaign) -> datetime.datetime: - """Return the publication date of the feed item, which is the campaign start date. - - Args: - item (ChzzkCampaign): The campaign object for the feed item. - - Returns: - datetime.datetime: The publication date of the feed item. - """ - return getattr(item, "start_date", timezone.now()) or timezone.now() - - def item_updateddate(self, item: models.ChzzkCampaign) -> datetime.datetime: - """Return the last updated date of the feed item, which is the campaign scraped date. - - Args: - item (ChzzkCampaign): The campaign object for the feed item. - - Returns: - datetime.datetime: The last updated date of the feed item. - """ - return getattr(item, "scraped_at", timezone.now()) or timezone.now() - - def item_author_name(self, item: models.ChzzkCampaign) -> str: - """Return the author name for the feed item. Since we don't have a specific author, return a default value. - - Args: - item (ChzzkCampaign): The campaign object for the feed item. - - Returns: - str: The author name for the feed item. - """ - return item.category_id or "Unknown Category" - - def feed_url(self) -> str: - """Return the URL of the feed itself. - - Returns: - str: The URL of the feed. - """ - return reverse("chzzk:campaign_feed") diff --git a/config/settings.py b/config/settings.py index dbb1285..b7347b3 100644 --- a/config/settings.py +++ b/config/settings.py @@ -140,11 +140,10 @@ INSTALLED_APPS: list[str] = [ "django.contrib.staticfiles", "django.contrib.postgres", # Internal apps - "chzzk.apps.ChzzkConfig", - "core.apps.CoreConfig", - "kick.apps.KickConfig", "twitch.apps.TwitchConfig", + "kick.apps.KickConfig", "youtube.apps.YoutubeConfig", + "core.apps.CoreConfig", # Third-party apps "django_celery_results", "django_celery_beat", @@ -172,35 +171,10 @@ TEMPLATES: list[dict[str, Any]] = [ }, ] - -def configure_databases(*, testing: bool, base_dir: Path) -> dict[str, dict[str, Any]]: - """Configure Django databases based on environment variables and testing mode. - - Args: - testing (bool): Whether the application is running in testing mode. - base_dir (Path): The base directory of the project, used for SQLite file location. - - Returns: - dict[str, dict[str, Any]]: The DATABASES setting for Django. - """ - use_sqlite: bool = env_bool("USE_SQLITE", default=False) - - if testing: - return { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": ":memory:", - }, - } - if use_sqlite: - return { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": str(base_dir / "db.sqlite3"), - }, - } - # Default: PostgreSQL - return { +DATABASES: dict[str, dict[str, Any]] = ( + {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} + if TESTING + else { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": os.getenv("POSTGRES_DB", "ttvdrops"), @@ -213,11 +187,6 @@ def configure_databases(*, testing: bool, base_dir: Path) -> dict[str, dict[str, "OPTIONS": {"connect_timeout": env_int("DB_CONNECT_TIMEOUT", 10)}, }, } - - -DATABASES: dict[str, dict[str, Any]] = configure_databases( - testing=TESTING, - base_dir=BASE_DIR, ) if not TESTING: diff --git a/config/tests/test_settings.py b/config/tests/test_settings.py index aa84ab9..1b4a5f9 100644 --- a/config/tests/test_settings.py +++ b/config/tests/test_settings.py @@ -3,7 +3,6 @@ import os import sys from contextlib import contextmanager from typing import TYPE_CHECKING -from typing import Any import pytest from django.contrib.sessions.models import Session @@ -250,43 +249,14 @@ def test_email_settings_from_env( assert reloaded.SERVER_EMAIL == "me@example.com" -def test_database_settings_when_use_sqlite_enabled( - monkeypatch: pytest.MonkeyPatch, - reload_settings_module: Callable[..., ModuleType], -) -> None: - """When USE_SQLITE=1 and not testing, DATABASES should use SQLite file.""" - monkeypatch.setattr("sys.argv", ["manage.py", "runserver"]) - monkeypatch.delenv("PYTEST_VERSION", raising=False) - monkeypatch.setenv("USE_SQLITE", "1") - - reloaded: ModuleType = reload_settings_module( - TESTING=None, - PYTEST_VERSION=None, - POSTGRES_DB="prod_db", - POSTGRES_USER="prod_user", - POSTGRES_PASSWORD="secret", - POSTGRES_HOST="db.host", - POSTGRES_PORT="5433", - CONN_MAX_AGE="120", - CONN_HEALTH_CHECKS="0", - ) - - assert reloaded.TESTING is False - db_cfg: dict[str, Any] = reloaded.DATABASES["default"] - assert db_cfg["ENGINE"] == "django.db.backends.sqlite3" - # Should use a file, not in-memory - assert db_cfg["NAME"].endswith("db.sqlite3") - - def test_database_settings_when_not_testing( monkeypatch: pytest.MonkeyPatch, reload_settings_module: Callable[..., ModuleType], ) -> None: """When not running tests, DATABASES should use the Postgres configuration.""" - # Ensure the module believes it's not running tests and USE_SQLITE is unset + # Ensure the module believes it's not running tests monkeypatch.setattr("sys.argv", ["manage.py", "runserver"]) monkeypatch.delenv("PYTEST_VERSION", raising=False) - monkeypatch.setenv("USE_SQLITE", "0") reloaded: ModuleType = reload_settings_module( TESTING=None, @@ -301,7 +271,7 @@ def test_database_settings_when_not_testing( ) assert reloaded.TESTING is False - db_cfg: dict[str, Any] = reloaded.DATABASES["default"] + db_cfg = reloaded.DATABASES["default"] assert db_cfg["ENGINE"] == "django.db.backends.postgresql" assert db_cfg["NAME"] == "prod_db" assert db_cfg["USER"] == "prod_user" diff --git a/config/urls.py b/config/urls.py index 6abb8f8..15b37bb 100644 --- a/config/urls.py +++ b/config/urls.py @@ -49,8 +49,6 @@ urlpatterns: list[URLPattern | URLResolver] = [ path(route="twitch/", view=include("twitch.urls", namespace="twitch")), # Kick app path(route="kick/", view=include("kick.urls", namespace="kick")), - # Chzzk app - path(route="chzzk/", view=include("chzzk.urls", namespace="chzzk")), # YouTube app path(route="youtube/", view=include("youtube.urls", namespace="youtube")), ] diff --git a/twitch/tests/example.json b/example.json similarity index 100% rename from twitch/tests/example.json rename to example.json diff --git a/templates/base.html b/templates/base.html index be2daed..79611af 100644 --- a/templates/base.html +++ b/templates/base.html @@ -228,10 +228,6 @@ Games | Organizations
- Chzzk - Dashboard | - Campaigns -
Other sites Steam | YouTube | diff --git a/templates/chzzk/campaign_detail.html b/templates/chzzk/campaign_detail.html deleted file mode 100644 index 7a38c83..0000000 --- a/templates/chzzk/campaign_detail.html +++ /dev/null @@ -1,52 +0,0 @@ -{% extends "base.html" %} -{% block title %} - {{ campaign.title }} -{% endblock title %} -{% block content %} -
-

{{ campaign.title }}

- - {% if campaign.image_url %} - {{ campaign.title }} - {% endif %} - {% if campaign.description %} -
- {{ campaign.description|linebreaksbr }} -
- {% endif %} -
- {% if campaign.starts_at %} -
- Starts: -
- {% endif %} - {% if campaign.ends_at %} -
- Ends: -
- {% endif %} -
-
-

Rewards

- {% if rewards %} -
    - {% for r in rewards %}
  • {{ r.title }} — {{ r.condition_for_minutes }} minutes of watch time
  • {% endfor %} -
- {% else %} -

No rewards available.

- {% endif %} - {% if campaign.external_url %} -

- View on chzzk -

- {% endif %} -
-{% endblock content %} diff --git a/templates/chzzk/campaign_list.html b/templates/chzzk/campaign_list.html deleted file mode 100644 index d256674..0000000 --- a/templates/chzzk/campaign_list.html +++ /dev/null @@ -1,62 +0,0 @@ -{% extends "base.html" %} -{% block title %} - chzzk Campaigns -{% endblock title %} -{% block extra_head %} - -{% endblock extra_head %} -{% block content %} -
-

chzzk campaigns

- - - {% if campaigns %} - - - - - - - - - - - {% for c in campaigns %} - - - - - - - {% endfor %} - -
NameOrganizationStartEnd
- {{ c.title }} - - {% if c.organization %} - {{ c.organization.name }} - {% endif %} - - {% if c.start_date %} - ({{ c.start_date|timesince }} ago) - {% endif %} - - {% if c.end_date %} - ({{ c.end_date|timesince }} ago) - {% endif %} -
- {% if is_paginated %} - {% include "includes/pagination.html" with page_obj=page_obj %} - {% endif %} - {% else %} -

No campaigns found.

- {% endif %} -
-{% endblock content %} diff --git a/templates/chzzk/dashboard.html b/templates/chzzk/dashboard.html deleted file mode 100644 index c7c6169..0000000 --- a/templates/chzzk/dashboard.html +++ /dev/null @@ -1,137 +0,0 @@ -{% extends "base.html" %} -{% block title %} - Chzzk Drops -{% endblock title %} -{% block extra_head %} - - - -{% endblock extra_head %} -{% block content %} -
-

Active Chzzk Drops

-
CHZZK is South Korean alternative to Twitch.
- -
- {% if active_campaigns %} - {% for campaign in active_campaigns %} - -
-
-

{{ campaign.category_value }}

-
Status: {{ campaign.state }} | Campaign #{{ campaign.campaign_no }}
-
-
-
- {% if campaign.image_url %} - {{ campaign.title }} image - {% else %} -
No Image
- {% endif %} -
-
-

- {{ campaign.title }} -

- {% if campaign.description %} -

{{ campaign.description }}

- {% endif %} - - {% if campaign.start_date %} -

- Starts: - - {% if campaign.start_date < now %} - (started {{ campaign.start_date|timesince }} ago) - {% else %} - (in {{ campaign.start_date|timeuntil }}) - {% endif %} -

- {% endif %} - - {% if campaign.end_date %} -

- Ends: - - {% if campaign.end_date < now %} - (ended {{ campaign.end_date|timesince }} ago) - {% else %} - (in {{ campaign.end_date|timeuntil }}) - {% endif %} -

- {% endif %} - {% if campaign.reward_type %} -

- Reward Type: {{ campaign.reward_type }} -

- {% endif %} -

- {% if campaign.account_link_url %}Connect account |{% endif %} - {% if campaign.pc_link_url %}View on Chzzk{% endif %} -

-
- Participating channels: -

{{ campaign.category_value }} is game wide.

-
- {% if campaign.rewards.all %} -
- Rewards: -
    - {% for reward in campaign.rewards.all %} -
  • - {% if reward.image_url %} - {{ reward.title }} - {% endif %} - {{ reward.title }} ({{ reward.condition_for_minutes }} min) -
  • - {% endfor %} -
-
- {% endif %} -
-
-
- {% endfor %} - {% else %} -

No active Chzzk drop campaigns at the moment. Check back later!

- {% endif %} -
-{% endblock content %} diff --git a/twitch/tests/test_better_import_drops.py b/twitch/tests/test_better_import_drops.py index 92effc5..2938625 100644 --- a/twitch/tests/test_better_import_drops.py +++ b/twitch/tests/test_better_import_drops.py @@ -620,7 +620,7 @@ class ExampleJsonImportTests(TestCase): command = Command() repo_root: Path = Path(__file__).resolve().parents[2] - example_path: Path = repo_root / "twitch" / "tests" / "example.json" + example_path: Path = repo_root / "example.json" responses = json.loads(example_path.read_text(encoding="utf-8"))