diff --git a/API.md b/API.md index 60e79e42..6a5041fa 100644 --- a/API.md +++ b/API.md @@ -59,4 +59,3 @@ that's broken, [file a bug][new_issue]. [eg_latest_week]: https://nkd.su/api/week/ [eg_week]: https://nkd.su/api/week/2013-01-05/ [eg_search]: https://nkd.su/api/search/?q=character%20song -[pester]: https://twitter.com/intent/tweet?text=%40mftb diff --git a/PRIVACY.md b/PRIVACY.md index 32324f49..f3f8f49c 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -61,6 +61,7 @@ This can include: - a [hashed][django-password-storage] password - a screen name and a display name - an avatar +- an email address [django-password-storage]: https://docs.djangoproject.com/en/3.2/topics/auth/passwords/#how-django-stores-passwords "how Django stores passwords" @@ -71,7 +72,9 @@ These will be used to: You can change your names or avatar at any time. Please contact an admin if you would like to have this information modified or deleted in a way that the -website doesn't give you controls for. +website doesn't give you controls for. Early in the migration away from +Twitter, less of this will be user-modifiable than we would like, but we'll be +working to sort that quickly. ### sessions diff --git a/README.md b/README.md index 5483d01f..3aaaa3e8 100644 --- a/README.md +++ b/README.md @@ -4,37 +4,31 @@ radio show currently broadcast on [The Cat]. [nkd.su]: https://nkd.su -[The Cat]: https://thisisthecat.com +[The Cat]: https://thecat1079.co.uk [Neko Desu]: https://nekodesu.radio ### How do I make a request? If there are any tracks on the front page you would like to request, click on -the ‘+’ next to the current requests and send the generated tweet. If nothing -on the front page strikes your fancy, feel free to use the search box to find -something more your style. Once you've found something, click the + button. If -you can't find what you're looking for, fill out the [request an addition] -form. +the ‘+’ next to the existing requests. If you're signed in, you'll be taken to +a page where you can see what you're requesting and, if you like, add an +optional accompanying message. -You can personalise your request with a message that will appear when people -hover over your user icon. All text that is not either a valid track URL or the -[@nkdsu] mention at the start of the tweet will be displayed. +If you're _not_ signed in, you'll have to follow the instructions on the login +page to set up an appropriate account before you can make requests. Sorry that +the login page is so busy; if you're new here, you can just head straight for +the 'new nkd.su visitors' form. -If you have javascript enabled, you can select tracks so that you can request -several at once by clicking anywhere in a track box that is not a link. +If nothing on the front page strikes your fancy, feel free to use the search +box to find something more your style. Once you've found something, click the + +button. If you can't find what you're looking for and would like to get it +added to the library, you can fill out the [request an addition] form. -None of your Twitter followers will see your request in their timelines unless -they're also following [@nkdsu]. +If you have javascript enabled, you can select multiple tracks to request them +at the same time. You can do this by clicking anywhere in a track box that is +not a link. [request an addition]: https://nkd.su/request -[@nkdsu]: https://twitter.com/nkdsu - -### All this is well and good but I don't like Twitter. - -Yeah, I get it. In that case, just send Peter an [email] or a text or -something. - -[email]: mailto:peter@nekodesu.radio ## How does it work? diff --git a/TWITTER.md b/TWITTER.md new file mode 100644 index 00000000..bb4bd712 --- /dev/null +++ b/TWITTER.md @@ -0,0 +1,18 @@ +It's no longer possible for nkd.su to require the use of Twitter, so we're +migrating to a local account system for making requests. + +**If you're an existing nkd.su requester**, you should register using your +Twitter account to inherit your existing request history. You'll be able to +create a password for logging in and disassociate your Twitter account; we just +want to provide continuity. + +**If you're new here**, you can make a local-only account from scratch. + +Either way, there are some things you should know: + +- Newly-created accounts can not use usernames that match the handle of any + Twitter user who has requested things in the past. +- The [privacy policy] is still worth reading. + +[@nkdsu]: https://twitter.com/nkdsu/status/744237593164980224 +[privacy policy]: https://nkd.su/info/privacy/ diff --git a/nkdsu/__init__.py b/nkdsu/__init__.py index e69de29b..53021a8a 100644 --- a/nkdsu/__init__.py +++ b/nkdsu/__init__.py @@ -0,0 +1,3 @@ +from .monkey import patch + +patch() diff --git a/nkdsu/apps/vote/admin.py b/nkdsu/apps/vote/admin.py index 16f9b8a4..8b8264ac 100644 --- a/nkdsu/apps/vote/admin.py +++ b/nkdsu/apps/vote/admin.py @@ -40,8 +40,8 @@ class DiscardShortlistAdmin(admin.ModelAdmin): class RequestAdmin(admin.ModelAdmin): - list_display = ('created', 'successful') - list_filter = ('successful',) + list_display = ('created', 'filled_by', 'claimant', 'submitted_by') + list_filter = ('filled_by', 'claimant', 'submitted_by') class NoteAdmin(admin.ModelAdmin): diff --git a/nkdsu/apps/vote/apps.py b/nkdsu/apps/vote/apps.py new file mode 100644 index 00000000..5bf0f813 --- /dev/null +++ b/nkdsu/apps/vote/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig +from django.db.models.signals import post_migrate, post_save + + +class VoteConfig(AppConfig): + name = 'nkdsu.apps.vote' + + def ready(self) -> None: + from . import signals + + post_save.connect(signals.create_profile_on_user_creation) + post_migrate.connect(signals.make_elfs) diff --git a/nkdsu/apps/vote/elfs.py b/nkdsu/apps/vote/elfs.py new file mode 100644 index 00000000..722b5a26 --- /dev/null +++ b/nkdsu/apps/vote/elfs.py @@ -0,0 +1,10 @@ +from django.contrib.auth.models import AnonymousUser, User + + +ELFS_NAME = "Elfs" + + +def is_elf(user: User | AnonymousUser) -> bool: + return user.is_authenticated and ( + user.is_staff or user.groups.filter(name=ELFS_NAME).exists() + ) diff --git a/nkdsu/apps/vote/fixtures/vote.json b/nkdsu/apps/vote/fixtures/vote.json index c257039a..7402d4bc 100644 --- a/nkdsu/apps/vote/fixtures/vote.json +++ b/nkdsu/apps/vote/fixtures/vote.json @@ -348,7 +348,6 @@ "pk": 1, "model": "vote.request", "fields": { - "successful": true, "blob": "{\"trivia\": \"borderlands\", \"trivia_question\": \"We have always treaded ____\", \"details\": \"Should be \\\"THE iDOLM@STER\\\" - there is no \\\"the\\\" at the moment.\", \"contact\": \"\"}", "created": "2014-01-05T23:30:04.751Z" } @@ -392,5 +391,19 @@ "public": true, "show": 79 } +}, +{ + "pk": 43, + "model": "auth.user", + "fields": { + "username": "someone" + } +}, +{ + "pk": 45, + "model": "vote.profile", + "fields": { + "user": 43 + } } ] diff --git a/nkdsu/apps/vote/forms.py b/nkdsu/apps/vote/forms.py index 4dbe2484..a867267d 100644 --- a/nkdsu/apps/vote/forms.py +++ b/nkdsu/apps/vote/forms.py @@ -3,12 +3,9 @@ from typing import Any, Optional from django import forms -from django.core.validators import validate_email from django.utils.safestring import mark_safe -import tweepy -from .models import Note, Request -from .utils import reading_tw_api +from .models import Note, Track, Vote from ..vote import trivia _disable_autocorrect = { @@ -22,24 +19,15 @@ ) -def email_or_twitter(address: str) -> None: - try: - validate_email(address) - except forms.ValidationError: - try: - reading_tw_api.get_user(screen_name=address.lstrip('@')) - except tweepy.TweepError: - raise forms.ValidationError( - 'Enter a valid email address or twitter username' - ) - +class ClearableFileInput(forms.widgets.ClearableFileInput): + """ + The stock clearable file widget generates HTML that cannot be easily laid + out in a reasonable way with CSS. In particular, the way the 'clear' + checkbox is not put in any kind of elements makes intentional layout + basically impossible. Here, we aim to fix that. + """ -class EmailOrTwitterField(forms.EmailField): - widget = forms.TextInput(attrs=dict(_disable_autocorrect, autocapitalize="off")) - default_error_messages = { - 'invalid': u'Enter a valid email address or Twitter username', - } - default_validators = [email_or_twitter] + template_name = 'widgets/clearable_file_input.html' class SearchForm(forms.Form): @@ -53,7 +41,6 @@ class TriviaForm(forms.Form): trivia_question = forms.CharField(widget=forms.HiddenInput) trivia = forms.CharField(required=False) - track = None def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) @@ -75,13 +62,6 @@ def clean_trivia(self): re.I, ) - request = Request() - request.successful = bool(human) - if self.track: - request.track_id = self.track.pk - request.serialise(self.cleaned_data) - request.save() - if not human: hint = ( "That's not right, sorry. There are hints None: - self.track = kwargs.pop('track') + def __init__(self, *args, track: Track, **kwargs) -> None: + self.track = track super().__init__(*args, **kwargs) -class RequestForm(TriviaForm): +class RequestForm(forms.Form): + """ + A form for requesting that a track be added to the library. + """ + title = forms.CharField(widget=_proper_noun_textinput, required=False) artist = forms.CharField(widget=_proper_noun_textinput, required=False) show = forms.CharField( @@ -120,29 +104,35 @@ class RequestForm(TriviaForm): label="Additional Details", required=False, ) - contact = EmailOrTwitterField(label="Email Address/Twitter name", required=True) def clean(self) -> Optional[dict[str, Any]]: cleaned_data = super().clean() if cleaned_data is None: return None - compulsory = Request.METADATA_KEYS - - filled = [ - cleaned_data[f] - for f in cleaned_data - if f not in compulsory and cleaned_data[f] - ] + filled = [cleaned_data[f] for f in cleaned_data if cleaned_data[f]] - if len(filled) < 2: + if len(filled) < 1: raise forms.ValidationError( - "I'm sure you can give us more information than that." + 'please provide at least some information to work with' ) return cleaned_data +class VoteForm(forms.ModelForm): + """ + A form for creating a :class:`.models.Vote`. + """ + + class Meta: + model = Vote + fields = ['text'] + widgets = { + 'text': forms.TextInput(), + } + + class CheckMetadataForm(forms.Form): id3_title = forms.CharField(required=False, widget=forms.Textarea()) id3_artist = forms.CharField(required=False, widget=forms.Textarea()) diff --git a/nkdsu/apps/vote/management/commands/get_votes_from_user.py b/nkdsu/apps/vote/management/commands/get_votes_from_user.py deleted file mode 100644 index 0df51516..00000000 --- a/nkdsu/apps/vote/management/commands/get_votes_from_user.py +++ /dev/null @@ -1,26 +0,0 @@ -from django.core.management.base import BaseCommand -import tweepy -from tweepy.parsers import JSONParser - -from ...models import Vote -from ...utils import _read_tw_auth - - -class Command(BaseCommand): - def add_arguments(self, parser): - parser.add_argument('screen_name', type=str) - - def handle(self, screen_name, *args, **options): - t = tweepy.API(_read_tw_auth, parser=JSONParser()) - tweets = t.user_timeline( - count=200, - include_entities=True, - tweet_mode='extended', - screen_name=screen_name, - ) - - for tweet in tweets: - if ('full_text' not in tweet) and (len(tweet['text']) == 140): - tweet = t.get_status(tweet['id'], tweet_mode='extended') - - Vote.handle_tweet(tweet) diff --git a/nkdsu/apps/vote/management/commands/listen_for_votes.py b/nkdsu/apps/vote/management/commands/listen_for_votes.py deleted file mode 100644 index 4aef0746..00000000 --- a/nkdsu/apps/vote/management/commands/listen_for_votes.py +++ /dev/null @@ -1,25 +0,0 @@ -import json - -from django.core.management.base import BaseCommand -import tweepy - -from ...models import Vote -from ...utils import READING_USERNAME, _read_tw_auth - - -class TweetListener(tweepy.StreamListener): - def on_data(self, data): - Vote.handle_tweet(json.loads(data)) - - def on_error(self, status): - print(status) - return True - - -class Command(BaseCommand): - def handle(self, *args, **options): - listener = TweetListener() - - stream = tweepy.Stream(_read_tw_auth, listener) - track = ['@%s' % READING_USERNAME] - stream.filter(track=track) diff --git a/nkdsu/apps/vote/management/commands/refresh_votes.py b/nkdsu/apps/vote/management/commands/refresh_votes.py deleted file mode 100644 index 7262ed2d..00000000 --- a/nkdsu/apps/vote/management/commands/refresh_votes.py +++ /dev/null @@ -1,20 +0,0 @@ -from django.core.management.base import BaseCommand -import tweepy -from tweepy.parsers import JSONParser - -from ...models import Vote -from ...utils import _read_tw_auth - - -class Command(BaseCommand): - def handle(self, *args, **options): - t = tweepy.API(_read_tw_auth, parser=JSONParser()) - mentions = t.mentions_timeline( - count=200, include_entities=True, tweet_mode='extended' - ) - - for tweet in mentions: - if ('full_text' not in tweet) and (len(tweet['text']) == 140): - tweet = t.get_status(tweet['id'], tweet_mode='extended') - - Vote.handle_tweet(tweet) diff --git a/nkdsu/apps/vote/management/commands/update_long_tweets.py b/nkdsu/apps/vote/management/commands/update_long_tweets.py deleted file mode 100644 index f2e6766c..00000000 --- a/nkdsu/apps/vote/management/commands/update_long_tweets.py +++ /dev/null @@ -1,26 +0,0 @@ -from django.core.management.base import BaseCommand -import tweepy -from tweepy.parsers import JSONParser - -from ...models import Vote -from ...utils import _read_tw_auth - - -class Command(BaseCommand): - def handle(self, *args, **options): - - t = tweepy.API(_read_tw_auth, parser=JSONParser()) - - for vote in Vote.objects.exclude(tweet_id__isnull=True).order_by('-date'): - if len(vote.text) != 280: - continue - - print(vote.text) - try: - tweet_obj = t.get_status(vote.tweet_id, tweet_mode='extended') - except tweepy.TweepError as e: - print(e) - continue - vote.text = tweet_obj.get('full_text', None) or tweet_obj.get('text', None) - print(vote.text) - vote.save() diff --git a/nkdsu/apps/vote/management/commands/update_twitter_avatars.py b/nkdsu/apps/vote/management/commands/update_twitter_avatars.py deleted file mode 100644 index c513015c..00000000 --- a/nkdsu/apps/vote/management/commands/update_twitter_avatars.py +++ /dev/null @@ -1,30 +0,0 @@ -import logging -from time import sleep - -from django.core.management.base import BaseCommand -from tweepy import TweepError - -from nkdsu.apps.vote.models import TwitterUser - - -logger = logging.getLogger(__name__) - - -class Command(BaseCommand): - help = ( - 'For every TwitterUser in the database, check to see if the avatar ' - 'works. If it does not, update it' - ) - - def handle(self, *args, **options): - for user in TwitterUser.objects.all(): - try: - user.update_from_api() - user.get_avatar(size=None, from_cache=False) - user.get_avatar(size='original', from_cache=False) - except TweepError: - # this user probably does not exist, but we might be getting - # rate limited - pass - - sleep(5) diff --git a/nkdsu/apps/vote/migrations/0001_initial.py b/nkdsu/apps/vote/migrations/0001_initial.py index 7187322c..2efbfbce 100644 --- a/nkdsu/apps/vote/migrations/0001_initial.py +++ b/nkdsu/apps/vote/migrations/0001_initial.py @@ -8,7 +8,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [ diff --git a/nkdsu/apps/vote/migrations/0002_request_track.py b/nkdsu/apps/vote/migrations/0002_request_track.py index c856448b..77710594 100644 --- a/nkdsu/apps/vote/migrations/0002_request_track.py +++ b/nkdsu/apps/vote/migrations/0002_request_track.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ('vote', '0001_initial'), ] diff --git a/nkdsu/apps/vote/migrations/0003_track_metadata_locked.py b/nkdsu/apps/vote/migrations/0003_track_metadata_locked.py index 790d1557..325c5145 100644 --- a/nkdsu/apps/vote/migrations/0003_track_metadata_locked.py +++ b/nkdsu/apps/vote/migrations/0003_track_metadata_locked.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ('vote', '0002_request_track'), ] diff --git a/nkdsu/apps/vote/migrations/0004_auto_20201024_1609.py b/nkdsu/apps/vote/migrations/0004_auto_20201024_1609.py index bad9ff4f..00c2df4c 100644 --- a/nkdsu/apps/vote/migrations/0004_auto_20201024_1609.py +++ b/nkdsu/apps/vote/migrations/0004_auto_20201024_1609.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ('vote', '0003_track_metadata_locked'), ] diff --git a/nkdsu/apps/vote/migrations/0005_auto_20210508_1518.py b/nkdsu/apps/vote/migrations/0005_auto_20210508_1518.py index 899a82d7..6978d032 100644 --- a/nkdsu/apps/vote/migrations/0005_auto_20210508_1518.py +++ b/nkdsu/apps/vote/migrations/0005_auto_20210508_1518.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ('vote', '0004_auto_20201024_1609'), ] diff --git a/nkdsu/apps/vote/migrations/0006_auto_20220423_1813.py b/nkdsu/apps/vote/migrations/0006_auto_20220423_1813.py index 63858c93..c7d9c6a4 100644 --- a/nkdsu/apps/vote/migrations/0006_auto_20220423_1813.py +++ b/nkdsu/apps/vote/migrations/0006_auto_20220423_1813.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ('vote', '0005_auto_20210508_1518'), ] diff --git a/nkdsu/apps/vote/migrations/0007_alter_track_id3_artist.py b/nkdsu/apps/vote/migrations/0007_alter_track_id3_artist.py index cf60fb9d..5d02320f 100644 --- a/nkdsu/apps/vote/migrations/0007_alter_track_id3_artist.py +++ b/nkdsu/apps/vote/migrations/0007_alter_track_id3_artist.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ('vote', '0006_auto_20220423_1813'), ] diff --git a/nkdsu/apps/vote/migrations/0008_alter_userbadge_badge.py b/nkdsu/apps/vote/migrations/0008_alter_userbadge_badge.py index bee37ddf..9122469d 100644 --- a/nkdsu/apps/vote/migrations/0008_alter_userbadge_badge.py +++ b/nkdsu/apps/vote/migrations/0008_alter_userbadge_badge.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ('vote', '0007_alter_track_id3_artist'), ] diff --git a/nkdsu/apps/vote/migrations/0009_vote_user.py b/nkdsu/apps/vote/migrations/0009_vote_user.py new file mode 100644 index 00000000..36ba8fce --- /dev/null +++ b/nkdsu/apps/vote/migrations/0009_vote_user.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.16 on 2022-12-12 10:53 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('vote', '0008_alter_userbadge_badge'), + ] + + operations = [ + migrations.AddField( + model_name='vote', + name='user', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/nkdsu/apps/vote/migrations/0010_profile.py b/nkdsu/apps/vote/migrations/0010_profile.py new file mode 100644 index 00000000..ee15e496 --- /dev/null +++ b/nkdsu/apps/vote/migrations/0010_profile.py @@ -0,0 +1,55 @@ +# Generated by Django 3.2.16 on 2022-12-12 11:32 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import nkdsu.apps.vote.models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('vote', '0009_vote_user'), + ] + + operations = [ + migrations.CreateModel( + name='Profile', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'avatar', + models.ImageField( + upload_to=nkdsu.apps.vote.models.avatar_upload_path + ), + ), + ('display_name', models.CharField(max_length=100)), + ( + 'twitter_user', + models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='profile', + to='vote.twitteruser', + ), + ), + ( + 'user', + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name='profile', + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/nkdsu/apps/vote/migrations/0011_alter_profile_display_name.py b/nkdsu/apps/vote/migrations/0011_alter_profile_display_name.py new file mode 100644 index 00000000..c16b46d8 --- /dev/null +++ b/nkdsu/apps/vote/migrations/0011_alter_profile_display_name.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.16 on 2022-12-12 13:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('vote', '0010_profile'), + ] + + operations = [ + migrations.AlterField( + model_name='profile', + name='display_name', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/nkdsu/apps/vote/migrations/0012_alter_profile_avatar.py b/nkdsu/apps/vote/migrations/0012_alter_profile_avatar.py new file mode 100644 index 00000000..3059ac0d --- /dev/null +++ b/nkdsu/apps/vote/migrations/0012_alter_profile_avatar.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.16 on 2022-12-12 13:23 + +from django.db import migrations, models +import nkdsu.apps.vote.models + + +class Migration(migrations.Migration): + dependencies = [ + ('vote', '0011_alter_profile_display_name'), + ] + + operations = [ + migrations.AlterField( + model_name='profile', + name='avatar', + field=models.ImageField( + blank=True, upload_to=nkdsu.apps.vote.models.avatar_upload_path + ), + ), + ] diff --git a/nkdsu/apps/vote/migrations/0013_alter_vote_text.py b/nkdsu/apps/vote/migrations/0013_alter_vote_text.py new file mode 100644 index 00000000..93046829 --- /dev/null +++ b/nkdsu/apps/vote/migrations/0013_alter_vote_text.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.16 on 2022-12-16 15:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('vote', '0012_alter_profile_avatar'), + ] + + operations = [ + migrations.AlterField( + model_name='vote', + name='text', + field=models.TextField( + blank=True, + help_text='A comment to be shown alongside your request', + max_length=280, + ), + ), + ] diff --git a/nkdsu/apps/vote/migrations/0014_auto_20230209_2114.py b/nkdsu/apps/vote/migrations/0014_auto_20230209_2114.py new file mode 100644 index 00000000..3c0a4ee8 --- /dev/null +++ b/nkdsu/apps/vote/migrations/0014_auto_20230209_2114.py @@ -0,0 +1,39 @@ +# Generated by Django 3.2.17 on 2023-02-09 21:14 + +from django.db import migrations, models +import django_resized.forms +import nkdsu.apps.vote.models + + +class Migration(migrations.Migration): + dependencies = [ + ('vote', '0013_alter_vote_text'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='is_abuser', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='profile', + name='is_patron', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='profile', + name='avatar', + field=django_resized.forms.ResizedImageField( + blank=True, + crop=['middle', 'center'], + force_format='PNG', + help_text='will be resized to 500x500 and converted to png, so provide that if you can', + keep_meta=False, + quality=-1, + scale=None, + size=[500, 500], + upload_to=nkdsu.apps.vote.models.avatar_upload_path, + ), + ), + ] diff --git a/nkdsu/apps/vote/migrations/0015_give_users_profiles.py b/nkdsu/apps/vote/migrations/0015_give_users_profiles.py new file mode 100644 index 00000000..06356004 --- /dev/null +++ b/nkdsu/apps/vote/migrations/0015_give_users_profiles.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.17 on 2023-02-09 22:10 + +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor +from django.db.migrations.state import StateApps + + +def give_users_profiles( + apps: StateApps, schema_editor: BaseDatabaseSchemaEditor +) -> None: + User = apps.get_model('auth', 'User') + Profile = apps.get_model('vote', 'Profile') + + for user in User.objects.filter(profile__isnull=True): + Profile.objects.create(user=user) + + +def do_nothing(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None: + pass + + +class Migration(migrations.Migration): + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('vote', '0014_auto_20230209_2114'), + ] + + operations = [migrations.RunPython(give_users_profiles, do_nothing)] diff --git a/nkdsu/apps/vote/migrations/0016_authenticated_requests.py b/nkdsu/apps/vote/migrations/0016_authenticated_requests.py new file mode 100644 index 00000000..f7941726 --- /dev/null +++ b/nkdsu/apps/vote/migrations/0016_authenticated_requests.py @@ -0,0 +1,79 @@ +# Generated by Django 3.2.17 on 2023-02-10 09:14 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor +from django.db.migrations.state import StateApps + + +def remove_unsuccessful_trivia_requests( + apps: StateApps, schema_editor: BaseDatabaseSchemaEditor +) -> None: + Request = apps.get_model('vote', 'Request') + Request.objects.filter(successful=False).delete() + + +def do_nothing(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None: + pass + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('vote', '0015_give_users_profiles'), + ] + + operations = [ + migrations.RunPython(remove_unsuccessful_trivia_requests, do_nothing), + migrations.RemoveField( + model_name='request', + name='successful', + ), + migrations.AddField( + model_name='request', + name='submitted_by', + field=models.ForeignKey( + blank=True, + help_text='the person who submitted this request', + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='submitted_requests', + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name='request', + name='claimant', + field=models.ForeignKey( + blank=True, + help_text='the elf who is taking care of this request', + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='claims', + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name='request', + name='filled_by', + field=models.ForeignKey( + blank=True, + help_text='the elf who fulfilled this request', + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name='request', + name='track', + field=models.ForeignKey( + blank=True, + help_text='the track that this request is about, if this is a request for a correction', + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to='vote.track', + ), + ), + ] diff --git a/nkdsu/apps/vote/mixins.py b/nkdsu/apps/vote/mixins.py index 27eb6f77..95533b0d 100644 --- a/nkdsu/apps/vote/mixins.py +++ b/nkdsu/apps/vote/mixins.py @@ -1,15 +1,12 @@ from __future__ import annotations -import codecs import datetime import re from abc import abstractmethod from collections import OrderedDict from copy import copy -from os import path from typing import Any, Generic, Iterable, Optional, Sequence, TypeVar, cast -from django.conf import settings from django.db.models import Model, QuerySet from django.db.utils import NotSupportedError from django.http import Http404, HttpRequest, HttpResponse @@ -18,7 +15,6 @@ from django.utils import timezone from django.views.generic import DetailView, ListView, TemplateView from django.views.generic.base import ContextMixin -from markdown import markdown from .models import Show, Track, TrackQuerySet, TwitterUser from .utils import BrowsableItem, memoize @@ -246,30 +242,6 @@ def get_context_data(self, **kwargs) -> dict[str, Any]: } -class MarkdownView(TemplateView): - template_name = 'markdown.html' - filename: str - title: str - - def get_context_data(self, **kwargs) -> dict[str, Any]: - context = super().get_context_data(**kwargs) - - words = markdown( - codecs.open( - path.join(settings.PROJECT_ROOT, self.filename), encoding='utf-8' - ).read() - ) - - context.update( - { - 'title': self.title, - 'words': words, - } - ) - - return context - - class TwitterUserDetailMixin(LetMemoizeGetObject[TwitterUser]): model = TwitterUser diff --git a/nkdsu/apps/vote/models.py b/nkdsu/apps/vote/models.py index f0271f5f..34173832 100644 --- a/nkdsu/apps/vote/models.py +++ b/nkdsu/apps/vote/models.py @@ -4,17 +4,17 @@ import json import re from dataclasses import asdict, dataclass +from enum import Enum, auto from io import BytesIO from string import ascii_letters -from typing import Any, Iterable, Literal, Optional, cast -from urllib.parse import urlparse +from typing import Any, Iterable, Optional, cast +from urllib.parse import quote +from uuid import uuid4 from Levenshtein import ratio from PIL import Image, ImageFilter -from dateutil import parser as date_parser from django.conf import settings from django.contrib.auth import get_user_model -from django.contrib.staticfiles import finders from django.core.cache import cache from django.core.exceptions import ValidationError from django.core.files import File @@ -23,29 +23,31 @@ from django.db.models import Q from django.template.defaultfilters import slugify from django.templatetags.static import static -from django.urls import Resolver404, resolve, reverse +from django.urls import reverse from django.utils import timezone from django.utils.timezone import get_default_timezone +from django_resized import ResizedImageField from markdown import markdown import requests -import tweepy from .managers import NoteQuerySet, TrackQuerySet from .parsers import ParsedArtist, parse_artist +from .placeholder_avatars import placeholder_avatar_for from .utils import ( READING_USERNAME, + assert_never, + cached, indefinitely, lastfm, length_str, memoize, musicbrainzngs, pk_cached, - posting_tw_api, - reading_tw_api, reify, split_id3_title, - vote_tweet_intent_url, + vote_url, ) +from .voter import Voter from ..vote import mixcloud @@ -100,6 +102,7 @@ def clean(self) -> None: ) @classmethod + @cached(2, 'vote:models:Show:current') def current(cls) -> Show: """ Get (or create, if necessary) the show that will next end. @@ -270,6 +273,7 @@ def tracks_sorted_by_votes(self) -> list[Track]: votes = ( Vote.objects.filter(show=self) .filter(Q(twitter_user__is_abuser=False) | Q(twitter_user__isnull=True)) + .filter(Q(user__profile__is_abuser=False) | Q(user__isnull=True)) .prefetch_related('tracks') .order_by('-date') ) @@ -346,10 +350,12 @@ class Meta: ordering = ['-showtime'] -class TwitterUser(CleanOnSaveMixin, models.Model): +class TwitterUser(Voter, CleanOnSaveMixin, models.Model): class Meta: ordering = ['screen_name'] + is_twitteruser = True + # Twitter stuff screen_name = models.CharField(max_length=100) user_id = models.BigIntegerField(unique=True) @@ -366,6 +372,11 @@ def __str__(self) -> str: def __repr__(self) -> str: return self.screen_name + def _twitter_user_and_profile( + self, + ) -> tuple[Optional[TwitterUser], Optional[Profile]]: + return (self, getattr(self, 'profile', None)) + def twitter_url(self) -> str: return 'https://twitter.com/%s' % self.screen_name @@ -373,196 +384,129 @@ def get_absolute_url(self) -> str: return reverse('vote:user', kwargs={'screen_name': self.screen_name}) def get_toggle_abuser_url(self) -> str: - return reverse('vote:admin:toggle_abuser', kwargs={'user_id': self.user_id}) + return reverse( + 'vote:admin:toggle_twitter_abuser', kwargs={'user_id': self.user_id} + ) - def get_avatar_url(self) -> str: - return static('i/vote-kinds/tweet.png') + def get_avatar_url(self, try_profile: bool = True) -> str: + if try_profile and hasattr(self, 'profile'): + return self.profile.get_avatar_url() + return static(placeholder_avatar_for(self)) @memoize - def get_avatar( - self, - size: Optional[Literal['original', 'normal']] = None, - from_cache: bool = True, - ) -> tuple[str, bytes]: - ck = f'twav:{size}:{self.pk}' - - if from_cache: - hit = cache.get(ck) - if hit: - return hit - - try: - url = self.get_twitter_user().profile_image_url_https - except tweepy.TweepError as e: - if (e.api_code == 63) or (e.api_code == 50): - # 63: user is suspended; 50: no such user - found = finders.find('i/suspended.png') - if found is None or isinstance(found, list): - raise RuntimeError(f'could not find the placeholder image: {found}') - rv = ('image/png', open(found, 'rb').read()) - else: - raise - else: - if size is not None: - # `size` here can, I think, also be stuff like '400x400' or whatever, - # but i'm not sure exactly what the limits are and we're not using any - # of them anyway, so here's what we support: - if size != 'original': - url_size = '_{}'.format(size) - else: - url_size = '' - url = re.sub(r'_normal(?=\.[^.]+$)', url_size, url) - - resp = requests.get(url) - rv = (resp.headers['content-type'], resp.content) - - # update_twitter_avatars will call this every day with - # from_cache=False, and might sometimes fail, so: - cache.set(ck, rv, int(60 * 60 * 24 * 2.1)) + def unordered_votes(self) -> models.QuerySet[Vote]: + if hasattr(self, 'profile'): + return self.profile.votes() + return self.vote_set.all() - return rv + def api_dict(self, verbose: bool = False) -> dict[str, Any]: + return { + 'user_name': self.name, + 'user_screen_name': self.screen_name, + 'user_image': self.get_avatar_url(), + 'user_id': self.user_id, + } @memoize - def votes(self) -> models.QuerySet[Vote]: - return self.vote_set.order_by('-date').prefetch_related('tracks') + def is_new(self) -> bool: + return not self.vote_set.exclude(show=Show.current()).exists() @memoize - def votes_with_liberal_preselection(self) -> models.QuerySet[Vote]: - return self.votes().prefetch_related( - 'show', - 'show__play_set', - 'show__play_set__track', # doesn't actually appear to work :< - ) + def is_placated(self) -> bool: + return self.vote_set.filter( + tracks__play__show=Show.current(), + show=Show.current(), + ).exists() @memoize - def votes_for(self, show: Show) -> models.QuerySet[Vote]: - return self.votes().filter(show=show) + def is_shortlisted(self) -> bool: + return self.vote_set.filter( + tracks__shortlist__show=Show.current(), + show=Show.current(), + ).exists() + @property @memoize - def tracks_voted_for_for(self, show: Show) -> list[Track]: - tracks = [] - track_pk_set = set() + def badges(self) -> models.QuerySet[UserBadge]: + return self.userbadge_set.all() - for vote in self.votes_for(show): - for track in vote.tracks.all(): - if track.pk not in track_pk_set: - track_pk_set.add(track.pk) - tracks.append(track) - return tracks +def avatar_upload_path(instance: Profile, filename: str) -> str: + return f"avatars/{instance.user.username}/{uuid4()}.png" - def _batting_average( - self, - cutoff: Optional[datetime.datetime] = None, - minimum_weight: float = 1, - ) -> Optional[float]: - def ba( - pk, current_show_pk, cutoff: Optional[datetime.datetime] - ) -> tuple[float, float]: - score: float = 0 - weight: float = 0 - - for vote in self.vote_set.filter(date__gt=cutoff).prefetch_related( - 'tracks' - ): - success = vote.success() - if success is not None: - score += success * vote.weight() - weight += vote.weight() - - return (score, weight) - - score, weight = ba(self.pk, Show.current().pk, cutoff) - - if weight >= minimum_weight: - return score / weight - else: - # there were no worthwhile votes - return None - return score +AVATAR_SIZE = 500 - @memoize - def batting_average(self, minimum_weight: float = 1) -> Optional[float]: - """ - Return a user's batting average for the past six months. - """ - - return self._batting_average( - cutoff=Show.at(timezone.now() - datetime.timedelta(days=31 * 6)).end, - minimum_weight=minimum_weight, - ) - def _streak(self, ls=[]) -> int: - show = Show.current().prev() - streak = 0 +class Profile(Voter, CleanOnSaveMixin, models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') + twitter_user = models.OneToOneField( + TwitterUser, + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name='profile', + ) + avatar = ResizedImageField( + upload_to=avatar_upload_path, + blank=True, + crop=['middle', 'center'], + force_format='PNG', + keep_meta=False, + size=[AVATAR_SIZE, AVATAR_SIZE], + help_text=f'will be resized to {AVATAR_SIZE}x{AVATAR_SIZE} and converted to png, so provide that if you can', + # it'd be nice to optipng these as they're uploaded, but we can always do it later or in a cron job + ) + display_name = models.CharField(max_length=100, blank=True) - while True: - if show is None: - return streak - elif not show.voting_allowed: - show = show.prev() - elif show.votes().filter(twitter_user=self).exists(): - streak += 1 - show = show.prev() - else: - break + is_patron = models.BooleanField(default=False) + is_abuser = models.BooleanField(default=False) - return streak + def __str__(self) -> str: + return f'{self.display_name} ({self.user.username})' - @memoize - def streak(self) -> int: - def streak(pk, current_show): - return self._streak() + def _twitter_user_and_profile( + self, + ) -> tuple[Optional[TwitterUser], Optional[Profile]]: + return (self.twitter_user, self) - return streak(self.pk, Show.current()) + def get_absolute_url(self) -> str: + return reverse("vote:profiles:profile", kwargs={'username': self.user.username}) - def all_time_batting_average(self, minimum_weight: float = 1) -> Optional[float]: - return self._batting_average(minimum_weight=minimum_weight) + def get_avatar_url(self) -> str: + if self.avatar: + return self.avatar.url + elif self.twitter_user: + return self.twitter_user.get_avatar_url(try_profile=False) + else: + return static(placeholder_avatar_for(self)) @memoize - @pk_cached(60 * 60 * 1) - def get_twitter_user(self) -> tweepy.User: - return reading_tw_api.get_user(user_id=self.user_id) - - def update_from_api(self) -> None: - """ - Update this user's database object based on the Twitter API. - """ - - api_user = self.get_twitter_user() + def unordered_votes(self) -> models.QuerySet[Vote]: + q = Q(user=self.user) + if self.twitter_user: + q = q | Q(twitter_user=self.twitter_user) - self.name = api_user.name - self.screen_name = api_user.screen_name - self.updated = timezone.now() + return Vote.objects.filter(q) - self.save() - - def api_dict(self, verbose: bool = False) -> dict[str, Any]: - return { - 'user_name': self.name, - 'user_screen_name': self.screen_name, - 'user_image': self.get_avatar_url(), - 'user_id': self.user_id, - } + @property # type: ignore[override] + def name(self) -> str: + return self.display_name or f'@{self.user.username}' - @memoize - def is_new(self) -> bool: - return not self.vote_set.exclude(show=Show.current()).exists() + @name.setter + def name(self, name: str) -> None: + self.display_name = name - @memoize - def is_placated(self) -> bool: - return self.vote_set.filter( - tracks__play__show=Show.current(), - show=Show.current(), - ).exists() + def get_toggle_abuser_url(self) -> str: + return reverse('vote:admin:toggle_local_abuser', kwargs={'user_id': self.pk}) + @property @memoize - def is_shortlisted(self) -> bool: - return self.vote_set.filter( - tracks__shortlist__show=Show.current(), - show=Show.current(), - ).exists() + def badges(self) -> models.QuerySet[UserBadge]: + if self.twitter_user: + return self.twitter_user.userbadge_set.all() + else: + return UserBadge.objects.none() # to be properly handled in issue #245 def art_path(i: Track, f: str) -> str: @@ -975,23 +919,34 @@ def notes(self) -> models.QuerySet[Note]: def public_notes(self) -> models.QuerySet[Note]: return self.notes().filter(public=True) - def play(self, tweet: bool = True) -> Play: + def play_tweet_content(self) -> str: + # here we add a zwsp after every . to prevent twitter from turning + # things into links + delinked_name = str(self).replace('.', '.\u200b') + + status = f'Now playing on {settings.HASHTAG}: {delinked_name}' + + if len(status) > settings.TWEET_LENGTH: + # twitter counts ellipses as two characters for some reason, so we get rid of two: + status = status[: settings.TWEET_LENGTH - 2].strip() + '…' + + return status + + def play_tweet_intent_url(self) -> str: + return 'https://twitter.com/intent/tweet?text={text}'.format( + text=quote(self.play_tweet_content()) + ) + + def play(self) -> Play: """ Mark this track as played. """ - play = Play( + return Play.objects.create( track=self, date=timezone.now(), ) - play.save() - - if tweet: - play.tweet() - - return play - play.alters_data = True # type: ignore def shortlist(self) -> None: @@ -1068,10 +1023,10 @@ def get_report_url(self) -> str: def get_vote_url(self) -> str: """ - Return the Twitter intent url for voting for this track alone. + Return the url for voting for this track alone. """ - return vote_tweet_intent_url([self]) + return vote_url([self]) def get_lastfm_track(self) -> dict[str, Any]: return lastfm( @@ -1237,12 +1192,32 @@ def api_dict(self, verbose: bool = False) -> dict[str, Any]: ) +class VoteKind(Enum): + #: A request made using the website's built-in requesting machinery + local = auto() + + #: A request derived from a tweet + twitter = auto() + + #: A request manually created by an admin to reflect, for example, an email + manual = auto() + + class Vote(SetShowBasedOnDateMixin, CleanOnSaveMixin, models.Model): # universal tracks = models.ManyToManyField(Track, db_index=True) date = models.DateTimeField(db_index=True) show = models.ForeignKey(Show, related_name='vote_set', on_delete=models.CASCADE) - text = models.TextField(blank=True) + text = models.TextField( + blank=True, + max_length=280, + help_text='A comment to be shown alongside your request', + ) + + # local only + user = models.ForeignKey( + User, blank=True, null=True, db_index=True, on_delete=models.SET_NULL + ) # twitter only twitter_user = models.ForeignKey( @@ -1254,122 +1229,67 @@ class Vote(SetShowBasedOnDateMixin, CleanOnSaveMixin, models.Model): name = models.CharField(max_length=40, blank=True) kind = models.CharField(max_length=10, choices=MANUAL_VOTE_KINDS, blank=True) - @classmethod - def handle_tweet(cls, tweet) -> Optional[Vote]: - """ - Take a tweet json object and create, save and return the vote it has - come to represent (or None if it's not a valid vote). - """ - - text = tweet.get('full_text', None) or tweet.get('text', None) - - if text is None: - if 'delete' in tweet: - Vote.objects.filter(tweet_id=tweet['delete']['status']['id']).delete() - return None - - if cls.objects.filter(tweet_id=tweet['id']).exists(): - return None # we already have this tweet - - created_at = date_parser.parse(tweet['created_at']) - - def mention_is_first_and_for_us(mention): - return ( - mention['indices'][0] == 0 - and mention['screen_name'] == READING_USERNAME - ) - - if not any( - [mention_is_first_and_for_us(m) for m in tweet['entities']['user_mentions']] - ): - return None - - show = Show.at(created_at) - - user_qs = TwitterUser.objects.filter(user_id=tweet['user']['id']) - try: - twitter_user = user_qs.get() - except TwitterUser.DoesNotExist: - twitter_user = TwitterUser( - user_id=tweet['user']['id'], - ) - - twitter_user.screen_name = tweet['user']['screen_name'] - twitter_user.name = tweet['user']['name'] - twitter_user.updated = created_at - twitter_user.save() - - tracks = [] - for url in (tweet.get('extended_entities') or tweet.get('entities', {})).get( - 'urls', () - ): - parsed = urlparse(url['expanded_url']) - - try: - match = resolve(parsed.path) - except Resolver404: - continue - - if match.namespace == 'vote' and match.url_name == 'track': - track_qs = Track.objects.public().filter(pk=match.kwargs['pk']) - - try: - track = track_qs.get() - except Track.DoesNotExist: - continue - - if ( - track.pk - not in (t.pk for t in twitter_user.tracks_voted_for_for(show)) - and track.eligible() - ): - tracks.append(track) - - if tracks: - vote = cls( - tweet_id=tweet['id'], - twitter_user=twitter_user, - date=created_at, - text=text, - ) - - vote.save() - - for track in tracks: - vote.tracks.add(track) - - vote.save() - return vote - else: - return None - def clean(self) -> None: - if self.is_manual: - if self.tweet_id or self.twitter_user_id: - raise ValidationError('Twitter attributes present on manual vote') - if not (self.name and self.kind): - raise ValidationError('Attributes missing from manual vote') + match self.vote_kind: + case VoteKind.twitter: + if self.tweet_id or self.twitter_user_id: + raise ValidationError('Twitter attributes present on manual vote') + if self.user: + raise ValidationError('Local attributes present on manual vote') + if not (self.name and self.kind): + raise ValidationError('Attributes missing from manual vote') + return + case VoteKind.manual: + if self.name or self.kind: + raise ValidationError('Manual attributes present on Twitter vote') + if self.user: + raise ValidationError('Local attributes present on Twitter vote') + if not (self.tweet_id and self.twitter_user_id): + raise ValidationError( + 'Twitter attributes missing from Twitter vote' + ) + return + case VoteKind.local: + if self.name or self.kind: + raise ValidationError('Manual attributes present on local vote') + if self.tweet_id or self.twitter_user_id: + raise ValidationError('Twitter attributes present on local vote') + if not self.user: + raise ValidationError('No user specified for local vote') + return + + assert_never(self.vote_kind) + + @property + def vote_kind(self) -> VoteKind: + if self.tweet_id: + return VoteKind.twitter + elif self.kind: + return VoteKind.manual else: - if self.name or self.kind: - raise ValidationError('Manual attributes present on Twitter vote') - if not (self.tweet_id and self.twitter_user_id): - raise ValidationError('Twitter attributes missing from Twitter vote') + return VoteKind.local - def either_name(self) -> str: - if self.name: - return self.name - assert self.twitter_user is not None - return '@{0}'.format(self.twitter_user.screen_name) + @property + def is_twitter(self) -> bool: + return self.vote_kind == VoteKind.twitter - @reify + @property def is_manual(self) -> bool: - return not bool(self.tweet_id) + return self.vote_kind == VoteKind.manual + + @property + def is_local(self) -> bool: + return self.vote_kind == VoteKind.local def get_image_url(self) -> str: - if self.twitter_user: + if self.user and self.user.profile: + return self.user.profile.get_avatar_url() + elif self.twitter_user: return self.twitter_user.get_avatar_url() - else: + elif self.vote_kind == VoteKind.manual: return static('i/vote-kinds/{0}.png'.format(self.kind)) + else: + return static('i/noise.png') def __str__(self) -> str: tracks = u', '.join([t.title for t in self.tracks.all()]) @@ -1388,7 +1308,7 @@ def content(self) -> str: content = self.text - if not self.is_manual: + if self.vote_kind == VoteKind.twitter: while content.lower().startswith('@{}'.format(READING_USERNAME).lower()): content = content.split(' ', 1)[1] @@ -1521,33 +1441,6 @@ def save(self, *args, **kwargs) -> None: self.track.revealed = timezone.now() self.track.save() - def get_tweet_text(self) -> str: - # here we add a zwsp after every . to prevent twitter from turning - # things into links - delinked_name = str(self.track).replace('.', '.\u200b') - - status = f'Now playing on {settings.HASHTAG}: {delinked_name}' - - if len(status) > settings.TWEET_LENGTH: - # twitter counts ellipses as two characters for some reason, so we get rid of two: - status = status[: settings.TWEET_LENGTH - 2].strip() + '…' - - return status - - def tweet(self) -> None: - """ - Send out a tweet for this play, set self.tweet_id and save. - """ - - if self.tweet_id is not None: - raise TypeError('This play has already been tweeted') - - tweet = posting_tw_api.update_status(self.get_tweet_text()) - self.tweet_id = tweet.id - self.save() - - tweet.alters_data = True # type: ignore - def api_dict(self, verbose: bool = False) -> dict[str, Any]: return { 'time': self.date, @@ -1607,23 +1500,45 @@ class Meta: class Request(CleanOnSaveMixin, models.Model): """ - A request for a database addition. Stored for the benefit of enjoying - hilarious spam. + A request for a database addition or modification. """ + #: keys of `blob` that no longer get set, but which may exist on historic Requests METADATA_KEYS = ['trivia', 'trivia_question', 'contact'] created = models.DateTimeField(auto_now_add=True) - successful = models.BooleanField() blob = models.TextField() + submitted_by = models.ForeignKey( + User, + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name='submitted_requests', + help_text='the person who submitted this request', + ) filled = models.DateTimeField(blank=True, null=True) filled_by = models.ForeignKey( - User, blank=True, null=True, on_delete=models.SET_NULL + User, + blank=True, + null=True, + on_delete=models.SET_NULL, + help_text='the elf who fulfilled this request', ) claimant = models.ForeignKey( - User, blank=True, null=True, related_name='claims', on_delete=models.SET_NULL + User, + blank=True, + null=True, + related_name='claims', + on_delete=models.SET_NULL, + help_text='the elf who is taking care of this request', + ) + track = models.ForeignKey( + Track, + blank=True, + null=True, + on_delete=models.SET_NULL, + help_text='the track that this request is about, if this is a request for a correction', ) - track = models.ForeignKey(Track, blank=True, null=True, on_delete=models.SET_NULL) def serialise(self, struct): self.blob = json.dumps(struct) diff --git a/nkdsu/apps/vote/placeholder_avatars.py b/nkdsu/apps/vote/placeholder_avatars.py new file mode 100644 index 00000000..cd791858 --- /dev/null +++ b/nkdsu/apps/vote/placeholder_avatars.py @@ -0,0 +1,38 @@ +from typing import Optional + +from django.contrib.staticfiles import finders + +from .voter import Voter + + +__all__ = ['placeholder_avatar_for'] + + +def placeholder_avatar_for(voter: Voter) -> str: + uid: tuple[str, Optional[int], Optional[int]] = ('nkdsu-voter',) + voter.voter_id + + return _static_path_from_filename( + AVATAR_FILENAMES[hash(uid) % len(AVATAR_FILENAMES)] + ) + + +AVATAR_FILENAMES = [ + f'icon{i}-{deg}.svg' for i in range(1, 5) for deg in range(0, 360, 15) +] + + +def _static_path_from_filename(avatar_filename: str) -> str: + return f'i/placeholder-avatars/{avatar_filename}' + + +def _verify_avatar_list() -> None: + """ + Make sure all the avatars are actually present. + """ + + for avatar_filename in AVATAR_FILENAMES: + if finders.find(_static_path_from_filename(avatar_filename)) is None: + raise AssertionError(f'{avatar_filename} placeholder avatar not present') + + +_verify_avatar_list() diff --git a/nkdsu/apps/vote/signals.py b/nkdsu/apps/vote/signals.py new file mode 100644 index 00000000..b50abb85 --- /dev/null +++ b/nkdsu/apps/vote/signals.py @@ -0,0 +1,27 @@ +from typing import Optional, Sequence + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.db.models import Model + +from .elfs import ELFS_NAME +from .models import Profile + +User = get_user_model() + + +def create_profile_on_user_creation( + sender: type[Model], + instance: Model, + created: bool, + raw: bool, + using: Optional[str], + update_fields: Optional[Sequence[str]], + **kwargs, +) -> None: + if created and (not raw) and isinstance(instance, User): + Profile.objects.create(user=instance) + + +def make_elfs(**kwargs) -> None: + Group.objects.get_or_create(name=ELFS_NAME) diff --git a/nkdsu/apps/vote/templates/widgets/clearable_file_input.html b/nkdsu/apps/vote/templates/widgets/clearable_file_input.html new file mode 100644 index 00000000..2b343edc --- /dev/null +++ b/nkdsu/apps/vote/templates/widgets/clearable_file_input.html @@ -0,0 +1,24 @@ +{% if widget.is_initial %} + + {{ widget.initial_text }}: + this file + + + {% if not widget.required %} + + + + + {% endif %} +{% endif %} + + + {% if widget.is_initial %} + {{ widget.input_text }}: + {% endif %} + + diff --git a/nkdsu/apps/vote/templatetags/vote_tags.py b/nkdsu/apps/vote/templatetags/vote_tags.py index 7a05dccf..c2890752 100644 --- a/nkdsu/apps/vote/templatetags/vote_tags.py +++ b/nkdsu/apps/vote/templatetags/vote_tags.py @@ -3,6 +3,7 @@ import datetime from typing import Iterable, Optional +from django.contrib.auth.models import AnonymousUser, User from django.db.models import QuerySet from django.template import Library from django.utils import timezone @@ -66,3 +67,22 @@ def total_length(tracks: Iterable[Track]): @register.filter def markdown(text: str) -> SafeText: return mark_safe(md(text)) + + +@register.filter +def is_elf(user: User) -> bool: + from ..elfs import is_elf + + return is_elf(user) + + +@register.filter +def eligible_for(track: Track, user: User | AnonymousUser) -> bool: + return ( + (user.is_authenticated) + and ( + track.pk + not in (t.pk for t in user.profile.tracks_voted_for_for(Show.current())) + ) + and track.eligible() + ) diff --git a/nkdsu/apps/vote/tests/test_models.py b/nkdsu/apps/vote/tests/test_models.py index 68b1d162..e6217071 100644 --- a/nkdsu/apps/vote/tests/test_models.py +++ b/nkdsu/apps/vote/tests/test_models.py @@ -1,9 +1,9 @@ from django.test import TestCase -from ..models import Play, Show, Track +from ..models import Track -class PlayTest(TestCase): +class TrackTest(TestCase): fixtures = ['vote.json'] def test_tweet_only_contains_first_role(self) -> None: @@ -15,10 +15,7 @@ def test_tweet_only_contains_first_role(self) -> None: ["Cat's Eye OP1", "Gintama Insert Song EP84"], ) - play = Play.objects.create( - track=track_with_multiple_roles, date=Show.objects.all()[0].showtime - ) self.assertEqual( - play.get_tweet_text(), + track_with_multiple_roles.play_tweet_content(), "Now playing on #usedoken: ‘CAT'S EYE’ (Cat's Eye OP1) - Anri", ) diff --git a/nkdsu/apps/vote/tests/tests.py b/nkdsu/apps/vote/tests/tests.py index 638b77a7..d6f65f4d 100644 --- a/nkdsu/apps/vote/tests/tests.py +++ b/nkdsu/apps/vote/tests/tests.py @@ -103,27 +103,6 @@ class TrackTest(TestCase): def test_can_delete_tracks(self) -> None: Track.objects.all()[0].delete() - -class PlayTest(TestCase): - fixtures = ['vote.json'] - - def setUp(self) -> None: - Play.objects.all().delete() - - def test_plays_for_already_played_tracks_can_not_be_added(self) -> None: - track_1, track_2 = Track.objects.all()[:2] - track_1.play(tweet=False) - track_2.play(tweet=False) - - self.assertEqual(Play.objects.all().count(), 2) - self.assertRaises(ValidationError, lambda: track_2.play(tweet=False)) - self.assertEqual(Play.objects.all().count(), 2) - - def test_plays_can_be_edited_after_the_fact(self) -> None: - play = Track.objects.all()[0].play(tweet=False) - play.date = mkutc(2009, 1, 1) - play.save() - def test_play_truncates_tweet_properly(self) -> None: track = Track.objects.create( id="longname", @@ -138,10 +117,27 @@ def test_play_truncates_tweet_properly(self) -> None: added=timezone.now(), revealed=timezone.now(), ) - tweet_text = Play.objects.create( - date=timezone.now(), - show=Show.at(timezone.now()), - track=track, - ).get_tweet_text() + tweet_text = track.play_tweet_content() self.assertRegex(tweet_text, r'^.{278}…$') self.assertEqual(len(tweet_text), 279) + + +class PlayTest(TestCase): + fixtures = ['vote.json'] + + def setUp(self) -> None: + Play.objects.all().delete() + + def test_plays_for_already_played_tracks_can_not_be_added(self) -> None: + track_1, track_2 = Track.objects.all()[:2] + track_1.play() + track_2.play() + + self.assertEqual(Play.objects.all().count(), 2) + self.assertRaises(ValidationError, lambda: track_2.play()) + self.assertEqual(Play.objects.all().count(), 2) + + def test_plays_can_be_edited_after_the_fact(self) -> None: + play = Track.objects.all()[0].play() + play.date = mkutc(2009, 1, 1) + play.save() diff --git a/nkdsu/apps/vote/twitter_auth.py b/nkdsu/apps/vote/twitter_auth.py index c2a57504..a4f4752e 100644 --- a/nkdsu/apps/vote/twitter_auth.py +++ b/nkdsu/apps/vote/twitter_auth.py @@ -1,16 +1,93 @@ +import re +from typing import TypedDict + from django.conf import settings +from django.contrib import messages +from django.contrib.auth.models import User +from django.core.files.base import ContentFile +from django.http import HttpRequest +import requests from social_core.backends.twitter import TwitterOAuth from social_core.pipeline import DEFAULT_AUTH_PIPELINE +from social_django.models import UserSocialAuth from social_django.strategy import DjangoStrategy +from .models import TwitterUser, avatar_upload_path + + +class DoNotAuthThroughTwitterPlease(BaseException): + msg: str + + def __init__(self, msg: str) -> None: + self.msg = msg + + +class UserDetailsDict(TypedDict): + username: str + fullname: str + default_profile_image: bool + profile_image_url_https: str + class NkdsuTwitterAuth(TwitterOAuth): def __init__(self, *args, **kwargs): - if 'redirect_uri' in kwargs: - kwargs['redirect_uri'] = settings.SITE_URL + kwargs['redirect_uri'] + redir = kwargs.get('redirect_uri') or '/' + kwargs['redirect_uri'] = settings.SITE_URL + redir super().__init__(*args, **kwargs) - def get_user_details(self, response): + def auth_allowed(self, response: dict, details: UserDetailsDict) -> bool: + allowed = super().auth_allowed(response, details) + + try: + existing_twitteruser = TwitterUser.objects.get(user_id=int(response['id'])) + except TwitterUser.DoesNotExist: + existing_twitteruser = None + + try: + existing_usa = UserSocialAuth.objects.get(uid=str(response['id'])) + except UserSocialAuth.DoesNotExist: + existing_usa = None + + request: HttpRequest = self.strategy.request + + if existing_twitteruser is None: + raise DoNotAuthThroughTwitterPlease( + 'the twitter account you logged in with has no history of requesting things on nkd.su; ' + 'you should make a account with a username and password instead' + ) + + if ( + # block auth attempts if this user is already signed in and already + # has an associated twitter account. there's no reason to repeat + # this process. + (request.user.is_authenticated) + and (request.user.profile.twitter_user is not None) + ): + raise DoNotAuthThroughTwitterPlease( + 'you are already logged in, and your account is already associated with ' + f'{request.user.profile.twitter_user.screen_name}. ' + 'you should not associate it again', + ) + + if hasattr(existing_twitteruser, 'profile'): + if ( + # this account is currently associated with a different user + (existing_usa is not None) + and (existing_twitteruser.profile.user != existing_usa.user) + ): + raise DoNotAuthThroughTwitterPlease( + 'this twitter account is currently associated with an nkd.su account other than yours' + ) + + if existing_usa is None: + raise DoNotAuthThroughTwitterPlease( + 'this twitter account has already been adopted and disconnected. ' + 'please log in with your username and password' + ) + + return allowed + + def get_user_details(self, response: dict) -> UserDetailsDict: """ Like super().get_user_details(), but doesn't try to split names into first and last parts. @@ -19,6 +96,8 @@ def get_user_details(self, response): return { 'username': response['screen_name'], 'fullname': response['name'], + 'default_profile_image': response['default_profile_image'], + 'profile_image_url_https': response['profile_image_url_https'], } @@ -30,4 +109,52 @@ class NkdsuStrategy(DjangoStrategy): """ def get_pipeline(self, backend=None): - return DEFAULT_AUTH_PIPELINE + return ( + DEFAULT_AUTH_PIPELINE + + ('nkdsu.apps.vote.twitter_auth.link_twitteruser',) + + ('nkdsu.apps.vote.twitter_auth.adopt_twitter_metadata',) + ) + + +def link_twitteruser(uid: int, user: User, is_new: bool, *args, **kwargs) -> None: + # this has been previously guaranteed to exist in our auth_allowed implementation + twitter_user = TwitterUser.objects.get(user_id=str(uid)) + profile = user.profile + + # and also, we should check this just to be sure: + if hasattr(twitter_user, 'profile') and (twitter_user.profile.user != user): + raise AssertionError( + f'auth_allowed() messed up, because {user!r} should not have been able to log in as {twitter_user!r}; ' + f'that user was already adopted by {twitter_user.profile.user!r}' + ) + + if is_new: + profile.is_abuser = twitter_user.is_abuser + profile.is_patron = twitter_user.is_patron + + if user.profile.twitter_user is None: + profile.twitter_user = twitter_user + profile.save() + + +def adopt_twitter_metadata( + request: HttpRequest, user: User, details: UserDetailsDict, *args, **kwargs +) -> None: + profile = user.profile + + if (not details['default_profile_image']) and (not profile.avatar): + try: + url = details['profile_image_url_https'] + avatar = ContentFile( + requests.get(re.sub(r'_normal(?=\.[^.]+$)', '', url)).content + ) + profile.avatar.save(name=avatar_upload_path(profile, ''), content=avatar) + except requests.RequestException as e: + messages.error( + request, f'sorry, we were unable to retrieve your twitter avatar: {e!r}' + ) + + if not profile.display_name: + profile.display_name = details['fullname'] + + profile.save() diff --git a/nkdsu/apps/vote/urls.py b/nkdsu/apps/vote/urls.py index 56cddee5..aa82cf47 100644 --- a/nkdsu/apps/vote/urls.py +++ b/nkdsu/apps/vote/urls.py @@ -27,6 +27,11 @@ ), url(r'^check-metadata/$', admin.CheckMetadata.as_view(), name='check_metadata'), url(r'^play/(?P.+)/$', admin.Play.as_view(), name='play'), + url( + r'^post-about-play/(?P.+)/$', + admin.PostAboutPlay.as_view(), + name='post_about_play', + ), url( r'^add-manual-vote/(?P.+)/$', admin.ManualVote.as_view(), @@ -54,7 +59,6 @@ ), url(r'^inudesu/$', admin.InuDesuTracks.as_view(), name='inudesu'), url(r'^artless/$', admin.ArtlessTracks.as_view(), name='artless'), - url(r'^trivia/$', admin.BadTrivia.as_view(), name='bad_trivia'), url( r'^shortlist/(?P.+)/$', admin.MakeShortlist.as_view(), name='shortlist' ), @@ -85,9 +89,14 @@ name='reset_shortlist_discard_selection', ), url( - r'^abuse/(?P.+)/$', - admin.ToggleAbuser.as_view(), - name='toggle_abuser', + r'^tw-abuse/(?P.+)/$', + admin.ToggleTwitterAbuser.as_view(), + name='toggle_twitter_abuser', + ), + url( + r'^local-abuse/(?P.+)/$', + admin.ToggleLocalAbuser.as_view(), + name='toggle_local_abuser', ), url(r'^make-note/(?P.+)/$', admin.MakeNote.as_view(), name='make_note'), url( @@ -126,7 +135,10 @@ ) profile_patterns = ( - [path('/', profiles.ProfileView.as_view(), name='profile')], + [ + path('@/', profiles.ProfileView.as_view(), name='profile'), + path('profile/', profiles.UpdateProfileView.as_view(), name='edit-profile'), + ], 'profiles', ) @@ -135,7 +147,7 @@ url(r'^vote-admin/', include(admin_patterns)), url(r'^js/', include(js_patterns)), url(r'^api/', include(api_patterns)), - url(r'^folks/', include(profile_patterns)), + url(r'^', include(profile_patterns)), url(r'^$', views.IndexView.as_view(), name='index'), url(r'^browse/$', views.Browse.as_view(), name='browse'), url(r'^anime/$', views.BrowseAnime.as_view(), name='browse_anime'), @@ -177,11 +189,6 @@ views.TwitterUserDetail.as_view(), name='user', ), - url( - r'^twitter-avatar/(?P\d+)/$', - views.TwitterAvatarView.as_view(), - name='twitter-avatar', - ), url(r'^artist/(?P.*)/$', views.Artist.as_view(), name='artist'), url(r'^anime/(?P.*)/$', views.Anime.as_view(), name='anime'), url(r'^composer/(?P.*)/$', views.Composer.as_view(), name='composer'), @@ -191,7 +198,10 @@ url(r'^info/privacy/$', views.Privacy.as_view(), name='privacy'), url(r'^info/tos/$', views.TermsOfService.as_view(), name='tos'), url(r'^info/api/$', views.APIDocs.as_view(), name='api_docs'), - url(r'^request/$', views.RequestAddition.as_view(), name='request_addition'), + url( + r'^request-addition/$', views.RequestAddition.as_view(), name='request_addition' + ), + url(r'^request/$', views.VoteView.as_view(), name='vote'), url(r'^set-dark-mode/$', views.SetDarkModeView.as_view(), name='set-dark-mode'), # tracks url(r'^(?P[0-9A-F]{16})/$', views.TrackDetail.as_view(), name='track'), diff --git a/nkdsu/apps/vote/utils.py b/nkdsu/apps/vote/utils.py index 5f91a7a9..b40dea00 100644 --- a/nkdsu/apps/vote/utils.py +++ b/nkdsu/apps/vote/utils.py @@ -1,7 +1,6 @@ from __future__ import annotations import logging -import re import string from dataclasses import dataclass from functools import partial @@ -10,40 +9,31 @@ Callable, Generic, Iterable, + NoReturn, Optional, TYPE_CHECKING, TypeVar, cast, ) -from urllib.parse import quote +from urllib.parse import urlencode from classtools import reify as ct_reify from django.conf import settings +from django.contrib.auth.models import User from django.core.cache import cache +from django.core.exceptions import ObjectDoesNotExist +from django.urls import reverse import musicbrainzngs +from mypy_extensions import KwArg, VarArg import requests -import tweepy if TYPE_CHECKING: - from .models import Track + from .models import Profile, Track logger = logging.getLogger(__name__) -_read_tw_auth = tweepy.OAuthHandler(settings.CONSUMER_KEY, settings.CONSUMER_SECRET) -_read_tw_auth.set_access_token( - settings.READING_ACCESS_TOKEN, settings.READING_ACCESS_TOKEN_SECRET -) -reading_tw_api = tweepy.API(_read_tw_auth) - -_post_tw_auth = tweepy.OAuthHandler(settings.CONSUMER_KEY, settings.CONSUMER_SECRET) -_post_tw_auth.set_access_token( - settings.POSTING_ACCESS_TOKEN, settings.POSTING_ACCESS_TOKEN_SECRET -) -posting_tw_api = tweepy.API(_post_tw_auth) - - indefinitely: int = ( (60 * 60 * 24 * 7) + (60 * 60) + 60 ) # one week, one hour and one minute @@ -81,43 +71,8 @@ def group(self) -> tuple[int, str]: return (decade, f"{decade}s") -def _get_short_url_length() -> int: - cache_key = 'tw-short-url-length' - length = cache.get(cache_key) - if length is not None: - return length - try: - length = reading_tw_api.configuration()['short_url_length_https'] - except tweepy.error.TweepError as e: - logger.critical( - "could not read twitter configuration to determine short URL length:\n{}".format( - e - ) - ) - length = 22 - - cache.set(cache_key, length) - return length - - -def _get_reading_username() -> str: - cache_key = 'tw-reading-username:{}'.format(settings.READING_ACCESS_TOKEN) - username = cache.get(cache_key) - if username is not None: - return username - - try: - username = reading_tw_api.auth.get_username() - except tweepy.error.TweepError as e: - logger.critical("could not read reading account's username:\n{}".format(e)) - return 'nkdsu' - else: - cache.set(cache_key, username) - return username - - -SHORT_URL_LENGTH: int = _get_short_url_length() -READING_USERNAME: str = _get_reading_username() +SHORT_URL_LENGTH: int = 20 +READING_USERNAME: str = 'nkdsu' def length_str(msec: float) -> str: @@ -138,36 +93,10 @@ def length_str(msec: float) -> str: return '%i:%02d' % (minutes, remainder_seconds) -def tweet_url(tweet: str) -> str: - return 'https://twitter.com/intent/tweet?in_reply_to={reply_id}&text={text}'.format( - reply_id='744237593164980224', text=quote(tweet) - ) - - def vote_url(tracks: Iterable[Track]) -> str: - return tweet_url(vote_tweet(tracks)) - - -def vote_tweet(tracks: Iterable[Track]) -> str: - """ - Return what a person should tweet to request `tracks`. - """ - - return ' '.join([t.get_public_url() for t in tracks]) - - -def vote_tweet_intent_url(tracks: Iterable[Track]) -> str: - tweet = vote_tweet(tracks) - return tweet_url(tweet) - - -def tweet_len(tweet: str) -> int: - placeholder_url = '' - while len(placeholder_url) < SHORT_URL_LENGTH: - placeholder_url = placeholder_url + 'x' - - shortened = re.sub(r'https?://[^\s]+', placeholder_url, tweet) - return len(shortened) + base = reverse('vote:vote') + query = {'t': ','.join(t.id for t in tracks)} + return f'{base}?{urlencode(query)}' def split_id3_title(id3_title: str) -> tuple[str, Optional[str]]: @@ -270,6 +199,29 @@ def reify(func: Callable[[Any], T]) -> T: return cast(T, ct_reify(func)) +C = TypeVar('C', bound=Callable[[VarArg(Any), KwArg(Any)], Any]) + + +def cached(seconds: int, cache_key: str) -> Callable[[C], C]: + def wrapper(func: C) -> C: + def wrapped(*a, **k) -> Any: + def do_thing(func, *a, **k) -> Any: + hit = cache.get(cache_key) + + if hit is not None: + return hit + + rv = func(*a, **k) + cache.set(cache_key, rv, seconds) + return rv + + return do_thing(func, *a, **k) + + return cast(C, wrapped) + + return wrapper + + def pk_cached(seconds: int) -> Callable[[T], T]: # does nothing (currently), but expresses a desire to cache stuff in future def wrapper(func: T) -> T: @@ -297,4 +249,17 @@ def lastfm(**kwargs): return resp.json() +def assert_never(value: NoReturn) -> NoReturn: + assert False, f'this code should not have been reached; got {value!r}' + + +def get_profile_for(user: User) -> Profile: + try: + return user.profile + except ObjectDoesNotExist: + from .models import Profile + + return Profile.objects.create(user=user) + + musicbrainzngs.set_useragent('nkd.su', '0', 'http://nkd.su/') diff --git a/nkdsu/apps/vote/views/__init__.py b/nkdsu/apps/vote/views/__init__.py index 2a3b346c..dc41568b 100644 --- a/nkdsu/apps/vote/views/__init__.py +++ b/nkdsu/apps/vote/views/__init__.py @@ -1,11 +1,15 @@ from __future__ import annotations import datetime +from abc import abstractmethod +from itertools import chain from random import sample from typing import Any, Iterable, Optional, Sequence, cast from django.conf import settings from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.models import AnonymousUser from django.core.mail import send_mail from django.core.paginator import InvalidPage, Paginator from django.db.models import Count, DurationField, F, QuerySet @@ -16,21 +20,23 @@ from django.urls import reverse, reverse_lazy from django.utils import timezone from django.utils.dateparse import parse_duration -from django.views.generic import DetailView, FormView, ListView, TemplateView -import tweepy +from django.views.generic import ( + CreateView, + DetailView, + FormView, + ListView, + TemplateView, +) -from ..forms import BadMetadataForm, DarkModeForm, RequestForm -from ..models import Show, Track, TrackQuerySet, TwitterUser +from nkdsu.mixins import AnyLoggedInUserMixin, MarkdownView +from ..forms import BadMetadataForm, DarkModeForm, RequestForm, VoteForm +from ..models import Profile, Request, Show, Track, TrackQuerySet, TwitterUser, Vote +from ..templatetags.vote_tags import eligible_for from ..utils import BrowsableItem, BrowsableYear, reify +from ..voter import Voter from ...vote import mixins -post_tw_auth = tweepy.OAuthHandler(settings.CONSUMER_KEY, settings.CONSUMER_SECRET) -post_tw_auth.set_access_token( - settings.POSTING_ACCESS_TOKEN, settings.POSTING_ACCESS_TOKEN_SECRET -) -tw_api = tweepy.API(post_tw_auth) - PRO_ROULETTE = 'pro-roulette-{}' @@ -355,16 +361,17 @@ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: return super().get(request, *args, **kwargs) -class TwitterUserDetail(mixins.TwitterUserDetailMixin, DetailView): - template_name = 'twitter_user_detail.html' - context_object_name = 'voter' +class VoterDetail(DetailView): paginate_by = 100 - model = TwitterUser + + @abstractmethod + def get_voter(self) -> Voter: + ... def get_context_data(self, **kwargs) -> dict[str, Any]: context = super().get_context_data(**kwargs) - votes = cast(TwitterUser, self.get_object()).votes_with_liberal_preselection() + votes = cast(Voter, self.get_voter()).votes_with_liberal_preselection() paginator = Paginator(votes, self.paginate_by) try: @@ -382,15 +389,20 @@ def get_context_data(self, **kwargs) -> dict[str, Any]: return context -class TwitterAvatarView(mixins.TwitterUserDetailMixin, DetailView): +class TwitterUserDetail(mixins.TwitterUserDetailMixin, VoterDetail): + template_name = 'twitter_user_detail.html' + context_object_name = 'voter' model = TwitterUser def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - image: bytes | str - content_type, image = cast(TwitterUser, self.get_object()).get_avatar( - size='original' if request.GET.get('size') == 'original' else None - ) - return HttpResponse(image, content_type=content_type) + twu = self.get_object() + if hasattr(twu, 'profile'): + return redirect(twu.profile.get_absolute_url()) + else: + return super().get(request, *args, **kwargs) + + def get_voter(self) -> TwitterUser: + return self.get_object() class Year(mixins.BreadcrumbMixin, mixins.TrackListWithAnimeGroupingListView): @@ -542,26 +554,44 @@ class Stats(TemplateView): template_name = 'stats.html' cache_key = 'stats:context' - def streaks(self) -> list[TwitterUser]: + 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 + + 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( - TwitterUser.objects.filter( - vote__show=last_votable_show, - ).distinct(), + 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, ) - def batting_averages(self) -> list[TwitterUser]: + def batting_averages(self) -> list[Voter]: users = [] minimum_weight = 4 cutoff = Show.at(timezone.now() - datetime.timedelta(days=7 * 5)).end - for user in set(TwitterUser.objects.filter(vote__date__gt=cutoff)): + 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) @@ -592,27 +622,27 @@ def get_context_data(self, **kwargs) -> dict[str, Any]: return context -class Info(mixins.MarkdownView): +class Info(MarkdownView): title = 'what?' filename = 'README.md' -class APIDocs(mixins.MarkdownView): +class APIDocs(MarkdownView): title = 'api' filename = 'API.md' -class Privacy(mixins.MarkdownView): +class Privacy(MarkdownView): title = 'privacy' filename = 'PRIVACY.md' -class TermsOfService(mixins.MarkdownView): +class TermsOfService(MarkdownView): title = 'tos' filename = 'TOS.md' -class ReportBadMetadata(mixins.BreadcrumbMixin, FormView): +class ReportBadMetadata(AnyLoggedInUserMixin, mixins.BreadcrumbMixin, FormView): form_class = BadMetadataForm template_name = 'report.html' @@ -633,13 +663,17 @@ def get_success_url(self) -> str: return self.get_track().get_absolute_url() def form_valid(self, form: BaseForm) -> HttpResponse: - f = form.cleaned_data - track = Track.objects.get(pk=self.kwargs['pk']) + assert self.request.user.is_authenticated # guaranteed by AnyLoggedInUserMixin + + request = Request(submitted_by=self.request.user, track=track) + request.serialise(form.cleaned_data) + request.save() + + f = form.cleaned_data fields = ['%s:\n%s' % (r, f[r]) for r in f if f[r]] fields.append(track.get_public_url()) - send_mail( '[nkd.su report] %s' % track.get_absolute_url(), '\n\n'.join(fields), @@ -664,7 +698,7 @@ def get_breadcrumbs(self) -> list[tuple[Optional[str], str]]: ] -class RequestAddition(mixins.MarkdownView, FormView): +class RequestAddition(AnyLoggedInUserMixin, MarkdownView, FormView): form_class = RequestForm template_name = 'request.html' success_url = reverse_lazy('vote:index') @@ -678,10 +712,13 @@ def get_initial(self) -> dict[str, Any]: } def form_valid(self, form: BaseForm) -> HttpResponse: - f = form.cleaned_data + assert self.request.user.is_authenticated # guaranteed by AnyLoggedInUserMixin + request = Request(submitted_by=self.request.user) + request.serialise(form.cleaned_data) + request.save() + f = form.cleaned_data fields = ['%s:\n%s' % (r, f[r]) for r in f if f[r]] - send_mail( '[nkd.su] %s' % f['title'], '\n\n'.join(fields), @@ -699,6 +736,62 @@ def form_valid(self, form: BaseForm) -> HttpResponse: return super().form_valid(form) +class VoteView(LoginRequiredMixin, CreateView): + form_class = VoteForm + template_name = 'vote.html' + success_url = reverse_lazy('vote:index') + + 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(',') + + if len(track_pks) > settings.MAX_REQUEST_TRACKS: + raise Http404('too many tracks') + + return track_pks + + 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, + ( + get_object_or_404(Track.objects.public(), pk=pk) + for pk in self.get_track_pks() + ), + ) + ) + + def get_form_kwargs(self) -> dict[str, Any]: + self.get_track_pks() # to make sure we throw a 404 before doing anything with too many tracks + + assert not isinstance(self.request.user, AnonymousUser) + instance = Vote(user=self.request.user, date=timezone.now()) + return { + **super().get_form_kwargs(), + 'instance': instance, + } + + def form_valid(self, form: VoteForm) -> HttpResponse: + resp = super().form_valid(form) + form.instance.tracks.set(self.get_tracks()) + self.request.session['selection'] = [] + return resp + + def get_context_data(self, **kwargs) -> dict[str, Any]: + tracks = self.get_tracks() + return { + **super().get_context_data(**kwargs), + 'tracks': tracks, + } + + class SetDarkModeView(FormView): http_method_names = ['post'] form_class = DarkModeForm diff --git a/nkdsu/apps/vote/views/admin.py b/nkdsu/apps/vote/views/admin.py index c790f1f7..489c58dc 100644 --- a/nkdsu/apps/vote/views/admin.py +++ b/nkdsu/apps/vote/views/admin.py @@ -1,9 +1,9 @@ import os import plistlib -from typing import Any +from typing import Any, Optional from django.contrib import messages -from django.contrib.auth.decorators import login_required, user_passes_test +from django.contrib.auth.decorators import user_passes_test from django.core.exceptions import ValidationError from django.forms import Form from django.http import HttpResponse @@ -21,13 +21,25 @@ ) from django.views.generic.base import TemplateResponseMixin +from nkdsu.mixins import AnyLoggedInUserMixin from .js import JSApiMixin +from ..elfs import is_elf from ..forms import CheckMetadataForm, LibraryUploadForm, NoteForm -from ..models import Block, Note, Request, Show, Track, TwitterUser, Vote +from ..models import Block, Note, Profile, Request, Show, Track, TwitterUser, Vote from ..update_library import metadata_consistency_checks, update_library -class AdminMixin: +class ElfMixin(AnyLoggedInUserMixin): + """ + A mixin for views that only elfs (or staff) can see. + """ + + @classmethod + def as_view(cls, **kw): + return user_passes_test(is_elf)(super().as_view(**kw)) + + +class AdminMixin(AnyLoggedInUserMixin): """ A mixin we should apply to all admin views. """ @@ -41,15 +53,7 @@ def handle_validation_error(self, error): @classmethod def as_view(cls, **kw): - return user_passes_test( - lambda u: u.is_authenticated and u.is_staff, - )(super().as_view(**kw)) - - -class AnyLoggedInUserMixin: - @classmethod - def as_view(cls, **kw): - return login_required(super().as_view(**kw)) + return user_passes_test(lambda u: u.is_staff)(super().as_view(**kw)) class TrackSpecificAdminMixin(AdminMixin): @@ -76,6 +80,12 @@ def get_redirect_url(self): return self.url + def get_context_data(self, *args, **kwargs): + return { + **super().get_context_data(*args, **kwargs), + 'next': self.get_redirect_url(), + } + def get_ajax_success_message(self): self.object = self.get_object() context = self.get_context_data() @@ -117,9 +127,9 @@ class DestructiveAdminAction(AdminActionMixin, TemplateResponseMixin): """ template_name = 'confirm.html' - deets = None + deets: Optional[str] = None - def get_deets(self): + def get_deets(self) -> Optional[str]: return self.deets def get_cancel_url(self): @@ -183,12 +193,21 @@ class Play(DestructiveAdminAction, DetailView): model = Track - def get_deets(self): + def get_deets(self) -> str: return str(self.get_object()) - def do_thing(self): + def do_thing(self) -> None: self.get_object().play() + def get_redirect_url(self) -> str: + return reverse( + 'vote:admin:post_about_play', kwargs={'pk': self.get_object().pk} + ) + + +class PostAboutPlay(TrackSpecificAdminMixin, TemplateView): + template_name = 'post_about_play.html' + class Hide(AdminAction, DetailView): model = Track @@ -395,12 +414,7 @@ def do_thing(self): class ToggleAbuser(AdminAction, DetailView): - model = TwitterUser - - def get_object(self): - return self.model.objects.get(user_id=self.kwargs['user_id']) - - def do_thing(self): + def do_thing(self) -> None: user = self.get_object() user.is_abuser = not user.is_abuser fmt = u"{} condemned" if user.is_abuser else u"{} redeemed" @@ -408,6 +422,20 @@ def do_thing(self): user.save() +class ToggleTwitterAbuser(ToggleAbuser): + model = TwitterUser + + def get_object(self): + return self.model.objects.get(user_id=self.kwargs['user_id']) + + +class ToggleLocalAbuser(ToggleAbuser): + model = Profile + + def get_object(self): + return self.model.objects.get(pk=self.kwargs['user_id']) + + class HiddenTracks(AdminMixin, ListView): model = Track template_name = 'hidden.html' @@ -437,15 +465,6 @@ def get_queryset(self): return self.model.objects.filter(background_art='') -class BadTrivia(AdminMixin, ListView): - model = Request - template_name = 'trivia.html' - context_object_name = 'requests' - - def get_queryset(self): - return self.model.objects.all().order_by('-created') - - class ShortlistSelection(SelectionAdminAction): fmt = u'{} shortlisted' @@ -522,15 +541,15 @@ def do_thing(self) -> None: messages.success(self.request, 'note removed') -class RequestList(AnyLoggedInUserMixin, ListView): +class RequestList(ElfMixin, ListView): template_name = 'requests.html' model = Request def get_queryset(self): - return super().get_queryset().filter(successful=True, filled=None) + return super().get_queryset().filter(filled=None) -class FillRequest(AnyLoggedInUserMixin, FormView): +class FillRequest(ElfMixin, FormView): allowed_methods = ['post'] form_class = Form @@ -538,7 +557,6 @@ def form_valid(self, form): request = get_object_or_404( Request, pk=self.kwargs['pk'], - successful=True, filled__isnull=True, ) @@ -550,7 +568,7 @@ def form_valid(self, form): return redirect(reverse('vote:admin:requests')) -class ClaimRequest(AnyLoggedInUserMixin, FormView): +class ClaimRequest(ElfMixin, FormView): allowed_methods = ['post'] form_class = Form @@ -571,7 +589,6 @@ def form_valid(self, form): request = get_object_or_404( Request, pk=self.kwargs['pk'], - successful=True, filled__isnull=True, claimant=None, ) @@ -583,7 +600,7 @@ def form_valid(self, form): return redirect(reverse('vote:admin:requests')) -class CheckMetadata(AnyLoggedInUserMixin, FormView): +class CheckMetadata(ElfMixin, FormView): form_class = CheckMetadataForm template_name = 'check_metadata.html' diff --git a/nkdsu/apps/vote/views/js.py b/nkdsu/apps/vote/views/js.py index 9e998c9b..586fa2d6 100644 --- a/nkdsu/apps/vote/views/js.py +++ b/nkdsu/apps/vote/views/js.py @@ -5,7 +5,8 @@ from django.views.generic import TemplateView from ..models import Track -from ..utils import tweet_len, tweet_url, vote_tweet +from ..templatetags.vote_tags import eligible_for +from ..utils import vote_url class JSApiMixin(object): @@ -36,9 +37,8 @@ def post(self, request, *args, **kwargs): selection = self.get_queryset() context['selection'] = selection - tweet = vote_tweet(selection) - if tweet_len(tweet) <= settings.TWEET_LENGTH: - context['vote_url'] = tweet_url(tweet) + if len(selection) <= settings.MAX_REQUEST_TRACKS: + context['vote_url'] = vote_url(selection) return self.render_to_response(context) @@ -64,7 +64,7 @@ def do_thing(self) -> None: track = qs[0] if ( self.request.user.is_authenticated and self.request.user.is_staff - ) or track.eligible(): + ) or eligible_for(track, self.request.user): selection.add(new_pk) self.request.session['selection'] = sorted(selection) diff --git a/nkdsu/apps/vote/views/profiles.py b/nkdsu/apps/vote/views/profiles.py index 49b39591..f12ba8e8 100644 --- a/nkdsu/apps/vote/views/profiles.py +++ b/nkdsu/apps/vote/views/profiles.py @@ -2,15 +2,22 @@ from django.contrib.auth import get_user_model from django.contrib.auth.base_user import AbstractBaseUser +from django.contrib.auth.mixins import LoginRequiredMixin from django.db.models import QuerySet from django.shortcuts import get_object_or_404 -from django.views.generic import DetailView +from django.urls import reverse +from django.views.generic import UpdateView + +from . import VoterDetail +from ..forms import ClearableFileInput +from ..models import Profile +from ..utils import get_profile_for User = get_user_model() -class ProfileView(DetailView): +class ProfileView(VoterDetail): model = User context_object_name = 'object' @@ -21,3 +28,29 @@ def get_object( queryset = self.get_queryset() return get_object_or_404(queryset, username=self.kwargs['username']) + + def get_voter(self) -> Profile: + user = self.get_object() + assert isinstance(user, User) + return user.profile + + +class UpdateProfileView(LoginRequiredMixin, UpdateView): + model = Profile + fields = ['display_name', 'avatar'] + template_name = 'edit_profile.html' + + def get_form(self): + form = super().get_form() + form.fields['avatar'].widget = ClearableFileInput() + return form + + def get_success_url(self) -> str: + return reverse( + 'vote:profiles:profile', kwargs={'username': self.request.user.username} + ) + + def get_object(self, queryset: Optional[QuerySet[Profile]] = None) -> Profile: + if not self.request.user.is_authenticated: + raise RuntimeError('LoginRequiredMixin should have prevented this') + return get_profile_for(self.request.user) diff --git a/nkdsu/apps/vote/voter.py b/nkdsu/apps/vote/voter.py new file mode 100644 index 00000000..556246d5 --- /dev/null +++ b/nkdsu/apps/vote/voter.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import datetime +from typing import Optional, Protocol, TYPE_CHECKING, _ProtocolMeta + +from django.db.models import BooleanField, CharField, QuerySet +from django.db.models.base import ModelBase +from django.utils import timezone +from .utils import memoize + +if TYPE_CHECKING: + from .models import UserBadge, Vote, Show, Track, Profile, TwitterUser + + # to check to see that their VoterProtocol implementations are complete: + Profile() + TwitterUser() + + +class ModelVoterMeta(_ProtocolMeta, ModelBase): + pass + + +class Voter(Protocol, metaclass=ModelVoterMeta): + name: str | CharField + pk: int + is_abuser: bool | BooleanField + is_patron: bool | BooleanField + + def _twitter_user_and_profile( + self, + ) -> tuple[Optional[TwitterUser], Optional[Profile]]: + ... + + @property + def voter_id(self) -> tuple[Optional[int], Optional[int]]: + """ + A unique identifier that will be the same for TwitterUser and Profile + instances that represent the same accounts. + """ + + twu, pr = self._twitter_user_and_profile() + return (None if twu is None else twu.pk, None if pr is None else pr.pk) + + @property + def badges(self) -> QuerySet[UserBadge]: + ... + + def unordered_votes(self) -> QuerySet[Vote]: + ... + + def get_toggle_abuser_url(self) -> str: + ... + + def votes(self) -> QuerySet[Vote]: + return self.unordered_votes().order_by('-date').prefetch_related('tracks') + + @memoize + def votes_with_liberal_preselection(self) -> QuerySet[Vote]: + return self.votes().prefetch_related( + 'show', + 'show__play_set', + 'show__play_set__track', # doesn't actually appear to work :< + ) + + @memoize + def votes_for(self, show: Show) -> QuerySet[Vote]: + return self.votes().filter(show=show) + + @memoize + def tracks_voted_for_for(self, show: Show) -> list[Track]: + tracks = [] + track_pk_set = set() + + for vote in self.votes_for(show): + for track in vote.tracks.all(): + if track.pk not in track_pk_set: + track_pk_set.add(track.pk) + tracks.append(track) + + return tracks + + def _batting_average( + self, + cutoff: Optional[datetime.datetime] = None, + minimum_weight: float = 1, + ) -> Optional[float]: + from .models import Show + + def ba( + pk, current_show_pk, cutoff: Optional[datetime.datetime] + ) -> tuple[float, float]: + score: float = 0 + weight: float = 0 + + for vote in self.votes().filter(date__gt=cutoff).prefetch_related('tracks'): + success = vote.success() + if success is not None: + score += success * vote.weight() + weight += vote.weight() + + return (score, weight) + + score, weight = ba(self.pk, Show.current().pk, cutoff) + + if weight >= minimum_weight: + return score / weight + else: + # there were no worthwhile votes + return None + + return score + + @memoize + def batting_average(self, minimum_weight: float = 1) -> Optional[float]: + """ + Return a user's batting average for the past six months. + """ + + from .models import Show + + return self._batting_average( + cutoff=Show.at(timezone.now() - datetime.timedelta(days=31 * 6)).end, + minimum_weight=minimum_weight, + ) + + def _streak(self, ls=[]) -> int: + from .models import Show + + show = Show.current().prev() + streak = 0 + + while True: + if show is None: + return streak + elif not show.voting_allowed: + show = show.prev() + elif show.votes().filter(twitter_user=self).exists(): + streak += 1 + show = show.prev() + else: + break + + return streak + + @memoize + def streak(self) -> int: + from .models import Show + + def streak(pk, current_show): + return self._streak() + + return streak(self.pk, Show.current()) + + def all_time_batting_average(self, minimum_weight: float = 1) -> Optional[float]: + return self._batting_average(minimum_weight=minimum_weight) diff --git a/nkdsu/forms.py b/nkdsu/forms.py new file mode 100644 index 00000000..7cb6477d --- /dev/null +++ b/nkdsu/forms.py @@ -0,0 +1,18 @@ +from django.contrib.auth.forms import UserCreationForm +from django.core.exceptions import ValidationError + +from .apps.vote.forms import TriviaForm +from .apps.vote.models import TwitterUser + + +class RegistrationForm(TriviaForm, UserCreationForm): + def clean_username(self) -> str: + username = self.cleaned_data['username'] + if TwitterUser.objects.filter(screen_name__iexact=username).exists(): + raise ValidationError( + "There's a Twitter user by this screen name who has requested music on nkd.su in the past. " + "If you are this user, you'll need to log in with Twitter to claim that username. " + "Otherwise, choose another name. " + ) + + return username diff --git a/nkdsu/middleware.py b/nkdsu/middleware.py index e24ab616..a85171ed 100644 --- a/nkdsu/middleware.py +++ b/nkdsu/middleware.py @@ -1,24 +1,68 @@ -from typing import Callable +from typing import Any, Callable, Sequence -from django.http import Http404, HttpRequest, HttpResponse +from django.contrib import messages +from django.http import HttpRequest, HttpResponse +from django.shortcuts import redirect from django.urls import reverse +from social_core.exceptions import NotAllowedToDisconnect +from .apps.vote.twitter_auth import DoNotAuthThroughTwitterPlease + + +class SocialAuthHandlingMiddleware: + """ + Middleware for doing nkd.su-specific workarounds for unwanted Python Social + Auth behaviour. + """ + + prefix: str + get_response: Callable[[HttpRequest], HttpResponse] -class SocialAuthBetaMiddleware: def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: + # establish what the base social_auth URL is, so that we can only + # affect social-auth URLs begin_url = reverse('social:begin', kwargs={'backend': 'twitter'}) suffix = 'login/twitter/' - assert begin_url.endswith(suffix) + assert begin_url.endswith(suffix) # make sure self.prefix = begin_url[: -len(suffix)] + + assert ( + self.prefix == '/s/' + ), self.prefix # make sure both the derived and expected values are the same + self.get_response = get_response def __call__(self, request: HttpRequest) -> HttpResponse: return self.get_response(request) - def process_view(self, request: HttpRequest, *a, **k) -> None: - if ( - request.resolver_match is not None - and 'social' in request.resolver_match.app_names - and not request.user.is_authenticated - ): - raise Http404('for now, we pretend this does not exist') + def process_view( + self, + request: HttpRequest, + view_func: Callable, + view_args: Sequence[Any], + view_kwargs: dict[str, Any], + ) -> HttpResponse: + if not request.path.startswith(self.prefix): + # this is not a social auth request. do nothing + return view_func(request, *view_args, **view_kwargs) + + # since we're catching exceptions, we might want to manually roll-back database transactions here + try: + return view_func(request, *view_args, **view_kwargs) + except DoNotAuthThroughTwitterPlease as e: + messages.warning(request, e.msg) + except NotAllowedToDisconnect: + messages.warning( + request, + 'you have to set a password or you will not be able to log back in', + ) + + if request.user.is_authenticated: + return redirect( + reverse( + 'vote:profiles:profile', + kwargs={'username': request.user.username}, + ) + ) + else: + return redirect(reverse('login')) diff --git a/nkdsu/mixins.py b/nkdsu/mixins.py new file mode 100644 index 00000000..f6dad1a3 --- /dev/null +++ b/nkdsu/mixins.py @@ -0,0 +1,38 @@ +import codecs +from os import path +from typing import Any + +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.views.generic import TemplateView +from markdown import markdown + + +class MarkdownView(TemplateView): + template_name = 'markdown.html' + filename: str + title: str + + def get_context_data(self, **kwargs) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + + words = markdown( + codecs.open( + path.join(settings.PROJECT_ROOT, self.filename), encoding='utf-8' + ).read() + ) + + context.update( + { + 'title': self.title, + 'words': words, + } + ) + + return context + + +class AnyLoggedInUserMixin: + @classmethod + def as_view(cls, **kw): + return login_required(super().as_view(**kw)) diff --git a/nkdsu/monkey.py b/nkdsu/monkey.py new file mode 100644 index 00000000..852b97d9 --- /dev/null +++ b/nkdsu/monkey.py @@ -0,0 +1,45 @@ +""" +Horrible hacks. Please ignore. +""" + + +def patch() -> None: + _replace_password_validators_help_text_html() + + +def _replace_password_validators_help_text_html() -> None: + """ + Don't use a
    to surface multiple password requirements against password + fields. It's invalid HTML when used within an `as_p`-rendered form, and + browsers interpret the DOM structure wrong as a result. As far as Firefox + is concerned, if we let Django do what it does by default, these help texts + aren't within a `.helptext` element at all. + + I hope this is made unnecessary if we adopt the `
    `-based form renderer + that becomes the default in Django 5.0. + """ + + from django.utils.functional import lazy + from django.contrib.auth import password_validation + + # make sure we're replacing something that exists: + assert password_validation.password_validators_help_text_html + + def replacement(password_validators=None) -> str: + """ + Return an HTML string with all help texts of all configured validators, + separated by `
    `. + """ + + from django.utils.html import format_html, format_html_join + from django.utils.safestring import mark_safe + + help_texts = password_validation.password_validators_help_texts( + password_validators + ) + help_items = format_html_join( + mark_safe('
    '), '{}', ((help_text,) for help_text in help_texts) + ) + return format_html('{}', help_items) + + password_validation.password_validators_help_text_html = lazy(replacement, str) diff --git a/nkdsu/settings.py b/nkdsu/settings.py index 2a5c0000..5dfb394c 100644 --- a/nkdsu/settings.py +++ b/nkdsu/settings.py @@ -54,6 +54,9 @@ TWEET_LENGTH = 280 +#: The maximum number of tracks that can be associated with a single Vote object +MAX_REQUEST_TRACKS = 6 + OPTIONS = {'timeout': 20} CONSUMER_KEY = '' # secret @@ -106,6 +109,7 @@ MANAGERS = ADMINS +ATOMIC_REQUESTS = True DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', @@ -152,7 +156,7 @@ 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', - 'nkdsu.middleware.SocialAuthBetaMiddleware', + 'nkdsu.middleware.SocialAuthHandlingMiddleware', ] ROOT_URLCONF = 'nkdsu.urls' @@ -200,6 +204,24 @@ 'django.contrib.auth.backends.ModelBackend', ] +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + 'OPTIONS': { + 'min_length': 10, + }, + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + LOGIN_URL = 'login' LOGOUT_URL = 'logout' LOGIN_REDIRECT_URL = 'vote:index' diff --git a/nkdsu/static/i/placeholder-avatars/icon1-0.svg b/nkdsu/static/i/placeholder-avatars/icon1-0.svg new file mode 100644 index 00000000..6de19697 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-0.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon1-105.svg b/nkdsu/static/i/placeholder-avatars/icon1-105.svg new file mode 100644 index 00000000..d60e5bb6 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-105.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon1-120.svg b/nkdsu/static/i/placeholder-avatars/icon1-120.svg new file mode 100644 index 00000000..9a76c8a6 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-120.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon1-135.svg b/nkdsu/static/i/placeholder-avatars/icon1-135.svg new file mode 100644 index 00000000..dbb5287b --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-135.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon1-15.svg b/nkdsu/static/i/placeholder-avatars/icon1-15.svg new file mode 100644 index 00000000..af845f9e --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-15.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon1-150.svg b/nkdsu/static/i/placeholder-avatars/icon1-150.svg new file mode 100644 index 00000000..d8084930 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-150.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon1-165.svg b/nkdsu/static/i/placeholder-avatars/icon1-165.svg new file mode 100644 index 00000000..13e3f6c5 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-165.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon1-180.svg b/nkdsu/static/i/placeholder-avatars/icon1-180.svg new file mode 100644 index 00000000..8690818d --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-180.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon1-195.svg b/nkdsu/static/i/placeholder-avatars/icon1-195.svg new file mode 100644 index 00000000..c5ae20c7 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-195.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon1-210.svg b/nkdsu/static/i/placeholder-avatars/icon1-210.svg new file mode 100644 index 00000000..2d4eed87 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-210.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon1-225.svg b/nkdsu/static/i/placeholder-avatars/icon1-225.svg new file mode 100644 index 00000000..f2fdfb91 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-225.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon1-240.svg b/nkdsu/static/i/placeholder-avatars/icon1-240.svg new file mode 100644 index 00000000..c9f99352 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-240.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon1-255.svg b/nkdsu/static/i/placeholder-avatars/icon1-255.svg new file mode 100644 index 00000000..3c457b84 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-255.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon1-270.svg b/nkdsu/static/i/placeholder-avatars/icon1-270.svg new file mode 100644 index 00000000..1a4eb2df --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-270.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon1-285.svg b/nkdsu/static/i/placeholder-avatars/icon1-285.svg new file mode 100644 index 00000000..aa2b8f2d --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-285.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon1-30.svg b/nkdsu/static/i/placeholder-avatars/icon1-30.svg new file mode 100644 index 00000000..8f3a61e2 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-30.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon1-300.svg b/nkdsu/static/i/placeholder-avatars/icon1-300.svg new file mode 100644 index 00000000..25167415 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-300.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon1-315.svg b/nkdsu/static/i/placeholder-avatars/icon1-315.svg new file mode 100644 index 00000000..a243a769 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-315.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon1-330.svg b/nkdsu/static/i/placeholder-avatars/icon1-330.svg new file mode 100644 index 00000000..5a1ee974 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-330.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon1-345.svg b/nkdsu/static/i/placeholder-avatars/icon1-345.svg new file mode 100644 index 00000000..b22e6ad6 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-345.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon1-45.svg b/nkdsu/static/i/placeholder-avatars/icon1-45.svg new file mode 100644 index 00000000..2bb6a913 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-45.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon1-60.svg b/nkdsu/static/i/placeholder-avatars/icon1-60.svg new file mode 100644 index 00000000..d9b16ea4 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-60.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon1-75.svg b/nkdsu/static/i/placeholder-avatars/icon1-75.svg new file mode 100644 index 00000000..90d6603b --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-75.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon1-90.svg b/nkdsu/static/i/placeholder-avatars/icon1-90.svg new file mode 100644 index 00000000..64f1e33a --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon1-90.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-0.svg b/nkdsu/static/i/placeholder-avatars/icon2-0.svg new file mode 100644 index 00000000..540a0b5d --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-0.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-105.svg b/nkdsu/static/i/placeholder-avatars/icon2-105.svg new file mode 100644 index 00000000..c99a381b --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-105.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-120.svg b/nkdsu/static/i/placeholder-avatars/icon2-120.svg new file mode 100644 index 00000000..2e320d8e --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-120.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-135.svg b/nkdsu/static/i/placeholder-avatars/icon2-135.svg new file mode 100644 index 00000000..0ecf0b1a --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-135.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-15.svg b/nkdsu/static/i/placeholder-avatars/icon2-15.svg new file mode 100644 index 00000000..59f0762a --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-15.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-150.svg b/nkdsu/static/i/placeholder-avatars/icon2-150.svg new file mode 100644 index 00000000..0bd033a7 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-150.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-165.svg b/nkdsu/static/i/placeholder-avatars/icon2-165.svg new file mode 100644 index 00000000..06ec56ad --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-165.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-180.svg b/nkdsu/static/i/placeholder-avatars/icon2-180.svg new file mode 100644 index 00000000..0cac99ae --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-180.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-195.svg b/nkdsu/static/i/placeholder-avatars/icon2-195.svg new file mode 100644 index 00000000..83373887 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-195.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-210.svg b/nkdsu/static/i/placeholder-avatars/icon2-210.svg new file mode 100644 index 00000000..6f62ecb2 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-210.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-225.svg b/nkdsu/static/i/placeholder-avatars/icon2-225.svg new file mode 100644 index 00000000..58a04606 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-225.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-240.svg b/nkdsu/static/i/placeholder-avatars/icon2-240.svg new file mode 100644 index 00000000..562dd948 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-240.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-255.svg b/nkdsu/static/i/placeholder-avatars/icon2-255.svg new file mode 100644 index 00000000..4a410528 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-255.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-270.svg b/nkdsu/static/i/placeholder-avatars/icon2-270.svg new file mode 100644 index 00000000..e8e452c1 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-270.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-285.svg b/nkdsu/static/i/placeholder-avatars/icon2-285.svg new file mode 100644 index 00000000..9bdd5fd1 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-285.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-30.svg b/nkdsu/static/i/placeholder-avatars/icon2-30.svg new file mode 100644 index 00000000..525b0f73 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-30.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-300.svg b/nkdsu/static/i/placeholder-avatars/icon2-300.svg new file mode 100644 index 00000000..2bd16d4a --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-300.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-315.svg b/nkdsu/static/i/placeholder-avatars/icon2-315.svg new file mode 100644 index 00000000..ee357b61 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-315.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-330.svg b/nkdsu/static/i/placeholder-avatars/icon2-330.svg new file mode 100644 index 00000000..3a492c92 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-330.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-345.svg b/nkdsu/static/i/placeholder-avatars/icon2-345.svg new file mode 100644 index 00000000..43cb14b5 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-345.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-45.svg b/nkdsu/static/i/placeholder-avatars/icon2-45.svg new file mode 100644 index 00000000..ba61dc99 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-45.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-60.svg b/nkdsu/static/i/placeholder-avatars/icon2-60.svg new file mode 100644 index 00000000..c65c0c1a --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-60.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-75.svg b/nkdsu/static/i/placeholder-avatars/icon2-75.svg new file mode 100644 index 00000000..0fc0a951 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-75.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon2-90.svg b/nkdsu/static/i/placeholder-avatars/icon2-90.svg new file mode 100644 index 00000000..b12df7c3 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon2-90.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-0.svg b/nkdsu/static/i/placeholder-avatars/icon3-0.svg new file mode 100644 index 00000000..b8660861 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-0.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-105.svg b/nkdsu/static/i/placeholder-avatars/icon3-105.svg new file mode 100644 index 00000000..9840481c --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-105.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-120.svg b/nkdsu/static/i/placeholder-avatars/icon3-120.svg new file mode 100644 index 00000000..500e4bfc --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-120.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-135.svg b/nkdsu/static/i/placeholder-avatars/icon3-135.svg new file mode 100644 index 00000000..2181b10c --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-135.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-15.svg b/nkdsu/static/i/placeholder-avatars/icon3-15.svg new file mode 100644 index 00000000..6400110f --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-15.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-150.svg b/nkdsu/static/i/placeholder-avatars/icon3-150.svg new file mode 100644 index 00000000..00cd21fa --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-150.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-165.svg b/nkdsu/static/i/placeholder-avatars/icon3-165.svg new file mode 100644 index 00000000..cafdf85e --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-165.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-180.svg b/nkdsu/static/i/placeholder-avatars/icon3-180.svg new file mode 100644 index 00000000..949e18ec --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-180.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-195.svg b/nkdsu/static/i/placeholder-avatars/icon3-195.svg new file mode 100644 index 00000000..023d717b --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-195.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-210.svg b/nkdsu/static/i/placeholder-avatars/icon3-210.svg new file mode 100644 index 00000000..d57fd1ca --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-210.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-225.svg b/nkdsu/static/i/placeholder-avatars/icon3-225.svg new file mode 100644 index 00000000..11bbb768 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-225.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-240.svg b/nkdsu/static/i/placeholder-avatars/icon3-240.svg new file mode 100644 index 00000000..411e5bbd --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-240.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-255.svg b/nkdsu/static/i/placeholder-avatars/icon3-255.svg new file mode 100644 index 00000000..ffb33969 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-255.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-270.svg b/nkdsu/static/i/placeholder-avatars/icon3-270.svg new file mode 100644 index 00000000..266c0c76 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-270.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-285.svg b/nkdsu/static/i/placeholder-avatars/icon3-285.svg new file mode 100644 index 00000000..1fd34a54 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-285.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-30.svg b/nkdsu/static/i/placeholder-avatars/icon3-30.svg new file mode 100644 index 00000000..9043487c --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-30.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-300.svg b/nkdsu/static/i/placeholder-avatars/icon3-300.svg new file mode 100644 index 00000000..42c15841 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-300.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-315.svg b/nkdsu/static/i/placeholder-avatars/icon3-315.svg new file mode 100644 index 00000000..e490cc52 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-315.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-330.svg b/nkdsu/static/i/placeholder-avatars/icon3-330.svg new file mode 100644 index 00000000..25a36c95 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-330.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-345.svg b/nkdsu/static/i/placeholder-avatars/icon3-345.svg new file mode 100644 index 00000000..6765a7b7 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-345.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-45.svg b/nkdsu/static/i/placeholder-avatars/icon3-45.svg new file mode 100644 index 00000000..4d691358 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-45.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-60.svg b/nkdsu/static/i/placeholder-avatars/icon3-60.svg new file mode 100644 index 00000000..e0613f76 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-60.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-75.svg b/nkdsu/static/i/placeholder-avatars/icon3-75.svg new file mode 100644 index 00000000..e1ce57ee --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-75.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon3-90.svg b/nkdsu/static/i/placeholder-avatars/icon3-90.svg new file mode 100644 index 00000000..520b4a62 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon3-90.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-0.svg b/nkdsu/static/i/placeholder-avatars/icon4-0.svg new file mode 100644 index 00000000..353dd52b --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-0.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-105.svg b/nkdsu/static/i/placeholder-avatars/icon4-105.svg new file mode 100644 index 00000000..43cbe3d4 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-105.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-120.svg b/nkdsu/static/i/placeholder-avatars/icon4-120.svg new file mode 100644 index 00000000..521a0c9b --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-120.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-135.svg b/nkdsu/static/i/placeholder-avatars/icon4-135.svg new file mode 100644 index 00000000..1d6e79fd --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-135.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-15.svg b/nkdsu/static/i/placeholder-avatars/icon4-15.svg new file mode 100644 index 00000000..e18d0cf3 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-15.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-150.svg b/nkdsu/static/i/placeholder-avatars/icon4-150.svg new file mode 100644 index 00000000..93bc2366 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-150.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-165.svg b/nkdsu/static/i/placeholder-avatars/icon4-165.svg new file mode 100644 index 00000000..013e588e --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-165.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-180.svg b/nkdsu/static/i/placeholder-avatars/icon4-180.svg new file mode 100644 index 00000000..9b2dc65b --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-180.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-195.svg b/nkdsu/static/i/placeholder-avatars/icon4-195.svg new file mode 100644 index 00000000..9984e2a7 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-195.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-210.svg b/nkdsu/static/i/placeholder-avatars/icon4-210.svg new file mode 100644 index 00000000..02d8d17b --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-210.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-225.svg b/nkdsu/static/i/placeholder-avatars/icon4-225.svg new file mode 100644 index 00000000..1e77430f --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-225.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-240.svg b/nkdsu/static/i/placeholder-avatars/icon4-240.svg new file mode 100644 index 00000000..05eeffb2 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-240.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-255.svg b/nkdsu/static/i/placeholder-avatars/icon4-255.svg new file mode 100644 index 00000000..60713a3e --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-255.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-270.svg b/nkdsu/static/i/placeholder-avatars/icon4-270.svg new file mode 100644 index 00000000..e9837f44 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-270.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-285.svg b/nkdsu/static/i/placeholder-avatars/icon4-285.svg new file mode 100644 index 00000000..762ef3ca --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-285.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-30.svg b/nkdsu/static/i/placeholder-avatars/icon4-30.svg new file mode 100644 index 00000000..c82d0cef --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-30.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-300.svg b/nkdsu/static/i/placeholder-avatars/icon4-300.svg new file mode 100644 index 00000000..340f0c45 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-300.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-315.svg b/nkdsu/static/i/placeholder-avatars/icon4-315.svg new file mode 100644 index 00000000..891ff0ed --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-315.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-330.svg b/nkdsu/static/i/placeholder-avatars/icon4-330.svg new file mode 100644 index 00000000..b9421e16 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-330.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-345.svg b/nkdsu/static/i/placeholder-avatars/icon4-345.svg new file mode 100644 index 00000000..ed5214d8 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-345.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-45.svg b/nkdsu/static/i/placeholder-avatars/icon4-45.svg new file mode 100644 index 00000000..5815b068 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-45.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-60.svg b/nkdsu/static/i/placeholder-avatars/icon4-60.svg new file mode 100644 index 00000000..ad49a0e1 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-60.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-75.svg b/nkdsu/static/i/placeholder-avatars/icon4-75.svg new file mode 100644 index 00000000..2052bf71 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-75.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/i/placeholder-avatars/icon4-90.svg b/nkdsu/static/i/placeholder-avatars/icon4-90.svg new file mode 100644 index 00000000..c7ef4938 --- /dev/null +++ b/nkdsu/static/i/placeholder-avatars/icon4-90.svg @@ -0,0 +1,12 @@ + + + + diff --git a/nkdsu/static/less/_elements.less b/nkdsu/static/less/_elements.less index 48ad76ca..e6314604 100644 --- a/nkdsu/static/less/_elements.less +++ b/nkdsu/static/less/_elements.less @@ -1,4 +1,5 @@ @import "_mixins.less"; +@import "_vars.less"; h2, h3, h4 { text-align: center; @@ -32,4 +33,43 @@ button.link { margin: none; font-family: inherit; font-size: inherit; + text-shadow: @basic-shadow 0 .1em 0; } + +.dark({ + button.link { + text-shadow: fadeout(black, 50%) 0 .1em 0; + } +}); + +// this rule is specific to override vaguer rules about and +html body main .button { + .nkdsu-font(); + text-shadow: none; + font-size: 1em; + + padding: .4em .8em; + display: inline-block; + + border-radius: .3em; + margin: 0; + border: none; + cursor: pointer; + + color: @bg; + background-color: @hl; + + &:hover { + background-color: @fg; + color: @bg; + } +} + +.dark({ + main .button { + &:hover { + background-color: @inv_fg; + color: @inv_bg; + } + } +}); diff --git a/nkdsu/static/less/_mixins.less b/nkdsu/static/less/_mixins.less index 838c2338..3c479c3d 100644 --- a/nkdsu/static/less/_mixins.less +++ b/nkdsu/static/less/_mixins.less @@ -12,6 +12,10 @@ } } +.nkdsu-font() { + font-family: "Bitter", "Calibri", sans-serif; +} + .clearfix() { &::after { content: ' '; @@ -35,13 +39,13 @@ .search-field { .noise(); + .nkdsu-font(); background-color: @inv_fg; color: @inv_bg; border-radius: .3em; border: none; outline: none; height: 1.5em; - font-family: "Bitter", "Calibri", sans-serif; padding: .1em .3em; font-size: 1.1em; } diff --git a/nkdsu/static/less/_standalone_vote.less b/nkdsu/static/less/_standalone_vote.less index 659ff935..99913301 100644 --- a/nkdsu/static/less/_standalone_vote.less +++ b/nkdsu/static/less/_standalone_vote.less @@ -1,6 +1,6 @@ @standalone-vote-border-radius: .4em; -.standalone-vote { +.standalone-vote, .vote-preview { display: inline-block; vertical-align: top; margin-bottom: 1em; @@ -50,3 +50,9 @@ } } } + +.vote-preview { + @media (min-width: @breakpoint) { + margin-left: 25%; + } +} diff --git a/nkdsu/static/less/main.less b/nkdsu/static/less/main.less index 0dd8600d..ba481351 100644 --- a/nkdsu/static/less/main.less +++ b/nkdsu/static/less/main.less @@ -18,9 +18,9 @@ html { } body { - font-family: "Bitter", "Calibri", sans-serif; - background-color: @bg; + .nkdsu-font(); .noise(); + background-color: @bg; color: @fg; text-shadow: @basic-shadow 0 .1em 0; height: 100%; @@ -193,6 +193,8 @@ body { padding-bottom: 13em; .markdown { + padding-bottom: 1em; + h1 { text-align: center; } @@ -268,14 +270,14 @@ body { } div.user { - .avatar img { + img.avatar { width: 6em; height: 6em; margin-top: .5em; border-radius: .4em; } - &.abuser .avatar img { + &.abuser img.avatar { .transform(rotate(180deg)); } @@ -643,35 +645,86 @@ p.new_tracks { margin-left: 37.5%; } -/* login form */ -form.login tr { - clear: both; -} - +/* forms */ form { text-align: center; margin: .5em 0; -} -form p { - text-align: left; - margin: .5em 0; -} + p { + text-align: left; + margin: .5em 0; + clear: both; + + &.submit { + text-align: center; + } -form p input { - text-align: left; + input, textarea { + text-align: left; + } + + .helptext { + font-size: .9em; + margin-top: .25em; + display: block; + } + + .clear-file { + display: block; + } + } + + ul.errorlist li { + list-style: none; + font-size: .9em; + color: @light; + } + + &.login tr { + clear: both; + } } -form p textarea { - text-align: left; +main.login { + max-width: 1.5 * @max-width !important; + + #login-methods { + display: flex; + flex-flow: row wrap; + justify-content: space-evenly; + + > section { + padding: 0 1em; + max-width: 30rem; + + p { + margin-bottom: .5em; + } + } + } } -form ul.errorlist li { - list-style: none; - font-size: .7em; - color: @light; - position: relative; - top: .6em; +.self-infobox { + padding: 1em 0; + background-color: fade(@light, 20%); + border-radius: .5em; + + p { + margin: .5em 1em; + } + + > p { + &:first-child { margin-top: 0 } + &:last-child { margin-bottom: 0 } + } + + section { + border: .3em solid fade(@light, 30%); + border-width: .3em 0; + + &:first-child { border-top-width: 0; > p:first-child { margin-top: 0; } } + &:last-child { border-bottom-width: 0; > p:last-child { margin-bottom: 0; } } + } } /* common to library updates and parsing checker */ @@ -852,12 +905,21 @@ dl.metadata-check { } @media (min-width:551px) { - form p label { - clear: both; - float: left; - width: 42%; - text-align: right; - padding-right: .5em; + form { + p { + > label { + clear: both; + float: left; + width: 42%; + text-align: right; + padding-right: .5em; + } + } + + .helptext, .clear-file, .replacement-file-input { + margin-left: 42%; + padding-left: .5em; + } } /* hover effects are desktop only */ diff --git a/nkdsu/templates/auth/register.html b/nkdsu/templates/auth/register.html new file mode 100644 index 00000000..db09d46d --- /dev/null +++ b/nkdsu/templates/auth/register.html @@ -0,0 +1,21 @@ +{% extends parent %} + +{% block title %}register{% endblock %} + +{% block content %} +

    register:

    + + + +

    + if you're an existing twitter requester, you probably want to + log in with twitter +

    +{% endblock %} diff --git a/nkdsu/templates/auth/set_password.html b/nkdsu/templates/auth/set_password.html new file mode 100644 index 00000000..e746795d --- /dev/null +++ b/nkdsu/templates/auth/set_password.html @@ -0,0 +1,15 @@ +{% extends parent %} + +{% block title %}set a password{% endblock %} + +{% block content %} +

    set a password

    + +

    you do not currently have a password. set one, so that you can log in with it

    + +
    + {% csrf_token %} + {{ form.as_p }} + +
    +{% endblock %} diff --git a/nkdsu/templates/auth/user_detail.html b/nkdsu/templates/auth/user_detail.html index 1d60c2a2..9c7f1fc2 100644 --- a/nkdsu/templates/auth/user_detail.html +++ b/nkdsu/templates/auth/user_detail.html @@ -3,16 +3,16 @@ {% block title %}{{ object.username }}{% endblock %} {% block content %} -

    {{ object.username }}

    +
    + {% include "include/voter_meta.html" with voter=object.profile %} + + {% if object == request.user %} + {% include "include/user_infobox.html" with user=request.user %} + {% endif %} + + {% include "include/voter_votes.html" with voter=object.profile %} +
    + +{% include "include/paginator.html" %} - {% if object.social_auth.all %} -

    - {% for social_auth in object.social_auth.all %} - {{ social_auth.provider }}: - {{ social_auth.extra_data.access_token.screen_name }} - {% endfor %} -

    - {% elif object == request.user %} -

    link twitter account

    - {% endif %} {% endblock %} diff --git a/nkdsu/templates/base.html b/nkdsu/templates/base.html index daa5c515..26a033e6 100644 --- a/nkdsu/templates/base.html +++ b/nkdsu/templates/base.html @@ -15,10 +15,6 @@ {% block title %}a neko desu request robot{% endblock %} | nkd.su - - {% block twitter_card %}{% endblock %} - - edit profile + +{% include "include/user_infobox.html" with user=request.user hide_edit_button=True %} + +
    + {% csrf_token %} + {{ form.as_p }} + +
    + +{% endblock %} diff --git a/nkdsu/templates/include/inline_vote.html b/nkdsu/templates/include/inline_vote.html index 6c04eaa5..499d29b9 100644 --- a/nkdsu/templates/include/inline_vote.html +++ b/nkdsu/templates/include/inline_vote.html @@ -2,7 +2,7 @@
  • - + {% if vote.hat %} {% endif %} @@ -25,9 +32,16 @@ {% if vote.is_manual %}

    {{ vote.name }}

    via {{ vote.kind }}

    - {% else %} + {% elif vote.is_twitter %}

    {{ vote.twitter_user.name }}

    @{{ vote.twitter_user.screen_name }}

    + {% elif vote.is_local %} + {% if vote.user.profile.display_name %} +

    {{ vote.user.profile.display_name }}

    +

    @{{ vote.user.username }}

    + {% else %} +

    @{{ vote.user.username }}

    + {% endif %} {% endif %}

    {{ vote.date|when }}

    diff --git a/nkdsu/templates/include/post_content.html b/nkdsu/templates/include/post_content.html index 82e1debb..5a8eeddb 100644 --- a/nkdsu/templates/include/post_content.html +++ b/nkdsu/templates/include/post_content.html @@ -1,3 +1,5 @@ +{% load vote_tags %} +
  • + {% if not user.is_authenticated and track.eligible or not user.is_staff and track|eligible_for:user %} +
  • +
  • {% endif %} diff --git a/nkdsu/templates/include/user_infobox.html b/nkdsu/templates/include/user_infobox.html new file mode 100644 index 00000000..54c05289 --- /dev/null +++ b/nkdsu/templates/include/user_infobox.html @@ -0,0 +1,48 @@ +
    + {% if user.profile.twitter_user %} +

    + your account has adopted historical requests and statistics from twitter user @{{ user.profile.twitter_user.screen_name }} +

    + {% endif %} + + {% if user.social_auth.all %} +
    + {% for social_auth in user.social_auth.all %} +

    + your account currently allows authentication via a + {{ social_auth.provider }} user named + {{ social_auth.extra_data.access_token.screen_name }} +

    + {% if social_auth.provider == "twitter" %} +
    + {% csrf_token %} + +

    + {% if user.has_usable_password %} + we'd prefer not to trust twitter any more than we have to, so please make sure you know your nkd.su password and then + {% else %} + we'd prefer not to trust twitter any more than we have to, so please set a password for your account so that you can disconnect it from twitter + {% endif %} +

    +
    + {% if user.profile.twitter_user %} +

    + disconnecting your account like this will not remove your adopted requests from @{{ user.profile.twitter_user.screen_name }} +

    + {% endif %} + {% endif %} + {% endfor %} +
    + {% else %} + {% if not user.profile.twitter_user %} +
    +

    you can link a twitter account to adopt all of its historic requests and statistics. if you want to disconnect your acount immediately after, you can do that here too

    +

    note: this will not work for twitter accounts that have never requested anything on nkd.su

    +
    + {% endif %} + {% endif %} + + {% if not hide_edit_button %} +

    edit your profile

    + {% endif %} +
    diff --git a/nkdsu/templates/include/vote.html b/nkdsu/templates/include/vote.html index 47695043..1b846b74 100644 --- a/nkdsu/templates/include/vote.html +++ b/nkdsu/templates/include/vote.html @@ -1,7 +1,7 @@ {% load vote_tags %}
    • @@ -12,7 +12,13 @@

      - {{ vote.date|when }} + + {% if vote.is_twitter %} + {{ vote.date|when }} + {% else %} + {{ vote.date|when }} + {% endif %} + {{ vote.content|safe }}

      diff --git a/nkdsu/templates/include/voter_meta.html b/nkdsu/templates/include/voter_meta.html new file mode 100644 index 00000000..d22f8ad6 --- /dev/null +++ b/nkdsu/templates/include/voter_meta.html @@ -0,0 +1,71 @@ +{% load vote_tags %} + +
      +

      + {% if voter.is_twitteruser %} + {{ voter.name }} + {% else %} + {{ voter.name }} + {% endif %} +

      + +

      + {% if voter.is_twitteruser %} + + @{{ voter.screen_name }} + + {% elif voter.display_name %} + @{{ voter.user.username }} + {% endif %} +

      + +

      + {{ voter.name }} +

      + + {% if voter.batting_average == None %} +

      needs to vote more

      + {% else %} +

      batting at {{ voter.batting_average|percent }} over the last six months

      + {% endif %} + + {% if voter.streak > 1 %} +

      voted for the last {{ voter.streak }} consecutive shows

      + {% endif %} + + {% if user.is_staff %} +

      + {% if voter.is_abuser %} + redeem {{ voter }} + {% else %} + condemn {{ voter }} + {% endif %} +

      + {% endif %} + + {% if voter.is_abuser %} +

      {{ voter.name }} is condemned until further notice.

      +

      requests will still appear on tracks, but will not influence their position on the front page.

      + {% endif %} + + {% if voter.is_patron %} +

      {{ voter.name }} supports Neko Desu on Patreon!

      + {% endif %} + + {% if voter.badges %} +
        + {% for badge in voter.badges %} +
      • + + +

        {{ badge.badge_info.description }}

        + + {% if badge.badge_info.url %} +

        More info…

        + {% endif %} +
      • + {% endfor %} +
      + {% endif %} +
      + diff --git a/nkdsu/templates/include/voter_votes.html b/nkdsu/templates/include/voter_votes.html new file mode 100644 index 00000000..f8877fb1 --- /dev/null +++ b/nkdsu/templates/include/voter_votes.html @@ -0,0 +1,18 @@ +{% load cache %} + +

      requests

      +
      + {% cache 300 twitter_user_detail:votes voter.user_id page_obj.number %} + {% regroup votes by show as votes_by_show %} + {% for group in votes_by_show %} +

      {{ group.grouper.showtime|date:"F jS Y" }}

      + {% spaceless %} +
        + {% for vote in group.list %} + {% include "include/vote.html" with show=group.grouper %} + {% endfor %} +
      + {% endspaceless %} + {% endfor %} + {% endcache %} +
      diff --git a/nkdsu/templates/index.html b/nkdsu/templates/index.html index cdff546b..b2779804 100644 --- a/nkdsu/templates/index.html +++ b/nkdsu/templates/index.html @@ -41,7 +41,7 @@

      shortlist

      {% if current_show.broadcasting %}

      current requests for tonight's show

      {% elif current_show %} -

      current requests for {{ current_show.showtime|date:"F"|lower }} {{ current_show.showtime|date:"jS" }}

      +

      current requests for {% include "include/show_date.html" with show=current_show %}

      {% endif %}
        {% include "include/tracklist.html" %} diff --git a/nkdsu/templates/post_about_play.html b/nkdsu/templates/post_about_play.html new file mode 100644 index 00000000..dc6a91c8 --- /dev/null +++ b/nkdsu/templates/post_about_play.html @@ -0,0 +1,14 @@ +{% extends parent %} + +{% block title %}post about this play{% endblock %} +{% block content %} + +

        you played {{ track }}

        + +
        +

        {{ track.play_tweet_content }}

        +

        post this tweet

        +

        return home

        +
        + +{% endblock %} diff --git a/nkdsu/templates/registration/login.html b/nkdsu/templates/registration/login.html index 32f4ab61..329f626a 100644 --- a/nkdsu/templates/registration/login.html +++ b/nkdsu/templates/registration/login.html @@ -1,19 +1,61 @@ {% extends parent %} -{% block title %}log in{% endblock %} +{% block title %}{{ title }}{% endblock %} + +{% block main_classes %} + login +{% endblock %} {% block content %} -

        log in

        - -{% if form.errors %} -

        wrong username/password, sorry

        -{% endif %} - - +

        log in:

        + + + +

        or:

        + +
        +
        +

        existing twitter-based users

        +
        +

        linking a twitter account when you first sign in will allow you to adopt the history of that twitter account's requests on nkd.su

        +

        we have no reason to believe that logging in with twitter will continue to work. please add a password to your account and then disconnect it from twitter once you've logged in with the button below

        +

        you can do this from your profile page, which will be linked in the footer once you have logged in

        + +

        note: creating an account this way will not work for twitter accounts that have never requested anything on nkd.su. use a username and password instead

        + +

        + log in with twitter… +

        +
        +
        + +
        +

        new nkd.su visitors

        +

        create a fresh nkd.su account with a username and password

        + + +
        +
        + + {% if is_vote %} +

        log in? what?

        +
        + {{ words|safe }} +
        + {% endif %} {% endblock %} diff --git a/nkdsu/templates/registration/password_change_done.html b/nkdsu/templates/registration/password_change_done.html new file mode 100644 index 00000000..54cd94ff --- /dev/null +++ b/nkdsu/templates/registration/password_change_done.html @@ -0,0 +1,10 @@ +{% extends parent %} + +{% block title %}password change successful{% endblock %} + +{% block content %} + +

        password change successful

        +

        cool, take me back home

        + +{% endblock %} diff --git a/nkdsu/templates/registration/password_change_form.html b/nkdsu/templates/registration/password_change_form.html new file mode 100644 index 00000000..7161726e --- /dev/null +++ b/nkdsu/templates/registration/password_change_form.html @@ -0,0 +1,13 @@ +{% extends parent %} + +{% block title %}change your password{% endblock %} + +{% block content %} +

        change your password

        + +
        + {% csrf_token %} + {{ form.as_p }} + +
        +{% endblock %} diff --git a/nkdsu/templates/requests.html b/nkdsu/templates/requests.html index b7e316c9..392d1efc 100644 --- a/nkdsu/templates/requests.html +++ b/nkdsu/templates/requests.html @@ -17,8 +17,16 @@

        requests

        ">

        {{ object.created }}

        + {% if object.submitted_by %} +

        + submitted by @{{ object.submitted_by.username }} +

        + {% endif %} + {% if object.claimant %} -

        claimed by {{ object.claimant.username }}

        +

        + claimed by @{{ object.claimant.username }} +

        {% endif %}
        diff --git a/nkdsu/templates/track_detail.html b/nkdsu/templates/track_detail.html index 43aba2ce..d97421b1 100644 --- a/nkdsu/templates/track_detail.html +++ b/nkdsu/templates/track_detail.html @@ -3,13 +3,6 @@ {% block title %}{{ track.title }}{% endblock %} -{% block twitter_card %} - - - - -{% endblock %} - {% block content %}
          diff --git a/nkdsu/templates/trivia.html b/nkdsu/templates/trivia.html deleted file mode 100644 index 5a8fdc21..00000000 --- a/nkdsu/templates/trivia.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends parent %} -{% load vote_tags %} - -{% block content %} -

          bad trivia

          -
          - {% for request in requests %} -
          - {% with request.struct as struct %} -

          {{ request.created|when }}{% if struct.contact %}, {{ struct.contact }}{% endif %}

          -
          -

          {{ struct.trivia_question }}

          -

          {{ struct.trivia }}

          -
          - {% endwith %} -
          - {% endfor %} -
          -{% endblock %} diff --git a/nkdsu/templates/twitter_user_detail.html b/nkdsu/templates/twitter_user_detail.html index 6e15e823..efb26488 100644 --- a/nkdsu/templates/twitter_user_detail.html +++ b/nkdsu/templates/twitter_user_detail.html @@ -1,89 +1,13 @@ {% extends parent %} -{% load vote_tags cache %} {% block title %}{{ voter.name }}{% endblock %} {% block content %}
          -
          -

          {{ voter.name }}

          -

          - - @{{ voter.screen_name }} - -

          + {% include "include/voter_meta.html" %} -

          - - {{ voter.name }} - -

          - - {% if voter.batting_average == None %} -

          needs to vote more

          - {% else %} -

          batting at {{ voter.batting_average|percent }} over the last six months

          - {% endif %} - - {% if voter.streak > 1 %} -

          voted for the last {{ voter.streak }} consecutive shows

          - {% endif %} - - {% if user.is_staff %} -

          - {% if voter.is_abuser %} - redeem {{ voter }} - {% else %} - condemn {{ voter }} - {% endif %} -

          - {% endif %} - - {% if voter.is_abuser %} -

          {{ voter.name }} is condemned until further notice.

          -

          requests will still appear on tracks, but will not influence their position on the front page.

          - {% endif %} - - {% if voter.is_patron %} -

          {{ voter.name }} supports Neko Desu on Patreon!

          - {% endif %} - - - {% if voter.userbadge_set.all %} -
            - {% for badge in voter.userbadge_set.all %} -
          • - - -

            {{ badge.badge_info.description }}

            - - {% if badge.badge_info.url %} -

            More info…

            - {% endif %} -
          • - {% endfor %} -
          - {% endif %} -
          - -

          requests

          -
          - {% cache 300 user_detail:votes voter.user_id page_obj.number %} - {% regroup votes by show as votes_by_show %} - {% for group in votes_by_show %} -

          {{ group.grouper.showtime|date:"F jS Y" }}

          - {% spaceless %} -
            - {% for vote in group.list %} - {% include "include/vote.html" with show=group.grouper %} - {% endfor %} -
          - {% endspaceless %} - {% endfor %} - {% endcache %} - -
          + {% include "include/voter_votes.html" %}
          {% include "include/paginator.html" %} diff --git a/nkdsu/templates/vote.html b/nkdsu/templates/vote.html new file mode 100644 index 00000000..890c42f5 --- /dev/null +++ b/nkdsu/templates/vote.html @@ -0,0 +1,28 @@ +{% extends parent %} + +{% block title %}request something{% endblock %} + +{% block content %} +

          Make a request for {% include "include/show_date.html" with show=current_show %}

          + + {% if tracks %} +
          +
            + {% for track in tracks %} + {% include "include/nanotrack.html" with tiny=True %} + {% endfor %} +
          +
          + +
          + {% csrf_token %} + {{ form.as_p }} + +
          + {% else %} +
          +

          you are not eligible to request any of the tracks you have selected at the moment

          +

          were you doing anything weird to end up in this situation? if you think you might have found a bug, please consider reporting it

          +

          + {% endif %} +{% endblock %} diff --git a/nkdsu/tests.py b/nkdsu/tests.py index a0c0a912..aab2025d 100644 --- a/nkdsu/tests.py +++ b/nkdsu/tests.py @@ -1,8 +1,14 @@ from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.test import TestCase from instant_coverage import InstantCoverageMixin, optional +from .apps.vote.elfs import ELFS_NAME, is_elf + + +User = get_user_model() + class EverythingTest( optional.ExternalLinks, @@ -14,7 +20,8 @@ class EverythingTest( fixtures = ['vote.json'] covered_urls = [ - '/vote-admin/abuse/46162630/', + '/vote-admin/tw-abuse/46162630/', + '/vote-admin/local-abuse/45/', '/vote-admin/block/0007C3F2760E0541/', '/vote-admin/block/0007C3F2760E0541/reason?reason=announced', '/vote-admin/unblock/0007C3F2760E0541/', @@ -26,6 +33,7 @@ class EverythingTest( '/vote-admin/discard/0007C3F2760E0541/', '/vote-admin/reset/0007C3F2760E0541/', '/vote-admin/make-note/0007C3F2760E0541/', + '/vote-admin/post-about-play/0007C3F2760E0541/', '/vote-admin/remove-note/2/', '/vote-admin/hidden/', '/vote-admin/inudesu/', @@ -33,7 +41,6 @@ class EverythingTest( '/vote-admin/add-manual-vote/0007C3F2760E0541/', '/vote-admin/upload/', '/vote-admin/requests/', - '/vote-admin/trivia/', '/vote-admin/check-metadata/', '/vote-admin/play/0007C3F2760E0541/', '/js/deselect/', @@ -57,7 +64,10 @@ class EverythingTest( '/info/api/', '/info/privacy/', '/info/tos/', + '/profile/', '/request/', + '/request/?t=0007C3F2760E0541', + '/request-addition/', '/roulette/', '/roulette/hipster/', '/roulette/indiscriminate/', @@ -83,8 +93,9 @@ class EverythingTest( '/added/', '/search/?q=Canpeki', '/user/EuricaeriS/', - '/folks/what/', + '/@what/', '/login/', + '/register/', '/cpw/', '/cpw-done/', # it's important that logout be last since we have a sublcass of this @@ -94,6 +105,8 @@ class EverythingTest( uncovered_urls = [ # some urls that require stuff to be in the session + '/profile/', + '/request/', '/vote-admin/upload/confirm/', '/vote-admin/shortlist-selection/', '/vote-admin/discard-selection/', @@ -105,10 +118,8 @@ class EverythingTest( '/vote-admin/requests/fill/1/', '/vote-admin/requests/claim/1/', '/set-dark-mode/', - # would require me to put twitter credentials in the public settings - # file - '/twitter-avatar/46162630/', - '/twitter-avatar/46162630/?size=original', + # can only be accessed if you are logged in with an unusable password + '/spw/', ] uncovered_includes = [ @@ -120,10 +131,8 @@ class EverythingTest( def setUp(self) -> None: super().setUp() - user = get_user_model()( + user = User( username='what', - is_staff=True, - is_superuser=True, ) user.set_password('what') user.save() @@ -139,3 +148,25 @@ class LoggedInEverythingTest(EverythingTest): def setUp(self) -> None: super().setUp() self.assertTrue(self.client.login(username='what', password='what')) + + +class ElfEverythingTest(EverythingTest): + def setUp(self) -> None: + super().setUp() + us = User.objects.get(username='what') + self.assertFalse(is_elf(us)) + elfs, _ = Group.objects.get_or_create(name=ELFS_NAME) + us.groups.add(elfs) + us.save() + self.assertTrue(is_elf(us)) + self.assertTrue(self.client.login(username='what', password='what')) + + +class StaffEverythingTest(EverythingTest): + def setUp(self) -> None: + super().setUp() + us = User.objects.get(username='what') + self.assertFalse(us.is_staff) + us.is_staff = True + us.save() + self.assertTrue(self.client.login(username='what', password='what')) diff --git a/nkdsu/urls.py b/nkdsu/urls.py index 3f44b2d7..36d7515b 100644 --- a/nkdsu/urls.py +++ b/nkdsu/urls.py @@ -1,23 +1,30 @@ from django.conf import settings from django.contrib import admin -from django.contrib.auth.views import LoginView, LogoutView, PasswordChangeView +from django.contrib.auth.views import ( + LogoutView, + PasswordChangeDoneView, + PasswordChangeView, +) from django.urls import include, path, re_path as url -from django.views.generic.base import RedirectView from django.views.static import serve +import social_django.urls from nkdsu.apps.vote import urls as vote_urls +from nkdsu.views import LoginView, RegisterView, SetPasswordView admin.autodiscover() urlpatterns = [ url(r'^', include(vote_urls)), path('admin/', admin.site.urls), - path('s/', include('social_django.urls', namespace='social')), + path('s/', include(social_django.urls, namespace='social')), # registration + url(r'^register/', RegisterView.as_view(), name='register'), url(r'^logout/', LogoutView.as_view(), {'next_page': '/'}, name='logout'), url(r'^login/', LoginView.as_view(), name='login'), + url(r'^spw/', SetPasswordView.as_view(), name='password_set'), url(r'^cpw/', PasswordChangeView.as_view(), name='password_change'), - url(r'^cpw-done/', RedirectView.as_view(url='/'), name='password_change_done'), + url(r'^cpw-done/', PasswordChangeDoneView.as_view(), name='password_change_done'), ] if settings.DEBUG: diff --git a/nkdsu/views.py b/nkdsu/views.py new file mode 100644 index 00000000..35b97791 --- /dev/null +++ b/nkdsu/views.py @@ -0,0 +1,82 @@ +from typing import Any +from urllib.parse import parse_qs, urlparse + +from django.contrib import messages +from django.contrib.auth import get_user_model, views as auth_views +from django.contrib.auth.decorators import user_passes_test +from django.contrib.auth.forms import SetPasswordForm +from django.http import HttpResponse +from django.shortcuts import get_object_or_404 +from django.urls import reverse_lazy +from django.views.generic import CreateView, FormView + +from .apps.vote.models import Track +from .forms import RegistrationForm +from .mixins import MarkdownView + + +User = get_user_model() + + +class LoginView(MarkdownView, auth_views.LoginView): + template_name = auth_views.LoginView.template_name + title = 'login' + filename = 'TWITTER.md' + + def get_context_data(self, **kwargs) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + + tracks = [] + + query_string = urlparse(context[self.redirect_field_name]).query + query = parse_qs(query_string) + + if query is not None: + tracks = [ + get_object_or_404(Track, pk=pk) + for pk_string in query.get('t', []) + for pk in pk_string.split(',') + ] + + return { + **context, + 'is_vote': bool(tracks), # this is naive, but we don't use ?t= for a lot + 'registration_form': RegistrationForm(), + } + + +class RegisterView(CreateView): + template_name = 'auth/register.html' + model = User + form_class = RegistrationForm + success_url = reverse_lazy('vote:profiles:edit-profile') + + def form_valid(self, form: RegistrationForm) -> HttpResponse: + resp = super().form_valid(form) + messages.success( + self.request, 'Registration complete! Log in to edit your profile.' + ) + return resp + + +class SetPasswordView(FormView): + template_name = 'auth/set_password.html' + form_class = SetPasswordForm + success_url = reverse_lazy('login') + + def get_form_kwargs(self) -> dict[str, Any]: + return {**super().get_form_kwargs(), 'user': self.request.user} + + @classmethod + def as_view(cls, **kw): + return user_passes_test( + lambda u: u.is_authenticated and not u.has_usable_password(), + )(super().as_view(**kw)) + + def form_valid(self, form: SetPasswordForm) -> HttpResponse: + form.save() + messages.success( + self.request, + f'you have set a password. please log in with it. your username is {form.user.get_username()}', + ) + return super().form_valid(form) diff --git a/requirements.txt b/requirements.txt index 5bb9c9be..f42b19ea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,8 @@ classtools django-extensions==3.1.5 django-instant-coverage==1.2.1 django-pipeline==2.0.8 -django-stubs>=1.12.0<1.13 +django-resized==1.0.2 +django-stubs>=1.13.0,<1.14 django>=3.2.14,<3.3 flake8-import-order<2 flake8>=3.8 @@ -14,7 +15,7 @@ mypy>=0.990,<1 ndg-httpsclient psycopg2 pyasn1 -pytest>=7.1<8 +pytest>=7.1,<8 Levenshtein>=0.20.8 python-dateutil==2.8.2 python-memcached==1.59 @@ -22,10 +23,9 @@ requests==2.28.1 sly==0.5 social-auth-app-django==5.0.0 social-auth-core==4.3.0 -tweepy==3.10.0 typeguard>=2.12.3,<3 types-markdown==3.4.2.1 types-python-dateutil==2.8.19.3 -types-requests==2.28.11.4 -types-ujson==5.5.0 -ujson==5.5.0 +types-requests==2.28.11.5 +types-ujson==5.6.0.0 +ujson==5.6.0