relay/app/search/views.py
2025-11-05 15:03:20 +01:00

138 lines
4.6 KiB
Python

from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from django.core.cache import cache
from django.db.models import Q
from django.core.paginator import Paginator
import logging
import hashlib
import re
from app.feeds.models import Post
logger = logging.getLogger(__name__)
class SearchView(APIView):
"""Search posts by free text or tags with pagination"""
def get(self, request):
query = request.query_params.get("q")
tag = request.query_params.get("tag")
page = int(request.query_params.get("page", 1))
per_page = min(int(request.query_params.get("perPage", 10)), 50)
# Validate search parameters
if not query and not tag:
return Response(
{
"type": "Error",
"errors": ["Either 'q' (query) or 'tag' parameter is required"],
"data": None,
},
status=status.HTTP_400_BAD_REQUEST,
)
if page < 1:
return Response(
{
"type": "Error",
"errors": ["Page number must be 1 or greater"],
"data": None,
},
status=status.HTTP_400_BAD_REQUEST,
)
# Build cache key
search_term = query if query else f"tag:{tag}"
cache_key = f"search_{hashlib.md5(search_term.encode()).hexdigest()[:8]}_{page}_{per_page}"
cached_response = cache.get(cache_key)
if cached_response is not None:
return Response(cached_response, status=status.HTTP_200_OK)
# Build search query
posts_query = Post.objects.select_related("profile").order_by("-created_at")
if query:
# Search in content using case-insensitive contains
posts_query = posts_query.filter(
Q(content__icontains=query) | Q(tags__icontains=query)
)
search_type = "query"
search_value = query
else:
# Search by specific tag (exact word match, case insensitive)
# Match tag as complete word: at start, end, middle, or alone
# Examples: "emacs", "emacs org-mode", "python emacs", "emacs-lisp emacs"
tag_escaped = re.escape(tag)
# Use word boundaries to match complete words only
tag_pattern = rf"(^|[\s]){tag_escaped}([\s]|$)"
posts_query = posts_query.filter(tags__iregex=tag_pattern)
search_type = "tag"
search_value = tag
# Get total count for pagination
total_posts = posts_query.count()
# Apply pagination
paginator = Paginator(posts_query, per_page)
if page > paginator.num_pages and total_posts > 0:
return Response(
{
"type": "Error",
"errors": [
f"Page {page} does not exist. Maximum page is {paginator.num_pages}"
],
"data": None,
},
status=status.HTTP_400_BAD_REQUEST,
)
posts_page = paginator.get_page(page)
# Build data array with post URLs
data = []
for post in posts_page:
post_url = f"{post.profile.feed}#{post.post_id}"
data.append(post_url)
# Generate pagination links
base_url = "/search"
if query:
base_url += f"?q={query}"
else:
base_url += f"?tag={tag}"
if per_page != 10:
base_url += f"&perPage={per_page}"
# Build response
response_data = {
"type": "Success",
"errors": [],
"data": data,
"meta": {
search_type: search_value,
"total": total_posts,
"page": page,
"perPage": per_page,
"hasNext": posts_page.has_next(),
"hasPrevious": posts_page.has_previous(),
},
"_links": {
"self": {"href": f"{base_url}&page={page}", "method": "GET"},
"next": {"href": f"{base_url}&page={page + 1}", "method": "GET"}
if posts_page.has_next()
else None,
"previous": {"href": f"{base_url}&page={page - 1}", "method": "GET"}
if posts_page.has_previous()
else None,
},
}
# Cache permanently (will be cleared by scan_feeds task)
cache.set(cache_key, response_data, None)
return Response(response_data, status=status.HTTP_200_OK)