Coverage for rfpy/api/endpoints/projects.py: 98%

244 statements  

« prev     ^ index     » next       coverage.py v7.0.1, created at 2022-12-31 16:00 +0000

1''' 

2Manage projects, project permissions & project notes 

3''' 

4from datetime import datetime 

5from rfpy.model.misc import Category 

6from typing import List, Set 

7 

8from sqlalchemy.orm import Session 

9 

10from rfpy.suxint import http 

11from rfpy.web import Pager, serial 

12from rfpy.model import ( 

13 Project, AuditEvent, Participant, ProjectPermission, SectionPermission, 

14 User, Section, Issue, ProjectWatchList, ProjectField 

15) 

16from rfpy.model.exc import BusinessRuleViolation 

17from rfpy.auth import perms, ROLES 

18from rfpy.api import fetch, validate, update, domain_permissions 

19 

20 

21@http 

22def get_projects(session: Session, 

23 user: User, 

24 project_statuses: Set[str] = None, 

25 project_sort: str = 'date_created', 

26 sort_order: str = 'desc', 

27 q_participant_id: str = None, 

28 q_project_title: str = None, 

29 q_category_id: str = None, 

30 pager: Pager = None) -> serial.ProjectList: 

31 ''' 

32 An array of projects that the user's organisation is a participant 

33 of and that the user, if restricted, has permission to access. 

34 

35 If no values are provided for projectStatus then projects with any status are returned. 

36 

37 Using the "participantId" query param will filter the list of projects to include only 

38 those for which have a Participant organisation whose ID is that of participantID. This 

39 parameter is only available to users in Consultant organisations and the participantId must 

40 be the ID of an organisation that is a Client of the Consultant. 

41 ''' 

42 if pager is None: 

43 pager = Pager(page=1, pagesize=100) 

44 

45 if q_participant_id: 

46 participant_org = fetch.organisation(session, q_participant_id) 

47 validate.check(user, perms.MANAGE_ORGANISATION, target_org=participant_org) 

48 

49 pq = fetch.projects_with_watched(session, user, participant_id=q_participant_id) 

50 

51 if q_category_id: 

52 pq = pq.filter(Project.categories.any(Category.id==q_category_id)) 

53 

54 sort = getattr(Project, project_sort) 

55 sort_directed = sort.asc() if sort_order == 'asc' else sort.desc() 

56 pq = pq.order_by(sort_directed) 

57 

58 if project_statuses: 

59 pq = pq.filter(Project.status.in_(project_statuses)) 

60 

61 if q_project_title: 

62 pq = pq.filter(Project.title.contains(q_project_title)) 

63 

64 res = [] 

65 total_records = pq.count() 

66 for proj in pq.slice(pager.startfrom, pager.goto): 

67 pdict = proj._asdict() 

68 res.append(pdict) 

69 

70 return { 

71 "data": res, 

72 "pagination": pager.asdict(total_records, len(res)) 

73 } 

74 

75 

76@http 

77def get_project(session: Session, project_id: int, user: User) -> serial.FullProject: 

78 '''Get a project checking that the user is in a participant 

79 organisation and has explicit user permissions for that project 

80 ''' 

81 project = fetch.project(session, project_id, with_description=True) 

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

83 schema: serial.FullProject = serial.FullProject.from_orm(project) 

84 schema.set_permissions(user) 

85 return schema 

86 

87 

88@http 

89def post_project(session, user, new_project_doc) -> serial.NewProjectIds: 

90 ''' 

91 Create a new Project 

92 

93 ID values provided in the category_ids field of the JSON request body 

94 are used to link the new project to categories with those ID values. 

95 

96 The 'section_id' field in the Response is the ID of the Root Section for the 

97 newly created project. 

98 ''' 

99 user.check_permission(perms.PROJECT_CREATE) 

100 ptitle = new_project_doc.pop('title') 

101 project_fields = new_project_doc.pop('project_fields', []) 

102 category_ids = new_project_doc.pop('category_ids', []) 

103 

104 sec_title = new_project_doc.pop('questionnaire_title') 

105 root_section = Section(title=sec_title) 

106 

107 project = Project(ptitle, **new_project_doc) 

108 project.owner_org = user.organisation 

109 project.author = user 

110 project.status = 'Draft' 

111 session.add(project) 

112 participant = Participant(organisation=user.organisation, 

113 role='Administrator') 

114 project.participants.append(participant) 

115 session.flush() 

116 evt = AuditEvent.create('PROJECT_CREATED', 

117 object_id=project.id, 

118 user=user, 

119 project=project) 

120 

121 root_section.project_id = project.id 

122 project.root_section = root_section 

