]> git.mar77i.info Git - chat/commitdiff
make those triggers produce valid json. tests for the user rest api endpoint
authormar77i <mar77i@protonmail.ch>
Sun, 10 May 2026 15:06:47 +0000 (17:06 +0200)
committermar77i <mar77i@protonmail.ch>
Sun, 10 May 2026 15:06:47 +0000 (17:06 +0200)
20 files changed:
channel/migrations/0001_initial.py
channel/migrations/0002_initial.py
channel/static/channel/chatutils.js
channel/static/channel/main.js [new file with mode: 0644]
channel/templates/channel/main.html
channel/urls.py
channel/websocket.py [new file with mode: 0644]
chat/asgi.py
chat/triggers.py
locales/de/LC_MESSAGES/django.po
rest/serializers.py
rest/tests.py [new file with mode: 0644]
rest/urls.py
rest/views.py
user/email.py
user/migrations/0001_initial.py
user/migrations/0002_user_last_password_change.py [deleted file]
user/tests.py
user/urls.py
user/views.py

index db5bcf75cbc5351a9e1dfb52ecc81297c03bdceb..283ee6f3faf3ece2ad9d0ced1520f25ecf40fbfb 100644 (file)
@@ -1,4 +1,4 @@
-# Generated by Django 6.0.4 on 2026-04-24 13:34
+# Generated by Django 6.0.4 on 2026-05-10 15:04
 
 import django.db.models.deletion
 from django.db import migrations, models
index 1972dd5f1f0cb71847a59da717da59641002fbb7..00f69abc095fcd3e59d00d877da15d850181bb4a 100644 (file)
@@ -1,4 +1,4 @@
-# Generated by Django 6.0.4 on 2026-04-24 13:34
+# Generated by Django 6.0.4 on 2026-05-10 15:04
 
 import django.db.models.deletion
 import pgtrigger.compiler
@@ -77,8 +77,8 @@ class Migration(migrations.Migration):
             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" || \',"posted_ts":\' || OLD."posted_ts" || \',"edited_ts":\' || OLD."edited_ts" || \',"user_id":\' || OLD."user_id" || \',"channel_id":\' || OLD."channel_id" || \',"text":\' || OLD."text" || \'}}\'\n                );\n                RETURN NULL;\n            ',
-                    hash="4d9a92efa5e09a6f1b6133b0058532a81f154a19",
+                    func='\n                PERFORM pg_notify(\n                    \'chat_channel\',\n                    json_build_object(\n                        \'op\', TG_OP,\n                        \'table\', TG_TABLE_NAME,\n                        \'obj\', json_build_object(\'field\', "OLD"."id", \'field\', "OLD"."posted_ts", \'field\', "OLD"."edited_ts", \'field\', "OLD"."user_id", \'field\', "OLD"."channel_id", \'field\', "OLD"."text")\n                    )\n                );\n                RETURN NULL;\n            ',
+                    hash="07fe162a2e795edf3c9f3a558a07a129c0d932a7",
                     operation="DELETE",
                     pgid="pgtrigger_chat_channel_delete_e3727",
                     table="channel_channelmessage",
@@ -91,8 +91,8 @@ class Migration(migrations.Migration):
             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" || \',"posted_ts":\' || NEW."posted_ts" || \',"edited_ts":\' || NEW."edited_ts" || \',"user_id":\' || NEW."user_id" || \',"channel_id":\' || NEW."channel_id" || \',"text":\' || NEW."text" || \'}}\'\n                );\n                RETURN NULL;\n            ',
-                    hash="53d66e09ee2f16df9a76df7c84d04f03c3899ecb",
+                    func='\n                PERFORM pg_notify(\n                    \'chat_channel\',\n                    json_build_object(\n                        \'op\', TG_OP,\n                        \'table\', TG_TABLE_NAME,\n                        \'obj\', json_build_object(\'field\', "NEW"."id", \'field\', "NEW"."posted_ts", \'field\', "NEW"."edited_ts", \'field\', "NEW"."user_id", \'field\', "NEW"."channel_id", \'field\', "NEW"."text")\n                    )\n                );\n                RETURN NULL;\n            ',
+                    hash="0d1f1559c08c23f871043630f4808a9e20716850",
                     operation="INSERT OR UPDATE",
                     pgid="pgtrigger_chat_channel_insert_update_a5e85",
                     table="channel_channelmessage",
@@ -105,8 +105,8 @@ class Migration(migrations.Migration):
             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",
+                    func="\n                PERFORM pg_notify(\n                    'chat_channel',\n                    json_build_object('op', TG_OP, 'table', TG_TABLE_NAME)\n                );\n                RETURN NULL;\n            ",
+                    hash="c79a373af4522bc4774ae554e5e93789aea06d05",
                     level="STATEMENT",
                     operation="TRUNCATE",
                     pgid="pgtrigger_chat_channel_truncate_ab388",
@@ -120,8 +120,8 @@ class Migration(migrations.Migration):
             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" || \',"added_ts":\' || OLD."added_ts" || \',"user_id":\' || OLD."user_id" || \',"channel_id":\' || OLD."channel_id" || \'}}\'\n                );\n                RETURN NULL;\n            ',
-                    hash="bdcd7bfc868f23b48db6af8414d7bfb0b607149d",
+                    func="\n                PERFORM pg_notify(\n                    'chat_channel',\n                    json_build_object(\n                        'op', TG_OP,\n                        'table', TG_TABLE_NAME,\n                        'obj', json_build_object('field', \"OLD\".\"id\", 'field', \"OLD\".\"added_ts\", 'field', \"OLD\".\"user_id\", 'field', \"OLD\".\"channel_id\")\n                    )\n                );\n                RETURN NULL;\n            ",
+                    hash="60bd75faca7769c12f0a42219e5abafcf46de6ca",
                     operation="DELETE",
                     pgid="pgtrigger_chat_channel_delete_d1dad",
                     table="channel_channeluser",
