Stuff and things

This commit is contained in:
2024-12-10 05:10:02 +01:00
parent 6af7cfbeb5
commit 69df18a4c2
8 changed files with 328 additions and 318 deletions

View File

@ -29,7 +29,11 @@
"mypy", "mypy",
"networkidle", "networkidle",
"nostatic", "nostatic",
"pgclone",
"pghistory",
"PGID", "PGID",
"pgstats",
"pgtrigger",
"platformdirs", "platformdirs",
"psycopg", "psycopg",
"PUID", "PUID",
@ -55,4 +59,5 @@
"xdefiant" "xdefiant"
], ],
"python.analysis.typeCheckingMode": "basic", "python.analysis.typeCheckingMode": "basic",
"python.analysis.enablePytestSupport": true,
} }

View File

@ -1,11 +1,9 @@
from django.contrib import admin from django.contrib import admin
from core.models import Benefit, DropCampaign, Game, Owner, Reward, RewardCampaign, TimeBasedDrop from core.models import Benefit, DropCampaign, Game, Owner, TimeBasedDrop
admin.site.register(Game) admin.site.register(Game)
admin.site.register(Owner) admin.site.register(Owner)
admin.site.register(RewardCampaign)
admin.site.register(DropCampaign) admin.site.register(DropCampaign)
admin.site.register(TimeBasedDrop) admin.site.register(TimeBasedDrop)
admin.site.register(Benefit) admin.site.register(Benefit)
admin.site.register(Reward)

View File

