Add image/icon upload

This commit is contained in:
Andrew Godwin 2022-11-17 08:21:42 -07:00
parent 7f8e792402
commit f5eafb0ca0
14 changed files with 205 additions and 78 deletions

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
*.psql *.psql
*.sqlite3 *.sqlite3
.venv .venv
/media/
notes.md notes.md

View file

@ -27,8 +27,10 @@ class PostStates(StateGraph):
""" """
post = await instance.afetch_full() post = await instance.afetch_full()
# Non-local posts should not be here # Non-local posts should not be here
# TODO: This seems to keep happening. Work out how?
if not post.local: 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 # Build list of targets - mentions always included
targets = set() targets = set()
async for mention in post.mentions.all(): async for mention in post.mentions.all():

15
core/uploads.py Normal file
View 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}"

View file

@ -449,6 +449,11 @@ form .button.delete {
background: var(--color-delete); background: var(--color-delete);
} }
form button.secondary,
form .button.secondary {
background: var(--color-bg-menu);
}
form button.toggle, form button.toggle,
form .button.toggle { form .button.toggle {
background: var(--color-bg-main); background: var(--color-bg-main);
@ -475,6 +480,13 @@ h1.identity {
margin: 15px 0 20px 15px; margin: 15px 0 20px 15px;
} }
h1.identity .banner {
width: 870px;
height: auto;
display: block;
margin: 0 0 20px 0;
}
h1.identity .icon { h1.identity .icon {
width: 80px; width: 80px;
height: 80px; height: 80px;

View file

@ -107,3 +107,6 @@ STATICFILES_DIRS = [
] ]
ALLOWED_HOSTS = ["*"] ALLOWED_HOSTS = ["*"]
MEDIA_ROOT = BASE_DIR / "media"
MEDIA_URL = "/media/"

View file

@ -1,5 +1,9 @@
import re
from django.conf import settings as djsettings
from django.contrib import admin as djadmin 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 activities.views import posts, timelines
from core import views as core from core import views as core
@ -18,6 +22,11 @@ urlpatterns = [
settings.SettingsRoot.as_view(), settings.SettingsRoot.as_view(),
name="settings", name="settings",
), ),
path(
"settings/profile/",
settings.ProfilePage.as_view(),
name="settings_profile",
),
path( path(
"settings/interface/", "settings/interface/",
settings.InterfacePage.as_view(), settings.InterfacePage.as_view(),
@ -87,4 +96,10 @@ urlpatterns = [
path(".stator/runner/", stator.RequestRunner.as_view()), path(".stator/runner/", stator.RequestRunner.as_view()),
# Django admin # Django admin
path("djadmin/", djadmin.site.urls), 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},
),
] ]

View file

@ -2,11 +2,7 @@
{% load activity_tags %} {% load activity_tags %}
<div class="post" data-takahe-id="{{ post.id }}"> <div class="post" data-takahe-id="{{ post.id }}">
{% if post.author.icon_uri %} <img src="{{ post.author.local_icon_url }}" class="icon">
<img src="{{post.author.icon_uri}}" class="icon">
{% else %}
<img src="{% static "img/unknown-icon-128.png" %}" class="icon">
{% endif %}
<time> <time>
{% if post.visibility == 0 %} {% if post.visibility == 0 %}

View file

@ -44,11 +44,14 @@
{% if not request.identity %} {% if not request.identity %}
No Identity No Identity
<img src="{% static "img/unknown-icon-128.png" %}" title="No identity selected"> <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 %} {% 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 }}"> <img src="{{ request.identity.icon_uri }}" title="{{ request.identity.handle }}">
{% else %} {% 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 }}"> <img src="{% static "img/unknown-icon-128.png" %}" title="{{ request.identity.handle }}">
{% endif %} {% endif %}
</a> </a>

View file

