From 05eb0d92e3407a99f716616241e08e179bf2a9da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Wed, 11 Feb 2026 03:14:04 +0100 Subject: [PATCH] Refactor HTML --- .github/copilot-instructions.md | 8 + .vscode/settings.json | 1 + templates/base.html | 57 ++-- templates/twitch/badge_list.html | 15 +- templates/twitch/badge_set_detail.html | 72 ++--- templates/twitch/campaign_detail.html | 115 +++---- templates/twitch/campaign_list.html | 18 ++ templates/twitch/channel_detail.html | 11 +- templates/twitch/channel_list.html | 50 ++- templates/twitch/dashboard.html | 71 ++--- templates/twitch/dataset_backups.html | 23 +- templates/twitch/debug.html | 59 ++-- templates/twitch/emote_gallery.html | 39 +-- templates/twitch/game_detail.html | 26 +- templates/twitch/games_grid.html | 16 +- templates/twitch/games_list.html | 25 +- templates/twitch/org_list.html | 7 + templates/twitch/reward_campaign_detail.html | 8 +- twitch/feeds.py | 44 +-- twitch/models.py | 35 ++- twitch/tests/test_backup.py | 13 +- twitch/tests/test_badge_views.py | 1 - twitch/tests/test_exports.py | 125 ++++++++ twitch/tests/test_feeds.py | 4 +- twitch/tests/test_views.py | 4 +- twitch/urls.py | 10 +- twitch/views.py | 312 ++++++++++++++++++- 27 files changed, 776 insertions(+), 393 deletions(-) create mode 100644 twitch/tests/test_exports.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 89fa815..5b441f8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -30,6 +30,8 @@ - Use template tags and filters for common operations - Avoid complex logic in templates - move it to views or template tags - Use static files properly with `{% load static %}` +- Avoid hiding controls with `
` or other collapse elements unless explicitly needed +- Prioritize accessibility and discoverability of features ## Settings - Use environment variables in a single `settings.py` file @@ -67,3 +69,9 @@ - Management commands in `twitch/management/commands/` for data import and maintenance tasks - Use `pyproject.toml` + uv for dependency and environment management - Use `uv run python manage.py ` to run Django management commands + +## Documentation & Project Organization +- Only create documentation files when explicitly requested by the user +- Do not generate markdown files summarizing work or changes unless asked +- Keep code comments focused on "why" not "what"; the code itself should be clear +- Update existing documentation rather than creating new files diff --git a/.vscode/settings.json b/.vscode/settings.json index e834d89..1302d02 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -27,6 +27,7 @@ "Facepunch", "Feedly", "filterwarnings", + "forloop", "granian", "gunicorn", "Hellsén", diff --git a/templates/base.html b/templates/base.html index 6b86ac6..bbae34e 100644 --- a/templates/base.html +++ b/templates/base.html @@ -155,31 +155,38 @@ -

ttvdrops

