Skip to content

Commit

Permalink
Merge branch 'user-websites'
Browse files Browse the repository at this point in the history
Closes #261.
  • Loading branch information
colons committed Nov 7, 2023
2 parents d574b70 + 80cfa80 commit 02cc49e
Show file tree
Hide file tree
Showing 10 changed files with 1,196 additions and 2,320 deletions.
3 changes: 2 additions & 1 deletion PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ This can include:
- a [hashed][django-password-storage] password
- a screen name and a display name
- an avatar
- an email address
- email addresses
- URLs of websites you choose to show on your profile page

[django-password-storage]: https://docs.djangoproject.com/en/3.2/topics/auth/passwords/#how-django-stores-passwords "how Django stores passwords"

Expand Down
44 changes: 44 additions & 0 deletions nkdsu/apps/vote/migrations/0022_userwebsite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from django.db import migrations, models
import django.db.models.deletion
import nkdsu.apps.vote.models


class Migration(migrations.Migration):
dependencies = [
('vote', '0021_track_archival'),
]

operations = [
migrations.CreateModel(
name='UserWebsite',
fields=[
(
'id',
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID',
),
),
('url', models.URLField()),
(
'profile',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='websites',
to='vote.profile',
),
),
],
bases=(nkdsu.apps.vote.models.CleanOnSaveMixin, models.Model),
),
migrations.AddConstraint(
model_name='userwebsite',
constraint=models.UniqueConstraint(
fields=('url', 'profile'),
name='unique_url_per_profile',
violation_error_message="You can't provide the same URL more than once",
),
),
]
92 changes: 90 additions & 2 deletions nkdsu/apps/vote/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from io import BytesIO
from string import ascii_letters
from typing import Any, Iterable, Optional, cast
from urllib.parse import quote
from urllib.parse import quote, urlparse
from uuid import uuid4

from Levenshtein import ratio
Expand All @@ -22,7 +22,7 @@
from django.core.files.temp import NamedTemporaryFile
from django.db import models
from django.db.models import Q
from django.db.models.constraints import CheckConstraint
from django.db.models.constraints import CheckConstraint, UniqueConstraint
from django.template.defaultfilters import slugify
from django.templatetags.static import static
from django.urls import reverse
Expand Down Expand Up @@ -54,6 +54,8 @@

User = get_user_model()

MAX_WEBSITES = 5


class CleanOnSaveMixin:
def save(self, *args, **kwargs):
Expand Down Expand Up @@ -487,6 +489,92 @@ def name(self, name: str) -> None:
def get_toggle_abuser_url(self) -> str:
return reverse('vote:admin:toggle_local_abuser', kwargs={'user_id': self.pk})

def has_max_websites(self) -> bool:
return self.websites.count() >= MAX_WEBSITES

def get_websites(self) -> Iterable[UserWebsite]:
return sorted(self.websites.all(), key=lambda w: (w.icon, w.url))


class UserWebsite(CleanOnSaveMixin, models.Model):
class Meta:
constraints = [
UniqueConstraint(
fields=['url', 'profile'],
name='unique_url_per_profile',
violation_error_message="You can't provide the same URL more than once",
),
]

url = models.URLField()
profile = models.ForeignKey(
Profile, related_name='websites', on_delete=models.CASCADE
)

def clean(self) -> None:
super().clean()
if self._state.adding and self.profile.websites.count() >= MAX_WEBSITES:
raise ValidationError('You cannot have any more websites')

