Merge branch 'main' into inbox-refactor

This commit is contained in:
Mouse Reeve 2021-02-24 09:51:34 -08:00
commit cad19ee878
58 changed files with 472 additions and 317 deletions

View file

@ -112,36 +112,42 @@ Once the build is complete, you can access the instance at `localhost:1333`
## Installing in Production
This project is still young and isn't, at the momoment, very stable, so please procede with caution when running in production.
### Server setup
- Get a domain name and set up DNS for your server
- Set your server up with appropriate firewalls for running a web application (this instruction set is tested again Ubuntu 20.04)
- Set up a mailgun account and the appropriate DNS settings
- Set up an email service (such as mailgun) and the appropriate SMTP/DNS settings
- Install Docker and docker-compose
### Install and configure BookWyrm
The `production` branch of BookWyrm contains a number of tools not on the `main` branch that are suited for running in production, such as `docker-compose` changes to update the default commands or configuration of containers, and indivudal changes to container config to enable things like SSL or regular backups.
Instructions for running BookWyrm in production:
- Get the application code:
`git clone git@github.com:mouse-reeve/bookwyrm.git`
- Switch to the `production` branch
`git checkout production`
- Create your environment variables file
`cp .env.example .env`
- Add your domain, email address, mailgun credentials
- Add your domain, email address, SMTP credentials
- Set a secure redis password and secret key
- Set a secure database password for postgres
- Update your nginx configuration in `nginx/default.conf`
- Replace `your-domain.com` with your domain name
- Run the application (this should also set up a Certbot ssl cert for your domain)
`docker-compose up --build`
Make sure all the images build successfully
- Run the application (this should also set up a Certbot ssl cert for your domain) with
`docker-compose up --build`, and make sure all the images build successfully
- When docker has built successfully, stop the process with `CTRL-C`
- Comment out the `command: certonly...` line in `docker-compose.yml`
- Run docker-compose in the background
`docker-compose up -d`
- Initialize the database
`./bw-dev initdb`
- Set up schedule backups with cron that runs that `docker-compose exec db pg_dump -U <databasename>` and saves the backup to a safe locationgi
- Congrats! You did it, go to your domain and enjoy the fruits of your labors
- Run docker-compose in the background with: `docker-compose up -d`
- Initialize the database with: `./bw-dev initdb`
- Set up schedule backups with cron that runs that `docker-compose exec db pg_dump -U <databasename>` and saves the backup to a safe location
Congrats! You did it, go to your domain and enjoy the fruits of your labors.
### Configure your instance
- Register a user account in the applcation UI
- Register a user account in the application UI
- Make your account a superuser (warning: do *not* use django's `createsuperuser` command)
- On your server, open the django shell
`./bw-dev shell`

View file

@ -216,10 +216,6 @@ def get_data(url):
raise ConnectorException()
if not resp.ok:
try:
resp.raise_for_status()
except requests.exceptions.HTTPError as e:
logger.exception(e)
raise ConnectorException()
try:
data = resp.json()

View file

@ -286,7 +286,7 @@ class OrderedCollectionPageMixin(ObjectMixin):
def to_ordered_collection(self, queryset, \
remote_id=None, page=False, collection_only=False, **kwargs):
''' an ordered collection of whatevers '''
'pure=pure, '' an ordered collection of whatevers '''
if not queryset.ordered:
raise RuntimeError('queryset must be ordered')
@ -480,7 +480,7 @@ def sign_and_send(sender, data, destination):
# pylint: disable=unused-argument
def to_ordered_collection_page(
queryset, remote_id, id_only=False, page=1, **kwargs):
queryset, remote_id, id_only=False, page=1, pure=False, **kwargs):
''' serialize and pagiante a queryset '''
paginated = Paginator(queryset, PAGE_LENGTH)
@ -488,7 +488,7 @@ def to_ordered_collection_page(
if id_only:
items = [s.remote_id for s in activity_page.object_list]
else:
items = [s.to_activity() for s in activity_page.object_list]
items = [s.to_activity(pure=pure) for s in activity_page.object_list]
prev_page = next_page = None
if activity_page.has_next():

View file

@ -1,8 +1,7 @@
''' defines relationships between users '''
from django.apps import apps
from django.db import models, transaction
from django.db import models, transaction, IntegrityError
from django.db.models import Q
from django.dispatch import receiver
from bookwyrm import activitypub
from .activitypub_mixin import ActivitypubMixin, ActivityMixin
@ -61,15 +60,26 @@ class UserFollows(ActivityMixin, UserRelationship):
''' Following a user '''
status = 'follows'
def save(self, *args, **kwargs):
''' never broadcast a creation (that's handled by "accept"), only a
deletion (an unfollow, as opposed to "reject" and undo pending) '''
super().save(*args, broadcast=False, **kwargs)
def to_activity(self):
''' overrides default to manually set serializer '''
return activitypub.Follow(**generate_activity(self))
def save(self, *args, **kwargs):
''' really really don't let a user follow someone who blocked them '''
# blocking in either direction is a no-go
if UserBlocks.objects.filter(
Q(
user_subject=self.user_subject,
user_object=self.user_object,
) | Q(
user_subject=self.user_object,
user_object=self.user_subject,
)
).exists():
raise IntegrityError()
# don't broadcast this type of relationship -- accepts and requests
# are handled by the UserFollowRequest model
super().save(*args, broadcast=False, **kwargs)
@classmethod
def from_request(cls, follow_request):
@ -88,24 +98,23 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
def save(self, *args, broadcast=True, **kwargs):
''' make sure the follow or block relationship doesn't already exist '''
try:
UserFollows.objects.get(
# don't create a request if a follow already exists
if UserFollows.objects.filter(
user_subject=self.user_subject,
user_object=self.user_object,
)
).exists():
raise IntegrityError()
# blocking in either direction is a no-go
UserBlocks.objects.get(
if UserBlocks.objects.filter(
Q(
user_subject=self.user_subject,
user_object=self.user_object,
)
UserBlocks.objects.get(
) | Q(
user_subject=self.user_object,
user_object=self.user_subject,
)
return
except (UserFollows.DoesNotExist, UserBlocks.DoesNotExist):
pass
).exists():
raise IntegrityError()
super().save(*args, **kwargs)
if broadcast and self.user_subject.local and not self.user_object.local:
@ -160,20 +169,15 @@ class UserBlocks(ActivityMixin, UserRelationship):
status = 'blocks'
activity_serializer = activitypub.Block
@receiver(models.signals.post_save, sender=UserBlocks)
#pylint: disable=unused-argument
def execute_after_save(sender, instance, created, *args, **kwargs):
def save(self, *args, **kwargs):
''' remove follow or follow request rels after a block is created '''
super().save(*args, **kwargs)
UserFollows.objects.filter(
Q(user_subject=instance.user_subject,
user_object=instance.user_object) | \
Q(user_subject=instance.user_object,
user_object=instance.user_subject)
Q(user_subject=self.user_subject, user_object=self.user_object) | \
Q(user_subject=self.user_object, user_object=self.user_subject)
).delete()
UserFollowRequest.objects.filter(
Q(user_subject=instance.user_subject,
user_object=instance.user_object) | \
Q(user_subject=instance.user_object,
user_object=instance.user_subject)
Q(user_subject=self.user_subject, user_object=self.user_object) | \
Q(user_subject=self.user_object, user_object=self.user_subject)
).delete()

View file

@ -6,11 +6,10 @@ from django.apps import apps
from django.contrib.auth.models import AbstractUser
from django.core.validators import MinValueValidator
from django.db import models
from django.dispatch import receiver
from django.utils import timezone
from bookwyrm import activitypub
from bookwyrm.connectors import get_data
from bookwyrm.connectors import get_data, ConnectorException
from bookwyrm.models.shelf import Shelf
from bookwyrm.models.status import Status, Review
from bookwyrm.settings import DOMAIN
@ -113,6 +112,16 @@ class User(OrderedCollectionPageMixin, AbstractUser):
activity_serializer = activitypub.Person
@classmethod
def viewer_aware_objects(cls, viewer):
''' the user queryset filtered for the context of the logged in user '''
queryset = cls.objects.filter(is_active=True)
if viewer.is_authenticated:
queryset = queryset.exclude(
blocks=viewer
)
return queryset
def to_outbox(self, filter_type=None, **kwargs):
''' an ordered collection of statuses '''
if filter_type:
@ -172,15 +181,23 @@ class User(OrderedCollectionPageMixin, AbstractUser):
def save(self, *args, **kwargs):
''' populate fields for new local users '''
# this user already exists, no need to populate fields
created = not bool(self.id)
if not self.local and not re.match(regex.full_username, self.username):
# generate a username that uses the domain (webfinger format)
actor_parts = urlparse(self.remote_id)
self.username = '%s@%s' % (self.username, actor_parts.netloc)
return super().save(*args, **kwargs)
super().save(*args, **kwargs)
if self.id or not self.local:
return super().save(*args, **kwargs)
# this user already exists, no need to populate fields
if not created:
super().save(*args, **kwargs)
return
# this is a new remote user, we need to set their remote server field
if not self.local:
super().save(*args, **kwargs)
set_remote_server.delay(self.id)
return
# populate fields for local users
self.remote_id = 'https://%s/user/%s' % (DOMAIN, self.localname)
@ -188,7 +205,32 @@ class User(OrderedCollectionPageMixin, AbstractUser):
self.shared_inbox = 'https://%s/inbox' % DOMAIN
self.outbox = '%s/outbox' % self.remote_id
return super().save(*args, **kwargs)
# an id needs to be set before we can proceed with related models
super().save(*args, **kwargs)
# create keys and shelves for new local users
self.key_pair = KeyPair.objects.create(
remote_id='%s/#main-key' % self.remote_id)
self.save(broadcast=False)
shelves = [{
'name': 'To Read',
'identifier': 'to-read',
}, {
'name': 'Currently Reading',
'identifier': 'reading',
}, {
'name': 'Read',
'identifier': 'read',
}]
for shelf in shelves:
Shelf(
name=shelf['name'],
identifier=shelf['identifier'],
user=self,
editable=False
).save(broadcast=False)
@property
def local_path(self):
@ -280,42 +322,6 @@ class AnnualGoal(BookWyrmModel):
finish_date__year__gte=self.year).count()
@receiver(models.signals.post_save, sender=User)
#pylint: disable=unused-argument
def execute_after_save(sender, instance, created, *args, **kwargs):
''' create shelves for new users '''
if not created:
return
if not instance.local:
set_remote_server.delay(instance.id)
return
instance.key_pair = KeyPair.objects.create(
remote_id='%s/#main-key' % instance.remote_id)
instance.save(broadcast=False)
shelves = [{
'name': 'To Read',
'identifier': 'to-read',
}, {
'name': 'Currently Reading',
'identifier': 'reading',
}, {
'name': 'Read',
'identifier': 'read',
}]
for shelf in shelves:
Shelf(
name=shelf['name'],
identifier=shelf['identifier'],
user=instance,
editable=False
).save(broadcast=False)
@app.task
def set_remote_server(user_id):
''' figure out the user's remote server in the background '''
@ -323,7 +329,7 @@ def set_remote_server(user_id):
actor_parts = urlparse(user.remote_id)
user.federated_server = \
get_or_create_remote_server(actor_parts.netloc)
user.save()
user.save(broadcast=False)
if user.bookwyrm_user:
get_remote_reviews.delay(user.outbox)
@ -337,19 +343,24 @@ def get_or_create_remote_server(domain):
except FederatedServer.DoesNotExist:
pass
try:
data = get_data('https://%s/.well-known/nodeinfo' % domain)
try:
nodeinfo_url = data.get('links')[0].get('href')
except (TypeError, KeyError):
return None
raise ConnectorException()
data = get_data(nodeinfo_url)
application_type = data.get('software', {}).get('name')
application_version = data.get('software', {}).get('version')
except ConnectorException:
application_type = application_version = None
server = FederatedServer.objects.create(
server_name=domain,
application_type=data['software']['name'],
application_version=data['software']['version'],
application_type=application_type,
application_version=application_version,
)
return server

View file

@ -100,9 +100,6 @@
.cover-container.is-medium {
height: 100px;
}
.cover-container.is-small {
height: 70px;
}
}
.cover-container.is-medium .no-cover div {

View file

@ -1,16 +0,0 @@
{% extends 'layout.html' %}
{% block content %}
<div class="columns">
<div class="column block">
{% include 'snippets/about.html' %}
</div>
<div class="column block">
<h2 class="title">Code of Conduct</h2>
<div class="content">
{{ site.code_of_conduct | safe }}
</div>
</div>
</div>
{% endblock %}

View file

@ -2,7 +2,7 @@
{% load bookwyrm_tags %}
{% block content %}
<div class="block">
<div class="columns">
<div class="columns is-mobile">
<div class="column">
<h1 class="title">{{ author.name }}</h1>
</div>

View file

@ -4,7 +4,7 @@
{% block content %}
<div class="block">
<div class="columns">
<div class="columns is-mobile">
<div class="column">
<h1 class="title">
{{ book.title }}{% if book.subtitle %}:
@ -42,26 +42,10 @@
<h3 class="title is-6 mb-1">Add cover</h3>
<form name="add-cover" method="POST" action="/upload-cover/{{ book.id }}" enctype="multipart/form-data">
{% csrf_token %}
<div class="field has-addons">
<div class="control">
<div class="file is-small mb-1">
<label class="file-label">
<input class="file-input" type="file" name="cover" accept="image/*" enctype="multipart/form-data" id="id_cover" required>
<span class="file-cta">
<span class="file-icon">
<i class="fas fa-upload"></i>
</span>
<span class="file-label">
Choose file...
</span>
</span>
<label class="label">
<input type="file" name="cover" accept="image/*" enctype="multipart/form-data" id="id_cover" required>
</label>
</div>
</div>
<div class="control">
<button class="button is-small is-primary" type="submit">Add</button>
</div>
</div>
</form>
</div>
{% endif %}
@ -242,7 +226,7 @@
<div class="block" id="reviews">
{% for review in reviews %}
<div class="block">
{% include 'snippets/status.html' with status=review hide_book=True depth=1 %}
{% include 'snippets/status/status.html' with status=review hide_book=True depth=1 %}
</div>
{% endfor %}

View file

@ -0,0 +1,22 @@
{% extends 'layout.html' %}
{% block content %}
<header class="block has-text-centered">
<h1 class="title">{{ site.name }}</h1>
<h2 class="subtitle">{{ site.instance_tagline }}</h2>
</header>
{% include 'discover/icons.html' %}
<section class="block">
{% include 'snippets/about.html' %}
</section>
<div class="block">
<h2 class="title">Code of Conduct</h2>
<div class="content">
{{ site.code_of_conduct | safe }}
</div>
</div>
{% endblock %}

View file

@ -1,33 +1,14 @@
{% extends 'layout.html' %}
{% block content %}
{% if not request.user.is_authenticated %}
<header class="block has-text-centered">
<h1 class="title">{{ site.name }}</h1>
<h2 class="subtitle">{{ site.instance_tagline }}</h2>
</header>
<section class="level is-mobile">
<div class="level-item has-text-centered">
<div>
<p class="title has-text-weight-normal"><span class="icon icon-graphic-paperplane"></span></p>
<p class="heading">Decentralized</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="title has-text-weight-normal"><span class="icon icon-graphic-heart"></span></p>
<p class="heading">Friendly</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="title has-text-weight-normal"><span class="icon icon-graphic-banknote"></span></p>
<p class="heading">Anti-Corporate</p>
</div>
</div>
</section>
{% include 'discover/icons.html' %}
{% if not request.user.is_authenticated %}
<section class="tile is-ancestor">
<div class="tile is-7 is-parent">
<div class="tile is-child box">
@ -49,10 +30,6 @@
</div>
</section>
{% else %}
<div class="block">
<h1 class="title has-text-centered">Discover</h1>
</div>
{% endif %}
<div class="block is-hidden-tablet">
@ -63,18 +40,18 @@
<div class="tile is-vertical">
<div class="tile is-parent">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/large-book.html' with book=books.0 %}
{% include 'discover/large-book.html' with book=books.0 %}
</div>
</div>
<div class="tile">
<div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/small-book.html' with book=books.1 %}
{% include 'discover/small-book.html' with book=books.1 %}
</div>
</div>
<div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/small-book.html' with book=books.2 %}
{% include 'discover/small-book.html' with book=books.2 %}
</div>
</div>
</div>
@ -83,18 +60,18 @@
<div class="tile">
<div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/small-book.html' with book=books.3 %}
{% include 'discover/small-book.html' with book=books.3 %}
</div>
</div>
<div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/small-book.html' with book=books.4 %}
{% include 'discover/small-book.html' with book=books.4 %}
</div>
</div>
</div>
<div class="tile is-parent">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/large-book.html' with book=books.5 %}
{% include 'discover/large-book.html' with book=books.5 %}
</div>
</div>
</div>

View file

@ -0,0 +1,21 @@
<section class="level is-mobile">
<div class="level-item has-text-centered">
<div>
<p class="title has-text-weight-normal"><span class="icon icon-graphic-paperplane"></span></p>
<p class="heading">Decentralized</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="title has-text-weight-normal"><span class="icon icon-graphic-heart"></span></p>
<p class="heading">Friendly</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="title has-text-weight-normal"><span class="icon icon-graphic-banknote"></span></p>
<p class="heading">Anti-Corporate</p>
</div>
</div>
</section>

View file

@ -2,7 +2,7 @@
{% if book %}
<div class="columns">
<div class="column is-narrow">
{% include 'snippets/book_cover.html' with book=book size="large" %}
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book size="large" %}</a>
{% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %}
</div>
<div class="column">

