Coverage for control/record.py : 94%

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"""Records in tables.
3* Rendering
4* Modification
5* Deletion
6"""
8from config import Config as C, Names as N
9from control.perm import permRecord
10from control.utils import pick as G, cap1, E, ELLIPS, ONE, S
11from control.html import HtmlElements as H
12from control.field import Field
14from control.cust.factory_details import factory as detailsFactory
17CT = C.tables
18CW = C.web
21MASTERS = CT.masters
22MAIN_TABLE = CT.userTables[0]
23ACTUAL_TABLES = set(CT.actualTables)
24REFRESH_TABLES = set(CT.refreshTables)
25USER_TABLES_LIST = CT.userTables
26USER_TABLES = set(USER_TABLES_LIST)
27WORKFLOW_TABLES = USER_TABLES | set(CT.userEntryTables)
28CASCADE_SPECS = CT.cascade
30# an easy way to go from assessment to contrib and from contrib to assessment
31# used in deleteButton
33TO_MASTER = {
34 USER_TABLES_LIST[i + 1]: USER_TABLES_LIST[i]
35 for i in range(len(USER_TABLES_LIST) - 1)
36}
39class Record:
40 """Deals with records."""
42 inheritProps = (
43 N.context,
44 N.uid,
45 N.eppn,
46 N.mkTable,
47 N.table,
48 N.fields,
49 N.prov,
50 N.isUserTable,
51 N.isUserEntryTable,
52 N.itemLabels,
53 )
55 def __init__(
56 self,
57 tableObj,
58 eid=None,
59 record=None,
60 withDetails=False,
61 readonly=False,
62 bodyMethod=None,
63 ):
64 """## Initialization
66 Store the incoming information.
68 A number of properties will be inherited from the table object
69 that spawns a record object.
71 Parameters
72 ----------
73 tableObj: object
74 See below.
75 eid, record, withDetails, readonly, bodyMethod
76 See `control.table.Table.record`
77 """
79 for prop in Record.inheritProps:
80 setattr(self, prop, getattr(tableObj, prop, None))
82 self.tableObj = tableObj
83 """*object* A `control.table.Table` object (or one of a derived class)
84 """
86 self.withDetails = withDetails
87 """*boolean* Whether to present a list of detail records below the record.
88 """
90 self.readonly = readonly
91 """*boolean* Whether to present the complete record in readonly mode.
92 """
94 self.bodyMethod = bodyMethod
95 """*function* How to compose the HTML for the body of the record.
96 """
98 context = self.context
99 table = self.table
101 self.DetailsClass = detailsFactory(table)
102 """*class* The class used for presenting details of this record.
104 It might be the base class `control.details.Details` or one of its
105 derived classes.
106 """
108 if record is None:
109 record = context.getItem(table, eid)
110 self.record = record
111 """*dict* The data of the record, keyed by field names.
112 """
114 self.eid = G(record, N._id)
115 """*ObjectId* The id of the record.
116 """
118 self.setPerm()
120 self.setWorkflow()
121 self.mayDelete = self.getDelPerm()
122 """*boolean* Whether the user may delete the record.
123 """
125 def getDelPerm(self):
126 """Compute the delete permission for this record.
128 The unbreakable rule is:
129 * Records with dependencies cannot be deleted if the dependencies
130 are not configured as `cascade-delete` in tables.yaml.
132 The next rules are workflow rules:
134 * if a record is fixed due to workflow constraints, no one can delete it;
135 * if a record is unfixed due to workflow, a user may delete it,
136 irrespective of normal permissions; workflow will determine
137 which records will appear unfixed to which users;
139 If these rules do not clinch it, the normal permission rules will
140 be applied:
142 * authenticated users may delete their own records in the
143 `contrib`, `assessment` and `review` tables
144 * superusers may delete records if the configured edit
145 permissions allow them
146 """
148 context = self.context
149 auth = context.auth
150 isUserTable = self.isUserTable
151 isUserEntryTable = self.isUserEntryTable
152 readonly = self.readonly
153 perm = self.perm
154 fixed = self.fixed
156 isAuthenticated = auth.authenticated()
157 isSuperuser = auth.superuser()
159 normalDelPerm = (
160 not isUserEntryTable
161 and not readonly
162 and isAuthenticated
163 and (isSuperuser or isUserTable and G(perm, N.isEdit))
164 )
165 return False if fixed else normalDelPerm
167 def reload(
168 self,
169 record,
170 ):
171 """Re-initializes a record object if its underlying data has changed.
173 This might be caused by an update in the record itself,
174 or a change in workflow conditions.
175 """
177 self.record = record
178 self.setPerm()
179 self.setWorkflow()
180 self.mayDelete = self.getDelPerm()
182 def getDependencies(self):
183 """Compute dependent records.
185 See `control.db.Db.dependencies`.
186 """
188 context = self.context
189 db = context.db
190 table = self.table
191 record = self.record
193 return db.dependencies(table, record)
195 def setPerm(self):
196 """Compute permission info for this record.
198 See `control.perm.permRecord`.
199 """
201 context = self.context
202 table = self.table
203 record = self.record
205 self.perm = permRecord(context, table, record)
207 def setWorkflow(self):
208 """Compute a workflow item for this record.
210 The workflow item corresponds to this record
211 if it is in the contrib table, otherwise to the
212 contrib that is the (grand)master of this record.
214 See `control.context.Context.getWorkflowItem` and
215 `control.workflow.apply.WorkflowItem`.
217 Returns
218 -------
219 void
220 The attribute `wfitem` will point to the workflow item.
221 If the record is not a valid part of any workflow,
222 or if there is no workflow item found,
223 `wfitem` will be set to `None`.
224 """
226 context = self.context
227 perm = self.perm
228 table = self.table
229 eid = self.eid
230 record = self.record
232 contribId = G(perm, N.contribId)
234 self.kind = None
235 self.fixed = None
236 valid = False
238 wfitem = context.getWorkflowItem(contribId)
239 if wfitem:
240 self.kind = wfitem.getKind(table, record)
241 valid = wfitem.isValid(table, eid, record)
242 self.mayRead = wfitem.checkReadable(self)
243 else:
244 valid = False if table in USER_TABLES - {MAIN_TABLE} else True
245 self.mayRead = None
247 if valid and wfitem:
248 self.fixed = wfitem.checkFixed(self)
249 self.wfitem = wfitem
250 else:
251 self.wfitem = None
253 self.valid = valid
255 def adjustWorkflow(self, update=True, delete=False):
256 """Recompute workflow information.
258 When this record or some other record has changed, it could have had
259 an impact on the workflow.
260 If there is reason to assume this has happened, this function can be called
261 to recompute the workflow item.
263 !!! warning
264 Do not confuse this method with the one with the same name in Tables:
265 `control.table.Table.adjustWorkflow` which does its work after the
266 insertion of a record.
268 Parameters
269 ----------
270 update: boolean, optional `True`
271 If `True`, reset the attribute `wfitem` to the recomputed workflow.
272 Otherwise, recomputation is done, but the attribute is not reset.
273 This is done if there is no use of the workflow info for the remaining
274 steps in processing the request.
275 delete: boolean, optional `False`
276 If `True`, delete the workflow item and set the attribute `wfitem`
277 to `None`
279 Returns
280 -------
281 void
282 The attribute `wfitem` will be set again.
283 """
285 context = self.context
286 wf = context.wf
287 perm = self.perm
289 contribId = G(perm, N.contribId)
290 if delete:
291 wf.delete(contribId)
292 self.wfitem = None
293 else:
294 wf.recompute(contribId)
295 if update:
296 self.wfitem = context.getWorkflowItem(contribId, requireFresh=True)
298 def task(self, task):
299 """Perform a workflow task.
301 See `control.workflow.apply.WorkflowItem.doTask`.
302 """
304 wfitem = self.wfitem
306 url = None
307 good = False
309 if wfitem:
310 url = wfitem.doTask(task, self)
312 if url is None:
313 table = self.table
314 eid = self.eid
315 url = f"""/{table}/{N.item}/{eid}"""
316 else:
317 good = True
318 return (good, url)
320 def field(self, fieldName, **kwargs):
321 """Factory function to wrap a field object around the data of a field.
323 !!! note
324 Workflow information will be checked whether this record is fixated.
325 If so, the new field object will be initialized with parameters
326 to make it uneditable.
328 !!! note
329 It will also be checked whether the field is a workflow field.
330 Such fields are not shown and edited in the normal way,
331 hence they will be set to unreadable and uneditable.
332 The manipulation of such fields is under control of workflow.
333 See `control.workflow.apply.WorkflowItem.isTask`.
335 !!! caution
336 The name of the field must be one for which field specs are defined
337 in the yaml file for the table.
339 Parameters
340 ----------
341 fieldName: string
343 Returns
344 -------
345 object
346 A `control.field.Field` object.
347 """
349 fields = self.fields
350 if fieldName not in fields:
351 return None
353 table = self.table
354 wfitem = self.wfitem
356 forceEdit = G(kwargs, N.mayEdit)
358 if wfitem:
359 fixed = wfitem.checkFixed(self, field=fieldName)
360 if fixed:
361 kwargs[N.mayEdit] = False
362 if wfitem.isTask(table, fieldName):
363 kwargs[N.mayRead] = False
364 kwargs[N.mayEdit] = forceEdit or not fixed
365 return Field(self, fieldName, **kwargs)
367 def delete(self):
368 """Delete a record.
370 Permissions and dependencies will be checked, as a result,
371 the deletion may be prevented.
372 See `Record.getDependencies` and `Record.getDelPerm`.
374 If deletion happens, workflow information will be adapted afterwards.
375 See `Record.adjustWorkflow`.
376 """
378 mayDelete = self.mayDelete
379 if not mayDelete:
380 return False
382 dependencies = self.getDependencies()
383 nRef = G(dependencies, N.reference, default=0)
385 if nRef: 385 ↛ 386line 385 didn't jump to line 386, because the condition on line 385 was never true
386 return False
388 nCas = G(dependencies, N.cascade, default=0)
389 if nCas:
390 if not self.deleteDetails(): 390 ↛ 391line 390 didn't jump to line 391, because the condition on line 390 was never true
391 return False
393 context = self.context
394 table = self.table
395 eid = self.eid
397 good = context.deleteItem(table, eid)
399 if table == MAIN_TABLE:
400 self.adjustWorkflow(delete=True)
401 elif table in WORKFLOW_TABLES:
402 self.adjustWorkflow(update=False)
404 return good
406 def deleteDetails(self):
407 """Delete the details of a record.
409 Permissions and dependencies will be checked, as a result,
410 the deletion may be prevented.
411 See `Record.getDependencies` and `Record.getDelPerm`.
413 Returns
414 -------
415 bool
416 Whether there are still dependencies after deleting the details.
417 """
419 context = self.context
420 db = context.db
421 table = self.table
422 eid = self.eid
424 for dtable in G(CASCADE_SPECS, table, default=[]):
425 db.deleteMany(dtable, {table: eid})
426 dependencies = self.getDependencies()
427 nRef = G(dependencies, N.reference, default=0)
428 return nRef == 0
430 def body(self, myMasters=None, hideMasters=False):
431 """Wrap the body of the record in HTML.
433 This is the part without the provenance information and without
434 the detail records.
436 This method can be overridden by `body` methods in derived classes.
438 Parameters
439 ----------
440 myMasters: iterable of string, optional `None`
441 A declaration of which fields must be treated as master fields.
442 hideMaster: boolean, optional `False`
443 If `True`, all master fields as declared in `myMasters` will be left out.
445 Returns
446 -------
447 string(html)
448 """
450 fieldSpecs = self.fields
451 provSpecs = self.prov
453 return H.join(
454 self.field(field, asMaster=field in myMasters).wrap()
455 for field in fieldSpecs
456 if (field not in provSpecs and not (hideMasters and field in myMasters))
457 )
459 def wrap(
460 self,
461 inner=True,
462 wrapMethod=None,
463 expanded=1,
464 withProv=True,
465 hideMasters=False,
466 showTable=None,
467 showEid=None,
468 extraCls=E,
469 ):
470 """Wrap the record into HTML.
472 A record can be displayed in several states:
474 expanded | effect
475 --- | ---
476 `-1` | only a title with a control to get the full details
477 `0` | full details, no control to collapse/expand
478 `1` | full details, with a control to collapse to the title
480 !!! note
481 When a record in state `1` or `-1` is sent to the client,
482 only the material that is displayed is sent. When the user clicks on the
483 expand/collapse control, the other part is fetched from the server
484 *at that very moment*.
485 So collapsing/expanding can be used to refresh the view on a record
486 if things have happened.
488 !!! caution
489 The triggering of the fetch actions for expanded/collapsed material
490 is done by the Javascript in `index.js`.
491 A single function does it all, and it is sensitive to the exact attributes
492 of the summary and the detail.
493 If you tamper with this code, you might end up with an infinite loop of
494 expanding and collapsing.
496 !!! hint
497 Pay extra attention to the attribute `fat`!
498 When it is present, it is an indication that the expanded material
499 is already on the client, and that it does not have to be fetched.
501 !!! note
502 There are several ways to customise the effect of `wrap` for specific
503 tables. Start with writing a derived class for that table with
504 `Record` as base class.
506 * write an alternative for `Record.body`,
507 e.g. `control.cust.review_record.ReviewR.bodyCompact`, and
508 initialize the `Record` with `(bodyMethod='compact')`.
509 Just specify the part of the name after `body` as string starting
510 with a lower case.
511 * override `Record.body`. This app does not do this currently.
512 * use a custom `wrap` function, by defining it in your derived class,
513 e.g. `control.cust.score_record.ScoreR.wrapHelp`.
514 Use it by calling `Record.wrap(wrapMethod=scoreObj.wrapHelp)`.
516 Parameters
517 ----------
518 inner: boolean, optional `True`
519 Whether to add the CSS class `inner` to the outer `<div>` of the result.
520 wrapMethod: function, optional `None`
521 The method to compose the result out of all its components.
522 Typically defined in a derived class.
523 If passed, this function will be called to deliver the result.
524 Otherwise, `wrap` does the composition itself.
525 expanded: {-1, 0, 1}
526 Whether to expand the record.
527 See the table above.
528 withProv: boolean, optional `True`
529 Include a display of the provenance fields.
530 hideMasters: boolean, optional `False`
531 Whether to hide the master fields.
532 If they are not hidden, they will be presented as hyperlinks to the
533 master record.
534 extraCls: string, optional `''`
535 An extra class to add to the outer `<div>`.
537 Returns
538 -------
539 string(html)
540 """
542 table = self.table
543 eid = self.eid
544 record = self.record
545 provSpecs = self.prov
546 valid = self.valid
547 withDetails = self.withDetails
549 withRefresh = table in REFRESH_TABLES
551 func = getattr(self, wrapMethod, None) if wrapMethod else None
552 if func: 552 ↛ 553line 552 didn't jump to line 553, because the condition on line 552 was never true
553 return func()
555 bodyMethod = self.bodyMethod
556 urlExtra = f"""?method={bodyMethod}""" if bodyMethod else E
557 fetchUrl = f"""/api/{table}/{N.item}/{eid}"""
559 itemKey = f"""{table}/{G(record, N._id)}"""
560 theTitle = self.title()
562 if expanded == -1:
563 return H.details(
564 theTitle,
565 H.div(ELLIPS),
566 itemKey,
567 fetchurl=fetchUrl,
568 urlextra=urlExtra,
569 urltitle=E,
570 )
572 bodyFunc = (
573 getattr(self, f"""{N.body}{cap1(bodyMethod)}""", self.body)
574 if bodyMethod
575 else self.body
576 )
577 myMasters = G(MASTERS, table, default=[])
579 deleteButton = self.deleteButton()
581 innerCls = " inner" if inner else E
582 warningCls = E if valid else " warning "
584 provenance = (
585 H.div(
586 H.detailx(
587 (N.prov, N.dismiss),
588 H.div(
589 [self.field(field).wrap() for field in provSpecs], cls="prov"
590 ),
591 f"""{table}/{G(record, N._id)}/{N.prov}""",
592 openAtts=dict(
593 cls="button small",
594 title="Provenance and editors of this record",
595 ),
596 closeAtts=dict(cls="button small", title="Hide provenance"),
597 cls="prov",
598 ),
599 cls="provx",
600 )
601 if withProv
602 else E
603 )
605 main = H.div(
606 [
607 deleteButton,
608 H.div(
609 H.join(bodyFunc(myMasters=myMasters, hideMasters=hideMasters)),
610 cls=f"{table.lower()}",
611 ),
612 *provenance,
613 ],
614 cls=f"record{innerCls} {extraCls} {warningCls}",
615 )
617 rButton = H.iconr(itemKey, "#main", msg=table) if withRefresh else E
618 details = (
619 self.DetailsClass(self).wrap(showTable=showTable, showEid=showEid)
620 if withDetails
621 else E
622 )
624 return (
625 H.details(
626 rButton + theTitle,
627 H.div(main + details),
628 itemKey,
629 fetchurl=fetchUrl,
630 urlextra=urlExtra,
631 urltitle="""/title""",
632 fat=ONE,
633 forceopen=ONE,
634 open=True,
635 )
636 if expanded == 1
637 else H.div(main + details)
638 )
640 def wrapLogical(self):
641 """Wrap the record into a dict.
643 A record can be displayed in several states:
645 full details
647 Returns
648 -------
649 dict
650 """
652 table = self.table
653 fieldSpecs = self.fields
654 provSpecs = self.prov
655 myMasters = G(MASTERS, table, default=[])
657 record = {
658 field: self.field(field, asMaster=field in myMasters).wrapBare(markup=None)
659 for field in fieldSpecs
660 if (field not in provSpecs and not (field in myMasters))
661 }
662 record["id"] = self.eid
663 return record
665 def deleteButton(self):
666 """Show the delete button and/or the number of dependencies.
668 Check the permissions in order to not show a delete button if the user
669 cannot delete the record.
671 Returns
672 -------
673 string(html)
674 """
676 mayDelete = self.mayDelete
678 if not mayDelete:
679 return E
681 record = self.record
682 table = self.table
683 itemSingle = self.itemLabels[0]
685 dependencies = self.getDependencies()
687 nCas = G(dependencies, N.cascade, default=0)
688 cascadeMsg = (
689 H.span(
690 f"""{nCas} detail record{E if nCas == 1 else S}""",
691 title="""Detail records will be deleted with the master record""",
692 cls="label small warning-o right",
693 )
694 if nCas
695 else E
696 )
697 cascadeMsgShort = (
698 f""" and {nCas} dependent record{E if nCas == 1 else S}""" if nCas else E
699 )
701 nRef = G(dependencies, N.reference, default=0)
703 if nRef:
704 plural = E if nRef == 1 else S
705 return H.span(
706 [
707 H.icon(
708 N.chain,
709 cls="medium right",
710 title=f"""Cannot delete because of {nRef} dependent record{plural}""",
711 ),
712 H.span(
713 f"""{nRef} dependent record{plural}""",
714 cls="label small warning-o right",
715 ),
716 ]
717 )
719 if table in TO_MASTER:
720 masterTable = G(TO_MASTER, table)
721 masterId = G(record, masterTable)
722 else:
723 masterTable = None
724 masterId = None
726 url = (
727 f"""/api/{table}/{N.delete}/{G(record, N._id)}"""
728 if masterTable is None or masterId is None
729 else f"""/api/{masterTable}/{masterId}/{table}/{N.delete}/{G(record, N._id)}"""
730 )
731 return H.span(
732 [
733 cascadeMsg,
734 H.iconx(
735 N.delete,
736 cls="medium right warning",
737 deleteurl=url,
738 title=f"""delete this {itemSingle}{cascadeMsgShort}""",
739 ),
740 ]
741 )
743 def title(self, markup=True, **kwargs):
744 """Generate a title for the record."""
745 record = self.record
746 valid = self.valid
748 warningCls = E if valid else " warning "
750 return Record.titleRaw(self, record, cls=warningCls, markup=markup, **kwargs)
752 def inActualCls(self, record):
753 """Get a CSS class name for a record based on whether it is *actual*.
755 Actual records belong to the current `package`, a record that specifies
756 which contribution types, and criteria are currently part of the workflow.
758 Parameters
759 ----------
760 record: dict | `None`
761 If `self` does not have a `record` attribute, the record data must
762 be passed here.
763 Returns
764 -------
765 string
766 `inactual` if the record is not actual, else the empty string.
767 """
769 table = self.table
770 if record is None:
771 record = self.record
773 isActual = (
774 table not in ACTUAL_TABLES
775 or not record
776 or G(record, N.actual, default=False)
777 )
778 return E if isActual else "inactual"
780 @staticmethod
781 def titleRaw(obj, record, cls=E, markup=True, **kwargs):
782 """Generate a title for a different record.
784 This is fast title generation.
785 No record object will be created.
787 The title will be based on the fields in the record,
788 and its formatting is assisted by the appropriate
789 type class in `control.typ.types`.
791 !!! hint
792 If the record is not "actual", its title will get a warning
793 background color.
794 See `control.db.Db.collect`.
796 Parameters
797 ----------
798 obj: object
799 Any object that has a table and context attribute, e.g. a table object
800 or a record object
801 record: dict
802 cls: string, optional, `''`
803 A CSS class to add to the outer element of the result
804 """
806 table = obj.table
807 context = obj.context
808 types = context.types
809 typesObj = getattr(types, table, None)
810 valueBare = typesObj.title(record=record, **kwargs)
812 if markup is None: 812 ↛ 813line 812 didn't jump to line 813, because the condition on line 812 was never true
813 return valueBare
815 inActualCls = Record.inActualCls(obj, record)
816 atts = dict(cls=f"{cls} {inActualCls}")
818 return H.span(valueBare, **atts, **kwargs)