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

1""" 

2Search for question, answer or note text within or across projects 

3""" 

4 

5from typing import Optional 

6 

7from sqlalchemy.orm import Session 

8 

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 

18 

19 

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'. 

31 

32 Optionally, filter results by project ID (project_id parameter) 

33 

34 20 results are returned per query. Results can be paged using the offset parameter. 

35 

36 The following special characters can be used in searchTerm to refine the search: 

37 

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 

41 

42 For example "+green fingers find* -expensive" will find records that: 

43 

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" 

48 

49 

50 ### Results 

51 A SearchResult record is returned for each hit. This has the following fields: 

52 

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 

63 

64 """ 

65 

66 if offset is None: 

67 offset = 0 

68 

69 user.check_is_standard() 

70 

71 if not search_options: 

72 return [] 

73 

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) 

77 

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] 

82 

83 

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. 

91 

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. 

94 

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. 

97 

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 

103 

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 ) 

111 

112 project = fetch.project(session, project_id) 

113 authorise.check(user, perms.PROJECT_VIEW_QUESTIONNAIRE, project=project) 

114 

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 ) 

124 

125 if dry_run: 

126 return changes 

127 

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) 

137 

138 return changes