From d4d8567ef83f421ba2c735654709883e9d4cc54f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= <tlovinator@gmail.com>
Date: Thu, 15 Aug 2024 03:18:29 +0200
Subject: [PATCH] Start subscription

---
 core/admin.py                                 |   2 +-
 core/management/commands/scrape_twitch.py     |   3 +-
 core/migrations/0001_initial.py               | 322 ++++++++++++++++++
 ..._options_alter_channel_options_and_more.py | 202 -----------
 ..._name_alter_channel_twitch_url_and_more.py |  37 --
 core/{models/twitch.py => models.py}          |  62 ++++
 core/models/__init__.py                       |   0
 core/settings.py                              |   2 +-
 core/signals.py                               | 125 +++++++
 core/templates/index.html                     |   6 +-
 core/views.py                                 |   2 +-
 11 files changed, 518 insertions(+), 245 deletions(-)
 create mode 100644 core/migrations/0001_initial.py
 delete mode 100644 core/migrations/0001_squashed_0003_alter_benefit_options_alter_channel_options_and_more.py
 delete mode 100644 core/migrations/0004_alter_channel_display_name_alter_channel_twitch_url_and_more.py
 rename core/{models/twitch.py => models.py} (79%)
 delete mode 100644 core/models/__init__.py
 create mode 100644 core/signals.py

diff --git a/core/admin.py b/core/admin.py
index 605bd55..53449f7 100644
--- a/core/admin.py
+++ b/core/admin.py
@@ -1,6 +1,6 @@
 from django.contrib import admin
 
-from .models.twitch import Benefit, Channel, DropCampaign, Game, Owner, Reward, RewardCampaign, TimeBasedDrop
+from .models import Benefit, Channel, DropCampaign, Game, Owner, Reward, RewardCampaign, TimeBasedDrop
 
 admin.site.register(Game)
 admin.site.register(Owner)
diff --git a/core/management/commands/scrape_twitch.py b/core/management/commands/scrape_twitch.py
index 8ec9431..92ef0b3 100644
--- a/core/management/commands/scrape_twitch.py
+++ b/core/management/commands/scrape_twitch.py
@@ -10,7 +10,7 @@ from platformdirs import user_data_dir
 from playwright.async_api import Playwright, async_playwright
 from playwright.async_api._generated import Response
 
-from core.models.twitch import Benefit, Channel, DropCampaign, Game, Owner, Reward, RewardCampaign, TimeBasedDrop
+from core.models import Benefit, Channel, DropCampaign, Game, Owner, Reward, RewardCampaign, TimeBasedDrop
 
 if TYPE_CHECKING:
     from playwright.async_api._generated import BrowserContext, Page