View file

@ -1,6 +1,6 @@
{% load bookwyrm_tags %}
{% if book %}
{% include 'snippets/book_cover.html' with book=book %}
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book %}</a>
{% if ratings %}
{% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %}
{% endif %}

View file

@ -62,7 +62,7 @@
<div class="column is-narrow">
<div class="block">
<h2 class="title is-4">Cover</h2>
<p>{{ form.cover }} </p>
<p>{{ form.cover }}</p>
{% for error in form.cover.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}

View file

@ -16,7 +16,7 @@
{% endif %}
{% for activity in activities %}
<div class="block">
{% include 'snippets/status.html' with status=activity %}
{% include 'snippets/status/status.html' with status=activity %}
</div>
{% endfor %}

View file

@ -32,7 +32,7 @@
{% endif %}
{% for activity in activities %}
<div class="block">
{% include 'snippets/status.html' with status=activity %}
{% include 'snippets/status/status.html' with status=activity %}
</div>
{% endfor %}

View file

@ -8,7 +8,7 @@
{% endwith %}
{% endif %}
{% include 'snippets/status.html' with status=status main=is_root %}
{% include 'snippets/status/status.html' with status=status main=is_root %}
{% if depth <= max_depth and direction >= 0 %}
{% for reply in status|replies %}

