Module control.datamodel
Expand source code Browse git
from markdown import markdown
from .generic import AttrDict, pseudoisonow
from .files import fileExists, listFilesAccepted, writeYaml
class Datamodel:
def __init__(self, Settings, Messages, Mongo):
"""Datamodel related operations.
This class has methods to manipulate various pieces of content
in the data sources, and hand it over to higher level objects.
It can find out dependencies between related records, and it knows
a thing or two about fields.
It is instantiated by a singleton object.
It has a method which is a factory for `control.datamodel.Field` objects,
which deal with individual fields.
Likewise it has a factory function for `control.datamodel.Upload` objects,
which deal with file uploads.
Parameters
----------
Settings: AttrDict
App-wide configuration data obtained from
`control.config.Config.Settings`.
Messages: object
Singleton instance of `control.messages.Messages`.
Mongo: object
Singleton instance of `control.mongo.Mongo`.
"""
self.Settings = Settings
self.Messages = Messages
Messages.debugAdd(self)
self.Mongo = Mongo
datamodel = Settings.datamodel
self.detailMaster = datamodel.detailMaster
self.masterDetail = datamodel.masterDetail
self.mainLink = datamodel.mainLink
textsConfig = datamodel.texts
fieldsConfig = datamodel.fields
fieldDistribution = datamodel.fieldDistribution
self.uploadsConfig = datamodel.uploads
self.fieldObjects = AttrDict()
self.uploadObjects = AttrDict()
fieldPaths = {}
for f, cfg in fieldsConfig.items():
nameSpace = cfg.nameSpace or ""
fieldPath = cfg.fieldPath or f
sep = "." if nameSpace and fieldPath else ""
fieldPaths[f] = f"{nameSpace}{sep}{fieldPath}"
self.textsConfig = textsConfig
self.fieldsConfig = fieldsConfig
self.fieldDistribution = fieldDistribution
self.fieldPaths = fieldPaths
@staticmethod
def specialize(table, record):
"""Specializes information to a table.
Field information is a mapping of keys to values.
When values are dicts, their keys are table names.
If a table is given, we can specialize for that table.
If the table does not occur as a key, we look if there is a key `""`,
and if so, we use that value. Otherwise we use None.
Parameters
----------
table: string
The table to which we must specialize the record
record: dict | AttrDict
The record, of which some fields have values per table
Returns
-------
AttrDict
The specialized record (a new copy)
"""
new = AttrDict()
for k, v in record.items():
if type(v) in {dict, AttrDict}:
new[k] = v.get(table, v.get("", None))
else:
new[k] = v
return new
def getTexts(self):
"""Get the names and info for the fixed text pages.
The contents of these pages are stored in fields the site record,
the path to the field is given in the text info dict.
Returns
-------
dict
Keyed by the name of the text, values are keys for the corresponding
metadata fields in the site record where the text is stored.
"""
return self.textsConfig
def getMetaFields(self, table, kinds, level=None, asDict=False):
"""Get the list of metadata fields for in the meta box.
Parameters
----------
table: string
Either `site` or `project` or `edition`
kinds: list | string | void
The kinds of fields to fetch: one or more of "main", "box" or "narrative".
If None, all kinds are used, in the order :main", "narrative", "box".
If a string: it is the single kind that is being used.
level: integer, optional None
If not None, append @ plus level to each meta key that is delivered,
and join the components with ` + `
If one of the fields is `title`, its level is one lower.
asDict: boolean, optional False
If True, the `level` parameter will be ignored.
Returns a dictionary with the field names as keys, and the field information
as values, specialized to the given table.
Returns
-------
dict | tuple of string | string
Returns a dict if `asDict` is True.
The meta keys in question, as a tuple if level is None, otherwise as a
"+"-separated string where each item is appended with a level indicator
"""
fieldDistribution = self.fieldDistribution
fieldsConfig = self.fieldsConfig
result = {} if asDict else []
kinds = (
["main", "narrative", "box"]
if kinds is None
else [kinds] if type(kinds) is str else list(kinds)
)
for k in kinds:
fields = fieldDistribution.get(k, {}).get(table, [])
if asDict:
for f in fields:
result[f] = self.specialize(table, fieldsConfig.get(f, AttrDict()))
else:
result.extend(fields)
return (
result
if asDict
else (
tuple(result)
if level is None
else " + ".join(
f"{x}@{level - 1 if x == 'title' else level}" for x in result
)
)
)
def checkMetaFields(self, table):
"""Get the list of metadata fields that must be present before publication.
Parameters
----------
table: string
Either `site` or `project` or `edition`
Returns
-------
tuple of string
The meta keys in question
"""
fieldDistribution = self.fieldDistribution
fieldsConfig = self.fieldsConfig
kinds = ("main", "narrative", "box")
result = []
for k in kinds:
fields = fieldDistribution.get(k, {}).get(table, [])
for f in fields:
info = self.specialize(table, fieldsConfig.get(f, AttrDict()))
if info.mandatory:
result.append(f)
return tuple(result)
def getMarkdownFields(self):
"""Gives the set of all fields with markdown content.
Returns
-------
set
"""
fieldsConfig = self.fieldsConfig
return {f for (f, cfg) in fieldsConfig.items() if cfg.get("tp", None) == "text"}
def getListFields(self):
"""Gives the set of all fields with list content.
Returns
-------
set
"""
fieldsConfig = self.fieldsConfig
return {
f
for (f, cfg) in fieldsConfig.items()
if cfg.get("multiple", True)
and cfg.get("tp", None) not in {"text", "datetime"}
}
def relevant(self, project=None, edition=None):
"""Get a relevant record and the table to which it belongs.
A relevant record is either a project record, or an edition record,
or the one and only site record.
If all optional parameters are None, we look for the site record.
If the project parameter is not None, we look for the project record.
This is the inverse of `context()`.
Paramenters
-----------
project: string | ObjectId | AttrDict, optional None
The project whose record we need.
edition: string | ObjectId | AttrDict, optional None
The edition whose record we need.
Returns
-------
tuple
* table: string; the table in which the record is found
* record id: string; the id of the record
* record: AttrDict; the record itself
If both project and edition are not None
"""
Settings = self.Settings
Mongo = self.Mongo
if edition is not None:
table = "edition"
(recordId, record) = Mongo.get(table, edition)
elif project is not None:
table = "project"
(recordId, record) = Mongo.get(table, project)
else:
table = "site"
siteCrit = Settings.siteCrit
record = Mongo.getRecord(table, siteCrit)
recordId = record._id
return (table, recordId, record)
def context(self, table, record):
"""Get the context of a record.
Get the project and edition records to which the record belongs.
Parameters
----------
table: string
The table in which the record sits.
record: string
The record.
This is the inverse of `relevant()`.
Returns
-------
tuple of tuple
(siteId, site, projectId, project, editionId, edition)
"""
Mongo = self.Mongo
(recordId, record) = Mongo.get(table, record)
if recordId is None:
return (None, None, None, None, None, None)
if table == "site":
(editionId, edition) = (None, None)
(projectId, project) = (None, None)
(siteId, site) = (recordId, record)
elif table == "project":
(editionId, edition) = (None, None)
(projectId, project) = (recordId, record)
(siteId, site) = Mongo.get("site", record.siteId)
elif table == "edition":
(editionId, edition) = (recordId, record)
(projectId, project) = Mongo.get("project", record.projectId)
(siteId, site) = Mongo.get("site", project.siteId)
return (siteId, site, projectId, project, editionId, edition)
def getDetailRecords(self, masterTable, master):
"""Retrieve the detail records of a master record.
It finds all records that have a field containing an id of the
given master record. But not those in cross-link records.
Details are not retrieved recursively, only the direct details
of a master are fetched.
Parameters
----------
masterTable: string
The name of the table in which the master record lives.
master: string | ObjectId | AttrDict
The master record.
Returns
-------
AttrDict
The list of detail records, categorized by detail table in which
they occur. The detail tables are the keys, the lists of records
in those tables are the values.
If the master record cannot be found or if there are no detail
records, the empty dict is returned.
"""
Mongo = self.Mongo
masterDetail = self.masterDetail
detailTable = masterDetail[masterTable]
if detailTable is None:
return AttrDict()
(masterId, master) = Mongo.get(masterTable, master)
if masterId is None:
return AttrDict()
crit = {f"{masterTable}Id": masterId}
detailRecords = AttrDict()
details = Mongo.getList(detailTable, crit)
if len(details):
detailRecords[detailTable] = details
return detailRecords
def getUserWork(self, user):
"""Gets the number of project and edition records of a user.
We will not delete users if the user is linked to a project or edition.
This function counts how many projects and editions a user is linked to.
Parameters
----------
user: string
The name of the user (field `user` in the record)
Returns
-------
integer
The number of projects
integer
The number of editions
"""
Mongo = self.Mongo
nProjects = len(Mongo.getList("projectUser", dict(user=user)))
nEditions = len(Mongo.getList("EditionUser", dict(user=user)))
return (nProjects, nEditions)
def getLinkedCrit(self, table, record, deleted=False):
"""Produce criteria to retrieve the linked records of a record.
It finds all cross-linked records containing an id of the
given record.
So no detail records.
Parameters
----------
table: string
The name of the table in which the record lives.
record: string | ObjectId | AttrDict
The record.
deleted: boolean, optional False
Search only in the records that are marked for deletion
Returns
-------
AttrDict
Keys: tables in which linked records exist.
Values: the criteria to find those linked records in that table.
"""
Mongo = self.Mongo
mainLink = self.mainLink
linkTables = mainLink[table]
if linkTables is None:
return AttrDict()
(recordId, record) = Mongo.get(table, record, deleted=deleted)
if recordId is None:
return AttrDict()
crit = {f"{table}Id": recordId}
linkCriteria = AttrDict()
for linkTable in linkTables:
linkCriteria[linkTable] = crit
return linkCriteria
def getKeywords(self, extra=None):
"""Get the lists of keywords that act as values for metadata fields.
A keyword is a string value and it belongs to a list of keywords.
The metadata fields that are declared with `tp: keyword` are associated
with a list of values: keywords.
We read the table of keywords, organize it by metadata field, and count
how many edition/project record use that keyword.
Parameters
----------
extra: dict, optional None
If passed, it is a dictionary keyed by metadata keys and valued
with value sets for those metadata keys. These values must be added to
the respective keyword lists.
These are typically from existing values in metadata fields that
have been accepted when different keyword lists were in effect.
Returns
-------
dict
keyed by name of the metadata field, then by the keyword itself,
and valued by the number of edition/project records it occurs in.
"""
Mongo = self.Mongo
Settings = self.Settings
datamodel = Settings.datamodel
fieldsConfig = datamodel.fields
fieldPaths = self.fieldPaths
keywordLists = {
field for (field, cfg) in fieldsConfig.items() if cfg.tp == "keyword"
}
keywords = {}
for name in keywordLists:
keywords[name] = {}
keywordItems = Mongo.getList("keyword", {})
for keywordRecord in keywordItems:
name = keywordRecord.name
if name not in keywords:
# in this case, the existing keywords contain vocab lists
# that are not associated with a metadata field:
# either the metadata config has changed
# or the keywords come from a different instance
keywords[name] = {}
fieldPath = fieldPaths[name]
value = keywordRecord.value
criteria = {fieldPath: value}
recordsP = Mongo.getList("project", criteria)
recordsE = Mongo.getList("edition", criteria)
occs = len(recordsP) + len(recordsE)
keywords[name][value] = occs
if extra is not None:
for name, values in extra.items():
fieldPath = fieldPaths[name]
for value in values:
criteria = {fieldPath: value}
recordsP = Mongo.getList("project", criteria)
recordsE = Mongo.getList("edition", criteria)
occs = len(recordsP) + len(recordsE)
keywords[name][value] = occs
return keywords
def makeField(self, key, table):
"""Make a field object and registers it.
An instance of class `control.datamodel.Field` is created,
geared to this particular field.
!!! note "Idempotent"
If the Field object is already registered, nothing is done.
Field objects are registered under their key and table.
Parameters
----------
key: string
Identifier for the field.
The configuration for this field will be retrieved using this key.
Returns
-------
object
The resulting Field object.
It is also added to the `fieldObjects` member.
"""
Settings = self.Settings
fieldObjects = self.fieldObjects
fieldObject = fieldObjects[(key, table)]
if fieldObject:
return fieldObject
Messages = self.Messages
Mongo = self.Mongo
fieldsConfig = self.fieldsConfig
fieldConfig = fieldsConfig[key]
if fieldConfig is None:
Messages.error(logmsg=f"Unknown field key '{key}'")
fieldConfig = AttrDict()
fieldObject = Field(Settings, Messages, Mongo, self, key, table, **fieldConfig)
fieldObjects[(key, table)] = fieldObject
return fieldObject
def makeUpload(self, key, fileName=None):
"""Make a file upload object and registers it.
An instance of class `control.datamodel.Upload` is created,
geared to this particular field.
!!! note "Idempotent"
If the Upload object is already registered, nothing is done.
Parameters
----------
key: string
Identifier for the upload.
The configuration for this upload will be retrieved using this key.
The new upload object will be stored under this key.
fileName: string, optional None
If present, it indicates that the uploaded file will have this prescribed
name.
A file name for an upload object may also have been specified in
the datamodel configuration.
Returns
-------
object
The resulting Upload object.
It is also added to the `uploadObjects` member.
"""
Settings = self.Settings
uploadObjects = self.uploadObjects
uploadsConfig = self.uploadsConfig
if fileName is None:
fileName = uploadsConfig.get(key, AttrDict()).fileName
uploadObject = uploadObjects[(key, fileName)]
if uploadObject:
return uploadObject
Messages = self.Messages
Mongo = self.Mongo
uploadsConfig = self.uploadsConfig
uploadsConfig = AttrDict(**uploadsConfig[key])
if uploadsConfig is None:
Messages.error(logmsg=f"Unknown upload key '{key}'")
if fileName is not None:
uploadsConfig["fileName"] = fileName
uploadObject = Upload(Settings, Messages, Mongo, key, **uploadsConfig)
uploadObjects[(key, fileName)] = uploadObject
return uploadObject
def getUploadConfig(self, key):
"""Get an upload config.
Parameters
----------
key: string
The key of the upload config
Returns
-------
object | void
The upload config found under the given key and file name, if
present, otherwise None
"""
return self.uploadsConfig[key]
def getUploadObject(self, key, fileName=None):
"""Get an upload object.
Parameters
----------
key: string
The key of the upload object
fileName: string, optional None
The file name of the upload object.
If not passed, the file name is derived from the config of the key.
Returns
-------
object | void
The upload object found under the given key and file name, if
present, otherwise None
"""
if fileName is None:
fileName = self.uploadsConfig[key].fileName
return self.uploadObjects[(key, fileName)]
class Field:
def __init__(self, Settings, Messages, Mongo, Datamodel, key, table, **kwargs):
"""Handle field business.
A Field object does not correspond with an individual field in a record.
It represents a *column*, i.e. a set of fields with the same name in all
records of a table.
First of all there is a method to retrieve the value of the field from
a specific record.
Then there are methods to deliver those values, either bare or formatted,
to produce edit widgets to modify the values, and handlers to save
values.
How to do this is steered by the specification of the field by keys and
values that are stored in this object.
Some field specifications may be table dependent. If a table is passed,
we can get the table dependent values by means of the static method
`control.datamodel.Datamodel.specialize`.
All field access should be guarded by the authorisation rules.
Parameters
----------
table: string
Name of the table for which we must specialize the field information
kwargs: dict
Field configuration arguments.
It certain parts of the field configuration
are not present, defaults will be provided.
"""
self.Settings = Settings
self.Messages = Messages
Messages.debugAdd(self)
self.Mongo = Mongo
self.Datamodel = Datamodel
self.key = key
"""The identifier of this field within the app.
"""
self.table = table
"""The table for which this field is specialized.
"""
self.nameSpace = ""
"""The first key to access the field data in a record.
Example `dc` (Dublin Core). So if a record has Dublin Core
metadata, we expect that metadata to exist under key `dc` in that record.
If the nameSpace is `""`, it is assumed that we can dig up the values without
going into a nameSpace sub-record first.
**NB.: This attribute is not table dependent.**
"""
self.fieldPath = key
"""Compound selector in a nested dict.
A string of keys, separated by `.`, which will be used to drill down
into a nested dict. At the end of the path we find the selected value.
This field selection is applied after the name space selection
(if `nameSpace` is not the empty string).
**NB.: This attribute is not table dependent.**
"""
self.tp = "string"
"""The value type of the field.
Value types can be string, integer, but also date-time, and values
from an other table (keyword).
The value "keyword" is used if the the field works with values from another
table (i.e. values from the keyword table). It is assumed that all these values
are strings.
If True, the value of such a field must consist of zero or more elements
of a prescribed list of keywords.
These lists are associated with certain metadata fields and can be managed
by admins in a widget on the MyWork page.
We do not enforce that the value of such a field is a member of the
associated list at all times. For example, if we import projects and editions
that have been made with different lists of keywords in force, we accept
foreign keywords. However, users will not be able to apply foreign keywords
when they edit fields.
**NB.: This attribute is not table dependent.**
"""
self.multiple = True
"""Whether multiple values are allowed.
**NB.: This attribute is not table dependent.**
"""
self.readonly = False
"""Whether the field can be edited manually by authorized users.
If this field is True, no user can directly change the value. Instead, the
system will fill in this value, dependent on the completion of certain actions.
**NB.: This attribute is not table dependent.**
"""
self.default = None
"""A default value to deliver if the field has no value.
**NB.: This attribute may be table dependent.**
"""
self.mandatory = False
"""Whether a value is mandatory.
**NB.: This attribute may be table dependent.**
"""
self.caption = key
"""A caption that may be displayed with the field value.
The caption may be a literal string with or without a placeholder `{}`.
If there is no place holder, the caption will precede the content of
the field.
If there is a placeholder, the content will replace the place holder
in the caption.
**NB.: This attribute may be table dependent.**
"""
for arg, value in Datamodel.specialize(table, kwargs).items():
if value is not None:
setattr(self, arg, value)
def specialize(self, table, record):
"""Specializes a record to a table.
Parameters
----------
table: string
The table in question
record: dict | AttrDict
The record in question
Returns
-------
AttrDict
The specialized record, a new copy
"""
return Datamodel.specialize(table, record)
def logical(self, record):
"""Give the logical value of the field in a record.
Parameters
----------
record: AttrDict
The record in which the field value is stored.
Returns
-------
any:
Whatever the value is that we find for that field.
No conversion/casting to other types will be performed.
If the field is not present, returns None, without warning.
"""
nameSpace = self.nameSpace
fieldPath = self.fieldPath
fields = fieldPath.split(".")
dataSource = record.get(nameSpace, {}) if nameSpace else record
for field in fields[0:-1]:
dataSource = dataSource.get(field, None)
if dataSource is None:
break
value = None if dataSource is None else dataSource.get(fields[-1], None)
return value
def resolved(self, record):
default = self.default
logical = self.logical(record)
return default if logical is None else logical
def setLogical(self, record, value):
"""Set the logical value of the field in a record.
Parameters
----------
record: AttrDict
The record in which the field value is to be stored.
It will be modified in place.
value: object
Any value to put into the record
"""
nameSpace = self.nameSpace
fieldPath = self.fieldPath
fields = fieldPath.split(".")
dataSource = record.setdefault(nameSpace, AttrDict()) if nameSpace else record
for field in fields[0:-1]:
dataSource = dataSource.setdefault(field, AttrDict())
dataSource[fields[-1]] = value
def bare(self, record, compact=False, joined=False):
"""Give the bare string value of the field in a record.
If the logical value of the field is None, its default will be filled in.
Parameters
----------
record: AttrDict
The record in which the field value is stored.
compact: boolean, optional False
Only relevant for datetime types: if True, omit the time, leaving only
the date plus the timezone (always `Z` = UTC).
joined: boolean|string, optional False
Only relevant for fields with multiple values, but not of type text:
If not False, it should be a string by which the values should be joined.
For example, if the value is [2, 3], if joined is False the result is:
`[2, 3]`, but if joined is ";" the result is `2;3`.
Returns
-------
string:
Whatever the value is that we find for that field, converted to string.
If the field is not present, returns the empty string, without warning.
"""
tp = self.tp
multiple = self.multiple
resolved = self.resolved(record)
# if not multiple and type(logical) in {list, tuple}:
# logical = logical[-1] if len(logical) else ""
return (
""
if resolved is None
else (
(resolved.split("T")[0] + "Z" if compact else resolved)
if tp == "datetime"
else (
str(resolved)
if joined is False
or tp == "text"
or not multiple
or type(resolved) in {str, int, bool}
else joined.join(str(r) for r in resolved)
)
)
)
def formatted(
self,
record,
level=None,
editable=False,
outerCls="fieldouter",
innerCls="fieldinner",
):
"""Give the formatted value of the field in a record.
Optionally also puts a caption and/or an edit control.
The value retrieved is (recursively) wrapped in HTML, steered by an additional
argument, as in `control.html.HtmlElements.wrapValue`.
be applied.
If the type is 'text', multiple values will simply be concatenated
with newlines in between, and no extra classes will be applied.
Instead, a markdown formatter is applied to the result.
For other types:
If the value is an iterable, each individual value is wrapped in a span
to which an (other) extra CSS class may be applied.
Parameters
----------
table: string
The table from which the record is taken
record: string | ObjectId | AttrDict
The record in which the field value is stored.
level: integer, optional None
The heading level in which a caption will be placed.
If None, no caption will be placed.
If 0, the caption will be placed in a span.
editable: boolean, optional False
Whether the field is editable by the current user.
If so, edit controls are provided.
But if the field has been declared as readonly in the field specs, no edit
controls will be provided.
outerCls: string optional "fieldouter"
If given, an extra CSS class for the outer element that wraps the total
value. Only relevant if the type is not 'text'
innerCls: string optional "fieldinner"
If given, an extra CSS class for the inner elements that wrap parts of the
value. Only relevant if the type is not 'text'
Returns
-------
string:
Whatever the value is that we find for that field, converted to HTML.
If the field is not present, returns the empty string, without warning.
"""
Settings = self.Settings
Mongo = self.Mongo
Datamodel = self.Datamodel
H = Settings.H
table = self.table
(recordId, record) = Mongo.get(table, record)
if recordId is None:
return ""
tp = self.tp
caption = self.caption
key = self.key
multiple = self.multiple
readonly = self.readonly
bare = self.bare(record)
resolved = self.resolved(record)
# if table == "site" and key == "abstract":
bareRep = bare or H.i(f"no {key}")
if tp == "text":
readonlyContent = markdown(bareRep, tight=False)
elif tp == "datetime" or tp == "date":
readonlyContent = bare or H.i("never")
else:
readonlyContent = H.wrapValue(
resolved,
outerElem="span",
outerAtts=dict(cls=outerCls),
innerElem="span",
innerAtts=dict(cls=innerCls),
)
if editable and not readonly:
keyRepUrl = "" if key is None else f"/{key}"
saveUrl = f"/save/{table}/{recordId}{keyRepUrl}"
updateButton = H.actionButton("edit_update", tip=f" for field {key}")
cancelButton = H.actionButton("edit_cancel", tip=f" for field {key}")
saveButton = H.actionButton("edit_save", tip=f" for field {key}")
messages = H.div("", cls="editmsgs")
orig = (
bare
if tp == "text"
else (
"§".join(resolved or []) if tp == "keyword" else writeYaml(resolved)
)
)
if tp == "keyword":
valueSet = (
set()
if resolved is None
else {resolved} if type(resolved) is str else set(resolved)
)
keywords = Datamodel.getKeywords(extra={key: valueSet})[key]
options = (
[] if multiple else [(f"Choose a {key} ...", "", valueSet == set())]
) + [(k, k, k in valueSet) for k in keywords]
editableContent = H.select(
options,
multiple=multiple,
cls="editcontent",
saveurl=saveUrl,
origValue=orig,
tp=tp,
)
else:
editableContent = H.textarea(
"", cls="editcontent", saveurl=saveUrl, origValue=orig, tp=key
)
content = "".join(
[
H.span(readonlyContent, cls="readonlycontent"),
H.nbsp,
editableContent,
updateButton,
saveButton,
cancelButton,
messages,
]
)
else:
content = readonlyContent
if level is not None:
if "{value}" in caption:
theCaption = caption.format(kind=table, value=content)
inCaption = True
else:
theCaption = caption
inCaption = False
if level == 0:
elem = "span"
cls = None
else:
elem = "div"
cls = f"lv lv{level}"
theCaption = H.elem(elem, theCaption, cls=cls)
else:
theCaption = ""
inCaption = False
fullContent = ("" if inCaption else theCaption) + (
theCaption if inCaption else content
)
return H.div(
fullContent,
cls="editwidget" if editable and not readonly else "readonlywidget",
)
class Upload:
def __init__(self, Settings, Messages, Mongo, key, **kwargs):
"""Handle upload business.
An upload is like a field of type 'file'.
The name of the uploaded file is stored in a record in MongoDb.
The contents of the file is stored on the file system.
A Upload object does not correspond with an individual field in a record.
It represents a *column*, i.e. a set of fields with the same name in all
records of a table.
First of all there is a method to retrieve the file name of an upload from
a specific record.
Then there are methods to deliver those values, either bare or formatted,
to produce widgets to upload or delete the corresponding files.
How to do this is steered by the specification of the upload by keys and
values that are stored in this object.
All upload access should be guarded by the authorisation rules.
Parameters
----------
kwargs: dict
Upload configuration arguments.
The following parts of the upload configuration
should be present: `table`, `accept`, while `caption`, `fileName`,
`show` are optional.
"""
self.Settings = Settings
self.Messages = Messages
Messages.debugAdd(self)
self.Mongo = Mongo
self.key = key
"""The identifier of this upload within the app.
"""
self.table = kwargs.get("table", None)
"""Indicates the directory where the actual file will be saved.
Possibe values:
* `site`: top level of the working data directory of the site
* `project`: project directory of the project in question
* `edition`: edition directory of the project in question
"""
self.accept = kwargs.get("accept", None)
"""The file types that the field accepts.
"""
self.caption = kwargs.get("caption", f"{self.table} ({self.accept})")
"""The text to display on the upload button.
"""
self.multiple = kwargs.get("multiple", False)
"""Whether multiple files of this type may be uploaded.
"""
self.fileName = kwargs.get("fileName", None)
"""The name of the file once it is uploaded.
The file name for the upload can be passed when the file name
is known in advance.
In that case, a file that is uploaded in this upload widget,
will get this as prescribed file name, regardless of the file name in the
upload request.
Without a file name, the upload widget will show all existing files
conforming to the `accept` setting, and will have a control to upload a
new file.
"""
self.show = kwargs.get("show", False)
"""Whether to show the contents of the file.
This is typically the case when the file is an image to be presented
as a logo.
"""
# let attributes be filled in from the function **kwargs
for arg, value in kwargs.items():
if value is not None:
setattr(self, arg, value)
# try to fill in defaults for attributes that are still None
good = True
for arg in ("table", "accept"):
if getattr(self, arg, None) is None:
Messages.error(logmsg=f"Missing info in Upload spec: {arg}")
good = False
if not good:
quit()
def getDir(self, record):
"""Give the path to the file in question.
The path can be used to build the static url and the save url.
It does not contain the file name.
If the path is non-empty, a "/" will be appended.
Parameters
----------
record: string | ObjectId | AttrDict
The record relevant to the upload
"""
table = self.table
recordId = record._id
projectId = (
recordId
if table == "project"
else record.projectId if table == "edition" else None
)
editionId = recordId if table == "edition" else None
path = (
""
if table == "site"
else (
f"project/{projectId}"
if table == "project"
else (
f"project/{projectId}/edition/{editionId}"
if table == "edition"
else None
)
)
)
sep = "/" if path else ""
return f"{path}{sep}"
def formatted(self, record, mayChange=False, bust=None, wrapped=True):
"""Give the formatted value of a file field in a record.
Optionally also puts an upload control.
Parameters
----------
record: string | ObjectId | AttrDict
The record relevant to the upload
mayChange: boolean, optional False
Whether the file may be changed.
If so, an upload widget is supplied, wich contains a a delete button.
bust: string, optional None
If not None, the image url of the file whose name is passed in
`bust` is made unique by adding the current time to it.
This is a cache buster.
wrapped: boolean, optional True
Whether the content should be wrapped in a container element.
See `control.html.HtmlElements.finput()`.
Returns
-------
string
The name of the uploaded file(s) and/or an upload control.
"""
Settings = self.Settings
H = Settings.H
workingDir = Settings.workingDir
key = self.key
fileName = self.fileName
accept = self.accept
caption = self.caption
show = self.show
recordId = record._id
fileNameRep = "-" if fileName is None else fileName
fid = f"{recordId}/{key}/{fileNameRep}"
path = self.getDir(record)
sep = "/" if path else ""
fullDir = f"{workingDir}{sep}{path}"
saveUrl = f"/upload/{fid}{sep}{path}"
deleteUrl = f"/deletefile/{fid}{sep}{path}".rstrip("/") + "/"
if fileName is None:
content = []
for fileNm in listFilesAccepted(fullDir, accept, withExt=True):
buster = (
f"?v={pseudoisonow()}"
if show and bust is not None and bust == fileNm
else ""
)
item = [fileNm, f"/data/{path}{fileNm}{buster}" if show else None]
content.append(item)
else:
buster = (
f"?v={pseudoisonow()}"
if show and bust is not None and bust == fileName
else ""
)
fullPath = (f"{workingDir}{sep}{path}").rstrip("/") + f"/{fileName}"
exists = fileExists(fullPath)
content = (
fileName,
exists,
f"/data/{path}{fileName}{buster}" if show else None,
)
return H.finput(
content,
accept,
mayChange,
saveUrl,
deleteUrl,
caption,
wrapped=wrapped,
buttonCls="button small",
cls=f"{key.lower()}",
)
Classes
class Datamodel (Settings, Messages, Mongo)
-
Datamodel related operations.
This class has methods to manipulate various pieces of content in the data sources, and hand it over to higher level objects.
It can find out dependencies between related records, and it knows a thing or two about fields.
It is instantiated by a singleton object.
It has a method which is a factory for
Field
objects, which deal with individual fields.Likewise it has a factory function for
Upload
objects, which deal with file uploads.Parameters
Settings
:AttrDict
- App-wide configuration data obtained from
Config.Settings
. Messages
:object
- Singleton instance of
Messages
. Mongo
:object
- Singleton instance of
Mongo
.
Expand source code Browse git
class Datamodel: def __init__(self, Settings, Messages, Mongo): """Datamodel related operations. This class has methods to manipulate various pieces of content in the data sources, and hand it over to higher level objects. It can find out dependencies between related records, and it knows a thing or two about fields. It is instantiated by a singleton object. It has a method which is a factory for `control.datamodel.Field` objects, which deal with individual fields. Likewise it has a factory function for `control.datamodel.Upload` objects, which deal with file uploads. Parameters ---------- Settings: AttrDict App-wide configuration data obtained from `control.config.Config.Settings`. Messages: object Singleton instance of `control.messages.Messages`. Mongo: object Singleton instance of `control.mongo.Mongo`. """ self.Settings = Settings self.Messages = Messages Messages.debugAdd(self) self.Mongo = Mongo datamodel = Settings.datamodel self.detailMaster = datamodel.detailMaster self.masterDetail = datamodel.masterDetail self.mainLink = datamodel.mainLink textsConfig = datamodel.texts fieldsConfig = datamodel.fields fieldDistribution = datamodel.fieldDistribution self.uploadsConfig = datamodel.uploads self.fieldObjects = AttrDict() self.uploadObjects = AttrDict() fieldPaths = {} for f, cfg in fieldsConfig.items(): nameSpace = cfg.nameSpace or "" fieldPath = cfg.fieldPath or f sep = "." if nameSpace and fieldPath else "" fieldPaths[f] = f"{nameSpace}{sep}{fieldPath}" self.textsConfig = textsConfig self.fieldsConfig = fieldsConfig self.fieldDistribution = fieldDistribution self.fieldPaths = fieldPaths @staticmethod def specialize(table, record): """Specializes information to a table. Field information is a mapping of keys to values. When values are dicts, their keys are table names. If a table is given, we can specialize for that table. If the table does not occur as a key, we look if there is a key `""`, and if so, we use that value. Otherwise we use None. Parameters ---------- table: string The table to which we must specialize the record record: dict | AttrDict The record, of which some fields have values per table Returns ------- AttrDict The specialized record (a new copy) """ new = AttrDict() for k, v in record.items(): if type(v) in {dict, AttrDict}: new[k] = v.get(table, v.get("", None)) else: new[k] = v return new def getTexts(self): """Get the names and info for the fixed text pages. The contents of these pages are stored in fields the site record, the path to the field is given in the text info dict. Returns ------- dict Keyed by the name of the text, values are keys for the corresponding metadata fields in the site record where the text is stored. """ return self.textsConfig def getMetaFields(self, table, kinds, level=None, asDict=False): """Get the list of metadata fields for in the meta box. Parameters ---------- table: string Either `site` or `project` or `edition` kinds: list | string | void The kinds of fields to fetch: one or more of "main", "box" or "narrative". If None, all kinds are used, in the order :main", "narrative", "box". If a string: it is the single kind that is being used. level: integer, optional None If not None, append @ plus level to each meta key that is delivered, and join the components with ` + ` If one of the fields is `title`, its level is one lower. asDict: boolean, optional False If True, the `level` parameter will be ignored. Returns a dictionary with the field names as keys, and the field information as values, specialized to the given table. Returns ------- dict | tuple of string | string Returns a dict if `asDict` is True. The meta keys in question, as a tuple if level is None, otherwise as a "+"-separated string where each item is appended with a level indicator """ fieldDistribution = self.fieldDistribution fieldsConfig = self.fieldsConfig result = {} if asDict else [] kinds = ( ["main", "narrative", "box"] if kinds is None else [kinds] if type(kinds) is str else list(kinds) ) for k in kinds: fields = fieldDistribution.get(k, {}).get(table, []) if asDict: for f in fields: result[f] = self.specialize(table, fieldsConfig.get(f, AttrDict())) else: result.extend(fields) return ( result if asDict else ( tuple(result) if level is None else " + ".join( f"{x}@{level - 1 if x == 'title' else level}" for x in result ) ) ) def checkMetaFields(self, table): """Get the list of metadata fields that must be present before publication. Parameters ---------- table: string Either `site` or `project` or `edition` Returns ------- tuple of string The meta keys in question """ fieldDistribution = self.fieldDistribution fieldsConfig = self.fieldsConfig kinds = ("main", "narrative", "box") result = [] for k in kinds: fields = fieldDistribution.get(k, {}).get(table, []) for f in fields: info = self.specialize(table, fieldsConfig.get(f, AttrDict())) if info.mandatory: result.append(f) return tuple(result) def getMarkdownFields(self): """Gives the set of all fields with markdown content. Returns ------- set """ fieldsConfig = self.fieldsConfig return {f for (f, cfg) in fieldsConfig.items() if cfg.get("tp", None) == "text"} def getListFields(self): """Gives the set of all fields with list content. Returns ------- set """ fieldsConfig = self.fieldsConfig return { f for (f, cfg) in fieldsConfig.items() if cfg.get("multiple", True) and cfg.get("tp", None) not in {"text", "datetime"} } def relevant(self, project=None, edition=None): """Get a relevant record and the table to which it belongs. A relevant record is either a project record, or an edition record, or the one and only site record. If all optional parameters are None, we look for the site record. If the project parameter is not None, we look for the project record. This is the inverse of `context()`. Paramenters ----------- project: string | ObjectId | AttrDict, optional None The project whose record we need. edition: string | ObjectId | AttrDict, optional None The edition whose record we need. Returns ------- tuple * table: string; the table in which the record is found * record id: string; the id of the record * record: AttrDict; the record itself If both project and edition are not None """ Settings = self.Settings Mongo = self.Mongo if edition is not None: table = "edition" (recordId, record) = Mongo.get(table, edition) elif project is not None: table = "project" (recordId, record) = Mongo.get(table, project) else: table = "site" siteCrit = Settings.siteCrit record = Mongo.getRecord(table, siteCrit) recordId = record._id return (table, recordId, record) def context(self, table, record): """Get the context of a record. Get the project and edition records to which the record belongs. Parameters ---------- table: string The table in which the record sits. record: string The record. This is the inverse of `relevant()`. Returns ------- tuple of tuple (siteId, site, projectId, project, editionId, edition) """ Mongo = self.Mongo (recordId, record) = Mongo.get(table, record) if recordId is None: return (None, None, None, None, None, None) if table == "site": (editionId, edition) = (None, None) (projectId, project) = (None, None) (siteId, site) = (recordId, record) elif table == "project": (editionId, edition) = (None, None) (projectId, project) = (recordId, record) (siteId, site) = Mongo.get("site", record.siteId) elif table == "edition": (editionId, edition) = (recordId, record) (projectId, project) = Mongo.get("project", record.projectId) (siteId, site) = Mongo.get("site", project.siteId) return (siteId, site, projectId, project, editionId, edition) def getDetailRecords(self, masterTable, master): """Retrieve the detail records of a master record. It finds all records that have a field containing an id of the given master record. But not those in cross-link records. Details are not retrieved recursively, only the direct details of a master are fetched. Parameters ---------- masterTable: string The name of the table in which the master record lives. master: string | ObjectId | AttrDict The master record. Returns ------- AttrDict The list of detail records, categorized by detail table in which they occur. The detail tables are the keys, the lists of records in those tables are the values. If the master record cannot be found or if there are no detail records, the empty dict is returned. """ Mongo = self.Mongo masterDetail = self.masterDetail detailTable = masterDetail[masterTable] if detailTable is None: return AttrDict() (masterId, master) = Mongo.get(masterTable, master) if masterId is None: return AttrDict() crit = {f"{masterTable}Id": masterId} detailRecords = AttrDict() details = Mongo.getList(detailTable, crit) if len(details): detailRecords[detailTable] = details return detailRecords def getUserWork(self, user): """Gets the number of project and edition records of a user. We will not delete users if the user is linked to a project or edition. This function counts how many projects and editions a user is linked to. Parameters ---------- user: string The name of the user (field `user` in the record) Returns ------- integer The number of projects integer The number of editions """ Mongo = self.Mongo nProjects = len(Mongo.getList("projectUser", dict(user=user))) nEditions = len(Mongo.getList("EditionUser", dict(user=user))) return (nProjects, nEditions) def getLinkedCrit(self, table, record, deleted=False): """Produce criteria to retrieve the linked records of a record. It finds all cross-linked records containing an id of the given record. So no detail records. Parameters ---------- table: string The name of the table in which the record lives. record: string | ObjectId | AttrDict The record. deleted: boolean, optional False Search only in the records that are marked for deletion Returns ------- AttrDict Keys: tables in which linked records exist. Values: the criteria to find those linked records in that table. """ Mongo = self.Mongo mainLink = self.mainLink linkTables = mainLink[table] if linkTables is None: return AttrDict() (recordId, record) = Mongo.get(table, record, deleted=deleted) if recordId is None: return AttrDict() crit = {f"{table}Id": recordId} linkCriteria = AttrDict() for linkTable in linkTables: linkCriteria[linkTable] = crit return linkCriteria def getKeywords(self, extra=None): """Get the lists of keywords that act as values for metadata fields. A keyword is a string value and it belongs to a list of keywords. The metadata fields that are declared with `tp: keyword` are associated with a list of values: keywords. We read the table of keywords, organize it by metadata field, and count how many edition/project record use that keyword. Parameters ---------- extra: dict, optional None If passed, it is a dictionary keyed by metadata keys and valued with value sets for those metadata keys. These values must be added to the respective keyword lists. These are typically from existing values in metadata fields that have been accepted when different keyword lists were in effect. Returns ------- dict keyed by name of the metadata field, then by the keyword itself, and valued by the number of edition/project records it occurs in. """ Mongo = self.Mongo Settings = self.Settings datamodel = Settings.datamodel fieldsConfig = datamodel.fields fieldPaths = self.fieldPaths keywordLists = { field for (field, cfg) in fieldsConfig.items() if cfg.tp == "keyword" } keywords = {} for name in keywordLists: keywords[name] = {} keywordItems = Mongo.getList("keyword", {}) for keywordRecord in keywordItems: name = keywordRecord.name if name not in keywords: # in this case, the existing keywords contain vocab lists # that are not associated with a metadata field: # either the metadata config has changed # or the keywords come from a different instance keywords[name] = {} fieldPath = fieldPaths[name] value = keywordRecord.value criteria = {fieldPath: value} recordsP = Mongo.getList("project", criteria) recordsE = Mongo.getList("edition", criteria) occs = len(recordsP) + len(recordsE) keywords[name][value] = occs if extra is not None: for name, values in extra.items(): fieldPath = fieldPaths[name] for value in values: criteria = {fieldPath: value} recordsP = Mongo.getList("project", criteria) recordsE = Mongo.getList("edition", criteria) occs = len(recordsP) + len(recordsE) keywords[name][value] = occs return keywords def makeField(self, key, table): """Make a field object and registers it. An instance of class `control.datamodel.Field` is created, geared to this particular field. !!! note "Idempotent" If the Field object is already registered, nothing is done. Field objects are registered under their key and table. Parameters ---------- key: string Identifier for the field. The configuration for this field will be retrieved using this key. Returns ------- object The resulting Field object. It is also added to the `fieldObjects` member. """ Settings = self.Settings fieldObjects = self.fieldObjects fieldObject = fieldObjects[(key, table)] if fieldObject: return fieldObject Messages = self.Messages Mongo = self.Mongo fieldsConfig = self.fieldsConfig fieldConfig = fieldsConfig[key] if fieldConfig is None: Messages.error(logmsg=f"Unknown field key '{key}'") fieldConfig = AttrDict() fieldObject = Field(Settings, Messages, Mongo, self, key, table, **fieldConfig) fieldObjects[(key, table)] = fieldObject return fieldObject def makeUpload(self, key, fileName=None): """Make a file upload object and registers it. An instance of class `control.datamodel.Upload` is created, geared to this particular field. !!! note "Idempotent" If the Upload object is already registered, nothing is done. Parameters ---------- key: string Identifier for the upload. The configuration for this upload will be retrieved using this key. The new upload object will be stored under this key. fileName: string, optional None If present, it indicates that the uploaded file will have this prescribed name. A file name for an upload object may also have been specified in the datamodel configuration. Returns ------- object The resulting Upload object. It is also added to the `uploadObjects` member. """ Settings = self.Settings uploadObjects = self.uploadObjects uploadsConfig = self.uploadsConfig if fileName is None: fileName = uploadsConfig.get(key, AttrDict()).fileName uploadObject = uploadObjects[(key, fileName)] if uploadObject: return uploadObject Messages = self.Messages Mongo = self.Mongo uploadsConfig = self.uploadsConfig uploadsConfig = AttrDict(**uploadsConfig[key]) if uploadsConfig is None: Messages.error(logmsg=f"Unknown upload key '{key}'") if fileName is not None: uploadsConfig["fileName"] = fileName uploadObject = Upload(Settings, Messages, Mongo, key, **uploadsConfig) uploadObjects[(key, fileName)] = uploadObject return uploadObject def getUploadConfig(self, key): """Get an upload config. Parameters ---------- key: string The key of the upload config Returns ------- object | void The upload config found under the given key and file name, if present, otherwise None """ return self.uploadsConfig[key] def getUploadObject(self, key, fileName=None): """Get an upload object. Parameters ---------- key: string The key of the upload object fileName: string, optional None The file name of the upload object. If not passed, the file name is derived from the config of the key. Returns ------- object | void The upload object found under the given key and file name, if present, otherwise None """ if fileName is None: fileName = self.uploadsConfig[key].fileName return self.uploadObjects[(key, fileName)]
Subclasses
Static methods
def specialize(table, record)
-
Specializes information to a table.
Field information is a mapping of keys to values. When values are dicts, their keys are table names. If a table is given, we can specialize for that table. If the table does not occur as a key, we look if there is a key
""
, and if so, we use that value. Otherwise we use None.Parameters
table
:string
- The table to which we must specialize the record
record
:dict | AttrDict
- The record, of which some fields have values per table
Returns
AttrDict
- The specialized record (a new copy)
Methods
def checkMetaFields(self, table)
-
Get the list of metadata fields that must be present before publication.
Parameters
table
:string
- Either
site
orproject
oredition
Returns
tuple
ofstring
- The meta keys in question
def context(self, table, record)
-
Get the context of a record.
Get the project and edition records to which the record belongs.
Parameters
table
:string
- The table in which the record sits.
record
:string
- The record.
This is the inverse of
relevant()
.Returns
tuple
oftuple
- (siteId, site, projectId, project, editionId, edition)
def getDetailRecords(self, masterTable, master)
-
Retrieve the detail records of a master record.
It finds all records that have a field containing an id of the given master record. But not those in cross-link records.
Details are not retrieved recursively, only the direct details of a master are fetched.
Parameters
masterTable
:string
- The name of the table in which the master record lives.
master
:string | ObjectId | AttrDict
- The master record.
Returns
AttrDict
- The list of detail records, categorized by detail table in which they occur. The detail tables are the keys, the lists of records in those tables are the values. If the master record cannot be found or if there are no detail records, the empty dict is returned.
def getKeywords(self, extra=None)
-
Get the lists of keywords that act as values for metadata fields.
A keyword is a string value and it belongs to a list of keywords. The metadata fields that are declared with
tp: keyword
are associated with a list of values: keywords.We read the table of keywords, organize it by metadata field, and count how many edition/project record use that keyword.
Parameters
extra
:dict
, optionalNone
- If passed, it is a dictionary keyed by metadata keys and valued with value sets for those metadata keys. These values must be added to the respective keyword lists. These are typically from existing values in metadata fields that have been accepted when different keyword lists were in effect.
Returns
dict
- keyed by name of the metadata field, then by the keyword itself, and valued by the number of edition/project records it occurs in.
def getLinkedCrit(self, table, record, deleted=False)
-
Produce criteria to retrieve the linked records of a record.
It finds all cross-linked records containing an id of the given record.
So no detail records.
Parameters
table
:string
- The name of the table in which the record lives.
record
:string | ObjectId | AttrDict
- The record.
deleted
:boolean
, optionalFalse
- Search only in the records that are marked for deletion
Returns
AttrDict
- Keys: tables in which linked records exist. Values: the criteria to find those linked records in that table.
def getListFields(self)
-
Gives the set of all fields with list content.
Returns
set
def getMarkdownFields(self)
-
Gives the set of all fields with markdown content.
Returns
set
def getMetaFields(self, table, kinds, level=None, asDict=False)
-
Get the list of metadata fields for in the meta box.
Parameters
table
:string
- Either
site
orproject
oredition
kinds
:list | string | void
- The kinds of fields to fetch: one or more of "main", "box" or "narrative". If None, all kinds are used, in the order :main", "narrative", "box". If a string: it is the single kind that is being used.
level
:integer
, optionalNone
- If not None, append @ plus level to each meta key that is delivered,
and join the components with
+
If one of the fields istitle
, its level is one lower. asDict
:boolean
, optionalFalse
- If True, the
level
parameter will be ignored. Returns a dictionary with the field names as keys, and the field information as values, specialized to the given table.
Returns
dict | tuple
ofstring | string
- Returns a dict if
asDict
is True. The meta keys in question, as a tuple if level is None, otherwise as a "+"-separated string where each item is appended with a level indicator
def getTexts(self)
-
Get the names and info for the fixed text pages.
The contents of these pages are stored in fields the site record, the path to the field is given in the text info dict.
Returns
dict
- Keyed by the name of the text, values are keys for the corresponding metadata fields in the site record where the text is stored.
def getUploadConfig(self, key)
-
Get an upload config.
Parameters
key
:string
- The key of the upload config
Returns
object | void
- The upload config found under the given key and file name, if present, otherwise None
def getUploadObject(self, key, fileName=None)
-
Get an upload object.
Parameters
key
:string
- The key of the upload object
fileName
:string
, optionalNone
- The file name of the upload object. If not passed, the file name is derived from the config of the key.
Returns
object | void
- The upload object found under the given key and file name, if present, otherwise None
def getUserWork(self, user)
-
Gets the number of project and edition records of a user.
We will not delete users if the user is linked to a project or edition. This function counts how many projects and editions a user is linked to.
Parameters
user
:string
- The name of the user (field
user
in the record)
Returns
integer
- The number of projects
integer
- The number of editions
def makeField(self, key, table)
-
Make a field object and registers it.
An instance of class
Field
is created, geared to this particular field.Idempotent
If the Field object is already registered, nothing is done. Field objects are registered under their key and table.
Parameters
key
:string
- Identifier for the field. The configuration for this field will be retrieved using this key.
Returns
object
- The resulting Field object.
It is also added to the
fieldObjects
member.
def makeUpload(self, key, fileName=None)
-
Make a file upload object and registers it.
An instance of class
Upload
is created, geared to this particular field.Idempotent
If the Upload object is already registered, nothing is done.
Parameters
key
:string
- Identifier for the upload. The configuration for this upload will be retrieved using this key. The new upload object will be stored under this key.
fileName
:string
, optionalNone
- If present, it indicates that the uploaded file will have this prescribed name. A file name for an upload object may also have been specified in the datamodel configuration.
Returns
object
- The resulting Upload object.
It is also added to the
uploadObjects
member.
def relevant(self, project=None, edition=None)
-
Get a relevant record and the table to which it belongs.
A relevant record is either a project record, or an edition record, or the one and only site record.
If all optional parameters are None, we look for the site record. If the project parameter is not None, we look for the project record.
This is the inverse of
context()
.Paramenters
project: string | ObjectId | AttrDict, optional None The project whose record we need. edition: string | ObjectId | AttrDict, optional None The edition whose record we need.
Returns
tuple
-
- table: string; the table in which the record is found
- record id: string; the id of the record
- record: AttrDict; the record itself
If both project and edition are not None
class Field (Settings, Messages, Mongo, Datamodel, key, table, **kwargs)
-
Handle field business.
A Field object does not correspond with an individual field in a record. It represents a column, i.e. a set of fields with the same name in all records of a table.
First of all there is a method to retrieve the value of the field from a specific record.
Then there are methods to deliver those values, either bare or formatted, to produce edit widgets to modify the values, and handlers to save values.
How to do this is steered by the specification of the field by keys and values that are stored in this object.
Some field specifications may be table dependent. If a table is passed, we can get the table dependent values by means of the static method
Datamodel.specialize()
.All field access should be guarded by the authorisation rules.
Parameters
table
:string
- Name of the table for which we must specialize the field information
kwargs
:dict
- Field configuration arguments. It certain parts of the field configuration are not present, defaults will be provided.
Expand source code Browse git
class Field: def __init__(self, Settings, Messages, Mongo, Datamodel, key, table, **kwargs): """Handle field business. A Field object does not correspond with an individual field in a record. It represents a *column*, i.e. a set of fields with the same name in all records of a table. First of all there is a method to retrieve the value of the field from a specific record. Then there are methods to deliver those values, either bare or formatted, to produce edit widgets to modify the values, and handlers to save values. How to do this is steered by the specification of the field by keys and values that are stored in this object. Some field specifications may be table dependent. If a table is passed, we can get the table dependent values by means of the static method `control.datamodel.Datamodel.specialize`. All field access should be guarded by the authorisation rules. Parameters ---------- table: string Name of the table for which we must specialize the field information kwargs: dict Field configuration arguments. It certain parts of the field configuration are not present, defaults will be provided. """ self.Settings = Settings self.Messages = Messages Messages.debugAdd(self) self.Mongo = Mongo self.Datamodel = Datamodel self.key = key """The identifier of this field within the app. """ self.table = table """The table for which this field is specialized. """ self.nameSpace = "" """The first key to access the field data in a record. Example `dc` (Dublin Core). So if a record has Dublin Core metadata, we expect that metadata to exist under key `dc` in that record. If the nameSpace is `""`, it is assumed that we can dig up the values without going into a nameSpace sub-record first. **NB.: This attribute is not table dependent.** """ self.fieldPath = key """Compound selector in a nested dict. A string of keys, separated by `.`, which will be used to drill down into a nested dict. At the end of the path we find the selected value. This field selection is applied after the name space selection (if `nameSpace` is not the empty string). **NB.: This attribute is not table dependent.** """ self.tp = "string" """The value type of the field. Value types can be string, integer, but also date-time, and values from an other table (keyword). The value "keyword" is used if the the field works with values from another table (i.e. values from the keyword table). It is assumed that all these values are strings. If True, the value of such a field must consist of zero or more elements of a prescribed list of keywords. These lists are associated with certain metadata fields and can be managed by admins in a widget on the MyWork page. We do not enforce that the value of such a field is a member of the associated list at all times. For example, if we import projects and editions that have been made with different lists of keywords in force, we accept foreign keywords. However, users will not be able to apply foreign keywords when they edit fields. **NB.: This attribute is not table dependent.** """ self.multiple = True """Whether multiple values are allowed. **NB.: This attribute is not table dependent.** """ self.readonly = False """Whether the field can be edited manually by authorized users. If this field is True, no user can directly change the value. Instead, the system will fill in this value, dependent on the completion of certain actions. **NB.: This attribute is not table dependent.** """ self.default = None """A default value to deliver if the field has no value. **NB.: This attribute may be table dependent.** """ self.mandatory = False """Whether a value is mandatory. **NB.: This attribute may be table dependent.** """ self.caption = key """A caption that may be displayed with the field value. The caption may be a literal string with or without a placeholder `{}`. If there is no place holder, the caption will precede the content of the field. If there is a placeholder, the content will replace the place holder in the caption. **NB.: This attribute may be table dependent.** """ for arg, value in Datamodel.specialize(table, kwargs).items(): if value is not None: setattr(self, arg, value) def specialize(self, table, record): """Specializes a record to a table. Parameters ---------- table: string The table in question record: dict | AttrDict The record in question Returns ------- AttrDict The specialized record, a new copy """ return Datamodel.specialize(table, record) def logical(self, record): """Give the logical value of the field in a record. Parameters ---------- record: AttrDict The record in which the field value is stored. Returns ------- any: Whatever the value is that we find for that field. No conversion/casting to other types will be performed. If the field is not present, returns None, without warning. """ nameSpace = self.nameSpace fieldPath = self.fieldPath fields = fieldPath.split(".") dataSource = record.get(nameSpace, {}) if nameSpace else record for field in fields[0:-1]: dataSource = dataSource.get(field, None) if dataSource is None: break value = None if dataSource is None else dataSource.get(fields[-1], None) return value def resolved(self, record): default = self.default logical = self.logical(record) return default if logical is None else logical def setLogical(self, record, value): """Set the logical value of the field in a record. Parameters ---------- record: AttrDict The record in which the field value is to be stored. It will be modified in place. value: object Any value to put into the record """ nameSpace = self.nameSpace fieldPath = self.fieldPath fields = fieldPath.split(".") dataSource = record.setdefault(nameSpace, AttrDict()) if nameSpace else record for field in fields[0:-1]: dataSource = dataSource.setdefault(field, AttrDict()) dataSource[fields[-1]] = value def bare(self, record, compact=False, joined=False): """Give the bare string value of the field in a record. If the logical value of the field is None, its default will be filled in. Parameters ---------- record: AttrDict The record in which the field value is stored. compact: boolean, optional False Only relevant for datetime types: if True, omit the time, leaving only the date plus the timezone (always `Z` = UTC). joined: boolean|string, optional False Only relevant for fields with multiple values, but not of type text: If not False, it should be a string by which the values should be joined. For example, if the value is [2, 3], if joined is False the result is: `[2, 3]`, but if joined is ";" the result is `2;3`. Returns ------- string: Whatever the value is that we find for that field, converted to string. If the field is not present, returns the empty string, without warning. """ tp = self.tp multiple = self.multiple resolved = self.resolved(record) # if not multiple and type(logical) in {list, tuple}: # logical = logical[-1] if len(logical) else "" return ( "" if resolved is None else ( (resolved.split("T")[0] + "Z" if compact else resolved) if tp == "datetime" else ( str(resolved) if joined is False or tp == "text" or not multiple or type(resolved) in {str, int, bool} else joined.join(str(r) for r in resolved) ) ) ) def formatted( self, record, level=None, editable=False, outerCls="fieldouter", innerCls="fieldinner", ): """Give the formatted value of the field in a record. Optionally also puts a caption and/or an edit control. The value retrieved is (recursively) wrapped in HTML, steered by an additional argument, as in `control.html.HtmlElements.wrapValue`. be applied. If the type is 'text', multiple values will simply be concatenated with newlines in between, and no extra classes will be applied. Instead, a markdown formatter is applied to the result. For other types: If the value is an iterable, each individual value is wrapped in a span to which an (other) extra CSS class may be applied. Parameters ---------- table: string The table from which the record is taken record: string | ObjectId | AttrDict The record in which the field value is stored. level: integer, optional None The heading level in which a caption will be placed. If None, no caption will be placed. If 0, the caption will be placed in a span. editable: boolean, optional False Whether the field is editable by the current user. If so, edit controls are provided. But if the field has been declared as readonly in the field specs, no edit controls will be provided. outerCls: string optional "fieldouter" If given, an extra CSS class for the outer element that wraps the total value. Only relevant if the type is not 'text' innerCls: string optional "fieldinner" If given, an extra CSS class for the inner elements that wrap parts of the value. Only relevant if the type is not 'text' Returns ------- string: Whatever the value is that we find for that field, converted to HTML. If the field is not present, returns the empty string, without warning. """ Settings = self.Settings Mongo = self.Mongo Datamodel = self.Datamodel H = Settings.H table = self.table (recordId, record) = Mongo.get(table, record) if recordId is None: return "" tp = self.tp caption = self.caption key = self.key multiple = self.multiple readonly = self.readonly bare = self.bare(record) resolved = self.resolved(record) # if table == "site" and key == "abstract": bareRep = bare or H.i(f"no {key}") if tp == "text": readonlyContent = markdown(bareRep, tight=False) elif tp == "datetime" or tp == "date": readonlyContent = bare or H.i("never") else: readonlyContent = H.wrapValue( resolved, outerElem="span", outerAtts=dict(cls=outerCls), innerElem="span", innerAtts=dict(cls=innerCls), ) if editable and not readonly: keyRepUrl = "" if key is None else f"/{key}" saveUrl = f"/save/{table}/{recordId}{keyRepUrl}" updateButton = H.actionButton("edit_update", tip=f" for field {key}") cancelButton = H.actionButton("edit_cancel", tip=f" for field {key}") saveButton = H.actionButton("edit_save", tip=f" for field {key}") messages = H.div("", cls="editmsgs") orig = ( bare if tp == "text" else ( "§".join(resolved or []) if tp == "keyword" else writeYaml(resolved) ) ) if tp == "keyword": valueSet = ( set() if resolved is None else {resolved} if type(resolved) is str else set(resolved) ) keywords = Datamodel.getKeywords(extra={key: valueSet})[key] options = ( [] if multiple else [(f"Choose a {key} ...", "", valueSet == set())] ) + [(k, k, k in valueSet) for k in keywords] editableContent = H.select( options, multiple=multiple, cls="editcontent", saveurl=saveUrl, origValue=orig, tp=tp, ) else: editableContent = H.textarea( "", cls="editcontent", saveurl=saveUrl, origValue=orig, tp=key ) content = "".join( [ H.span(readonlyContent, cls="readonlycontent"), H.nbsp, editableContent, updateButton, saveButton, cancelButton, messages, ] ) else: content = readonlyContent if level is not None: if "{value}" in caption: theCaption = caption.format(kind=table, value=content) inCaption = True else: theCaption = caption inCaption = False if level == 0: elem = "span" cls = None else: elem = "div" cls = f"lv lv{level}" theCaption = H.elem(elem, theCaption, cls=cls) else: theCaption = "" inCaption = False fullContent = ("" if inCaption else theCaption) + ( theCaption if inCaption else content ) return H.div( fullContent, cls="editwidget" if editable and not readonly else "readonlywidget", )
Instance variables
var caption
-
A caption that may be displayed with the field value.
The caption may be a literal string with or without a placeholder
{}
.If there is no place holder, the caption will precede the content of the field.
If there is a placeholder, the content will replace the place holder in the caption.
NB.: This attribute may be table dependent.
var default
-
A default value to deliver if the field has no value.
NB.: This attribute may be table dependent.
var fieldPath
-
Compound selector in a nested dict.
A string of keys, separated by
.
, which will be used to drill down into a nested dict. At the end of the path we find the selected value.This field selection is applied after the name space selection (if
nameSpace
is not the empty string).NB.: This attribute is not table dependent.
var key
-
The identifier of this field within the app.
var mandatory
-
Whether a value is mandatory.
NB.: This attribute may be table dependent.
var multiple
-
Whether multiple values are allowed.
NB.: This attribute is not table dependent.
var nameSpace
-
The first key to access the field data in a record.
Example
dc
(Dublin Core). So if a record has Dublin Core metadata, we expect that metadata to exist under keydc
in that record.If the nameSpace is
""
, it is assumed that we can dig up the values without going into a nameSpace sub-record first.NB.: This attribute is not table dependent.
var readonly
-
Whether the field can be edited manually by authorized users.
If this field is True, no user can directly change the value. Instead, the system will fill in this value, dependent on the completion of certain actions.
NB.: This attribute is not table dependent.
var table
-
The table for which this field is specialized.
var tp
-
The value type of the field.
Value types can be string, integer, but also date-time, and values from an other table (keyword).
The value "keyword" is used if the the field works with values from another table (i.e. values from the keyword table). It is assumed that all these values are strings.
If True, the value of such a field must consist of zero or more elements of a prescribed list of keywords.
These lists are associated with certain metadata fields and can be managed by admins in a widget on the MyWork page.
We do not enforce that the value of such a field is a member of the associated list at all times. For example, if we import projects and editions that have been made with different lists of keywords in force, we accept foreign keywords. However, users will not be able to apply foreign keywords when they edit fields.
NB.: This attribute is not table dependent.
Methods
def bare(self, record, compact=False, joined=False)
-
Give the bare string value of the field in a record.
If the logical value of the field is None, its default will be filled in.
Parameters
record
:AttrDict
- The record in which the field value is stored.
compact
:boolean
, optionalFalse
- Only relevant for datetime types: if True, omit the time, leaving only
the date plus the timezone (always
Z
= UTC). joined
:boolean|string
, optionalFalse
-
Only relevant for fields with multiple values, but not of type text: If not False, it should be a string by which the values should be joined.
For example, if the value is [2, 3], if joined is False the result is:
[2, 3]
, but if joined is ";" the result is2;3
.
Returns
string:
- Whatever the value is that we find for that field, converted to string. If the field is not present, returns the empty string, without warning.
def formatted(self, record, level=None, editable=False, outerCls='fieldouter', innerCls='fieldinner')
-
Give the formatted value of the field in a record.
Optionally also puts a caption and/or an edit control.
The value retrieved is (recursively) wrapped in HTML, steered by an additional argument, as in
HtmlElements.wrapValue()
. be applied.If the type is 'text', multiple values will simply be concatenated with newlines in between, and no extra classes will be applied. Instead, a markdown formatter is applied to the result.
For other types:
If the value is an iterable, each individual value is wrapped in a span to which an (other) extra CSS class may be applied.
Parameters
table
:string
- The table from which the record is taken
record
:string | ObjectId | AttrDict
- The record in which the field value is stored.
level
:integer
, optionalNone
- The heading level in which a caption will be placed. If None, no caption will be placed. If 0, the caption will be placed in a span.
editable
:boolean
, optionalFalse
- Whether the field is editable by the current user. If so, edit controls are provided. But if the field has been declared as readonly in the field specs, no edit controls will be provided.
outerCls
:string optional "fieldouter"
- If given, an extra CSS class for the outer element that wraps the total value. Only relevant if the type is not 'text'
innerCls
:string optional "fieldinner"
- If given, an extra CSS class for the inner elements that wrap parts of the value. Only relevant if the type is not 'text'
Returns
string:
- Whatever the value is that we find for that field, converted to HTML. If the field is not present, returns the empty string, without warning.
def logical(self, record)
-
Give the logical value of the field in a record.
Parameters
record
:AttrDict
- The record in which the field value is stored.
Returns
any:
- Whatever the value is that we find for that field. No conversion/casting to other types will be performed. If the field is not present, returns None, without warning.
def resolved(self, record)
def setLogical(self, record, value)
-
Set the logical value of the field in a record.
Parameters
record
:AttrDict
- The record in which the field value is to be stored. It will be modified in place.
value
:object
- Any value to put into the record
def specialize(self, table, record)
-
Specializes a record to a table.
Parameters
table
:string
- The table in question
record
:dict | AttrDict
- The record in question
Returns
AttrDict
- The specialized record, a new copy
class Upload (Settings, Messages, Mongo, key, **kwargs)
-
Handle upload business.
An upload is like a field of type 'file'. The name of the uploaded file is stored in a record in MongoDb. The contents of the file is stored on the file system.
A Upload object does not correspond with an individual field in a record. It represents a column, i.e. a set of fields with the same name in all records of a table.
First of all there is a method to retrieve the file name of an upload from a specific record.
Then there are methods to deliver those values, either bare or formatted, to produce widgets to upload or delete the corresponding files.
How to do this is steered by the specification of the upload by keys and values that are stored in this object.
All upload access should be guarded by the authorisation rules.
Parameters
kwargs
:dict
- Upload configuration arguments.
The following parts of the upload configuration
should be present:
table
,accept
, whilecaption
,fileName
,show
are optional.
Expand source code Browse git
class Upload: def __init__(self, Settings, Messages, Mongo, key, **kwargs): """Handle upload business. An upload is like a field of type 'file'. The name of the uploaded file is stored in a record in MongoDb. The contents of the file is stored on the file system. A Upload object does not correspond with an individual field in a record. It represents a *column*, i.e. a set of fields with the same name in all records of a table. First of all there is a method to retrieve the file name of an upload from a specific record. Then there are methods to deliver those values, either bare or formatted, to produce widgets to upload or delete the corresponding files. How to do this is steered by the specification of the upload by keys and values that are stored in this object. All upload access should be guarded by the authorisation rules. Parameters ---------- kwargs: dict Upload configuration arguments. The following parts of the upload configuration should be present: `table`, `accept`, while `caption`, `fileName`, `show` are optional. """ self.Settings = Settings self.Messages = Messages Messages.debugAdd(self) self.Mongo = Mongo self.key = key """The identifier of this upload within the app. """ self.table = kwargs.get("table", None) """Indicates the directory where the actual file will be saved. Possibe values: * `site`: top level of the working data directory of the site * `project`: project directory of the project in question * `edition`: edition directory of the project in question """ self.accept = kwargs.get("accept", None) """The file types that the field accepts. """ self.caption = kwargs.get("caption", f"{self.table} ({self.accept})") """The text to display on the upload button. """ self.multiple = kwargs.get("multiple", False) """Whether multiple files of this type may be uploaded. """ self.fileName = kwargs.get("fileName", None) """The name of the file once it is uploaded. The file name for the upload can be passed when the file name is known in advance. In that case, a file that is uploaded in this upload widget, will get this as prescribed file name, regardless of the file name in the upload request. Without a file name, the upload widget will show all existing files conforming to the `accept` setting, and will have a control to upload a new file. """ self.show = kwargs.get("show", False) """Whether to show the contents of the file. This is typically the case when the file is an image to be presented as a logo. """ # let attributes be filled in from the function **kwargs for arg, value in kwargs.items(): if value is not None: setattr(self, arg, value) # try to fill in defaults for attributes that are still None good = True for arg in ("table", "accept"): if getattr(self, arg, None) is None: Messages.error(logmsg=f"Missing info in Upload spec: {arg}") good = False if not good: quit() def getDir(self, record): """Give the path to the file in question. The path can be used to build the static url and the save url. It does not contain the file name. If the path is non-empty, a "/" will be appended. Parameters ---------- record: string | ObjectId | AttrDict The record relevant to the upload """ table = self.table recordId = record._id projectId = ( recordId if table == "project" else record.projectId if table == "edition" else None ) editionId = recordId if table == "edition" else None path = ( "" if table == "site" else ( f"project/{projectId}" if table == "project" else ( f"project/{projectId}/edition/{editionId}" if table == "edition" else None ) ) ) sep = "/" if path else "" return f"{path}{sep}" def formatted(self, record, mayChange=False, bust=None, wrapped=True): """Give the formatted value of a file field in a record. Optionally also puts an upload control. Parameters ---------- record: string | ObjectId | AttrDict The record relevant to the upload mayChange: boolean, optional False Whether the file may be changed. If so, an upload widget is supplied, wich contains a a delete button. bust: string, optional None If not None, the image url of the file whose name is passed in `bust` is made unique by adding the current time to it. This is a cache buster. wrapped: boolean, optional True Whether the content should be wrapped in a container element. See `control.html.HtmlElements.finput()`. Returns ------- string The name of the uploaded file(s) and/or an upload control. """ Settings = self.Settings H = Settings.H workingDir = Settings.workingDir key = self.key fileName = self.fileName accept = self.accept caption = self.caption show = self.show recordId = record._id fileNameRep = "-" if fileName is None else fileName fid = f"{recordId}/{key}/{fileNameRep}" path = self.getDir(record) sep = "/" if path else "" fullDir = f"{workingDir}{sep}{path}" saveUrl = f"/upload/{fid}{sep}{path}" deleteUrl = f"/deletefile/{fid}{sep}{path}".rstrip("/") + "/" if fileName is None: content = [] for fileNm in listFilesAccepted(fullDir, accept, withExt=True): buster = ( f"?v={pseudoisonow()}" if show and bust is not None and bust == fileNm else "" ) item = [fileNm, f"/data/{path}{fileNm}{buster}" if show else None] content.append(item) else: buster = ( f"?v={pseudoisonow()}" if show and bust is not None and bust == fileName else "" ) fullPath = (f"{workingDir}{sep}{path}").rstrip("/") + f"/{fileName}" exists = fileExists(fullPath) content = ( fileName, exists, f"/data/{path}{fileName}{buster}" if show else None, ) return H.finput( content, accept, mayChange, saveUrl, deleteUrl, caption, wrapped=wrapped, buttonCls="button small", cls=f"{key.lower()}", )
Instance variables
var accept
-
The file types that the field accepts.
var caption
-
The text to display on the upload button.
var fileName
-
The name of the file once it is uploaded.
The file name for the upload can be passed when the file name is known in advance. In that case, a file that is uploaded in this upload widget, will get this as prescribed file name, regardless of the file name in the upload request.
Without a file name, the upload widget will show all existing files conforming to the
accept
setting, and will have a control to upload a new file. var key
-
The identifier of this upload within the app.
var multiple
-
Whether multiple files of this type may be uploaded.
var show
-
Whether to show the contents of the file.
This is typically the case when the file is an image to be presented as a logo.
var table
-
Indicates the directory where the actual file will be saved.
Possibe values:
site
: top level of the working data directory of the siteproject
: project directory of the project in questionedition
: edition directory of the project in question
Methods
def formatted(self, record, mayChange=False, bust=None, wrapped=True)
-
Give the formatted value of a file field in a record.
Optionally also puts an upload control.
Parameters
record
:string | ObjectId | AttrDict
- The record relevant to the upload
mayChange
:boolean
, optionalFalse
- Whether the file may be changed. If so, an upload widget is supplied, wich contains a a delete button.
bust
:string
, optionalNone
- If not None, the image url of the file whose name is passed in
bust
is made unique by adding the current time to it. This is a cache buster. wrapped
:boolean
, optionalTrue
- Whether the content should be wrapped in a container element.
See
HtmlElements.finput()
.
Returns
string
- The name of the uploaded file(s) and/or an upload control.
def getDir(self, record)
-
Give the path to the file in question.
The path can be used to build the static url and the save url.
It does not contain the file name. If the path is non-empty, a "/" will be appended.
Parameters
record
:string | ObjectId | AttrDict
- The record relevant to the upload