Coverage for postrfp/buyer/api/endpoints/projects.py: 100%

277 statements  

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

1""" 

2Manage projects, project permissions & project notes 

3""" 

4 

5from datetime import datetime 

6 

7 

8from sqlalchemy import select 

9from sqlalchemy.orm import Session 

10from sqlalchemy.exc import IntegrityError 

11 

12from postrfp.authorisation import perms 

13from postrfp.shared import fetch, update 

14from postrfp.shared.decorators import http 

15from postrfp.shared.pager import Pager 

16from postrfp.shared.issue_transition import change_issue_status 

17from postrfp.shared import serial 

18from postrfp.model import ( 

19 Project, 

20 AuditEvent, 

21 Participant, 

22 ProjectPermission, 

23 SectionPermission, 

24 User, 

25 Section, 

26 Issue, 

27 ProjectWatchList, 

28 ProjectField, 

29 ProjectApproval, 

30) 

31from postrfp.model.audit import evt_types 

32from postrfp.model.misc import Category 

33from postrfp.model.exc import BusinessRuleViolation, DuplicateDataProvided 

34from postrfp.authorisation.roles import ROLES 

35from postrfp.buyer.api import authorise, domain_permissions 

36 

37 

38@http 

39def get_projects( 

40 session: Session, 

41 user: User, 

42 project_statuses: set[str] | None = None, 

43 project_sort: str = "date_created", 

44 sort_order: str = "desc", 

45 q_participant_id: str | None = None, 

46 q_title: str | None = None, 

47 q_category_id: str | None = None, 

48 pager: Pager | None = None, 

49) -> serial.ProjectList: 

50 """ 

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

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

53 

54 If no values are provided for project_statuses then projects with any status are returned. 

55 

56 Using the "participant_id" query param will filter the list of projects to include only 

57 those for which have a Participant organisation whose ID is that of participant_id. This 

58 parameter is only available to users in Consultant organisations and the participant_id must 

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

60 """ 

61 if pager is None: 

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

63 

64 if q_participant_id: 

65 participant_org = fetch.organisation(session, q_participant_id) 

66 authorise.check(user, perms.MANAGE_ORGANISATION, target_org=participant_org) 

67 

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

69 

70 if q_category_id: 

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

72 

73 sort = getattr(Project, project_sort) 

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

75 pq = pq.order_by(sort_directed) 

76 

77 if project_statuses: 

78 from postrfp.model.fsm import Status 

79 

80 statuses_q = select(Status.id).where(Status.name.in_(project_statuses)) 

81 pq = pq.filter(Project.current_status_id.in_(statuses_q)) 

82 

83 if q_title: 

84 pq = pq.filter(Project.title.contains(q_title)) 

85 

86 total_records = pq.count() 

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

88 

89 return serial.ProjectList( 

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

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

92 ) 

93 

94 

95@http 

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

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

98 organisation and has explicit user permissions for that project 

99 """ 

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

101 authorise.check(user, perms.PROJECT_ACCESS, project=project) 

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

103 schema.set_permissions(user) 

104 return schema 

105 

106 

107@http 

108def post_project( 

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

110) -> serial.NewProjectIds: 

111 """ 

112 Create a new Project 

113 

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

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

116 

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

118 newly created project. 

119 """ 

120 user.check_permission(perms.PROJECT_CREATE) 

121 ptitle = new_project_doc.pop("title") 

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

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

124 

125 sec_title = new_project_doc.pop("questionnaire_title") 

126 root_section = Section(title=sec_title, number="") 

127 

128 project = Project(ptitle, **new_project_doc) 

129 project.owner_org = user.organisation 

130 project.author = user 

131 session.add(project) 

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

133 project.participants.add(participant) 

134 session.flush() 

135 assert project.workflow is not None 

136 project.current_status = project.workflow.get_initial_status() 

137 evt = AuditEvent.create( 

138 session, 

139 evt_types.PROJECT_CREATED, 

140 object_id=project.id, 

141 user=user, 

142 project=project, 

143 ) 

144 

145 root_section.project_id = project.id 

146 project.root_section = root_section 

147 root_section.renumber() 

148 

149 # project_fields 

150 for idx, pf in enumerate(project_fields): 

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

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

153 project.project_fields.append(new_pf) 

154 

155 if category_ids: 

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

157 for cat_id in category_ids: 

158 if cat_id not in cat_map: 

159 org_id = user.organisation.id 

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

161 cat = cat_map[cat_id] 

162 project.categories.append(cat) 

163 

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

165 if not v: 

166 continue 

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

168 session.add(evt) 

169 session.flush() 

170 

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

172 

173 

174@http 

175def put_project( 

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

177): 

178 """ 

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

