Add image/icon upload
This commit is contained in:
parent
7f8e792402
commit
f5eafb0ca0
|
@ -1,4 +1,5 @@
|
||||||
*.psql
|
*.psql
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
.venv
|
.venv
|
||||||
|
/media/
|
||||||
notes.md
|
notes.md
|
||||||
|
|
|
@ -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():
|
||||||
|
|
|
@ -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);
|
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;
|
||||||
|
|
|
@ -107,3 +107,6 @@ STATICFILES_DIRS = [
|
||||||
]
|
]
|
||||||
|
|
||||||
ALLOWED_HOSTS = ["*"]
|
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.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},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 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
|
||||||
|
|
|
@ -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):
|
||||||
|
|
||||||
|
|
|
@ -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(".")
|
||||||
|
|
Loading…
Reference in New Issue