diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index 54f34d8..85a370a 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -1,38 +1,60 @@ -name: Django CI +name: CI -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] +on: [push] jobs: - build: + test: runs-on: ubuntu-latest - strategy: - max-parallel: 4 - matrix: - python-version: [3.7, 3.8, 3.9] + + services: + postgres: + image: postgres:11 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: ['5432:5432'] + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - name: Install Dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - name: Run Tests - env: - DEBUG: "0" - SECRET_KEY: ${{ secrets.SECRET_KEY }} - DEFAULT_AVATAR: ${{ secrets.DEFAULT_AVATAR }} - CLOUDINARY_CLOUD_NAME: ${{ secrets.CLOUDINARY_CLOUD_NAME }} - CLOUDINARY_API_KEY: ${{ secrets.CLOUDINARY_API_KEY }} - CLOUDINARY_API_SECRET: ${{ secrets.CLOUDINARY_API_SECRET }} - run: | - cd backend - python manage.py test \ No newline at end of file + - uses: actions/checkout@v1 + with: + fetch-depth: 1 + + - name: Set up Python 3.7 + uses: actions/setup-python@v1 + with: + python-version: 3.7 + + - uses: actions/cache@v1 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + if: steps.cache.outputs.cache-hit != 'true' + + - name: Run Tests + env: + DATABASE_URL: 'postgres://postgres:postgres@localhost:${{ job.services.postgres.ports[5432] }}/postgres' + DEBUG: "0" + SECRET_KEY: ${{ secrets.SECRET_KEY }} + DEFAULT_AVATAR: ${{ secrets.DEFAULT_AVATAR }} + CLOUDINARY_CLOUD_NAME: ${{ secrets.CLOUDINARY_CLOUD_NAME }} + CLOUDINARY_API_KEY: ${{ secrets.CLOUDINARY_API_KEY }} + CLOUDINARY_API_SECRET: ${{ secrets.CLOUDINARY_API_SECRET }} + DB_USERNAME: ${{ secrets.POSTGRES_USER }} + DB_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + DB_HOST: ${{ secrets.POSTGRES_HOST }} + DB_DATABASE: ${{ secrets.POSTGRES_DB }} + DB_PORT: ${{ secrets.POSTGRES_PORT }} + REDIS_HOST: localhost + REDIS_PORT: 6379 + run: | + cd backend + python manage.py test \ No newline at end of file diff --git a/backend/comments/__init__.py b/backend/comments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/comments/admin.py b/backend/comments/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/backend/comments/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/comments/apps.py b/backend/comments/apps.py new file mode 100644 index 0000000..a90cc97 --- /dev/null +++ b/backend/comments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CommentsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'comments' diff --git a/backend/comments/consumers.py b/backend/comments/consumers.py new file mode 100644 index 0000000..cee4d6b --- /dev/null +++ b/backend/comments/consumers.py @@ -0,0 +1,37 @@ +import json +from channels.generic.websocket import AsyncWebsocketConsumer + + +class CommentsConsumer(AsyncWebsocketConsumer): + async def connect(self): + self.room_group_name = 'owner' + + await self.channel_layer.group_add( + self.room_group_name, + self.channel_name + ) + + await self.accept() + + async def receive(self, text_data): + text_data_json = json.loads(text_data) + comment = text_data_json['message'] + + await self.channel_layer.group_send( + self.room_group_name, + { + 'type': 'broadcast_comment', + 'message': comment + } + ) + + async def broadcast_comment(self, event): + comment = event['message'] + + await self.send(text_data=json.dumps({ + 'type': 'chat', + 'message': comment + })) + + +chat_consumer = CommentsConsumer.as_asgi() diff --git a/backend/comments/migrations/__init__.py b/backend/comments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/comments/models.py b/backend/comments/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/backend/comments/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/backend/comments/routing.py b/backend/comments/routing.py new file mode 100644 index 0000000..2c267b9 --- /dev/null +++ b/backend/comments/routing.py @@ -0,0 +1,6 @@ +from django.urls import re_path +from . import consumers + +websocket_urlpatterns = [ + re_path(r'ws/socket-server/', consumers.CommentsConsumer.as_asgi()) +] \ No newline at end of file diff --git a/backend/comments/tests.py b/backend/comments/tests.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/comments/views.py b/backend/comments/views.py new file mode 100644 index 0000000..ff1f27d --- /dev/null +++ b/backend/comments/views.py @@ -0,0 +1,4 @@ +from django.shortcuts import render + +def index(request): + return render(request, 'index.html') \ No newline at end of file diff --git a/backend/configs/environment.py b/backend/configs/environment.py new file mode 100644 index 0000000..64b5495 --- /dev/null +++ b/backend/configs/environment.py @@ -0,0 +1,26 @@ +import os + +CLOUDINARY_NAME = os.environ.get('CLOUDINARY_CLOUD_NAME') + +CLOUDINARY_API_KEY = os.environ.get('CLOUDINARY_API_KEY') + +CLOUDINARY_API_SECRET = os.environ.get('CLOUDINARY_API_SECRET') + +DEFAULT_AVATAR = os.environ.get('DEFAULT_AVATAR') + +DB_DATABASE = os.environ.get('POSTGRES_DB') + +DB_USER = os.environ.get('POSTGRES_USER') + +DB_PASSWORD = os.environ.get('POSTGRES_PASSWORD') + +DB_HOST = os.environ.get('POSTGRES_HOST') + +DB_PORT = os.environ.get('POSTGRES_PORT') + +SECRET_KEY = os.environ.get('SECRET_KEY') + +REDIS_HOST = os.environ.get('REDIS_HOST') + +REDIS_PORT = os.environ.get('REDIS_PORT') + diff --git a/backend/slides/tests.py b/backend/slides/tests.py index 92dcd50..206b9ba 100644 --- a/backend/slides/tests.py +++ b/backend/slides/tests.py @@ -118,10 +118,11 @@ def test_delete_others_slide_throws_excpetion(self): } response = self.client.post(reverse("slides-list"), data=data) + new_slide = response.json() self.client.credentials(HTTP_AUTHORIZATION=self.token) response = self.client.delete( - reverse("slides-detail", kwargs={'pk': 2}), data=data) + reverse("slides-detail", kwargs={'pk': new_slide['data']['id']}), data=data) result = response.json() self.assertEqual(response.status_code, 403) @@ -162,6 +163,7 @@ def test_not_existed_retrieve_slide_throws_exception(self): response = self.client.get(reverse('slides-detail', kwargs={'pk': 4})) result = response.json() + self.assertEqual(response.status_code, 404) self.assertIn(result['detail'], 'Not found.') @@ -226,10 +228,10 @@ def test_update_others_slides_throws_exception(self): "is_public": False, "is_live": False } - + self.client.credentials(HTTP_AUTHORIZATION=self.token) response = self.client.patch( - reverse("slides-detail", kwargs={'pk': new_slides['data']['user_data']['id']}), data=update_data) + reverse("slides-detail", kwargs={'pk': new_slides['data']['id']}), data=update_data) result = response.json() diff --git a/backend/slidy_api/asgi.py b/backend/slidy_api/asgi.py index 61fa8c1..47dbb3e 100644 --- a/backend/slidy_api/asgi.py +++ b/backend/slidy_api/asgi.py @@ -1,16 +1,17 @@ -""" -ASGI config for slidy_api project. - -It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/ -""" - import os from django.core.asgi import get_asgi_application +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.auth import AuthMiddlewareStack +import comments.routing os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'slidy_api.settings') -application = get_asgi_application() +application = ProtocolTypeRouter({ + 'http':get_asgi_application(), + 'websocket': ( + URLRouter( + comments.routing.websocket_urlpatterns + ) + ) +}) \ No newline at end of file diff --git a/backend/slidy_api/settings.py b/backend/slidy_api/settings.py index 00d6d37..26aa1d4 100644 --- a/backend/slidy_api/settings.py +++ b/backend/slidy_api/settings.py @@ -11,6 +11,7 @@ """ from pathlib import Path +from configs import environment import os import cloudinary @@ -23,7 +24,7 @@ # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = os.environ.get('SECRET_KEY') +SECRET_KEY = environment.SECRET_KEY # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -33,6 +34,7 @@ # Application definition INSTALLED_APPS = [ + 'channels', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -42,6 +44,7 @@ # Internal Apps 'users', 'slides', + 'comments', # Third Party Packages 'rest_framework', 'rest_framework.authtoken', @@ -50,6 +53,8 @@ 'requests' ] +ASGI_APPLICATION = 'slidy_api.asgi.application' + MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -78,18 +83,31 @@ }, ] -WSGI_APPLICATION = 'slidy_api.wsgi.application' - +ASGI_APPLICATION = 'slidy_api.asgi.application' +CHANNEL_LAYERS = { + 'default': { + "BACKEND": "channels_redis.core.RedisChannelLayer", + 'CONFIG': { + 'hosts': [(environment.REDIS_HOST, environment.REDIS_PORT)], + } + } +} + # Database # https://docs.djangoproject.com/en/4.0/ref/settings/#databases + DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': environment.DB_DATABASE, + 'USER': environment.DB_USER, + 'PASSWORD': environment.DB_PASSWORD, + 'HOST': environment.DB_HOST, + 'PORT': environment.DB_PORT, + } } -} # Password validation @@ -145,10 +163,12 @@ ] } -DEFAULT_AVATAR = os.environ.get('DEFAULT_AVATAR') +DEFAULT_AVATAR = environment.DEFAULT_AVATAR cloudinary.config( - cloud_name = os.environ.get('CLOUDINARY_CLOUD_NAME'), - api_key = os.environ.get('CLOUDINARY_API_KEY'), - api_secret = os.environ.get('CLOUDINARY_API_SECRET') -) \ No newline at end of file + cloud_name = environment.CLOUDINARY_NAME, + api_key = environment.CLOUDINARY_API_KEY, + api_secret = environment.CLOUDINARY_API_SECRET +) + +serve_static = True \ No newline at end of file diff --git a/backend/slidy_api/urls.py b/backend/slidy_api/urls.py index 70fd4b3..c57ab01 100644 --- a/backend/slidy_api/urls.py +++ b/backend/slidy_api/urls.py @@ -4,5 +4,5 @@ urlpatterns = [ path('admin/', admin.site.urls, name='admin'), path('api/v1/auth/', include('users.urls')), - path('api/v1/', include('slidy_api.routers')) + path('api/v1/', include('slidy_api.routers')), ] diff --git a/backend/users/tests.py b/backend/users/tests.py index a4c24bd..2ba06d8 100644 --- a/backend/users/tests.py +++ b/backend/users/tests.py @@ -1,4 +1,5 @@ from rest_framework.test import APITestCase + from .models import User from knox.models import AuthToken @@ -156,14 +157,16 @@ def test_update_own_avatar(self): self.assertEqual(response.status_code, 200) self.assertEqual(result['data']['avatar'], data['avatar']) + def test_update_other_avatar_throws_exception(self): - self.client.post(reverse('sign-up'), data={ + new_user_response = self.client.post(reverse('sign-up'), data={ "username": "ahmedsaleh", "password": "Test123456!!", "email": "ahmedsaleh@gmail.com", "first_name": "Ahmed", "last_name": "Saleh" }) + new_user = new_user_response.json() self.client.credentials(HTTP_AUTHORIZATION=self.token) @@ -172,7 +175,7 @@ def test_update_other_avatar_throws_exception(self): } response = self.client.put( - reverse('users-detail', kwargs={'pk': 2}), data=data) + reverse('users-detail', kwargs={'pk': new_user['data']['id']}), data=data) result = response.json() self.assertEqual(response.status_code, 403) diff --git a/backend/users/views.py b/backend/users/views.py index 9706735..7f02d67 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -77,10 +77,8 @@ def post(self, request, *args, **kwargs): return Response({ "status_code": 200, "message": "success", - "data": { - "user": self.get_serializer(user).data, - "token": AuthToken.objects.create(user)[1] - } + "data": UserSerializer(user).data, + "token": AuthToken.objects.create(user)[1], }, status=status.HTTP_200_OK) diff --git a/requirements.txt b/requirements.txt index b238b70..0777c1a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,8 @@ cloudinary requests uplink markdown -cloudinary \ No newline at end of file +cloudinary +asgiref>=3.6.0 +channels==3.0.5 +channels_redis==4.0.0 +psycopg2-binary