@@ -74,6 +74,7 @@ async def add_reward_campaign(campaign: dict | None) -> None:  # noqa: C901
     Args:
         campaign (dict): The reward campaign to add.
     """
+    # sourcery skip: low-code-quality
     if not campaign:
         return
 
diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py
new file mode 100644
index 0000000..bd4b516
--- /dev/null
+++ b/core/migrations/0001_initial.py
@@ -0,0 +1,322 @@
+# Generated by Django 5.1 on 2024-08-15 00:28
+
+import django.contrib.auth.models
+import django.contrib.auth.validators
+import django.db.models.deletion
+import django.utils.timezone
+from django.conf import settings
+from django.db import migrations, models
+from django.db.migrations.operations.base import Operation
+
+
+class Migration(migrations.Migration):
+    initial = True
+
+    dependencies: list[tuple[str, str]] = [
+        ("auth", "0012_alter_user_first_name_max_length"),
+    ]
+
+    operations: list[Operation] = [
+        migrations.CreateModel(
+            name="DropCampaign",
+            fields=[
+                ("created_at", models.DateTimeField(auto_created=True, null=True)),
+                ("id", models.TextField(primary_key=True, serialize=False)),
+                ("modified_at", models.DateTimeField(auto_now=True, null=True)),
+                ("account_link_url", models.URLField(null=True)),
+                ("description", models.TextField(null=True)),
+                ("details_url", models.URLField(null=True)),
+                ("ends_at", models.DateTimeField(null=True)),
+                ("starts_at", models.DateTimeField(null=True)),
+                ("image_url", models.URLField(null=True)),
+                ("name", models.TextField(null=True)),
+                ("status", models.TextField(null=True)),
+            ],
+        ),
+        migrations.CreateModel(
+            name="Game",
+            fields=[
+                ("twitch_id", models.TextField(primary_key=True, serialize=False)),
+                ("game_url", models.URLField(default="https://www.twitch.tv/", null=True)),
+                ("name", models.TextField(default="Game name unknown", null=True)),
+                (
+                    "box_art_url",
+                    models.URLField(default="https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg", null=True),
+                ),
+                ("slug", models.TextField(null=True)),
+            ],
+        ),
+        migrations.CreateModel(
+            name="Owner",
+            fields=[
+                ("id", models.TextField(primary_key=True, serialize=False)),
+                ("name", models.TextField(null=True)),
+            ],
+        ),
+        migrations.CreateModel(
+            name="User",
+            fields=[
+                ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+                ("password", models.CharField(max_length=128, verbose_name="password")),
+                ("last_login", models.DateTimeField(blank=True, null=True, verbose_name="last login")),
+                (
+                    "is_superuser",
+                    models.BooleanField(
+                        default=False,
+                        help_text="Designates that this user has all permissions without explicitly assigning them.",
+                        verbose_name="superuser status",
+                    ),
+                ),
+                (
+                    "username",
+                    models.CharField(
+                        error_messages={"unique": "A user with that username already exists."},
+                        help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
+                        max_length=150,
+                        unique=True,
+                        validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
+                        verbose_name="username",
+                    ),
+                ),
+                ("first_name", models.CharField(blank=True, max_length=150, verbose_name="first name")),
+                ("last_name", models.CharField(blank=True, max_length=150, verbose_name="last name")),
+                ("email", models.EmailField(blank=True, max_length=254, verbose_name="email address")),
+                (
+                    "is_staff",
+                    models.BooleanField(
+                        default=False,
+                        help_text="Designates whether the user can log into this admin site.",
+                        verbose_name="staff status",
+                    ),
+                ),
+                (
+                    "is_active",
+                    models.BooleanField(
+                        default=True,
+                        help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",  # noqa: E501
+                        verbose_name="active",
+                    ),
+                ),
+                ("date_joined", models.DateTimeField(default=django.utils.timezone.now, verbose_name="date joined")),
+                ("subscribe_to_news", models.BooleanField(default=False, help_text="Subscribe to news")),
+                ("subscribe_to_new_games", models.BooleanField(default=False, help_text="Subscribe to new games")),
+                (
+                    "groups",
+                    models.ManyToManyField(
+                        blank=True,
+                        help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",  # noqa: E501
+                        related_name="user_set",
+                        related_query_name="user",
+                        to="auth.group",
+                        verbose_name="groups",
+                    ),
+                ),
+                (
+                    "user_permissions",
+                    models.ManyToManyField(
+                        blank=True,
+                        help_text="Specific permissions for this user.",
+                        related_name="user_set",
+                        related_query_name="user",
+                        to="auth.permission",
+                        verbose_name="user permissions",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "user",
+                "verbose_name_plural": "users",
+                "abstract": False,
+            },
+            managers=[
+                ("objects", django.contrib.auth.models.UserManager()),
+            ],
+        ),
+        migrations.CreateModel(
+            name="DiscordWebhook",
+            fields=[
+                ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+                ("url", models.URLField()),
+                ("name", models.CharField(max_length=255)),
+                (
+                    "user",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="discord_webhooks",
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+            ],
+        ),
+        migrations.CreateModel(
+            name="Channel",
+            fields=[
+                ("twitch_id", models.TextField(primary_key=True, serialize=False)),
+                ("display_name", models.TextField(default="Channel name unknown", null=True)),
+                ("name", models.TextField(null=True)),
+                ("twitch_url", models.URLField(default="https://www.twitch.tv/", null=True)),
+                ("live", models.BooleanField(default=False)),
+                ("drop_campaigns", models.ManyToManyField(related_name="channels", to="core.dropcampaign")),
+            ],
+        ),
+        migrations.AddField(
+            model_name="dropcampaign",
+            name="game",
+            field=models.ForeignKey(
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="drop_campaigns",
+                to="core.game",
+            ),
+        ),
+        migrations.CreateModel(
+            name="GameSubscription",
+            fields=[
+                ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+                ("game", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="core.game")),
+                ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+                ("webhook", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="core.discordwebhook")),
+            ],
+            options={
+                "unique_together": {("user", "game")},
+            },
+        ),
+        migrations.AddField(
+            model_name="user",
+            name="subscribed_games",
+            field=models.ManyToManyField(
+                blank=True,
+                related_name="subscribed_users",
+                through="core.GameSubscription",
+                to="core.game",
+            ),
+        ),
+        migrations.AddField(
+            model_name="game",
+            name="org",
+            field=models.ForeignKey(
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="games",
+                to="core.owner",
+            ),
+        ),
+        migrations.CreateModel(
+            name="OwnerSubscription",
+            fields=[
+                ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+                ("owner", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="core.owner")),
+                ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+                ("webhook", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="core.discordwebhook")),
+            ],
+            options={
+                "unique_together": {("user", "owner")},
+            },
+        ),
+        migrations.AddField(
+            model_name="user",
+            name="subscribed_owners",
+            field=models.ManyToManyField(
+                blank=True,
+                related_name="subscribed_users",
+                through="core.OwnerSubscription",
+                to="core.owner",
+            ),
+        ),
+        migrations.CreateModel(
+            name="RewardCampaign",
+            fields=[
+                ("created_at", models.DateTimeField(auto_created=True, null=True)),
+                ("id", models.TextField(primary_key=True, serialize=False)),
+                ("modified_at", models.DateTimeField(auto_now=True, null=True)),
+                ("name", models.TextField(null=True)),
+                ("brand", models.TextField(null=True)),
+                ("starts_at", models.DateTimeField(null=True)),
+                ("ends_at", models.DateTimeField(null=True)),
+                ("status", models.TextField(null=True)),
+                ("summary", models.TextField(null=True)),
+                ("instructions", models.TextField(null=True)),
+                ("reward_value_url_param", models.TextField(null=True)),
+                ("external_url", models.URLField(null=True)),
+                ("about_url", models.URLField(null=True)),
+                ("is_site_wide", models.BooleanField(null=True)),
+                ("sub_goal", models.PositiveBigIntegerField(null=True)),
+                ("minute_watched_goal", models.PositiveBigIntegerField(null=True)),
+                ("image_url", models.URLField(null=True)),
+                (
+                    "game",
+                    models.ForeignKey(
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="reward_campaigns",
+                        to="core.game",
+                    ),
+                ),
+            ],
+        ),
+        migrations.CreateModel(
+            name="Reward",
+            fields=[
+                ("id", models.TextField(primary_key=True, serialize=False)),
+                ("name", models.TextField(null=True)),
+                ("banner_image_url", models.URLField(null=True)),
+                ("thumbnail_image_url", models.URLField(null=True)),
+                ("earnable_until", models.DateTimeField(null=True)),
+                ("redemption_instructions", models.TextField(null=True)),
+                ("redemption_url", models.URLField(null=True)),
+                (
+                    "campaign",
+                    models.ForeignKey(
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="rewards",
+                        to="core.rewardcampaign",
+                    ),
+                ),
+            ],
+        ),
+        migrations.CreateModel(
+            name="TimeBasedDrop",
+            fields=[
+                ("created_at", models.DateTimeField(auto_created=True, null=True)),
+                ("id", models.TextField(primary_key=True, serialize=False)),
+                ("modified_at", models.DateTimeField(auto_now=True, null=True)),
+                ("required_subs", models.PositiveBigIntegerField(null=True)),
+                ("ends_at", models.DateTimeField(null=True)),
+                ("name", models.TextField(null=True)),
+                ("required_minutes_watched", models.PositiveBigIntegerField(null=True)),
+                ("starts_at", models.DateTimeField(null=True)),
+                (
+                    "drop_campaign",
+                    models.ForeignKey(
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="drops",
+                        to="core.dropcampaign",
+                    ),
+                ),
+            ],
+        ),
+        migrations.CreateModel(
+            name="Benefit",
+            fields=[
+                ("created_at", models.DateTimeField(auto_created=True, null=True)),
+                ("id", models.TextField(primary_key=True, serialize=False)),
+                ("modified_at", models.DateTimeField(auto_now=True, null=True)),
+                ("twitch_created_at", models.DateTimeField(null=True)),
+                ("entitlement_limit", models.PositiveBigIntegerField(null=True)),
+                ("image_url", models.URLField(null=True)),
+                ("is_ios_available", models.BooleanField(null=True)),
+                ("name", models.TextField(null=True)),
+                (
+                    "time_based_drop",
+                    models.ForeignKey(
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="benefits",
+                        to="core.timebaseddrop",
+                    ),
+                ),
+            ],
+        ),
+    ]
diff --git a/core/migrations/0001_squashed_0003_alter_benefit_options_alter_channel_options_and_more.py b/core/migrations/0001_squashed_0003_alter_benefit_options_alter_channel_options_and_more.py
deleted file mode 100644
index 56e9800..0000000
--- a/core/migrations/0001_squashed_0003_alter_benefit_options_alter_channel_options_and_more.py
+++ /dev/null
@@ -1,202 +0,0 @@
-# Generated by Django 5.1 on 2024-08-12 23:16
-
-import django.db.models.deletion
-from django.db import migrations, models
-from django.db.migrations.operations.base import Operation
-
-
-class Migration(migrations.Migration):
-    replaces: list[tuple[str, str]] = [
-        ("core", "0001_initial"),
-        ("core", "0002_alter_benefit_time_based_drop_and_more"),
-        ("core", "0003_alter_benefit_options_alter_channel_options_and_more"),
-    ]
-
-    initial = True
-
-    dependencies: list[tuple[str, str]] = []
-
-    operations: list[Operation] = [
-        migrations.CreateModel(
-            name="Owner",
-            fields=[
-                ("id", models.TextField(primary_key=True, serialize=False)),
-                ("name", models.TextField(null=True)),
-            ],
-            options={
-                "abstract": False,
-            },
-        ),
-        migrations.CreateModel(
-            name="Game",
-            fields=[
-                ("twitch_id", models.TextField(primary_key=True, serialize=False)),
-                ("game_url", models.URLField(null=True)),
-                ("name", models.TextField(null=True)),
-                ("box_art_url", models.URLField(null=True)),
-                ("slug", models.TextField(null=True)),
-                (
-                    "org",
-                    models.ForeignKey(
-                        null=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        related_name="games",
-                        to="core.owner",
-                    ),
-                ),
-            ],
-            options={
-                "abstract": False,
-            },
-        ),
-        migrations.CreateModel(
-            name="RewardCampaign",
-            fields=[
-                ("created_at", models.DateTimeField(auto_created=True, null=True)),
-                ("id", models.TextField(primary_key=True, serialize=False)),
-                ("modified_at", models.DateTimeField(auto_now=True, null=True)),
-                ("name", models.TextField(null=True)),
-                ("brand", models.TextField(null=True)),
-                ("starts_at", models.DateTimeField(null=True)),
-                ("ends_at", models.DateTimeField(null=True)),
-                ("status", models.TextField(null=True)),
-                ("summary", models.TextField(null=True)),
-                ("instructions", models.TextField(null=True)),
-                ("reward_value_url_param", models.TextField(null=True)),
-                ("external_url", models.URLField(null=True)),
-                ("about_url", models.URLField(null=True)),
-                ("is_site_wide", models.BooleanField(null=True)),
-                ("sub_goal", models.PositiveBigIntegerField(null=True)),
-                ("minute_watched_goal", models.PositiveBigIntegerField(null=True)),
-                ("image_url", models.URLField(null=True)),
-                (
-                    "game",
-                    models.ForeignKey(
-                        null=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        related_name="reward_campaigns",
-                        to="core.game",
-                    ),
-                ),
-            ],
-            options={
-                "abstract": False,
-            },
-        ),
-        migrations.CreateModel(
-            name="DropCampaign",
-            fields=[
-                ("created_at", models.DateTimeField(auto_created=True, null=True)),
-                ("id", models.TextField(primary_key=True, serialize=False)),
-                ("modified_at", models.DateTimeField(auto_now=True, null=True)),
-                ("account_link_url", models.URLField(null=True)),
-                ("description", models.TextField(null=True)),
-                ("details_url", models.URLField(null=True)),
-                ("ends_at", models.DateTimeField(null=True)),
-                ("starts_at", models.DateTimeField(null=True)),
-                ("image_url", models.URLField(null=True)),
-                ("name", models.TextField(null=True)),
-                ("status", models.TextField(null=True)),
-                (
-                    "game",
-                    models.ForeignKey(
-                        null=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        related_name="drop_campaigns",
-                        to="core.game",
-                    ),
-                ),
-            ],
-            options={
-                "abstract": False,
-            },
-        ),
-        migrations.CreateModel(
-            name="TimeBasedDrop",
-            fields=[
-                ("created_at", models.DateTimeField(auto_created=True, null=True)),
-                ("id", models.TextField(primary_key=True, serialize=False)),
-                ("modified_at", models.DateTimeField(auto_now=True, null=True)),
-                ("required_subs", models.PositiveBigIntegerField(null=True)),
-                ("ends_at", models.DateTimeField(null=True)),
-                ("name", models.TextField(null=True)),
-                ("required_minutes_watched", models.PositiveBigIntegerField(null=True)),
-                ("starts_at", models.DateTimeField(null=True)),
-                (
-                    "drop_campaign",
-                    models.ForeignKey(
-                        null=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        related_name="drops",
-                        to="core.dropcampaign",
-                    ),
-                ),
-            ],
-            options={
-                "abstract": False,
-            },
-        ),
-        migrations.CreateModel(
-            name="Channel",
-            fields=[
-                ("twitch_id", models.TextField(primary_key=True, serialize=False)),
-                ("display_name", models.TextField(null=True)),
-                ("name", models.TextField(null=True)),
-                ("twitch_url", models.URLField(null=True)),
-                ("live", models.BooleanField(default=False)),
-                ("drop_campaigns", models.ManyToManyField(related_name="channels", to="core.dropcampaign")),
-            ],
-            options={
-                "abstract": False,
-            },
-        ),
-        migrations.CreateModel(
-            name="Benefit",
-            fields=[
-                ("created_at", models.DateTimeField(auto_created=True, null=True)),
-                ("id", models.TextField(primary_key=True, serialize=False)),
-                ("modified_at", models.DateTimeField(auto_now=True, null=True)),
-                ("twitch_created_at", models.DateTimeField(null=True)),
-                ("entitlement_limit", models.PositiveBigIntegerField(null=True)),
-                ("image_url", models.URLField(null=True)),
-                ("is_ios_available", models.BooleanField(null=True)),
-                ("name", models.TextField(null=True)),
-                (
-                    "time_based_drop",
-                    models.ForeignKey(
-                        null=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        related_name="benefits",
-                        to="core.timebaseddrop",
-                    ),
-                ),
-            ],
-            options={
-                "abstract": False,
-            },
-        ),
-        migrations.CreateModel(
-            name="Reward",
-            fields=[
-                ("id", models.TextField(primary_key=True, serialize=False)),
-                ("name", models.TextField(null=True)),
-                ("banner_image_url", models.URLField(null=True)),
-                ("thumbnail_image_url", models.URLField(null=True)),
-                ("earnable_until", models.DateTimeField(null=True)),
-                ("redemption_instructions", models.TextField(null=True)),
-                ("redemption_url", models.URLField(null=True)),
-                (
-                    "campaign",
-                    models.ForeignKey(
-                        null=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        related_name="rewards",
-                        to="core.rewardcampaign",
-                    ),
-                ),
-            ],
-            options={
-                "abstract": False,
-            },
-        ),
-    ]
diff --git a/core/migrations/0004_alter_channel_display_name_alter_channel_twitch_url_and_more.py b/core/migrations/0004_alter_channel_display_name_alter_channel_twitch_url_and_more.py
deleted file mode 100644
index 4944620..0000000
--- a/core/migrations/0004_alter_channel_display_name_alter_channel_twitch_url_and_more.py
+++ /dev/null
@@ -1,37 +0,0 @@
-# Generated by Django 5.1 on 2024-08-13 18:17
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-    dependencies = [
-        ("core", "0001_squashed_0003_alter_benefit_options_alter_channel_options_and_more"),
-    ]
-
-    operations = [
-        migrations.AlterField(
-            model_name="channel",
-            name="display_name",
-            field=models.TextField(default="Channel name unknown", null=True),
-        ),
-        migrations.AlterField(
-            model_name="channel",
-            name="twitch_url",
-            field=models.URLField(default="https://www.twitch.tv/", null=True),
-        ),
-        migrations.AlterField(
-            model_name="game",
-            name="box_art_url",
-            field=models.URLField(default="https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg", null=True),
-        ),
-        migrations.AlterField(
-            model_name="game",
-            name="game_url",
-            field=models.URLField(default="https://www.twitch.tv/", null=True),
-        ),
-        migrations.AlterField(
-            model_name="game",
-            name="name",
-            field=models.TextField(default="Game name unknown", null=True),
-        ),
-    ]
diff --git a/core/models/twitch.py b/core/models.py
similarity index 79%
rename from core/models/twitch.py
rename to core/models.py
index 453b3f1..57e128b 100644
--- a/core/models/twitch.py
+++ b/core/models.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 import logging
 
+from django.contrib.auth.models import AbstractUser
 from django.db import models
 
 logger: logging.Logger = logging.getLogger(__name__)
@@ -179,3 +180,64 @@ class Reward(models.Model):
 
     def __str__(self) -> str:
         return self.name or "Reward name unknown"
+
+
+class User(AbstractUser):
+    """Extended User model to include subscriptions."""
+
+    subscribed_games = models.ManyToManyField(
+        "Game",
+        through="GameSubscription",
+        related_name="subscribed_users",
+        blank=True,
+    )
+    subscribed_owners = models.ManyToManyField(
+        "Owner",
+        through="OwnerSubscription",
+        related_name="subscribed_users",
+        blank=True,
+    )
+    subscribe_to_news = models.BooleanField(default=False, help_text="Subscribe to news")
+    subscribe_to_new_games = models.BooleanField(default=False, help_text="Subscribe to new games")
+
+    def __str__(self) -> str:
+        return self.username
+
+
+class DiscordWebhook(models.Model):
+    """A Discord webhook for sending notifications."""
+
+    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="discord_webhooks")
+    url = models.URLField()
+    name = models.CharField(max_length=255)
+
+    def __str__(self) -> str:
+        return f"{self.name} ({self.user.username})"
+
+
+class GameSubscription(models.Model):
+    """A subscription to a specific game with a chosen webhook."""
+
+    user = models.ForeignKey(User, on_delete=models.CASCADE)
+    game = models.ForeignKey(Game, on_delete=models.CASCADE)
+    webhook = models.ForeignKey(DiscordWebhook, on_delete=models.CASCADE)
+
+    class Meta:
+        unique_together = ("user", "game")
+
+    def __str__(self) -> str:
+        return f"{self.user.username} -> {self.game.name} via {self.webhook.name}"
+
+
+class OwnerSubscription(models.Model):
+    """A subscription to a specific owner with a chosen webhook."""
+
+    user = models.ForeignKey(User, on_delete=models.CASCADE)
+    owner = models.ForeignKey(Owner, on_delete=models.CASCADE)
+    webhook = models.ForeignKey(DiscordWebhook, on_delete=models.CASCADE)
+
+    class Meta:
+        unique_together = ("user", "owner")
+
+    def __str__(self) -> str:
+        return f"{self.user.username} -> {self.owner.name} via {self.webhook.name}"
diff --git a/core/models/__init__.py b/core/models/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/core/settings.py b/core/settings.py
index 5f47bce..8e186ea 100644
--- a/core/settings.py
+++ b/core/settings.py
@@ -43,7 +43,7 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
 STATICFILES_DIRS: list[Path] = [BASE_DIR / "static"]
 STATIC_ROOT: Path = BASE_DIR / "staticfiles"
 STATIC_ROOT.mkdir(exist_ok=True)
-
+AUTH_USER_MODEL = "core.User"
 if DEBUG:
     INTERNAL_IPS: list[str] = ["127.0.0.1"]
 
diff --git a/core/signals.py b/core/signals.py
new file mode 100644
index 0000000..2ec2acc
--- /dev/null
+++ b/core/signals.py
@@ -0,0 +1,125 @@
+import logging
+from typing import TYPE_CHECKING
+
+from discord_webhook import DiscordWebhook
+from django.db.models.manager import BaseManager
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+from django.utils import timezone
+
+from core.models import DropCampaign, Game, GameSubscription, Owner, OwnerSubscription
+
+if TYPE_CHECKING:
+    import requests
+    from django.db.models.manager import BaseManager
+
+logger: logging.Logger = logging.getLogger(__name__)
+
+
+def convert_time_to_discord_timestamp(time: timezone.datetime | None) -> str:
+    """Discord uses <t:UNIX_TIMESTAMP:R> for timestamps.
+
+    Args:
+        time: The time to convert to a Discord timestamp.
+
+    Returns:
+        str: The Discord timestamp string. If time is None, returns "Unknown".
+    """
+    return f"<t:{int(time.timestamp())}:R>" if time else "Unknown"
+
+
+@receiver(signal=post_save, sender=DropCampaign)
+def notify_users_of_new_drop(sender: DropCampaign, instance: DropCampaign, created: bool, **kwargs) -> None:  # noqa: ANN003, ARG001, FBT001
+    """Notify users of a new drop campaign.
+
+    Args:
+        sender (DropCampaign): The model we are sending the signal from.
+        instance (DropCampaign): The instance of the model that was created.
+        created (bool): Whether the instance was created or updated.
+        **kwargs: Additional keyword arguments.
+    """
+    if not created:
+        logger.debug("Drop campaign '%s' was updated.", instance.name)
+        return
+
+    game: Game | None = instance.game
+    if not game:
+        return
+
+    owner: Owner = game.owner  # type: ignore  # noqa: PGH003
+
+    # Notify users subscribed to the game
+    game_subs: BaseManager[GameSubscription] = GameSubscription.objects.filter(game=game)
+    for sub in game_subs:
+        if not sub.webhook.url:
+            logger.error("No webhook URL provided.")
+            return
+
+        webhook = DiscordWebhook(
+            url=sub.webhook.url,
+            content=generate_game_message(instance=instance, game=game, sub=sub),
+            username=f"{game.name} drop.",
+            rate_limit_retry=True,
+        )
+        response: requests.Response = webhook.execute()
+        logger.debug(response)
+
+    # Notify users subscribed to the owner
+    owner_subs: BaseManager[OwnerSubscription] = OwnerSubscription.objects.filter(owner=owner)
+    for sub in owner_subs:
+        if not sub.webhook.url:
+            logger.error("No webhook URL provided.")
+            return
+
+        webhook = DiscordWebhook(
+            url=sub.webhook.url,
+            content=generate_owner_message(instance=instance, owner=owner, sub=sub),
+            username=f"{owner.name} drop.",
+            rate_limit_retry=True,
+        )
+        response: requests.Response = webhook.execute()
+        logger.debug(response)
+
+
+def generate_game_message(instance: DropCampaign, game: Game, sub: GameSubscription) -> str:
+    """Generate a message for a drop campaign.
+
+    Args:
+        instance (DropCampaign): Drop campaign instance.
+        game (Game): Game instance.
+        sub (GameSubscription): Game subscription instance.
+
+    Returns:
+        str: The message to send to Discord.
+    """
+    game_name: str = game.name or "Unknown"
+    description: str = instance.description or "No description provided."
+    start_at: str = convert_time_to_discord_timestamp(instance.starts_at)
+    end_at: str = convert_time_to_discord_timestamp(instance.ends_at)
+    msg: str = f"{game_name}: {instance.name}\n{description}\nStarts: {start_at}\nEnds: {end_at}"
+
+    logger.info("Discord message: '%s' to '%s'", msg, sub.webhook.url)
+
+    return msg
+
+
+def generate_owner_message(instance: DropCampaign, owner: Owner, sub: OwnerSubscription) -> str:
+    """Generate a message for a drop campaign.
+
+    Args:
+        instance (DropCampaign): Drop campaign instance.
+        owner (Owner): Owner instance.
+        sub (OwnerSubscription): Owner subscription instance.
+
+    Returns:
+        str: The message to send to Discord.
+    """
+    owner_name: str = owner.name or "Unknown"
+    description: str = instance.description or "No description provided."
+    start_at: str = convert_time_to_discord_timestamp(instance.starts_at)
+    end_at: str = convert_time_to_discord_timestamp(instance.ends_at)
+    msg: str = f"{owner_name}: {instance.name}\n{description}\nStarts: {start_at}\nEnds: {end_at}"
+
+    logger.info("Discord message: '%s' to '%s'", msg, sub.webhook.url)
+
+    return msg
diff --git a/core/templates/index.html b/core/templates/index.html
index 15b93cc..83ba268 100644
--- a/core/templates/index.html
+++ b/core/templates/index.html
@@ -7,7 +7,10 @@
             <div class="col-lg-9">
                 {% include "partials/info_box.html" %}
                 {% include "partials/news.html" %}
-                <h2>Reward Campaigns</h2>
+                <h2>
+                    Reward campaigns -
+                    <div class="d-inline text-muted">{{ reward_campaigns.count }} campaigns</div>
+                </h2>
                 {% for campaign in reward_campaigns %}
                     {% include "partials/reward_campaign_card.html" %}
                 {% endfor %}
@@ -16,7 +19,6 @@
                     <div class="d-inline text-muted ">{{ games.count }} games</div>
                 </h2>
                 {% for game in games %}
-                    {# Only show games with drop campaigns #}
                     {% if game.drop_campaigns.count > 0 %}
                         {% include "partials/game_card.html" %}
                     {% endif %}
diff --git a/core/views.py b/core/views.py
index a419f08..6393550 100644
--- a/core/views.py
+++ b/core/views.py
@@ -9,7 +9,7 @@ from django.db.models.manager import BaseManager
 from django.template.response import TemplateResponse
 from django.utils import timezone  # type: ignore  # noqa: PGH003
 
-from core.models.twitch import DropCampaign, Game, RewardCampaign
+from core.models import DropCampaign, Game, RewardCampaign
 
 if TYPE_CHECKING:
     from django.db.models.manager import BaseManager