From f58e12106e35c8be8866700d4ddd971032a9607e Mon Sep 17 00:00:00 2001 From: mar77i Date: Wed, 18 Sep 2024 02:38:26 +0200 Subject: [PATCH] initial commit --- .gitignore | 5 + chat/__init__.py | 0 chat/admin.py | 3 + chat/apps.py | 19 ++++ chat/asgi.py | 30 ++++++ chat/forms.py | 34 +++++++ chat/management/__init__.py | 0 chat/management/commands/__init__.py | 0 chat/management/commands/runserver.py | 13 +++ chat/migrations/0001_initial.py | 43 ++++++++ chat/migrations/__init__.py | 0 chat/models.py | 21 ++++ chat/settings.py | 103 +++++++++++++++++++ chat/static/chat/index.js | 2 + chat/static/chat/login.css | 53 ++++++++++ chat/static/chat/style.css | 11 ++ chat/templates/chat/base.html | 19 ++++ chat/templates/chat/chat.html | 46 +++++++++ chat/templates/chat/login.html | 28 ++++++ chat/templates/chat/success.html | 20 ++++ chat/tests.py | 139 ++++++++++++++++++++++++++ chat/urls.py | 41 ++++++++ chat/utils.py | 23 +++++ chat/views.py | 128 ++++++++++++++++++++++++ chat/websocket.py | 16 +++ manage.py | 35 +++++++ setup_venv.sh | 8 ++ todo.txt | 17 ++++ 28 files changed, 857 insertions(+) create mode 100644 .gitignore create mode 100644 chat/__init__.py create mode 100644 chat/admin.py create mode 100644 chat/apps.py create mode 100644 chat/asgi.py create mode 100644 chat/forms.py create mode 100644 chat/management/__init__.py create mode 100644 chat/management/commands/__init__.py create mode 100644 chat/management/commands/runserver.py create mode 100644 chat/migrations/0001_initial.py create mode 100644 chat/migrations/__init__.py create mode 100644 chat/models.py create mode 100644 chat/settings.py create mode 100644 chat/static/chat/index.js create mode 100644 chat/static/chat/login.css create mode 100644 chat/static/chat/style.css create mode 100644 chat/templates/chat/base.html create mode 100644 chat/templates/chat/chat.html create mode 100644 chat/templates/chat/login.html create mode 100644 chat/templates/chat/success.html create mode 100644 chat/tests.py create mode 100644 chat/urls.py create mode 100644 chat/utils.py create mode 100644 chat/views.py create mode 100644 chat/websocket.py create mode 100755 manage.py create mode 100644 setup_venv.sh create mode 100644 todo.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a2e0220 --- /dev/null +++ b/.gitignore @@ -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 index 0000000..e69de29 diff --git a/chat/admin.py b/chat/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/chat/admin.py @@ -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 index 0000000..bebd81f --- /dev/null +++ b/chat/apps.py @@ -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 index 0000000..27a53c7 --- /dev/null +++ b/chat/asgi.py @@ -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 index 0000000..d8c7d50 --- /dev/null +++ b/chat/forms.py @@ -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 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/runserver.py b/chat/management/commands/runserver.py new file mode 100644 index 0000000..da7b9b7 --- /dev/null +++ b/chat/management/commands/runserver.py @@ -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 index 0000000..9d0cc76 --- /dev/null +++ b/chat/migrations/0001_initial.py @@ -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 index 0000000..e69de29 diff --git a/chat/models.py b/chat/models.py new file mode 100644 index 0000000..028b431 --- /dev/null +++ b/chat/models.py @@ -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 index 0000000..f237fd8 --- /dev/null +++ b/chat/settings.py @@ -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 index 0000000..7bb1a02 --- /dev/null +++ b/chat/static/chat/index.js @@ -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 index 0000000..1ff69b9 --- /dev/null +++ b/chat/static/chat/login.css @@ -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 index 0000000..b70087e --- /dev/null +++ b/chat/static/chat/style.css @@ -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 index 0000000..94075d3 --- /dev/null +++ b/chat/templates/chat/base.html @@ -0,0 +1,19 @@ +{% load static %} + + + + + + {% block title %}Title{% endblock title %} + + {% block head %}{% endblock head %} + + + {% block header %}{% endblock header %} +
+ {% block main %} + {% endblock main %} +
+ {% block footer %}{% endblock footer %} + + diff --git a/chat/templates/chat/chat.html b/chat/templates/chat/chat.html new file mode 100644 index 0000000..b770d9f --- /dev/null +++ b/chat/templates/chat/chat.html @@ -0,0 +1,46 @@ +{% extends "chat/base.html" %} +{% load static %} + +{% block header %} + +{% endblock header %} + +{% block main %} +
+ {% block messages %} + {% endblock messages %} +
+
+ {% block input %}{% endblock input %} +
+{% endblock main %} + +{% block footer %} + +{% endblock footer %} diff --git a/chat/templates/chat/login.html b/chat/templates/chat/login.html new file mode 100644 index 0000000..4eebb30 --- /dev/null +++ b/chat/templates/chat/login.html @@ -0,0 +1,28 @@ +{% extends "chat/base.html" %} +{% load static i18n %} + +{% block head %} + +{% endblock head %} + +{% block main %} +

{% translate view.title %}

+
+ {% csrf_token %} + {{ form.as_p }} +
+

+{% if view.title == 'Login' %} + Reset Password +
+ Register +{% elif view.title == 'Register' %} + Back +{% endif %} +

+

+ +

+
+
+{% endblock main %} diff --git a/chat/templates/chat/success.html b/chat/templates/chat/success.html new file mode 100644 index 0000000..9a0c38e --- /dev/null +++ b/chat/templates/chat/success.html @@ -0,0 +1,20 @@ +{% extends "chat/base.html" %} +{% load static i18n %} + +{% block head %} + +{% endblock head %} + +{% block main %} +

{% translate view.title %}

+
+

{{ msg }}

+
+

+

+

+ Back +

+
+
+{% endblock main %} diff --git a/chat/tests.py b/chat/tests.py new file mode 100644 index 0000000..14f6a2b --- /dev/null +++ b/chat/tests.py @@ -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 index 0000000..2537671 --- /dev/null +++ b/chat/urls.py @@ -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/", + 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 index 0000000..6d594c6 --- /dev/null +++ b/chat/utils.py @@ -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 index 0000000..becc39c --- /dev/null +++ b/chat/views.py @@ -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.
" + "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 index 0000000..b306be5 --- /dev/null +++ b/chat/websocket.py @@ -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 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 index 0000000..ab7c5ba --- /dev/null +++ b/setup_venv.sh @@ -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 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 -- 2.47.0