123 

124 # project_fields 

125 for idx, pf in enumerate(project_fields): 

126 new_pf = ProjectField(position=idx, **pf) 

127 evt.add_change(f'extra fields/{new_pf.key}', None, new_pf.value) 

128 project.project_fields.append(new_pf) 

129 

130 if category_ids: 

131 cat_map = {cat.id: cat for cat in user.organisation.categories} 

132 for cat_id in category_ids: 

133 if cat_id not in cat_map: 

134 org_id = user.organisation.id 

135 raise ValueError(f'Category #{cat_id} is not found for org {org_id}') 

136 cat = cat_map[cat_id] 

137 project.categories.append(cat) 

138 

139 for k, v in new_project_doc.items(): 

140 if not v: 

141 continue 

142 evt.add_change(k.title(), '', v) # title() converts string to Title Case 

143 session.add(evt) 

144 session.flush() 

145 

146 return serial.NewProjectIds(id=project.id, section_id=root_section.id) 

147 

148 

149@http 

150def put_project(session, user, project_id, update_project_doc): 

151 ''' 

152 Update the project with the values contained in the UpdateableProject document. 

153 

154 If 'project_fields' is set then the provided list of project fields is used 

155 to overwrite the existing values. 

156 ''' 

157 

158 project = fetch.project(session, project_id) 

159 validate.check(user, perms.PROJECT_EDIT, project=project) 

160 

161 evt = AuditEvent.create('PROJECT_UPDATED', 

162 object_id=project.id, 

163 user=user, 

164 project=project) 

165 

166 # project_fields 

167 project_fields = update_project_doc.pop('project_fields', None) 

168 if project_fields: 

169 project.project_fields.clear() 

170 session.flush() # Required for delete-orphan cascade to work 

171 for idx, pf in enumerate(project_fields): 

172 new_pf = ProjectField(position=idx, **pf) 

173 evt.add_change(f'extra fields/{new_pf.key}', None, new_pf.value) 

174 project.project_fields.append(new_pf) 

175 

176 for attr_name, new_val in update_project_doc.items(): 

177 current_val = getattr(project, attr_name) 

178 if current_val != new_val: 

179 evt.add_change(attr_name.title(), current_val, new_val) 

180 setattr(project, attr_name, new_val) 

181 session.add(evt) 

182 

183 

184@http 

185def delete_project(session: Session, user: User, project_id: int): 

186 ''' 

187 Delete the given project together with all sections, subsections and questions 

188 ''' 

189 user.check_permission(perms.PROJECT_DELETE) 

190 project = fetch.project(session, project_id) 

191 

192 update.delete_qinstances_update_def_refcounts(session, project_id) 

193 

194 session.query(Section).filter(Section.id == project.section_id).delete() 

195 session.query(Issue).filter(Issue.project_id == project.id).delete() 

196 evt = AuditEvent.create('PROJECT_DELETED', object_id=project_id, user=user, 

197 project=project) 

198 session.add(evt) 

199 session.flush() 

200 session.delete(project) 

201 

202 

203@http 

204def get_project_participants(session, project_id, user) -> serial.ParticipantList: 

205 ''' 

206 List organisations that are registered as Participants in the given project together 

207 with the Role assigned in the given project. 

208 ''' 

209 project = fetch.project(session, project_id) 

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

211 return [p.as_dict() for p in project.participants] 

212 

213 

214@http 

215def put_project_participants(session, 

216 user, 

217 project_id, 

218 participants_list) -> serial.ParticipantList: 

219 ''' 

220 Update the Organisations participating in the given project together with their Roles 

221 

222 The current user's own Organisation cannot be removed as participant. 

223 ''' 

224 project = fetch.project(session, project_id) 

225 validate.check(user, perms.PROJECT_MANAGE_ROLES, project=project) 

226 

227 new_participants_dict = {p['org_id']: p['role'] for p in participants_list} 

228 

229 for p in project.participants: 

230 if p.org_id not in new_participants_dict: 

231 if p.org_id != user.org_id: # don't unparticipate the current user 

232 session.delete(p) 

233 else: 

234 p.role = new_participants_dict[p.org_id] 

235 del new_participants_dict[p.org_id] 

236 

237 # new_participants_dict now contains only orgs not already a Participant in this project 

238 for org_id, role in new_participants_dict.items(): 

239 org = fetch.organisation(session, org_id) 

240 if not org.is_buyside: 

241 raise ValueError((f'Only Buyer or Consultant organisations can be Participants.' 

242 f' {org_id} is a {org.type}')) 

243 project.participants.append(Participant(organisation=org, role=role)) 

244 

245 return [p.as_dict() for p in project.participants] 

