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

244 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-24 10:52 +0000

1""" 

2Manage projects, project permissions & project notes 

3""" 

4 

5from datetime import datetime 

6from rfpy.model.misc import Category 

7from typing import List, Set, Optional 

8 

9from sqlalchemy.orm import Session 

10from sqlalchemy import Table, update as update_stmt 

11 

12from rfpy.suxint import http 

13from rfpy.web import Pager, serial 

14from rfpy.model import ( 

15 Project, 

16 AuditEvent, 

17 Participant, 

18 ProjectPermission, 

19 SectionPermission, 

20 User, 

21 Section, 

22 Issue, 

23 ProjectWatchList, 

24 ProjectField, 

25) 

26from rfpy.model.exc import BusinessRuleViolation 

27from rfpy.auth import perms, ROLES 

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

29 

30 

31@http 

32def get_projects( 

33 session: Session, 

34 user: User, 

35 project_statuses: Optional[Set[str]] = None, 

36 project_sort: str = "date_created", 

37 sort_order: str = "desc", 

38 q_participant_id: Optional[str] = None, 

39 q_project_title: Optional[str] = None, 

40 q_category_id: Optional[str] = None, 

41 pager: Optional[Pager] = None, 

42) -> serial.ProjectList: 

43 """ 

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

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

46 

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

48 

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

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

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

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

53 """ 

54 if pager is None: 

55 pager = Pager(page=1, page_size=100) 

56 

57 if q_participant_id: 

58 participant_org = fetch.organisation(session, q_participant_id) 

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

60 

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

62 

63 if q_category_id: 

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

65 

66 sort = getattr(Project, project_sort) 

67 sort_directed = sort.asc() if sort_order == "asc" else sort.desc() 

68 pq = pq.order_by(sort_directed) 

69 

70 if project_statuses: 

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

72 

73 if q_project_title: 

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

75 

76 total_records = pq.count() 

77 records = pq.slice(pager.startfrom, pager.goto).all() 

78 

79 return serial.ProjectList( 

80 data=list(serial.ListProject.model_validate(p) for p in records), 

81 pagination=pager.as_pagination(total_records, len(records)), 

82 ) 

83 

84 

85@http 

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

87 """Get a project checking that the user is in a participant 

88 organisation and has explicit user permissions for that project 

89 """ 

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

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

92 schema: serial.FullProject = serial.FullProject.model_validate(project) 

93 schema.set_permissions(user) 

94 return schema 

95 

96 

97@http 

98def post_project( 

99 session: Session, user: User, new_project_doc: dict 

100) -> serial.NewProjectIds: 

101 """ 

102 Create a new Project 

103 

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

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

106 

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

108 newly created project. 

109 """ 

110 user.check_permission(perms.PROJECT_CREATE) 

111 ptitle = new_project_doc.pop("title") 

112 project_fields = new_project_doc.pop("project_fields", []) 

113 category_ids = new_project_doc.pop("category_ids", []) 

114 

115 sec_title = new_project_doc.pop("questionnaire_title") 

116 root_section = Section(title=sec_title) 

117 

118 project = Project(ptitle, **new_project_doc) 

119 project.owner_org = user.organisation 

120 project.author = user 

121 project.status = "Draft" 

122 session.add(project) 

123 participant = Participant(organisation=user.organisation, role="Administrator") 

124 project.participants.add(participant) 

125 session.flush() 

126 evt = AuditEvent.create( 

127 session, "PROJECT_CREATED", object_id=project.id, user=user, project=project 

128 ) 

129 

130 root_section.project_id = project.id 

131 project.root_section = root_section 

132 

133 # project_fields 

134 for idx, pf in enumerate(project_fields): 

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

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

137 project.project_fields.append(new_pf) 

138 

139 if category_ids: 

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

141 for cat_id in category_ids: 

142 if cat_id not in cat_map: 

143 org_id = user.organisation.id 

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

145 cat = cat_map[cat_id] 

146 project.categories.append(cat) 

147 

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

149 if not v: 

150 continue 

151 evt.add_change(k.title(), "", v) # title() converts string to Title Case 

152 session.add(evt) 

153 session.flush() 

154 

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

156 

157 

158@http 

159def put_project( 

160 session: Session, user: User, project_id: int, update_project_doc: dict 

161): 

162 """ 

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

164 

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

166 to overwrite the existing values. 

167 """ 

168 

169 project = fetch.project(session, project_id) 

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

171 

172 evt = AuditEvent.create( 

173 session, "PROJECT_UPDATED", object_id=project.id, user=user, project=project 

174 ) 

175 

176 # project_fields 

177 project_fields = update_project_doc.pop("project_fields", None) 

178 if project_fields: 

179 project.project_fields.clear() 

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

181 for idx, pf in enumerate(project_fields): 

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

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

184 project.project_fields.append(new_pf) 

185 

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

187 current_val = getattr(project, attr_name) 

188 if current_val != new_val: 

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

190 setattr(project, attr_name, new_val) 

191 session.add(evt) 

192 

193 

194@http 

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