@property
def icon(self) -> str:
"""
Return an appropriate identify for for what kind of URL this is.
>>> UserWebsite(url='https://someone.tumblr.com').icon
'tumblr'
>>> UserWebsite(url='https://tumblr.com/someone').icon
'tumblr'
>>> UserWebsite(url='https://cohost.org/someone').icon
'cohost'
>>> UserWebsite(url='https://bsky.app/profile/someone').icon
'bsky'
>>> UserWebsite(url='https://www.instagram.com/someone').icon
'instagram'
>>> UserWebsite(url='https://www.threads.net/@someone').icon
'threads'
>>> UserWebsite(url='https://linkedin.com/in/someone-jsioadj/').icon
'linkedin'
>>> UserWebsite(url='https://facebook.com/someone').icon
'facebook'
>>> UserWebsite(url='https://www.youtube.com/@someone').icon
'youtube'
>>> UserWebsite(url='https://www.youtube.com/channel/someone/').icon
'youtube'
>>> UserWebsite(url='https://www.twitch.tv/someone/').icon
'twitch'
>>> UserWebsite(url='https://website.tld').icon
'website'
"""

hostname = urlparse(self.url).hostname
assert hostname is not None, f"url {self.url!r} has no hostname"

rv = {
'bsky.app': 'bsky',
'cohost.org': 'cohost',
'facebook.com': 'facebook',
'instagram.com': 'instagram',
'linkedin.com': 'linkedin',
'threads.net': 'threads',
'tumblr.com': 'tumblr',
'twitch.tv': 'twitch',
'twitter.com': 'twitter',
'x.com': 'x',
'youtube.com': 'youtube',
}.get(hostname.removeprefix('www.'))

if rv is not None:
return rv

# some places let you use subdomains:
if hostname.endswith('.tumblr.com'):
return 'tumblr'
if hostname.endswith('.cohost.com'):
return 'cohost'

return 'website'


def art_path(i: Track, f: str) -> str:
return 'art/bg/%s.%s' % (i.pk, f.split('.')[-1])
Expand Down
17 changes: 17 additions & 0 deletions nkdsu/apps/vote/templatetags/vote_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import datetime
from typing import Iterable, Optional
from urllib.parse import urlparse

from allauth.account.models import EmailAddress
from django.contrib.auth.models import AnonymousUser, User
Expand Down Expand Up @@ -121,3 +122,19 @@ def eligible_for(track: Track, user: User | AnonymousUser) -> bool:
)
and track.eligible()
)


@register.filter
def strip_scheme(url: str) -> str:
"""
Strip the scheme from a URL, if present.
>>> strip_scheme('https://nkd.su/path?q=s#fragment')
'nkd.su/path?q=s#fragment'
>>> strip_scheme('ftps://nkd.su/path?q=s#fragment')
'nkd.su/path?q=s#fragment'
>>> strip_scheme('not a url')
'not a url'
"""

return url.removeprefix(f'{urlparse(url).scheme}://')
50 changes: 48 additions & 2 deletions nkdsu/apps/vote/views/profiles.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
from typing import Optional

from django.contrib import messages
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.core.exceptions import ValidationError
from django.db.models import QuerySet
from django.shortcuts import get_object_or_404
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
from django.views.generic import UpdateView

from . import VoterDetail
from ..forms import ClearableFileInput
from ..models import Profile
from ..models import Profile, UserWebsite
from ..utils import get_profile_for


Expand All @@ -29,6 +32,49 @@ def get_object(

return get_object_or_404(queryset, username=self.kwargs['username'])

def post(self, request: HttpRequest, username: str) -> HttpResponse:
user = self.get_object()
assert isinstance(user, User)

if request.user != user:
messages.warning(self.request, "this isn't you. stop that.")
return redirect('.')

delete_pk = request.POST.get('delete-website')
if delete_pk is not None:
try:
website = user.profile.websites.get(pk=delete_pk)
except UserWebsite.DoesNotExist:
messages.warning(
self.request, "that website isn't on your profile at the moment"
)
return redirect('.')
else:
website.delete()
messages.success(
self.request, f"website {website.url!r} removed from your profile"
)
return redirect('.')

if request.POST.get('add-website') == 'yes' and request.POST.get('url'):
if user.profile.has_max_websites():
messages.warning(
self.request, "don't you think you have enough websites already"
)
return redirect('.')
else:
try:
website = user.profile.websites.create(url=request.POST['url'])
except ValidationError as e:
messages.warning(self.request, ', '.join(e.messages))
return redirect('.')
messages.success(
self.request, f"website {website.url} added to your profile"
)
return redirect('.')

return redirect('.')

def get_voter(self) -> Profile:
user = self.get_object()
assert isinstance(user, User)
Expand Down
10 changes: 8 additions & 2 deletions nkdsu/apps/vote/voter.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
from __future__ import annotations

import datetime
from typing import Optional, Protocol, TYPE_CHECKING, _ProtocolMeta
from typing import Iterable, 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
from .models import UserBadge, Vote, Show, Track, Profile, TwitterUser, UserWebsite

# to check to see that their VoterProtocol implementations are complete:
Profile()
Expand Down Expand Up @@ -118,6 +118,12 @@ def is_shortlisted(self) -> bool:
.exists()
)

