mirror of
https://github.com/jointakahe/takahe.git
synced 2024-12-25 23:00:29 +00:00
Add image/icon upload
This commit is contained in:
parent
7f8e792402
commit
f5eafb0ca0
14 changed files with 205 additions and 78 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,4 +1,5 @@
|
|||
*.psql
|
||||
*.sqlite3
|
||||
.venv
|
||||
/media/
|
||||
notes.md
|
||||
|
|
|
@ -27,8 +27,10 @@ class PostStates(StateGraph):
|
|||
"""
|
||||
post = await instance.afetch_full()
|
||||
# Non-local posts should not be here
|
||||
# TODO: This seems to keep happening. Work out how?
|
||||
if not post.local:
|
||||
raise ValueError(f"Trying to run handle_new on a non-local post {post.pk}!")
|
||||
print(f"Trying to run handle_new on a non-local post {post.pk}!")
|
||||
return cls.fanned_out
|
||||
# Build list of targets - mentions always included
|
||||
targets = set()
|
||||
async for mention in post.mentions.all():
|
||||
|
|
15
core/uploads.py
Normal file
15
core/uploads.py
Normal file
|
@ -0,0 +1,15 @@
|
|||
import base64
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def upload_namer(prefix, instance, filename):
|
||||
"""
|
||||
Names uploaded images, obscuring their original name with a random UUID.
|
||||
"""
|
||||
now = timezone.now()
|
||||
_, old_extension = os.path.splitext(filename)
|
||||
new_filename = base64.b32encode(uuid.uuid4().bytes).decode("ascii")
|
||||
return f"{prefix}/{now.year}/{now.month}/{now.day}/{new_filename}{old_extension}"
|
|
@ -449,6 +449,11 @@ form .button.delete {
|
|||
background: var(--color-delete);
|
||||
}
|
||||
|
||||
form button.secondary,
|
||||
form .button.secondary {
|
||||
background: var(--color-bg-menu);
|
||||
}
|
||||
|
||||
form button.toggle,
|
||||
form .button.toggle {
|
||||
background: var(--color-bg-main);
|
||||
|
@ -475,6 +480,13 @@ h1.identity {
|
|||
margin: 15px 0 20px 15px;
|
||||
}
|
||||
|
||||
h1.identity .banner {
|
||||
width: 870px;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
h1.identity .icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
|
|
|
@ -107,3 +107,6 @@ STATICFILES_DIRS = [
|
|||
]
|
||||
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
MEDIA_URL = "/media/"
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
import re
|
||||
|
||||
from django.conf import settings as djsettings
|
||||
from django.contrib import admin as djadmin
|
||||
from django.urls import path
|
||||
from django.urls import path, re_path
|
||||
from django.views.static import serve
|
||||
|
||||
from activities.views import posts, timelines
|
||||
from core import views as core
|
||||
|
@ -18,6 +22,11 @@ urlpatterns = [
|
|||
settings.SettingsRoot.as_view(),
|
||||
name="settings",
|
||||
),
|
||||
path(
|
||||
"settings/profile/",
|
||||
settings.ProfilePage.as_view(),
|
||||
name="settings_profile",
|
||||
),
|
||||
path(
|
||||
"settings/interface/",
|
||||
settings.InterfacePage.as_view(),
|
||||
|
@ -87,4 +96,10 @@ urlpatterns = [
|
|||
path(".stator/runner/", stator.RequestRunner.as_view()),
|
||||
# Django admin
|
||||
path("djadmin/", djadmin.site.urls),
|
||||
# Media files
|
||||
re_path(
|
||||
r"^%s(?P<path>.*)$" % re.escape(djsettings.MEDIA_URL.lstrip("/")),
|
||||
serve,
|
||||
kwargs={"document_root": djsettings.MEDIA_ROOT},
|
||||
),
|
||||
]
|
||||
|
|
|
@ -2,11 +2,7 @@
|
|||
{% load activity_tags %}
|
||||
<div class="post" data-takahe-id="{{ post.id }}">
|
||||
|
||||
{% if post.author.icon_uri %}
|
||||
<img src="{{post.author.icon_uri}}" class="icon">
|
||||
{% else %}
|
||||
<img src="{% static "img/unknown-icon-128.png" %}" class="icon">
|
||||
{% endif %}
|
||||
<img src="{{ post.author.local_icon_url }}" class="icon">
|
||||
|
||||
<time>
|
||||
{% if post.visibility == 0 %}
|
||||
|
|
|
@ -44,11 +44,14 @@
|
|||
{% if not request.identity %}
|
||||
No Identity
|
||||
<img src="{% static "img/unknown-icon-128.png" %}" title="No identity selected">
|
||||
{% elif request.identity.icon %}
|
||||
{{ request.identity.username }}
|
||||
<img src="{{ request.identity.icon.url }}" title="{{ request.identity.handle }}">
|
||||
{% elif request.identity.icon_uri %}
|
||||
{{ request.identity.username }} <small>@{{ request.identity.domain_id }}</small>
|
||||
{{ request.identity.username }}
|
||||
<img src="{{ request.identity.icon_uri }}" title="{{ request.identity.handle }}">
|
||||
{% else %}
|
||||
{{ request.identity.username }} <small>@{{ request.identity.domain_id }}</small>
|
||||
{{ request.identity.username }}
|
||||
<img src="{% static "img/unknown-icon-128.png" %}" title="{{ request.identity.handle }}">
|
||||
{% endif %}
|
||||
</a>
|
||||
|
|
|
@ -9,11 +9,10 @@
|
|||
</nav>
|
||||
|
||||
<h1 class="identity">
|
||||
{% if identity.icon_uri %}
|
||||
<img src="{{identity.icon_uri}}" class="icon">
|
||||
{% else %}
|
||||
<img src="{% static "img/unknown-icon-128.png" %}" class="icon">
|
||||
{% if identity.local_image_url %}
|
||||
<img src="{{ identity.local_image_url }}" class="banner">
|
||||
{% endif %}
|
||||
<img src="{{ identity.local_icon_url }}" class="icon">
|
||||
|
||||
{% if request.identity %}
|
||||
<form action="{{ identity.urls.action }}" method="POST" class="inline follow">
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<nav>
|
||||
<a href="#" {% if section == "profile" %}class="selected"{% endif %}>Profile</a>
|
||||
<a href="{% url "settings_profile" %}" {% if section == "profile" %}class="selected"{% endif %}>Profile</a>
|
||||
<a href="#" {% if section == "interface" %}class="selected"{% endif %}>Interface</a>
|
||||
<a href="#" {% if section == "filtering" %}class="selected"{% endif %}>Filtering</a>
|
||||
</nav>
|
||||
|
|
19
templates/settings/profile.html
Normal file
19
templates/settings/profile.html
Normal file
|
@ -0,0 +1,19 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Profile - Settings{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% block menu %}
|
||||
{% include "settings/_menu.html" %}
|
||||
{% endblock %}
|
||||
<form action="." method="POST" enctype="multipart/form-data" >
|
||||
{% csrf_token %}
|
||||
{% for field in form %}
|
||||
{% include "forms/_field.html" %}
|
||||
{% endfor %}
|
||||
<div class="buttons">
|
||||
<a href="{{ request.identity.urls.view }}" class="button secondary">View Profile</a>
|
||||
<button>Save</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
|
@ -1,5 +1,3 @@
|
|||
import base64
|
||||
import uuid
|
||||
from functools import partial
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
@ -10,9 +8,11 @@ from asgiref.sync import async_to_sync, sync_to_async
|
|||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from django.db import models
|
||||
from django.templatetags.static import static
|
||||
from django.utils import timezone
|
||||
|
||||
from core.ld import canonicalise
|
||||
from core.uploads import upload_namer
|
||||
from stator.models import State, StateField, StateGraph, StatorModel
|
||||
from users.models.domain import Domain
|
||||
|
||||
|
@ -33,15 +33,6 @@ class IdentityStates(StateGraph):
|
|||
return "updated"
|
||||
|
||||
|
||||
def upload_namer(prefix, instance, filename):
|
||||
"""
|
||||
Names uploaded images etc.
|
||||
"""
|
||||
now = timezone.now()
|
||||
filename = base64.b32encode(uuid.uuid4().bytes).decode("ascii")
|
||||
return f"{prefix}/{now.year}/{now.month}/{now.day}/{filename}"
|
||||
|
||||
|
||||
class Identity(StatorModel):
|
||||
"""
|
||||
Represents both local and remote Fediverse identities (actors)
|
||||
|
@ -128,6 +119,26 @@ class Identity(StatorModel):
|
|||
else:
|
||||
return f"/@{self.username}@{self.domain_id}/"
|
||||
|
||||
def local_icon_url(self):
|
||||
"""
|
||||
Returns an icon for us, with fallbacks to a placeholder
|
||||
"""
|
||||
if self.icon:
|
||||
return self.icon.url
|
||||
elif self.icon_uri:
|
||||
return self.icon_uri
|
||||
else:
|
||||
return static("img/unknown-icon-128.png")
|
||||
|
||||
def local_image_url(self):
|
||||
"""
|
||||
Returns a background image for us, returning None if there isn't one
|
||||
"""
|
||||
if self.image:
|
||||
return self.image.url
|
||||
elif self.image_uri:
|
||||
return self.image_uri
|
||||
|
||||
### Alternate constructors/fetchers ###
|
||||
|
||||
@classmethod
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
import re
|
||||
from functools import partial
|
||||
from typing import ClassVar, Dict
|
||||
|
||||
from django import forms
|
||||
from django.db import models
|
||||
|
@ -11,6 +9,7 @@ from django.views.generic import FormView, RedirectView, TemplateView
|
|||
from core.models import Config
|
||||
from users.decorators import admin_required
|
||||
from users.models import Domain, Identity, User
|
||||
from users.views.settings import SettingsPage
|
||||
|
||||
|
||||
@method_decorator(admin_required, name="dispatch")
|
||||
|
@ -19,7 +18,7 @@ class AdminRoot(RedirectView):
|
|||
|
||||
|
||||
@method_decorator(admin_required, name="dispatch")
|
||||
class AdminSettingsPage(FormView):
|
||||
class AdminSettingsPage(SettingsPage):
|
||||
"""
|
||||
Shows a settings page dynamically created from our settings layout
|
||||
at the bottom of the page. Don't add this to a URL directly - subclass!
|
||||
|
@ -27,32 +26,6 @@ class AdminSettingsPage(FormView):
|
|||
|
||||
template_name = "admin/settings.html"
|
||||
options_class = Config.SystemOptions
|
||||
section: ClassVar[str]
|
||||
options: Dict[str, Dict[str, str]]
|
||||
|
||||
def get_form_class(self):
|
||||
# Create the fields dict from the config object
|
||||
fields = {}
|
||||
for key, details in self.options.items():
|
||||
config_field = self.options_class.__fields__[key]
|
||||
if config_field.type_ is bool:
|
||||
form_field = partial(
|
||||
forms.BooleanField,
|
||||
widget=forms.Select(
|
||||
choices=[(True, "Enabled"), (False, "Disabled")]
|
||||
),
|
||||
)
|
||||
elif config_field.type_ is str:
|
||||
form_field = forms.CharField
|
||||
else:
|
||||
raise ValueError(f"Cannot render settings type {config_field.type_}")
|
||||
fields[key] = form_field(
|
||||
label=details["title"],
|
||||
help_text=details.get("help_text", ""),
|
||||
required=details.get("required", False),
|
||||
)
|
||||
# Create a form class dynamically (yeah, right?) and return that
|
||||
return type("SettingsForm", (forms.Form,), fields)
|
||||
|
||||
def load_config(self):
|
||||
return Config.load_system()
|
||||
|
@ -60,27 +33,6 @@ class AdminSettingsPage(FormView):
|
|||
def save_config(self, key, value):
|
||||
Config.set_system(key, value)
|
||||
|
||||
def get_initial(self):
|
||||
config = self.load_config()
|
||||
initial = {}
|
||||
for key in self.options.keys():
|
||||
initial[key] = getattr(config, key)
|
||||
return initial
|
||||
|
||||
def get_context_data(self):
|
||||
context = super().get_context_data()
|
||||
context["section"] = self.section
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
# Save each key
|
||||
for field in form:
|
||||
self.save_config(
|
||||
field.name,
|
||||
form.cleaned_data[field.name],
|
||||
)
|
||||
return redirect(".")
|
||||
|
||||
|
||||
class BasicPage(AdminSettingsPage):
|
||||
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
from functools import partial
|
||||
from typing import ClassVar, Dict
|
||||
|
||||
from django import forms
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.generic import RedirectView
|
||||
from django.views.generic import FormView, RedirectView
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from core.models import Config
|
||||
from users.decorators import identity_required
|
||||
from users.views.admin import AdminSettingsPage
|
||||
|
||||
|
||||
@method_decorator(identity_required, name="dispatch")
|
||||
|
@ -11,7 +16,8 @@ class SettingsRoot(RedirectView):
|
|||
url = "/settings/interface/"
|
||||
|
||||
|
||||
class SettingsPage(AdminSettingsPage):
|
||||
@method_decorator(identity_required, name="dispatch")
|
||||
class SettingsPage(FormView):
|
||||
"""
|
||||
Shows a settings page dynamically created from our settings layout
|
||||
at the bottom of the page. Don't add this to a URL directly - subclass!
|
||||
|
@ -19,6 +25,32 @@ class SettingsPage(AdminSettingsPage):
|
|||
|
||||
options_class = Config.IdentityOptions
|
||||
template_name = "settings/settings.html"
|
||||
section: ClassVar[str]
|
||||
options: Dict[str, Dict[str, str]]
|
||||
|
||||
def get_form_class(self):
|
||||
# Create the fields dict from the config object
|
||||
fields = {}
|
||||
for key, details in self.options.items():
|
||||
config_field = self.options_class.__fields__[key]
|
||||
if config_field.type_ is bool:
|
||||
form_field = partial(
|
||||
forms.BooleanField,
|
||||
widget=forms.Select(
|
||||
choices=[(True, "Enabled"), (False, "Disabled")]
|
||||
),
|
||||
)
|
||||
elif config_field.type_ is str:
|
||||
form_field = forms.CharField
|
||||
else:
|
||||
raise ValueError(f"Cannot render settings type {config_field.type_}")
|
||||
fields[key] = form_field(
|
||||
label=details["title"],
|
||||
help_text=details.get("help_text", ""),
|
||||
required=details.get("required", False),
|
||||
)
|
||||
# Create a form class dynamically (yeah, right?) and return that
|
||||
return type("SettingsForm", (forms.Form,), fields)
|
||||
|
||||
def load_config(self):
|
||||
return Config.load_identity(self.request.identity)
|
||||
|
@ -26,6 +58,27 @@ class SettingsPage(AdminSettingsPage):
|
|||
def save_config(self, key, value):
|
||||
Config.set_identity(self.request.identity, key, value)
|
||||
|
||||
def get_initial(self):
|
||||
config = self.load_config()
|
||||
initial = {}
|
||||
for key in self.options.keys():
|
||||
initial[key] = getattr(config, key)
|
||||
return initial
|
||||
|
||||
def get_context_data(self):
|
||||
context = super().get_context_data()
|
||||
context["section"] = self.section
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
# Save each key
|
||||
for field in form:
|
||||
self.save_config(
|
||||
field.name,
|
||||
form.cleaned_data[field.name],
|
||||
)
|
||||
return redirect(".")
|
||||
|
||||
|
||||
class InterfacePage(SettingsPage):
|
||||
|
||||
|
@ -37,3 +90,49 @@ class InterfacePage(SettingsPage):
|
|||
"help_text": "If enabled, changes all 'Post' buttons to 'Toot!'",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@method_decorator(identity_required, name="dispatch")
|
||||
class ProfilePage(FormView):
|
||||
"""
|
||||
Lets the identity's profile be edited
|
||||
"""
|
||||
|
||||
template_name = "settings/profile.html"
|
||||
|
||||
class form_class(forms.Form):
|
||||
name = forms.CharField(max_length=500)
|
||||
summary = forms.CharField(widget=forms.Textarea, required=False)
|
||||
icon = forms.ImageField(required=False)
|
||||
image = forms.ImageField(required=False)
|
||||
|
||||
def get_initial(self):
|
||||
return {
|
||||
"name": self.request.identity.name,
|
||||
"summary": self.request.identity.summary,
|
||||
}
|
||||
|
||||
def get_context_data(self):
|
||||
context = super().get_context_data()
|
||||
context["section"] = "profile"
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
# Update identity name and summary
|
||||
self.request.identity.name = form.cleaned_data["name"]
|
||||
self.request.identity.summary = form.cleaned_data["summary"]
|
||||
# Resize images
|
||||
icon = form.cleaned_data.get("icon")
|
||||
image = form.cleaned_data.get("image")
|
||||
if icon:
|
||||
resized_image = ImageOps.fit(Image.open(icon), (400, 400))
|
||||
icon.open()
|
||||
resized_image.save(icon)
|
||||
self.request.identity.icon = icon
|
||||
if image:
|
||||
resized_image = ImageOps.fit(Image.open(image), (1500, 500))
|
||||
image.open()
|
||||
resized_image.save(image)
|
||||
self.request.identity.image = image
|
||||
self.request.identity.save()
|
||||
return redirect(".")
|
||||
|
|
Loading…
Reference in a new issue