diff --git a/apps/hadis/views/category.py b/apps/hadis/views/category.py index 0a0095a..149d79c 100644 --- a/apps/hadis/views/category.py +++ b/apps/hadis/views/category.py @@ -477,14 +477,10 @@ class CategoriesView(ListAPIView): ) search_query = (self.request.query_params.get('search') or '').strip() if search_query: - from utils.text_normalizer import normalize_for_search - norm_q = normalize_for_search(search_query) - queryset = queryset.filter( - Q(normalized_text__icontains=norm_q) | - Q(title__icontains=search_query) | - Q(slug__icontains=search_query) | - Q(description__icontains=search_query) - ) + from utils.search_parser import build_advanced_search_q + search_conditions = build_advanced_search_q(search_query, ['normalized_text']) + if search_conditions: + queryset = queryset.filter(search_conditions).distinct() is_root = self.request.query_params.get('is_root', '').lower() if is_root in ['true', '1']: @@ -517,14 +513,10 @@ class CategoriesBySectView(ListAPIView): ) search_query = (self.request.query_params.get('search') or '').strip() if search_query: - from utils.text_normalizer import normalize_for_search - norm_q = normalize_for_search(search_query) - queryset = queryset.filter( - Q(normalized_text__icontains=norm_q) | - Q(title__icontains=search_query) | - Q(slug__icontains=search_query) | - Q(description__icontains=search_query) - ) + from utils.search_parser import build_advanced_search_q + search_conditions = build_advanced_search_q(search_query, ['normalized_text']) + if search_conditions: + queryset = queryset.filter(search_conditions).distinct() is_root_param = self.request.query_params.get('is_root', None) if is_root_param is not None: diff --git a/apps/hadis/views/hadis.py b/apps/hadis/views/hadis.py index 9ecaea5..1023775 100644 --- a/apps/hadis/views/hadis.py +++ b/apps/hadis/views/hadis.py @@ -361,21 +361,15 @@ class HadisListView(ListAPIView): # 👇 1. Apply Search Filter search_query = self.request.query_params.get('search', None) if search_query: - from utils.text_normalizer import normalize_for_search - norm_q = normalize_for_search(search_query) - - search_conditions = Q(normalized_text__icontains=norm_q) - search_conditions |= Q(text__icontains=search_query) - search_conditions |= Q(title__icontains=search_query) - search_conditions |= Q(title_narrator__icontains=search_query) - search_conditions |= Q(translation__icontains=search_query) - search_conditions |= Q(tags__title__icontains=search_query) - search_conditions |= Q(references__book_reference__normalized_text__icontains=norm_q) - search_conditions |= Q(references__book_reference__title__icontains=search_query) - search_conditions |= Q(transmitters__transmitter__normalized_text__icontains=norm_q) - search_conditions |= Q(transmitters__transmitter__full_name__icontains=search_query) - search_conditions |= Q(transmitters__transmitter__known_as__icontains=search_query) - queryset = queryset.filter(search_conditions) + from utils.search_parser import build_advanced_search_q + search_fields = [ + 'normalized_text', + 'references__book_reference__normalized_text', + 'transmitters__transmitter__normalized_text' + ] + search_conditions = build_advanced_search_q(search_query, search_fields) + if search_conditions: + queryset = queryset.filter(search_conditions).distinct() # 👇 2. Apply Status Filter (Supports multiple comma-separated slugs) status_filter = self.request.query_params.get('status', None) @@ -631,29 +625,19 @@ class HadisMainListView(ListAPIView): def apply_search_filter(self, queryset, search_query): """ - Apply search filter across multiple fields including normalized text and JSONFields. - Searches in: normalized_text, title, title_narrator, text, translation, tags, book references, transmitters + Apply advanced boolean search filter across multiple fields including normalized text. + Supports exact phrase ("..."), mandatory (+), exclude (-), and grouping (...). """ - from django.db.models import Q - from utils.text_normalizer import normalize_for_search - norm_q = normalize_for_search(search_query) - - # Primary normalized search (matches Arabic/Persian/English invariantly) - search_conditions = Q(normalized_text__icontains=norm_q) - - # Fallback raw search - search_conditions |= Q(text__icontains=search_query) - search_conditions |= Q(title__icontains=search_query) - search_conditions |= Q(title_narrator__icontains=search_query) - search_conditions |= Q(translation__icontains=search_query) - search_conditions |= Q(tags__title__icontains=search_query) - search_conditions |= Q(references__book_reference__normalized_text__icontains=norm_q) - search_conditions |= Q(references__book_reference__title__icontains=search_query) - search_conditions |= Q(transmitters__transmitter__normalized_text__icontains=norm_q) - search_conditions |= Q(transmitters__transmitter__full_name__icontains=search_query) - search_conditions |= Q(transmitters__transmitter__known_as__icontains=search_query) - - return queryset.filter(search_conditions) + from utils.search_parser import build_advanced_search_q + search_fields = [ + 'normalized_text', + 'references__book_reference__normalized_text', + 'transmitters__transmitter__normalized_text' + ] + search_conditions = build_advanced_search_q(search_query, search_fields) + if search_conditions: + return queryset.filter(search_conditions).distinct() + return queryset #we add this later @@ -929,19 +913,14 @@ class CategoryHadisCorrectionsView(ListAPIView): # Optional search query filter search_query = self.request.query_params.get('search', None) if search_query: - from utils.text_normalizer import normalize_for_search - norm_q = normalize_for_search(search_query) - search_conditions = ( - Q(normalized_text__icontains=norm_q) | - Q(hadis__normalized_text__icontains=norm_q) | - Q(title__icontains=search_query) | - Q(text__icontains=search_query) | - Q(narrator__icontains=search_query) | - Q(translation__icontains=search_query) | - Q(hadis__title__icontains=search_query) | - Q(hadis__title_narrator__icontains=search_query) - ) - queryset = queryset.filter(search_conditions) + from utils.search_parser import build_advanced_search_q + search_fields = [ + 'normalized_text', + 'hadis__normalized_text' + ] + search_conditions = build_advanced_search_q(search_query, search_fields) + if search_conditions: + queryset = queryset.filter(search_conditions).distinct() # Optional filter by specific hadith slug(s) or ID(s) hadis_param_keys = ['hadis_slug', 'hadis_slugs', 'hadiths'] diff --git a/apps/hadis/views/reference.py b/apps/hadis/views/reference.py index 3a36c96..2cc7e70 100644 --- a/apps/hadis/views/reference.py +++ b/apps/hadis/views/reference.py @@ -35,16 +35,11 @@ class BookReferencesView(ListAPIView): return queryset def apply_search_filter(self, queryset, search_query): - """ - Apply search filter across book titles (JSONField) and normalized text. - Searches in: normalized_text, title - """ - from utils.text_normalizer import normalize_for_search - norm_q = normalize_for_search(search_query) - - search_conditions = Q(normalized_text__icontains=norm_q) | Q(title__icontains=search_query) - - return queryset.filter(search_conditions) + from utils.search_parser import build_advanced_search_q + search_conditions = build_advanced_search_q(search_query, ['normalized_text']) + if search_conditions: + return queryset.filter(search_conditions).distinct() + return queryset class BookAuthorView(ListAPIView): diff --git a/apps/hadis/views/reference_v2.py b/apps/hadis/views/reference_v2.py index d3b2157..228d383 100644 --- a/apps/hadis/views/reference_v2.py +++ b/apps/hadis/views/reference_v2.py @@ -162,15 +162,10 @@ class BookReferenceV2ListView(generics.ListAPIView): search = self.request.query_params.get('search') if search: - from django.db.models import Q - from utils.text_normalizer import normalize_for_search - norm_q = normalize_for_search(search) - qs = qs.filter( - Q(normalized_text__icontains=norm_q) | - Q(title__icontains=search) | - Q(description__icontains=search) | - Q(author__name__icontains=search) - ) + from utils.search_parser import build_advanced_search_q + search_conditions = build_advanced_search_q(search, ['normalized_text']) + if search_conditions: + qs = qs.filter(search_conditions).distinct() # Category (Multi-select) categories = self._get_multi_param('category', 'categories', 'category[]', 'categories[]') diff --git a/apps/hadis/views/transmitter.py b/apps/hadis/views/transmitter.py index 93f848b..7510e15 100644 --- a/apps/hadis/views/transmitter.py +++ b/apps/hadis/views/transmitter.py @@ -42,20 +42,12 @@ class TransmitterView(ListAPIView): # 1. Apply search filter (Searching across name-related JSONFields, slug, legacy_id) if search_query: - from utils.text_normalizer import normalize_for_search - norm_q = normalize_for_search(search_query) - q_filter = ( - Q(normalized_text__icontains=norm_q) | - Q(full_name__icontains=search_query) | - Q(kunya__icontains=search_query) | - Q(known_as__icontains=search_query) | - Q(nickname__icontains=search_query) | - Q(slug__icontains=search_query) | - Q(legacy_id__icontains=search_query) - ) + from utils.search_parser import build_advanced_search_q + q_filter = build_advanced_search_q(search_query, ['normalized_text', 'slug', 'legacy_id']) if search_query.isdigit(): q_filter |= Q(legacy_number=int(search_query)) | Q(generation=int(search_query)) - queryset = queryset.filter(q_filter) + if q_filter: + queryset = queryset.filter(q_filter) # 2. Apply categorical filters if status_filter: @@ -424,15 +416,10 @@ class NarratorTeachersView(ListAPIView): search_query = self.request.query_params.get('search', None) if search_query: - from utils.text_normalizer import normalize_for_search - norm_q = normalize_for_search(search_query) - queryset = queryset.filter( - Q(normalized_text__icontains=norm_q) | - Q(full_name__icontains=search_query) | - Q(kunya__icontains=search_query) | - Q(known_as__icontains=search_query) | - Q(nickname__icontains=search_query) - ) + from utils.search_parser import build_advanced_search_q + q_filter = build_advanced_search_q(search_query, ['normalized_text']) + if q_filter: + queryset = queryset.filter(q_filter) return queryset @@ -477,15 +464,10 @@ class NarratorStudentsView(ListAPIView): search_query = self.request.query_params.get('search', None) if search_query: - from utils.text_normalizer import normalize_for_search - norm_q = normalize_for_search(search_query) - queryset = queryset.filter( - Q(normalized_text__icontains=norm_q) | - Q(full_name__icontains=search_query) | - Q(kunya__icontains=search_query) | - Q(known_as__icontains=search_query) | - Q(nickname__icontains=search_query) - ) + from utils.search_parser import build_advanced_search_q + q_filter = build_advanced_search_q(search_query, ['normalized_text']) + if q_filter: + queryset = queryset.filter(q_filter) return queryset diff --git a/utils/search_parser.py b/utils/search_parser.py new file mode 100644 index 0000000..331017f --- /dev/null +++ b/utils/search_parser.py @@ -0,0 +1,154 @@ +""" +Advanced Search Query Parser for Boolean & Operator-based Search. +Supports: +- Exact Phrase Match: "phrase" or «phrase» +- Mandatory / Required Term: +word or +"phrase" +- Excluded / Negative Term: -word or -"phrase" +- Grouped OR Conditions: (word1 word2) or +(word1 word2) +- Excluded Grouped OR Conditions: -(word1 word2) +- Compound Expressions: +term1 +term2 +"exact phrase" +(g1 g2) -excluded +Seamlessly integrates with Arabic & Persian search normalization. +""" + +import re +from typing import List, Optional, Tuple, Union +from django.db.models import Q +from utils.text_normalizer import normalize_for_search + +TOKEN_REGEX = re.compile( + r'(?P(?P[+-]?)\s*\((?P[^)]+)\))|' + r'(?P(?P[+-]?)\s*["«](?P[^"»]+)["»])|' + r'(?P(?P[+-]?)(?P[^\s()«»"]+))' +) + + +def sanitize_search_query(query: str) -> str: + """ + Cleans search query and balances unclosed quotes/parentheses. + """ + if not query: + return "" + q = query.strip() + # Balance double quotes + if q.count('"') % 2 != 0: + q += '"' + # Balance Persian/Arabic quotes + if q.count('«') > q.count('»'): + q += '»' * (q.count('«') - q.count('»')) + # Balance parentheses + if q.count('(') > q.count(')'): + q += ')' * (q.count('(') - q.count(')')) + return q + + +def parse_search_tokens(query: str) -> List[Tuple[str, str, Union[str, List[str]]]]: + """ + Parses a query string into structured search tokens: + - ('PHRASE', '+' | '-', phrase_text) + - ('WORD', '+' | '-', word_text) + - ('GROUP', '+' | '-', [item1, item2, ...]) + """ + q = sanitize_search_query(query) + tokens = [] + + for m in TOKEN_REGEX.finditer(q): + if m.group('group'): + prefix = m.group('g_prefix') or '+' + body = m.group('g_body').strip() + items = [] + for im in re.finditer(r'["«](?P[^"»]+)["»]|(?P[^\s"«»]+)', body): + txt = im.group('phrase') or im.group('word') + if txt and txt.strip(): + items.append(txt.strip()) + if items: + tokens.append(('GROUP', prefix, items)) + elif m.group('phrase'): + prefix = m.group('p_prefix') or '+' + body = m.group('p_body').strip() + if body: + tokens.append(('PHRASE', prefix, body)) + elif m.group('word'): + prefix = m.group('w_prefix') or '+' + body = m.group('w_body').strip() + if body and body not in ('+', '-'): + tokens.append(('WORD', prefix, body)) + + return tokens + + +def _build_single_term_match(norm_term: str, fields: List[str]) -> Q: + """ + Builds a Q condition matching a normalized term across the specified fields. + """ + term_q = Q() + for f in fields: + term_q |= Q(**{f"{f}__icontains": norm_term}) + return term_q + + +def build_advanced_search_q(query_str: Optional[str], fields: Optional[List[str]] = None) -> Q: + """ + Converts a user search query with operators (+, -, "", ()) into a Django Q object. + + :param query_str: Raw search query from the request + :param fields: List of model fields to search in (defaults to ['normalized_text']) + :return: Combined Django Q object + """ + if not query_str or not query_str.strip(): + return Q() + + if not fields: + fields = ['normalized_text'] + + tokens = parse_search_tokens(query_str) + if not tokens: + return Q() + + positive_q = Q() + has_positive = False + + negative_q = Q() + has_negative = False + + for token_type, prefix, payload in tokens: + is_exclude = (prefix == '-') + + if token_type in ('WORD', 'PHRASE'): + norm_val = normalize_for_search(payload) + if not norm_val: + continue + + term_q = _build_single_term_match(norm_val, fields) + if not term_q: + continue + + if is_exclude: + negative_q &= ~term_q + has_negative = True + else: + positive_q &= term_q + has_positive = True + + elif token_type == 'GROUP': + group_q = Q() + for item in payload: + norm_it = normalize_for_search(item) + if norm_it: + group_q |= _build_single_term_match(norm_it, fields) + + if group_q: + if is_exclude: + negative_q &= ~group_q + has_negative = True + else: + positive_q &= group_q + has_positive = True + + if has_positive and has_negative: + return positive_q & negative_q + elif has_positive: + return positive_q + elif has_negative: + return negative_q + + return Q()