196 """ 

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

198 """ 

199 user.check_permission(perms.PROJECT_DELETE) 

200 project = fetch.project(session, project_id) 

201 

202 update.delete_qinstances_update_def_refcounts(session, project_id) 

203 

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

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

206 evt = AuditEvent.create( 

207 session, "PROJECT_DELETED", object_id=project_id, user=user, project=project 

208 ) 

209 session.add(evt) 

210 session.flush() 

211 session.delete(project) 

212 

213 

214@http 

215def get_project_participants( 

216 session: Session, project_id: int, user: User 

217) -> serial.ParticipantList: 

218 """ 

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

220 with the Role assigned in the given project. 

221 """ 

222 project = fetch.project(session, project_id) 

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

224 return serial.ParticipantList( 

225 list(serial.Participant.model_validate(p) for p in project.participants) 

226 ) 

227 

228 

229@http 

230def put_project_participants( 

231 session: Session, user: User, project_id: int, participants_list: list 

232) -> serial.ParticipantList: 

233 """ 

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

235 

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

237 """ 

238 project = fetch.project(session, project_id) 

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

240 

241 new_participants_dict = {p["org_id"]: p["role"] for p in participants_list} 

242 

243 for p in project.participants: 

244 if p.org_id not in new_participants_dict: 

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

246 session.delete(p) 

247 else: 

248 p.role = new_participants_dict[p.org_id] 

249 del new_participants_dict[p.org_id] 

250 

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

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

253 org = fetch.organisation(session, org_id) 

254 if not org.is_buyside: 

255 raise ValueError( 

256 ( 

257 f"Only Buyer or Consultant organisations can be Participants." 

258 f" {org_id} is a {org.type}" 

259 ) 

260 ) 

261 project.participants.add(Participant(organisation=org, role=role)) 

262 

263 return serial.ParticipantList( 

264 list(serial.Participant.model_validate(p) for p in project.participants) 

265 ) 

266 

267 

268@http 

269def put_project_permissions( 

270 session, user, project_id, perm_doc: serial.ProjectPermission 

271): 

272 """ 

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

274 """ 

275 project = fetch.project(session, project_id) 

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

277 validate.check( 

278 user, perms.PROJECT_MANAGE_ROLES, project=project, target_org=user.organisation 

279 ) 

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

281 

282 

283@http 

284def get_project_users( 

285 session: Session, user: User, project_id: int, restricted_users=False 

286) -> serial.UserList: 

287 project = fetch.project(session, project_id) 

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

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

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

291 return serial.UserList([serial.User.model_validate(u) for u in user_query]) 

292 

293 

294@http 

295def get_project_user_permissions( 

296 session: Session, user: User, project_id: int, target_user: Optional[User] = None 

297) -> serial.ProjectUser: 

298 """ 

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

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

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

302 permission (role) in the project. 

303 

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

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

306 """ 

307 project = fetch.project(session, project_id) 

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

309 

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

311 perms_user = user 

312 else: 

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

314 validate.check( 

315 user, 

316 perms.MANAGE_USERS, 

317 target_org=perms_user.organisation, 

318 target_user=perms_user, 

319 ) 

320 

321 participant = project.get_participant(perms_user.organisation) 

322 

323 project_permissions = ROLES[participant.role] 

324 

325 udict = serial.ProjectUser.model_validate(perms_user) 

326 udict.effectivePermissions = sorted( 

327 project_permissions & perms_user.all_permissions 

328 ) 

329 udict.userPermissions = sorted(perms_user.all_permissions) 

330 udict.participantPermissions = sorted(project_permissions) 

331 udict.participantRole = participant.role 

332 

333 return udict 

334 

335 

336@http 

337def get_project_permissions( 

338 session: Session, user: User, project_id: int, target_user: User 

339): 

340 """ 

341 returns { 

342 target_user: user whose permissions are in focus 

343 permissions: list of section ids the user can access 

344 } 

345 """ 

346 project = fetch.project(session, project_id) 

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

348 validate.check( 

349 user, 

350 perms.MANAGE_USERS, 

351 target_user=target_user, 

352 target_org=target_user.organisation, 

353 ) 

354 

355 proj_query = ( 

356 session.query(SectionPermission) 

357 .join(ProjectPermission) 

358 .join(Participant) 

359 .filter( 

360 Participant.org_id == target_user.organisation.id, 

361 SectionPermission.user == target_user, 

362 Participant.project == project, 

363 ) 

364 ) 

365 

366 return { 

367 "permissions": [sp.section_id for sp in proj_query], 

368 "user": target_user.as_dict(), 

369 } 

370 

371 

372@http 

373def get_project_watchlist( 

374 session: Session, user: User, project_id: int 

375) -> List[serial.Watcher]: 

376 """ 

377 List the users watching this project 

378 """ 

379 project = fetch.project(session, project_id) 

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

381 return [ 

382 serial.Watcher.model_validate(w) 

383 for w in fetch.project_watchers(session, project) 

384 ] 

385 

386 

387@http 

