├── .gitignore ├── .pre-commit-config.yaml ├── __init__.py ├── chat ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_chatmessage_room.py │ └── __init__.py ├── models.py ├── tests.py ├── utils.py └── views.py ├── docker-compose.yml ├── manage.py ├── project ├── __init__.py ├── asgi.py ├── settings.py ├── templates │ └── lobby.html ├── urls.py └── wsgi.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # ---> Python 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/#use-with-ide 111 | .pdm.toml 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | .idea/ 162 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.11 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.4.0 6 | hooks: 7 | - id: check-ast 8 | - id: check-merge-conflict 9 | - id: check-case-conflict 10 | - id: detect-private-key 11 | - id: check-added-large-files 12 | - id: check-json 13 | - id: check-symlinks 14 | - id: check-toml 15 | - id: end-of-file-fixer 16 | - id: trailing-whitespace 17 | - repo: https://github.com/charliermarsh/ruff-pre-commit 18 | rev: 'v0.0.247' 19 | hooks: 20 | - id: ruff 21 | args: 22 | - --fix 23 | - repo: https://github.com/asottile/reorder_python_imports 24 | rev: v3.9.0 25 | hooks: 26 | - id: reorder-python-imports 27 | args: 28 | - --py310-plus 29 | - --application-directories=.:src 30 | exclude: migrations/ 31 | - repo: https://github.com/asottile/pyupgrade 32 | rev: v3.3.1 33 | hooks: 34 | - id: pyupgrade 35 | args: 36 | - --py310-plus 37 | exclude: migrations/ 38 | - repo: https://github.com/adamchainz/django-upgrade 39 | rev: 1.12.0 40 | hooks: 41 | - id: django-upgrade 42 | args: 43 | - --target-version=3.2 44 | - repo: https://github.com/asottile/yesqa 45 | rev: v1.4.0 46 | hooks: 47 | - id: yesqa 48 | - repo: https://github.com/asottile/add-trailing-comma 49 | rev: v2.4.0 50 | hooks: 51 | - id: add-trailing-comma 52 | args: 53 | - --py36-plus 54 | - repo: https://github.com/psf/black 55 | rev: 23.1.0 56 | hooks: 57 | - id: black 58 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valberg/django-sse/7b04c404aa75d4c4e3a70d8b0847905694a95c93/__init__.py -------------------------------------------------------------------------------- /chat/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valberg/django-sse/7b04c404aa75d4c4e3a70d8b0847905694a95c93/chat/__init__.py -------------------------------------------------------------------------------- /chat/admin.py: -------------------------------------------------------------------------------- 1 | # Register your models here. 2 | -------------------------------------------------------------------------------- /chat/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ChatConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "chat" 7 | -------------------------------------------------------------------------------- /chat/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.1 on 2023-05-16 06:45 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | initial = True 8 | 9 | dependencies = [] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="ChatMessage", 14 | fields=[ 15 | ( 16 | "id", 17 | models.BigAutoField( 18 | auto_created=True, 19 | primary_key=True, 20 | serialize=False, 21 | verbose_name="ID", 22 | ), 23 | ), 24 | ("user", models.CharField(max_length=255)), 25 | ("text", models.CharField(max_length=255)), 26 | ], 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /chat/migrations/0002_chatmessage_room.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.1 on 2023-05-17 19:11 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("chat", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="chatmessage", 14 | name="room", 15 | field=models.CharField(default="lobby", max_length=255), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /chat/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valberg/django-sse/7b04c404aa75d4c4e3a70d8b0847905694a95c93/chat/migrations/__init__.py -------------------------------------------------------------------------------- /chat/models.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.db import models 4 | 5 | 6 | class ChatMessage(models.Model): 7 | user = models.CharField(max_length=255) 8 | text = models.CharField(max_length=255) 9 | room = models.CharField(max_length=255, default="lobby") 10 | 11 | def as_json(self): 12 | return json.dumps( 13 | { 14 | "id": self.id, 15 | "user": self.user, 16 | "text": self.text, 17 | "room": self.room, 18 | }, 19 | ) 20 | -------------------------------------------------------------------------------- /chat/tests.py: -------------------------------------------------------------------------------- 1 | # Create your tests here. 2 | -------------------------------------------------------------------------------- /chat/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.db import connection 4 | 5 | 6 | def notify(*, channel: str, event: str, event_id: int, data: str) -> None: 7 | payload = json.dumps( 8 | { 9 | "event": event, 10 | "event_id": event_id, 11 | "data": data, 12 | }, 13 | ) 14 | with connection.cursor() as cursor: 15 | cursor.execute( 16 | f"NOTIFY {channel}, '{payload}'", 17 | ) 18 | 19 | 20 | def sse_message(*, event: str, event_id: int, data: str) -> str: 21 | return f"id: {event_id}\n" f"event: {event}\n" f"data: {data}\n\n" 22 | -------------------------------------------------------------------------------- /chat/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections.abc import AsyncGenerator 3 | 4 | import psycopg 5 | from django.db import connection 6 | from django.http import HttpRequest 7 | from django.http import HttpResponse 8 | from django.http import StreamingHttpResponse 9 | from django.views.decorators.csrf import csrf_exempt 10 | from django.views.decorators.http import require_POST 11 | 12 | from chat.models import ChatMessage 13 | from chat.utils import notify 14 | from chat.utils import sse_message 15 | 16 | 17 | async def stream_messages(last_id: int | None = None) -> AsyncGenerator[str, None]: 18 | connection_params = connection.get_connection_params() 19 | 20 | # Remove the cursor_factory parameter since I can't get 21 | # the default from Django 4.2.1 to work. 22 | # Django 4.2 didn't have the parameter and that worked. 23 | connection_params.pop("cursor_factory") 24 | 25 | aconnection = await psycopg.AsyncConnection.connect( 26 | **connection_params, 27 | autocommit=True, 28 | ) 29 | channel_name = "lobby" 30 | 31 | # Uncomment the following to generate random message to 32 | # test that we are streaming messages that are created 33 | # while the client is disconnected. 34 | 35 | # await ChatMessage.objects.acreate( 36 | # user="system", 37 | # text="randomly generated", room=channel_name) 38 | 39 | if last_id: 40 | messages = ChatMessage.objects.filter(id__gt=last_id) 41 | async for message in messages: 42 | yield sse_message( 43 | event="message_created", 44 | event_id=message.id, 45 | data=message.as_json(), 46 | ) 47 | 48 | async with aconnection.cursor() as acursor: 49 | await acursor.execute(f"LISTEN {channel_name}") 50 | gen = aconnection.notifies() 51 | async for notify_message in gen: 52 | payload = json.loads(notify_message.payload) 53 | event = payload.get("event") 54 | event_id = payload.get("event_id") 55 | data = payload.get("data") 56 | yield sse_message( 57 | event=event, 58 | event_id=event_id, 59 | data=data, 60 | ) 61 | 62 | 63 | async def stream_messages_view( 64 | request: HttpRequest, 65 | ) -> StreamingHttpResponse: 66 | last_id = request.headers.get("Last-Event-ID") 67 | return StreamingHttpResponse( 68 | streaming_content=stream_messages(last_id=last_id), 69 | content_type="text/event-stream", 70 | ) 71 | 72 | 73 | @csrf_exempt 74 | @require_POST 75 | def post_message_view(request: HttpRequest) -> HttpResponse: 76 | message = request.POST.get("message") 77 | user = request.POST.get("user") 78 | message = ChatMessage.objects.create(user=user, text=message) 79 | notify( 80 | channel="lobby", 81 | event="message_created", 82 | event_id=message.id, 83 | data=message.as_json(), 84 | ) 85 | return HttpResponse("OK") 86 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | postgres: 5 | image: postgres:15 6 | environment: 7 | POSTGRES_PASSWORD: 'postgres' 8 | ports: 9 | - "5432:5432" 10 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?", 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valberg/django-sse/7b04c404aa75d4c4e3a70d8b0847905694a95c93/project/__init__.py -------------------------------------------------------------------------------- /project/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for project project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ 8 | """ 9 | import os 10 | 11 | from django.core.asgi import get_asgi_application 12 | 13 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") 14 | 15 | application = get_asgi_application() 16 | -------------------------------------------------------------------------------- /project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for project project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.2.1. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.2/ref/settings/ 11 | """ 12 | from pathlib import Path 13 | 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "django-insecure-+t(25z=ua4y7j(wd4$y3p*%k73w6q)v%cmd-(w(#(#ki8+@(q5" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = ["*"] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "daphne", 35 | "django.contrib.admin", 36 | "django.contrib.auth", 37 | "django.contrib.contenttypes", 38 | "django.contrib.sessions", 39 | "django.contrib.messages", 40 | "django.contrib.staticfiles", 41 | "chat", 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | "django.middleware.security.SecurityMiddleware", 46 | "django.contrib.sessions.middleware.SessionMiddleware", 47 | "django.middleware.common.CommonMiddleware", 48 | "django.middleware.csrf.CsrfViewMiddleware", 49 | "django.contrib.auth.middleware.AuthenticationMiddleware", 50 | "django.contrib.messages.middleware.MessageMiddleware", 51 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 52 | ] 53 | 54 | ROOT_URLCONF = "project.urls" 55 | 56 | TEMPLATES = [ 57 | { 58 | "BACKEND": "django.template.backends.django.DjangoTemplates", 59 | "DIRS": [BASE_DIR / "project" / "templates"], 60 | "APP_DIRS": True, 61 | "OPTIONS": { 62 | "context_processors": [ 63 | "django.template.context_processors.debug", 64 | "django.template.context_processors.request", 65 | "django.contrib.auth.context_processors.auth", 66 | "django.contrib.messages.context_processors.messages", 67 | ], 68 | }, 69 | }, 70 | ] 71 | 72 | WSGI_APPLICATION = "project.wsgi.application" 73 | ASGI_APPLICATION = "project.asgi.application" 74 | 75 | 76 | # Database 77 | # https://docs.djangoproject.com/en/4.2/ref/settings/#databases 78 | 79 | DATABASES = { 80 | "default": { 81 | "ENGINE": "django.db.backends.postgresql", 82 | "NAME": "postgres", 83 | "USER": "postgres", 84 | "PASSWORD": "postgres", 85 | "HOST": "localhost", 86 | "PORT": "5432", 87 | }, 88 | } 89 | 90 | 91 | # Internationalization 92 | # https://docs.djangoproject.com/en/4.2/topics/i18n/ 93 | 94 | LANGUAGE_CODE = "en-us" 95 | 96 | TIME_ZONE = "UTC" 97 | 98 | USE_I18N = True 99 | 100 | USE_TZ = True 101 | 102 | 103 | # Static files (CSS, JavaScript, Images) 104 | # https://docs.djangoproject.com/en/4.2/howto/static-files/ 105 | 106 | STATIC_URL = "static/" 107 | 108 | # Default primary key field type 109 | # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field 110 | 111 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 112 | -------------------------------------------------------------------------------- /project/templates/lobby.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |