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
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-24 10:52 +0000
1"""
2Manage projects, project permissions & project notes
3"""
5from datetime import datetime
6from rfpy.model.misc import Category
7from typing import List, Set, Optional
9from sqlalchemy.orm import Session
10from sqlalchemy import Table, update as update_stmt
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
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.
47 If no values are provided for projectStatus then projects with any status are returned.
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)
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)
61 pq = fetch.projects_with_watched(session, user, participant_id=q_participant_id)
63 if q_category_id:
64 pq = pq.filter(Project.categories.any(Category.id == q_category_id))
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)
70 if project_statuses:
71 pq = pq.filter(Project.status.in_(project_statuses))
73 if q_project_title:
74 pq = pq.filter(Project.title.contains(q_project_title))
76 total_records = pq.count()
77 records = pq.slice(pager.startfrom, pager.goto).all()
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 )
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
97@http
98def post_project(
99 session: Session, user: User, new_project_doc: dict
100) -> serial.NewProjectIds:
101 """
102 Create a new Project
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.
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", [])
115 sec_title = new_project_doc.pop("questionnaire_title")
116 root_section = Section(title=sec_title)
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 )
130 root_section.project_id = project.id
131 project.root_section = root_section
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)
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)
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()
155 return serial.NewProjectIds(id=project.id, section_id=root_section.id)
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.
165 If 'project_fields' is set then the provided list of project fields is used
166 to overwrite the existing values.
167 """
169 project = fetch.project(session, project_id)
170 validate.check(user, perms.PROJECT_EDIT, project=project)
172 evt = AuditEvent.create(
173 session, "PROJECT_UPDATED", object_id=project.id, user=user, project=project
174 )
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)
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)
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)
202 update.delete_qinstances_update_def_refcounts(session, project_id)
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)
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 )
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
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)
241 new_participants_dict = {p["org_id"]: p["role"] for p in participants_list}
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]
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))
263 return serial.ParticipantList(
264 list(serial.Participant.model_validate(p) for p in project.participants)
265 )
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)
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])
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.
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)
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 )
321 participant = project.get_participant(perms_user.organisation)
323 project_permissions = ROLES[participant.role]
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
333 return udict
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 )
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 )
366 return {
367 "permissions": [sp.section_id for sp in proj_query],
368 "user": target_user.as_dict(),
369 }
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 ]
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)
398 target_user_id = watch_doc.targetUser
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 )
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)
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)
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)
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)
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.
476 Changes the status of the Project from 'Draft' to 'Live'.
478 ### Update Issue Statuses
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.
485 If the Project's require_acceptance field is true, the the status of each Issue
486 will change from 'Not Sent' to 'Opportunity'.
488 If the Project's required_acceptance field is false the each Issue will move from
489 status 'Not Sent' to 'Accepted'.
491 Only issues whose ID values are included in the JSON body 'release_issue_ids' field will
492 be updated.
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.
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)
502 # Workaround for poor design decision to use Polymorphic identity for Project
503 # classes
504 dt_now = datetime.now()
505 session.expunge(draft_project)
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)
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 )
518 pevt.add_change("status", "Draft", "Live")
519 pevt.add_change("date_published", None, str(dt_now))
521 issue_id_set = set(publish_doc.get("release_issue_ids", []))
523 new_issue_status = "Opportunity" if project.require_acceptance else "Accepted"
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
535@http
536def put_project_close(session: Session, user: User, project_id: int):
537 """
538 Close the given project.
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)
546 for opportunity in live_project.opportunity_issues:
547 opportunity.change_status(user, "Retracted")
549 session.expunge(live_project)
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)
556 project = fetch.project(session, project_id)
557 pevt = AuditEvent.create(
558 session, "PROJECT_CLOSED", object_id=project_id, user=user, project=project
559 )
561 pevt.add_change("status", "Live", "Closed")
562 session.add(pevt)