Merge pull request #336 from mouse-reeve/user-shelves

User-created shelves
This commit is contained in:
Mouse Reeve 2020-11-10 22:06:40 -08:00 committed by GitHub
commit 56850b9574
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 330 additions and 98 deletions

View File

@ -159,3 +159,8 @@ class CreateInviteForm(CustomForm):
choices=[(i, "%d uses" % (i,)) for i in [1, 5, 10, 25, 50, 100]] choices=[(i, "%d uses" % (i,)) for i in [1, 5, 10, 25, 50, 100]]
+ [(None, 'Unlimited')]) + [(None, 'Unlimited')])
} }
class ShelfForm(CustomForm):
class Meta:
model = models.Shelf
fields = ['user', 'name', 'privacy']

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2020-11-10 20:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0008_work_default_edition'),
]
operations = [
migrations.AddField(
model_name='shelf',
name='privacy',
field=models.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255),
),
]

View File

@ -1,8 +1,9 @@
''' puttin' books on shelves ''' ''' puttin' books on shelves '''
import re
from django.db import models from django.db import models
from bookwyrm import activitypub from bookwyrm import activitypub
from .base_model import BookWyrmModel, OrderedCollectionMixin from .base_model import BookWyrmModel, OrderedCollectionMixin, PrivacyLevels
class Shelf(OrderedCollectionMixin, BookWyrmModel): class Shelf(OrderedCollectionMixin, BookWyrmModel):
@ -11,6 +12,11 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
identifier = models.CharField(max_length=100) identifier = models.CharField(max_length=100)
user = models.ForeignKey('User', on_delete=models.PROTECT) user = models.ForeignKey('User', on_delete=models.PROTECT)
editable = models.BooleanField(default=True) editable = models.BooleanField(default=True)
privacy = models.CharField(
max_length=255,
default='public',
choices=PrivacyLevels.choices
)
books = models.ManyToManyField( books = models.ManyToManyField(
'Edition', 'Edition',
symmetrical=False, symmetrical=False,
@ -18,6 +24,15 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
through_fields=('shelf', 'book') through_fields=('shelf', 'book')
) )
def save(self, *args, **kwargs):
''' set the identifier '''
saved = super().save(*args, **kwargs)
if not self.identifier:
slug = re.sub(r'[^\w]', '', self.name).lower()
self.identifier = '%s-%d' % (slug, self.id)
return super().save(*args, **kwargs)
return saved
@property @property
def collection_queryset(self): def collection_queryset(self):
''' list of books for this shelf, overrides OrderedCollectionMixin ''' ''' list of books for this shelf, overrides OrderedCollectionMixin '''

View File

