Coverage for postrfp/buyer/api/endpoints/search.py: 100%
42 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-22 21:34 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-22 21:34 +0000
1"""
2Search for question, answer or note text within or across projects
3"""
5from typing import Optional
7from sqlalchemy.orm import Session
9from postrfp.model.questionnaire.b36 import to_b36
10from postrfp.shared import fetch, update
11from postrfp.shared.decorators import http
12from postrfp.model import User
13from postrfp.buyer.api import authorise
14from postrfp.shared import serial
15from postrfp.authorisation import perms
16from postrfp.model.composite import update_qmeta_table
17from postrfp.model.questionnaire.nodes import QuestionInstance, QuestionDefinition
20@http
21def get_search(
22 session: Session,
23 user: User,
24 search_term: str,
25 search_options: list[str],
26 q_project_id: int,
27 offset: Optional[int] = None,
28) -> list[serial.SearchResult]:
29 """
30 Text search for the various Object types given by 'options'.
32 Optionally, filter results by project ID (project_id parameter)
34 20 results are returned per query. Results can be paged using the offset parameter.
36 The following special characters can be used in searchTerm to refine the search:
38 * Prepend a + symbol to a word to only see results with that word
39 * Prepend a - symbol to exlude any records containing that word
40 * Append * to a word to act as a wildcard
42 For example "+green fingers find* -expensive" will find records that:
44 1. Must contain the work "green"
45 2. Should contain "fingers"
46 3. Should contain words starting with "find" e.g. "findings" or "finds" or "Findlay"
47 4. Must not contain the work "expensive"
50 ### Results
51 A SearchResult record is returned for each hit. This has the following fields:
53 * __klass__ - identifies the type of search result, corresponding to the options parameter
54 * __project_title__ - title of the associated Project
55 * __object_id__ - for questions, answers, choices and scoreComment records this is in the form
56 of a string containing section ID and question ID separated by a | character, e.g.
57 239|121231, where 239 is the Section ID and 121231 is the Question ID. For note records
58 the object_id is the ID of that note.
59 * __object_ref__ - for questions, answers, choices and scoreComment this is the question number
60 e.g. '4.2.18'
61 * __snippet__ - this a block of text surrounding a containing the searchTerm
62 * __search_score__ - a score indicating the closeness of match
64 """
66 if offset is None:
67 offset = 0
69 user.check_is_standard()
71 if not search_options:
72 return []
74 if q_project_id is not None:
75 project = fetch.project(session, q_project_id)
76 authorise.check(user, perms.PROJECT_ACCESS, project=project)
78 hits = fetch.search(
79 session, user.org_id, search_term, search_options, q_project_id, offset
80 )
81 return [serial.SearchResult(**h) for h in hits]
84@http
85def post_project_questions_replace(
86 session: Session, user: User, project_id: int, replace_doc: serial.TextReplace
87) -> list[serial.ReplacedItem]:
88 """
89 Replace search_term with replace_term (from the Request's json body) in all questions in the
90 project. Question titles, label field and multiple choice options are updated.
92 If the JSON body field 'dry_run' is true then no update is made, but the results returned
93 show what changes would be made.
95 The Response is an array of ReplacedItem records, one for each question element or question
96 title that has been changed, together with the old and new values.
98 This search is case sensitive
99 """
100 search_term = replace_doc.search_term
101 replace_term = replace_doc.replace_term
102 dry_run = replace_doc.dry_run
104 if len(search_term) < 3:
105 raise ValueError(
106 (
107 f"'{search_term}' is an invalid search term. "
108 f" must be at least 3 characters wrong"
109 )
110 )
112 project = fetch.project(session, project_id)
113 authorise.check(user, perms.PROJECT_VIEW_QUESTIONNAIRE, project=project)
115 changes = list(
116 update.label_text(project, search_term, replace_term, dry_run=dry_run)
117 )
118 changes.extend(
119 update.question_titles(project, search_term, replace_term, dry_run=dry_run)
120 )
121 changes.extend(
122 update.choices_text(project, search_term, replace_term, dry_run=dry_run)
123 )
125 if dry_run:
126 return changes
128 qnums = {to_b36(c["question_number"]) for c in changes}
129 qidq = (
130 session.query(QuestionDefinition.id)
131 .join(QuestionInstance)
132 .filter(QuestionInstance.project == project)
133 .filter(QuestionInstance.b36_number.in_(qnums))
134 )
135 qids = [r.id for r in qidq]
136 update_qmeta_table(session, qids)
138 return changes