Coverage for rfpy/api/endpoints/search.py: 100%

40 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-24 10:52 +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 rfpy.suxint import http 

10from rfpy.model import User 

11from rfpy.api import fetch, validate, update 

12from rfpy.web import serial 

13from rfpy.auth import perms 

14from rfpy.model.composite import update_qmeta_table 

15from rfpy.model.questionnaire import QuestionInstance, QuestionDefinition, to_b36 

16 

17 

18@http 

19def get_search( 

20 session: Session, 

21 user: User, 

22 search_term: str, 

23 search_options: list[str], 

24 q_project_id: int, 

25 offset: Optional[int] = None, 

26) -> list[serial.SearchResult]: 

27 """ 

28 Text search for the various Object types given by 'options'. 

29 

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

31 

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

33 

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

35 

36 * Prepend a + symbol to a word to only see results with that word 

37 * Prepend a - symbol to exlude any records containing that word 

38 * Append * to a word to act as a wildcard 

39 

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

41 

42 1. Must contain the work "green" 

43 2. Should contain "fingers" 

44 3. Should contain words starting with "find" e.g. "findings" or "finds" or "Findlay" 

45 4. Must not contain the work "expensive" 

46 

47 

48 ### Results 

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

50 

51 * __klass__ - identifies the type of search result, corresponding to the options parameter 

52 * __project_title__ - title of the associated Project 

53 * __object_id__ - for questions, answers, choices and scoreComment records this is in the form 

54 of a string containing section ID and question ID separated by a | character, e.g. 

55 239|121231, where 239 is the Section ID and 121231 is the Question ID. For note records 

56 the object_id is the ID of that note. 

57 * __object_ref__ - for questions, answers, choices and scoreComment this is the question number 

58 e.g. '4.2.18' 

59 * __snippet__ - this a block of text surrounding a containing the searchTerm 

60 * __search_score__ - a score indicating the closeness of match 

61 

62 """ 

63 

64 if offset is None: 

65 offset = 0 

66 

67 user.check_is_standard() 

68 

69 if not search_options: 

70 return [] 

71 

72 if q_project_id is not None: 

73 project = fetch.project(session, q_project_id) 

74 validate.check(user, perms.PROJECT_ACCESS, project=project) 

75 

76 hits = fetch.search( 

77 session, user.org_id, search_term, search_options, q_project_id, offset 

78 ) 

79 return [serial.SearchResult(**h) for h in hits] 

80 

81 

82@http 

83def post_project_questions_replace( 

84 session, user, project_id, replace_doc: serial.TextReplace 

85) -> list[serial.ReplacedItem]: 

86 """ 

87 Replace search_term with replace_term (from the Request's json body) in all questions in the 

88 project. Question titles, label field and multiple choice options are updated. 

89 

90 If the JSON body field 'dry_run' is true then no update is made, but the results returned 

91 show what changes would be made. 

92 

93 The Response is an array of ReplacedItem records, one for each question element or question 

94 title that has been changed, together with the old and new values. 

95 

96 This search is case sensitive 

97 """ 

98 search_term = replace_doc.search_term 

99 replace_term = replace_doc.replace_term 

100 dry_run = replace_doc.dry_run 

101 

102 if len(search_term) < 3: 

103 raise ValueError( 

104 ( 

105 f"'{search_term}' is an invalid search term. " 

106 f" must be at least 3 characters wrong" 

107 ) 

108 ) 

109 

110 project = fetch.project(session, project_id) 

111 validate.check(user, perms.PROJECT_VIEW_QUESTIONNAIRE, project=project) 

112 

113 changes = list( 

114 update.label_text(project, search_term, replace_term, dry_run=dry_run) 

115 ) 

116 changes.extend( 

117 update.question_titles(project, search_term, replace_term, dry_run=dry_run) 

118 ) 

119 changes.extend( 

120 update.choices_text(project, search_term, replace_term, dry_run=dry_run) 

121 ) 

122 

123 if dry_run: 

124 return changes 

125 

126 qnums = {to_b36(c["question_number"]) for c in changes} 

127 qidq = ( 

128 session.query(QuestionDefinition.id) 

129 .join(QuestionInstance) 

130 .filter(QuestionInstance.project == project) 

131 .filter(QuestionInstance.number.in_(qnums)) 

132 ) 

133 qids = [r.id for r in qidq] 

134 update_qmeta_table(session, qids) 

135 

136 return changes