--- /dev/null
+.idea/
+__pycache__/
+chat/settings_local.py
+staticfiles
+venv/
--- /dev/null
+from django.contrib import admin
+
+# Register your models here.
--- /dev/null
+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()
--- /dev/null
+"""
+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()
--- /dev/null
+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']
--- /dev/null
+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:]))
--- /dev/null
+# 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)),
+ ],
+ ),
+ ]
--- /dev/null
+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()
--- /dev/null
+"""
+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
--- /dev/null
+(function () {
+}());
\ No newline at end of file
--- /dev/null
+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%;
+}
--- /dev/null
+* {
+ margin: 0;
+ padding: 0;
+ background-color: #222;
+ color: white;
+}
+
+nav {
+ width: 10em;
+ height: 100%;
+}
--- /dev/null
+{% 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>
--- /dev/null
+{% 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 %}
--- /dev/null
+{% 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 %}
--- /dev/null
+{% 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 %}
--- /dev/null
+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):
+ ...
--- /dev/null
+"""
+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}
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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!'
+ })
--- /dev/null
+#!/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()
--- /dev/null
+#!/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]'
--- /dev/null
+[ ] 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