180 

181 If 'project_fields' is provided the existing values are overwritten. 

182 """ 

183 

184 project = fetch.project(session, project_id) 

185 authorise.check(user, perms.PROJECT_EDIT, project=project) 

186 

187 evt = AuditEvent.create( 

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

189 ) 

190 

191 # project_fields 

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

193 if project_fields: 

194 project.project_fields.clear() 

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

196 for idx, pf in enumerate(project_fields): 

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

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

199 project.project_fields.append(new_pf) 

200 

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

202 current_val = getattr(project, attr_name) 

203 if current_val != new_val: 

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

205 setattr(project, attr_name, new_val) 

206 session.add(evt) 

207 

208 

209@http 

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

211 """ 

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

213 """ 

214 user.check_permission(perms.PROJECT_DELETE) 

215 project = fetch.project(session, project_id) 

216 

217 update.delete_qinstances_update_def_refcounts(session, project_id) 

218 

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

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

221 evt = AuditEvent.create( 

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

223 ) 

224 session.add(evt) 

225 session.flush() 

226 session.delete(project) 

227 

228 

229@http 

230def get_project_participants( 

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

232) -> serial.ParticipantList: 

233 """ 

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

235 with the Role assigned in the given project. 

236 """ 

237 project = fetch.project(session, project_id) 

238 authorise.check(user, perms.PROJECT_ACCESS, project=project) 

239 return serial.ParticipantList( 

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

241 ) 

242 

243 

244@http 

245def put_project_participants( 

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

247) -> serial.ParticipantList: 

248 """ 

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

250 

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

252 """ 

253 project = fetch.project(session, project_id) 

254 authorise.check(user, perms.PROJECT_MANAGE_ROLES, project=project) 

255 

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

257 

258 for p in project.participants: 

259 if p.org_id not in new_participants_dict: 

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

261 session.delete(p) 

262 else: 

263 p.role = new_participants_dict[p.org_id] 

264 del new_participants_dict[p.org_id] 

265 

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

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

268 org = fetch.organisation(session, org_id) 

269 if not org.is_buyside: 

270 raise ValueError( 

271 ( 

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

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

274 ) 

275 ) 

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

277 

278 return serial.ParticipantList( 

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

280 ) 

281 

282 

283@http 

284def put_project_permissions( 

285 session: Session, user: User, project_id: int, perm_doc: serial.ProjectPermission 

286): 

287 """ 

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

289 """ 

290 project = fetch.project(session, project_id) 

291 authorise.check(user, perms.MANAGE_USERS, target_org=user.organisation) 

292 authorise.check( 

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

294 ) 

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

296 

297 

298@http 

299def get_project_users( 

300 session: Session, user: User, project_id: int, restricted_users: bool = False 

301) -> serial.UserList: 

302 """ 

303 List all participant users with access to the project. 

304 """ 

305 project = fetch.project(session, project_id) 

306 authorise.check(user, perms.PROJECT_ACCESS, project=project) 

307 authorise.check(user, perms.MANAGE_USERS, target_org=user.organisation) 

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

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

310 

311 

312@http 

313def get_project_user_permissions( 

314 session: Session, user: User, project_id: int, target_user: User | None = None 

315) -> serial.ProjectUser: 

316 """ 

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

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

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

320 permission (role) in the project. 

321 

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

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