388def post_project_watch( 

389 session: Session, user: User, project_id: int, watch_doc: serial.TargetUser 

390) -> List[serial.Watcher]: 

391 """ 

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

393 Returns a list of user ids watching the current project 

394 """ 

395 project = fetch.project(session, project_id) 

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

397 

398 target_user_id = watch_doc.targetUser 

399 

400 if target_user_id is None: 

401 watcher_user = user 

402 else: 

403 watcher_user = fetch.user(session, target_user_id) 

404 validate.check( 

405 user, 

406 perms.MANAGE_USERS, 

407 target_user=watcher_user, 

408 target_org=watcher_user.organisation, 

409 ) 

410 

411 if not project.add_watcher(watcher_user): 

412 raise BusinessRuleViolation( 

413 "User %s is already watching Project %s" % (watcher_user.id, project.title) 

414 ) 

415 session.flush() 

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

417 

418 

419@http 

420def delete_project_watch( 

421 session, user, project_id, target_user 

422) -> List[serial.Watcher]: 

423 """ 

424 Remove the target user from the watchlist for this project 

425 """ 

426 project = fetch.project(session, project_id) 

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

428 if target_user is not user: 

429 validate.check( 

430 user, 

431 perms.MANAGE_USERS, 

432 target_user=target_user, 

433 target_org=target_user.organisation, 

434 ) 

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

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

437 

438 

439@http 

440def put_project_category( 

441 session: Session, user: User, project_id: int, category_id: int 

442): 

443 """Assign the category with the given ID to the project at this URL""" 

444 project = fetch.project(session, project_id) 

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

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

447 if category in project.categories: 

448 raise ValueError( 

449 f"Category ID {category_id} is already assigned to project {project_id}" 

450 ) 

451 project.categories.append(category) 

452 

453 

454@http 

455def delete_project_category( 

456 session: Session, user: User, project_id: int, category_id: int 

457): 

458 """Remove the category with the given ID from the project at this URL""" 

459 project = fetch.project(session, project_id) 

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

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

462 if category not in project.categories: 

463 raise ValueError( 

464 f"Category ID {category_id} is not assigned to project {project_id}" 

465 ) 

466 project.categories.remove(category) 

467 

468 

469@http 

470def put_project_publish( 

471 session: Session, user: User, project_id: int, publish_doc: dict 

472) -> List[serial.PublishResult]: 

473 """ 

474 Publish a project. 

475 

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

477 

478 ### Update Issue Statuses 

479 

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

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

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

483 'Required Acceptance' field. 

484 

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

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

487 

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

489 status 'Not Sent' to 'Accepted'. 

490 

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

492 be updated. 

493 

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

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

496 

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

498 """ 

499 draft_project = fetch.project(session, project_id) 

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

501 

502 # Workaround for poor design decision to use Polymorphic identity for Project 

503 # classes 

504 dt_now = datetime.now() 

505 session.expunge(draft_project) 

506 

507 # Table inherits from FromClause, which is the real type of Project.__table__ 

508 ptable: Table = Project.__table__ # type: ignore 

509 stmt = update_stmt(ptable).where(ptable.c.id == project_id).values(status="Live") 

510 session.execute(stmt) 

511 

512 project = fetch.project(session, project_id) 

513 project.date_published = dt_now 

514 pevt = AuditEvent.create( 

515 session, "PROJECT_PUBLISHED", object_id=project_id, user=user, project=project 

516 ) 

517 

518 pevt.add_change("status", "Draft", "Live") 

519 pevt.add_change("date_published", None, str(dt_now)) 

520 

521 issue_id_set = set(publish_doc.get("release_issue_ids", [])) 

522 

523 new_issue_status = "Opportunity" if project.require_acceptance else "Accepted" 

524 

525 res = [] 

526 for issue in project.issues: 

527 if issue.status == "Not Sent" and issue.id in issue_id_set: 

528 issue.change_status(user, new_issue_status) 

529 res_id = issue.respondent_id or issue.respondent_email 

530 assert res_id is not None, f"Issue {issue.id} has no respondent ID or email" 

531 res.append(serial.PublishResult(issue_id=issue.id, respondent_id=res_id)) 

532 return res 

533 

534 

535@http 

536def put_project_close(session: Session, user: User, project_id: int): 

537 """ 

538 Close the given project. 

539 

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

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

542 """ 

543 live_project = fetch.project(session, project_id) 

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

545 

546 for opportunity in live_project.opportunity_issues: 

547 opportunity.change_status(user, "Retracted") 

548 

549 session.expunge(live_project) 

550 

551 # See explanation in put_project_publish 

552 ptable: Table = Project.__table__ # type: ignore 

553 exp = ptable.update().where(ptable.c.id == project_id).values(status="Closed") 

554 session.execute(exp) 

555 

556 project = fetch.project(session, project_id) 

557 pevt = AuditEvent.create( 

558 session, "PROJECT_CLOSED", object_id=project_id, user=user, project=project 

559 ) 

560 

561 pevt.add_change("status", "Live", "Closed") 

562 session.add(pevt)