diff --git a/templates/twitch/dashboard.html b/templates/twitch/dashboard.html
index 6219c3b..f7ca59c 100644
--- a/templates/twitch/dashboard.html
+++ b/templates/twitch/dashboard.html
@@ -85,6 +85,34 @@ Hover over the end time to see the exact date and time.
text-align: left">
Duration: {{ campaign.start_at|timesince:campaign.end_at }}
+ {% if campaign.allow_channels.all %}
+
+
Channels:
+
+ {% for channel in campaign.allow_channels.all %}
+ {% if forloop.counter <= 5 %}
+ -
+
+ {{ channel.display_name }}
+
+
+ {% endif %}
+ {% endfor %}
+ {% if campaign.allow_channels.all|length > 5 %}
+ -
+ ... and {{ campaign.allow_channels.all|length|add:"-5" }} more
+
+ {% endif %}
+
+
+ {% endif %}
{% endfor %}
diff --git a/twitch/management/commands/import_drops.py b/twitch/management/commands/import_drops.py
index 939d3b5..d86cacd 100644
--- a/twitch/management/commands/import_drops.py
+++ b/twitch/management/commands/import_drops.py
@@ -12,7 +12,7 @@ from django.core.management.base import BaseCommand, CommandError, CommandParser
from django.db import transaction
from django.utils import timezone
-from twitch.models import DropBenefit, DropBenefitEdge, DropCampaign, Game, Organization, TimeBasedDrop
+from twitch.models import Channel, DropBenefit, DropBenefitEdge, DropCampaign, Game, Organization, TimeBasedDrop
if TYPE_CHECKING:
from datetime import datetime
@@ -97,10 +97,15 @@ class Command(BaseCommand):
for p in paths:
try:
path: Path = Path(p)
- processed_path: Path = path / processed_dir
- processed_path.mkdir(exist_ok=True)
-
self.validate_path(path)
+
+ # For files, use the parent directory for processed files
+ if path.is_file():
+ processed_path: Path = path.parent / processed_dir
+ else:
+ processed_path: Path = path / processed_dir
+
+ processed_path.mkdir(exist_ok=True)
self.process_drops(continue_on_error=continue_on_error, path=path, processed_path=processed_path)
except CommandError as e:
@@ -528,6 +533,10 @@ class Command(BaseCommand):
Returns:
Returns the DropCampaign object.
"""
+ # Extract allow data from campaign_data
+ allow_data = campaign_data.get("allow", {})
+ allow_is_enabled = allow_data.get("isEnabled")
+
drop_campaign_defaults: dict[str, Any] = {
"game": game,
"name": campaign_data.get("name"),
@@ -538,6 +547,7 @@ class Command(BaseCommand):
"start_at": parse_date(campaign_data.get("startAt") or campaign_data.get("startsAt")),
"end_at": parse_date(campaign_data.get("endAt") or campaign_data.get("endsAt")),
"is_account_connected": campaign_data.get("self", {}).get("isAccountConnected"),
+ "allow_is_enabled": allow_is_enabled,
}
# Run .strip() on all string fields to remove leading/trailing whitespace
for key, value in drop_campaign_defaults.items():
@@ -551,6 +561,33 @@ class Command(BaseCommand):
id=campaign_data["id"],
defaults=drop_campaign_defaults,
)
+
+ # Handle allow_channels (many-to-many relationship)
+ allow_channels: list[dict[str, str]] = allow_data.get("channels", [])
+ if allow_channels:
+ channel_objects: list[Channel] = []
+ for channel_data in allow_channels:
+ channel_defaults: dict[str, str | None] = {
+ "name": channel_data.get("name"),
+ "display_name": channel_data.get("displayName"),
+ }
+ # Run .strip() on all string fields to remove leading/trailing whitespace
+ for key, value in channel_defaults.items():
+ if isinstance(value, str):
+ channel_defaults[key] = value.strip()
+
+ # Filter out None values
+ channel_defaults = {k: v for k, v in channel_defaults.items() if v is not None}
+
+ channel, _ = Channel.objects.update_or_create(
+ id=channel_data["id"],
+ defaults=channel_defaults,
+ )
+ channel_objects.append(channel)
+
+ # Set the many-to-many relationship
+ drop_campaign.allow_channels.set(channel_objects)
+
if created:
self.stdout.write(self.style.SUCCESS(f"Created new drop campaign: {drop_campaign.name} (ID: {drop_campaign.id})"))
return drop_campaign
diff --git a/twitch/migrations/0013_dropcampaign_allow_is_enabled_channel_and_more.py b/twitch/migrations/0013_dropcampaign_allow_is_enabled_channel_and_more.py
new file mode 100644
index 0000000..aeb5d59
--- /dev/null
+++ b/twitch/migrations/0013_dropcampaign_allow_is_enabled_channel_and_more.py
@@ -0,0 +1,37 @@
+# Generated by Django 5.2.5 on 2025-09-08 17:08
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('twitch', '0012_dropbenefit_search_vector_game_search_vector_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='dropcampaign',
+ name='allow_is_enabled',
+ field=models.BooleanField(default=True, help_text='Whether the campaign allows participation.'),
+ ),
+ migrations.CreateModel(
+ name='Channel',
+ fields=[
+ ('id', models.CharField(help_text='The unique Twitch identifier for the channel.', max_length=255, primary_key=True, serialize=False, verbose_name='Channel ID')),
+ ('name', models.CharField(db_index=True, help_text='The lowercase username of the channel.', max_length=255, verbose_name='Username')),
+ ('display_name', models.CharField(db_index=True, help_text='The display name of the channel (with proper capitalization).', max_length=255, verbose_name='Display Name')),
+ ('added_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Timestamp when this channel record was created.')),
+ ('updated_at', models.DateTimeField(auto_now=True, help_text='Timestamp when this channel record was last updated.')),
+ ],
+ options={
+ 'ordering': ['display_name'],
+ 'indexes': [models.Index(fields=['name'], name='twitch_chan_name_15d566_idx'), models.Index(fields=['display_name'], name='twitch_chan_display_2bf213_idx')],
+ },
+ ),
+ migrations.AddField(
+ model_name='dropcampaign',
+ name='allow_channels',
+ field=models.ManyToManyField(blank=True, help_text='Channels that are allowed to participate in this campaign.', related_name='allowed_campaigns', to='twitch.channel'),
+ ),
+ ]
diff --git a/twitch/models.py b/twitch/models.py
index 47cf818..153e578 100644
--- a/twitch/models.py
+++ b/twitch/models.py
@@ -173,6 +173,50 @@ class Game(models.Model):
return self.id
+class Channel(models.Model):
+ """Represents a Twitch channel that can participate in drop campaigns."""
+
+ id = models.CharField(
+ max_length=255,
+ primary_key=True,
+ verbose_name="Channel ID",
+ help_text="The unique Twitch identifier for the channel.",
+ )
+ name = models.CharField(
+ max_length=255,
+ db_index=True,
+ verbose_name="Username",
+ help_text="The lowercase username of the channel.",
+ )
+ display_name = models.CharField(
+ max_length=255,
+ db_index=True,
+ verbose_name="Display Name",
+ help_text="The display name of the channel (with proper capitalization).",
+ )
+
+ added_at = models.DateTimeField(
+ auto_now_add=True,
+ db_index=True,
+ help_text="Timestamp when this channel record was created.",
+ )
+ updated_at = models.DateTimeField(
+ auto_now=True,
+ help_text="Timestamp when this channel record was last updated.",
+ )
+
+ class Meta:
+ ordering = ["display_name"]
+ indexes: ClassVar[list] = [
+ models.Index(fields=["name"]),
+ models.Index(fields=["display_name"]),
+ ]
+
+ def __str__(self) -> str:
+ """Return a string representation of the channel."""
+ return self.display_name or self.name or self.id
+
+
class DropCampaign(models.Model):
"""Represents a Twitch drop campaign."""
@@ -224,6 +268,16 @@ class DropCampaign(models.Model):
default=False,
help_text="Indicates if the user account is linked.",
)
+ allow_is_enabled = models.BooleanField(
+ default=True,
+ help_text="Whether the campaign allows participation.",
+ )
+ allow_channels = models.ManyToManyField(
+ Channel,
+ blank=True,
+ related_name="allowed_campaigns",
+ help_text="Channels that are allowed to participate in this campaign.",
+ )
# PostgreSQL full-text search field
search_vector = SearchVectorField(null=True, blank=True)
diff --git a/twitch/views.py b/twitch/views.py
index 14abb08..492b97a 100644
--- a/twitch/views.py
+++ b/twitch/views.py
@@ -532,7 +532,8 @@ def dashboard(request: HttpRequest) -> HttpResponse:
Prefetch(
"time_based_drops",
queryset=TimeBasedDrop.objects.prefetch_related("benefits"),
- )
+ ),
+ "allow_channels",
)
)