Follow/unfollow behavior

Fixes #23
This commit is contained in:
Mouse Reeve 2020-02-18 22:44:13 -08:00
parent 14d300162d
commit 95bfb61cf3
10 changed files with 138 additions and 102 deletions

View file

@ -3,5 +3,5 @@ from .actor import get_actor
from .collection import get_outbox, get_outbox_page, get_add, get_remove, \ from .collection import get_outbox, get_outbox_page, get_add, get_remove, \
get_following, get_followers get_following, get_followers
from .create import get_create from .create import get_create
from .follow import get_follow_request, get_accept from .follow import get_follow_request, get_unfollow, get_accept
from .status import get_review, get_review_article, get_status, get_replies from .status import get_review, get_review_article, get_status, get_replies

View file

@ -16,6 +16,21 @@ def get_follow_request(user, to_follow):
'object': to_follow.actor, 'object': to_follow.actor,
} }
def get_unfollow(relationship):
''' undo that precious bond of friendship '''
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': '%s/undo' % relationship.absolute_id,
'type': 'Undo',
'actor': relationship.user_subject.actor,
'object': {
'id': relationship.relationship_id,
'type': 'Follow',
'actor': relationship.user_subject.actor,
'object': relationship.user_object.actor,
}
}
def get_accept(user, request_activity): def get_accept(user, request_activity):
''' accept a follow request ''' ''' accept a follow request '''

View file

@ -37,6 +37,9 @@ def shared_inbox(request):
if activity['type'] == 'Follow': if activity['type'] == 'Follow':
response = handle_incoming_follow(activity) response = handle_incoming_follow(activity)
elif activity['type'] == 'Undo':
response = handle_incoming_undo(activity)
elif activity['type'] == 'Create': elif activity['type'] == 'Create':
response = handle_incoming_create(activity) response = handle_incoming_create(activity)
@ -183,10 +186,32 @@ def handle_incoming_follow(activity):
# figure out who they are # figure out who they are
user = get_or_create_remote_user(activity['actor']) user = get_or_create_remote_user(activity['actor'])
# TODO: allow users to manually approve requests # TODO: allow users to manually approve requests
models.UserRelationship.objects.create(
user_subject=to_follow,
user_object=user,
status='follow_request',
relationship_id=activity['id']
)
outgoing.handle_outgoing_accept(user, to_follow, activity) outgoing.handle_outgoing_accept(user, to_follow, activity)
return HttpResponse() return HttpResponse()
def handle_incoming_undo(activity):
''' unfollow a local user '''
obj = activity['object']
if not obj['type'] == 'Follow':
#idk how to undo other things
return HttpResponseNotFound()
try:
requester = get_or_create_remote_user(obj['actor'])
to_unfollow = models.User.objects.get(actor=obj['object'])
except models.User.DoesNotExist:
return HttpResponseNotFound()
to_unfollow.followers.remove(requester)
return HttpResponse()
def handle_incoming_follow_accept(activity): def handle_incoming_follow_accept(activity):
''' hurray, someone remote accepted a follow request ''' ''' hurray, someone remote accepted a follow request '''
# figure out who they want to follow # figure out who they want to follow

View file