246 

247 

248@http 

249def put_project_permissions(session, user, project_id, perm_doc: serial.ProjectPermission): 

250 ''' 

251 Assign section permissions for a restricted user to access the project 

252 ''' 

253 project = fetch.project(session, project_id) 

254 validate.check(user, perms.MANAGE_USERS, target_org=user.organisation) 

255 validate.check(user, perms.PROJECT_MANAGE_ROLES, project=project, target_org=user.organisation) 

256 return domain_permissions.save_project_permissions(user, project, perm_doc) 

257 

258 

259@http 

260def get_project_users(session, user, project_id, restricted_users) -> List[serial.User]: 

261 project = fetch.project(session, project_id) 

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

263 validate.check(user, perms.MANAGE_USERS, target_org=user.organisation) 

264 user_query = fetch.project_users(user, project_id, restricted_users) 

265 return [serial.User.from_orm(u) for u in user_query] 

266 

267 

268@http 

269def get_project_user_permissions(session, user, project_id, target_user=None): 

270 ''' 

271 Returns a User dictionary augmented with sets of permissions (string names) given the 

272 the user's effective permissions in the given project. This set is derived from the 

273 intersection of the user's permissions plus the user's participant organisation 

274 permission (role) in the project. 

275 

276 If target_user is given then the permissions apply to that user, otherwise 

277 the permissions apply to the current user (user arg) 

278 ''' 

279 project = fetch.project(session, project_id) 

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

281 

282 if target_user is None or target_user == user.id: 

283 perms_user = user 

284 else: 

285 perms_user = fetch.user(session, target_user.id) 

286 validate.check(user, perms.MANAGE_USERS, 

287 target_org=perms_user.organisation, 

288 target_user=perms_user) 

289 

290 participant = project.get_participant(perms_user.organisation) 

291 

292 project_permissions = ROLES[participant.role] 

293 

294 udict = perms_user.as_dict() 

295 udict['effectivePermissions'] = sorted(project_permissions & perms_user.all_permissions) 

296 udict['userPermissions'] = sorted(perms_user.all_permissions) 

297 udict['participantPermissions'] = sorted(project_permissions) 

298 udict['participantRole'] = participant.role 

299 

300 return udict 

301 

302 

303@http 

304def get_project_permissions(session, user, project_id, target_user): 

305 ''' 

306 returns { 

307 target_user: user whose permissions are in focus 

308 permissions: list of section ids the user can access 

309 } 

310 ''' 

311 project = fetch.project(session, project_id) 

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

313 validate.check(user, perms.MANAGE_USERS, 

314 target_user=target_user, 

315 target_org=target_user.organisation) 

316 

317 proj_query = session.query(SectionPermission)\ 

318 .join(ProjectPermission)\ 

319 .join(Participant)\ 

320 .filter(Participant.org_id == target_user.organisation.id, 

321 SectionPermission.user == target_user, 

322 Participant.project == project) 

323 

324 return { 

325 'permissions': [sp.section_id for sp in proj_query], 

326 'user': target_user.as_dict() 

327 } 

328 

329 

330@http 

331def get_project_watchlist(session, user, project_id) -> List[serial.Watcher]: 

332 ''' 

333 List the users watching this project 

334 ''' 

335 project = fetch.project(session, project_id) 

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

337 return [serial.Watcher.from_orm(w) for w in fetch.project_watchers(session, project)] 

338 

339 

340@http 

341def post_project_watch(session, user, project_id, watch_doc) -> List[serial.Watcher]: 

342 ''' 

343 Assign a new user as a watcher of the given project. 

344 Returns a list of user ids watching the current project 

345 ''' 

346 project = fetch.project(session, project_id) 

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

348 

349 target_user_id = watch_doc.get('targetUser', None) 

350 

351 if target_user_id is None: 

352 watcher_user = user 

353 else: 

354 watcher_user = fetch.user(session, target_user_id) 

355 validate.check(user, perms.MANAGE_USERS, 

356 target_user=watcher_user, target_org=watcher_user.organisation) 

357 

358 if not project.add_watcher(watcher_user): 

359 raise BusinessRuleViolation('User %s is already watching Project %s' 

360 % (watcher_user.id, project.title)) 

361 session.flush() 

362 return get_project_watchlist(session, user, project.id) 

363 

364 

365@http 

366def delete_project_watch(session, user, project_id, target_user) -> List[serial.Watcher]: 

367 ''' 

368 Remove the target user from the watchlist for this project 

369 ''' 

370 project = fetch.project(session, project_id) 

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

372 if target_user is not user: 

373 validate.check(user, perms.MANAGE_USERS, 

374 target_user=target_user, target_org=target_user.organisation) 

