from __future__ import annotations
import datetime
from abc import abstractmethod
from functools import cached_property
from itertools import chain
from random import sample
from typing import Any, Iterable, Optional, Sequence, cast, overload
from django import forms
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import AccessMixin, LoginRequiredMixin
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
from django.core.mail import send_mail
from django.core.paginator import InvalidPage, Paginator
from django.db.models import Count, DurationField, F, QuerySet
from django.db.models.functions import Cast, Now
from django.forms import BaseForm
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.templatetags.static import static
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.dateparse import parse_duration
from django.views.generic import (
CreateView,
DetailView,
FormView,
ListView,
TemplateView,
UpdateView,
)
from requests.exceptions import RequestException
from nkdsu.mixins import MarkdownView
from .elf import ElfMixin
from ..anime import get_anime, suggest_anime
from ..forms import BadMetadataForm, DarkModeForm, RequestForm, VoteForm
from ..models import (
ProRouletteCommitment,
Profile,
Request,
Role,
Show,
Track,
TrackQuerySet,
TwitterUser,
UserTrackList,
UserTrackListTrack,
Vote,
)
from ..templatetags.vote_tags import eligible_for
from ..utils import (
BrowsableItem,
BrowsableYear,
clear_selection_if_matches,
vote_edit_cutoff,
)
from ..voter import Voter
from ...vote import mixins
PRO_ROULETTE = 'pro-roulette-{}'
[docs]
class IndexView(mixins.CurrentShowMixin, TemplateView):
section = 'home'
template_name = 'index.html'
[docs]
def get_context_data(self, **kwargs) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
show = context['show']
def track_should_be_in_main_list(track: Track) -> bool:
if self.request.user.is_authenticated and self.request.user.is_staff:
if track in show.shortlisted() or track in show.discarded():
return False
if track in show.playlist():
return False
return True
context['tracks'] = filter(
track_should_be_in_main_list, show.tracks_sorted_by_votes()
)
return context
[docs]
class Browse(TemplateView):
section = 'browse'
template_name = 'browse.html'
[docs]
class BrowseAnime(mixins.BrowseCategory):
section = 'browse'
category_name = 'anime'
[docs]
def get_categories(self) -> Iterable[BrowsableItem]:
for title in Track.all_anime_titles():
yield BrowsableItem(
url=reverse("vote:anime", kwargs={"anime": title}), name=title
)
[docs]
class BrowseArtists(mixins.BrowseCategory):
section = 'browse'
category_name = 'artists'
[docs]
def get_categories(self) -> Iterable[BrowsableItem]:
for artist in Track.all_artists():
yield BrowsableItem(
url=reverse("vote:artist", kwargs={"artist": artist}), name=artist
)
[docs]
class BrowseComposers(mixins.BrowseCategory):
section = 'browse'
category_name = 'composers'
[docs]
def get_categories(self) -> Iterable[BrowsableItem]:
for composer in Track.all_composers():
yield BrowsableItem(
url=reverse("vote:composer", kwargs={"composer": composer}),
name=composer,
)
[docs]
class BrowseYears(mixins.BrowseCategory):
section = 'browse'
category_name = 'years'
contents_required = False
searchable = False
[docs]
def get_categories(self) -> Iterable[BrowsableItem]:
for year, has_tracks in Track.complete_decade_range():
yield BrowsableYear(
name=str(year),
url=reverse("vote:year", kwargs={"year": year}) if has_tracks else None,
)
[docs]
class BrowseRoles(ElfMixin, mixins.BrowseCategory):
section = 'browse'
template_name = 'browse_roles.html'
category_name = 'roles'
[docs]
def get_categories(self) -> Iterable[BrowsableItem]:
for role in Track.all_public_roles():
yield BrowsableItem(url=None, name=role)
[docs]
class Archive(mixins.BreadcrumbMixin, mixins.ArchiveList):
section = 'browse'
template_name = 'archive.html'
breadcrumbs = mixins.BrowseCategory.breadcrumbs
[docs]
def get_queryset(self) -> QuerySet[Show]:
return (
super()
.get_queryset()
.filter(end__lt=timezone.now())
.prefetch_related('play_set', 'vote_set')
)
[docs]
class ShowDetail(mixins.BreadcrumbMixin, mixins.ShowDetail):
section = 'browse'
template_name = 'show_detail.html'
breadcrumbs = mixins.BrowseCategory.breadcrumbs + [
(reverse_lazy('vote:archive'), 'past shows')
]
model = Show
object: Show
[docs]
class ListenRedirect(mixins.ShowDetail):
section = 'browse'
template_name = 'show_detail.html'
[docs]
def get(self, *a, **k) -> HttpResponse:
super().get(*a, **k)
cloudcasts = self.object.cloudcasts()
if len(cloudcasts) == 1:
return redirect(cloudcasts[0]['url'])
elif len(cloudcasts) > 1:
messages.warning(
self.request,
"There's more than one Mixcloud upload for this show. "
"Please pick one of the {} listed below.".format(len(cloudcasts)),
)
else:
messages.error(
self.request,
"Sorry, we couldn't find an appropriate Mixcloud upload to "
"take you to.",
)
return redirect(cast(Show, self.object).get_absolute_url())
[docs]
class Roulette(ListView, AccessMixin):
section = 'roulette'
model = Track
template_name = 'roulette.html'
context_object_name = 'tracks'
default_minutes_count = 1
default_decade = 1980
modes = [
('hipster', 'hipster'),
('indiscriminate', 'indiscriminate'),
('almost-100', 'almost 100'),
('decade', 'decade'),
('short', 'short'),
('staple', 'staple'),
('pro', 'pro (only for pros)'),
]
[docs]
def get(self, request, *args, **kwargs) -> HttpResponse:
if kwargs.get('mode') != 'pro' and self.commitment() is not None:
return redirect(reverse('vote:roulette', kwargs={'mode': 'pro'}))
elif kwargs.get('mode') == 'pro' and not request.user.is_authenticated:
messages.warning(self.request, 'pro roulette requires you to log in')
return self.handle_no_permission()
elif kwargs.get('mode') is None:
if request.user.is_staff:
mode = 'short'
else:
mode = 'hipster'
return redirect(reverse('vote:roulette', kwargs={'mode': mode}))
else:
return super().get(request, *args, **kwargs)
@overload
def commitment(self, commit_from: TrackQuerySet) -> ProRouletteCommitment: ...
@overload
def commitment(self) -> Optional[ProRouletteCommitment]: ...
[docs]
def commitment(
self, commit_from: Optional[TrackQuerySet] = None
) -> Optional[ProRouletteCommitment]:
if not self.request.user.is_authenticated:
assert commit_from is None, 'cannot commit if not authenticated'
return None
try:
return ProRouletteCommitment.objects.get(
user=self.request.user, show=Show.current()
)
except ProRouletteCommitment.DoesNotExist:
if commit_from:
return ProRouletteCommitment.objects.create(
user=self.request.user,
show=Show.current(),
track=next(t for t in commit_from.order_by('?') if t.eligible()),
)
else:
return None
[docs]
def get_base_queryset(self) -> TrackQuerySet:
return self.model.objects.public()
[docs]
def get_tracks(self) -> tuple[Iterable[Track], int]:
qs = self.get_base_queryset()
if self.kwargs.get('mode') == 'pro':
return ([self.commitment(commit_from=qs).track], 1)
elif self.kwargs.get('mode') == 'hipster':
qs = qs.filter(play=None)
elif self.kwargs.get('mode') == 'almost-100':
qs = qs.exclude(
play__date__gt=Show.current().end - datetime.timedelta(days=(7 * 80)),
).exclude(play=None)
elif self.kwargs.get('mode') == 'decade':
qs = qs.for_decade(int(self.kwargs.get('decade', self.default_decade)))
elif self.kwargs.get('mode') == 'staple':
# Staple track: having been played more than once per year(ish)
# since the track was made available. Exclude tracks that don't
# yet have enough plays to be reasonably called a "staple".
qs = (
qs.annotate(plays=Count('play'))
.filter(plays__gt=2)
.annotate(
time_per_play=Cast(
((Now() - F('revealed')) / F('plays')),
output_field=DurationField(),
)
)
.filter(time_per_play__lt=parse_duration('365 days'))
)
# order_by('?') fails when annotate() has been used
staples = list(qs)
return (sample(staples, min(len(staples), 5)), len(staples))
elif self.kwargs.get('mode') == 'short':
length_msec = (
int(self.kwargs.get('minutes', self.default_minutes_count)) * 60 * 1000
)
qs = qs.filter(msec__gt=length_msec - 60_000, msec__lte=length_msec)
return (qs.order_by('?')[:5], qs.count())
[docs]
def get_context_data(self, **kwargs) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
mode = self.kwargs['mode']
decade_str = self.kwargs.get('decade', str(self.default_decade))
minutes_str = self.kwargs.get('minutes', str(self.default_minutes_count))
tracks, option_count = self.get_tracks()
context.update(
{
'pro_roulette_commitment': self.commitment(),
'decades': Track.all_decades(),
'decade': int(decade_str) if decade_str else None,
'minutes': int(minutes_str) if minutes_str else None,
'allowed_minutes': (1, 2, 3),
'mode': mode,
'mode_name': dict(self.modes)[mode],
'modes': self.modes,
'tracks': tracks,
'option_count': option_count,
}
)
return context
[docs]
class Search(ListView):
template_name = 'search.html'
model = Track
context_object_name = 'tracks'
paginate_by = 20
[docs]
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
resp = super().get(request, *args, **kwargs)
qs = self.get_queryset()
track_animes: set[str] = set(
(
role_detail.anime
for t in qs
for role_detail in t.role_details
if role_detail.anime is not None
)
)
all_animes = track_animes | self.anime_suggestions
# if our search results are identical to an anime detail page, or if
# there's one suggestion and no results, take us there instead
if len(all_animes) == 1:
(anime,) = all_animes
anime_qs = self.model.objects.by_anime(anime)
if anime is not None and (
(not qs)
or sorted((t.pk for t in anime_qs)) == sorted((t.pk for t in qs))
):
return redirect(reverse('vote:anime', kwargs={'anime': anime}))
return resp
@cached_property
def _queryset(self) -> TrackQuerySet:
return self.model.objects.search(
self.request.GET.get('q', ''),
show_secret_tracks=(
self.request.user.is_authenticated and self.request.user.is_staff
),
)
[docs]
def get_queryset(self) -> QuerySet[Track]:
return self._queryset
@cached_property
def anime_suggestions(self) -> set[str]:
query = self.request.GET.get('q')
return set() if query is None else suggest_anime(query)
[docs]
def get_context_data(self, **kwargs) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
query = self.request.GET.get('q', '')
context.update(
{
'query': query,
'anime_suggestions': self.anime_suggestions,
}
)
return context
[docs]
class TrackSpecificMixin:
kwargs: dict[str, Any]
[docs]
def get_track(self) -> Track:
return get_object_or_404(Track, pk=self.kwargs['pk'])
[docs]
def get_context_data(self, *args, **kwargs) -> dict[str, Any]:
context = super().get_context_data(*args, **kwargs) # type: ignore [misc]
context['track'] = self.get_track()
return context
[docs]
class TrackDetail(DetailView):
model = Track
template_name = 'track_detail.html'
context_object_name = 'track'
[docs]
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
self.object = cast(Track, self.get_object())
if kwargs.get('slug', None) != self.object.slug():
return redirect(self.object.get_absolute_url())
else:
return super().get(request, *args, **kwargs)
[docs]
class VoterDetail(DetailView):
paginate_by = 100
[docs]
@abstractmethod
def get_voter(self) -> Voter: ...
[docs]
def get_context_data(self, **kwargs) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
votes = cast(Voter, self.get_voter()).votes_with_liberal_preselection()
paginator = Paginator(votes, self.paginate_by)
try:
vote_page = paginator.page(self.request.GET.get('page', 1))
except InvalidPage:
raise Http404('Not a page')
context.update(
{
'votes': vote_page,
'page_obj': vote_page,
}
)
return context
[docs]
class UpdateVoteView(LoginRequiredMixin, UpdateView):
template_name = 'vote_edit.html'
fields = ['text']
[docs]
def get_queryset(self) -> QuerySet[Vote]:
# enforced by LoginRequiredMixin:
assert not isinstance(self.request.user, AnonymousUser)
return Vote.objects.filter(
user=self.request.user, show__showtime__gte=vote_edit_cutoff().showtime
)
[docs]
def get_success_url(self) -> str:
return reverse('vote:profiles:profile', kwargs={'username': self.object.user})
[docs]
class Year(mixins.BreadcrumbMixin, mixins.TrackListWithAnimeGroupingListView):
section = 'browse'
breadcrumbs = mixins.BrowseCategory.breadcrumbs + [
(reverse_lazy('vote:browse_years'), 'years')
]
template_name = 'year.html'
[docs]
def get_track_queryset(self) -> TrackQuerySet:
return Track.objects.public().filter(year=int(self.kwargs['year']))
[docs]
def get_context_data(self):
year = int(self.kwargs['year'])
def year_if_tracks_exist(year: int) -> Optional[int]:
return year if Track.objects.public().filter(year=year) else None
return {
**super().get_context_data(),
'year': year,
'previous_year': year_if_tracks_exist(year - 1),
'next_year': year_if_tracks_exist(year + 1),
}
[docs]
class Artist(mixins.BreadcrumbMixin, mixins.TrackListWithAnimeGroupingListView):
template_name = 'artist_detail.html'
section = 'browse'
breadcrumbs = mixins.BrowseCategory.breadcrumbs + [
(reverse_lazy('vote:browse_artists'), 'artists')
]
[docs]
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
response = super().get(request, *args, **kwargs)
if len(self.tracks) == 0:
response.status_code = 404
return response
[docs]
def get_track_queryset(self) -> Sequence[Track]:
return Track.objects.by_artist(
self.kwargs['artist'],
show_secret_tracks=(
self.request.user.is_authenticated and self.request.user.is_staff
),
)
[docs]
def artist_suggestions(self) -> set[str]:
return Track.suggest_artists(self.kwargs['artist'])
[docs]
def get_context_data(self, **kwargs) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
self.tracks = context['tracks']
context.update(
{
'artist': self.kwargs['artist'],
'played': [t for t in context['tracks'] if t.last_play()],
'artist_suggestions': self.artist_suggestions,
'tracks_as_composer': len(
Track.objects.by_composer(self.kwargs['artist'])
),
}
)
return context
[docs]
class Anime(mixins.BreadcrumbMixin, ListView):
section = 'browse'
breadcrumbs = mixins.BrowseCategory.breadcrumbs + [
(reverse_lazy('vote:browse_anime'), 'anime')
]
model = Track
template_name = 'anime_detail.html'
context_object_name = 'tracks'
[docs]
def get_queryset(self) -> list[Track]:
tracks = self.model.objects.by_anime(
self.kwargs['anime'],
show_secret_tracks=(
self.request.user.is_authenticated and self.request.user.is_staff
),
)
if len(tracks) == 0:
raise Http404('No tracks for this anime')
else:
return tracks
[docs]
def get_context_data(self, **kwargs) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
role_tracks: list[tuple[Role, Track]] = sorted(
(
(role, track)
for track in context[self.context_object_name]
for role in track.role_details_for_anime(self.kwargs['anime'])
),
key=lambda rt: rt[0],
)
anime_data = get_anime(self.kwargs['anime'])
related_anime = (
anime_data.related_anime()
if anime_data is not None
else context['tracks'][0]
.role_details_for_anime(self.kwargs['anime'])[0]
.related_anime
)
context.update(
{
'anime': self.kwargs['anime'],
'role_tracks': role_tracks,
'anime_data': anime_data,
'related_anime': related_anime,
}
)
return context
[docs]
class AnimePicture(Anime):
[docs]
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
self.get_queryset() # 404 if this isn't an anime we have on record
anime_data = get_anime(self.kwargs['anime'])
if anime_data is None:
raise Http404()
if not anime_data.picture_is_cached():
try:
anime_data.cache_picture()
except RequestException:
return redirect(static('i/naidesu.svg'))
return redirect(anime_data.cached_picture_url())
[docs]
class Composer(mixins.BreadcrumbMixin, mixins.TrackListWithAnimeGroupingListView):
section = 'browse'
template_name = 'composer_detail.html'
breadcrumbs = mixins.BrowseCategory.breadcrumbs + [
(reverse_lazy('vote:browse_composers'), 'composers')
]
[docs]
def get_track_queryset(self) -> Sequence[Track]:
if self.request.user.is_authenticated and self.request.user.is_staff:
qs = Track.objects.all()
else:
qs = Track.objects.public()
return qs.by_composer(self.kwargs['composer'])
[docs]
def get_context_data(self, **kwargs) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
context.update(
{
'composer': self.kwargs['composer'],
'tracks_as_artist': len(
Track.objects.by_artist(self.kwargs['composer'])
),
}
)
return context
[docs]
class Added(
mixins.BreadcrumbMixin, mixins.TrackListWithAnimeGrouping, mixins.ShowDetail
):
default_to_current = True
section = 'new tracks'
template_name = 'added.html'
paginate_by = 50
breadcrumbs = mixins.BrowseCategory.breadcrumbs + [
(reverse_lazy('vote:archive'), 'past shows')
]
model = Show
object: Show
[docs]
def get_track_queryset(self) -> TrackQuerySet:
return self.get_object().revealed()
[docs]
class Stats(TemplateView):
section = 'stats'
template_name = 'stats.html'
cache_key = 'stats:context'
[docs]
def unique_voters(
self, profiles: QuerySet[Profile], twitter_users: QuerySet[TwitterUser]
) -> list[Voter]:
seen_ids: set[tuple[Optional[int], Optional[int]]] = set()
voters: list[Voter] = []
for voter in chain(profiles, twitter_users):
vid = voter.voter_id
if vid not in seen_ids:
voters.append(voter)
seen_ids.add(voter.voter_id)
return voters
[docs]
def streaks(self) -> list[Voter]:
last_votable_show = Show.current().prev()
while last_votable_show is not None and not last_votable_show.voting_allowed:
last_votable_show = last_votable_show.prev()
return sorted(
self.unique_voters(
Profile.objects.filter(user__vote__show=last_votable_show),
TwitterUser.objects.filter(vote__show=last_votable_show),
),
key=lambda u: u.streak(),
reverse=True,
)
[docs]
def batting_averages(self) -> list[Voter]:
users = []
minimum_weight = 4
cutoff = Show.at(timezone.now() - datetime.timedelta(days=7 * 5)).end
for user in self.unique_voters(
Profile.objects.filter(user__vote__date__gt=cutoff),
TwitterUser.objects.filter(vote__date__gt=cutoff),
):
if user.batting_average(minimum_weight=minimum_weight):
users.append(user)
return sorted(
users,
key=lambda u: u.batting_average(minimum_weight=minimum_weight) or 0,
reverse=True,
)
[docs]
def popular_tracks(self) -> list[tuple[Track, int]]:
cutoff = Show.at(timezone.now() - datetime.timedelta(days=31 * 6)).end
tracks = []
for track in Track.objects.public():
tracks.append((track, track.vote_set.filter(date__gt=cutoff).count()))
return sorted(tracks, key=lambda t: t[1], reverse=True)
[docs]
def get_context_data(self, **kwargs) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
context.update(
{
'streaks': self.streaks,
'batting_averages': self.batting_averages,
'popular_tracks': self.popular_tracks,
}
)
return context
[docs]
class Info(MarkdownView):
title = 'what?'
filename = 'README.md'
[docs]
class APIDocs(MarkdownView):
title = 'api'
filename = 'API.md'
[docs]
class Privacy(MarkdownView):
title = 'privacy'
filename = 'PRIVACY.md'
[docs]
class TermsOfService(MarkdownView):
title = 'tos'
filename = 'TOS.md'
[docs]
class RequestAddition(LoginRequiredMixin, MarkdownView, FormView):
form_class = RequestForm
template_name = 'request.html'
success_url = reverse_lazy('vote:index')
filename = 'ELIGIBILITY.md'
title = 'Request an addition to the library'
[docs]
def get_initial(self) -> dict[str, Any]:
return {
**super().get_initial(),
**{k: v for (k, v) in self.request.GET.items()},
}
[docs]
class TracksFromGetParamMixin(TemplateView):
[docs]
def get_track_pks(self) -> list[str]:
track_pks_raw = self.request.GET.get('t')
if track_pks_raw is None:
return []
track_pks = track_pks_raw.split(',')
return track_pks
[docs]
def get_tracks(self) -> list[Track]:
tracks = [
get_object_or_404(Track.objects.public(), pk=pk)
for pk in self.get_track_pks()
]
if not tracks:
raise Http404('no tracks picked')
return tracks
[docs]
def get_context_data(self, **kwargs) -> dict[str, Any]:
tracks = self.get_tracks()
return {
**super().get_context_data(**kwargs),
'tracks': tracks,
}
[docs]
class VoteView(LoginRequiredMixin, TracksFromGetParamMixin, CreateView[Vote, VoteForm]):
model = Vote
form_class = VoteForm
template_name = 'vote.html'
success_url = reverse_lazy('vote:index')
[docs]
def get_track_pks(self) -> list[str]:
track_pks = super().get_track_pks()
if len(track_pks) > settings.MAX_REQUEST_TRACKS:
raise Http404('too many tracks')
return track_pks
[docs]
def get_tracks(self) -> list[Track]:
def track_should_be_allowed_for_this_user(track: Track) -> bool:
return eligible_for(track, self.request.user)
return list(filter(track_should_be_allowed_for_this_user, super().get_tracks()))
[docs]
class AddToListView(LoginRequiredMixin, TracksFromGetParamMixin, FormView):
template_name = 'add_to_list.html'
[docs]
def get_context_data(self, **kwargs) -> dict[str, Any]:
assert self.request.user.is_authenticated
context = super().get_context_data(**kwargs)
return {
**context,
'already_in': {
track: UserTrackList.objects.filter(
user=self.request.user, tracks=track
)
for track in context['tracks']
},
}
[docs]
class SetDarkModeView(FormView):
http_method_names = ['post']
form_class = DarkModeForm
success_url = reverse_lazy('vote:index')
[docs]
def get_success_url(self) -> str:
return self.request.META.get('HTTP_REFERER', self.success_url)