@ -1,20 +1,25 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import ClassVar, Self from typing import TYPE_CHECKING, ClassVar, Self
import auto_prefetch
import pghistory
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from core.models_utils import update_fields, wrong_typename from core.models_utils import update_fields, wrong_typename
if TYPE_CHECKING:
from django.db.models import Index
logger: logging.Logger = logging.getLogger(__name__) logger: logging.Logger = logging.getLogger(__name__)
class User(AbstractUser): class User(AbstractUser):
"""Custom user model.""" """Custom user model."""
class Meta: class Meta(auto_prefetch.Model.Meta):
ordering: ClassVar[list[str]] = ["username"] ordering: ClassVar[list[str]] = ["username"]
def __str__(self) -> str: def __str__(self) -> str:
@ -22,7 +27,7 @@ class User(AbstractUser):
return self.username return self.username
class ScrapedJson(models.Model): class ScrapedJson(auto_prefetch.Model):
"""The JSON data from the Twitch API. """The JSON data from the Twitch API.
This data is from https://github.com/TheLovinator1/TwitchDropsMiner. This data is from https://github.com/TheLovinator1/TwitchDropsMiner.
@ -33,7 +38,7 @@ class ScrapedJson(models.Model):
modified_at = models.DateTimeField(auto_now=True) modified_at = models.DateTimeField(auto_now=True)
imported_at = models.DateTimeField(null=True) imported_at = models.DateTimeField(null=True)
class Meta: class Meta(auto_prefetch.Model.Meta):
ordering: ClassVar[list[str]] = ["-created_at"] ordering: ClassVar[list[str]] = ["-created_at"]
def __str__(self) -> str: def __str__(self) -> str:
@ -41,26 +46,44 @@ class ScrapedJson(models.Model):
return f"{'' if self.imported_at else 'Not imported - '}{self.created_at}" return f"{'' if self.imported_at else 'Not imported - '}{self.created_at}"
class Owner(models.Model): @pghistory.track()
class Owner(auto_prefetch.Model):
"""The company or person that owns the game. """The company or person that owns the game.
Drops will be grouped by the owner. Users can also subscribe to owners. Drops will be grouped by the owner. Users can also subscribe to owners.
JSON:
{
"data": {
"user": {
"dropCampaign": {
"owner": {
"id": "36c4e21d-bdf3-410c-97c3-5a5a4bf1399b",
"name": "The Pok\u00e9mon Company",
"__typename": "Organization"
}
}
}
}
}
""" """
# "ad299ac0-f1a5-417d-881d-952c9aed00e9" # Django fields
twitch_id = models.TextField(primary_key=True) # Example: "36c4e21d-bdf3-410c-97c3-5a5a4bf1399b"
twitch_id = models.TextField(primary_key=True, help_text="The Twitch ID of the owner.")
# When the owner was first added to the database.
created_at = models.DateTimeField(auto_created=True) created_at = models.DateTimeField(auto_created=True)
# When the owner was last modified.
modified_at = models.DateTimeField(auto_now=True) modified_at = models.DateTimeField(auto_now=True)
# "Microsoft" # Twitch fields
name = models.TextField(blank=True) # Example: "The Pokémon Company"
name = models.TextField(blank=True, help_text="The name of the owner.")
class Meta: class Meta(auto_prefetch.Model.Meta):
ordering: ClassVar[list[str]] = ["name"] ordering: ClassVar[list[str]] = ["name"]
indexes: ClassVar[list[Index]] = [
models.Index(fields=["name"], name="owner_name_idx"),
models.Index(fields=["created_at"], name="owner_created_at_idx"),
]
def __str__(self) -> str: def __str__(self) -> str:
"""Return the name of the owner.""" """Return the name of the owner."""
@ -79,40 +102,113 @@ class Owner(models.Model):
return self return self
class Game(models.Model): @pghistory.track()
"""The game the drop campaign is for. Note that some reward campaigns are not tied to a game.""" class Game(auto_prefetch.Model):
"""The game the drop campaign is for. Note that some reward campaigns are not tied to a game.
# "509658" JSON:
twitch_id = models.TextField(primary_key=True) {
"data": {
"user": {
"dropCampaign": {
"game": {
"id": "155409827",
"slug": "pokemon-trading-card-game-live",
"displayName": "Pok\u00e9mon Trading Card Game Live",
"__typename": "Game"
}
}
}
}
}
# When the game was first added to the database. Secondary JSON:
created_at = models.DateTimeField(auto_created=True) {
"data": {
"currentUser": {
"dropCampaigns": [
{
"game": {
"id": "155409827",
"displayName": "Pok\u00e9mon Trading Card Game Live",
"boxArtURL": "https://static-cdn.jtvnw.net/ttv-boxart/155409827_IGDB-120x160.jpg",
"__typename": "Game"
}
}
]
}
}
}
# When the game was last modified. Tertiary JSON:
modified_at = models.DateTimeField(auto_now=True) [
{
"data": {
"user": {
"dropCampaign": {
"timeBasedDrops": [
{
"benefitEdges": [
{
"benefit": {
"id": "ea74f727-a52f-11ef-811f-0a58a9feac02",
"createdAt": "2024-11-17T22:04:28.735Z",
"entitlementLimit": 1,
"game": {
"id": "155409827",
"name": "Pok\u00e9mon Trading Card Game Live",
"__typename": "Game"
}
}
}
]
}
]
}
}
}
}
]
"""
# "https://www.twitch.tv/directory/category/halo-infinite" # Django fields
game_url = models.URLField(blank=True) # "155409827"
twitch_id = models.TextField(primary_key=True, help_text="The Twitch ID of the game.")
created_at = models.DateTimeField(auto_created=True, help_text="When the game was first added to the database.")
modified_at = models.DateTimeField(auto_now=True, help_text="When the game was last modified.")
# "Halo Infinite" # Twitch fields
name = models.TextField(blank=True) # "https://www.twitch.tv/directory/category/pokemon-trading-card-game-live"
# This is created when the game is created.
game_url = models.URLField(blank=True, help_text="The URL to the game on Twitch.")
# "https://static-cdn.jtvnw.net/ttv-boxart/Halo%20Infinite.jpg" # "Pokémon Trading Card Game Live"
box_art_url = models.URLField(blank=True) display_name = models.TextField(blank=True, help_text="The display name of the game.")
# "halo-infinite" # "Pokémon Trading Card Game Live"
name = models.TextField(blank=True, help_text="The name of the game.")
# "https://static-cdn.jtvnw.net/ttv-boxart/155409827_IGDB-120x160.jpg"
box_art_url = models.URLField(blank=True, help_text="URL to the box art of the game.")
# "pokemon-trading-card-game-live"
slug = models.TextField(blank=True) slug = models.TextField(blank=True)
# The owner of the game. # The owner of the game.
# This is optional because some games are not tied to an owner. # This is optional because some games are not tied to an owner.
org = models.ForeignKey(Owner, on_delete=models.CASCADE, related_name="games", null=True) org = auto_prefetch.ForeignKey(Owner, on_delete=models.CASCADE, related_name="games", null=True)
class Meta: class Meta(auto_prefetch.Model.Meta):
ordering: ClassVar[list[str]] = ["name"] ordering: ClassVar[list[str]] = ["display_name"]
indexes: ClassVar[list[Index]] = [
models.Index(fields=["display_name"], name="game_display_name_idx"),
models.Index(fields=["name"], name="game_name_idx"),
models.Index(fields=["created_at"], name="game_created_at_idx"),
]
def __str__(self) -> str: def __str__(self) -> str:
"""Return the name of the game and when it was created.""" """Return the name of the game and when it was created."""
return f"{self.name or self.twitch_id} - {self.created_at}" return f"{self.display_name or self.twitch_id} - {self.created_at}"
def import_json(self, data: dict, owner: Owner | None) -> Self: def import_json(self, data: dict, owner: Owner | None) -> Self:
"""Import the data from the Twitch API.""" """Import the data from the Twitch API."""
@ -121,7 +217,8 @@ class Game(models.Model):
# Map the fields from the JSON data to the Django model fields. # Map the fields from the JSON data to the Django model fields.
field_mapping: dict[str, str] = { field_mapping: dict[str, str] = {
"displayName": "name", "displayName": "display_name",
"name": "name",
"boxArtURL": "box_art_url", "boxArtURL": "box_art_url",
"slug": "slug", "slug": "slug",
} }
@ -143,56 +240,63 @@ class Game(models.Model):
return self return self
class DropCampaign(models.Model): @pghistory.track()
class DropCampaign(auto_prefetch.Model):
"""This is the drop campaign we will see on the front end.""" """This is the drop campaign we will see on the front end."""
# Django fields
# "f257ce6e-502a-11ef-816e-0a58a9feac02" # "f257ce6e-502a-11ef-816e-0a58a9feac02"
twitch_id = models.TextField(primary_key=True) twitch_id = models.TextField(primary_key=True, help_text="The Twitch ID of the drop campaign.")
created_at = models.DateTimeField(
# When the drop campaign was first added to the database. auto_created=True,
created_at = models.DateTimeField(auto_created=True) help_text="When the drop campaign was first added to the database.",
)
# When the drop campaign was last modified. modified_at = models.DateTimeField(auto_now=True, help_text="When the drop campaign was last modified.")
modified_at = models.DateTimeField(auto_now=True)
# Twitch fields
# "https://www.halowaypoint.com/settings/linked-accounts" # "https://www.halowaypoint.com/settings/linked-accounts"
account_link_url = models.URLField(blank=True) account_link_url = models.URLField(blank=True, help_text="The URL to link accounts for the drop campaign.")
# "Tune into this HCS Grassroots event to earn Halo Infinite in-game content!" # "Tune into this HCS Grassroots event to earn Halo Infinite in-game content!"
description = models.TextField(blank=True) description = models.TextField(blank=True, help_text="The description of the drop campaign.")
# "https://www.halowaypoint.com" # "https://www.halowaypoint.com"
details_url = models.URLField(blank=True) details_url = models.URLField(blank=True, help_text="The URL to the details of the drop campaign.")
# "2024-08-12T05:59:59.999Z" # "2024-08-12T05:59:59.999Z"
ends_at = models.DateTimeField(null=True) ends_at = models.DateTimeField(null=True, help_text="When the drop campaign ends.")
# "2024-08-11T11:00:00Z"" # "2024-08-11T11:00:00Z""
starts_at = models.DateTimeField(null=True) starts_at = models.DateTimeField(null=True, help_text="When the drop campaign starts.")
# "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/c8e02666-8b86-471f-bf38-7ece29a758e4.png" # "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/c8e02666-8b86-471f-bf38-7ece29a758e4.png"
image_url = models.URLField(blank=True) image_url = models.URLField(blank=True, help_text="The URL to the image for the drop campaign.")
# "HCS Open Series - Week 1 - DAY 2 - AUG11" # "HCS Open Series - Week 1 - DAY 2 - AUG11"
name = models.TextField(blank=True) name = models.TextField(blank=True, help_text="The name of the drop campaign.")
# "ACTIVE" # "ACTIVE"
status = models.TextField(blank=True) status = models.TextField(blank=True, help_text="The status of the drop campaign.")
# The game this drop campaign is for. # The game this drop campaign is for.
game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name="drop_campaigns", null=True) game = auto_prefetch.ForeignKey(to=Game, on_delete=models.CASCADE, related_name="drop_campaigns", null=True)
# The JSON data from the Twitch API. # The JSON data from the Twitch API.
# We use this to find out where the game came from. # We use this to find out where the game came from.
scraped_json = models.ForeignKey( scraped_json = auto_prefetch.ForeignKey(
ScrapedJson, to=ScrapedJson,
null=True, null=True,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
help_text="Reference to the JSON data from the Twitch API.", help_text="Reference to the JSON data from the Twitch API.",
) )
class Meta: class Meta(auto_prefetch.Model.Meta):
ordering: ClassVar[list[str]] = ["ends_at"] ordering: ClassVar[list[str]] = ["ends_at"]
indexes: ClassVar[list[Index]] = [
models.Index(fields=["name"], name="drop_campaign_name_idx"),
models.Index(fields=["starts_at"], name="drop_campaign_starts_at_idx"),
models.Index(fields=["ends_at"], name="drop_campaign_ends_at_idx"),
]
def __str__(self) -> str: def __str__(self) -> str:
"""Return the name of the drop campaign and when it was created.""" """Return the name of the drop campaign and when it was created."""
@ -226,18 +330,9 @@ class DropCampaign(models.Model):
if updated > 0: if updated > 0:
logger.info("Updated %s fields for %s", updated, self) logger.info("Updated %s fields for %s", updated, self)
# Update the drop campaign's status if the new status is different. if not scraping_local_files:
# When scraping local files:
# - Only update if the status changes from "ACTIVE" to "EXPIRED".
# When scraping from the Twitch API:
# - Always update the status regardless of its value.
status = data.get("status") status = data.get("status")
if status and status != self.status: if status and status != self.status:
# Check if scraping local files and status changes from ACTIVE to EXPIRED
should_update = scraping_local_files and status == "EXPIRED" and self.status == "ACTIVE"
# Always update if not scraping local files
if not scraping_local_files or should_update:
self.status = status self.status = status
self.save() self.save()
@ -250,37 +345,102 @@ class DropCampaign(models.Model):
return self return self
class TimeBasedDrop(models.Model): @pghistory.track()
"""This is the drop we will see on the front end.""" class TimeBasedDrop(auto_prefetch.Model):
"""This is the drop we will see on the front end.
JSON:
{
"data": {
"user": {
"dropCampaign": {
"timeBasedDrops": [
{
"id": "bd663e10-b297-11ef-a6a3-0a58a9feac02",
"requiredSubs": 0,
"benefitEdges": [
{
"benefit": {
"id": "f751ba67-7c8b-4c41-b6df-bcea0914f3ad_CUSTOM_ID_EnergisingBoltFlaskEffect",
"createdAt": "2024-12-04T23:25:50.995Z",
"entitlementLimit": 1,
"game": {
"id": "1702520304",
"name": "Path of Exile 2",
"__typename": "Game"
},
"imageAssetURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/REWARD/d70e4e75-7237-4730-9a10-b6016aaaa795.png",
"isIosAvailable": false,
"name": "Energising Bolt Flask",
"ownerOrganization": {
"id": "f751ba67-7c8b-4c41-b6df-bcea0914f3ad",
"name": "Grinding Gear Games",
"__typename": "Organization"
},
"distributionType": "DIRECT_ENTITLEMENT",
"__typename": "DropBenefit"
},
"entitlementLimit": 1,
"__typename": "DropBenefitEdge"
}
],
"endAt": "2024-12-14T07:59:59.996Z",
"name": "Early Access Bundle",
"preconditionDrops": null,
"requiredMinutesWatched": 180,
"startAt": "2024-12-06T19:00:00Z",
"__typename": "TimeBasedDrop"
}
],
"__typename": "DropCampaign"
},
"__typename": "User"
}
}
}
""" # noqa: E501
# Django fields
# "d5cdf372-502b-11ef-bafd-0a58a9feac02" # "d5cdf372-502b-11ef-bafd-0a58a9feac02"
twitch_id = models.TextField(primary_key=True) twitch_id = models.TextField(primary_key=True, help_text="The Twitch ID of the drop.")
created_at = models.DateTimeField(auto_created=True, help_text="When the drop was first added to the database.")
# When the drop was first added to the database. modified_at = models.DateTimeField(auto_now=True, help_text="When the drop was last modified.")
created_at = models.DateTimeField(auto_created=True)
# When the drop was last modified.
modified_at = models.DateTimeField(auto_now=True)
# Twitch fields
# "1" # "1"
required_subs = models.PositiveBigIntegerField(null=True) required_subs = models.PositiveBigIntegerField(null=True, help_text="The number of subs required for the drop.")
# "2024-08-12T05:59:59.999Z" # "2024-08-12T05:59:59.999Z"
ends_at = models.DateTimeField(null=True) ends_at = models.DateTimeField(null=True, help_text="When the drop ends.")
# "Cosmic Nexus Chimera" # "Cosmic Nexus Chimera"
name = models.TextField(blank=True) name = models.TextField(blank=True, help_text="The name of the drop.")
# "120" # "120"
required_minutes_watched = models.PositiveBigIntegerField(null=True) required_minutes_watched = models.PositiveBigIntegerField(
null=True,
help_text="The number of minutes watched required.",
)
# "2024-08-11T11:00:00Z" # "2024-08-11T11:00:00Z"
starts_at = models.DateTimeField(null=True) starts_at = models.DateTimeField(null=True, help_text="When the drop starts.")
drop_campaign = models.ForeignKey(DropCampaign, on_delete=models.CASCADE, related_name="drops", null=True) # The drop campaign this drop is part of.
drop_campaign = auto_prefetch.ForeignKey(
DropCampaign,
on_delete=models.CASCADE,
related_name="drops",
null=True,
help_text="The drop campaign this drop is part of.",
)
class Meta: class Meta(auto_prefetch.Model.Meta):
ordering: ClassVar[list[str]] = ["required_minutes_watched"] ordering: ClassVar[list[str]] = ["required_minutes_watched"]
indexes: ClassVar[list[Index]] = [
models.Index(fields=["name"], name="time_based_drop_name_idx"),
models.Index(fields=["starts_at"], name="time_based_drop_starts_at_idx"),
models.Index(fields=["ends_at"], name="time_based_drop_ends_at_idx"),
]
def __str__(self) -> str: def __str__(self) -> str:
"""Return the name of the drop and when it was created.""" """Return the name of the drop and when it was created."""
@ -291,6 +451,10 @@ class TimeBasedDrop(models.Model):
if wrong_typename(data, "TimeBasedDrop"): if wrong_typename(data, "TimeBasedDrop"):
return self return self
# preconditionDrops is null in the JSON. We probably should use it when we know what it is.
if data.get("preconditionDrops"):
logger.error("preconditionDrops is not None for %s", self)
field_mapping: dict[str, str] = { field_mapping: dict[str, str] = {
"name": "name", "name": "name",
"requiredSubs": "required_subs", "requiredSubs": "required_subs",
@ -311,43 +475,63 @@ class TimeBasedDrop(models.Model):
return self return self
class Benefit(models.Model): @pghistory.track()
class Benefit(auto_prefetch.Model):
"""Benefits are the rewards for the drops.""" """Benefits are the rewards for the drops."""
# Django fields
# "d5cdf372-502b-11ef-bafd-0a58a9feac02" # "d5cdf372-502b-11ef-bafd-0a58a9feac02"
twitch_id = models.TextField(primary_key=True) twitch_id = models.TextField(primary_key=True)
# When the benefit was first added to the database.
created_at = models.DateTimeField(null=True, auto_created=True) created_at = models.DateTimeField(null=True, auto_created=True)
# When the benefit was last modified.
modified_at = models.DateTimeField(auto_now=True) modified_at = models.DateTimeField(auto_now=True)
# Note: This is Twitch's created_at from the API. # Twitch fields
# Note: This is Twitch's created_at from the API and not our created_at.
# "2023-11-09T01:18:00.126Z" # "2023-11-09T01:18:00.126Z"
twitch_created_at = models.DateTimeField(null=True) twitch_created_at = models.DateTimeField(null=True, help_text="When the benefit was created on Twitch.")
# "1" # "1"
entitlement_limit = models.PositiveBigIntegerField(null=True) entitlement_limit = models.PositiveBigIntegerField(
null=True,
help_text="The number of times the benefit can be claimed.",
)
# "https://static-cdn.jtvnw.net/twitch-quests-assets/REWARD/e58ad175-73f6-4392-80b8-fb0223163733.png" # "https://static-cdn.jtvnw.net/twitch-quests-assets/REWARD/e58ad175-73f6-4392-80b8-fb0223163733.png"
image_url = models.URLField(blank=True) image_asset_url = models.URLField(blank=True, help_text="The URL to the image for the benefit.")
# "True" or "False". None if unknown. # "True" or "False". None if unknown.
is_ios_available = models.BooleanField(null=True) is_ios_available = models.BooleanField(null=True, help_text="If the benefit is farmable on iOS.")
# "Cosmic Nexus Chimera" # "Cosmic Nexus Chimera"
name = models.TextField(blank=True) name = models.TextField(blank=True, help_text="The name of the benefit.")
time_based_drop = models.ForeignKey( # The game this benefit is for.
time_based_drop = auto_prefetch.ForeignKey(
TimeBasedDrop, TimeBasedDrop,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="benefits", related_name="benefits",
null=True, null=True,
help_text="The time based drop this benefit is for.",
) )
class Meta: # The game this benefit is for.
game = auto_prefetch.ForeignKey(Game, on_delete=models.CASCADE, related_name="benefits", null=True)
# The owner of the benefit.
owner_organization = auto_prefetch.ForeignKey(Owner, on_delete=models.CASCADE, related_name="benefits", null=True)
# Distribution type.
# "DIRECT_ENTITLEMENT"
distribution_type = models.TextField(blank=True, help_text="The distribution type of the benefit.")
class Meta(auto_prefetch.Model.Meta):
ordering: ClassVar[list[str]] = ["-twitch_created_at"] ordering: ClassVar[list[str]] = ["-twitch_created_at"]
indexes: ClassVar[list[Index]] = [
models.Index(fields=["name"], name="benefit_name_idx"),
models.Index(fields=["twitch_created_at"], name="benefit_twitch_created_at_idx"),
models.Index(fields=["created_at"], name="benefit_created_at_idx"),
models.Index(fields=["is_ios_available"], name="benefit_is_ios_available_idx"),
]
def __str__(self) -> str: def __str__(self) -> str:
"""Return the name of the benefit and when it was created.""" """Return the name of the benefit and when it was created."""
@ -360,10 +544,11 @@ class Benefit(models.Model):
field_mapping: dict[str, str] = { field_mapping: dict[str, str] = {
"name": "name", "name": "name",
"imageAssetURL": "image_url", "imageAssetURL": "image_asset_url",
"entitlementLimit": "entitlement_limit", "entitlementLimit": "entitlement_limit",
"isIOSAvailable": "is_ios_available", "isIosAvailable": "is_ios_available",
"createdAt": "twitch_created_at", "createdAt": "twitch_created_at",
"distributionType": "distribution_type",
} }
updated: int = update_fields(instance=self, data=data, field_mapping=field_mapping) updated: int = update_fields(instance=self, data=data, field_mapping=field_mapping)
if updated > 0: if updated > 0:
@ -378,201 +563,16 @@ class Benefit(models.Model):
logger.info("Updated time based drop %s for %s", time_based_drop, self) logger.info("Updated time based drop %s for %s", time_based_drop, self)
self.save() self.save()
return self
class RewardCampaign(models.Model):
"""Buy subscriptions to earn rewards."""
# "dc4ff0b4-4de0-11ef-9ec3-621fb0811846"
twitch_id = models.TextField(primary_key=True)
# When the reward campaign was first added to the database.
created_at = models.DateTimeField(auto_created=True)
# When the reward campaign was last modified.
modified_at = models.DateTimeField(auto_now=True)
# "Buy 1 new sub, get 3 months of Apple TV+"
name = models.TextField(blank=True)
# "Apple TV+"
brand = models.TextField(blank=True)
# "2024-08-11T11:00:00Z"
starts_at = models.DateTimeField(null=True)
# "2024-08-12T05:59:59.999Z"
ends_at = models.DateTimeField(null=True)
# "UNKNOWN"
status = models.TextField(blank=True)
# "Get 3 months of Apple TV+ with the purchase of a new sub"
summary = models.TextField(blank=True)
# "Buy a new sub to get 3 months of Apple TV+"
instructions = models.TextField(blank=True)
# ""
reward_value_url_param = models.TextField(blank=True)
# "https://tv.apple.com/includes/commerce/redeem/code-entry"
external_url = models.URLField(blank=True)
# "https://blog.twitch.tv/2024/07/26/sub-and-get-apple-tv/"
about_url = models.URLField(blank=True)
# "True" or "False". None if unknown.
is_site_wide = models.BooleanField(null=True)
# "1"
subs_goal = models.PositiveBigIntegerField(null=True)
# "0"
minute_watched_goal = models.PositiveBigIntegerField(null=True)
# "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/quests_appletv_q3_2024/apple_150x200.png"
image_url = models.URLField(blank=True)
game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name="reward_campaigns", null=True)
scraped_json = models.ForeignKey(
ScrapedJson,
null=True,
on_delete=models.SET_NULL,
help_text="Reference to the JSON data from the Twitch API.",
)
class Meta:
ordering: ClassVar[list[str]] = ["-starts_at"]
def __str__(self) -> str:
"""Return the name of the reward campaign and when it was created."""
return f"{self.name or self.twitch_id} - {self.created_at}"
def import_json(self, data: dict) -> Self: # noqa: C901
"""Import the data from the Twitch API."""
if wrong_typename(data, "RewardCampaign"):
return self
field_mapping: dict[str, str] = {
"name": "name",
"brand": "brand",
"startsAt": "starts_at",
"endsAt": "ends_at",
"status": "status",
"summary": "summary",
"instructions": "instructions",
"rewardValueURLParam": "reward_value_url_param", # wtf is this?
"externalURL": "external_url",
"aboutURL": "about_url",
"isSitewide": "is_site_wide",
}
updated: int = update_fields(instance=self, data=data, field_mapping=field_mapping)
if updated > 0:
logger.info("Updated %s fields for %s", updated, self)
if data.get("unlockRequirements", {}):
subs_goal = data["unlockRequirements"].get("subsGoal")
if subs_goal and subs_goal != self.subs_goal:
self.subs_goal = subs_goal
self.save()
minutes_watched_goal = data["unlockRequirements"].get("minuteWatchedGoal")
if minutes_watched_goal and minutes_watched_goal != self.minute_watched_goal:
self.minute_watched_goal = minutes_watched_goal
self.save()
image_url = data.get("image", {}).get("image1xURL")
if image_url and image_url != self.image_url:
self.image_url = image_url
self.save()
if data.get("game") and data["game"].get("id"): if data.get("game") and data["game"].get("id"):
game_instance, created = Game.objects.update_or_create(twitch_id=data["game"]["id"]) game_instance, created = Game.objects.update_or_create(twitch_id=data["game"]["id"])
game_instance.import_json(data["game"], None) game_instance.import_json(data["game"], None)
if created: if created:
logger.info("Added game %s to %s", game_instance, self) logger.info("Added game %s to %s", game_instance, self)
if "rewards" in data: if data.get("ownerOrganization") and data["ownerOrganization"].get("id"):
for reward in data["rewards"]: owner_instance, created = Owner.objects.update_or_create(twitch_id=data["ownerOrganization"]["id"])
reward_instance, created = Reward.objects.update_or_create(twitch_id=reward["id"]) owner_instance.import_json(data["ownerOrganization"])
reward_instance.import_json(reward, self)
if created: if created:
logger.info("Added reward %s to %s", reward_instance, self) logger.info("Added owner %s to %s", owner_instance, self)
return self
class Reward(models.Model):
"""This from the RewardCampaign."""
# "dc2e9810-4de0-11ef-9ec3-621fb0811846"
twitch_id = models.TextField(primary_key=True)
# When the reward was first added to the database.
created_at = models.DateTimeField(auto_created=True)
# When the reward was last modified.
modified_at = models.DateTimeField(auto_now=True)
# "3 months of Apple TV+"
name = models.TextField(blank=True)
# "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/quests_appletv_q3_2024/apple_200x200.png"
banner_image_url = models.URLField(blank=True)
# "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/quests_appletv_q3_2024/apple_200x200.png"
thumbnail_image_url = models.URLField(blank=True)
# "2024-08-19T19:00:00Z"
earnable_until = models.DateTimeField(null=True)
# ""
redemption_instructions = models.TextField(blank=True)
# "https://tv.apple.com/includes/commerce/redeem/code-entry"
redemption_url = models.URLField(blank=True)
campaign = models.ForeignKey(RewardCampaign, on_delete=models.CASCADE, related_name="rewards", null=True)
class Meta:
ordering: ClassVar[list[str]] = ["-earnable_until"]
def __str__(self) -> str:
"""Return the name of the reward and when it was created."""
return f"{self.name or self.twitch_id} - {self.created_at}"
def import_json(self, data: dict, reward_campaign: RewardCampaign | None) -> Self:
"""Import the data from the Twitch API."""
if wrong_typename(data, "Reward"):
return self
field_mapping: dict[str, str] = {
"name": "name",
"earnableUntil": "earnable_until",
"redemptionInstructions": "redemption_instructions",
"redemptionURL": "redemption_url",
}
updated: int = update_fields(instance=self, data=data, field_mapping=field_mapping)
if updated > 0:
logger.info("Updated %s fields for %s", updated, self)
banner_image_url = data.get("bannerImage", {}).get("image1xURL")
if banner_image_url and banner_image_url != self.banner_image_url:
self.banner_image_url = banner_image_url
self.save()
thumbnail_image_url = data.get("thumbnailImage", {}).get("image1xURL")
if thumbnail_image_url and thumbnail_image_url != self.thumbnail_image_url:
self.thumbnail_image_url = thumbnail_image_url
self.save()
if reward_campaign and reward_campaign != self.campaign:
self.campaign = reward_campaign
self.save()
return self return self

View File

@ -75,6 +75,7 @@ def get_value(data: dict, key: str) -> datetime | str | None:
""" """
data_key: Any | None = data.get(key) data_key: Any | None = data.get(key)
if not data_key: if not data_key:
logger.error("Key %s not found in %s", key, data)
return None return None
# Dates are in the format "2024-08-12T05:59:59.999Z" # Dates are in the format "2024-08-12T05:59:59.999Z"

View File

@ -106,8 +106,10 @@ SERVER_EMAIL: str | None = os.getenv(key="EMAIL_HOST_USER", default=None)
DISCORD_WEBHOOK_URL: str = os.getenv(key="DISCORD_WEBHOOK_URL", default="") DISCORD_WEBHOOK_URL: str = os.getenv(key="DISCORD_WEBHOOK_URL", default="")
# The list of all installed applications that Django knows about. # The list of all installed applications that Django knows about.
# Be sure to add pghistory.admin above the django.contrib.admin, otherwise the custom admin templates won't be used.
INSTALLED_APPS: list[str] = [ INSTALLED_APPS: list[str] = [
"core.apps.CoreConfig", "core.apps.CoreConfig",
"pghistory.admin",
"django.contrib.admin", "django.contrib.admin",
"django.contrib.auth", "django.contrib.auth",
"django.contrib.contenttypes", "django.contrib.contenttypes",
@ -116,6 +118,10 @@ INSTALLED_APPS: list[str] = [
"django.contrib.staticfiles", "django.contrib.staticfiles",
"django.contrib.sites", "django.contrib.sites",
"debug_toolbar", "debug_toolbar",
"pgclone",
"pghistory",
"pgstats",
"pgtrigger",
] ]
# Middleware is a framework of hooks into Django's request/response processing. # Middleware is a framework of hooks into Django's request/response processing.

View File

@ -4,15 +4,36 @@ from debug_toolbar.toolbar import debug_toolbar_urls # type: ignore[import-unty
from django.contrib import admin from django.contrib import admin
from django.urls import URLPattern, URLResolver, path from django.urls import URLPattern, URLResolver, path
from core.views import game_view, games_view, index, reward_campaign_view from core.views import game_view, games_view, index
app_name: str = "core" app_name: str = "core"
# TODO(TheLovinator): Add a 404 page and a 500 page.
# https://docs.djangoproject.com/en/dev/topics/http/views/#customizing-error-views
# TODO(TheLovinator): Add a robots.txt file.
# https://developers.google.com/search/docs/crawling-indexing/robots/intro
# TODO(TheLovinator): Add sitemaps
# https://docs.djangoproject.com/en/dev/ref/contrib/sitemaps/
# TODO(TheLovinator): Add a favicon.
# https://docs.djangoproject.com/en/dev/howto/static-files/#serving-files-in-development
# TODO(TheLovinator): Add funding.json
# https://floss.fund/funding-manifest/
# TODO(TheLovinator): Add a humans.txt file.
# https://humanstxt.org/
# TODO(TheLovinator): Add pghistory context when importing JSON.
# https://django-pghistory.readthedocs.io/en/3.5.0/context/#using-pghistorycontext
# The URL patterns for the core app.
urlpatterns: list[URLPattern | URLResolver] = [ urlpatterns: list[URLPattern | URLResolver] = [
path(route="admin/", view=admin.site.urls), path(route="admin/", view=admin.site.urls),
path(route="", view=index, name="index"), path(route="", view=index, name="index"),
path(route="game/<int:twitch_id>/", view=game_view, name="game"), path(route="game/<int:twitch_id>/", view=game_view, name="game"),
path(route="games/", view=games_view, name="games"), path(route="games/", view=games_view, name="games"),
path(route="reward_campaigns/", view=reward_campaign_view, name="reward_campaigns"),
*debug_toolbar_urls(), *debug_toolbar_urls(),
] ]

View File

@ -8,7 +8,7 @@ from django.http import HttpRequest, HttpResponse
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.utils import timezone from django.utils import timezone
from core.models import Benefit, DropCampaign, Game, RewardCampaign, TimeBasedDrop from core.models import Benefit, DropCampaign, Game, TimeBasedDrop
if TYPE_CHECKING: if TYPE_CHECKING:
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
@ -17,15 +17,6 @@ if TYPE_CHECKING:
logger: logging.Logger = logging.getLogger(__name__) logger: logging.Logger = logging.getLogger(__name__)
def get_reward_campaigns() -> QuerySet[RewardCampaign]:
"""Get the reward campaigns.
Returns:
QuerySet[RewardCampaign]: The reward campaigns.
"""
return RewardCampaign.objects.all().prefetch_related("rewards").order_by("-created_at")
def get_games_with_drops() -> QuerySet[Game]: def get_games_with_drops() -> QuerySet[Game]:
"""Get the games with drops, sorted by when the drop campaigns end. """Get the games with drops, sorted by when the drop campaigns end.
@ -66,7 +57,6 @@ def index(request: HttpRequest) -> HttpResponse:
HttpResponse: The response object HttpResponse: The response object
""" """
try: try:
reward_campaigns: QuerySet[RewardCampaign] = get_reward_campaigns()
games: QuerySet[Game] = get_games_with_drops() games: QuerySet[Game] = get_games_with_drops()
except Exception: except Exception:
@ -74,7 +64,6 @@ def index(request: HttpRequest) -> HttpResponse:
return HttpResponse(status=500) return HttpResponse(status=500)
context: dict[str, Any] = { context: dict[str, Any] = {
"reward_campaigns": reward_campaigns,
"games": games, "games": games,
} }
return TemplateResponse(request, "index.html", context) return TemplateResponse(request, "index.html", context)
@ -125,17 +114,3 @@ def games_view(request: HttpRequest) -> HttpResponse:
context: dict[str, QuerySet[Game] | str] = {"games": games} context: dict[str, QuerySet[Game] | str] = {"games": games}
return TemplateResponse(request=request, template="games.html", context=context) return TemplateResponse(request=request, template="games.html", context=context)
def reward_campaign_view(request: HttpRequest) -> HttpResponse:
"""Render the reward campaign view page.
Args:
request (HttpRequest): The request object.
Returns:
HttpResponse: The response object.
"""
reward_campaigns: QuerySet[RewardCampaign] = RewardCampaign.objects.all()
context: dict[str, QuerySet[RewardCampaign]] = {"reward_campaigns": reward_campaigns}
return TemplateResponse(request=request, template="reward_campaigns.html", context=context)

View File

@ -12,6 +12,10 @@ dependencies = [
"platformdirs", "platformdirs",
"psycopg[binary,pool]", "psycopg[binary,pool]",
"python-dotenv", "python-dotenv",
"django-pghistory",
"django-pgclone",
"django-pgstats",
"django-auto-prefetch",
] ]
# You can install development dependencies with `uv install --dev`. # You can install development dependencies with `uv install --dev`.