]> git.mar77i.info Git - chat/commitdiff
initial commit
authormar77i <mar77i@protonmail.ch>
Wed, 18 Sep 2024 00:38:26 +0000 (02:38 +0200)
committermar77i <mar77i@protonmail.ch>
Wed, 18 Sep 2024 00:38:26 +0000 (02:38 +0200)
28 files changed:
.gitignore [new file with mode: 0644]
chat/__init__.py [new file with mode: 0644]
chat/admin.py [new file with mode: 0644]
chat/apps.py [new file with mode: 0644]
chat/asgi.py [new file with mode: 0644]
chat/forms.py [new file with mode: 0644]
chat/management/__init__.py [new file with mode: 0644]
chat/management/commands/__init__.py [new file with mode: 0644]
chat/management/commands/runserver.py [new file with mode: 0644]
chat/migrations/0001_initial.py [new file with mode: 0644]
chat/migrations/__init__.py [new file with mode: 0644]
chat/models.py [new file with mode: 0644]
chat/settings.py [new file with mode: 0644]
chat/static/chat/index.js [new file with mode: 0644]
chat/static/chat/login.css [new file with mode: 0644]
chat/static/chat/style.css [new file with mode: 0644]
chat/templates/chat/base.html [new file with mode: 0644]
chat/templates/chat/chat.html [new file with mode: 0644]
chat/templates/chat/login.html [new file with mode: 0644]
chat/templates/chat/success.html [new file with mode: 0644]
chat/tests.py [new file with mode: 0644]
chat/urls.py [new file with mode: 0644]
chat/utils.py [new file with mode: 0644]
chat/views.py [new file with mode: 0644]
chat/websocket.py [new file with mode: 0644]
manage.py [new file with mode: 0755]
setup_venv.sh [new file with mode: 0644]
todo.txt [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..a2e0220
--- /dev/null
@@ -0,0 +1,5 @@
+.idea/
+__pycache__/
+chat/settings_local.py
+staticfiles
+venv/
diff --git a/chat/__init__.py b/chat/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/chat/admin.py b/chat/admin.py
new file mode 100644 (file)
index 0000000..8c38f3f
--- /dev/null
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/chat/apps.py b/chat/apps.py
new file mode 100644 (file)
index 0000000..bebd81f
--- /dev/null
@@ -0,0 +1,19 @@
+from django.apps import AppConfig
+from django.contrib.admin import ModelAdmin
+
+from .utils import get_app_configs
+
+
+class ChatConfig(AppConfig):
+    default_auto_field = 'django.db.models.BigAutoField'
+    name = 'chat'
+
+    @staticmethod
+    def autodiscover_models():
+        from django.contrib.admin import site
+        for app_config in get_app_configs():
+            for model in app_config.get_models():
+                site.register(model, ModelAdmin)
+
+    def ready(self):
+        self.autodiscover_models()
diff --git a/chat/asgi.py b/chat/asgi.py
new file mode 100644 (file)
index 0000000..27a53c7
--- /dev/null
@@ -0,0 +1,30 @@
+"""
+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/5.1/howto/deployment/asgi/
+"""
+
+import os
+
+import django
+from django.core.handlers.asgi import ASGIHandler
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'chat.settings')
+
+
+class ASGIHandlerWithWebsocket(ASGIHandler):
+    async def __call__(self, scope, receive, send):
+        from .urls import websocket_urls
+        if scope["type"] == "websocket":
+            await websocket_urls.get(scope["path"])(scope, receive, send)
+        else:
+            await super().__call__(scope, receive, send)
+
+
+
+def get_asgi_application():
+    django.setup(set_prefix=False)
+    return ASGIHandlerWithWebsocket()
diff --git a/chat/forms.py b/chat/forms.py
new file mode 100644 (file)
index 0000000..d8c7d50
--- /dev/null
@@ -0,0 +1,34 @@
+from django.conf import settings
+from django.contrib.auth.models import User
+from django.contrib.auth.password_validation import validate_password
+from django.core.exceptions import ValidationError
+from django.forms import CharField, EmailField, ModelForm
+from django.forms.widgets import PasswordInput
+
+from chat.utils import build_url
+
+
+class RegisterForm(ModelForm):
+    email = EmailField(required=True)
+    password = CharField(widget=PasswordInput, validators=[validate_password])
+    password_again = CharField(widget=PasswordInput)
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+    def clean(self):
+        cleaned_data = super().clean()
+        if cleaned_data["password"] != cleaned_data["password_again"]:
+            raise ValidationError("Passwords did not match!")
+        return cleaned_data
+
+    def save(self, commit=True):
+        if not settings.TRUST_USER_REGISTRATIONS:
+            self.instance.is_active = False
+        self.instance.last_login = None
+        self.instance.set_password(self.instance.password)
+        return super().save(commit)
+
+    class Meta:
+        model = User
+        fields = ['email', 'username', 'first_name', 'last_name', 'password']
diff --git a/chat/management/__init__.py b/chat/management/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/chat/management/commands/__init__.py b/chat/management/commands/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/chat/management/commands/runserver.py b/chat/management/commands/runserver.py
new file mode 100644 (file)
index 0000000..da7b9b7
--- /dev/null
@@ -0,0 +1,13 @@
+import os
+
+from django.core.management import BaseCommand, call_command
+from uvicorn.main import main
+
+
+class Command(BaseCommand):
+    def run_from_argv(self, argv):
+        call_command("collectstatic", "--noinput", "-v0")
+        assert argv[1] == "runserver"
+        os.environ.setdefault("UVICORN_APP", "chat.asgi:get_asgi_application")
+        argv[:2] = (f"{argv[0]} {argv[1]}",)
+        main(("--factory", "--lifespan", "off", *argv[1:]))
diff --git a/chat/migrations/0001_initial.py b/chat/migrations/0001_initial.py
new file mode 100644 (file)
index 0000000..9d0cc76
--- /dev/null
@@ -0,0 +1,43 @@
+# Generated by Django 5.1.1 on 2024-09-11 10:09
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Channel',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('users', models.ManyToManyField(related_name='channels', to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='ChannelMessage',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('ts', models.DateTimeField(auto_now=True)),
+                ('text', models.TextField()),
+                ('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='chat.channel')),
+            ],
+        ),
+        migrations.CreateModel(
+            name='PrivateMessage',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('ts', models.DateTimeField(auto_now=True)),
+                ('text', models.TextField()),
+                ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pns_recieved', to=settings.AUTH_USER_MODEL)),
+                ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pms_sent', to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+    ]
diff --git a/chat/migrations/__init__.py b/chat/migrations/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/chat/models.py b/chat/models.py
new file mode 100644 (file)
index 0000000..028b431
--- /dev/null
@@ -0,0 +1,21 @@
+from django.db.models import (
+    CASCADE, DateTimeField, ForeignKey, ManyToManyField, Model, TextField
+)
+from django.contrib.auth.models import User
+
+
+class PrivateMessage(Model):
+    sender = ForeignKey(User, related_name="pms_sent", on_delete=CASCADE)
+    recipient = ForeignKey(User, related_name="pns_recieved", on_delete=CASCADE)
+    ts = DateTimeField(auto_now=True)
+    text = TextField()
+
+
+class Channel(Model):
+    users = ManyToManyField(User, related_name="channels")
+
+
+class ChannelMessage(Model):
+    channel = ForeignKey(Channel, on_delete=CASCADE)
+    ts = DateTimeField(auto_now=True)
+    text = TextField()
diff --git a/chat/settings.py b/chat/settings.py
new file mode 100644 (file)
index 0000000..f237fd8
--- /dev/null
@@ -0,0 +1,103 @@
+"""
+Django settings for chat project.
+
+Generated by 'django-admin startproject' using Django 5.1.1.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/5.1/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/5.1/ref/settings/
+"""
+
+from pathlib import Path
+
+from django.conf.global_settings import LOGIN_REDIRECT_URL
+
+# Build paths inside the project like this: BASE_DIR / 'subdir'.
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+DEBUG = True
+
+ALLOWED_HOSTS = []
+
+
+# Application definition
+
+INSTALLED_APPS = [
+    "chat",
+    'django.contrib.admin',
+    'django.contrib.auth',
+    'django.contrib.contenttypes',
+    'django.contrib.sessions',
+    'django.contrib.messages',
+    'django.contrib.staticfiles',
+]
+
+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',
+]
+
+ROOT_URLCONF = 'chat.urls'
+
+TEMPLATES = [
+    {
+        'BACKEND': 'django.template.backends.django.DjangoTemplates',
+        'DIRS': [],
+        'APP_DIRS': True,
+        'OPTIONS': {
+            'context_processors': [
+                'django.template.context_processors.debug',
+                'django.template.context_processors.request',
+                'django.template.context_processors.static',
+                'django.contrib.auth.context_processors.auth',
+                'django.contrib.messages.context_processors.messages',
+            ],
+        },
+    },
+]
+
+DATABASES = {
+    'default': {
+        'ENGINE': 'django.db.backends.sqlite3',
+        'NAME': BASE_DIR / 'db.sqlite3',
+    }
+}
+
+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',
+    },
+]
+
+LANGUAGE_CODE = 'en-us'
+TIME_ZONE = 'UTC'
+USE_I18N = True
+USE_TZ = True
+STATIC_URL = 'static/'
+STATIC_ROOT = BASE_DIR / "staticfiles"
+DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
+LOGIN_URL = "/login"
+LOGIN_REDIRECT_URL = "/"
+TRUST_USER_REGISTRATIONS = False
+MAX_EMAIL_CONFIRMATION_AGE_SECONDS = 86400 * 14
+
+try:
+    from .settings_local import *
+except ImportError:
+    pass
diff --git a/chat/static/chat/index.js b/chat/static/chat/index.js
new file mode 100644 (file)
index 0000000..7bb1a02
--- /dev/null
@@ -0,0 +1,2 @@
+(function () {
+}());
\ No newline at end of file
diff --git a/chat/static/chat/login.css b/chat/static/chat/login.css
new file mode 100644 (file)
index 0000000..1ff69b9
--- /dev/null
@@ -0,0 +1,53 @@
+html, body {
+    width: 100%;
+    height: 100%;
+}
+
+body {
+    display: flex;
+}
+
+main {
+    margin: auto;
+    min-width: 300px;
+    width: 30em;
+    border: 1px solid black;
+}
+
+main > h1 {
+    padding: 1em;
+    border-bottom: 1px solid black;
+}
+
+main > form * {
+    margin: .33em;
+    padding: .33em;
+}
+
+main > form > .errorlist > li {
+    list-style: none;
+    background-color: darkred;
+    border: 1px solid black;
+}
+
+main > form > p > label {
+    width: 8em;
+    display: inline-block;
+}
+
+main > form > p > input {
+    width: 21em;
+    float: right;
+}
+
+main > form > p > span {
+    display: inline-block;
+}
+
+main > form > .controls {
+    display: flex;
+}
+
+main > form > .controls > * {
+    width: 50%;
+}
diff --git a/chat/static/chat/style.css b/chat/static/chat/style.css
new file mode 100644 (file)
index 0000000..b70087e
--- /dev/null
@@ -0,0 +1,11 @@
+* {
+    margin: 0;
+    padding: 0;
+    background-color: #222;
+    color: white;
+}
+
+nav {
+    width: 10em;
+    height: 100%;
+}
diff --git a/chat/templates/chat/base.html b/chat/templates/chat/base.html
new file mode 100644 (file)
index 0000000..94075d3
--- /dev/null
@@ -0,0 +1,19 @@
+{% load static %}<!DOCTYPE html>
+<html lang="{% block lang %}en{% endblock lang %}">
+    <head>
+        <meta charset="UTF-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1.0">
+        <meta http-equiv="X-UA-Compatible" content="ie=edge">
+        <title>{% block title %}Title{% endblock title %}</title>
+        <link rel="stylesheet" href="{% static 'chat/style.css' %}">
+        {% block head %}{% endblock head %}
+    </head>
+    <body>
+        {% block header %}{% endblock header %}
+        <main>
+            {% block main %}
+            {% endblock main %}
+        </main>
+        {% block footer %}{% endblock footer %}
+    </body>
+</html>
diff --git a/chat/templates/chat/chat.html b/chat/templates/chat/chat.html
new file mode 100644 (file)
index 0000000..b770d9f
--- /dev/null
@@ -0,0 +1,46 @@
+{% extends "chat/base.html" %}
+{% load static %}
+
+{% block header %}
+    <nav>
+        <div class="user">
+            {% block user_properties %}{{ request.user }}{% endblock user_properties %}
+        </div>
+        <div class="channels">
+            <ul>
+                {% for channel in request.user.channels.all %}
+                    <li>
+                        {% block channel_link %}
+                            {{ channel }}
+                        {% endblock channel_link %}
+                    </li>
+                {% endfor %}
+            </ul>
+        </div>
+        <div class="users">
+            <ul>
+                {% for user in User.objects.all %}
+                    <li>
+                        {% block user_pms_links %}
+                            {{ user }}
+                        {% endblock user_pms_links %}
+                    </li>
+                {% endfor %}
+            </ul>
+        </div>
+    </nav>
+{% endblock header %}
+
+{% block main %}
+    <div class="messages">
+        {% block messages %}
+        {% endblock messages %}
+    </div>
+    <div class="input">
+        {% block input %}<textarea></textarea>{% endblock input %}
+    </div>
+{% endblock main %}
+
+{% block footer %}
+    <script src="{% static 'chat/index.js' %}"></script>
+{% endblock footer %}
diff --git a/chat/templates/chat/login.html b/chat/templates/chat/login.html
new file mode 100644 (file)
index 0000000..4eebb30
--- /dev/null
@@ -0,0 +1,28 @@
+{% extends "chat/base.html" %}
+{% load static i18n %}
+
+{% block head %}
+    <link rel="stylesheet" href="{% static 'chat/login.css' %}">
+{% endblock head %}
+
+{% block main %}
+    <h1>{% translate view.title %}</h1>
+    <form method="post">
+        {% csrf_token %}
+        {{ form.as_p }}
+        <div class="controls">
+            <p>
+{% if view.title == 'Login' %}
+                <a href="{% url 'chat-reset-password' %}">Reset Password</a>
+                <br />
+                <a href="{% url 'chat-register' %}">Register</a>
+{% elif view.title == 'Register' %}
+                <a href="{% url 'chat-login' %}">Back</a>
+{% endif %}
+            </p>
+            <p>
+                <input type="submit" value="Submit">
+            </p>
+        </div>
+    </form>
+{% endblock main %}
diff --git a/chat/templates/chat/success.html b/chat/templates/chat/success.html
new file mode 100644 (file)
index 0000000..9a0c38e
--- /dev/null
@@ -0,0 +1,20 @@
+{% extends "chat/base.html" %}
+{% load static i18n %}
+
+{% block head %}
+    <link rel="stylesheet" href="{% static 'chat/login.css' %}">
+{% endblock head %}
+
+{% block main %}
+    <h1>{% translate view.title %}</h1>
+    <form>
+        <p>{{ msg }}</p>
+        <div class="controls">
+            <p>
+            </p>
+            <p>
+                <a href="{% url 'chat-login' %}">Back</a>
+            </p>
+        </div>
+    </form>
+{% endblock main %}
diff --git a/chat/tests.py b/chat/tests.py
new file mode 100644 (file)
index 0000000..14f6a2b
--- /dev/null
@@ -0,0 +1,139 @@
+from html.parser import HTMLParser
+from unittest.mock import patch
+
+from django.conf import settings
+from django.contrib.auth.models import User
+from django.core import mail
+from django.core.handlers.wsgi import WSGIRequest
+from django.test import TestCase, override_settings
+
+from chat.utils import build_url
+
+
+class FormExtractor(HTMLParser):
+    IGNORE_TAGS = (
+        "a",
+        "body",
+        "div",
+        "h1",
+        "head",
+        "html",
+        "label",
+        "link",
+        "main",
+        "meta",
+        "p",
+        "span",
+        "title",
+    )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.forms = []
+
+    @staticmethod
+    def get_attr(attr_key, attrs):
+        input_type = None
+        for key, value in attrs:
+            if key == attr_key:
+                assert input_type is None
+                input_type = value
+        return input_type
+
+    def handle_starttag(self, tag, attrs):
+        tag = tag.lower()
+        if tag in self.IGNORE_TAGS:
+            return
+        elif tag == "form":
+            self.forms.append((attrs, []))
+        elif tag == "input":
+            input_name = self.get_attr("name", attrs)
+            input_type = self.get_attr("type", attrs)
+            input_value = self.get_attr("value", attrs)
+            if input_type in ("text", "hidden", "email", "password"):
+                self.forms[-1][1].append((input_name, input_type, input_value))
+            elif input_type in "submit":
+                pass
+            else:
+                raise ValueError(f"unhandled input type: {repr(input_type)}")
+        else:
+            raise ValueError(f"unhandled tag: {repr(tag)}")
+
+
+class ChatTest(TestCase):
+    def do_registration(self):
+        response = self.client.get("/register/")
+        fe = FormExtractor()
+        fe.feed(response.content.decode())
+        self.assertEqual(len(fe.forms), 1)
+        form_attrs, fields = fe.forms[0]
+        assert FormExtractor.get_attr("method", form_attrs).lower() == "post"
+        csrf_token = None
+        for field_name, field_type, field_value in fields:
+            if field_name == "csrfmiddlewaretoken":
+                assert csrf_token is None and field_type == "hidden"
+                csrf_token = field_value
+        payload = {
+            "csrfmiddlewaretoken": csrf_token,
+            "email": "new_user@example.com",
+            "username": "new_user_username",
+            "first_name": "John",
+            "last_name": "Doe",
+            "password": "EhMacarena",
+            "password_again": "EhMacarena",
+        }
+        self.assertRaises(User.DoesNotExist, User.objects.get, email=payload["email"])
+        response = self.client.post("/register/", payload)
+        self.assertEqual(response.status_code, 302)
+        request = WSGIRequest(self.client._base_environ())
+        self.assertEqual(
+            response.headers["Location"],
+            build_url(request, "chat-register", params={"success": "1"}),
+        )
+        user = User.objects.get(email=payload["email"])
+        user.check_password(payload["password"])
+        self.assertEqual(len(mail.outbox), 1)
+        confirmation_email = mail.outbox[0]
+        self.assertDictEqual(
+            {
+                k: v
+                for k, v in confirmation_email.__dict__.items()
+                if k not in ("body", "connection")
+            },
+            {
+                "to": [payload["email"]],
+                "cc": [],
+                "bcc": [],
+                "reply_to": [],
+                "from_email": settings.DEFAULT_FROM_EMAIL,
+                "subject": "[Chat-App] Confirm your email address!",
+                "attachments": [],
+                "extra_headers": {},
+                "alternatives": [],
+            }
+        )
+        confirm_url = confirmation_email.body
+        pos = confirm_url.find(build_url(request, "chat-confirm-email", kwargs={"token": "_"})[:-1])
+        self.assertGreaterEqual(pos, 0)
+        confirm_url = confirm_url[pos:]
+        return user, payload, confirm_url
+
+    def confirm_email(self, email, confirm_url):
+        self.assertIsNone(User.objects.get(email=email).last_login)
+        self.client.get(confirm_url)
+        self.assertIsNotNone(User.objects.get(email=email).last_login)
+
+    @override_settings(TRUST_USER_REGISTRATIONS=True)
+    def test_registration_with_trust_user_registrations(self):
+        user, payload, confirm_url = self.do_registration()
+        self.assertTrue(user.is_active)
+        self.confirm_email(payload["email"], confirm_url)
+
+    @override_settings(TRUST_USER_REGISTRATIONS=False)
+    def test_registration_without_trust_user_registrations(self):
+        user, payload, confirm_url = self.do_registration()
+        self.assertFalse(user.is_active)
+        self.confirm_email(payload["email"], confirm_url)
+
+    def test_expired_email_confirmation(self):
+        ...
diff --git a/chat/urls.py b/chat/urls.py
new file mode 100644 (file)
index 0000000..2537671
--- /dev/null
@@ -0,0 +1,41 @@
+"""
+URL configuration for chat project.
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+    https://docs.djangoproject.com/en/5.1/topics/http/urls/
+Examples:
+Function views
+    1. Add an import:  from my_app import views
+    2. Add a URL to urlpatterns:  path('', views.home, name='home')
+Class-based views
+    1. Add an import:  from other_app.views import Home
+    2. Add a URL to urlpatterns:  path('', Home.as_view(), name='home')
+Including another URLconf
+    1. Import the include() function: from django.urls import include, path
+    2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
+"""
+from django.conf import settings
+from django.conf.urls.static import static
+from django.contrib import admin
+from django.urls import path
+from .views import (
+    ChatView, ConfirmEmailView, LoginView, LogoutView, PasswordResetView, RegisterView,
+)
+from .websocket import handle_websocket
+
+urlpatterns = [
+    path("admin/", admin.site.urls),
+    *static(settings.STATIC_URL, document_root=settings.STATIC_ROOT),
+    path("", ChatView.as_view(), name="chat-main"),
+    path("login/", LoginView.as_view(), name="chat-login"),
+    path("reset-password/", PasswordResetView.as_view(), name="chat-reset-password"),
+    path("register/", RegisterView.as_view(), name="chat-register"),
+    path(
+        "confirm-email/<token>",
+        ConfirmEmailView.as_view(),
+        name="chat-confirm-email",
+    ),
+    path("logout/", LogoutView.as_view(), name="chat-logout"),
+]
+
+websocket_urls = {"/": handle_websocket}
diff --git a/chat/utils.py b/chat/utils.py
new file mode 100644 (file)
index 0000000..6d594c6
--- /dev/null
@@ -0,0 +1,23 @@
+from django.urls import reverse
+from django.utils.http import urlencode
+from django.utils.module_loading import module_has_submodule
+
+
+def get_app_configs(required_submodule=None):
+    from django.apps import apps
+    for app_config in apps.get_app_configs():
+        if "/site-packages/" not in app_config.path and (
+            required_submodule is None
+            or module_has_submodule(app_config.module, required_submodule)
+        ):
+            yield app_config
+
+
+def build_url(request, *args, **kwargs):
+    params = kwargs.pop("params", None)
+    url = reverse(*args, **kwargs)
+    if request is not None:
+        url = request.build_absolute_uri(url)
+    if params:
+        return f"{url}?{urlencode(params)}"
+    return url
diff --git a/chat/views.py b/chat/views.py
new file mode 100644 (file)
index 0000000..becc39c
--- /dev/null
@@ -0,0 +1,128 @@
+from base64 import b64decode, b64encode
+from django.conf import settings
+from django.contrib.auth.forms import AuthenticationForm, PasswordResetForm
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth.models import User, update_last_login
+from django.contrib.auth.views import LoginView as DjangoLoginView, LogoutView as DjangoLogoutView, PasswordResetView as DjangoPasswordResetView
+from django.core.signing import TimestampSigner
+from django.utils.safestring import mark_safe
+from django.views.generic import TemplateView
+from django.views.generic.detail import SingleObjectTemplateResponseMixin
+from django.views.generic.edit import BaseCreateView
+
+from .forms import RegisterForm
+from .utils import build_url
+
+
+class ChatView(LoginRequiredMixin, TemplateView):
+    template_name = "chat/chat.html"
+
+
+class LoginView(DjangoLoginView):
+    form_class = AuthenticationForm
+    template_name = "chat/login.html"
+    title = "Login"
+
+    def get_success_url(self):
+        return build_url(self.request, "chat-main")
+
+
+class LogoutView(DjangoLogoutView):
+    http_method_names = ["post", "options", "get"]
+    template_name = "chat/success.html"
+    title = "Logout"
+
+    def get(self, request, *args, **kwargs):
+        return self.post(request, *args, **kwargs)
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context["msg"] = context["title"]
+        return context
+
+
+class PasswordResetView(DjangoPasswordResetView):
+    form_class = PasswordResetForm
+    template_name = "chat/login.html"
+    title = "Reset Password"
+
+
+class RegisterView(SingleObjectTemplateResponseMixin, BaseCreateView):
+    form_class = RegisterForm
+    title = "Register"
+
+    def get_template_names(self):
+        if self.request.GET.get("success") == "1":
+            return ["chat/success.html"]
+        return ["chat/login.html"]
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        if self.request.GET.get("success") == "1":
+            if settings.TRUST_USER_REGISTRATIONS:
+                context["msg"] = mark_safe("Registration complete!")
+            else:
+                context["msg"] = mark_safe(
+                    "Registration complete.<br />"
+                    "Your user account will be activated shortly."
+                )
+        return context
+
+    def get_success_url(self):
+        return build_url(self.request, "chat-register", params={"success": "1"})
+
+    def form_valid(self, form):
+        response = super().form_valid(form)
+        # instance = self.object
+        # here, email the user to confirm their email address
+        url = build_url(
+            self.request,
+            "chat-confirm-email",
+            args=[
+                b64encode(
+                    TimestampSigner().sign(self.object.email).encode()
+                ).decode().rstrip("=")
+            ]
+        )
+        self.object.email_user(
+            "[Chat-App] Confirm your email address!",
+            f"Hello {str(self.object)}\nClick here to confirm your email address:\n{url}"
+        )
+        if not settings.TRUST_USER_REGISTRATIONS:
+            # here, email admin to activate the new user
+            pass
+        return response
+
+
+class ConfirmEmailView(TemplateView):
+    template_name = "chat/success.html"
+    title = "Email confirmed"
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+        self.msg = None
+
+    def get(self, request, *args, **kwargs):
+        token = f"{kwargs['token']}{'=' * (-len(kwargs['token']) % 3)}"
+        email = TimestampSigner().unsign(
+            b64decode(token.encode()).decode(),
+            max_age=settings.MAX_EMAIL_CONFIRMATION_AGE_SECONDS,
+        )
+        user = User.objects.get(email=email)
+        if settings.TRUST_USER_REGISTRATIONS or user.is_active:
+            self.msg = "You are ready to go!"
+        else:
+            self.msg = "Your user account will be activated shortly."
+        if user.last_login:
+            if self.msg:
+                self.msg = f"Email has already been confirmed! {self.msg}"
+            else:
+                self.msg = "Email has already been confirmed!"
+        update_last_login(None, user)
+        return super().get(request, *args, **kwargs)
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        if self.msg:
+            context["msg"] = self.msg
+        return context
diff --git a/chat/websocket.py b/chat/websocket.py
new file mode 100644 (file)
index 0000000..b306be5
--- /dev/null
@@ -0,0 +1,16 @@
+async def handle_websocket(scope, receive, send):
+    while True:
+        event = await receive()
+
+        if event['type'] == 'websocket.connect':
+            await send({'type': 'websocket.accept'})
+
+        if event['type'] == 'websocket.disconnect':
+            break
+
+        if event['type'] == 'websocket.receive':
+            if event['text'] == 'ping':
+                await send({
+                    'type': 'websocket.send',
+                    'text': 'pong!'
+                })
diff --git a/manage.py b/manage.py
new file mode 100755 (executable)
index 0000000..ba3d51f
--- /dev/null
+++ b/manage.py
@@ -0,0 +1,35 @@
+#!/usr/bin/env venv/bin/python
+"""Django's command-line utility for administrative tasks."""
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+
+def setup_virtual_env():
+    venv_dir = Path(__file__).parent / "venv"
+    subprocess.run(["bash", str(venv_dir.parent / "setup_venv.sh")])
+    os.environ.setdefault("VIRTUAL_ENV", str(venv_dir))
+    venv_bin = str(venv_dir / "bin")
+    if venv_bin not in os.environ["PATH"].split(":"):
+        os.environ["PATH"] = f"{venv_bin}:{os.environ['PATH']}"
+    os.environ.pop("PYTHONHOME", None)
+
+def main():
+    """Run administrative tasks."""
+    if "VIRTUAL_ENV" not in os.environ:
+        setup_virtual_env()
+    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/setup_venv.sh b/setup_venv.sh
new file mode 100644 (file)
index 0000000..ab7c5ba
--- /dev/null
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+
+export PYTHON="${PYTHON:-python}"
+
+"${PYTHON}" -m venv venv
+. venv/bin/activate
+"${PYTHON}" -m pip install -qU pip
+"${PYTHON}" -m pip install -qU django 'uvicorn[standard]'
diff --git a/todo.txt b/todo.txt
new file mode 100644 (file)
index 0000000..5f599c5
--- /dev/null
+++ b/todo.txt
@@ -0,0 +1,17 @@
+[ ] login tests
+    last_login is None is used as a sentinel state
+    for unconfirmed email user accounts.
+   - login is impossible if email address is unconfirmed
+     (which is the same as last_login is None)
+[ ] passsword reset
+   - email
+   - tests
+[ ] css for chat UI
+[ ] JS: ws and polling boilerplate
+[ ] ws and chat tests?
+[ ] hook up ws to a pg queue (or comparable)
+[ ] user settings, change password
+[ ] channel management: channel admins?
+[ ] file uploads
+[ ] media uploads: images, gifs, even video files?
+[ ] moderation functions: report messages and hardcoded complaints channel