View file

@ -50,7 +50,7 @@
<div class="column is-narrow">
<div class="box">
<a href="{{ book.book.local_path }}">
{% include 'snippets/discover/small-book.html' with book=book.book rating=goal.ratings %}
{% include 'discover/small-book.html' with book=book.book rating=goal.ratings %}
</a>
</div>
</div>

View file

@ -19,7 +19,7 @@
{% for item in items %}
<li class="block pb-3">
<div class="card">
<div class="card-content columns p-0 mb-0">
<div class="card-content columns p-0 mb-0 is-mobile">
<div class="column is-narrow pt-0 pb-0">
<a href="{{ item.book.local_path }}">{% include 'snippets/book_cover.html' with book=item.book size="medium" %}</a>
</div>
@ -73,7 +73,7 @@
{% endif %}
{% for book in suggested_books %}
{% if book %}
<div class="block columns">
<div class="block columns is-mobile">
<div class="column is-narrow">
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book size="small" %}</a>
</div>

View file

@ -2,7 +2,7 @@
{% load bookwyrm_tags %}
{% block content %}
<header class="columns content">
<header class="columns content is-mobile">
<div class="column">
<h1 class="title">{{ list.name }} <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span></h1>
<p class="subtitle help">Created {% if list.curation != 'open' %} and curated{% endif %} by {% include 'snippets/username.html' with user=list.user %}</p>

