Use function based views instead of class based views

This commit is contained in:
Joakim Hellsén 2026-01-05 22:21:02 +01:00
commit 5e415ae1c8
No known key found for this signature in database
3 changed files with 218 additions and 261 deletions

View file

@ -12,8 +12,8 @@
<h2 id="organizations-header">Organizations</h2> <h2 id="organizations-header">Organizations</h2>
<ul id="organizations-list"> <ul id="organizations-list">
{% for org in results.organizations %} {% for org in results.organizations %}
<li id="org-{{ org.pk }}"> <li id="org-{{ org.twitch_id }}">
<a href="{% url 'twitch:organization_detail' org.pk %}">{{ org.name }}</a> <a href="{% url 'twitch:organization_detail' org.twitch_id %}">{{ org.name }}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@ -22,8 +22,8 @@
<h2 id="games-header">Games</h2> <h2 id="games-header">Games</h2>
<ul id="games-list"> <ul id="games-list">
{% for game in results.games %} {% for game in results.games %}
<li id="game-{{ game.pk }}"> <li id="game-{{ game.twitch_id }}">
<a href="{% url 'twitch:game_detail' game.pk %}">{{ game.display_name }}</a> <a href="{% url 'twitch:game_detail' game.twitch_id %}">{{ game.display_name }}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@ -32,8 +32,8 @@
<h2 id="campaigns-header">Campaigns</h2> <h2 id="campaigns-header">Campaigns</h2>
<ul id="campaigns-list"> <ul id="campaigns-list">
{% for campaign in results.campaigns %} {% for campaign in results.campaigns %}
<li id="campaign-{{ campaign.pk }}"> <li id="campaign-{{ campaign.twitch_id }}">
<a href="{% url 'twitch:campaign_detail' campaign.pk %}">{{ campaign.name }}</a> <a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}">{{ campaign.name }}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@ -42,8 +42,8 @@
<h2 id="drops-header">Drops</h2> <h2 id="drops-header">Drops</h2>
<ul id="drops-list"> <ul id="drops-list">
{% for drop in results.drops %} {% for drop in results.drops %}
<li id="drop-{{ drop.id }}"> <li id="drop-{{ drop.twitch_id }}">
<a href="{% url 'twitch:campaign_detail' drop.campaign.pk %}#drop-{{ drop.id }}">{{ drop.name }}</a> (in {{ drop.campaign.name }}) <a href="{% url 'twitch:campaign_detail' drop.campaign.pk %}#drop-{{ drop.twitch_id }}">{{ drop.name }}</a> (in {{ drop.campaign.name }})
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@ -51,7 +51,7 @@
{% if results.benefits %} {% if results.benefits %}
<h2 id="benefits-header">Benefits</h2> <h2 id="benefits-header">Benefits</h2>
<ul id="benefits-list"> <ul id="benefits-list">
{% for benefit in results.benefits %}<li id="benefit-{{ benefit.id }}">{{ benefit.name }}</li>{% endfor %} {% for benefit in results.benefits %}<li id="benefit-{{ benefit.twitch_id }}">{{ benefit.name }}</li>{% endfor %}
</ul> </ul>
{% endif %} {% endif %}
{% endif %} {% endif %}

View file