@ -1,4 +1,4 @@
# Generated by Django 3.0.3 on 2020-02-17 02:39 # Generated by Django 3.0.3 on 2020-02-19 06:43
from django.conf import settings from django.conf import settings
import django.contrib.auth.models import django.contrib.auth.models
@ -41,11 +41,10 @@ class Migration(migrations.Migration):
('outbox', models.CharField(max_length=255, unique=True)), ('outbox', models.CharField(max_length=255, unique=True)),
('summary', models.TextField(blank=True, null=True)), ('summary', models.TextField(blank=True, null=True)),
('local', models.BooleanField(default=True)), ('local', models.BooleanField(default=True)),
('fedireads_user', models.BooleanField(default=True)),
('localname', models.CharField(max_length=255, null=True, unique=True)), ('localname', models.CharField(max_length=255, null=True, unique=True)),
('name', models.CharField(blank=True, max_length=100, null=True)), ('name', models.CharField(blank=True, max_length=100, null=True)),
('avatar', models.ImageField(blank=True, null=True, upload_to='avatars/')), ('avatar', models.ImageField(blank=True, null=True, upload_to='avatars/')),
('created_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
], ],
options={ options={
'verbose_name': 'user', 'verbose_name': 'user',
@ -60,66 +59,96 @@ class Migration(migrations.Migration):
name='Author', name='Author',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(blank=True, null=True)),
('created_date', models.DateTimeField(auto_now_add=True)),
('openlibrary_key', models.CharField(max_length=255)), ('openlibrary_key', models.CharField(max_length=255)),
('data', fedireads.utils.fields.JSONField()), ('data', fedireads.utils.fields.JSONField()),
('added_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
], ],
options={
'abstract': False,
},
), ),
migrations.CreateModel( migrations.CreateModel(
name='Book', name='Book',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(blank=True, null=True)),
('created_date', models.DateTimeField(auto_now_add=True)),
('openlibrary_key', models.CharField(max_length=255, unique=True)), ('openlibrary_key', models.CharField(max_length=255, unique=True)),
('data', fedireads.utils.fields.JSONField()), ('data', fedireads.utils.fields.JSONField()),
('cover', models.ImageField(blank=True, null=True, upload_to='covers/')), ('cover', models.ImageField(blank=True, null=True, upload_to='covers/')),
('added_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), ('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('authors', models.ManyToManyField(to='fedireads.Author')), ('authors', models.ManyToManyField(to='fedireads.Author')),
], ],
options={
'abstract': False,
},
), ),
migrations.CreateModel( migrations.CreateModel(
name='FederatedServer', name='FederatedServer',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(blank=True, null=True)),
('created_date', models.DateTimeField(auto_now_add=True)),
('server_name', models.CharField(max_length=255, unique=True)), ('server_name', models.CharField(max_length=255, unique=True)),
('status', models.CharField(default='federated', max_length=255)), ('status', models.CharField(default='federated', max_length=255)),
('application_type', models.CharField(max_length=255, null=True)), ('application_type', models.CharField(max_length=255, null=True)),
], ],
options={
'abstract': False,
},
), ),
migrations.CreateModel( migrations.CreateModel(
name='Shelf', name='Shelf',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(blank=True, null=True)),
('created_date', models.DateTimeField(auto_now_add=True)),
('name', models.CharField(max_length=100)), ('name', models.CharField(max_length=100)),
('identifier', models.CharField(max_length=100)), ('identifier', models.CharField(max_length=100)),
('editable', models.BooleanField(default=True)), ('editable', models.BooleanField(default=True)),
('created_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='Status', name='Status',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(blank=True, null=True)),
('created_date', models.DateTimeField(auto_now_add=True)),
('status_type', models.CharField(default='Note', max_length=255)), ('status_type', models.CharField(default='Note', max_length=255)),
('activity_type', models.CharField(default='Note', max_length=255)), ('activity_type', models.CharField(default='Note', max_length=255)),
('local', models.BooleanField(default=True)), ('local', models.BooleanField(default=True)),
('privacy', models.CharField(default='public', max_length=255)), ('privacy', models.CharField(default='public', max_length=255)),
('sensitive', models.BooleanField(default=False)), ('sensitive', models.BooleanField(default=False)),
('content', models.TextField(blank=True, null=True)),
('created_date', models.DateTimeField(auto_now_add=True)),
('mention_books', models.ManyToManyField(related_name='mention_book', to='fedireads.Book')), ('mention_books', models.ManyToManyField(related_name='mention_book', to='fedireads.Book')),
('mention_users', models.ManyToManyField(related_name='mention_user', to=settings.AUTH_USER_MODEL)), ('mention_users', models.ManyToManyField(related_name='mention_user', to=settings.AUTH_USER_MODEL)),
('reply_parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='fedireads.Status')), ('reply_parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='fedireads.Status')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
], ],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='UserRelationship',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(blank=True, null=True)),
('created_date', models.DateTimeField(auto_now_add=True)),
('status', models.CharField(default='follows', max_length=100, null=True)),
('relationship_id', models.CharField(max_length=100)),
('user_object', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='user_object', to=settings.AUTH_USER_MODEL)),
('user_subject', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='user_subject', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
), ),
migrations.CreateModel( migrations.CreateModel(
name='ShelfBook', name='ShelfBook',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(blank=True, null=True)),
('created_date', models.DateTimeField(auto_now_add=True)), ('created_date', models.DateTimeField(auto_now_add=True)),
('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), ('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Book')), ('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Book')),
@ -152,7 +181,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='user', model_name='user',
name='followers', name='followers',
field=models.ManyToManyField(to=settings.AUTH_USER_MODEL), field=models.ManyToManyField(through='fedireads.UserRelationship', to=settings.AUTH_USER_MODEL),
), ),
migrations.AddField( migrations.AddField(
model_name='user', model_name='user',
@ -176,6 +205,9 @@ class Migration(migrations.Migration):
('rating', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(5)])), ('rating', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(5)])),
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Book')), ('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Book')),
], ],
options={
'abstract': False,
},
bases=('fedireads.status',), bases=('fedireads.status',),
), ),
] ]

View file

@ -1,80 +0,0 @@
# Generated by Django 3.0.3 on 2020-02-19 01:18
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('fedireads', '0001_initial'),
]
operations = [
migrations.RenameField(
model_name='author',
old_name='added_date',
new_name='created_date',
),
migrations.RenameField(
model_name='book',
old_name='added_date',
new_name='created_date',
),
migrations.RemoveField(
model_name='author',
name='updated_date',
),
migrations.RemoveField(
model_name='book',
name='updated_date',
),
migrations.RemoveField(
model_name='shelf',
name='updated_date',
),
migrations.RemoveField(
model_name='user',
name='created_date',
),
migrations.RemoveField(
model_name='user',
name='updated_date',
),
migrations.AddField(
model_name='author',
name='content',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='book',
name='content',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='federatedserver',
name='content',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='federatedserver',
name='created_date',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='shelf',
name='content',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='shelfbook',
name='content',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='user',
name='fedireads_user',
field=models.BooleanField(default=True),
),
]

