From 4bc5fcef7ac8ff18e843e4cdcaed6e047d2eeadd Mon Sep 17 00:00:00 2001 From: Denis Istratov Date: Thu, 28 May 2026 09:48:16 +0300 Subject: [PATCH] hw6, hw7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Redis / Celery / Celery Beat - кэширование популярных тегов и лучших участников - асинхронные уведомления в Centrifugo - отправка email-уведомлений через Celery - полнотекстовый поиск и подсказки - Docker Compose инфраструктура --- .env.docker | 15 +- .env.example | 15 +- Dockerfile | 2 +- README.md | 80 +- application/__init__.py | 3 + application/celery.py | 15 + application/settings.py | 62 +- centrifugo/config.json | 10 + docker-compose.yml | 112 +- gunicorn_conf.py | 8 + nginx.conf | 45 + questions/context_processors.py | 55 + ...0004_answer_created_at_and_search_index.py | 18 + questions/models.py | 8 + questions/static/core/css/style.css | 36 + questions/static/core/js/script.js | 247 +- questions/tasks.py | 94 + questions/templates/core/layout/base.html | 16 +- questions/templates/core/question.html | 7 +- questions/urls.py | 1 + questions/views.py | 56 + requirements.txt | 8 + simple_wsgi.py | 28 + static/admin/css/autocomplete.css | 279 + static/admin/css/base.css | 1180 ++ static/admin/css/changelists.css | 343 + static/admin/css/dark_mode.css | 130 + static/admin/css/dashboard.css | 29 + static/admin/css/forms.css | 498 + static/admin/css/login.css | 61 + static/admin/css/nav_sidebar.css | 150 + static/admin/css/responsive.css | 904 ++ static/admin/css/responsive_rtl.css | 89 + static/admin/css/rtl.css | 293 + static/admin/css/unusable_password_field.css | 19 + .../css/vendor/select2/LICENSE-SELECT2.md | 21 + static/admin/css/vendor/select2/select2.css | 481 + .../admin/css/vendor/select2/select2.min.css | 1 + static/admin/css/widgets.css | 613 + static/admin/img/LICENSE | 20 + static/admin/img/README.txt | 7 + static/admin/img/calendar-icons.svg | 63 + static/admin/img/gis/move_vertex_off.svg | 1 + static/admin/img/gis/move_vertex_on.svg | 1 + static/admin/img/icon-addlink.svg | 3 + static/admin/img/icon-alert.svg | 3 + static/admin/img/icon-calendar.svg | 9 + static/admin/img/icon-changelink.svg | 3 + static/admin/img/icon-clock.svg | 9 + static/admin/img/icon-deletelink.svg | 3 + static/admin/img/icon-hidelink.svg | 3 + static/admin/img/icon-no.svg | 3 + static/admin/img/icon-unknown-alt.svg | 3 + static/admin/img/icon-unknown.svg | 3 + static/admin/img/icon-viewlink.svg | 3 + static/admin/img/icon-yes.svg | 3 + static/admin/img/inline-delete.svg | 3 + static/admin/img/search.svg | 3 + static/admin/img/selector-icons.svg | 34 + static/admin/img/sorting-icons.svg | 19 + static/admin/img/tooltag-add.svg | 3 + static/admin/img/tooltag-arrowright.svg | 3 + static/admin/js/SelectBox.js | 116 + static/admin/js/SelectFilter2.js | 311 + static/admin/js/actions.js | 204 + static/admin/js/admin/DateTimeShortcuts.js | 408 + static/admin/js/admin/RelatedObjectLookups.js | 252 + static/admin/js/autocomplete.js | 33 + static/admin/js/calendar.js | 239 + static/admin/js/cancel.js | 29 + static/admin/js/change_form.js | 16 + static/admin/js/core.js | 184 + static/admin/js/filters.js | 30 + static/admin/js/inlines.js | 359 + static/admin/js/jquery.init.js | 8 + static/admin/js/nav_sidebar.js | 79 + static/admin/js/popup_response.js | 15 + static/admin/js/prepopulate.js | 43 + static/admin/js/prepopulate_init.js | 15 + static/admin/js/theme.js | 51 + static/admin/js/unusable_password_field.js | 29 + static/admin/js/urlify.js | 169 + static/admin/js/vendor/jquery/LICENSE.txt | 20 + static/admin/js/vendor/jquery/jquery.js | 10716 ++++++++++++++++ static/admin/js/vendor/jquery/jquery.min.js | 2 + static/admin/js/vendor/select2/LICENSE.md | 21 + static/admin/js/vendor/select2/i18n/af.js | 3 + static/admin/js/vendor/select2/i18n/ar.js | 3 + static/admin/js/vendor/select2/i18n/az.js | 3 + static/admin/js/vendor/select2/i18n/bg.js | 3 + static/admin/js/vendor/select2/i18n/bn.js | 3 + static/admin/js/vendor/select2/i18n/bs.js | 3 + static/admin/js/vendor/select2/i18n/ca.js | 3 + static/admin/js/vendor/select2/i18n/cs.js | 3 + static/admin/js/vendor/select2/i18n/da.js | 3 + static/admin/js/vendor/select2/i18n/de.js | 3 + static/admin/js/vendor/select2/i18n/dsb.js | 3 + static/admin/js/vendor/select2/i18n/el.js | 3 + static/admin/js/vendor/select2/i18n/en.js | 3 + static/admin/js/vendor/select2/i18n/es.js | 3 + static/admin/js/vendor/select2/i18n/et.js | 3 + static/admin/js/vendor/select2/i18n/eu.js | 3 + static/admin/js/vendor/select2/i18n/fa.js | 3 + static/admin/js/vendor/select2/i18n/fi.js | 3 + static/admin/js/vendor/select2/i18n/fr.js | 3 + static/admin/js/vendor/select2/i18n/gl.js | 3 + static/admin/js/vendor/select2/i18n/he.js | 3 + static/admin/js/vendor/select2/i18n/hi.js | 3 + static/admin/js/vendor/select2/i18n/hr.js | 3 + static/admin/js/vendor/select2/i18n/hsb.js | 3 + static/admin/js/vendor/select2/i18n/hu.js | 3 + static/admin/js/vendor/select2/i18n/hy.js | 3 + static/admin/js/vendor/select2/i18n/id.js | 3 + static/admin/js/vendor/select2/i18n/is.js | 3 + static/admin/js/vendor/select2/i18n/it.js | 3 + static/admin/js/vendor/select2/i18n/ja.js | 3 + static/admin/js/vendor/select2/i18n/ka.js | 3 + static/admin/js/vendor/select2/i18n/km.js | 3 + static/admin/js/vendor/select2/i18n/ko.js | 3 + static/admin/js/vendor/select2/i18n/lt.js | 3 + static/admin/js/vendor/select2/i18n/lv.js | 3 + static/admin/js/vendor/select2/i18n/mk.js | 3 + static/admin/js/vendor/select2/i18n/ms.js | 3 + static/admin/js/vendor/select2/i18n/nb.js | 3 + static/admin/js/vendor/select2/i18n/ne.js | 3 + static/admin/js/vendor/select2/i18n/nl.js | 3 + static/admin/js/vendor/select2/i18n/pl.js | 3 + static/admin/js/vendor/select2/i18n/ps.js | 3 + static/admin/js/vendor/select2/i18n/pt-BR.js | 3 + static/admin/js/vendor/select2/i18n/pt.js | 3 + static/admin/js/vendor/select2/i18n/ro.js | 3 + static/admin/js/vendor/select2/i18n/ru.js | 3 + static/admin/js/vendor/select2/i18n/sk.js | 3 + static/admin/js/vendor/select2/i18n/sl.js | 3 + static/admin/js/vendor/select2/i18n/sq.js | 3 + .../admin/js/vendor/select2/i18n/sr-Cyrl.js | 3 + static/admin/js/vendor/select2/i18n/sr.js | 3 + static/admin/js/vendor/select2/i18n/sv.js | 3 + static/admin/js/vendor/select2/i18n/th.js | 3 + static/admin/js/vendor/select2/i18n/tk.js | 3 + static/admin/js/vendor/select2/i18n/tr.js | 3 + static/admin/js/vendor/select2/i18n/uk.js | 3 + static/admin/js/vendor/select2/i18n/vi.js | 3 + static/admin/js/vendor/select2/i18n/zh-CN.js | 3 + static/admin/js/vendor/select2/i18n/zh-TW.js | 3 + .../admin/js/vendor/select2/select2.full.js | 6820 ++++++++++ .../js/vendor/select2/select2.full.min.js | 2 + static/admin/js/vendor/xregexp/LICENSE.txt | 21 + static/admin/js/vendor/xregexp/xregexp.js | 6126 +++++++++ static/admin/js/vendor/xregexp/xregexp.min.js | 17 + static/core/css/bootstrap.min.css | 6 + static/core/css/style.css | 438 + static/core/img/profile.webp | Bin 0 -> 17672 bytes static/core/js/script.js | 169 + 154 files changed, 33164 insertions(+), 198 deletions(-) create mode 100644 application/celery.py create mode 100644 centrifugo/config.json create mode 100644 gunicorn_conf.py create mode 100644 nginx.conf create mode 100644 questions/context_processors.py create mode 100644 questions/migrations/0004_answer_created_at_and_search_index.py create mode 100644 questions/tasks.py create mode 100644 simple_wsgi.py create mode 100644 static/admin/css/autocomplete.css create mode 100644 static/admin/css/base.css create mode 100644 static/admin/css/changelists.css create mode 100644 static/admin/css/dark_mode.css create mode 100644 static/admin/css/dashboard.css create mode 100644 static/admin/css/forms.css create mode 100644 static/admin/css/login.css create mode 100644 static/admin/css/nav_sidebar.css create mode 100644 static/admin/css/responsive.css create mode 100644 static/admin/css/responsive_rtl.css create mode 100644 static/admin/css/rtl.css create mode 100644 static/admin/css/unusable_password_field.css create mode 100644 static/admin/css/vendor/select2/LICENSE-SELECT2.md create mode 100644 static/admin/css/vendor/select2/select2.css create mode 100644 static/admin/css/vendor/select2/select2.min.css create mode 100644 static/admin/css/widgets.css create mode 100644 static/admin/img/LICENSE create mode 100644 static/admin/img/README.txt create mode 100644 static/admin/img/calendar-icons.svg create mode 100644 static/admin/img/gis/move_vertex_off.svg create mode 100644 static/admin/img/gis/move_vertex_on.svg create mode 100644 static/admin/img/icon-addlink.svg create mode 100644 static/admin/img/icon-alert.svg create mode 100644 static/admin/img/icon-calendar.svg create mode 100644 static/admin/img/icon-changelink.svg create mode 100644 static/admin/img/icon-clock.svg create mode 100644 static/admin/img/icon-deletelink.svg create mode 100644 static/admin/img/icon-hidelink.svg create mode 100644 static/admin/img/icon-no.svg create mode 100644 static/admin/img/icon-unknown-alt.svg create mode 100644 static/admin/img/icon-unknown.svg create mode 100644 static/admin/img/icon-viewlink.svg create mode 100644 static/admin/img/icon-yes.svg create mode 100644 static/admin/img/inline-delete.svg create mode 100644 static/admin/img/search.svg create mode 100644 static/admin/img/selector-icons.svg create mode 100644 static/admin/img/sorting-icons.svg create mode 100644 static/admin/img/tooltag-add.svg create mode 100644 static/admin/img/tooltag-arrowright.svg create mode 100644 static/admin/js/SelectBox.js create mode 100644 static/admin/js/SelectFilter2.js create mode 100644 static/admin/js/actions.js create mode 100644 static/admin/js/admin/DateTimeShortcuts.js create mode 100644 static/admin/js/admin/RelatedObjectLookups.js create mode 100644 static/admin/js/autocomplete.js create mode 100644 static/admin/js/calendar.js create mode 100644 static/admin/js/cancel.js create mode 100644 static/admin/js/change_form.js create mode 100644 static/admin/js/core.js create mode 100644 static/admin/js/filters.js create mode 100644 static/admin/js/inlines.js create mode 100644 static/admin/js/jquery.init.js create mode 100644 static/admin/js/nav_sidebar.js create mode 100644 static/admin/js/popup_response.js create mode 100644 static/admin/js/prepopulate.js create mode 100644 static/admin/js/prepopulate_init.js create mode 100644 static/admin/js/theme.js create mode 100644 static/admin/js/unusable_password_field.js create mode 100644 static/admin/js/urlify.js create mode 100644 static/admin/js/vendor/jquery/LICENSE.txt create mode 100644 static/admin/js/vendor/jquery/jquery.js create mode 100644 static/admin/js/vendor/jquery/jquery.min.js create mode 100644 static/admin/js/vendor/select2/LICENSE.md create mode 100644 static/admin/js/vendor/select2/i18n/af.js create mode 100644 static/admin/js/vendor/select2/i18n/ar.js create mode 100644 static/admin/js/vendor/select2/i18n/az.js create mode 100644 static/admin/js/vendor/select2/i18n/bg.js create mode 100644 static/admin/js/vendor/select2/i18n/bn.js create mode 100644 static/admin/js/vendor/select2/i18n/bs.js create mode 100644 static/admin/js/vendor/select2/i18n/ca.js create mode 100644 static/admin/js/vendor/select2/i18n/cs.js create mode 100644 static/admin/js/vendor/select2/i18n/da.js create mode 100644 static/admin/js/vendor/select2/i18n/de.js create mode 100644 static/admin/js/vendor/select2/i18n/dsb.js create mode 100644 static/admin/js/vendor/select2/i18n/el.js create mode 100644 static/admin/js/vendor/select2/i18n/en.js create mode 100644 static/admin/js/vendor/select2/i18n/es.js create mode 100644 static/admin/js/vendor/select2/i18n/et.js create mode 100644 static/admin/js/vendor/select2/i18n/eu.js create mode 100644 static/admin/js/vendor/select2/i18n/fa.js create mode 100644 static/admin/js/vendor/select2/i18n/fi.js create mode 100644 static/admin/js/vendor/select2/i18n/fr.js create mode 100644 static/admin/js/vendor/select2/i18n/gl.js create mode 100644 static/admin/js/vendor/select2/i18n/he.js create mode 100644 static/admin/js/vendor/select2/i18n/hi.js create mode 100644 static/admin/js/vendor/select2/i18n/hr.js create mode 100644 static/admin/js/vendor/select2/i18n/hsb.js create mode 100644 static/admin/js/vendor/select2/i18n/hu.js create mode 100644 static/admin/js/vendor/select2/i18n/hy.js create mode 100644 static/admin/js/vendor/select2/i18n/id.js create mode 100644 static/admin/js/vendor/select2/i18n/is.js create mode 100644 static/admin/js/vendor/select2/i18n/it.js create mode 100644 static/admin/js/vendor/select2/i18n/ja.js create mode 100644 static/admin/js/vendor/select2/i18n/ka.js create mode 100644 static/admin/js/vendor/select2/i18n/km.js create mode 100644 static/admin/js/vendor/select2/i18n/ko.js create mode 100644 static/admin/js/vendor/select2/i18n/lt.js create mode 100644 static/admin/js/vendor/select2/i18n/lv.js create mode 100644 static/admin/js/vendor/select2/i18n/mk.js create mode 100644 static/admin/js/vendor/select2/i18n/ms.js create mode 100644 static/admin/js/vendor/select2/i18n/nb.js create mode 100644 static/admin/js/vendor/select2/i18n/ne.js create mode 100644 static/admin/js/vendor/select2/i18n/nl.js create mode 100644 static/admin/js/vendor/select2/i18n/pl.js create mode 100644 static/admin/js/vendor/select2/i18n/ps.js create mode 100644 static/admin/js/vendor/select2/i18n/pt-BR.js create mode 100644 static/admin/js/vendor/select2/i18n/pt.js create mode 100644 static/admin/js/vendor/select2/i18n/ro.js create mode 100644 static/admin/js/vendor/select2/i18n/ru.js create mode 100644 static/admin/js/vendor/select2/i18n/sk.js create mode 100644 static/admin/js/vendor/select2/i18n/sl.js create mode 100644 static/admin/js/vendor/select2/i18n/sq.js create mode 100644 static/admin/js/vendor/select2/i18n/sr-Cyrl.js create mode 100644 static/admin/js/vendor/select2/i18n/sr.js create mode 100644 static/admin/js/vendor/select2/i18n/sv.js create mode 100644 static/admin/js/vendor/select2/i18n/th.js create mode 100644 static/admin/js/vendor/select2/i18n/tk.js create mode 100644 static/admin/js/vendor/select2/i18n/tr.js create mode 100644 static/admin/js/vendor/select2/i18n/uk.js create mode 100644 static/admin/js/vendor/select2/i18n/vi.js create mode 100644 static/admin/js/vendor/select2/i18n/zh-CN.js create mode 100644 static/admin/js/vendor/select2/i18n/zh-TW.js create mode 100644 static/admin/js/vendor/select2/select2.full.js create mode 100644 static/admin/js/vendor/select2/select2.full.min.js create mode 100644 static/admin/js/vendor/xregexp/LICENSE.txt create mode 100644 static/admin/js/vendor/xregexp/xregexp.js create mode 100644 static/admin/js/vendor/xregexp/xregexp.min.js create mode 100644 static/core/css/bootstrap.min.css create mode 100644 static/core/css/style.css create mode 100644 static/core/img/profile.webp create mode 100644 static/core/js/script.js diff --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
\n
\n
avatar
\n
0
\n
\n
\n

${data.body}

\n
\n
\n
\n`; + const answers = document.querySelectorAll('.answer-card'); + if (answers.length) { + answers[answers.length - 1].insertAdjacentHTML('afterend', answerHtml); + } else { + const questionArticle = document.querySelector('article.card'); + if (questionArticle) { + questionArticle.insertAdjacentHTML('afterend', answerHtml); + } else { + location.reload(); + } + } + }); + centrifuge.connect(); + } + } catch (e) { + console.error('centrifuge init error', e); + } +})(); diff --git a/questions/tasks.py b/questions/tasks.py new file mode 100644 index 0000000..cb81196 --- /dev/null +++ b/questions/tasks.py @@ -0,0 +1,94 @@ +from __future__ import annotations +from celery import shared_task +from django.core.cache import cache +from django.conf import settings +import requests +from django.core.mail import send_mail +from django.utils import timezone +from datetime import timedelta +from django.db.models import Count, Sum, Q +from django.contrib.auth.models import User +from .models import Tag, Question, Answer + + + +@shared_task +def rebuild_popular_tags(): + # compute top 10 tags by number of questions in last 3 months + 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 = [ + {'name': t.name, 'count': t.q_count} + for t in popular_qs + ] + # store in cache + cache.set('popular_tags', popular, timeout=60 * 60) + return popular + + +@shared_task +def rebuild_best_members(): + # compute top 10 users by activity (questions created in last week + total answers) + week_ago = timezone.now() - timedelta(days=7) + + # users with most questions in the last week (by count) + users_q = User.objects.annotate( + q_count=Count('question', filter=Q(question__created_at__gte=week_ago)) + ) + + # users with most answers in the last week + users_a = User.objects.annotate( + a_count=Count('answer', filter=Q(answer__created_at__gte=week_ago)) + ) + + # combine scores + 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) + + # build list of users sorted by score + best_users = User.objects.filter(id__in=list(scores.keys())) + best = [] + for u in best_users: + best.append({ + 'id': u.id, + 'username': u.username, + 'score': scores.get(u.id, 0) + }) + + best.sort(key=lambda x: x['score'], reverse=True) + best = best[:10] + + cache.set('best_members', best, timeout=60 * 60) + return best + + +@shared_task +def publish_new_answer_centrifugo(question_id: int, payload: dict): + # Send message to centrifugo pushed via HTTP API + # Use Centrifugo HTTP API (v4) publish endpoint + api_url = settings.CENTRIFUGO_URL.rstrip('/') + '/api' + headers = {'Authorization': f'apikey {settings.CENTRIFUGO_API_KEY}'} + data = { + 'method': 'publish', + 'params': { + 'channel': f'question_{question_id}', + 'data': payload, + } + } + try: + requests.post(api_url, json=data, headers=headers, timeout=5) + except Exception: + pass + + +@shared_task +def send_new_answer_email(subject: str, message: str, recipient_list: list[str]): + send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, recipient_list) diff --git a/questions/templates/core/layout/base.html b/questions/templates/core/layout/base.html index 9dc7ccf..ad1ceaa 100644 --- a/questions/templates/core/layout/base.html +++ b/questions/templates/core/layout/base.html @@ -7,12 +7,14 @@ +