- Redis / Celery / Celery Beat
- кэширование популярных тегов и лучших участников
- асинхронные уведомления в Centrifugo
- отправка email-уведомлений через Celery
- полнотекстовый поиск и подсказки
- Docker Compose инфраструктура
This commit is contained in:
redtea committed 2026-05-28 09:48:16 +03:00
1 parent becfbeb070
commit 4bc5fcef7a
154 files changed
+33164 -198

No files matched your search

+55
View File
@@ -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,
}
@@ -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,
),
]
+8
View File
@@ -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):
+36
View File
@@ -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;
+117 -130
View File
@@ -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 => `
<a class="search-suggestion" href="${item.url}">${item.title}</a>
`).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('<span> voted </span>');
},
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('<span> voted </span>');
});
});
$(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('<span> voted </span>');
}
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('<span> voted </span>');
});
});
$(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('<span> voted </span>');
},
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('<span> voted </span>');
});
});
$(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('<span> voted </span>');
},
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('<span> voted </span>');
});
});
$(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(`
<span class="correct-label">
✔ Correct
</span>
`);
},
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(`
<span class="correct-label">
✔ Correct
</span>
`);
});
});
});
(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<article class="answer-card" id="answer-${data.id}">\n <div>\n <div class="avatar-box">avatar</div>\n <div class="vote-box"><span id="answer-rating-${data.id}">0</span></div>\n </div>\n <div>\n <p>${data.body}</p>\n <div class="answer-controls"></div>\n </div>\n</article>\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);
}
})();
+94
View File
@@ -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)
+13 -3
View File
@@ -7,12 +7,14 @@
<meta name="description" content="VkDesk Q&A">
<link rel="stylesheet" href="{% static 'core/css/style.css' %}">
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://unpkg.com/centrifuge@2.2.1/centrifuge.min.js"></script>
</head>
<body>
<header class="site-header">
<a href="/" class="logo not-underlined">VkDesk</a>
<form class="search">
<input type="text" placeholder="Search">
<input id="search-input" type="text" placeholder="Search">
<div id="search-suggestions" class="search-suggestions"></div>
<a class="button button--ask" href="ask">ASK IT</a>
</form>
<div class="user-block">
@@ -49,11 +51,19 @@
<div class="sidebar-section">
<h2>Trending Tags</h2>
<div class="tag-cloud">
{% for tag in tags %}
<a href="/?tag={{ tag }}" class="not-underlined">{{tag}}</a>
{% for tag in popular_tags %}
<a href="/?tag={{ tag.name }}" class="not-underlined">{{ tag.name }}</a>
{% endfor %}
</div>
</div>
<div class="sidebar-section">
<h2>Best Members</h2>
<ul class="best-members">
{% for member in best_members %}
<li>{{ member.username }} ({{ member.score }})</li>
{% endfor %}
</ul>
</div>
</aside>
</div>
<footer class="footer">2026</footer>
+6 -1
View File
@@ -187,4 +187,9 @@
</form>
{% endblock %}
{% endblock %}
<script>
window.ASK_QUESTION_ID = {{ question.id }};
window.ANSWERS_COUNT = {{ answers|length }};
</script>
+1
View File
@@ -12,6 +12,7 @@ urlpatterns = [
path('question', views.question, name='question'),
path('signup', views.signup, name='signup'),
path('hot', views.hot, name='hot'),
path('search', views.search, name='search'),
path('logout', views.logout, name='logout'),
path('ajax/question/react/', views.question_react),
path('ajax/answer/react/', views.answer_react),
+56
View File
@@ -6,6 +6,7 @@ from django.utils.http import url_has_allowed_host_and_scheme
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector
from .models import (
Question,
@@ -22,6 +23,7 @@ from .forms import (
AskForm,
AnswerForm
)
from . import tasks as tasks_module
def index(request):
@@ -104,6 +106,30 @@ def hot(request):
})
def search(request):
query_text = request.GET.get('q', '').strip()
if not query_text:
return JsonResponse({'results': []})
vector = SearchVector('title', 'body')
query = SearchQuery(query_text)
questions = Question.objects.annotate(
rank=SearchRank(vector, query)
).filter(rank__gte=0.1).order_by('-rank')[:10]
results = [
{
'id': q.id,
'title': q.title,
'url': f'/question?question_id={q.id}'
}
for q in questions
]
return JsonResponse({'results': results})
def question(request):
question_id = request.GET.get('question_id')
@@ -130,6 +156,36 @@ def question(request):
question=question
)
# trigger async notifications and cache updates
try:
payload = {
'id': answer.id,
'body': answer.body,
'user': answer.user.username
}
tasks_module.publish_new_answer_centrifugo.delay(question.id, payload)
except Exception:
pass
try:
# send email to question author
recipient = []
if hasattr(question.user, 'profile'):
recipient = [question.user.profile.email]
subject = f'New answer to your question: {question.title}'
message = f'User {answer.user.username} answered your question.\n\n{answer.body}'
if recipient and recipient[0]:
tasks_module.send_new_answer_email.delay(subject, message, recipient)
except Exception:
pass
# optionally trigger cache rebuilds
try:
tasks_module.rebuild_popular_tags.delay()
tasks_module.rebuild_best_members.delay()
except Exception:
pass
return redirect(
f'/question?question_id={question.id}#answer-{answer.id}'
)