Coverage for control/field.py : 93%

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"""Fields of records of tables.
3* Display readonly/editable
4* Refresh buttons
5* Saving values
6"""
8from flask import request, abort
10from config import Config as C, Names as N
11from control.html import HtmlElements as H
12from control.utils import pick as G, bencode, cap1, E, BLANK, ONE, COMMA
13from control.perm import getPermField
14from control.typ.value import ConversionError
16CT = C.tables
17CW = C.web
18CF = C.workflow
21DEFAULT_TYPE = CT.defaultType
22CONSTRAINED = CT.constrained
23WITH_NOW = CT.withNow
24WORKFLOW_TABLES = set(CT.userTables) | set(CT.userEntryTables)
25CASCADE_SPECS = CT.cascade
27WORKFLOW_FIELDS = CF.fields
29REFRESH = CW.messages[N.refresh]
30LIMIT_JSON = CW.limitJson
33class Field:
34 """Deals with fields."""
36 inheritProps = (
37 N.context,
38 N.uid,
39 N.eppn,
40 N.table,
41 N.record,
42 N.eid,
43 N.perm,
44 N.readonly,
45 N.mayRead,
46 )
48 def __init__(
49 self,
50 recordObj,
51 field,
52 asMaster=False,
53 readonly=None,
54 mayRead=None,
55 mayEdit=None,
56 ):
57 """## Initialization
59 Store the incoming information.
61 A number of properties will be inherited from the record object
62 that spawns a field object.
64 The value of the field will be looked up from the record and saved into the attribute
65 `value`.
67 Set the attribute `fieldTypeObj` to a suitable derived class of
68 `control.typ.base.TypeBase`.
70 !!! caution
71 Fields that point to master records are never editable in this app.
73 !!! hint
74 The parameters `readonly`, `mayRead`, `mayEdit` are optional.
75 If they are not passed or `None`, values for these will be taken
76 from the `recordObj` that has spawned this field object.
78 !!! caution
79 Whether a field is readable or editable, depends first on how it
80 is configured in the .yaml file in `tables` that correspnds to the table.
81 This can be overriden by setting the `mayRead` and `mayEdit` attributes
82 in the `recordObj`, and it can be overridden again by explicitly
83 passing values for them here.
85 Parameters
86 ----------
87 recordObj: object
88 See below.
89 field: string
90 See below.
91 asMaster: boolean, optional `False`
92 See below.
93 readonly: boolean | `None`, optional `None`
94 Whether the field must be presented readonly.
95 mayRead: boolean | `None`, optional `None`
96 If passed, overrides the configured read permission for this field.
97 mayEdit: boolean | `None`, optional `None`
98 If passed, overrides the configured write permission for this field.
99 """
101 for prop in Field.inheritProps:
102 setattr(self, prop, getattr(recordObj, prop, None))
104 self.recordObj = recordObj
105 """*object* A `control.record.Record` object (or one of a derived class)
106 """
108 self.field = field
109 """*string* The name of the field
110 """
112 self.asMaster = asMaster
113 """*boolean* Whether this field points to a master record.
114 """
116 table = self.table
118 withNow = G(WITH_NOW, table)
119 if withNow:
120 nowFields = []
121 for info in withNow.values():
122 if type(info) is str:
123 nowFields.append(info)
124 else:
125 nowFields.extend(info)
126 nowFields = set(nowFields)
127 else:
128 nowFields = set()
130 self.withRefresh = field == N.modified or field in nowFields
131 """*boolean* Whether the field needs a refresh button.
132 """
134 self.withNow = G(withNow, field)
135 """*dict* Which field updates need a timestamp?
137 The info comes from tables.yaml, under key `withNow`.
138 It is keyed by table name, then by field name, and the value
139 is a single field or lists of two fields with the names of
140 corresponding timestamp fields.
142 When a (boolean) field has two timestamp fields, the first one is used if
143 he value is a list, the first one will be used if the modification
144 sets the field to `True` and the second one when the field becomes `False`.
146 !!! hint
147 In assessments, when `submitted` becomes `True`, `dateSubmitted` receives
148 a timestamp. When `submitted` becomes `False`, it is `dateWithdrawn` that
149 receives the timestamp.
150 """
152 fieldSpecs = recordObj.fields
153 fieldSpec = G(fieldSpecs, field)
155 record = self.record
156 self.value = G(record, field)
157 """*mixed* The value of the field.
158 """
160 require = G(fieldSpec, N.perm, default={})
161 self.require = require
162 """*dict* The required permissions for this field.
164 Keys are `read` and `edit`, the values are `True` or `False`.
165 """
167 self.label = G(fieldSpec, N.label, default=cap1(field))
168 """*string* A label to display in front of the field.
169 """
171 self.tp = G(fieldSpec, N.type, default=DEFAULT_TYPE)
172 """*string* The data type of the field."""
174 self.multiple = G(fieldSpec, N.multiple, default=False)
175 """*boolean* Whether the field value consists of multiple values or a single one.
176 """
178 self.extensible = G(fieldSpec, N.extensible, default=False)
179 """*boolean* Whether the user may add new values to the value table of this field.
180 """
182 context = self.context
184 perm = self.perm
185 table = self.table
186 eid = self.eid
187 tp = self.tp
188 types = context.types
190 fieldTypeObj = getattr(types, tp, None)
191 self.fieldTypeObj = fieldTypeObj
192 """*object* The type object by which the value of this field can be interpreted.
193 """
195 self.widgetType = fieldTypeObj.widgetType
196 """*string* The type of widget for presenting an edit view on this field.
197 """
199 readonly = self.readonly if readonly is None else readonly
201 (self.mayRead, self.mayEdit) = getPermField(
202 table, perm, require, **self.getActualMinimum()
203 )
204 if mayRead is not None:
205 self.mayRead = mayRead
206 if mayEdit is not None:
207 self.mayEdit = mayEdit
209 if readonly or asMaster:
210 self.mayEdit = False
212 self.atts = dict(table=table, eid=eid, field=field)
213 """*dict* Identification of this field value: table, id of record, name of field.
215 !!! hint
216 To be used to pass to buttons in widgets for this field.
217 """
219 def getActualMinimum(self):
220 tp = self.tp
221 if tp == N.permissionGroup:
222 context = self.context
223 db = context.db
224 auth = context.auth
225 user = auth.user
226 userGroup = G(user, N.group)
227 minimum = G(G(db.permissionGroup, userGroup), N.rep)
228 value = self.value
229 actual = G(G(db.permissionGroup, value), N.rep)
230 return dict(actual=actual, minimum=minimum)
231 return {}
233 def save(self, data):
234 """Save a new value for this field to MongoDb.
236 Before saving, permissions and workflow conditions will be checked.
238 After saving, workflow information will be adjusted.
240 !!! caution
241 If the `editors` field of an assessment or review is modified,
242 the same value should be put in the detail records, the
243 `criteriaEntry` and `reviewEntry` records respectively.
245 Parameters
246 ----------
247 data: mixed
248 The new value(s) for this field.
249 It comes straight from the client, and will be converted to proper
250 Python/MongoDb values
251 """
253 mayEdit = self.mayEdit
255 if not mayEdit: 255 ↛ 256line 255 didn't jump to line 256, because the condition on line 255 was never true
256 return False
258 context = self.context
259 db = context.db
260 uid = self.uid
261 eppn = self.eppn
262 table = self.table
263 eid = self.eid
264 field = self.field
265 extensible = self.extensible
266 record = self.record
267 recordObj = self.recordObj
268 require = self.require
269 constrain = None
270 constrainField = G(CONSTRAINED, field)
271 if constrainField:
272 constrainValue = G(record, constrainField)
273 if constrainValue: 273 ↛ 276line 273 didn't jump to line 276, because the condition on line 273 was never false
274 constrain = (constrainField, constrainValue)
276 multiple = self.multiple
277 fieldTypeObj = self.fieldTypeObj
278 conversion = fieldTypeObj.fromStr if fieldTypeObj else None
279 kwargs = dict(uid=uid, eppn=eppn, extensible=extensible) if extensible else {}
280 if constrain:
281 kwargs[N.constrain] = constrain
283 if conversion is not None: 283 ↛ 292line 283 didn't jump to line 292, because the condition on line 283 was never false
284 try:
285 if multiple:
286 data = [conversion(d, **kwargs) for d in data or []]
287 else:
288 data = conversion(data, **kwargs)
289 except ConversionError:
290 return False
292 modified = G(record, N.modified)
293 nowFields = []
294 if data is not None:
295 withNow = self.withNow
296 if withNow:
297 withNowField = (
298 withNow
299 if type(withNow) is str
300 else withNow[0]
301 if data
302 else withNow[1]
303 )
304 nowFields.append(withNowField)
306 result = db.updateField(
307 table,
308 eid,
309 field,
310 data,
311 eppn,
312 modified,
313 nowFields=nowFields,
314 )
315 if not result: 315 ↛ 316line 315 didn't jump to line 316, because the condition on line 315 was never true
316 return False
318 (updates, deletions) = result
319 record = context.getItem(table, eid, requireFresh=True)
321 recordObj.reload(record)
322 self.value = G(record, field)
323 self.perm = recordObj.perm
324 perm = self.perm
325 (self.mayRead, self.mayEdit) = getPermField(
326 table, perm, require, **self.getActualMinimum()
327 )
329 good = True
330 if field == N.editors and table in CASCADE_SPECS:
331 for dtable in CASCADE_SPECS[table]:
332 drecords = db.getDetails(dtable, table, eid)
333 for drecord in drecords:
334 deid = G(drecord, N._id)
335 result = db.updateField(
336 dtable,
337 deid,
338 N.editors,
339 data,
340 eppn,
341 modified,
342 nowFields=nowFields,
343 )
344 if not result: 344 ↛ 345line 344 didn't jump to line 345, because the condition on line 344 was never true
345 good = False
347 if table in WORKFLOW_TABLES and field in WORKFLOW_FIELDS:
348 recordObj.adjustWorkflow()
350 return good
352 def isEmpty(self):
353 """Whether the value(s) is/are empty.
355 A single value is empty if it is `None`.
357 A mutliple value is empty if it is `None` or `[]`.
359 Returns
360 -------
361 boolean
362 """
364 value = self.value
365 multiple = self.multiple
366 return value is None or multiple and value == []
368 def isBlank(self):
369 """Whether the value(s) is/are blank.
371 A single value is empty if it is `None` or `''`.
373 A mutliple value is empty if it is `None` or `[]`, or all its
374 component values are blank.
376 Returns
377 -------
378 boolean
379 """
381 value = self.value
382 multiple = self.multiple
383 return ( 383 ↛ exitline 383 didn't jump to the function exit
384 value is None
385 or value == E
386 or multiple
387 and (value == [] or all(v is None or v == E for v in value))
388 )
390 def wrapBare(self, markup=True):
391 """Produce the bare field value.
393 This is the result of calling the
394 `control.typ.base.TypeBase.toDisplay` method on the derived
395 type class that matches the type of the field.
397 Returns
398 -------
399 string(html)
400 """
402 context = self.context
403 types = context.types
404 tp = self.tp
405 value = self.value
406 multiple = self.multiple
408 fieldTypeObj = getattr(types, tp, None)
409 method = fieldTypeObj.toDisplay
411 return ( 411 ↛ exitline 411 didn't jump to the function exit
412 (
413 [method(val, markup=markup) for val in (value or [])]
414 if multiple
415 else method(value, markup=markup)
416 )
417 if markup is None
418 else (
419 BLANK.join(method(val, markup=markup) for val in (value or []))
420 if multiple
421 else method(value, markup=markup)
422 )
423 )
425 def wrap(self, action=None, asEdit=False, empty=False, withLabel=True, cls=E):
426 """Wrap the field into HTML.
428 If there is an `action`, data from the request is picked up and
429 `Field.save` is called to save that data to the MongoDB.
430 Depending on the `action`, the field is then rendered as follows:
432 action | effect
433 --- | ---
434 `save` | no rendering
435 `view`| a read only rendering
436 `edit` | an edit widget
438 Whether a field is presented as an editable field depends on a number of factors:
440 factor | story
441 --- | ---
442 is master | a field pointing to a master will not be edited
443 attribute `mayEdit` | does the current user have permission to edit the field?
444 action `edit` or parameter `asEdit` | do we want to present an editable widget?
446 Parameters
447 ----------
448 action: {save, edit, view}, optional `None`
449 If present, data will be saved to the database first.
450 asEdit: boolean, optional `False`
451 No data will be saved.
452 The field will rendered editable, if permitted.
453 empty: boolean, optional `False`
454 Only relevant for readonly views: if the value is empty, just present the
455 empty string and nothing else.
456 withLabel: boolean, optional `True`
457 Whether to precede the value with a field label.
458 The label is specified in the field specs which are in the table's .yaml
459 file in `control/tables`.
460 cls: string, optional `''`
461 A CSS class to append to the outer `<div>` of the result.
463 Returns
464 -------
465 string(html)
466 If there was an `action`, the bare representation of the field value is returned.
467 Otherwise, and if `withLabel`, a label is added.
468 """
470 mayRead = self.mayRead
472 if mayRead is False:
473 return E
475 asMaster = self.asMaster
476 mayEdit = self.mayEdit
478 if action is not None and not asMaster:
479 contentLength = request.content_length
480 if contentLength is not None and contentLength > LIMIT_JSON: 480 ↛ 481line 480 didn't jump to line 481, because the condition on line 480 was never true
481 abort(400)
482 data = request.get_json()
483 if data is not None and N.save in data:
484 if mayEdit:
485 good = self.save(data[N.save])
486 else:
487 good = False
488 if not good:
489 abort(400)
491 if action == N.save: 491 ↛ 492line 491 didn't jump to line 492, because the condition on line 491 was never true
492 return E
494 editable = mayEdit and (action == N.edit or asEdit) and not asMaster
495 widget = self.wrapWidget(editable, cls=cls)
497 if action is not None:
498 return H.join(widget)
500 if empty and self.isEmpty(): 500 ↛ 501line 500 didn't jump to line 501, because the condition on line 500 was never true
501 return E
503 label = self.label
504 editClass = " edit" if editable else E
506 return (
507 H.div(
508 [
509 H.div(f"""{label}:""", cls="record-label"),
510 H.div(widget, cls=f"record-value{editClass}"),
511 ],
512 cls="record-row",
513 )
514 if withLabel
515 else H.div(widget, cls=f"record-value{editClass}")
516 )
518 def wrapWidget(self, editable, cls=E):
519 """Wrap the field value.
521 A widget shows the value and may have additional controls to
523 * edit the value
524 * refresh the value
526 Refresh fields are those fields that change if other fields are updated,
527 typically fields that record the moment on which something happened.
528 These fields will get a refresh button automatically.
530 Fields may have three conditions relevant for rendering:
532 condition | rendering
533 --- | ---
534 not editable | readonly
535 editable in readonly view | readonly with button for editable view
536 editable in edit view | editable with button for readonly view
538 Parameters
539 ----------
540 editable: boolean
541 Whether the field should be presented in editable form
542 cls
543 See `Field.wrap()`
545 Returns
546 -------
547 button: string(html)
548 representation: string(html)
550 They are packaged as a tuple.
551 """
553 atts = self.atts
554 mayEdit = self.mayEdit
555 withRefresh = self.withRefresh
557 button = (
558 H.iconx(N.ok, cls="small", action=N.view, **atts)
559 if editable
560 else (
561 H.iconx(N.edit, cls="small", action=N.edit, **atts)
562 if mayEdit
563 else H.iconx(
564 N.refresh,
565 cls="small",
566 action=N.view,
567 title=REFRESH,
568 **atts,
569 )
570 if withRefresh
571 else E
572 )
573 )
575 return (button, self.wrapValue(editable, cls=cls))
577 def wrapValue(self, editable, cls=E):
578 """Wraps the value of a field.
580 !!! hint "field=value in comments"
581 In the result we include a string
583 ``` html
584 <!-- title=My contribution -->
585 ```
587 but then with the field name instead of `title`
588 and the unmarked-up value instead of `My contribution`.
589 This makes it easier for the test suite
590 to spot the fields and their values.
592 Parameters
593 ----------
594 editable: boolean
595 Whether the field should be presented in editable form
596 cls
597 See `Field.wrap()`
599 Returns
600 -------
601 string(html)
602 """
603 context = self.context
604 types = context.types
605 fieldTypeObj = self.fieldTypeObj
606 field = self.field
607 value = self.value
608 tp = self.tp
609 multiple = self.multiple
610 extensible = self.extensible
611 widgetType = self.widgetType
613 baseCls = "tags" if widgetType == N.related else "values"
614 isSelectWidget = widgetType == N.related
616 args = []
617 if isSelectWidget and editable:
618 record = self.record
619 constrain = None
620 constrainField = G(CONSTRAINED, field)
621 if constrainField: 621 ↛ 625line 621 didn't jump to line 625, because the condition on line 621 was never false
622 constrainValue = G(record, constrainField)
623 if constrainValue: 623 ↛ 625line 623 didn't jump to line 625, because the condition on line 623 was never false
624 constrain = (constrainField, constrainValue)
625 args.append(multiple)
626 args.append(extensible)
627 args.append(constrain)
628 atts = dict(wtype=widgetType)
630 if editable:
631 typeObj = getattr(types, tp, None)
632 method = typeObj.toOrig
633 origStr = [method(v) for v in value or []] if multiple else method(value)
634 atts[N.orig] = bencode(origStr)
635 if multiple:
636 atts[N.multiple] = ONE
637 if extensible:
638 atts[N.extensible] = ONE
640 method = fieldTypeObj.widget if editable else fieldTypeObj.toDisplay
641 extraCls = E if editable else cls
642 valueBare = (
643 (COMMA.join(val for val in value or []) if multiple else value)
644 if tp == N.markdown
645 else (
646 COMMA.join(
647 fieldTypeObj.toDisplay(val, markup=False) for val in value or []
648 )
649 if multiple
650 else fieldTypeObj.toDisplay(value, markup=False)
651 )
652 )
654 return f"<!-- {field}={valueBare} -->" + (
655 H.div(
656 [
657 method(val, *args)
658 for val in (value or []) + ([E] if editable else [])
659 ],
660 **atts,
661 cls=baseCls,
662 )
663 if multiple and not (editable and isSelectWidget)
664 else H.div(method(value, *args), **atts, cls=f"value {extraCls}")
665 )