]> git.mar77i.info Git - chat/commitdiff
initial commit
authormar77i <mar77i@protonmail.ch>
Tue, 7 Apr 2026 21:35:39 +0000 (23:35 +0200)
committermar77i <mar77i@protonmail.ch>
Fri, 24 Apr 2026 11:39:13 +0000 (13:39 +0200)
49 files changed:
.gitignore [new file with mode: 0644]
.pre-commit-config.yaml [new file with mode: 0644]
channel/__init__.py [new file with mode: 0644]
channel/admin.py [new file with mode: 0644]
channel/apps.py [new file with mode: 0644]
channel/migrations/0001_initial.py [new file with mode: 0644]
channel/migrations/0002_initial.py [new file with mode: 0644]
channel/migrations/__init__.py [new file with mode: 0644]
channel/models.py [new file with mode: 0644]
channel/signals.py [new file with mode: 0644]
channel/templates/channel/main.html [new file with mode: 0644]
channel/tests.py [new file with mode: 0644]
channel/urls.py [new file with mode: 0644]
channel/views.py [new file with mode: 0644]
chat/__init__.py [new file with mode: 0644]
chat/apps.py [new file with mode: 0644]
chat/asgi.py [new file with mode: 0644]
chat/bridge.py [new file with mode: 0644]
chat/management/__init__.py [new file with mode: 0644]
chat/management/commands/__init__.py [new file with mode: 0644]
chat/management/commands/db.py [new file with mode: 0644]
chat/management/commands/makemessages.py [new file with mode: 0644]
chat/management/commands/runuvicorn.py [new file with mode: 0644]
chat/settings.py [new file with mode: 0644]
chat/static/chat/chatutils.js [new file with mode: 0644]
chat/templates/chat/base.html [new file with mode: 0644]
chat/triggers.py [new file with mode: 0644]
chat/urls.py [new file with mode: 0644]
locales/de/LC_MESSAGES/django.po [new file with mode: 0644]
manage.py [new file with mode: 0755]
pyproject.toml [new file with mode: 0644]
readme.md [new file with mode: 0644]
scripts/deploy.sh [new file with mode: 0755]
scripts/deploy_remote.py [new file with mode: 0755]
scripts/pyjslint.py [new file with mode: 0755]
scripts/sort_gitignore.sh [new file with mode: 0755]
setup_venv.sh [new file with mode: 0644]
user/__init__.py [new file with mode: 0644]
user/admin.py [new file with mode: 0644]
user/apps.py [new file with mode: 0644]
user/email.py [new file with mode: 0644]
user/forms.py [new file with mode: 0644]
user/migrations/0001_initial.py [new file with mode: 0644]
user/migrations/__init__.py [new file with mode: 0644]
user/models.py [new file with mode: 0644]
user/templates/user/user.html [new file with mode: 0644]
user/tests.py [new file with mode: 0644]
user/urls.py [new file with mode: 0644]
user/views.py [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..fc8509f
--- /dev/null
@@ -0,0 +1,7 @@
+chat/settings_local.py
+*.dump
+.env
+.idea/
+*.mo
+__pycache__/
+.venv/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644 (file)
index 0000000..74a6126
--- /dev/null
@@ -0,0 +1,57 @@
+# See https://pre-commit.com for more information
+# See https://pre-commit.com/hooks.html for more hooks
+repos:
+- repo: local
+  hooks:
+  - id: ruff-check
+    name: ruff check
+    description: "Run 'ruff check' for extremely fast Python linting"
+    entry: ruff check --force-exclude
+    language: python
+    types_or: [python, pyi, jupyter]
+    require_serial: true
+  - id: ruff-format
+    name: ruff format
+    description: "Run 'ruff format' for extremely fast Python formatting"
+    entry: ruff format --force-exclude
+    language: python
+    types_or: [python, pyi, jupyter]
+    require_serial: true
+  - id: beautysh
+    name: beautysh
+    description: |
+      A bash beautifier for the masses.
+      https://pypi.python.org/pypi/beautysh
+    entry: beautysh
+    language: python
+    types: [shell]
+  - id: jslint
+    name: jslint
+    description: "JSLint for the project js files"
+    entry: >
+      scripts/pyjslint.py -o for:true -o browser:true -o this:true
+        -g console -g WebSocket -g _
+    language: python
+    types: [javascript]
+  - id: sort_gitignore
+    name: sort_gitignore
+    description: "Deterministically sort gitignore"
+    language: unsupported
+    pass_filenames: false
+    entry: scripts/sort_gitignore.sh .gitignore
+  - id: makemessages
+    name: makemessages_all
+    description: "Make translation messages"
+    language: unsupported
+    pass_filenames: false
+    entry: ./manage.py makemessages --all --no-obsolete --pre-commit
+  - id: djlint-reformat-django
+    name: djLint formatting for Django
+    entry: djlint --reformat --profile=django
+    types_or: [ html ]
+    language: python
+  - id: djlint-django
+    name: djLint linting for Django
+    entry: djlint --profile=django
+    types_or: [html]
+    language: python
\ No newline at end of file
diff --git a/channel/__init__.py b/channel/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/channel/admin.py b/channel/admin.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/channel/apps.py b/channel/apps.py
new file mode 100644 (file)
index 0000000..b27158c
--- /dev/null
@@ -0,0 +1,10 @@
+from importlib import import_module
+
+from django.apps import AppConfig
+
+
+class ChannelConfig(AppConfig):
+    name = "channel"
+
+    def ready(self):
+        import_module("channel.signals")
diff --git a/channel/migrations/0001_initial.py b/channel/migrations/0001_initial.py
new file mode 100644 (file)
index 0000000..c588bc0
--- /dev/null
@@ -0,0 +1,86 @@
+# Generated by Django 6.0.4 on 2026-04-24 11:37
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    initial = True
+
+    dependencies = []
+
+    operations = [
+        migrations.CreateModel(
+            name="Channel",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("created_ts", models.DateTimeField(auto_now_add=True)),
+                ("name", models.CharField(max_length=256, unique=True)),
+            ],
+        ),
+        migrations.CreateModel(
+            name="ChannelUser",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("added_ts", models.DateTimeField(auto_now_add=True)),
+            ],
+        ),
+        migrations.CreateModel(
+            name="PrivateMessage",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("posted_ts", models.DateTimeField(auto_now_add=True)),
+                ("edited_ts", models.DateTimeField(editable=False, null=True)),
+                ("text", models.TextField()),
+            ],
+        ),
+        migrations.CreateModel(
+            name="ChannelMessage",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("posted_ts", models.DateTimeField(auto_now_add=True)),
+                ("edited_ts", models.DateTimeField(editable=False, null=True)),
+                ("text", models.TextField()),
+                (
+                    "channel",
+                    models.ForeignKey(
+                        editable=False,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="channel.channel",
+                    ),
+                ),
+            ],
+        ),
+    ]
diff --git a/channel/migrations/0002_initial.py b/channel/migrations/0002_initial.py
new file mode 100644 (file)
index 0000000..889e588
--- /dev/null
@@ -0,0 +1,247 @@
+# Generated by Django 6.0.4 on 2026-04-24 11:37
+
+import django.db.models.deletion
+import pgtrigger.compiler
+import pgtrigger.migrations
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    initial = True
+
+    dependencies = [
+        ("channel", "0001_initial"),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="channelmessage",
+            name="user",
+            field=models.ForeignKey(
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                to=settings.AUTH_USER_MODEL,
+            ),
+        ),
+        migrations.AddField(
+            model_name="channeluser",
+            name="channel",
+            field=models.ForeignKey(
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                to="channel.channel",
+            ),
+        ),
+        migrations.AddField(
+            model_name="channeluser",
+            name="user",
+            field=models.ForeignKey(
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                to=settings.AUTH_USER_MODEL,
+            ),
+        ),
+        migrations.AddField(
+            model_name="channel",
+            name="users",
+            field=models.ManyToManyField(
+                related_name="channels",
+                through="channel.ChannelUser",
+                to=settings.AUTH_USER_MODEL,
+            ),
+        ),
+        migrations.AddField(
+            model_name="privatemessage",
+            name="recipient",
+            field=models.ForeignKey(
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="pns_recieved",
+                to=settings.AUTH_USER_MODEL,
+            ),
+        ),
+        migrations.AddField(
+            model_name="privatemessage",
+            name="sender",
+            field=models.ForeignKey(
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="pms_sent",
+                to=settings.AUTH_USER_MODEL,
+            ),
+        ),
+        pgtrigger.migrations.AddTrigger(
+            model_name="channelmessage",
+            trigger=pgtrigger.compiler.Trigger(
+                name="chat_channel_delete",
+                sql=pgtrigger.compiler.UpsertTriggerSql(
+                    func='\n                PERFORM pg_notify(\n                    \'chat_channel\',\n                    \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n                    \'","obj":{"id":\' || OLD."id" || \',"user_id":\' || OLD."user_id" || \',"channel_id":\' || OLD."channel_id" || \'}}\'\n                );\n                RETURN NULL;\n            ',
+                    hash="dee2cb1106ec730d60f5af5665fee20ddc1db488",
+                    operation="DELETE",
+                    pgid="pgtrigger_chat_channel_delete_e3727",
+                    table="channel_channelmessage",
+                    when="AFTER",
+                ),
+            ),
+        ),
+        pgtrigger.migrations.AddTrigger(
+            model_name="channelmessage",
+            trigger=pgtrigger.compiler.Trigger(
+                name="chat_channel_insert_update",
+                sql=pgtrigger.compiler.UpsertTriggerSql(
+                    func='\n                PERFORM pg_notify(\n                    \'chat_channel\',\n                    \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n                    \'","obj":{"id":\' || NEW."id" || \',"user_id":\' || NEW."user_id" || \',"channel_id":\' || NEW."channel_id" || \'}}\'\n                );\n                RETURN NULL;\n            ',
+                    hash="24860285f07bfec230d8fe91e1e5f1e27dbf4ab5",
+                    operation="INSERT OR UPDATE",
+                    pgid="pgtrigger_chat_channel_insert_update_a5e85",
+                    table="channel_channelmessage",
+                    when="AFTER",
+                ),
+            ),
+        ),
+        pgtrigger.migrations.AddTrigger(
+            model_name="channelmessage",
+            trigger=pgtrigger.compiler.Trigger(
+                name="chat_channel_truncate",
+                sql=pgtrigger.compiler.UpsertTriggerSql(
+                    func="\n                PERFORM pg_notify(\n                    'chat_channel',\n                    '{\"op\":\"' || TG_OP || '\",\"table\":\"' || TG_TABLE_NAME || '\"}'\n                );\n                RETURN NULL;\n            ",
+                    hash="2a95850c0c6614d80da1343422459dcf122d13b0",
+                    level="STATEMENT",
+                    operation="TRUNCATE",
+                    pgid="pgtrigger_chat_channel_truncate_ab388",
+                    table="channel_channelmessage",
+                    when="AFTER",
+                ),
+            ),
+        ),
+        pgtrigger.migrations.AddTrigger(
+            model_name="channeluser",
+            trigger=pgtrigger.compiler.Trigger(
+                name="chat_channel_delete",
+                sql=pgtrigger.compiler.UpsertTriggerSql(
+                    func='\n                PERFORM pg_notify(\n                    \'chat_channel\',\n                    \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n                    \'","obj":{"id":\' || OLD."id" || \',"user_id":\' || OLD."user_id" || \',"channel_id":\' || OLD."channel_id" || \'}}\'\n                );\n                RETURN NULL;\n            ',
+                    hash="2fe0eac7d74107a4b22294668b0a01c0fd4b9c5b",
+                    operation="DELETE",
+                    pgid="pgtrigger_chat_channel_delete_d1dad",
+                    table="channel_channeluser",
+                    when="AFTER",
+                ),
+            ),
+        ),
+        pgtrigger.migrations.AddTrigger(
+            model_name="channeluser",
+            trigger=pgtrigger.compiler.Trigger(
+                name="chat_channel_insert_update",
+                sql=pgtrigger.compiler.UpsertTriggerSql(
+                    func='\n                PERFORM pg_notify(\n                    \'chat_channel\',\n                    \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n                    \'","obj":{"id":\' || NEW."id" || \',"user_id":\' || NEW."user_id" || \',"channel_id":\' || NEW."channel_id" || \'}}\'\n                );\n                RETURN NULL;\n            ',
+                    hash="5122e638478843f6f34a2c7af368ee2f7734efdd",
+                    operation="INSERT OR UPDATE",
+                    pgid="pgtrigger_chat_channel_insert_update_4abda",
+                    table="channel_channeluser",
+                    when="AFTER",
+                ),
+            ),
+        ),
+        pgtrigger.migrations.AddTrigger(
+            model_name="channeluser",
+            trigger=pgtrigger.compiler.Trigger(
+                name="chat_channel_truncate",
+                sql=pgtrigger.compiler.UpsertTriggerSql(
+                    func="\n                PERFORM pg_notify(\n                    'chat_channel',\n                    '{\"op\":\"' || TG_OP || '\",\"table\":\"' || TG_TABLE_NAME || '\"}'\n                );\n                RETURN NULL;\n            ",
+                    hash="38f70e9cefd5f5687f702110ced17fc7d875eb1b",
+                    level="STATEMENT",
+                    operation="TRUNCATE",
+                    pgid="pgtrigger_chat_channel_truncate_be185",
+                    table="channel_channeluser",
+                    when="AFTER",
+                ),
+            ),
+        ),
+        pgtrigger.migrations.AddTrigger(
+            model_name="channel",
+            trigger=pgtrigger.compiler.Trigger(
+                name="chat_channel_delete",
+                sql=pgtrigger.compiler.UpsertTriggerSql(
+                    func='\n                PERFORM pg_notify(\n                    \'chat_channel\',\n                    \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n                    \'","obj":{"id":\' || OLD."id" || \',"name":\' || OLD."name" || \'}}\'\n                );\n                RETURN NULL;\n            ',
+                    hash="dd1c4a527a14046c19cd2d260336f61c954091bc",
+                    operation="DELETE",
+                    pgid="pgtrigger_chat_channel_delete_71f70",
+                    table="channel_channel",
+                    when="AFTER",
+                ),
+            ),
+        ),
+        pgtrigger.migrations.AddTrigger(
+            model_name="channel",
+            trigger=pgtrigger.compiler.Trigger(
+                name="chat_channel_insert_update",
+                sql=pgtrigger.compiler.UpsertTriggerSql(
+                    func='\n                PERFORM pg_notify(\n                    \'chat_channel\',\n                    \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n                    \'","obj":{"id":\' || NEW."id" || \',"name":\' || NEW."name" || \'}}\'\n                );\n                RETURN NULL;\n            ',
+                    hash="80f6d071b8557fbda0cc1bc2483b8876186fbe94",
+                    operation="INSERT OR UPDATE",
+                    pgid="pgtrigger_chat_channel_insert_update_0f4bc",
+                    table="channel_channel",
+                    when="AFTER",
+                ),
+            ),
+        ),
+        pgtrigger.migrations.AddTrigger(
+            model_name="channel",
+            trigger=pgtrigger.compiler.Trigger(
+                name="chat_channel_truncate",
+                sql=pgtrigger.compiler.UpsertTriggerSql(
+                    func="\n                PERFORM pg_notify(\n                    'chat_channel',\n                    '{\"op\":\"' || TG_OP || '\",\"table\":\"' || TG_TABLE_NAME || '\"}'\n                );\n                RETURN NULL;\n            ",
+                    hash="570f8232ef884343064eb3240fdc36e61052297e",
+                    level="STATEMENT",
+                    operation="TRUNCATE",
+                    pgid="pgtrigger_chat_channel_truncate_2f496",
+                    table="channel_channel",
+                    when="AFTER",
+                ),
+            ),
+        ),
+        pgtrigger.migrations.AddTrigger(
+            model_name="privatemessage",
+            trigger=pgtrigger.compiler.Trigger(
+                name="chat_channel_delete",
+                sql=pgtrigger.compiler.UpsertTriggerSql(
+                    func='\n                PERFORM pg_notify(\n                    \'chat_channel\',\n                    \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n                    \'","obj":{"id":\' || OLD."id" || \',"sender_id":\' || OLD."sender_id" || \',"recipient_id":\' || OLD."recipient_id" || \'}}\'\n                );\n                RETURN NULL;\n            ',
+                    hash="54f02ee6495116653d2d634032e42e3ac3d01712",
+                    operation="DELETE",
+                    pgid="pgtrigger_chat_channel_delete_5cdfa",
+                    table="channel_privatemessage",
+                    when="AFTER",
+                ),
+            ),
+        ),
+        pgtrigger.migrations.AddTrigger(
+            model_name="privatemessage",
+            trigger=pgtrigger.compiler.Trigger(
+                name="chat_channel_insert_update",
+                sql=pgtrigger.compiler.UpsertTriggerSql(
+                    func='\n                PERFORM pg_notify(\n                    \'chat_channel\',\n                    \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n                    \'","obj":{"id":\' || NEW."id" || \',"sender_id":\' || NEW."sender_id" || \',"recipient_id":\' || NEW."recipient_id" || \'}}\'\n                );\n                RETURN NULL;\n            ',
+                    hash="c49996d1cd9f313f657e55f87b3588d2b68ba81b",
+                    operation="INSERT OR UPDATE",
+                    pgid="pgtrigger_chat_channel_insert_update_4c85f",
+                    table="channel_privatemessage",
+                    when="AFTER",
+                ),
+            ),
+        ),
+        pgtrigger.migrations.AddTrigger(
+            model_name="privatemessage",
+            trigger=pgtrigger.compiler.Trigger(
+                name="chat_channel_truncate",
+                sql=pgtrigger.compiler.UpsertTriggerSql(
+                    func="\n                PERFORM pg_notify(\n                    'chat_channel',\n                    '{\"op\":\"' || TG_OP || '\",\"table\":\"' || TG_TABLE_NAME || '\"}'\n                );\n                RETURN NULL;\n            ",
+                    hash="5adc21d8be79bfa8f0253ad28f0cef04b28c4752",
+                    level="STATEMENT",
+                    operation="TRUNCATE",
+                    pgid="pgtrigger_chat_channel_truncate_d9c27",
+                    table="channel_privatemessage",
+                    when="AFTER",
+                ),
+            ),
+        ),
+    ]
diff --git a/channel/migrations/__init__.py b/channel/migrations/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/channel/models.py b/channel/models.py
new file mode 100644 (file)
index 0000000..a70477b
--- /dev/null
@@ -0,0 +1,56 @@
+from django.db.models import (
+    CASCADE,
+    CharField,
+    DateTimeField,
+    ForeignKey,
+    ManyToManyField,
+    Model,
+    TextField,
+)
+
+from chat.triggers import chat_channel
+from user.models import User
+
+
+class PrivateMessage(Model):
+    posted_ts = DateTimeField(auto_now_add=True)
+    edited_ts = DateTimeField(null=True, editable=False)
+    sender = ForeignKey(
+        User, related_name="pms_sent", on_delete=CASCADE, editable=False
+    )
+    recipient = ForeignKey(
+        User, related_name="pns_recieved", on_delete=CASCADE, editable=False
+    )
+    text = TextField()
+
+    class Meta:
+        triggers = [*chat_channel(("id", "sender_id", "recipient_id"))]
+
+
+class Channel(Model):
+    created_ts = DateTimeField(auto_now_add=True)
+    name = CharField(max_length=256, unique=True)
+    users = ManyToManyField(User, related_name="channels", through="ChannelUser")
+
+    class Meta:
+        triggers = [*chat_channel(("id", "name"))]
+
+
+class ChannelUser(Model):
+    added_ts = DateTimeField(auto_now_add=True)
+    user = ForeignKey(User, on_delete=CASCADE, editable=False)
+    channel = ForeignKey(Channel, on_delete=CASCADE, editable=False)
+
+    class Meta:
+        triggers = [*chat_channel(("id", "user_id", "channel_id"))]
+
+
+class ChannelMessage(Model):
+    posted_ts = DateTimeField(auto_now_add=True)
+    edited_ts = DateTimeField(null=True, editable=False)
+    user = ForeignKey(User, on_delete=CASCADE, editable=False)
+    channel = ForeignKey(Channel, on_delete=CASCADE, editable=False)
+    text = TextField()
+
+    class Meta:
+        triggers = [*chat_channel(("id", "user_id", "channel_id"))]
diff --git a/channel/signals.py b/channel/signals.py
new file mode 100644 (file)
index 0000000..e60c791
--- /dev/null
@@ -0,0 +1,9 @@
+from django.db.models.signals import pre_save
+from django.dispatch import receiver
+from django.utils.timezone import now
+
+
+@receiver(pre_save)
+def update_edited_at_timestamp(signal, instance, **__):
+    if hasattr(instance, "edited_at") and instance.pk:
+        instance.edited_at = now()
diff --git a/channel/templates/channel/main.html b/channel/templates/channel/main.html
new file mode 100644 (file)
index 0000000..cc93e2a
--- /dev/null
@@ -0,0 +1,14 @@
+{% extends "chat/base.html" %}
+{% block main %}
+    <h1>{{ title }}</h1>
+    {% if messages %}
+        <ul class="messages">
+            {% for message in messages %}
+                <li class="{{ message.tags|default:'' }}">{{ message }}</li>
+            {% endfor %}
+        </ul>
+    {% endif %}
+    <form action="{% url 'user-logout' %}">
+        <button>Log Out</button>
+    </form>
+{% endblock main %}
diff --git a/channel/tests.py b/channel/tests.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/channel/urls.py b/channel/urls.py
new file mode 100644 (file)
index 0000000..e02305a
--- /dev/null
@@ -0,0 +1,7 @@
+from django.urls import path
+
+from .views import ChannelMainView
+
+urlpatterns = [
+    path("", ChannelMainView.as_view(), name="channel-main"),
+]
diff --git a/channel/views.py b/channel/views.py
new file mode 100644 (file)
index 0000000..f9566ff
--- /dev/null
@@ -0,0 +1,5 @@
+from django.views.generic import TemplateView
+
+
+class ChannelMainView(TemplateView):
+    template_name = "channel/main.html"
diff --git a/chat/__init__.py b/chat/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/chat/apps.py b/chat/apps.py
new file mode 100644 (file)
index 0000000..194dfde
--- /dev/null
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class ChatConfig(AppConfig):
+    name = "chat"
diff --git a/chat/asgi.py b/chat/asgi.py
new file mode 100644 (file)
index 0000000..dcb3f85
--- /dev/null
@@ -0,0 +1,25 @@
+"""
+ASGI config for chat project.
+
+It exposes the ASGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
+"""
+
+import os
+
+from django.core.asgi import get_asgi_application
+
+from .bridge import bridge
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "chat.settings")
+
+django_app = get_asgi_application()
+
+
+async def application(scope, receive, send):
+    if scope["type"] == "lifespan":
+        await bridge.listen(receive, send)
+    else:
+        await django_app(scope, receive, send)
diff --git a/chat/bridge.py b/chat/bridge.py
new file mode 100644 (file)
index 0000000..6ad9cbc
--- /dev/null
@@ -0,0 +1,66 @@
+from asgiref.sync import async_to_sync
+from asyncio import get_running_loop, run_coroutine_threadsafe
+from functools import partial
+from inspect import iscoroutine, iscoroutinefunction
+from logging import getLogger
+
+from django.conf import settings
+
+logger = getLogger("django")
+
+
+class AsyncBridge:
+    def __init__(self):
+        self.running = True
+        self.loop = None
+
+    async def handle_startup(self, send):
+        try:
+            self.loop = get_running_loop()
+            await send({"type": "lifespan.startup.complete"})
+        except Exception as exc:
+            await send({"type": "lifespan.startup.failed", "message": str(exc)})
+
+    async def handle_shutdown(self, send):
+        await send({"type": "lifespan.shutdown.complete"})
+        self.running = False
+
+    async def listen(self, receive, send):
+        while self.running:
+            if callback := {
+                "lifespan.startup": self.handle_startup,
+                "lifespan.shutdown": self.handle_shutdown,
+            }.get((await receive())["type"]):
+                await callback(send)
+
+    @staticmethod
+    def _log_errors(future):
+        try:
+            future.result()
+        except Exception:
+            logger.exception("Background task failed:")
+
+    def call_async(self, func_or_coro, *args, **kwargs):
+        if iscoroutinefunction(func_or_coro):
+            func_or_coro = func_or_coro(*args, **kwargs)
+        if self.loop:
+            if iscoroutine(func_or_coro):
+                future = run_coroutine_threadsafe(func_or_coro, self.loop)
+            else:
+                future = self.loop.run_in_executor(
+                    None, partial(func_or_coro, *args, **kwargs)
+                )
+            future.add_done_callback(self._log_errors)
+        elif settings.DEBUG:
+            if iscoroutine(func_or_coro):
+                async_to_sync(lambda: func_or_coro)()
+            else:
+                func_or_coro(*args, **kwargs)
+        else:
+            raise RuntimeError(
+                "AsyncBridge loop not initialized (Uvicorn not running)."
+            )
+        return None
+
+
+bridge = AsyncBridge()
diff --git a/chat/management/__init__.py b/chat/management/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/chat/management/commands/__init__.py b/chat/management/commands/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/chat/management/commands/db.py b/chat/management/commands/db.py
new file mode 100644 (file)
index 0000000..524349e
--- /dev/null
@@ -0,0 +1,83 @@
+import os
+from datetime import date
+
+from django.core.management import BaseCommand
+from django.core.management.base import CommandError
+from django.db import connections
+from django.db.backends.base.base import BaseDatabaseWrapper
+from django.db.backends.postgresql.client import DatabaseClient
+
+
+class DumpClient(DatabaseClient):
+    executable_name = "pg_dump"
+
+    @classmethod
+    def settings_to_cmd_args_env(cls, settings_dict, parameters):
+        args, env = super().settings_to_cmd_args_env(settings_dict, parameters)
+        args.append("-Fc")
+        return args, env
+
+
+class RestoreClient(DatabaseClient):
+    executable_name = "pg_restore"
+
+    def settings_to_cmd_args_env(cls, settings_dict, parameters):
+        args, env = super().settings_to_cmd_args_env(settings_dict, parameters)
+        if args[-1] == settings_dict["NAME"]:
+            args.insert(-1, "-d")
+        args.append("-O")
+        return args, env
+
+
+class Command(BaseCommand):
+    def add_arguments(self, parser):
+        parser.add_argument(
+            "action", nargs="?", default="dump", choices=("dump", "restore")
+        )
+        parser.add_argument("--stdout", action="store_true")
+        parser.add_argument("--filename")
+        parser.add_argument("--connection", default="default")
+
+    def generate_filename(self):
+        filename = f"backup-{date.today().strftime('%Y%m%d')}.dump"
+        if not os.path.exists(filename):
+            return filename
+        filename_base = filename[:-5]
+        i = 2
+        filename = f"{filename_base}_{i}.dump"
+        while os.path.exists(filename):
+            i += 1
+            filename = f"{filename_base}_{i}.dump"
+        return filename
+
+    def handle_dump(self, connection: BaseDatabaseWrapper, **options):
+        parameters = []
+        if not options["stdout"]:
+            if options["filename"]:
+                filename = options["filename"]
+            else:
+                filename = self.generate_filename()
+            parameters.extend(("--file", filename))
+        DumpClient(connection).runshell(parameters)
+
+    def handle_restore(self, connection: BaseDatabaseWrapper, **options):
+        filename = options["filename"]
+        if not filename:
+            raise CommandError("Error: filename required")
+        db_name = connection.settings_dict["NAME"]
+        db_user = connection.settings_dict["USER"]
+        try:
+            connection.settings_dict["NAME"] = "postgres"
+            connection.client.runshell(["-q", "-c", f'DROP DATABASE "{db_name}"'])
+            connection.client.runshell(
+                ["-q", "-c", f'CREATE DATABASE "{db_name}" OWNER "{db_user}"']
+            )
+        finally:
+            connection.settings_dict["NAME"] = db_name
+        RestoreClient(connection).runshell([filename])
+
+    def handle(self, **options):
+        {
+            "dump": self.handle_dump,
+            "restore": self.handle_restore,
+        }[options["action"]](connections[options["connection"]], **options)
diff --git a/chat/management/commands/makemessages.py b/chat/management/commands/makemessages.py
new file mode 100644 (file)
index 0000000..a87de61
--- /dev/null
@@ -0,0 +1,74 @@
+import re
+import sys
+from pathlib import Path
+from subprocess import DEVNULL, CalledProcessError, check_output
+
+from django.conf import settings
+from django.core.management.commands.makemessages import Command as MakeMessagesCommand
+
+CREATION_DATE_PATTERN = re.compile(
+    r'^("POT-Creation-Date: )[0-9]+(-[0-9]{2}){2} [0-9]{2}:[0-9]{2}\+[0-4]{4}\\n"\n$',
+    re.MULTILINE,
+)
+
+
+class Command(MakeMessagesCommand):
+    msgmerge_options = [*MakeMessagesCommand.msgmerge_options, "--no-fuzzy-matching"]
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.pre_commit = False
+        self.old_po_file_lines: dict[Path, list[str]] = {}
+
+    def add_arguments(self, parser):
+        super().add_arguments(parser)
+        parser.add_argument(
+            "--pre-commit",
+            action="store_true",
+            help="Prevents updating POT-Creation-Date to avoid VCS noise.",
+        )
+
+    def retain_old_po_file(self, po_file):
+        try:
+            self.old_po_file_lines[po_file] = check_output(
+                ["git", "show", f"HEAD:{po_file.relative_to(settings.BASE_DIR)}"],
+                stderr=DEVNULL,
+                text=True,
+            ).splitlines(keepends=True)
+        except CalledProcessError:
+            return
+
+    def write_po_file(self, potfile, locale):
+        if self.pre_commit:
+            self.retain_old_po_file(
+                Path(potfile).parent / locale / "LC_MESSAGES" / f"{self.domain}.po"
+            )
+        super().write_po_file(potfile, locale)
+
+    def restore_pot_creation_date_header(self, po_file, old_lines):
+        with po_file.open("rt") as fh:
+            new_lines = list(fh)
+        if old_lines == new_lines:
+            return
+        for old_line in old_lines:
+            if CREATION_DATE_PATTERN.fullmatch(old_line):
+                break
+        else:
+            return
+        for i, new_line in enumerate(new_lines):
+            if not CREATION_DATE_PATTERN.fullmatch(new_line):
+                continue
+            new_lines[i] = old_line
+            if new_lines == old_lines:
+                rel_path = po_file.relative_to(settings.BASE_DIR)
+                print(f'NOTE: undoing header change in "{rel_path}"', file=sys.stderr)
+                with po_file.open("wt") as fh:
+                    fh.write("".join(old_lines))
+            break
+
+    def handle(self, **options):
+        self.pre_commit = options["pre_commit"]
+        super().handle(**options)
+        if self.pre_commit:
+            for po_file, old_lines in self.old_po_file_lines.items():
+                self.restore_pot_creation_date_header(po_file, old_lines)
diff --git a/chat/management/commands/runuvicorn.py b/chat/management/commands/runuvicorn.py
new file mode 100644 (file)
index 0000000..d9d1b04
--- /dev/null
@@ -0,0 +1,20 @@
+import uvicorn
+from django.core.management.base import BaseCommand
+
+
+class Command(BaseCommand):
+    help = "Runs the project with Uvicorn"
+
+    def add_arguments(self, parser):
+        parser.add_argument("--host", default="127.0.0.1")
+        parser.add_argument("--port", type=int, default=8000)
+        parser.add_argument("--reload", action="store_true", default=True)
+        parser.add_argument("--no-reload", dest="reload", action="store_false")
+
+    def handle(self, **options):
+        uvicorn.run(
+            "chat.asgi:application",
+            host=options["host"],
+            port=options["port"],
+            reload=options["reload"],
+        )
diff --git a/chat/settings.py b/chat/settings.py
new file mode 100644 (file)
index 0000000..33f123b
--- /dev/null
@@ -0,0 +1,121 @@
+import os
+from pathlib import Path
+
+BASE_DIR = Path(__file__).resolve().parents[1]
+SECRET_KEY = os.environ["SECRET_KEY"]
+DEBUG = bool(int(os.environ.get("DEBUG", "1")))
+ALLOWED_HOSTS = [os.environ.get("ALLOWED_HOST") or "localhost"]
+ADMINS = [os.environ["ADMIN_EMAIL"]]
+
+INSTALLED_APPS = [
+    "django.contrib.admin",
+    "django.contrib.auth",
+    "django.contrib.contenttypes",
+    "django.contrib.sessions",
+    "django.contrib.messages",
+    "django.contrib.staticfiles",
+    "pgtrigger",
+    "channel",
+    "chat",
+    "user",
+]
+MIDDLEWARE = [
+    "django.middleware.security.SecurityMiddleware",
+    "django.contrib.sessions.middleware.SessionMiddleware",
+    "django.middleware.common.CommonMiddleware",
+    "django.middleware.csrf.CsrfViewMiddleware",
+    "django.contrib.auth.middleware.AuthenticationMiddleware",
+    "django.contrib.messages.middleware.MessageMiddleware",
+    "django.middleware.clickjacking.XFrameOptionsMiddleware",
+    "django.contrib.auth.middleware.LoginRequiredMiddleware",
+]
+
+ROOT_URLCONF = "chat.urls"
+
+TEMPLATES = [
+    {
+        "BACKEND": "django.template.backends.django.DjangoTemplates",
+        "DIRS": ["chat/templates"],
+        "APP_DIRS": True,
+        "OPTIONS": {
+            "context_processors": [
+                "django.template.context_processors.request",
+                "django.contrib.auth.context_processors.auth",
+                "django.contrib.messages.context_processors.messages",
+            ],
+        },
+    },
+]
+
+ASGI_APPLICATION = "chat.asgi.application"
+
+DATABASES = {
+    "default": {
+        "ENGINE": "django.db.backends.postgresql",
+        "HOST": os.environ["PGHOST"],
+        "PORT": os.environ["PGPORT"],
+        "USER": os.environ["PGUSER"],
+        "PASSWORD": os.environ["PGPASS"],
+        "NAME": os.environ["PGDATABASE"],
+    }
+}
+
+AUTH_USER_MODEL = "user.User"
+AUTH_PASSWORD_VALIDATORS = [
+    {
+        "NAME": (
+            "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
+        ),
+    },
+    {
+        "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
+    },
+    {
+        "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
+    },
+    {
+        "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
+    },
+    {
+        "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
+        "OPTIONS": {"min_length": 9},
+    },
+]
+
+LANGUAGE_CODE = "en-us"
+TIME_ZONE = "UTC"
+USE_I18N = True
+USE_TZ = True
+LOCALE_PATHS = [BASE_DIR / "locales"]
+
+STATIC_URL = "static/"
+STATIC_ROOT = BASE_DIR / "static"
+MEDIA_URL = "media/"
+MEDIA_ROOT = BASE_DIR / "media"
+LOGIN_URL = "/user/login"
+
+DEFAULT_FROM_EMAIL = os.environ["DEFAULT_FROM_EMAIL"]
+EMAIL_HOST = os.environ.get("SMTP_HOST") or "localhost"
+EMAIL_PORT = int(os.environ.get("SMTP_PORT") or "25")
+EMAIL_HOST_USER = os.environ.get("SMTP_USER") or ""
+EMAIL_HOST_PASSWORD = os.environ.get("SMTP_PASSWORD") or ""
+EMAIL_USE_TLS = os.environ.get("SMTP_CONN") == "SMTP_SSL"
+
+LOGGING = {
+    "version": 1,
+    "disable_existing_loggers": False,
+    "handlers": {
+        "console": {
+            "class": "logging.StreamHandler",
+        },
+    },
+    "root": {
+        "handlers": ["console"],
+        "level": "INFO",
+    },
+}
+
+try:
+    from .settings_local import *  # noqa: F403
+except ImportError:
+    pass
diff --git a/chat/static/chat/chatutils.js b/chat/static/chat/chatutils.js
new file mode 100644 (file)
index 0000000..04184f8
--- /dev/null
@@ -0,0 +1,205 @@
+(function () {
+    function foreach(obj, callback, start) {
+        var i;
+        var o;
+        var c;
+        if (!Array.isArray(obj)) {
+            o = Object.keys(obj);
+            c = function (key) {
+                return callback(key, obj[key]);
+            };
+        } else {
+            o = obj;
+            c = callback;
+        }
+        for (i = start || 0; i < o.length; i += 1) {
+            if (c(o[i]) === true) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    function left_pad(s, length, fill) {
+        var i;
+        if (typeof s !== "string") {
+            s = s.toString();
+        }
+        for (i = 0; s.length < length; i += 1) {
+            s = fill[i % fill.length] + s;
+        }
+        return s;
+    }
+
+    function intl_dateformat(date, locale, obj) {
+        return new Intl.DateTimeFormat(locale || "en-US", obj).format(date);
+    }
+
+    function get_timezone_offset(date) {
+        var tzo = date.getTimezoneOffset();
+        var sign;
+        if (tzo < 0) {
+            sign = "-";
+            tzo *= -1;
+        } else {
+            sign = "+";
+        }
+        return [
+            sign,
+            left_pad(Math.floor(tzo / 60), 2, "0"),
+            left_pad(Math.floor(tzo % 60), 2, "0")
+        ].join("");
+    }
+
+    function get_timezone(date, locale) {
+        var s = intl_dateformat(date, locale, {"timeZoneName": "short"});
+        var pos = s.indexOf(",");
+        if (pos !== -1) {
+            s = s.substring(pos + 1).trim();
+        }
+        return s;
+    }
+
+    window.chatutils = {
+        "foreach": foreach,
+        "select": function (selector) {
+            if (selector.substring(0, 1) === "#") {
+                return document.getElementById(selector.substring(1));
+            }
+            if (selector.substring(0, 1) === ".") {
+                return document.getElementsByClassName(selector.substring(1));
+            }
+        },
+        "strftime": function (date, s, locale) {
+            var pos;
+            var out = [];
+            while (true) {
+                pos = s.indexOf("%");
+                if (pos === -1) {
+                    break;
+                }
+                out.push(s.substring(0, pos));
+                switch (s[pos + 1]) {
+                case "a":
+                    out.push(
+                        intl_dateformat(date, locale, {"weekday": "short"})
+                    );
+                    break;
+                case "A":
+                    out.push(
+                        intl_dateformat(date, locale, {"weekday": "long"})
+                    );
+                    break;
+                case "b":
+                    out.push(intl_dateformat(date, locale, {"month": "short"}));
+                    break;
+                case "B":
+                    out.push(intl_dateformat(date, locale, {"month": "long"}));
+                    break;
+                case "c":
+                    out.push(date.toLocaleString(date));
+                    break;
+                case "d":
+                    out.push(left_pad(date.getDate(), 2, "0"));
+                    break;
+                case "f":
+                    out.push(left_pad(date.getMilliseconds(), 3, "0"));
+                    break;
+                case "H":
+                    out.push(left_pad(date.getHours(), 2, "0"));
+                    break;
+                case "I":
+                    if (date.getHours() % 12 === 0) {
+                        out.push("12");
+                    } else {
+                        out.push(left_pad(date.getHours() % 12, 2, "0"));
+                    }
+                    break;
+                // missing: "j"
+                case "m":
+                    out.push(left_pad(date.getMonth() + 1, 2, "0"));
+                    break;
+                case "M":
+                    out.push(left_pad(date.getMinutes(), 2, "0"));
+                    break;
+                case "p":
+                    if (date.getHours() < 12) {
+                        out.push("AM");
+                    } else {
+                        out.push("PM");
+                    }
+                    break;
+                case "S":
+                    out.push(left_pad(date.getSeconds(), 2, "0"));
+                    break;
+                // missing: "U"
+                case "w":
+                    out.push(date.getDay().toString());
+                    break;
+                // missing: "W"
+                case "x":
+                    out.push(date.toLocaleDateString());
+                    break;
+                case "X":
+                    out.push(date.toLocaleTimeString());
+                    break;
+                case "y":
+                    out.push(left_pad(date.getFullYear() % 100, 2, "0"));
+                    break;
+                case "Y":
+                    out.push(left_pad(date.getFullYear(), 4, "0"));
+                    break;
+                case "z":
+                    out.push(get_timezone_offset(date));
+                    break;
+                case "Z":
+                    out.push(get_timezone(date, locale));
+                    break;
+                case "%":
+                    out.push(s[pos + 1]);
+                    break;
+                default:
+                    out.push(s.substring(pos, pos + 2));
+                }
+                s = s.substring(pos + 2);
+            }
+            out.push(s);
+            return out.join("");
+        },
+        "xhr": function (method, url, callbacks, data) {
+            var request = new XMLHttpRequest();
+            if (!callbacks.hasOwnProperty("error")) {
+                request.addEventListener(
+                    "error",
+                    function (msg) {
+                        console.log("xhr_error", msg);
+                    }
+                );
+            }
+            if (!callbacks.hasOwnProperty("abort")) {
+                request.addEventListener(
+                    "abort",
+                    function (msg) {
+                        console.log("xhr_abort", msg);
+                    }
+                );
+            }
+            foreach(
+                callbacks,
+                function (event_name, callback) {
+                    request.addEventListener(event_name, callback);
+                }
+            );
+            request.open(method, url);
+            if (
+                method.toLowerCase() === "put"
+                || method.toLowerCase() === "post"
+            ) {
+                request.send(data);
+            } else {
+                request.send();
+            }
+            return request;
+        }
+    };
+}());
diff --git a/chat/templates/chat/base.html b/chat/templates/chat/base.html
new file mode 100644 (file)
index 0000000..6973bde
--- /dev/null
@@ -0,0 +1,31 @@
+{% load static %}
+<!DOCTYPE html>
+<html lang="{% block lang %}en{% endblock lang %}">
+    <head>
+        {% block head %}
+            {% block meta %}
+                <meta charset="UTF-8">
+                <meta name="viewport" content="width=device-width, initial-scale=1.0">
+                <meta http-equiv="X-UA-Compatible" content="ie=edge">
+                <meta name="description" content="{{ meta_description }}">
+                <meta name="keywords" content="{{ meta_keywords }}">
+            {% endblock meta %}
+            {# <link rel="stylesheet" href="{% static 'chat/style.css' %}"> #}
+            <title>
+                {% block title %}
+                    Title
+                {% endblock title %}
+            </title>
+        {% endblock head %}
+    </head>
+    <body>
+        {% block header %}
+        {% endblock header %}
+        <main>
+            {% block main %}
+            {% endblock main %}
+        </main>
+        {% block footer %}
+        {% endblock footer %}
+    </body>
+</html>
diff --git a/chat/triggers.py b/chat/triggers.py
new file mode 100644 (file)
index 0000000..e75acff
--- /dev/null
@@ -0,0 +1,56 @@
+from pgtrigger import After, Delete, Insert, Row, Statement, Trigger, Truncate, Update
+
+
+def fields_to_json(t, fields):
+    return ",".join(f'"{field}":\' || {t}."{field}" || \'' for field in fields)
+
+
+class TriggerChannel:
+    def __init__(self, name):
+        self.name = name
+
+    def __call__(self, fields):
+        yield Trigger(
+            name=f"{self.name}_delete",
+            level=Row,
+            operation=Delete,
+            when=After,
+            func=f"""
+                PERFORM pg_notify(
+                    '{self.name}',
+                    '{{"op":"' || TG_OP || '","table":"' || TG_TABLE_NAME ||
+                    '","obj":{{{fields_to_json("OLD", fields)}}}}}'
+                );
+                RETURN NULL;
+            """,
+        )
+        yield Trigger(
+            name=f"{self.name}_insert_update",
+            level=Row,
+            operation=Insert | Update,
+            when=After,
+            func=f"""
+                PERFORM pg_notify(
+                    '{self.name}',
+                    '{{"op":"' || TG_OP || '","table":"' || TG_TABLE_NAME ||
+                    '","obj":{{{fields_to_json("NEW", fields)}}}}}'
+                );
+                RETURN NULL;
+            """,
+        )
+        yield Trigger(
+            name=f"{self.name}_truncate",
+            level=Statement,
+            operation=Truncate,
+            when=After,
+            func=f"""
+                PERFORM pg_notify(
+                    '{self.name}',
+                    '{{"op":"' || TG_OP || '","table":"' || TG_TABLE_NAME || '"}}'
+                );
+                RETURN NULL;
+            """,
+        )
+
+
+chat_channel = TriggerChannel("chat_channel")
diff --git a/chat/urls.py b/chat/urls.py
new file mode 100644 (file)
index 0000000..c867df7
--- /dev/null
@@ -0,0 +1,22 @@
+from django.conf import settings
+from django.contrib import admin
+from django.contrib.auth.decorators import login_not_required
+from django.contrib.staticfiles.urls import static
+from django.urls import include, path
+from django.views.generic import RedirectView
+
+urlpatterns = [
+    path(
+        "",
+        login_not_required(
+            RedirectView.as_view(pattern_name="channel-main", permanent=True)
+        ),
+        name="root-redirect",
+    ),
+    path("user/", include("user.urls")),
+    path("channel/", include("channel.urls")),
+    path("admin/", admin.site.urls),
+]
+
+if settings.DEBUG:
+    urlpatterns.extend(static(settings.STATIC_URL))
diff --git a/locales/de/LC_MESSAGES/django.po b/locales/de/LC_MESSAGES/django.po
new file mode 100644 (file)
index 0000000..0cbbb9d
--- /dev/null
@@ -0,0 +1,62 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2026-04-22 11:54+0000\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#: user/email.py:26
+msgid "Password reset request"
+msgstr ""
+
+#: user/email.py:28
+msgid ""
+"Hello\n"
+"\n"
+"Someone that is hopefully you, has requested a password request link:\n"
+"\n"
+msgstr ""
+
+#: user/email.py:47
+msgid "Chat Registration"
+msgstr ""
+
+#: user/email.py:48
+#, python-brace-format
+msgid ""
+"username: {username}\n"
+"message: {message}\n"
+"\n"
+"{url}"
+msgstr ""
+
+#: user/forms.py:15 user/forms.py:74
+msgid "Username (or email)"
+msgstr ""
+
+#: user/forms.py:84
+msgid "Message to admins"
+msgstr ""
+
+#: user/views.py:60
+msgid "Registration email submitted."
+msgstr ""
+
+#: user/views.py:76
+msgid "If this user exists, an email is currently being sent."
+msgstr ""
+
+#: user/views.py:122
+msgid "User was already created."
+msgstr ""
+
+#: user/views.py:125
+msgid "User was successfully created."
+msgstr ""
diff --git a/manage.py b/manage.py
new file mode 100755 (executable)
index 0000000..93ff897
--- /dev/null
+++ b/manage.py
@@ -0,0 +1,23 @@
+#!/usr/bin/env python
+"""Django's command-line utility for administrative tasks."""
+
+import os
+import sys
+
+
+def main():
+    """Run administrative tasks."""
+    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "chat.settings")
+    try:
+        from django.core.management import execute_from_command_line
+    except ImportError as exc:
+        raise ImportError(
+            "Couldn't import Django. Are you sure it's installed and "
+            "available on your PYTHONPATH environment variable? Did you "
+            "forget to activate a virtual environment?"
+        ) from exc
+    execute_from_command_line(sys.argv)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644 (file)
index 0000000..a0eb000
--- /dev/null
@@ -0,0 +1,2 @@
+[tool.djlint]
+max_line_length = 88
diff --git a/readme.md b/readme.md
new file mode 100644 (file)
index 0000000..563ba5a
--- /dev/null
+++ b/readme.md
@@ -0,0 +1,17 @@
+# postgresql upgrade process
+
+```
+# pacman -Syu
+# mv /var/lib/postgres/{data,olddata}
+# mkdir /var/lib/postgres/{tmp,data}
+# chown postgres:postgres /var/lib/postgres/{tmp,data}
+# cd /var/lib/postgres/tmp/
+# su postgres -s/bin/bash
+[postgres]$ initdb -D /var/lib/postgres/data --locale=C.UTF-8 --encoding=UTF8 --data-checksums
+[postgres]$ pg_upgrade -b /opt/pgsql-17/bin -B /usr/bin -d ../olddata/ -D ../data
+[postgres]$ rm -rf ../olddata
+exit
+# rc-service postgresql start
+# su postgres -s/bin/bash
+[postgres]$ vacuumdb --all --analyze-in-stages
+```
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
new file mode 100755 (executable)
index 0000000..feaa73a
--- /dev/null
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+
+. .env
+
+{
+    sed -r -e 's@#!/usr/bin/env python3@exec sudo -u http python3@' \
+        -e "$(( $(wc -l<deploy_remote.py) - 1 )),\$d" scripts/deploy_remote.py
+    printf 'DATA = """\n'
+    tar cz $(git ls-files) | base64
+    printf '"""\n\n\nif __name__ == "__main__":\n    main()\n'
+} | ssh -C ${SSH_REMOTE} bash
diff --git a/scripts/deploy_remote.py b/scripts/deploy_remote.py
new file mode 100755 (executable)
index 0000000..acd336e
--- /dev/null
@@ -0,0 +1,116 @@
+#!/usr/bin/env python3
+
+import os
+import shlex
+import site
+import sys
+from base64 import b64decode
+from io import BytesIO
+from pathlib import Path
+from shutil import rmtree
+from signal import SIGHUP
+from subprocess import check_call, check_output
+from tarfile import TarFile
+from time import sleep
+
+DATA = ""
+SKIP_PATHS = (Path(".env"), Path("media"), Path(".venv"), Path("static"))
+BASE_DIR = "/srv/http/freiheit-einfa.ch"
+DJANGO_ENV = {
+    "VIRTUAL_ENV": f"{BASE_DIR}/.venv",
+    "DJANGO_SETTINGS_MODULE": "chat.settings",
+}
+
+
+def process_env(filepath):
+    with open(filepath, "r") as f:
+        for line in f:
+            line = line.rstrip()
+            if not line or line.startswith("#"):
+                continue
+            try:
+                key, value = line.split("=", 1)
+                os.environ[key] = shlex.split(value)[0]
+            except (ValueError, IndexError):
+                continue
+
+
+def setup_django():
+    for key, value in DJANGO_ENV.items():
+        os.environ.setdefault(key, value)
+
+    site_packages = (
+        Path(os.environ["VIRTUAL_ENV"])
+        / "lib"
+        / f"python{sys.version_info.major}.{sys.version_info.minor}"
+        / "site-packages"
+    )
+    if os.path.exists(site_packages):
+        site.addsitedir(site_packages)
+    from django import setup
+
+    setup()
+    from django.core.management import call_command
+
+    call_command("collectstatic", "--noinput")
+    call_command("migrate", "--noinput")
+    call_command("compilemessages", "--ignore", ".venv")
+
+
+def get_child_pids(pid):
+    for pid_path in Path("/proc").iterdir():
+        try:
+            child_pid = int(pid_path.name)
+            content = (pid_path / "stat").read_text()
+            r_paren = content.rfind(")")
+            if r_paren >= 0 and int(content[r_paren + 1 :].split(maxsplit=2)[1]) == pid:
+                yield child_pid
+        except (
+            FileNotFoundError,
+            IndexError,
+            PermissionError,
+            ProcessLookupError,
+            ValueError,
+        ):
+            continue
+
+
+def restart_gunicorn():
+    main_pid = int(check_output(["systemctl", "show", "-P", "MainPID", "uvicorn"]))
+    if main_pid == 0:
+        print("Gunicorn is not running. Never mind, then.")
+        return
+    print(f"Sending SIGHUP to PID {main_pid}")
+    orig = set(get_child_pids(main_pid))
+    os.kill(main_pid, SIGHUP)
+    while orig & (children := set(get_child_pids(main_pid))) or len(children) < len(
+        orig
+    ):
+        sleep(0.3)
+    print("Gunicorn restarted.")
+
+
+def main():
+    os.chdir(BASE_DIR)
+    print("Wiping existing setup...")
+    for entry in Path().iterdir():
+        if entry in SKIP_PATHS:
+            continue
+        if entry.is_dir():
+            rmtree(entry)
+        else:
+            entry.unlink()
+    print("Extracting new tarball...")
+    with TarFile.open(mode="r|gz", fileobj=BytesIO(b64decode(DATA))) as tf:
+        tf.extractall()
+    sys.stdout.flush()
+    print("Setting up virtual env...")
+    check_call(["bash", "./setup_venv.sh"])
+    process_env("./.env")
+    print("Setting up django...")
+    setup_django()
+    restart_gunicorn()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/scripts/pyjslint.py b/scripts/pyjslint.py
new file mode 100755 (executable)
index 0000000..08faa56
--- /dev/null
@@ -0,0 +1,148 @@
+#!/usr/bin/env python
+
+# Copyright (C) 2011  Alejandro Blanco <ablanco@yaco.es>
+# Copyright (C) 2024  mar77i <mar77i@protonmail.ch>
+
+import json
+import sys
+from argparse import ArgumentParser
+from contextlib import contextmanager
+from pathlib import Path
+from subprocess import call
+from tempfile import NamedTemporaryFile
+from urllib.request import urlopen
+
+node_script = r"""
+var jslint = require({jslint}).default.jslint;
+var read = require("fs").readFileSync;
+var result;
+
+function print_warning(warning) {
+    if (warning === null) {
+        return;
+    }
+    if (warning.evidence !== undefined) {
+        console.log(warning.evidence);
+    }
+    console.log(warning.formatted_message);
+}
+
+console.log("Analyzing file " + process.argv[2]);
+result = jslint(read(process.argv[2], "utf8"), {jsoptions}, {jsglobals});
+result.warnings.forEach(print_warning);
+if (result.warnings.length > 0) {
+    console.error(result.warnings.length + " Error(s) found.");
+    process.exit(1);
+} else {
+    console.log("No errors found.");
+    process.exit(0);
+}
+"""
+
+
+def try_json_loads(value):
+    try:
+        return json.loads(value)
+    except json.JSONDecodeError:
+        return value
+
+
+@contextmanager
+def get_writable_tmpfile(mode="w", **kwargs):
+    tmp_dir = Path("/dev/shm")
+    if not tmp_dir.exists():
+        tmp_dir = None
+    kwargs.setdefault("dir", tmp_dir)
+    kwargs.setdefault("delete_on_close", False)
+    with NamedTemporaryFile(mode, **kwargs) as fh:
+        yield fh
+
+
+@contextmanager
+def get_launcher(jslint, jsoptions, jsglobals):
+    jsoptions_dict = {
+        key: try_json_loads(value)
+        for key, value in (item.split(":", 1) for item in jsoptions)
+    }
+    with get_writable_tmpfile() as launcher:
+        launcher.write(
+            node_script.replace("{jslint}", json.dumps(str(jslint.absolute())))
+            .replace("{jsoptions}", json.dumps(jsoptions_dict))
+            .replace("{jsglobals}", json.dumps(jsglobals))
+        )
+        launcher.close()
+        yield launcher.name
+
+
+@contextmanager
+def get_jslint_js(path: Path | None, upgrade) -> str:
+    if path is not None and not upgrade:
+        yield path
+        return
+    with get_writable_tmpfile("wb") if path is None else path.open("wb") as fh:
+        with urlopen(
+            (
+                "https://raw.githubusercontent.com/jslint-org/"
+                "jslint/refs/heads/beta/jslint.mjs"
+            )
+        ) as response:
+            fh.write(response.read())
+        fh.close()
+        yield Path(fh.name)
+
+
+def main():
+    parser = ArgumentParser(f"Usage: {sys.argv[0]}")
+    parser.add_argument(
+        "-u",
+        "--upgrade",
+        dest="upgrade",
+        help="Upgrade JSLint",
+        action="store_true",
+        default=False,
+    )
+    parser.add_argument(
+        "--jslint",
+        "-j",
+        help="JSLint location",
+        type=Path,
+        default=None,  # Path(__file__).parent / "jslint.js",
+    )
+    parser.add_argument(
+        "-o",
+        "--option",
+        dest="jsoptions",
+        help="JSLint options",
+        default=["maxerr: 100"],
+        action="append",
+    )
+    parser.add_argument(
+        "-g",
+        "--global",
+        dest="jsglobals",
+        help="JSLint globals",
+        default=[],
+        action="append",
+    )
+    parser.add_argument(
+        "-n", "--node", dest="node", help="Node location", default="node"
+    )
+    parser.add_argument("files", nargs="+")
+
+    print("p", sys.argv, file=sys.stderr)
+    args = parser.parse_args()
+    print("q", args, file=sys.stderr)
+    returncode = 0
+    with (
+        get_jslint_js(args.jslint, args.upgrade) as jslint,
+        get_launcher(jslint, args.jsoptions, args.jsglobals) as lint_name,
+    ):
+        for file in args.files:
+            ret = call([args.node, lint_name, file])
+            if ret:
+                returncode = ret
+    return returncode
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/scripts/sort_gitignore.sh b/scripts/sort_gitignore.sh
new file mode 100755 (executable)
index 0000000..50c68b2
--- /dev/null
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+
+sorted="$(sort -df "${1}")"
+if ! diff -q <(printf '%s\n' "${sorted}") "${1}"; then
+    printf '%s\n' "${sorted}" > "${1}"
+    exit 1
+fi
diff --git a/setup_venv.sh b/setup_venv.sh
new file mode 100644 (file)
index 0000000..a1c2153
--- /dev/null
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+
+export PYTHON="${PYTHON:-python}"
+
+"${PYTHON}" -m venv .venv
+. .venv/bin/activate
+if [[ -r .env ]]; then
+    set -a
+    . .env
+    set +a
+fi
+export CFLAGS="$(
+    python -c 'import sysconfig; print(sysconfig.get_config_var("CFLAGS"))'
+) -O2"
+"${PYTHON}" -m pip install -U --no-binary \
+    beautysh coverage django-pgtrigger django-stubs djlint gunicorn \
+    pip pre-commit 'psycopg[c]' ruff 'uvicorn[standard]'
diff --git a/user/__init__.py b/user/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/user/admin.py b/user/admin.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/user/apps.py b/user/apps.py
new file mode 100644 (file)
index 0000000..1f2369a
--- /dev/null
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class UserConfig(AppConfig):
+    name = "user"
diff --git a/user/email.py b/user/email.py
new file mode 100644 (file)
index 0000000..4aec994
--- /dev/null
@@ -0,0 +1,53 @@
+from base64 import urlsafe_b64encode
+
+from django.conf import settings
+from django.contrib.auth.tokens import default_token_generator
+from django.core.mail import EmailMultiAlternatives, send_mail, get_connection
+from django.urls import reverse
+from django.utils.encoding import force_bytes
+from django.utils.translation import gettext_lazy as _
+
+from .models import User
+
+
+def send_password_reset_email(request, username_or_email):
+    try:
+        user = User.objects.get_by_natural_key(username_or_email)
+    except User.DoesNotExist:
+        return
+    relative_url = reverse(
+        "user-reset-password",
+        query={
+            "token": default_token_generator.make_token(user),
+            "uidb64": urlsafe_b64encode(force_bytes(user.pk)).decode(),
+        },
+    )
+    send_mail(
+        _("Password reset request"),
+        _(
+            "Hello\n\n"
+            "Someone that is hopefully you, has requested a password request link:\n\n"
+            f"{request.build_absolute_uri(relative_url)}\n\n"
+            "yours,\n"
+            f"{settings.ALLOWED_HOSTS[0]}"
+        ),
+        None,
+        [user.email],
+    )
+
+
+def send_register_email(request, data):
+    url = request.build_absolute_uri(
+        reverse(
+            "user-create",
+            query={key: value for key, value in data.items() if key != "message"},
+        )
+    )
+    return EmailMultiAlternatives(
+        _("Chat Registration"),
+        _("username: {username}\nmessage: {message}\n\n{url}").format(**data, url=url),
+        None,
+        settings.ADMINS,
+        connection=get_connection(),
+        reply_to=[data["email"]],
+    ).send()
diff --git a/user/forms.py b/user/forms.py
new file mode 100644 (file)
index 0000000..0df4870
--- /dev/null
@@ -0,0 +1,84 @@
+from django.contrib.auth import authenticate
+from django.core.exceptions import ValidationError
+from django.forms import CharField, Form, ModelForm, PasswordInput, Textarea, TextInput
+from django.utils.text import capfirst
+from django.utils.translation import gettext_lazy as _
+from django.views.decorators.debug import sensitive_variables
+
+from .models import User
+
+django_gettext_lazy = _
+
+
+class AuthenticationForm(Form):
+    username = CharField(
+        label=_("Username (or email)"),
+        widget=TextInput(attrs={"autofocus": True}),
+    )
+    password = CharField(
+        label=django_gettext_lazy("Password"),
+        strip=False,
+        widget=PasswordInput(attrs={"autocomplete": "current-password"}),
+    )
+
+    error_messages = {
+        "invalid_login": django_gettext_lazy(
+            "Please enter a correct %(username)s and password. Note that both "
+            "fields may be case-sensitive."
+        ),
+    }
+
+    def __init__(self, request=None, *args, **kwargs):
+        """
+        The 'request' parameter is set for custom auth use by subclasses.
+        The form data comes in via the standard 'data' kwarg.
+        """
+        self.request = request
+        self.user_cache = None
+        super().__init__(*args, **kwargs)
+
+        # Set the max length and label for the "username" field.
+        self.username_field = User._meta.get_field(User.USERNAME_FIELD)
+        username_max_length = max(
+            self.username_field.max_length or 254,
+            User._meta.get_field(User.EMAIL_FIELD).max_length or 254,
+        )
+        self.fields["username"].max_length = username_max_length
+        self.fields["username"].widget.attrs["maxlength"] = username_max_length
+        if self.fields["username"].label is None:
+            self.fields["username"].label = capfirst(self.username_field.verbose_name)
+
+    @sensitive_variables()
+    def clean(self):
+        username = self.cleaned_data.get("username")
+        password = self.cleaned_data.get("password")
+
+        if username is not None and password:
+            self.user_cache = authenticate(
+                self.request, username=username, password=password
+            )
+            if self.user_cache is None:
+                raise ValidationError(
+                    self.error_messages["invalid_login"],
+                    code="invalid_login",
+                    params={"username": self.fields["username"].label},
+                )
+        return self.cleaned_data
+
+    def get_user(self):
+        return self.user_cache
+
+
+class ResetPasswordForm(Form):
+    username = CharField(
+        label=_("Username (or email)"),
+        widget=TextInput(attrs={"autofocus": True}),
+    )
+
+
+class RegisterForm(ModelForm):
+    class Meta:
+        model = User
+        fields = ["username", "email"]
+
+    message = CharField(label=_("Message to admins"), widget=Textarea())
diff --git a/user/migrations/0001_initial.py b/user/migrations/0001_initial.py
new file mode 100644 (file)
index 0000000..d61d057
--- /dev/null
@@ -0,0 +1,171 @@
+# Generated by Django 6.0.4 on 2026-04-24 11:37
+
+import django.contrib.auth.validators
+import django.utils.timezone
+import pgtrigger.compiler
+import pgtrigger.migrations
+import user.models
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    initial = True
+
+    dependencies = [
+        ("auth", "0012_alter_user_first_name_max_length"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="User",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("password", models.CharField(max_length=128, verbose_name="password")),
+                (
+                    "last_login",
+                    models.DateTimeField(
+                        blank=True, null=True, verbose_name="last login"
+                    ),
+                ),
+                (
+                    "is_superuser",
+                    models.BooleanField(
+                        default=False,
+                        help_text="Designates that this user has all permissions without explicitly assigning them.",
+                        verbose_name="superuser status",
+                    ),
+                ),
+                (
+                    "username",
+                    models.CharField(
+                        error_messages={
+                            "unique": "A user with that username already exists."
+                        },
+                        help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
+                        max_length=150,
+                        unique=True,
+                        validators=[
+                            django.contrib.auth.validators.UnicodeUsernameValidator()
+                        ],
+                        verbose_name="username",
+                    ),
+                ),
+                (
+                    "first_name",
+                    models.CharField(
+                        blank=True, max_length=150, verbose_name="first name"
+                    ),
+                ),
+                (
+                    "last_name",
+                    models.CharField(
+                        blank=True, max_length=150, verbose_name="last name"
+                    ),
+                ),
+                (
+                    "email",
+                    models.EmailField(
+                        blank=True, max_length=254, verbose_name="email address"
+                    ),
+                ),
+                (
+                    "is_staff",
+                    models.BooleanField(
+                        default=False,
+                        help_text="Designates whether the user can log into this admin site.",
+                        verbose_name="staff status",
+                    ),
+                ),
+                (
+                    "is_active",
+                    models.BooleanField(
+                        default=True,
+                        help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
+                        verbose_name="active",
+                    ),
+                ),
+                (
+                    "date_joined",
+                    models.DateTimeField(
+                        default=django.utils.timezone.now, verbose_name="date joined"
+                    ),
+                ),
+                (
+                    "groups",
+                    models.ManyToManyField(
+                        blank=True,
+                        help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
+                        related_name="user_set",
+                        related_query_name="user",
+                        to="auth.group",
+                        verbose_name="groups",
+                    ),
+                ),
+                (
+                    "user_permissions",
+                    models.ManyToManyField(
+                        blank=True,
+                        help_text="Specific permissions for this user.",
+                        related_name="user_set",
+                        related_query_name="user",
+                        to="auth.permission",
+                        verbose_name="user permissions",
+                    ),
+                ),
+            ],
+            managers=[
+                ("objects", user.models.UserManager()),
+            ],
+        ),
+        pgtrigger.migrations.AddTrigger(
+            model_name="user",
+            trigger=pgtrigger.compiler.Trigger(
+                name="chat_channel_delete",
+                sql=pgtrigger.compiler.UpsertTriggerSql(
+                    func='\n                PERFORM pg_notify(\n                    \'chat_channel\',\n                    \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n                    \'","obj":{"id":\' || OLD."id" || \',"username":\' || OLD."username" || \',"email":\' || OLD."email" || \',"first_name":\' || OLD."first_name" || \',"last_name":\' || OLD."last_name" || \',"date_joined":\' || OLD."date_joined" || \',"channels":\' || OLD."channels" || \'}}\'\n                );\n                RETURN NULL;\n            ',
+                    hash="028118469da41b01d89a7189ba91aab95db1c53f",
+                    operation="DELETE",
+                    pgid="pgtrigger_chat_channel_delete_a66e5",
+                    table="user_user",
+                    when="AFTER",
+                ),
+            ),
+        ),
+        pgtrigger.migrations.AddTrigger(
+            model_name="user",
+            trigger=pgtrigger.compiler.Trigger(
+                name="chat_channel_insert_update",
+                sql=pgtrigger.compiler.UpsertTriggerSql(
+                    func='\n                PERFORM pg_notify(\n                    \'chat_channel\',\n                    \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n                    \'","obj":{"id":\' || NEW."id" || \',"username":\' || NEW."username" || \',"email":\' || NEW."email" || \',"first_name":\' || NEW."first_name" || \',"last_name":\' || NEW."last_name" || \',"date_joined":\' || NEW."date_joined" || \',"channels":\' || NEW."channels" || \'}}\'\n                );\n                RETURN NULL;\n            ',
+                    hash="225c79a6c8ca8556fcad9b1bd9826fd90172f63a",
+                    operation="INSERT OR UPDATE",
+                    pgid="pgtrigger_chat_channel_insert_update_72ed2",
+                    table="user_user",
+                    when="AFTER",
+                ),
+            ),
+        ),
+        pgtrigger.migrations.AddTrigger(
+            model_name="user",
+            trigger=pgtrigger.compiler.Trigger(
+                name="chat_channel_truncate",
+                sql=pgtrigger.compiler.UpsertTriggerSql(
+                    func="\n                PERFORM pg_notify(\n                    'chat_channel',\n                    '{\"op\":\"' || TG_OP || '\",\"table\":\"' || TG_TABLE_NAME || '\"}'\n                );\n                RETURN NULL;\n            ",
+                    hash="967da6f33240942f142b2c1848e263089d958e83",
+                    level="STATEMENT",
+                    operation="TRUNCATE",
+                    pgid="pgtrigger_chat_channel_truncate_8a96a",
+                    table="user_user",
+                    when="AFTER",
+                ),
+            ),
+        ),
+    ]
diff --git a/user/migrations/__init__.py b/user/migrations/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/user/models.py b/user/models.py
new file mode 100644 (file)
index 0000000..288deb8
--- /dev/null
@@ -0,0 +1,41 @@
+from django.contrib.auth.models import AbstractUser, UserManager as DjangoUserManager
+from django.db.models import Q, QuerySet
+
+from chat.triggers import chat_channel
+
+
+class UserManager(DjangoUserManager):
+    def _get_by_natural_key(self, username, queryset_method):
+        return queryset_method(
+            self.filter(is_active=True),
+            Q(**{self.model.USERNAME_FIELD: username})
+            | Q(**{self.model.EMAIL_FIELD: username}),
+        )
+
+    def get_by_natural_key(self, username):
+        return self._get_by_natural_key(username, QuerySet.get)
+
+    async def aget_by_natural_key(self, username):
+        return await self._get_by_natural_key(username, QuerySet.aget)
+
+
+class User(AbstractUser):
+    objects = UserManager()
+
+    class Meta:
+        triggers = [
+            *chat_channel(
+                (
+                    "id",
+                    "username",
+                    "email",
+                    "first_name",
+                    "last_name",
+                    "date_joined",
+                    "channels",
+                )
+            )
+        ]
+
+    def is_privileged(self):
+        return self.is_staff or self.is_superuser
diff --git a/user/templates/user/user.html b/user/templates/user/user.html
new file mode 100644 (file)
index 0000000..58633a8
--- /dev/null
@@ -0,0 +1,28 @@
+{% extends "chat/base.html" %}
+{% block title %}
+    {{ title }}
+{% endblock title %}
+{% block main %}
+    <h1>{{ title }}</h1>
+    {% if messages %}
+        <ul class="messages">
+            {% for message in messages %}
+                <li class="{{ message.tags|default:'' }}">{{ message }}</li>
+            {% endfor %}
+        </ul>
+    {% endif %}
+    {% if form %}
+        <form method="post">
+            {% csrf_token %}
+            {{ form.as_p }}
+            <p>
+                <input type="submit" value="Submit" />
+            </p>
+        </form>
+    {% endif %}
+    {% for label, url in links.items %}
+        <p>
+            <a href="{{ url }}">{{ label }}</a>
+        </p>
+    {% endfor %}
+{% endblock main %}
diff --git a/user/tests.py b/user/tests.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/user/urls.py b/user/urls.py
new file mode 100644 (file)
index 0000000..9389c19
--- /dev/null
@@ -0,0 +1,11 @@
+from django.urls import path
+
+from .views import LoginView, LogoutView, RegisterView, ResetPasswordView, create_user
+
+urlpatterns = [
+    path("login", LoginView.as_view(), name="user-login"),
+    path("logout", LogoutView.as_view(), name="user-logout"),
+    path("reset-password", ResetPasswordView.as_view(), name="user-reset-password"),
+    path("register", RegisterView.as_view(), name="user-register"),
+    path("create", create_user, name="user-create"),
+]
diff --git a/user/views.py b/user/views.py
new file mode 100644 (file)
index 0000000..e90b2de
--- /dev/null
@@ -0,0 +1,126 @@
+from base64 import urlsafe_b64decode
+
+from django.contrib.auth import logout
+from django.contrib.auth.decorators import login_not_required
+from django.contrib.auth.forms import SetPasswordForm
+from django.contrib.auth.tokens import default_token_generator
+from django.contrib.auth.views import (
+    INTERNAL_RESET_SESSION_TOKEN,
+    LoginView as DjangoLoginView,
+)
+from django.shortcuts import redirect
+from django.urls import reverse_lazy
+from django.utils.decorators import method_decorator
+from django.utils.translation import gettext_lazy as _
+from django.contrib import messages
+from django.views.decorators.http import require_GET
+from django.views.generic import FormView, RedirectView
+
+from chat.bridge import bridge
+from .email import send_password_reset_email, send_register_email
+from .forms import (
+    AuthenticationForm,
+    RegisterForm,
+    ResetPasswordForm,
+    django_gettext_lazy,
+)
+from .models import User
+
+
+class LoginView(DjangoLoginView):
+    next_page = reverse_lazy("channel-main")
+    form_class = AuthenticationForm
+    template_name = "user/user.html"
+    extra_context = {
+        "title": "Login",
+        "links": {
+            "Reset Password": reverse_lazy("user-reset-password"),
+            "Register": reverse_lazy("user-register"),
+        },
+    }
+
+
+class LogoutView(RedirectView):
+    url = reverse_lazy("user-login")
+
+    def get(self, request, *args, **kwargs):
+        logout(request)
+        return super().get(request)
+
+
+@method_decorator(login_not_required, name="dispatch")
+class RegisterView(FormView):
+    form_class = RegisterForm
+    template_name = "user/user.html"
+    extra_context = {"title": "Register"}
+    success_url = reverse_lazy("user-login")
+
+    def form_valid(self, form):
+        bridge.call_async(send_register_email, self.request, form.cleaned_data)
+        messages.info(self.request, _("Registration email submitted."))
+        return redirect("user-login")
+
+
+@method_decorator(login_not_required, name="dispatch")
+class ResetPasswordView(FormView):
+    form_class = ResetPasswordForm
+    template_name = "user/user.html"
+    extra_context = {"title": "Reset Password"}
+    success_url = reverse_lazy("user-login")
+
+    def send_password_reset_email(self, form):
+        bridge.call_async(
+            send_password_reset_email, self.request, form.cleaned_data["username"]
+        )
+        messages.info(
+            self.request, _("If this user exists, an email is currently being sent.")
+        )
+
+    def save_new_password(self, form):
+        form.save()
+        del self.request.session[INTERNAL_RESET_SESSION_TOKEN]
+        messages.info(self.request, django_gettext_lazy("Password reset complete"))
+
+    def form_valid(self, form):
+        if "username" in form.cleaned_data:
+            self.send_password_reset_email(form)
+        elif (
+            "new_password1" in form.cleaned_data
+            and "new_password2" in form.cleaned_data
+        ):
+            self.save_new_password(form)
+        return super().form_valid(form)
+
+    def get_form(self, form_class=None):
+        request = self.request
+        if (
+            request.method == "GET"
+            and "token" in request.GET
+            and "uidb64" in request.GET
+        ):
+            user = User.objects.get(pk=urlsafe_b64decode(request.GET["uidb64"]))
+            default_token_generator.check_token(user, request.GET["token"])
+            request.session[INTERNAL_RESET_SESSION_TOKEN] = user.pk
+        elif (
+            request.method == "POST"
+            and "new_password1" in request.POST
+            and "new_password2" in request.POST
+            and "token" in request.GET
+        ):
+            user = User.objects.get(pk=request.session[INTERNAL_RESET_SESSION_TOKEN])
+            default_token_generator.check_token(user, request.GET["token"])
+        else:
+            return super().get_form(form_class)
+        return SetPasswordForm(user, **self.get_form_kwargs())
+
+
+@require_GET
+def create_user(request):
+    if User.objects.filter(
+        username=request.GET["username"], email=request.GET["email"]
+    ):
+        messages.info(request, _("User was already created."))
+    else:
+        User.objects.create(**request.GET.dict(), is_active=True)
+        messages.info(request, _("User was successfully created."))
+    return redirect("channel-main")