From 09b21d4f43062279a63938aed7e93453b9f53b68 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= <tlovinator@gmail.com>
Date: Fri, 2 Aug 2024 05:10:23 +0200
Subject: [PATCH] WIP

---
 core/templates/games.html                     |  41 +-
 core/templates/index.html                     |   2 +-
 core/templates/partials/header.html           |   6 +-
 .../partials/reward_campaigns_toc.html        |  12 -
 core/templates/partials/toc.html              |  19 -
 core/templates/reward_campaigns.html          |   2 +-
 core/urls.py                                  |   6 +-
 core/views.py                                 |  66 ++-
 testboi.py                                    | 380 ++++++++++++++++++
 testboi2.py                                   | 252 ++++++++++++
 .../management/commands/scrape_twitch.py      |  24 ++
 ...annel_frontendgame_frontendorg_and_more.py | 135 +++++++
 twitch_app/models.py                          |  69 +++-
 13 files changed, 940 insertions(+), 74 deletions(-)
 delete mode 100644 core/templates/partials/reward_campaigns_toc.html
 delete mode 100644 core/templates/partials/toc.html
 create mode 100644 testboi.py
 create mode 100644 testboi2.py
 create mode 100644 twitch_app/migrations/0010_frontendchannel_frontendgame_frontendorg_and_more.py

diff --git a/core/templates/games.html b/core/templates/games.html
index d01d075..6972c02 100644
--- a/core/templates/games.html
+++ b/core/templates/games.html
@@ -2,28 +2,33 @@
 {% block content %}
     <div class="container mt-4">
         <div class="row">
-            {% for game in games %}
-                <div class="col-xl-3 col-lg-4 col-md-6 col-sm-12 mb-4">
-                    <div class="card h-100 shadow-sm">
-                        <img src="{{ game.image_url }}"
-                             class="card-img-top"
-                             alt="{{ game.display_name }}">
-                        <div class="card-body d-flex flex-column">
-                            <h5 class="card-title">{{ game.display_name }}</h5>
-                            <div class="mt-auto">
-                                <div class="form-check form-switch">
-                                    <input class="form-check-input" type="checkbox" role="switch" id="new">
-                                    <label class="form-check-label" for="new">Notify when new</label>
-                                </div>
-                                <div class="form-check form-switch">
-                                    <input class="form-check-input" type="checkbox" role="switch" id="live">
-                                    <label class="form-check-label" for="live">Notify when farmable</label>
+            <div class="col-lg-3">{{ toc|safe }}</div>
+            <div class="col-lg-9">
+                <div class="row">
+                    {% for game in games %}
+                        <div class="col-xl-3 col-lg-4 col-md-6 col-sm-12 mb-4">
+                            <div class="card h-100 shadow-sm">
+                                <img src="{{ game.image_url }}"
+                                     class="card-img-top"
+                                     alt="{{ game.display_name }}">
+                                <div class="card-body d-flex flex-column">
+                                    <h5 class="card-title">{{ game.display_name }}</h5>
+                                    <div class="mt-auto">
+                                        <div class="form-check form-switch">
+                                            <input class="form-check-input" type="checkbox" role="switch" id="new">
+                                            <label class="form-check-label" for="new">Notify when new</label>
+                                        </div>
+                                        <div class="form-check form-switch">
+                                            <input class="form-check-input" type="checkbox" role="switch" id="live">
+                                            <label class="form-check-label" for="live">Notify when farmable</label>
+                                        </div>
+                                    </div>
                                 </div>
                             </div>
                         </div>
-                    </div>
+                    {% endfor %}
                 </div>
-            {% endfor %}
+            </div>
         </div>
     </div>
 {% endblock content %}
diff --git a/core/templates/index.html b/core/templates/index.html
index d3008d0..12c0268 100644
--- a/core/templates/index.html
+++ b/core/templates/index.html
@@ -3,7 +3,7 @@
 {% block content %}
     <div class="container mt-4">
         <div class="row">
-            <div class="col-lg-3">{% include "partials/toc.html" %}</div>
+            <div class="col-lg-3">{{ toc|safe }}</div>
             <div class="col-lg-9">
                 {% include "partials/info_box.html" %}
                 {% include "partials/news.html" %}
diff --git a/core/templates/partials/header.html b/core/templates/partials/header.html
index 32ce186..cce3236 100644
--- a/core/templates/partials/header.html
+++ b/core/templates/partials/header.html
@@ -7,15 +7,15 @@
             <li class="nav-item">
                 <a class="nav-link" href='{% url "core:games" %}'>Games</a>
             </li>
+            <li>
+                <a class="nav-link" href='{% url "core:reward_campaigns" %}'>Reward campaigns</a>
+            </li>
             <li class="nav-item">
                 <a class="nav-link" href=''>API</a>
             </li>
             <li class="nav-item d-none d-sm-block">
                 <a class="nav-link" href="https://github.com/sponsors/TheLovinator1">Donate</a>
             </li>
-            <li>
-                <a class="nav-link" href='{% url "core:reward_campaigns" %}'>Reward campaigns</a>
-            </li>
         </ul>
     </nav>
 </header>