@@ -134,8 +134,8 @@ class Migration(migrations.Migration):
             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" || \',"added_ts":\' || NEW."added_ts" || \',"user_id":\' || NEW."user_id" || \',"channel_id":\' || NEW."channel_id" || \'}}\'\n                );\n                RETURN NULL;\n            ',
-                    hash="2c178a72a40e11e6ea1f2fca4b4e94fe0b590277",
+                    func="\n                PERFORM pg_notify(\n                    'chat_channel',\n                    json_build_object(\n                        'op', TG_OP,\n                        'table', TG_TABLE_NAME,\n                        'obj', json_build_object('field', \"NEW\".\"id\", 'field', \"NEW\".\"added_ts\", 'field', \"NEW\".\"user_id\", 'field', \"NEW\".\"channel_id\")\n                    )\n                );\n                RETURN NULL;\n            ",
+                    hash="94006ba6a11d60a0c08e9e9812a1dd4dc2be4671",
                     operation="INSERT OR UPDATE",
                     pgid="pgtrigger_chat_channel_insert_update_4abda",
                     table="channel_channeluser",
@@ -148,8 +148,8 @@ class Migration(migrations.Migration):
             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",
+                    func="\n                PERFORM pg_notify(\n                    'chat_channel',\n                    json_build_object('op', TG_OP, 'table', TG_TABLE_NAME)\n                );\n                RETURN NULL;\n            ",
+                    hash="24b3dfe313c8d9fb49df15607acae940d611dd8a",
                     level="STATEMENT",
                     operation="TRUNCATE",
                     pgid="pgtrigger_chat_channel_truncate_be185",
@@ -163,8 +163,8 @@ class Migration(migrations.Migration):
             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" || \',"created_ts":\' || OLD."created_ts" || \',"name":\' || OLD."name" || \'}}\'\n                );\n                RETURN NULL;\n            ',
-                    hash="d5bc46053d7e825d228f68ce90aa068a3bf33458",
+                    func="\n                PERFORM pg_notify(\n                    'chat_channel',\n                    json_build_object(\n                        'op', TG_OP,\n                        'table', TG_TABLE_NAME,\n                        'obj', json_build_object('field', \"OLD\".\"id\", 'field', \"OLD\".\"created_ts\", 'field', \"OLD\".\"name\")\n                    )\n                );\n                RETURN NULL;\n            ",
+                    hash="e4beddaa6aeea6939a68be72310dbf204993946d",
                     operation="DELETE",
                     pgid="pgtrigger_chat_channel_delete_71f70",
                     table="channel_channel",
@@ -177,8 +177,8 @@ class Migration(migrations.Migration):
             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" || \',"created_ts":\' || NEW."created_ts" || \',"name":\' || NEW."name" || \'}}\'\n                );\n                RETURN NULL;\n            ',
-                    hash="0ca4fae492403b7b12267933aadc6c8f8cc325ef",
+                    func="\n                PERFORM pg_notify(\n                    'chat_channel',\n                    json_build_object(\n                        'op', TG_OP,\n                        'table', TG_TABLE_NAME,\n                        'obj', json_build_object('field', \"NEW\".\"id\", 'field', \"NEW\".\"created_ts\", 'field', \"NEW\".\"name\")\n                    )\n                );\n                RETURN NULL;\n            ",
+                    hash="4215198b8261bd097742e9227732675a3d0bdc1c",
                     operation="INSERT OR UPDATE",
                     pgid="pgtrigger_chat_channel_insert_update_0f4bc",
                     table="channel_channel",
@@ -191,8 +191,8 @@ class Migration(migrations.Migration):
             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",
+                    func="\n                PERFORM pg_notify(\n                    'chat_channel',\n                    json_build_object('op', TG_OP, 'table', TG_TABLE_NAME)\n                );\n                RETURN NULL;\n            ",
+                    hash="80b7a9088bce0dcb1e3e7adf6b54e5473272f203",
                     level="STATEMENT",
                     operation="TRUNCATE",
                     pgid="pgtrigger_chat_channel_truncate_2f496",
@@ -206,8 +206,8 @@ class Migration(migrations.Migration):
             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" || \',"posted_ts":\' || OLD."posted_ts" || \',"edited_ts":\' || OLD."edited_ts" || \',"sender_id":\' || OLD."sender_id" || \',"recipient_id":\' || OLD."recipient_id" || \',"text":\' || OLD."text" || \'}}\'\n                );\n                RETURN NULL;\n            ',
-                    hash="80d30fe7e136d5e9fb35eb4a4d8f8e05080d7868",
+                    func='\n                PERFORM pg_notify(\n                    \'chat_channel\',\n                    json_build_object(\n                        \'op\', TG_OP,\n                        \'table\', TG_TABLE_NAME,\n                        \'obj\', json_build_object(\'field\', "OLD"."id", \'field\', "OLD"."posted_ts", \'field\', "OLD"."edited_ts", \'field\', "OLD"."sender_id", \'field\', "OLD"."recipient_id", \'field\', "OLD"."text")\n                    )\n                );\n                RETURN NULL;\n            ',
+                    hash="04d3d16eba42ed3fcd7bae3e0c45b9fdcd0ec05f",
                     operation="DELETE",
                     pgid="pgtrigger_chat_channel_delete_5cdfa",
                     table="channel_privatemessage",
@@ -220,8 +220,8 @@ class Migration(migrations.Migration):
             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" || \',"posted_ts":\' || NEW."posted_ts" || \',"edited_ts":\' || NEW."edited_ts" || \',"sender_id":\' || NEW."sender_id" || \',"recipient_id":\' || NEW."recipient_id" || \',"text":\' || NEW."text" || \'}}\'\n                );\n                RETURN NULL;\n            ',
