${data.body}
\n \ndiff --git a/.env.docker b/.env.docker
index 4b358b2..2900408 100644
--- a/.env.docker
+++ b/.env.docker
@@ -10,4 +10,17 @@ DB_USER=askme_user
DB_PASSWORD=askme_password
DB_HOST=db
DB_PORT=5432
-DB_ENGINE="django.db.backends.postgresql"
\ No newline at end of file
+DB_ENGINE="django.db.backends.postgresql"
+REDIS_HOST=redis
+REDIS_PORT=6379
+REDIS_CACHE_DB=1
+REDIS_BROKER_DB=2
+REDIS_BEAT_DB=3
+
+CENTRIFUGO_URL=http://centrifugo:8000
+CENTRIFUGO_API_KEY=centrifugo_secret
+
+EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
+EMAIL_HOST=maildev
+EMAIL_PORT=1025
+DEFAULT_FROM_EMAIL=webmaster@localhost
\ No newline at end of file
diff --git a/.env.example b/.env.example
index f60c574..bc17af7 100644
--- a/.env.example
+++ b/.env.example
@@ -6,4 +6,17 @@ DB_USER=postgres
DB_PASSWORD=1234567
DB_HOST=localhost
DB_PORT=5432
-DB_ENGINE="django.db.backends.postgresql"
\ No newline at end of file
+DB_ENGINE="django.db.backends.postgresql"
+REDIS_HOST=localhost
+REDIS_PORT=6379
+REDIS_CACHE_DB=1
+REDIS_BROKER_DB=2
+REDIS_BEAT_DB=3
+
+CENTRIFUGO_URL=http://localhost:8001
+CENTRIFUGO_API_KEY=replace_me
+
+EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
+EMAIL_HOST=localhost
+EMAIL_PORT=1025
+DEFAULT_FROM_EMAIL=webmaster@localhost
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 0ae6f79..b4856b5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.11
+FROM python:3.12
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
diff --git a/README.md b/README.md
index 5c13247..6471752 100644
--- a/README.md
+++ b/README.md
@@ -1,42 +1,66 @@
-## Добро пожаловать в мой репозиторий :)
+# 2026-VK-EDU-Web-12-Istratov-D
-Позже здесь будет проект-дз...
+## Запуск в Docker
-### О себе
+1. Скопируйте `.env.docker` в `.env` или создайте свой файл на основе `.env.example`.
+2. Запустите сервисы:
-Я интересуюсь **backend-разработкой**, **серверными инструментами** и **микроконтроллерами**.
-Пишу на Java, Python и C++, начинал с написания плагинов для Minecraft и разных полезных утилит.
-
-🔧 Студент VK Education, курса "Web-разработка".
-
-
-## Запуск проекта
-Перед запуском нужно составить корректный .env файл! Можно использовать .env.example!
-
-### На текущей машине
-
-```shell
-python manage.py runserver 8000
-pip install --no-cache-dir -r requirements.txt
-```
-
-### Docker
-
-ВАЖНО! Использовать .env.docker вместо .env.example в качестве образца!
-
-```shell
+```bash
docker compose up --build
```
-Запустится именно на 127.0.0.1:8000
+3. Откройте приложение: `http://127.0.0.1:8080`
+4. Centrifugo доступен на `http://127.0.0.1:8001`
+5. Maildev доступен на `http://127.0.0.1:1080`
+## Переменные окружения
-## Миграции и заполнение дб
+В `.env` должны быть:
-Заполнить ДБ 10000 рандомных данных
+- `SECRET_KEY`
+- `DEBUG`
+- `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`
+- `DB_ENGINE`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`
+- `REDIS_HOST`, `REDIS_PORT`, `REDIS_CACHE_DB`, `REDIS_BROKER_DB`, `REDIS_BEAT_DB`
+- `CENTRIFUGO_URL`, `CENTRIFUGO_API_KEY`
+- `EMAIL_BACKEND`, `EMAIL_HOST`, `EMAIL_PORT`, `DEFAULT_FROM_EMAIL`
-```
+## Миграции
+
+После изменений моделей выполните:
+
+```bash
python manage.py makemigrations
python manage.py migrate
+```
+
+## Дополнительные конфигурации
+
+- `gunicorn_conf.py` — конфигурация Gunicorn с двумя воркерами.
+- `simple_wsgi.py` — простой WSGI-приложение для запуска без Django на `localhost:8081`.
+- `nginx.conf` — пример конфигурации Nginx для отдачи `/uploads/`, статических файлов и проксирования на Gunicorn.
+
+## Заполнить базу данных
+
+```bash
python manage.py fill_db 10000
+```
+
+Когда запущено в докере:
+
+```bash
+docker compose exec python-app python manage.py fill_db 10000
+```
+
+Это удалит предыдущие данные из дб!
+
+## Реализовано
+
+- Redis / Celery / Celery Beat
+- кэширование популярных тегов и лучших участников
+- асинхронные уведомления в Centrifugo
+- отправка email-уведомлений через Celery
+- полнотекстовый поиск и подсказки
+- Docker Compose инфраструктура
+
```
\ No newline at end of file
diff --git a/application/__init__.py b/application/__init__.py
index e69de29..fb989c4 100644
--- a/application/__init__.py
+++ b/application/__init__.py
@@ -0,0 +1,3 @@
+from .celery import app as celery_app
+
+__all__ = ('celery_app',)
diff --git a/application/celery.py b/application/celery.py
new file mode 100644
index 0000000..9a8d465
--- /dev/null
+++ b/application/celery.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+import os
+from celery import Celery
+from django.conf import settings
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'application.settings')
+
+app = Celery('application')
+app.config_from_object('django.conf:settings', namespace='CELERY')
+app.autodiscover_tasks()
+
+
+@app.task(bind=True)
+def debug_task(self):
+ print(f'Request: {self.request!r}')
diff --git a/application/settings.py b/application/settings.py
index c7d8dbd..244d4fd 100644
--- a/application/settings.py
+++ b/application/settings.py
@@ -26,7 +26,14 @@ SECRET_KEY = os.getenv("SECRET_KEY")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.getenv("DEBUG") == "True"
-ALLOWED_HOSTS = []
+ALLOWED_HOSTS = ['127.0.0.1', 'localhost']
+
+# Allow requests coming through nginx on host:8080 (used in local Docker Compose)
+# Add any additional origins you use (including scheme and port) here.
+CSRF_TRUSTED_ORIGINS = [
+ 'http://127.0.0.1:8080',
+ 'http://localhost:8080',
+]
# Application definition
@@ -38,6 +45,7 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
+ 'django.contrib.postgres',
'questions',
]
@@ -68,6 +76,7 @@ TEMPLATES = [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
+ 'questions.context_processors.sidebar_data',
],
},
},
@@ -125,7 +134,7 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/
-STATIC_URL = 'static/'
+STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
@@ -136,4 +145,51 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
MEDIA_URL = '/media/'
-MEDIA_ROOT = BASE_DIR / 'media'
\ No newline at end of file
+MEDIA_ROOT = BASE_DIR / 'media'
+
+# Redis / Celery / Cache settings
+REDIS_HOST = os.getenv('REDIS_HOST', 'redis')
+REDIS_PORT = os.getenv('REDIS_PORT', '6379')
+REDIS_CACHE_DB = os.getenv('REDIS_CACHE_DB', '1')
+REDIS_BROKER_DB = os.getenv('REDIS_BROKER_DB', '2')
+REDIS_BEAT_DB = os.getenv('REDIS_BEAT_DB', '3')
+
+CACHES = {
+ "default": {
+ "BACKEND": "django_redis.cache.RedisCache",
+ "LOCATION": f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_CACHE_DB}",
+ "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"},
+ "TIMEOUT": 60 * 10,
+ }
+}
+
+# Celery settings
+CELERY_BROKER_URL = f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_BROKER_DB}"
+CELERY_RESULT_BACKEND = f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_BEAT_DB}"
+CELERY_BEAT_SCHEDULER = "redbeat.RedBeatScheduler"
+CELERY_REDBEAT_REDIS_URL = f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_BEAT_DB}"
+
+# Centrifugo (real-time) settings
+CENTRIFUGO_URL = os.getenv('CENTRIFUGO_URL', 'http://centrifugo:8000')
+CENTRIFUGO_API_KEY = os.getenv('CENTRIFUGO_API_KEY', 'centrifugo_secret')
+
+# Email settings (use maildev for local development)
+EMAIL_BACKEND = os.getenv('EMAIL_BACKEND', 'django.core.mail.backends.smtp.EmailBackend')
+EMAIL_HOST = os.getenv('EMAIL_HOST', 'maildev')
+EMAIL_PORT = int(os.getenv('EMAIL_PORT', '1025'))
+EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', 'False') == 'True'
+DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'webmaster@localhost')
+
+# Celery beat schedule (periodic tasks)
+from celery.schedules import crontab
+
+CELERY_BEAT_SCHEDULE = {
+ 'rebuild_popular_tags': {
+ 'task': 'questions.tasks.rebuild_popular_tags',
+ 'schedule': crontab(minute='*/30'),
+ },
+ 'rebuild_best_members': {
+ 'task': 'questions.tasks.rebuild_best_members',
+ 'schedule': crontab(minute='*/15'),
+ },
+}
diff --git a/centrifugo/config.json b/centrifugo/config.json
new file mode 100644
index 0000000..9b27c1f
--- /dev/null
+++ b/centrifugo/config.json
@@ -0,0 +1,10 @@
+{
+ "secret": "centrifugo_secret",
+ "namespaces": [
+ {
+ "name": "public",
+ "anonymous": true
+ }
+ ],
+ "allowed_origins": ["*"]
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index 38eb7b9..3035a60 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,70 +1,122 @@
-version: '3.8'
-
services:
-
db:
-
image: postgres:16
-
container_name: postgres-container
-
restart: always
-
env_file:
- .env
-
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
-
volumes:
- postgres_data:/var/lib/postgresql/data
-
networks:
- django-network
-
-
python-app:
-
build: .
-
container_name: django-app-container
-
restart: always
-
- ports:
- - "8000:8000"
-
working_dir: /app
-
volumes:
- .:/app
- - media_data:/app/media
-
+ - ./media:/app/media
env_file:
- .env
-
depends_on:
- db
-
+ - redis
+ - maildev
command: >
sh -c "
- python manage.py migrate &&
- python manage.py runserver 0.0.0.0:8000
+ python manage.py migrate --noinput &&
+ python manage.py collectstatic --noinput &&
+ gunicorn application.wsgi:application --config gunicorn_conf.py
"
-
networks:
- django-network
+ redis:
+ image: redis:7
+ container_name: redis-container
+ restart: always
+ volumes:
+ - redis_data:/data
+ ports:
+ - "6379:6379"
+ networks:
+ - django-network
+ celery:
+ build: .
+ container_name: celery-worker
+ command: celery -A application worker --loglevel=info
+ volumes:
+ - .:/app
+ env_file:
+ - .env
+ depends_on:
+ - redis
+ - db
+ networks:
+ - django-network
+
+ celery-beat:
+ build: .
+ container_name: celery-beat
+ command: celery -A application beat --loglevel=info -S redbeat.RedBeatScheduler
+ volumes:
+ - .:/app
+ env_file:
+ - .env
+ depends_on:
+ - redis
+ - db
+ networks:
+ - django-network
+
+ centrifugo:
+ image: centrifugo/centrifugo:latest
+ container_name: centrifugo
+ restart: always
+ command: centrifugo --config=/centrifugo/config.json
+ volumes:
+ - ./centrifugo:/centrifugo:ro
+ ports:
+ - "8001:8000"
+ networks:
+ - django-network
+
+ maildev:
+ image: maildev/maildev:latest
+ container_name: maildev
+ restart: always
+ ports:
+ - "1080:1080"
+ - "1025:1025"
+ networks:
+ - django-network
+
+ nginx:
+ image: nginx:latest
+ container_name: nginx-container
+ restart: always
+ ports:
+ - "8080:80"
+ volumes:
+ - ./nginx.conf:/etc/nginx/nginx.conf:ro
+ - ./static:/var/www/ask_pupkin/static:ro
+ - ./media:/var/www/ask_pupkin/uploads:ro
+ depends_on:
+ - python-app
+ networks:
+ - django-network
volumes:
+ redis_data:
postgres_data:
media_data:
-
-
networks:
- django-network:
\ No newline at end of file
+ django-network:
diff --git a/gunicorn_conf.py b/gunicorn_conf.py
new file mode 100644
index 0000000..6c92c8e
--- /dev/null
+++ b/gunicorn_conf.py
@@ -0,0 +1,8 @@
+bind = '0.0.0.0:8000'
+workers = 2
+worker_class = 'sync'
+threads = 2
+timeout = 120
+loglevel = 'info'
+accesslog = '-'
+errorlog = '-'
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 0000000..6ec51ff
--- /dev/null
+++ b/nginx.conf
@@ -0,0 +1,45 @@
+worker_processes auto;
+events { worker_connections 1024; }
+http {
+ include mime.types;
+ default_type application/octet-stream;
+ sendfile on;
+ gzip on;
+ gzip_types text/plain text/css application/javascript application/json image/svg+xml;
+
+ proxy_cache_path /tmp/nginx-cache levels=1:2 keys_zone=django:10m max_size=100m inactive=60m;
+
+ upstream django {
+ server python-app:8000;
+ }
+
+ server {
+ listen 80;
+ server_name localhost;
+
+ location /static/ {
+ alias /var/www/ask_pupkin/static/;
+ access_log off;
+ expires 30d;
+ add_header Cache-Control "public, max-age=2592000";
+ }
+
+ location /media/ {
+ alias /var/www/ask_pupkin/uploads/;
+ access_log off;
+ expires 7d;
+ add_header Cache-Control "public, max-age=604800";
+ }
+
+ location / {
+ proxy_pass http://django;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_cache django;
+ proxy_cache_bypass $http_cache_control;
+ add_header X-Proxy-Cache $upstream_cache_status;
+ }
+ }
+}
diff --git a/questions/context_processors.py b/questions/context_processors.py
new file mode 100644
index 0000000..c37fa9c
--- /dev/null
+++ b/questions/context_processors.py
@@ -0,0 +1,55 @@
+from datetime import timedelta
+from django.utils import timezone
+from django.core.cache import cache
+from django.contrib.auth.models import User
+from django.db.models import Count, Q
+
+from .models import Tag
+
+
+def sidebar_data(request):
+ popular_tags = cache.get('popular_tags')
+ best_members = cache.get('best_members')
+
+ if popular_tags is None:
+ three_months_ago = timezone.now() - timedelta(days=90)
+ popular_qs = Tag.objects.annotate(
+ q_count=Count('question', filter=Q(question__created_at__gte=three_months_ago))
+ ).order_by('-q_count')[:10]
+ popular_tags = [
+ {'name': tag.name, 'count': tag.q_count}
+ for tag in popular_qs
+ ]
+ cache.set('popular_tags', popular_tags, timeout=60 * 60)
+
+ if best_members is None:
+ week_ago = timezone.now() - timedelta(days=7)
+ users_q = User.objects.annotate(
+ q_count=Count('question', filter=Q(question__created_at__gte=week_ago))
+ )
+ users_a = User.objects.annotate(
+ a_count=Count('answer', filter=Q(answer__created_at__gte=week_ago)))
+
+ scores = {}
+ for u in users_q:
+ scores[u.id] = scores.get(u.id, 0) + (u.q_count or 0)
+ for u in users_a:
+ scores[u.id] = scores.get(u.id, 0) + (u.a_count or 0)
+
+ best_members_list = User.objects.filter(id__in=list(scores.keys()))
+ best_members = [
+ {
+ 'id': u.id,
+ 'username': u.username,
+ 'score': scores.get(u.id, 0)
+ }
+ for u in best_members_list
+ ]
+ best_members.sort(key=lambda x: x['score'], reverse=True)
+ best_members = best_members[:10]
+ cache.set('best_members', best_members, timeout=60 * 60)
+
+ return {
+ 'popular_tags': popular_tags,
+ 'best_members': best_members,
+ }
diff --git a/questions/migrations/0004_answer_created_at_and_search_index.py b/questions/migrations/0004_answer_created_at_and_search_index.py
new file mode 100644
index 0000000..dc7ad77
--- /dev/null
+++ b/questions/migrations/0004_answer_created_at_and_search_index.py
@@ -0,0 +1,18 @@
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('questions', '0003_question_correct_answer'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='answer',
+ name='created_at',
+ field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
+ preserve_default=False,
+ ),
+ ]
diff --git a/questions/models.py b/questions/models.py
index d337727..5bd62ca 100644
--- a/questions/models.py
+++ b/questions/models.py
@@ -1,5 +1,7 @@
from django.db import models
from django.contrib.auth.models import User
+from django.contrib.postgres.indexes import GinIndex
+from django.contrib.postgres.search import SearchVector
import uuid
import os
@@ -85,6 +87,11 @@ class Question(models.Model):
def __str__(self):
return self.title
+ class Meta:
+ indexes = [
+ GinIndex(SearchVector('title', 'body'), name='question_search_idx')
+ ]
+
class QuestionReaction(models.Model):
@@ -130,6 +137,7 @@ class Answer(models.Model):
body = models.TextField()
rating = models.IntegerField(default=0)
+ created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
diff --git a/questions/static/core/css/style.css b/questions/static/core/css/style.css
index 236b684..c9ae6df 100644
--- a/questions/static/core/css/style.css
+++ b/questions/static/core/css/style.css
@@ -58,6 +58,7 @@ a:hover {
gap: 0.6rem;
flex: 1;
min-width: 40%;
+ position: relative;
}
.search input {
@@ -218,6 +219,41 @@ a:hover {
margin-bottom: 1.5rem;
}
+.search-suggestions {
+ position: absolute;
+ background: #ffffff;
+ border: 1px solid #1f2d3a;
+ border-radius: 0.5rem;
+ width: calc(100% - 2rem);
+ max-height: 18rem;
+ overflow: auto;
+ display: none;
+ z-index: 100;
+ margin-top: 0.4rem;
+}
+
+.search-suggestion {
+ display: block;
+ padding: 0.6rem 0.9rem;
+ color: #1f2d3a;
+ text-decoration: none;
+ border-bottom: 1px solid #e8f0f7;
+}
+
+.search-suggestion:hover {
+ background: #f0f7ff;
+}
+
+.best-members {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.best-members li {
+ margin-bottom: 0.5rem;
+}
+
.tag-cloud {
display: flex;
flex-wrap: wrap;
diff --git a/questions/static/core/js/script.js b/questions/static/core/js/script.js
index 4c9ac84..ee76b13 100644
--- a/questions/static/core/js/script.js
+++ b/questions/static/core/js/script.js
@@ -5,9 +5,7 @@ function getCookie(name) {
for (let cookie of cookies) {
cookie = cookie.trim();
if (cookie.startsWith(name + '=')) {
- cookieValue = decodeURIComponent(
- cookie.substring(name.length + 1)
- );
+ cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
@@ -16,29 +14,56 @@ function getCookie(name) {
}
const csrftoken = getCookie('csrftoken');
+const searchInput = document.getElementById('search-input');
+const searchSuggestions = document.getElementById('search-suggestions');
+let searchTimeout = null;
-$(document).on('click', '.question-like', function () {
- const btn = $(this);
- const questionId = btn.data('id');
+function setSearchSuggestions(results) {
+ if (!searchSuggestions) return;
+ if (!results.length) {
+ searchSuggestions.innerHTML = '';
+ searchSuggestions.style.display = 'none';
+ return;
+ }
+ searchSuggestions.innerHTML = results.map(item => `
+ ${item.title}
+ `).join('');
+ searchSuggestions.style.display = 'block';
+}
+
+if (searchInput) {
+ searchInput.addEventListener('input', function () {
+ const query = this.value.trim();
+ clearTimeout(searchTimeout);
+ if (!query) {
+ setSearchSuggestions([]);
+ return;
+ }
+ searchTimeout = setTimeout(function () {
+ fetch(`/search?q=${encodeURIComponent(query)}`)
+ .then(response => response.json())
+ .then(data => {
+ setSearchSuggestions(data.results || []);
+ })
+ .catch(() => setSearchSuggestions([]));
+ }, 250);
+ });
+ document.addEventListener('click', function (event) {
+ if (!searchSuggestions.contains(event.target) && event.target !== searchInput) {
+ searchSuggestions.style.display = 'none';
+ }
+ });
+}
+
+function postReaction(url, data, onSuccess) {
$.ajax({
- url: '/ajax/question/react/',
+ url,
type: 'POST',
headers: {
'X-CSRFToken': csrftoken
},
- data: {
- question_id: questionId,
- type: 'like'
- },
- success: function (response) {
- $('#question-rating-' + questionId)
- .text(response.rating);
- btn.remove();
- $('.question-dislike[data-id="' + questionId + '"]')
- .remove();
- $('#question-rating-' + questionId)
- .after(' voted ');
- },
+ data,
+ success: onSuccess,
error: function (xhr) {
if (xhr.responseJSON) {
alert(xhr.responseJSON.error);
@@ -47,136 +72,98 @@ $(document).on('click', '.question-like', function () {
}
}
});
+}
+
+$(document).on('click', '.question-like', function () {
+ const btn = $(this);
+ const questionId = btn.data('id');
+ postReaction('/ajax/question/react/', {question_id: questionId, type: 'like'}, function (response) {
+ $('#question-rating-' + questionId).text(response.rating);
+ btn.remove();
+ $('.question-dislike[data-id="' + questionId + '"]').remove();
+ $('#question-rating-' + questionId).after(' voted ');
+ });
});
$(document).on('click', '.question-dislike', function () {
const btn = $(this);
const questionId = btn.data('id');
- $.ajax({
- url: '/ajax/question/react/',
- type: 'POST',
- headers: {
- 'X-CSRFToken': csrftoken
- },
- data: {
- question_id: questionId,
- type: 'dislike'
- },
- success: function (response) {
- $('#question-rating-' + questionId)
- .text(response.rating);
- btn.remove();
- $('.question-like[data-id="' + questionId + '"]')
- .remove();
- $('#question-rating-' + questionId)
- .after(' voted ');
- }
- error: function (xhr) {
- if (xhr.responseJSON) {
- alert(xhr.responseJSON.error);
- } else {
- alert('Server error');
- }
- }
+ postReaction('/ajax/question/react/', {question_id: questionId, type: 'dislike'}, function (response) {
+ $('#question-rating-' + questionId).text(response.rating);
+ btn.remove();
+ $('.question-like[data-id="' + questionId + '"]').remove();
+ $('#question-rating-' + questionId).after(' voted ');
});
});
$(document).on('click', '.answer-like', function () {
const btn = $(this);
const answerId = btn.data('id');
- $.ajax({
- url: '/ajax/answer/react/',
- type: 'POST',
- headers: {
- 'X-CSRFToken': csrftoken
- },
- data: {
- answer_id: answerId,
- type: 'like'
- }
- success: function (response) {
- $('#answer-rating-' + answerId)
- .text(response.rating);
- btn.remove();
- $('.answer-dislike[data-id="' + answerId + '"]')
- .remove();
- $('#answer-rating-' + answerId)
- .after(' voted ');
- },
- error: function (xhr) {
- if (xhr.responseJSON) {
- alert(xhr.responseJSON.error);
- } else {
- alert('Server error');
- }
- }
+ postReaction('/ajax/answer/react/', {answer_id: answerId, type: 'like'}, function (response) {
+ $('#answer-rating-' + answerId).text(response.rating);
+ btn.remove();
+ $('.answer-dislike[data-id="' + answerId + '"]').remove();
+ $('#answer-rating-' + answerId).after(' voted ');
});
});
$(document).on('click', '.answer-dislike', function () {
const btn = $(this);
const answerId = btn.data('id');
- $.ajax({
- url: '/ajax/answer/react/',
- type: 'POST',
- headers: {
- 'X-CSRFToken': csrftoken
- },
- data: {
- answer_id: answerId,
- type: 'dislike'
- },
- success: function (response) {
- $('#answer-rating-' + answerId)
- .text(response.rating);
- btn.remove();
- $('.answer-like[data-id="' + answerId + '"]')
- .remove();
-
- $('#answer-rating-' + answerId)
- .after(' voted ');
- },
-
- error: function (xhr) {
-
- if (xhr.responseJSON) {
- alert(xhr.responseJSON.error);
- } else {
- alert('Server error');
- }
- }
+ postReaction('/ajax/answer/react/', {answer_id: answerId, type: 'dislike'}, function (response) {
+ $('#answer-rating-' + answerId).text(response.rating);
+ btn.remove();
+ $('.answer-like[data-id="' + answerId + '"]').remove();
+ $('#answer-rating-' + answerId).after(' voted ');
});
});
-
$(document).on('click', '.correct-answer-btn', function () {
const btn = $(this);
- $.ajax({
- url: '/ajax/answer/correct/',
- type: 'POST',
- headers: {
- 'X-CSRFToken': csrftoken
- },
- data: {
- question_id: btn.data('question'),
- answer_id: btn.data('answer')
- },
- success: function (response) {
- $('.correct-label').remove();
- $('#answer-' + response.correct_answer_id)
- .find('.answer-controls')
- .html(`
-
- ✔ Correct
-
- `);
- },
- error: function (xhr) {
- if (xhr.responseJSON) {
- alert(xhr.responseJSON.error);
- } else {
- alert('Server error');
- }
- }
+ postReaction('/ajax/answer/correct/', {
+ question_id: btn.data('question'),
+ answer_id: btn.data('answer')
+ }, function (response) {
+ $('.correct-label').remove();
+ $('#answer-' + response.correct_answer_id).find('.answer-controls').html(`
+
+ ✔ Correct
+
+ `);
});
-});
\ No newline at end of file
+});
+
+(function () {
+ try {
+ if (window.ASK_QUESTION_ID) {
+ const host = location.hostname;
+ const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
+ const centrifugoWs = protocol + '://' + host + ':8001/connection/websocket';
+
+ const centrifuge = new Centrifuge(centrifugoWs);
+ centrifuge.on('connect', function (ctx) {
+ console.log('centrifuge connected', ctx);
+ });
+
+ const channel = 'question_' + window.ASK_QUESTION_ID;
+ centrifuge.subscribe(channel, function (message) {
+ const data = message.data;
+ const answerHtml = `\n ${data.body}
, because it just gets in the way. + from_box.parentNode.removeChild(p); + } else if (p.classList.contains("help")) { + // Move help text up to the top so it isn't below the select + // boxes or wrapped off on the side to the right of the add + // button: + from_box.parentNode.insertBefore(p, from_box.parentNode.firstChild); + } + } + + //