diff --git a/core/templates/partials/reward_campaigns_toc.html b/core/templates/partials/reward_campaigns_toc.html
deleted file mode 100644
index 102624d..0000000
--- a/core/templates/partials/reward_campaigns_toc.html
+++ /dev/null
@@ -1,12 +0,0 @@
-<div class="position-sticky d-none d-lg-block toc">
-    <div class="card">
-        <div class="card-body">
-            <div id="toc-list" class="list-group">
-                {% for campaign in reward_campaigns %}
-                <a class="list-group-item list-group-item-action plain-text-item"
-                href="#reward-{{ campaign.id }}">{{ campaign }}</a>
-                {% endfor %}
-            </div>
-        </div>
-    </div>
-</div>
diff --git a/core/templates/partials/toc.html b/core/templates/partials/toc.html
deleted file mode 100644
index 9f50e40..0000000
--- a/core/templates/partials/toc.html
+++ /dev/null
@@ -1,19 +0,0 @@
-<div class="position-sticky d-none d-lg-block toc">
-    <div class="card">
-        <div class="card-body">
-            <div id="toc-list" class="list-group">
-                <a class="list-group-item list-group-item-action plain-text-item"
-                   href="#info-box">Information</a>
-                <a class="list-group-item list-group-item-action plain-text-item"
-                   href="#game-list">Site news</a>
-                {% for org in orgs %}
-                    {{ org }}
-                    {% for game in org.games.all %}
-                        <a class="list-group-item list-group-item-action plain-text-item"
-                           href="#game-{{ game.game_id }}">{{ game.display_name }}</a>
-                    {% endfor %}
-                {% endfor %}
-            </div>
-        </div>
-    </div>
-</div>
diff --git a/core/templates/reward_campaigns.html b/core/templates/reward_campaigns.html
index 0de02c9..ac05b56 100644
--- a/core/templates/reward_campaigns.html
+++ b/core/templates/reward_campaigns.html
@@ -3,7 +3,7 @@
 {% block content %}
     <div class="container mt-4">
         <div class="row">
-            <div class="col-lg-3">{% include "partials/reward_campaigns_toc.html" %}</div>
+            <div class="col-lg-3">{{ toc }}</div>
             <div class="col-lg-9">
                 <h2>Reward Campaigns</h2>
                 <div>
diff --git a/core/urls.py b/core/urls.py
index 44129e5..7fa7302 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -2,7 +2,7 @@ from __future__ import annotations
 
 from django.urls import URLPattern, URLResolver, path
 
-from core.views import GameView, RewardCampaignView, index
+from core.views import game_view, index, reward_campaign_view
 
 app_name: str = "core"
 
@@ -10,12 +10,12 @@ urlpatterns: list[URLPattern | URLResolver] = [
     path(route="", view=index, name="index"),
     path(
         route="games/",
-        view=GameView.as_view(),
+        view=game_view,
         name="games",
     ),
     path(
         route="reward_campaigns/",
-        view=RewardCampaignView.as_view(),
+        view=reward_campaign_view,
         name="reward_campaigns",
     ),
 ]
diff --git a/core/views.py b/core/views.py
index ac236b2..0103e6c 100644
--- a/core/views.py
+++ b/core/views.py
@@ -1,12 +1,13 @@
 from __future__ import annotations
 
 import logging
+from dataclasses import dataclass
 from typing import TYPE_CHECKING
 
 import hishel
 from django.conf import settings
+from django.db.models.manager import BaseManager
 from django.template.response import TemplateResponse
-from django.views.generic import ListView
 
 from core.data import WebhookData
 from twitch_app.models import Game, RewardCampaign
@@ -58,26 +59,59 @@ def get_webhook_data(webhook: str) -> WebhookData:
     )
 
 
+@dataclass
+class TOCItem:
+    """Table of contents item."""
+
+    name: str
+    toc_id: str
+
+
+def build_toc(list_of_things: list[TOCItem]) -> str:
+    """Build the table of contents."""
+    html: str = """
+    <div class="position-sticky d-none d-lg-block toc">
+        <div class="card">
+            <div class="card-body">
+                <div id="toc-list" class="list-group">
+    """
+
+    for item in list_of_things:
+        html += (
+            f'<a class="list-group-item list-group-item-action plain-text-item" href="#{item.toc_id}">{item.name}</a>'
+        )
+    html += """</div></div></div></div>"""
+    return html
+
+
 def index(request: HttpRequest) -> HttpResponse:
     """Render the index page."""
     reward_campaigns: BaseManager[RewardCampaign] = RewardCampaign.objects.all()
 
