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,53 +103,28 @@ 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:
queryset = self.get_queryset()
# Use twitch_id as the lookup field since it's the primary key
pk: str | None = self.kwargs.get(self.pk_url_kwarg)
try: try:
org: Organization = queryset.get(twitch_id=pk) organization: Organization = Organization.objects.get(twitch_id=twitch_id)
except Organization.DoesNotExist as exc: except Organization.DoesNotExist as exc:
msg = "No organization found matching the query" msg = "No organization found matching the query"
raise Http404(msg) from exc raise Http404(msg) from exc
return org
def get_context_data(self, **kwargs) -> dict[str, Any]:
"""Add additional context data.
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] games: QuerySet[Game] = organization.games.all() # pyright: ignore[reportAttributeAccessIssue]
serialized_org: str = serialize( serialized_org: str = serialize(
@ -168,58 +145,63 @@ class OrgDetailView(DetailView):
pretty_org_data: str = json.dumps(org_data[0], indent=4) pretty_org_data: str = json.dumps(org_data[0], indent=4)
context.update( context: dict[str, Any] = {
{ "organization": organization,
"games": games, "games": games,
"org_data": pretty_org_data, "org_data": pretty_org_data,
}, }
)
return context return render(request, "twitch/organization_detail.html", 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]:
"""Get queryset of drop campaigns.
Returns: Returns:
QuerySet: Filtered drop campaigns. HttpResponse: The rendered campaign list page.
""" """
queryset: QuerySet[DropCampaign] = super().get_queryset() game_filter: str | None = request.GET.get("game")
game_filter: str | None = self.request.GET.get("game") status_filter: str | None = request.GET.get("status")
per_page: int = 100
queryset: QuerySet[DropCampaign] = DropCampaign.objects.all()
if game_filter: if game_filter:
queryset = queryset.filter(game__id=game_filter) queryset = queryset.filter(game__id=game_filter)
return queryset.select_related("game__owner").order_by("-start_at") queryset = queryset.select_related("game__owner").order_by("-start_at")
def get_context_data(self, **kwargs) -> dict[str, Any]: # Optionally filter by status (active, upcoming, expired)
"""Add additional context data. 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)
Args: paginator = Paginator(queryset, per_page)
**kwargs: Additional arguments. 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)
Returns: context: dict[str, Any] = {
dict: Context data. "campaigns": campaigns,
""" "games": Game.objects.all().order_by("display_name"),
context: dict[str, Any] = super().get_context_data(**kwargs) "status_options": ["active", "upcoming", "expired"],
"now": now,
context["games"] = Game.objects.all().order_by("display_name") "selected_game": game_filter or "",
context["status_options"] = ["active", "upcoming", "expired"] "selected_per_page": per_page,
context["now"] = timezone.now() "selected_status": status_filter or "",
context["selected_game"] = self.request.GET.get("game", "") }
context["selected_per_page"] = self.paginate_by return render(request, "twitch/campaign_list.html", context)
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,44 +220,69 @@ 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, def _enhance_drops_with_context(drops: QuerySet[TimeBasedDrop], now: datetime.datetime) -> list[dict[str, Any]]:
queryset: QuerySet[DropCampaign] | None = None, """Helper to enhance drops with countdown and context.
) -> Model:
"""Get the campaign object with related data prefetched.
Args: Args:
queryset: Optional queryset to use. drops: QuerySet of TimeBasedDrop objects.
now: Current datetime.
Returns: Returns:
DropCampaign: The campaign object with prefetched relations. List of dicts with drop, local_start, local_end, timezone_name, and countdown_text.
""" """
if queryset is None: enhanced = []
queryset = self.get_queryset() 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
queryset = queryset.select_related("game__owner")
return super().get_object(queryset=queryset) def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpResponse:
"""Function-based view for a drop campaign detail.
def get_context_data(self, **kwargs: object) -> dict[str, Any]: # noqa: PLR0914
"""Add additional context data.
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.
Raises:
Http404: If the campaign is not found.
""" """
context: dict[str, Any] = super().get_context_data(**kwargs) try:
campaign: DropCampaign = context["campaign"] 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] = ( drops: QuerySet[TimeBasedDrop] = (
TimeBasedDrop.objects TimeBasedDrop.objects
.filter(campaign=campaign) .filter(campaign=campaign)
@ -330,46 +337,19 @@ class DropCampaignDetailView(DetailView):
campaign_data[0]["fields"]["drops"] = drops_data campaign_data[0]["fields"]["drops"] = drops_data
# Enhance drops with additional context data
enhanced_drops: list[dict[str, TimeBasedDrop | datetime.datetime | str | None]] = []
now: datetime.datetime = timezone.now() now: datetime.datetime = timezone.now()
for drop in drops: enhanced_drops = _enhance_drops_with_context(drops, now)
# Calculate countdown text
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: context: dict[str, Any] = {
countdown_text: str = f"{days}d {hours}h {minutes}m" "campaign": campaign,
elif hours > 0: "now": now,
countdown_text = f"{hours}h {minutes}m" "drops": enhanced_drops,
elif minutes > 0: "campaign_data": format_and_color_json(campaign_data[0]),
countdown_text = f"{minutes}m {seconds}s" "owner": campaign.game.owner,
else: "allowed_channels": campaign.allow_channels.all().order_by("display_name"),
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 return render(request, "twitch/campaign_detail.html", context)
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