diff --git a/twitch/feeds.py b/twitch/feeds.py
index c11f36a..9f593f8 100644
--- a/twitch/feeds.py
+++ b/twitch/feeds.py
@@ -3,36 +3,41 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from django.contrib.syndication.views import Feed
+from django.db.models.query import QuerySet
from django.urls import reverse
+from django.utils import timezone
from django.utils.html import format_html
+from django.utils.safestring import SafeText
-from twitch.models import DropCampaign, Game, Organization
+from twitch.models import DropCampaign, Game, Organization, TimeBasedDrop
if TYPE_CHECKING:
import datetime
+ from django.db.models import Model, QuerySet
+
# MARK: /rss/organizations/
class OrganizationFeed(Feed):
"""RSS feed for latest organizations."""
- title = "TTVDrops Organizations"
- link = "/organizations/"
- description = "Latest organizations on TTVDrops"
+ title: str = "TTVDrops Organizations"
+ link: str = "/organizations/"
+ description: str = "Latest organizations on TTVDrops"
def items(self) -> list[Organization]:
"""Return the latest 100 organizations."""
return list(Organization.objects.order_by("-id")[:100])
- def item_title(self, item: Organization) -> str:
+ def item_title(self, item: Model) -> SafeText:
"""Return the organization name as the item title."""
- return item.name
+ return SafeText(getattr(item, "name", str(item)))
- def item_description(self, item: Organization) -> str:
+ def item_description(self, item: Model) -> SafeText:
"""Return a description of the organization."""
- return f"Organization {item.name}"
+ return SafeText(f"Organization {getattr(item, 'name', str(item))}")
- def item_link(self, item: Organization) -> str:
+ def item_link(self, item: Model) -> str:
"""Return the link to the organization detail."""
return reverse("twitch:organization_detail", args=[item.pk])
@@ -41,23 +46,23 @@ class OrganizationFeed(Feed):
class GameFeed(Feed):
"""RSS feed for latest games."""
- title = "TTVDrops Games"
- link = "/games/"
- description = "Latest games on TTVDrops"
+ title: str = "TTVDrops Games"
+ link: str = "/games/"
+ description: str = "Latest games on TTVDrops"
def items(self) -> list[Game]:
"""Return the latest 100 games."""
return list(Game.objects.order_by("-id")[:100])
- def item_title(self, item: Game) -> str:
- """Return the game name as the item title."""
- return str(item)
+ def item_title(self, item: Model) -> SafeText:
+ """Return the game name as the item title (SafeText for RSS)."""
+ return SafeText(str(item))
- def item_description(self, item: Game) -> str:
+ def item_description(self, item: Model) -> SafeText:
"""Return a description of the game."""
- return f"Game {item.display_name}"
+ return SafeText(f"Game {getattr(item, 'display_name', str(item))}")
- def item_link(self, item: Game) -> str:
+ def item_link(self, item: Model) -> str:
"""Return the link to the game detail."""
return reverse("twitch:game_detail", args=[item.pk])
@@ -66,117 +71,116 @@ class GameFeed(Feed):
class DropCampaignFeed(Feed):
"""RSS feed for latest drop campaigns."""
- title = "Twitch Drop Campaigns"
- link = "/campaigns/"
- description = "Latest Twitch drop campaigns"
- feed_url = "/rss/campaigns/"
- feed_copyright = "Information wants to be free."
+ title: str = "Twitch Drop Campaigns"
+ link: str = "/campaigns/"
+ description: str = "Latest Twitch drop campaigns"
+ feed_url: str = "/rss/campaigns/"
+ feed_copyright: str = "Information wants to be free."
def items(self) -> list[DropCampaign]:
"""Return the latest 100 drop campaigns."""
return list(DropCampaign.objects.select_related("game").order_by("-added_at")[:100])
- def item_title(self, item: DropCampaign) -> str:
- """Return the campaign name as the item title."""
- return f"{item.game.display_name}: {item.clean_name}"
+ def item_title(self, item: Model) -> SafeText:
+ """Return the campaign name as the item title (SafeText for RSS)."""
+ game: Game | None = getattr(item, "game", None)
+ game_name: str = getattr(game, "display_name", str(game)) if game else ""
+ clean_name: str = getattr(item, "clean_name", str(item))
+ return SafeText(f"{game_name}: {clean_name}")
- def item_description(self, item: DropCampaign) -> str:
+ def item_description(self, item: Model) -> SafeText: # noqa: PLR0915
"""Return a description of the campaign."""
- description = ""
-
- # Include the campaign image if available
- if item.image_url:
+ description: str = ""
+ image_url: str | None = getattr(item, "image_url", None)
+ name: str = getattr(item, "name", str(item))
+ if image_url:
description += format_html(
'
',
- item.image_url,
- item.name,
+ image_url,
+ name,
)
-
- # Add the campaign description text
- description += format_html("
{}
", item.description) if item.description else ""
-
- # Add start and end dates for clarity
- if item.start_at:
- description += f"Starts: {item.start_at.strftime('%Y-%m-%d %H:%M %Z')}
"
- if item.end_at:
- description += f"Ends: {item.end_at.strftime('%Y-%m-%d %H:%M %Z')}
"
-
- # Add information about the drops in this campaign
- drops = item.time_based_drops.select_related().prefetch_related("benefits").all() # type: ignore[attr-defined]
+ desc_text: str | None = getattr(item, "description", None)
+ if desc_text:
+ description += format_html("{}
", desc_text)
+ start_at: datetime.datetime | None = getattr(item, "start_at", None)
+ end_at: datetime.datetime | None = getattr(item, "end_at", None)
+ if start_at:
+ description += f"Starts: {start_at.strftime('%Y-%m-%d %H:%M %Z')}
"
+ if end_at:
+ description += f"Ends: {end_at.strftime('%Y-%m-%d %H:%M %Z')}
"
+ drops: QuerySet[TimeBasedDrop] | None = getattr(item, "time_based_drops", None)
if drops:
- description += "Drops in this campaign:
"
- table_header = (
- ''
- ""
- '| Benefits | '
- 'Drop Name | '
- 'Requirements | '
- 'Period | '
- "
"
- )
- description += table_header
+ drops_qs: QuerySet[TimeBasedDrop] = drops.select_related().prefetch_related("benefits").all()
+ if drops_qs:
+ description += "Drops in this campaign:
"
+ table_header = (
+ ''
+ ""
+ '| Benefits | '
+ 'Drop Name | '
+ 'Requirements | '
+ 'Period | '
+ "
"
+ )
+ description += table_header
+ for drop in drops_qs:
+ description += ""
+ description += ''
+ for benefit in drop.benefits.all():
+ if getattr(benefit, "image_asset_url", None):
+ description += format_html(
+ ' ',
+ benefit.image_asset_url,
+ benefit.name,
+ )
+ else:
+ placeholder_img = (
+ ' '
+ )
+ description += placeholder_img
+ description += " | "
+ description += f'{getattr(drop, "name", str(drop))} | '
+ requirements: str = ""
+ if getattr(drop, "required_minutes_watched", None):
+ requirements = f"{drop.required_minutes_watched} minutes watched"
+ if getattr(drop, "required_subs", 0) > 0:
+ if requirements:
+ requirements += f" and {drop.required_subs} subscriptions required"
+ else:
+ requirements = f"{drop.required_subs} subscriptions required"
+ description += f'{requirements} | '
+ period: str = ""
+ start_at = getattr(drop, "start_at", None)
+ end_at = getattr(drop, "end_at", None)
+ if start_at is not None:
+ period += start_at.strftime("%Y-%m-%d %H:%M %Z")
+ if end_at is not None:
+ if period:
+ period += " - " + end_at.strftime("%Y-%m-%d %H:%M %Z")
+ else:
+ period = end_at.strftime("%Y-%m-%d %H:%M %Z")
+ description += f'{period} | '
+ description += "
"
+ description += "
"
+ details_url: str | None = getattr(item, "details_url", None)
+ if details_url:
+ description += format_html('About this drop
', details_url)
+ return SafeText(description)
- for drop in drops:
- description += ""
- # Benefits column with images
- description += ''
- for benefit in drop.benefits.all():
- if benefit.image_asset_url:
- description += format_html(
- ' ',
- benefit.image_asset_url,
- benefit.name,
- )
- else:
- placeholder_img = (
- ' '
- )
- description += placeholder_img
- description += " | "
-
- # Drop name
- description += f'{drop.name} | '
-
- # Requirements
- requirements = ""
- if drop.required_minutes_watched:
- requirements = f"{drop.required_minutes_watched} minutes watched"
- if drop.required_subs > 0:
- if requirements:
- requirements += f" and {drop.required_subs} subscriptions required"
- else:
- requirements = f"{drop.required_subs} subscriptions required"
- description += f'{requirements} | '
-
- # Period
- period = ""
- if drop.start_at:
- period += drop.start_at.strftime("%Y-%m-%d %H:%M %Z")
- if drop.end_at:
- if period:
- period += " - " + drop.end_at.strftime("%Y-%m-%d %H:%M %Z")
- else:
- period = drop.end_at.strftime("%Y-%m-%d %H:%M %Z")
- description += f'{period} | '
-
- description += "
"
-
- description += "
"
-
- # Add a clear link to the campaign details page
- if item.details_url:
- description += format_html('About this drop
', item.details_url)
-
- return f"{description}"
-
- def item_link(self, item: DropCampaign) -> str:
+ def item_link(self, item: Model) -> str:
"""Return the link to the campaign detail."""
return reverse("twitch:campaign_detail", args=[item.pk])
- def item_pubdate(self, item: DropCampaign) -> datetime.datetime:
- """Returns the publication date to the feed item."""
- return item.start_at
+ def item_pubdate(self, item: Model) -> datetime.datetime:
+ """Returns the publication date to the feed item. Fallback to updated_at or now if missing."""
+ start_at: datetime.datetime | None = getattr(item, "start_at", None)
+ if start_at:
+ return start_at
+ updated_at: datetime.datetime | None = getattr(item, "updated_at", None)
+ if updated_at:
+ return updated_at
+ return timezone.now()
def item_updateddate(self, item: DropCampaign) -> datetime.datetime:
"""Returns the campaign's last update time."""
diff --git a/twitch/views.py b/twitch/views.py
index e77f40d..7b527ef 100644
--- a/twitch/views.py
+++ b/twitch/views.py
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any
from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector
from django.core.serializers import serialize
-from django.db.models import Count, F, Prefetch, Q
+from django.db.models import BaseManager, Count, F, Model, Prefetch, Q
from django.db.models.functions import Trim
from django.db.models.query import QuerySet
from django.http import HttpRequest, HttpResponse
@@ -118,28 +118,27 @@ class OrgDetailView(DetailView):
Returns:
dict: Context data.
"""
- context = super().get_context_data(**kwargs)
- organization: Organization = self.object
+ context: dict[str, Any] = super().get_context_data(**kwargs)
+ organization: Organization = self.get_object() # pyright: ignore[reportAssignmentType]
+ games: QuerySet[Game] = organization.games.all() # pyright: ignore[reportAttributeAccessIssue]
- games: QuerySet[Game, Game] = organization.games.all() # pyright: ignore[reportAttributeAccessIssue]
-
- serialized_org = serialize(
+ serialized_org: str = serialize(
"json",
[organization],
fields=("name",),
)
- org_data = json.loads(serialized_org)
+ org_data: list[dict] = json.loads(serialized_org)
if games.exists():
- serialized_games = serialize(
+ serialized_games: str = serialize(
"json",
games,
fields=("slug", "name", "display_name", "box_art"),
)
- games_data = json.loads(serialized_games)
+ games_data: list[dict] = json.loads(serialized_games)
org_data[0]["fields"]["games"] = games_data
- pretty_org_data = json.dumps(org_data[0], indent=4)
+ pretty_org_data: str = json.dumps(org_data[0], indent=4)
context.update({
"games": games,
@@ -186,9 +185,9 @@ class DropCampaignListView(ListView):
context["games"] = Game.objects.all().order_by("display_name")
context["status_options"] = ["active", "upcoming", "expired"]
context["now"] = timezone.now()
- context["selected_game"] = str(self.request.GET.get(key="game", default=""))
+ context["selected_game"] = self.request.GET.get("game", "")
context["selected_per_page"] = self.paginate_by
- context["selected_status"] = self.request.GET.get(key="status", default="")
+ context["selected_status"] = self.request.GET.get("status", "")
return context
@@ -214,7 +213,7 @@ class DropCampaignDetailView(DetailView):
template_name = "twitch/campaign_detail.html"
context_object_name = "campaign"
- def get_object(self, queryset: QuerySet[DropCampaign] | None = None) -> DropCampaign:
+ def get_object(self, queryset: QuerySet[DropCampaign] | None = None) -> Model:
"""Get the campaign object with related data prefetched.
Args:
@@ -240,8 +239,8 @@ class DropCampaignDetailView(DetailView):
dict: Context data.
"""
context: dict[str, Any] = super().get_context_data(**kwargs)
- campaign = context["campaign"]
- drops: QuerySet[TimeBasedDrop, TimeBasedDrop] = (
+ campaign: DropCampaign = context["campaign"]
+ drops: BaseManager[TimeBasedDrop] = (
TimeBasedDrop.objects.filter(campaign=campaign).select_related("campaign").prefetch_related("benefits").order_by("required_minutes_watched")
)
@@ -276,11 +275,11 @@ class DropCampaignDetailView(DetailView):
"end_at",
),
)
- drops_data = json.loads(serialized_drops)
+ drops_data: list[dict[str, Any]] = json.loads(serialized_drops)
for i, drop in enumerate(drops):
- benefits = drop.benefits.all()
- if benefits.exists():
+ benefits: list[DropBenefit] = list(drop.benefits.all())
+ if benefits:
serialized_benefits = serialize(
"json",
benefits,
@@ -292,11 +291,11 @@ class DropCampaignDetailView(DetailView):
campaign_data[0]["fields"]["drops"] = drops_data
# Enhance drops with additional context data
- enhanced_drops = []
+ enhanced_drops: list[dict[str, TimeBasedDrop | datetime.datetime | str | None]] = []
now: datetime.datetime = timezone.now()
for drop in drops:
# Ensure benefits are loaded
- benefits = list(drop.benefits.all())
+ benefits: list[DropBenefit] = list(drop.benefits.all())
# Calculate countdown text
if drop.end_at and drop.end_at > now:
@@ -318,7 +317,7 @@ class DropCampaignDetailView(DetailView):
else:
countdown_text = "Expired"
- enhanced_drop: dict[str, str | datetime.datetime | TimeBasedDrop] = {
+ enhanced_drop: dict[str, TimeBasedDrop | datetime.datetime | str | None] = {
"drop": drop,
"local_start": drop.start_at,
"local_end": drop.end_at,
@@ -380,7 +379,7 @@ class GamesGridView(ListView):
context: dict[str, Any] = super().get_context_data(**kwargs)
now: datetime.datetime = timezone.now()
- games_with_campaigns: QuerySet[Game, Game] = (
+ games_with_campaigns: BaseManager[Game] = (
Game.objects.filter(drop_campaigns__isnull=False)
.select_related("owner")
.annotate(
@@ -426,10 +425,10 @@ class GameDetailView(DetailView):
Expired campaigns are filtered based on either end date or status.
"""
context: dict[str, Any] = super().get_context_data(**kwargs)
- game: Game = self.get_object()
+ game: Game = self.get_object() # pyright: ignore[reportAssignmentType]
now: datetime.datetime = timezone.now()
- all_campaigns: QuerySet[DropCampaign, DropCampaign] = (
+ all_campaigns: BaseManager[DropCampaign] = (
DropCampaign.objects.filter(game=game)
.select_related("game__owner")
.prefetch_related(
@@ -456,14 +455,14 @@ class GameDetailView(DetailView):
# Add unique sorted benefits to each campaign object
for campaign in all_campaigns:
- benefits_dict = {} # Use dict to track unique benefits by ID
+ benefits_dict: dict[int, DropBenefit] = {} # Use dict to track unique benefits by ID
for drop in campaign.time_based_drops.all(): # type: ignore[attr-defined]
for benefit in drop.benefits.all():
benefits_dict[benefit.id] = benefit
# Sort benefits by name and attach to campaign
campaign.sorted_benefits = sorted(benefits_dict.values(), key=lambda b: b.name) # type: ignore[attr-defined]
- serialized_game = serialize(
+ serialized_game: str = serialize(
"json",
[game],
fields=(
@@ -474,7 +473,7 @@ class GameDetailView(DetailView):
"owner",
),
)
- game_data = json.loads(serialized_game)
+ game_data: list[dict[str, Any]] = json.loads(serialized_game)
if all_campaigns.exists():
serialized_campaigns = serialize(
@@ -491,7 +490,7 @@ class GameDetailView(DetailView):
"is_account_connected",
),
)
- campaigns_data = json.loads(serialized_campaigns)
+ campaigns_data: list[dict[str, Any]] = json.loads(serialized_campaigns)
game_data[0]["fields"]["campaigns"] = campaigns_data
context.update({
@@ -500,7 +499,7 @@ class GameDetailView(DetailView):
"expired_campaigns": expired_campaigns,
"owner": game.owner,
"now": now,
- "game_data": format_and_color_json(game_data[0]),
+ "game_data": format_and_color_json(json.dumps(game_data[0], indent=4)),
})
return context
@@ -718,10 +717,10 @@ class ChannelDetailView(DetailView):
dict: Context data with active, upcoming, and expired campaigns for this channel.
"""
context: dict[str, Any] = super().get_context_data(**kwargs)
- channel: Channel = self.get_object()
+ channel: Channel = self.get_object() # pyright: ignore[reportAssignmentType]
now: datetime.datetime = timezone.now()
- all_campaigns: QuerySet[DropCampaign, DropCampaign] = (
+ all_campaigns: QuerySet[DropCampaign] = (
DropCampaign.objects.filter(allow_channels=channel)
.select_related("game__owner")
.prefetch_related(
@@ -749,7 +748,7 @@ class ChannelDetailView(DetailView):
# Add unique sorted benefits to each campaign object
for campaign in all_campaigns:
- benefits_dict = {} # Use dict to track unique benefits by ID
+ benefits_dict: dict[int, DropBenefit] = {} # Use dict to track unique benefits by ID
for drop in campaign.time_based_drops.all(): # type: ignore[attr-defined]
for benefit in drop.benefits.all():
benefits_dict[benefit.id] = benefit