DjangoCon version

This commit is contained in:
Ken Whitesell 2024-09-20 23:17:07 -04:00
commit e2bdc2f8ba
51 changed files with 50927 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
.settings
.project
.pydevproject
.env
*.pyc
*.pyo
.vscode
!__init__.py
.code-workspace.code-workspace
.workspace.code-workspace
workspace.code-workspace
__pycache__
db.sqlite3

44
README.md Normal file
View File

@ -0,0 +1,44 @@
WebRTC demo using Django, Channels, HTMX, and Coturn
This is a Proof-of-Concept demonstration of WebRTC sharing video using Django, Channels, and HTMX as a signalling service, with Coturn providing STUN and TURN support.
Running this:
If you're just looking to run this in a local environment, running it using runserver does work.
However, getting other machines to connect to your server can be problematic due to the requirement that some of the protocols involved will only work across https. Therefore, for anything other than just
trying it out on your own system, you'll want to deploy this to a server.
Here's a summary of what's needed to get this running.
Keep in mind that this is a full deployment, so everything you're used to doing applies here.
(Note: A full detailed description of a deployment is beyond the scope of this readme.)
- Create your virtual environment
(My version was built using Python 3.12)
- Install packages listed in the requirements.txt file
- Install and run redis or a true redis-compatible server for the channels layer
(I have only ever used redis. This is untested with any of the forks.)
- Adjust your settings as appropriate. At a minimum, you will probably want to change:
- CSRF_TRUSTED_ORIGINS
- ALLOWED_HOSTS
- STATIC_ROOT
- Run `manage.py migrate` to initialize the database
- Configure nginx.
- See the sample file `rtc` in the nginx directory. You will need SSL certificates.
- Set up Daphne and gunicorn to run your project
- I use `supervisor` as the process manager. A sample configuration file is in the etc/supervisor/conf.d directory
- Deploy your Django project
- Copy it to an appropriate directory
- Run `collectstatic`
- Configure coturn - sample file in etc/turnserver.conf
- You will need a non-local location for your coturn instance if you're behind a NAT
- You can use a public STUN server, but TURN should be on a public IP address
- (This is not an issue if everyone using this is behind the same NAT)

BIN
Talk slides.pdf Normal file

Binary file not shown.

View File

@ -0,0 +1,44 @@
server {
server_name *your.server.dns.name*;
listen 443 ssl;
listen [::]:443 ssl;
default_type text/html;
ssi on;
ssl_certificate */etc/your/fullchain.pem*;
ssl_certificate_key */etc/your/privkey.pem*;
root /var/www/rtc/;
location /static/ {
alias /var/www/rtc/;
}
location / {
proxy_pass http://127.0.0.1:8000;
include /etc/nginx/proxy_params;
}
location /ws/ {
proxy_pass http://127.0.0.1:8001/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $server_name;
}
}
server {
if ($host = demo.kww.us) {
return 301 https://$host$request_uri;
} # managed by Certbot
server_name demo.kww.us;
return 404; # managed by Certbot
}

View File

@ -0,0 +1,54 @@
[group:rtc]
programs=clear,django,daphne
priority=100
[program:clear]
command = /home/rtc/ve/bin/python /home/rtc/rtc/manage.py channel_cleanup
numprocs = 1
directory = /home/rtc/rtc_demo
umask = 002
autostart = true
startsecs = 2
startretries = 3
autorestart = unexpected
exitcodes = 0
stopsignal = QUIT
stopwaitsecs = 10
stopasgroup = true
killasgroup = true
user = rtc
redirect_stderr = false
[program:django]
command = /home/rtc/ve/bin/gunicorn -b 127.0.0.1:8000 -w 4 rtc_demo.wsgi:application
numprocs = 1
directory = /home/rtc/rtc_demo
umask = 002
autostart = true
startsecs = 2
startretries = 3
autorestart = unexpected
exitcodes = 0
stopsignal = QUIT
stopwaitsecs = 10
stopasgroup = true
killasgroup = true
user = rtc
redirect_stderr = false
[program:daphne]
command = /home/rtc/ve/bin/daphne -u /run/rtc/rtc-daphne.sock rtc_demo.asgi:application
numprocs = 1
directory = /home/rtc/rtc_demo
umask = 002
autostart = true
startsecs = 2
startretries = 3
autorestart = unexpected
exitcodes = 0
stopsignal = QUIT
stopwaitsecs = 10
stopasgroup = true
killasgroup = true
user = rtc
redirect_stderr = false

19
etc/turnserver.conf Normal file
View File

@ -0,0 +1,19 @@
listening-ip=*your IPv4 address*
listening-ip=*your IPv6 address*
verbose
user=dcus:dcus2024
lt-cred-mech
realm=rtc.kww.us
no-tls
no-dtls
log-file=/var/log/coturn.log
simple-log
new-log-timestamp
proc-user=turnserver
proc-group=turnserver
cli-password=cli-secure-password
web-admin-ip=127.0.0.1
web-admin-port=8001
no-tlsv1
no-tlsv1_1
no-tlsv1_2

22
manage.py Executable file
View File

@ -0,0 +1,22 @@
#!/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', 'rtc_demo.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()

32
requirements.txt Normal file
View File

@ -0,0 +1,32 @@
asgiref==3.8.1
attrs==24.2.0
autobahn==24.4.2
Automat==24.8.1
cffi==1.17.1
channels==4.1.0
channels-redis==4.2.0
constantly==23.10.4
crispy-bootstrap5==2024.2
cryptography==43.0.1
daphne==4.1.2
Django==5.1.1
django-channels-presence-4.0==1.1.2
django-crispy-forms==2.3
gunicorn==23.0.0
hyperlink==21.0.0
idna==3.8
incremental==24.7.2
msgpack==1.0.8
pyasn1==0.6.1
pyasn1_modules==0.4.1
pycparser==2.22
pyOpenSSL==24.2.1
redis==5.0.8
service-identity==24.1.0
setuptools==74.1.2
sqlparse==0.5.1
Twisted==24.7.0
txaio==23.1.1
typing_extensions==4.12.2
wheel==0.44.0
zope.interface==7.0.3

0
rtc/__init__.py Normal file
View File

13
rtc/admin.py Normal file
View File

@ -0,0 +1,13 @@
from channels_presence.models import Presence, Room
from django.contrib import admin
admin.site.register(Room)
@admin.register(Presence)
class PresenceAdmin(admin.ModelAdmin):
list_display = ['user', 'room_name', 'channel_name']
list_select_related = ['room']
@admin.display
def room_name(self, obj):
return obj.room.channel_name

210
rtc/consumers.py Normal file
View File

@ -0,0 +1,210 @@
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from channels_presence.models import Presence, Room
from django.db.models import Case, F, Q, When, Value
from django.db.models.functions import Concat, Right
from django.template.loader import render_to_string
class RtcConsumer(AsyncJsonWebsocketConsumer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.function_dict = {
'rtc': self._rtc,
'join': self._join,
'hangup': self._hangup,
}
@database_sync_to_async
def _presence_connect(self, rtc_name):
# Remove all existing connections to this room for this user.
Presence.objects.leave_all(self.channel_name)
self.room = Room.objects.add(rtc_name, self.channel_name, self.scope["user"])
@database_sync_to_async
def _presence_disconnect(self, channel_name):
Presence.objects.leave_all(channel_name)
@database_sync_to_async
def _presence_touch(self):
Presence.objects.touch(self.channel_name)
@database_sync_to_async
def _leave_room(self, room):
Room.objects.remove(room, self.channel_name)
@property
def short_name(self):
return 'peer-'+self.channel_name[-6:]
@property
def user_name(self):
return self.scope['user'].first_name or self.scope['user'].username
def _create_self_div(self):
return render_to_string(
'rtc/video_panel.html', {
'id': f'{self.short_name}',
'user_name': self.user_name
}
)
def _create_other_div(self, occupant):
return render_to_string(
'rtc/video_panel.html', {
'id': f'{occupant["short_name"]}',
'user_name': occupant['user_name']
}
)
async def connect(self):
rtc_name = self.scope['url_route']['kwargs']['rtc_name']
await self._presence_disconnect(self.channel_name)
self.rtc_call = 'rtc_%s' % rtc_name
await self._presence_connect(self.rtc_call)
await self.accept()
await self.send_json({
'rtc': {'type': 'connect', 'channel_name': self.channel_name}
})
await self.send_json({
'html': render_to_string('rtc/header.html', {'room': rtc_name})
})
async def disconnect(self, close_code):
# Leave room group
await self._hangup()
await self._presence_disconnect(self.channel_name)
# Send "remove video" to all_but_me
await self._all_but_me(self.rtc_call,
{
'type': 'rtc_message',
'rtc': {
'type': 'disconnected',
'channel_name': self.channel_name,
},
}
)
# Receive message from WebSocket
async def receive_json(self, content):
await self._presence_touch()
for message_key, message_value in content.items():
if message_key in self.function_dict:
await self.function_dict[message_key](message_value)
async def _hangup(self, hangup=None):
await self._leave_room(self.rtc_call)
await self._all_but_me(self.rtc_call,
{
'type': 'rtc_message',
'rtc': {
'type': 'disconnected',
'channel_name': self.channel_name,
}
}
)
self.rtc_call = None
async def _join(self, rtc_call):
if self.rtc_call:
await self._leave_room(self.rtc_call)
self.rtc_call = rtc_call
await self.send_json({
'html': render_to_string('rtc/header.html', {'room': rtc_call})
})
# Send list of connected peers (occupants) to self
occupants = await self._room_occupants(self.rtc_call)
all_divs = "\n".join([
self._create_other_div(occupant)
for occupant in occupants
if occupant['channel_name'] != self.channel_name
])
#NOTE: The html must be sent before the connections
# Otherwise, the connection functions in the client
# will be trying to access the div before it exists
await self.send_json({
'html': all_divs
})
await self.send_json({
'rtc': {
'type': 'others',
'ids': occupants,
},
})
await self._all_but_me(self.rtc_call,
{
'type': 'html_message',
'html': self._create_self_div()
}
)
# Send self.channel_name to all connected peers
await self._all_but_me(self.rtc_call,
{
'type': 'rtc_message',
'rtc': {
'type': 'other',
'channel_name': self.channel_name,
'user_name': self.user_name,
'short_name': self.short_name
},
}
)
await self._presence_connect(self.rtc_call)
async def _rtc(self, rtc):
# If there's a recipient, send to it.
if 'recipient' in rtc:
await self.channel_layer.send(
rtc['recipient'], {
'type': 'rtc_message',
'rtc': rtc,
}
)
else:
await self._all_but_me(
self.rtc_call, {
'type': 'rtc_message',
'rtc': rtc
}
)
@database_sync_to_async
def _room_occupants(self, room):
return list(Presence.objects.filter(
room__channel_name=room
).annotate(
user_name=Case(
When(~Q(user__first_name=''), then=F('user__first_name')),
default=F('user__username')
),
short_name=Concat(Value('peer-'), Right('channel_name', 6))
).values('channel_name', 'user_name', 'short_name'))
async def _all_but_me(self, room, message):
occupants = await self._room_occupants(room)
for occupant in occupants:
if occupant['channel_name'] != self.channel_name:
await self.channel_layer.send(
occupant['channel_name'], message
)
async def rtc_message(self, event):
# Send message to WebSocket
await self.send_json({
'rtc': event['rtc']
})
async def html_message(self, event):
# Send message to WebSocket
await self.send_json(event)

27
rtc/forms.py Normal file
View File

@ -0,0 +1,27 @@
from django import forms
from django.core.exceptions import ValidationError
from django.contrib.auth.models import User
class LoginForm(forms.Form):
screen_name = forms.CharField(label="First name (or nickname)", max_length=12)
name = forms.CharField(label="Real name", max_length=50)
def clean(self):
super().clean()
screen_name = self.cleaned_data.get('screen_name', '')
name = self.cleaned_data.get('name', '')
self.user = None
found_name = User.objects.filter(username=name)
if found_name:
if found_name.exclude(first_name=screen_name):
self.add_error("name",
"The name exists but does not match the screen name used"
)
else:
self.user = found_name.first()
if screen_name == name:
self.add_error("screen_name",
"Do not use your real name as your screen name"
)

View File

@ -0,0 +1,28 @@
import asyncio
from django.core.management.base import BaseCommand
from django.core.management.base import BaseCommand
from channels.db import database_sync_to_async
from channels_presence.models import Room
class Command(BaseCommand):
help = "Periodically runs the channel presence clean-up commands"
def handle(self, *args, **options):
asyncio.run(self.do_clean_up())
async def do_clean_up(self):
await asyncio.gather(
self.prune_rooms(),
self.prune_presences()
)
async def prune_rooms(self):
while True:
await database_sync_to_async(Room.objects.prune_rooms)()
await asyncio.sleep(1800)
async def prune_presences(self):
while True:
await database_sync_to_async(Room.objects.prune_presences)()
await asyncio.sleep(600)

View File

8
rtc/routing.py Normal file
View File

@ -0,0 +1,8 @@
# chat/routing.py
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r'ws/(?P<rtc_name>\w+)/$', consumers.RtcConsumer.as_asgi()),
]

View File

@ -0,0 +1,3 @@
<div id="header">
<h4>{{room}}</h4>
</div>

View File

@ -0,0 +1,40 @@
{% extends "base.html" %}
{% load static %}
{% block page_scripts %}
<script src="{% static 'lib/htmx.org/dist/htmx.js' %}"></script>
<script src="{% static 'lib/htmx.org/dist/ext/ws.js' %}"></script>
<script src="{% static 'js/tr.js' %}"></script>
<script src="{% static 'lib/adapter/adapter-latest.js' %}"></script>
{% endblock page_scripts %}
{% block app_content %}
<div id="websocket-div" ws-connect="/ws/lobby/" hx-ext="tr-ext, ws">
<main class="videoContent">
<div id="videos" class="row">
<div class="col-sm-4 col-md-3 col-lg-3 col-xl-2">
<button class="join" type="button" id="call-button" hx-on:click="handleCallButton(event)">Join Call</button>
<div id="header">
</div>
<div id="self-div">
<figure id="self">
<video autoplay muted playsinline poster="{% static 'img/placeholder.png' %}"></video>
<figcaption>You</figcaption>
</figure>
<button aria-label="Toggle camera" role="switch" aria-checked="true"
type="button" id="toggle-cam" hx-on:click="toggleCam(event)">Cam</button>
<div class="row" style="margin-top: 10px;">
<form method="post" action="/logout/">
{% csrf_token %}
<button type="submit">Log out</button>
</form>
</div>
</div>
</div>
<div class="col-sm-8 col-md-9 col-lg-9 col-xl-10">
<div id="others" class="row">
</div>
</div>
</div>
</main>
</div>
<script src="{% static 'js/client.js' %}"></script>
{% endblock app_content %}

View File

@ -0,0 +1,9 @@
{% load static %}
<div id="others" hx-swap-oob="beforeend">
<div id="{{id}}-div" class="col-sm-7 col-md-5 col-lg-4 col-xl-3">
<figure id="{{id}}">
<video autoplay playsinline poster="{% static 'img/placeholder.png' %}"></video>
<figcaption>{{user_name}}</figcaption>
</figure>
</div>
</div>

28
rtc/views.py Normal file
View File

@ -0,0 +1,28 @@
from django.contrib.auth import login
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.http import HttpResponseRedirect
from django.shortcuts import render
from rtc.forms import LoginForm
def site_login(request):
form = LoginForm(request.POST or None)
if request.method == "POST":
if form.is_valid():
if not form.user:
user = User(
username=form.cleaned_data['name'],
first_name=form.cleaned_data['screen_name'],
is_active=True,
)
user.set_password("no password needed")
user.save()
else:
user = form.user
login(request, user)
return HttpResponseRedirect("/")
return render(request, 'registration/login.html', {'form': form})
@login_required
def index(request):
return render(request, 'rtc/index.html', {})

0
rtc_demo/__init__.py Normal file
View File

5
rtc_demo/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class ChannelsPresence(AppConfig):
name = 'channels_presence'
default_auto_field = 'django.db.models.AutoField'

29
rtc_demo/asgi.py Normal file
View File

@ -0,0 +1,29 @@
"""
ASGI config for rtc_demo 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/3.2/howto/deployment/asgi/
"""
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'rtc_demo.settings')
django.setup()
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application
import rtc.routing
application = ProtocolTypeRouter({
"http": get_asgi_application(),
# Just HTTP for now. (We can add other protocols later.)
"websocket": AuthMiddlewareStack(
URLRouter(
rtc.routing.websocket_urlpatterns
)
),
})

124
rtc_demo/settings.py Normal file
View File

@ -0,0 +1,124 @@
"""
Django settings for rtc_demo project.
"""
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-apt*tnv=g5f=0a1qtp-s&doqdks*53&ztw***+snqmse^1ymk%'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = ['demo.kww.us']
CSRF_TRUSTED_ORIGINS = [
'http://127.0.0.1', 'http://localhost', 'http://MSI.local'
]
# Application definition
INSTALLED_APPS = [
'rtc',
'daphne',
'channels',
'crispy_bootstrap5',
'crispy_forms',
'rtc_demo.apps.ChannelsPresence',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.messages',
'django.contrib.sessions',
'django.contrib.staticfiles',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'rtc_demo.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
CRISPY_ALLOWED_TEMPLATE_PACKS = 'bootstrap5'
CRISPY_TEMPLATE_PACK = 'bootstrap5'
WSGI_APPLICATION = 'rtc_demo.wsgi.application'
ASGI_APPLICATION = 'rtc_demo.asgi.application'
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
"hosts": [('127.0.0.1', 6379)],
"capacity": 10000,
},
},
}
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
'TEST': {
'NAME': BASE_DIR / 'db_test.sqlite3'
}
}
}
AUTH_PASSWORD_VALIDATORS = []
# Internationalization
# https://docs.djangoproject.com/en/3.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
LOGIN_REDIRECT_URL = 'index'
LOGOUT_REDIRECT_URL = '/login/'
LOGIN_URL = '/login/'
STATIC_URL = '/static/'
STATIC_ROOT = '/var/www/rtc/'
STATICFILES_DIRS = [
BASE_DIR / "static"
]
# Default primary key field type
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

28
rtc_demo/urls.py Normal file
View File

@ -0,0 +1,28 @@
"""rtc_demo URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/3.2/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.contrib import admin
from django.contrib.auth.views import LogoutView
from django.urls import path
from rtc.views import index, site_login
urlpatterns = [
path('', index, name="home"),
path('login/', site_login, name="login"),
path('logout/', LogoutView.as_view(), name="logout"),
path('admin/', admin.site.urls),
]

16
rtc_demo/wsgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
WSGI config for rtc_demo project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'rtc_demo.settings')
application = get_wsgi_application()

65
static/css/main.css Normal file
View File

@ -0,0 +1,65 @@
html {
margin: 0;
padding: 0;
font-family: "Lucida Grande", Arial, sans-serif;
font-size: 18px;
font-weight: bold;
line-height: 22px;
}
body {
color: #666666;
background-color: #c7d8df;
}
video {
background-color: #ddd;
display: block;
width: 160px;
height: 120px;
}
#appContent {
margin-top: 20px;
margin-left: 20px;
}
.join {
background-color: green;
color: white;
}
.leave {
background-color: #CA0;
color: black;
}
#call-button {
width: 143px;
margin-right: 11px;
}
#header {
margin-bottom: 11px;
}
button[aria-checked="false"] {
text-decoration: line-through;
color: white;
background: red;
}
figure {
position: relative;
}
figcaption {
color: #eee;
background: rgba(16,16,16,0.6);
font-weight: normal;
font-size: 14px;
bottom: 0;
left: 0;
width: 160px;
}

BIN
static/img/placeholder.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

356
static/js/client.js Normal file
View File

@ -0,0 +1,356 @@
'use strict';
const $self = {
user_name: "",
rtc_config: {
iceServers: [
{ urls: 'stun:kww.us:3478' },
{ urls: 'stun:stun.l.google.com:19302' },
{
username: "dcus",
credential: "dcus2024",
urls: 'turn:kww.us:3478',
},
],
iceTransportPolicy: "all"
},
media_constraints: {
audio: false,
video: true,
},
video_constraints: {
height: {max:240, min:48, ideal:120},
width: {max:320, min:64, ideal:160}
},
media_stream: new MediaStream(),
media_tracks: {},
features: { audio: false },
ws: null,
ws_json: function(data) {
this.ws.send(JSON.stringify(data));
}
};
const $others = new Map();
function element_id(id='self') {
if (id === 'self') return '#self';
else return $others.get(id).short_name;
}
function signal(recipient, signal) {
$self.ws_json(
{ 'rtc': {
'type': 'signal', 'recipient': recipient,
'sender': $self.id, 'signal': signal
}
}
)
};
function connected({channel_name}) {
if (channel_name) {
$self.id = channel_name;
}
};
function connected_other(conn_info) {
initialize_other(conn_info, true);
establish_features(conn_info.channel_name);
};
function connected_others({ids}) {
for (let conn_info of ids) {
if (conn_info.channel_name === $self.id) continue;
initialize_other(conn_info, false);
establish_features(conn_info.channel_name);
}
};
function disconnected_other({channel_name}) {
console.log(`disconnected_other: ${channel_name}`);
reset_other(channel_name);
};
async function signalled({sender,
signal: {candidate, description} }) {
const id = sender;
const other = $others.get(id);
const self_state = other.self_states;
if (description) {
if (description.type === '_reset') {
retry_connection(id);
return;
}
// Work with incoming description
const readyForOffer =
!self_state.making_offer &&
(other.connection.signalingState === 'stable'
|| self_state.remote_answer_pending);
const offerCollision = description.type === 'offer' && !readyForOffer;
self_state.ignoring_offer = !self_state.is_polite && offerCollision;
if (self_state.ignoring_offer) {
return;
}
self_state.remote_answer_pending = description.type === 'answer';
try {
await other.connection.setRemoteDescription(description);
} catch(e) {
retry_connection(id);
return;
}
self_state.remote_answer_pending = false;
if (description.type === 'offer') {
try {
await other.connection.setLocalDescription();
} catch(e) {
const answer = await other.connection.createAnswer();
await other.connection.setLocalDescription(answer);
} finally {
signal(id, {'description': other.connection.localDescription});
self_state.suppressing_offer = false;
}
}
} else if (candidate) {
// Work with incoming ICE
try {
await other.connection.addIceCandidate(candidate);
} catch(e) {
// Log error unless $self is ignoring offers
// and candidate is not an empty string
if (!self_state.ignoring_offer && candidate.candidate.length > 1) {
console.error(`Unable to add ICE candidate for other ID: ${id}`, e);
}
}
}
}
apps._add('rtc', 'connect', connected)
apps._add('rtc', 'other', connected_other);
apps._add('rtc', 'others', connected_others);
apps._add('rtc', 'disconnected', disconnected_other);
apps._add('rtc', 'signal', signalled);
function display_stream(stream, id = 'self') {
var selector = `${element_id(id)} video`;
var element = document.querySelector(selector);
if (element) { element.srcObject = stream; };
}
async function request_user_media(media_constraints) {
$self.media = await navigator.mediaDevices.getUserMedia(media_constraints);
$self.media_tracks.video = $self.media.getVideoTracks()[0];
$self.media_tracks.video.applyConstraints($self.video_constraints);
$self.media_stream.addTrack($self.media_tracks.video);
display_stream($self.media_stream);
}
function add_features(id) {
const other = $others.get(id);
function manage_video(video_feature) {
other.features['video'] = video_feature;
if (other.media_tracks.video) {
if (video_feature) {
other.media_stream.addTrack(other.media_tracks.video);
} else {
other.media_stream.removeTrack(other.media_tracks.video);
display_stream(other.media_stream, id);
}
}
}
other.features_channel = other.connection.createDataChannel('features', {negotiated: true, id: 500 });
other.features_channel.onopen = function(event){
other.features_channel.send(JSON.stringify($self.features))
};
other.features_channel.onmessage = function(event) {
const features = JSON.parse(event.data);
if ('video' in features) {
manage_video(features['video']);
}
};
}
function share_features(id, ...features) {
const other = $others.get(id);
const shared_features = {};
if (!other.features_channel) return;
for (let f of features) {
shared_features[f] = $self.features[f];
}
try {
other.features_channel.send(JSON.stringify(shared_features));
} catch(e) {
console.error('Error sending features:', e);
}
}
request_user_media($self.media_constraints);
function establish_features(id) {
register_rtc_callbacks(id);
add_features(id);
const other = $others.get(id);
for (let track in $self.media_tracks) {
other.connection.addTrack($self.media_tracks[track]);
}
}
function initialize_other({channel_name, user_name, short_name}, polite) {
$others.set(channel_name, {
user_name: user_name,
short_name: '#' + short_name,
connection: new RTCPeerConnection($self.rtc_config),
media_stream: new MediaStream(),
media_tracks: {},
features: { 'connection_count': 0},
self_states: {
is_polite: polite,
making_offer: false,
ignoring_offer: false,
remote_answer_pending: false,
suppressing_offer: false
}
});
}
function reset_other(channel_name, preserve) {
const other = $others.get(channel_name);
display_stream(null, channel_name);
if (other) {
if (other.connection) {
other.connection.close();
}
}
if (!preserve) {
let qs = document.querySelector(`${other.short_name}-div`);
if (qs) { qs.remove(); };
$others.delete(channel_name);
}
}
function retry_connection(channel_name) {
const polite = $others.get(channel_name).self_states.is_polite;
reset_other(channel_name, true);
//TODO bundle id with username for this call
initialize_other({channel_name, user_name}, polite);
$others.get(channel_name).self_states.suppressing_offer = polite;
establish_features(channel_name);
if (polite) {
signal(channel_name,
{'description': {'type': '_reset'}}
);
}
}
function register_rtc_callbacks(id) {
const other = $others.get(id);
other.connection.onconnectionstatechange = conn_state_change(id);
other.connection.onnegotiationneeded = conn_negotiation(id);
other.connection.onicecandidate = ice_candidate(id);
other.connection.ontrack = other_track(id);
}
function conn_state_change(id) {
return function() {
const other = $others.get(id);
const otherElement = document.querySelector(`${other.short_name}`);
if (otherElement) {
otherElement.dataset.connectionState = other.connection.connectionState;
}
}
}
function conn_negotiation(id) {
return async function() {
const other = $others.get(id);
const self_state = other.self_states;
if (self_state.suppressing_offer) return;
try {
self_state.making_offer = true;
await other.connection.setLocalDescription();
} catch(e) {
const offer = await other.connection.createOffer();
await other.connection.setLocalDescription(offer);
} finally {
signal(id, {'description': other.connection.localDescription});
self_state.making_offer = false;
};
}
}
function ice_candidate(id) {
return function({candidate}) {
signal(id, {candidate});
}
}
function other_track(id) {
return function({track}) {
const other = $others.get(id);
other.media_tracks[track.kind] = track;
other.media_stream.addTrack(track);
display_stream(other.media_stream, id);
};
}
htmx.on('htmx:wsOpen', function(e) {
$self.ws = e.detail.socketWrapper;
$self.ws_json({'room': 'lobby'});
});
function handleCallButton(event) {
const call_button = event.target;
if (call_button.className === 'join') {
call_button.className = 'leave';
call_button.innerText = 'Leave Call';
if ($self.ws) {
$self.ws_json({'join': 'video'});
}
} else {
// Leave the call
call_button.className = 'join';
call_button.innerText = 'Join Call';
$self.ws_json({'hangup': true});
for (let channel_name of $others.keys()) {
reset_other(channel_name);
}
let node_list = document.querySelectorAll('[id^="other-"][id$="-div"]');
for (let node of node_list) { node.remove(); }
}
};
function toggleCam(event) {
const button = event.target;
const video = $self.media_tracks.video;
const state = video.enabled = !video.enabled;
$self.features.video = state;
button.setAttribute('aria-checked', state);
for (let id of $others.keys()) {
share_features(id, 'video');
}
if (state) {
$self.media_stream.addTrack($self.media_tracks.video);
} else {
$self.media_stream.removeTrack($self.media_tracks.video);
display_stream($self.media_stream);
}
}

62
static/js/tr.js Normal file
View File

@ -0,0 +1,62 @@
(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api;
/* data is the parsed JSON response
data.html is the html to add to the page
*/
htmx.defineExtension('tr-ext', {
transformResponse : function(text, xhr, elt) {
const data = JSON.parse(text);
for (var [app, message] of Object.entries(data)) {
var event = message.type;
apps._forward(app, event, message)
}
if ('remove' in data) {
htmx.remove(htmx.find(data.remove));
}
if (data.html === undefined) { data.html = ""; };
return data.html;
}
})
})();
htmx.on('htmx:wsOpen', function(e) {
// Send heartbeat at least every 30 seconds
setInterval(function() {
$self.ws_json({"signal": "hb"});
}, 25000 + (Math.random() * 5000) );
});
const apps = {
'_add': function(app, name, target) {
if (!apps[app]) {
apps[app] = new Map();
}
if (!apps[app].has(name)) {
apps[app].set(name, new Array());
}
if (!apps[app].get(name).includes(target)) {
apps[app].get(name).push(target);
}
},
'_remove': function(app, name, target) {
if (apps[app]) {
idx = apps[app].get(name).indexOf(target)
if (idx > -1) {
apps[app].get(name).splice(idx, 1);
}
if (apps[app].get(name).length == 0) {
apps[app].delete(name);
}
}
},
'_forward': function(app, name, message) {
if (apps[app] && apps[app].has(name)) {
for (target of apps[app].get(name)) {
target(message);
}
}
},
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

10837
static/lib/bootstrap/css/bootstrap.css vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

5016
static/lib/bootstrap/js/bootstrap.js vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

467
static/lib/htmx.org/dist/ext/ws.js vendored Normal file
View File

@ -0,0 +1,467 @@
/*
WebSockets Extension
============================
This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions.
*/
(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api
htmx.defineExtension('ws', {
/**
* init is called once, when this extension is first registered.
* @param {import("../htmx").HtmxInternalApi} apiRef
*/
init: function(apiRef) {
// Store reference to internal API
api = apiRef
// Default function for creating new EventSource objects
if (!htmx.createWebSocket) {
htmx.createWebSocket = createWebSocket
}
// Default setting for reconnect delay
if (!htmx.config.wsReconnectDelay) {
htmx.config.wsReconnectDelay = 'full-jitter'
}
},
/**
* onEvent handles all events passed to this extension.
*
* @param {string} name
* @param {Event} evt
*/
onEvent: function(name, evt) {
var parent = evt.target || evt.detail.elt
switch (name) {
// Try to close the socket when elements are removed
case 'htmx:beforeCleanupElement':
var internalData = api.getInternalData(parent)
if (internalData.webSocket) {
internalData.webSocket.close()
}
return
// Try to create websockets when elements are processed
case 'htmx:beforeProcessNode':
forEach(queryAttributeOnThisOrChildren(parent, 'ws-connect'), function(child) {
ensureWebSocket(child)
})
forEach(queryAttributeOnThisOrChildren(parent, 'ws-send'), function(child) {
ensureWebSocketSend(child)
})
}
}
})
function splitOnWhitespace(trigger) {
return trigger.trim().split(/\s+/)
}
function getLegacyWebsocketURL(elt) {
var legacySSEValue = api.getAttributeValue(elt, 'hx-ws')
if (legacySSEValue) {
var values = splitOnWhitespace(legacySSEValue)
for (var i = 0; i < values.length; i++) {
var value = values[i].split(/:(.+)/)
if (value[0] === 'connect') {
return value[1]
}
}
}
}
/**
* ensureWebSocket creates a new WebSocket on the designated element, using
* the element's "ws-connect" attribute.
* @param {HTMLElement} socketElt
* @returns
*/
function ensureWebSocket(socketElt) {
// If the element containing the WebSocket connection no longer exists, then
// do not connect/reconnect the WebSocket.
if (!api.bodyContains(socketElt)) {
return
}
// Get the source straight from the element's value
var wssSource = api.getAttributeValue(socketElt, 'ws-connect')
if (wssSource == null || wssSource === '') {
var legacySource = getLegacyWebsocketURL(socketElt)
if (legacySource == null) {
return
} else {
wssSource = legacySource
}
}
// Guarantee that the wssSource value is a fully qualified URL
if (wssSource.indexOf('/') === 0) {
var base_part = location.hostname + (location.port ? ':' + location.port : '')
if (location.protocol === 'https:') {
wssSource = 'wss://' + base_part + wssSource
} else if (location.protocol === 'http:') {
wssSource = 'ws://' + base_part + wssSource
}
}
var socketWrapper = createWebsocketWrapper(socketElt, function() {
return htmx.createWebSocket(wssSource)
})
socketWrapper.addEventListener('message', function(event) {
if (maybeCloseWebSocketSource(socketElt)) {
return
}
var response = event.data
if (!api.triggerEvent(socketElt, 'htmx:wsBeforeMessage', {
message: response,
socketWrapper: socketWrapper.publicInterface
})) {
return
}
api.withExtensions(socketElt, function(extension) {
response = extension.transformResponse(response, null, socketElt)
})
var settleInfo = api.makeSettleInfo(socketElt)
var fragment = api.makeFragment(response)
if (fragment.children.length) {
var children = Array.from(fragment.children)
for (var i = 0; i < children.length; i++) {
api.oobSwap(api.getAttributeValue(children[i], 'hx-swap-oob') || 'true', children[i], settleInfo)
}
}
api.settleImmediately(settleInfo.tasks)
api.triggerEvent(socketElt, 'htmx:wsAfterMessage', { message: response, socketWrapper: socketWrapper.publicInterface })
})
// Put the WebSocket into the HTML Element's custom data.
api.getInternalData(socketElt).webSocket = socketWrapper
}
/**
* @typedef {Object} WebSocketWrapper
* @property {WebSocket} socket
* @property {Array<{message: string, sendElt: Element}>} messageQueue
* @property {number} retryCount
* @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state
* @property {(message: string, sendElt: Element) => void} send
* @property {(event: string, handler: Function) => void} addEventListener
* @property {() => void} handleQueuedMessages
* @property {() => void} init
* @property {() => void} close
*/
/**
*
* @param socketElt
* @param socketFunc
* @returns {WebSocketWrapper}
*/
function createWebsocketWrapper(socketElt, socketFunc) {
var wrapper = {
socket: null,
messageQueue: [],
retryCount: 0,
/** @type {Object<string, Function[]>} */
events: {},
addEventListener: function(event, handler) {
if (this.socket) {
this.socket.addEventListener(event, handler)
}
if (!this.events[event]) {
this.events[event] = []
}
this.events[event].push(handler)
},
sendImmediately: function(message, sendElt) {
if (!this.socket) {
api.triggerErrorEvent()
}
if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
message,
socketWrapper: this.publicInterface
})) {
this.socket.send(message)
sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
message,
socketWrapper: this.publicInterface
})
}
},
send: function(message, sendElt) {
if (this.socket.readyState !== this.socket.OPEN) {
this.messageQueue.push({ message, sendElt })
} else {
this.sendImmediately(message, sendElt)
}
},
handleQueuedMessages: function() {
while (this.messageQueue.length > 0) {
var queuedItem = this.messageQueue[0]
if (this.socket.readyState === this.socket.OPEN) {
this.sendImmediately(queuedItem.message, queuedItem.sendElt)
this.messageQueue.shift()
} else {
break
}
}
},
init: function() {
if (this.socket && this.socket.readyState === this.socket.OPEN) {
// Close discarded socket
this.socket.close()
}
// Create a new WebSocket and event handlers
/** @type {WebSocket} */
var socket = socketFunc()
// The event.type detail is added for interface conformance with the
// other two lifecycle events (open and close) so a single handler method
// can handle them polymorphically, if required.
api.triggerEvent(socketElt, 'htmx:wsConnecting', { event: { type: 'connecting' } })
this.socket = socket
socket.onopen = function(e) {
wrapper.retryCount = 0
api.triggerEvent(socketElt, 'htmx:wsOpen', { event: e, socketWrapper: wrapper.publicInterface })
wrapper.handleQueuedMessages()
}
socket.onclose = function(e) {
// If socket should not be connected, stop further attempts to establish connection
// If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) {
var delay = getWebSocketReconnectDelay(wrapper.retryCount)
setTimeout(function() {
wrapper.retryCount += 1
wrapper.init()
}, delay)
}
// Notify client code that connection has been closed. Client code can inspect `event` field
// to determine whether closure has been valid or abnormal
api.triggerEvent(socketElt, 'htmx:wsClose', { event: e, socketWrapper: wrapper.publicInterface })
}
socket.onerror = function(e) {
api.triggerErrorEvent(socketElt, 'htmx:wsError', { error: e, socketWrapper: wrapper })
maybeCloseWebSocketSource(socketElt)
}
var events = this.events
Object.keys(events).forEach(function(k) {
events[k].forEach(function(e) {
socket.addEventListener(k, e)
})
})
},
close: function() {
this.socket.close()
}
}
wrapper.init()
wrapper.publicInterface = {
send: wrapper.send.bind(wrapper),
sendImmediately: wrapper.sendImmediately.bind(wrapper),
queue: wrapper.messageQueue
}
return wrapper
}
/**
* ensureWebSocketSend attaches trigger handles to elements with
* "ws-send" attribute
* @param {HTMLElement} elt
*/
function ensureWebSocketSend(elt) {
var legacyAttribute = api.getAttributeValue(elt, 'hx-ws')
if (legacyAttribute && legacyAttribute !== 'send') {
return
}
var webSocketParent = api.getClosestMatch(elt, hasWebSocket)
processWebSocketSend(webSocketParent, elt)
}
/**
* hasWebSocket function checks if a node has webSocket instance attached
* @param {HTMLElement} node
* @returns {boolean}
*/
function hasWebSocket(node) {
return api.getInternalData(node).webSocket != null
}
/**
* processWebSocketSend adds event listeners to the <form> element so that
* messages can be sent to the WebSocket server when the form is submitted.
* @param {HTMLElement} socketElt
* @param {HTMLElement} sendElt
*/
function processWebSocketSend(socketElt, sendElt) {
var nodeData = api.getInternalData(sendElt)
var triggerSpecs = api.getTriggerSpecs(sendElt)
triggerSpecs.forEach(function(ts) {
api.addTriggerHandler(sendElt, ts, nodeData, function(elt, evt) {
if (maybeCloseWebSocketSource(socketElt)) {
return
}
/** @type {WebSocketWrapper} */
var socketWrapper = api.getInternalData(socketElt).webSocket
var headers = api.getHeaders(sendElt, api.getTarget(sendElt))
var results = api.getInputValues(sendElt, 'post')
var errors = results.errors
var rawParameters = Object.assign({}, results.values)
var expressionVars = api.getExpressionVars(sendElt)
var allParameters = api.mergeObjects(rawParameters, expressionVars)
var filteredParameters = api.filterValues(allParameters, sendElt)
var sendConfig = {
parameters: filteredParameters,
unfilteredParameters: allParameters,
headers,
errors,
triggeringEvent: evt,
messageBody: undefined,
socketWrapper: socketWrapper.publicInterface
}
if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) {
return
}
if (errors && errors.length > 0) {
api.triggerEvent(elt, 'htmx:validation:halted', errors)
return
}
var body = sendConfig.messageBody
if (body === undefined) {
var toSend = Object.assign({}, sendConfig.parameters)
if (sendConfig.headers) { toSend.HEADERS = headers }
body = JSON.stringify(toSend)
}
socketWrapper.send(body, elt)
if (evt && api.shouldCancel(evt, elt)) {
evt.preventDefault()
}
})
})
}
/**
* getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
* @param {number} retryCount // The number of retries that have already taken place
* @returns {number}
*/
function getWebSocketReconnectDelay(retryCount) {
/** @type {"full-jitter" | ((retryCount:number) => number)} */
var delay = htmx.config.wsReconnectDelay
if (typeof delay === 'function') {
return delay(retryCount)
}
if (delay === 'full-jitter') {
var exp = Math.min(retryCount, 6)
var maxDelay = 1000 * Math.pow(2, exp)
return maxDelay * Math.random()
}
logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')
}
/**
* maybeCloseWebSocketSource checks to the if the element that created the WebSocket
* still exists in the DOM. If NOT, then the WebSocket is closed and this function
* returns TRUE. If the element DOES EXIST, then no action is taken, and this function
* returns FALSE.
*
* @param {*} elt
* @returns
*/
function maybeCloseWebSocketSource(elt) {
if (!api.bodyContains(elt)) {
api.getInternalData(elt).webSocket.close()
return true
}
return false
}
/**
* createWebSocket is the default method for creating new WebSocket objects.
* it is hoisted into htmx.createWebSocket to be overridden by the user, if needed.
*
* @param {string} url
* @returns WebSocket
*/
function createWebSocket(url) {
var sock = new WebSocket(url, [])
sock.binaryType = htmx.config.wsBinaryType
return sock
}
/**
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
*
* @param {HTMLElement} elt
* @param {string} attributeName
*/
function queryAttributeOnThisOrChildren(elt, attributeName) {
var result = []
// If the parent element also contains the requested attribute, then add it to the results too.
if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, 'hx-ws')) {
result.push(elt)
}
// Search all child nodes that match the requested attribute
elt.querySelectorAll('[' + attributeName + '], [data-' + attributeName + '], [data-hx-ws], [hx-ws]').forEach(function(node) {
result.push(node)
})
return result
}
/**
* @template T
* @param {T[]} arr
* @param {(T) => void} func
*/
function forEach(arr, func) {
if (arr) {
for (var i = 0; i < arr.length; i++) {
func(arr[i])
}
}
}
})()

5132
static/lib/htmx.org/dist/htmx.js vendored Normal file

File diff suppressed because it is too large Load Diff

1
static/lib/htmx.org/dist/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
static/lib/htmx.org/dist/htmx.min.js.gz vendored Normal file

Binary file not shown.

10881
static/lib/jquery/jquery-3.6.0.js vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

20
templates/base.html Normal file
View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
{% load static %}
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebRTC Demo</title>
<link href="{% static 'lib/bootstrap/css/bootstrap.min.css' %}" type="text/css" rel="stylesheet">
<link href="{% static 'css/main.css' %}" type="text/css" rel="stylesheet">
{% block page_scripts %}
{% endblock page_scripts %}
</head>
<body>
<div class="container" id="appContent">
<div class="row"><div class="offset-sm-2 col-sm-9"><h2>WebRTC Demo</h2></div></div>
{% block app_content %}
{% endblock app_content %}
</div>
</body>
</html>

View File

@ -0,0 +1,28 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block app_content %}
There is no real security on this site.<br/>
<br/>
Pick any screen name you wish to use for the "First name" field.<br/>
<br/>
Please supply your real name in the "Real name" field.<br/>
<br/>
If you get disconnected and wish to reconnect, the "First name" you use<br/>
must be the same "First name" that you previously used with your "Real name".<br/>
(In other words, the "First name" must match the "Real name" when initially created.)<br/>
<br/><br/>
<form method="post">
{% csrf_token %}
<div class="col-sm-4">
{{ form | crispy }}
</div>
<br>
<input type="submit" value="login">
<input type="hidden" name="next" value="{{ next }}">
</form>
<br>
<br>
{% endblock %}