From 7eb3edaab0247e228e39aa45e2ae5f975a7fe394 Mon Sep 17 00:00:00 2001 From: mar77i Date: Tue, 7 Apr 2026 23:35:39 +0200 Subject: [PATCH] initial commit --- .gitignore | 7 + .pre-commit-config.yaml | 57 ++++++ channel/__init__.py | 0 channel/admin.py | 0 channel/apps.py | 10 + channel/migrations/0001_initial.py | 86 ++++++++ channel/migrations/0002_initial.py | 247 +++++++++++++++++++++++ channel/migrations/__init__.py | 0 channel/models.py | 56 +++++ channel/signals.py | 9 + channel/templates/channel/main.html | 14 ++ channel/tests.py | 0 channel/urls.py | 7 + channel/views.py | 5 + chat/__init__.py | 0 chat/apps.py | 5 + chat/asgi.py | 25 +++ chat/bridge.py | 66 ++++++ chat/management/__init__.py | 0 chat/management/commands/__init__.py | 0 chat/management/commands/db.py | 83 ++++++++ chat/management/commands/makemessages.py | 74 +++++++ chat/management/commands/runuvicorn.py | 20 ++ chat/settings.py | 121 +++++++++++ chat/static/chat/chatutils.js | 205 +++++++++++++++++++ chat/templates/chat/base.html | 31 +++ chat/triggers.py | 56 +++++ chat/urls.py | 22 ++ locales/de/LC_MESSAGES/django.po | 62 ++++++ manage.py | 23 +++ pyproject.toml | 2 + readme.md | 17 ++ scripts/deploy.sh | 11 + scripts/deploy_remote.py | 116 +++++++++++ scripts/pyjslint.py | 148 ++++++++++++++ scripts/sort_gitignore.sh | 7 + setup_venv.sh | 17 ++ user/__init__.py | 0 user/admin.py | 0 user/apps.py | 5 + user/email.py | 53 +++++ user/forms.py | 84 ++++++++ user/migrations/0001_initial.py | 171 ++++++++++++++++ user/migrations/__init__.py | 0 user/models.py | 41 ++++ user/templates/user/user.html | 28 +++ user/tests.py | 0 user/urls.py | 11 + user/views.py | 126 ++++++++++++ 49 files changed, 2128 insertions(+) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 channel/__init__.py create mode 100644 channel/admin.py create mode 100644 channel/apps.py create mode 100644 channel/migrations/0001_initial.py create mode 100644 channel/migrations/0002_initial.py create mode 100644 channel/migrations/__init__.py create mode 100644 channel/models.py create mode 100644 channel/signals.py create mode 100644 channel/templates/channel/main.html create mode 100644 channel/tests.py create mode 100644 channel/urls.py create mode 100644 channel/views.py create mode 100644 chat/__init__.py create mode 100644 chat/apps.py create mode 100644 chat/asgi.py create mode 100644 chat/bridge.py create mode 100644 chat/management/__init__.py create mode 100644 chat/management/commands/__init__.py create mode 100644 chat/management/commands/db.py create mode 100644 chat/management/commands/makemessages.py create mode 100644 chat/management/commands/runuvicorn.py create mode 100644 chat/settings.py create mode 100644 chat/static/chat/chatutils.js create mode 100644 chat/templates/chat/base.html create mode 100644 chat/triggers.py create mode 100644 chat/urls.py create mode 100644 locales/de/LC_MESSAGES/django.po create mode 100755 manage.py create mode 100644 pyproject.toml create mode 100644 readme.md create mode 100755 scripts/deploy.sh create mode 100755 scripts/deploy_remote.py create mode 100755 scripts/pyjslint.py create mode 100755 scripts/sort_gitignore.sh create mode 100644 setup_venv.sh create mode 100644 user/__init__.py create mode 100644 user/admin.py create mode 100644 user/apps.py create mode 100644 user/email.py create mode 100644 user/forms.py create mode 100644 user/migrations/0001_initial.py create mode 100644 user/migrations/__init__.py create mode 100644 user/models.py create mode 100644 user/templates/user/user.html create mode 100644 user/tests.py create mode 100644 user/urls.py create mode 100644 user/views.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc8509f --- /dev/null +++ b/.gitignore @@ -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 index 0000000..74a6126 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 index 0000000..e69de29 diff --git a/channel/admin.py b/channel/admin.py new file mode 100644 index 0000000..e69de29 diff --git a/channel/apps.py b/channel/apps.py new file mode 100644 index 0000000..b27158c --- /dev/null +++ b/channel/apps.py @@ -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 index 0000000..c588bc0 --- /dev/null +++ b/channel/migrations/0001_initial.py @@ -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 index 0000000..889e588 --- /dev/null +++ b/channel/migrations/0002_initial.py @@ -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 index 0000000..e69de29 diff --git a/channel/models.py b/channel/models.py new file mode 100644 index 0000000..a70477b --- /dev/null +++ b/channel/models.py @@ -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 index 0000000..e60c791 --- /dev/null +++ b/channel/signals.py @@ -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 index 0000000..cc93e2a --- /dev/null +++ b/channel/templates/channel/main.html @@ -0,0 +1,14 @@ +{% extends "chat/base.html" %} +{% block main %} +

{{ title }}

+ {% if messages %} + + {% endif %} +
+ +
+{% endblock main %} diff --git a/channel/tests.py b/channel/tests.py new file mode 100644 index 0000000..e69de29 diff --git a/channel/urls.py b/channel/urls.py new file mode 100644 index 0000000..e02305a --- /dev/null +++ b/channel/urls.py @@ -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 index 0000000..f9566ff --- /dev/null +++ b/channel/views.py @@ -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 index 0000000..e69de29 diff --git a/chat/apps.py b/chat/apps.py new file mode 100644 index 0000000..194dfde --- /dev/null +++ b/chat/apps.py @@ -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 index 0000000..dcb3f85 --- /dev/null +++ b/chat/asgi.py @@ -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 index 0000000..6ad9cbc --- /dev/null +++ b/chat/bridge.py @@ -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 index 0000000..e69de29 diff --git a/chat/management/commands/__init__.py b/chat/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/management/commands/db.py b/chat/management/commands/db.py new file mode 100644 index 0000000..524349e --- /dev/null +++ b/chat/management/commands/db.py @@ -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 index 0000000..a87de61 --- /dev/null +++ b/chat/management/commands/makemessages.py @@ -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 index 0000000..d9d1b04 --- /dev/null +++ b/chat/management/commands/runuvicorn.py @@ -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 index 0000000..33f123b --- /dev/null +++ b/chat/settings.py @@ -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 index 0000000..04184f8 --- /dev/null +++ b/chat/static/chat/chatutils.js @@ -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 index 0000000..6973bde --- /dev/null +++ b/chat/templates/chat/base.html @@ -0,0 +1,31 @@ +{% load static %} + + + + {% block head %} + {% block meta %} + + + + + + {% endblock meta %} + {# #} + + {% block title %} + Title + {% endblock title %} + + {% endblock head %} + + + {% block header %} + {% endblock header %} +
+ {% block main %} + {% endblock main %} +
+ {% block footer %} + {% endblock footer %} + + diff --git a/chat/triggers.py b/chat/triggers.py new file mode 100644 index 0000000..e75acff --- /dev/null +++ b/chat/triggers.py @@ -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 index 0000000..c867df7 --- /dev/null +++ b/chat/urls.py @@ -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 index 0000000..0cbbb9d --- /dev/null +++ b/locales/de/LC_MESSAGES/django.po @@ -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 \n" +"Language-Team: LANGUAGE \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 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 index 0000000..a0eb000 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.djlint] +max_line_length = 88 diff --git a/readme.md b/readme.md new file mode 100644 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 index 0000000..feaa73a --- /dev/null +++ b/scripts/deploy.sh @@ -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= 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 index 0000000..08faa56 --- /dev/null +++ b/scripts/pyjslint.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python + +# Copyright (C) 2011 Alejandro Blanco +# Copyright (C) 2024 mar77i + +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 index 0000000..50c68b2 --- /dev/null +++ b/scripts/sort_gitignore.sh @@ -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 index 0000000..a1c2153 --- /dev/null +++ b/setup_venv.sh @@ -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 index 0000000..e69de29 diff --git a/user/admin.py b/user/admin.py new file mode 100644 index 0000000..e69de29 diff --git a/user/apps.py b/user/apps.py new file mode 100644 index 0000000..1f2369a --- /dev/null +++ b/user/apps.py @@ -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 index 0000000..4aec994 --- /dev/null +++ b/user/email.py @@ -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 index 0000000..0df4870 --- /dev/null +++ b/user/forms.py @@ -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 index 0000000..d61d057 --- /dev/null +++ b/user/migrations/0001_initial.py @@ -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 index 0000000..e69de29 diff --git a/user/models.py b/user/models.py new file mode 100644 index 0000000..288deb8 --- /dev/null +++ b/user/models.py @@ -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 index 0000000..58633a8 --- /dev/null +++ b/user/templates/user/user.html @@ -0,0 +1,28 @@ +{% extends "chat/base.html" %} +{% block title %} + {{ title }} +{% endblock title %} +{% block main %} +

{{ title }}

+ {% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + {% if form %} +
+ {% csrf_token %} + {{ form.as_p }} +

+ +

+
+ {% endif %} + {% for label, url in links.items %} +

+ {{ label }} +

+ {% endfor %} +{% endblock main %} diff --git a/user/tests.py b/user/tests.py new file mode 100644 index 0000000..e69de29 diff --git a/user/urls.py b/user/urls.py new file mode 100644 index 0000000..9389c19 --- /dev/null +++ b/user/urls.py @@ -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 index 0000000..e90b2de --- /dev/null +++ b/user/views.py @@ -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") -- 2.53.0