Fixed #421: Allow profile editing via API

This commit is contained in:
Andrew Godwin 2023-01-15 16:15:57 -07:00
parent e39355ceb5
commit de9261251e
6 changed files with 108 additions and 17 deletions

View file

@ -51,6 +51,7 @@ class Account(Schema):
statuses_count: int statuses_count: int
followers_count: int followers_count: int
following_count: int following_count: int
source: dict | None
class MediaAttachment(Schema): class MediaAttachment(Schema):

View file

@ -1,12 +1,15 @@
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse, QueryDict
from django.http.multipartparser import MultiPartParser
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from ninja import Field, Schema from ninja import Field, Schema
from activities.models import Post
from activities.services import SearchService from activities.services import SearchService
from api import schemas from api import schemas
from api.decorators import identity_required from api.decorators import identity_required
from api.pagination import MastodonPaginator from api.pagination import MastodonPaginator
from api.views.base import api_router from api.views.base import api_router
from core.models import Config
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
@ -15,7 +18,62 @@ from users.shortcuts import by_handle_or_404
@api_router.get("/v1/accounts/verify_credentials", response=schemas.Account) @api_router.get("/v1/accounts/verify_credentials", response=schemas.Account)
@identity_required @identity_required
def verify_credentials(request): def verify_credentials(request):
return request.identity.to_mastodon_json() return request.identity.to_mastodon_json(source=True)
@api_router.patch("/v1/accounts/update_credentials", response=schemas.Account)
@identity_required
def update_credentials(
request,
):
# Django won't load POST and FILES for patch methods, so we do it.
if request.content_type == "multipart/form-data":
POST, FILES = MultiPartParser(
request.META, request, request.upload_handlers, request.encoding
).parse()
elif request.content_type == "application/x-www-form-urlencoded":
POST = QueryDict(request.body, encoding=request._encoding)
FILES = {}
else:
return HttpResponse(status=400)
identity = request.identity
service = IdentityService(identity)
if "display_name" in POST:
identity.name = POST["display_name"]
if "note" in POST:
service.set_summary(POST["note"])
if "discoverable" in POST:
identity.discoverable = POST["discoverable"] == "checked"
if "source[privacy]" in POST:
privacy_map = {
"public": Post.Visibilities.public,
"unlisted": Post.Visibilities.unlisted,
"private": Post.Visibilities.followers,
"direct": Post.Visibilities.mentioned,
}
Config.set_identity(
identity,
"default_post_visibility",
privacy_map[POST["source[privacy]"]],
)
if "fields_attributes[0][name]" in POST:
identity.metadata = []
for i in range(4):
name_name = f"fields_attributes[{i}][name]"
value_name = f"fields_attributes[{i}][value]"
if name_name and value_name in POST:
# Empty value means delete this item
if not POST[value_name]:
break
identity.metadata.append(
{"name": POST[name_name], "value": POST[value_name]}
)
if "avatar" in FILES:
service.set_icon(FILES["avatar"])
if "header" in FILES:
service.set_image(FILES["header"])
identity.save()
return identity.to_mastodon_json(source=True)
@api_router.get("/v1/accounts/relationships", response=list[schemas.Relationship]) @api_router.get("/v1/accounts/relationships", response=list[schemas.Relationship])

View file

@ -2,7 +2,7 @@
<div class="actions" role="menubar"> <div class="actions" role="menubar">
{% if request.identity == identity %} {% if request.identity == identity %}
<a href="{% url "settings_profile" %}" class="button" title="Edit Profile"> <a href="{% url "settings_profile" %}" class="button" title="Edit Profile">
<i class="fa-solid fa-user-edit"></i> <i class="fa-solid fa-user-edit"></i> Edit
</a> </a>
{% elif not inbound_block %} {% elif not inbound_block %}
{% if inbound_follow or outbound_mute %} {% if inbound_follow or outbound_mute %}

View file