@ -9,11 +9,10 @@
</nav> </nav>
<h1 class="identity"> <h1 class="identity">
{% if identity.icon_uri %} {% if identity.local_image_url %}
<img src="{{identity.icon_uri}}" class="icon"> <img src="{{ identity.local_image_url }}" class="banner">
{% else %}
<img src="{% static "img/unknown-icon-128.png" %}" class="icon">
{% endif %} {% endif %}
<img src="{{ identity.local_icon_url }}" class="icon">
{% if request.identity %} {% if request.identity %}
<form action="{{ identity.urls.action }}" method="POST" class="inline follow"> <form action="{{ identity.urls.action }}" method="POST" class="inline follow">

View file

@ -1,5 +1,5 @@
<nav> <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 == "interface" %}class="selected"{% endif %}>Interface</a>
<a href="#" {% if section == "filtering" %}class="selected"{% endif %}>Filtering</a> <a href="#" {% if section == "filtering" %}class="selected"{% endif %}>Filtering</a>
</nav> </nav>

View 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 %}

View file

@ -1,5 +1,3 @@
import base64
import uuid
from functools import partial from functools import partial
from typing import Optional, Tuple from typing import Optional, Tuple
from urllib.parse import urlparse 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 import serialization
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import rsa
from django.db import models from django.db import models
from django.templatetags.static import static
from django.utils import timezone from django.utils import timezone
from core.ld import canonicalise from core.ld import canonicalise
from core.uploads import upload_namer
from stator.models import State, StateField, StateGraph, StatorModel from stator.models import State, StateField, StateGraph, StatorModel
from users.models.domain import Domain from users.models.domain import Domain
@ -33,15 +33,6 @@ class IdentityStates(StateGraph):
return "updated" 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): class Identity(StatorModel):
""" """
Represents both local and remote Fediverse identities (actors) Represents both local and remote Fediverse identities (actors)
@ -128,6 +119,26 @@ class Identity(StatorModel):
else: else:
return f"/@{self.username}@{self.domain_id}/" 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 ### ### Alternate constructors/fetchers ###
@classmethod @classmethod

View file

@ -1,6 +1,4 @@
import re import re
from functools import partial
from typing import ClassVar, Dict
from django import forms from django import forms
from django.db import models from django.db import models
@ -11,6 +9,7 @@ from django.views.generic import FormView, RedirectView, TemplateView
from core.models import Config from core.models import Config
from users.decorators import admin_required from users.decorators import admin_required
from users.models import Domain, Identity, User from users.models import Domain, Identity, User
from users.views.settings import SettingsPage
@method_decorator(admin_required, name="dispatch") @method_decorator(admin_required, name="dispatch")
@ -19,7 +18,7 @@ class AdminRoot(RedirectView):
@method_decorator(admin_required, name="dispatch") @method_decorator(admin_required, name="dispatch")
class AdminSettingsPage(FormView): class AdminSettingsPage(SettingsPage):
""" """
Shows a settings page dynamically created from our settings layout 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! 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" template_name = "admin/settings.html"
options_class = Config.SystemOptions 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): def load_config(self):
return Config.load_system() return Config.load_system()
@ -60,27 +33,6 @@ class AdminSettingsPage(FormView):
def save_config(self, key, value): def save_config(self, key, value):
Config.set_system(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): class BasicPage(AdminSettingsPage):

View file

@ -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.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 core.models import Config
from users.decorators import identity_required from users.decorators import identity_required
from users.views.admin import AdminSettingsPage
@method_decorator(identity_required, name="dispatch") @method_decorator(identity_required, name="dispatch")
@ -11,7 +16,8 @@ class SettingsRoot(RedirectView):
url = "/settings/interface/" 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 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! 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 options_class = Config.IdentityOptions
template_name = "settings/settings.html" 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): def load_config(self):
return Config.load_identity(self.request.identity) return Config.load_identity(self.request.identity)
@ -26,6 +58,27 @@ class SettingsPage(AdminSettingsPage):
def save_config(self, key, value): def save_config(self, key, value):
Config.set_identity(self.request.identity, 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): class InterfacePage(SettingsPage):
@ -37,3 +90,49 @@ class InterfacePage(SettingsPage):
"help_text": "If enabled, changes all 'Post' buttons to 'Toot!'", "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(".")