View file

@ -5,7 +5,7 @@
<h1 class="title">Lists</h1>
</header>
{% if request.user.is_authenticated and not lists.has_previous %}
<header class="block columns">
<header class="block columns is-mobile">
<div class="column">
<h2 class="title">Your lists</h2>
</div>

View file

@ -2,28 +2,6 @@
{% block header %}Invites{% endblock %}
{% load humanize %}
{% block panel %}
<section class="block">
<table class="table is-striped">
<tr>
<th>Link</th>
<th>Expires</th>
<th>Max uses</th>
<th>Times used</th>
</tr>
{% if not invites %}
<tr><td colspan="4">No active invites</td></tr>
{% endif %}
{% for invite in invites %}
<tr>
<td><a href="{{ invite.link }}">{{ invite.link }}</td>
<td>{{ invite.expiry|naturaltime }}</td>
<td>{{ invite.use_limit }}</td>
<td>{{ invite.times_used }}</td>
</tr>
{% endfor %}
</table>
</section>
<section class="block">
<h2 class="title is-4">Generate New Invite</h2>
@ -47,4 +25,27 @@
<button class="button is-primary" type="submit">Create Invite</button>
</form>
</section>
<section class="block">
<table class="table is-striped">
<tr>
<th>Link</th>
<th>Expires</th>
<th>Max uses</th>
<th>Times used</th>
</tr>
{% if not invites %}
<tr><td colspan="4">No active invites</td></tr>
{% endif %}
{% for invite in invites %}
<tr>
<td><a href="{{ invite.link }}">{{ invite.link }}</td>
<td>{{ invite.expiry|naturaltime }}</td>
<td>{{ invite.use_limit }}</td>
<td>{{ invite.times_used }}</td>
</tr>
{% endfor %}
</table>
{% include 'snippets/pagination.html' with page=invites path=request.path %}
</section>
{% endblock %}

View file

@ -11,7 +11,7 @@
</form>
<form name="unboost" action="/unboost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} active {% if not request.user|boosted:status %}hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
{% csrf_token %}
<button class="button is-small is-success" type="submit">
<button class="button is-small is-primary" type="submit">
<span class="icon icon-boost" title="Un-boost status">
<span class="is-sr-only">Un-boost status</span>
</span>

View file

@ -10,7 +10,7 @@
</form>
<form name="unfavorite" action="/unfavorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} active {% if not request.user|liked:status %}hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
{% csrf_token %}
<button class="button is-success is-small" type="submit">
<button class="button is-primary is-small" type="submit">
<span class="icon icon-heart" title="Un-like status">
<span class="is-sr-only">Un-like status</span>
</span>

View file

@ -5,9 +5,13 @@
Follow request already sent.
</div>
{% elif user in request.user.blocks.all %}
{% include 'snippets/block_button.html' %}
{% else %}
<form action="/follow/" method="POST" class="interaction follow-{{ user.id }} {% if request.user in user.followers.all %}hidden{%endif %}" data-id="follow-{{ user.id }}">
<div class="field has-addons">
<div class="control">
<form action="/follow/" method="POST" class="interaction follow-{{ user.id }} {% if request.user in user.followers.all %}hidden{%endif %}" data-id="follow-{{ user.id }}">
{% csrf_token %}
<input type="hidden" name="user" value="{{ user.username }}">
{% if user.manually_approves_followers %}
@ -15,10 +19,15 @@ Follow request already sent.
{% else %}
<button class="button is-small is-link" type="submit">Follow</button>
{% endif %}
</form>
<form action="/unfollow/" method="POST" class="interaction follow-{{ user.id }} {% if not request.user in user.followers.all %}hidden{%endif %}" data-id="follow-{{ user.id }}">
</form>
<form action="/unfollow/" method="POST" class="interaction follow-{{ user.id }} {% if not request.user in user.followers.all %}hidden{%endif %}" data-id="follow-{{ user.id }}">
{% csrf_token %}
<input type="hidden" name="user" value="{{ user.username }}">
<button class="button is-small is-danger is-light" type="submit">Unfollow</button>
</form>
</form>
</div>
<div class="control">
{% include 'snippets/user_options.html' with user=user class="is-small" %}
</div>
</div>
{% endif %}