@ -13,7 +13,7 @@ from django.utils.functional import lazy
from lxml import etree from lxml import etree
from core.exceptions import ActorMismatchError from core.exceptions import ActorMismatchError
from core.html import ContentRenderer, strip_html from core.html import ContentRenderer, html_to_plaintext, strip_html
from core.ld import ( from core.ld import (
canonicalise, canonicalise,
format_ld_date, format_ld_date,
@ -830,8 +830,8 @@ class Identity(StatorModel):
"acct": self.handle or "", "acct": self.handle or "",
} }
def to_mastodon_json(self, include_counts=True): def to_mastodon_json(self, source=False, include_counts=True):
from activities.models import Emoji from activities.models import Emoji, Post
header_image = self.local_image_url() header_image = self.local_image_url()
missing = StaticAbsoluteUrl("img/missing.png").absolute missing = StaticAbsoluteUrl("img/missing.png").absolute
@ -843,7 +843,7 @@ class Identity(StatorModel):
f"{self.name} {self.summary} {metadata_value_text}", self.domain f"{self.name} {self.summary} {metadata_value_text}", self.domain
) )
renderer = ContentRenderer(local=False) renderer = ContentRenderer(local=False)
return { result = {
"id": self.pk, "id": self.pk,
"username": self.username or "", "username": self.username or "",
"acct": self.handle, "acct": self.handle,
@ -881,6 +881,25 @@ class Identity(StatorModel):
"followers_count": self.inbound_follows.count() if include_counts else 0, "followers_count": self.inbound_follows.count() if include_counts else 0,
"following_count": self.outbound_follows.count() if include_counts else 0, "following_count": self.outbound_follows.count() if include_counts else 0,
} }
if source:
privacy_map = {
Post.Visibilities.public: "public",
Post.Visibilities.unlisted: "unlisted",
Post.Visibilities.local_only: "unlisted",
Post.Visibilities.followers: "private",
Post.Visibilities.mentioned: "direct",
}
result["source"] = {
"note": html_to_plaintext(self.summary),
"fields": result["fields"],
"privacy": privacy_map[
Config.load_identity(self).default_post_visibility
],
"sensitive": False,
"language": "unk",
"follow_requests_count": 0,
}
return result
### Cryptography ### ### Cryptography ###

View file

@ -2,6 +2,7 @@ from django.db import models
from django.template.defaultfilters import linebreaks_filter from django.template.defaultfilters import linebreaks_filter
from activities.models import FanOut from activities.models import FanOut
from core.files import resize_image
from core.html import strip_html from core.html import strip_html
from users.models import ( from users.models import (
Block, Block,
@ -185,3 +186,21 @@ class IdentityService:
else: else:
self.identity.summary = None self.identity.summary = None
self.identity.save() self.identity.save()
def set_icon(self, file):
"""
Sets the user's avatar image
"""
self.identity.icon.save(
file.name,
resize_image(file, size=(400, 400)),
)
def set_image(self, file):
"""
Sets the user's header image
"""
self.identity.image.save(
file.name,
resize_image(file, size=(1500, 500)),
)

View file

@ -4,7 +4,6 @@ from django.shortcuts import redirect
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.generic import FormView from django.views.generic import FormView
from core.files import resize_image
from core.html import html_to_plaintext from core.html import html_to_plaintext
from core.models.config import Config from core.models.config import Config
from users.decorators import identity_required from users.decorators import identity_required
@ -77,22 +76,17 @@ class ProfilePage(FormView):
def form_valid(self, form): def form_valid(self, form):
# Update basic info # Update basic info
identity = self.request.identity identity = self.request.identity
service = IdentityService(identity)
identity.name = form.cleaned_data["name"] identity.name = form.cleaned_data["name"]
identity.discoverable = form.cleaned_data["discoverable"] identity.discoverable = form.cleaned_data["discoverable"]
IdentityService(identity).set_summary(form.cleaned_data["summary"]) service.set_summary(form.cleaned_data["summary"])
# Resize images # Resize images
icon = form.cleaned_data.get("icon") icon = form.cleaned_data.get("icon")
image = form.cleaned_data.get("image") image = form.cleaned_data.get("image")
if isinstance(icon, File): if isinstance(icon, File):
identity.icon.save( service.set_icon(icon)
icon.name,
resize_image(icon, size=(400, 400)),
)
if isinstance(image, File): if isinstance(image, File):
identity.image.save( service.set_image(image)
image.name,
resize_image(image, size=(1500, 500)),
)
identity.metadata = form.cleaned_data.get("metadata") identity.metadata = form.cleaned_data.get("metadata")
# Clear images if specified # Clear images if specified