hw6, hw7
- Redis / Celery / Celery Beat - кэширование популярных тегов и лучших участников - асинхронные уведомления в Centrifugo - отправка email-уведомлений через Celery - полнотекстовый поиск и подсказки - Docker Compose инфраструктура
This commit is contained in:
1 parent
becfbeb070
commit
4bc5fcef7a
154 files changed
+33164
-198
No files matched your search
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
|
||||
@@ -187,4 +187,9 @@
|
||||
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
<script>
|
||||
window.ASK_QUESTION_ID = {{ question.id }};
|
||||
window.ANSWERS_COUNT = {{ answers|length }};
|
||||
</script>
|
||||
@@ -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),
|
||||
|
||||
@@ -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}'
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user