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

View File

@ -55,6 +55,21 @@ urlpatterns = [
settings.InterfacePage.as_view(), settings.InterfacePage.as_view(),
name="settings_interface", 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( path(
"admin/", "admin/",
admin.AdminRoot.as_view(), admin.AdminRoot.as_view(),

View File

@ -6,6 +6,9 @@
<a href="{% url "settings_interface" %}" {% if section == "interface" %}class="selected"{% endif %} title="Interface"> <a href="{% url "settings_interface" %}" {% if section == "interface" %}class="selected"{% endif %} title="Interface">
<i class="fa-solid fa-display"></i> Interface <i class="fa-solid fa-display"></i> Interface
</a> </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> <h3>Account</h3>
<a href="{% url "settings_security" %}" {% if section == "security" %}class="selected"{% endif %} title="Login &amp; Security"> <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 <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. Ensures that timeline clearing works as expected.
""" """
# Follow the remote user # Follow the remote user
service = IdentityService(remote_identity) service = IdentityService(identity)
service.follow_from(identity) service.follow(remote_identity)
# Create an inbound new post message mentioning us # Create an inbound new post message mentioning us
message = { message = {
"id": "test", "id": "test",
@ -243,9 +243,9 @@ def test_clear_timeline(
# Now, submit either a user block (for full clear) or unfollow (for post clear) # Now, submit either a user block (for full clear) or unfollow (for post clear)
if full: if full:
service.block_from(identity) service.block(remote_identity)
else: else:
service.unfollow_from(identity) service.unfollow(remote_identity)
# Run stator once to process the timeline clear message # Run stator once to process the timeline clear message
stator.run_single_cycle_sync() stator.run_single_cycle_sync()

View File

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

View File

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

View File

@ -58,8 +58,10 @@ class IdentityService:
def following(self) -> models.QuerySet[Identity]: def following(self) -> models.QuerySet[Identity]:
return ( return (
Identity.objects.active() Identity.objects.filter(
.filter(inbound_follows__source=self.identity) inbound_follows__source=self.identity,
inbound_follows__state__in=FollowStates.group_active(),
)
.not_deleted() .not_deleted()
.order_by("username") .order_by("username")
.select_related("domain") .select_related("domain")
@ -67,91 +69,94 @@ class IdentityService:
def followers(self) -> models.QuerySet[Identity]: def followers(self) -> models.QuerySet[Identity]:
return ( 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() .not_deleted()
.order_by("username") .order_by("username")
.select_related("domain") .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). Follows a user (or does nothing if already followed).
Returns the follow. Returns the follow.
""" """
if from_identity == self.identity: if target_identity == self.identity:
raise ValueError("You cannot follow yourself") 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). 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") 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: if existing_follow:
existing_follow.transition_perform(FollowStates.undone) existing_follow.transition_perform(FollowStates.undone)
InboxMessage.create_internal( InboxMessage.create_internal(
{ {
"type": "ClearTimeline", "type": "ClearTimeline",
"actor": from_identity.pk, "object": target_identity.pk,
"object": self.identity.pk, "actor": self.identity.pk,
} }
) )
def block_from(self, from_identity: Identity) -> Block: def block(self, target_identity: Identity) -> Block:
""" """
Blocks a user. Blocks a user.
""" """
if from_identity == self.identity: if target_identity == self.identity:
raise ValueError("You cannot block yourself") raise ValueError("You cannot block yourself")
self.unfollow_from(from_identity) self.unfollow(target_identity)
block = Block.create_local_block(from_identity, self.identity) block = Block.create_local_block(self.identity, target_identity)
InboxMessage.create_internal( InboxMessage.create_internal(
{ {
"type": "ClearTimeline", "type": "ClearTimeline",
"actor": from_identity.pk, "actor": self.identity.pk,
"object": self.identity.pk, "object": target_identity.pk,
"fullErase": True, "fullErase": True,
} }
) )
return block return block
def unblock_from(self, from_identity: Identity): def unblock(self, target_identity: Identity):
""" """
Unlocks a user Unlocks a user
""" """
if from_identity == self.identity: if target_identity == self.identity:
raise ValueError("You cannot unblock yourself") 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: if existing_block and existing_block.active:
existing_block.transition_perform(BlockStates.undone) existing_block.transition_perform(BlockStates.undone)
def mute_from( def mute(
self, self,
from_identity: Identity, target_identity: Identity,
duration: int = 0, duration: int = 0,
include_notifications: bool = False, include_notifications: bool = False,
) -> Block: ) -> Block:
""" """
Mutes a user. Mutes a user.
""" """
if from_identity == self.identity: if target_identity == self.identity:
raise ValueError("You cannot mute yourself") raise ValueError("You cannot mute yourself")
return Block.create_local_mute( return Block.create_local_mute(
from_identity,
self.identity, self.identity,
target_identity,
duration=duration or None, duration=duration or None,
include_notifications=include_notifications, include_notifications=include_notifications,
) )
def unmute_from(self, from_identity: Identity): def unmute(self, target_identity: Identity):
""" """
Unmutes a user Unmutes a user
""" """
if from_identity == self.identity: if target_identity == self.identity:
raise ValueError("You cannot unmute yourself") 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: if existing_block and existing_block.active:
existing_block.transition_perform(BlockStates.undone) existing_block.transition_perform(BlockStates.undone)
@ -234,3 +239,26 @@ class IdentityService:
file.name, file.name,
resize_image(file, size=(1500, 500)), 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 # See what action we should perform
action = self.request.POST["action"] action = self.request.POST["action"]
if action == "follow": if action == "follow":
IdentityService(identity).follow_from(self.request.identity) IdentityService(request.identity).follow(identity)
elif action == "unfollow": elif action == "unfollow":
IdentityService(identity).unfollow_from(self.request.identity) IdentityService(request.identity).unfollow(identity)
elif action == "block": elif action == "block":
IdentityService(identity).block_from(self.request.identity) IdentityService(request.identity).block(identity)
elif action == "unblock": elif action == "unblock":
IdentityService(identity).unblock_from(self.request.identity) IdentityService(request.identity).unblock(identity)
elif action == "mute": elif action == "mute":
IdentityService(identity).mute_from(self.request.identity) IdentityService(request.identity).mute(identity)
elif action == "unmute": elif action == "unmute":
IdentityService(identity).unmute_from(self.request.identity) IdentityService(request.identity).unmute(identity)
elif action == "hide_boosts": elif action == "hide_boosts":
IdentityService(identity).follow_from(self.request.identity, boosts=False) IdentityService(request.identity).follow(identity, boosts=False)
elif action == "show_boosts": elif action == "show_boosts":
IdentityService(identity).follow_from(self.request.identity, boosts=True) IdentityService(request.identity).follow(identity, boosts=True)
else: else:
raise ValueError(f"Cannot handle identity action {action}") raise ValueError(f"Cannot handle identity action {action}")
return redirect(identity.urls.view) 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 django.views.generic import RedirectView
from users.decorators import identity_required 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.interface import InterfacePage # noqa
from users.views.settings.profile import ProfilePage # noqa from users.views.settings.profile import ProfilePage # noqa
from users.views.settings.security import SecurityPage # 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