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
« 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
8from sqlalchemy.orm import Session
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
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.
35 If no values are provided for projectStatus then projects with any status are returned.
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)
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)
49 pq = fetch.projects_with_watched(session, user, participant_id=q_participant_id)
51 if q_category_id:
52 pq = pq.filter(Project.categories.any(Category.id==q_category_id))
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)
58 if project_statuses:
59 pq = pq.filter(Project.status.in_(project_statuses))
61 if q_project_title:
62 pq = pq.filter(Project.title.contains(q_project_title))
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)
70 return {
71 "data": res,
72 "pagination": pager.asdict(total_records, len(res))
73 }
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
88@http
89def post_project(session, user, new_project_doc) -> serial.NewProjectIds:
90 '''
91 Create a new Project
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.
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', [])
104 sec_title = new_project_doc.pop('questionnaire_title')
105 root_section = Section(title=sec_title)
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)
121 root_section.project_id = project.id
122 project.root_section = root_section
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)
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)
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()
146 return serial.NewProjectIds(id=project.id, section_id=root_section.id)
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.
154 If 'project_fields' is set then the provided list of project fields is used
155 to overwrite the existing values.
156 '''
158 project = fetch.project(session, project_id)
159 validate.check(user, perms.PROJECT_EDIT, project=project)
161 evt = AuditEvent.create('PROJECT_UPDATED',
162 object_id=project.id,
163 user=user,
164 project=project)
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)
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)
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)
192 update.delete_qinstances_update_def_refcounts(session, project_id)
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)
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]
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
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)
227 new_participants_dict = {p['org_id']: p['role'] for p in participants_list}
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]
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))
245 return [p.as_dict() for p in project.participants]
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)
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]
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.
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)
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)
290 participant = project.get_participant(perms_user.organisation)
292 project_permissions = ROLES[participant.role]
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
300 return udict
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)
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)
324 return {
325 'permissions': [sp.section_id for sp in proj_query],
326 'user': target_user.as_dict()
327 }
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)]
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)
349 target_user_id = watch_doc.get('targetUser', None)
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)
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)
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)
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)
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)
401@http
402def put_project_publish(session, user, project_id, publish_doc) -> List[serial.PublishResult]:
403 '''
404 Publish a project.
406 Changes the status of the Project from 'Draft' to 'Live'.
408 ### Update Issue Statuses
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.
415 If the Project's require_acceptance field is true, the the status of each Issue
416 will change from 'Not Sent' to 'Opportunity'.
418 If the Project's required_acceptance field is false the each Issue will move from
419 status 'Not Sent' to 'Accepted'.
421 Only issues whose ID values are included in the JSON body 'release_issue_ids' field will
422 be updated.
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.
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)
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)
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)
445 pevt.add_change('status', 'Draft', 'Live')
446 pevt.add_change('date_published', None, str(dt_now))
448 issue_id_set = set(publish_doc.get('release_issue_ids', []))
450 new_issue_status = 'Opportunity' if project.require_acceptance else 'Accepted'
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))
458 return res
461@http
462def put_project_close(session, user, project_id):
463 '''
464 Close the given project.
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)
472 for opportunity in live_project.opportunity_issues:
473 opportunity.change_status(user, 'Retracted')
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)
480 project = fetch.project(session, project_id)
481 pevt = AuditEvent.create('PROJECT_CLOSED', object_id=project_id, user=user,
482 project=project)
484 pevt.add_change('status', 'Live', 'Closed')
485 session.add(pevt)