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
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-22 21:34 +0000
1"""
2Manage projects, project permissions & project notes
3"""
5from datetime import datetime
8from sqlalchemy import select
9from sqlalchemy.orm import Session
10from sqlalchemy.exc import IntegrityError
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
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.
54 If no values are provided for project_statuses then projects with any status are returned.
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)
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)
68 pq = fetch.projects_with_watched(session, user, participant_id=q_participant_id)
70 if q_category_id:
71 pq = pq.filter(Project.categories.any(Category.id == q_category_id))
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)
77 if project_statuses:
78 from postrfp.model.fsm import Status
80 statuses_q = select(Status.id).where(Status.name.in_(project_statuses))
81 pq = pq.filter(Project.current_status_id.in_(statuses_q))
83 if q_title:
84 pq = pq.filter(Project.title.contains(q_title))
86 total_records = pq.count()
87 records = pq.slice(pager.startfrom, pager.goto).all()
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 )
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
107@http
108def post_project(
109 session: Session, user: User, new_project_doc: dict
110) -> serial.NewProjectIds:
111 """
112 Create a new Project
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.
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", [])
125 sec_title = new_project_doc.pop("questionnaire_title")
126 root_section = Section(title=sec_title, number="")
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 )
145 root_section.project_id = project.id
146 project.root_section = root_section
147 root_section.renumber()
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)
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)
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()
171 return serial.NewProjectIds(id=project.id, section_id=root_section.id)
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.
181 If 'project_fields' is provided the existing values are overwritten.
182 """
184 project = fetch.project(session, project_id)
185 authorise.check(user, perms.PROJECT_EDIT, project=project)
187 evt = AuditEvent.create(
188 session, "PROJECT_UPDATED", object_id=project.id, user=user, project=project
189 )
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)
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)
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)
217 update.delete_qinstances_update_def_refcounts(session, project_id)
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)
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 )
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
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)
256 new_participants_dict = {p["org_id"]: p["role"] for p in participants_list}
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]
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))
278 return serial.ParticipantList(
279 list(serial.Participant.model_validate(p) for p in project.participants)
280 )
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)
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])
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.
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)
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 )
339 participant = project.get_participant(perms_user.organisation)
341 participant_role = participant.role or "No Role Assigned"
343 project_permissions = ROLES[participant_role]
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
353 return udict
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 )
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 )
386 return {
387 "permissions": [sp.section_id for sp in proj_query],
388 "user": target_user.as_dict(),
389 }
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 ]
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)
418 target_user_id = watch_doc.targetUser
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 )
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)
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)
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)
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)
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.
499 Changes the status of the Project from 'Draft' to 'Live'.
501 ### Update Issue Statuses
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.
508 If the Project's require_acceptance field is true, the the status of each Issue
509 will change from 'Not Sent' to 'Opportunity'.
511 If the Project's required_acceptance field is false the each Issue will move from
512 status 'Not Sent' to 'Accepted'.
514 Only issues whose ID values are included in the JSON body 'release_issue_ids' field will
515 be updated.
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.
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)
525 dt_now = datetime.now()
527 # Table inherits from FromClause, which is the real type of Project.__table__
528 project.status_name = "Live"
530 project.date_published = dt_now
531 pevt = AuditEvent.create(
532 session, "PROJECT_PUBLISHED", object_id=project_id, user=user, project=project
533 )
535 pevt.add_change("status", "Draft", "Live")
536 pevt.add_change("date_published", None, str(dt_now))
538 issue_id_set = set(publish_doc.get("release_issue_ids", []))
540 new_issue_status = "Opportunity"
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
552@http
553def put_project_close(session: Session, user: User, project_id: int):
554 """
555 Close the given project.
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)
563 for opportunity in project.opportunity_issues:
564 change_issue_status(opportunity, user, "Retracted")
566 project.status_name = "Closed"
568 pevt = AuditEvent.create(
569 session, "PROJECT_CLOSED", object_id=project_id, user=user, project=project
570 )
572 pevt.add_change("status", "Live", "Closed")
573 session.add(pevt)
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.
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)
590 return [serial.ProjectApproval.model_validate(a) for a in project.approvals]
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.
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.
604 The approval is recorded in the ProjectApproval table together with
605 the project status at the time of approval.
607 @permission: approveProject
608 """
609 project = fetch.project(session, project_id)
610 authorise.check(user, perms.PROJECT_APPROVE, project=project)
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
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]
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.
639 Approvals can be deleted by:
640 - the user who created the approval
641 - a user with both editProject and approveProject permissions
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)
655 authorise.check(user, perms.PROJECT_APPROVE, project=project)
657 project.approvals.remove(approval)
658 session.flush()
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]