View file

@ -9,7 +9,7 @@
<input type="hidden" name="privacy" value="public">
<input type="hidden" name="rating" value="{{ forloop.counter }}">
<div class="field is-grouped stars form-rate-stars mb-1">
<div class="field is-grouped stars form-rate-stars mb-1 has-text-warning-dark">
<label class="is-sr-only" for="rating-no-rating-{{ book.id }}">No rating</label>
<input class="is-sr-only" type="radio" name="rating" value="" id="rating-no-rating-{{ book.id }}" checked>
{% for i in '12345'|make_list %}

View file

@ -1,6 +1,7 @@
{% load humanize %}
{% load bookwyrm_tags %}
{% if books|length > 0 %}
<div class="table-container">
<table class="table is-striped is-fullwidth">
<tr class="book-preview">
@ -74,6 +75,7 @@
</tr>
{% endfor %}
</table>
</div>
{% else %}
<p>This shelf is empty.</p>
{% if shelf.editable %}

View file

@ -1,7 +1,7 @@
<p class="stars">
<span class="is-sr-only">{% if rating %}{{ rating|floatformat }} star{{ rating|floatformat | pluralize }}{% else %}No rating{% endif %}</span>
{% for i in '12345'|make_list %}
<span class="icon icon-star-{% if rating >= forloop.counter %}full{% elif rating|floatformat:0 >= forloop.counter|floatformat:0 %}half{% else %}empty{% endif %}" aria-hidden="true">
<span class="icon is-small mr-1 icon-star-{% if rating >= forloop.counter %}full{% elif rating|floatformat:0 >= forloop.counter|floatformat:0 %}half{% else %}empty{% endif %}" aria-hidden="true">
</span>
{% endfor %}
</p>

View file

@ -3,6 +3,7 @@
<div class="column is-narrow">
<div>
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book %}</a>
{% include 'snippets/stars.html' with rating=book|rating:request.user %}
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
</div>
</div>

View file

@ -4,8 +4,8 @@
{% include 'snippets/avatar.html' with user=status.user %}
{% include 'snippets/username.html' with user=status.user %}
boosted
{% include 'snippets/status_body.html' with status=status|boosted_status %}
{% include 'snippets/status/status_body.html' with status=status|boosted_status %}
{% else %}
{% include 'snippets/status_body.html' with status=status %}
{% include 'snippets/status/status_body.html' with status=status %}
{% endif %}
{% endif %}

View file

@ -5,13 +5,13 @@
{% block card-header %}
<h3 class="card-header-title has-background-white-ter is-block">
{% include 'snippets/status_header.html' with status=status %}
{% include 'snippets/status/status_header.html' with status=status %}
</h3>
{% endblock %}
{% block card-content %}
{% include 'snippets/status_content.html' with status=status %}
{% include 'snippets/status/status_content.html' with status=status %}
{% endblock %}
@ -55,7 +55,7 @@
<a href="{{ status.remote_id }}">{{ status.published_date | post_date }}</a>
</div>
<div class="card-footer-item">
{% include 'snippets/status_options.html' with class="is-small" right=True %}
{% include 'snippets/status/status_options.html' with class="is-small" right=True %}
</div>
{% endblock %}

View file

@ -54,9 +54,9 @@
{% if status.book or status.mention_books.count %}
<div class="{% if status.status_type != 'GeneratedNote' %}box has-background-white-bis{% endif %}">
{% if status.book %}
{% include 'snippets/book_preview.html' with book=status.book %}
{% include 'snippets/status/book_preview.html' with book=status.book %}
{% elif status.mention_books.count %}
{% include 'snippets/book_preview.html' with book=status.mention_books.first %}
{% include 'snippets/status/book_preview.html' with book=status.mention_books.first %}
{% endif %}
</div>
{% endif %}

View file

@ -15,19 +15,15 @@
<div class="block">
<h2 class="title">Followers</h2>
{% for followers in followers %}
<div class="block">
<div class="field is-grouped">
<div class="control">
<div class="block columns">
<div class="column">
{% include 'snippets/avatar.html' with user=followers %}
</div>
<div class="control">
{% include 'snippets/username.html' with user=followers show_full=True %}
</div>
<div class="control">
<div class="column is-narrow">
{% include 'snippets/follow_button.html' with user=followers %}
</div>
</div>
</div>
{% endfor %}
{% if not followers.count %}
<div>{{ user|username }} has no followers</div>

View file

@ -15,19 +15,15 @@
<div class="block">
<h2 class="title">Following</h2>
{% for follower in user.following.all %}
<div class="block">
<div class="field is-grouped">
<div class="control">
<div class="block columns">
<div class="column">
{% include 'snippets/avatar.html' with user=follower %}
</div>
<div class="control">
{% include 'snippets/username.html' with user=follower show_full=True %}
</div>
<div class="control">
<div class="column">
{% include 'snippets/follow_button.html' with user=follower %}
</div>
</div>
</div>
{% endfor %}
{% if not following.count %}
<div>{{ user|username }} isn't following any users</div>

View file

@ -1,7 +1,7 @@
{% extends 'user/user_layout.html' %}
{% block header %}
<div class="columns">
<div class="columns is-mobile">
<div class="column">
<h1 class="title">
{% if is_self %}Your

View file

@ -38,7 +38,7 @@
{% include 'user/create_shelf_form.html' with controls_text='create-shelf-form' %}
</div>
<div class="block columns">
<div class="block columns is-mobile">
<div class="column">
<h2 class="title is-3">
{{ shelf.name }}

View file

