Add initial follow import/export

Fixes #437
This commit is contained in:
Andrew Godwin 2023-02-13 20:50:43 -07:00
parent 1c5ef675f0
commit 9a0008db06
11 changed files with 332 additions and 54 deletions

View file

@ -3,6 +3,7 @@ from typing import Any
from django.core.files import File
from django.http import HttpRequest
from django.shortcuts import get_object_or_404
from hatchway import ApiResponse, QueryOrBody, api_view
from activities.models import Post
from activities.services import SearchService
@ -10,7 +11,6 @@ from api import schemas
from api.decorators import identity_required
from api.pagination import MastodonPaginator, PaginatingApiResponse, PaginationResult
from core.models import Config
from hatchway import ApiResponse, QueryOrBody, api_view
from users.models import Identity
from users.services import IdentityService
from users.shortcuts import by_handle_or_404
@ -224,8 +224,8 @@ def account_follow(request, id: str, reblogs: bool = True) -> schemas.Relationsh
identity = get_object_or_404(
Identity.objects.exclude(restriction=Identity.Restriction.blocked), pk=id
)
service = IdentityService(identity)
service.follow_from(request.identity, boosts=reblogs)
service = IdentityService(request.identity)
service.follow(identity, boosts=reblogs)
return schemas.Relationship.from_identity_pair(identity, request.identity)
@ -235,8 +235,8 @@ def account_unfollow(request, id: str) -> schemas.Relationship:
identity = get_object_or_404(
Identity.objects.exclude(restriction=Identity.Restriction.blocked), pk=id
)
service = IdentityService(identity)
service.unfollow_from(request.identity)
service = IdentityService(request.identity)
service.unfollow(identity)
return schemas.Relationship.from_identity_pair(identity, request.identity)
@ -244,8 +244,8 @@ def account_unfollow(request, id: str) -> schemas.Relationship:
@identity_required
def account_block(request, id: str) -> schemas.Relationship:
identity = get_object_or_404(Identity, pk=id)
service = IdentityService(identity)
service.block_from(request.identity)
service = IdentityService(request.identity)
service.block(identity)
return schemas.Relationship.from_identity_pair(identity, request.identity)
@ -253,8 +253,8 @@ def account_block(request, id: str) -> schemas.Relationship:
@identity_required
def account_unblock(request, id: str) -> schemas.Relationship:
identity = get_object_or_404(Identity, pk=id)
service = IdentityService(identity)
service.unblock_from(request.identity)
service = IdentityService(request.identity)
service.unblock(identity)
return schemas.Relationship.from_identity_pair(identity, request.identity)
@ -267,9 +267,9 @@ def account_mute(
duration: QueryOrBody[int] = 0,
) -> schemas.Relationship:
identity = get_object_or_404(Identity, pk=id)
service = IdentityService(identity)
service.mute_from(
request.identity,
service = IdentityService(request.identity)
service.mute(
identity,
duration=duration,
include_notifications=notifications,
)
@ -280,8 +280,8 @@ def account_mute(
@api_view.post
def account_unmute(request, id: str) -> schemas.Relationship:
identity = get_object_or_404(Identity, pk=id)
service = IdentityService(identity)
service.unmute_from(request.identity)
service = IdentityService(request.identity)
service.unmute(identity)
return schemas.Relationship.from_identity_pair(identity, request.identity)

View file

@ -55,6 +55,21 @@ urlpatterns = [
settings.InterfacePage.as_view(),
name="settings_interface",
),
path(
"settings/import_export/",
settings.ImportExportPage.as_view(),
name="settings_import_export",
),
path(
"settings/import_export/following.csv",
settings.CsvFollowing.as_view(),
name="settings_export_following_csv",
),
path(
"settings/import_export/followers.csv",
settings.CsvFollowers.as_view(),
name="settings_export_followers_csv",
),
path(
"admin/",
admin.AdminRoot.as_view(),

View file

@ -6,6 +6,9 @@
<a href="{% url "settings_interface" %}" {% if section == "interface" %}class="selected"{% endif %} title="Interface">
<i class="fa-solid fa-display"></i> Interface
</a>
<a href="{% url "settings_import_export" %}" {% if section == "importexport" %}class="selected"{% endif %} title="Interface">
<i class="fa-solid fa-cloud-arrow-up"></i> Import/Export
</a>
<h3>Account</h3>
<a href="{% url "settings_security" %}" {% if section == "security" %}class="selected"{% endif %} title="Login &amp; Security">
<i class="fa-solid fa-key"></i> Login &amp; Security

View file

@ -0,0 +1,68 @@
{% extends "settings/base.html" %}
{% block subtitle %}Import/Export{% endblock %}
{% block content %}
<form action="." method="POST" enctype="multipart/form-data">
{% csrf_token %}
<fieldset>
<legend>Import</legend>
{% if bad_format %}
<div class="announcement">Error: The file you uploaded was not a valid {{ bad_format }} CSV.</div>
{% endif %}
{% if success %}
<div class="announcement">Your <i>{{ success }}</i> CSV import was received. It will be processed in the background.</div>
{% endif %}
{% include "forms/_field.html" with field=form.csv %}
{% include "forms/_field.html" with field=form.import_type %}
{% include "forms/_field.html" with field=form.replace %}
</fieldset>
<div class="buttons">
<button>Import</button>
</div>
<fieldset>
<legend>Export</legend>
<table class="items">
<tr>
<td>
Following list
<small>{{ numbers.outbound_follows }} {{ numbers.outbound_follows|pluralize:"follow,follows" }}</small>
</td>
<td>
<a href="{% url "settings_export_following_csv" %}">Download CSV</a>
</td>
</tr>
<tr>
<td>
Followers list
<small>{{ numbers.inbound_follows }} {{ numbers.inbound_follows|pluralize:"follower,followers" }}</small>
</td>
<td>
<a href="{% url "settings_export_followers_csv" %}">Download CSV</a>
</td>
</tr>
<tr>
<td>
Individual blocks
<small>{{ numbers.blocks }} {{ numbers.blocks|pluralize:"people,people" }}</small>
</td>
<td>
</td>
</tr>
<tr>
<td>
Individual mutes
<small>{{ numbers.mutes }} {{ numbers.mutes|pluralize:"people,people" }}</small>
</td>
<td>
</td>
</tr>
</table>
</fieldset>
</form>
{% endblock %}

View file

@ -207,8 +207,8 @@ def test_clear_timeline(
Ensures that timeline clearing works as expected.
"""
# Follow the remote user
service = IdentityService(remote_identity)
service.follow_from(identity)
service = IdentityService(identity)
service.follow(remote_identity)
# Create an inbound new post message mentioning us
message = {
"id": "test",
@ -243,9 +243,9 @@ def test_clear_timeline(
# Now, submit either a user block (for full clear) or unfollow (for post clear)
if full:
service.block_from(identity)
service.block(remote_identity)
else:
service.unfollow_from(identity)
service.unfollow(remote_identity)
# Run stator once to process the timeline clear message
stator.run_single_cycle_sync()

View file

@ -20,7 +20,7 @@ def test_follow(
Ensures that follow sending and acceptance works
"""
# Make the follow
follow = IdentityService(remote_identity).follow_from(identity)
follow = IdentityService(identity).follow(remote_identity)
assert Follow.objects.get(pk=follow.pk).state == FollowStates.unrequested
# Run stator to make it try and send out the remote request
httpx_mock.add_response(

View file

@ -16,6 +16,7 @@ class InboxMessageStates(StateGraph):
async def handle_received(cls, instance: "InboxMessage"):
from activities.models import Post, PostInteraction, TimelineEvent
from users.models import Block, Follow, Identity, Report
from users.services import IdentityService
match instance.message_type:
case "follow":
@ -154,6 +155,10 @@ class InboxMessageStates(StateGraph):
await sync_to_async(TimelineEvent.handle_clear_timeline)(
instance.message["object"]
)
case "addfollow":
await sync_to_async(IdentityService.handle_internal_add_follow)(
instance.message["object"]
)
case unknown:
raise ValueError(
f"Cannot handle activity of type __internal__.{unknown}"

View file

@ -58,8 +58,10 @@ class IdentityService:
def following(self) -> models.QuerySet[Identity]:
return (
Identity.objects.active()
.filter(inbound_follows__source=self.identity)
Identity.objects.filter(
inbound_follows__source=self.identity,
inbound_follows__state__in=FollowStates.group_active(),
)
.not_deleted()
.order_by("username")
.select_related("domain")
@ -67,91 +69,94 @@ class IdentityService:
def followers(self) -> models.QuerySet[Identity]:
return (
Identity.objects.filter(outbound_follows__target=self.identity)
Identity.objects.filter(
outbound_follows__target=self.identity,
inbound_follows__state__in=FollowStates.group_active(),
)
.not_deleted()
.order_by("username")
.select_related("domain")
)
def follow_from(self, from_identity: Identity, boosts=True) -> Follow:
def follow(self, target_identity: Identity, boosts=True) -> Follow:
"""
Follows a user (or does nothing if already followed).
Returns the follow.
"""
if from_identity == self.identity:
if target_identity == self.identity:
raise ValueError("You cannot follow yourself")
return Follow.create_local(from_identity, self.identity, boosts=boosts)
return Follow.create_local(self.identity, target_identity, boosts=boosts)
def unfollow_from(self, from_identity: Identity):
def unfollow(self, target_identity: Identity):
"""
Unfollows a user (or does nothing if not followed).
"""
if from_identity == self.identity:
if target_identity == self.identity:
raise ValueError("You cannot unfollow yourself")
existing_follow = Follow.maybe_get(from_identity, self.identity)
existing_follow = Follow.maybe_get(self.identity, target_identity)
if existing_follow:
existing_follow.transition_perform(FollowStates.undone)
InboxMessage.create_internal(
{
"type": "ClearTimeline",
"actor": from_identity.pk,
"object": self.identity.pk,
"object": target_identity.pk,
"actor": self.identity.pk,
}
)
def block_from(self, from_identity: Identity) -> Block:
def block(self, target_identity: Identity) -> Block:
"""
Blocks a user.
"""
if from_identity == self.identity:
if target_identity == self.identity:
raise ValueError("You cannot block yourself")
self.unfollow_from(from_identity)
block = Block.create_local_block(from_identity, self.identity)
self.unfollow(target_identity)
block = Block.create_local_block(self.identity, target_identity)
InboxMessage.create_internal(
{
"type": "ClearTimeline",
"actor": from_identity.pk,
"object": self.identity.pk,
"actor": self.identity.pk,
"object": target_identity.pk,
"fullErase": True,
}
)
return block
def unblock_from(self, from_identity: Identity):
def unblock(self, target_identity: Identity):
"""
Unlocks a user
"""
if from_identity == self.identity:
if target_identity == self.identity:
raise ValueError("You cannot unblock yourself")
existing_block = Block.maybe_get(from_identity, self.identity, mute=False)
existing_block = Block.maybe_get(self.identity, target_identity, mute=False)
if existing_block and existing_block.active:
existing_block.transition_perform(BlockStates.undone)
def mute_from(
def mute(
self,
from_identity: Identity,
target_identity: Identity,
duration: int = 0,
include_notifications: bool = False,
) -> Block:
"""
Mutes a user.
"""
if from_identity == self.identity:
if target_identity == self.identity:
raise ValueError("You cannot mute yourself")
return Block.create_local_mute(
from_identity,
self.identity,
target_identity,
duration=duration or None,
include_notifications=include_notifications,
)
def unmute_from(self, from_identity: Identity):
def unmute(self, target_identity: Identity):
"""
Unmutes a user
"""
if from_identity == self.identity:
if target_identity == self.identity:
raise ValueError("You cannot unmute yourself")
existing_block = Block.maybe_get(from_identity, self.identity, mute=True)
existing_block = Block.maybe_get(self.identity, target_identity, mute=True)
if existing_block and existing_block.active:
existing_block.transition_perform(BlockStates.undone)
@ -234,3 +239,26 @@ class IdentityService:
file.name,
resize_image(file, size=(1500, 500)),
)
@classmethod
def handle_internal_add_follow(cls, payload):
"""
Handles an inbox message saying we need to follow a handle
Message format:
{
"type": "AddFollow",
"source": "90310938129083",
"target_handle": "andrew@aeracode.org",
"boosts": true,
}
"""
# Retrieve ourselves
self = cls(Identity.objects.get(pk=payload["source"]))
# Get the remote end (may need a fetch)
username, domain = payload["target_handle"].split("@")
target_identity = Identity.by_username_and_domain(username, domain, fetch=True)
if target_identity is None:
raise ValueError(f"Cannot find identity to follow: {target_identity}")
# Follow!
self.follow(target_identity=target_identity, boosts=payload.get("boosts", True))

View file

@ -249,21 +249,21 @@ class ActionIdentity(View):
# See what action we should perform
action = self.request.POST["action"]
if action == "follow":
IdentityService(identity).follow_from(self.request.identity)
IdentityService(request.identity).follow(identity)
elif action == "unfollow":
IdentityService(identity).unfollow_from(self.request.identity)
IdentityService(request.identity).unfollow(identity)
elif action == "block":
IdentityService(identity).block_from(self.request.identity)
IdentityService(request.identity).block(identity)
elif action == "unblock":
IdentityService(identity).unblock_from(self.request.identity)
IdentityService(request.identity).unblock(identity)
elif action == "mute":
IdentityService(identity).mute_from(self.request.identity)
IdentityService(request.identity).mute(identity)
elif action == "unmute":
IdentityService(identity).unmute_from(self.request.identity)
IdentityService(request.identity).unmute(identity)
elif action == "hide_boosts":
IdentityService(identity).follow_from(self.request.identity, boosts=False)
IdentityService(request.identity).follow(identity, boosts=False)
elif action == "show_boosts":
IdentityService(identity).follow_from(self.request.identity, boosts=True)
IdentityService(request.identity).follow(identity, boosts=True)
else:
raise ValueError(f"Cannot handle identity action {action}")
return redirect(identity.urls.view)

View file

@ -2,6 +2,11 @@ from django.utils.decorators import method_decorator
from django.views.generic import RedirectView
from users.decorators import identity_required
from users.views.settings.import_export import ( # noqa
CsvFollowers,
CsvFollowing,
ImportExportPage,
)
from users.views.settings.interface import InterfacePage # noqa
from users.views.settings.profile import ProfilePage # noqa
from users.views.settings.security import SecurityPage # noqa

View file

@ -0,0 +1,154 @@
import csv
from django import forms
from django.http import HttpResponse
from django.shortcuts import redirect
from django.utils.decorators import method_decorator
from django.views.generic import FormView, View
from users.decorators import identity_required
from users.models import Follow, InboxMessage
@method_decorator(identity_required, name="dispatch")
class ImportExportPage(FormView):
"""
Lets the identity's profile be edited
"""
template_name = "settings/import_export.html"
extra_context = {"section": "importexport"}
class form_class(forms.Form):
csv = forms.FileField(help_text="The CSV file you want to import")
import_type = forms.ChoiceField(
help_text="The type of data you wish to import",
choices=[("following", "Following list")],
)
def form_valid(self, form):
# Load CSV (we don't touch the DB till the whole file comes in clean)
try:
lines = form.cleaned_data["csv"].read().decode("utf-8").splitlines()
reader = csv.DictReader(lines)
prepared_data = []
for row in reader:
entry = {
"handle": row["Account address"],
"boosts": not (row["Show boosts"].lower().strip()[0] == "f"),
}
if len(entry["handle"].split("@")) != 2:
raise ValueError("Handle looks wrong")
prepared_data.append(entry)
except (TypeError, ValueError):
return redirect(".?bad_format=following")
# For each one, add an inbox message to create that follow
# We can't do them all inline here as the identity fetch might take ages
for entry in prepared_data:
InboxMessage.create_internal(
{
"type": "AddFollow",
"source": self.request.identity.pk,
"target_handle": entry["handle"],
"boosts": entry["boosts"],
}
)
return redirect(".?success=following")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["numbers"] = {
"outbound_follows": self.request.identity.outbound_follows.active().count(),
"inbound_follows": self.request.identity.inbound_follows.active().count(),
"blocks": self.request.identity.outbound_blocks.active()
.filter(mute=False)
.count(),
"mutes": self.request.identity.outbound_blocks.active()
.filter(mute=True)
.count(),
}
context["bad_format"] = self.request.GET.get("bad_format")
context["success"] = self.request.GET.get("success")
return context
class CsvView(View):
"""
Generic view that exports a queryset as a CSV
"""
# Mapping of CSV column title to method or model attribute name
# We rely on the fact that python dicts are stably ordered!
columns: dict[str, str]
# Filename to download as
filename: str = "export.csv"
def get_queryset(self):
raise NotImplementedError()
def get(self, request):
response = HttpResponse(
content_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{self.filename}"'},
)
writer = csv.writer(response)
writer.writerow(self.columns.keys())
for item in self.get_queryset(request):
row = []
for attrname in self.columns.values():
# Get value
getter = getattr(self, attrname, None)
if getter:
value = getter(item)
elif hasattr(item, attrname):
value = getattr(item, attrname)
else:
raise ValueError(f"Cannot export attribute {attrname}")
# Make it into CSV format
if type(value) == bool:
value = "true" if value else "false"
elif type(value) == int:
value = str(value)
row.append(value)
writer.writerow(row)
return response
class CsvFollowing(CsvView):
columns = {
"Account address": "get_handle",
"Show boosts": "boosts",
"Notify on new posts": "get_notify",
"Languages": "get_languages",
}
filename = "following.csv"
def get_queryset(self, request):
return self.request.identity.outbound_follows.active()
def get_handle(self, follow: Follow):
return follow.target.handle
def get_notify(self, follow: Follow):
return False
def get_languages(self, follow: Follow):
return ""
class CsvFollowers(CsvView):
columns = {
"Account address": "get_handle",
}
filename = "followers.csv"
def get_queryset(self, request):
return self.request.identity.inbound_follows.active()
def get_handle(self, follow: Follow):
return follow.target.handle