def has_max_websites(self) -> bool:
return True

def get_websites(self) -> Iterable[UserWebsite]:
return []

def _batting_average(
self,
cutoff: Optional[datetime.datetime] = None,
Expand Down
30 changes: 30 additions & 0 deletions nkdsu/static/less/main.less
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,35 @@ body {
}
}

.websites {
margin-bottom: 0.5em;
text-align: center;

ul li {
&::before {
.fas();
content: @fa-var-globe;
letter-spacing: .2em; // for eggbug
}
&[data-icon="bsky"]::before { content: @fa-var-cloud }
&[data-icon="cohost"]::before { content: %("%s%s", @fa-var-egg, @fa-var-bug) }
&[data-icon="facebook"]::before { .fab(); content: @fa-var-facebook }
&[data-icon="instagram"]::before { .fab(); content: @fa-var-instagram }
&[data-icon="linkedin"]::before { .fab(); content: @fa-var-linkedin }
&[data-icon="threads"]::before { .fas(); content: @fa-var-threads }
&[data-icon="tumblr"]::before { .fab(); content: @fa-var-tumblr }
&[data-icon="twitch"]::before { .fab(); content: @fa-var-twitch }
&[data-icon="twitter"]::before { .fab(); content: @fa-var-twitter }
&[data-icon="x"]::before { .fab(); content: @fa-var-x }
&[data-icon="youtube"]::before { .fab(); content: @fa-var-youtube }

form {
margin: 0 .5em;
display: inline-block;
}
}
}

.user-votes {
> ul {
.clearfix();
Expand Down Expand Up @@ -836,6 +865,7 @@ main.login {
}

.self-infobox {
margin: .5em 0;
padding: .6em 0;
background-color: fade(@light, 20%);
border-radius: .5em;
Expand Down
32 changes: 32 additions & 0 deletions nkdsu/templates/include/voter_meta.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,38 @@ <h2>
<p class="patron">{{ voter.name }} supports Neko Desu <a href="https://www.patreon.com/NekoDesu">financially</a>!</p>
{% endif %}

{% if voter.get_websites or voter == request.user %}
<div class="websites">
<ul>
{% for website in voter.get_websites %}
<li data-icon="{{ website.icon }}">
<a href="{{ website.url }}">{{ website.url|strip_scheme }}</a>
<form method="post">
{% csrf_token %}
<button title="remove this URL from your profile" class="link" type="submit" name="delete-website" value="{{ website.pk }}"></button>
</form>
</li>
{% endfor %}
</ul>

{% if voter == request.user.profile and not voter.has_max_websites %}
<details>
<summary>add a website</summary>
<form method="post">
{% csrf_token %}
<p>
<label>URL:</label>
<input type="text" name="url" placeholder="https://mycoolwebsite.gov"/>
</p>
<p class="submit">
<button class="button" name="add-website" value="yes" type="submit">Add website</button>
</p>
</form>
</details>
{% endif %}
</div>
{% endif %}

{% if voter.badges %}
<ul class="badges">
{% for badge in voter.badges %}
Expand Down
Loading

0 comments on commit 02cc49e

Please sign in to comment.