@ -1,7 +1,7 @@
{% extends 'user/user_layout.html' %}
{% block header %}
<div class="columns">
<div class="columns is-mobile">
<div class="column">
<h1 class="title">User profile</h1>
</div>
@ -54,7 +54,7 @@
{% endif %}
<div>
<div class="columns">
<div class="columns is-mobile">
<h2 class="title column">User Activity</h2>
<div class="column is-narrow">
<a class="icon icon-rss" target="_blank" href="{{ user.local_path }}/rss">
@ -64,7 +64,7 @@
</div>
{% for activity in activities %}
<div class="block" id="feed">
{% include 'snippets/status.html' with status=activity %}
{% include 'snippets/status/status.html' with status=activity %}
</div>
{% endfor %}
{% if not activities %}

View file

@ -43,14 +43,7 @@
</div>
</div>
{% if not is_self and request.user.is_authenticated %}
<div class="field has-addons">
<div class="control">
{% include 'snippets/follow_button.html' with user=user %}
</div>
<div class="control">
{% include 'snippets/user_options.html' with user=user class="is-small" %}
</div>
</div>
{% endif %}
{% if is_self and user.follower_requests.all %}

View file

@ -1,16 +1,18 @@
''' testing models '''
from unittest.mock import patch
from django.test import TestCase
import responses
from bookwyrm import models
from bookwyrm.settings import DOMAIN
# pylint: disable=missing-class-docstring
# pylint: disable=missing-function-docstring
class User(TestCase):
def setUp(self):
self.user = models.User.objects.create_user(
'mouse@%s' % DOMAIN, 'mouse@mouse.mouse', 'mouseword',
local=True, localname='mouse', name='hi')
local=True, localname='mouse', name='hi', bookwyrm_user=False)
def test_computed_fields(self):
''' username instead of id here '''
@ -28,7 +30,7 @@ class User(TestCase):
with patch('bookwyrm.models.user.set_remote_server.delay'):
user = models.User.objects.create_user(
'rat', 'rat@rat.rat', 'ratword', local=False,
remote_id='https://example.com/dfjkg')
remote_id='https://example.com/dfjkg', bookwyrm_user=False)
self.assertEqual(user.username, 'rat@example.com')
@ -62,7 +64,7 @@ class User(TestCase):
self.assertEqual(activity['name'], self.user.name)
self.assertEqual(activity['inbox'], self.user.inbox)
self.assertEqual(activity['outbox'], self.user.outbox)
self.assertEqual(activity['bookwyrmUser'], True)
self.assertEqual(activity['bookwyrmUser'], False)
self.assertEqual(activity['discoverable'], True)
self.assertEqual(activity['type'], 'Person')
@ -71,3 +73,83 @@ class User(TestCase):
self.assertEqual(activity['type'], 'OrderedCollection')
self.assertEqual(activity['id'], self.user.outbox)
self.assertEqual(activity['totalItems'], 0)
def test_set_remote_server(self):
server = models.FederatedServer.objects.create(
server_name=DOMAIN,
application_type='test type',
application_version=3
)
models.user.set_remote_server(self.user.id)
self.user.refresh_from_db()
self.assertEqual(self.user.federated_server, server)
@responses.activate
def test_get_or_create_remote_server(self):
responses.add(
responses.GET,
'https://%s/.well-known/nodeinfo' % DOMAIN,
json={'links': [{'href': 'http://www.example.com'}, {}]}
)
responses.add(
responses.GET,
'http://www.example.com',
json={'software': {'name': 'hi', 'version': '2'}},
)
server = models.user.get_or_create_remote_server(DOMAIN)
self.assertEqual(server.server_name, DOMAIN)
self.assertEqual(server.application_type, 'hi')
self.assertEqual(server.application_version, '2')
@responses.activate
def test_get_or_create_remote_server_no_wellknown(self):
responses.add(
responses.GET,
'https://%s/.well-known/nodeinfo' % DOMAIN,
status=404
)
server = models.user.get_or_create_remote_server(DOMAIN)
self.assertEqual(server.server_name, DOMAIN)
self.assertIsNone(server.application_type)
self.assertIsNone(server.application_version)
@responses.activate
def test_get_or_create_remote_server_no_links(self):
responses.add(
responses.GET,
'https://%s/.well-known/nodeinfo' % DOMAIN,
json={'links': [{'href': 'http://www.example.com'}, {}]}
)
responses.add(
responses.GET,
'http://www.example.com',
status=404
)
server = models.user.get_or_create_remote_server(DOMAIN)
self.assertEqual(server.server_name, DOMAIN)
self.assertIsNone(server.application_type)
self.assertIsNone(server.application_version)
@responses.activate
def test_get_or_create_remote_server_unknown_format(self):
responses.add(
responses.GET,
'https://%s/.well-known/nodeinfo' % DOMAIN,
json={'links': [{'href': 'http://www.example.com'}, {}]}
)
responses.add(
responses.GET,
'http://www.example.com',
json={'fish': 'salmon'}
)
server = models.user.get_or_create_remote_server(DOMAIN)
self.assertEqual(server.server_name, DOMAIN)
self.assertIsNone(server.application_type)
self.assertIsNone(server.application_version)

View file

@ -1,5 +1,6 @@
''' test for app action functionality '''
from unittest.mock import patch
from django.utils import timezone
from django.contrib.auth.models import AnonymousUser
from django.template.response import TemplateResponse
@ -30,6 +31,7 @@ class GoalViews(TestCase):
)
self.anonymous_user = AnonymousUser
self.anonymous_user.is_authenticated = False
models.SiteSettings.objects.create()
def test_goal_page_no_goal(self):
@ -48,6 +50,7 @@ class GoalViews(TestCase):
request.user = self.local_user
result = view(request, self.local_user.localname, 2020)
result.render()
self.assertIsInstance(result, TemplateResponse)
@ -62,16 +65,23 @@ class GoalViews(TestCase):
def test_goal_page_public(self):
''' view a user's public goal '''
models.ReadThrough.objects.create(
finish_date=timezone.now(),
user=self.local_user,
book=self.book,
)
models.AnnualGoal.objects.create(
user=self.local_user,
year=2020,
year=timezone.now().year,
goal=128937123,
privacy='public')
view = views.Goal.as_view()
request = self.factory.get('')
request.user = self.rat
result = view(request, self.local_user.localname, 2020)
result = view(request, self.local_user.localname, timezone.now().year)
result.render()
self.assertIsInstance(result, TemplateResponse)
def test_goal_page_private(self):