324 """ 

325 project = fetch.project(session, project_id) 

326 authorise.check(user, perms.PROJECT_ACCESS, project=project) 

327 

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

329 perms_user = user 

330 else: 

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

332 authorise.check( 

333 user, 

334 perms.MANAGE_USERS, 

335 target_org=perms_user.organisation, 

336 target_user=perms_user, 

337 ) 

338 

339 participant = project.get_participant(perms_user.organisation) 

340 

341 participant_role = participant.role or "No Role Assigned" 

342 

343 project_permissions = ROLES[participant_role] 

344 

345 udict = serial.ProjectUser.model_validate(perms_user) 

346 udict.effectivePermissions = sorted( 

347 project_permissions & perms_user.all_permissions 

348 ) 

349 udict.userPermissions = sorted(perms_user.all_permissions) 

350 udict.participantPermissions = sorted(project_permissions) 

351 udict.participantRole = participant.role 

352 

353 return udict 

354 

355 

356@http 

357def get_project_permissions( 

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

359): 

360 """ 

361 returns { 

362 target_user: user whose permissions are in focus 

363 permissions: list of section ids the user can access 

364 } 

365 """ 

366 project = fetch.project(session, project_id) 

367 authorise.check(user, perms.PROJECT_ACCESS, project=project) 

368 authorise.check( 

369 user, 

370 perms.MANAGE_USERS, 

371 target_user=target_user, 

372 target_org=target_user.organisation, 

373 ) 

374 

375 proj_query = ( 

376 session.query(SectionPermission) 

377 .join(ProjectPermission) 

378 .join(Participant) 

379 .filter( 

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

381 SectionPermission.user == target_user, 

382 Participant.project == project, 

383 ) 

384 ) 

385 

386 return { 

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

388 "user": target_user.as_dict(), 

389 } 

390 

391 

392@http 

393def get_project_watchlist( 

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

395) -> list[serial.Watcher]: 

396 """ 

397 List the users watching this project 

398 """ 

399 project = fetch.project(session, project_id) 

400 authorise.check(user, perms.PROJECT_ACCESS, project=project) 

401 return [ 

402 serial.Watcher.model_validate(w) 

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

404 ] 

405 

406 

407@http 

408def post_project_watch( 

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

410) -> list[serial.Watcher]: 

411 """ 

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

413 Returns a list of user ids watching the current project 

414 """ 

415 project = fetch.project(session, project_id) 

416 authorise.check(user, perms.PROJECT_ACCESS, project=project) 

417 

418 target_user_id = watch_doc.targetUser 

419 

420 if target_user_id is None: 

421 watcher_user = user 

422 else: 

423 watcher_user = fetch.user(session, target_user_id) 

424 authorise.check( 

425 user, 

426 perms.MANAGE_USERS, 

427 target_user=watcher_user, 

428 target_org=watcher_user.organisation, 

429 ) 

430 

431 if not project.add_watcher(watcher_user): 

432 raise BusinessRuleViolation( 

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

434 ) 

435 session.flush() 

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

437 

438 

439@http 

440def delete_project_watch( 

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

442) -> list[serial.Watcher]: 

443 """ 

444 Remove the target user from the watchlist for this project 

445 """ 

446 project = fetch.project(session, project_id) 

447 authorise.check(user, perms.PROJECT_ACCESS, project=project) 

448 if target_user is not user: 

449 authorise.check( 

450 user, 

451 perms.MANAGE_USERS, 

452 target_user=target_user, 

453 target_org=target_user.organisation, 

454 ) 

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

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

457 

458 

459@http 

460def put_project_category( 

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

462): 

463 """ 

464 Assign the category with the given ID to the project at this URL 

465 @permission: editProject 

466 """ 

467 project = fetch.project(session, project_id) 

468 authorise.check(user, perms.PROJECT_EDIT, project=project) 

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

470 if category in project.categories: 

471 raise ValueError( 

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

473 ) 

474 project.categories.append(category) 

475 

476 

477@http 

478def delete_project_category( 

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

480): 

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

482 project = fetch.project(session, project_id) 

483 authorise.check(user, perms.PROJECT_EDIT, project=project) 

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

485 if category not in project.categories: 

486 raise ValueError( 

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

488 ) 

489 project.categories.remove(category) 

490 

491 

492@http 

493def put_project_publish( 

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

495) -> list[serial.PublishResult]: 

496 """ 

497 Publish a project. 

498 

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

500 

501 ### Update Issue Statuses 

502 

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

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

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

506 'Required Acceptance' field. 

507 

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

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

510 

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

512 status 'Not Sent' to 'Accepted'. 

513 

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

515 be updated. 