@ -18,39 +18,15 @@ urlpatterns: list[URLPattern] = [
path("", views.dashboard, name="dashboard"), path("", views.dashboard, name="dashboard"),
path("search/", views.search_view, name="search"), path("search/", views.search_view, name="search"),
path("debug/", views.debug_view, name="debug"), path("debug/", views.debug_view, name="debug"),
path( path("campaigns/", views.drop_campaign_list_view, name="campaign_list"),
"campaigns/", path("campaigns/<str:twitch_id>/", views.drop_campaign_detail_view, name="campaign_detail"),
views.DropCampaignListView.as_view(),
name="campaign_list",
),
path(
"campaigns/<str:pk>/",
views.DropCampaignDetailView.as_view(),
name="campaign_detail",
),
path("games/", views.GamesGridView.as_view(), name="game_list"), path("games/", views.GamesGridView.as_view(), name="game_list"),
path( path("games/list/", views.GamesListView.as_view(), name="game_list_simple"),
"games/list/", path("games/<str:twitch_id>/", views.GameDetailView.as_view(), name="game_detail"),
views.GamesListView.as_view(),
name="game_list_simple",
),
path(
"games/<str:pk>/",
views.GameDetailView.as_view(),
name="game_detail",
),
path("organizations/", views.OrgListView.as_view(), name="org_list"), path("organizations/", views.OrgListView.as_view(), name="org_list"),
path( path("organizations/<str:twitch_id>/", views.organization_detail_view, name="organization_detail"),
"organizations/<str:pk>/",
views.OrgDetailView.as_view(),
name="organization_detail",
),
path("channels/", views.ChannelListView.as_view(), name="channel_list"), path("channels/", views.ChannelListView.as_view(), name="channel_list"),
path( path("channels/<str:twitch_id>/", views.ChannelDetailView.as_view(), name="channel_detail"),
"channels/<str:pk>/",
views.ChannelDetailView.as_view(),
name="channel_detail",
),
path("rss/organizations/", OrganizationFeed(), name="organization_feed"), path("rss/organizations/", OrganizationFeed(), name="organization_feed"),
path("rss/games/", GameFeed(), name="game_feed"), path("rss/games/", GameFeed(), name="game_feed"),
path("rss/campaigns/", DropCampaignFeed(), name="campaign_feed"), path("rss/campaigns/", DropCampaignFeed(), name="campaign_feed"),

View file

@ -8,10 +8,12 @@ from collections import defaultdict
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Any from typing import Any
from django.core.paginator import EmptyPage
from django.core.paginator import PageNotAnInteger
from django.core.paginator import Paginator
from django.core.serializers import serialize from django.core.serializers import serialize
from django.db.models import Count from django.db.models import Count
from django.db.models import F from django.db.models import F
from django.db.models import Model
from django.db.models import Prefetch from django.db.models import Prefetch
from django.db.models import Q from django.db.models import Q
from django.db.models.functions import Trim from django.db.models.functions import Trim
@ -101,125 +103,105 @@ class OrgListView(ListView):
context_object_name = "orgs" context_object_name = "orgs"
# MARK: /organizations/<pk>/ # MARK: /organizations/<twitch_id>/
class OrgDetailView(DetailView):
"""Detail view for organization."""
model = Organization
template_name = "twitch/organization_detail.html"
context_object_name = "organization"
def get_object( def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpResponse:
self, """Function-based view for organization detail.
queryset: QuerySet[Organization] | None = None,
) -> Organization:
"""Get the organization object using twitch_id.
Args: Args:
queryset: Optional queryset to use. request: The HTTP request.
twitch_id: The Twitch ID of the organization.
Returns: Returns:
Organization: The organization object. HttpResponse: The rendered organization detail page.
Raises: Raises:
Http404: If the organization is not found. Http404: If the organization is not found.
""" """
if queryset is None: try:
queryset = self.get_queryset() organization: Organization = Organization.objects.get(twitch_id=twitch_id)
except Organization.DoesNotExist as exc:
msg = "No organization found matching the query"
raise Http404(msg) from exc
# Use twitch_id as the lookup field since it's the primary key games: QuerySet[Game] = organization.games.all() # pyright: ignore[reportAttributeAccessIssue]
pk: str | None = self.kwargs.get(self.pk_url_kwarg)
try:
org: Organization = queryset.get(twitch_id=pk)
except Organization.DoesNotExist as exc:
msg = "No organization found matching the query"
raise Http404(msg) from exc
return org serialized_org: str = serialize(
"json",
[organization],
fields=("name",),
)
org_data: list[dict] = json.loads(serialized_org)
def get_context_data(self, **kwargs) -> dict[str, Any]: if games.exists():
"""Add additional context data. serialized_games: str = serialize(
Args:
**kwargs: Additional arguments.
Returns:
dict: Context data.
"""
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]
serialized_org: str = serialize(
"json", "json",
[organization], games,
fields=("name",), fields=("slug", "name", "display_name", "box_art"),
) )
org_data: list[dict] = json.loads(serialized_org) games_data: list[dict] = json.loads(serialized_games)
org_data[0]["fields"]["games"] = games_data
if games.exists(): pretty_org_data: str = json.dumps(org_data[0], indent=4)
serialized_games: str = serialize(
"json",
games,
fields=("slug", "name", "display_name", "box_art"),
)
games_data: list[dict] = json.loads(serialized_games)
org_data[0]["fields"]["games"] = games_data
pretty_org_data: str = json.dumps(org_data[0], indent=4) context: dict[str, Any] = {
"organization": organization,
"games": games,
"org_data": pretty_org_data,
}
context.update( return render(request, "twitch/organization_detail.html", context)
{
"games": games,
"org_data": pretty_org_data,
},
)
return context
# MARK: /campaigns/ # MARK: /campaigns/
class DropCampaignListView(ListView): def drop_campaign_list_view(request: HttpRequest) -> HttpResponse:
"""List view for drop campaigns.""" """Function-based view for drop campaigns list.
model = DropCampaign Args:
template_name = "twitch/campaign_list.html" request: The HTTP request.
context_object_name = "campaigns"
paginate_by = 100
def get_queryset(self) -> QuerySet[DropCampaign]: Returns:
"""Get queryset of drop campaigns. HttpResponse: The rendered campaign list page.
"""
game_filter: str | None = request.GET.get("game")
status_filter: str | None = request.GET.get("status")
per_page: int = 100
queryset: QuerySet[DropCampaign] = DropCampaign.objects.all()
Returns: if game_filter:
QuerySet: Filtered drop campaigns. queryset = queryset.filter(game__id=game_filter)
"""
queryset: QuerySet[DropCampaign] = super().get_queryset()
game_filter: str | None = self.request.GET.get("game")
if game_filter: queryset = queryset.select_related("game__owner").order_by("-start_at")
queryset = queryset.filter(game__id=game_filter)
return queryset.select_related("game__owner").order_by("-start_at") # Optionally filter by status (active, upcoming, expired)
now = timezone.now()
if status_filter == "active":
queryset = queryset.filter(start_at__lte=now, end_at__gte=now)
elif status_filter == "upcoming":
queryset = queryset.filter(start_at__gt=now)
elif status_filter == "expired":
queryset = queryset.filter(end_at__lt=now)
def get_context_data(self, **kwargs) -> dict[str, Any]: paginator = Paginator(queryset, per_page)
"""Add additional context data. page = request.GET.get("page") or 1
try:
campaigns = paginator.page(page)
except PageNotAnInteger:
campaigns = paginator.page(1)
except EmptyPage:
campaigns = paginator.page(paginator.num_pages)
Args: context: dict[str, Any] = {
**kwargs: Additional arguments. "campaigns": campaigns,
"games": Game.objects.all().order_by("display_name"),
Returns: "status_options": ["active", "upcoming", "expired"],
dict: Context data. "now": now,
""" "selected_game": game_filter or "",
context: dict[str, Any] = super().get_context_data(**kwargs) "selected_per_page": per_page,
"selected_status": status_filter or "",
context["games"] = Game.objects.all().order_by("display_name") }
context["status_options"] = ["active", "upcoming", "expired"] return render(request, "twitch/campaign_list.html", context)
context["now"] = timezone.now()
context["selected_game"] = self.request.GET.get("game", "")
context["selected_per_page"] = self.paginate_by
context["selected_status"] = self.request.GET.get("status", "")
return context
def format_and_color_json(data: dict[str, Any] | str) -> str: def format_and_color_json(data: dict[str, Any] | str) -> str:
@ -238,138 +220,136 @@ def format_and_color_json(data: dict[str, Any] | str) -> str:
return highlight(formatted_code, JsonLexer(), HtmlFormatter()) return highlight(formatted_code, JsonLexer(), HtmlFormatter())
# MARK: /campaigns/<pk>/ # MARK: /campaigns/<twitch_id>/
class DropCampaignDetailView(DetailView):
"""Detail view for a drop campaign."""
model = DropCampaign # MARK: /campaigns/<twitch_id>/
template_name = "twitch/campaign_detail.html"
context_object_name = "campaign"
def get_object(
self,
queryset: QuerySet[DropCampaign] | None = None,
) -> Model:
"""Get the campaign object with related data prefetched.
Args: def _enhance_drops_with_context(drops: QuerySet[TimeBasedDrop], now: datetime.datetime) -> list[dict[str, Any]]:
queryset: Optional queryset to use. """Helper to enhance drops with countdown and context.
Returns: Args:
DropCampaign: The campaign object with prefetched relations. drops: QuerySet of TimeBasedDrop objects.
""" now: Current datetime.
if queryset is None:
queryset = self.get_queryset()
queryset = queryset.select_related("game__owner") Returns:
List of dicts with drop, local_start, local_end, timezone_name, and countdown_text.
"""
enhanced = []
for drop in drops:
if drop.end_at and drop.end_at > now:
time_diff: datetime.timedelta = drop.end_at - now
days: int = time_diff.days
hours, remainder = divmod(time_diff.seconds, 3600)
minutes, seconds = divmod(remainder, 60)
if days > 0:
countdown_text: str = f"{days}d {hours}h {minutes}m"
elif hours > 0:
countdown_text = f"{hours}h {minutes}m"
elif minutes > 0:
countdown_text = f"{minutes}m {seconds}s"
else:
countdown_text = f"{seconds}s"
elif drop.start_at and drop.start_at > now:
countdown_text = "Not started"
else:
countdown_text = "Expired"
enhanced.append({
"drop": drop,
"local_start": drop.start_at,
"local_end": drop.end_at,
"timezone_name": "UTC",
"countdown_text": countdown_text,
})
return enhanced
return super().get_object(queryset=queryset)
def get_context_data(self, **kwargs: object) -> dict[str, Any]: # noqa: PLR0914 def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpResponse:
"""Add additional context data. """Function-based view for a drop campaign detail.
Args: Args:
**kwargs: Additional arguments. request: The HTTP request.
twitch_id: The Twitch ID of the campaign.
Returns: Returns:
dict: Context data. HttpResponse: The rendered campaign detail page.
"""
context: dict[str, Any] = super().get_context_data(**kwargs)
campaign: DropCampaign = context["campaign"]
drops: QuerySet[TimeBasedDrop] = (
TimeBasedDrop.objects
.filter(campaign=campaign)
.select_related("campaign")
.prefetch_related("benefits")
.order_by("required_minutes_watched")
)
serialized_campaign = serialize( Raises:
Http404: If the campaign is not found.
"""
try:
campaign: DropCampaign = DropCampaign.objects.select_related("game__owner").get(twitch_id=twitch_id)
except DropCampaign.DoesNotExist as exc:
msg = "No campaign found matching the query"
raise Http404(msg) from exc
drops: QuerySet[TimeBasedDrop] = (
TimeBasedDrop.objects
.filter(campaign=campaign)
.select_related("campaign")
.prefetch_related("benefits")
.order_by("required_minutes_watched")
)
serialized_campaign = serialize(
"json",
[campaign],
fields=(
"name",
"description",
"details_url",
"account_link_url",
"image_url",
"start_at",
"end_at",
"is_account_connected",
"game",
"created_at",
"updated_at",
),
)
campaign_data = json.loads(serialized_campaign)
if drops.exists():
serialized_drops = serialize(
"json", "json",
[campaign], drops,
fields=( fields=(
"name", "name",
"description", "required_minutes_watched",
"details_url", "required_subs",
"account_link_url",
"image_url",
"start_at", "start_at",
"end_at", "end_at",
"is_account_connected",
"game",
"created_at",
"updated_at",
), ),
) )
campaign_data = json.loads(serialized_campaign) drops_data: list[dict[str, Any]] = json.loads(serialized_drops)
if drops.exists(): for i, drop in enumerate(drops):
serialized_drops = serialize( drop_benefits: list[DropBenefit] = list(drop.benefits.all())
"json", if drop_benefits:
drops, serialized_benefits = serialize(
fields=( "json",
"name", drop_benefits,
"required_minutes_watched", fields=("name", "image_asset_url"),
"required_subs", )
"start_at", benefits_data = json.loads(serialized_benefits)
"end_at", drops_data[i]["fields"]["benefits"] = benefits_data
),
)
drops_data: list[dict[str, Any]] = json.loads(serialized_drops)
for i, drop in enumerate(drops): campaign_data[0]["fields"]["drops"] = drops_data
drop_benefits: list[DropBenefit] = list(drop.benefits.all())
if drop_benefits:
serialized_benefits = serialize(
"json",
drop_benefits,
fields=("name", "image_asset_url"),
)
benefits_data = json.loads(serialized_benefits)
drops_data[i]["fields"]["benefits"] = benefits_data
campaign_data[0]["fields"]["drops"] = drops_data now: datetime.datetime = timezone.now()
enhanced_drops = _enhance_drops_with_context(drops, now)
# Enhance drops with additional context data context: dict[str, Any] = {
enhanced_drops: list[dict[str, TimeBasedDrop | datetime.datetime | str | None]] = [] "campaign": campaign,
now: datetime.datetime = timezone.now() "now": now,
for drop in drops: "drops": enhanced_drops,
# Calculate countdown text "campaign_data": format_and_color_json(campaign_data[0]),
if drop.end_at and drop.end_at > now: "owner": campaign.game.owner,
time_diff: datetime.timedelta = drop.end_at - now "allowed_channels": campaign.allow_channels.all().order_by("display_name"),
days: int = time_diff.days }
hours, remainder = divmod(time_diff.seconds, 3600)
minutes, seconds = divmod(remainder, 60)
if days > 0: return render(request, "twitch/campaign_detail.html", context)
countdown_text: str = f"{days}d {hours}h {minutes}m"
elif hours > 0:
countdown_text = f"{hours}h {minutes}m"
elif minutes > 0:
countdown_text = f"{minutes}m {seconds}s"
else:
countdown_text = f"{seconds}s"
elif drop.start_at and drop.start_at > now:
countdown_text = "Not started"
else:
countdown_text = "Expired"
enhanced_drop: dict[str, TimeBasedDrop | datetime.datetime | str | None] = {
"drop": drop,
"local_start": drop.start_at,
"local_end": drop.end_at,
"timezone_name": "UTC",
"countdown_text": countdown_text,
}
enhanced_drops.append(enhanced_drop)
context["now"] = now
context["drops"] = enhanced_drops
context["campaign_data"] = format_and_color_json(campaign_data[0])
context["owner"] = campaign.game.owner
context["allowed_channels"] = campaign.allow_channels.all().order_by("display_name")
return context
# MARK: /games/ # MARK: /games/
@ -448,13 +428,14 @@ class GamesGridView(ListView):
return context return context
# MARK: /games/<pk>/ # MARK: /games/<twitch_id>/
class GameDetailView(DetailView): class GameDetailView(DetailView):
"""Detail view for a game.""" """Detail view for a game."""
model = Game model = Game
template_name = "twitch/game_detail.html" template_name = "twitch/game_detail.html"
context_object_name = "game" context_object_name = "game"
lookup_field = "twitch_id"
def get_object(self, queryset: QuerySet[Game] | None = None) -> Game: def get_object(self, queryset: QuerySet[Game] | None = None) -> Game:
"""Get the game object using twitch_id as the primary key lookup. """Get the game object using twitch_id as the primary key lookup.
@ -472,9 +453,9 @@ class GameDetailView(DetailView):
queryset = self.get_queryset() queryset = self.get_queryset()
# Use twitch_id as the lookup field since it's the primary key # Use twitch_id as the lookup field since it's the primary key
pk = self.kwargs.get(self.pk_url_kwarg) twitch_id = self.kwargs.get("twitch_id")
try: try:
game = queryset.get(twitch_id=pk) game = queryset.get(twitch_id=twitch_id)
except Game.DoesNotExist as exc: except Game.DoesNotExist as exc:
msg = "No game found matching the query" msg = "No game found matching the query"
raise Http404(msg) from exc raise Http404(msg) from exc
@ -832,13 +813,14 @@ class ChannelListView(ListView):
return context return context
# MARK: /channels/<pk>/ # MARK: /channels/<twitch_id>/
class ChannelDetailView(DetailView): class ChannelDetailView(DetailView):
"""Detail view for a channel.""" """Detail view for a channel."""
model = Channel model = Channel
template_name = "twitch/channel_detail.html" template_name = "twitch/channel_detail.html"
context_object_name = "channel" context_object_name = "channel"
lookup_field = "twitch_id"
def get_object(self, queryset: QuerySet[Channel] | None = None) -> Channel: def get_object(self, queryset: QuerySet[Channel] | None = None) -> Channel:
"""Get the channel object using twitch_id as the primary key lookup. """Get the channel object using twitch_id as the primary key lookup.
@ -855,10 +837,9 @@ class ChannelDetailView(DetailView):
if queryset is None: if queryset is None:
queryset = self.get_queryset() queryset = self.get_queryset()
# Use twitch_id as the lookup field since it's the primary key twitch_id = self.kwargs.get("twitch_id")
pk = self.kwargs.get(self.pk_url_kwarg)
try: try:
channel = queryset.get(twitch_id=pk) channel = queryset.get(twitch_id=twitch_id)
except Channel.DoesNotExist as exc: except Channel.DoesNotExist as exc:
msg = "No channel found matching the query" msg = "No channel found matching the query"
raise Http404(msg) from exc raise Http404(msg) from exc