View file

@ -1,5 +1,5 @@
''' bring all the models into the app namespace ''' ''' bring all the models into the app namespace '''
from .book import Shelf, ShelfBook, Book, Author from .book import Shelf, ShelfBook, Book, Author
from .user import User, FederatedServer from .user import User, UserRelationship, FederatedServer
from .activity import Status, Review from .activity import Status, Review

View file

@ -34,7 +34,12 @@ class User(AbstractUser):
# name is your display name, which you can change at will # name is your display name, which you can change at will
name = models.CharField(max_length=100, blank=True, null=True) name = models.CharField(max_length=100, blank=True, null=True)
avatar = models.ImageField(upload_to='avatars/', blank=True, null=True) avatar = models.ImageField(upload_to='avatars/', blank=True, null=True)
followers = models.ManyToManyField('self', symmetrical=False) followers = models.ManyToManyField(
'self',
symmetrical=False,
through='UserRelationship',
through_fields=('user_subject', 'user_object')
)
@property @property
def absolute_id(self): def absolute_id(self):
@ -43,6 +48,29 @@ class User(AbstractUser):
return 'https://%s/%s/%s' % (DOMAIN, model_name, self.localname) return 'https://%s/%s/%s' % (DOMAIN, model_name, self.localname)
class UserRelationship(FedireadsModel):
''' many-to-many through table for followers '''
user_subject = models.ForeignKey(
'User',
on_delete=models.PROTECT,
related_name='user_subject'
)
user_object = models.ForeignKey(
'User',
on_delete=models.PROTECT,
related_name='user_object'
)
# follow or follow_request for pending TODO: blocking?
status = models.CharField(max_length=100, default='follows', null=True)
relationship_id = models.CharField(max_length=100)
@property
def absolute_id(self):
''' use shelf identifier as absolute id '''
base_path = self.user_subject.absolute_id
return '%s#%s/%d' % (base_path, self.status, self.id)
class FederatedServer(FedireadsModel): class FederatedServer(FedireadsModel):
''' store which server's we federate with ''' ''' store which server's we federate with '''
server_name = models.CharField(max_length=255, unique=True) server_name = models.CharField(max_length=255, unique=True)

View file

@ -77,9 +77,26 @@ def handle_outgoing_follow(user, to_follow):
raise(error['error']) raise(error['error'])
def handle_outgoing_unfollow(user, to_unfollow):
''' someone local wants to follow someone '''
relationship = models.UserRelationship.objects.get(
user_object=user,
user_subject=to_unfollow
)
activity = activitypub.get_unfollow(relationship)
errors = broadcast(user, activity, [to_unfollow.inbox])
to_unfollow.followers.remove(user)
for error in errors:
raise(error['error'])
def handle_outgoing_accept(user, to_follow, request_activity): def handle_outgoing_accept(user, to_follow, request_activity):
''' send an acceptance message to a follow request ''' ''' send an acceptance message to a follow request '''
to_follow.followers.add(user) relationship = models.UserRelationship.objects.get(
relationship_id=request_activity['id']
)
relationship.status = 'follow'
relationship.save()
activity = activitypub.get_accept(to_follow, request_activity) activity = activitypub.get_accept(to_follow, request_activity)
recipient = get_recipients(to_follow, 'direct', direct_recipients=[user]) recipient = get_recipients(to_follow, 'direct', direct_recipients=[user])
broadcast(to_follow, activity, recipient) broadcast(to_follow, activity, recipient)

View file

@ -28,7 +28,7 @@
{% if user.is_authenticated %} {% if user.is_authenticated %}
<form name="logout" action="/logout/" method="post"> <form name="logout" action="/logout/" method="post">
{% csrf_token %} {% csrf_token %}
Welcome, {% include 'snippets/username.html' %} Welcome, {% include 'snippets/username.html' with user=request.user %}
<input type="submit" value="Log out"></input> <input type="submit" value="Log out"></input>
</form> </form>
{% else %} {% else %}

View file

@ -278,11 +278,10 @@ def follow(request):
@login_required @login_required
def unfollow(request): def unfollow(request):
''' unfollow a user ''' ''' unfollow a user '''
# TODO: this is not an implementation!! user = request.user
followed = request.POST.get('user') to_unfollow = models.User.objects.get(id=request.POST.get('user'))
followed = models.User.objects.get(id=followed) outgoing.handle_outgoing_unfollow(user, to_unfollow)
followed.followers.remove(request.user) return redirect('/user/%s' % to_unfollow.username)
return redirect('/user/%s' % followed.username)
@login_required @login_required