parent
1c5ef675f0
commit
9a0008db06
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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 & Security">
|
||||
<i class="fa-solid fa-key"></i> Login & Security
|
||||
|
|
|
@ -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 %}
|
|
@ -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()
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue