├── config ├── settings │ ├── __init__.py │ ├── test.py │ └── local.py ├── __init__.py ├── routing.py ├── api_router.py ├── celery_app.py ├── asgi.py ├── wsgi.py └── urls.py ├── .gitattributes ├── conversa_dj ├── chats │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── tests.py │ ├── views.py │ ├── admin.py │ ├── apps.py │ ├── api │ │ ├── paginaters.py │ │ ├── views.py │ │ └── serializers.py │ ├── models.py │ ├── middleware.py │ └── consumers.py ├── static │ ├── fonts │ │ └── .gitkeep │ ├── js │ │ └── project.js │ ├── images │ │ └── favicons │ │ │ └── favicon.ico │ └── css │ │ └── project.css ├── users │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_models.py │ │ ├── test_tasks.py │ │ ├── test_swagger.py │ │ ├── test_urls.py │ │ ├── test_drf_urls.py │ │ ├── test_drf_views.py │ │ ├── factories.py │ │ ├── test_forms.py │ │ ├── test_admin.py │ │ └── test_views.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── context_processors.py │ ├── tasks.py │ ├── api │ │ ├── serializers.py │ │ └── views.py │ ├── apps.py │ ├── urls.py │ ├── adapters.py │ ├── models.py │ ├── admin.py │ ├── forms.py │ └── views.py ├── utils │ ├── __init__.py │ └── storages.py ├── templates │ ├── pages │ │ ├── about.html │ │ └── home.html │ ├── 403.html │ ├── 404.html │ ├── account │ │ ├── base.html │ │ ├── account_inactive.html │ │ ├── password_reset_from_key_done.html │ │ ├── signup_closed.html │ │ ├── verification_sent.html │ │ ├── password_reset_done.html │ │ ├── password_set.html │ │ ├── password_change.html │ │ ├── logout.html │ │ ├── signup.html │ │ ├── verified_email_required.html │ │ ├── password_reset.html │ │ ├── email_confirm.html │ │ ├── password_reset_from_key.html │ │ ├── login.html │ │ └── email.html │ ├── 500.html │ ├── users │ │ ├── user_form.html │ │ └── user_detail.html │ └── base.html ├── __init__.py ├── contrib │ ├── __init__.py │ └── sites │ │ ├── __init__.py │ │ └── migrations │ │ ├── __init__.py │ │ ├── 0004_alter_options_ordering_domain.py │ │ ├── 0002_alter_domain_unique.py │ │ ├── 0001_initial.py │ │ └── 0003_set_site_domain_and_name.py └── conftest.py ├── CONTRIBUTORS.txt ├── frontend ├── src │ ├── react-app-env.d.ts │ ├── index.css │ ├── models │ │ ├── User.ts │ │ ├── Conversation.ts │ │ └── Message.ts │ ├── setupTests.ts │ ├── App.test.tsx │ ├── components │ │ ├── ProtectedRoute.tsx │ │ ├── Conversations.tsx │ │ ├── Message.tsx │ │ ├── ChatLoader.tsx │ │ ├── ActiveConversations.tsx │ │ ├── Login.tsx │ │ ├── Navbar.tsx │ │ └── Chat.tsx │ ├── services │ │ ├── AuthHeader.ts │ │ └── AuthService.ts │ ├── reportWebVitals.ts │ ├── index.tsx │ ├── App.tsx │ └── contexts │ │ ├── AuthContext.tsx │ │ └── NotificationContext.tsx ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── index.html ├── postcss.config.js ├── tailwind.config.js ├── .gitignore ├── tsconfig.json ├── package.json └── README.md ├── docs ├── __init__.py ├── pycharm │ ├── images │ │ ├── 1.png │ │ ├── 2.png │ │ ├── 3.png │ │ ├── 4.png │ │ ├── 7.png │ │ ├── 8.png │ │ ├── f1.png │ │ ├── f2.png │ │ ├── f3.png │ │ ├── f4.png │ │ ├── issue1.png │ │ └── issue2.png │ └── configuration.rst ├── users.rst ├── index.rst ├── Makefile ├── make.bat ├── howto.rst └── conf.py ├── pytest.ini ├── compose ├── local │ ├── docs │ │ ├── start │ │ └── Dockerfile │ └── django │ │ ├── celery │ │ ├── beat │ │ │ └── start │ │ ├── worker │ │ │ └── start │ │ └── flower │ │ │ └── start │ │ ├── start │ │ └── Dockerfile └── production │ ├── postgres │ ├── maintenance │ │ ├── _sourced │ │ │ ├── constants.sh │ │ │ ├── yes_no.sh │ │ │ ├── countdown.sh │ │ │ └── messages.sh │ │ ├── backups │ │ ├── backup │ │ └── restore │ └── Dockerfile │ ├── django │ ├── celery │ │ ├── beat │ │ │ └── start │ │ ├── worker │ │ │ └── start │ │ └── flower │ │ │ └── start │ ├── start │ ├── entrypoint │ └── Dockerfile │ ├── traefik │ ├── Dockerfile │ └── traefik.yml │ └── aws │ ├── Dockerfile │ └── maintenance │ ├── download │ └── upload ├── locale └── README.rst ├── .dockerignore ├── .idea ├── misc.xml ├── vcs.xml ├── modules.xml ├── webResources.xml ├── runConfigurations │ ├── docker_compose_up_docs.xml │ ├── docker_compose_up_django.xml │ ├── merge_production_dotenvs_in_dotenv.xml │ ├── pytest___.xml │ ├── pytest__users.xml │ ├── migrate.xml │ ├── runserver.xml │ └── runserver_plus.xml └── conversa_dj.iml ├── .readthedocs.yml ├── .envs └── .local │ ├── .postgres │ └── .django ├── .vscode └── settings.json ├── .pylintrc ├── .editorconfig ├── requirements ├── production.txt ├── base.txt └── local.txt ├── .pre-commit-config.yaml ├── LICENSE ├── setup.cfg ├── manage.py ├── .github ├── workflows │ └── ci.yml └── dependabot.yml ├── production.yml ├── merge_production_dotenvs_in_dotenv.py ├── local.yml ├── README.md └── .gitignore /config/settings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /conversa_dj/chats/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conversa_dj/static/fonts/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conversa_dj/users/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conversa_dj/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CONTRIBUTORS.txt: -------------------------------------------------------------------------------- 1 | Matthew Freire 2 | -------------------------------------------------------------------------------- /conversa_dj/users/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conversa_dj/chats/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conversa_dj/users/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conversa_dj/templates/pages/about.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | -------------------------------------------------------------------------------- /conversa_dj/templates/pages/home.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | -------------------------------------------------------------------------------- /frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /conversa_dj/static/js/project.js: -------------------------------------------------------------------------------- 1 | /* Project specific Javascript goes here. */ 2 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /conversa_dj/chats/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /conversa_dj/chats/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /docs/__init__.py: -------------------------------------------------------------------------------- 1 | # Included so that Django's startproject comment runs against the docs directory 2 | -------------------------------------------------------------------------------- /docs/pycharm/images/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justdjango/django_chat/HEAD/docs/pycharm/images/1.png -------------------------------------------------------------------------------- /docs/pycharm/images/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justdjango/django_chat/HEAD/docs/pycharm/images/2.png -------------------------------------------------------------------------------- /docs/pycharm/images/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justdjango/django_chat/HEAD/docs/pycharm/images/3.png -------------------------------------------------------------------------------- /docs/pycharm/images/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justdjango/django_chat/HEAD/docs/pycharm/images/4.png -------------------------------------------------------------------------------- /docs/pycharm/images/7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justdjango/django_chat/HEAD/docs/pycharm/images/7.png -------------------------------------------------------------------------------- /docs/pycharm/images/8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justdjango/django_chat/HEAD/docs/pycharm/images/8.png -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /docs/pycharm/images/f1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justdjango/django_chat/HEAD/docs/pycharm/images/f1.png -------------------------------------------------------------------------------- /docs/pycharm/images/f2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justdjango/django_chat/HEAD/docs/pycharm/images/f2.png -------------------------------------------------------------------------------- /docs/pycharm/images/f3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justdjango/django_chat/HEAD/docs/pycharm/images/f3.png -------------------------------------------------------------------------------- /docs/pycharm/images/f4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justdjango/django_chat/HEAD/docs/pycharm/images/f4.png -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justdjango/django_chat/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justdjango/django_chat/HEAD/frontend/public/logo192.png -------------------------------------------------------------------------------- /frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justdjango/django_chat/HEAD/frontend/public/logo512.png -------------------------------------------------------------------------------- /frontend/src/models/User.ts: -------------------------------------------------------------------------------- 1 | export interface UserModel { 2 | username: string; 3 | token: string 4 | } 5 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --ds=config.settings.test --reuse-db 3 | python_files = tests.py test_*.py 4 | -------------------------------------------------------------------------------- /docs/pycharm/images/issue1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justdjango/django_chat/HEAD/docs/pycharm/images/issue1.png -------------------------------------------------------------------------------- /docs/pycharm/images/issue2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justdjango/django_chat/HEAD/docs/pycharm/images/issue2.png -------------------------------------------------------------------------------- /compose/local/docs/start: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | make livehtml 8 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /conversa_dj/static/images/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justdjango/django_chat/HEAD/conversa_dj/static/images/favicons/favicon.ico -------------------------------------------------------------------------------- /compose/production/postgres/maintenance/_sourced/constants.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | BACKUP_DIR_PATH='/backups' 5 | BACKUP_FILE_PREFIX='backup' 6 | -------------------------------------------------------------------------------- /locale/README.rst: -------------------------------------------------------------------------------- 1 | Translations 2 | ============ 3 | 4 | Translations will be placed in this folder when running:: 5 | 6 | python manage.py makemessages 7 | -------------------------------------------------------------------------------- /compose/local/django/celery/beat/start: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | 6 | 7 | rm -f './celerybeat.pid' 8 | celery -A config.celery_app beat -l INFO 9 | -------------------------------------------------------------------------------- /compose/production/django/celery/beat/start: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | 8 | exec celery -A config.celery_app beat -l INFO 9 | -------------------------------------------------------------------------------- /compose/local/django/celery/worker/start: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | 6 | 7 | watchgod celery.__main__.main --args -A config.celery_app worker -l INFO 8 | -------------------------------------------------------------------------------- /compose/production/django/celery/worker/start: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | 8 | exec celery -A config.celery_app worker -l INFO 9 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .editorconfig 2 | .gitattributes 3 | .github 4 | .gitignore 5 | .gitlab-ci.yml 6 | .idea 7 | .pre-commit-config.yaml 8 | .readthedocs.yml 9 | .travis.yml 10 | venv 11 | -------------------------------------------------------------------------------- /conversa_dj/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.0" 2 | __version_info__ = tuple( 3 | int(num) if num.isdigit() else num 4 | for num in __version__.replace("-", ".", 1).split(".") 5 | ) 6 | -------------------------------------------------------------------------------- /conversa_dj/chats/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Conversation, Message 3 | 4 | 5 | admin.site.register(Conversation) 6 | admin.site.register(Message) 7 | -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["./src/**/*.{js,jsx,ts,tsx}"], 3 | theme: { 4 | extend: {}, 5 | }, 6 | plugins: [require("@tailwindcss/forms")], 7 | }; 8 | -------------------------------------------------------------------------------- /compose/local/django/start: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | 8 | python manage.py migrate 9 | python manage.py runserver_plus 0.0.0.0:8000 10 | -------------------------------------------------------------------------------- /conversa_dj/chats/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ChatsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "conversa_dj.chats" 7 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /config/__init__.py: -------------------------------------------------------------------------------- 1 | # This will make sure the app is always imported when 2 | # Django starts so that shared_task will use this app. 3 | from .celery_app import app as celery_app 4 | 5 | __all__ = ("celery_app",) 6 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | configuration: docs/conf.py 5 | 6 | build: 7 | image: testing 8 | 9 | python: 10 | version: 3.9 11 | install: 12 | - requirements: requirements/local.txt 13 | -------------------------------------------------------------------------------- /conversa_dj/contrib/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | To understand why this file is here, please read: 3 | 4 | http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django 5 | """ 6 | -------------------------------------------------------------------------------- /conversa_dj/contrib/sites/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | To understand why this file is here, please read: 3 | 4 | http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django 5 | """ 6 | -------------------------------------------------------------------------------- /compose/production/traefik/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM traefik:v2.2.11 2 | RUN mkdir -p /etc/traefik/acme \ 3 | && touch /etc/traefik/acme/acme.json \ 4 | && chmod 600 /etc/traefik/acme/acme.json 5 | COPY ./compose/production/traefik/traefik.yml /etc/traefik 6 | -------------------------------------------------------------------------------- /conversa_dj/chats/api/paginaters.py: -------------------------------------------------------------------------------- 1 | from rest_framework.pagination import PageNumberPagination 2 | 3 | 4 | class MessagePagination(PageNumberPagination): 5 | page_size = 10 6 | page_size_query_param = "page_size" 7 | max_page_size = 100 8 | -------------------------------------------------------------------------------- /conversa_dj/contrib/sites/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | To understand why this file is here, please read: 3 | 4 | http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django 5 | """ 6 | -------------------------------------------------------------------------------- /compose/production/django/start: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | 8 | python /app/manage.py collectstatic --noinput 9 | 10 | /usr/local/bin/gunicorn config.wsgi --bind 0.0.0.0:5000 --chdir=/app 11 | -------------------------------------------------------------------------------- /conversa_dj/users/tests/test_models.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from conversa_dj.users.models import User 4 | 5 | pytestmark = pytest.mark.django_db 6 | 7 | 8 | def test_user_get_absolute_url(user: User): 9 | assert user.get_absolute_url() == f"/users/{user.username}/" 10 | -------------------------------------------------------------------------------- /frontend/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /compose/local/django/celery/flower/start: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | 6 | 7 | celery \ 8 | -A config.celery_app \ 9 | -b "${CELERY_BROKER_URL}" \ 10 | flower \ 11 | --basic_auth="${CELERY_FLOWER_USER}:${CELERY_FLOWER_PASSWORD}" 12 | -------------------------------------------------------------------------------- /compose/production/postgres/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:14.1 2 | 3 | COPY ./compose/production/postgres/maintenance /usr/local/bin/maintenance 4 | RUN chmod +x /usr/local/bin/maintenance/* 5 | RUN mv /usr/local/bin/maintenance/* /usr/local/bin \ 6 | && rmdir /usr/local/bin/maintenance 7 | -------------------------------------------------------------------------------- /frontend/src/models/Conversation.ts: -------------------------------------------------------------------------------- 1 | import { MessageModel } from './Message'; 2 | import { UserModel } from './User'; 3 | 4 | export interface ConversationModel { 5 | id: string; 6 | name: string; 7 | last_message: MessageModel | null; 8 | other_user: UserModel; 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/models/Message.ts: -------------------------------------------------------------------------------- 1 | import { UserModel } from './User'; 2 | 3 | export interface MessageModel { 4 | id: string; 5 | room: string; 6 | from_user: UserModel; 7 | to_user: UserModel; 8 | content: string; 9 | timestamp: string; 10 | read: boolean; 11 | } 12 | -------------------------------------------------------------------------------- /conversa_dj/users/context_processors.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def allauth_settings(request): 5 | """Expose some settings from django-allauth in templates.""" 6 | return { 7 | "ACCOUNT_ALLOW_REGISTRATION": settings.ACCOUNT_ALLOW_REGISTRATION, 8 | } 9 | -------------------------------------------------------------------------------- /compose/production/django/celery/flower/start: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | 6 | 7 | exec celery \ 8 | -A config.celery_app \ 9 | -b "${CELERY_BROKER_URL}" \ 10 | flower \ 11 | --basic_auth="${CELERY_FLOWER_USER}:${CELERY_FLOWER_PASSWORD}" 12 | -------------------------------------------------------------------------------- /config/routing.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from conversa_dj.chats.consumers import ChatConsumer, NotificationConsumer 4 | 5 | websocket_urlpatterns = [ 6 | path("chats//", ChatConsumer.as_asgi()), 7 | path("notifications/", NotificationConsumer.as_asgi()), 8 | ] 9 | -------------------------------------------------------------------------------- /conversa_dj/users/tasks.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | 3 | from config import celery_app 4 | 5 | User = get_user_model() 6 | 7 | 8 | @celery_app.task() 9 | def get_users_count(): 10 | """A pointless Celery task to demonstrate usage.""" 11 | return User.objects.count() 12 | -------------------------------------------------------------------------------- /conversa_dj/templates/403.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Forbidden (403){% endblock %} 4 | 5 | {% block content %} 6 |

Forbidden (403)

7 | 8 |

{% if exception %}{{ exception }}{% else %}You're not allowed to access this page.{% endif %}

9 | {% endblock content %} 10 | -------------------------------------------------------------------------------- /conversa_dj/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Page not found{% endblock %} 4 | 5 | {% block content %} 6 |

Page not found

7 | 8 |

{% if exception %}{{ exception }}{% else %}This is not the page you were looking for.{% endif %}

9 | {% endblock content %} 10 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /conversa_dj/static/css/project.css: -------------------------------------------------------------------------------- 1 | /* These styles are generated from project.scss. */ 2 | 3 | .alert-debug { 4 | color: black; 5 | background-color: white; 6 | border-color: #d6e9c6; 7 | } 8 | 9 | .alert-error { 10 | color: #b94a48; 11 | background-color: #f2dede; 12 | border-color: #eed3d7; 13 | } 14 | -------------------------------------------------------------------------------- /conversa_dj/users/api/serializers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from rest_framework import serializers 3 | 4 | User = get_user_model() 5 | 6 | 7 | class UserSerializer(serializers.ModelSerializer): 8 | class Meta: 9 | model = User 10 | fields = ["username", "name"] 11 | -------------------------------------------------------------------------------- /.envs/.local/.postgres: -------------------------------------------------------------------------------- 1 | # PostgreSQL 2 | # ------------------------------------------------------------------------------ 3 | POSTGRES_HOST=postgres 4 | POSTGRES_PORT=5432 5 | POSTGRES_DB=conversa_dj 6 | POSTGRES_USER=pYTIQDlNRYJBrDkZghCpQpWhxooMONiz 7 | POSTGRES_PASSWORD=1wBhgOapb83ZlFR7OVYkVyFL2Gd0BNOUhkXCSbpDu2eI45KeePJZjQLnOjHgkjF2 8 | -------------------------------------------------------------------------------- /conversa_dj/utils/storages.py: -------------------------------------------------------------------------------- 1 | from storages.backends.s3boto3 import S3Boto3Storage 2 | 3 | 4 | class StaticRootS3Boto3Storage(S3Boto3Storage): 5 | location = "static" 6 | default_acl = "public-read" 7 | 8 | 9 | class MediaRootS3Boto3Storage(S3Boto3Storage): 10 | location = "media" 11 | file_overwrite = False 12 | -------------------------------------------------------------------------------- /conversa_dj/templates/account/base.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}{% block head_title %}{% endblock head_title %}{% endblock title %} 3 | 4 | {% block content %} 5 |
6 |
7 | {% block inner %}{% endblock %} 8 |
9 |
10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /frontend/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /conversa_dj/templates/account/account_inactive.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% translate "Account Inactive" %}{% endblock %} 6 | 7 | {% block inner %} 8 |

{% translate "Account Inactive" %}

9 | 10 |

{% translate "This account is inactive." %}

11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /conversa_dj/templates/account/password_reset_from_key_done.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% block head_title %}{% translate "Change Password" %}{% endblock %} 5 | 6 | {% block inner %} 7 |

{% translate "Change Password" %}

8 |

{% translate 'Your password is now changed.' %}

9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/.git": true, 4 | "**/.svn": true, 5 | "**/.hg": true, 6 | "**/CVS": true, 7 | "**/.DS_Store": true, 8 | "**/Thumbs.db": true, 9 | "venv": true, 10 | "frontend/node_modules": true 11 | }, 12 | "python.formatting.provider": "black" 13 | } -------------------------------------------------------------------------------- /conversa_dj/templates/account/signup_closed.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% translate "Sign Up Closed" %}{% endblock %} 6 | 7 | {% block inner %} 8 |

{% translate "Sign Up Closed" %}

9 | 10 |

{% translate "We are sorry, but the sign up is currently closed." %}

11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | load-plugins=pylint_django, pylint_celery 3 | django-settings-module=config.settings.base 4 | [FORMAT] 5 | max-line-length=120 6 | 7 | [MESSAGES CONTROL] 8 | disable=missing-docstring,invalid-name 9 | 10 | [DESIGN] 11 | max-parents=13 12 | 13 | [TYPECHECK] 14 | generated-members=REQUEST,acl_users,aq_parent,"[a-zA-Z]+_set{1,2}",save,delete 15 | -------------------------------------------------------------------------------- /conversa_dj/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from conversa_dj.users.models import User 4 | from conversa_dj.users.tests.factories import UserFactory 5 | 6 | 7 | @pytest.fixture(autouse=True) 8 | def media_storage(settings, tmpdir): 9 | settings.MEDIA_ROOT = tmpdir.strpath 10 | 11 | 12 | @pytest.fixture 13 | def user() -> User: 14 | return UserFactory() 15 | -------------------------------------------------------------------------------- /conversa_dj/templates/500.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Server Error{% endblock %} 4 | 5 | {% block content %} 6 |

Ooops!!! 500

7 | 8 |

Looks like something went wrong!

9 | 10 |

We track these errors automatically, but if the problem persists feel free to contact us. In the meantime, try refreshing.

11 | {% endblock content %} 12 | -------------------------------------------------------------------------------- /conversa_dj/users/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class UsersConfig(AppConfig): 6 | name = "conversa_dj.users" 7 | verbose_name = _("Users") 8 | 9 | def ready(self): 10 | try: 11 | import conversa_dj.users.signals # noqa F401 12 | except ImportError: 13 | pass 14 | -------------------------------------------------------------------------------- /compose/production/aws/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM garland/aws-cli-docker:1.15.47 2 | 3 | COPY ./compose/production/aws/maintenance /usr/local/bin/maintenance 4 | COPY ./compose/production/postgres/maintenance/_sourced /usr/local/bin/maintenance/_sourced 5 | 6 | RUN chmod +x /usr/local/bin/maintenance/* 7 | 8 | RUN mv /usr/local/bin/maintenance/* /usr/local/bin \ 9 | && rmdir /usr/local/bin/maintenance 10 | -------------------------------------------------------------------------------- /compose/production/postgres/maintenance/_sourced/yes_no.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | yes_no() { 5 | declare desc="Prompt for confirmation. \$\"\{1\}\": confirmation message." 6 | local arg1="${1}" 7 | 8 | local response= 9 | read -r -p "${arg1} (y/[n])? " response 10 | if [[ "${response}" =~ ^[Yy]$ ]] 11 | then 12 | exit 0 13 | else 14 | exit 1 15 | fi 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/components/ProtectedRoute.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { Navigate } from "react-router-dom"; 3 | import { AuthContext } from "../contexts/AuthContext"; 4 | 5 | export function ProtectedRoute({ children }: { children: any }) { 6 | const { user } = useContext(AuthContext); 7 | if (!user) { 8 | return ; 9 | } 10 | 11 | return children; 12 | } 13 | -------------------------------------------------------------------------------- /compose/production/postgres/maintenance/_sourced/countdown.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | countdown() { 5 | declare desc="A simple countdown. Source: https://superuser.com/a/611582" 6 | local seconds="${1}" 7 | local d=$(($(date +%s) + "${seconds}")) 8 | while [ "$d" -ge `date +%s` ]; do 9 | echo -ne "$(date -u --date @$(($d - `date +%s`)) +%H:%M:%S)\r"; 10 | sleep 0.1 11 | done 12 | } 13 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /frontend/src/services/AuthHeader.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestHeaders } from "axios"; 2 | 3 | export default function authHeader(): AxiosRequestHeaders { 4 | const localstorageUser = localStorage.getItem("user"); 5 | if (!localstorageUser) { 6 | return {}; 7 | } 8 | const user = JSON.parse(localstorageUser); 9 | if (user && user.token) { 10 | return { Authorization: `Token ${user.token}` }; 11 | } 12 | return {}; 13 | } 14 | -------------------------------------------------------------------------------- /conversa_dj/users/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from conversa_dj.users.views import ( 4 | user_detail_view, 5 | user_redirect_view, 6 | user_update_view, 7 | ) 8 | 9 | app_name = "users" 10 | urlpatterns = [ 11 | path("~redirect/", view=user_redirect_view, name="redirect"), 12 | path("~update/", view=user_update_view, name="update"), 13 | path("/", view=user_detail_view, name="detail"), 14 | ] 15 | -------------------------------------------------------------------------------- /.idea/webResources.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /docs/users.rst: -------------------------------------------------------------------------------- 1 | .. _users: 2 | 3 | Users 4 | ====================================================================== 5 | 6 | Starting a new project, it’s highly recommended to set up a custom user model, 7 | even if the default User model is sufficient for you. 8 | 9 | This model behaves identically to the default user model, 10 | but you’ll be able to customize it in the future if the need arises. 11 | 12 | .. automodule:: conversa_dj.users.models 13 | :members: 14 | :noindex: 15 | 16 | -------------------------------------------------------------------------------- /frontend/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{py,rst,ini}] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.{html,css,scss,json,yml,xml}] 16 | indent_style = space 17 | indent_size = 2 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | 22 | [Makefile] 23 | indent_style = tab 24 | 25 | [nginx.conf] 26 | indent_style = space 27 | indent_size = 2 28 | -------------------------------------------------------------------------------- /compose/production/postgres/maintenance/backups: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | ### View backups. 5 | ### 6 | ### Usage: 7 | ### $ docker-compose -f .yml (exec |run --rm) postgres backups 8 | 9 | 10 | set -o errexit 11 | set -o pipefail 12 | set -o nounset 13 | 14 | 15 | working_dir="$(dirname ${0})" 16 | source "${working_dir}/_sourced/constants.sh" 17 | source "${working_dir}/_sourced/messages.sh" 18 | 19 | 20 | message_welcome "These are the backups you have got:" 21 | 22 | ls -lht "${BACKUP_DIR_PATH}" 23 | -------------------------------------------------------------------------------- /conversa_dj/templates/account/verification_sent.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% translate "Verify Your E-mail Address" %}{% endblock %} 6 | 7 | {% block inner %} 8 |

{% translate "Verify Your E-mail Address" %}

9 | 10 |

{% blocktranslate %}We have sent an e-mail to you for verification. Follow the link provided to finalize the signup process. Please contact us if you do not receive it within a few minutes.{% endblocktranslate %}

11 | 12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /conversa_dj/templates/users/user_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load crispy_forms_tags %} 3 | 4 | {% block title %}{{ user.username }}{% endblock %} 5 | 6 | {% block content %} 7 |

{{ user.username }}

8 |
9 | {% csrf_token %} 10 | {{ form|crispy }} 11 |
12 |
13 | 14 |
15 |
16 |
17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /config/api_router.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from rest_framework.routers import DefaultRouter, SimpleRouter 3 | 4 | from conversa_dj.chats.api.views import ConversationViewSet, MessageViewSet 5 | from conversa_dj.users.api.views import UserViewSet 6 | 7 | if settings.DEBUG: 8 | router = DefaultRouter() 9 | else: 10 | router = SimpleRouter() 11 | 12 | router.register("conversations", ConversationViewSet) 13 | router.register("users", UserViewSet) 14 | router.register("messages", MessageViewSet) 15 | 16 | 17 | app_name = "api" 18 | urlpatterns = router.urls 19 | -------------------------------------------------------------------------------- /conversa_dj/templates/account/password_reset_done.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load account %} 5 | 6 | {% block head_title %}{% translate "Password Reset" %}{% endblock %} 7 | 8 | {% block inner %} 9 |

{% translate "Password Reset" %}

10 | 11 | {% if user.is_authenticated %} 12 | {% include "account/snippets/already_logged_in.html" %} 13 | {% endif %} 14 | 15 |

{% blocktranslate %}We have sent you an e-mail. Please contact us if you do not receive it within a few minutes.{% endblocktranslate %}

16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /conversa_dj/templates/account/password_set.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load crispy_forms_tags %} 5 | 6 | {% block head_title %}{% translate "Set Password" %}{% endblock %} 7 | 8 | {% block inner %} 9 |

{% translate "Set Password" %}

10 | 11 |
12 | {% csrf_token %} 13 | {{ form|crispy }} 14 | 15 |
16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /conversa_dj/users/tests/test_tasks.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from celery.result import EagerResult 3 | 4 | from conversa_dj.users.tasks import get_users_count 5 | from conversa_dj.users.tests.factories import UserFactory 6 | 7 | pytestmark = pytest.mark.django_db 8 | 9 | 10 | def test_user_count(settings): 11 | """A basic test to execute the get_users_count Celery task.""" 12 | UserFactory.create_batch(3) 13 | settings.CELERY_TASK_ALWAYS_EAGER = True 14 | task_result = get_users_count.delay() 15 | assert isinstance(task_result, EagerResult) 16 | assert task_result.result == 3 17 | -------------------------------------------------------------------------------- /.envs/.local/.django: -------------------------------------------------------------------------------- 1 | # General 2 | # ------------------------------------------------------------------------------ 3 | USE_DOCKER=yes 4 | IPYTHONDIR=/app/.ipython 5 | # Redis 6 | # ------------------------------------------------------------------------------ 7 | REDIS_URL=redis://redis:6379/0 8 | REDIS_HOST=redis 9 | REDIS_PORT=6379 10 | 11 | # Celery 12 | # ------------------------------------------------------------------------------ 13 | 14 | # Flower 15 | CELERY_FLOWER_USER=DzlnBDyfwpfqlOUcNKmZzELsZjuocUkp 16 | CELERY_FLOWER_PASSWORD=omZIrNc5JIN07UovDEOjiBHja9K2c5jgO2bKKGC6gcaZuTM6gD7uMRWKktA7m1R7 17 | -------------------------------------------------------------------------------- /conversa_dj/templates/account/password_change.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load crispy_forms_tags %} 5 | 6 | {% block head_title %}{% translate "Change Password" %}{% endblock %} 7 | 8 | {% block inner %} 9 |

{% translate "Change Password" %}

10 | 11 |
12 | {% csrf_token %} 13 | {{ form|crispy }} 14 | 15 |
16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Conversa Django documentation master file, created by 2 | sphinx-quickstart. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Conversa Django's documentation! 7 | ====================================================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | howto 14 | pycharm/configuration 15 | users 16 | 17 | 18 | 19 | Indices and tables 20 | ================== 21 | 22 | * :ref:`genindex` 23 | * :ref:`modindex` 24 | * :ref:`search` 25 | -------------------------------------------------------------------------------- /conversa_dj/contrib/sites/migrations/0004_alter_options_ordering_domain.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-02-04 14:49 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("sites", "0003_set_site_domain_and_name"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name="site", 15 | options={ 16 | "ordering": ["domain"], 17 | "verbose_name": "site", 18 | "verbose_name_plural": "sites", 19 | }, 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /requirements/production.txt: -------------------------------------------------------------------------------- 1 | # PRECAUTION: avoid production dependencies that aren't in development 2 | 3 | -r base.txt 4 | 5 | gunicorn==20.1.0 # https://github.com/benoitc/gunicorn 6 | psycopg2==2.9.3 # https://github.com/psycopg/psycopg2 7 | Collectfast==2.2.0 # https://github.com/antonagestam/collectfast 8 | sentry-sdk==1.5.12 # https://github.com/getsentry/sentry-python 9 | 10 | # Django 11 | # ------------------------------------------------------------------------------ 12 | django-storages[boto3]==1.12.3 # https://github.com/jschneier/django-storages 13 | django-anymail[mailgun]==8.5 # https://github.com/anymail/django-anymail 14 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | const root = ReactDOM.createRoot( 8 | document.getElementById('root') as HTMLElement 9 | ); 10 | root.render( 11 | 12 | 13 | 14 | ); 15 | 16 | // If you want to start measuring performance in your app, pass a function 17 | // to log results (for example: reportWebVitals(console.log)) 18 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 19 | reportWebVitals(); 20 | -------------------------------------------------------------------------------- /.idea/runConfigurations/docker_compose_up_docs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /config/celery_app.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from celery import Celery 4 | 5 | # set the default Django settings module for the 'celery' program. 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local") 7 | 8 | app = Celery("conversa_dj") 9 | 10 | # Using a string here means the worker doesn't have to serialize 11 | # the configuration object to child processes. 12 | # - namespace='CELERY' means all celery-related configuration keys 13 | # should have a `CELERY_` prefix. 14 | app.config_from_object("django.conf:settings", namespace="CELERY") 15 | 16 | # Load task modules from all registered Django app configs. 17 | app.autodiscover_tasks() 18 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /conversa_dj/templates/account/logout.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% translate "Sign Out" %}{% endblock %} 6 | 7 | {% block inner %} 8 |

{% translate "Sign Out" %}

9 | 10 |

{% translate 'Are you sure you want to sign out?' %}

11 | 12 |
13 | {% csrf_token %} 14 | {% if redirect_field_value %} 15 | 16 | {% endif %} 17 | 18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /conversa_dj/contrib/sites/migrations/0002_alter_domain_unique.py: -------------------------------------------------------------------------------- 1 | import django.contrib.sites.models 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [("sites", "0001_initial")] 8 | 9 | operations = [ 10 | migrations.AlterField( 11 | model_name="site", 12 | name="domain", 13 | field=models.CharField( 14 | max_length=100, 15 | unique=True, 16 | validators=[django.contrib.sites.models._simple_domain_name_validator], 17 | verbose_name="domain name", 18 | ), 19 | ) 20 | ] 21 | -------------------------------------------------------------------------------- /conversa_dj/users/adapters.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from allauth.account.adapter import DefaultAccountAdapter 4 | from allauth.socialaccount.adapter import DefaultSocialAccountAdapter 5 | from django.conf import settings 6 | from django.http import HttpRequest 7 | 8 | 9 | class AccountAdapter(DefaultAccountAdapter): 10 | def is_open_for_signup(self, request: HttpRequest): 11 | return getattr(settings, "ACCOUNT_ALLOW_REGISTRATION", True) 12 | 13 | 14 | class SocialAccountAdapter(DefaultSocialAccountAdapter): 15 | def is_open_for_signup(self, request: HttpRequest, sociallogin: Any): 16 | return getattr(settings, "ACCOUNT_ALLOW_REGISTRATION", True) 17 | -------------------------------------------------------------------------------- /conversa_dj/users/tests/test_swagger.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.urls import reverse 3 | 4 | pytestmark = pytest.mark.django_db 5 | 6 | 7 | def test_swagger_accessible_by_admin(admin_client): 8 | url = reverse("api-docs") 9 | response = admin_client.get(url) 10 | assert response.status_code == 200 11 | 12 | 13 | def test_swagger_ui_not_accessible_by_normal_user(client): 14 | url = reverse("api-docs") 15 | response = client.get(url) 16 | assert response.status_code == 403 17 | 18 | 19 | def test_api_schema_generated_successfully(admin_client): 20 | url = reverse("api-schema") 21 | response = admin_client.get(url) 22 | assert response.status_code == 200 23 | -------------------------------------------------------------------------------- /compose/production/postgres/maintenance/_sourced/messages.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | message_newline() { 5 | echo 6 | } 7 | 8 | message_debug() 9 | { 10 | echo -e "DEBUG: ${@}" 11 | } 12 | 13 | message_welcome() 14 | { 15 | echo -e "\e[1m${@}\e[0m" 16 | } 17 | 18 | message_warning() 19 | { 20 | echo -e "\e[33mWARNING\e[0m: ${@}" 21 | } 22 | 23 | message_error() 24 | { 25 | echo -e "\e[31mERROR\e[0m: ${@}" 26 | } 27 | 28 | message_info() 29 | { 30 | echo -e "\e[37mINFO\e[0m: ${@}" 31 | } 32 | 33 | message_suggestion() 34 | { 35 | echo -e "\e[33mSUGGESTION\e[0m: ${@}" 36 | } 37 | 38 | message_success() 39 | { 40 | echo -e "\e[32mSUCCESS\e[0m: ${@}" 41 | } 42 | -------------------------------------------------------------------------------- /.idea/runConfigurations/docker_compose_up_django.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 13 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /conversa_dj/users/tests/test_urls.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.urls import resolve, reverse 3 | 4 | from conversa_dj.users.models import User 5 | 6 | pytestmark = pytest.mark.django_db 7 | 8 | 9 | def test_detail(user: User): 10 | assert ( 11 | reverse("users:detail", kwargs={"username": user.username}) 12 | == f"/users/{user.username}/" 13 | ) 14 | assert resolve(f"/users/{user.username}/").view_name == "users:detail" 15 | 16 | 17 | def test_update(): 18 | assert reverse("users:update") == "/users/~update/" 19 | assert resolve("/users/~update/").view_name == "users:update" 20 | 21 | 22 | def test_redirect(): 23 | assert reverse("users:redirect") == "/users/~redirect/" 24 | assert resolve("/users/~redirect/").view_name == "users:redirect" 25 | -------------------------------------------------------------------------------- /compose/production/aws/maintenance/download: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ### Download a file from your Amazon S3 bucket to the postgres /backups folder 4 | ### 5 | ### Usage: 6 | ### $ docker-compose -f production.yml run --rm awscli <1> 7 | 8 | set -o errexit 9 | set -o pipefail 10 | set -o nounset 11 | 12 | working_dir="$(dirname ${0})" 13 | source "${working_dir}/_sourced/constants.sh" 14 | source "${working_dir}/_sourced/messages.sh" 15 | 16 | export AWS_ACCESS_KEY_ID="${DJANGO_AWS_ACCESS_KEY_ID}" 17 | export AWS_SECRET_ACCESS_KEY="${DJANGO_AWS_SECRET_ACCESS_KEY}" 18 | export AWS_STORAGE_BUCKET_NAME="${DJANGO_AWS_STORAGE_BUCKET_NAME}" 19 | 20 | 21 | aws s3 cp s3://${AWS_STORAGE_BUCKET_NAME}${BACKUP_DIR_PATH}/${1} ${BACKUP_DIR_PATH}/${1} 22 | 23 | message_success "Finished downloading ${1}." 24 | -------------------------------------------------------------------------------- /conversa_dj/users/tests/test_drf_urls.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.urls import resolve, reverse 3 | 4 | from conversa_dj.users.models import User 5 | 6 | pytestmark = pytest.mark.django_db 7 | 8 | 9 | def test_user_detail(user: User): 10 | assert ( 11 | reverse("api:user-detail", kwargs={"username": user.username}) 12 | == f"/api/users/{user.username}/" 13 | ) 14 | assert resolve(f"/api/users/{user.username}/").view_name == "api:user-detail" 15 | 16 | 17 | def test_user_list(): 18 | assert reverse("api:user-list") == "/api/users/" 19 | assert resolve("/api/users/").view_name == "api:user-list" 20 | 21 | 22 | def test_user_me(): 23 | assert reverse("api:user-me") == "/api/users/me/" 24 | assert resolve("/api/users/me/").view_name == "api:user-me" 25 | -------------------------------------------------------------------------------- /conversa_dj/templates/account/signup.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load crispy_forms_tags %} 5 | 6 | {% block head_title %}{% translate "Signup" %}{% endblock %} 7 | 8 | {% block inner %} 9 |

{% translate "Sign Up" %}

10 | 11 |

{% blocktranslate %}Already have an account? Then please sign in.{% endblocktranslate %}

12 | 13 | 21 | 22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /frontend/src/services/AuthService.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { UserModel } from "../models/User"; 3 | 4 | 5 | class AuthService { 6 | setUserInLocalStorage(data: UserModel) { 7 | localStorage.setItem("user", JSON.stringify(data)); 8 | } 9 | 10 | async login(username: string, password: string): Promise { 11 | const response = await axios.post("http://127.0.0.1:8000/auth-token/", { username, password }); 12 | if (!response.data.token) { 13 | return response.data; 14 | } 15 | this.setUserInLocalStorage(response.data); 16 | return response.data; 17 | } 18 | 19 | logout() { 20 | localStorage.removeItem("user"); 21 | } 22 | 23 | getCurrentUser() { 24 | const user = localStorage.getItem("user")!; 25 | return JSON.parse(user); 26 | } 27 | } 28 | 29 | export default new AuthService(); 30 | -------------------------------------------------------------------------------- /conversa_dj/templates/users/user_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load static %} 3 | 4 | {% block title %}User: {{ object.username }}{% endblock %} 5 | 6 | {% block content %} 7 |
8 | 9 |
10 |
11 | 12 |

{{ object.username }}

13 | {% if object.name %} 14 |

{{ object.name }}

15 | {% endif %} 16 |
17 |
18 | 19 | {% if object == request.user %} 20 | 21 |
22 | 23 |
24 | My Info 25 | E-Mail 26 | 27 |
28 | 29 |
30 | 31 | {% endif %} 32 | 33 |
34 | {% endblock content %} 35 | -------------------------------------------------------------------------------- /conversa_dj/templates/account/verified_email_required.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% translate "Verify Your E-mail Address" %}{% endblock %} 6 | 7 | {% block inner %} 8 |

{% translate "Verify Your E-mail Address" %}

9 | 10 | {% url 'account_email' as email_url %} 11 | 12 |

{% blocktranslate %}This part of the site requires us to verify that 13 | you are who you claim to be. For this purpose, we require that you 14 | verify ownership of your e-mail address. {% endblocktranslate %}

15 | 16 |

{% blocktranslate %}We have sent an e-mail to you for 17 | verification. Please click on the link inside this e-mail. Please 18 | contact us if you do not receive it within a few minutes.{% endblocktranslate %}

19 | 20 |

{% blocktranslate %}Note: you can still change your e-mail address.{% endblocktranslate %}

21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /compose/production/aws/maintenance/upload: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ### Upload the /backups folder to Amazon S3 4 | ### 5 | ### Usage: 6 | ### $ docker-compose -f production.yml run --rm awscli upload 7 | 8 | set -o errexit 9 | set -o pipefail 10 | set -o nounset 11 | 12 | working_dir="$(dirname ${0})" 13 | source "${working_dir}/_sourced/constants.sh" 14 | source "${working_dir}/_sourced/messages.sh" 15 | 16 | export AWS_ACCESS_KEY_ID="${DJANGO_AWS_ACCESS_KEY_ID}" 17 | export AWS_SECRET_ACCESS_KEY="${DJANGO_AWS_SECRET_ACCESS_KEY}" 18 | export AWS_STORAGE_BUCKET_NAME="${DJANGO_AWS_STORAGE_BUCKET_NAME}" 19 | 20 | 21 | message_info "Upload the backups directory to S3 bucket {$AWS_STORAGE_BUCKET_NAME}" 22 | 23 | aws s3 cp ${BACKUP_DIR_PATH} s3://${AWS_STORAGE_BUCKET_NAME}${BACKUP_DIR_PATH} --recursive 24 | 25 | message_info "Cleaning the directory ${BACKUP_DIR_PATH}" 26 | 27 | rm -rf ${BACKUP_DIR_PATH}/* 28 | 29 | message_success "Finished uploading and cleaning." 30 | -------------------------------------------------------------------------------- /conversa_dj/users/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AbstractUser 2 | from django.db.models import CharField 3 | from django.urls import reverse 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | 7 | class User(AbstractUser): 8 | """ 9 | Default custom user model for Conversa Django. 10 | If adding fields that need to be filled at user signup, 11 | check forms.SignupForm and forms.SocialSignupForms accordingly. 12 | """ 13 | 14 | #: First and last name do not cover name patterns around the globe 15 | name = CharField(_("Name of User"), blank=True, max_length=255) 16 | first_name = None # type: ignore 17 | last_name = None # type: ignore 18 | 19 | def get_absolute_url(self): 20 | """Get url for user's detail view. 21 | 22 | Returns: 23 | str: URL for user detail. 24 | 25 | """ 26 | return reverse("users:detail", kwargs={"username": self.username}) 27 | -------------------------------------------------------------------------------- /conversa_dj/templates/account/password_reset.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load account %} 5 | {% load crispy_forms_tags %} 6 | 7 | {% block head_title %}{% translate "Password Reset" %}{% endblock %} 8 | 9 | {% block inner %} 10 | 11 |

{% translate "Password Reset" %}

12 | {% if user.is_authenticated %} 13 | {% include "account/snippets/already_logged_in.html" %} 14 | {% endif %} 15 | 16 |

{% translate "Forgotten your password? Enter your e-mail address below, and we'll send you an e-mail allowing you to reset it." %}

17 | 18 |
19 | {% csrf_token %} 20 | {{ form|crispy }} 21 | 22 |
23 | 24 |

{% blocktranslate %}Please contact us if you have any trouble resetting your password.{% endblocktranslate %}

25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /conversa_dj/users/tests/test_drf_views.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.test import RequestFactory 3 | 4 | from conversa_dj.users.api.views import UserViewSet 5 | from conversa_dj.users.models import User 6 | 7 | pytestmark = pytest.mark.django_db 8 | 9 | 10 | class TestUserViewSet: 11 | def test_get_queryset(self, user: User, rf: RequestFactory): 12 | view = UserViewSet() 13 | request = rf.get("/fake-url/") 14 | request.user = user 15 | 16 | view.request = request 17 | 18 | assert user in view.get_queryset() 19 | 20 | def test_me(self, user: User, rf: RequestFactory): 21 | view = UserViewSet() 22 | request = rf.get("/fake-url/") 23 | request.user = user 24 | 25 | view.request = request 26 | 27 | response = view.me(request) 28 | 29 | assert response.data == { 30 | "username": user.username, 31 | "name": user.name, 32 | "url": f"http://testserver/api/users/{user.username}/", 33 | } 34 | -------------------------------------------------------------------------------- /conversa_dj/users/tests/factories.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Sequence 2 | 3 | from django.contrib.auth import get_user_model 4 | from factory import Faker, post_generation 5 | from factory.django import DjangoModelFactory 6 | 7 | 8 | class UserFactory(DjangoModelFactory): 9 | 10 | username = Faker("user_name") 11 | email = Faker("email") 12 | name = Faker("name") 13 | 14 | @post_generation 15 | def password(self, create: bool, extracted: Sequence[Any], **kwargs): 16 | password = ( 17 | extracted 18 | if extracted 19 | else Faker( 20 | "password", 21 | length=42, 22 | special_chars=True, 23 | digits=True, 24 | upper_case=True, 25 | lower_case=True, 26 | ).evaluate(None, None, extra={"locale": None}) 27 | ) 28 | self.set_password(password) 29 | 30 | class Meta: 31 | model = get_user_model() 32 | django_get_or_create = ["username"] 33 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: "^docs/|/migrations/" 2 | default_stages: [commit] 3 | 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v4.2.0 7 | hooks: 8 | - id: trailing-whitespace 9 | - id: end-of-file-fixer 10 | - id: check-yaml 11 | 12 | - repo: https://github.com/asottile/pyupgrade 13 | rev: v2.32.1 14 | hooks: 15 | - id: pyupgrade 16 | args: [--py39-plus] 17 | 18 | - repo: https://github.com/psf/black 19 | rev: 22.3.0 20 | hooks: 21 | - id: black 22 | 23 | - repo: https://github.com/PyCQA/isort 24 | rev: 5.10.1 25 | hooks: 26 | - id: isort 27 | 28 | - repo: https://github.com/PyCQA/flake8 29 | rev: 4.0.1 30 | hooks: 31 | - id: flake8 32 | args: ["--config=setup.cfg"] 33 | additional_dependencies: [flake8-isort] 34 | 35 | # sets up .pre-commit-ci.yaml to ensure pre-commit dependencies stay up to date 36 | ci: 37 | autoupdate_schedule: weekly 38 | skip: [] 39 | submodules: false 40 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = ./_build 10 | APP = /app 11 | 12 | .PHONY: help livehtml apidocs Makefile 13 | 14 | # Put it first so that "make" without argument is like "make help". 15 | help: 16 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -c . 17 | 18 | # Build, watch and serve docs with live reload 19 | livehtml: 20 | sphinx-autobuild -b html --host 0.0.0.0 --port 9000 --watch $(APP) -c . $(SOURCEDIR) $(BUILDDIR)/html 21 | 22 | # Outputs rst files from django application code 23 | apidocs: 24 | sphinx-apidoc -o $(SOURCEDIR)/api $(APP) 25 | 26 | # Catch-all target: route all unknown targets to Sphinx using the new 27 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 28 | %: Makefile 29 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -c . 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | Copyright (c) 2022, Matthew Freire 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules,venv 4 | 5 | [pycodestyle] 6 | max-line-length = 120 7 | exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules,venv 8 | 9 | [isort] 10 | line_length = 88 11 | known_first_party = conversa_dj,config 12 | multi_line_output = 3 13 | default_section = THIRDPARTY 14 | skip = venv/ 15 | skip_glob = **/migrations/*.py 16 | include_trailing_comma = true 17 | force_grid_wrap = 0 18 | use_parentheses = true 19 | 20 | [mypy] 21 | python_version = 3.9 22 | check_untyped_defs = True 23 | ignore_missing_imports = True 24 | warn_unused_ignores = True 25 | warn_redundant_casts = True 26 | warn_unused_configs = True 27 | plugins = mypy_django_plugin.main, mypy_drf_plugin.main 28 | 29 | [mypy.plugins.django-stubs] 30 | django_settings_module = config.settings.test 31 | 32 | [mypy-*.migrations.*] 33 | # Django migrations should not produce any errors: 34 | ignore_errors = True 35 | 36 | [coverage:run] 37 | include = conversa_dj/* 38 | omit = *migrations*, *tests* 39 | plugins = 40 | django_coverage_plugin 41 | -------------------------------------------------------------------------------- /compose/production/django/entrypoint: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | 8 | 9 | # N.B. If only .env files supported variable expansion... 10 | export CELERY_BROKER_URL="${REDIS_URL}" 11 | 12 | 13 | if [ -z "${POSTGRES_USER}" ]; then 14 | base_postgres_image_default_user='postgres' 15 | export POSTGRES_USER="${base_postgres_image_default_user}" 16 | fi 17 | export DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}" 18 | 19 | postgres_ready() { 20 | python << END 21 | import sys 22 | 23 | import psycopg2 24 | 25 | try: 26 | psycopg2.connect( 27 | dbname="${POSTGRES_DB}", 28 | user="${POSTGRES_USER}", 29 | password="${POSTGRES_PASSWORD}", 30 | host="${POSTGRES_HOST}", 31 | port="${POSTGRES_PORT}", 32 | ) 33 | except psycopg2.OperationalError: 34 | sys.exit(-1) 35 | sys.exit(0) 36 | 37 | END 38 | } 39 | until postgres_ready; do 40 | >&2 echo 'Waiting for PostgreSQL to become available...' 41 | sleep 1 42 | done 43 | >&2 echo 'PostgreSQL is available' 44 | 45 | exec "$@" 46 | -------------------------------------------------------------------------------- /conversa_dj/templates/account/email_confirm.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load account %} 5 | 6 | {% block head_title %}{% translate "Confirm E-mail Address" %}{% endblock %} 7 | 8 | 9 | {% block inner %} 10 |

{% translate "Confirm E-mail Address" %}

11 | 12 | {% if confirmation %} 13 | 14 | {% user_display confirmation.email_address.user as user_display %} 15 | 16 |

{% blocktranslate with confirmation.email_address.email as email %}Please confirm that {{ email }} is an e-mail address for user {{ user_display }}.{% endblocktranslate %}

17 | 18 |
19 | {% csrf_token %} 20 | 21 |
22 | 23 | {% else %} 24 | 25 | {% url 'account_email' as email_url %} 26 | 27 |

{% blocktranslate %}This e-mail confirmation link expired or is invalid. Please issue a new e-mail confirmation request.{% endblocktranslate %}

28 | 29 | {% endif %} 30 | 31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /conversa_dj/templates/account/password_reset_from_key.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load crispy_forms_tags %} 5 | {% block head_title %}{% translate "Change Password" %}{% endblock %} 6 | 7 | {% block inner %} 8 |

{% if token_fail %}{% translate "Bad Token" %}{% else %}{% translate "Change Password" %}{% endif %}

9 | 10 | {% if token_fail %} 11 | {% url 'account_reset_password' as passwd_reset_url %} 12 |

{% blocktranslate %}The password reset link was invalid, possibly because it has already been used. Please request a new password reset.{% endblocktranslate %}

13 | {% else %} 14 | {% if form %} 15 |
16 | {% csrf_token %} 17 | {{ form|crispy }} 18 | 19 |
20 | {% else %} 21 |

{% translate 'Your password is now changed.' %}

22 | {% endif %} 23 | {% endif %} 24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | from pathlib import Path 5 | 6 | if __name__ == "__main__": 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local") 8 | 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError: 12 | # The above import may fail for some other reason. Ensure that the 13 | # issue is really that Django is missing to avoid masking other 14 | # exceptions on Python 2. 15 | try: 16 | import django # noqa 17 | except ImportError: 18 | raise ImportError( 19 | "Couldn't import Django. Are you sure it's installed and " 20 | "available on your PYTHONPATH environment variable? Did you " 21 | "forget to activate a virtual environment?" 22 | ) 23 | 24 | raise 25 | 26 | # This allows easy placement of apps within the interior 27 | # conversa_dj directory. 28 | current_path = Path(__file__).parent.resolve() 29 | sys.path.append(str(current_path / "conversa_dj")) 30 | 31 | execute_from_command_line(sys.argv) 32 | -------------------------------------------------------------------------------- /conversa_dj/users/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth import admin as auth_admin 3 | from django.contrib.auth import get_user_model 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from conversa_dj.users.forms import UserAdminChangeForm, UserAdminCreationForm 7 | 8 | User = get_user_model() 9 | 10 | 11 | @admin.register(User) 12 | class UserAdmin(auth_admin.UserAdmin): 13 | 14 | form = UserAdminChangeForm 15 | add_form = UserAdminCreationForm 16 | fieldsets = ( 17 | (None, {"fields": ("username", "password")}), 18 | (_("Personal info"), {"fields": ("name", "email")}), 19 | ( 20 | _("Permissions"), 21 | { 22 | "fields": ( 23 | "is_active", 24 | "is_staff", 25 | "is_superuser", 26 | "groups", 27 | "user_permissions", 28 | ), 29 | }, 30 | ), 31 | (_("Important dates"), {"fields": ("last_login", "date_joined")}), 32 | ) 33 | list_display = ["username", "name", "is_superuser"] 34 | search_fields = ["name"] 35 | -------------------------------------------------------------------------------- /.idea/runConfigurations/merge_production_dotenvs_in_dotenv.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 21 | 22 | -------------------------------------------------------------------------------- /config/settings/test.py: -------------------------------------------------------------------------------- 1 | """ 2 | With these settings, tests run faster. 3 | """ 4 | 5 | from .base import * # noqa 6 | from .base import env 7 | 8 | # GENERAL 9 | # ------------------------------------------------------------------------------ 10 | # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key 11 | SECRET_KEY = env( 12 | "DJANGO_SECRET_KEY", 13 | default="KTcKJUIOw8RiWxF4oLcLrsitKHACgLcVWnfnCfF7gcqhPypPdL0YWIltM8zNDLuE", 14 | ) 15 | # https://docs.djangoproject.com/en/dev/ref/settings/#test-runner 16 | TEST_RUNNER = "django.test.runner.DiscoverRunner" 17 | 18 | # PASSWORDS 19 | # ------------------------------------------------------------------------------ 20 | # https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers 21 | PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] 22 | 23 | # EMAIL 24 | # ------------------------------------------------------------------------------ 25 | # https://docs.djangoproject.com/en/dev/ref/settings/#email-backend 26 | EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" 27 | 28 | # Your stuff... 29 | # ------------------------------------------------------------------------------ 30 | -------------------------------------------------------------------------------- /compose/production/postgres/maintenance/backup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | ### Create a database backup. 5 | ### 6 | ### Usage: 7 | ### $ docker-compose -f .yml (exec |run --rm) postgres backup 8 | 9 | 10 | set -o errexit 11 | set -o pipefail 12 | set -o nounset 13 | 14 | 15 | working_dir="$(dirname ${0})" 16 | source "${working_dir}/_sourced/constants.sh" 17 | source "${working_dir}/_sourced/messages.sh" 18 | 19 | 20 | message_welcome "Backing up the '${POSTGRES_DB}' database..." 21 | 22 | 23 | if [[ "${POSTGRES_USER}" == "postgres" ]]; then 24 | message_error "Backing up as 'postgres' user is not supported. Assign 'POSTGRES_USER' env with another one and try again." 25 | exit 1 26 | fi 27 | 28 | export PGHOST="${POSTGRES_HOST}" 29 | export PGPORT="${POSTGRES_PORT}" 30 | export PGUSER="${POSTGRES_USER}" 31 | export PGPASSWORD="${POSTGRES_PASSWORD}" 32 | export PGDATABASE="${POSTGRES_DB}" 33 | 34 | backup_filename="${BACKUP_FILE_PREFIX}_$(date +'%Y_%m_%dT%H_%M_%S').sql.gz" 35 | pg_dump | gzip > "${BACKUP_DIR_PATH}/${backup_filename}" 36 | 37 | 38 | message_success "'${POSTGRES_DB}' database backup '${backup_filename}' has been created and placed in '${BACKUP_DIR_PATH}'." 39 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | 8 | if "%SPHINXBUILD%" == "" ( 9 | set SPHINXBUILD=sphinx-build -c . 10 | ) 11 | set SOURCEDIR=_source 12 | set BUILDDIR=_build 13 | set APP=..\conversa_dj 14 | 15 | if "%1" == "" goto help 16 | 17 | %SPHINXBUILD% >NUL 2>NUL 18 | if errorlevel 9009 ( 19 | echo. 20 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 21 | echo.installed, then set the SPHINXBUILD environment variable to point 22 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 23 | echo.may add the Sphinx directory to PATH. 24 | echo. 25 | echo.Install sphinx-autobuild for live serving. 26 | echo.If you don't have Sphinx installed, grab it from 27 | echo.http://sphinx-doc.org/ 28 | exit /b 1 29 | ) 30 | 31 | %SPHINXBUILD% -b %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 32 | goto end 33 | 34 | :livehtml 35 | sphinx-autobuild -b html --open-browser -p 9000 --watch %APP% -c . %SOURCEDIR% %BUILDDIR%/html 36 | GOTO :EOF 37 | 38 | :apidocs 39 | sphinx-apidoc -o %SOURCEDIR%/api %APP% 40 | GOTO :EOF 41 | 42 | :help 43 | %SPHINXBUILD% -b help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 44 | 45 | :end 46 | popd 47 | -------------------------------------------------------------------------------- /.idea/runConfigurations/pytest___.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 25 | -------------------------------------------------------------------------------- /.idea/runConfigurations/pytest__users.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 25 | -------------------------------------------------------------------------------- /conversa_dj/users/tests/test_forms.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module for all Form Tests. 3 | """ 4 | import pytest 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | from conversa_dj.users.forms import UserAdminCreationForm 8 | from conversa_dj.users.models import User 9 | 10 | pytestmark = pytest.mark.django_db 11 | 12 | 13 | class TestUserAdminCreationForm: 14 | """ 15 | Test class for all tests related to the UserAdminCreationForm 16 | """ 17 | 18 | def test_username_validation_error_msg(self, user: User): 19 | """ 20 | Tests UserAdminCreation Form's unique validator functions correctly by testing: 21 | 1) A new user with an existing username cannot be added. 22 | 2) Only 1 error is raised by the UserCreation Form 23 | 3) The desired error message is raised 24 | """ 25 | 26 | # The user already exists, 27 | # hence cannot be created. 28 | form = UserAdminCreationForm( 29 | { 30 | "username": user.username, 31 | "password1": user.password, 32 | "password2": user.password, 33 | } 34 | ) 35 | 36 | assert not form.is_valid() 37 | assert len(form.errors) == 1 38 | assert "username" in form.errors 39 | assert form.errors["username"][0] == _("This username has already been taken.") 40 | -------------------------------------------------------------------------------- /frontend/src/components/Conversations.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import { AuthContext } from "../contexts/AuthContext"; 4 | 5 | interface UserResponse { 6 | username: string; 7 | name: string; 8 | url: string; 9 | } 10 | 11 | export function Conversations() { 12 | const { user } = useContext(AuthContext); 13 | const [users, setUsers] = useState([]); 14 | 15 | useEffect(() => { 16 | async function fetchUsers() { 17 | const res = await fetch("http://127.0.0.1:8000/api/users/all/", { 18 | headers: { 19 | Authorization: `Token ${user?.token}`, 20 | }, 21 | }); 22 | const data = await res.json(); 23 | setUsers(data); 24 | } 25 | fetchUsers(); 26 | }, [user]); 27 | 28 | function createConversationName(username: string) { 29 | const namesAlph = [user?.username, username].sort(); 30 | return `${namesAlph[0]}__${namesAlph[1]}`; 31 | } 32 | 33 | return ( 34 |
35 | {users 36 | .filter((u) => u.username !== user?.username) 37 | .map((u) => ( 38 | 42 |
{u.username}
43 | 44 | ))} 45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /config/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config 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/dev/howto/deployment/asgi/ 8 | 9 | """ 10 | import os 11 | import sys 12 | from pathlib import Path 13 | 14 | from django.core.asgi import get_asgi_application 15 | 16 | # This allows easy placement of apps within the interior 17 | # conversa_dj directory. 18 | 19 | ROOT_DIR = Path(__file__).resolve(strict=True).parent.parent 20 | sys.path.append(str(ROOT_DIR / "conversa_dj")) 21 | 22 | # If DJANGO_SETTINGS_MODULE is unset, default to the local settings 23 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local") 24 | 25 | # This application object is used by any ASGI server configured to use this file. 26 | django_application = get_asgi_application() 27 | 28 | # Import websocket application here, so apps from django_application are loaded first 29 | from config import routing # noqa isort:skip 30 | 31 | from channels.routing import ProtocolTypeRouter, URLRouter # noqa isort:skip 32 | from conversa_dj.chats.middleware import TokenAuthMiddleware # noqa isort:skip 33 | 34 | 35 | application = ProtocolTypeRouter( 36 | { 37 | "http": get_asgi_application(), 38 | "websocket": TokenAuthMiddleware(URLRouter(routing.websocket_urlpatterns)), 39 | } 40 | ) 41 | -------------------------------------------------------------------------------- /conversa_dj/users/forms.py: -------------------------------------------------------------------------------- 1 | from allauth.account.forms import SignupForm 2 | from allauth.socialaccount.forms import SignupForm as SocialSignupForm 3 | from django.contrib.auth import forms as admin_forms 4 | from django.contrib.auth import get_user_model 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | User = get_user_model() 8 | 9 | 10 | class UserAdminChangeForm(admin_forms.UserChangeForm): 11 | class Meta(admin_forms.UserChangeForm.Meta): 12 | model = User 13 | 14 | 15 | class UserAdminCreationForm(admin_forms.UserCreationForm): 16 | """ 17 | Form for User Creation in the Admin Area. 18 | To change user signup, see UserSignupForm and UserSocialSignupForm. 19 | """ 20 | 21 | class Meta(admin_forms.UserCreationForm.Meta): 22 | model = User 23 | 24 | error_messages = { 25 | "username": {"unique": _("This username has already been taken.")} 26 | } 27 | 28 | 29 | class UserSignupForm(SignupForm): 30 | """ 31 | Form that will be rendered on a user sign up section/screen. 32 | Default fields will be added automatically. 33 | Check UserSocialSignupForm for accounts created from social. 34 | """ 35 | 36 | 37 | class UserSocialSignupForm(SocialSignupForm): 38 | """ 39 | Renders the form when user has signed up using social accounts. 40 | Default fields will be added automatically. 41 | See UserSignupForm otherwise. 42 | """ 43 | -------------------------------------------------------------------------------- /docs/howto.rst: -------------------------------------------------------------------------------- 1 | How To - Project Documentation 2 | ====================================================================== 3 | 4 | Get Started 5 | ---------------------------------------------------------------------- 6 | 7 | Documentation can be written as rst files in `conversa_dj/docs`. 8 | 9 | 10 | To build and serve docs, use the commands:: 11 | 12 | docker-compose -f local.yml up docs 13 | 14 | 15 | 16 | Changes to files in `docs/_source` will be picked up and reloaded automatically. 17 | 18 | `Sphinx `_ is the tool used to build documentation. 19 | 20 | Docstrings to Documentation 21 | ---------------------------------------------------------------------- 22 | 23 | The sphinx extension `apidoc `_ is used to automatically document code using signatures and docstrings. 24 | 25 | Numpy or Google style docstrings will be picked up from project files and available for documentation. See the `Napoleon `_ extension for details. 26 | 27 | For an in-use example, see the `page source <_sources/users.rst.txt>`_ for :ref:`users`. 28 | 29 | To compile all docstrings automatically into documentation source files, use the command: 30 | :: 31 | 32 | make apidocs 33 | 34 | 35 | This can be done in the docker container: 36 | :: 37 | 38 | docker run --rm docs make apidocs 39 | -------------------------------------------------------------------------------- /conversa_dj/users/tests/test_admin.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.urls import reverse 3 | 4 | from conversa_dj.users.models import User 5 | 6 | pytestmark = pytest.mark.django_db 7 | 8 | 9 | class TestUserAdmin: 10 | def test_changelist(self, admin_client): 11 | url = reverse("admin:users_user_changelist") 12 | response = admin_client.get(url) 13 | assert response.status_code == 200 14 | 15 | def test_search(self, admin_client): 16 | url = reverse("admin:users_user_changelist") 17 | response = admin_client.get(url, data={"q": "test"}) 18 | assert response.status_code == 200 19 | 20 | def test_add(self, admin_client): 21 | url = reverse("admin:users_user_add") 22 | response = admin_client.get(url) 23 | assert response.status_code == 200 24 | 25 | response = admin_client.post( 26 | url, 27 | data={ 28 | "username": "test", 29 | "password1": "My_R@ndom-P@ssw0rd", 30 | "password2": "My_R@ndom-P@ssw0rd", 31 | }, 32 | ) 33 | assert response.status_code == 302 34 | assert User.objects.filter(username="test").exists() 35 | 36 | def test_view_user(self, admin_client): 37 | user = User.objects.get(username="admin") 38 | url = reverse("admin:users_user_change", kwargs={"object_id": user.pk}) 39 | response = admin_client.get(url) 40 | assert response.status_code == 200 41 | -------------------------------------------------------------------------------- /conversa_dj/users/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.contrib.auth.mixins import LoginRequiredMixin 3 | from django.contrib.messages.views import SuccessMessageMixin 4 | from django.urls import reverse 5 | from django.utils.translation import gettext_lazy as _ 6 | from django.views.generic import DetailView, RedirectView, UpdateView 7 | 8 | User = get_user_model() 9 | 10 | 11 | class UserDetailView(LoginRequiredMixin, DetailView): 12 | 13 | model = User 14 | slug_field = "username" 15 | slug_url_kwarg = "username" 16 | 17 | 18 | user_detail_view = UserDetailView.as_view() 19 | 20 | 21 | class UserUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): 22 | 23 | model = User 24 | fields = ["name"] 25 | success_message = _("Information successfully updated") 26 | 27 | def get_success_url(self): 28 | assert ( 29 | self.request.user.is_authenticated 30 | ) # for mypy to know that the user is authenticated 31 | return self.request.user.get_absolute_url() 32 | 33 | def get_object(self): 34 | return self.request.user 35 | 36 | 37 | user_update_view = UserUpdateView.as_view() 38 | 39 | 40 | class UserRedirectView(LoginRequiredMixin, RedirectView): 41 | 42 | permanent = False 43 | 44 | def get_redirect_url(self): 45 | return reverse("users:detail", kwargs={"username": self.request.user.username}) 46 | 47 | 48 | user_redirect_view = UserRedirectView.as_view() 49 | -------------------------------------------------------------------------------- /.idea/runConfigurations/migrate.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 32 | 33 | -------------------------------------------------------------------------------- /conversa_dj/chats/api/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework.mixins import ListModelMixin, RetrieveModelMixin 2 | from rest_framework.viewsets import GenericViewSet 3 | 4 | from conversa_dj.chats.models import Conversation, Message 5 | from conversa_dj.chats.api.paginaters import MessagePagination 6 | 7 | from .serializers import MessageSerializer, ConversationSerializer 8 | 9 | 10 | class ConversationViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): 11 | serializer_class = ConversationSerializer 12 | queryset = Conversation.objects.none() 13 | lookup_field = "name" 14 | 15 | def get_queryset(self): 16 | queryset = Conversation.objects.filter( 17 | name__contains=self.request.user.username 18 | ) 19 | return queryset 20 | 21 | def get_serializer_context(self): 22 | return {"request": self.request, "user": self.request.user} 23 | 24 | 25 | class MessageViewSet(ListModelMixin, GenericViewSet): 26 | serializer_class = MessageSerializer 27 | queryset = Message.objects.none() 28 | pagination_class = MessagePagination 29 | 30 | def get_queryset(self): 31 | conversation_name = self.request.GET.get("conversation") 32 | queryset = ( 33 | Message.objects.filter( 34 | conversation__name__contains=self.request.user.username, 35 | ) 36 | .filter(conversation__name=conversation_name) 37 | .order_by("-timestamp") 38 | ) 39 | return queryset 40 | -------------------------------------------------------------------------------- /frontend/src/components/Message.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { AuthContext } from "../contexts/AuthContext"; 3 | import { MessageModel } from "../models/Message"; 4 | 5 | export function classNames(...classes: any) { 6 | return classes.filter(Boolean).join(" "); 7 | } 8 | 9 | export function Message({ message }: { message: MessageModel }) { 10 | const { user } = useContext(AuthContext); 11 | 12 | function formatMessageTimestamp(timestamp: string) { 13 | const date = new Date(timestamp); 14 | return date.toLocaleTimeString().slice(0, 5); 15 | } 16 | 17 | return ( 18 |
  • 26 |
    32 |
    33 | {message.content} 34 | 41 | {formatMessageTimestamp(message.timestamp)} 42 | 43 |
    44 |
    45 |
  • 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /frontend/src/components/ChatLoader.tsx: -------------------------------------------------------------------------------- 1 | export function ChatLoader() { 2 | return ( 3 |
    4 | 11 | 15 | 19 | 20 |
    21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /conversa_dj/contrib/sites/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | import django.contrib.sites.models 2 | from django.contrib.sites.models import _simple_domain_name_validator 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [] 9 | 10 | operations = [ 11 | migrations.CreateModel( 12 | name="Site", 13 | fields=[ 14 | ( 15 | "id", 16 | models.AutoField( 17 | verbose_name="ID", 18 | serialize=False, 19 | auto_created=True, 20 | primary_key=True, 21 | ), 22 | ), 23 | ( 24 | "domain", 25 | models.CharField( 26 | max_length=100, 27 | verbose_name="domain name", 28 | validators=[_simple_domain_name_validator], 29 | ), 30 | ), 31 | ("name", models.CharField(max_length=50, verbose_name="display name")), 32 | ], 33 | options={ 34 | "ordering": ("domain",), 35 | "db_table": "django_site", 36 | "verbose_name": "site", 37 | "verbose_name_plural": "sites", 38 | }, 39 | bases=(models.Model,), 40 | managers=[("objects", django.contrib.sites.models.SiteManager())], 41 | ) 42 | ] 43 | -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | pytz==2022.1 # https://github.com/stub42/pytz 2 | python-slugify==6.1.2 # https://github.com/un33k/python-slugify 3 | Pillow==9.1.0 # https://github.com/python-pillow/Pillow 4 | argon2-cffi==21.3.0 # https://github.com/hynek/argon2_cffi 5 | redis==4.3.1 # https://github.com/redis/redis-py 6 | hiredis==2.0.0 # https://github.com/redis/hiredis-py 7 | celery==5.2.6 # pyup: < 6.0 # https://github.com/celery/celery 8 | django-celery-beat==2.2.1 # https://github.com/celery/django-celery-beat 9 | flower==1.0.0 # https://github.com/mher/flower 10 | 11 | # Django 12 | # ------------------------------------------------------------------------------ 13 | django==3.2.13 # pyup: < 4.0 # https://www.djangoproject.com/ 14 | django-environ==0.8.1 # https://github.com/joke2k/django-environ 15 | django-model-utils==4.2.0 # https://github.com/jazzband/django-model-utils 16 | django-allauth==0.50.0 # https://github.com/pennersr/django-allauth 17 | django-crispy-forms==1.14.0 # https://github.com/django-crispy-forms/django-crispy-forms 18 | crispy-bootstrap5==0.6 # https://github.com/django-crispy-forms/crispy-bootstrap5 19 | django-redis==5.2.0 # https://github.com/jazzband/django-redis 20 | # Django REST Framework 21 | djangorestframework==3.13.1 # https://github.com/encode/django-rest-framework 22 | django-cors-headers==3.11.0 # https://github.com/adamchainz/django-cors-headers 23 | # DRF-spectacular for api documentation 24 | drf-spectacular==0.22.1 # https://github.com/tfranzel/drf-spectacular 25 | 26 | channels==3.0.4 27 | channels_redis==3.4.0 -------------------------------------------------------------------------------- /.idea/runConfigurations/runserver.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 33 | 34 | -------------------------------------------------------------------------------- /.idea/runConfigurations/runserver_plus.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 33 | 34 | -------------------------------------------------------------------------------- /conversa_dj/chats/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.contrib.auth import get_user_model 4 | from django.db import models 5 | 6 | User = get_user_model() 7 | 8 | 9 | class Conversation(models.Model): 10 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 11 | name = models.CharField(max_length=128) 12 | online = models.ManyToManyField(to=User, blank=True) 13 | 14 | def get_online_count(self): 15 | return self.online.count() 16 | 17 | def join(self, user): 18 | self.online.add(user) 19 | self.save() 20 | 21 | def leave(self, user): 22 | self.online.remove(user) 23 | self.save() 24 | 25 | def __str__(self): 26 | return f"{self.name} ({self.get_online_count()})" 27 | 28 | 29 | class Message(models.Model): 30 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 31 | conversation = models.ForeignKey( 32 | Conversation, on_delete=models.CASCADE, related_name="messages" 33 | ) 34 | from_user = models.ForeignKey( 35 | User, on_delete=models.CASCADE, related_name="messages_from_me" 36 | ) 37 | to_user = models.ForeignKey( 38 | User, on_delete=models.CASCADE, related_name="messages_to_me" 39 | ) 40 | content = models.CharField(max_length=512) 41 | timestamp = models.DateTimeField(auto_now_add=True) 42 | read = models.BooleanField(default=False) 43 | 44 | def __str__(self): 45 | return f"From {self.from_user.username} to {self.to_user.username}: {self.content} [{self.timestamp}]" 46 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@headlessui/react": "^1.6.1", 7 | "@heroicons/react": "^1.0.6", 8 | "@tailwindcss/forms": "^0.5.1", 9 | "@testing-library/jest-dom": "^5.16.4", 10 | "@testing-library/react": "^13.2.0", 11 | "@testing-library/user-event": "^13.5.0", 12 | "@types/jest": "^27.5.1", 13 | "@types/node": "^16.11.35", 14 | "@types/react": "^18.0.9", 15 | "@types/react-dom": "^18.0.4", 16 | "axios": "^0.27.2", 17 | "formik": "^2.2.9", 18 | "react": "^18.1.0", 19 | "react-dom": "^18.1.0", 20 | "react-hotkeys-hook": "^3.4.6", 21 | "react-infinite-scroll-component": "^6.1.0", 22 | "react-router-dom": "^6.3.0", 23 | "react-scripts": "5.0.1", 24 | "react-use-websocket": "^4.1.0", 25 | "typescript": "^4.6.4", 26 | "web-vitals": "^2.1.4" 27 | }, 28 | "scripts": { 29 | "start": "react-scripts start", 30 | "build": "react-scripts build", 31 | "test": "react-scripts test", 32 | "eject": "react-scripts eject" 33 | }, 34 | "eslintConfig": { 35 | "extends": [ 36 | "react-app", 37 | "react-app/jest" 38 | ] 39 | }, 40 | "browserslist": { 41 | "production": [ 42 | ">0.2%", 43 | "not dead", 44 | "not op_mini all" 45 | ], 46 | "development": [ 47 | "last 1 chrome version", 48 | "last 1 firefox version", 49 | "last 1 safari version" 50 | ] 51 | }, 52 | "devDependencies": { 53 | "autoprefixer": "^10.4.7", 54 | "postcss": "^8.4.13", 55 | "tailwindcss": "^3.0.24" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # Enable Buildkit and let compose use it to speed up image building 4 | env: 5 | DOCKER_BUILDKIT: 1 6 | COMPOSE_DOCKER_CLI_BUILD: 1 7 | 8 | on: 9 | pull_request: 10 | branches: [ "master", "main" ] 11 | paths-ignore: [ "docs/**" ] 12 | 13 | push: 14 | branches: [ "master", "main" ] 15 | paths-ignore: [ "docs/**" ] 16 | 17 | concurrency: 18 | group: ${{ github.head_ref || github.run_id }} 19 | cancel-in-progress: true 20 | 21 | jobs: 22 | linter: 23 | runs-on: ubuntu-latest 24 | steps: 25 | 26 | - name: Checkout Code Repository 27 | uses: actions/checkout@v3 28 | 29 | - name: Set up Python 30 | uses: actions/setup-python@v3 31 | with: 32 | python-version: "3.9" 33 | cache: pip 34 | cache-dependency-path: | 35 | requirements/base.txt 36 | requirements/local.txt 37 | 38 | - name: Run pre-commit 39 | uses: pre-commit/action@v2.0.3 40 | 41 | # With no caching at all the entire ci process takes 4m 30s to complete! 42 | pytest: 43 | runs-on: ubuntu-latest 44 | 45 | steps: 46 | 47 | - name: Checkout Code Repository 48 | uses: actions/checkout@v3 49 | 50 | - name: Build the Stack 51 | run: docker-compose -f local.yml build 52 | 53 | - name: Run DB Migrations 54 | run: docker-compose -f local.yml run --rm django python manage.py migrate 55 | 56 | - name: Run Django Tests 57 | run: docker-compose -f local.yml run django pytest 58 | 59 | - name: Tear down the Stack 60 | run: docker-compose -f local.yml down 61 | -------------------------------------------------------------------------------- /conversa_dj/chats/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-05-19 17:01 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import uuid 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Conversation', 20 | fields=[ 21 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 22 | ('name', models.CharField(max_length=128)), 23 | ('online', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)), 24 | ], 25 | ), 26 | migrations.CreateModel( 27 | name='Message', 28 | fields=[ 29 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 30 | ('content', models.CharField(max_length=512)), 31 | ('timestamp', models.DateTimeField(auto_now_add=True)), 32 | ('read', models.BooleanField(default=False)), 33 | ('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='chats.conversation')), 34 | ('from_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages_from_me', to=settings.AUTH_USER_MODEL)), 35 | ('to_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages_to_me', to=settings.AUTH_USER_MODEL)), 36 | ], 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /.idea/conversa_dj.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 24 | 25 | 27 | 28 | 31 | 32 | 38 | 39 | 40 | 43 | 44 | -------------------------------------------------------------------------------- /config/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for Conversa Django project. 3 | 4 | This module contains the WSGI application used by Django's development server 5 | and any production WSGI deployments. It should expose a module-level variable 6 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 7 | this application via the ``WSGI_APPLICATION`` setting. 8 | 9 | Usually you will have the standard Django WSGI application here, but it also 10 | might make sense to replace the whole Django WSGI application with a custom one 11 | that later delegates to the Django one. For example, you could introduce WSGI 12 | middleware here, or combine a Django application with an application of another 13 | framework. 14 | 15 | """ 16 | import os 17 | import sys 18 | from pathlib import Path 19 | 20 | from django.core.wsgi import get_wsgi_application 21 | 22 | # This allows easy placement of apps within the interior 23 | # conversa_dj directory. 24 | ROOT_DIR = Path(__file__).resolve(strict=True).parent.parent 25 | sys.path.append(str(ROOT_DIR / "conversa_dj")) 26 | # We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks 27 | # if running multiple sites in the same mod_wsgi process. To fix this, use 28 | # mod_wsgi daemon mode with each site in its own daemon process, or use 29 | # os.environ["DJANGO_SETTINGS_MODULE"] = "config.settings.production" 30 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") 31 | 32 | # This application object is used by any WSGI server configured to use this 33 | # file. This includes Django's development server, if the WSGI_APPLICATION 34 | # setting points here. 35 | application = get_wsgi_application() 36 | # Apply WSGI middleware here. 37 | # from helloworld.wsgi import HelloWorldApplication 38 | # application = HelloWorldApplication(application) 39 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { BrowserRouter, Routes, Route } from "react-router-dom"; 3 | import { ActiveConversations } from "./components/ActiveConversations"; 4 | import { Chat } from "./components/Chat"; 5 | import { Conversations } from "./components/Conversations"; 6 | import { Login } from "./components/Login"; 7 | import { Navbar } from "./components/Navbar"; 8 | import { ProtectedRoute } from "./components/ProtectedRoute"; 9 | import { AuthContextProvider } from "./contexts/AuthContext"; 10 | import { NotificationContextProvider } from "./contexts/NotificationContext"; 11 | 12 | export default function App() { 13 | return ( 14 | 15 | 16 | 20 | 21 | 22 | 23 | 24 | } 25 | > 26 | 30 | 31 | 32 | } 33 | /> 34 | 38 | 39 | 40 | } 41 | /> 42 | 46 | 47 | 48 | } 49 | /> 50 | } /> 51 | 52 | 53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /conversa_dj/users/api/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from rest_framework import status 3 | from rest_framework.authtoken.models import Token 4 | from rest_framework.decorators import action 5 | from rest_framework.mixins import ListModelMixin, RetrieveModelMixin, UpdateModelMixin 6 | from rest_framework.response import Response 7 | from rest_framework.viewsets import GenericViewSet 8 | from rest_framework.authtoken.views import ObtainAuthToken 9 | 10 | from .serializers import UserSerializer 11 | 12 | User = get_user_model() 13 | 14 | 15 | class UserViewSet(RetrieveModelMixin, ListModelMixin, UpdateModelMixin, GenericViewSet): 16 | serializer_class = UserSerializer 17 | queryset = User.objects.all() 18 | lookup_field = "username" 19 | 20 | def get_queryset(self, *args, **kwargs): 21 | assert isinstance(self.request.user.id, int) 22 | return self.queryset.filter(id=self.request.user.id) 23 | 24 | @action(detail=False) 25 | def me(self, request): 26 | serializer = UserSerializer(request.user, context={"request": request}) 27 | return Response(status=status.HTTP_200_OK, data=serializer.data) 28 | 29 | @action(detail=False) 30 | def all(self, request): 31 | serializer = UserSerializer( 32 | User.objects.all(), many=True, context={"request": request} 33 | ) 34 | return Response(status=status.HTTP_200_OK, data=serializer.data) 35 | 36 | 37 | class CustomObtainAuthTokenView(ObtainAuthToken): 38 | def post(self, request, *args, **kwargs): 39 | serializer = self.get_serializer(data=request.data) 40 | serializer.is_valid(raise_exception=True) 41 | user = serializer.validated_data["user"] 42 | token, created = Token.objects.get_or_create(user=user) 43 | return Response({"token": token.key, "username": user.username}) 44 | -------------------------------------------------------------------------------- /compose/production/postgres/maintenance/restore: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | ### Restore database from a backup. 5 | ### 6 | ### Parameters: 7 | ### <1> filename of an existing backup. 8 | ### 9 | ### Usage: 10 | ### $ docker-compose -f .yml (exec |run --rm) postgres restore <1> 11 | 12 | 13 | set -o errexit 14 | set -o pipefail 15 | set -o nounset 16 | 17 | 18 | working_dir="$(dirname ${0})" 19 | source "${working_dir}/_sourced/constants.sh" 20 | source "${working_dir}/_sourced/messages.sh" 21 | 22 | 23 | if [[ -z ${1+x} ]]; then 24 | message_error "Backup filename is not specified yet it is a required parameter. Make sure you provide one and try again." 25 | exit 1 26 | fi 27 | backup_filename="${BACKUP_DIR_PATH}/${1}" 28 | if [[ ! -f "${backup_filename}" ]]; then 29 | message_error "No backup with the specified filename found. Check out the 'backups' maintenance script output to see if there is one and try again." 30 | exit 1 31 | fi 32 | 33 | message_welcome "Restoring the '${POSTGRES_DB}' database from the '${backup_filename}' backup..." 34 | 35 | if [[ "${POSTGRES_USER}" == "postgres" ]]; then 36 | message_error "Restoring as 'postgres' user is not supported. Assign 'POSTGRES_USER' env with another one and try again." 37 | exit 1 38 | fi 39 | 40 | export PGHOST="${POSTGRES_HOST}" 41 | export PGPORT="${POSTGRES_PORT}" 42 | export PGUSER="${POSTGRES_USER}" 43 | export PGPASSWORD="${POSTGRES_PASSWORD}" 44 | export PGDATABASE="${POSTGRES_DB}" 45 | 46 | message_info "Dropping the database..." 47 | dropdb "${PGDATABASE}" 48 | 49 | message_info "Creating a new database..." 50 | createdb --owner="${POSTGRES_USER}" 51 | 52 | message_info "Applying the backup to the new database..." 53 | gunzip -c "${backup_filename}" | psql "${POSTGRES_DB}" 54 | 55 | message_success "The '${POSTGRES_DB}' database has been restored from the '${backup_filename}' backup." 56 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
    32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /production.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | volumes: 4 | production_postgres_data: {} 5 | production_postgres_data_backups: {} 6 | production_traefik: {} 7 | 8 | services: 9 | django: &django 10 | build: 11 | context: . 12 | dockerfile: ./compose/production/django/Dockerfile 13 | image: conversa_dj_production_django 14 | platform: linux/x86_64 15 | depends_on: 16 | - postgres 17 | - redis 18 | env_file: 19 | - ./.envs/.production/.django 20 | - ./.envs/.production/.postgres 21 | command: /start 22 | 23 | postgres: 24 | build: 25 | context: . 26 | dockerfile: ./compose/production/postgres/Dockerfile 27 | image: conversa_dj_production_postgres 28 | volumes: 29 | - production_postgres_data:/var/lib/postgresql/data:Z 30 | - production_postgres_data_backups:/backups:z 31 | env_file: 32 | - ./.envs/.production/.postgres 33 | 34 | traefik: 35 | build: 36 | context: . 37 | dockerfile: ./compose/production/traefik/Dockerfile 38 | image: conversa_dj_production_traefik 39 | depends_on: 40 | - django 41 | volumes: 42 | - production_traefik:/etc/traefik/acme:z 43 | ports: 44 | - "0.0.0.0:80:80" 45 | - "0.0.0.0:443:443" 46 | - "0.0.0.0:5555:5555" 47 | 48 | redis: 49 | image: redis:6 50 | 51 | celeryworker: 52 | <<: *django 53 | image: conversa_dj_production_celeryworker 54 | command: /start-celeryworker 55 | 56 | celerybeat: 57 | <<: *django 58 | image: conversa_dj_production_celerybeat 59 | command: /start-celerybeat 60 | 61 | flower: 62 | <<: *django 63 | image: conversa_dj_production_flower 64 | command: /start-flower 65 | 66 | awscli: 67 | build: 68 | context: . 69 | dockerfile: ./compose/production/aws/Dockerfile 70 | env_file: 71 | - ./.envs/.production/.django 72 | volumes: 73 | - production_postgres_data_backups:/backups:z 74 | -------------------------------------------------------------------------------- /conversa_dj/templates/account/login.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load account socialaccount %} 5 | {% load crispy_forms_tags %} 6 | 7 | {% block head_title %}{% translate "Sign In" %}{% endblock %} 8 | 9 | {% block inner %} 10 | 11 |

    {% translate "Sign In" %}

    12 | 13 | {% get_providers as socialaccount_providers %} 14 | 15 | {% if socialaccount_providers %} 16 |

    17 | {% translate "Please sign in with one of your existing third party accounts:" %} 18 | {% if ACCOUNT_ALLOW_REGISTRATION %} 19 | {% blocktranslate trimmed %} 20 | Or, sign up 21 | for a {{ site_name }} account and sign in below: 22 | {% endblocktranslate %} 23 | {% endif %} 24 |

    25 | 26 |
    27 | 28 |
      29 | {% include "socialaccount/snippets/provider_list.html" with process="login" %} 30 |
    31 | 32 | 33 | 34 |
    35 | 36 | {% include "socialaccount/snippets/login_extra.html" %} 37 | 38 | {% else %} 39 | {% if ACCOUNT_ALLOW_REGISTRATION %} 40 |

    41 | {% blocktranslate trimmed %} 42 | If you have not created an account yet, then please 43 | sign up first. 44 | {% endblocktranslate %} 45 |

    46 | {% endif %} 47 | {% endif %} 48 | 49 | 58 | 59 | {% endblock %} 60 | -------------------------------------------------------------------------------- /requirements/local.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | Werkzeug[watchdog]==2.0.3 # https://github.com/pallets/werkzeug 4 | ipdb==0.13.9 # https://github.com/gotcha/ipdb 5 | psycopg2==2.9.3 # https://github.com/psycopg/psycopg2 6 | watchgod==0.8.2 # https://github.com/samuelcolvin/watchgod 7 | 8 | # Testing 9 | # ------------------------------------------------------------------------------ 10 | mypy==0.950 # https://github.com/python/mypy 11 | django-stubs==1.9.0 # https://github.com/typeddjango/django-stubs 12 | pytest==7.1.2 # https://github.com/pytest-dev/pytest 13 | pytest-sugar==0.9.4 # https://github.com/Frozenball/pytest-sugar 14 | djangorestframework-stubs==1.4.0 # https://github.com/typeddjango/djangorestframework-stubs 15 | 16 | # Documentation 17 | # ------------------------------------------------------------------------------ 18 | sphinx==4.5.0 # https://github.com/sphinx-doc/sphinx 19 | sphinx-autobuild==2021.3.14 # https://github.com/GaretJax/sphinx-autobuild 20 | 21 | # Code quality 22 | # ------------------------------------------------------------------------------ 23 | flake8==4.0.1 # https://github.com/PyCQA/flake8 24 | flake8-isort==4.1.1 # https://github.com/gforcada/flake8-isort 25 | coverage==6.3.3 # https://github.com/nedbat/coveragepy 26 | black==22.3.0 # https://github.com/psf/black 27 | pylint-django==2.5.3 # https://github.com/PyCQA/pylint-django 28 | pylint-celery==0.3 # https://github.com/PyCQA/pylint-celery 29 | pre-commit==2.19.0 # https://github.com/pre-commit/pre-commit 30 | 31 | # Django 32 | # ------------------------------------------------------------------------------ 33 | factory-boy==3.2.1 # https://github.com/FactoryBoy/factory_boy 34 | 35 | django-debug-toolbar==3.4.0 # https://github.com/jazzband/django-debug-toolbar 36 | django-extensions==3.1.5 # https://github.com/django-extensions/django-extensions 37 | django-coverage-plugin==2.0.3 # https://github.com/nedbat/django_coverage_plugin 38 | pytest-django==4.5.2 # https://github.com/pytest-dev/pytest-django 39 | -------------------------------------------------------------------------------- /compose/production/traefik/traefik.yml: -------------------------------------------------------------------------------- 1 | log: 2 | level: INFO 3 | 4 | entryPoints: 5 | web: 6 | # http 7 | address: ":80" 8 | http: 9 | # https://docs.traefik.io/routing/entrypoints/#entrypoint 10 | redirections: 11 | entryPoint: 12 | to: web-secure 13 | 14 | web-secure: 15 | # https 16 | address: ":443" 17 | 18 | flower: 19 | address: ":5555" 20 | 21 | certificatesResolvers: 22 | letsencrypt: 23 | # https://docs.traefik.io/master/https/acme/#lets-encrypt 24 | acme: 25 | email: "matt@justdjango.com" 26 | storage: /etc/traefik/acme/acme.json 27 | # https://docs.traefik.io/master/https/acme/#httpchallenge 28 | httpChallenge: 29 | entryPoint: web 30 | 31 | http: 32 | routers: 33 | web-secure-router: 34 | rule: "Host(`chat.justdjango.com`)" 35 | entryPoints: 36 | - web-secure 37 | middlewares: 38 | - csrf 39 | service: django 40 | tls: 41 | # https://docs.traefik.io/master/routing/routers/#certresolver 42 | certResolver: letsencrypt 43 | 44 | flower-secure-router: 45 | rule: "Host(`chat.justdjango.com`)" 46 | entryPoints: 47 | - flower 48 | service: flower 49 | tls: 50 | # https://docs.traefik.io/master/routing/routers/#certresolver 51 | certResolver: letsencrypt 52 | 53 | middlewares: 54 | csrf: 55 | # https://docs.traefik.io/master/middlewares/headers/#hostsproxyheaders 56 | # https://docs.djangoproject.com/en/dev/ref/csrf/#ajax 57 | headers: 58 | hostsProxyHeaders: ["X-CSRFToken"] 59 | 60 | services: 61 | django: 62 | loadBalancer: 63 | servers: 64 | - url: http://django:5000 65 | 66 | flower: 67 | loadBalancer: 68 | servers: 69 | - url: http://flower:5555 70 | 71 | providers: 72 | # https://docs.traefik.io/master/providers/file/ 73 | file: 74 | filename: /etc/traefik/traefik.yml 75 | watch: true 76 | -------------------------------------------------------------------------------- /frontend/src/components/ActiveConversations.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import { AuthContext } from "../contexts/AuthContext"; 4 | import { ConversationModel } from "../models/Conversation"; 5 | 6 | export function ActiveConversations() { 7 | const { user } = useContext(AuthContext); 8 | const [conversations, setActiveConversations] = useState( 9 | [] 10 | ); 11 | 12 | useEffect(() => { 13 | async function fetchUsers() { 14 | const res = await fetch("http://127.0.0.1:8000/api/conversations/", { 15 | headers: { 16 | Authorization: `Token ${user?.token}`, 17 | }, 18 | }); 19 | const data = await res.json(); 20 | setActiveConversations(data); 21 | } 22 | fetchUsers(); 23 | }, [user]); 24 | 25 | function createConversationName(username: string) { 26 | const namesAlph = [user?.username, username].sort(); 27 | return `${namesAlph[0]}__${namesAlph[1]}`; 28 | } 29 | 30 | function formatMessageTimestamp(timestamp?: string) { 31 | if (!timestamp) return; 32 | const date = new Date(timestamp); 33 | return date.toLocaleTimeString().slice(0, 5); 34 | } 35 | 36 | return ( 37 |
    38 | {conversations.map((c) => ( 39 | 43 |
    44 |

    45 | {c.other_user.username} 46 |

    47 |
    48 |

    {c.last_message?.content}

    49 |

    50 | {formatMessageTimestamp(c.last_message?.timestamp)} 51 |

    52 |
    53 |
    54 | 55 | ))} 56 |
    57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /frontend/src/contexts/AuthContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, ReactNode, useState } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | import axios, { AxiosInstance } from "axios"; 4 | 5 | import authHeader from "../services/AuthHeader"; 6 | import AuthService from "../services/AuthService"; 7 | import { UserModel } from "../models/User"; 8 | 9 | const DefaultProps = { 10 | login: () => null, 11 | logout: () => null, 12 | authAxios: axios, 13 | user: null, 14 | }; 15 | 16 | export interface AuthProps { 17 | login: (username: string, password: string) => any; 18 | logout: () => void; 19 | authAxios: AxiosInstance; 20 | user: UserModel | null; 21 | } 22 | 23 | export const AuthContext = createContext(DefaultProps); 24 | 25 | export const AuthContextProvider: React.FC<{ children: ReactNode }> = ({ 26 | children, 27 | }) => { 28 | const navigate = useNavigate(); 29 | const [user, setUser] = useState(() => AuthService.getCurrentUser()); 30 | 31 | async function login(username: string, password: string) { 32 | const data = await AuthService.login(username, password); 33 | setUser(data); 34 | return data; 35 | } 36 | 37 | function logout() { 38 | AuthService.logout(); 39 | setUser(null); 40 | navigate("/login"); 41 | } 42 | 43 | // axios instance for making requests 44 | const authAxios = axios.create(); 45 | 46 | // request interceptor for adding token 47 | authAxios.interceptors.request.use((config) => { 48 | // add token to request headers 49 | config.headers = authHeader(); 50 | return config; 51 | }); 52 | 53 | authAxios.interceptors.response.use( 54 | (response) => { 55 | return response; 56 | }, 57 | (error) => { 58 | if (error.response.status === 401) { 59 | logout(); 60 | } 61 | return Promise.reject(error); 62 | } 63 | ); 64 | 65 | return ( 66 | 67 | {children} 68 | 69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /conversa_dj/chats/api/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from django.contrib.auth import get_user_model 3 | from conversa_dj.chats.models import Message, Conversation 4 | from conversa_dj.users.api.serializers import UserSerializer 5 | 6 | 7 | User = get_user_model() 8 | 9 | 10 | class MessageSerializer(serializers.ModelSerializer): 11 | from_user = serializers.SerializerMethodField() 12 | to_user = serializers.SerializerMethodField() 13 | conversation = serializers.SerializerMethodField() 14 | 15 | class Meta: 16 | model = Message 17 | fields = ( 18 | "id", 19 | "conversation", 20 | "from_user", 21 | "to_user", 22 | "content", 23 | "timestamp", 24 | "read", 25 | ) 26 | 27 | def get_conversation(self, obj): 28 | return str(obj.conversation.id) 29 | 30 | def get_from_user(self, obj): 31 | return UserSerializer(obj.from_user).data 32 | 33 | def get_to_user(self, obj): 34 | return UserSerializer(obj.to_user).data 35 | 36 | 37 | class ConversationSerializer(serializers.ModelSerializer): 38 | other_user = serializers.SerializerMethodField() 39 | last_message = serializers.SerializerMethodField() 40 | 41 | class Meta: 42 | model = Conversation 43 | fields = ("id", "name", "other_user", "last_message") 44 | 45 | def get_last_message(self, obj): 46 | messages = obj.messages.all().order_by("-timestamp") 47 | if not messages.exists(): 48 | return None 49 | message = messages[0] 50 | return MessageSerializer(message).data 51 | 52 | def get_other_user(self, obj): 53 | usernames = obj.name.split("__") 54 | context = {} 55 | for username in usernames: 56 | if username != self.context["user"].username: 57 | # This is the other participant 58 | other_user = User.objects.get(username=username) 59 | return UserSerializer(other_user, context=context).data 60 | -------------------------------------------------------------------------------- /compose/local/docs/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PYTHON_VERSION=3.9-slim-bullseye 2 | 3 | # define an alias for the specfic python version used in this file. 4 | FROM python:${PYTHON_VERSION} as python 5 | 6 | 7 | # Python build stage 8 | FROM python as python-build-stage 9 | 10 | ENV PYTHONDONTWRITEBYTECODE 1 11 | 12 | RUN apt-get update && apt-get install --no-install-recommends -y \ 13 | # dependencies for building Python packages 14 | build-essential \ 15 | # psycopg2 dependencies 16 | libpq-dev \ 17 | # cleaning up unused files 18 | && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ 19 | && rm -rf /var/lib/apt/lists/* 20 | 21 | # Requirements are installed here to ensure they will be cached. 22 | COPY ./requirements /requirements 23 | 24 | # create python dependency wheels 25 | RUN pip wheel --no-cache-dir --no-deps --wheel-dir /usr/src/app/wheels \ 26 | -r /requirements/local.txt -r /requirements/production.txt \ 27 | && rm -rf /requirements 28 | 29 | 30 | # Python 'run' stage 31 | FROM python as python-run-stage 32 | 33 | ARG BUILD_ENVIRONMENT 34 | ENV PYTHONUNBUFFERED 1 35 | ENV PYTHONDONTWRITEBYTECODE 1 36 | 37 | RUN apt-get update && apt-get install --no-install-recommends -y \ 38 | # To run the Makefile 39 | make \ 40 | # psycopg2 dependencies 41 | libpq-dev \ 42 | # Translations dependencies 43 | gettext \ 44 | # Uncomment below lines to enable Sphinx output to latex and pdf 45 | # texlive-latex-recommended \ 46 | # texlive-fonts-recommended \ 47 | # texlive-latex-extra \ 48 | # latexmk \ 49 | # cleaning up unused files 50 | && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ 51 | && rm -rf /var/lib/apt/lists/* 52 | 53 | # copy python dependency wheels from python-build-stage 54 | COPY --from=python-build-stage /usr/src/app/wheels /wheels 55 | 56 | # use wheels to install python dependencies 57 | RUN pip install --no-cache /wheels/* \ 58 | && rm -rf /wheels 59 | 60 | COPY ./compose/local/docs/start /start-docs 61 | RUN sed -i 's/\r$//g' /start-docs 62 | RUN chmod +x /start-docs 63 | 64 | WORKDIR /docs 65 | -------------------------------------------------------------------------------- /frontend/src/contexts/NotificationContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, ReactNode, useContext, useState } from "react"; 2 | import useWebSocket, { ReadyState } from "react-use-websocket"; 3 | import { AuthContext } from "./AuthContext"; 4 | 5 | const DefaultProps = { 6 | unreadMessageCount: 0, 7 | connectionStatus: "Uninstantiated", 8 | }; 9 | 10 | export interface NotificationProps { 11 | unreadMessageCount: number; 12 | connectionStatus: string; 13 | } 14 | 15 | export const NotificationContext = 16 | createContext(DefaultProps); 17 | 18 | export const NotificationContextProvider: React.FC<{ children: ReactNode }> = ({ 19 | children, 20 | }) => { 21 | const { user } = useContext(AuthContext); 22 | const [unreadMessageCount, setUnreadMessageCount] = useState(0); 23 | 24 | const { readyState } = useWebSocket( 25 | user ? `ws://127.0.0.1:8000/notifications/` : null, 26 | { 27 | queryParams: { 28 | token: user ? user.token : "", 29 | }, 30 | onOpen: () => { 31 | console.log("Connected to Notifications!"); 32 | }, 33 | onClose: () => { 34 | console.log("Disconnected from Notifications!"); 35 | }, 36 | onMessage: (e) => { 37 | const data = JSON.parse(e.data); 38 | switch (data.type) { 39 | case "unread_count": 40 | setUnreadMessageCount(data.unread_count); 41 | break; 42 | case "new_message_notification": 43 | setUnreadMessageCount((count) => (count += 1)); 44 | break; 45 | default: 46 | console.error("Unknown message type!"); 47 | break; 48 | } 49 | }, 50 | } 51 | ); 52 | 53 | const connectionStatus = { 54 | [ReadyState.CONNECTING]: "Connecting", 55 | [ReadyState.OPEN]: "Open", 56 | [ReadyState.CLOSING]: "Closing", 57 | [ReadyState.CLOSED]: "Closed", 58 | [ReadyState.UNINSTANTIATED]: "Uninstantiated", 59 | }[readyState]; 60 | 61 | return ( 62 | 65 | {children} 66 | 67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /merge_production_dotenvs_in_dotenv.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from typing import Sequence 4 | 5 | import pytest 6 | 7 | ROOT_DIR_PATH = Path(__file__).parent.resolve() 8 | PRODUCTION_DOTENVS_DIR_PATH = ROOT_DIR_PATH / ".envs" / ".production" 9 | PRODUCTION_DOTENV_FILE_PATHS = [ 10 | PRODUCTION_DOTENVS_DIR_PATH / ".django", 11 | PRODUCTION_DOTENVS_DIR_PATH / ".postgres", 12 | ] 13 | DOTENV_FILE_PATH = ROOT_DIR_PATH / ".env" 14 | 15 | 16 | def merge( 17 | output_file_path: str, merged_file_paths: Sequence[str], append_linesep: bool = True 18 | ) -> None: 19 | with open(output_file_path, "w") as output_file: 20 | for merged_file_path in merged_file_paths: 21 | with open(merged_file_path) as merged_file: 22 | merged_file_content = merged_file.read() 23 | output_file.write(merged_file_content) 24 | if append_linesep: 25 | output_file.write(os.linesep) 26 | 27 | 28 | def main(): 29 | merge(DOTENV_FILE_PATH, PRODUCTION_DOTENV_FILE_PATHS) 30 | 31 | 32 | @pytest.mark.parametrize("merged_file_count", range(3)) 33 | @pytest.mark.parametrize("append_linesep", [True, False]) 34 | def test_merge(tmpdir_factory, merged_file_count: int, append_linesep: bool): 35 | tmp_dir_path = Path(str(tmpdir_factory.getbasetemp())) 36 | 37 | output_file_path = tmp_dir_path / ".env" 38 | 39 | expected_output_file_content = "" 40 | merged_file_paths = [] 41 | for i in range(merged_file_count): 42 | merged_file_ord = i + 1 43 | 44 | merged_filename = f".service{merged_file_ord}" 45 | merged_file_path = tmp_dir_path / merged_filename 46 | 47 | merged_file_content = merged_filename * merged_file_ord 48 | 49 | with open(merged_file_path, "w+") as file: 50 | file.write(merged_file_content) 51 | 52 | expected_output_file_content += merged_file_content 53 | if append_linesep: 54 | expected_output_file_content += os.linesep 55 | 56 | merged_file_paths.append(merged_file_path) 57 | 58 | merge(output_file_path, merged_file_paths, append_linesep) 59 | 60 | with open(output_file_path) as output_file: 61 | actual_output_file_content = output_file.read() 62 | 63 | assert actual_output_file_content == expected_output_file_content 64 | 65 | 66 | if __name__ == "__main__": 67 | main() 68 | -------------------------------------------------------------------------------- /local.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | volumes: 4 | conversa_dj_local_postgres_data: {} 5 | conversa_dj_local_postgres_data_backups: {} 6 | 7 | services: 8 | django: &django 9 | build: 10 | context: . 11 | dockerfile: ./compose/local/django/Dockerfile 12 | image: conversa_dj_local_django 13 | container_name: conversa_dj_local_django 14 | platform: linux/x86_64 15 | depends_on: 16 | - postgres 17 | - redis 18 | volumes: 19 | - .:/app:z 20 | env_file: 21 | - ./.envs/.local/.django 22 | - ./.envs/.local/.postgres 23 | ports: 24 | - "8000:8000" 25 | command: /start 26 | 27 | postgres: 28 | build: 29 | context: . 30 | dockerfile: ./compose/production/postgres/Dockerfile 31 | image: conversa_dj_production_postgres 32 | container_name: conversa_dj_local_postgres 33 | volumes: 34 | - conversa_dj_local_postgres_data:/var/lib/postgresql/data:Z 35 | - conversa_dj_local_postgres_data_backups:/backups:z 36 | env_file: 37 | - ./.envs/.local/.postgres 38 | ports: 39 | - "5433:5432" 40 | 41 | docs: 42 | image: conversa_dj_local_docs 43 | container_name: conversa_dj_local_docs 44 | platform: linux/x86_64 45 | build: 46 | context: . 47 | dockerfile: ./compose/local/docs/Dockerfile 48 | env_file: 49 | - ./.envs/.local/.django 50 | volumes: 51 | - ./docs:/docs:z 52 | - ./config:/app/config:z 53 | - ./conversa_dj:/app/conversa_dj:z 54 | ports: 55 | - "9000:9000" 56 | command: /start-docs 57 | 58 | redis: 59 | image: redis:6 60 | container_name: conversa_dj_local_redis 61 | ports: 62 | - "6379:6379" 63 | 64 | celeryworker: 65 | <<: *django 66 | image: conversa_dj_local_celeryworker 67 | container_name: conversa_dj_local_celeryworker 68 | depends_on: 69 | - redis 70 | - postgres 71 | ports: [] 72 | command: /start-celeryworker 73 | 74 | celerybeat: 75 | <<: *django 76 | image: conversa_dj_local_celerybeat 77 | container_name: conversa_dj_local_celerybeat 78 | depends_on: 79 | - redis 80 | - postgres 81 | ports: [] 82 | command: /start-celerybeat 83 | 84 | flower: 85 | <<: *django 86 | image: conversa_dj_local_flower 87 | container_name: conversa_dj_local_flower 88 | ports: 89 | - "5555:5555" 90 | command: /start-flower 91 | -------------------------------------------------------------------------------- /config/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls.static import static 3 | from django.contrib import admin 4 | from django.urls import include, path 5 | from django.views import defaults as default_views 6 | from django.views.generic import TemplateView 7 | from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView 8 | 9 | from conversa_dj.users.api.views import CustomObtainAuthTokenView 10 | 11 | urlpatterns = [ 12 | path("", TemplateView.as_view(template_name="pages/home.html"), name="home"), 13 | path( 14 | "about/", TemplateView.as_view(template_name="pages/about.html"), name="about" 15 | ), 16 | # Django Admin, use {% url 'admin:index' %} 17 | path(settings.ADMIN_URL, admin.site.urls), 18 | # User management 19 | path("users/", include("conversa_dj.users.urls", namespace="users")), 20 | path("accounts/", include("allauth.urls")), 21 | # Your stuff: custom urls includes go here 22 | ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 23 | 24 | # API URLS 25 | urlpatterns += [ 26 | # API base url 27 | path("api/", include("config.api_router")), 28 | # DRF auth token 29 | path("auth-token/", CustomObtainAuthTokenView.as_view()), 30 | path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"), 31 | path( 32 | "api/docs/", 33 | SpectacularSwaggerView.as_view(url_name="api-schema"), 34 | name="api-docs", 35 | ), 36 | ] 37 | 38 | if settings.DEBUG: 39 | # This allows the error pages to be debugged during development, just visit 40 | # these url in browser to see how these error pages look like. 41 | urlpatterns += [ 42 | path( 43 | "400/", 44 | default_views.bad_request, 45 | kwargs={"exception": Exception("Bad Request!")}, 46 | ), 47 | path( 48 | "403/", 49 | default_views.permission_denied, 50 | kwargs={"exception": Exception("Permission Denied")}, 51 | ), 52 | path( 53 | "404/", 54 | default_views.page_not_found, 55 | kwargs={"exception": Exception("Page not Found")}, 56 | ), 57 | path("500/", default_views.server_error), 58 | ] 59 | if "debug_toolbar" in settings.INSTALLED_APPS: 60 | import debug_toolbar 61 | 62 | urlpatterns = [path("__debug__/", include(debug_toolbar.urls))] + urlpatterns 63 | -------------------------------------------------------------------------------- /conversa_dj/contrib/sites/migrations/0003_set_site_domain_and_name.py: -------------------------------------------------------------------------------- 1 | """ 2 | To understand why this file is here, please read: 3 | 4 | http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django 5 | """ 6 | from django.conf import settings 7 | from django.db import migrations 8 | 9 | 10 | def _update_or_create_site_with_sequence(site_model, connection, domain, name): 11 | """Update or create the site with default ID and keep the DB sequence in sync.""" 12 | site, created = site_model.objects.update_or_create( 13 | id=settings.SITE_ID, 14 | defaults={ 15 | "domain": domain, 16 | "name": name, 17 | }, 18 | ) 19 | if created: 20 | # We provided the ID explicitly when creating the Site entry, therefore the DB 21 | # sequence to auto-generate them wasn't used and is now out of sync. If we 22 | # don't do anything, we'll get a unique constraint violation the next time a 23 | # site is created. 24 | # To avoid this, we need to manually update DB sequence and make sure it's 25 | # greater than the maximum value. 26 | max_id = site_model.objects.order_by('-id').first().id 27 | with connection.cursor() as cursor: 28 | cursor.execute("SELECT last_value from django_site_id_seq") 29 | (current_id,) = cursor.fetchone() 30 | if current_id <= max_id: 31 | cursor.execute( 32 | "alter sequence django_site_id_seq restart with %s", 33 | [max_id + 1], 34 | ) 35 | 36 | 37 | def update_site_forward(apps, schema_editor): 38 | """Set site domain and name.""" 39 | Site = apps.get_model("sites", "Site") 40 | _update_or_create_site_with_sequence( 41 | Site, 42 | schema_editor.connection, 43 | "chat.justdjango.com", 44 | "Conversa Django", 45 | ) 46 | 47 | 48 | def update_site_backward(apps, schema_editor): 49 | """Revert site domain and name to default.""" 50 | Site = apps.get_model("sites", "Site") 51 | _update_or_create_site_with_sequence( 52 | Site, 53 | schema_editor.connection, 54 | "example.com", 55 | "example.com", 56 | ) 57 | 58 | 59 | class Migration(migrations.Migration): 60 | 61 | dependencies = [("sites", "0002_alter_domain_unique")] 62 | 63 | operations = [migrations.RunPython(update_site_forward, update_site_backward)] 64 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | 13 | import os 14 | import sys 15 | import django 16 | 17 | if os.getenv("READTHEDOCS", default=False) == "True": 18 | sys.path.insert(0, os.path.abspath("..")) 19 | os.environ["DJANGO_READ_DOT_ENV_FILE"] = "True" 20 | os.environ["USE_DOCKER"] = "no" 21 | else: 22 | sys.path.insert(0, os.path.abspath("/app")) 23 | os.environ["DATABASE_URL"] = "sqlite:///readthedocs.db" 24 | os.environ["CELERY_BROKER_URL"] = os.getenv("REDIS_URL", "redis://redis:6379") 25 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local") 26 | django.setup() 27 | 28 | # -- Project information ----------------------------------------------------- 29 | 30 | project = "Conversa Django" 31 | copyright = """2022, Matthew Freire""" 32 | author = "Matthew Freire" 33 | 34 | 35 | # -- General configuration --------------------------------------------------- 36 | 37 | # Add any Sphinx extension module names here, as strings. They can be 38 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 39 | # ones. 40 | extensions = [ 41 | "sphinx.ext.autodoc", 42 | "sphinx.ext.napoleon", 43 | ] 44 | 45 | # Add any paths that contain templates here, relative to this directory. 46 | # templates_path = ["_templates"] 47 | 48 | # List of patterns, relative to source directory, that match files and 49 | # directories to ignore when looking for source files. 50 | # This pattern also affects html_static_path and html_extra_path. 51 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 52 | 53 | # -- Options for HTML output ------------------------------------------------- 54 | 55 | # The theme to use for HTML and HTML Help pages. See the documentation for 56 | # a list of builtin themes. 57 | # 58 | html_theme = "alabaster" 59 | 60 | # Add any paths that contain custom static files (such as style sheets) here, 61 | # relative to this directory. They are copied after the builtin static files, 62 | # so a file named "default.css" will overwrite the builtin "default.css". 63 | # html_static_path = ["_static"] 64 | -------------------------------------------------------------------------------- /compose/local/django/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PYTHON_VERSION=3.9-slim-bullseye 2 | 3 | # define an alias for the specfic python version used in this file. 4 | FROM python:${PYTHON_VERSION} as python 5 | 6 | # Python build stage 7 | FROM python as python-build-stage 8 | 9 | ARG BUILD_ENVIRONMENT=local 10 | 11 | # Install apt packages 12 | RUN apt-get update && apt-get install --no-install-recommends -y \ 13 | # dependencies for building Python packages 14 | build-essential \ 15 | # psycopg2 dependencies 16 | libpq-dev 17 | 18 | # Requirements are installed here to ensure they will be cached. 19 | COPY ./requirements . 20 | 21 | # Create Python Dependency and Sub-Dependency Wheels. 22 | RUN pip wheel --wheel-dir /usr/src/app/wheels \ 23 | -r ${BUILD_ENVIRONMENT}.txt 24 | 25 | 26 | # Python 'run' stage 27 | FROM python as python-run-stage 28 | 29 | ARG BUILD_ENVIRONMENT=local 30 | ARG APP_HOME=/app 31 | 32 | ENV PYTHONUNBUFFERED 1 33 | ENV PYTHONDONTWRITEBYTECODE 1 34 | ENV BUILD_ENV ${BUILD_ENVIRONMENT} 35 | 36 | WORKDIR ${APP_HOME} 37 | 38 | # Install required system dependencies 39 | RUN apt-get update && apt-get install --no-install-recommends -y \ 40 | # psycopg2 dependencies 41 | libpq-dev \ 42 | # Translations dependencies 43 | gettext \ 44 | # cleaning up unused files 45 | && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ 46 | && rm -rf /var/lib/apt/lists/* 47 | 48 | # All absolute dir copies ignore workdir instruction. All relative dir copies are wrt to the workdir instruction 49 | # copy python dependency wheels from python-build-stage 50 | COPY --from=python-build-stage /usr/src/app/wheels /wheels/ 51 | 52 | # use wheels to install python dependencies 53 | RUN pip install --no-cache-dir --no-index --find-links=/wheels/ /wheels/* \ 54 | && rm -rf /wheels/ 55 | 56 | COPY ./compose/production/django/entrypoint /entrypoint 57 | RUN sed -i 's/\r$//g' /entrypoint 58 | RUN chmod +x /entrypoint 59 | 60 | COPY ./compose/local/django/start /start 61 | RUN sed -i 's/\r$//g' /start 62 | RUN chmod +x /start 63 | 64 | 65 | COPY ./compose/local/django/celery/worker/start /start-celeryworker 66 | RUN sed -i 's/\r$//g' /start-celeryworker 67 | RUN chmod +x /start-celeryworker 68 | 69 | COPY ./compose/local/django/celery/beat/start /start-celerybeat 70 | RUN sed -i 's/\r$//g' /start-celerybeat 71 | RUN chmod +x /start-celerybeat 72 | 73 | COPY ./compose/local/django/celery/flower/start /start-flower 74 | RUN sed -i 's/\r$//g' /start-flower 75 | RUN chmod +x /start-flower 76 | 77 | 78 | # copy application code to WORKDIR 79 | COPY . ${APP_HOME} 80 | 81 | ENTRYPOINT ["/entrypoint"] 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Conversa Django 2 | 3 | An awesome Django chat app built with Django Channels 4 | 5 | [![Built with Cookiecutter Django](https://img.shields.io/badge/built%20with-Cookiecutter%20Django-ff69b4.svg?logo=cookiecutter)](https://github.com/cookiecutter/cookiecutter-django/) 6 | [![Black code style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) 7 | 8 | License: MIT 9 | 10 | ## Settings 11 | 12 | Moved to [settings](http://cookiecutter-django.readthedocs.io/en/latest/settings.html). 13 | 14 | ## Basic Commands 15 | 16 | ### Setting Up Your Users 17 | 18 | - To create a **normal user account**, just go to Sign Up and fill out the form. Once you submit it, you'll see a "Verify Your E-mail Address" page. Go to your console to see a simulated email verification message. Copy the link into your browser. Now the user's email should be verified and ready to go. 19 | 20 | - To create a **superuser account**, use this command: 21 | 22 | $ python manage.py createsuperuser 23 | 24 | For convenience, you can keep your normal user logged in on Chrome and your superuser logged in on Firefox (or similar), so that you can see how the site behaves for both kinds of users. 25 | 26 | ### Type checks 27 | 28 | Running type checks with mypy: 29 | 30 | $ mypy conversa_dj 31 | 32 | ### Test coverage 33 | 34 | To run the tests, check your test coverage, and generate an HTML coverage report: 35 | 36 | $ coverage run -m pytest 37 | $ coverage html 38 | $ open htmlcov/index.html 39 | 40 | #### Running tests with pytest 41 | 42 | $ pytest 43 | 44 | ### Live reloading and Sass CSS compilation 45 | 46 | Moved to [Live reloading and SASS compilation](https://cookiecutter-django.readthedocs.io/en/latest/developing-locally.html#sass-compilation-live-reloading). 47 | 48 | ### Celery 49 | 50 | This app comes with Celery. 51 | 52 | To run a celery worker: 53 | 54 | ``` bash 55 | cd conversa_dj 56 | celery -A config.celery_app worker -l info 57 | ``` 58 | 59 | Please note: For Celery's import magic to work, it is important *where* the celery commands are run. If you are in the same folder with *manage.py*, you should be right. 60 | 61 | ### Sentry 62 | 63 | Sentry is an error logging aggregator service. You can sign up for a free account at or download and host it yourself. 64 | The system is set up with reasonable defaults, including 404 logging and integration with the WSGI application. 65 | 66 | You must set the DSN url in production. 67 | 68 | ## Deployment 69 | 70 | The following details how to deploy this application. 71 | 72 | ### Docker 73 | 74 | See detailed [cookiecutter-django Docker documentation](http://cookiecutter-django.readthedocs.io/en/latest/deployment-with-docker.html). 75 | -------------------------------------------------------------------------------- /docs/pycharm/configuration.rst: -------------------------------------------------------------------------------- 1 | Docker Remote Debugging 2 | ======================= 3 | 4 | To connect to python remote interpreter inside docker, you have to make sure first, that Pycharm is aware of your docker. 5 | 6 | Go to *Settings > Build, Execution, Deployment > Docker*. If you are on linux, you can use docker directly using its socket `unix:///var/run/docker.sock`, if you are on Windows or Mac, make sure that you have docker-machine installed, then you can simply *Import credentials from Docker Machine*. 7 | 8 | .. image:: images/1.png 9 | 10 | Configure Remote Python Interpreter 11 | ----------------------------------- 12 | 13 | This repository comes with already prepared "Run/Debug Configurations" for docker. 14 | 15 | .. image:: images/2.png 16 | 17 | But as you can see, at the beginning there is something wrong with them. They have red X on django icon, and they cannot be used, without configuring remote python interpreter. To do that, you have to go to *Settings > Build, Execution, Deployment* first. 18 | 19 | 20 | Next, you have to add new remote python interpreter, based on already tested deployment settings. Go to *Settings > Project > Project Interpreter*. Click on the cog icon, and click *Add Remote*. 21 | 22 | .. image:: images/3.png 23 | 24 | Switch to *Docker Compose* and select `local.yml` file from directory of your project, next set *Service name* to `django` 25 | 26 | .. image:: images/4.png 27 | 28 | Having that, click *OK*. Close *Settings* panel, and wait few seconds... 29 | 30 | .. image:: images/7.png 31 | 32 | After few seconds, all *Run/Debug Configurations* should be ready to use. 33 | 34 | .. image:: images/8.png 35 | 36 | **Things you can do with provided configuration**: 37 | 38 | * run and debug python code 39 | 40 | .. image:: images/f1.png 41 | 42 | * run and debug tests 43 | 44 | .. image:: images/f2.png 45 | .. image:: images/f3.png 46 | 47 | * run and debug migrations or different django management commands 48 | 49 | .. image:: images/f4.png 50 | 51 | * and many others.. 52 | 53 | Known issues 54 | ------------ 55 | 56 | * Pycharm hangs on "Connecting to Debugger" 57 | 58 | .. image:: images/issue1.png 59 | 60 | This might be fault of your firewall. Take a look on this ticket - https://youtrack.jetbrains.com/issue/PY-18913 61 | 62 | * Modified files in `.idea` directory 63 | 64 | Most of the files from `.idea/` were added to `.gitignore` with a few exceptions, which were made, to provide "ready to go" configuration. After adding remote interpreter some of these files are altered by PyCharm: 65 | 66 | .. image:: images/issue2.png 67 | 68 | In theory you can remove them from repository, but then, other people will lose a ability to initialize a project from provided configurations as you did. To get rid of this annoying state, you can run command:: 69 | 70 | $ git update-index --assume-unchanged conversa_dj.iml 71 | -------------------------------------------------------------------------------- /config/settings/local.py: -------------------------------------------------------------------------------- 1 | from .base import * # noqa 2 | from .base import env 3 | 4 | # GENERAL 5 | # ------------------------------------------------------------------------------ 6 | # https://docs.djangoproject.com/en/dev/ref/settings/#debug 7 | DEBUG = True 8 | # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key 9 | SECRET_KEY = env( 10 | "DJANGO_SECRET_KEY", 11 | default="7AIJR98dKTbJakHK8F8MbGfwqLDiRohK5GbraVuMvemJxSqpedPQtyRVuV5BH18H", 12 | ) 13 | # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts 14 | ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1"] 15 | 16 | # CACHES 17 | # ------------------------------------------------------------------------------ 18 | # https://docs.djangoproject.com/en/dev/ref/settings/#caches 19 | CACHES = { 20 | "default": { 21 | "BACKEND": "django.core.cache.backends.locmem.LocMemCache", 22 | "LOCATION": "", 23 | } 24 | } 25 | 26 | # EMAIL 27 | # ------------------------------------------------------------------------------ 28 | # https://docs.djangoproject.com/en/dev/ref/settings/#email-backend 29 | EMAIL_BACKEND = env( 30 | "DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend" 31 | ) 32 | 33 | # django-debug-toolbar 34 | # ------------------------------------------------------------------------------ 35 | # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#prerequisites 36 | INSTALLED_APPS += ["debug_toolbar"] # noqa F405 37 | # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#middleware 38 | MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa F405 39 | # https://django-debug-toolbar.readthedocs.io/en/latest/configuration.html#debug-toolbar-config 40 | DEBUG_TOOLBAR_CONFIG = { 41 | "DISABLE_PANELS": ["debug_toolbar.panels.redirects.RedirectsPanel"], 42 | "SHOW_TEMPLATE_CONTEXT": True, 43 | } 44 | # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#internal-ips 45 | INTERNAL_IPS = ["127.0.0.1", "10.0.2.2"] 46 | if env("USE_DOCKER") == "yes": 47 | import socket 48 | 49 | hostname, _, ips = socket.gethostbyname_ex(socket.gethostname()) 50 | INTERNAL_IPS += [".".join(ip.split(".")[:-1] + ["1"]) for ip in ips] 51 | 52 | # django-extensions 53 | # ------------------------------------------------------------------------------ 54 | # https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration 55 | INSTALLED_APPS += ["django_extensions"] # noqa F405 56 | # Celery 57 | # ------------------------------------------------------------------------------ 58 | 59 | # https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-eager-propagates 60 | CELERY_TASK_EAGER_PROPAGATES = True 61 | # Your stuff... 62 | # ------------------------------------------------------------------------------ 63 | CORS_ALLOWED_ORIGINS = ["http://localhost:3000"] 64 | -------------------------------------------------------------------------------- /frontend/src/components/Login.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useContext, useEffect } from "react"; 2 | import { useFormik } from "formik"; 3 | import { useNavigate } from "react-router-dom"; 4 | import { AuthContext } from "../contexts/AuthContext"; 5 | 6 | export function Login() { 7 | const navigate = useNavigate(); 8 | const [error, setError] = useState(null); 9 | const { user, login } = useContext(AuthContext); 10 | 11 | const formik = useFormik({ 12 | initialValues: { 13 | username: "", 14 | password: "", 15 | }, 16 | onSubmit: async (values, { setSubmitting }) => { 17 | setSubmitting(true); 18 | const { username, password } = values; 19 | const res = await login(username, password); 20 | if (res.error || res.data) { 21 | if (res.data && res.data.detail) { 22 | setError(res.data.detail); 23 | } 24 | } else { 25 | navigate("/"); 26 | } 27 | setSubmitting(false); 28 | }, 29 | }); 30 | 31 | useEffect(() => { 32 | if (user) { 33 | navigate("/"); 34 | } 35 | }, [user]); 36 | 37 | return ( 38 |
    39 |
    40 |
    41 |

    42 | Sign in to your account 43 |

    44 |
    45 | 46 |
    47 | {error &&
    {JSON.stringify(error)}
    } 48 | 49 |
    50 | 58 | 66 |
    67 | 68 | 74 |
    75 |
    76 |
    77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /compose/production/django/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PYTHON_VERSION=3.9-slim-bullseye 2 | 3 | 4 | 5 | # define an alias for the specfic python version used in this file. 6 | FROM python:${PYTHON_VERSION} as python 7 | 8 | # Python build stage 9 | FROM python as python-build-stage 10 | 11 | ARG BUILD_ENVIRONMENT=production 12 | 13 | # Install apt packages 14 | RUN apt-get update && apt-get install --no-install-recommends -y \ 15 | # dependencies for building Python packages 16 | build-essential \ 17 | # psycopg2 dependencies 18 | libpq-dev 19 | 20 | # Requirements are installed here to ensure they will be cached. 21 | COPY ./requirements . 22 | 23 | # Create Python Dependency and Sub-Dependency Wheels. 24 | RUN pip wheel --wheel-dir /usr/src/app/wheels \ 25 | -r ${BUILD_ENVIRONMENT}.txt 26 | 27 | 28 | # Python 'run' stage 29 | FROM python as python-run-stage 30 | 31 | ARG BUILD_ENVIRONMENT=production 32 | ARG APP_HOME=/app 33 | 34 | ENV PYTHONUNBUFFERED 1 35 | ENV PYTHONDONTWRITEBYTECODE 1 36 | ENV BUILD_ENV ${BUILD_ENVIRONMENT} 37 | 38 | WORKDIR ${APP_HOME} 39 | 40 | RUN addgroup --system django \ 41 | && adduser --system --ingroup django django 42 | 43 | 44 | # Install required system dependencies 45 | RUN apt-get update && apt-get install --no-install-recommends -y \ 46 | # psycopg2 dependencies 47 | libpq-dev \ 48 | # Translations dependencies 49 | gettext \ 50 | # cleaning up unused files 51 | && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ 52 | && rm -rf /var/lib/apt/lists/* 53 | 54 | # All absolute dir copies ignore workdir instruction. All relative dir copies are wrt to the workdir instruction 55 | # copy python dependency wheels from python-build-stage 56 | COPY --from=python-build-stage /usr/src/app/wheels /wheels/ 57 | 58 | # use wheels to install python dependencies 59 | RUN pip install --no-cache-dir --no-index --find-links=/wheels/ /wheels/* \ 60 | && rm -rf /wheels/ 61 | 62 | 63 | COPY --chown=django:django ./compose/production/django/entrypoint /entrypoint 64 | RUN sed -i 's/\r$//g' /entrypoint 65 | RUN chmod +x /entrypoint 66 | 67 | 68 | COPY --chown=django:django ./compose/production/django/start /start 69 | RUN sed -i 's/\r$//g' /start 70 | RUN chmod +x /start 71 | COPY --chown=django:django ./compose/production/django/celery/worker/start /start-celeryworker 72 | RUN sed -i 's/\r$//g' /start-celeryworker 73 | RUN chmod +x /start-celeryworker 74 | 75 | 76 | COPY --chown=django:django ./compose/production/django/celery/beat/start /start-celerybeat 77 | RUN sed -i 's/\r$//g' /start-celerybeat 78 | RUN chmod +x /start-celerybeat 79 | 80 | 81 | COPY ./compose/production/django/celery/flower/start /start-flower 82 | RUN sed -i 's/\r$//g' /start-flower 83 | RUN chmod +x /start-flower 84 | 85 | 86 | # copy application code to WORKDIR 87 | COPY --chown=django:django . ${APP_HOME} 88 | 89 | # make django owner of the WORKDIR directory as well. 90 | RUN chown django:django ${APP_HOME} 91 | 92 | USER django 93 | 94 | ENTRYPOINT ["/entrypoint"] 95 | -------------------------------------------------------------------------------- /conversa_dj/templates/account/email.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends "account/base.html" %} 3 | 4 | {% load i18n %} 5 | {% load crispy_forms_tags %} 6 | 7 | {% block head_title %}{% translate "Account" %}{% endblock %} 8 | 9 | {% block inner %} 10 |

    {% translate "E-mail Addresses" %}

    11 | 12 | {% if user.emailaddress_set.all %} 13 |

    {% translate 'The following e-mail addresses are associated with your account:' %}

    14 | 15 | 44 | 45 | {% else %} 46 |

    {% translate 'Warning:'%} {% translate "You currently do not have any e-mail address set up. You should really add an e-mail address so you can receive notifications, reset your password, etc." %}

    47 | 48 | {% endif %} 49 | 50 | 51 |

    {% translate "Add E-mail Address" %}

    52 | 53 |
    54 | {% csrf_token %} 55 | {{ form|crispy }} 56 | 57 |
    58 | 59 | {% endblock %} 60 | 61 | 62 | {% block inline_javascript %} 63 | {{ block.super }} 64 | 78 | {% endblock %} 79 | -------------------------------------------------------------------------------- /conversa_dj/chats/middleware.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import parse_qs 2 | from channels.db import database_sync_to_async 3 | 4 | from django.contrib.auth import get_user_model 5 | from django.utils.translation import gettext_lazy as _ 6 | from rest_framework.exceptions import AuthenticationFailed 7 | 8 | User = get_user_model() 9 | 10 | 11 | class TokenAuthentication: 12 | """ 13 | Simple token based authentication. 14 | 15 | Clients should authenticate by passing the token key in the query parameters. 16 | For example: 17 | 18 | ?token=401f7ac837da42b97f613d789819ff93537bee6a 19 | """ 20 | 21 | model = None 22 | 23 | def get_model(self): 24 | if self.model is not None: 25 | return self.model 26 | from rest_framework.authtoken.models import Token 27 | 28 | return Token 29 | 30 | """ 31 | A custom token model may be used, but must have the following properties. 32 | 33 | * key -- The string identifying the token 34 | * user -- The user to which the token belongs 35 | """ 36 | 37 | def authenticate_credentials(self, key): 38 | model = self.get_model() 39 | try: 40 | token = model.objects.select_related("user").get(key=key) 41 | except model.DoesNotExist: 42 | raise AuthenticationFailed(_("Invalid token.")) 43 | 44 | if not token.user.is_active: 45 | raise AuthenticationFailed(_("User inactive or deleted.")) 46 | 47 | return token.user 48 | 49 | 50 | @database_sync_to_async 51 | def get_user(scope): 52 | """ 53 | Return the user model instance associated with the given scope. 54 | If no user is retrieved, return an instance of `AnonymousUser`. 55 | """ 56 | # postpone model import to avoid ImproperlyConfigured error before Django 57 | # setup is complete. 58 | from django.contrib.auth.models import AnonymousUser 59 | 60 | if "token" not in scope: 61 | raise ValueError( 62 | "Cannot find token in scope. You should wrap your consumer in " 63 | "TokenAuthMiddleware." 64 | ) 65 | token = scope["token"] 66 | user = None 67 | try: 68 | auth = TokenAuthentication() 69 | user = auth.authenticate_credentials(token) 70 | except AuthenticationFailed: 71 | pass 72 | return user or AnonymousUser() 73 | 74 | 75 | class TokenAuthMiddleware: 76 | """ 77 | Custom middleware that takes a token from the query string and authenticates via 78 | Django Rest Framework authtoken. 79 | """ 80 | 81 | def __init__(self, app): 82 | # Store the ASGI application we were passed 83 | self.app = app 84 | 85 | async def __call__(self, scope, receive, send): 86 | # Look up user from query string (you should also do things like 87 | # checking if it is a valid user ID, or if scope["user"] is already 88 | # populated). 89 | query_params = parse_qs(scope["query_string"].decode()) 90 | token = query_params["token"][0] 91 | scope["token"] = token 92 | scope["user"] = await get_user(scope) 93 | return await self.app(scope, receive, send) 94 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Config for Dependabot updates. See Documentation here: 2 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | # Update GitHub actions in workflows 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | # Check for updates to GitHub Actions every weekday 10 | schedule: 11 | interval: "daily" 12 | 13 | # Enable version updates for Docker 14 | # We need to specify each Dockerfile in a separate entry because Dependabot doesn't 15 | # support wildcards or recursively checking subdirectories. Check this issue for updates: 16 | # https://github.com/dependabot/dependabot-core/issues/2178 17 | - package-ecosystem: "docker" 18 | # Look for a `Dockerfile` in the `compose/local/django` directory 19 | directory: "compose/local/django/" 20 | # Check for updates to GitHub Actions every weekday 21 | schedule: 22 | interval: "daily" 23 | 24 | # Enable version updates for Docker 25 | - package-ecosystem: "docker" 26 | # Look for a `Dockerfile` in the `compose/local/docs` directory 27 | directory: "compose/local/docs/" 28 | # Check for updates to GitHub Actions every weekday 29 | schedule: 30 | interval: "daily" 31 | 32 | # Enable version updates for Docker 33 | - package-ecosystem: "docker" 34 | # Look for a `Dockerfile` in the `compose/local/node` directory 35 | directory: "compose/local/node/" 36 | # Check for updates to GitHub Actions every weekday 37 | schedule: 38 | interval: "daily" 39 | 40 | # Enable version updates for Docker 41 | - package-ecosystem: "docker" 42 | # Look for a `Dockerfile` in the `compose/production/aws` directory 43 | directory: "compose/production/aws/" 44 | # Check for updates to GitHub Actions every weekday 45 | schedule: 46 | interval: "daily" 47 | 48 | # Enable version updates for Docker 49 | - package-ecosystem: "docker" 50 | # Look for a `Dockerfile` in the `compose/production/django` directory 51 | directory: "compose/production/django/" 52 | # Check for updates to GitHub Actions every weekday 53 | schedule: 54 | interval: "daily" 55 | 56 | # Enable version updates for Docker 57 | - package-ecosystem: "docker" 58 | # Look for a `Dockerfile` in the `compose/production/postgres` directory 59 | directory: "compose/production/postgres/" 60 | # Check for updates to GitHub Actions every weekday 61 | schedule: 62 | interval: "daily" 63 | 64 | # Enable version updates for Docker 65 | - package-ecosystem: "docker" 66 | # Look for a `Dockerfile` in the `compose/production/traefik` directory 67 | directory: "compose/production/traefik/" 68 | # Check for updates to GitHub Actions every weekday 69 | schedule: 70 | interval: "daily" 71 | 72 | # Enable version updates for Python/Pip - Production 73 | - package-ecosystem: "pip" 74 | # Look for a `requirements.txt` in the `root` directory 75 | # also 'setup.cfg', 'runtime.txt' and 'requirements/*.txt' 76 | directory: "/" 77 | # Check for updates to GitHub Actions every weekday 78 | schedule: 79 | interval: "daily" 80 | -------------------------------------------------------------------------------- /conversa_dj/users/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.conf import settings 3 | from django.contrib import messages 4 | from django.contrib.auth.models import AnonymousUser 5 | from django.contrib.messages.middleware import MessageMiddleware 6 | from django.contrib.sessions.middleware import SessionMiddleware 7 | from django.http import HttpRequest, HttpResponseRedirect 8 | from django.test import RequestFactory 9 | from django.urls import reverse 10 | 11 | from conversa_dj.users.forms import UserAdminChangeForm 12 | from conversa_dj.users.models import User 13 | from conversa_dj.users.tests.factories import UserFactory 14 | from conversa_dj.users.views import ( 15 | UserRedirectView, 16 | UserUpdateView, 17 | user_detail_view, 18 | ) 19 | 20 | pytestmark = pytest.mark.django_db 21 | 22 | 23 | class TestUserUpdateView: 24 | """ 25 | TODO: 26 | extracting view initialization code as class-scoped fixture 27 | would be great if only pytest-django supported non-function-scoped 28 | fixture db access -- this is a work-in-progress for now: 29 | https://github.com/pytest-dev/pytest-django/pull/258 30 | """ 31 | 32 | def dummy_get_response(self, request: HttpRequest): 33 | return None 34 | 35 | def test_get_success_url(self, user: User, rf: RequestFactory): 36 | view = UserUpdateView() 37 | request = rf.get("/fake-url/") 38 | request.user = user 39 | 40 | view.request = request 41 | 42 | assert view.get_success_url() == f"/users/{user.username}/" 43 | 44 | def test_get_object(self, user: User, rf: RequestFactory): 45 | view = UserUpdateView() 46 | request = rf.get("/fake-url/") 47 | request.user = user 48 | 49 | view.request = request 50 | 51 | assert view.get_object() == user 52 | 53 | def test_form_valid(self, user: User, rf: RequestFactory): 54 | view = UserUpdateView() 55 | request = rf.get("/fake-url/") 56 | 57 | # Add the session/message middleware to the request 58 | SessionMiddleware(self.dummy_get_response).process_request(request) 59 | MessageMiddleware(self.dummy_get_response).process_request(request) 60 | request.user = user 61 | 62 | view.request = request 63 | 64 | # Initialize the form 65 | form = UserAdminChangeForm() 66 | form.cleaned_data = [] 67 | view.form_valid(form) 68 | 69 | messages_sent = [m.message for m in messages.get_messages(request)] 70 | assert messages_sent == ["Information successfully updated"] 71 | 72 | 73 | class TestUserRedirectView: 74 | def test_get_redirect_url(self, user: User, rf: RequestFactory): 75 | view = UserRedirectView() 76 | request = rf.get("/fake-url") 77 | request.user = user 78 | 79 | view.request = request 80 | 81 | assert view.get_redirect_url() == f"/users/{user.username}/" 82 | 83 | 84 | class TestUserDetailView: 85 | def test_authenticated(self, user: User, rf: RequestFactory): 86 | request = rf.get("/fake-url/") 87 | request.user = UserFactory() 88 | 89 | response = user_detail_view(request, username=user.username) 90 | 91 | assert response.status_code == 200 92 | 93 | def test_not_authenticated(self, user: User, rf: RequestFactory): 94 | request = rf.get("/fake-url/") 95 | request.user = AnonymousUser() 96 | 97 | response = user_detail_view(request, username=user.username) 98 | login_url = reverse(settings.LOGIN_URL) 99 | 100 | assert isinstance(response, HttpResponseRedirect) 101 | assert response.status_code == 302 102 | assert response.url == f"{login_url}?next=/fake-url/" 103 | -------------------------------------------------------------------------------- /frontend/src/components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { Outlet, Link } from "react-router-dom"; 3 | import { AuthContext } from "../contexts/AuthContext"; 4 | import { NotificationContext } from "../contexts/NotificationContext"; 5 | 6 | export function Navbar() { 7 | const { user, logout } = useContext(AuthContext); 8 | const { unreadMessageCount } = useContext(NotificationContext); 9 | return ( 10 | <> 11 | 102 |
    103 | 104 |
    105 | 106 | ); 107 | } 108 | -------------------------------------------------------------------------------- /conversa_dj/users/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-11-20 11:23 2 | import django.contrib.auth.models 3 | import django.contrib.auth.validators 4 | from django.db import migrations, models 5 | import django.utils.timezone 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ("auth", "0012_alter_user_first_name_max_length"), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="User", 19 | fields=[ 20 | ( 21 | "id", 22 | models.BigAutoField( 23 | auto_created=True, 24 | primary_key=True, 25 | serialize=False, 26 | verbose_name="ID", 27 | ), 28 | ), 29 | ("password", models.CharField(max_length=128, verbose_name="password")), 30 | ( 31 | "last_login", 32 | models.DateTimeField( 33 | blank=True, null=True, verbose_name="last login" 34 | ), 35 | ), 36 | ( 37 | "is_superuser", 38 | models.BooleanField( 39 | default=False, 40 | help_text="Designates that this user has all permissions without explicitly assigning them.", 41 | verbose_name="superuser status", 42 | ), 43 | ), 44 | ( 45 | "username", 46 | models.CharField( 47 | error_messages={ 48 | "unique": "A user with that username already exists." 49 | }, 50 | help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", 51 | max_length=150, 52 | unique=True, 53 | validators=[ 54 | django.contrib.auth.validators.UnicodeUsernameValidator() 55 | ], 56 | verbose_name="username", 57 | ), 58 | ), 59 | ( 60 | "email", 61 | models.EmailField( 62 | blank=True, max_length=254, verbose_name="email address" 63 | ), 64 | ), 65 | ( 66 | "is_staff", 67 | models.BooleanField( 68 | default=False, 69 | help_text="Designates whether the user can log into this admin site.", 70 | verbose_name="staff status", 71 | ), 72 | ), 73 | ( 74 | "is_active", 75 | models.BooleanField( 76 | default=True, 77 | help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", 78 | verbose_name="active", 79 | ), 80 | ), 81 | ( 82 | "date_joined", 83 | models.DateTimeField( 84 | default=django.utils.timezone.now, verbose_name="date joined" 85 | ), 86 | ), 87 | ( 88 | "name", 89 | models.CharField( 90 | blank=True, max_length=255, verbose_name="Name of User" 91 | ), 92 | ), 93 | ( 94 | "groups", 95 | models.ManyToManyField( 96 | blank=True, 97 | help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", 98 | related_name="user_set", 99 | related_query_name="user", 100 | to="auth.Group", 101 | verbose_name="groups", 102 | ), 103 | ), 104 | ( 105 | "user_permissions", 106 | models.ManyToManyField( 107 | blank=True, 108 | help_text="Specific permissions for this user.", 109 | related_name="user_set", 110 | related_query_name="user", 111 | to="auth.Permission", 112 | verbose_name="user permissions", 113 | ), 114 | ), 115 | ], 116 | options={ 117 | "verbose_name": "user", 118 | "verbose_name_plural": "users", 119 | "abstract": False, 120 | }, 121 | managers=[ 122 | ("objects", django.contrib.auth.models.UserManager()), 123 | ], 124 | ), 125 | ] 126 | -------------------------------------------------------------------------------- /conversa_dj/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static i18n %} 2 | {% get_current_language as LANGUAGE_CODE %} 3 | 4 | 5 | 6 | 7 | {% block title %}Conversa Django{% endblock title %} 8 | 9 | 10 | 11 | 12 | 13 | 14 | {% block css %} 15 | 16 | 17 | 18 | 19 | 20 | 21 | {% endblock %} 22 | 24 | {# Placed at the top of the document so pages load faster with defer #} 25 | {% block javascript %} 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | {% endblock javascript %} 34 | 35 | 36 | 37 | 38 | 39 |
    40 | 80 | 81 |
    82 | 83 |
    84 | 85 | {% if messages %} 86 | {% for message in messages %} 87 |
    88 | {{ message }} 89 | 90 |
    91 | {% endfor %} 92 | {% endif %} 93 | 94 | {% block content %} 95 |

    Use this document as a way to quick start any new project.

    96 | {% endblock content %} 97 | 98 |
    99 | 100 | {% block modal %}{% endblock modal %} 101 | 102 | {% block inline_javascript %} 103 | {% comment %} 104 | Script tags with only code, no src (defer by default). To run 105 | with a "defer" so that you run inline code: 106 | 109 | {% endcomment %} 110 | {% endblock inline_javascript %} 111 | 112 | 113 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 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 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | staticfiles/ 55 | 56 | # Sphinx documentation 57 | docs/_build/ 58 | 59 | # PyBuilder 60 | target/ 61 | 62 | # pyenv 63 | .python-version 64 | 65 | # celery beat schedule file 66 | celerybeat-schedule 67 | 68 | # Environments 69 | .venv 70 | venv/ 71 | ENV/ 72 | 73 | # Rope project settings 74 | .ropeproject 75 | 76 | # mkdocs documentation 77 | /site 78 | 79 | # mypy 80 | .mypy_cache/ 81 | 82 | 83 | ### Node template 84 | # Logs 85 | logs 86 | *.log 87 | npm-debug.log* 88 | yarn-debug.log* 89 | yarn-error.log* 90 | 91 | # Runtime data 92 | pids 93 | *.pid 94 | *.seed 95 | *.pid.lock 96 | 97 | # Directory for instrumented libs generated by jscoverage/JSCover 98 | lib-cov 99 | 100 | # Coverage directory used by tools like istanbul 101 | coverage 102 | 103 | # nyc test coverage 104 | .nyc_output 105 | 106 | # Bower dependency directory (https://bower.io/) 107 | bower_components 108 | 109 | # node-waf configuration 110 | .lock-wscript 111 | 112 | # Compiled binary addons (http://nodejs.org/api/addons.html) 113 | build/Release 114 | 115 | # Dependency directories 116 | node_modules/ 117 | jspm_packages/ 118 | 119 | # Typescript v1 declaration files 120 | typings/ 121 | 122 | # Optional npm cache directory 123 | .npm 124 | 125 | # Optional eslint cache 126 | .eslintcache 127 | 128 | # Optional REPL history 129 | .node_repl_history 130 | 131 | # Output of 'npm pack' 132 | *.tgz 133 | 134 | # Yarn Integrity file 135 | .yarn-integrity 136 | 137 | 138 | ### Linux template 139 | *~ 140 | 141 | # temporary files which can be created if a process still has a handle open of a deleted file 142 | .fuse_hidden* 143 | 144 | # KDE directory preferences 145 | .directory 146 | 147 | # Linux trash folder which might appear on any partition or disk 148 | .Trash-* 149 | 150 | # .nfs files are created when an open file is removed but is still being accessed 151 | .nfs* 152 | 153 | 154 | ### VisualStudioCode template 155 | .vscode/* 156 | !.vscode/settings.json 157 | !.vscode/tasks.json 158 | !.vscode/launch.json 159 | !.vscode/extensions.json 160 | *.code-workspace 161 | 162 | # Local History for Visual Studio Code 163 | .history/ 164 | 165 | 166 | # Provided default Pycharm Run/Debug Configurations should be tracked by git 167 | # In case of local modifications made by Pycharm, use update-index command 168 | # for each changed file, like this: 169 | # git update-index --assume-unchanged .idea/conversa_dj.iml 170 | ### JetBrains template 171 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 172 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 173 | 174 | # User-specific stuff: 175 | .idea/**/workspace.xml 176 | .idea/**/tasks.xml 177 | .idea/dictionaries 178 | 179 | # Sensitive or high-churn files: 180 | .idea/**/dataSources/ 181 | .idea/**/dataSources.ids 182 | .idea/**/dataSources.xml 183 | .idea/**/dataSources.local.xml 184 | .idea/**/sqlDataSources.xml 185 | .idea/**/dynamic.xml 186 | .idea/**/uiDesigner.xml 187 | 188 | # Gradle: 189 | .idea/**/gradle.xml 190 | .idea/**/libraries 191 | 192 | # CMake 193 | cmake-build-debug/ 194 | 195 | # Mongo Explorer plugin: 196 | .idea/**/mongoSettings.xml 197 | 198 | ## File-based project format: 199 | *.iws 200 | 201 | ## Plugin-specific files: 202 | 203 | # IntelliJ 204 | out/ 205 | 206 | # mpeltonen/sbt-idea plugin 207 | .idea_modules/ 208 | 209 | # JIRA plugin 210 | atlassian-ide-plugin.xml 211 | 212 | # Cursive Clojure plugin 213 | .idea/replstate.xml 214 | 215 | # Crashlytics plugin (for Android Studio and IntelliJ) 216 | com_crashlytics_export_strings.xml 217 | crashlytics.properties 218 | crashlytics-build.properties 219 | fabric.properties 220 | 221 | 222 | 223 | ### Windows template 224 | # Windows thumbnail cache files 225 | Thumbs.db 226 | ehthumbs.db 227 | ehthumbs_vista.db 228 | 229 | # Dump file 230 | *.stackdump 231 | 232 | # Folder config file 233 | Desktop.ini 234 | 235 | # Recycle Bin used on file shares 236 | $RECYCLE.BIN/ 237 | 238 | # Windows Installer files 239 | *.cab 240 | *.msi 241 | *.msm 242 | *.msp 243 | 244 | # Windows shortcuts 245 | *.lnk 246 | 247 | 248 | ### macOS template 249 | # General 250 | *.DS_Store 251 | .AppleDouble 252 | .LSOverride 253 | 254 | # Icon must end with two \r 255 | Icon 256 | 257 | # Thumbnails 258 | ._* 259 | 260 | # Files that might appear in the root of a volume 261 | .DocumentRevisions-V100 262 | .fseventsd 263 | .Spotlight-V100 264 | .TemporaryItems 265 | .Trashes 266 | .VolumeIcon.icns 267 | .com.apple.timemachine.donotpresent 268 | 269 | # Directories potentially created on remote AFP share 270 | .AppleDB 271 | .AppleDesktop 272 | Network Trash Folder 273 | Temporary Items 274 | .apdisk 275 | 276 | 277 | ### SublimeText template 278 | # Cache files for Sublime Text 279 | *.tmlanguage.cache 280 | *.tmPreferences.cache 281 | *.stTheme.cache 282 | 283 | # Workspace files are user-specific 284 | *.sublime-workspace 285 | 286 | # Project files should be checked into the repository, unless a significant 287 | # proportion of contributors will probably not be using Sublime Text 288 | # *.sublime-project 289 | 290 | # SFTP configuration file 291 | sftp-config.json 292 | 293 | # Package control specific files 294 | Package Control.last-run 295 | Package Control.ca-list 296 | Package Control.ca-bundle 297 | Package Control.system-ca-bundle 298 | Package Control.cache/ 299 | Package Control.ca-certs/ 300 | Package Control.merged-ca-bundle 301 | Package Control.user-ca-bundle 302 | oscrypto-ca-bundle.crt 303 | bh_unicode_properties.cache 304 | 305 | # Sublime-github package stores a github token in this file 306 | # https://packagecontrol.io/packages/sublime-github 307 | GitHub.sublime-settings 308 | 309 | 310 | ### Vim template 311 | # Swap 312 | [._]*.s[a-v][a-z] 313 | [._]*.sw[a-p] 314 | [._]s[a-v][a-z] 315 | [._]sw[a-p] 316 | 317 | # Session 318 | Session.vim 319 | 320 | # Temporary 321 | .netrwhist 322 | 323 | # Auto-generated tag files 324 | tags 325 | 326 | ### Project template 327 | conversa_dj/media/ 328 | 329 | .pytest_cache/ 330 | .ipython/ 331 | .env 332 | .envs/* 333 | !.envs/.local/ 334 | -------------------------------------------------------------------------------- /conversa_dj/chats/consumers.py: -------------------------------------------------------------------------------- 1 | import json 2 | from uuid import UUID 3 | 4 | from asgiref.sync import async_to_sync 5 | from django.contrib.auth import get_user_model 6 | from channels.generic.websocket import JsonWebsocketConsumer 7 | from conversa_dj.chats.api.serializers import MessageSerializer 8 | 9 | from conversa_dj.chats.models import Conversation, Message 10 | 11 | 12 | User = get_user_model() 13 | 14 | 15 | class UUIDEncoder(json.JSONEncoder): 16 | def default(self, obj): 17 | if isinstance(obj, UUID): 18 | # if the obj is uuid, we simply return the value of uuid 19 | return obj.hex 20 | return json.JSONEncoder.default(self, obj) 21 | 22 | 23 | class ChatConsumer(JsonWebsocketConsumer): 24 | """ 25 | This consumer is used to show user's online status, 26 | and send notifications. 27 | """ 28 | 29 | def __init__(self, *args, **kwargs): 30 | super().__init__(args, kwargs) 31 | self.user = None 32 | self.conversation_name = None 33 | self.conversation = None 34 | 35 | def connect(self): 36 | self.user = self.scope["user"] 37 | if not self.user.is_authenticated: 38 | return 39 | 40 | self.accept() 41 | self.conversation_name = ( 42 | f"{self.scope['url_route']['kwargs']['conversation_name']}" 43 | ) 44 | self.conversation, created = Conversation.objects.get_or_create( 45 | name=self.conversation_name 46 | ) 47 | 48 | async_to_sync(self.channel_layer.group_add)( 49 | self.conversation_name, 50 | self.channel_name, 51 | ) 52 | 53 | self.send_json( 54 | { 55 | "type": "online_user_list", 56 | "users": [user.username for user in self.conversation.online.all()], 57 | } 58 | ) 59 | 60 | async_to_sync(self.channel_layer.group_send)( 61 | self.conversation_name, 62 | { 63 | "type": "user_join", 64 | "user": self.user.username, 65 | }, 66 | ) 67 | 68 | self.conversation.online.add(self.user) 69 | 70 | messages = self.conversation.messages.all().order_by("-timestamp")[0:10] 71 | message_count = self.conversation.messages.all().count() 72 | self.send_json( 73 | { 74 | "type": "last_50_messages", 75 | "messages": MessageSerializer(messages, many=True).data, 76 | "has_more": message_count > 5, 77 | } 78 | ) 79 | 80 | def disconnect(self, code): 81 | if self.user.is_authenticated: 82 | # send the leave event to the room 83 | async_to_sync(self.channel_layer.group_send)( 84 | self.conversation_name, 85 | { 86 | "type": "user_leave", 87 | "user": self.user.username, 88 | }, 89 | ) 90 | self.conversation.online.remove(self.user) 91 | return super().disconnect(code) 92 | 93 | def get_receiver(self): 94 | usernames = self.conversation_name.split("__") 95 | for username in usernames: 96 | if username != self.user.username: 97 | # This is the receiver 98 | return User.objects.get(username=username) 99 | 100 | def receive_json(self, content, **kwargs): 101 | message_type = content["type"] 102 | 103 | if message_type == "read_messages": 104 | messages_to_me = self.conversation.messages.filter(to_user=self.user) 105 | messages_to_me.update(read=True) 106 | 107 | # Update the unread message count 108 | unread_count = Message.objects.filter(to_user=self.user, read=False).count() 109 | async_to_sync(self.channel_layer.group_send)( 110 | self.user.username + "__notifications", 111 | { 112 | "type": "unread_count", 113 | "unread_count": unread_count, 114 | }, 115 | ) 116 | 117 | if message_type == "typing": 118 | async_to_sync(self.channel_layer.group_send)( 119 | self.conversation_name, 120 | { 121 | "type": "typing", 122 | "user": self.user.username, 123 | "typing": content["typing"], 124 | }, 125 | ) 126 | 127 | if message_type == "chat_message": 128 | 129 | message = Message.objects.create( 130 | from_user=self.user, 131 | to_user=self.get_receiver(), 132 | content=content["message"], 133 | conversation=self.conversation, 134 | ) 135 | 136 | async_to_sync(self.channel_layer.group_send)( 137 | self.conversation_name, 138 | { 139 | "type": "chat_message_echo", 140 | "name": self.user.username, 141 | "message": MessageSerializer(message).data, 142 | }, 143 | ) 144 | 145 | notification_group_name = self.get_receiver().username + "__notifications" 146 | async_to_sync(self.channel_layer.group_send)( 147 | notification_group_name, 148 | { 149 | "type": "new_message_notification", 150 | "name": self.user.username, 151 | "message": MessageSerializer(message).data, 152 | }, 153 | ) 154 | 155 | return super().receive_json(content, **kwargs) 156 | 157 | def chat_message_echo(self, event): 158 | self.send_json(event) 159 | 160 | def user_join(self, event): 161 | self.send_json(event) 162 | 163 | def user_leave(self, event): 164 | self.send_json(event) 165 | 166 | def typing(self, event): 167 | self.send_json(event) 168 | 169 | def new_message_notification(self, event): 170 | self.send_json(event) 171 | 172 | def unread_count(self, event): 173 | self.send_json(event) 174 | 175 | @classmethod 176 | def encode_json(cls, content): 177 | return json.dumps(content, cls=UUIDEncoder) 178 | 179 | 180 | class NotificationConsumer(JsonWebsocketConsumer): 181 | def __init__(self, *args, **kwargs): 182 | super().__init__(args, kwargs) 183 | self.user = None 184 | self.notification_group_name = None 185 | 186 | def connect(self): 187 | self.user = self.scope["user"] 188 | if not self.user.is_authenticated: 189 | return 190 | 191 | self.accept() 192 | 193 | # private notification group 194 | self.notification_group_name = self.user.username + "__notifications" 195 | async_to_sync(self.channel_layer.group_add)( 196 | self.notification_group_name, 197 | self.channel_name, 198 | ) 199 | 200 | # Send count of unread messages 201 | unread_count = Message.objects.filter(to_user=self.user, read=False).count() 202 | self.send_json( 203 | { 204 | "type": "unread_count", 205 | "unread_count": unread_count, 206 | } 207 | ) 208 | 209 | def disconnect(self, code): 210 | async_to_sync(self.channel_layer.group_discard)( 211 | self.notification_group_name, 212 | self.channel_name, 213 | ) 214 | return super().disconnect(code) 215 | 216 | def new_message_notification(self, event): 217 | self.send_json(event) 218 | 219 | def unread_count(self, event): 220 | self.send_json(event) 221 | -------------------------------------------------------------------------------- /frontend/src/components/Chat.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useRef, useState } from "react"; 2 | import { useParams } from "react-router-dom"; 3 | import useWebSocket, { ReadyState } from "react-use-websocket"; 4 | import InfiniteScroll from "react-infinite-scroll-component"; 5 | import { useHotkeys } from "react-hotkeys-hook"; 6 | import { AuthContext } from "../contexts/AuthContext"; 7 | import { MessageModel } from "../models/Message"; 8 | import { Message } from "./Message"; 9 | import { ChatLoader } from "./ChatLoader"; 10 | import { ConversationModel } from "../models/Conversation"; 11 | 12 | export function Chat() { 13 | const { conversationName } = useParams(); 14 | const { user } = useContext(AuthContext); 15 | const [participants, setParticipants] = useState([]); 16 | const [conversation, setConversation] = useState( 17 | null 18 | ); 19 | const [messageHistory, setMessageHistory] = useState([]); 20 | const [message, setMessage] = useState(""); 21 | const [page, setPage] = useState(2); 22 | const [hasMoreMessages, setHasMoreMessages] = useState(false); 23 | const [meTyping, setMeTyping] = useState(false); 24 | const [typing, setTyping] = useState(false); 25 | 26 | function updateTyping(event: { user: string; typing: boolean }) { 27 | if (event.user !== user!.username) { 28 | setTyping(event.typing); 29 | } 30 | } 31 | 32 | const { readyState, sendJsonMessage } = useWebSocket( 33 | user ? `ws://127.0.0.1:8000/chats/${conversationName}/` : null, 34 | { 35 | queryParams: { 36 | token: user ? user.token : "", 37 | }, 38 | onOpen: () => { 39 | console.log("Connected!"); 40 | }, 41 | onClose: () => { 42 | console.log("Disconnected!"); 43 | }, 44 | // onMessage handler 45 | onMessage: (e) => { 46 | const data = JSON.parse(e.data); 47 | switch (data.type) { 48 | case "chat_message_echo": 49 | setMessageHistory((prev: any) => [data.message, ...prev]); 50 | sendJsonMessage({ 51 | type: "read_messages", 52 | }); 53 | break; 54 | case "last_50_messages": 55 | setMessageHistory(data.messages); 56 | setHasMoreMessages(data.has_more); 57 | break; 58 | case "user_join": 59 | setParticipants((pcpts: string[]) => { 60 | if (!pcpts.includes(data.user)) { 61 | return [...pcpts, data.user]; 62 | } 63 | return pcpts; 64 | }); 65 | break; 66 | case "user_leave": 67 | setParticipants((pcpts: string[]) => { 68 | const newPcpts = pcpts.filter((x) => x !== data.user); 69 | return newPcpts; 70 | }); 71 | break; 72 | case "online_user_list": 73 | setParticipants(data.users); 74 | break; 75 | case "typing": 76 | updateTyping(data); 77 | break; 78 | default: 79 | console.error("Unknown message type!"); 80 | break; 81 | } 82 | }, 83 | } 84 | ); 85 | 86 | const connectionStatus = { 87 | [ReadyState.CONNECTING]: "Connecting", 88 | [ReadyState.OPEN]: "Open", 89 | [ReadyState.CLOSING]: "Closing", 90 | [ReadyState.CLOSED]: "Closed", 91 | [ReadyState.UNINSTANTIATED]: "Uninstantiated", 92 | }[readyState]; 93 | 94 | useEffect(() => { 95 | if (connectionStatus === "Open") { 96 | sendJsonMessage({ 97 | type: "read_messages", 98 | }); 99 | } 100 | }, [connectionStatus, sendJsonMessage]); 101 | 102 | async function fetchMessages() { 103 | const apiRes = await fetch( 104 | `http://127.0.0.1:8000/api/messages/?conversation=${conversationName}&page=${page}`, 105 | { 106 | method: "GET", 107 | headers: { 108 | Accept: "application/json", 109 | "Content-Type": "application/json", 110 | Authorization: `Token ${user?.token}`, 111 | }, 112 | } 113 | ); 114 | if (apiRes.status === 200) { 115 | const data: { 116 | count: number; 117 | next: string | null; // URL 118 | previous: string | null; // URL 119 | results: MessageModel[]; 120 | } = await apiRes.json(); 121 | setHasMoreMessages(data.next !== null); 122 | setPage(page + 1); 123 | setMessageHistory((prev: MessageModel[]) => prev.concat(data.results)); 124 | } 125 | } 126 | 127 | const timeout = useRef(); 128 | 129 | function timeoutFunction() { 130 | setMeTyping(false); 131 | sendJsonMessage({ type: "typing", typing: false }); 132 | } 133 | 134 | function onType() { 135 | if (meTyping === false) { 136 | setMeTyping(true); 137 | sendJsonMessage({ type: "typing", typing: true }); 138 | timeout.current = setTimeout(timeoutFunction, 5000); 139 | } else { 140 | clearTimeout(timeout.current); 141 | timeout.current = setTimeout(timeoutFunction, 5000); 142 | } 143 | } 144 | 145 | function handleChangeMessage(e: any) { 146 | setMessage(e.target.value); 147 | onType(); 148 | } 149 | 150 | useEffect(() => () => clearTimeout(timeout.current), []); 151 | 152 | const handleSubmit = () => { 153 | if (message.length === 0) return; 154 | if (message.length > 512) return; 155 | sendJsonMessage({ 156 | type: "chat_message", 157 | message, 158 | }); 159 | setMessage(""); 160 | clearTimeout(timeout.current); 161 | timeoutFunction(); 162 | }; 163 | 164 | const inputReference: any = useHotkeys( 165 | "enter", 166 | () => { 167 | handleSubmit(); 168 | }, 169 | { 170 | enableOnTags: ["INPUT"], 171 | } 172 | ); 173 | 174 | useEffect(() => { 175 | (inputReference.current as HTMLElement).focus(); 176 | }, [inputReference]); 177 | 178 | useEffect(() => { 179 | async function fetchConversation() { 180 | const apiRes = await fetch( 181 | `http://127.0.0.1:8000/api/conversations/${conversationName}/`, 182 | { 183 | method: "GET", 184 | headers: { 185 | Accept: "application/json", 186 | "Content-Type": "application/json", 187 | Authorization: `Token ${user?.token}`, 188 | }, 189 | } 190 | ); 191 | if (apiRes.status === 200) { 192 | const data: ConversationModel = await apiRes.json(); 193 | setConversation(data); 194 | } 195 | } 196 | fetchConversation(); 197 | }, [conversationName, user]); 198 | 199 | return ( 200 |
    201 | The WebSocket is currently {connectionStatus} 202 | {conversation && ( 203 |
    204 |

    205 | Chat with user: {conversation.other_user.username} 206 |

    207 | 208 | {conversation.other_user.username} is currently 209 | {participants.includes(conversation.other_user.username) 210 | ? " online" 211 | : " offline"} 212 | 213 | {typing && ( 214 |

    typing...

    215 | )} 216 |
    217 | )} 218 | 219 |
    220 | 231 | 234 |
    235 | 236 |
    242 |
    243 | {/* Put the scroll bar always on the bottom */} 244 | } 251 | scrollableTarget="scrollableDiv" 252 | > 253 | {messageHistory.map((message: MessageModel) => ( 254 | 255 | ))} 256 | 257 |
    258 |
    259 |
    260 | ); 261 | } 262 | --------------------------------------------------------------------------------