View file

@ -56,12 +56,14 @@ class ViewsHelpers(TestCase):
def test_get_user_from_username(self):
''' works for either localname or username '''
self.assertEqual(
views.helpers.get_user_from_username('mouse'), self.local_user)
views.helpers.get_user_from_username(
self.local_user, 'mouse'), self.local_user)
self.assertEqual(
views.helpers.get_user_from_username(
'mouse@local.com'), self.local_user)
self.local_user, 'mouse@local.com'), self.local_user)
with self.assertRaises(models.User.DoesNotExist):
views.helpers.get_user_from_username('mojfse@example.com')
views.helpers.get_user_from_username(
self.local_user, 'mojfse@example.com')
def test_is_api_request(self):
@ -188,18 +190,18 @@ class ViewsHelpers(TestCase):
def test_is_bookwyrm_request(self):
''' checks if a request came from a bookwyrm instance '''
request = self.factory.get('', {'q': 'Test Book'})
self.assertFalse(views.helpers.is_bookworm_request(request))
self.assertFalse(views.helpers.is_bookwyrm_request(request))
request = self.factory.get(
'', {'q': 'Test Book'},
HTTP_USER_AGENT=\
"http.rb/4.4.1 (Mastodon/3.3.0; +https://mastodon.social/)"
)
self.assertFalse(views.helpers.is_bookworm_request(request))
self.assertFalse(views.helpers.is_bookwyrm_request(request))
request = self.factory.get(
'', {'q': 'Test Book'}, HTTP_USER_AGENT=USER_AGENT)
self.assertTrue(views.helpers.is_bookworm_request(request))
self.assertTrue(views.helpers.is_bookwyrm_request(request))
def test_existing_user(self):

View file

@ -7,6 +7,7 @@ from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models, views
from bookwyrm.settings import USER_AGENT
# pylint: disable=too-many-public-methods
@ -90,3 +91,39 @@ class OutboxView(TestCase):
data = json.loads(result.content)
self.assertEqual(data['type'], 'OrderedCollection')
self.assertEqual(data['totalItems'], 1)
def test_outbox_bookwyrm_request_true(self):
''' should differentiate between bookwyrm and outside requests '''
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.Review.objects.create(
name='hi',
content='look at this',
user=self.local_user,
book=self.book,
privacy='public',
)
request = self.factory.get('', {'page': 1}, HTTP_USER_AGENT=USER_AGENT)
result = views.Outbox.as_view()(request, 'mouse')
data = json.loads(result.content)
self.assertEqual(len(data['orderedItems']), 1)
self.assertEqual(data['orderedItems'][0]['type'], 'Review')
def test_outbox_bookwyrm_request_false(self):
''' should differentiate between bookwyrm and outside requests '''
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.Review.objects.create(
name='hi',
content='look at this',
user=self.local_user,
book=self.book,
privacy='public',
)
request = self.factory.get('', {'page': 1})
result = views.Outbox.as_view()(request, 'mouse')
data = json.loads(result.content)
self.assertEqual(len(data['orderedItems']), 1)
self.assertEqual(data['orderedItems'][0]['type'], 'Article')

View file

@ -41,6 +41,7 @@ class RssFeedView(TestCase):
''' load an rss feed '''
view = rss_feed.RssFeed()
request = self.factory.get('/user/rss_user/rss')
request.user = self.user
with patch("bookwyrm.models.SiteSettings.objects.get") as site:
site.return_value = self.site
result = view(request, username=self.user.username)

View file

