diff --git a/chzzk/management/__init__.py b/chzzk/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chzzk/management/commands/__init__.py b/chzzk/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chzzk/management/commands/import_chzzk_campaign.py b/chzzk/management/commands/import_chzzk_campaign.py new file mode 100644 index 0000000..f4c0875 --- /dev/null +++ b/chzzk/management/commands/import_chzzk_campaign.py @@ -0,0 +1,137 @@ +from typing import TYPE_CHECKING +from typing import Any + +if TYPE_CHECKING: + import argparse + + from chzzk.schemas import ChzzkCampaignV1 + 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 ChzzkApiResponseV1 +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"]) + for api_version, url_template in CHZZK_API_URLS: + url: str = url_template.format(campaign_no=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: ChzzkCampaignV1 | ChzzkCampaignV2 + if api_version == "v1": + campaign_data = ChzzkApiResponseV1.model_validate(data).content + elif api_version == "v2": + campaign_data = ChzzkApiResponseV2.model_validate(data).content + else: + msg: str = f"Unknown API version: {api_version}" + self.stdout.write(self.style.ERROR(msg)) + continue + + # Save campaign + campaign_obj, created = ChzzkCampaign.objects.update_or_create( + campaign_no=campaign_data.campaign_no, + source_api=api_version, + 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": data, + }, + ) + if created: + self.stdout.write( + self.style.SUCCESS( + f"Created campaign {campaign_no} from {api_version}", + ), + ) + 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} from {api_version}", + ), + ) diff --git a/chzzk/migrations/0001_initial.py b/chzzk/migrations/0001_initial.py new file mode 100644 index 0000000..52e6e62 --- /dev/null +++ b/chzzk/migrations/0001_initial.py @@ -0,0 +1,100 @@ +# Generated by Django 6.0.3 on 2026-03-31 19:33 + +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 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(unique=True)), + ("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", models.JSONField()), + ], + options={ + "ordering": ["-start_date"], + "unique_together": {("campaign_no", "source_api")}, + }, + ), + 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/0002_alter_chzzkcampaign_campaign_no.py b/chzzk/migrations/0002_alter_chzzkcampaign_campaign_no.py new file mode 100644 index 0000000..e35eb48 --- /dev/null +++ b/chzzk/migrations/0002_alter_chzzkcampaign_campaign_no.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.3 on 2026-03-31 19:53 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + """Alter campaign_no field in ChzzkCampaign to remove unique constraint.""" + + dependencies = [ + ("chzzk", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="chzzkcampaign", + name="campaign_no", + field=models.BigIntegerField(), + ), + ] diff --git a/chzzk/models.py b/chzzk/models.py index e69de29..2aefe93 100644 --- a/chzzk/models.py +++ b/chzzk/models.py @@ -0,0 +1,63 @@ +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) # 'v1' or 'v2' + scrape_status = models.CharField(max_length=32, default="success") + raw_json = models.JSONField() + + class Meta: + unique_together = ("campaign_no", "source_api") + 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 new file mode 100644 index 0000000..20a0986 --- /dev/null +++ b/chzzk/schemas.py @@ -0,0 +1,107 @@ +from typing import Any + +from pydantic import BaseModel +from pydantic import Field + + +class ChzzkRewardV1(BaseModel): + """Pydantic schema for Chzzk v1 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") + + # Only in v1 API + campaign_reward_type: str | None = Field(None, alias="campaignRewardType") + + model_config = {"extra": "forbid"} + + +class ChzzkRewardV2(BaseModel): + """Pydantic schema for Chzzk 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 ChzzkCampaignV1(BaseModel): + """Pydantic schema for Chzzk v1 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[ChzzkRewardV1] = Field(..., alias="rewardList") + has_ios_based_reward: bool = Field(..., alias="hasIosBasedReward") + drops_campaign_not_started: bool = Field(..., alias="dropsCampaignNotStarted") + campaign_reward_type: str | None = Field(None, alias="campaignRewardType") + account_link_url: str = Field(..., alias="accountLinkUrl") + + model_config = {"extra": "forbid"} + + +class ChzzkCampaignV2(BaseModel): + """Pydantic schema for Chzzk v2 campaign object.""" + + title: str + state: str + campaign_no: int = Field(..., alias="campaignNo") + image_url: str = Field(..., alias="imageUrl") + description: str + 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 ChzzkApiResponseV1(BaseModel): + """Pydantic schema for Chzzk v1 API response.""" + + code: int + message: Any | None + content: ChzzkCampaignV1 + + model_config = {"extra": "forbid"} + + +class ChzzkApiResponseV2(BaseModel): + """Pydantic schema for Chzzk v2 API response.""" + + code: int + message: Any | None + content: ChzzkCampaignV2 + + model_config = {"extra": "forbid"} diff --git a/chzzk/tests/test_schemas.py b/chzzk/tests/test_schemas.py new file mode 100644 index 0000000..2192fad --- /dev/null +++ b/chzzk/tests/test_schemas.py @@ -0,0 +1,40 @@ +import json +from pathlib import Path + +import pydantic +import pytest + +from chzzk.schemas import ChzzkApiResponseV1 +from chzzk.schemas import ChzzkApiResponseV2 + +TESTS_DIR = Path(__file__).parent + + +@pytest.mark.parametrize( + ("fname", "data_model"), + [ + ("v1_905.json", ChzzkApiResponseV1), + ("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), + ("v2_905.json", ChzzkApiResponseV1), + ], +) +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 new file mode 100644 index 0000000..52b7479 --- /dev/null +++ b/chzzk/tests/v1_905.json @@ -0,0 +1,58 @@ +{ + "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 new file mode 100644 index 0000000..55dd1e4 --- /dev/null +++ b/chzzk/tests/v2_905.json @@ -0,0 +1,55 @@ +{ + "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/config/settings.py b/config/settings.py index b7347b3..dbb1285 100644 --- a/config/settings.py +++ b/config/settings.py @@ -140,10 +140,11 @@ INSTALLED_APPS: list[str] = [ "django.contrib.staticfiles", "django.contrib.postgres", # Internal apps - "twitch.apps.TwitchConfig", - "kick.apps.KickConfig", - "youtube.apps.YoutubeConfig", + "chzzk.apps.ChzzkConfig", "core.apps.CoreConfig", + "kick.apps.KickConfig", + "twitch.apps.TwitchConfig", + "youtube.apps.YoutubeConfig", # Third-party apps "django_celery_results", "django_celery_beat", @@ -171,10 +172,35 @@ TEMPLATES: list[dict[str, Any]] = [ }, ] -DATABASES: dict[str, dict[str, Any]] = ( - {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} - if TESTING - else { + +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 { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": os.getenv("POSTGRES_DB", "ttvdrops"), @@ -187,6 +213,11 @@ DATABASES: dict[str, dict[str, Any]] = ( "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 1b4a5f9..aa84ab9 100644 --- a/config/tests/test_settings.py +++ b/config/tests/test_settings.py @@ -3,6 +3,7 @@ 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 @@ -249,14 +250,14 @@ def test_email_settings_from_env( assert reloaded.SERVER_EMAIL == "me@example.com" -def test_database_settings_when_not_testing( +def test_database_settings_when_use_sqlite_enabled( 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 + """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, @@ -271,7 +272,36 @@ def test_database_settings_when_not_testing( ) assert reloaded.TESTING is False - db_cfg = reloaded.DATABASES["default"] + 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 + 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, + 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.postgresql" assert db_cfg["NAME"] == "prod_db" assert db_cfg["USER"] == "prod_user" diff --git a/example.json b/twitch/tests/example.json similarity index 100% rename from example.json rename to twitch/tests/example.json diff --git a/twitch/tests/test_better_import_drops.py b/twitch/tests/test_better_import_drops.py index 2938625..92effc5 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 / "example.json" + example_path: Path = repo_root / "twitch" / "tests" / "example.json" responses = json.loads(example_path.read_text(encoding="utf-8"))