Migration reset, start of docs, env vars

This commit is contained in:
Andrew Godwin 2022-11-18 08:28:15 -07:00
parent 1b44a25331
commit 81de10b70c
43 changed files with 679 additions and 679 deletions

1
.gitignore vendored
View file

@ -2,5 +2,6 @@
*.sqlite3
.venv
/*.env
/docs/_build
/media/
notes.md

View file

@ -1,9 +1,16 @@
# Generated by Django 4.1.3 on 2022-11-11 20:02
# Generated by Django 4.1.3 on 2022-11-18 17:49
import functools
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
import activities.models.fan_out
import activities.models.post
import activities.models.post_attachment
import activities.models.post_interaction
import core.uploads
import stator.models
@ -42,7 +49,12 @@ class Migration(migrations.Migration):
),
),
("local", models.BooleanField()),
("object_uri", models.CharField(blank=True, max_length=500, null=True)),
(
"object_uri",
models.CharField(
blank=True, max_length=500, null=True, unique=True
),
),
(
"visibility",
models.IntegerField(
@ -63,26 +75,222 @@ class Migration(migrations.Migration):
"in_reply_to",
models.CharField(blank=True, max_length=500, null=True),
),
("hashtags", models.JSONField(blank=True, null=True)),
("published", models.DateTimeField(default=django.utils.timezone.now)),
("edited", models.DateTimeField(blank=True, null=True)),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
(
"author",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="statuses",
related_name="posts",
to="users.identity",
),
),
(
"mentions",
models.ManyToManyField(
related_name="posts_mentioning", to="users.identity"
blank=True, related_name="posts_mentioning", to="users.identity"
),
),
(
"to",
models.ManyToManyField(
related_name="posts_to", to="users.identity"
blank=True, related_name="posts_to", to="users.identity"
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="PostInteraction",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("state_ready", models.BooleanField(default=True)),
("state_changed", models.DateTimeField(auto_now_add=True)),
("state_attempted", models.DateTimeField(blank=True, null=True)),
("state_locked_until", models.DateTimeField(blank=True, null=True)),
(
"state",
stator.models.StateField(
choices=[
("new", "new"),
("fanned_out", "fanned_out"),
("undone", "undone"),
("undone_fanned_out", "undone_fanned_out"),
],
default="new",
graph=activities.models.post_interaction.PostInteractionStates,
max_length=100,
),
),
(
"object_uri",
models.CharField(
blank=True, max_length=500, null=True, unique=True
),
),
(
"type",
models.CharField(
choices=[("like", "Like"), ("boost", "Boost")], max_length=100
),
),
("published", models.DateTimeField(default=django.utils.timezone.now)),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
(
"identity",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="interactions",
to="users.identity",
),
),
(
"post",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="interactions",
to="activities.post",
),
),
],
options={
"index_together": {("type", "identity", "post")},
},
),
migrations.CreateModel(
name="PostAttachment",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("state_ready", models.BooleanField(default=True)),
("state_changed", models.DateTimeField(auto_now_add=True)),
("state_attempted", models.DateTimeField(blank=True, null=True)),
("state_locked_until", models.DateTimeField(blank=True, null=True)),
(
"state",
stator.models.StateField(
choices=[("new", "new"), ("fetched", "fetched")],
default="new",
graph=activities.models.post_attachment.PostAttachmentStates,
max_length=100,
),
),
("mimetype", models.CharField(max_length=200)),
(
"file",
models.FileField(
blank=True,
null=True,
upload_to=functools.partial(
core.uploads.upload_namer, *("attachments",), **{}
),
),
),
("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.IntegerField(blank=True, null=True)),
("focal_y", models.IntegerField(blank=True, null=True)),
("blurhash", models.TextField(blank=True, null=True)),
(
"post",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="attachments",
to="activities.post",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="FanOut",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("state_ready", models.BooleanField(default=True)),
("state_changed", models.DateTimeField(auto_now_add=True)),
("state_attempted", models.DateTimeField(blank=True, null=True)),
("state_locked_until", models.DateTimeField(blank=True, null=True)),
(
"state",
stator.models.StateField(
choices=[("new", "new"), ("sent", "sent")],
default="new",
graph=activities.models.fan_out.FanOutStates,
max_length=100,
),
),
(
"type",
models.CharField(
choices=[
("post", "Post"),
("interaction", "Interaction"),
("undo_interaction", "Undo Interaction"),
],
max_length=100,
),
),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
(
"identity",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="fan_outs",
to="users.identity",
),
),
(
"subject_post",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="fan_outs",
to="activities.post",
),
),
(
"subject_post_interaction",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="fan_outs",
to="activities.postinteraction",
),
),
],
@ -107,10 +315,11 @@ class Migration(migrations.Migration):
models.CharField(
choices=[
("post", "Post"),
("mention", "Mention"),
("like", "Like"),
("follow", "Follow"),
("boost", "Boost"),
("mentioned", "Mentioned"),
("liked", "Liked"),
("followed", "Followed"),
("boosted", "Boosted"),
],
max_length=100,
),
@ -140,15 +349,25 @@ class Migration(migrations.Migration):
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="timeline_events_about_us",
related_name="timeline_events",
to="activities.post",
),
),
(
"subject_post_interaction",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="timeline_events",
to="activities.postinteraction",
),
),
],
options={
"index_together": {
("identity", "type", "subject_post", "subject_identity"),
("identity", "type", "subject_identity"),
("identity", "type", "subject_post", "subject_identity"),
},
},
),

View file

@ -1,103 +0,0 @@
# Generated by Django 4.1.3 on 2022-11-12 05:36
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
import activities.models.fan_out
import stator.models
class Migration(migrations.Migration):
dependencies = [
("users", "0001_initial"),
("activities", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="post",
name="authored",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AlterField(
model_name="post",
name="author",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="posts",
to="users.identity",
),
),
migrations.AlterField(
model_name="post",
name="mentions",
field=models.ManyToManyField(
blank=True, related_name="posts_mentioning", to="users.identity"
),
),
migrations.AlterField(
model_name="post",
name="to",
field=models.ManyToManyField(
blank=True, related_name="posts_to", to="users.identity"
),
),
migrations.CreateModel(
name="FanOut",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("state_ready", models.BooleanField(default=True)),
("state_changed", models.DateTimeField(auto_now_add=True)),
("state_attempted", models.DateTimeField(blank=True, null=True)),
("state_locked_until", models.DateTimeField(blank=True, null=True)),
(
"state",
stator.models.StateField(
choices=[("new", "new"), ("sent", "sent")],
default="new",
graph=activities.models.fan_out.FanOutStates,
max_length=100,
),
),
(
"type",
models.CharField(
choices=[("post", "Post"), ("boost", "Boost")], max_length=100
),
),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
(
"identity",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="fan_outs",
to="users.identity",
),
),
(
"subject_post",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="fan_outs",
to="activities.post",
),
),
],
options={
"abstract": False,
},
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 4.1.3 on 2022-11-13 03:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("activities", "0002_fan_out"),
]
operations = [
migrations.AlterField(
model_name="post",
name="object_uri",
field=models.CharField(blank=True, max_length=500, null=True, unique=True),
),
]

View file

@ -1,126 +0,0 @@
# Generated by Django 4.1.3 on 2022-11-14 00:41
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
import activities.models.post_interaction
import stator.models
class Migration(migrations.Migration):
dependencies = [
("users", "0002_identity_public_key_id"),
("activities", "0003_alter_post_object_uri"),
]
operations = [
migrations.RenameField(
model_name="post",
old_name="authored",
new_name="published",
),
migrations.AlterField(
model_name="fanout",
name="type",
field=models.CharField(
choices=[("post", "Post"), ("interaction", "Interaction")],
max_length=100,
),
),
migrations.AlterField(
model_name="timelineevent",
name="subject_post",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="timeline_events",
to="activities.post",
),
),
migrations.CreateModel(
name="PostInteraction",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("state_ready", models.BooleanField(default=True)),
("state_changed", models.DateTimeField(auto_now_add=True)),
("state_attempted", models.DateTimeField(blank=True, null=True)),
("state_locked_until", models.DateTimeField(blank=True, null=True)),
(
"state",
stator.models.StateField(
choices=[("new", "new"), ("fanned_out", "fanned_out")],
default="new",
graph=activities.models.post_interaction.PostInteractionStates,
max_length=100,
),
),
(
"object_uri",
models.CharField(
blank=True, max_length=500, null=True, unique=True
),
),
(
"type",
models.CharField(
choices=[("like", "Like"), ("boost", "Boost")], max_length=100
),
),
("published", models.DateTimeField(default=django.utils.timezone.now)),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
(
"identity",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="interactions",
to="users.identity",
),
),
(
"post",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="interactions",
to="activities.post",
),
),
],
options={
"index_together": {("type", "identity", "post")},
},
),
migrations.AddField(
model_name="fanout",
name="subject_post_interaction",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="fan_outs",
to="activities.postinteraction",
),
),
migrations.AddField(
model_name="timelineevent",
name="subject_post_interaction",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="timeline_events",
to="activities.postinteraction",
),
),
]

View file

@ -1,48 +0,0 @@
# Generated by Django 4.1.3 on 2022-11-16 20:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"activities",
"0004_rename_authored_post_published_alter_fanout_type_and_more",
),
]
operations = [
migrations.AddField(
model_name="post",
name="hashtags",
field=models.JSONField(default=[]),
),
migrations.AlterField(
model_name="fanout",
name="type",
field=models.CharField(
choices=[
("post", "Post"),
("interaction", "Interaction"),
("undo_interaction", "Undo Interaction"),
],
max_length=100,
),
),
migrations.AlterField(
model_name="timelineevent",
name="type",
field=models.CharField(
choices=[
("post", "Post"),
("boost", "Boost"),
("mentioned", "Mentioned"),
("liked", "Liked"),
("followed", "Followed"),
("boosted", "Boosted"),
],
max_length=100,
),
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 4.1.3 on 2022-11-17 04:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("activities", "0005_post_hashtags_alter_fanout_type_and_more"),
]
operations = [
migrations.AlterField(
model_name="post",
name="hashtags",
field=models.JSONField(blank=True, null=True),
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 4.1.3 on 2022-11-17 04:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("activities", "0006_alter_post_hashtags"),
]
operations = [
migrations.AddField(
model_name="post",
name="edited",
field=models.DateTimeField(blank=True, null=True),
),
]

View file

@ -1,69 +0,0 @@
# Generated by Django 4.1.3 on 2022-11-17 05:42
import django.db.models.deletion
from django.db import migrations, models
import activities.models.post_attachment
import stator.models
class Migration(migrations.Migration):
dependencies = [
("activities", "0007_post_edited"),
]
operations = [
migrations.CreateModel(
name="PostAttachment",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("state_ready", models.BooleanField(default=True)),
("state_changed", models.DateTimeField(auto_now_add=True)),
("state_attempted", models.DateTimeField(blank=True, null=True)),
("state_locked_until", models.DateTimeField(blank=True, null=True)),
(
"state",
stator.models.StateField(
choices=[("new", "new"), ("fetched", "fetched")],
default="new",
graph=activities.models.post_attachment.PostAttachmentStates,
max_length=100,
),
),
("mimetype", models.CharField(max_length=200)),
(
"file",
models.FileField(
blank=True, null=True, upload_to="attachments/%Y/%m/%d/"
),
),
("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.IntegerField(blank=True, null=True)),
("focal_y", models.IntegerField(blank=True, null=True)),
("blurhash", models.TextField(blank=True, null=True)),
(
"post",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="attachments",
to="activities.post",
),
),
],
options={
"abstract": False,
},
),
]

View file

@ -1,28 +0,0 @@
# Generated by Django 4.1.3 on 2022-11-18 01:40
import functools
from django.db import migrations, models
import core.uploads
class Migration(migrations.Migration):
dependencies = [
("activities", "0008_postattachment"),
]
operations = [
migrations.AlterField(
model_name="postattachment",
name="file",
field=models.FileField(
blank=True,
null=True,
upload_to=functools.partial(
core.uploads.upload_namer, *("attachments",), **{}
),
),
),
]

View file

@ -9,7 +9,4 @@ class CoreConfig(AppConfig):
name = "core"
def ready(self) -> None:
from core.models import Config
Config.system = Config.load_system()
jsonld.set_document_loader(builtin_document_loader)

View file

@ -1,16 +1,20 @@
# Generated by Django 4.1.3 on 2022-11-16 21:23
# Generated by Django 4.1.3 on 2022-11-18 17:49
import functools
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import core.uploads
class Migration(migrations.Migration):
initial = True
dependencies = [
("users", "0002_identity_public_key_id"),
("users", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
@ -32,7 +36,11 @@ class Migration(migrations.Migration):
(
"image",
models.ImageField(
blank=True, null=True, upload_to="config/%Y/%m/%d/"
blank=True,
null=True,
upload_to=functools.partial(
core.uploads.upload_namer, *("config",), **{}
),
),
),
(

View file

@ -1,28 +0,0 @@
# Generated by Django 4.1.3 on 2022-11-18 01:40
import functools
from django.db import migrations, models
import core.uploads
class Migration(migrations.Migration):
dependencies = [
("core", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="config",
name="image",
field=models.ImageField(
blank=True,
null=True,
upload_to=functools.partial(
core.uploads.upload_namer, *("config",), **{}
),
),
),
]

View file

@ -160,7 +160,7 @@ class Config(models.Model):
site_icon: UploadedImage = static("img/icon-128.png")
site_banner: UploadedImage = static("img/fjords-banner-600.jpg")
signup_allowed: bool = False
signup_allowed: bool = True
signup_invite_only: bool = False
signup_text: str = ""

20
docs/Makefile Normal file
View file

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

26
docs/conf.py Normal file
View file

@ -0,0 +1,26 @@
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = "Takahē"
copyright = "2022, Andrew Godwin"
author = "Andrew Godwin"
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions: list = []
templates_path = ["_templates"]
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = "alabaster"
html_static_path = ["_static"]

13
docs/index.rst Normal file
View file

@ -0,0 +1,13 @@
Takahē
======
Welcome to the Takahē documentation! Takahē is an ActivityPub server, designed
for low- to medium-size installations, and with the ability to serve multiple
domains at once.
.. toctree::
:maxdepth: 2
:caption: Contents:
installation

76
docs/installation.rst Normal file
View file

@ -0,0 +1,76 @@
Installation
============
We recommend running using the Docker/OCI image; this contains all of the
necessary dependencies and static file handling preconfigured for you.
All configuration is done via either environment variables, or online through
the web interface.
Prerequisites
-------------
* SSL support (Takahē *requires* HTTPS)
* Something that can run Docker/OCI images ("serverless" platforms are fine!)
* A PostgreSQL 14 (or above) database
* One of these to store uploaded images and media:
* Amazon S3
* Google Cloud Storage
* Writable local directory (must be accessible by all running copies!)
Environment Variables
---------------------
All of these variables are *required* for a working installation, and should
be provided from the first boot.
* ``PGHOST``, ``PGPORT``, ``PGUSER``, ``PGDATABASE``, and ``PGPASSWORD`` are the
standard PostgreSQL environment variables for configuring your database.
* ``TAKAHE_MEDIA_BACKEND`` must be one of ``local``, ``s3`` or ``gcs``.
* If it is set to ``local``, you must also provide ``TAKAHE_MEDIA_ROOT``,
the path to the local media directory, and ``TAKAHE_MEDIA_URL``, a
fully-qualified URL prefix that serves that directory.
* If it is set to ``gcs``, you must also provide ``TAKAHE_MEDIA_BUCKET``,
the name of the bucket to store files in.
* If it is set to ``s3``, you must also provide ``TAKAHE_MEDIA_BUCKET``,
the name of the bucket to store files in.
* ``TAKAHE_MAIN_DOMAIN`` should be the domain name (without ``https://``) that
will be used for default links (such as in emails). It does *not* need to be
the same as any domain you are hosting user accounts on.
* ``TAKAHE_EMAIL_HOST`` and ``TAKAHE_EMAIL_PORT`` (along with
``TAKAHE_EMAIL_USER`` and ``TAKAHE_EMAIL_PASSWORD``, if needed) should point
to an SMTP server Takahe can use for sending email. Email is *required*, to
allow account creation and password resets.
* If you are using SendGrid, you can just set an API key in
``TAKAHE_EMAIL_SENDGRID_KEY`` instead.
* ``TAKAHE_EMAIL_FROM`` is the email address that emails from the system will
appear to come from.
* ``TAKAHE_AUTO_ADMIN_EMAIL`` should be an email address that you would like to
be automatically promoted to administrator when it signs up. You only need
this for initial setup, and can unset it after that if you like.
Making An Admin Account
-----------------------
Once the webserver is up and working, go to the "create account" flow and
create a new account using the email you specified in
``TAKAHE_AUTO_ADMIN_EMAIL``.
Once you set your password using the link emailed to you, you will have an
admin account.
If your email settings have a problem and you don't get the email, don't worry;
fix them and then follow the "reset my password" flow on the login screen, and
you'll get another password reset email that you can use.

35
docs/make.bat Normal file
View file

@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://www.sphinx-doc.org/
exit /b 1
)
if "%1" == "" goto help
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

View file

@ -11,3 +11,4 @@ psycopg2~=2.9.5
bleach~=5.0.1
pydantic~=1.10.2
django-htmx~=1.13.0
django-storages[google,boto3]~=1.13.1

View file

@ -549,6 +549,10 @@ form .buttons {
margin: -20px 0 15px 0;
}
form p+.buttons {
margin-top: 0;
}
.right-column form .buttons {
margin: 5px 10px 5px 0;
}

View file

@ -4,6 +4,7 @@ from asgiref.sync import async_to_sync
from django.apps import apps
from django.core.management.base import BaseCommand
from core.models import Config
from stator.models import StatorModel
from stator.runner import StatorRunner
@ -22,6 +23,8 @@ class Command(BaseCommand):
parser.add_argument("model_labels", nargs="*", type=str)
def handle(self, model_labels: List[str], concurrency: int, *args, **options):
# Cache system config
Config.system = Config.load_system()
# Resolve the models list into names
models = cast(
List[Type[StatorModel]],

View file

@ -1,4 +1,4 @@
# Generated by Django 4.1.3 on 2022-11-10 05:56
# Generated by Django 4.1.3 on 2022-11-18 17:49
from django.db import migrations, models

View file

@ -1,5 +1,7 @@
import os
import sys
from pathlib import Path
from typing import Optional
BASE_DIR = Path(__file__).resolve().parent.parent.parent
@ -56,11 +58,11 @@ WSGI_APPLICATION = "takahe.wsgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql_psycopg2",
"HOST": os.environ.get("POSTGRES_HOST", "localhost"),
"PORT": os.environ.get("POSTGRES_PORT", 5432),
"NAME": os.environ.get("POSTGRES_DB", "takahe"),
"USER": os.environ.get("POSTGRES_USER", "postgres"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD"),
"HOST": os.environ.get("PGHOST", "localhost"),
"PORT": os.environ.get("PGPORT", 5432),
"NAME": os.environ.get("PGDATABASE", "takahe"),
"USER": os.environ.get("PGUSER", "postgres"),
"PASSWORD": os.environ.get("PGPASSWORD"),
}
}
@ -109,12 +111,47 @@ STATICFILES_DIRS = [
ALLOWED_HOSTS = ["*"]
### User-configurable options, pulled from the environment ###
MAIN_DOMAIN = os.environ["TAKAHE_MAIN_DOMAIN"]
if "/" in MAIN_DOMAIN:
print("TAKAHE_MAIN_DOMAIN should be just the domain name - no https:// or path")
sys.exit(1)
EMAIL_FROM = os.environ["TAKAHE_EMAIL_FROM"]
# Note that this MUST be a fully qualified URL in production
MEDIA_URL = os.environ.get("TAKAHE_MEDIA_URL", "/media/")
MEDIA_ROOT = os.environ.get("TAKAHE_MEDIA_ROOT", BASE_DIR / "media")
if os.environ.get("TAKAHE_EMAIL_CONSOLE_ONLY"):
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
EMAIL_FROM = "test@example.com"
else:
EMAIL_FROM = os.environ["TAKAHE_EMAIL_FROM"]
if "TAKAHE_EMAIL_SENDGRID_KEY" in os.environ:
EMAIL_HOST = "smtp.sendgrid.net"
EMAIL_PORT = 587
EMAIL_HOST_USER: Optional[str] = "apikey"
EMAIL_HOST_PASSWORD: Optional[str] = os.environ["TAKAHE_EMAIL_SENDGRID_KEY"]
EMAIL_USE_TLS = True
else:
EMAIL_HOST = os.environ["TAKAHE_EMAIL_HOST"]
EMAIL_PORT = int(os.environ["TAKAHE_EMAIL_PORT"])
EMAIL_HOST_USER = os.environ.get("TAKAHE_EMAIL_USER")
EMAIL_HOST_PASSWORD = os.environ.get("TAKAHE_EMAIL_PASSWORD")
EMAIL_USE_SSL = EMAIL_PORT == 465
EMAIL_USE_TLS = EMAIL_PORT == 587
AUTO_ADMIN_EMAIL = os.environ.get("TAKAHE_AUTO_ADMIN_EMAIL")
# Set up media storage
MEDIA_BACKEND = os.environ.get("TAKAHE_MEDIA_BACKEND", None)
if MEDIA_BACKEND == "local":
# Note that this MUST be a fully qualified URL in production
MEDIA_URL = os.environ.get("TAKAHE_MEDIA_URL", "/media/")
MEDIA_ROOT = os.environ.get("TAKAHE_MEDIA_ROOT", BASE_DIR / "media")
elif MEDIA_BACKEND == "gcs":
DEFAULT_FILE_STORAGE = "storages.backends.gcloud.GoogleCloudStorage"
GS_BUCKET_NAME = os.environ["TAKAHE_MEDIA_BUCKET"]
elif MEDIA_BACKEND == "s3":
DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
AWS_STORAGE_BUCKET_NAME = os.environ["TAKAHE_MEDIA_BUCKET"]
else:
print("Unknown TAKAHE_MEDIA_BACKEND value")
sys.exit(1)

View file

@ -86,8 +86,7 @@ urlpatterns = [
),
# Identity views
path("@<handle>/", identity.ViewIdentity.as_view()),
path("@<handle>/actor/", activitypub.Actor.as_view()),
path("@<handle>/actor/inbox/", activitypub.Inbox.as_view()),
path("@<handle>/inbox/", activitypub.Inbox.as_view()),
path("@<handle>/action/", identity.ActionIdentity.as_view()),
# Posts
path("compose/", posts.Compose.as_view(), name="compose"),
@ -109,6 +108,8 @@ urlpatterns = [
# Well-known endpoints
path(".well-known/webfinger", activitypub.Webfinger.as_view()),
path(".well-known/host-meta", activitypub.HostMeta.as_view()),
path(".well-known/nodeinfo", activitypub.NodeInfo.as_view()),
path("nodeinfo/2.0/", activitypub.NodeInfo2.as_view()),
# Task runner
path(".stator/runner/", stator.RequestRunner.as_view()),
# Django admin

View file

@ -33,8 +33,10 @@
<fieldset>
<legend>Access Control</legend>
{% include "forms/_field.html" with field=form.public %}
{% include "forms/_field.html" with field=form.default %}
</fieldset>
<div class="buttons">
<a href="{% url "admin_domains" %}" class="button secondary left">Back</a>
<button>Create</button>
</div>
</form>

View file

@ -13,8 +13,10 @@
<fieldset>
<legend>Access Control</legend>
{% include "forms/_field.html" with field=form.public %}
{% include "forms/_field.html" with field=form.default %}
</fieldset>
<div class="buttons">
<a href="{{ domain.urls.root }}" class="button secondary left">Back</a>
<a href="{{ domain.urls.delete }}" class="button delete">Delete</a>
<button>Save</button>
</div>

View file

@ -14,6 +14,9 @@
{% if domain.service_domain %}({{ domain.service_domain }}){% endif %}
</small>
</span>
{% if domain.default %}
<span class="pill">Default</span>
{% endif %}
</a>
{% empty %}
<p class="option empty">You have no domains set up.</p>

View file

@ -12,6 +12,7 @@
{% endfor %}
</fieldset>
<div class="buttons">
<a href="{% url "trigger_reset" %}" class="secondary button left">Forgot Password</a>
<button>Login</button>
</div>
</form>

View file

@ -1,13 +1,14 @@
{% extends "base.html" %}
{% block title %}Password Reset{% endblock %}
{% block title %}Password Set{% endblock %}
{% block content %}
<form>
<fieldset>
<legend>Password Reset</legend>
<legend>Password Set</legend>
<p>
Your password for <tt>{{ email }}</tt> has been reset!
Your password for <tt>{{ email }}</tt> has been set. You can
now <a href="/auth/login/">login</a>.
</p>
</fieldset>
</form>

View file

@ -12,8 +12,8 @@
</fieldset>
<fieldset>
<legend>Images</legend>
{% include "forms/_field.html" with field=form.icon preview=request.identity.icon.url %}
{% include "forms/_field.html" with field=form.image preview=request.identity.image.url %}
{% include "forms/_field.html" with field=form.icon %}
{% include "forms/_field.html" with field=form.image %}
</fieldset>
<div class="buttons">
<a href="{{ request.identity.urls.view }}" class="button secondary left">View Profile</a>

View file

@ -1,4 +1,4 @@
# Generated by Django 4.1.3 on 2022-11-11 20:02
# Generated by Django 4.1.3 on 2022-11-18 17:49
import functools
@ -6,10 +6,12 @@ import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import core.uploads
import stator.models
import users.models.follow
import users.models.identity
import users.models.inbox_message
import users.models.password_reset
class Migration(migrations.Migration):
@ -45,6 +47,7 @@ class Migration(migrations.Migration):
("deleted", models.BooleanField(default=False)),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
("last_seen", models.DateTimeField(auto_now_add=True)),
],
options={
"abstract": False,
@ -70,6 +73,7 @@ class Migration(migrations.Migration):
("local", models.BooleanField()),
("blocked", models.BooleanField(default=False)),
("public", models.BooleanField(default=False)),
("default", models.BooleanField(default=False)),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
(
@ -111,6 +115,25 @@ class Migration(migrations.Migration):
"abstract": False,
},
),
migrations.CreateModel(
name="Invite",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("token", models.CharField(max_length=500, unique=True)),
("email", models.EmailField(blank=True, max_length=254, null=True)),
("note", models.TextField(blank=True, null=True)),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name="UserEvent",
fields=[
@ -146,6 +169,48 @@ class Migration(migrations.Migration):
),
],
),
migrations.CreateModel(
name="PasswordReset",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("state_ready", models.BooleanField(default=True)),
("state_changed", models.DateTimeField(auto_now_add=True)),
("state_attempted", models.DateTimeField(blank=True, null=True)),
("state_locked_until", models.DateTimeField(blank=True, null=True)),
(
"state",
stator.models.StateField(
choices=[("new", "new"), ("sent", "sent")],
default="new",
graph=users.models.password_reset.PasswordResetStates,
max_length=100,
),
),
("token", models.CharField(max_length=500, unique=True)),
("new_account", models.BooleanField()),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="password_resets",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="Identity",
fields=[
@ -194,9 +259,7 @@ class Migration(migrations.Migration):
blank=True,
null=True,
upload_to=functools.partial(
users.models.identity.upload_namer,
*("profile_images",),
**{},
core.uploads.upload_namer, *("profile_images",), **{}
),
),
),
@ -206,14 +269,13 @@ class Migration(migrations.Migration):
blank=True,
null=True,
upload_to=functools.partial(
users.models.identity.upload_namer,
*("background_images",),
**{},
core.uploads.upload_namer, *("background_images",), **{}
),
),
),
("private_key", models.TextField(blank=True, null=True)),
("public_key", models.TextField(blank=True, null=True)),
("public_key_id", models.TextField(blank=True, null=True)),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
("fetched", models.DateTimeField(blank=True, null=True)),
@ -224,6 +286,7 @@ class Migration(migrations.Migration):
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="identities",
to="users.domain",
),
),
@ -302,7 +365,7 @@ class Migration(migrations.Migration):
("local_requested", "local_requested"),
("remote_requested", "remote_requested"),
("accepted", "accepted"),
("undone_locally", "undone_locally"),
("undone", "undone"),
("undone_remotely", "undone_remotely"),
],
default="unrequested",

View file

@ -1,18 +0,0 @@
# Generated by Django 4.1.3 on 2022-11-12 21:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="identity",
name="public_key_id",
field=models.TextField(blank=True, null=True),
),
]

View file

@ -1,34 +0,0 @@
# Generated by Django 4.1.3 on 2022-11-17 04:18
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0002_identity_public_key_id"),
]
operations = [
migrations.AddField(
model_name="user",
name="last_seen",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AlterField(
model_name="identity",
name="domain",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="identities",
to="users.domain",
),
),
]

View file

@ -1,60 +0,0 @@
# Generated by Django 4.1.3 on 2022-11-18 01:40
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import stator.models
import users.models.password_reset
class Migration(migrations.Migration):
dependencies = [
("users", "0003_user_last_seen_alter_identity_domain"),
]
operations = [
migrations.CreateModel(
name="PasswordReset",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("state_ready", models.BooleanField(default=True)),
("state_changed", models.DateTimeField(auto_now_add=True)),
("state_attempted", models.DateTimeField(blank=True, null=True)),
("state_locked_until", models.DateTimeField(blank=True, null=True)),
(
"state",
stator.models.StateField(
choices=[("new", "new"), ("sent", "sent")],
default="new",
graph=users.models.password_reset.PasswordResetStates,
max_length=100,
),
),
("token", models.CharField(max_length=500, unique=True)),
("new_account", models.BooleanField()),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="password_resets",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
]

View file

@ -1,32 +0,0 @@
# Generated by Django 4.1.3 on 2022-11-18 06:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0004_passwordreset"),
]
operations = [
migrations.CreateModel(
name="Invite",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("token", models.CharField(max_length=500, unique=True)),
("email", models.EmailField(blank=True, max_length=254, null=True)),
("note", models.TextField(blank=True, null=True)),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
],
),
]

View file

@ -41,6 +41,9 @@ class Domain(models.Model):
# should)
public = models.BooleanField(default=False)
# If this is the default domain (shown as the default entry for new users)
default = models.BooleanField(default=False)
# Domains can also be linked to one or more users for their private use
# This should be display domains ONLY
users = models.ManyToManyField("users.User", related_name="domains", blank=True)
@ -52,7 +55,7 @@ class Domain(models.Model):
root = "/admin/domains/"
create = "/admin/domains/create/"
edit = "/admin/domains/{self.domain}/"
delete = "/admin/domains/{self.domain}/delete/"
delete = "{edit}delete/"
@classmethod
def get_remote_domain(cls, domain: str) -> "Domain":
@ -81,7 +84,7 @@ class Domain(models.Model):
return cls.objects.filter(
models.Q(public=True) | models.Q(users__id=user.id),
local=True,
)
).order_by("-default", "domain")
def __str__(self):
return self.domain

View file

@ -12,7 +12,7 @@ from stator.models import State, StateField, StateGraph, StatorModel
class PasswordResetStates(StateGraph):
new = State(try_interval=3)
new = State(try_interval=300)
sent = State()
new.transitions_to(sent)

View file

@ -1,18 +1,22 @@
import json
from asgiref.sync import async_to_sync
from django.conf import settings
from django.http import Http404, HttpResponse, HttpResponseBadRequest, JsonResponse
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import View
from activities.models import Post
from core.ld import canonicalise
from core.models import Config
from core.signatures import (
HttpSignature,
LDSignature,
VerificationError,
VerificationFormatError,
)
from takahe import __version__
from users.models import Identity, InboxMessage
from users.shortcuts import by_handle_or_404
@ -37,6 +41,51 @@ class HostMeta(View):
)
class NodeInfo(View):
"""
Returns the well-known nodeinfo response, pointing to the 2.0 one
"""
def get(self, request):
host = request.META.get("HOST", settings.MAIN_DOMAIN)
return JsonResponse(
{
"links": [
{
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
"href": f"https://{host}/nodeinfo/2.0/",
}
]
}
)
class NodeInfo2(View):
"""
Returns the nodeinfo 2.0 response
"""
def get(self, request):
# Fetch some user stats
local_identities = Identity.objects.filter(local=True).count()
local_posts = Post.objects.filter(local=True).count()
return JsonResponse(
{
"version": "2.0",
"software": {"name": "takahe", "version": __version__},
"protocols": ["activitypub"],
"services": {"outbound": [], "inbound": []},
"usage": {
"users": {"total": local_identities},
"localPosts": local_posts,
},
"openRegistrations": Config.system.signup_allowed
and not Config.system.signup_invite_only,
"metadata": {},
}
)
class Webfinger(View):
"""
Services webfinger requests
@ -70,16 +119,6 @@ class Webfinger(View):
)
class Actor(View):
"""
Returns the AP Actor object
"""
def get(self, request, handle):
identity = by_handle_or_404(self.request, handle)
return JsonResponse(canonicalise(identity.to_ap(), include_security=True))
@method_decorator(csrf_exempt, name="dispatch")
class Inbox(View):
"""

View file

@ -41,6 +41,11 @@ class DomainCreate(FormView):
widget=forms.Select(choices=[(True, "Public"), (False, "Private")]),
required=False,
)
default = forms.BooleanField(
help_text="If this is the default option for new identities",
widget=forms.Select(choices=[(True, "Yes"), (False, "No")]),
required=False,
)
domain_regex = re.compile(
r"^((?!-))(xn--)?[a-z0-9][a-z0-9-_]{0,61}[a-z0-9]{0,1}\.(xn--)?([a-z0-9\-]{1,61}|[a-z0-9-]{1,30}\.[a-z]{2,})$"
@ -72,13 +77,22 @@ class DomainCreate(FormView):
)
return self.cleaned_data["service_domain"]
def clean_default(self):
value = self.cleaned_data["default"]
if value and not self.cleaned_data.get("public"):
raise forms.ValidationError("A non-public domain cannot be the default")
return value
def form_valid(self, form):
Domain.objects.create(
domain = Domain.objects.create(
domain=form.cleaned_data["domain"],
service_domain=form.cleaned_data["service_domain"] or None,
public=form.cleaned_data["public"],
default=form.cleaned_data["default"],
local=True,
)
if domain.default:
Domain.objects.exclude(pk=domain.pk).update(default=False)
return redirect(Domain.urls.root)
@ -88,21 +102,17 @@ class DomainEdit(FormView):
template_name = "admin/domain_edit.html"
extra_context = {"section": "domains"}
class form_class(forms.Form):
domain = forms.CharField(
help_text="The domain displayed as part of a user's identity.\nCannot be changed after the domain has been created.",
disabled=True,
)
service_domain = forms.CharField(
help_text="Optional - a domain that serves Takahē if it is not running on the main domain.\nCannot be changed after the domain has been created.",
disabled=True,
required=False,
)
public = forms.BooleanField(
help_text="If any user on this server can create identities here",
widget=forms.Select(choices=[(True, "Public"), (False, "Private")]),
required=False,
)
class form_class(DomainCreate.form_class):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["domain"].disabled = True
self.fields["service_domain"].disabled = True
def clean_domain(self):
return self.cleaned_data["domain"]
def clean_service_domain(self):
return self.cleaned_data["service_domain"]
def dispatch(self, request, domain):
self.domain = get_object_or_404(
@ -110,14 +120,17 @@ class DomainEdit(FormView):
)
return super().dispatch(request)
def get_context_data(self):
context = super().get_context_data()
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context["domain"] = self.domain
return context
def form_valid(self, form):
self.domain.public = form.cleaned_data["public"]
self.domain.default = form.cleaned_data["default"]
self.domain.save()
if self.domain.default:
Domain.objects.exclude(pk=self.domain.pk).update(default=False)
return redirect(Domain.urls.root)
def get_initial(self):
@ -125,6 +138,7 @@ class DomainEdit(FormView):
"domain": self.domain.domain,
"service_domain": self.domain.service_domain,
"public": self.domain.public,
"default": self.domain.default,
}
@ -150,4 +164,4 @@ class DomainDelete(TemplateView):
if self.domain.identities.exists():
raise ValueError("Tried to delete domain with identities!")
self.domain.delete()
return redirect("/settings/system/domains/")
return redirect("admin_domains")

View file

@ -1,4 +1,5 @@
from django import forms
from django.conf import settings
from django.contrib.auth.password_validation import validate_password
from django.contrib.auth.views import LoginView, LogoutView
from django.shortcuts import get_object_or_404, render
@ -50,6 +51,10 @@ class Signup(FormView):
def form_valid(self, form):
user = User.objects.create(email=form.cleaned_data["email"])
# Auto-promote the user to admin if that setting is set
if settings.AUTO_ADMIN_EMAIL and user.email == settings.AUTO_ADMIN_EMAIL:
user.admin = True
user.save()
PasswordReset.create_for_user(user)
if "invite_code" in form.cleaned_data:
Invite.objects.filter(token=form.cleaned_data["invite_code"]).delete()

View file

@ -2,11 +2,12 @@ import string
from django import forms
from django.contrib.auth.decorators import login_required
from django.http import Http404
from django.http import Http404, JsonResponse
from django.shortcuts import redirect
from django.utils.decorators import method_decorator
from django.views.generic import FormView, TemplateView, View
from core.ld import canonicalise
from core.models import Config
from users.decorators import identity_required
from users.models import Domain, Follow, FollowStates, Identity, IdentityStates
@ -14,16 +15,41 @@ from users.shortcuts import by_handle_or_404
class ViewIdentity(TemplateView):
"""
Shows identity profile pages, and also acts as the Actor endpoint when
approached with the right Accept header.
"""
template_name = "identity/view.html"
def get_context_data(self, handle):
def get(self, request, handle):
# Make sure we understand this handle
identity = by_handle_or_404(
self.request,
handle,
local=False,
fetch=True,
)
# If they're coming in looking for JSON, they want the actor
accept = request.META.get("HTTP_ACCEPT", "text/html").lower()
if (
"application/json" in accept
or "application/ld" in accept
or "application/activity" in accept
):
# Return actor info
return self.serve_actor(identity)
else:
# Show normal page
return super().get(request, identity=identity)
def serve_actor(self, identity):
# If this not a local actor, redirect to their canonical URI
if not identity.local:
return redirect(identity.actor_uri)
return JsonResponse(canonicalise(identity.to_ap(), include_security=True))
def get_context_data(self, identity):
posts = identity.posts.all()[:100]
if identity.data_age > Config.system.identity_max_age:
identity.transition_perform(IdentityStates.outdated)
@ -150,7 +176,7 @@ class CreateIdentity(FormView):
domain = form.cleaned_data["domain"]
domain_instance = Domain.get_domain(domain)
new_identity = Identity.objects.create(
actor_uri=f"https://{domain_instance.uri_domain}/@{username}@{domain}/actor/",
actor_uri=f"https://{domain_instance.uri_domain}/@{username}@{domain}/",
username=username.lower(),
domain_id=domain,
name=form.cleaned_data["name"],

View file

@ -147,8 +147,8 @@ class ProfilePage(FormView):
return {
"name": self.request.identity.name,
"summary": self.request.identity.summary,
"icon": self.request.identity.icon.url,
"image": self.request.identity.image.url,
"icon": self.request.identity.icon and self.request.identity.icon.url,
"image": self.request.identity.image and self.request.identity.image.url,
}
def form_valid(self, form):