Update feeds
This commit is contained in:
parent
4b4723c77c
commit
fd856d839b
1 changed files with 504 additions and 262 deletions
668
twitch/feeds.py
668
twitch/feeds.py
|
|
@ -32,106 +32,78 @@ if TYPE_CHECKING:
|
||||||
logger: logging.Logger = logging.getLogger("ttvdrops")
|
logger: logging.Logger = logging.getLogger("ttvdrops")
|
||||||
|
|
||||||
|
|
||||||
# MARK: /rss/organizations/
|
def insert_date_info(item: Model, parts: list[SafeText]) -> None:
|
||||||
class OrganizationFeed(Feed):
|
"""Insert start and end date information into parts list.
|
||||||
"""RSS feed for latest organizations."""
|
|
||||||
|
|
||||||
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("-updated_at")[:100])
|
|
||||||
|
|
||||||
def item_title(self, item: Model) -> SafeText:
|
|
||||||
"""Return the organization name as the item title."""
|
|
||||||
return SafeText(getattr(item, "name", str(item)))
|
|
||||||
|
|
||||||
def item_description(self, item: Model) -> SafeText:
|
|
||||||
"""Return a description of the organization."""
|
|
||||||
return SafeText(f"Organization {getattr(item, 'name', str(item))}")
|
|
||||||
|
|
||||||
def item_link(self, item: Model) -> str:
|
|
||||||
"""Return the link to the organization detail."""
|
|
||||||
return reverse("twitch:organization_detail", args=[item.pk])
|
|
||||||
|
|
||||||
|
|
||||||
# MARK: /rss/games/
|
|
||||||
class GameFeed(Feed):
|
|
||||||
"""RSS feed for latest games."""
|
|
||||||
|
|
||||||
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: Model) -> SafeText:
|
|
||||||
"""Return the game name as the item title (SafeText for RSS)."""
|
|
||||||
return SafeText(str(item))
|
|
||||||
|
|
||||||
def item_description(self, item: Model) -> SafeText:
|
|
||||||
"""Return a description of the game."""
|
|
||||||
return SafeText(f"Game {getattr(item, 'display_name', str(item))}")
|
|
||||||
|
|
||||||
def item_link(self, item: Model) -> str:
|
|
||||||
"""Return the link to the game detail."""
|
|
||||||
return reverse("twitch:game_detail", args=[item.pk])
|
|
||||||
|
|
||||||
|
|
||||||
# MARK: /rss/campaigns/
|
|
||||||
class DropCampaignFeed(Feed):
|
|
||||||
"""RSS feed for latest drop campaigns."""
|
|
||||||
|
|
||||||
def _get_channel_name_from_drops(self, 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
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_channel_from_benefit(self, benefit: Model) -> str | None:
|
|
||||||
"""Get the Twitch channel name associated with a drop benefit.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
benefit (Model): The drop benefit model instance.
|
item (Model): The campaign item containing start_at and end_at.
|
||||||
|
parts (list[SafeText]): The list of HTML parts to append to.
|
||||||
|
"""
|
||||||
|
end_at: datetime.datetime | None = getattr(item, "end_at", None)
|
||||||
|
start_at: datetime.datetime | None = getattr(item, "start_at", None)
|
||||||
|
|
||||||
|
if start_at or end_at:
|
||||||
|
start_part: SafeString = (
|
||||||
|
format_html("Starts: {} ({})", start_at.strftime("%Y-%m-%d %H:%M %Z"), naturaltime(start_at))
|
||||||
|
if start_at
|
||||||
|
else SafeText("")
|
||||||
|
)
|
||||||
|
end_part: SafeString = (
|
||||||
|
format_html("Ends: {} ({})", end_at.strftime("%Y-%m-%d %H:%M %Z"), naturaltime(end_at))
|
||||||
|
if end_at
|
||||||
|
else SafeText("")
|
||||||
|
)
|
||||||
|
# Start date and end date separated by a line break if both present
|
||||||
|
if start_part and end_part:
|
||||||
|
parts.append(format_html("<p>{}<br />{}</p>", start_part, end_part))
|
||||||
|
elif start_part:
|
||||||
|
parts.append(format_html("<p>{}</p>", start_part))
|
||||||
|
elif end_part:
|
||||||
|
parts.append(format_html("<p>{}</p>", end_part))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_drops_data(drops_qs: QuerySet[TimeBasedDrop]) -> list[dict]:
|
||||||
|
"""Build a simplified data structure for rendering drops in a template.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str | None: The Twitch channel name if found, else None.
|
list[dict]: A list of dictionaries each containing `name`, `benefits`,
|
||||||
|
`requirements`, and `period` for a drop, suitable for template rendering.
|
||||||
"""
|
"""
|
||||||
drop_obj: QuerySet[TimeBasedDrop] | None = getattr(benefit, "drops", None)
|
drops_data: list[dict] = []
|
||||||
if drop_obj and hasattr(drop_obj, "all"):
|
for drop in drops_qs:
|
||||||
try:
|
requirements: str = ""
|
||||||
return self._get_channel_name_from_drops(drop_obj.all())
|
required_minutes: int | None = getattr(drop, "required_minutes_watched", None)
|
||||||
except AttributeError:
|
required_subs: int = getattr(drop, "required_subs", 0) or 0
|
||||||
logger.exception("Exception occurred while resolving channel name for benefit")
|
if required_minutes:
|
||||||
return None
|
requirements = f"{required_minutes} minutes watched"
|
||||||
|
if required_subs > 0:
|
||||||
|
sub_word: Literal["subs", "sub"] = "subs" if required_subs > 1 else "sub"
|
||||||
|
if requirements:
|
||||||
|
requirements += f" and {required_subs} {sub_word} required"
|
||||||
|
else:
|
||||||
|
requirements = f"{required_subs} {sub_word} required"
|
||||||
|
|
||||||
def _resolve_channel_name(self, drop: dict) -> str | None:
|
period: str = ""
|
||||||
"""Try to resolve the Twitch channel name for a drop dict's benefits or fallback keys.
|
drop_start: datetime.datetime | None = getattr(drop, "start_at", None)
|
||||||
|
drop_end: datetime.datetime | None = getattr(drop, "end_at", None)
|
||||||
|
if drop_start is not None:
|
||||||
|
period += drop_start.strftime("%Y-%m-%d %H:%M %Z")
|
||||||
|
if drop_end is not None:
|
||||||
|
if period:
|
||||||
|
period += " - " + drop_end.strftime("%Y-%m-%d %H:%M %Z")
|
||||||
|
else:
|
||||||
|
period = drop_end.strftime("%Y-%m-%d %H:%M %Z")
|
||||||
|
|
||||||
Args:
|
drops_data.append({
|
||||||
drop (dict): The drop data dictionary.
|
"name": getattr(drop, "name", str(drop)),
|
||||||
|
"benefits": list(drop.benefits.all()),
|
||||||
|
"requirements": requirements,
|
||||||
|
"period": period,
|
||||||
|
})
|
||||||
|
return drops_data
|
||||||
|
|
||||||
Returns:
|
|
||||||
str | None: The Twitch channel name if found, else None.
|
|
||||||
"""
|
|
||||||
benefits: list[Model] = drop.get("benefits", [])
|
|
||||||
benefit0: Model | None = benefits[0] if benefits else None
|
|
||||||
if benefit0 and hasattr(benefit0, "drops"):
|
|
||||||
channel_name: str | None = self.get_channel_from_benefit(benefit0)
|
|
||||||
if channel_name:
|
|
||||||
return channel_name
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _build_channels_html(self, channels: QuerySet[Channel], game: Game | None) -> SafeText:
|
def _build_channels_html(channels: QuerySet[Channel], game: Game | None) -> SafeText:
|
||||||
"""Render up to max_links channel links as <li>, then a count of additional channels, or fallback to game category link.
|
"""Render up to max_links channel links as <li>, then a count of additional channels, or fallback to game category link.
|
||||||
|
|
||||||
If only one channel and drop_requirements is '1 subscriptions required',
|
If only one channel and drop_requirements is '1 subscriptions required',
|
||||||
|
|
@ -189,66 +161,56 @@ class DropCampaignFeed(Feed):
|
||||||
display_name,
|
display_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
"""RSS feed for latest drop campaigns."""
|
|
||||||
|
|
||||||
title: str = "Twitch Drop Campaigns"
|
def _get_channel_name_from_drops(drops: QuerySet[TimeBasedDrop]) -> str | None:
|
||||||
link: str = "/campaigns/"
|
for d in drops:
|
||||||
description: str = "Latest Twitch drop campaigns"
|
campaign: DropCampaign | None = getattr(d, "campaign", None)
|
||||||
feed_url: str = "/rss/campaigns/"
|
if campaign:
|
||||||
feed_copyright: str = "Information wants to be free."
|
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
|
||||||
|
return None
|
||||||
|
|
||||||
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: Model) -> SafeText:
|
def get_channel_from_benefit(benefit: Model) -> str | None:
|
||||||
"""Return the campaign name as the item title (SafeText for RSS)."""
|
"""Get the Twitch channel name associated with a drop benefit.
|
||||||
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 _build_drops_data(self, drops_qs: QuerySet[TimeBasedDrop]) -> list[dict]:
|
Args:
|
||||||
"""Build a simplified data structure for rendering drops in a template.
|
benefit (Model): The drop benefit model instance.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[dict]: A list of dictionaries each containing `name`, `benefits`,
|
str | None: The Twitch channel name if found, else None.
|
||||||
`requirements`, and `period` for a drop, suitable for template rendering.
|
|
||||||
"""
|
"""
|
||||||
drops_data: list[dict] = []
|
drop_obj: QuerySet[TimeBasedDrop] | None = getattr(benefit, "drops", None)
|
||||||
for drop in drops_qs:
|
if drop_obj and hasattr(drop_obj, "all"):
|
||||||
requirements: str = ""
|
try:
|
||||||
required_minutes: int | None = getattr(drop, "required_minutes_watched", None)
|
return _get_channel_name_from_drops(drop_obj.all())
|
||||||
required_subs: int = getattr(drop, "required_subs", 0) or 0
|
except AttributeError:
|
||||||
if required_minutes:
|
logger.exception("Exception occurred while resolving channel name for benefit")
|
||||||
requirements = f"{required_minutes} minutes watched"
|
return None
|
||||||
if required_subs > 0:
|
|
||||||
sub_word: Literal["subs", "sub"] = "subs" if required_subs > 1 else "sub"
|
|
||||||
if requirements:
|
|
||||||
requirements += f" and {required_subs} {sub_word} required"
|
|
||||||
else:
|
|
||||||
requirements = f"{required_subs} {sub_word} required"
|
|
||||||
|
|
||||||
period: str = ""
|
|
||||||
drop_start: datetime.datetime | None = getattr(drop, "start_at", None)
|
|
||||||
drop_end: datetime.datetime | None = getattr(drop, "end_at", None)
|
|
||||||
if drop_start is not None:
|
|
||||||
period += drop_start.strftime("%Y-%m-%d %H:%M %Z")
|
|
||||||
if drop_end is not None:
|
|
||||||
if period:
|
|
||||||
period += " - " + drop_end.strftime("%Y-%m-%d %H:%M %Z")
|
|
||||||
else:
|
|
||||||
period = drop_end.strftime("%Y-%m-%d %H:%M %Z")
|
|
||||||
|
|
||||||
drops_data.append({
|
def _resolve_channel_name(drop: dict) -> str | None:
|
||||||
"name": getattr(drop, "name", str(drop)),
|
"""Try to resolve the Twitch channel name for a drop dict's benefits or fallback keys.
|
||||||
"benefits": list(drop.benefits.all()),
|
|
||||||
"requirements": requirements,
|
|
||||||
"period": period,
|
|
||||||
})
|
|
||||||
return drops_data
|
|
||||||
|
|
||||||
def _construct_drops_summary(self, drops_data: list[dict]) -> SafeText:
|
Args:
|
||||||
|
drop (dict): The drop data dictionary.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str | None: The Twitch channel name if found, else None.
|
||||||
|
"""
|
||||||
|
benefits: list[Model] = drop.get("benefits", [])
|
||||||
|
benefit0: Model | None = benefits[0] if benefits else None
|
||||||
|
if benefit0 and hasattr(benefit0, "drops"):
|
||||||
|
channel_name: str | None = get_channel_from_benefit(benefit0)
|
||||||
|
if channel_name:
|
||||||
|
return channel_name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _construct_drops_summary(drops_data: list[dict]) -> SafeText:
|
||||||
"""Construct a safe HTML summary of drops and their benefits.
|
"""Construct a safe HTML summary of drops and their benefits.
|
||||||
|
|
||||||
If the requirements indicate a subscription is required, link the benefit names to the Twitch channel.
|
If the requirements indicate a subscription is required, link the benefit names to the Twitch channel.
|
||||||
|
|
@ -274,7 +236,7 @@ class DropCampaignFeed(Feed):
|
||||||
for drop in sorted_drops:
|
for drop in sorted_drops:
|
||||||
requirements: str = drop.get("requirements", "")
|
requirements: str = drop.get("requirements", "")
|
||||||
benefits: list[DropBenefit] = drop.get("benefits", [])
|
benefits: list[DropBenefit] = drop.get("benefits", [])
|
||||||
channel_name: str | None = self._resolve_channel_name(drop)
|
channel_name: str | None = _resolve_channel_name(drop)
|
||||||
is_sub_required: bool = "sub required" in requirements or "subs required" in requirements
|
is_sub_required: bool = "sub required" in requirements or "subs required" in requirements
|
||||||
benefit_names: list[tuple[str]] = []
|
benefit_names: list[tuple[str]] = []
|
||||||
for b in benefits:
|
for b in benefits:
|
||||||
|
|
@ -296,13 +258,231 @@ class DropCampaignFeed(Feed):
|
||||||
items.append(format_html("<li>{}</li>", benefits_str))
|
items.append(format_html("<li>{}</li>", benefits_str))
|
||||||
return format_html("<ul>{}</ul>", format_html_join("", "{}", [(item,) for item in items]))
|
return format_html("<ul>{}</ul>", format_html_join("", "{}", [(item,) for item in items]))
|
||||||
|
|
||||||
|
|
||||||
|
# MARK: /rss/organizations/
|
||||||
|
class OrganizationFeed(Feed):
|
||||||
|
"""RSS feed for latest organizations."""
|
||||||
|
|
||||||
|
title: str = "TTVDrops Organizations"
|
||||||
|
link: str = "/organizations/"
|
||||||
|
description: str = "Latest organizations on TTVDrops"
|
||||||
|
feed_copyright: str = "Information wants to be free."
|
||||||
|
|
||||||
|
def items(self) -> list[Organization]:
|
||||||
|
"""Return the latest 100 organizations."""
|
||||||
|
return list(Organization.objects.order_by("-added_at")[:100])
|
||||||
|
|
||||||
|
def item_title(self, item: Model) -> SafeText:
|
||||||
|
"""Return the organization name as the item title."""
|
||||||
|
if not isinstance(item, Organization):
|
||||||
|
logger.error("item_title called with non-Organization item: %s", type(item))
|
||||||
|
return SafeText("New Twitch organization added")
|
||||||
|
|
||||||
|
return SafeText(getattr(item, "name", str(item)))
|
||||||
|
|
||||||
|
def item_description(self, item: Model) -> SafeText:
|
||||||
|
"""Return a description of the organization."""
|
||||||
|
if not isinstance(item, Organization):
|
||||||
|
logger.error("item_description called with non-Organization item: %s", type(item))
|
||||||
|
return SafeText("No description available.")
|
||||||
|
|
||||||
|
description_parts: list[SafeText] = []
|
||||||
|
|
||||||
|
name: str = getattr(item, "name", "")
|
||||||
|
twitch_id: str = getattr(item, "twitch_id", "")
|
||||||
|
|
||||||
|
# Link to ttvdrops organization page
|
||||||
|
description_parts.extend((
|
||||||
|
SafeText("<p>New Twitch organization added to TTVDrops:</p>"),
|
||||||
|
SafeText(
|
||||||
|
f"<p><a href='{reverse('twitch:organization_detail', args=[twitch_id])}' target='_blank' rel='noopener noreferrer'>{name}</a></p>", # noqa: E501
|
||||||
|
),
|
||||||
|
))
|
||||||
|
return SafeText("".join(str(part) for part in description_parts))
|
||||||
|
|
||||||
|
def item_link(self, item: Model) -> str:
|
||||||
|
"""Return the link to the organization detail."""
|
||||||
|
if not isinstance(item, Organization):
|
||||||
|
logger.error("item_link called with non-Organization item: %s", type(item))
|
||||||
|
return reverse("twitch:dashboard")
|
||||||
|
|
||||||
|
return reverse("twitch:organization_detail", args=[item.twitch_id])
|
||||||
|
|
||||||
|
def item_pubdate(self, item: Model) -> datetime.datetime:
|
||||||
|
"""Returns the publication date to the feed item.
|
||||||
|
|
||||||
|
Fallback to added_at or now if missing.
|
||||||
|
"""
|
||||||
|
added_at: datetime.datetime | None = getattr(item, "added_at", None)
|
||||||
|
if added_at:
|
||||||
|
return added_at
|
||||||
|
return timezone.now()
|
||||||
|
|
||||||
|
def item_updateddate(self, item: Model) -> datetime.datetime:
|
||||||
|
"""Returns the organization's last update time."""
|
||||||
|
updated_at: datetime.datetime | None = getattr(item, "updated_at", None)
|
||||||
|
if updated_at:
|
||||||
|
return updated_at
|
||||||
|
return timezone.now()
|
||||||
|
|
||||||
|
def item_guid(self, item: Model) -> str:
|
||||||
|
"""Return a unique identifier for each organization."""
|
||||||
|
twitch_id: str = getattr(item, "twitch_id", "unknown")
|
||||||
|
return twitch_id + "@ttvdrops.com"
|
||||||
|
|
||||||
|
def item_author_name(self, item: Model) -> str:
|
||||||
|
"""Return the author name for the organization."""
|
||||||
|
if not isinstance(item, Organization):
|
||||||
|
logger.error("item_author_name called with non-Organization item: %s", type(item))
|
||||||
|
return "Twitch"
|
||||||
|
|
||||||
|
return getattr(item, "name", "Twitch")
|
||||||
|
|
||||||
|
|
||||||
|
# MARK: /rss/games/
|
||||||
|
class GameFeed(Feed):
|
||||||
|
"""RSS feed for latest games."""
|
||||||
|
|
||||||
|
title: str = "Games - TTVDrops"
|
||||||
|
link: str = "/games/"
|
||||||
|
description: str = "Latest games on TTVDrops"
|
||||||
|
feed_copyright: str = "Information wants to be free."
|
||||||
|
|
||||||
|
def items(self) -> list[Game]:
|
||||||
|
"""Return the latest 100 games."""
|
||||||
|
return list(Game.objects.order_by("-added_at")[:100])
|
||||||
|
|
||||||
|
def item_title(self, item: Model) -> SafeText:
|
||||||
|
"""Return the game name as the item title (SafeText for RSS)."""
|
||||||
|
if not isinstance(item, Game):
|
||||||
|
logger.error("item_title called with non-Game item: %s", type(item))
|
||||||
|
return SafeText("New Twitch game added to TTVDrops")
|
||||||
|
|
||||||
|
return SafeText(item.get_game_name)
|
||||||
|
|
||||||
|
def item_description(self, item: Model) -> SafeText:
|
||||||
|
"""Return a description of the game."""
|
||||||
|
# Return all the information we have about the game
|
||||||
|
if not isinstance(item, Game):
|
||||||
|
logger.error("item_description called with non-Game item: %s", type(item))
|
||||||
|
return SafeText("No description available.")
|
||||||
|
|
||||||
|
twitch_id: str = getattr(item, "twitch_id", "")
|
||||||
|
slug: str = getattr(item, "slug", "")
|
||||||
|
name: str = getattr(item, "name", "")
|
||||||
|
display_name: str = getattr(item, "display_name", "")
|
||||||
|
box_art: str | None = getattr(item, "box_art", None)
|
||||||
|
owner: Organization | None = getattr(item, "owner", None)
|
||||||
|
|
||||||
|
description_parts: list[SafeText] = []
|
||||||
|
|
||||||
|
game_name: str = display_name or name or slug or twitch_id
|
||||||
|
game_owner: str = owner.name if owner else "Unknown Owner"
|
||||||
|
|
||||||
|
if box_art:
|
||||||
|
description_parts.append(
|
||||||
|
SafeText(f"<img src='{box_art}' alt='Box Art for {game_name}' width='600' height='800' />"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if slug:
|
||||||
|
description_parts.append(
|
||||||
|
SafeText(
|
||||||
|
f"<p><a href='https://www.twitch.tv/directory/game/{slug}' target='_blank' rel='noopener noreferrer'>{game_name} by {game_owner}</a></p>", # noqa: E501
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
description_parts.append(SafeText(f"<p>{game_name} by {game_owner}</p>"))
|
||||||
|
|
||||||
|
if twitch_id:
|
||||||
|
description_parts.append(SafeText(f"<small>Twitch ID: {twitch_id}</small>"))
|
||||||
|
|
||||||
|
return SafeText("".join(str(part) for part in description_parts))
|
||||||
|
|
||||||
|
def item_link(self, item: Model) -> str:
|
||||||
|
"""Return the link to the game detail."""
|
||||||
|
if not isinstance(item, Game):
|
||||||
|
logger.error("item_link called with non-Game item: %s", type(item))
|
||||||
|
return reverse("twitch:dashboard")
|
||||||
|
|
||||||
|
return reverse("twitch:game_detail", args=[item.twitch_id])
|
||||||
|
|
||||||
|
def item_pubdate(self, item: Model) -> datetime.datetime:
|
||||||
|
"""Returns the publication date to the feed item.
|
||||||
|
|
||||||
|
Fallback to added_at or now if missing.
|
||||||
|
"""
|
||||||
|
added_at: datetime.datetime | None = getattr(item, "added_at", None)
|
||||||
|
if added_at:
|
||||||
|
return added_at
|
||||||
|
return timezone.now()
|
||||||
|
|
||||||
|
def item_updateddate(self, item: Model) -> datetime.datetime:
|
||||||
|
"""Returns the game's last update time."""
|
||||||
|
updated_at: datetime.datetime | None = getattr(item, "updated_at", None)
|
||||||
|
if updated_at:
|
||||||
|
return updated_at
|
||||||
|
return timezone.now()
|
||||||
|
|
||||||
|
def item_guid(self, item: Model) -> str:
|
||||||
|
"""Return a unique identifier for each game."""
|
||||||
|
twitch_id: str = getattr(item, "twitch_id", "unknown")
|
||||||
|
return twitch_id + "@ttvdrops.com"
|
||||||
|
|
||||||
|
def item_author_name(self, item: Model) -> str:
|
||||||
|
"""Return the author name for the game, typically the owner organization name."""
|
||||||
|
owner: Organization | None = getattr(item, "owner", None)
|
||||||
|
if owner and owner.name:
|
||||||
|
return owner.name
|
||||||
|
|
||||||
|
return "Twitch"
|
||||||
|
|
||||||
|
def item_enclosure_url(self, item: Model) -> str:
|
||||||
|
"""Returns the URL of the game's box art for enclosure."""
|
||||||
|
box_art: str | None = getattr(item, "box_art", None)
|
||||||
|
if box_art:
|
||||||
|
return box_art
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def item_enclosure_length(self, item: Model) -> int: # noqa: ARG002
|
||||||
|
"""Returns the length of the enclosure."""
|
||||||
|
# TODO(TheLovinator): Track image size for proper length # noqa: TD003
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def item_enclosure_mime_type(self, item: Model) -> str: # noqa: ARG002
|
||||||
|
"""Returns the MIME type of the enclosure."""
|
||||||
|
# TODO(TheLovinator): Determine actual MIME type if needed # noqa: TD003
|
||||||
|
return "image/jpeg"
|
||||||
|
|
||||||
|
|
||||||
|
# MARK: /rss/campaigns/
|
||||||
|
class DropCampaignFeed(Feed):
|
||||||
|
"""RSS feed for latest drop campaigns."""
|
||||||
|
|
||||||
|
title: str = "Twitch Drop Campaigns"
|
||||||
|
link: str = "/campaigns/"
|
||||||
|
description: str = "Latest Twitch drop campaigns on TTVDrops"
|
||||||
|
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: 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: Model) -> SafeText:
|
def item_description(self, item: Model) -> SafeText:
|
||||||
"""Return a description of the campaign."""
|
"""Return a description of the campaign."""
|
||||||
drops_data: list[dict] = []
|
drops_data: list[dict] = []
|
||||||
|
|
||||||
drops: QuerySet[TimeBasedDrop] | None = getattr(item, "time_based_drops", None)
|
drops: QuerySet[TimeBasedDrop] | None = getattr(item, "time_based_drops", None)
|
||||||
if drops:
|
if drops:
|
||||||
drops_data = self._build_drops_data(drops.select_related().prefetch_related("benefits").all())
|
drops_data = _build_drops_data(drops.select_related().prefetch_related("benefits").all())
|
||||||
|
|
||||||
parts: list[SafeText] = []
|
parts: list[SafeText] = []
|
||||||
|
|
||||||
|
|
@ -318,17 +498,17 @@ class DropCampaignFeed(Feed):
|
||||||
parts.append(format_html("<p>{}</p>", desc_text))
|
parts.append(format_html("<p>{}</p>", desc_text))
|
||||||
|
|
||||||
# Insert start and end date info
|
# Insert start and end date info
|
||||||
self.insert_date_info(item, parts)
|
insert_date_info(item, parts)
|
||||||
|
|
||||||
if drops_data:
|
if drops_data:
|
||||||
parts.append(format_html("<p>{}</p>", self._construct_drops_summary(drops_data)))
|
parts.append(format_html("<p>{}</p>", _construct_drops_summary(drops_data)))
|
||||||
|
|
||||||
# Only show channels if drop is not subscription only
|
# Only show channels if drop is not subscription only
|
||||||
if not getattr(item, "is_subscription_only", False):
|
if not getattr(item, "is_subscription_only", False):
|
||||||
channels: QuerySet[Channel] | None = getattr(item, "allow_channels", None)
|
channels: QuerySet[Channel] | None = getattr(item, "allow_channels", None)
|
||||||
if channels is not None:
|
if channels is not None:
|
||||||
game: Game | None = getattr(item, "game", None)
|
game: Game | None = getattr(item, "game", None)
|
||||||
parts.append(self._build_channels_html(channels, game=game))
|
parts.append(_build_channels_html(channels, game=game))
|
||||||
|
|
||||||
details_url: str | None = getattr(item, "details_url", None)
|
details_url: str | None = getattr(item, "details_url", None)
|
||||||
if details_url:
|
if details_url:
|
||||||
|
|
@ -336,35 +516,6 @@ class DropCampaignFeed(Feed):
|
||||||
|
|
||||||
return SafeText("".join(str(p) for p in parts))
|
return SafeText("".join(str(p) for p in parts))
|
||||||
|
|
||||||
def insert_date_info(self, item: Model, parts: list[SafeText]) -> None:
|
|
||||||
"""Insert start and end date information into parts list.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
item (Model): The campaign item containing start_at and end_at.
|
|
||||||
parts (list[SafeText]): The list of HTML parts to append to.
|
|
||||||
"""
|
|
||||||
end_at: datetime.datetime | None = getattr(item, "end_at", None)
|
|
||||||
start_at: datetime.datetime | None = getattr(item, "start_at", None)
|
|
||||||
|
|
||||||
if start_at or end_at:
|
|
||||||
start_part: SafeString = (
|
|
||||||
format_html("Starts: {} ({})", start_at.strftime("%Y-%m-%d %H:%M %Z"), naturaltime(start_at))
|
|
||||||
if start_at
|
|
||||||
else SafeText("")
|
|
||||||
)
|
|
||||||
end_part: SafeString = (
|
|
||||||
format_html("Ends: {} ({})", end_at.strftime("%Y-%m-%d %H:%M %Z"), naturaltime(end_at))
|
|
||||||
if end_at
|
|
||||||
else SafeText("")
|
|
||||||
)
|
|
||||||
# Start date and end date separated by a line break if both present
|
|
||||||
if start_part and end_part:
|
|
||||||
parts.append(format_html("<p>{}<br />{}</p>", start_part, end_part))
|
|
||||||
elif start_part:
|
|
||||||
parts.append(format_html("<p>{}</p>", start_part))
|
|
||||||
elif end_part:
|
|
||||||
parts.append(format_html("<p>{}</p>", end_part))
|
|
||||||
|
|
||||||
def item_link(self, item: Model) -> str:
|
def item_link(self, item: Model) -> str:
|
||||||
"""Return the link to the campaign detail."""
|
"""Return the link to the campaign detail."""
|
||||||
if not isinstance(item, DropCampaign):
|
if not isinstance(item, DropCampaign):
|
||||||
|
|
@ -378,12 +529,9 @@ class DropCampaignFeed(Feed):
|
||||||
|
|
||||||
Fallback to updated_at or now if missing.
|
Fallback to updated_at or now if missing.
|
||||||
"""
|
"""
|
||||||
start_at: datetime.datetime | None = getattr(item, "start_at", None)
|
added_at: datetime.datetime | None = getattr(item, "added_at", None)
|
||||||
if start_at:
|
if added_at:
|
||||||
return start_at
|
return added_at
|
||||||
updated_at: datetime.datetime | None = getattr(item, "updated_at", None)
|
|
||||||
if updated_at:
|
|
||||||
return updated_at
|
|
||||||
return timezone.now()
|
return timezone.now()
|
||||||
|
|
||||||
def item_updateddate(self, item: DropCampaign) -> datetime.datetime:
|
def item_updateddate(self, item: DropCampaign) -> datetime.datetime:
|
||||||
|
|
@ -432,66 +580,160 @@ class DropCampaignFeed(Feed):
|
||||||
|
|
||||||
|
|
||||||
# MARK: /rss/games/<twitch_id>/campaigns/
|
# MARK: /rss/games/<twitch_id>/campaigns/
|
||||||
class GameCampaignFeed(DropCampaignFeed):
|
class GameCampaignFeed(Feed):
|
||||||
"""RSS feed for campaigns of a specific game."""
|
"""RSS feed for the latest drop campaigns of a specific game."""
|
||||||
|
|
||||||
def get_object(self, request: HttpRequest, twitch_id: str) -> Game: # noqa: ARG002
|
def get_object(self, request: HttpRequest, twitch_id: str) -> Game: # noqa: ARG002
|
||||||
"""Get the game object for this feed.
|
"""Retrieve the Game instance for the given Twitch ID.
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The HTTP request.
|
|
||||||
twitch_id: The Twitch ID of the game.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Game: The game object.
|
Game: The corresponding Game object.
|
||||||
"""
|
"""
|
||||||
return Game.objects.get(twitch_id=twitch_id)
|
return Game.objects.get(twitch_id=twitch_id)
|
||||||
|
|
||||||
def title(self, obj: Game) -> str: # pyright: ignore[reportIncompatibleVariableOverride]
|
def item_link(self, item: DropCampaign) -> str:
|
||||||
"""Return the feed title."""
|
"""Return the link to the campaign detail."""
|
||||||
|
return reverse("twitch:campaign_detail", args=[item.twitch_id])
|
||||||
|
|
||||||
|
def title(self, obj: Game) -> str:
|
||||||
|
"""Return the feed title for the game campaigns."""
|
||||||
return f"TTVDrops: {obj.display_name} Campaigns"
|
return f"TTVDrops: {obj.display_name} Campaigns"
|
||||||
|
|
||||||
def link(self, obj: Game) -> str: # pyright: ignore[reportIncompatibleVariableOverride]
|
def link(self, obj: Game) -> str:
|
||||||
"""Return the link to the game detail."""
|
"""Return the absolute URL to the game detail page."""
|
||||||
return reverse("twitch:game_detail", args=[obj.twitch_id])
|
return reverse("twitch:game_detail", args=[obj.twitch_id])
|
||||||
|
|
||||||
def description(self, obj: Game) -> str: # pyright: ignore[reportIncompatibleVariableOverride]
|
def description(self, obj: Game) -> str:
|
||||||
"""Return the feed description."""
|
"""Return a description for the feed."""
|
||||||
return f"Latest drop campaigns for {obj.display_name}"
|
return f"Latest drop campaigns for {obj.display_name}"
|
||||||
|
|
||||||
def items(self, obj: Game) -> list[DropCampaign]: # pyright: ignore[reportIncompatibleMethodOverride]
|
def items(self, obj: Game) -> list[DropCampaign]:
|
||||||
"""Return the latest 100 campaigns for this game."""
|
"""Return the latest 100 drop campaigns for this game, ordered by most recently added."""
|
||||||
return list(DropCampaign.objects.filter(game=obj).select_related("game").order_by("-added_at")[:100])
|
return list(DropCampaign.objects.filter(game=obj).select_related("game").order_by("-added_at")[:100])
|
||||||
|
|
||||||
|
|
||||||
# MARK: /rss/organizations/<twitch_id>/campaigns/
|
# MARK: /rss/organizations/<twitch_id>/campaigns/
|
||||||
class OrganizationCampaignFeed(DropCampaignFeed):
|
class OrganizationCampaignFeed(Feed):
|
||||||
"""RSS feed for campaigns of a specific organization."""
|
"""RSS feed for campaigns of a specific organization."""
|
||||||
|
|
||||||
def get_object(self, request: HttpRequest, twitch_id: str) -> Organization: # noqa: ARG002
|
def get_object(self, request: HttpRequest, twitch_id: str) -> Organization: # noqa: ARG002
|
||||||
"""Get the organization object for this feed.
|
"""Retrieve the Organization instance for the given Twitch ID.
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The HTTP request.
|
|
||||||
twitch_id: The Twitch ID of the organization.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Organization: The organization object.
|
Organization: The corresponding Organization object.
|
||||||
"""
|
"""
|
||||||
return Organization.objects.get(twitch_id=twitch_id)
|
return Organization.objects.get(twitch_id=twitch_id)
|
||||||
|
|
||||||
def title(self, obj: Organization) -> str: # pyright: ignore[reportIncompatibleVariableOverride]
|
def item_link(self, item: DropCampaign) -> str:
|
||||||
"""Return the feed title."""
|
"""Return the link to the campaign detail."""
|
||||||
|
return reverse("twitch:campaign_detail", args=[item.twitch_id])
|
||||||
|
|
||||||
|
def title(self, obj: Organization) -> str:
|
||||||
|
"""Return the feed title for the organization's campaigns."""
|
||||||
return f"TTVDrops: {obj.name} Campaigns"
|
return f"TTVDrops: {obj.name} Campaigns"
|
||||||
|
|
||||||
def link(self, obj: Organization) -> str: # pyright: ignore[reportIncompatibleVariableOverride]
|
def link(self, obj: Organization) -> str:
|
||||||
"""Return the link to the organization detail."""
|
"""Return the absolute URL to the organization detail page."""
|
||||||
return reverse("twitch:organization_detail", args=[obj.twitch_id])
|
return reverse("twitch:organization_detail", args=[obj.twitch_id])
|
||||||
|
|
||||||
def description(self, obj: Organization) -> str: # pyright: ignore[reportIncompatibleVariableOverride]
|
def description(self, obj: Organization) -> str:
|
||||||
"""Return the feed description."""
|
"""Return a description for the feed."""
|
||||||
return f"Latest drop campaigns for {obj.name}"
|
return f"Latest drop campaigns for organization {obj.name}"
|
||||||
|
|
||||||
def items(self, obj: Organization) -> list[DropCampaign]: # pyright: ignore[reportIncompatibleMethodOverride]
|
def items(self, obj: Organization) -> list[DropCampaign]:
|
||||||
"""Return the latest 100 campaigns for this organization."""
|
"""Return the latest 100 drop campaigns for this organization, ordered by most recently added."""
|
||||||
return list(DropCampaign.objects.filter(game__owner=obj).select_related("game").order_by("-added_at")[:100])
|
return list(DropCampaign.objects.filter(game__owner=obj).select_related("game").order_by("-added_at")[:100])
|
||||||
|
|
||||||
|
def item_author_name(self, item: DropCampaign) -> str:
|
||||||
|
"""Return the author name for the campaign, typically the game name."""
|
||||||
|
item_game: Game | None = getattr(item, "game", None)
|
||||||
|
if item_game and item_game.display_name:
|
||||||
|
return item_game.display_name
|
||||||
|
|
||||||
|
return "Twitch"
|
||||||
|
|
||||||
|
def item_guid(self, item: DropCampaign) -> str:
|
||||||
|
"""Return a unique identifier for each campaign."""
|
||||||
|
return item.twitch_id + "@ttvdrops.com"
|
||||||
|
|
||||||
|
def item_enclosure_url(self, item: DropCampaign) -> str:
|
||||||
|
"""Returns the URL of the campaign image for enclosure."""
|
||||||
|
return item.image_url
|
||||||
|
|
||||||
|
def item_enclosure_length(self, item: DropCampaign) -> int: # noqa: ARG002
|
||||||
|
"""Returns the length of the enclosure."""
|
||||||
|
# TODO(TheLovinator): Track image size for proper length # noqa: TD003
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def item_enclosure_mime_type(self, item: DropCampaign) -> str: # noqa: ARG002
|
||||||
|
"""Returns the MIME type of the enclosure."""
|
||||||
|
# TODO(TheLovinator): Determine actual MIME type if needed # noqa: TD003
|
||||||
|
return "image/jpeg"
|
||||||
|
|
||||||
|
def item_categories(self, item: DropCampaign) -> tuple[str, ...]:
|
||||||
|
"""Returns the associated game's name as a category."""
|
||||||
|
categories: list[str] = ["twitch", "drops"]
|
||||||
|
|
||||||
|
item_game: Game | None = getattr(item, "game", None)
|
||||||
|
if item_game:
|
||||||
|
categories.append(item_game.get_game_name)
|
||||||
|
item_game_owner: Organization | None = getattr(item_game, "owner", None)
|
||||||
|
if item_game_owner:
|
||||||
|
categories.extend((str(item_game_owner.name), str(item_game_owner.twitch_id)))
|
||||||
|
|
||||||
|
return tuple(categories)
|
||||||
|
|
||||||
|
def item_updateddate(self, item: DropCampaign) -> datetime.datetime:
|
||||||
|
"""Returns the campaign's last update time."""
|
||||||
|
return item.updated_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.
|
||||||
|
"""
|
||||||
|
added_at: datetime.datetime | None = getattr(item, "added_at", None)
|
||||||
|
if added_at:
|
||||||
|
return added_at
|
||||||
|
return timezone.now()
|
||||||
|
|
||||||
|
def item_description(self, item: Model) -> SafeText:
|
||||||
|
"""Return a description of the campaign."""
|
||||||
|
drops_data: list[dict] = []
|
||||||
|
|
||||||
|
drops: QuerySet[TimeBasedDrop] | None = getattr(item, "time_based_drops", None)
|
||||||
|
if drops:
|
||||||
|
drops_data = _build_drops_data(drops.select_related().prefetch_related("benefits").all())
|
||||||
|
|
||||||
|
parts: list[SafeText] = []
|
||||||
|
|
||||||
|
image_url: str | None = getattr(item, "image_url", None)
|
||||||
|
if image_url:
|
||||||
|
item_name: str = getattr(item, "name", str(object=item))
|
||||||
|
parts.append(
|
||||||
|
format_html('<img src="{}" alt="{}" width="160" height="160" />', image_url, item_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
desc_text: str | None = getattr(item, "description", None)
|
||||||
|
if desc_text:
|
||||||
|
parts.append(format_html("<p>{}</p>", desc_text))
|
||||||
|
|
||||||
|
# Insert start and end date info
|
||||||
|
insert_date_info(item, parts)
|
||||||
|
|
||||||
|
if drops_data:
|
||||||
|
parts.append(format_html("<p>{}</p>", _construct_drops_summary(drops_data)))
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
if channels is not None:
|
||||||
|
game: Game | None = getattr(item, "game", None)
|
||||||
|
parts.append(_build_channels_html(channels, game=game))
|
||||||
|
|
||||||
|
details_url: str | None = getattr(item, "details_url", None)
|
||||||
|
if details_url:
|
||||||
|
parts.append(format_html('<a href="{}">About</a>', details_url))
|
||||||
|
|
||||||
|
return SafeText("".join(str(p) for p in parts))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue