Coverage for postrfp/vendor/api/issue.py: 98%

58 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-22 21:34 +0000

1""" 

2Respondent Issue endpoints. 

3 

4Exposes a limited Issue view for respondents: list/retrieve Issues, perform 

5allowed status transitions, toggle workflow usage, and create/list respondent 

6notes (with audit logging where applicable). 

7""" 

8 

9from sqlalchemy import not_, and_ 

10from sqlalchemy.orm import Session 

11 

12from postrfp.model import ( 

13 Issue, 

14 Project, 

15 IssueWatchList, 

16 Organisation, 

17 ProjectNote, 

18 AuditEvent, 

19 User, 

20) 

21from postrfp.authorisation import perms 

22from postrfp.shared import serial, fetch 

23from postrfp.shared.decorators import http 

24from postrfp.shared.issue_transition import change_issue_status 

25from postrfp.vendor.validation import validate 

26 

27# columns exposed to respondent users 

28respondent_columns = ( 

29 Issue.id, 

30 Issue.status, 

31 Issue.issue_date, 

32 Issue.accepted_date, 

33 Issue.deadline, 

34 Issue.label, 

35 Issue.winloss_exposed, 

36 Issue.winloss_expiry, 

37 Issue.submitted_date, 

38 Issue.respondent_id, 

39 Issue.use_workflow, 

40 Project.title, 

41 Project.org_id, 

42 IssueWatchList.date_created.isnot(None).label("is_watched"), 

43) 

44 

45 

46def _issue_q(session, user): 

47 return ( 

48 session.query(Issue) 

49 .join(Project) 

50 .join(Organisation) 

51 .outerjoin( 

52 IssueWatchList, 

53 and_( 

54 IssueWatchList.user_id == user.id, IssueWatchList.issue_id == Issue.id 

55 ), 

56 ) 

57 .with_entities(*respondent_columns) 

58 ) 

59 

60 

61@http 

62def get_issue( 

63 session: Session, effective_user: User, issue_id: int 

64) -> serial.VendorIssue: 

65 """ 

66 Retrieve a single Issue visible to the respondent (restricted field set including 

67 project title and watch status). Visibility validated against respondent org. 

68 """ 

69 # Using subscript notation here because we want to 

70 # reuse the list of columns but this won't work for returning 

71 # a single value 

72 issue = _issue_q(session, effective_user).filter(Issue.id == issue_id)[0] 

73 

74 validate(effective_user, issue) 

75 return issue._asdict() 

76 

77 

78@http 

79def get_issues(session: Session, effective_user: User) -> list[serial.VendorIssue]: 

80 """ 

81 List respondent Issues (excludes Not Sent / Retracted). Ordered by most recent 

82 issue_date descending. 

83 """ 

84 issues = ( 

85 _issue_q(session, effective_user) 

86 .filter( 

87 Issue.respondent_id == effective_user.organisation.id, 

88 not_(Issue.status.in_(("Not Sent", "Retracted"))), 

89 ) 

90 .order_by(Issue.issue_date.desc()) 

91 ) 

92 return [i._asdict() for i in issues] 

93 

94 

95""" Status Changes """ 

96 

97 

98@http 

99def post_issue_status( 

100 session: "Session", 

101 effective_user: "User", 

102 issue_id: int, 

103 issue_status_doc: serial.IssueStatus, 

104) -> None: 

105 """ 

106 Perform an allowed respondent status transition: Accepted, Submitted, Declined. 

107 Enforces mandatory answer / deadline rules on submission. Emits audit events. 

108 """ 

109 issue = fetch.issue(session, issue_id) 

110 status = issue_status_doc.new_status 

111 

112 # This check is already effectively performed in Issue.changed_status 

113 # but double checking here 

114 permitted_statuses = ("Accepted", "Submitted", "Declined") 

115 if status not in permitted_statuses: 

116 raise ValueError("new status must be one of %s" % str(permitted_statuses)) 

117 status_perms = { 

118 "Accepted": perms.ISSUE_ACCEPT, 

119 "Submitted": perms.ISSUE_SUBMIT, 

120 "Declined": perms.ISSUE_DECLINE, 

121 } 

122 action = status_perms[status] 

123 validate(effective_user, issue=issue, action=action) 

124 change_issue_status(issue, effective_user, status) 

125 

126 

127@http 

128def post_issue_workflow( 

129 session: Session, 

130 effective_user: User, 

131 issue_id: int, 

132 issue_workflow_doc: serial.IssueUseWorkflow, 

133) -> None: 

134 """ 

135 Toggle workflow usage flag for this Issue (no audit event). Permission: 

136 ISSUE_UPDATE_WORKFLOW. 

137 """ 

138 issue = fetch.issue(session, issue_id) 

139 validate(effective_user, issue=issue, action=perms.ISSUE_UPDATE_WORKFLOW) 

140 issue.use_workflow = issue_workflow_doc.use_workflow 

141 

142 

143""" NOTES """ 

144 

145 

146@http 

147def post_issue_note( 

148 session: Session, 

149 effective_user: User, 

150 issue_id: int, 

151 respondent_note_doc: serial.RespondentNote, 

152) -> serial.Id: 

153 """ 

154 Create a respondent note (kind=RespondentNote). Private flag restricts visibility 

155 to respondent org. Emits PROJECT_NOTE_ADDED audit event with field changes. 

156 """ 

157 issue = fetch.issue(session, issue_id) 

158 validate(effective_user, issue, action=perms.PROJECT_ADD_NOTE) 

159 project = issue.project 

160 

161 note = ProjectNote( 

162 kind="RespondentNote", 

163 project=project, 

164 note_text=respondent_note_doc.note_text, 

165 private=respondent_note_doc.private, 

166 user_id=effective_user.id, 

167 org_id=effective_user.org_id, 

168 target_org_id=None, # RespondentNotes don't set target_org_id 

169 ) 

170 

171 session.add(note) 

172 session.flush() 

173 

174 evt = AuditEvent.create( 

175 session, 

176 "PROJECT_NOTE_ADDED", 

177 project=project, 

178 object_id=note.id, 

179 user_id=note.user_id, 

180 org_id=note.org_id, 

181 ) 

182 evt.add_change("note_text", "", note.note_text) 

183 evt.add_change("private", "", note.private) 

184 session.add(evt) 

185 return serial.Id(id=note.id) 

186 

187 

188@http 

189def get_issue_notes( 

190 session: Session, effective_user: User, issue_id: int 

191) -> list[serial.ReadNote]: 

192 """ 

193 List notes visible to the respondent for this Issue (privacy / distribution 

194 filtering applied). 

195 """ 

196 issue = fetch.issue(session, issue_id) 

197 validate(effective_user, issue, action=perms.ISSUE_VIEW_ANSWERS) 

198 nq = fetch.vendor_notes(issue, effective_user) 

199 rn = serial.ReadNote 

200 return [rn.model_validate(note) for note in nq]