diff --git a/.vscode/launch.json b/.vscode/launch.json
index bc52e5e..d070863 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -8,7 +8,7 @@
"program": "${workspaceFolder}/manage.py",
"args": [
"runserver",
- "--noreload",
+ "--reload",
"--nothreading"
],
"django": true,
diff --git a/config/settings.py b/config/settings.py
index 47326b0..4883d2c 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -70,6 +70,7 @@ INSTALLED_APPS: list[str] = [
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
+ "ninja",
]
MIDDLEWARE: list[str] = [
@@ -130,3 +131,26 @@ STORAGES: dict[str, dict[str, str]] = {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
+
+LOGGING = {
+ "version": 1,
+ "disable_existing_loggers": False,
+ "handlers": {
+ "console": {
+ "level": "DEBUG",
+ "class": "logging.StreamHandler",
+ },
+ },
+ "loggers": {
+ "": {
+ "handlers": ["console"],
+ "level": "DEBUG",
+ "propagate": True,
+ },
+ "django.utils.autoreload": { # Remove spam
+ "handlers": ["console"],
+ "level": "INFO",
+ "propagate": True,
+ },
+ },
+}
diff --git a/config/urls.py b/config/urls.py
index df572f3..b6ccf6b 100644
--- a/config/urls.py
+++ b/config/urls.py
@@ -1,10 +1,21 @@
from django.contrib import admin
-from django.urls import include, path
+from django.urls import URLPattern, include, path
from django.urls.resolvers import URLResolver
+from ninja import NinjaAPI
+
+from twitch.api import router as twitch_router
+
+api = NinjaAPI(
+ title="TTVDrops API",
+ version="1.0.0",
+ description="No rate limits, but don't abuse it.",
+)
+api.add_router(prefix="/twitch", router=twitch_router)
app_name: str = "config"
-urlpatterns: list[URLResolver] = [
+urlpatterns: list[URLPattern | URLResolver] = [
path(route="admin/", view=admin.site.urls),
path(route="", view=include(arg="core.urls")),
+ path(route="api/", view=api.urls),
]
diff --git a/core/templates/base.html b/core/templates/base.html
deleted file mode 100644
index b105fc4..0000000
--- a/core/templates/base.html
+++ /dev/null
@@ -1,120 +0,0 @@
-{% load static %}
-
-
-
-
-
-
-
-
- {% block title %}
- Twitch drops
- {% endblock title %}
-
-
-
- {% if messages %}
-
- {% for message in messages %}
- - {{ message }}
- {% endfor %}
-
- {% endif %}
-
-
-
-
-
-
- {% block content %}{% endblock %}
-
-
-
-
-
diff --git a/core/templates/index.html b/core/templates/index.html
index 04814d2..15808b8 100644
--- a/core/templates/index.html
+++ b/core/templates/index.html
@@ -93,13 +93,35 @@
font-weight: 600;
margin: 0;
}
+
+ /* Django messages framework */
+ .messages {
+ list-style-type: none;
+ }
+
+ /* Make error messages red and success messages green */
+ .error {
+ color: red;
+ }
+ .success {
+ color: green;
+ }
+
+ {% if messages %}
+
+ {% for message in messages %}
+ - {{ message }}
+ {% endfor %}
+
+ {% endif %}
Twitch Drops
{% for organization, org_data in orgs_data.items %}
diff --git a/core/urls.py b/core/urls.py
index d3ca671..97915a1 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -1,11 +1,12 @@
from __future__ import annotations
-from django.urls import URLPattern, path
+from django.urls import URLPattern, URLResolver, path
from . import views
app_name: str = "core"
-urlpatterns: list[URLPattern] = [
+
+urlpatterns: list[URLPattern | URLResolver] = [
path(route="", view=views.index, name="index"),
]
diff --git a/requirements.txt b/requirements.txt
index c5e3788..6116719 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -10,3 +10,4 @@ sentry-sdk[django]
whitenoise[brotli]
platformdirs
playwright
+django-ninja
diff --git a/twitch/api.py b/twitch/api.py
new file mode 100644
index 0000000..587e96f
--- /dev/null
+++ b/twitch/api.py
@@ -0,0 +1,124 @@
+import datetime
+
+from django.db.models.manager import BaseManager
+from django.http import HttpRequest
+from ninja import Router, Schema
+
+from .models import (
+ Channel,
+ DropBenefit,
+ DropCampaign,
+ Game,
+ Organization,
+ TimeBasedDrop,
+)
+
+router = Router(
+ tags=["twitch"],
+)
+
+
+class OrganizationSchema(Schema):
+ id: str | None = None
+ name: str | None = None
+ added_at: datetime.datetime | None = None
+ modified_at: datetime.datetime | None = None
+
+
+class ChannelSchema(Schema):
+ id: str
+ display_name: str | None = None
+ name: str | None = None
+ added_at: datetime.datetime | None = None
+ modified_at: datetime.datetime | None = None
+
+
+class GameSchema(Schema):
+ id: str
+ slug: str | None = None
+ twitch_url: str | None = None
+ display_name: str | None = None
+ added_at: datetime.datetime | None = None
+ modified_at: datetime.datetime | None = None
+
+
+class DropBenefitSchema(Schema):
+ id: str
+ created_at: datetime.datetime | None = None
+ entitlement_limit: int | None = None
+ image_asset_url: str | None = None
+ is_ios_available: bool | None = None
+ name: str | None = None
+ owner_organization: OrganizationSchema
+ game: GameSchema
+ added_at: datetime.datetime | None = None
+ modified_at: datetime.datetime | None = None
+
+
+class TimeBasedDropSchema(Schema):
+ id: str
+ required_subs: int | None = None
+ end_at: datetime.datetime | None = None
+ name: str | None = None
+ required_minutes_watched: int | None = None
+ start_at: datetime.datetime | None = None
+ benefits: list[DropBenefitSchema]
+ added_at: datetime.datetime | None = None
+ modified_at: datetime.datetime | None = None
+
+
+class DropCampaignSchema(Schema):
+ id: str
+ account_link_url: str | None = None
+ description: str | None = None
+ details_url: str | None = None
+ added_at: datetime.datetime | None = None
+ modified_at: datetime.datetime | None = None
+
+
+# http://localhost:8000/api/twitch/organizations
+@router.get("/organizations", response=list[OrganizationSchema])
+def get_organizations(
+ request: HttpRequest, # noqa: ARG001
+) -> BaseManager[Organization]:
+ """Get all organizations."""
+ return Organization.objects.all()
+
+
+# http://localhost:8000/api/twitch/channels
+@router.get("/channels", response=list[ChannelSchema])
+def get_channels(request: HttpRequest) -> BaseManager[Channel]: # noqa: ARG001
+ """Get all channels."""
+ return Channel.objects.all()
+
+
+# http://localhost:8000/api/twitch/games
+@router.get("/games", response=list[GameSchema])
+def get_games(request: HttpRequest) -> BaseManager[Game]: # noqa: ARG001
+ """Get all games."""
+ return Game.objects.all()
+
+
+# http://localhost:8000/api/twitch/drop_benefits
+@router.get("/drop_benefits", response=list[DropBenefitSchema])
+def get_drop_benefits(request: HttpRequest) -> BaseManager[DropBenefit]: # noqa: ARG001
+ """Get all drop benefits."""
+ return DropBenefit.objects.all()
+
+
+# http://localhost:8000/api/twitch/drop_campaigns
+@router.get("/drop_campaigns", response=list[DropCampaignSchema])
+def get_drop_campaigns(
+ request: HttpRequest, # noqa: ARG001
+) -> BaseManager[DropCampaign]:
+ """Get all drop campaigns."""
+ return DropCampaign.objects.all()
+
+
+# http://localhost:8000/api/twitch/time_based_drops
+@router.get("/time_based_drops", response=list[TimeBasedDropSchema])
+def get_time_based_drops(
+ request: HttpRequest, # noqa: ARG001
+) -> BaseManager[TimeBasedDrop]:
+ """Get all time-based drops."""
+ return TimeBasedDrop.objects.all()
diff --git a/twitch/management/commands/scrape_twitch.py b/twitch/management/commands/scrape_twitch.py
index ee56b76..c7de4aa 100644
--- a/twitch/management/commands/scrape_twitch.py
+++ b/twitch/management/commands/scrape_twitch.py
@@ -1,7 +1,8 @@
import asyncio
+import logging
import typing
from pathlib import Path
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING
from asgiref.sync import sync_to_async
from django.core.management.base import BaseCommand
@@ -22,6 +23,7 @@ from twitch.models import (
if TYPE_CHECKING:
from playwright.async_api._generated import BrowserContext, Page
+# Where to store the Firefox profile
data_dir = Path(
user_data_dir(
appname="TTVDrops",
@@ -35,6 +37,8 @@ if not data_dir:
msg = "DATA_DIR is not set in settings.py"
raise ValueError(msg)
+logger: logging.Logger = logging.getLogger("twitch.management.commands.scrape_twitch")
+
async def insert_data(data: dict) -> None: # noqa: PLR0914
"""Insert data into the database.
@@ -44,32 +48,39 @@ async def insert_data(data: dict) -> None: # noqa: PLR0914
"""
user_data: dict = data.get("data", {}).get("user")
if not user_data:
+ logger.debug("No user data found")
return
user_id = user_data["id"]
drop_campaign_data = user_data["dropCampaign"]
if not drop_campaign_data:
+ logger.debug("No drop campaign data found")
return
+ logger.info("Inserting data for user ID: %s", user_id)
+
# Create or get the organization
owner_data = drop_campaign_data["owner"]
- owner, _ = await sync_to_async(Organization.objects.get_or_create)(
+ owner, created = await sync_to_async(Organization.objects.get_or_create)(
id=owner_data["id"],
defaults={"name": owner_data["name"]},
)
+ logger.debug("Organization %s: %s", "created" if created else "retrieved", owner)
+
# Create or get the game
game_data = drop_campaign_data["game"]
- game, _ = await sync_to_async(Game.objects.get_or_create)(
+ game, created = await sync_to_async(Game.objects.get_or_create)(
id=game_data["id"],
defaults={
"slug": game_data["slug"],
"display_name": game_data["displayName"],
},
)
+ logger.debug("Game %s: %s", "created" if created else "retrieved", game)
# Create the drop campaign
- drop_campaign, _ = await sync_to_async(DropCampaign.objects.get_or_create)(
+ drop_campaign, created = await sync_to_async(DropCampaign.objects.get_or_create)(
id=drop_campaign_data["id"],
defaults={
"account_link_url": drop_campaign_data["accountLinkURL"],
@@ -84,15 +95,23 @@ async def insert_data(data: dict) -> None: # noqa: PLR0914
"owner": owner,
},
)
+ logger.debug(
+ "Drop campaign %s: %s",
+ "created" if created else "retrieved",
+ drop_campaign,
+ )
+
if not drop_campaign_data["allow"]:
+ logger.debug("No allowed data in drop campaign")
return
if not drop_campaign_data["allow"]["channels"]:
+ logger.debug("No allowed channels in drop campaign")
return
# Create channels
for channel_data in drop_campaign_data["allow"]["channels"]:
- channel, _ = await sync_to_async(Channel.objects.get_or_create)(
+ channel, created = await sync_to_async(Channel.objects.get_or_create)(
id=channel_data["id"],
defaults={
"display_name": channel_data["displayName"],
@@ -100,6 +119,7 @@ async def insert_data(data: dict) -> None: # noqa: PLR0914
},
)
await sync_to_async(drop_campaign.channels.add)(channel)
+ logger.debug("Channel %s: %s", "created" if created else "retrieved", channel)
# Create time-based drops
for drop_data in drop_campaign_data["timeBasedDrops"]:
@@ -110,18 +130,29 @@ async def insert_data(data: dict) -> None: # noqa: PLR0914
benefit_data = edge["benefit"]
benefit_owner_data = benefit_data["ownerOrganization"]
- benefit_owner, _ = await sync_to_async(Organization.objects.get_or_create)(
+ benefit_owner, created = await sync_to_async(
+ Organization.objects.get_or_create,
+ )(
id=benefit_owner_data["id"],
defaults={"name": benefit_owner_data["name"]},
)
-
+ logger.debug(
+ "Benefit owner %s: %s",
+ "created" if created else "retrieved",
+ benefit_owner,
+ )
benefit_game_data = benefit_data["game"]
- benefit_game, _ = await sync_to_async(Game.objects.get_or_create)(
+ benefit_game, created = await sync_to_async(Game.objects.get_or_create)(
id=benefit_game_data["id"],
defaults={"name": benefit_game_data["name"]},
)
+ logger.debug(
+ "Benefit game %s: %s",
+ "created" if created else "retrieved",
+ benefit_game,
+ )
- benefit, _ = await sync_to_async(DropBenefit.objects.get_or_create)(
+ benefit, created = await sync_to_async(DropBenefit.objects.get_or_create)(
id=benefit_data["id"],
defaults={
"created_at": benefit_data["createdAt"],
@@ -134,8 +165,15 @@ async def insert_data(data: dict) -> None: # noqa: PLR0914
},
)
drop_benefits.append(benefit)
+ logger.debug(
+ "Benefit %s: %s",
+ "created" if created else "retrieved",
+ benefit,
+ )
- time_based_drop, _ = await sync_to_async(TimeBasedDrop.objects.get_or_create)(
+ time_based_drop, created = await sync_to_async(
+ TimeBasedDrop.objects.get_or_create,
+ )(
id=drop_data["id"],
defaults={
"required_subs": drop_data["requiredSubs"],
@@ -147,23 +185,41 @@ async def insert_data(data: dict) -> None: # noqa: PLR0914
)
await sync_to_async(time_based_drop.benefits.set)(drop_benefits)
await sync_to_async(drop_campaign.time_based_drops.add)(time_based_drop)
+ logger.debug(
+ "Time-based drop %s: %s",
+ "created" if created else "retrieved",
+ time_based_drop,
+ )
# Create or get the user
- user, _ = await sync_to_async(User.objects.get_or_create)(id=user_id)
+ user, created = await sync_to_async(User.objects.get_or_create)(id=user_id)
await sync_to_async(user.drop_campaigns.add)(drop_campaign)
+ logger.debug(
+ "User %s: %s",
+ "created" if created else "retrieved",
+ user,
+ )
class Command(BaseCommand):
help = "Scrape Twitch Drops Campaigns with login using Firefox"
- async def run(self, playwright: Playwright) -> list[Any]:
+ async def run( # noqa: PLR6301
+ self,
+ playwright: Playwright,
+ ) -> list[dict[str, typing.Any]]:
profile_dir: Path = Path(data_dir / "firefox-profile")
profile_dir.mkdir(parents=True, exist_ok=True)
+ logger.debug(
+ "Launching Firefox browser with user data directory: %s",
+ profile_dir,
+ )
browser: BrowserContext = await playwright.firefox.launch_persistent_context(
user_data_dir=profile_dir,
headless=True,
)
+ logger.debug("Launched Firefox browser")
page: Page = await browser.new_page()
json_data: list[dict] = []
@@ -173,11 +229,19 @@ class Command(BaseCommand):
try:
body: typing.Any = await response.json()
json_data.extend(body)
- except Exception: # noqa: BLE001
- self.stdout.write(f"Failed to parse JSON from {response.url}")
+ logger.debug(
+ "Received JSON data from %s",
+ response.url,
+ )
+ except Exception:
+ logger.exception(
+ "Failed to parse JSON from %s",
+ response.url,
+ )
page.on("response", handle_response)
await page.goto("https://www.twitch.tv/drops/campaigns")
+ logger.debug("Navigated to Twitch drops campaigns page")
logged_in = False
while not logged_in:
@@ -187,14 +251,16 @@ class Command(BaseCommand):
timeout=30000,
)
logged_in = True
- self.stdout.write("Logged in")
+ logger.info("Logged in to Twitch")
except KeyboardInterrupt as e:
raise KeyboardInterrupt from e
except Exception: # noqa: BLE001
await asyncio.sleep(5)
- self.stdout.write("Waiting for login")
+ logger.info("Waiting for login")
await page.wait_for_load_state("networkidle")
+ logger.debug("Page is idle")
+
await browser.close()
for campaign in json_data:
diff --git a/twitch/migrations/0001_initial.py b/twitch/migrations/0001_initial.py
index 708d68b..3c952fa 100644
--- a/twitch/migrations/0001_initial.py
+++ b/twitch/migrations/0001_initial.py
@@ -2,14 +2,15 @@
import django.db.models.deletion
from django.db import migrations, models
+from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration):
initial = True
- dependencies = []
+ dependencies: list[tuple[str, str]] = []
- operations = [
+ operations: list[Operation] = [
migrations.CreateModel(
name="Channel",
fields=[
diff --git a/twitch/migrations/0002_alter_dropcampaign_options_alter_dropcampaign_game_and_more.py b/twitch/migrations/0002_alter_dropcampaign_options_alter_dropcampaign_game_and_more.py
index 2b85304..2a90461 100644
--- a/twitch/migrations/0002_alter_dropcampaign_options_alter_dropcampaign_game_and_more.py
+++ b/twitch/migrations/0002_alter_dropcampaign_options_alter_dropcampaign_game_and_more.py
@@ -2,14 +2,15 @@
import django.db.models.deletion
from django.db import migrations, models
+from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration):
- dependencies = [
+ dependencies: list[tuple[str, str]] = [
("twitch", "0001_initial"),
]
- operations = [
+ operations: list[Operation] = [
migrations.AlterModelOptions(
name="dropcampaign",
options={"verbose_name_plural": "Drop Campaigns"},
diff --git a/twitch/migrations/0003_channel_added_at_channel_modified_at_and_more.py b/twitch/migrations/0003_channel_added_at_channel_modified_at_and_more.py
new file mode 100644
index 0000000..9396cc8
--- /dev/null
+++ b/twitch/migrations/0003_channel_added_at_channel_modified_at_and_more.py
@@ -0,0 +1,83 @@
+# Generated by Django 5.1a1 on 2024-06-22 19:21
+
+from django.db import migrations, models
+from django.db.migrations.operations.base import Operation
+
+
+class Migration(migrations.Migration):
+ dependencies: list[tuple[str, str]] = [
+ ("twitch", "0002_alter_dropcampaign_options_alter_dropcampaign_game_and_more"),
+ ]
+
+ operations: list[Operation] = [
+ migrations.AddField(
+ model_name="channel",
+ name="added_at",
+ field=models.DateTimeField(auto_now_add=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="channel",
+ name="modified_at",
+ field=models.DateTimeField(auto_now=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="dropbenefit",
+ name="added_at",
+ field=models.DateTimeField(auto_now_add=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="dropbenefit",
+ name="modified_at",
+ field=models.DateTimeField(auto_now=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="dropcampaign",
+ name="added_at",
+ field=models.DateTimeField(auto_now_add=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="dropcampaign",
+ name="modified_at",
+ field=models.DateTimeField(auto_now=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="game",
+ name="added_at",
+ field=models.DateTimeField(auto_now_add=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="game",
+ name="modified_at",
+ field=models.DateTimeField(auto_now=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="organization",
+ name="added_at",
+ field=models.DateTimeField(auto_now_add=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="organization",
+ name="modified_at",
+ field=models.DateTimeField(auto_now=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="timebaseddrop",
+ name="added_at",
+ field=models.DateTimeField(auto_now_add=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="timebaseddrop",
+ name="modified_at",
+ field=models.DateTimeField(auto_now=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="user",
+ name="added_at",
+ field=models.DateTimeField(auto_now_add=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="user",
+ name="modified_at",
+ field=models.DateTimeField(auto_now=True, null=True),
+ ),
+ ]
diff --git a/twitch/migrations/0004_alter_dropcampaign_options.py b/twitch/migrations/0004_alter_dropcampaign_options.py
new file mode 100644
index 0000000..96c1318
--- /dev/null
+++ b/twitch/migrations/0004_alter_dropcampaign_options.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.1a1 on 2024-06-22 19:27
+
+from django.db import migrations
+from django.db.migrations.operations.base import Operation
+
+
+class Migration(migrations.Migration):
+ dependencies: list[tuple[str, str]] = [
+ ("twitch", "0003_channel_added_at_channel_modified_at_and_more"),
+ ]
+
+ operations: list[Operation] = [
+ migrations.AlterModelOptions(
+ name="dropcampaign",
+ options={},
+ ),
+ ]
diff --git a/twitch/migrations/0005_game_twitch_url.py b/twitch/migrations/0005_game_twitch_url.py
new file mode 100644
index 0000000..86dbab9
--- /dev/null
+++ b/twitch/migrations/0005_game_twitch_url.py
@@ -0,0 +1,26 @@
+# Generated by Django 5.1a1 on 2024-06-22 20:05
+
+import django.db.models.functions.text
+from django.db import migrations, models
+from django.db.migrations.operations.base import Operation
+
+
+class Migration(migrations.Migration):
+ dependencies: list[tuple[str, str]] = [
+ ("twitch", "0004_alter_dropcampaign_options"),
+ ]
+
+ operations: list[Operation] = [
+ migrations.AddField(
+ model_name="game",
+ name="twitch_url",
+ field=models.GeneratedField( # type: ignore # noqa: PGH003
+ db_persist=True,
+ expression=django.db.models.functions.text.Concat(
+ models.Value("https://www.twitch.tv/directory/category/"),
+ "slug",
+ ),
+ output_field=models.TextField(),
+ ),
+ ),
+ ]
diff --git a/twitch/models.py b/twitch/models.py
index 1244c62..297dbf7 100644
--- a/twitch/models.py
+++ b/twitch/models.py
@@ -1,9 +1,15 @@
from django.db import models
+from django.db.models import Value
+from django.db.models.functions import (
+ Concat,
+)
class Organization(models.Model):
id = models.TextField(primary_key=True)
name = models.TextField(blank=True, null=True)
+ added_at = models.DateTimeField(blank=True, null=True, auto_now_add=True)
+ modified_at = models.DateTimeField(blank=True, null=True, auto_now=True)
def __str__(self) -> str:
return self.name or self.id
@@ -12,7 +18,14 @@ class Organization(models.Model):
class Game(models.Model):
id = models.TextField(primary_key=True)
slug = models.TextField(blank=True, null=True)
+ twitch_url = models.GeneratedField( # type: ignore # noqa: PGH003
+ expression=Concat(Value("https://www.twitch.tv/directory/category/"), "slug"),
+ output_field=models.TextField(),
+ db_persist=True,
+ )
display_name = models.TextField(blank=True, null=True)
+ added_at = models.DateTimeField(blank=True, null=True, auto_now_add=True)
+ modified_at = models.DateTimeField(blank=True, null=True, auto_now=True)
def __str__(self) -> str:
return self.display_name or self.slug or self.id
@@ -22,6 +35,8 @@ class Channel(models.Model):
id = models.TextField(primary_key=True)
display_name = models.TextField(blank=True, null=True)
name = models.TextField(blank=True, null=True)
+ added_at = models.DateTimeField(blank=True, null=True, auto_now_add=True)
+ modified_at = models.DateTimeField(blank=True, null=True, auto_now=True)
def __str__(self) -> str:
return self.display_name or self.name or self.id
@@ -36,6 +51,8 @@ class DropBenefit(models.Model):
name = models.TextField(blank=True, null=True)
owner_organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
game = models.ForeignKey(Game, on_delete=models.CASCADE)
+ added_at = models.DateTimeField(blank=True, null=True, auto_now_add=True)
+ modified_at = models.DateTimeField(blank=True, null=True, auto_now=True)
def __str__(self) -> str:
return self.name or self.id
@@ -49,6 +66,8 @@ class TimeBasedDrop(models.Model):
required_minutes_watched = models.IntegerField(blank=True, null=True)
start_at = models.DateTimeField(blank=True, null=True)
benefits = models.ManyToManyField(DropBenefit)
+ added_at = models.DateTimeField(blank=True, null=True, auto_now_add=True)
+ modified_at = models.DateTimeField(blank=True, null=True, auto_now=True)
def __str__(self) -> str:
return self.name or self.id
@@ -76,9 +95,8 @@ class DropCampaign(models.Model):
)
channels = models.ManyToManyField(Channel)
time_based_drops = models.ManyToManyField(TimeBasedDrop)
-
- class Meta:
- verbose_name_plural = "Drop Campaigns"
+ added_at = models.DateTimeField(blank=True, null=True, auto_now_add=True)
+ modified_at = models.DateTimeField(blank=True, null=True, auto_now=True)
def __str__(self) -> str:
return self.name or self.id
@@ -87,6 +105,8 @@ class DropCampaign(models.Model):
class User(models.Model):
id = models.TextField(primary_key=True)
drop_campaigns = models.ManyToManyField(DropCampaign)
+ added_at = models.DateTimeField(blank=True, null=True, auto_now_add=True)
+ modified_at = models.DateTimeField(blank=True, null=True, auto_now=True)
def __str__(self) -> str:
return self.id