-                    hash="168788f9d00ca7895225db105540b91b8919ad66",
+                    func='\n                PERFORM pg_notify(\n                    \'chat_channel\',\n                    json_build_object(\n                        \'op\', TG_OP,\n                        \'table\', TG_TABLE_NAME,\n                        \'obj\', json_build_object(\'field\', "NEW"."id", \'field\', "NEW"."posted_ts", \'field\', "NEW"."edited_ts", \'field\', "NEW"."sender_id", \'field\', "NEW"."recipient_id", \'field\', "NEW"."text")\n                    )\n                );\n                RETURN NULL;\n            ',
+                    hash="078224b936ff9395c5ea4cae1729671d25b0b1f5",
                     operation="INSERT OR UPDATE",
                     pgid="pgtrigger_chat_channel_insert_update_4c85f",
                     table="channel_privatemessage",
@@ -234,8 +234,8 @@ class Migration(migrations.Migration):
             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",
+                    func="\n                PERFORM pg_notify(\n                    'chat_channel',\n                    json_build_object('op', TG_OP, 'table', TG_TABLE_NAME)\n                );\n                RETURN NULL;\n            ",
+                    hash="f329902c9edc996be6f7b56c8a3e318aed50f1c3",
                     level="STATEMENT",
                     operation="TRUNCATE",
                     pgid="pgtrigger_chat_channel_truncate_d9c27",
