fetch remote identity
This commit is contained in:
parent
31ba886210
commit
c1ef7b3892
26 changed files with 328 additions and 102 deletions
|
@ -1,13 +1,11 @@
|
|||
import hashlib
|
||||
import logging
|
||||
import uuid
|
||||
import re
|
||||
|
||||
import django_rq
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import BadRequest
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rq.job import Job
|
||||
|
@ -15,7 +13,8 @@ from rq.job import Job
|
|||
from catalog.common.models import ItemCategory, SiteName
|
||||
from catalog.common.sites import AbstractSite, SiteManager
|
||||
from common.config import PAGE_LINK_NUMBER
|
||||
from common.utils import PageLinksGenerator
|
||||
from common.utils import HTTPResponseHXRedirect, PageLinksGenerator
|
||||
from users.views import query_identity
|
||||
|
||||
from ..models import *
|
||||
from .external import ExternalSources
|
||||
|
@ -24,16 +23,7 @@ from .models import enqueue_fetch, get_fetch_lock, query_index
|
|||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HTTPResponseHXRedirect(HttpResponseRedirect):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self["HX-Redirect"] = self["Location"]
|
||||
|
||||
status_code = 200
|
||||
|
||||
|
||||
def fetch_refresh(request, job_id):
|
||||
retry = request.GET
|
||||
try:
|
||||
job = Job.fetch(id=job_id, connection=django_rq.get_connection("fetch"))
|
||||
item_url = job.return_value()
|
||||
|
@ -102,6 +92,9 @@ def visible_categories(request):
|
|||
|
||||
|
||||
def search(request):
|
||||
keywords = request.GET.get("q", default="").strip()
|
||||
if re.match(r"^[@@]", keywords):
|
||||
return query_identity(request, keywords.replace("@", "@"))
|
||||
category = request.GET.get("c", default="all").strip().lower()
|
||||
hide_category = False
|
||||
if category == "all" or not category:
|
||||
|
@ -115,7 +108,6 @@ def search(request):
|
|||
hide_category = True
|
||||
except:
|
||||
categories = visible_categories(request)
|
||||
keywords = request.GET.get("q", default="").strip()
|
||||
tag = request.GET.get("tag", default="").strip()
|
||||
p = request.GET.get("page", default="1")
|
||||
p = int(p) if p.isdigit() else 1
|
||||
|
|
|
@ -312,6 +312,7 @@ def discover(request):
|
|||
"discover.html",
|
||||
{
|
||||
"user": user,
|
||||
"identity": user.identity,
|
||||
"gallery_list": gallery_list,
|
||||
"recent_podcast_episodes": recent_podcast_episodes,
|
||||
"books_in_progress": books_in_progress,
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
<input type="search"
|
||||
name="q"
|
||||
id="q"
|
||||
placeholder="标题、创作者、ISBN、站外条目链接"
|
||||
placeholder="标题、创作者、ISBN、站外条目链接、@用户名、@用户名@实例"
|
||||
class="search"
|
||||
value="{{ request.GET.q|default:'' }}" />
|
||||
<select name="c">
|
||||
|
|
|
@ -38,29 +38,33 @@
|
|||
<summary>
|
||||
<div>
|
||||
<div class="avatar">
|
||||
<a href="{{ user.url }}" onclick="window.location = this.href;">
|
||||
<a href="{{ identity.url }}" onclick="window.location = this.href;">
|
||||
{% comment %} onclick to workaround webkit issue with <a /> in <summary /> {% endcomment %}
|
||||
<img src="{{ user.avatar }}" alt="">
|
||||
<img src="{{ identity.avatar }}" alt="">
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<hgroup>
|
||||
<h6 class="nickname">{{ user.display_name }}</h6>
|
||||
<h6 class="nickname">{{ identity.display_name }}</h6>
|
||||
<div>
|
||||
<a href="{{ user.mastodon_account.url }}"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
onclick="window.open(this.href); return false;">
|
||||
<span class="handler">{{ user.handler }}</span>
|
||||
</a>
|
||||
<span class="handler">{{ identity.full_handle }}</span>
|
||||
{% if identity.user and identity.user.mastodon_account %}
|
||||
<a href="{{ identity.user.mastodon_account.url }}"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
onclick="window.open(this.href); return false;"
|
||||
title="@{{ user.mastodon_acct }}">
|
||||
<i class="fa-solid fa-circle-nodes"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</hgroup>
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
<span class="action">
|
||||
{% if user == request.user %}
|
||||
{% if user.locked %}
|
||||
{% if identity.user == request.user %}
|
||||
{% if identity.locked %}
|
||||
<span>
|
||||
<a title="你已开启关注审核">
|
||||
<i class="fa-solid fa-user-shield"></i>
|
||||
|
@ -71,7 +75,7 @@
|
|||
{% include 'users/profile_actions.html' %}
|
||||
{% endif %}
|
||||
</span>
|
||||
<p>{{ user.mastodon_account.note|bleach:"a,p,span,br" }}</p>
|
||||
<p>{{ identity.summary|bleach:"a,p,span,br" }}</p>
|
||||
</details>
|
||||
</article>
|
||||
</section>
|
||||
|
@ -79,9 +83,9 @@
|
|||
{% if show_progress %}
|
||||
<section>
|
||||
<article>
|
||||
<details {% if user.featured_collections.all %}open{% endif %}>
|
||||
<details {% if identity.featured_collections.all %}open{% endif %}>
|
||||
<summary>{% trans '当前目标' %}</summary>
|
||||
{% for featured_collection in user.featured_collections.all %}
|
||||
{% for featured_collection in identity.featured_collections.all %}
|
||||
{% user_visibility_of featured_collection as visible %}
|
||||
{% if visible %}
|
||||
{% user_stats_of collection=featured_collection user=user as stats %}
|
||||
|
@ -190,7 +194,7 @@
|
|||
<div class="tag-list">
|
||||
{% for t in top_tags %}
|
||||
<span>
|
||||
<a href="{% url 'journal:user_tag_member_list' user.handler t %}">{{ t }}</a>
|
||||
<a href="{% url 'journal:user_tag_member_list' identity.handler t %}">{{ t }}</a>
|
||||
</span>
|
||||
{% empty %}
|
||||
<div class="empty">暂无可见标签</div>
|
||||
|
@ -198,7 +202,7 @@
|
|||
</div>
|
||||
<small>
|
||||
{% if top_tags %}
|
||||
<a href="{% url 'journal:user_tag_list' user.handler %}">...{% trans '全部' %}</a>
|
||||
<a href="{% url 'journal:user_tag_list' identity.handler %}">...{% trans '全部' %}</a>
|
||||
{% endif %}
|
||||
</small>
|
||||
</details>
|
||||
|
|
|
@ -15,8 +15,12 @@ def mastodon(domain):
|
|||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def current_user_relationship(context, user: "User"):
|
||||
current_user = context["request"].user
|
||||
def current_user_relationship(context, target_identity: "APIdentity"):
|
||||
current_identity = (
|
||||
context["request"].user.identity
|
||||
if context["request"].user.is_authenticated
|
||||
else None
|
||||
)
|
||||
r = {
|
||||
"requesting": False,
|
||||
"following": False,
|
||||
|
@ -26,9 +30,7 @@ def current_user_relationship(context, user: "User"):
|
|||
"rejecting": False,
|
||||
"status": "",
|
||||
}
|
||||
if current_user and current_user.is_authenticated and current_user != user:
|
||||
current_identity = context["request"].user.identity
|
||||
target_identity = user.identity
|
||||
if current_identity and current_identity != target_identity:
|
||||
if current_identity.is_blocking(
|
||||
target_identity
|
||||
) or current_identity.is_blocked_by(target_identity):
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.http import Http404, HttpRequest
|
||||
from django.http import Http404, HttpRequest, HttpResponseRedirect
|
||||
from django.utils import timezone
|
||||
from django.utils.baseconv import base62
|
||||
|
||||
|
@ -18,6 +18,14 @@ class AuthedHttpRequest(HttpRequest):
|
|||
target_identity: "APIdentity"
|
||||
|
||||
|
||||
class HTTPResponseHXRedirect(HttpResponseRedirect):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self["HX-Redirect"] = self["Location"]
|
||||
|
||||
status_code = 200
|
||||
|
||||
|
||||
class PageLinksGenerator:
|
||||
# TODO inherit django paginator
|
||||
"""
|
||||
|
|
|
@ -19,26 +19,24 @@ class Like(Piece): # TODO remove
|
|||
|
||||
@staticmethod
|
||||
def user_liked_piece(owner, piece):
|
||||
return Like.objects.filter(owner=owner.identity, target=piece).exists()
|
||||
return Like.objects.filter(owner=owner, target=piece).exists()
|
||||
|
||||
@staticmethod
|
||||
def user_like_piece(owner, piece):
|
||||
if not piece:
|
||||
return
|
||||
like = Like.objects.filter(owner=owner.identity, target=piece).first()
|
||||
like = Like.objects.filter(owner=owner, target=piece).first()
|
||||
if not like:
|
||||
like = Like.objects.create(owner=owner.identity, target=piece)
|
||||
like = Like.objects.create(owner=owner, target=piece)
|
||||
return like
|
||||
|
||||
@staticmethod
|
||||
def user_unlike_piece(owner, piece):
|
||||
if not piece:
|
||||
return
|
||||
Like.objects.filter(owner=owner.identity, target=piece).delete()
|
||||
Like.objects.filter(owner=owner, target=piece).delete()
|
||||
|
||||
@staticmethod
|
||||
def user_likes_by_class(owner, cls):
|
||||
ctype_id = ContentType.objects.get_for_model(cls)
|
||||
return Like.objects.filter(
|
||||
owner=owner.identity, target__polymorphic_ctype=ctype_id
|
||||
)
|
||||
return Like.objects.filter(owner=owner, target__polymorphic_ctype=ctype_id)
|
||||
|
|
|
@ -10,19 +10,20 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{% if user == request.user %}
|
||||
{% if identity.user == request.user %}
|
||||
<title>{{ site_name }} - {% trans '我的个人主页' %}</title>
|
||||
{% else %}
|
||||
<title>{{ site_name }} - {{ user.display_name }}</title>
|
||||
<title>{{ site_name }} - {{ identity.display_name }}</title>
|
||||
{% endif %}
|
||||
<meta property="og:title" content="{{ site_name }}用户 - {{ user.handler }}">
|
||||
<meta property="og:title"
|
||||
content="{{ identity.handler }} - {{ site_name }}">
|
||||
<meta property="og:url" content="{{ request.build_absolute_uri }}">
|
||||
<meta property="og:image" content="{{ user.avatar }}">
|
||||
<meta property="og:image" content="{{ identity.avatar }}">
|
||||
<meta property="og:site_name" content="{{ site_name }}">
|
||||
{% if user.preference.no_anonymous_view %}<meta name="robots" content="noindex">{% endif %}
|
||||
{% if identity.preference.no_anonymous_view %}<meta name="robots" content="noindex">{% endif %}
|
||||
<link rel="alternate"
|
||||
type="application/rss+xml"
|
||||
title="{{ site_name }} - {{ user.handler }}的评论"
|
||||
title="{{ site_name }} - {{ identity.handler }}的评论"
|
||||
href="{{ request.build_absolute_uri }}feed/reviews/">
|
||||
{% include "common_libs.html" with jquery=0 v2=1 %}
|
||||
<script src="{% static 'js/calendar_yearview_blocks.js' %}" defer></script>
|
||||
|
@ -31,7 +32,7 @@
|
|||
rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
{% if request.user == user %}
|
||||
{% if request.user == identity.user %}
|
||||
{% include "_header.html" with current="home" %}
|
||||
{% else %}
|
||||
{% include "_header.html" %}
|
||||
|
@ -48,7 +49,7 @@
|
|||
</p>
|
||||
</div>
|
||||
<span class="calendar_data"
|
||||
hx-get="{% url 'journal:user_calendar_data' user.handler %}"
|
||||
hx-get="{% url 'journal:user_calendar_data' identity.handler %}"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML"
|
||||
style="display:none"></span>
|
||||
|
@ -62,7 +63,7 @@
|
|||
<h5>
|
||||
{{ shelf.title }}
|
||||
<small>
|
||||
<a href="{% if shelf_type == 'reviewed' %}{% url 'journal:user_review_list' user.handler category %}{% else %}{% url 'journal:user_mark_list' user.handler shelf_type category %}{% endif %}">{{ shelf.count }}</a>
|
||||
<a href="{% if shelf_type == 'reviewed' %}{% url 'journal:user_review_list' identity.handler category %}{% else %}{% url 'journal:user_mark_list' identity.handler shelf_type category %}{% endif %}">{{ shelf.count }}</a>
|
||||
</small>
|
||||
</h5>
|
||||
<ul class="cards">
|
||||
|
@ -89,8 +90,8 @@
|
|||
<h5>
|
||||
{% trans '创建的收藏单' %}
|
||||
<small>
|
||||
<a href="{% url 'journal:user_collection_list' user.handler %}">{{ collections_count }}</a>
|
||||
{% if user == request.user %}
|
||||
<a href="{% url 'journal:user_collection_list' identity.handler %}">{{ collections_count }}</a>
|
||||
{% if identity.user == request.user %}
|
||||
<a href="{% url 'journal:collection_create' %}">
|
||||
<i class="fa-regular fa-square-plus"></i>
|
||||
</a>
|
||||
|
@ -117,7 +118,7 @@
|
|||
<h5>
|
||||
{% trans '关注的收藏单' %}
|
||||
<small>
|
||||
<a href="{% url 'journal:user_liked_collection_list' user.handler %}">{{ liked_collections_count }}</a>
|
||||
<a href="{% url 'journal:user_liked_collection_list' identity.handler %}">{{ liked_collections_count }}</a>
|
||||
</small>
|
||||
</h5>
|
||||
<ul class="cards">
|
||||
|
@ -186,6 +187,10 @@
|
|||
{% include "_sidebar.html" with show_progress=1 show_profile=1 %}
|
||||
</main>
|
||||
{% include "_footer.html" %}
|
||||
<a href="{{ user.mastodon_account.url }}" rel="me" style="display:none">Mastodon verification</a>
|
||||
{% if identity.user and identity.user.mastodon_account %}
|
||||
<a href="{{ identity.user.mastodon_account.url }}"
|
||||
rel="me"
|
||||
style="display:none">Mastodon verification</a>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -266,6 +266,7 @@ def collection_edit(request: AuthedHttpRequest, collection_uuid=None):
|
|||
"form": form,
|
||||
"collection": collection,
|
||||
"user": collection.owner.user if collection else request.user,
|
||||
"identity": collection.owner if collection else request.user.identity,
|
||||
},
|
||||
)
|
||||
elif request.method == "POST":
|
||||
|
@ -300,6 +301,7 @@ def user_collection_list(request: AuthedHttpRequest, user_name):
|
|||
"user_collection_list.html",
|
||||
{
|
||||
"user": target.user,
|
||||
"identity": target,
|
||||
"collections": collections,
|
||||
},
|
||||
)
|
||||
|
@ -317,6 +319,7 @@ def user_liked_collection_list(request: AuthedHttpRequest, user_name):
|
|||
"user_collection_list.html",
|
||||
{
|
||||
"user": target.user,
|
||||
"identity": target,
|
||||
"collections": collections,
|
||||
"liked": True,
|
||||
},
|
||||
|
|
|
@ -20,11 +20,9 @@ PAGE_SIZE = 10
|
|||
|
||||
def target_identity_required(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
request = kwargs["request"]
|
||||
handler = kwargs["user_name"]
|
||||
def wrapper(request, user_name, *args, **kwargs):
|
||||
try:
|
||||
target = APIdentity.get_by_handler(handler)
|
||||
target = APIdentity.get_by_handler(user_name)
|
||||
except:
|
||||
return render_user_not_found(request)
|
||||
if not target.is_visible_to_user(request.user):
|
||||
|
@ -33,6 +31,7 @@ def target_identity_required(func):
|
|||
# request.identity = (
|
||||
# request.user.identity if request.user.is_authenticated else None
|
||||
# )
|
||||
return func(request, user_name, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
@ -100,7 +99,13 @@ def render_list(
|
|||
return render(
|
||||
request,
|
||||
f"user_{type}_list.html",
|
||||
{"user": target.user, "members": members, "tag": tag, "pagination": pagination},
|
||||
{
|
||||
"user": target.user,
|
||||
"identity": target,
|
||||
"members": members,
|
||||
"tag": tag,
|
||||
"pagination": pagination,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -81,6 +81,7 @@ def profile(request: AuthedHttpRequest, user_name):
|
|||
"profile.html",
|
||||
{
|
||||
"user": target.user,
|
||||
"identity": target,
|
||||
"top_tags": top_tags,
|
||||
"shelf_list": shelf_list,
|
||||
"collections": collections[:10],
|
||||
|
@ -96,13 +97,14 @@ def profile(request: AuthedHttpRequest, user_name):
|
|||
|
||||
|
||||
def user_calendar_data(request, user_name):
|
||||
if request.method != "GET":
|
||||
if request.method != "GET" or not request.user.is_authenticated:
|
||||
raise BadRequest()
|
||||
user = User.get(user_name)
|
||||
if user is None or not request.user.is_authenticated:
|
||||
return HttpResponse("")
|
||||
max_visiblity = max_visiblity_to_user(request.user, user.identity)
|
||||
calendar_data = user.shelf_manager.get_calendar_data(max_visiblity)
|
||||
try:
|
||||
target = APIdentity.get_by_handler(user_name)
|
||||
except:
|
||||
return HttpResponse("unavailable")
|
||||
max_visiblity = max_visiblity_to_user(request.user, target)
|
||||
calendar_data = target.shelf_manager.get_calendar_data(max_visiblity)
|
||||
return render(
|
||||
request,
|
||||
"calendar_data.html",
|
||||
|
|
|
@ -31,6 +31,7 @@ def user_tag_list(request, user_name):
|
|||
"user_tag_list.html",
|
||||
{
|
||||
"user": target.user,
|
||||
"identity": target,
|
||||
"tags": tags,
|
||||
},
|
||||
)
|
||||
|
|
|
@ -66,7 +66,7 @@ def _get_visibility(post_visibility):
|
|||
|
||||
def _update_or_create_post(pk, obj):
|
||||
post = Post.objects.get(pk=pk)
|
||||
owner = Takahe.get_or_create_apidentity(post.author)
|
||||
owner = Takahe.get_or_create_remote_apidentity(post.author)
|
||||
if not post.type_data:
|
||||
logger.warning(f"Post {post} has no type_data")
|
||||
return
|
||||
|
|
|
@ -486,4 +486,24 @@ class Migration(migrations.Migration):
|
|||
"unique_together": {("source", "target")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="InboxMessage",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("message", models.JSONField()),
|
||||
("state", models.CharField(default="received", max_length=100)),
|
||||
("state_changed", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
"db_table": "users_inboxmessage",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1393,3 +1393,34 @@ class Block(models.Model):
|
|||
block.uri = source.actor_uri + f"block/{block.pk}/"
|
||||
block.save()
|
||||
return block
|
||||
|
||||
|
||||
class InboxMessage(models.Model):
|
||||
"""
|
||||
an incoming inbox message that needs processing.
|
||||
|
||||
Yes, this is kind of its own message queue built on the state graph system.
|
||||
It's fine. It'll scale up to a decent point.
|
||||
"""
|
||||
|
||||
message = models.JSONField()
|
||||
|
||||
# state = StateField(InboxMessageStates)
|
||||
state = models.CharField(max_length=100, default="received")
|
||||
state_changed = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
# managed = False
|
||||
db_table = "users_inboxmessage"
|
||||
|
||||
@classmethod
|
||||
def create_internal(cls, payload):
|
||||
"""
|
||||
Creates an internal action message
|
||||
"""
|
||||
cls.objects.create(
|
||||
message={
|
||||
"type": "__internal__",
|
||||
"object": payload,
|
||||
}
|
||||
)
|
||||
|
|
|
@ -89,6 +89,7 @@ class Takahe:
|
|||
logger.info(f"Creating takahe identity {u}@{domain}")
|
||||
identity = Identity.objects.create(
|
||||
actor_uri=f"https://{domain.uri_domain}/@{u.username}@{domain.domain}/",
|
||||
profile_uri=u.url,
|
||||
username=u.username,
|
||||
domain=domain,
|
||||
name=u.username,
|
||||
|
@ -121,6 +122,16 @@ class Takahe:
|
|||
u.save(update_fields=["identity"])
|
||||
return apidentity
|
||||
|
||||
@staticmethod
|
||||
def get_identity_by_handler(username: str, domain: str) -> Identity | None:
|
||||
return Identity.objects.filter(
|
||||
username__iexact=username, domain__domain__iexact=domain
|
||||
).first()
|
||||
|
||||
@staticmethod
|
||||
def fetch_remote_identity(handler: str) -> int | None:
|
||||
InboxMessage.create_internal({"type": "FetchIdentity", "handle": handler})
|
||||
|
||||
@staticmethod
|
||||
def get_identity(pk: int):
|
||||
return Identity.objects.get(pk=pk)
|
||||
|
@ -134,20 +145,21 @@ class Takahe:
|
|||
)
|
||||
|
||||
@staticmethod
|
||||
def get_or_create_apidentity(identity: Identity):
|
||||
def get_or_create_remote_apidentity(identity: Identity):
|
||||
from users.models import APIdentity
|
||||
|
||||
apid = APIdentity.objects.filter(pk=identity.pk).first()
|
||||
if not apid:
|
||||
if identity.local:
|
||||
raise ValueError(f"local takahe identity {identity} missing APIdentity")
|
||||
if not identity.domain:
|
||||
if not identity.domain_id:
|
||||
raise ValueError(f"remote takahe identity {identity} missing domain")
|
||||
apid = APIdentity.objects.create(
|
||||
id=identity.pk,
|
||||
user=None,
|
||||
local=False,
|
||||
username=identity.username,
|
||||
domain_name=identity.domain.domain,
|
||||
domain_name=identity.domain_id,
|
||||
deleted=identity.deleted,
|
||||
)
|
||||
return apid
|
||||
|
|
|
@ -41,7 +41,8 @@ class Migration(migrations.Migration):
|
|||
(
|
||||
"user",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="identity",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
|
|
|
@ -56,6 +56,7 @@ def init_identity(apps, schema_editor):
|
|||
takahe_identity = TakaheIdentity.objects.create(
|
||||
pk=user.pk,
|
||||
actor_uri=f"https://{service_domain or domain}/@{username}@{domain}/",
|
||||
profile_uri=user.url,
|
||||
username=username,
|
||||
domain=tdomain,
|
||||
name=username,
|
||||
|
@ -66,7 +67,6 @@ def init_identity(apps, schema_editor):
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("users", "0012_apidentity"),
|
||||
]
|
||||
|
|
|
@ -2,10 +2,11 @@ from functools import cached_property
|
|||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from loguru import logger
|
||||
from django.templatetags.static import static
|
||||
|
||||
from takahe.utils import Takahe
|
||||
|
||||
from .preference import Preference
|
||||
from .user import User
|
||||
|
||||
|
||||
|
@ -16,7 +17,9 @@ class APIdentity(models.Model):
|
|||
This model is used as 1:1 mapping to Takahe Identity Model
|
||||
"""
|
||||
|
||||
user = models.OneToOneField("User", models.CASCADE, related_name="identity")
|
||||
user = models.OneToOneField(
|
||||
"User", models.SET_NULL, related_name="identity", null=True
|
||||
)
|
||||
local = models.BooleanField()
|
||||
username = models.CharField(max_length=500, blank=True, null=True)
|
||||
domain_name = models.CharField(max_length=500, blank=True, null=True)
|
||||
|
@ -28,6 +31,9 @@ class APIdentity(models.Model):
|
|||
models.Index(fields=["domain_name", "username"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.pk}:{self.username}@{self.domain_name}"
|
||||
|
||||
@cached_property
|
||||
def takahe_identity(self):
|
||||
return Takahe.get_identity(self.pk)
|
||||
|
@ -44,6 +50,10 @@ class APIdentity(models.Model):
|
|||
def discoverable(self):
|
||||
return self.takahe_identity.discoverable
|
||||
|
||||
@property
|
||||
def locked(self):
|
||||
return self.takahe_identity.manually_approves_followers
|
||||
|
||||
@property
|
||||
def actor_uri(self):
|
||||
return self.takahe_identity.actor_uri
|
||||
|
@ -54,11 +64,15 @@ class APIdentity(models.Model):
|
|||
|
||||
@property
|
||||
def display_name(self):
|
||||
return self.takahe_identity.name
|
||||
return self.takahe_identity.name or self.username
|
||||
|
||||
@property
|
||||
def summary(self):
|
||||
return self.takahe_identity.summary or ""
|
||||
|
||||
@property
|
||||
def avatar(self):
|
||||
return self.user.avatar # FiXME
|
||||
return self.takahe_identity.icon_uri or static("img/avatar.svg") # fixme
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
|
@ -66,14 +80,18 @@ class APIdentity(models.Model):
|
|||
|
||||
@property
|
||||
def preference(self):
|
||||
return self.user.preference
|
||||
return self.user.preference if self.user else Preference()
|
||||
|
||||
@property
|
||||
def full_handle(self):
|
||||
return f"@{self.username}@{self.domain_name}"
|
||||
|
||||
@property
|
||||
def handler(self):
|
||||
if self.local:
|
||||
return self.username
|
||||
else:
|
||||
return f"{self.username}@{self.domain_name}"
|
||||
return f"@{self.username}@{self.domain_name}"
|
||||
|
||||
@property
|
||||
def following(self):
|
||||
|
@ -157,21 +175,40 @@ class APIdentity(models.Model):
|
|||
|
||||
@classmethod
|
||||
def get_by_handler(cls, handler: str) -> "APIdentity":
|
||||
"""
|
||||
Handler format
|
||||
'id' - local identity with username 'id'
|
||||
'id@site' - local identity with linked mastodon id == 'id@site'
|
||||
'@id' - local identity with username 'id'
|
||||
'@id@site' - remote activitypub identity 'id@site'
|
||||
"""
|
||||
s = handler.split("@")
|
||||
if len(s) == 1:
|
||||
return cls.objects.get(username=s[0], local=True, deleted__isnull=True)
|
||||
elif len(s) == 2:
|
||||
l = len(s)
|
||||
if l == 1 or (l == 2 and s[0] == ""):
|
||||
return cls.objects.get(
|
||||
user__mastodon_username=s[0],
|
||||
user__mastodon_site=s[1],
|
||||
username__iexact=s[0] if l == 1 else s[1],
|
||||
local=True,
|
||||
deleted__isnull=True,
|
||||
)
|
||||
elif len(s) == 3 and s[0] == "":
|
||||
elif l == 2:
|
||||
return cls.objects.get(
|
||||
username=s[0], domain_name=s[1], local=False, deleted__isnull=True
|
||||
user__mastodon_username__iexact=s[0],
|
||||
user__mastodon_site__iexact=s[1],
|
||||
deleted__isnull=True,
|
||||
)
|
||||
elif l == 3 and s[0] == "":
|
||||
i = cls.objects.filter(
|
||||
username__iexact=s[1], domain_name__iexact=s[2], deleted__isnull=True
|
||||
).first()
|
||||
if i:
|
||||
return i
|
||||
if s[2].lower() != settings.SITE_INFO["site_domain"].lower():
|
||||
identity = Takahe.get_identity_by_handler(s[1], s[2])
|
||||
if identity:
|
||||
return Takahe.get_or_create_remote_apidentity(identity)
|
||||
raise cls.DoesNotExist(f"Identity not exist {handler}")
|
||||
else:
|
||||
raise cls.DoesNotExist(f"Invalid handler {handler}")
|
||||
raise cls.DoesNotExist(f"Identity handler invalid {handler}")
|
||||
|
||||
@cached_property
|
||||
def activity_manager(self):
|
||||
|
|
4
users/templates/users/fetch_identity_failed.html
Normal file
4
users/templates/users/fetch_identity_failed.html
Normal file
|
@ -0,0 +1,4 @@
|
|||
<p>
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
无法找到用户,请确认拼写正确;也可能服务器正忙,请稍后再尝试。
|
||||
</p>
|
42
users/templates/users/fetch_identity_pending.html
Normal file
42
users/templates/users/fetch_identity_pending.html
Normal file
|
@ -0,0 +1,42 @@
|
|||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load l10n %}
|
||||
{% load humanize %}
|
||||
{% load admin_url %}
|
||||
{% load mastodon %}
|
||||
{% load oauth_token %}
|
||||
{% load truncate %}
|
||||
{% load highlight %}
|
||||
{% load thumb %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh" class="classic-page">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ site_name }} - {% trans '查询用户' %}</title>
|
||||
{% include "common_libs.html" with jquery=0 v2=1 %}
|
||||
</head>
|
||||
<body>
|
||||
{% include '_header.html' %}
|
||||
<main>
|
||||
<div class="grid__main">
|
||||
<article>
|
||||
{% if handle %}
|
||||
<h5>正在从联邦网络查询{{ handle }}</h5>
|
||||
<div hx-get="{% url 'users:fetch_refresh' %}?handle={{ handle }}"
|
||||
hx-trigger="load delay:2s"
|
||||
hx-swap="outerHTML">
|
||||
<i class="fa-solid fa-compact-disc fa-spin loading"></i>
|
||||
</div>
|
||||
{% else %}
|
||||
<h5>获取系统繁忙,请稍等几秒钟再搜索</h5>
|
||||
{% endif %}
|
||||
</article>
|
||||
</div>
|
||||
<aside class="grid__aside bottom">
|
||||
{% include "_sidebar_search.html" %}
|
||||
</aside>
|
||||
</main>
|
||||
{% include '_footer.html' %}
|
||||
</body>
|
||||
</html>
|
5
users/templates/users/fetch_identity_refresh.html
Normal file
5
users/templates/users/fetch_identity_refresh.html
Normal file
|
@ -0,0 +1,5 @@
|
|||
<div hx-get="{% url 'users:fetch_refresh' %}?handle={{ handle }}&retry={{ retry }}"
|
||||
hx-trigger="load delay:{{ delay }}s"
|
||||
hx-swap="outerHTML">
|
||||
<i class="fa-solid fa-compact-disc fa-spin loading"></i>
|
||||
</div>
|
|
@ -1,10 +1,10 @@
|
|||
{% load mastodon %}
|
||||
{% current_user_relationship user as relationship %}
|
||||
{% current_user_relationship identity as relationship %}
|
||||
{% if relationship.rejecting %}
|
||||
<span class="tag-list">
|
||||
<span><a title="点击可取消屏蔽"
|
||||
hx-confirm="确定要取消对该用户的屏蔽吗?"
|
||||
hx-post="{% url 'users:unblock' user.handler %}"
|
||||
hx-post="{% url 'users:unblock' identity.handler %}"
|
||||
hx-target="closest .action"
|
||||
hx-swap="innerHTML">已屏蔽</a></span>
|
||||
</span>
|
||||
|
@ -18,7 +18,7 @@
|
|||
{% endif %}
|
||||
{% if show_home %}
|
||||
<span>
|
||||
<a title="用户主页" href="{{ user.url }}">
|
||||
<a title="用户主页" href="{{ identity.url }}">
|
||||
<i class="fa-solid fa-home"></i>
|
||||
</a>
|
||||
</span>
|
||||
|
@ -28,7 +28,7 @@
|
|||
<span>
|
||||
<a title="已关注,点击可取消关注"
|
||||
class="activated"
|
||||
hx-post="{% url 'users:unfollow' user.handler %}"
|
||||
hx-post="{% url 'users:unfollow' identity.handler %}"
|
||||
hx-target="closest .action"
|
||||
hx-swap="innerHTML">
|
||||
<i class="fa-solid fa-user-check"></i>
|
||||
|
@ -38,10 +38,10 @@
|
|||
<span><a title="已在联邦宇宙关注该用户" class="activated"><i class="fa-solid fa-circle-check"></i></a></span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if user.locked %}
|
||||
{% if identity.locked %}
|
||||
<span>
|
||||
<a title="用户已开启关注审核"
|
||||
hx-post="{% url 'users:locked' user.handler %}"
|
||||
hx-post="{% url 'users:locked' identity.handler %}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
<i class="fa-solid fa-user-shield"></i>
|
||||
|
@ -50,7 +50,7 @@
|
|||
{% else %}
|
||||
<span>
|
||||
<a title="点击可关注该用户"
|
||||
hx-post="{% url 'users:follow' user.handler %}"
|
||||
hx-post="{% url 'users:follow' identity.handler %}"
|
||||
hx-target="closest .action"
|
||||
hx-swap="innerHTML">
|
||||
<i class="fa-solid fa-user-plus"></i>
|
||||
|
@ -61,7 +61,7 @@
|
|||
{% if not relationship.muting %}
|
||||
<span>
|
||||
<a title="点击可隐藏该用户"
|
||||
hx-post="{% url 'users:mute' user.handler %}"
|
||||
hx-post="{% url 'users:mute' identity.handler %}"
|
||||
hx-target="closest .action"
|
||||
hx-swap="innerHTML">
|
||||
<i class="fa-solid fa-volume-high"></i>
|
||||
|
@ -71,7 +71,7 @@
|
|||
<span>
|
||||
<a title="已隐藏,点击可取消隐藏"
|
||||
class="activated"
|
||||
hx-post="{% url 'users:unmute' user.handler %}"
|
||||
hx-post="{% url 'users:unmute' identity.handler %}"
|
||||
hx-target="closest .action"
|
||||
hx-swap="innerHTML">
|
||||
<i class="fa-solid fa-volume-off"></i>
|
||||
|
@ -87,11 +87,11 @@
|
|||
<span>
|
||||
<a title="点击可屏蔽该用户"
|
||||
hx-confirm="确定要屏蔽该用户吗?"
|
||||
hx-post="{% url 'users:block' user.handler %}"
|
||||
hx-post="{% url 'users:block' identity.handler %}"
|
||||
hx-target="closest .action"
|
||||
hx-swap="innerHTML">
|
||||
<i class="fa-solid fa-user-slash"></i>
|
||||
</a>
|
||||
</span>
|
||||
{% comment %} <span><a href="{% url 'users:report' %}?user_id={{ user.id }}">{% trans '投诉用户' %}</a></span> {% endcomment %}
|
||||
{% comment %} <span><a href="{% url 'users:report' %}?user_id={{ identity.id }}">{% trans '投诉用户' %}</a></span> {% endcomment %}
|
||||
{% endif %}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
|
||||
from takahe.utils import Takahe
|
||||
|
@ -15,6 +16,24 @@ class UserTest(TestCase):
|
|||
self.bob = User.register(
|
||||
mastodon_site="KKCity", mastodon_username="Bob", username="bob"
|
||||
).identity
|
||||
self.domain = settings.SITE_INFO.get("site_domain")
|
||||
|
||||
def test_handle(self):
|
||||
self.assertEqual(APIdentity.get_by_handler("Alice"), self.alice)
|
||||
self.assertEqual(APIdentity.get_by_handler("@alice"), self.alice)
|
||||
self.assertEqual(APIdentity.get_by_handler("Alice@MySpace"), self.alice)
|
||||
self.assertEqual(APIdentity.get_by_handler("alice@myspace"), self.alice)
|
||||
self.assertEqual(APIdentity.get_by_handler("@alice@" + self.domain), self.alice)
|
||||
self.assertEqual(APIdentity.get_by_handler("@Alice@" + self.domain), self.alice)
|
||||
self.assertRaises(
|
||||
APIdentity.DoesNotExist, APIdentity.get_by_handler, "@Alice@MySpace"
|
||||
)
|
||||
self.assertRaises(
|
||||
APIdentity.DoesNotExist, APIdentity.get_by_handler, "@alice@KKCity"
|
||||
)
|
||||
|
||||
def test_fetch(self):
|
||||
pass
|
||||
|
||||
def test_follow(self):
|
||||
self.alice.follow(self.bob)
|
||||
|
|
|
@ -12,6 +12,7 @@ urlpatterns = [
|
|||
path("register", register, name="register"),
|
||||
path("connect", connect, name="connect"),
|
||||
path("reconnect", reconnect, name="reconnect"),
|
||||
path("fetch_refresh", fetch_refresh, name="fetch_refresh"),
|
||||
path("data", data, name="data"),
|
||||
path("info", account_info, name="info"),
|
||||
path("data/import/status", data_import_status, name="import_status"),
|
||||
|
|
|
@ -9,18 +9,20 @@ from django.urls import reverse
|
|||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from common.config import *
|
||||
from common.utils import HTTPResponseHXRedirect
|
||||
from management.models import Announcement
|
||||
from mastodon.api import *
|
||||
from takahe.utils import Takahe
|
||||
|
||||
from .account import *
|
||||
from .data import *
|
||||
from .forms import ReportForm
|
||||
from .models import Preference, Report, User
|
||||
from .models import APIdentity, Preference, Report, User
|
||||
|
||||
|
||||
def render_user_not_found(request):
|
||||
def render_user_not_found(request, user_name=""):
|
||||
sec_msg = _("😖哎呀,这位用户好像还没有加入本站,快去联邦宇宙呼唤TA来注册吧!")
|
||||
msg = _("未找到该用户")
|
||||
msg = _("未找到用户") + user_name
|
||||
return render(
|
||||
request,
|
||||
"common/error.html",
|
||||
|
@ -42,6 +44,37 @@ def render_user_blocked(request):
|
|||
)
|
||||
|
||||
|
||||
def query_identity(request, handle):
|
||||
try:
|
||||
i = APIdentity.get_by_handler(handle)
|
||||
return redirect(i.url)
|
||||
except APIdentity.DoesNotExist:
|
||||
if len(handle.split("@")) == 3:
|
||||
Takahe.fetch_remote_identity(handle)
|
||||
return render(
|
||||
request, "users/fetch_identity_pending.html", {"handle": handle}
|
||||
)
|
||||
else:
|
||||
return render_user_not_found(request, handle)
|
||||
|
||||
|
||||
def fetch_refresh(request):
|
||||
handle = request.GET.get("handle", "")
|
||||
try:
|
||||
i = APIdentity.get_by_handler(handle)
|
||||
return HTTPResponseHXRedirect(i.url)
|
||||
except:
|
||||
retry = int(request.GET.get("retry", 0)) + 1
|
||||
if retry > 10:
|
||||
return render(request, "users/fetch_identity_failed.html")
|
||||
else:
|
||||
return render(
|
||||
request,
|
||||
"users/fetch_identity_refresh.html",
|
||||
{"handle": handle, "retry": retry, "delay": retry * 2},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def follow(request, user_name):
|
||||
if request.method != "POST":
|
||||
|
|
Loading…
Add table
Reference in a new issue