parent
7c6a0a5e3e
commit
f3520b21df
15 changed files with 645 additions and 5 deletions
|
@ -7,6 +7,7 @@ from django.contrib.contenttypes.models import ContentType
|
|||
from .book.models import Edition, EditionInSchema, EditionSchema, Series, Work
|
||||
from .collection.models import Collection as CatalogCollection
|
||||
from .common.models import (
|
||||
AvailableItemCategory,
|
||||
ExternalResource,
|
||||
IdType,
|
||||
Item,
|
||||
|
|
26
journal/static/js/roughviz.umd.js
Normal file
26
journal/static/js/roughviz.umd.js
Normal file
File diff suppressed because one or more lines are too long
|
@ -310,6 +310,7 @@
|
|||
_privateMethods.init(configs);
|
||||
// return false;
|
||||
}
|
||||
alert('x');
|
||||
(function () {
|
||||
new inputTags({
|
||||
container: document.getElementsByClassName("tag-input")[0],
|
||||
|
|
|
@ -42,7 +42,14 @@
|
|||
<div class="sortable">
|
||||
{% if request.user.is_authenticated %}
|
||||
<section class="entity-sort shelf" id="calendar_grid">
|
||||
<h5>书影音日历</h5>
|
||||
<h5>
|
||||
书影音日历
|
||||
{% if year %}
|
||||
<small>
|
||||
<a href="{% url 'journal:wrapped' year %}">{{ year }} 年度统计</a>
|
||||
</small>
|
||||
{% endif %}
|
||||
</h5>
|
||||
<div class="calendar_view cards">
|
||||
<p style="text-align: center;">
|
||||
<i class="fa-solid fa-compact-disc fa-spin loading"></i>
|
||||
|
|
139
journal/templates/wrapped.html
Normal file
139
journal/templates/wrapped.html
Normal file
|
@ -0,0 +1,139 @@
|
|||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load l10n %}
|
||||
{% load humanize %}
|
||||
{% load admin_url %}
|
||||
{% load mastodon %}
|
||||
{% load oauth_token %}
|
||||
{% load truncate %}
|
||||
{% load thumb %}
|
||||
{% load collection %}
|
||||
{% load user_actions %}
|
||||
<!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 }} - {{ identity.display_name }} - {{ year }} 年度统计</title>
|
||||
{% include "common_libs.html" %}
|
||||
{% comment %} <script src="https://unpkg.com/rough-viz@2.0.5"></script> {% endcomment %}
|
||||
<script src="{% static 'js/roughviz.umd.js' %}"></script>
|
||||
{% comment %} <script src="{% static 'js/saveSvgAsPng.js' %}"></script> {% endcomment %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/save-svg-as-png@1.4.17/lib/saveSvgAsPng.min.js"></script>
|
||||
<style>
|
||||
.yAxisviz0, .rough-yAxisviz0 {
|
||||
display: none;
|
||||
}
|
||||
.xAxisviz0 {
|
||||
{% comment %} opacity: 0.3; {% endcomment %}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
<main>
|
||||
<div class="grid__main">
|
||||
<span class="action">
|
||||
<span>
|
||||
<a onclick="restyle()" title="换样子"><i class="fa-solid fa-shuffle"></i></a>
|
||||
</span>
|
||||
<span>
|
||||
<a hx-get="{% url 'journal:wrapped_share' year %}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
title="转发到时间轴"><i class="fa-solid fa-share-from-square"></i></a>
|
||||
</span>
|
||||
<span>
|
||||
<a onclick="saveSvgAsPng($('#viz0').children('svg')[0], 'wrapped.png');"
|
||||
title="下载图片"><i class="fa-solid fa-download"></i></a>
|
||||
</span>
|
||||
</span>
|
||||
<h5>{{ year }} 年度统计</h5>
|
||||
<div id="viz0" style="max-width: 100%; aspect-ratio: 1 / 1;"></div>
|
||||
{{ by_cat|json_script:"cat-data" }}
|
||||
{{ monthly|json_script:"mon-data" }}
|
||||
{{ data|json_script:"data" }}
|
||||
<script>
|
||||
var cats = JSON.parse(document.getElementById('cat-data').textContent);
|
||||
var data = JSON.parse(document.getElementById('data').textContent);
|
||||
var opts = {
|
||||
title: "{{ identity.user.mastodon_acct | default:identity.full_handle }} - {{ year }}",
|
||||
element: '#viz0',
|
||||
font: 1,
|
||||
data: data,
|
||||
labels: "Month",
|
||||
highlight: "#666",
|
||||
stackColorMapping: {
|
||||
'📚': '#B4D2A5',
|
||||
'🎬': '#7CBDFE',
|
||||
'📺': '#FED37C',
|
||||
'💿': '#FEA36D',
|
||||
'🎮': '#C5A290',
|
||||
'🎙️': '#9D6AB0',
|
||||
'🎭': '#FE7C7C',
|
||||
'x': '#FDDB23',
|
||||
},
|
||||
roughness: 1,
|
||||
fillStyle: 'solid',
|
||||
margin: { top: 80, left: 20, right: 20, bottom: 80 },
|
||||
stroke: 1,
|
||||
padding: 0.2,
|
||||
color: 'red',
|
||||
strokeWidth: 1,
|
||||
axisStrokeWidth: 1,
|
||||
innerStrokeWidth: 1,
|
||||
fillWeight: 1,
|
||||
axisRoughness: 1,
|
||||
yLabel:"",
|
||||
};
|
||||
var viz0 = new roughViz.StackedBar(opts);
|
||||
viz0.setTitle = function(title) {
|
||||
$('#viz0').children('svg').css("background-color", "#fbfcfc").css("border-radius", "1rem");
|
||||
|
||||
viz0.svg.append("text")
|
||||
.attr("x", viz0.width / 2)
|
||||
.attr("y", 0 - viz0.margin.top / 2)
|
||||
.attr("class", "title")
|
||||
.attr("text-anchor", "middle")
|
||||
.style( "font-size", 20)
|
||||
.style( "font-weight", "bold" )
|
||||
.style("font-family", viz0.fontFamily)
|
||||
.style("color", "#000")
|
||||
.text(title);
|
||||
|
||||
viz0.svg.append("text")
|
||||
.attr("x", viz0.width / 2)
|
||||
.attr("y", viz0.height + viz0.margin.top -30)
|
||||
.attr("class", "title")
|
||||
.attr("text-anchor", "middle")
|
||||
.style( "font-size", 20 )
|
||||
.style("font-family", viz0.fontFamily)
|
||||
.style("color", "#000")
|
||||
.text(cats);
|
||||
|
||||
viz0.svg.append("text")
|
||||
.attr("x", viz0.width)
|
||||
.attr("y", viz0.height + viz0.margin.top -10)
|
||||
.attr("class", "title")
|
||||
.attr("text-anchor", "end")
|
||||
.style( "font-size", 10 )
|
||||
.style("font-family", viz0.fontFamily)
|
||||
.style("color", "#666")
|
||||
.text("{{ identity.profile_uri }}");
|
||||
};
|
||||
if ($('#viz0')[0].clientHeight < 250) {
|
||||
$('#viz0')[0].style.height = $('#viz0')[0].clientWidth + 'px';
|
||||
}
|
||||
viz0.boundRedraw()
|
||||
function restyle() {
|
||||
opts.roughness = Math.random()*4;
|
||||
opts.fillStyle = ["hachure", "solid", "zigzag", "cross-hatch", "dashed", "zigzag-line"][Math.floor(Math.random() * 6)];
|
||||
viz0.redraw(opts);
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
{% include "_sidebar.html" with show_profile=1 %}
|
||||
</main>
|
||||
{% include "_footer.html" %}
|
||||
</body>
|
||||
</html>
|
80
journal/templates/wrapped_share.html
Normal file
80
journal/templates/wrapped_share.html
Normal file
|
@ -0,0 +1,80 @@
|
|||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load l10n %}
|
||||
{% load humanize %}
|
||||
{% load admin_url %}
|
||||
{% load mastodon %}
|
||||
{% load oauth_token %}
|
||||
{% load truncate %}
|
||||
{% load highlight %}
|
||||
{% load thumb %}
|
||||
{% load duration %}
|
||||
<dialog open
|
||||
_="on close_dialog add .closing then wait for animationend then remove me">
|
||||
<article>
|
||||
<header>
|
||||
<link to="#"
|
||||
aria-label="Close"
|
||||
class="close"
|
||||
_="on click trigger close_dialog" />
|
||||
<strong>分享年度统计图</strong>
|
||||
</header>
|
||||
<div>
|
||||
<form method="post" action="{% url 'journal:wrapped_share' year %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="img" id="img" value="">
|
||||
<div style="display: flex;">
|
||||
<div style="width:6em; margin:1em;">
|
||||
<img id="preview" alt="preview" style="width:6em;" />
|
||||
</div>
|
||||
<div style="width:100%;">
|
||||
<textarea type="text"
|
||||
name="comment"
|
||||
placeholder="分享附言"
|
||||
style="width:100%;
|
||||
height:100%">分享 #我的{{year}}书影音</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin:0.5em">
|
||||
<fieldset>
|
||||
可见性:
|
||||
<input type="radio"
|
||||
name="visibility"
|
||||
value="0"
|
||||
required
|
||||
id="id_visibility_0"
|
||||
checked />
|
||||
<label for="id_visibility_0">公开</label>
|
||||
<input type="radio"
|
||||
name="visibility"
|
||||
value="1"
|
||||
required
|
||||
id="id_visibility_1">
|
||||
<label for="id_visibility_1">仅关注者</label>
|
||||
<input type="radio"
|
||||
name="visibility"
|
||||
value="2"
|
||||
required=""
|
||||
id="id_visibility_2">
|
||||
<label for="id_visibility_2">仅自己</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div>
|
||||
<input type="submit" value="{% trans '分享' %}">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
<script type="text/javascript">
|
||||
function share(uri){
|
||||
const pfx = 'data:image/png;base64,';
|
||||
$('#preview').attr('src', uri);
|
||||
if (uri.startsWith(pfx)) {
|
||||
$('#img').val(uri.substring(pfx.length));
|
||||
} else {
|
||||
alert('分享失败');
|
||||
}
|
||||
}
|
||||
svgAsPngUri($('#viz0').children('svg')[0]).then(share);
|
||||
</script>
|
||||
</dialog>
|
|
@ -154,4 +154,6 @@ urlpatterns = [
|
|||
name="user_calendar_data",
|
||||
),
|
||||
path("users/<str:id>/feed/reviews/", ReviewFeed(), name="review_feed"),
|
||||
path("wrapped/<int:year>/", WrappedView.as_view(), name="wrapped"),
|
||||
path("wrapped/<int:year>/share", WrappedShareView.as_view(), name="wrapped_share"),
|
||||
]
|
||||
|
|
|
@ -20,3 +20,4 @@ from .post import piece_replies, post_like, post_replies, post_reply, post_unlik
|
|||
from .profile import profile, user_calendar_data
|
||||
from .review import ReviewFeed, review_edit, review_retrieve, user_review_list
|
||||
from .tag import user_tag_edit, user_tag_list, user_tag_member_list
|
||||
from .wrapped import WrappedShareView, WrappedView
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import datetime
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import BadRequest, ObjectDoesNotExist, PermissionDenied
|
||||
from django.http import Http404, HttpResponse, HttpResponseRedirect
|
||||
|
@ -71,8 +73,16 @@ def profile(request: AuthedHttpRequest, user_name):
|
|||
q_piece_visible_to_user(request.user)
|
||||
)
|
||||
top_tags = target.tag_manager.public_tags[:10]
|
||||
year = None
|
||||
else:
|
||||
top_tags = target.tag_manager.all_tags[:10]
|
||||
today = datetime.date.today()
|
||||
if today.month > 11:
|
||||
year = today.year
|
||||
elif today.month < 2:
|
||||
year = today.year - 1
|
||||
else:
|
||||
year = None
|
||||
return render(
|
||||
request,
|
||||
"profile.html",
|
||||
|
@ -89,6 +99,7 @@ def profile(request: AuthedHttpRequest, user_name):
|
|||
],
|
||||
"liked_collections_count": liked_collections.count(),
|
||||
"layout": target.preference.profile_layout,
|
||||
"year": year,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
137
journal/views/wrapped.py
Normal file
137
journal/views/wrapped.py
Normal file
|
@ -0,0 +1,137 @@
|
|||
import base64
|
||||
import calendar
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.db.models import Count, F
|
||||
from django.db.models.functions import ExtractMonth
|
||||
from django.http import HttpRequest, HttpResponseRedirect
|
||||
from django.http.response import HttpResponse
|
||||
from django.views.generic.base import TemplateView
|
||||
|
||||
from catalog.models import (
|
||||
AvailableItemCategory,
|
||||
ItemCategory,
|
||||
PodcastEpisode,
|
||||
item_content_types,
|
||||
)
|
||||
from journal.models import Comment, ShelfType
|
||||
from mastodon.api import boost_toot_later, get_toot_visibility, post_toot_later
|
||||
from takahe.utils import Takahe
|
||||
from users.models import User
|
||||
|
||||
_type_emoji = {
|
||||
"movie": "🎬",
|
||||
"music": "💿",
|
||||
"album": "💿",
|
||||
"game": "🎮",
|
||||
"tv": "📺",
|
||||
"tvshow": "📺",
|
||||
"tvseason": "📺",
|
||||
"book": "📚",
|
||||
"edition": "📚",
|
||||
"podcast": "🎙️",
|
||||
"performance": "🎭",
|
||||
"performanceproduction": "🎭",
|
||||
}
|
||||
|
||||
|
||||
def _type_to_emoji():
|
||||
cts = item_content_types()
|
||||
return {v: _type_emoji.get(k.__name__.lower(), k.__name__) for k, v in cts.items()}
|
||||
|
||||
|
||||
class WrappedView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "wrapped.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
user: User = self.request.user # type: ignore
|
||||
target = user.identity
|
||||
year = kwargs.get("year")
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["identity"] = target
|
||||
cnt = {}
|
||||
cats = []
|
||||
_item_types = _type_to_emoji()
|
||||
for cat in AvailableItemCategory:
|
||||
queryset = target.shelf_manager.get_latest_members(
|
||||
ShelfType.COMPLETE, ItemCategory(cat)
|
||||
).filter(created_time__year=year)
|
||||
cnt[cat] = queryset.count()
|
||||
if cat.value == "podcast":
|
||||
pc = (
|
||||
Comment.objects.filter(
|
||||
owner=target,
|
||||
item__polymorphic_ctype_id=item_content_types()[PodcastEpisode],
|
||||
)
|
||||
.values("item__podcastepisode__program_id")
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
cnt[cat] += pc
|
||||
if cnt[cat] > 0:
|
||||
cats.append(f"{_type_emoji[cat.value]}x{cnt[cat]}")
|
||||
context["by_cat"] = " ".join(cats)
|
||||
all = list(
|
||||
target.shelf_manager.get_latest_members(ShelfType.COMPLETE)
|
||||
.filter(created_time__year=year)
|
||||
.annotate(month=ExtractMonth("created_time"))
|
||||
.annotate(cat=F("item__polymorphic_ctype_id"))
|
||||
.values("month", "cat")
|
||||
.annotate(total=Count("month"))
|
||||
.order_by("month")
|
||||
.values_list("month", "cat", "total")
|
||||
)
|
||||
data = [{"Month": calendar.month_abbr[m]} for m in range(1, 13)]
|
||||
for m, ct, cnt in all:
|
||||
data[m - 1][_item_types[ct]] = data[m - 1].get(_item_types[ct], 0) + cnt
|
||||
podcast_by_month = list(
|
||||
Comment.objects.filter(
|
||||
owner=target,
|
||||
item__polymorphic_ctype_id=item_content_types()[PodcastEpisode],
|
||||
)
|
||||
.filter(created_time__year=year)
|
||||
.annotate(month=ExtractMonth("created_time"))
|
||||
.values("item__podcastepisode__program_id", "month")
|
||||
.distinct()
|
||||
.annotate(total=Count("month", distinct=True))
|
||||
.values_list("month", "total")
|
||||
)
|
||||
for m, cnt in podcast_by_month:
|
||||
data[m - 1]["🎙️"] = data[m - 1].get("🎙️", 0) + cnt
|
||||
context["data"] = data
|
||||
return context
|
||||
|
||||
|
||||
class WrappedShareView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "wrapped_share.html"
|
||||
|
||||
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
img = base64.b64decode(request.POST.get("img", ""))
|
||||
comment = request.POST.get("comment", "")
|
||||
visibility = int(request.POST.get("visibility", 0))
|
||||
user: User = request.user # type: ignore
|
||||
identity = user.identity # type: ignore
|
||||
media = Takahe.upload_image(
|
||||
identity.pk, "year.png", img, "image/png", "NeoDB Yearly Summary"
|
||||
)
|
||||
post = Takahe.post(
|
||||
identity.pk,
|
||||
"",
|
||||
comment,
|
||||
Takahe.visibility_n2t(visibility, user.preference.post_public_mode),
|
||||
attachments=[media],
|
||||
)
|
||||
classic_repost = user.preference.mastodon_repost_mode == 1
|
||||
if classic_repost:
|
||||
post_toot_later(
|
||||
user,
|
||||
comment,
|
||||
get_toot_visibility(visibility, user),
|
||||
img=img,
|
||||
img_name="year.png",
|
||||
img_type="image/png",
|
||||
)
|
||||
elif post:
|
||||
boost_toot_later(user, post.url)
|
||||
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
|
|
@ -3,6 +3,7 @@ import html
|
|||
import random
|
||||
import re
|
||||
import string
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
import django_rq
|
||||
|
@ -129,6 +130,33 @@ def boost_toot_later(user, post_url):
|
|||
)
|
||||
|
||||
|
||||
def post_toot_later(
|
||||
user,
|
||||
content,
|
||||
visibility,
|
||||
local_only=False,
|
||||
update_id=None,
|
||||
spoiler_text=None,
|
||||
img=None,
|
||||
img_name=None,
|
||||
img_type=None,
|
||||
):
|
||||
if user and user.mastodon_token and user.mastodon_site and content:
|
||||
django_rq.get_queue("fetch").enqueue(
|
||||
post_toot,
|
||||
user.mastodon_site,
|
||||
content,
|
||||
visibility,
|
||||
user.mastodon_token,
|
||||
local_only,
|
||||
update_id,
|
||||
spoiler_text,
|
||||
img,
|
||||
img_name,
|
||||
img_type,
|
||||
)
|
||||
|
||||
|
||||
def post_toot(
|
||||
site,
|
||||
content,
|
||||
|
@ -137,18 +165,47 @@ def post_toot(
|
|||
local_only=False,
|
||||
update_id=None,
|
||||
spoiler_text=None,
|
||||
img=None,
|
||||
img_name=None,
|
||||
img_type=None,
|
||||
):
|
||||
headers = {
|
||||
"User-Agent": USER_AGENT,
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Idempotency-Key": random_string_generator(16),
|
||||
}
|
||||
media_id = None
|
||||
if img and img_name and img_type:
|
||||
try:
|
||||
media_id = (
|
||||
requests.post(
|
||||
"https://" + get_api_domain(site) + "/api/v1/media",
|
||||
headers=headers,
|
||||
data={},
|
||||
files={"file": (img_name, img, img_type)},
|
||||
)
|
||||
.json()
|
||||
.get("id")
|
||||
)
|
||||
ready = False
|
||||
while ready is False:
|
||||
time.sleep(3)
|
||||
j = requests.get(
|
||||
"https://" + get_api_domain(site) + "/api/v1/media/" + media_id,
|
||||
headers=headers,
|
||||
).json()
|
||||
ready = j.get("url") is not None
|
||||
except Exception as e:
|
||||
logger.warning(f"Error uploading image {e}")
|
||||
headers["Idempotency-Key"] = random_string_generator(16)
|
||||
response = None
|
||||
url = "https://" + get_api_domain(site) + API_PUBLISH_TOOT
|
||||
payload = {
|
||||
"status": content,
|
||||
"visibility": visibility,
|
||||
}
|
||||
if media_id:
|
||||
payload["media_ids[]"] = [media_id]
|
||||
if spoiler_text:
|
||||
payload["spoiler_text"] = spoiler_text
|
||||
if local_only:
|
||||
|
@ -163,7 +220,8 @@ def post_toot(
|
|||
response.status_code = 200
|
||||
if response is not None and response.status_code != 200:
|
||||
logger.warning(f"Error {url} {response.status_code}")
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.warning(f"Error posting {e}")
|
||||
response = None
|
||||
return response
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
blurhash-python
|
||||
cachetools
|
||||
dateparser
|
||||
discord.py
|
||||
|
|
|
@ -813,4 +813,77 @@ class Migration(migrations.Migration):
|
|||
],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PostAttachment",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("state", models.CharField(default="new", max_length=100)),
|
||||
("state_changed", models.DateTimeField(auto_now_add=True)),
|
||||
("mimetype", models.CharField(max_length=200)),
|
||||
(
|
||||
"file",
|
||||
models.FileField(
|
||||
blank=True,
|
||||
null=True,
|
||||
storage=takahe.models.upload_store,
|
||||
upload_to=functools.partial(
|
||||
takahe.models.upload_namer, *("attachments",), **{}
|
||||
),
|
||||
),
|
||||
),
|
||||
(
|
||||
"thumbnail",
|
||||
models.ImageField(
|
||||
blank=True,
|
||||
null=True,
|
||||
storage=takahe.models.upload_store,
|
||||
upload_to=functools.partial(
|
||||
takahe.models.upload_namer,
|
||||
*("attachment_thumbnails",),
|
||||
**{}
|
||||
),
|
||||
),
|
||||
),
|
||||
("remote_url", models.CharField(blank=True, max_length=500, null=True)),
|
||||
("name", models.TextField(blank=True, null=True)),
|
||||
("width", models.IntegerField(blank=True, null=True)),
|
||||
("height", models.IntegerField(blank=True, null=True)),
|
||||
("focal_x", models.FloatField(blank=True, null=True)),
|
||||
("focal_y", models.FloatField(blank=True, null=True)),
|
||||
("blurhash", models.TextField(blank=True, null=True)),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
("updated", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"author",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="attachments",
|
||||
to="takahe.identity",
|
||||
),
|
||||
),
|
||||
(
|
||||
"post",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="attachments",
|
||||
to="takahe.post",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "activities_postattachment",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
|
@ -848,6 +848,7 @@ class Post(models.Model):
|
|||
"""
|
||||
|
||||
interactions: "models.QuerySet[PostInteraction]"
|
||||
attachments: "models.QuerySet[PostAttachment]"
|
||||
|
||||
class Visibilities(models.IntegerChoices):
|
||||
public = 0
|
||||
|
@ -1079,8 +1080,8 @@ class Post(models.Model):
|
|||
days=settings.FANOUT_LIMIT_DAYS
|
||||
):
|
||||
post.state = "fanned_out" # add post quietly if it's old
|
||||
# if attachments:# FIXME
|
||||
# post.attachments.set(attachments)
|
||||
if attachments:
|
||||
post.attachments.set(attachments)
|
||||
# if question: # FIXME
|
||||
# post.type = question["type"]
|
||||
# post.type_data = PostTypeData(__root__=question).__root__
|
||||
|
@ -1121,7 +1122,8 @@ class Post(models.Model):
|
|||
self.edited = timezone.now()
|
||||
self.mentions.set(self.mentions_from_content(content, self.author))
|
||||
self.emojis.set(Emoji.emojis_from_content(content, None))
|
||||
# self.attachments.set(attachments or []) # fixme
|
||||
if attachments is not None:
|
||||
self.attachments.set(attachments or []) # type: ignore
|
||||
if type_data:
|
||||
self.type_data = type_data
|
||||
self.save()
|
||||
|
@ -1251,6 +1253,65 @@ class FanOut(models.Model):
|
|||
updated = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
class PostAttachment(models.Model):
|
||||
"""
|
||||
An attachment to a Post. Could be an image, a video, etc.
|
||||
"""
|
||||
|
||||
post = models.ForeignKey(
|
||||
"takahe.post",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="attachments",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
author = models.ForeignKey(
|
||||
"takahe.Identity",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="attachments",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
# state = StateField(graph=PostAttachmentStates)
|
||||
state = models.CharField(max_length=100, default="new")
|
||||
state_changed = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
mimetype = models.CharField(max_length=200)
|
||||
|
||||
# Files may not be populated if it's remote and not cached on our side yet
|
||||
file = models.FileField(
|
||||
upload_to=partial(upload_namer, "attachments"),
|
||||
null=True,
|
||||
blank=True,
|
||||
storage=upload_store,
|
||||
)
|
||||
thumbnail = models.ImageField(
|
||||
upload_to=partial(upload_namer, "attachment_thumbnails"),
|
||||
null=True,
|
||||
blank=True,
|
||||
storage=upload_store,
|
||||
)
|
||||
|
||||
remote_url = models.CharField(max_length=500, null=True, blank=True)
|
||||
|
||||
# This is the description for images, at least
|
||||
name = models.TextField(null=True, blank=True)
|
||||
|
||||
width = models.IntegerField(null=True, blank=True)
|
||||
height = models.IntegerField(null=True, blank=True)
|
||||
focal_x = models.FloatField(null=True, blank=True)
|
||||
focal_y = models.FloatField(null=True, blank=True)
|
||||
blurhash = models.TextField(null=True, blank=True)
|
||||
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
updated = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
# managed = False
|
||||
db_table = "activities_postattachment"
|
||||
|
||||
|
||||
class EmojiQuerySet(models.QuerySet):
|
||||
def usable(self, domain: Domain | None = None):
|
||||
"""
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
import io
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
# import blurhash
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.files.images import ImageFile
|
||||
from PIL import Image
|
||||
|
||||
from .models import *
|
||||
|
||||
|
@ -364,6 +368,37 @@ class Takahe:
|
|||
Block.objects.filter(state="new").update(state="sent")
|
||||
Block.objects.exclude(state="sent").delete()
|
||||
|
||||
@staticmethod
|
||||
def upload_image(
|
||||
author_pk: int,
|
||||
filename: str,
|
||||
content: bytes,
|
||||
mimetype: str,
|
||||
description: str = "",
|
||||
) -> PostAttachment:
|
||||
if len(content) > 1024 * 1024 * 5:
|
||||
raise ValueError("Image too large")
|
||||
main_file = ImageFile(io.BytesIO(content), name=filename)
|
||||
resized_image = Image.open(io.BytesIO(content))
|
||||
resized_image.thumbnail((400, 225), resample=Image.Resampling.BILINEAR)
|
||||
new_image_bytes = io.BytesIO()
|
||||
resized_image.save(new_image_bytes, format="webp", save_all=True)
|
||||
thumbnail_file = ImageFile(new_image_bytes, name="image.webp")
|
||||
# hash = blurhash.encode(resized_image, 4, 4)
|
||||
attachment = PostAttachment.objects.create(
|
||||
mimetype=mimetype,
|
||||
width=main_file.width,
|
||||
height=main_file.height,
|
||||
name=description or None,
|
||||
state="fetched",
|
||||
author_id=author_pk,
|
||||
file=main_file,
|
||||
thumbnail=thumbnail_file,
|
||||
# blurhash=hash,
|
||||
)
|
||||
attachment.save()
|
||||
return attachment
|
||||
|
||||
@staticmethod
|
||||
def post(
|
||||
author_pk: int,
|
||||
|
@ -376,6 +411,7 @@ class Takahe:
|
|||
post_pk: int | None = None,
|
||||
post_time: datetime.datetime | None = None,
|
||||
reply_to_pk: int | None = None,
|
||||
attachments: list | None = None,
|
||||
) -> Post | None:
|
||||
identity = Identity.objects.get(pk=author_pk)
|
||||
post = (
|
||||
|
@ -399,6 +435,7 @@ class Takahe:
|
|||
visibility=visibility,
|
||||
type_data=data,
|
||||
published=post_time,
|
||||
attachments=attachments,
|
||||
)
|
||||
else:
|
||||
post = Post.create_local(
|
||||
|
@ -411,6 +448,7 @@ class Takahe:
|
|||
type_data=data,
|
||||
published=post_time,
|
||||
reply_to=reply_to_post,
|
||||
attachments=attachments,
|
||||
)
|
||||
return post
|
||||
|
||||
|
@ -646,6 +684,10 @@ class Takahe:
|
|||
if not post:
|
||||
logger.warning(f"Cannot find post {post_pk}")
|
||||
return
|
||||
identity = Identity.objects.filter(pk=identity_pk).first()
|
||||
if not identity:
|
||||
logger.warning(f"Cannot find identity {identity_pk}")
|
||||
return
|
||||
interaction = PostInteraction.objects.get_or_create(
|
||||
type=type,
|
||||
identity_id=identity_pk,
|
||||
|
|
Loading…
Add table
Reference in a new issue