index 04184f8d2f28ae65d65b127b9d27ee297ffd1790..5f5750375b6a302c2944a4aee36d1dedca989038 100644 (file)
@@ -1,4 +1,5 @@
 (function () {
+    var data_methods = ["post", "put", "patch"];
     function foreach(obj, callback, start) {
         var i;
         var o;
             out.push(s);
             return out.join("");
         },
-        "xhr": function (method, url, callbacks, data) {
+        "xhr": function (method, url, data, load_cb, abort_cb, error_cb) {
             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.addEventListener(
+                "load",
+                load_cb || function (event) {
+                    console.log(
+                        "xhr_load",
+                        event.target.status,
+                        event.target.response
+                    );
+                }
+            );
+            request.addEventListener(
+                "abort",
+                abort_cb || function (event) {
+                    console.log("xhr_abort", event.target.response);
+                }
+            );
+            request.addEventListener(
+                "error",
+                error_cb || function (event) {
+                    console.log("xhr_error", event.target.response);
                 }
             );
             request.open(method, url);
-            if (
-                method.toLowerCase() === "put"
-                || method.toLowerCase() === "post"
-            ) {
-                request.send(data);
+            if (data_methods.includes(method.toLowerCase())) {
+                request.setRequestHeader("Content-Type", "application/json");
+                if (typeof data === "object") {
+                    data = JSON.stringify(data);
+                }
             } else {
-                request.send();
+                data = null;
             }
+            request.send(data);
             return request;
         }
     };
diff --git a/channel/static/channel/main.js b/channel/static/channel/main.js
new file mode 100644 (file)
index 0000000..7be0c27
--- /dev/null
@@ -0,0 +1,45 @@
+(function () {
+    /*
+    - make it as simple as possible to connect and reconnect the websocket
+    - let's use fragments for navigation:
+      - #user:<user-id> for private messages
+      - #channel:<channel-id> for channels
+    var text_node = document.createTextNode;
+    var create_tag = document.createElement;
+     */
+
+    function websocket_message(event) {
+        console.log("websocket_message", event);
+    }
+
+    function websocket_close(event) {
+        console.log("websocket_close", event);
+    }
+
+    function websocket_error(event) {
+        console.log("websocket_error", event);
+    }
+
+    function websocket_connect() {
+        var websocket_schemas = {"http:": "ws:", "https:": "wss:"};
+        var websocket = new WebSocket(
+            websocket_schemas[window.location.protocol]
+            + "//"
+            + window.location.host
+            + "/"
+        );
+        websocket.addEventListener("message", websocket_message);
+        websocket.addEventListener("close", websocket_close);
+        websocket.addEventListener("error", websocket_error);
+    }
+
+    document.addEventListener(
+        "readystatechange",
+        function () {
+            if (document.readyState !== "complete") {
+                return;
+            }
+            websocket_connect();
+        }
+    );
+}());
index d0599bf9ab6f7a359856932440be43783c01186a..9ab1ff76fc57e74a804d9a6bf41a0ace038b7e6f 100644 (file)
@@ -4,6 +4,7 @@
     {{ block.super }}
     <link rel="stylesheet" href="{% static 'channel/styles.css' %}">
     <script src="{% static 'channel/chatutils.js' %}"></script>
+    <script src="{% static 'channel/main.js' %}"></script>
 {% endblock head %}
 {% block header %}
     {% if messages %}
index e02305ac42f19714b25de506e817619c0a39bc90..bba6c8c38723e2a233f37bb8b80589516d747c68 100644 (file)
@@ -1,7 +1,9 @@
 from django.urls import path
 
 from .views import ChannelMainView
+from .websocket import handle_websocket
 
 urlpatterns = [
     path("", ChannelMainView.as_view(), name="channel-main"),
 ]
+websocket_urls = {"/": handle_websocket}
diff --git a/channel/websocket.py b/channel/websocket.py
new file mode 100644 (file)
index 0000000..0b0b105
--- /dev/null
@@ -0,0 +1,117 @@
+import json
+from asyncio import (
+    CancelledError,
+    ensure_future,
+    get_running_loop,
+)
+from contextlib import contextmanager
+from functools import partial
+from io import BytesIO
+from traceback import print_exc
+
+from asgiref.sync import sync_to_async
+from django.core.handlers.asgi import ASGIRequest
+from django.contrib.sessions.middleware import SessionMiddleware
+from django.contrib.auth import aget_user
+from django.db import connection
+
+from chat.triggers import TriggerChannel
+
+
+@contextmanager
+def listen_notify_handler(connection, callback):
+    loop = get_running_loop()
+    loop.add_reader(connection.fileno(), partial(connection.execute, "SELECT 1"))
+    connection.add_notify_handler(callback)
+    for name in TriggerChannel.registry:
+        connection.execute(f"LISTEN {name}")
+    try:
+        yield
+    finally:
+        for name in TriggerChannel.registry:
+            connection.execute(f"UNLISTEN {name}")
+        connection.remove_notify_handler(callback)
+        loop.remove_reader(connection.fileno())
+
+
+def filter_trigger_always(coro, data, user, user_channels):
+    ensure_future(coro)
+
+
+def filter_trigger_privatemessage(coro, data, user, user_channels):
+    if user.pk in (data["obj"]["sender_id"], data["obj"]["recipient_id"]):
+        ensure_future(coro)
+    else:
+        coro.close()
+
+
+filter_triggers = {
+    "user_user": filter_trigger_always,
+    "channel_channel": filter_trigger_always,
+    # "channel_channelmessage": filter_trigger_channelmessage,
+    # "channel_channeluser": filter_trigger_channeluser,
+    "channel_privatemessage": filter_trigger_privatemessage,
+}
+
+
+def process_triggers(send, user, user_channels, notification):
+    data = json.loads(notification.payload)
+    filter_triggers[data["table"]](
+        send({"type": "websocket.send", "text": notification.payload}),
+        data,
+        user,
+        user_channels,
+    )
+
+
+def get_user_channels(user):
+    return set(user.channels.all().values_list("pk", flat=True))
+
+
+async def process_ws(receive, send):
+    while True:
+        event = await receive()
+        if event["type"] == "websocket.connect":
+            await send({"type": "websocket.accept"})
+        elif event["type"] == "websocket.disconnect":
+            return
+        elif event["type"] == "websocket.receive":
+            # ...maybe make it possible to request data through the ws?
+            if event["text"] == "ping":
+                await send(
+                    {
+                        "type": "websocket.send",
+                        "text": "pong",
+                    },
+                )
+
+
+async def handle_websocket(scope, receive, send):
+    request = ASGIRequest({**scope, "method": "_ws"}, BytesIO())
+    SessionMiddleware(lambda x: None).process_request(request)
+    request.user = await aget_user(request)
+
+    if not request.user.is_authenticated:
+        await send({"type": "websocket.close"})
+        return
+
+    await sync_to_async(connection.connect)()
+    with listen_notify_handler(
+        connection.connection,
+        partial(
+            process_triggers,
+            send,
+            request.user,
+            await sync_to_async(get_user_channels)(request.user),
+        ),
+    ):
+        try:
+            await process_ws(receive, send)
+        except CancelledError:
+            pass
+        except Exception:
+            print_exc()
+            try:
+                await send({"type": "websocket.close"})
+            except Exception:
+                pass
index dcb3f854c0be92180d310cbd8cb1ae3c0447a77e..e72a38dcc438657185277f67c99433570d214b19 100644 (file)
@@ -19,7 +19,11 @@ django_app = get_asgi_application()
 
 
 async def application(scope, receive, send):
+    from channel.urls import websocket_urls
+
     if scope["type"] == "lifespan":
         await bridge.listen(receive, send)
+    elif scope["type"] == "websocket":
+        await websocket_urls.get(scope["path"])(scope, receive, send)
     else:
         await django_app(scope, receive, send)
index e75acff42e901f345d352cc89220d167ba79bd1d..469e84902d67fb77396e8274aadd795f2f84e370 100644 (file)
@@ -1,12 +1,19 @@
 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)
+def fields_to_json_build_object(table, fields):
+    args = []
+    for field in fields:
+        args.extend(("'field'", f'"{table}"."{field}"'))
+    return f"json_build_object({', '.join(args)})"
 
 
 class TriggerChannel:
+    registry = {}
+
     def __init__(self, name):
+        assert name not in self.registry
+        self.registry[name] = self
         self.name = name
 
     def __call__(self, fields):
@@ -18,8 +25,11 @@ class TriggerChannel:
             func=f"""
                 PERFORM pg_notify(
                     '{self.name}',
-                    '{{"op":"' || TG_OP || '","table":"' || TG_TABLE_NAME ||
-                    '","obj":{{{fields_to_json("OLD", fields)}}}}}'
+                    json_build_object(
+                        'op', TG_OP,
+                        'table', TG_TABLE_NAME,
+                        'obj', {fields_to_json_build_object("OLD", fields)}
+                    )
                 );
                 RETURN NULL;
             """,
@@ -32,8 +42,11 @@ class TriggerChannel:
             func=f"""
                 PERFORM pg_notify(
                     '{self.name}',
-                    '{{"op":"' || TG_OP || '","table":"' || TG_TABLE_NAME ||
-                    '","obj":{{{fields_to_json("NEW", fields)}}}}}'
+                    json_build_object(
+                        'op', TG_OP,
+                        'table', TG_TABLE_NAME,
+                        'obj', {fields_to_json_build_object("NEW", fields)}
+                    )
                 );
                 RETURN NULL;
             """,
@@ -46,7 +59,7 @@ class TriggerChannel:
             func=f"""
                 PERFORM pg_notify(
                     '{self.name}',
-                    '{{"op":"' || TG_OP || '","table":"' || TG_TABLE_NAME || '"}}'
+                    json_build_object('op', TG_OP, 'table', TG_TABLE_NAME)
                 );
                 RETURN NULL;
             """,
index 802a7d272c499277062fd7aeef3453846b11df32..e26cfc91747d49c4d786451754962a5cb191c23e 100644 (file)
@@ -2,7 +2,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-05-01 09:44+0000\n"
+"POT-Creation-Date: 2026-05-09 12:56+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"
@@ -12,7 +12,11 @@ msgstr ""
 "Content-Transfer-Encoding: 8bit\n"
 "Plural-Forms: nplurals=2; plural=(n != 1);\n"
 
-#: user/email.py:29
+#: user/email.py:30
+msgid "New user password reset"
+msgstr ""
+
+#: user/email.py:32
 #, python-brace-format
 msgid ""
 "Hello {username}\n"
@@ -25,7 +29,11 @@ msgid ""
 "{host}"
 msgstr ""
 
-#: user/email.py:35
+#: user/email.py:37
+msgid "Password reset request"
+msgstr ""
+
+#: user/email.py:39
 #, python-brace-format
 msgid ""
 "Hello {username}\n"
@@ -38,17 +46,11 @@ msgid ""
 "{host}"
 msgstr ""
 
-#: user/email.py:40
-#, python-brace-format
-msgid "{host}: Password reset request"
-msgstr ""
-
-#: user/email.py:58
-#, python-brace-format
-msgid "{host}: Confirm your email address"
+#: user/email.py:61
+msgid "Confirm your email address"
 msgstr ""
 
-#: user/email.py:60
+#: user/email.py:65
 #, python-brace-format
 msgid ""
 "Hello {username}\n"
@@ -62,11 +64,11 @@ msgid ""
 "{host}"
 msgstr ""
 
-#: user/email.py:82
-msgid "Chat Registration Request"
+#: user/email.py:86
+msgid "Chat registration request"
 msgstr ""
 
-#: user/email.py:83
+#: user/email.py:89
 #, python-brace-format
 msgid ""
 "username: {username}\n"
index 05e9b2a6f2842c724f5b5c907905615f6ceb090f..e852b9df177ad1d92270909374557af87bd3285c 100644 (file)
@@ -23,7 +23,7 @@ class ModelSerializer:
     def field_to_json(self, field_name, instance):
         if field_name == "url":
             name = self.model._meta.verbose_name.lower().replace(" ", "")
-            value = reverse(f"{name}-detail", args=[instance.pk])
+            value = reverse(f"api-{name}-detail", args=[instance.pk])
             if self.request:
                 value = self.request.build_absolute_uri(value)
             return value
diff --git a/rest/tests.py b/rest/tests.py
new file mode 100644 (file)
index 0000000..ac8e593
--- /dev/null
@@ -0,0 +1,13 @@
+import json
+
+from django.test.client import Client
+
+
+class RestClient(Client):
+    def put_json(self, path, data, *args, **kwargs):
+        kwargs.setdefault("content_type", "application/json")
+        return super().put(path, json.dumps(data), *args, **kwargs)
+
+    def post_json(self, path, data, *args, **kwargs):
+        kwargs.setdefault("content_type", "application/json")
+        return super().post(path, json.dumps(data), *args, **kwargs)
index ca951630c6dcf036cc4c301221daab11bcb760db..39627c039f399e4b8da90387794d78708d330937 100644 (file)
@@ -2,18 +2,16 @@ from django.urls import path
 
 
 urlpatterns = []
+list_map = {"get": "list", "post": "create"}
+detail_map = {"get": "detail", "put": "update", "delete": "delete"}
 
 
 def get_urls(view_class, name, *extra_patterns):
-    for method_map in (
-        {"get": "list", "post": "create"},
-        {"get": "detail", "put": "update", "delete": "delete"},
+    for pattern, url_name, method_map in (
+        (f"{name}/", f"api-{name}-list", list_map),
+        (f"{name}/<int:id>/", f"api-{name}-detail", detail_map),
     ):
         urlpatterns.append(
-            path(
-                f"{name}/",
-                view_class.as_view(method_map=method_map),
-                name=f"api-{name}-list",
-            )
+            path(pattern, view_class.as_view(method_map=method_map), name=url_name)
         )
     urlpatterns.extend(extra_patterns)
index 0135228a9358918a5c726a014df489efced0d131..288d9910b81667142599407901836499309de750 100644 (file)
@@ -20,7 +20,8 @@ class ModelRestView(View):
     def get_queryset(self):
         return QuerySet(self.serializer.model).all()
 
-    def get_object(self):
+    @cached_property
+    def instance(self):
         return self.get_queryset().get(pk=self.kwargs["id"])
 
     def paginate(self, queryset, to_json):
@@ -33,6 +34,8 @@ class ModelRestView(View):
             page_size = queryset.count()
         elif "list_all" not in query:
             page_size = settings.DEFAULT_PAGE_SIZE
+            if not queryset.ordered:
+                queryset = queryset.order_by("id")
             queryset = queryset[max(count - page_size, 0) :]
         else:
             page_size = count
@@ -55,46 +58,44 @@ class ModelRestView(View):
             return {"indent": 4}
         return {}
 
-    def list(self):
-        serializer = self.serializer(self.request)
+    def list(self, request, *args, **kwargs):
         return HttpResponse(
             json.dumps(
-                self.paginate(self.get_queryset(), serializer.to_json),
+                self.paginate(self.get_queryset(), self.serializer(request).to_json),
                 **self.get_json_dump_kwargs(),
             ),
             content_type="application/json",
         )
 
-    def create(self):
-        serializer = self.serializer(self.request)
-        instance = serializer.model()
-        serializer.save(json.load(self.request), instance)
+    def create(self, request, *args, **kwargs):
+        serializer = self.serializer(request)
+        self.instance = serializer.model()
+        serializer.save(json.load(request), self.instance)
         return HttpResponse(
-            json.dumps(serializer.to_json(instance)),
+            json.dumps(serializer.to_json(self.instance)),
             content_type="application/json",
             status=201,
         )
 
-    def detail(self):
+    def detail(self, request, *args, **kwargs):
         return HttpResponse(
             json.dumps(
-                self.serializer(self.request).to_json(self.get_object()),
+                self.serializer(request).to_json(self.instance),
                 **self.get_json_dump_kwargs(),
             ),
             content_type="application/json",
         )
 
-    def update(self):
-        serializer = self.serializer(self.request)
-        instance = self.get_object()
-        serializer.save(json.load(self.request), instance)
+    def update(self, request, *args, **kwargs):
+        serializer = self.serializer(request)
+        serializer.save(json.load(request), self.instance)
         return HttpResponse(
-            json.dumps(serializer.to_josn(instance)),
+            json.dumps(serializer.to_json(self.instance)),
             content_type="application/json",
         )
 
-    def delete(self):
-        self.get_object().delete()
+    def delete(self, request, *args, **kwargs):
+        self.instance.delete()
         return HttpResponse(status=204)
 
     @cached_property
@@ -102,14 +103,7 @@ class ModelRestView(View):
         return self.method_map[self.request.method.lower()]
 
     def dispatch(self, request, *args, **kwargs):
-        try:
-            return getattr(self, self.action)()
-        except Exception as e:
-            return HttpResponse(
-                json.dumps({type(e).__name__: e.args}),
-                content_type="application/json",
-                status=500,
-            )
+        return getattr(self, self.action)(request, *args, **kwargs)
 
 
 class PrivilegeRequiredMixin:
index ab5ceb20310a97fb2453ce3c61bb8866782f0080..32c9cb5674a0e0eb686a35a467ee3d1ab47b03df 100644 (file)
@@ -16,6 +16,8 @@ def send_password_reset_email(request, username_or_email, created=False):
         user = User.objects.get_by_natural_key(username_or_email)
     except User.DoesNotExist:
         return
+    if not user.email:
+        return
     host = request.get_host()
     relative_url = reverse(
         "user-reset-password",
@@ -25,19 +27,21 @@ def send_password_reset_email(request, username_or_email, created=False):
         },
     )
     if created:
+        subject = _("New user password reset")
         msg = _(
             "Hello {username}\n\n"
             "Your user has been created. Please reset your password:\n\n"
             "{url}\n\nyours,\n{host}"
         )
     else:
+        subject = _("Password reset request")
         msg = _(
             "Hello {username}\n\n"
             "Someone that is hopefully you, has requested a password reset link:\n\n"
             "{url}\n\nyours,\n{host}"
         )
     send_mail(
-        _("{host}: Password reset request").format(host=host),
+        f"{host}: {subject}",
         msg.format(
             host=host,
             url=request.build_absolute_uri(relative_url),
@@ -54,8 +58,9 @@ def send_register_confirmation_email(request, form_data):
         query={"token": signing.dumps(form_data)},
     )
     host = request.get_host()
+    subject = _("Confirm your email address")
     send_mail(
-        _("{host}: Confirm your email address").format(host=host),
+        f"{host}: {subject}",
         _(
             "Hello {username}\n\n"
             "Someone that is hopefully you, has filled in the registration form.\n"
@@ -78,8 +83,9 @@ def send_registration_email(request, form_data):
             query={key: value for key, value in form_data.items() if key != "message"},
         )
     )
+    subject = _("Chat registration request")
     return EmailMultiAlternatives(
-        _("Chat Registration Request"),
+        f"{request.get_host()}: {subject}",
         _("username: {username}\nemail: {email}\nmessage: {message}\n\n{url}").format(
             **form_data, url=url
         ),
index 8b1dc6d07cb2f5847ed1edfc5ec067d9d5552911..1f38c83b524c15475bd6e7df2295ffa84ad335ab 100644 (file)
@@ -1,4 +1,4 @@
-# Generated by Django 6.0.4 on 2026-04-24 13:34
+# Generated by Django 6.0.4 on 2026-05-10 15:04
 
 import django.contrib.auth.validators
 import django.utils.timezone
@@ -98,6 +98,7 @@ class Migration(migrations.Migration):
                         default=django.utils.timezone.now, verbose_name="date joined"
                     ),
                 ),
+                ("last_password_change", models.DateTimeField(blank=True, null=True)),
                 (
                     "groups",
                     models.ManyToManyField(
@@ -130,8 +131,8 @@ class Migration(migrations.Migration):
             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" || \'}}\'\n                );\n                RETURN NULL;\n            ',
-                    hash="d9fa857efc39e0e22a104e69363f4cfaecada94a",
+                    func='\n                PERFORM pg_notify(\n                    \'chat_channel\',\n                    json_build_object(\n                        \'op\', TG_OP,\n                        \'table\', TG_TABLE_NAME,\n                        \'obj\', json_build_object(\'field\', "OLD"."id", \'field\', "OLD"."username", \'field\', "OLD"."email", \'field\', "OLD"."first_name", \'field\', "OLD"."last_name", \'field\', "OLD"."date_joined")\n                    )\n                );\n                RETURN NULL;\n            ',
+                    hash="9cf43edc5ce554d755b1e3978cfc5bf687c8f5ed",
                     operation="DELETE",
                     pgid="pgtrigger_chat_channel_delete_a66e5",
                     table="user_user",
@@ -144,8 +145,8 @@ class Migration(migrations.Migration):
             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" || \'}}\'\n                );\n                RETURN NULL;\n            ',
-                    hash="bc33ad5f18bff543d5524d33e48f447ab5b9e997",
+                    func='\n                PERFORM pg_notify(\n                    \'chat_channel\',\n                    json_build_object(\n                        \'op\', TG_OP,\n                        \'table\', TG_TABLE_NAME,\n                        \'obj\', json_build_object(\'field\', "NEW"."id", \'field\', "NEW"."username", \'field\', "NEW"."email", \'field\', "NEW"."first_name", \'field\', "NEW"."last_name", \'field\', "NEW"."date_joined")\n                    )\n                );\n                RETURN NULL;\n            ',
+                    hash="d48d015aa62321255d7ce9cde38703fe03d87881",
                     operation="INSERT OR UPDATE",
                     pgid="pgtrigger_chat_channel_insert_update_72ed2",
                     table="user_user",
@@ -158,8 +159,8 @@ class Migration(migrations.Migration):
             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",
+                    func="\n                PERFORM pg_notify(\n                    'chat_channel',\n                    json_build_object('op', TG_OP, 'table', TG_TABLE_NAME)\n                );\n                RETURN NULL;\n            ",
+                    hash="c7a19f0c2b05edc799fdee39cd011bddd2456b7d",
                     level="STATEMENT",
                     operation="TRUNCATE",
                     pgid="pgtrigger_chat_channel_truncate_8a96a",
diff --git a/user/migrations/0002_user_last_password_change.py b/user/migrations/0002_user_last_password_change.py
deleted file mode 100644 (file)
index b4b69c1..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-# Generated by Django 6.0.4 on 2026-04-29 07:10
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-    dependencies = [
-        ("user", "0001_initial"),
-    ]
-
-    operations = [
-        migrations.AddField(
-            model_name="user",
-            name="last_password_change",
-            field=models.DateTimeField(blank=True, null=True),
-        ),
-    ]
index 3dec14e4b02d110454822420cd44e3aa8109e8d7..0a1367ffed79480838c77a2980ad541808fd082e 100644 (file)
@@ -1,4 +1,6 @@
+import json
 from base64 import b64encode
+from datetime import UTC, datetime
 from urllib.parse import parse_qsl, urlsplit
 
 from django.conf import settings
@@ -10,6 +12,7 @@ from django.http import HttpResponseRedirect
 from django.test import TestCase, override_settings
 from django.urls import reverse
 
+from rest.tests import RestClient
 from .models import User
 
 # test all user-related stuff here
@@ -17,16 +20,16 @@ from .models import User
 # - user rest view
 
 
+def pending_messages(request):
+    return [(message.level, message.message) for message in get_messages(request)]
+
+
 @override_settings(DEFAULT_FROM_EMAIL="noreply@testserver.com")
 class PasswordResetTest(TestCase):
-    @staticmethod
-    def messages(request):
-        return [(message.level, message.message) for message in get_messages(request)]
-
     def check_reset_password_response(self, response, expected_outbox, username=None):
         request = response.wsgi_request
         self.assertEqual(
-            self.messages(request),
+            pending_messages(request),
             [(INFO, "If this user exists, an email is currently being sent.")],
         )
         self.client.get(response.headers["Location"])
@@ -137,7 +140,7 @@ class PasswordResetTest(TestCase):
             else:
                 self.assertIsInstance(response, HttpResponseRedirect)
                 self.assertEqual(
-                    self.messages(response.wsgi_request),
+                    pending_messages(response.wsgi_request),
                     [(INFO, "Password reset complete.")],
                 )
         self.client.get(reverse("user-login"))
@@ -150,10 +153,12 @@ class PasswordResetTest(TestCase):
         self.assertEqual(response.status_code, 302)
         self.assertEqual(response.content, b"")
         self.assertEqual(
-            self.messages(response.wsgi_request),
+            pending_messages(response.wsgi_request),
             [(INFO, "Token validation failed")],
         )
 
+
+class RegisterTest(TestCase):
     @override_settings(ADMINS=["admin@testserver.com"])
     def test_register(self):
         self.assertEqual(len(mail.outbox), 0)
@@ -169,7 +174,7 @@ class PasswordResetTest(TestCase):
         }
         response = self.client.post(url, payload)
         self.assertEqual(
-            self.messages(response.wsgi_request),
+            pending_messages(response.wsgi_request),
             [
                 (
                     INFO,
@@ -194,7 +199,7 @@ class PasswordResetTest(TestCase):
         self.client.get(reverse("user-login"))
         response = self.client.get(mail.outbox[0].body[len(prefix) : -len(suffix)])
         self.assertEqual(
-            self.messages(response.wsgi_request),
+            pending_messages(response.wsgi_request),
             [(INFO, "Registration was filed with the admins.")],
         )
         self.assertEqual(response.status_code, 302)
@@ -227,7 +232,7 @@ class PasswordResetTest(TestCase):
         self.client.force_login(admin)
         response = self.client.get(mail.outbox[1].body[len(prefix) :])
         self.assertEqual(
-            self.messages(response.wsgi_request),
+            pending_messages(response.wsgi_request),
             [(INFO, "User has been successfully created.")],
         )
         user = User.objects.get(email=payload["email"])
@@ -235,6 +240,8 @@ class PasswordResetTest(TestCase):
             (user.username, user.email), (payload["username"], payload["email"])
         )
 
+
+class UserTest(TestCase):
     def test_get_full_email(self):
         user = User.objects.create(email="test@testserver.com")
         self.assertEqual(user.get_full_email(), user.email)
@@ -247,6 +254,8 @@ class PasswordResetTest(TestCase):
         del user.first_name
         self.assertEqual(user.get_full_email(), f"{user.last_name} <{user.email}>")
 
+
+class LoginTest(TestCase):
     def is_redirect(self, response, expected_location=None):
         self.assertEqual(response.status_code, 302)
         if expected_location is not None:
@@ -262,6 +271,7 @@ class PasswordResetTest(TestCase):
         user.save()
         url = reverse("user-login")
         chat_url = reverse("channel-main")
+        url = f"{url}?next={chat_url}"
         self.is_redirect(self.client.get(chat_url), url)
         response = self.client.post(
             url,
@@ -287,3 +297,123 @@ class PasswordResetTest(TestCase):
             self.assertEqual(self.client.get(chat_url).status_code, 200)
             self.assertEqual(self.client.get(reverse("user-logout")).status_code, 302)
             self.is_redirect(self.client.get(chat_url), url)
+
+
+class UserRestViewTest(TestCase):
+    client_class = RestClient
+    client: RestClient
+
+    @classmethod
+    def setUpTestData(cls):
+        for i in range(50):
+            User.objects.create(
+                username=f"steve{i}",
+                email=f"steve{i}@testserver.com",
+                last_login=datetime(2026, 3, 31, 5, 23, tzinfo=UTC),
+            )
+
+    user: User
+
+    def setUp(self):
+        self.user = User.objects.first()
+        self.client.force_login(self.user)
+
+    def test_api_user_list(self):
+        data1 = json.loads(self.client.get(reverse("api-user-list")).content)
+        ids1 = {item["id"] for item in data1["result"]}
+        ids2 = {
+            item["id"]
+            for item in json.loads(self.client.get(data1["previous"]).content)["result"]
+        }
+
+        self.assertEqual(len(ids1), settings.DEFAULT_PAGE_SIZE)
+        self.assertEqual(len(ids2), settings.DEFAULT_PAGE_SIZE)
+        self.assertEqual(len(ids1 & ids2), 0)
+
+    def test_api_user_detail(self):
+        data = json.loads(self.client.get(reverse("api-user-list")).content)["result"]
+        self.assertEqual(len(data), settings.DEFAULT_PAGE_SIZE)
+        for item in data:
+            item_data = json.loads(self.client.get(item["url"]).content)
+            self.assertEqual(item, item_data)
+
+    def test_api_user_create(self):
+        self.assertFalse(self.user.is_staff)
+        self.assertEqual(len(mail.outbox), 0)
+        with self.assertLogs("django.request", level="WARNING") as cm:
+            response = self.client.post_json(
+                reverse("api-user-list"),
+                {"username": "querty", "email": "querty@testserver.com"},
+            )
+        self.assertEqual(
+            cm.output, ["WARNING:django.request:Method Not Allowed: /api/user/"]
+        )
+        self.assertEqual(response.status_code, 405)
+        self.user.is_staff = True
+        self.user.save()
+        payload = {"username": "querty", "email": "querty@testserver.com"}
+        response = self.client.post_json(reverse("api-user-list"), payload)
+        self.assertEqual(response.status_code, 201)
+        new_user = json.loads(response.content)
+        instance = User.objects.get(pk=new_user["id"])
+        self.assertEqual(new_user["username"], instance.username)
+        self.assertEqual(new_user["email"], instance.email)
+        self.assertEqual(len(mail.outbox), 1)
+        self.assertEqual(mail.outbox[0].to, [payload["email"]])
+        self.assertEqual(
+            mail.outbox[0].subject,
+            f"{response.wsgi_request.get_host()}: New user password reset",
+        )
+
+    def test_api_user_update(self):
+        self.assertFalse(self.user.is_staff)
+        user_id = self.user.pk
+        self.user.refresh_from_db()
+        first_name = "Steve"
+        self.assertNotEqual(self.user.first_name, first_name)
+        response = self.client.put_json(
+            reverse("api-user-detail", args=[user_id]),
+            {"first_name": first_name},
+        )
+        self.assertEqual(json.loads(response.content)["first_name"], first_name)
+        self.user.refresh_from_db()
+        self.assertEqual(self.user.first_name, first_name)
+        other_user = User.objects.exclude(pk=user_id).first()
+        other_first_name = "Jonathan"
+        self.assertNotEqual(other_user.first_name, other_first_name)
+        with self.assertLogs("django.request", level="WARNING") as cm:
+            response = self.client.put_json(
+                reverse("api-user-detail", args=[other_user.pk]),
+                {"first_name": other_first_name},
+            )
+        self.assertEqual(
+            cm.output,
+            [f"WARNING:django.request:Method Not Allowed: /api/user/{other_user.pk}/"],
+        )
+        self.assertEqual(response.status_code, 405)
+        self.user.is_staff = True
+        self.user.save()
+        response = self.client.put_json(
+            reverse("api-user-detail", args=[other_user.pk]),
+            {"first_name": other_first_name},
+        )
+        self.assertEqual(json.loads(response.content)["first_name"], other_first_name)
+        other_user.refresh_from_db()
+        self.assertEqual(other_user.first_name, other_first_name)
+
+    def test_api_user_delete(self):
+        self.assertFalse(self.user.is_staff)
+        user = User.objects.exclude(pk=self.user.pk).last()
+        with self.assertLogs("django.request", level="WARNING") as cm:
+            response = self.client.delete(reverse("api-user-detail", args=[user.pk]))
+        self.assertEqual(
+            cm.output,
+            [f"WARNING:django.request:Method Not Allowed: /api/user/{user.pk}/"],
+        )
+        self.assertEqual(response.status_code, 405)
+        self.user.is_staff = True
+        self.user.save()
+        response = self.client.delete(reverse("api-user-detail", args=[user.pk]))
+        self.assertEqual(response.status_code, 204)
+        self.assertEqual(response.content, b"")
+        self.assertFalse(User.objects.filter(pk=user.pk).exists())
index 882424ec4166489cc4b7d921a429c1522a5760d5..ee3ac0ea90a5446ef920e438549b798521f87b5b 100644 (file)
@@ -1,6 +1,6 @@
 from django.urls import path
 
-from rest.urls import get_urls
+from rest.urls import detail_map, get_urls
 from .views import (
     LoginView,
     LogoutView,
@@ -31,9 +31,7 @@ get_urls(
     "user",
     path(
         "user/current/",
-        UserRestView.as_view(
-            method_map={"get": "detail", "put": "update", "delete": "delete"}
-        ),
+        UserRestView.as_view(method_map=detail_map),
         name="api-user-current",
     ),
 )
index c2c3e2d6ffc9f080f5dbf44d9a94f3487dc7b4e2..069f6584dd7ec4c66b647b8c526c5015cbed9c65 100644 (file)
@@ -189,3 +189,9 @@ class UserRestView(PrivilegeRequiredMixin, ModelRestView):
         if not user.is_privileged() and self.kwargs.get("id") == user.pk:
             return ["GET", "PUT"]
         return super().get_permitted_methods()
+
+    def create(self, request, *args, **kwargs):
+        response = super().create(request, *args, **kwargs)
+        if response.status_code == 201:
+            send_password_reset_email(request, self.instance.email, True)
+        return response