Coverage for control/table.py : 86%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""Tables.
3* Selection
4* Rendering
5* Record insertion
6"""
8from flask import request
10from config import Config as C, Names as N
11from control.html import HtmlElements as H
12from control.utils import pick as G, E, ELLIPS, NBSP, ONE
13from control.perm import checkTable
14from control.cust.factory_record import factory as recordFactory
16CP = C.perm
17CT = C.tables
18CW = C.workflow
21GROUP_RANK = CP.groupRank
22MAIN_TABLE = CT.userTables[0]
23INTER_TABLE = CT.userTables[1]
24USER_TABLES = set(CT.userTables)
25USER_ENTRY_TABLES = set(CT.userEntryTables)
26SENSITIVE_TABLES = (USER_TABLES - {MAIN_TABLE}) | USER_ENTRY_TABLES
27SENSITIVE_FIELDS = {"costTotal", "costDescription"}
28VALUE_TABLES = set(CT.valueTables)
29SYSTEM_TABLES = set(CT.systemTables)
30ITEMS = CT.items
31PROV_SPECS = CT.prov
33ASSESSMENT_STAGES = set(CW.assessmentStages)
36class Table:
37 """Deals with tables."""
39 def __init__(self, context, table):
40 """## Initialization
42 Store the incoming information.
44 Set the RecordClass to a suitable derived class of Record,
45 otherwise to the base class `control.record.Record` itself.
47 Parameters
48 ----------
49 context: object
50 See below.
51 table: string
52 See below.
53 """
55 self.context = context
56 """*object* A `control.context.Context` singleton.
57 """
59 db = context.db
60 auth = context.auth
61 user = auth.user
63 self.table = table
64 """*string* Name of the table.
65 """
67 self.isMainTable = table == MAIN_TABLE
68 """*boolean* Whether the table is the main table, i.e. `contrib`.
69 """
71 self.isInterTable = table == INTER_TABLE
72 """*boolean* Whether the table is the inter table, i.e. `assessment`.
73 """
75 self.isUserTable = table in USER_TABLES
76 """*boolean* Whether the table is one that collects user content.
78 !!! hint
79 As opposed to value tables.
80 """
82 self.isUserEntryTable = table in USER_ENTRY_TABLES
83 """*boolean* Whether the table is one that collects user entries.
85 !!! hint
86 `criteriaEntry` and `reviewEntry`.
87 """
89 self.isValueTable = table in VALUE_TABLES
90 """*boolean* Whether the table is a value table.
92 Value tables have records that contain representations of fixed values,
93 e.g. disciplines, decisions, scores, and also users and criteria.
94 """
96 self.isSystemTable = table in SYSTEM_TABLES
97 """*boolean* Whether the table is a system table.
99 Some value tables are deemed system tables, e.g. `decision`, `permissionGroup`.
100 """
102 self.itemLabels = G(ITEMS, table, default=[table, f"""{table}s"""])
103 """*(string, string)* How to call an item in the table, singular and plural.
104 """
106 self.prov = PROV_SPECS
107 """*dict* Field specifications for the provenance fields.
109 As in tables.yaml under key `prov`.
110 """
112 self.fields = getattr(CT, table, {})
113 """*dict* Field specifications for the fields in this table.
115 As in the xxx.yaml file in the `server/tables`, where `xxx` is the name of
116 the table.
117 """
119 self.uid = G(user, N._id)
120 """*ObjectId* The id of the current user.
121 """
123 self.eppn = G(user, N.eppn)
124 """*ObjectId* The eppn of the current user.
126 !!! hint
127 The eppn is the user identifying attribute from the identity provider.
128 """
130 self.group = auth.groupRep()
131 """*ObjectId* The permission group of the current user.
132 """
134 self.countryId = G(user, N.country)
135 """*ObjectId* The country of the current user.
136 """
138 isUserTable = self.isUserTable
139 isValueTable = self.isValueTable
140 isSystemTable = self.isSystemTable
141 isSuperuser = auth.superuser()
142 isSysadmin = auth.sysadmin()
144 self.mayInsert = auth.authenticated() and (
145 isUserTable or isValueTable and isSuperuser or isSystemTable and isSysadmin
146 )
147 """*boolean* Whether the user may insert a new record into this table.
148 """
150 def titleSortkey(r):
151 return self.title(r, withRole=True).lower()
153 self.titleSortkey = titleSortkey
154 """*function* Given a record delivers a key for sorting the records.
156 The key is based on the title.
157 """
159 def groupSortkey(r):
160 title = self.title(r, withRole=True).lower()
161 group = G(r, N.group, "")
162 groupRep = G(G(db.permissionGroup, group, {}), N.rep, E) or E
163 rank = G(GROUP_RANK, groupRep, 0)
164 return (-rank, title)
166 self.groupSortkey = groupSortkey
167 """*function* Given a record delivers a key for sorting the records.
169 The key is based on the permission group and then on the title.
170 """
172 self.RecordClass = recordFactory(table)
173 """*class* The class used for manipulating records of this table.
175 It might be the base class `control.record.Record` or one of its
176 derived classes.
177 """
179 def record(
180 self, eid=None, record=None, withDetails=False, readonly=False, bodyMethod=None,
181 ):
182 """Factory function to wrap a record object around the data of a record.
184 !!! note
185 Only one of `eid` or `record` needs to be passed.
187 Parameters
188 ----------
189 eid: ObjectId, optional `None`
190 Entity id to identify the record
191 record: dict, optional `None`
192 The full record
193 withDetails: boolean, optional `False`
194 Whether to present a list of detail records below the record
195 readonly: boolean, optional `False`
196 Whether to present the complete record in readonly mode
197 bodyMethod: function, optional `None`
198 How to compose the HTML for the body of the record.
199 If `None` is passed, the default will be chosen:
200 `control.record.Record.body`.
201 Some particular tables have their own implementation of `body()`
202 and they may supply alternative body methods as well.
204 Returns
205 -------
206 object
207 A `control.record.Record` object.
208 """
210 return self.RecordClass(
211 self,
212 eid=eid,
213 record=record,
214 withDetails=withDetails,
215 readonly=readonly,
216 bodyMethod=bodyMethod,
217 )
219 def readable(self, record):
220 """Is the record readable?
222 !!! note
223 Readibility is a workflow condition.
224 We have to construct a record object and retrieve workflow info
225 to find out.
227 Parameters
228 ----------
229 record: dict
230 The full record
232 Returns
233 -------
234 boolean
235 """
237 return self.RecordClass(self, record=record).mayRead is not False
239 def insert(self, force=False):
240 """Insert a new, (blank) record into the table.
242 !!! note
243 The permission is defined upon intialization of the record.
244 See `control.table.Table` .
246 The rules are:
247 * authenticated users may create new records in the main user tables:
248 `contrib`, and, (under additional workflow constraints),
249 `assessment`, `review`.
250 * superusers may create new value records (under additional
251 constraints)
252 * system admins may create new records in system tables
254 !!! note
255 `force=True` is used when the system needs to insert additional
256 records in other tables. The code for specific tables will instruct so.
258 Parameters
259 ----------
260 force: boolean, optional `False`
261 Permissions are respected, unless `force=True`.
263 Returns
264 -------
265 ObjectId
266 id of the inserted item
267 """
269 mayInsert = force or self.mayInsert and self.withInsert(N.my)
270 if not mayInsert:
271 return None
273 context = self.context
274 db = context.db
275 uid = self.uid
276 eppn = self.eppn
277 table = self.table
279 result = db.insertItem(table, uid, eppn, False)
280 if table == MAIN_TABLE:
281 self.adjustWorkflow(result)
283 return result
285 def adjustWorkflow(self, contribId, new=True):
286 """Adjust the `control.workflow.apply.WorkflowItem`
287 that is dependent on changed data.
289 Parameters
290 ----------
291 contribId: ObjectId
292 The id of the workflow item.
293 new: boolean, optional `True`
294 If `True`, insert the computed workflow as a new item;
295 otherwise update the existing item.
296 """
298 context = self.context
299 wf = context.wf
301 if new:
302 wf.insert(contribId)
303 else:
304 wf.recompute(contribId)
306 def stage(self, record, table, kind=None):
307 """Retrieve the workflow attribute `stage` from a record, if existing.
309 This is a quick and direct way to retrieve workflow info for a record.
311 Parameters
312 ----------
313 record: dict
314 The full record
315 table: string {contrib, assessment, review}
316 kind: string {`expert`, `final`}, optional `None`
317 Only if we want review attributes
319 Returns
320 -------
321 string | `None`
322 """
324 recordObj = self.record(record=record)
326 wfitem = recordObj.wfitem
327 return wfitem.stage(table, kind=kind) if wfitem else None
329 def creators(self, record, table, kind=None):
330 """Retrieve the workflow attribute `creators` from a record, if existing.
332 This is a quick and direct way to retrieve workflow info for a record.
334 Parameters
335 ----------
336 record: dict
337 The full record
338 table: string {contrib, assessment, review}
339 kind: string {`expert`, `final`}, optional `None`
340 Only if we want review attributes
342 Returns
343 -------
344 (list of ObjectId) | `None`
345 """
347 recordObj = self.record(record=record)
349 wfitem = recordObj.wfitem
350 return wfitem.creators(table, kind=kind) if wfitem else None
352 def wrap(self, openEid, action=None, logical=False):
353 """Wrap the list of records into HTML or Json.
355 action | selection
356 --- | ---
357 `my` | records that the current user has created or is an editor of
358 `our` | records that the current user can edit, assess, review, or select
359 `assess` | records that the current user is assessing
360 `assign` | records that the current office user must assign to reviewers
361 `reviewer` | records that the current user is reviewing
362 `reviewdone` | records that the current user has reviewed
363 `select` | records that the current national coordinator user can select
365 Permissions will be checked before executing one of these list actions.
366 See `control.table.Table.mayList`.
368 !!! caution "Workflow restrictions"
369 There might be additional restrictions on individual records
370 due to workflow. Some records may not be readable.
371 They will be filtered out.
373 !!! note
374 Whether records are presented in an opened or closed state
375 depends onn how the user has last left them.
376 This information is stored in `localStorage` inn the browser.
377 However, if the last action was the creation of a nnew record,
378 we want to open the list with the new record open and scrolled to,
379 so that the usercan start filling in the blank record straightaway.
381 Parameters
382 ----------
383 openEid: ObjectId
384 The id of a record that must forcibly be opened.
385 action: string, optional, `None`
386 If present, a specific record selection will be presented,
387 otherwise all records go to the interface.
388 logical: boolean, optional `False`
389 If True, return the data as a dict or list, otherwise wrap it in HTML
391 Returns
392 -------
393 string(html) or any
394 """
396 if not self.mayList(action=action): 396 ↛ 397line 396 didn't jump to line 397, because the condition on line 396 was never true
397 return None
399 context = self.context
400 db = context.db
401 table = self.table
402 uid = self.uid
403 countryId = self.countryId
404 titleSortkey = self.groupSortkey if table == N.user else self.titleSortkey
405 (itemSingular, itemPlural) = self.itemLabels
407 params = (
408 dict(my=uid)
409 if action == N.my
410 else dict(our=countryId)
411 if action == N.our
412 else dict(my=uid)
413 if action == N.assess
414 else dict(assign=True)
415 if action == N.assign
416 else dict(review=uid)
417 if action == N.review
418 else dict(review=uid)
419 if action == N.reviewdone
420 else dict(selectable=countryId)
421 if action == N.select
422 else {}
423 )
424 if request.args:
425 params.update(request.args)
427 records = db.getList(table, titleSortkey, select=self.isMainTable, **params)
428 if not logical: 428 ↛ 432line 428 didn't jump to line 432, because the condition on line 428 was never false
429 insertButton = self.insertButton() if self.withInsert(action) else E
430 sep = NBSP if insertButton else E
432 if action == N.assess:
433 records = [
434 record
435 for record in records
436 if self.stage(record, N.assessment) in ASSESSMENT_STAGES
437 and self.stage(record, N.review, kind=N.final)
438 not in {N.reviewAccept, N.reviewReject}
439 and uid in self.creators(record, N.assessment)
440 ]
441 if action == N.review:
442 records = [record for record in records if not self.myFinished(uid, record)]
443 if action == N.reviewdone:
444 records = [record for record in records if self.myFinished(uid, record)]
446 recordsJson = []
447 recordsHtml = []
448 nRecords = 0
449 sensitive = table in SENSITIVE_TABLES
450 for record in records:
451 if not sensitive or self.readable(record) is not False: 451 ↛ 450line 451 didn't jump to line 450, because the condition on line 451 was never false
452 nRecords += 1
453 if logical: 453 ↛ 454line 453 didn't jump to line 454, because the condition on line 453 was never true
454 recordsJson.append(self.record(record=record).wrapLogical())
455 else:
456 recordsHtml.append(
457 H.details(
458 self.title(record, withRole=True),
459 H.div(ELLIPS),
460 f"""{table}/{G(record, N._id)}""",
461 fetchurl=f"""/api/{table}/{N.item}/{G(record, N._id)}""",
462 urltitle=E,
463 urlextra=E,
464 **self.forceOpen(G(record, N._id), openEid),
465 )
466 )
468 if logical: 468 ↛ 469line 468 didn't jump to line 469, because the condition on line 468 was never true
469 return recordsJson
471 itemLabel = itemSingular if nRecords == 1 else itemPlural
472 nRepCmt = f"""<!-- mainN~{nRecords}~{itemLabel} -->"""
473 nRep = nRepCmt + H.span(f"""{nRecords} {itemLabel}""", cls="stats")
475 return H.div(
476 [H.span([insertButton, sep, nRep])] + recordsHtml, cls=f"table {table}",
477 )
479 def withInsert(self, action):
480 context = self.context
481 auth = context.auth
482 table = self.table
483 return (
484 action == N.my and table == MAIN_TABLE
485 or table in VALUE_TABLES
486 and auth.superuser()
487 or table in SYSTEM_TABLES
488 and auth.sysadmin()
489 )
491 @staticmethod
492 def myKind(uid, record):
493 """Quickly determine the kind of reviewer that somebody is.
495 Parameters
496 ----------
497 uid: ObjectId
498 The user as reviewer.
499 record: dict
500 The review of which the user is or is not a reviewer.
502 Returns
503 -------
504 string {`expert`, `final`} | `None`
505 """
507 return (
508 N.expert
509 if G(record, N.reviewerE) == uid
510 else N.final
511 if G(record, N.reviewerF) == uid
512 else None
513 )
515 def myFinished(self, uid, record):
516 """Quickly determine whethe somebody is done reviewing.
518 Parameters
519 ----------
520 uid: ObjectId
521 The user as reviewer.
522 record: dict
523 The review in question.
525 The question is: did `uid` take a review decision, or
526 has the final reviewer already decided anyway?
528 Returns
529 -------
530 bool
531 """
533 return self.stage(record, N.review, kind=N.final) in {
534 N.reviewAccept,
535 N.reviewReject,
536 } or self.stage(record, N.review, kind=Table.myKind(uid, record)) in {
537 N.reviewAdviseAccept,
538 N.reviewAdviseReject,
539 N.reviewAccept,
540 N.reviewReject,
541 }
543 def insertButton(self):
544 """Present an insert button on the interface.
546 Only if the user has rights to insert new items in this table.
547 """
549 mayInsert = self.mayInsert
551 if not mayInsert: 551 ↛ 552line 551 didn't jump to line 552, because the condition on line 551 was never true
552 return E
554 table = self.table
555 itemSingle = self.itemLabels[0]
557 return H.a(
558 f"""New {itemSingle}""",
559 f"""/api/{table}/{N.insert}""",
560 cls="small task info",
561 )
563 def mayList(self, action=None):
564 """Checks permission for a list action.
566 Hera are the rules:
568 * all users may see the whole contrib table (not all fields!);
569 * superusers may see all tables with all list actions;
570 * authenticated users may see
571 * contribs, assessments, reviews
572 * value tables.
574 Parameters
575 ----------
576 action: string, optional `None`
577 The action to check permissions for.
578 If not present, it will be checked whether
579 the user may see the list of all records.
581 Returns
582 -------
583 boolean
584 """
586 table = self.table
587 context = self.context
588 auth = context.auth
589 return checkTable(auth, table) and (action is None or auth.authenticated())
591 def title(self, record, markup=True, **kwargs):
592 """Fast way to get a title on the basis of the record only.
594 When record titles have to be generated for many records in a list,
595 we forego the sophistications of the special tables, and we pick some
596 fields from the record itself.
598 The proper way would be:
600 ``` python
601 return obj.record(record=record).title(**atts)
602 ```
604 but that is painfully slow for the contribution table.
606 Parameters
607 ----------
608 record: dict
609 The full record
611 Returns
612 -------
613 string
614 """
616 # return obj.record(record=record).title(**atts)
617 return self.RecordClass.titleRaw(self, record, markup=markup, **kwargs)
619 @staticmethod
620 def forceOpen(theEid, openEid):
621 """HTML attribute that trigger forced opening.
623 Elements with the `forceopen` attribute will be found by Javascript
624 and be forced to open after loading.
626 We only return this attribute if `theId` is equal to `openEid`.
628 !!! hint
629 The use case comes from iterating through many records and only
630 add the `forceopen` attribute for a specific record.
632 Parameters
633 ----------
634 theId: string
635 openId: string
637 Returns
638 -------
639 dict
640 `{forceopen='1'}` | `None`
641 """
643 return dict(forceopen=ONE) if openEid and str(theEid) == openEid else dict()