@ -37,4 +37,5 @@
<glyph unicode="&#xe9d8;" glyph-name="star-half" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538zM512 206.502l-0.942-0.496 0.942 570.768 111.736-226.396 249.836-36.304-180.788-176.222 42.678-248.83-223.462 117.48z" /> <glyph unicode="&#xe9d8;" glyph-name="star-half" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538zM512 206.502l-0.942-0.496 0.942 570.768 111.736-226.396 249.836-36.304-180.788-176.222 42.678-248.83-223.462 117.48z" />
<glyph unicode="&#xe9d9;" glyph-name="star-full" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538z" /> <glyph unicode="&#xe9d9;" glyph-name="star-full" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538z" />
<glyph unicode="&#xe9da;" glyph-name="heart" d="M755.188 896c-107.63 0-200.258-87.554-243.164-179-42.938 91.444-135.578 179-243.216 179-148.382 0-268.808-120.44-268.808-268.832 0-301.846 304.5-380.994 512.022-679.418 196.154 296.576 511.978 387.206 511.978 679.418 0 148.392-120.43 268.832-268.812 268.832z" /> <glyph unicode="&#xe9da;" glyph-name="heart" d="M755.188 896c-107.63 0-200.258-87.554-243.164-179-42.938 91.444-135.578 179-243.216 179-148.382 0-268.808-120.44-268.808-268.832 0-301.846 304.5-380.994 512.022-679.418 196.154 296.576 511.978 387.206 511.978 679.418 0 148.392-120.43 268.832-268.812 268.832z" />
<glyph unicode="&#xea0a;" glyph-name="plus" d="M992 576h-352v352c0 17.672-14.328 32-32 32h-192c-17.672 0-32-14.328-32-32v-352h-352c-17.672 0-32-14.328-32-32v-192c0-17.672 14.328-32 32-32h352v-352c0-17.672 14.328-32 32-32h192c17.672 0 32 14.328 32 32v352h352c17.672 0 32 14.328 32 32v192c0 17.672-14.328 32-32 32z" />
</font></defs></svg> </font></defs></svg>

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -1,10 +1,10 @@
@font-face { @font-face {
font-family: 'icomoon'; font-family: 'icomoon';
src: url('fonts/icomoon.eot?jhaogg'); src: url('fonts/icomoon.eot?rd4abb');
src: url('fonts/icomoon.eot?jhaogg#iefix') format('embedded-opentype'), src: url('fonts/icomoon.eot?rd4abb#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?jhaogg') format('truetype'), url('fonts/icomoon.ttf?rd4abb') format('truetype'),
url('fonts/icomoon.woff?jhaogg') format('woff'), url('fonts/icomoon.woff?rd4abb') format('woff'),
url('fonts/icomoon.svg?jhaogg#icomoon') format('svg'); url('fonts/icomoon.svg?rd4abb#icomoon') format('svg');
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
font-display: block; font-display: block;
@ -115,3 +115,6 @@
.icon-heart:before { .icon-heart:before {
content: "\e9da"; content: "\e9da";
} }
.icon-plus:before {
content: "\ea0a";
}

View File

@ -1,6 +1,16 @@
{% extends 'layout.html' %} {% extends 'layout.html' %}
{% load fr_display %} {% load fr_display %}
{% block content %} {% block content %}
<div class="block">
<h1 class="title">
{% if is_self %}Your
{% else %}
{% include 'snippets/username.html' with user=user possessive=True %}
{% endif %}
followers
</h1>
</div>
{% include 'snippets/user_header.html' with user=user %} {% include 'snippets/user_header.html' with user=user %}
<div class="block"> <div class="block">

View File

@ -1,6 +1,16 @@
{% extends 'layout.html' %} {% extends 'layout.html' %}
{% load fr_display %} {% load fr_display %}
{% block content %} {% block content %}
<div class="block">
<h1 class="title">
Users following
{% if is_self %}you
{% else %}
{% include 'snippets/username.html' with user=user %}
{% endif %}
</h1>
</div>
{% include 'snippets/user_header.html' with user=user %} {% include 'snippets/user_header.html' with user=user %}
<div class="block"> <div class="block">

View File

@ -1,17 +1,122 @@
{% extends 'layout.html' %} {% extends 'layout.html' %}
{% load fr_display %} {% load fr_display %}
{% block content %} {% block content %}
<div class="columns">
<div class="column">
<h1 class="title">
{% if is_self %}Your
{% else %}
{% include 'snippets/username.html' with user=user possessive=True %}
{% endif %}
shelves
</h1>
</div>
</div>
{% include 'snippets/user_header.html' with user=user %} {% include 'snippets/user_header.html' with user=user %}
<div class="block"> <div class="block columns">
<div class="tabs"> <div class="column">
<ul> <div class="tabs" role="tablist">
{% for shelf_tab in shelves %} <ul>
<li class="{% if shelf_tab.identifier == shelf.identifier %}is-active{% endif %}"> {% for shelf_tab in shelves %}
<a href="/user/{{ user | username }}/shelf/{{ shelf_tab.identifier }}">{{ shelf_tab.name }}</a> <li class="{% if shelf_tab.identifier == shelf.identifier %}is-active{% endif %}">
</li> <a href="/user/{{ user | username }}/shelf/{{ shelf_tab.identifier }}" role="tab" aria-selected="{% if shelf_tab.identifier == shelf.identifier %}true{% else %}false{% endif %}">{{ shelf_tab.name }}</a>
{% endfor %} </li>
</ul> {% endfor %}
</ul>
</div>
</div>
{% if is_self %}
<div class="column is-narrow">
<input type="radio" id="create-shelf-form-hide" name="create-shelf-form" class="toggle-control" checked>
<div class="toggle-content hidden">
<label for="create-shelf-form-show">
<div role="button" tabindex="0">
<span class="icon icon-plus">
<span class="is-sr-only">Create new shelf</span>
</span>
</div>
</label>
</div>
</div>
{% endif %}
</div>
<input type="radio" id="create-shelf-form-show" name="create-shelf-form" class="toggle-control">
<div class="toggle-content hidden">
<div class="box mb-5">
<h2 class="title is-4">Create new shelf</h2>
<form name="create-shelf" action="/create-shelf/" method="post">
{% csrf_token %}
<input type="hidden" name="user" value="{{ request.user.id }}">
<div class="field">
<label class="label" for="id_name_create">Name:</label>
<input type="text" name="name" maxlength="100" class="input" required="true" id="id_name_create">
</div>
<label class="label">
<p>Shelf privacy:</p>
{% include 'snippets/privacy_select.html' with no_label=True %}
</label>
<div class="field is-grouped">
<button class="button is-primary" type="submit">Create shelf</button>
<label role="button" class="button" for="create-shelf-form-hide" tabindex="0">Cancel<label>
</div>
</form>
</div>
</div>
<div class="block columns">
<div class="column">
<h2 class="title is-3">
{{ shelf.name }}
<span class="subtitle">
{% include 'snippets/privacy-icons.html' with item=shelf %}
</span>
</h2>
</div>
{% if is_self %}
<div class="column is-narrow">
<input type="radio" id="edit-shelf-form-hide" name="edit-shelf-form" class="toggle-control" checked>
<div class="toggle-content hidden">
<label for="edit-shelf-form-show">
<div role="button" tabindex="0">
<span class="icon icon-pencil">
<span class="is-sr-only">Edit shelf</span>
</span>
</div>
</label>
</div>
</div>
{% endif %}
</div>
<input type="radio" id="edit-shelf-form-show" name="edit-shelf-form" class="toggle-control">
<div class="toggle-content hidden">
<div class="box mb-5">
<h2 class="title is-4">Edit shelf</h2>
<form name="create-shelf" action="/edit-shelf/{{ shelf.id }}" method="post">
{% csrf_token %}
<input type="hidden" name="user" value="{{ request.user.id }}">
{% if shelf.editable %}
<div class="field">
<label class="label" for="id_name">Name:</label>
<input type="text" name="name" maxlength="100" class="input" required="true" value="{{ shelf.name }}" id="id_name">
</div>
{% endif %}
<label class="label">
<p>Shelf privacy:</p>
{% include 'snippets/privacy_select.html' with no_label=True current=shelf.privacy %}
</label>
<div class="field is-grouped">
<button class="button is-primary" type="submit">Update shelf</button>
<label role="button" class="button" for="edit-shelf-form-hide" tabindex="0">Cancel<label>
</div>
</form>
</div> </div>
</div> </div>

View File

@ -0,0 +1,18 @@
{% if item.privacy == 'public' %}
<span class="icon icon-globe">
<span class="is-sr-only">Public post</span>
</span>
{% elif item.privacy == 'unlisted' %}
<span class="icon icon-unlock">
<span class="is-sr-only">Unlisted post</span>
</span>
{% elif item.privacy == 'followers' %}
<span class="icon icon-lock">
<span class="is-sr-only">Followers-only post</span>
</span>
{% else %}
<span class="icon icon-envelope">
<span class="is-sr-only">Private post</span>
</span>
{% endif %}

View File

@ -5,10 +5,18 @@
<label class="is-sr-only" for="privacy-{{ uuid }}">Post privacy</label> <label class="is-sr-only" for="privacy-{{ uuid }}">Post privacy</label>
{% endif %} {% endif %}
<select name="privacy" id="privacy-{{ uuid }}"> <select name="privacy" id="privacy-{{ uuid }}">
<option value="public" selected>Public</option> <option value="public" {% if not current or current == 'public' %}selected{% endif %}>
<option value="unlisted">Unlisted</option> Public
<option value="followers">Followers only</option> </option>
<option value="direct">Private</option> <option value="unlisted" {% if current == 'unlisted' %}selected{% endif %}>
Unlisted
</option>
<option value="followers" {% if current == 'followers' %}selected{% endif %}>
Followers only
</option>
<option value="direct" {% if current == 'direct' %}selected{% endif %}>
Private
</option>
</select> </select>
{% endwith %} {% endwith %}
</div> </div>

View File

@ -76,5 +76,15 @@
</table> </table>
{% else %} {% else %}
<p>This shelf is empty.</p> <p>This shelf is empty.</p>
{% if shelf.editable %}
<form name="delete-shelf" action="/delete-shelf/{{ shelf.id }}" method="post">
{% csrf_token %}
<input type="hidden" name="user" value="{{ request.user.id }}">
<button class="button is-danger is-light" type="submit">
Delete shelf
</button>
</form>
{% endif %}
{% endif %} {% endif %}

View File

@ -60,23 +60,7 @@
</div> </div>
<div class="card-footer-item"> <div class="card-footer-item">
{% if status.privacy == 'public' %} {% include 'snippets/privacy-icons.html' with item=status %}
<span class="icon icon-globe">
<span class="is-sr-only">Public post</span>
</span>
{% elif status.privacy == 'unlisted' %}
<span class="icon icon-unlock">
<span class="is-sr-only">Unlisted post</span>
</span>
{% elif status.privacy == 'followers' %}
<span class="icon icon-lock">
<span class="is-sr-only">Followers-only post</span>
</span>
{% else %}
<span class="icon icon-envelope">
<span class="is-sr-only">Private post</span>
</span>
{% endif %}
</div> </div>
<div class="card-footer-item"> <div class="card-footer-item">

View File

@ -1,19 +1,6 @@
{% load humanize %} {% load humanize %}
{% load fr_display %} {% load fr_display %}
<div class="block"> <div class="block">
<div class="level">
<h2 class="title">User Profile</h2>
{% if is_self %}
<div class="level-right">
<a href="/user-edit/" class="edit-link">edit
<span class="icon icon-pencil">
<span class="is-sr-only">Edit profile</span>
</span>
</a>
</div>
{% endif %}
</div>
<div class="columns"> <div class="columns">
<div class="column is-narrow"> <div class="column is-narrow">
<div class="media"> <div class="media">

View File

@ -1,6 +1,21 @@
{% extends 'layout.html' %} {% extends 'layout.html' %}
{% block content %} {% block content %}
<div class="columns">
<div class="column">
<h1 class="title">User profile</h1>
</div>
{% if is_self %}
<div class="column is-narrow">
<a href="/user-edit/">
<span class="icon icon-pencil">
<span class="is-sr-only">Edit profile</span>
</span>
</a>
</div>
{% endif %}
</div>
{% include 'snippets/user_header.html' with user=user %} {% include 'snippets/user_header.html' with user=user %}
<div class="block"> <div class="block">

View File

@ -1,25 +0,0 @@
{% extends 'layout.html' %}
{% load fr_display %}
{% block content %}
{% include 'snippets/user_header.html' with user=user %}
<div class="block">
<div class="tabs">
<ul>
{% for shelf in shelves %}
<li class="{% if true %}is-active{% endif %}">
<a href="/user/{{ user | username }}/shelves/{{ shelf.identifier }}">{{ shelf.name }}</a>
</li>
{% endfor %}
</ul>
<h2 class="title">{{ shelf.name }}</h2>
</div>
{% for shelf in shelves %}
<div class="block">
{% include 'snippets/shelf.html' with shelf=shelf ratings=ratings %}
</div>
{% endfor %}
{% endblock %}

View File

@ -120,6 +120,9 @@ urlpatterns = [
re_path(r'^delete-status/?$', actions.delete_status), re_path(r'^delete-status/?$', actions.delete_status),
re_path(r'^create-shelf/?$', actions.create_shelf),
re_path(r'^edit-shelf/(?P<shelf_id>\d+)?$', actions.edit_shelf),
re_path(r'^delete-shelf/(?P<shelf_id>\d+)?$', actions.delete_shelf),
re_path(r'^shelve/?$', actions.shelve), re_path(r'^shelve/?$', actions.shelve),
re_path(r'^unshelve/?$', actions.unshelve), re_path(r'^unshelve/?$', actions.unshelve),
re_path(r'^start-reading/?$', actions.start_reading), re_path(r'^start-reading/?$', actions.start_reading),

View File

@ -11,7 +11,7 @@ from django.contrib.auth.decorators import login_required, permission_required
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.http import HttpResponseBadRequest, HttpResponseNotFound from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.shortcuts import redirect from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.utils import timezone from django.utils import timezone
@ -272,6 +272,44 @@ def upload_cover(request, book_id):
return redirect('/book/%s' % book.id) return redirect('/book/%s' % book.id)
@login_required
def create_shelf(request):
''' user generated shelves '''
form = forms.ShelfForm(request.POST)
if not form.is_valid():
return redirect(request.headers.get('Referer', '/'))
shelf = form.save()
return redirect('/user/%s/shelf/%s' % \
(request.user.localname, shelf.identifier))
@login_required
def edit_shelf(request, shelf_id):
''' user generated shelves '''
shelf = get_object_or_404(models.Shelf, id=shelf_id)
if request.user != shelf.user:
return HttpResponseBadRequest()
form = forms.ShelfForm(request.POST, instance=shelf)
if not form.is_valid():
return redirect(request.headers.get('Referer', '/'))
shelf = form.save()
return redirect('/user/%s/shelf/%s' % \
(request.user.localname, shelf.identifier))
@login_required
def delete_shelf(request, shelf_id):
''' user generated shelves '''
shelf = get_object_or_404(models.Shelf, id=shelf_id)
if request.user != shelf.user or not shelf.editable:
return HttpResponseBadRequest()
shelf.delete()
return redirect('/user/%s/shelves' % request.user.localname)
@login_required @login_required
def shelve(request): def shelve(request):
''' put a on a user's shelf ''' ''' put a on a user's shelf '''

View File

@ -311,8 +311,9 @@ def notifications_page(request):
notifications.update(read=True) notifications.update(read=True)
return TemplateResponse(request, 'notifications.html', data) return TemplateResponse(request, 'notifications.html', data)
@csrf_exempt @csrf_exempt
def user_page(request, username, subpage=None, shelf=None): def user_page(request, username):
''' profile page for a user ''' ''' profile page for a user '''
try: try:
user = get_user_from_username(username) user = get_user_from_username(username)
@ -329,19 +330,6 @@ def user_page(request, username, subpage=None, shelf=None):
'user': user, 'user': user,
'is_self': request.user.id == user.id, 'is_self': request.user.id == user.id,
} }
if subpage == 'followers':
data['followers'] = user.followers.all()
return TemplateResponse(request, 'followers.html', data)
if subpage == 'following':
data['following'] = user.following.all()
return TemplateResponse(request, 'following.html', data)
if subpage == 'shelves':
data['shelves'] = user.shelf_set.all()
if shelf:
data['shelf'] = user.shelf_set.get(identifier=shelf)
else:
data['shelf'] = user.shelf_set.first()
return TemplateResponse(request, 'shelf.html', data)
data['shelf_count'] = user.shelf_set.count() data['shelf_count'] = user.shelf_set.count()
shelves = [] shelves = []
@ -376,7 +364,13 @@ def followers_page(request, username):
if is_api_request(request): if is_api_request(request):
return JsonResponse(user.to_followers_activity(**request.GET)) return JsonResponse(user.to_followers_activity(**request.GET))
return user_page(request, username, subpage='followers') data = {
'title': '%s: followers' % user.name,
'user': user,
'is_self': request.user.id == user.id,
'followers': user.followers.all(),
}
return TemplateResponse(request, 'followers.html', data)
@csrf_exempt @csrf_exempt
@ -393,16 +387,19 @@ def following_page(request, username):
if is_api_request(request): if is_api_request(request):
return JsonResponse(user.to_following_activity(**request.GET)) return JsonResponse(user.to_following_activity(**request.GET))
return user_page(request, username, subpage='following') data = {
'title': '%s: following' % user.name,
'user': user,
'is_self': request.user.id == user.id,
'following': user.following.all(),
}
return TemplateResponse(request, 'following.html', data)
@csrf_exempt @csrf_exempt
def user_shelves_page(request, username): def user_shelves_page(request, username):
''' list of followers ''' ''' list of followers '''
if request.method != 'GET': return shelf_page(request, username, None)
return HttpResponseBadRequest()
return user_page(request, username, subpage='shelves')
@csrf_exempt @csrf_exempt
@ -629,10 +626,40 @@ def shelf_page(request, username, shelf_identifier):
except models.User.DoesNotExist: except models.User.DoesNotExist:
return HttpResponseNotFound() return HttpResponseNotFound()
shelf = models.Shelf.objects.get(user=user, identifier=shelf_identifier) if shelf_identifier:
shelf = user.shelf_set.get(identifier=shelf_identifier)
else:
shelf = user.shelf_set.first()
is_self = request.user == user
shelves = user.shelf_set
if not is_self:
follower = user.followers.filter(id=request.user.id).exists()
# make sure the user has permission to view the shelf
if shelf.privacy == 'direct' or \
(shelf.privacy == 'followers' and not follower):
return HttpResponseNotFound()
# only show other shelves that should be visible
if follower:
shelves = shelves.filter(privacy__in=['public', 'followers'])
else:
print('hi')
shelves = shelves.filter(privacy='public')
if is_api_request(request): if is_api_request(request):
return JsonResponse(shelf.to_activity(**request.GET)) return JsonResponse(shelf.to_activity(**request.GET))
return user_page( data = {
request, username, subpage='shelves', shelf=shelf_identifier) 'title': user.name,
'user': user,
'is_self': is_self,
'shelves': shelves.all(),
'shelf': shelf,
'create_form': forms.ShelfForm(),
'edit_form': forms.ShelfForm(shelf),
}
return TemplateResponse(request, 'shelf.html', data)