mirror of
https://github.com/jointakahe/takahe.git
synced 2024-11-28 18:21:00 +00:00
parent
1c5ef675f0
commit
9a0008db06
11 changed files with 332 additions and 54 deletions
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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 & Security">
|
<a href="{% url "settings_security" %}" {% if section == "security" %}class="selected"{% endif %} title="Login & Security">
|
||||||
<i class="fa-solid fa-key"></i> Login & Security
|
<i class="fa-solid fa-key"></i> Login & Security
|
||||||
|
|
68
templates/settings/import_export.html
Normal file
68
templates/settings/import_export.html
Normal 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 %}
|
|
@ -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()
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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}"
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
154
users/views/settings/import_export.py
Normal file
154
users/views/settings/import_export.py
Normal 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
|
Loading…
Reference in a new issue