@ -13,7 +13,7 @@ from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH
from .helpers import get_activity_feed
from .helpers import get_user_from_username
from .helpers import is_api_request, is_bookworm_request, object_visible_to_user
from .helpers import is_api_request, is_bookwyrm_request, object_visible_to_user
# pylint: disable= no-self-use
@ -65,7 +65,7 @@ class DirectMessage(View):
user = None
if username:
try:
user = get_user_from_username(username)
user = get_user_from_username(request.user, username)
except models.User.DoesNotExist:
pass
if user:
@ -91,7 +91,7 @@ class Status(View):
def get(self, request, username, status_id):
''' display a particular status (and replies, etc) '''
try:
user = get_user_from_username(username)
user = get_user_from_username(request.user, username)
status = models.Status.objects.select_subclasses().get(
id=status_id, deleted=False)
except ValueError:
@ -107,7 +107,7 @@ class Status(View):
if is_api_request(request):
return ActivitypubResponse(
status.to_activity(pure=not is_bookworm_request(request)))
status.to_activity(pure=not is_bookwyrm_request(request)))
data = {**feed_page_data(request.user), **{
'title': 'Status by %s' % user.username,

View file

@ -14,7 +14,7 @@ def follow(request):
''' follow another user, here or abroad '''
username = request.POST['user']
try:
to_follow = get_user_from_username(username)
to_follow = get_user_from_username(request.user, username)
except models.User.DoesNotExist:
return HttpResponseBadRequest()
@ -35,7 +35,7 @@ def unfollow(request):
''' unfollow a user '''
username = request.POST['user']
try:
to_unfollow = get_user_from_username(username)
to_unfollow = get_user_from_username(request.user, username)
except models.User.DoesNotExist:
return HttpResponseBadRequest()
@ -52,7 +52,7 @@ def accept_follow_request(request):
''' a user accepts a follow request '''
username = request.POST['user']
try:
requester = get_user_from_username(username)
requester = get_user_from_username(request.user, username)
except models.User.DoesNotExist:
return HttpResponseBadRequest()
@ -75,7 +75,7 @@ def delete_follow_request(request):
''' a user rejects a follow request '''
username = request.POST['user']
try:
requester = get_user_from_username(username)
requester = get_user_from_username(request.user, username)
except models.User.DoesNotExist:
return HttpResponseBadRequest()

View file

@ -18,7 +18,7 @@ class Goal(View):
''' track books for the year '''
def get(self, request, username, year):
''' reading goal page '''
user = get_user_from_username(username)
user = get_user_from_username(request.user, username)
year = int(year)
goal = models.AnnualGoal.objects.filter(
year=year, user=user
@ -42,7 +42,7 @@ class Goal(View):
def post(self, request, username, year):
''' update or create an annual goal '''
user = get_user_from_username(username)
user = get_user_from_username(request.user, username)
if user != request.user:
return HttpResponseNotFound()

View file

@ -9,13 +9,13 @@ from bookwyrm.status import create_generated_note
from bookwyrm.utils import regex
def get_user_from_username(username):
def get_user_from_username(viewer, username):
''' helper function to resolve a localname or a username to a user '''
# raises DoesNotExist if user is now found
try:
return models.User.objects.get(localname=username)
return models.User.viewer_aware_objects(viewer).get(localname=username)
except models.User.DoesNotExist:
return models.User.objects.get(username=username)
return models.User.viewer_aware_objects(viewer).get(username=username)
def is_api_request(request):
@ -24,8 +24,8 @@ def is_api_request(request):
request.path[-5:] == '.json'
def is_bookworm_request(request):
''' check if the request is coming from another bookworm instance '''
def is_bookwyrm_request(request):
''' check if the request is coming from another bookwyrm instance '''
user_agent = request.headers.get('User-Agent')
if user_agent is None or \
re.search(regex.bookwyrm_user_agent, user_agent) is None:

View file

@ -1,5 +1,6 @@
''' invites when registration is closed '''
from django.contrib.auth.decorators import login_required, permission_required
from django.core.paginator import Paginator
from django.http import HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
@ -7,6 +8,7 @@ from django.utils.decorators import method_decorator
from django.views import View
from bookwyrm import forms, models
from bookwyrm.settings import PAGE_LENGTH
# pylint: disable= no-self-use
@ -18,10 +20,18 @@ class ManageInvites(View):
''' create invites '''
def get(self, request):
''' invite management page '''
try:
page = int(request.GET.get('page', 1))
except ValueError:
page = 1
paginated = Paginator(models.SiteInvite.objects.filter(
user=request.user
).order_by('-created_date'), PAGE_LENGTH)
data = {
'title': 'Invitations',
'invites': models.SiteInvite.objects.filter(
user=request.user).order_by('-created_date'),
'invites': paginated.page(page),
'form': forms.CreateInviteForm(),
}
return TemplateResponse(request, 'settings/manage_invites.html', data)
@ -36,7 +46,15 @@ class ManageInvites(View):
invite.user = request.user
invite.save()
return redirect('/settings/invites')
paginated = Paginator(models.SiteInvite.objects.filter(
user=request.user
).order_by('-created_date'), PAGE_LENGTH)
data = {
'title': 'Invitations',
'invites': paginated.page(1),
'form': form
}
return TemplateResponse(request, 'settings/manage_invites.html', data)
class Invite(View):

View file

@ -16,7 +16,7 @@ class About(View):
data = {
'title': 'About',
}
return TemplateResponse(request, 'about.html', data)
return TemplateResponse(request, 'discover/about.html', data)
class Home(View):
''' discover page or home feed depending on auth '''
@ -56,4 +56,4 @@ class Discover(View):
'books': list(set(books)),
'ratings': ratings
}
return TemplateResponse(request, 'discover.html', data)
return TemplateResponse(request, 'discover/discover.html', data)

View file

@ -65,7 +65,7 @@ class UserLists(View):
page = int(request.GET.get('page', 1))
except ValueError:
page = 1
user = get_user_from_username(username)
user = get_user_from_username(request.user, username)
lists = models.List.objects.filter(user=user).all()
lists = privacy_filter(
request.user, lists, ['public', 'followers', 'unlisted'])

View file

@ -4,6 +4,7 @@ from django.shortcuts import get_object_or_404
from django.views import View
from bookwyrm import activitypub, models
from .helpers import is_bookwyrm_request
# pylint: disable= no-self-use
@ -17,6 +18,10 @@ class Outbox(View):
filter_type = None
return JsonResponse(
user.to_outbox(**request.GET, filter_type=filter_type),
user.to_outbox(
**request.GET,
filter_type=filter_type,
pure=not is_bookwyrm_request(request)
),
encoder=activitypub.ActivityEncoder
)

View file

@ -11,7 +11,7 @@ class RssFeed(Feed):
def get_object(self, request, username):
''' the user who's posts get serialized '''
return get_user_from_username(username)
return get_user_from_username(request.user, username)
def link(self, obj):

View file

@ -33,7 +33,7 @@ class Search(View):
handle_remote_webfinger(query)
# do a user search
user_results = models.User.objects.annotate(
user_results = models.User.viewer_aware_objects(request.user).annotate(
similarity=Greatest(
TrigramSimilarity('username', query),
TrigramSimilarity('localname', query),

View file

@ -19,7 +19,7 @@ class Shelf(View):
def get(self, request, username, shelf_identifier):
''' display a shelf '''
try:
user = get_user_from_username(username)
user = get_user_from_username(request.user, username)
except models.User.DoesNotExist:
return HttpResponseNotFound()

View file

@ -26,7 +26,7 @@ class User(View):
def get(self, request, username):
''' profile page for a user '''
try:
user = get_user_from_username(username)
user = get_user_from_username(request.user, username)
except models.User.DoesNotExist:
return HttpResponseNotFound()
@ -96,7 +96,7 @@ class Followers(View):
def get(self, request, username):
''' list of followers '''
try:
user = get_user_from_username(username)
user = get_user_from_username(request.user, username)
except models.User.DoesNotExist:
return HttpResponseNotFound()
@ -121,7 +121,7 @@ class Following(View):
def get(self, request, username):
''' list of followers '''
try:
user = get_user_from_username(username)
user = get_user_from_username(request.user, username)
except models.User.DoesNotExist:
return HttpResponseNotFound()