375 project.watch_list.filter(ProjectWatchList.user == target_user).delete() 

376 return get_project_watchlist(session, user, project.id) 

377 

378 

379@http 

380def put_project_category(session, user, project_id, category_id): 

381 '''Assign the category with the given ID to the project at this URL''' 

382 project = fetch.project(session, project_id) 

383 validate.check(user, perms.PROJECT_EDIT, project=project) 

384 category = fetch.category_for_user(session, user, category_id) 

385 if category in project.categories: 

386 raise ValueError(f'Category ID {category_id} is already assigned to project {project_id}') 

387 project.categories.append(category) 

388 

389 

390@http 

391def delete_project_category(session, user, project_id, category_id): 

392 '''Remove the category with the given ID from the project at this URL''' 

393 project = fetch.project(session, project_id) 

394 validate.check(user, perms.PROJECT_EDIT, project=project) 

395 category = fetch.category_for_user(session, user, category_id) 

396 if category not in project.categories: 

397 raise ValueError(f'Category ID {category_id} is not assigned to project {project_id}') 

398 project.categories.remove(category) 

399 

400 

401@http 

402def put_project_publish(session, user, project_id, publish_doc) -> List[serial.PublishResult]: 

403 ''' 

404 Publish a project. 

405 

406 Changes the status of the Project from 'Draft' to 'Live'. 

407 

408 ### Update Issue Statuses 

409 

410 When project is published it is often useful to simultaneously "release" the issues that 

411 belong to the project. "Release" means to change the status of an issue from 'Not Sent' to 

412 either 'Opportunity' or 'Accepted', depending on the value of the parent project's 

413 'Required Acceptance' field. 

414 

415 If the Project's require_acceptance field is true, the the status of each Issue 

416 will change from 'Not Sent' to 'Opportunity'. 

417 

418 If the Project's required_acceptance field is false the each Issue will move from 

419 status 'Not Sent' to 'Accepted'. 

420 

421 Only issues whose ID values are included in the JSON body 'release_issue_ids' field will 

422 be updated. 

423 

424 If there are no values given in the JSON body field 'release_issue_id' 

425 then the status of all issues belong to the project remain unchanged. 

426 

427 ID values in 'release_issue_ids' that do not belong to the current project are ignored. 

428 ''' 

429 draft_project = fetch.project(session, project_id) 

430 validate.check(user, perms.PROJECT_PUBLISH, project=draft_project) 

431 

432 # Workaround for poorly chosen design decision to use Polymorphic identity for Project 

433 # classes 

434 dt_now = datetime.now() 

435 session.expunge(draft_project) 

436 ptable = Project.__table__ 

437 exp = ptable.update().where(ptable.c.id == project_id).values(status='Live') 

438 session.execute(exp) 

439 

440 project = fetch.project(session, project_id) 

441 project.date_published = dt_now 

442 pevt = AuditEvent.create('PROJECT_PUBLISHED', object_id=project_id, user=user, 

443 project=project) 

444 

445 pevt.add_change('status', 'Draft', 'Live') 

446 pevt.add_change('date_published', None, str(dt_now)) 

447 

448 issue_id_set = set(publish_doc.get('release_issue_ids', [])) 

449 

450 new_issue_status = 'Opportunity' if project.require_acceptance else 'Accepted' 

451 

452 res = [] 

453 for issue in project.issues: 

454 if issue.status == 'Not Sent' and issue.id in issue_id_set: 

455 issue.change_status(user, new_issue_status) 

456 res.append(dict(issue_id=issue.id, issue_respondent_id=issue.respondent_id)) 

457 

458 return res 

459 

460 

461@http 

462def put_project_close(session, user, project_id): 

463 ''' 

464 Close the given project. 

465 

466 Changes the status of the Project from 'Live' to 'Closed'. 

467 Any pending issues at status 'Opportunity' will be retracted. 

468 ''' 

469 live_project = fetch.project(session, project_id) 

470 validate.check(user, perms.PROJECT_CLOSE, project=live_project) 

471 

472 for opportunity in live_project.opportunity_issues: 

473 opportunity.change_status(user, 'Retracted') 

474 

475 session.expunge(live_project) 

476 ptable = Project.__table__ 

477 exp = ptable.update().where(ptable.c.id == project_id).values(status='Closed') 

478 session.execute(exp) 

479 

480 project = fetch.project(session, project_id) 

481 pevt = AuditEvent.create('PROJECT_CLOSED', object_id=project_id, user=user, 

482 project=project) 

483 

484 pevt.add_change('status', 'Live', 'Closed') 

485 session.add(pevt)