516 

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

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

519 

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

521 """ 

522 project = fetch.project(session, project_id) 

523 authorise.check(user, perms.PROJECT_PUBLISH, project=project) 

524 

525 dt_now = datetime.now() 

526 

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

528 project.status_name = "Live" 

529 

530 project.date_published = dt_now 

531 pevt = AuditEvent.create( 

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

533 ) 

534 

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

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

537 

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

539 

540 new_issue_status = "Opportunity" 

541 

542 res = [] 

543 for issue in project.issues: 

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

545 change_issue_status(issue, user, new_issue_status) 

546 res_id = issue.respondent_id or issue.respondent_email 

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

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

549 return res 

550 

551 

552@http 

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

554 """ 

555 Close the given project. 

556 

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

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

559 """ 

560 project = fetch.project(session, project_id) 

561 authorise.check(user, perms.PROJECT_CLOSE, project=project) 

562 

563 for opportunity in project.opportunity_issues: 

564 change_issue_status(opportunity, user, "Retracted") 

565 

566 project.status_name = "Closed" 

567 

568 pevt = AuditEvent.create( 

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

570 ) 

571 

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

573 session.add(pevt) 

574 

575 

576@http 

577def get_project_approvals( 

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

579) -> list[serial.ProjectApproval]: 

580 """ 

581 List all approvals for the given project. 

582 

583 An approval is recorded when a user approves a project for its current status. 

584 A user may only approve a given project once for each distinct project status. 

585 @permission: accessProject 

586 """ 

587 project = fetch.project(session, project_id) 

588 authorise.check(user, perms.PROJECT_ACCESS, project=project) 

589 

590 return [serial.ProjectApproval.model_validate(a) for a in project.approvals] 

591 

592 

593@http 

594def post_project_approval( 

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

596) -> list[serial.ProjectApproval]: 

597 """ 

598 Log an approval for the given project by the current user. 

599 

600 A user may only approve a given project once for each distinct project status. 

601 If the user has already approved the project for its current status then 

602 an error is raised. 

603 

604 The approval is recorded in the ProjectApproval table together with 

605 the project status at the time of approval. 

606 

607 @permission: approveProject 

608 """ 

609 project = fetch.project(session, project_id) 

610 authorise.check(user, perms.PROJECT_APPROVE, project=project) 

611 

612 try: 

613 approval = project.log_approval(user) 

614 session.flush() 

615 except IntegrityError as e: 

616 session.rollback() 

617 raise DuplicateDataProvided( 

618 f"User {user.id} has already approved Project ID {project.id} " 

619 f"at status {project.status_name}" 

620 ) from e 

621 

622 AuditEvent.create( 

623 session, 

624 evt_types.PROJECT_APPROVAL_LOGGED, 

625 object_id=approval.id, 

626 user=user, 

627 project=project, 

628 ) 

629 return [serial.ProjectApproval.model_validate(a) for a in project.approvals] 

630 

631 

632@http 

633def delete_project_approval( 

634 session: Session, user: User, project_id: int, approval_id: int 

635) -> list[serial.ProjectApproval]: 

636 """ 

637 Remove the given approval from the project. 

638 

639 Approvals can be deleted by: 

640 - the user who created the approval 

641 - a user with both editProject and approveProject permissions 

642 

643 @permission: approveProject 

644 """ 

645 project = fetch.project(session, project_id) 

646 approval = session.get_one(ProjectApproval, approval_id) 

647 if approval.project_id != project.id: 

648 raise ValueError( 

649 f"Approval ID {approval_id} does not belong to Project ID {project_id}" 

650 ) 

651 if approval.user_id != user.id: 

652 approval_user = session.get_one(User, approval.user_id) 

653 authorise.check(user, perms.MANAGE_USERS, target_org=approval_user.organisation) 

654 

655 authorise.check(user, perms.PROJECT_APPROVE, project=project) 

656 

657 project.approvals.remove(approval) 

658 session.flush() 

659 

660 AuditEvent.create( 

661 session, 

662 evt_types.PROJECT_APPROVAL_DELETED, 

663 object_id=approval_id, 

664 user=user, 

665 project=project, 

666 ) 

667 return [serial.ProjectApproval.model_validate(a) for a in project.approvals]