-    return TemplateResponse(
-        request=request,
-        template="index.html",
-        context={"reward_campaigns": reward_campaigns},
-    )
+    toc: str = build_toc([
+        TOCItem(name="Information", toc_id="#info-box"),
+        TOCItem(name="Games", toc_id="#games"),
+    ])
+
+    context: dict[str, BaseManager[RewardCampaign] | str] = {"reward_campaigns": reward_campaigns, "toc": toc}
+    return TemplateResponse(request=request, template="index.html", context=context)
 
 
-class GameView(ListView):
-    model = Game
-    template_name: str = "games.html"
-    context_object_name: str = "games"
-    paginate_by = 100
+def game_view(request: HttpRequest) -> HttpResponse:
+    """Render the game view page."""
+    games: BaseManager[Game] = Game.objects.all()
+
+    tocs: list[TOCItem] = [
+        TOCItem(name=game.display_name, toc_id=game.slug) for game in games if game.display_name and game.slug
+    ]
+    toc: str = build_toc(tocs)
+
+    context: dict[str, BaseManager[Game] | str] = {"games": games, "toc": toc}
+    return TemplateResponse(request=request, template="games.html", context=context)
 
 
-class RewardCampaignView(ListView):
-    model = RewardCampaign
-    template_name: str = "reward_campaigns.html"
-    context_object_name: str = "reward_campaigns"
-    paginate_by = 100
+def reward_campaign_view(request: HttpRequest) -> HttpResponse:
+    """Render the reward campaign view page."""
+    reward_campaigns: BaseManager[RewardCampaign] = RewardCampaign.objects.all()
+    context: dict[str, BaseManager[RewardCampaign]] = {"reward_campaigns": reward_campaigns}
+    return TemplateResponse(request=request, template="reward_campaigns.html", context=context)
diff --git a/testboi.py b/testboi.py
new file mode 100644
index 0000000..79ca7b6
--- /dev/null
+++ b/testboi.py
@@ -0,0 +1,380 @@
+from datetime import datetime
+from enum import Enum
+from typing import Any
+from uuid import UUID
+
+
+class ChannelTypename(Enum):
+    CHANNEL = "Channel"
+    GAME = "Game"
+    ORGANIZATION = "Organization"
+
+
+class Channel:
+    id: int
+    display_name: str
+    name: str
+    typename: ChannelTypename
+
+    def __init__(self, id: int, display_name: str, name: str, typename: ChannelTypename) -> None:
+        self.id = id
+        self.display_name = display_name
+        self.name = name
+        self.typename = typename
+
+
+class AllowTypename(Enum):
+    DROP_CAMPAIGN_ACL = "DropCampaignACL"
+
+
+class Allow:
+    channels: list[Channel] | None
+    is_enabled: bool
+    typename: AllowTypename
+
+    def __init__(self, channels: list[Channel] | None, is_enabled: bool, typename: AllowTypename) -> None:
+        self.channels = channels
+        self.is_enabled = is_enabled
+        self.typename = typename
+
+
+class SelfTypename(Enum):
+    DROP_CAMPAIGN_SELF_EDGE = "DropCampaignSelfEdge"
+
+
+class Self:
+    is_account_connected: bool
+    typename: SelfTypename
+
+    def __init__(self, is_account_connected: bool, typename: SelfTypename) -> None:
+        self.is_account_connected = is_account_connected
+        self.typename = typename
+
+
+class Game:
+    id: int
+    slug: str
+    display_name: str
+    typename: ChannelTypename
+
+    def __init__(self, id: int, slug: str, display_name: str, typename: ChannelTypename) -> None:
+        self.id = id
+        self.slug = slug
+        self.display_name = display_name
+        self.typename = typename
+
+
+class PurpleOwner:
+    id: UUID | int
+    name: str | None
+    typename: ChannelTypename
+    display_name: str | None
+    slug: str | None
+
+    def __init__(
+        self,
+        id: UUID | int,
+        name: str | None,
+        typename: ChannelTypename,
+        display_name: str | None,
+        slug: str | None,
+    ) -> None:
+        self.id = id
+        self.name = name
+        self.typename = typename
+        self.display_name = display_name
+        self.slug = slug
+
+
+class Status(Enum):
+    ACTIVE = "ACTIVE"
+    EXPIRED = "EXPIRED"
+
+
+class GameClass:
+    id: UUID | int
+    name: str
+    typename: ChannelTypename
+
+    def __init__(self, id: UUID | int, name: str, typename: ChannelTypename) -> None:
+        self.id = id
+        self.name = name
+        self.typename = typename
+
+
+class BenefitTypename(Enum):
+    DROP_BENEFIT = "DropBenefit"
+
+
+class Benefit:
+    id: str
+    created_at: datetime
+    entitlement_limit: int
+    game: GameClass
+    image_asset_url: str
+    is_ios_available: bool
+    name: str
+    owner_organization: GameClass
+    typename: BenefitTypename
+
+    def __init__(
+        self,
+        id: str,
+        created_at: datetime,
+        entitlement_limit: int,
+        game: GameClass,
+        image_asset_url: str,
+        is_ios_available: bool,
+        name: str,
+        owner_organization: GameClass,
+        typename: BenefitTypename,
+    ) -> None:
+        self.id = id
+        self.created_at = created_at
+        self.entitlement_limit = entitlement_limit
+        self.game = game
+        self.image_asset_url = image_asset_url
+        self.is_ios_available = is_ios_available
+        self.name = name
+        self.owner_organization = owner_organization
+        self.typename = typename
+
+
+class BenefitEdgeTypename(Enum):
+    DROP_BENEFIT_EDGE = "DropBenefitEdge"
+
+
+class BenefitEdge:
+    benefit: Benefit
+    entitlement_limit: int
+    typename: BenefitEdgeTypename
+
+    def __init__(self, benefit: Benefit, entitlement_limit: int, typename: BenefitEdgeTypename) -> None:
+        self.benefit = benefit
+        self.entitlement_limit = entitlement_limit
+        self.typename = typename
+
+
+class TimeBasedDropTypename(Enum):
+    TIME_BASED_DROP = "TimeBasedDrop"
+
+
+class TimeBasedDrop:
+    id: UUID
+    required_subs: int
+    benefit_edges: list[BenefitEdge]
+    end_at: datetime
+    name: str
+    precondition_drops: None
+    required_minutes_watched: int
+    start_at: datetime
+    typename: TimeBasedDropTypename
+
+    def __init__(
+        self,
+        id: UUID,
+        required_subs: int,
+        benefit_edges: list[BenefitEdge],
+        end_at: datetime,
+        name: str,
+        precondition_drops: None,
+        required_minutes_watched: int,
+        start_at: datetime,
+        typename: TimeBasedDropTypename,
+    ) -> None:
+        self.id = id
+        self.required_subs = required_subs
+        self.benefit_edges = benefit_edges
+        self.end_at = end_at
+        self.name = name
+        self.precondition_drops = precondition_drops
+        self.required_minutes_watched = required_minutes_watched
+        self.start_at = start_at
+        self.typename = typename
+
+
+class DropCampaignTypename(Enum):
+    DROP_CAMPAIGN = "DropCampaign"
+
+
+class PurpleDropCampaign:
+    id: UUID
+    drop_campaign_self: Self
+    allow: Allow
+    account_link_url: str
+    description: str
+    details_url: str
+    end_at: datetime
+    event_based_drops: list[Any]
+    game: Game
+    image_url: str
+    name: str
+    owner: PurpleOwner
+    start_at: datetime
+    status: Status
+    time_based_drops: list[TimeBasedDrop]
+    typename: DropCampaignTypename
+
+    def __init__(
+        self,
+        id: UUID,
+        drop_campaign_self: Self,
+        allow: Allow,
+        account_link_url: str,
+        description: str,
+        details_url: str,
+        end_at: datetime,
+        event_based_drops: list[Any],
+        game: Game,
+        image_url: str,
+        name: str,
+        owner: PurpleOwner,
+        start_at: datetime,
+        status: Status,
+        time_based_drops: list[TimeBasedDrop],
+        typename: DropCampaignTypename,
+    ) -> None:
+        self.id = id
+        self.drop_campaign_self = drop_campaign_self
+        self.allow = allow
+        self.account_link_url = account_link_url
+        self.description = description
+        self.details_url = details_url
+        self.end_at = end_at
+        self.event_based_drops = event_based_drops
+        self.game = game
+        self.image_url = image_url
+        self.name = name
+        self.owner = owner
+        self.start_at = start_at
+        self.status = status
+        self.time_based_drops = time_based_drops
+        self.typename = typename
+
+
+class UserTypename(Enum):
+    USER = "User"
+
+
+class PurpleUser:
+    id: int
+    drop_campaign: PurpleDropCampaign
+    typename: UserTypename
+
+    def __init__(self, id: int, drop_campaign: PurpleDropCampaign, typename: UserTypename) -> None:
+        self.id = id
+        self.drop_campaign = drop_campaign
+        self.typename = typename
+
+
+class DropCampaign100_Data:
+    user: PurpleUser
+
+    def __init__(self, user: PurpleUser) -> None:
+        self.user = user
+
+
+class OperationName(Enum):
+    DROP_CAMPAIGN_DETAILS = "DropCampaignDetails"
+
+
+class Extensions:
+    duration_milliseconds: int
+    operation_name: OperationName
+    request_id: str
+
+    def __init__(self, duration_milliseconds: int, operation_name: OperationName, request_id: str) -> None:
+        self.duration_milliseconds = duration_milliseconds
+        self.operation_name = operation_name
+        self.request_id = request_id
+
+
+class DropCampaign99:
+    data: DropCampaign100_Data
+    extensions: Extensions
+
+    def __init__(self, data: DropCampaign100_Data, extensions: Extensions) -> None:
+        self.data = data
+        self.extensions = extensions
+
+
+class FluffyDropCampaign:
+    id: UUID
+    drop_campaign_self: Self
+    allow: Allow
+    account_link_url: str
+    description: str
+    details_url: str
+    end_at: datetime
+    event_based_drops: list[Any]
+    game: Game
+    image_url: str
+    name: str
+    owner: GameClass
+    start_at: datetime
+    status: Status
+    time_based_drops: list[TimeBasedDrop]
+    typename: DropCampaignTypename
+
+    def __init__(
+        self,
+        id: UUID,
+        drop_campaign_self: Self,
+        allow: Allow,
+        account_link_url: str,
+        description: str,
+        details_url: str,
+        end_at: datetime,
+        event_based_drops: list[Any],
+        game: Game,
+        image_url: str,
+        name: str,
+        owner: GameClass,
+        start_at: datetime,
+        status: Status,
+        time_based_drops: list[TimeBasedDrop],
+        typename: DropCampaignTypename,
+    ) -> None:
+        self.id = id
+        self.drop_campaign_self = drop_campaign_self
+        self.allow = allow
+        self.account_link_url = account_link_url
+        self.description = description
+        self.details_url = details_url
+        self.end_at = end_at
+        self.event_based_drops = event_based_drops
+        self.game = game
+        self.image_url = image_url
+        self.name = name
+        self.owner = owner
+        self.start_at = start_at
+        self.status = status
+        self.time_based_drops = time_based_drops
+        self.typename = typename
+
+
+class FluffyUser:
+    id: int
+    drop_campaign: FluffyDropCampaign
+    typename: UserTypename
+
+    def __init__(self, id: int, drop_campaign: FluffyDropCampaign, typename: UserTypename) -> None:
+        self.id = id
+        self.drop_campaign = drop_campaign
+        self.typename = typename
+
+
+class DropCampaign109_Data:
+    user: FluffyUser
+
+    def __init__(self, user: FluffyUser) -> None:
+        self.user = user
+
+
+class DropCampaign149:
+    data: DropCampaign109_Data
+    extensions: Extensions
+
+    def __init__(self, data: DropCampaign109_Data, extensions: Extensions) -> None:
+        self.data = data
+        self.extensions = extensions
diff --git a/testboi2.py b/testboi2.py
new file mode 100644
index 0000000..a8c38ce
--- /dev/null
+++ b/testboi2.py
@@ -0,0 +1,252 @@
+from datetime import datetime
+from enum import Enum
+from uuid import UUID
+
+
+class SelfTypename(Enum):
+    DROP_CAMPAIGN_SELF_EDGE = "DropCampaignSelfEdge"
+
+
+class Self:
+    is_account_connected: bool
+    typename: SelfTypename
+
+    def __init__(self, is_account_connected: bool, typename: SelfTypename) -> None:
+        self.is_account_connected = is_account_connected
+        self.typename = typename
+
+
+class GameTypename(Enum):
+    GAME = "Game"
+
+
+class Game:
+    id: int
+    display_name: str
+    box_art_url: str
+    typename: GameTypename
+
+    def __init__(self, id: int, display_name: str, box_art_url: str, typename: GameTypename) -> None:
+        self.id = id
+        self.display_name = display_name
+        self.box_art_url = box_art_url
+        self.typename = typename
+
+
+class OwnerTypename(Enum):
+    ORGANIZATION = "Organization"
+
+
+class Owner:
+    id: UUID
+    name: str
+    typename: OwnerTypename
+
+    def __init__(self, id: UUID, name: str, typename: OwnerTypename) -> None:
+        self.id = id
+        self.name = name
+        self.typename = typename
+
+
+class Status(Enum):
+    ACTIVE = "ACTIVE"
+    EXPIRED = "EXPIRED"
+
+
+class DropCampaignTypename(Enum):
+    DROP_CAMPAIGN = "DropCampaign"
+
+
+class DropCampaign:
+    id: UUID
+    name: str
+    owner: Owner
+    game: Game
+    status: Status
+    start_at: datetime
+    end_at: datetime
+    details_url: str
+    account_link_url: str
+    drop_campaign_self: Self
+    typename: DropCampaignTypename
+
+    def __init__(
+        self,
+        id: UUID,
+        name: str,
+        owner: Owner,
+        game: Game,
+        status: Status,
+        start_at: datetime,
+        end_at: datetime,
+        details_url: str,
+        account_link_url: str,
+        drop_campaign_self: Self,
+        typename: DropCampaignTypename,
+    ) -> None:
+        self.id = id
+        self.name = name
+        self.owner = owner
+        self.game = game
+        self.status = status
+        self.start_at = start_at
+        self.end_at = end_at
+        self.details_url = details_url
+        self.account_link_url = account_link_url
+        self.drop_campaign_self = drop_campaign_self
+        self.typename = typename
+
+
+class CurrentUser:
+    id: int
+    login: str
+    drop_campaigns: list[DropCampaign]
+    typename: str
+
+    def __init__(self, id: int, login: str, drop_campaigns: list[DropCampaign], typename: str) -> None:
+        self.id = id
+        self.login = login
+        self.drop_campaigns = drop_campaigns
+        self.typename = typename
+
+
+class Image:
+    image1_x_url: str
+    typename: str
+
+    def __init__(self, image1_x_url: str, typename: str) -> None:
+        self.image1_x_url = image1_x_url
+        self.typename = typename
+
+
+class Reward:
+    id: UUID
+    name: str
+    banner_image: Image
+    thumbnail_image: Image
+    earnable_until: datetime
+    redemption_instructions: str
+    redemption_url: str
+    typename: str
+
+    def __init__(
+        self,
+        id: UUID,
+        name: str,
+        banner_image: Image,
+        thumbnail_image: Image,
+        earnable_until: datetime,
+        redemption_instructions: str,
+        redemption_url: str,
+        typename: str,
+    ) -> None:
+        self.id = id
+        self.name = name
+        self.banner_image = banner_image
+        self.thumbnail_image = thumbnail_image
+        self.earnable_until = earnable_until
+        self.redemption_instructions = redemption_instructions
+        self.redemption_url = redemption_url
+        self.typename = typename
+
+
+class UnlockRequirements:
+    subs_goal: int
+    minute_watched_goal: int
+    typename: str
+
+    def __init__(self, subs_goal: int, minute_watched_goal: int, typename: str) -> None:
+        self.subs_goal = subs_goal
+        self.minute_watched_goal = minute_watched_goal
+        self.typename = typename
+
+
+class RewardCampaignsAvailableToUser:
+    id: UUID
+    name: str
+    brand: str
+    starts_at: datetime
+    ends_at: datetime
+    status: str
+    summary: str
+    instructions: str
+    external_url: str
+    reward_value_url_param: str
+    about_url: str
+    is_sitewide: bool
+    game: None
+    unlock_requirements: UnlockRequirements
+    image: Image
+    rewards: list[Reward]
+    typename: str
+
+    def __init__(
+        self,
+        id: UUID,
+        name: str,
+        brand: str,
+        starts_at: datetime,
+        ends_at: datetime,
+        status: str,
+        summary: str,
+        instructions: str,
+        external_url: str,
+        reward_value_url_param: str,
+        about_url: str,
+        is_sitewide: bool,
+        game: None,
+        unlock_requirements: UnlockRequirements,
+        image: Image,
+        rewards: list[Reward],
+        typename: str,
+    ) -> None:
+        self.id = id
+        self.name = name
+        self.brand = brand
+        self.starts_at = starts_at
+        self.ends_at = ends_at
+        self.status = status
+        self.summary = summary
+        self.instructions = instructions
+        self.external_url = external_url
+        self.reward_value_url_param = reward_value_url_param
+        self.about_url = about_url
+        self.is_sitewide = is_sitewide
+        self.game = game
+        self.unlock_requirements = unlock_requirements
+        self.image = image
+        self.rewards = rewards
+        self.typename = typename
+
+
+class Data:
+    current_user: CurrentUser
+    reward_campaigns_available_to_user: list[RewardCampaignsAvailableToUser]
+
+    def __init__(
+        self,
+        current_user: CurrentUser,
+        reward_campaigns_available_to_user: list[RewardCampaignsAvailableToUser],
+    ) -> None:
+        self.current_user = current_user
+        self.reward_campaigns_available_to_user = reward_campaigns_available_to_user
+
+
+class Extensions:
+    duration_milliseconds: int
+    operation_name: str
+    request_id: str
+
+    def __init__(self, duration_milliseconds: int, operation_name: str, request_id: str) -> None:
+        self.duration_milliseconds = duration_milliseconds
+        self.operation_name = operation_name
+        self.request_id = request_id
+
+
+class RewardCampaign11:
+    data: Data
+    extensions: Extensions
+
+    def __init__(self, data: Data, extensions: Extensions) -> None:
+        self.data = data
+        self.extensions = extensions
diff --git a/twitch_app/management/commands/scrape_twitch.py b/twitch_app/management/commands/scrape_twitch.py
index a7a44a2..75a578b 100644
--- a/twitch_app/management/commands/scrape_twitch.py
+++ b/twitch_app/management/commands/scrape_twitch.py
@@ -28,6 +28,7 @@ from twitch_app.models import (
 
 if TYPE_CHECKING:
     from playwright.async_api._generated import BrowserContext, Page
+import json
 
 # Where to store the Chrome profile
 data_dir = Path(
@@ -66,6 +67,7 @@ async def add_or_get_game(json_data: dict, name: str) -> tuple[Game | None, bool
             "slug": json_data.get("slug"),
             "display_name": json_data.get("displayName"),
             "typename": json_data.get("__typename"),
+            "box_art_url": json_data.get("boxArtURL"),  # Only for RewardCampaigns
         },
     )
 
@@ -204,6 +206,9 @@ async def add_or_get_drop_campaign(
         logger.warning("No drop campaign data found")
         return None, False
 
+    if drop_campaign_data.get("__typename") != "Game":
+        logger.error("__typename is not 'Game' for %s", drop_campaign_data.get("name", "Unknown Drop Campaign"))
+
     drop_campaign, _ = await DropCampaign.objects.aupdate_or_create(
         id=drop_campaign_data["id"],
         defaults={
@@ -546,16 +551,35 @@ class Command(BaseCommand):
                 continue
 
             if "rewardCampaignsAvailableToUser" in campaign["data"]:
+                # Save to folder named "reward_campaigns"
+                dir_name: Path = Path("reward_campaigns")
+                dir_name.mkdir(parents=True, exist_ok=True)
+                with open(file=Path(dir_name / f"reward_campaign_{num}.json"), mode="w", encoding="utf-8") as f:
+                    json.dump(campaign, f, indent=4)
+
                 await add_reward_campaign(campaign)
 
             if "dropCampaign" in campaign.get("data", {}).get("user", {}):
                 if not campaign["data"]["user"]["dropCampaign"]:
                     logger.warning("No drop campaign found")
                     continue
+
+                # Save to folder named "drop_campaign"
+                dir_name: Path = Path("drop_campaign")
+                dir_name.mkdir(parents=True, exist_ok=True)
+                with open(file=Path(dir_name / f"drop_campaign_{num}.json"), mode="w", encoding="utf-8") as f:
+                    json.dump(campaign, f, indent=4)
+
                 await add_drop_campaign(campaign)
 
             if "dropCampaigns" in campaign.get("data", {}).get("user", {}):
                 for drop_campaign in campaign["data"]["user"]["dropCampaigns"]:
+                    # Save to folder named "drop_campaigns"
+                    dir_name: Path = Path("drop_campaigns")
+                    dir_name.mkdir(parents=True, exist_ok=True)
+                    with open(file=Path(dir_name / f"drop_campaign_{num}.json"), mode="w", encoding="utf-8") as f:
+                        json.dump(drop_campaign, f, indent=4)
+
                     await add_drop_campaign(drop_campaign)
 
         return json_data
diff --git a/twitch_app/migrations/0010_frontendchannel_frontendgame_frontendorg_and_more.py b/twitch_app/migrations/0010_frontendchannel_frontendgame_frontendorg_and_more.py
new file mode 100644
index 0000000..e403de8
--- /dev/null
+++ b/twitch_app/migrations/0010_frontendchannel_frontendgame_frontendorg_and_more.py
@@ -0,0 +1,135 @@
+# Generated by Django 5.1rc1 on 2024-08-02 01:20
+
+import django.db.models.deletion
+import django.db.models.manager
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("twitch_app", "0009_alter_benefit_entitlement_limit_and_more"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="FrontEndChannel",
+            fields=[
+                ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+                ("name", models.TextField(blank=True, null=True)),
+                ("twitch_url", models.URLField(blank=True, null=True)),
+                ("live", models.BooleanField(default=False)),
+            ],
+            options={
+                "abstract": False,
+                "base_manager_name": "prefetch_manager",
+            },
+            managers=[
+                ("objects", django.db.models.manager.Manager()),
+                ("prefetch_manager", django.db.models.manager.Manager()),
+            ],
+        ),
+        migrations.CreateModel(
+            name="FrontEndGame",
+            fields=[
+                ("twitch_id", models.TextField(primary_key=True, serialize=False)),
+                ("game_url", models.URLField(blank=True, null=True)),
+                ("display_name", models.TextField(blank=True, null=True)),
+            ],
+            options={
+                "abstract": False,
+                "base_manager_name": "prefetch_manager",
+            },
+            managers=[
+                ("objects", django.db.models.manager.Manager()),
+                ("prefetch_manager", django.db.models.manager.Manager()),
+            ],
+        ),
+        migrations.CreateModel(
+            name="FrontEndOrg",
+            fields=[
+                ("id", models.TextField(primary_key=True, serialize=False)),
+                ("name", models.TextField(blank=True, null=True)),
+                ("url", models.TextField(blank=True, null=True)),
+            ],
+            options={
+                "abstract": False,
+                "base_manager_name": "prefetch_manager",
+            },
+            managers=[
+                ("objects", django.db.models.manager.Manager()),
+                ("prefetch_manager", django.db.models.manager.Manager()),
+            ],
+        ),
+        migrations.AddField(
+            model_name="game",
+            name="box_art_url",
+            field=models.URLField(blank=True, null=True),
+        ),
+        migrations.CreateModel(
+            name="FrontEndDropCampaign",
+            fields=[
+                ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+                ("account_link_url", models.URLField(blank=True, null=True)),
+                ("about_url", models.URLField(blank=True, null=True)),
+                ("ends_at", models.DateTimeField(null=True)),
+                ("starts_at", models.DateTimeField(null=True)),
+                ("channels", models.ManyToManyField(related_name="drop_campaigns", to="twitch_app.frontendchannel")),
+                (
+                    "game",
+                    models.ForeignKey(
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="drop_campaigns",
+                        to="twitch_app.frontendgame",
+                    ),
+                ),
+            ],
+            options={
+                "abstract": False,
+                "base_manager_name": "prefetch_manager",
+            },
+            managers=[
+                ("objects", django.db.models.manager.Manager()),
+                ("prefetch_manager", django.db.models.manager.Manager()),
+            ],
+        ),
+        migrations.CreateModel(
+            name="FrontEndDrop",
+            fields=[
+                ("id", models.TextField(primary_key=True, serialize=False)),
+                ("created_at", models.DateTimeField(null=True)),
+                ("name", models.TextField(blank=True, null=True)),
+                ("image_url", models.URLField(blank=True, null=True)),
+                ("limit", models.PositiveBigIntegerField(null=True)),
+                ("is_ios_available", models.BooleanField(null=True)),
+                ("minutes_watched", models.PositiveBigIntegerField(null=True)),
+                (
+                    "drop_campaign",
+                    models.ForeignKey(
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="drops",
+                        to="twitch_app.frontenddropcampaign",
+                    ),
+                ),
+            ],
+            options={
+                "abstract": False,
+                "base_manager_name": "prefetch_manager",
+            },
+            managers=[
+                ("objects", django.db.models.manager.Manager()),
+                ("prefetch_manager", django.db.models.manager.Manager()),
+            ],
+        ),
+        migrations.AddField(
+            model_name="frontendgame",
+            name="org",
+            field=models.ForeignKey(
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="games",
+                to="twitch_app.frontendorg",
+            ),
+        ),
+    ]
diff --git a/twitch_app/models.py b/twitch_app/models.py
index 9e8b330..92daf42 100644
--- a/twitch_app/models.py
+++ b/twitch_app/models.py
@@ -1,3 +1,5 @@
+import typing
+
 import auto_prefetch
 from django.db import models
 
@@ -25,6 +27,7 @@ class Game(auto_prefetch.Model):
     id = models.AutoField(primary_key=True)
     slug = models.TextField(null=True, blank=True)
     display_name = models.TextField(null=True, blank=True)
+    box_art_url = models.URLField(null=True, blank=True)
     typename = models.TextField(null=True, blank=True)
 
     def __str__(self) -> str:
@@ -502,6 +505,11 @@ class DropCampaign(auto_prefetch.Model):
         typename (str): The type name of the object, typically "DropCampaign".
     """
 
+    STATUS_CHOICES: typing.ClassVar[list[tuple[str, str]]] = [
+        ("ACTIVE", "Active"),
+        ("EXPIRED", "Expired"),
+    ]
+
     id = models.TextField(primary_key=True)
     allow = auto_prefetch.ForeignKey(Allow, on_delete=models.CASCADE, related_name="drop_campaigns", null=True)
     account_link_url = models.URLField(null=True, blank=True)
@@ -514,9 +522,68 @@ class DropCampaign(auto_prefetch.Model):
     name = models.TextField(null=True, blank=True)
     owner = auto_prefetch.ForeignKey(Owner, on_delete=models.CASCADE, related_name="drop_campaigns", null=True)
     starts_at = models.DateTimeField(null=True)
-    status = models.TextField(null=True, blank=True)
+    status = models.TextField(choices=STATUS_CHOICES, null=True, blank=True)
     time_based_drops = models.ManyToManyField(TimeBasedDrop, related_name="drop_campaigns")
     typename = models.TextField(null=True, blank=True)
 
     def __str__(self) -> str:
         return self.name or "Unknown"
+
+
+class FrontEndChannel(auto_prefetch.Model):
+    """This is the channel we will see on the front end."""
+
+    name = models.TextField(null=True, blank=True)
+    twitch_url = models.URLField(null=True, blank=True)
+    live = models.BooleanField(default=False)
+
+
+class FrontEndOrg(auto_prefetch.Model):
+    """Drops are group by organization -> by game -> by drop campaign."""
+
+    id = models.TextField(primary_key=True)
+    name = models.TextField(null=True, blank=True)
+    url = models.TextField(null=True, blank=True)
+
+
+class FrontEndGame(auto_prefetch.Model):
+    """This is the game we will see on the front end."""
+
+    twitch_id = models.TextField(primary_key=True)
+    game_url = models.URLField(null=True, blank=True)
+    display_name = models.TextField(null=True, blank=True)
+
+    org = models.ForeignKey(FrontEndOrg, on_delete=models.CASCADE, related_name="games", null=True)
+
+    def __str__(self) -> str:
+        return self.display_name or "Unknown"
+
+
+class FrontEndDropCampaign(auto_prefetch.Model):
+    """This is the drop campaign we will see on the front end."""
+
+    account_link_url = models.URLField(null=True, blank=True)
+    about_url = models.URLField(null=True, blank=True)
+
+    ends_at = models.DateTimeField(null=True)
+    starts_at = models.DateTimeField(null=True)
+
+    game = models.ForeignKey(FrontEndGame, on_delete=models.CASCADE, related_name="drop_campaigns", null=True)
+
+    channels = models.ManyToManyField(FrontEndChannel, related_name="drop_campaigns")
+
+
+class FrontEndDrop(auto_prefetch.Model):
+    """This is the drop we will see on the front end."""
+
+    id = models.TextField(primary_key=True)
+    created_at = models.DateTimeField(null=True)
+
+    name = models.TextField(null=True, blank=True)
+    image_url = models.URLField(null=True, blank=True)
+
+    drop_campaign = models.ForeignKey(FrontEndDropCampaign, on_delete=models.CASCADE, related_name="drops", null=True)
+
+    limit = models.PositiveBigIntegerField(null=True)
+    is_ios_available = models.BooleanField(null=True)
+    minutes_watched = models.PositiveBigIntegerField(null=True)