- Twitch: - Dashboard | - Campaigns | - Rewards | - Games | - Orgs | - Channels | - Badges | - Emotes -
- Other: - RSS | - Debug | - Dataset | - Donate | - GitHub -
-
- - -
+ +
{% if messages %}
    {% for message in messages %} diff --git a/templates/twitch/badge_list.html b/templates/twitch/badge_list.html index 5dfc573..9db3ef5 100644 --- a/templates/twitch/badge_list.html +++ b/templates/twitch/badge_list.html @@ -3,14 +3,9 @@ Chat Badges - ttvdrops {% endblock title %} {% block content %} -

    Twitch Chat Badges

    -
    -These are the global chat badges available on Twitch.
    -
    +

    {{ badge_sets.count }} Twitch Chat Badges

    {% if badge_sets %} -

    total badge sets: {{ badge_sets.count }}

    {% for data in badge_data %} -

    [{{ data.set.set_id }}]

    @@ -28,9 +23,10 @@ These are the global chat badges available on Twitch. {{ badge.title }} -
    -
    - {{ badge.description }} + {% if badge.description != badge.title %} +
    + {{ badge.description }} + {% endif %} @@ -38,7 +34,6 @@ These are the global chat badges available on Twitch.
    {% if data.badges|length > 1 %}versions: {{ data.badges|length }}{% endif %} {% endfor %} -
    {% else %}

    No badge sets found.

    diff --git a/templates/twitch/badge_set_detail.html b/templates/twitch/badge_set_detail.html index 83059e1..e724454 100644 --- a/templates/twitch/badge_set_detail.html +++ b/templates/twitch/badge_set_detail.html @@ -3,33 +3,21 @@ {{ badge_set.set_id }} Badges - ttvdrops {% endblock title %} {% block content %} -

    - Badge Set: {{ badge_set.set_id }} -

    -

    - Back to all badges -

    -
      -
    • - Set ID: {{ badge_set.set_id }} -
    • -
    • - Total Versions: {{ badges.count }} -
    • -
    • - Added: {{ badge_set.added_at|date:"Y-m-d H:i:s T" }} -
    • -
    • - Updated: {{ badge_set.updated_at|date:"Y-m-d H:i:s T" }} -
    • -
    +

    {{ badge_set.set_id }}

    {% if badges %} -

    Badge Versions ({{ badges.count }})

    +

    + {{ badges.count }} + {% if badges.count == 1 %} + version + {% else %} + versions + {% endif %} +

    - + @@ -52,35 +40,33 @@ height: 72px !important; object-fit: contain" /> - + - + {% if badge.award_campaigns %} +
    + The following campaigns have the same name as this badge and may be awarding it: + +
    + {% endif %} {% endfor %}
    IDPreview Title Description Images - {{ badge.title }} - {{ badge.title }} {{ badge.description }} - 18px | - 36px | - 72px + + [18px] + [36px] + [72px] {% if badge.click_url %} - {{ badge.click_action|default:"visit_url" }} + {{ badge.click_action }} {% else %} - None - {% endif %} - {% if badge.award_campaigns %} -
    - Awarded by Drop Campaigns: - -
    + - {% endif %}
    diff --git a/templates/twitch/campaign_detail.html b/templates/twitch/campaign_detail.html index eeae339..6bd0aee 100644 --- a/templates/twitch/campaign_detail.html +++ b/templates/twitch/campaign_detail.html @@ -5,52 +5,37 @@ {% endblock title %} {% block content %} - {% if campaign.game %} -

    - {{ campaign.game.get_game_name }} - {{ campaign.clean_name }} -

    - {% else %} -

    {{ campaign.clean_name }}

    - {% endif %} - {% if owner %} -

    - {{ owner.name }} -

    - {% endif %} - -
    +

    {% if campaign.game %} - RSS feed for {{ campaign.game.display_name }} campaigns + {{ campaign.game.get_game_name }} - {{ campaign.clean_name }} + {% else %} + {{ campaign.clean_name }} {% endif %} - {% if owner %} - RSS feed for {{ owner.name }} campaigns - {% endif %} -

    + + + {% for org in owners %} +

    + {{ org.name }} +

    + {% endfor %} - {% if campaign.image_best_url or campaign.image_url %} - {{ campaign.name }} {% endif %} -

    {{ campaign.description|linebreaksbr }}

    +

    {{ campaign.description|linebreaksbr }}

    {% if campaign.end_at < now %} - {% else %} - @@ -59,73 +44,47 @@
    {% if campaign.start_at > now %} - {% else %} - {% endif %}
    - -
    - -
    -
    - - {% if campaign.details_url %} - {# TODO: Archive this URL automatically #} -

    - Official Details -

    - {% endif %} - - {% if campaign.account_link_url %} - {# TODO: Archive this URL automatically #} -

    - Connect Account -

    - {% endif %} - +
    + + {% if campaign.details_url %}[details]{% endif %} + + {% if campaign.account_link_url %} + [connect] + {% endif %} + + {% if campaign.game %} + [rss] + {% endif %} +
    {% if allowed_channels %}
    Allowed Channels
    -
    +
    {% for channel in allowed_channels %} - {{ channel.display_name }} + {{ channel.display_name }} {% endfor %}
    {% else %} Go to a participating live channel @@ -135,7 +94,7 @@ - + diff --git a/templates/twitch/campaign_list.html b/templates/twitch/campaign_list.html index 4f84d1b..867ab91 100644 --- a/templates/twitch/campaign_list.html +++ b/templates/twitch/campaign_list.html @@ -14,6 +14,24 @@ style="margin-right: 1rem" title="RSS feed for all campaigns">RSS feed for all campaigns + + -

    {{ channel.display_name }}

    +

    {{ channel.display_name }}

    {% if channel.display_name != channel.name %} -

    +

    Username: {{ channel.name }}

    {% endif %} @@ -14,13 +14,6 @@

    Channel ID: {{ channel.twitch_id }}

    -

    - Added to database: - -

    {% if active_campaigns %}
    Active Campaigns
    Benefits Drop Name Requirements Period
    diff --git a/templates/twitch/channel_list.html b/templates/twitch/channel_list.html index ee034a6..ec03c19 100644 --- a/templates/twitch/channel_list.html +++ b/templates/twitch/channel_list.html @@ -1,21 +1,17 @@ {% extends "base.html" %} {% load static %} {% block title %} - Channels - Twitch Drops Tracker + Channels {% endblock title %} {% block content %} -

    Channels

    +

    Channels

    Browse all channels that can participate in drop campaigns

    - - + - + {% if search_query %} Clear {% endif %} @@ -27,42 +23,36 @@ - {% for channel in channels %} - + - {% endfor %}
    Channel Username CampaignsAdded
    - {{ channel.display_name }} + {{ channel.display_name }} {{ channel.name }} {{ channel.campaign_count|default:0 }} - -
    {% if is_paginated %} -

    - {% if page_obj.has_previous %} - «« - « - {% endif %} - Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} - {% if page_obj.has_next %} - » - »» - {% endif %} -

    -

    Showing {{ page_obj.start_index }} to {{ page_obj.end_index }} of {{ page_obj.paginator.count }} channels

    +
    +

    + {% if page_obj.has_previous %} + [first] + [previous] + {% endif %} + Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} + {% if page_obj.has_next %} + [next] + [last] + {% endif %} +

    +

    Showing {{ page_obj.start_index }} to {{ page_obj.end_index }} of {{ page_obj.paginator.count }} channels

    +
    {% endif %} {% else %} {% if search_query %} diff --git a/templates/twitch/dashboard.html b/templates/twitch/dashboard.html index 8f4ea82..f6ee0c2 100644 --- a/templates/twitch/dashboard.html +++ b/templates/twitch/dashboard.html @@ -5,7 +5,7 @@ {% endblock title %} {% block content %}
    -

    Twitch Drops

    +

    Twitch Drops

     Latest drops are shown first within each game. Click on a campaign or game title to see more details.
     Hover over the end time to see the exact date and time.
    @@ -43,82 +43,73 @@ Hover over the end time to see the exact date and time.
                             
    - {% for campaign in game_data.campaigns %} -
    - - Image for {{ campaign.name }} + Image for {{ campaign_data.campaign.name }} -

    {{ campaign.clean_name }}

    +

    {{ campaign_data.campaign.clean_name }}

    - - - -
    Channels:
      - {% if campaign.allow_is_enabled %} - {% if campaign.allow_channels.all %} - {% for channel in campaign.allow_channels.all %} + {% if campaign_data.campaign.allow_is_enabled %} + {% if campaign_data.allowed_channels %} + {% for channel in campaign_data.allowed_channels %} {% if forloop.counter <= 5 %}
    • {{ channel.display_name }}
    • {% endif %} {% endfor %} - {% if campaign.allow_channels.all|length > 5 %} + {% if campaign_data.allowed_channels|length > 5 %}
    • - ... and {{ campaign.allow_channels.all|length|add:"-5" }} more + ... and {{ campaign_data.allowed_channels|length|add:"-5" }} more
    • {% endif %} {% else %} {% if campaign.game.twitch_directory_url %}
    • - Browse {{ campaign.game.display_name }} category + rel="nofollow ugc" + title="Open Twitch category page for {{ campaign_data.campaign.game.display_name }} with Drops filter"> + Browse {{ campaign_data.campaign.game.display_name }} category
    • {% else %} @@ -126,12 +117,11 @@ Hover over the end time to see the exact date and time. {% endif %} {% endif %} {% else %} - {% if campaign.game.twitch_directory_url %} + {% if campaign_data.campaign.game.twitch_directory_url %}
    • - + Go to a participating live channel
    • @@ -227,8 +217,7 @@ Hover over the end time to see the exact date and time. {% if campaign.external_url %}
      -

      Dataset Backups

      -

      Scanning {{ data_dir }} for database backups.

      +

      Dataset Backups

      {% if datasets %} - - {% for dataset in datasets %} - - - + + - {% endfor %} diff --git a/templates/twitch/debug.html b/templates/twitch/debug.html index 8e418dc..a76aeaf 100644 --- a/templates/twitch/debug.html +++ b/templates/twitch/debug.html @@ -3,12 +3,13 @@ Debug {% endblock title %} {% block content %} -

      Debug Data Integrity Report

      +

      Debug

      - Generated at: + Generated at:

      -

      Distinct GraphQL operation_names ({{ operation_names_with_counts|length }})

      +

      Distinct GraphQL operation_names ({{ operation_names_with_counts|length }})

      {% if operation_names_with_counts %}
      NamePath Size UpdatedDownload
      {{ dataset.name }}{{ dataset.display_path }} + {{ dataset.name }} + {{ dataset.size }} - {% if dataset.download_path %} - Download - {% else %} - - - {% endif %} -
      @@ -29,11 +30,11 @@ {% endif %}
      -

      Games Without an Assigned Owner ({{ games_without_owner|length }})

      +

      Games Without an Assigned Owner ({{ games_without_owner|length }})

      {% if games_without_owner %} -
        +
          {% for game in games_without_owner %} -
        • +
        • {{ game.display_name }} (ID: {{ game.twitch_id }})
        • {% endfor %} @@ -43,14 +44,13 @@ {% endif %}
      -

      Campaigns With Broken Image URLs ({{ broken_image_campaigns|length }})

      +

      Campaigns without Image URLs ({{ broken_image_campaigns|length }})

      {% if broken_image_campaigns %} -
        + @@ -59,16 +59,13 @@ {% endif %}
      -

      Benefits With Broken Image URLs ({{ broken_benefit_images|length }})

      +

      Benefits without image URLs ({{ broken_benefit_images|length }})

      {% if broken_benefit_images %} -
        +
          {% for b in broken_benefit_images %} - {# A benefit is linked to a game via a drop and a campaign. #} - {# We use the 'with' tag to get the first drop for cleaner access. #} {% with first_drop=b.drops.all.0 %} -
        • +
        • {{ b.name }} - {# Check if the relationship path to the game exists #} {% if first_drop and first_drop.campaign and first_drop.campaign.game %} (Game: {{ first_drop.campaign.game.display_name }}) {% else %} @@ -84,11 +81,11 @@ {% endif %}
      -

      Active Campaigns Missing Image ({{ active_missing_image|length }})

      +

      Active Campaigns Missing Image ({{ active_missing_image|length }})

      {% if active_missing_image %} -
      -

      Time-Based Drops Without Benefits ({{ drops_without_benefits|length }})

      +

      Time-Based Drops Without Benefits ({{ drops_without_benefits|length }})

      {% if drops_without_benefits %} -
      -

      Campaigns With Invalid Dates ({{ invalid_date_campaigns|length }})

      +

      Campaigns With Invalid Dates ({{ invalid_date_campaigns|length }})

      {% if invalid_date_campaigns %} -
        +
          {% for c in invalid_date_campaigns %} -
        • +
        • {{ c.name }} (Game: {{ c.game.display_name }}) - Start: {{ c.start_at|default:'(none)' }} / End: {{ c.end_at|default:'(none)' }} @@ -131,9 +128,9 @@ {% endif %}
      -

      Duplicate Campaign Names Per Game ({{ duplicate_name_campaigns|length }})

      +

      Duplicate Campaign Names Per Game ({{ duplicate_name_campaigns|length }})

      {% if duplicate_name_campaigns %} -
      +
      @@ -158,13 +155,11 @@ {% endif %}
      -

      - Campaigns Missing DropCampaignDetails ({{ campaigns_missing_dropcampaigndetails|length }}) -

      +

      Campaigns Missing DropCampaignDetails ({{ campaigns_missing_dropcampaigndetails|length }})

      {% if campaigns_missing_dropcampaigndetails %} -
        +
          {% for c in campaigns_missing_dropcampaigndetails %} -
        • +
        • {{ c.name }} (Game: {{ c.game.display_name }}) - Operations: {{ c.operation_names|join:", "|default:'(none)' }} diff --git a/templates/twitch/emote_gallery.html b/templates/twitch/emote_gallery.html index d7c2481..c9833be 100644 --- a/templates/twitch/emote_gallery.html +++ b/templates/twitch/emote_gallery.html @@ -4,28 +4,19 @@ {% endblock title %} {% block content %}

          Emotes

          - + {% for emote in emotes %} + + Emote + + {% empty %} +

          No drop campaigns with emotes found.

          + {% endfor %} {% endblock content %} diff --git a/templates/twitch/game_detail.html b/templates/twitch/game_detail.html index 387f8d3..684658a 100644 --- a/templates/twitch/game_detail.html +++ b/templates/twitch/game_detail.html @@ -4,22 +4,21 @@ {% endblock title %} {% block content %} -

          +

          {{ game.display_name }} {% if game.display_name != game.name and game.name %}({{ game.name }}){% endif %}

          -
          + @@ -31,9 +30,14 @@ alt="{{ game.name }}" /> {% endif %} - {% if owner %} - {{ owner.name }} + {% if owners %} + + {% for owner in owners %} + {{ owner.name }} + {% if not forloop.last %},{% endif %} + {% endfor %} + {% endif %} {% if active_campaigns %}
          Active Campaigns
          diff --git a/templates/twitch/games_grid.html b/templates/twitch/games_grid.html index 8ed4927..c4e13fe 100644 --- a/templates/twitch/games_grid.html +++ b/templates/twitch/games_grid.html @@ -6,15 +6,13 @@

          All Games

          -

          Browse all available games

          -

          - List View -

          - -
          {% if games_by_org %} diff --git a/templates/twitch/games_list.html b/templates/twitch/games_list.html index addf3de..4995d6a 100644 --- a/templates/twitch/games_list.html +++ b/templates/twitch/games_list.html @@ -4,22 +4,23 @@ {% endblock title %} {% block content %}
          -

          Games List

          -

          - Grid View -

          - -
          - RSS feed for all games +

          Games List

          + {% if games_by_org %} {% for organization, games in games_by_org.items %} -

          {{ organization.name }}

          -
            +

            + {{ organization.name }} +

            +
              {% for item in games %} -
            • +
            • {{ item.game.display_name }}
            • {% endfor %} diff --git a/templates/twitch/org_list.html b/templates/twitch/org_list.html index a8be4ed..9a6c680 100644 --- a/templates/twitch/org_list.html +++ b/templates/twitch/org_list.html @@ -10,6 +10,13 @@ style="margin-right: 1rem" title="RSS feed for all organizations">RSS feed for organizations
          + +
          + [csv] + [json] +
          {% if orgs %}
            {% for organization in orgs %} diff --git a/templates/twitch/reward_campaign_detail.html b/templates/twitch/reward_campaign_detail.html index 4891b49..db57243 100644 --- a/templates/twitch/reward_campaign_detail.html +++ b/templates/twitch/reward_campaign_detail.html @@ -90,14 +90,10 @@ {% if reward_campaign.external_url or reward_campaign.about_url %}

            {% if reward_campaign.external_url %} - Claim Reward → + Claim Reward → {% endif %} {% if reward_campaign.about_url %} - Learn More → + Learn More → {% endif %}

            {% endif %} diff --git a/twitch/feeds.py b/twitch/feeds.py index fc8c488..c921e4d 100644 --- a/twitch/feeds.py +++ b/twitch/feeds.py @@ -48,7 +48,15 @@ def _with_campaign_related(queryset: QuerySet[DropCampaign]) -> QuerySet[DropCam queryset=TimeBasedDrop.objects.prefetch_related("benefits"), ) - return queryset.select_related("game").prefetch_related("game__owners", "allow_channels", drops_prefetch) + return queryset.select_related("game").prefetch_related( + "game__owners", + Prefetch( + "allow_channels", + queryset=Channel.objects.order_by("display_name"), + to_attr="channels_ordered", + ), + drops_prefetch, + ) def insert_date_info(item: Model, parts: list[SafeText]) -> None: @@ -122,30 +130,27 @@ def _build_drops_data(drops_qs: QuerySet[TimeBasedDrop]) -> list[dict]: return drops_data -def _build_channels_html(channels: QuerySet[Channel], game: Game | None) -> SafeText: +def _build_channels_html(channels: list[Channel] | QuerySet[Channel], game: Game | None) -> SafeText: """Render up to max_links channel links as
          • , then a count of additional channels, or fallback to game category link. If only one channel and drop_requirements is '1 subscriptions required', merge the Twitch link with the '1 subs' row. Args: - channels (QuerySet[Channel]): The queryset of channels. + channels (list[Channel] | QuerySet[Channel]): The channels (already ordered). game (Game | None): The game object for fallback link. Returns: SafeText: HTML
              with up to max_links channel links, count of more, or fallback link. """ # noqa: E501 max_links = 5 - channels_all: list[Channel] = list(channels.all()) + channels_all: list[Channel] = list(channels) if isinstance(channels, list) else list(channels.all()) total: int = len(channels_all) if channels_all: items: list[SafeString] = [ format_html( - "
            • " - '{}' - "
            • ", + '
            • {}
            • ', ch.name, ch.display_name, ch.display_name, @@ -175,10 +180,7 @@ def _build_channels_html(channels: QuerySet[Channel], game: Game | None) -> Safe # If no channel is associated, the drop is category-wide; link to the game's Twitch directory display_name: str = getattr(game, "display_name", "this game") return format_html( - "", + '', game.twitch_directory_url, display_name, display_name, @@ -189,11 +191,9 @@ def _get_channel_name_from_drops(drops: QuerySet[TimeBasedDrop]) -> str | None: for d in drops: campaign: DropCampaign | None = getattr(d, "campaign", None) if campaign: - allow_channels: QuerySet[Channel] | None = getattr(campaign, "allow_channels", None) - if allow_channels: - channels: QuerySet[Channel, Channel] = allow_channels.all() - if channels: - return channels[0].name + channels: list[Channel] | None = getattr(campaign, "channels_ordered", None) + if channels: + return channels[0].name return None @@ -279,7 +279,7 @@ def _construct_drops_summary(drops_data: list[dict]) -> SafeText: badge_desc: str | None = badge_descriptions_by_title.get(benefit_name) if is_sub_required and channel_name: linked_name: SafeString = format_html( - '{}', + '{}', channel_name, benefit_name, ) @@ -427,7 +427,7 @@ class GameFeed(Feed): if slug: description_parts.append( SafeText( - f"

              {game_name} by {game_owner}

              ", # noqa: E501 + f"

              {game_name} by {game_owner}

              ", ), ) else: @@ -559,7 +559,7 @@ class DropCampaignFeed(Feed): # Only show channels if drop is not subscription only if not getattr(item, "is_subscription_only", False): - channels: QuerySet[Channel] | None = getattr(item, "allow_channels", None) + channels: list[Channel] | None = getattr(item, "channels_ordered", None) if channels is not None: game: Game | None = getattr(item, "game", None) parts.append(_build_channels_html(channels, game=game)) @@ -704,7 +704,7 @@ class GameCampaignFeed(Feed): # Only show channels if drop is not subscription only if not getattr(item, "is_subscription_only", False): - channels: QuerySet[Channel] | None = getattr(item, "allow_channels", None) + channels: list[Channel] | None = getattr(item, "channels_ordered", None) if channels is not None: game: Game | None = getattr(item, "game", None) parts.append(_build_channels_html(channels, game=game)) @@ -888,7 +888,7 @@ class OrganizationCampaignFeed(Feed): # Only show channels if drop is not subscription only if not getattr(item, "is_subscription_only", False): - channels: QuerySet[Channel] | None = getattr(item, "allow_channels", None) + channels: list[Channel] | None = getattr(item, "channels_ordered", None) if channels is not None: game: Game | None = getattr(item, "game", None) parts.append(_build_channels_html(channels, game=game)) diff --git a/twitch/models.py b/twitch/models.py index 528571b..2349b35 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -66,8 +66,7 @@ class Organization(auto_prefetch.Model): url: str = reverse("twitch:organization_detail", args=[self.twitch_id]) return format_html( - "

              New Twitch organization added to TTVDrops:

              \n" - '

              {}

              ', + '

              New Twitch organization added to TTVDrops:

              \n

              {}

              ', url, name, ) @@ -456,6 +455,38 @@ class DropCampaign(auto_prefetch.Model): ) return self.image_url or "" + @property + def duration_iso(self) -> str: + """Return the campaign duration in ISO 8601 format (e.g., 'P3DT4H30M'). + + This is used for the
      Game