Module control.content
Expand source code Browse git
import os
import json
from io import BytesIO
from tempfile import mkdtemp
from zipfile import ZipFile, ZIP_DEFLATED, ZIP_STORED
from traceback import format_exception
import yaml
import magic
from flask import jsonify
from .generic import AttrDict, isonow, plainify
from .files import (
fileExists,
fileRemove,
dirExists,
dirMake,
dirCopy,
dirRemove,
extNm,
readYaml,
fSize,
dirNm,
initTree,
FDEL,
)
from .datamodel import Datamodel
from .flask import requestData, getReferrer, redirectStatus, stream
from .admin import Admin
from .checkgltf import check
class Content(Datamodel):
def __init__(self, Settings, Messages, Viewers, Mongo, Wrap):
"""Retrieving content from database and file system.
This class has methods to retrieve various pieces of content
from the data sources, and hand it over to the `control.pages.Pages`
class that will compose a response out of it.
It is instantiated by a singleton object.
Parameters
----------
Settings: AttrDict
App-wide configuration data obtained from
`control.config.Config.Settings`.
Viewers: object
Singleton instance of `control.viewers.Viewers`.
Messages: object
Singleton instance of `control.messages.Messages`.
Mongo: object
Singleton instance of `control.mongo.Mongo`.
Wrap: object
Singleton instance of `control.wrap.Wrap`.
"""
super().__init__(Settings, Messages, Mongo)
self.Viewers = Viewers
self.Wrap = Wrap
def addAuth(self, Auth):
"""Give this object a handle to the Auth object.
Because of cyclic dependencies some objects require to be given
a handle to Auth after their initialization.
"""
self.Auth = Auth
def addPublish(self, Publish):
"""Give this object a handle to the Publish object.
Because of cyclic dependencies some objects require to be given
a handle to Publish after their initialization.
"""
self.Publish = Publish
def getProjects(self):
"""Get the list of all projects.
Well, the list of all projects visible to the current user.
Unpublished projects are only visible to users that belong to that project.
Visible projects are each displayed by means of an icon and a title.
Both link to a landing page for the project.
Returns
-------
string
A list of captions of the projects,
wrapped in a HTML string.
"""
Mongo = self.Mongo
Wrap = self.Wrap
(siteTable, siteId, site) = self.relevant()
if siteId is None:
return ""
return Wrap.projectsMain(site, Mongo.getList("project", {}, sort="title"))
def getEditions(self, project):
"""Get the list of the editions of a project.
Well, only if the project is visible to the current user.
See `Content.getProjects()`.
Editions are each displayed by means of an icon and a title.
Both link to a landing page for the edition.
Parameters
----------
project: string | ObjectId | AttrDict
The project in question.
Returns
-------
string
A list of captions of the editions of the project,
wrapped in a HTML string.
"""
Mongo = self.Mongo
Wrap = self.Wrap
(projectId, project) = Mongo.get("project", project)
if projectId is None:
return ""
return Wrap.editionsMain(
project,
Mongo.getList("edition", dict(projectId=projectId), sort="title"),
)
def getScene(self, projectId, edition, version=None, action=None):
"""Get the scene of an edition of a project.
Well, only if the current user is authorised.
A scene is displayed by means of an icon and a row of buttons.
There are also buttons to upload model files and the scene file.
If action is not None, the scene is loaded in a specific version of the
viewer in a specific mode (`read` or `edit`).
The edition knows which viewer to choose.
Which version and which mode are used is determined by the parameters.
If the parameters do not specify values, sensible defaults are chosen.
Parameters
----------
projectId: ObjectId
The id of the project to which the edition belongs.
edition: string | ObjectId | AttrDict
The edition in question.
version: string, optional None
The version of the chosen viewer that will be used.
If no version or a non-existing version are specified,
the latest existing version for that viewer will be chosen.
action: string, optional read
The mode in which the viewer should be opened.
If the mode is `update`, the viewer is opened in edit mode, if the
scene file exists, otherwise in create mode, which, in case
of the Voyager viewer, means `dragdrop` mode, in older versions
`standalone`.
All other modes lead to the viewer being opened in read-only
mode.
If the mode is read-only, but the scene file is missing, no viewer
will be opened.
Returns
-------
string
A caption of the scene of the edition,
with possibly a frame with the 3D viewer showing the scene.
The result is wrapped in a HTML string.
"""
Settings = self.Settings
H = Settings.H
workingDir = Settings.workingDir
modelzFile = Settings.modelzFile
Mongo = self.Mongo
Viewers = self.Viewers
Wrap = self.Wrap
(editionId, edition) = Mongo.get("edition", edition)
if editionId is None:
return ""
(viewer, sceneFile) = Viewers.getViewInfo(edition)
version = Viewers.check(viewer, version)
if sceneFile is None:
sceneExists = False
baseResult = ""
else:
scenePath = (
f"{workingDir}/project/{projectId}/edition/{editionId}/{sceneFile}"
)
sceneExists = fileExists(scenePath)
baseResult = Wrap.sceneMain(
projectId, edition, sceneFile, viewer, version, action, sceneExists
)
if action is None:
action = "read"
zipUpload = (
""
if sceneExists or sceneFile is None
else (
H.h(4, "Scene plus model files, zipped")
+ H.div(self.getUpload(edition, "modelz", fileName=modelzFile))
)
)
sceneUpload = (
""
if sceneFile is None
else H.div(self.getUpload(edition, "scene", fileName=sceneFile))
)
return (
baseResult
+ H.h(4, "Scene" if sceneExists else "No scene yet")
+ sceneUpload
+ H.h(4, "Model files")
+ H.div(self.getUpload(edition, "model"))
+ zipUpload
)
def getAdmin(self):
"""Get the controls on relevant projects, editions and users.
Admin users get the list of all users.
Normal users get the list of users associated with
* the project of which they are organiser
* the editions of which they are editor or reviewer
Guests and not-logged-in users cannot see any user.
If the user has rights to modify the association
between users and projects/editions, he will get
the controls to do so.
Returns
-------
string
"""
Settings = self.Settings
H = Settings.H
(adminContent, adminToc) = Admin(self).wrap()
return (
adminContent,
H.div(
[
H.div(
H.a(
"Manual for admins",
"https://github.com/CLARIAH/pure3dx"
"/blob/main/docs/manual-admin.md",
target=H.blank,
)
),
H.div(adminToc),
],
),
)
def createProject(self, site):
"""Creates a new project.
Parameters
----------
site: AttrDict | string
record that represents the site, or its id.
It acts as a master record for all projects.
Returns
-------
ObjectId
The id of the new project.
"""
Settings = self.Settings
Messages = self.Messages
Mongo = self.Mongo
Auth = self.Auth
workingDir = Settings.workingDir
(siteId, site) = Mongo.get("site", site)
if siteId is None:
return None
permitted = Auth.authorise("site", site, action="create", insertTable="project")
if not permitted:
return None
User = Auth.myDetails()
user = User.user
name = User.nickname
title = "Project without title"
dcMeta = dict(
title=title,
creator=name,
dateCreated=isonow(),
)
projectId = Mongo.insertRecord(
"project", dict(title=title, siteId=siteId, dc=dcMeta, isVisible=False)
)
Mongo.insertRecord(
"projectUser", dict(projectId=projectId, user=user, role="organiser")
)
projectDir = f"{workingDir}/project/{projectId}"
if dirExists(projectDir):
Messages.warning(
msg="The new project already exists on the file system",
logmsg=f"New project {projectId} already exists on the filesystem.",
)
else:
dirMake(projectDir)
return projectId
def deleteItem(self, table, record):
"""Deletes an item, project or edition.
Deletion means: mark as deleted.
A record is marked as deleted if it has a field `markedDeleted: true` in it.
Parameters
----------
table: string
The kind of item: `project` or `edition`.
record: string | ObjectId | AttrDict
The item in question.
Returns
-------
boolean
Whether the deletion was successful.
"""
Messages = self.Messages
Mongo = self.Mongo
Auth = self.Auth
User = Auth.myDetails()
name = User.nickname
(recordId, record) = Mongo.get(table, record)
projectRep = f"{record.projectId}/" if table == "edition" else ""
itemRep = f"{table} {projectRep}{recordId}"
head = f"DELETE (on behalf of {name}) {itemRep}: "
if recordId is None:
Messages.warning(
msg=f"Delete {table}: no such {table}",
logmsg=f"{head}Not found",
)
return None
permitted = Auth.authorise(table, record, action="delete")
if not permitted:
Messages.error(
msg=f"Delete {table}: no such {table}",
logmsg=f"{head}Not permitted",
)
return None
details = self.getDetailRecords(table, record)
nDetails = len(details)
if nDetails:
plural = "" if nDetails == 1 else "s"
Messages.warning(
msg=f"Cannot delete {table} because it has "
f"{nDetails} detail record{plural}",
logmsg=f"{head}Prevented because of {nDetails} detail record{plural}",
)
return None
good = True
links = self.getLinkedCrit(table, record)
if links:
for linkTable, linkCriteria in links.items():
(thisGood, count) = Mongo.deleteRecords(linkTable, linkCriteria, name)
if not thisGood:
good = False
Messages.error(
msg=f"Error during removing link records from {linkTable}",
logmsg=(
"{head}Cannot delete link records from "
f"{linkTable} with {linkCriteria}"
),
)
break
plural = "" if count == 1 else "s"
Messages.info(
logmsg=(
f"{head}Deleted {count} link record{plural} from {linkTable}"
)
)
if not good:
return False
good = self.deleteItemFiles(table, record, recordId, name)
if good:
Messages.info(logmsg=f"{head}deleted the {table} record")
else:
Messages.error(logmsg=f"{head}could not delete this {table} record")
return False
good = Mongo.deleteRecord(table, dict(_id=recordId), name)
if good:
Messages.info(
msg=f"Deleted {table}",
logmsg=f"{head}Success",
)
else:
Messages.error(
msg=f"Failed to delete {table}",
logmsg=f"{head}Failed",
)
return good
def deleteItemFiles(self, table, record, recordId, name):
"""Deletes the files that correspond to a project or edition.
Deletion means: mark as deleted.
A directory is marked as deleted if it has a file `__deleted__.txt` in it.
Parameters
----------
table: string
The kind of item: `project` or `edition`.
record: AttrDict
The item record in question.
recordId: AttrDict
The the id of the item record in question.
name: string
The user name of the user that triggered the delete action.
Returns
-------
boolean
Whether the deletion was successful.
"""
Messages = self.Messages
Settings = self.Settings
workingDir = Settings.workingDir
if table == "project":
itemDirTail = f"{table}/{recordId}"
elif table == "edition":
projectId = record.projectId
itemDirTail = f"/project/{projectId}/{table}/{recordId}"
itemDir = f"{workingDir}/{itemDirTail}"
head = f"DELETE DIRECTORY (on behalf of {name}) {itemDirTail}: "
if dirExists(itemDir):
Messages.info(logmsg=f"{head}by adding {FDEL}")
with open(f"{itemDir}/{FDEL}", "w") as fh:
fh.write("deleted\n")
else:
Messages.info(logmsg=f"{head}no such directory")
return True
def createEdition(self, project):
"""Creates a new edition.
Parameters
----------
project: AttrDict | string
record that represents the maste project, or its id.
Returns
-------
ObjectId
The id of the new edition.
"""
Mongo = self.Mongo
Messages = self.Messages
Auth = self.Auth
Settings = self.Settings
workingDir = Settings.workingDir
def fillin(template, values):
typ = type(template)
if typ is str:
for k, v in values.items():
template = template.replace(f"«{k}»", v)
return template
if typ in {list, tuple}:
return [fillin(e, values) for e in template]
if typ in {dict, AttrDict}:
return {k: fillin(v, values) for (k, v) in template.items()}
return template
editionSettingsTemplate = Settings.editionSettingsTemplate
viewerDefault = Settings.viewerDefault
viewerInfo = Settings.viewers[viewerDefault] or AttrDict()
versionDefault = viewerInfo.defaultVersion
sceneFile = viewerInfo.sceneFile
values = dict(viewer=viewerDefault, version=versionDefault, scene=sceneFile)
editionSettings = fillin(editionSettingsTemplate, values)
(projectId, project) = Mongo.get("project", project)
if projectId is None:
return None
permitted = Auth.authorise(
"project", project, action="create", insertTable="edition"
)
if not permitted:
return None
User = Auth.myDetails()
user = User.user
name = User.nickname
title = "Edition without title"
dcMeta = dict(
title=title,
creator=name,
dateCreated=isonow(),
)
editionId = Mongo.insertRecord(
"edition",
dict(
title=title,
projectId=projectId,
dc=dcMeta,
settings=editionSettings,
isPublished=False,
),
)
Mongo.insertRecord(
"editionUser", dict(editionId=editionId, user=user, role="editor")
)
editionDir = f"{workingDir}/project/{projectId}/edition/{editionId}"
if dirExists(editionDir):
Messages.warning(
msg="The new edition already exists on the file system",
logmsg=f"New edition {editionId} already exists on the filesystem.",
)
else:
dirMake(editionDir)
return editionId
def saveValue(self, table, record, key):
"""Saves a value of into a record.
A record is a document, which is a (nested) dict.
A value is inserted somewhere (deep) in that dict.
The value is given by the request.
Where exactly is given by a path that is stored in the field information,
which is accessible by the key.
If we save a value to an edition record, and the record has not yet a
date created, we fill in today for the date created.
Parameters
----------
table: string
The relevant table.
record: string | ObjectId | AttrDict | void
The relevant record.
key: string
an identifier for the meta data field.
Returns
-------
dict
Contains the following keys:
* `status`: whether the save action was successful
* `messages`: messages issued during the process
* `readonly`: the html of the updated formatted value,
this will replace the currently displayed value.
"""
Auth = self.Auth
Mongo = self.Mongo
fieldPaths = self.fieldPaths
value = json.loads(requestData())
F = self.makeField(key, table)
readonly = F.readonly
nameSpace = F.nameSpace
permitted = Auth.authorise(table, record, nameSpace=nameSpace, action="update")
if not permitted:
return dict(stat=False, messages=[["error", "update not allowed"]])
if readonly:
return dict(stat=False, messages=[["error", f"{key} is not updatable"]])
tp = F.tp
multiple = F.multiple
fieldPath = fieldPaths[key]
(recordId, record) = Mongo.get(table, record)
if recordId is None:
return dict(
stat=False,
messages=[["error", "record does not exist"]],
)
sValue = (
value
if tp == "text" or tp == "string" and not multiple
else (
value
if tp == "keyword"
else plainify(readYaml(value, plain=True, ignore=True))
)
)
update = {fieldPath: sValue}
if key == "title":
update[key] = sValue
if Mongo.updateRecord(table, dict(_id=recordId), update) is None:
return dict(
stat=False,
messages=[["error", "could not update the record in the database"]],
)
else:
(recordId, record) = Mongo.get(table, recordId)
now = isonow()
dateCreatedPath = fieldPaths["dateCreated"]
dateModifiedPath = fieldPaths["dateModified"]
if not self.getValue(table, record, "dateCreated", manner="logical"):
Mongo.updateRecord(table, dict(_id=recordId), {dateCreatedPath: now})
Mongo.updateRecord(table, dict(_id=recordId), {dateModifiedPath: now})
return dict(
stat=True,
messages=[],
readonly=F.formatted(record, editable=False, level=None),
)
def saveRole(self, user, table, recordId):
"""Saves a role into a user or cross table record.
The role is given by the request.
Parameters
----------
user: string
The eppn of the user.
table: string | void
The relevant table. If not None, it indicates whether we are updating
site-wide roles, otherwise project/edition roles.
recordId: string | void
The id of the relevant record. If not None, it is a project/edition
record Id, which can be used to locate the cross record between the
user table and the project/edition record where the user's
role is stored.
If None, the user's role is inside the user record.
Returns
-------
dict
Contains the following keys:
* `status`: whether the save action was successful
* `messages`: messages issued during the process
* `updated`: if the action was successful, all user management info
will be passed back and will replace the currently displayed
material.
"""
newRole = json.loads(requestData())
return Admin(self).saveRole(user, newRole, table, recordId)
def createUser(self, user):
"""Creates a new user with a given user name.
Parameters
----------
user: string
The user name of the user.
This should be different from the user names of existing users.
Returns
-------
dict
Contains the following keys:
* `status`: whether the create action was successful
* `messages`: messages issued during the process
* `name`: the name under which the new user has been saved
"""
return Admin(self).createUser(user)
def deleteUser(self, user):
"""Deletes a test user with a given user name.
Parameters
----------
user: string
The user name of the user.
This should be a test user, not linked to any project or edition.
Returns
-------
dict
Contains the following keys:
* `status`: whether the create action was successful
* `messages`: messages issued during the process
"""
return Admin(self).deleteUser(user)
def linkUser(self, table, recordId):
"""Links a user in certain role to a project/edition record.
The user and role are given by the request.
Parameters
----------
table: string
The relevant table.
recordId: string
The id of the relevant record,
which can be used to locate the cross record between the
user table and the project/edition record where the user's
role is stored.
Returns
-------
dict
Containsthe following keys:
* `status`: whether the save action was successful
* `messages`: messages issued during the process
* `updated`: if the action was successful, all user management info
will be passed back and will replace the currently displayed
material.
"""
(newRole, newUser) = json.loads(requestData())
return Admin(self).linkUser(newUser, newRole, table, recordId)
def getValue(self, table, record, key, level=None, manner="formatted"):
"""Retrieve a metadata value.
Metadata sits in a big, potentially deeply nested dictionary of keys
and values.
These locations are known to the system (based on `fields.yml`).
This function retrieves the information from those known locations.
If a value is in fact composed of multiple values, it will be
handled accordingly.
If the user may edit the value, an edit button is added.
Parameters
----------
key: string
an identifier for the meta data field.
table: string
The relevant table.
record: string | ObjectId | AttrDict | void
The relevant record.
level: string, optional None
The heading level with which the value should be formatted.
* `0`: No heading level
* `None`: no formatting at all
manner: string, optional wrapped
If it is "formatted", the value is represented fully wrapped in HTML,
possibly with edit/save controls.
If it is "bare", the value is represented as a simple string.
If it is "barex", the value is represented as a compact simple string.
If it is "logical", the logical value is returned.
Returns
-------
string
It is assumed that the metadata value that is addressed exists.
If not, we return the empty string.
"""
Auth = self.Auth
actions = Auth.authorise(table, record)
if "read" not in actions:
return None
F = self.makeField(key, table)
nameSpace = F.nameSpace
isBare = manner in {"bare", "barex"}
compact = manner == "barex"
isLogical = manner == "logical"
if isBare or isLogical:
return F.bare(record, compact=compact) if isBare else F.logical(record)
editable = Auth.authorise(table, record, nameSpace=nameSpace, action="update")
return F.formatted(record, editable=editable, level=level)
def getValues(self, table, record, fieldSpecs):
"""Puts several pieces of metadata on the web page.
Parameters
----------
fieldSpecs: string
`,`-separated list of fieldSpecs
table: string
The relevant table
record: string | ObjectId | AttrDict | void
The relevant record
Returns
-------
string
The join of the individual results of retrieving metadata value.
"""
Settings = self.Settings
H = Settings.H
return H.content(
self.getValue(table, record, key, level=level) or ""
for (key, level) in (
fieldSpec.strip().split("@", 1)
for fieldSpec in fieldSpecs.split("+")
if fieldSpec
)
)
def getUpload(self, record, key, fileName=None, bust=None, wrapped=True):
"""Display the name and/or upload controls of an uploaded file.
The user may upload model files and a scene file to an edition,
and various png files as icons for projects, edtions, and scenes.
Here we produce the control to do so.
Only if the user has `update` authorisation, an upload/delete widget
will be returned.
Parameters
----------
record: string | ObjectId | AttrDict | void
The relevant record.
key: string
an identifier for the upload field.
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.
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. That will
bust the cache for the image, so that uploaded images replace the
existing images.
This is useful when this function is called to provide udated
content for an file upload widget after it has been used to
successfully upload a file. The file name of the uploaded
file is known, and that is the one that gets a cache buster appended.
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 file that is currently present, or the indication
that no file is present.
If the user has edit permission for the edition, we display
widgets to upload a new file or to delete the existing file.
"""
Auth = self.Auth
uploadConfig = self.getUploadConfig(key)
table = uploadConfig.table
actions = Auth.authorise(table, record)
if "read" not in actions:
return None
F = self.makeUpload(key, fileName=fileName)
return F.formatted(record, "update" in actions, bust=bust, wrapped=wrapped)
def getDownload(self, table, record):
"""Display the name and/or upload controls of an uploaded file.
The user may upload model files and a scene file to an edition,
and various png files as icons for projects, edtions, and scenes.
Here we produce the control to do so.
Only if the user has `update` authorisation, an upload/delete widget
will be returned.
Parameters
----------
table: string
The table in which the relevant record sits
record: string | ObjectId | AttrDict
The relevant record.
Returns
-------
string
The name of the file that is currently present, or the indication
that no file is present.
If the user has edit permission for the edition, we display
widgets to upload a new file or to delete the existing file.
"""
Settings = self.Settings
H = Settings.H
Mongo = self.Mongo
Auth = self.Auth
(recordId, record) = Mongo.get(table, record)
if recordId is None:
return ""
actions = Auth.authorise(table, record)
if "read" not in actions:
return ""
return H.p(
H.iconx(
"download",
text="Download",
href=f"/download/{table}/{recordId}",
cls="button small",
)
)
def getPublishInfo(self, table, record):
"""Display the number under which a project/edition is published.
Editions of a project may have been published. If that is the case,
the project has been assigned a sequence number, under which it can be
found on the static site with published material.
Here we collect that number, and, for editions, we may put a publish
button here.
Parameters
----------
table: string
The table in which the relevant record sits
record: string | ObjectId | AttrDict
The relevant record.
Returns
-------
string
In case of a project: the number of the project on the static site.
In case of an edition: the number of the project and the number of the
edition on the static site. If the edition is not yet published, and
the user is allowed to publish the edition, then a publish button is
also added.
"""
Settings = self.Settings
H = Settings.H
Mongo = self.Mongo
Auth = self.Auth
pubUrl = Settings.pubUrl
published = Settings.published
(recordId, record) = Mongo.get(table, record)
if recordId is None:
return ""
actions = Auth.authorise(table, record)
if "read" not in actions or table not in {"project", "edition"}:
return ""
(site, siteId, projectId, project, editionId, edition) = self.context(
table, record
)
pPubNum = project.pubNum
projectPubStr = (
H.i("No published editions")
if pPubNum is None
else H.span("Published: ")
+ H.a(
f"{pPubNum} ⌲",
f"{pubUrl}/project/{pPubNum}/index.html",
target=published,
cls="button large",
)
)
if table == "project":
return H.p(projectPubStr)
ePubNum = edition.pubNum
editionIsPublished = edition.isPublished
editionPubRow = (
(
H.p(H.i("Published:") + H.nbsp),
H.p(
H.a(
f"{pPubNum}/{ePubNum} ⌲",
f"{pubUrl}/project/{pPubNum}/edition/{ePubNum}/index.html",
target=published,
cls="button large",
)
),
H.p(self.getValue("edition", edition, "datePublished", manner="barex")),
)
if editionIsPublished
else (
(
H.p(H.i("Unpublished") + H.nbsp),
H.p(dateUnPublished),
H.p(
H.i("last published")
+ H.nbsp
+ H.span(
self.getValue(
"edition", edition, "datePublished", manner="barex"
)
)
),
)
if (
dateUnPublished := self.getValue(
"edition", edition, "dateUnPublished", manner="barex"
)
)
else (
H.p(H.i("Never published") + H.nbsp),
"",
"",
)
)
)
can = dict(
precheck=True,
publish=not editionIsPublished,
unpublish=editionIsPublished,
republish=editionIsPublished,
)
rows = []
for kind, kindRep, tbRecRoles in (
(
"precheck",
"check (meta)data",
(
("edition", edition, "editor"),
("project", project, "organiser"),
("site", site, "admin"),
("site", site, "root"),
),
),
("publish", "publish", (("project", project, "organiser"),)),
(
("publishf", "publish"),
"force-publish",
(("project", project, "organiser"),),
),
(
"republish",
"re-publish",
(("site", site, "admin"), ("site", site, "root")),
),
(
("republishf", "republish"),
"force-re-publish",
(("site", site, "admin"), ("site", site, "root")),
),
(
"unpublish",
"un-publish",
(("site", site, "admin"), ("site", site, "root")),
),
):
asKind = kind
if type(kind) is tuple:
(kind, asKind) = kind
if can[asKind]:
rows.append(
(
H.p("You may"),
H.p(
H.a(
kindRep, href=f"/{kind}/{recordId}", cls="button medium"
)
),
)
if asKind in actions
else (
H.p("You may not"),
H.p(H.i(kindRep)),
H.p(Auth.getInvolvedUsers(tbRecRoles, asString=True)),
)
)
return H.table(
[([(cell, {}) for cell in editionPubRow], {})],
[([(cell, {}) for cell in row], {}) for row in rows],
)
def getViewerFile(self, path):
"""Gets a viewer-related file from the file system.
This is about files that are part of the viewer software.
The viewer software is located in a specific directory on the server.
This is the viewer base.
Parameters
----------
path: string
The path of the viewer file within viewer base.
Returns
-------
string
The full path to the viewer file, if it exists.
Otherwise, we raise an error that will lead to a 404 response.
"""
Settings = self.Settings
Messages = self.Messages
viewerDir = Settings.viewerDir
viewerPath = f"{viewerDir}/{path}"
if not fileExists(viewerPath):
logmsg = f"Accessing {viewerPath}: "
logmsg += "does not exist. "
Messages.error(
msg="Accessing a file",
logmsg=logmsg,
)
return viewerPath
def getDataFile(self, table, record, path, content=False, lenient=False):
"""Gets a data file from the file system.
All data files are located under a specific directory on the server.
This is the data directory.
Below that the files are organized by projects and editions.
Projects and editions corresponds to records in tables in MongoDB.
Parameters
----------
path: string
The path of the data file within site/project/edition directory
within the data directory.
project: string | ObjectId | AttrDict
The id of the project in question.
edition: string | ObjectId | AttrDict
The id of the edition in question.
content: boolean, optional False
If True, delivers the content of the file, instead of the path
lenient: boolean, optional False
If True, do not complain if the file does not exist.
Returns
-------
string
The full path of the data file, if it exists.
But if the `content` parameter is True, we deliver the content of the file.
Otherwise, we raise an error that will lead to a 404 response, except
when `lenient` is True.
"""
Settings = self.Settings
H = Settings.H
Messages = self.Messages
Auth = self.Auth
workingDir = Settings.workingDir
(site, siteId, projectId, project, editionId, edition) = self.context(
table, record
)
urlBase = (
""
if project is None
else (
f"project/{projectId}"
if edition is None
else f"project/{projectId}/edition/{editionId}"
)
)
sep = "/" if urlBase else ""
base = f"{workingDir}{sep}{urlBase}"
dataPath = base if path is None else f"{base}/{path}"
(table, recordId, record) = self.relevant(project=project, edition=edition)
if recordId is None:
Messages.error(msg="record does not exist")
return ""
permitted = Auth.authorise(table, record, action="read")
fexists = fileExists(dataPath)
if permitted and fexists:
if content:
with open(dataPath) as fh:
result = fh.read()
else:
result = dataPath
else:
result = (
""
if content
else dataPath if lenient else H.span(dataPath, cls="error")
)
if not lenient:
logmsg = f"Accessing {dataPath}: "
if not permitted:
logmsg += "not allowed. "
if not fexists:
logmsg += "does not exist. "
Messages.error(
msg=f"Accessing file {path}",
logmsg=logmsg,
)
result = "" if content else dataPath
return result
def breadCrumb(self, project):
"""Makes a link to the landing page of a project.
Parameters
----------
project: string | ObjectId | AttrDict
The project in question.
"""
Settings = self.Settings
H = Settings.H
Mongo = self.Mongo
(projectId, project) = Mongo.get("project", project)
if not project:
return ""
projectUrl = f"/project/{projectId}"
text = self.getValue("project", project, "title", manner="bare")
if not text:
text = H.i("no title")
return H.p(
[
"Project: ",
H.a(
text,
projectUrl,
cls="button medium",
title="back to the project page",
),
]
)
def precheck(self, record):
"""Check the articles of an edition prior to publishing.
Parameters
----------
record: string
The record of the edition to be checked.
Return
------
response
A status response.
It will also generate a a bunch of toc files in the edition.
"""
Messages = self.Messages
Mongo = self.Mongo
Auth = self.Auth
Publish = self.Publish
(recordId, record) = Mongo.get("edition", record)
if recordId is None:
Messages.error(
msg="record does not exist", logmsg=f"edition {recordId} does not exist"
)
return False
permitted = Auth.authorise("edition", record, action="precheck")
if not permitted:
logmsg = f"Checking articles not permitted: edition: {recordId}"
msg = "Checking articles of edition not permitted"
Messages.warning(msg=msg, logmsg=logmsg)
return False
(siteId, site, projectId, project, editionId, edition) = self.context(
"edition", record
)
return Publish.Precheck.checkEdition(site, project, editionId, edition)
def publish(self, record, force):
"""Publish an edition.
Parameters
----------
record: string
The record of the item to be published.
force: boolean
If True, ignore when some checks fail
Return
------
response
A publish status response.
"""
Messages = self.Messages
Mongo = self.Mongo
Auth = self.Auth
Publish = self.Publish
(recordId, record) = Mongo.get("edition", record)
if recordId is None:
Messages.error(
msg="record does not exist", logmsg=f"edition {recordId} does not exist"
)
return False
permitted = Auth.authorise("edition", record, action="publish")
if not permitted:
logmsg = f"Publish not permitted: edition: {recordId}"
msg = "Publishing of edition not permitted"
Messages.warning(msg=msg, logmsg=logmsg)
return False
(siteId, site, projectId, project, editionId, edition) = self.context(
"edition", record
)
return Publish.updateEdition(site, project, edition, "add", force=force)
def republish(self, record, force):
"""Re-ublish an edition.
Parameters
----------
record: string
The record of the item to be re-published.
force: boolean
If True, ignore when some checks fail
Return
------
response
A re-publish status response.
"""
Messages = self.Messages
Mongo = self.Mongo
Auth = self.Auth
Publish = self.Publish
(recordId, record) = Mongo.get("edition", record)
if recordId is None:
Messages.error(
msg="record does not exist", logmsg=f"edition {recordId} does not exist"
)
return False
permitted = Auth.authorise("edition", record, action="republish")
if not permitted:
logmsg = f"Re-publish not permitted: edition: {recordId}"
msg = "Re-publishing of edition not permitted"
Messages.warning(msg=msg, logmsg=logmsg)
return False
(siteId, site, projectId, project, editionId, edition) = self.context(
"edition", record
)
return Publish.updateEdition(
site, project, edition, "add", force=force, again=True
)
def unpublish(self, record):
"""Unpublish an edition.
Parameters
----------
record: string
The record of the item to be unpublished.
Return
------
response
An unpublish status response.
"""
Messages = self.Messages
Mongo = self.Mongo
Auth = self.Auth
Publish = self.Publish
(recordId, record) = Mongo.get("edition", record)
if recordId is None:
Messages.error(
msg="record does not exist", logmsg=f"edition {recordId} does not exist"
)
return False
permitted = Auth.authorise("edition", record, action="unpublish")
if not permitted:
logmsg = f"Unpublish not permitted: edition: {recordId}"
msg = "Unpublishing of edition not permitted"
Messages.warning(msg=msg, logmsg=logmsg)
return False
(siteId, site, projectId, project, editionId, edition) = self.context(
"edition", record
)
return Publish.updateEdition(site, project, edition, "remove")
def generate(self):
"""Regenerate the HTML for the published site.
Not only the site wide files, but also all projects and editions.
Return
------
response
A publish status response.
"""
Messages = self.Messages
Auth = self.Auth
Publish = self.Publish
site = self.relevant()[-1]
permitted = Auth.authorise("site", site, action="republish")
if not permitted:
logmsg = "Generate pages is not permitted"
msg = "Generate pages is not permitted"
Messages.warning(msg=msg, logmsg=logmsg)
return False
return Publish.generatePages(True, True)
def download(self, table, record):
"""Responds with a download of a project or edition.
The following measures have been taken to facilitate big downloads:
1. the zipfile is now created in a temporary directory;
1. the zipfile is now streamed to the client;
1. when we add files to the zip file, we do not compress glb, mp4 files etc.
This reduces the zipping time greatly. Without this, the request would
time out after 60 seconds, and the browser would fire a new request;
Still, despite these measures, the response itself may too much time.
What we see is that for a certain download of 1.25 GB, the request is
answered after 20-30 seconds with a response (200). Then streaming starts,
but after 3 minutes, at 1.08GB the client reports that the server has
disconnected. This happens in Safari, Firefox, en curl.
So, with this issue unresolved, we try to avoid it from happening.
Before preparing the download, we examine the total uncompressed file
size. If that is more than 1 GB, we refuse to download and give a warning.
Parameters
----------
table: string
The table where the item to be downloaded sits.
record: string
The record of the item to be downloaded.
Return
------
response
A download response.
The download data will be streamed.
"""
Settings = self.Settings
Messages = self.Messages
Mongo = self.Mongo
Auth = self.Auth
tempDir = Settings.tempDir
workingDir = Settings.workingDir
User = Auth.myDetails()
name = User.nickname
dontCompress = set(Settings.dontCompress)
limits = Settings.limits
sizeUnit = limits.sizeUnit
sizeUnitSize = limits.sizeUnitSize
sizeUnits = limits.sizeUnits
# sizeUnits = limits.sizeUnitsOptimistic
sizeLimit = sizeUnits * sizeUnitSize
(recordId, record) = Mongo.get(table, record)
projectRep = f"{record.projectId}/" if table == "edition" else ""
itemRep = f"{table} {projectRep}{recordId}"
head = f"DOWNLOAD (on behalf of {name}) {itemRep}: "
if recordId is None:
Messages.error(logmsg=f"{head}record does not exist")
return jsonify(status=False, msgs=[["warning", "record does not exist"]])
permitted = Auth.authorise(table, record, action="read")
if not permitted:
logmsg = f"{head}not permitted"
msg = f"Download of {table}/{recordId} not permitted"
Messages.warning(msg=msg, logmsg=logmsg)
return jsonify(status=False, msgs=[["warning", msg]])
(siteId, site, projectId, project, editionId, edition) = self.context(
table, record
)
ref = getReferrer().removeprefix("/")
src = f"{workingDir}/project/{projectId}"
if edition is not None:
src += f"/edition/{editionId}"
def sizeOf(path):
sep = "/" if path else ""
size = 0
with os.scandir(f"{src}{sep}{path}") as dh:
for entry in dh:
name = entry.name
if entry.is_file():
srcFile = f"{src}/{path}{sep}{name}"
size += fSize(srcFile)
elif entry.is_dir():
size += sizeOf(f"{path}{sep}{name}")
return size
totalSize = sizeOf("")
if totalSize > sizeLimit:
totalUnits = int(round(totalSize / sizeUnitSize))
logmsg = (
f"{head}too big: "
f"{totalUnits} {sizeUnit} is more than {sizeUnits} {sizeUnit}"
)
msg = (
"too big: "
f"{totalUnits} {sizeUnit} is more than {sizeUnits} {sizeUnit}"
)
Messages.error(msg=msg, logmsg=logmsg)
return redirectStatus(f"/{ref}", False)
dst = mkdtemp(dir=tempDir)
Messages.info(logmsg=f"{head}CREATE TEMP DIR {dst}")
def deleteTmpDir():
dirRemove(dst)
Messages.info(logmsg=f"{head}DELETE TEMP DIR {dst}")
good = True
try:
landing = f"{dst}/{recordId}"
fileName = f"{table}-{recordId}.zip"
zipFilePath = f"{dst}/{fileName}"
if edition is None:
yamlDest = f"{landing}/project.yaml"
else:
yamlDest = f"{landing}/edition.yaml"
dirCopy(src, landing)
with open(yamlDest, "w") as yh:
yaml.dump(Mongo.consolidate(project), yh, allow_unicode=True)
if edition is None:
editions = Mongo.getList(
"edition", dict(projectId=projectId), sort="title"
)
for ed in editions:
edId = ed._id
yamlDest = f"{landing}/edition/{edId}/edition.yaml"
with open(yamlDest, "w") as yh:
yaml.dump(Mongo.consolidate(ed), yh, allow_unicode=True)
with ZipFile(zipFilePath, "w", compression=ZIP_DEFLATED) as zipFile:
def compress(path):
sep = "/" if path else ""
with os.scandir(f"{landing}{sep}{path}") as dh:
for entry in dh:
name = entry.name
if entry.is_file():
ext = extNm(name).lower()
method = (
ZIP_STORED if ext in dontCompress else ZIP_DEFLATED
)
arcFile = f"{path}{sep}{name}"
srcFile = f"{landing}/{arcFile}"
zipFile.write(srcFile, arcFile, compress_type=method)
elif entry.is_dir():
compress(f"{path}{sep}{name}")
compress("")
dirRemove(landing)
Messages.info(logmsg=f"{head}zipped the data")
except Exception as e:
msg = f"Could not zip the {table}/{recordId} data"
logmsg = f"{head}could not zip the data"
Messages.error(msg=msg, logmsg=msg)
Messages.error(logmsg="".join(format_exception(e)))
deleteTmpDir()
result = redirectStatus(f"/{ref}", False)
good = False
if good:
zipLength = fSize(zipFilePath)
headers = {
"Expires": "0",
"Cache-Control": "no-cache, no-store, must-revalidate",
"Content-Type": "application/zip",
"Content-Disposition": f'attachment; filename="{fileName}"',
# "Content-Encoding": "identity",
"Content-Length": zipLength,
}
CHUNK_SIZE = 8192
def readFileChunks():
try:
with open(zipFilePath, "rb") as fd:
while 1:
buf = fd.read(CHUNK_SIZE)
if buf:
yield buf
else:
break
except Exception as e:
msg = "Could not stream the {table}/{recordId} data"
logmsg = f"{head}could not stream the data"
Messages.error(msg=msg, logmsg=logmsg)
Messages.error(logmsg="".join(format_exception(e)))
Messages.info(logmsg=f"{head}streaming done")
deleteTmpDir()
result = (stream(readFileChunks()), headers)
return result
def saveFile(self, record, key, path, fileName, targetFileName=None):
"""Saves a file in the context given by a record.
The parameter `key` refers to a configuration section in the datamodel.
This determines what file type to expect.
We only accept files whose name has an extension that matches the expected
file type.
The key `modelz` expects a zip file with the files of an edition, in particular
a scene file and model files. We make sure that these files have the
proper type, and we also perform checks on the other parts of the zip file,
namely whether they have decent paths.
Parameters
----------
record: string | ObjectId | AttrDict | void
The relevant record.
key: string
The upload key
path: string
The path from the context directory to the file
fileName: string
Name of the file to be saved as mentioned in the request.
targetFileName: string, optional None
The name of the file as which the uploaded file will be saved;
if None, the file will be saved with the name from the request.
Return
------
response
A json response with the status of the save operation:
* a boolean: whether the save succeeded
* a list of messages to display
* content: new content for an upload control (only if successful)
"""
fileContent = requestData() # essential to have this early on in the body
# if not, the error responses might go wrong in some browsers
Settings = self.Settings
H = Settings.H
Messages = self.Messages
Mongo = self.Mongo
Auth = self.Auth
workingDir = Settings.workingDir
User = Auth.myDetails()
name = User.nickname
uploadConfig = self.getUploadConfig(key)
table = uploadConfig.table
(recordId, record) = Mongo.get(table, record)
projectRep = f"{record.projectId}/" if table == "edition" else ""
itemRep = f"{table} {projectRep}{recordId}"
head = f"UPLOAD (on behalf of {name}) {itemRep}: "
if recordId is None:
Messages.error(logmsg=f"{head}record does not exist")
return jsonify(status=False, msgs=[["warning", "record does not exist"]])
permitted = Auth.authorise(table, record, action="update")
saveName = fileName
if targetFileName is not None:
saveName = targetFileName
filePath = f"{path}{saveName}"
fileFullPath = f"{workingDir}/{filePath}"
if not permitted:
logmsg = f"{head}Upload not permitted: {key}: {fileFullPath}"
msg = f"Upload not permitted: {fileName}"
Messages.warning(logmsg=logmsg)
return jsonify(status=False, msgs=[["warning", msg]])
(good, msgs) = self.checkFileContent(key, targetFileName, fileName, fileContent)
if not good:
return jsonify(status=False, msgs=msgs)
if key == "modelz":
destDir = f"{workingDir}/{path}"
(good, msgs) = self.processModelZip(fileContent, destDir)
if good:
Messages.info(logmsg=f"{head}Success")
return jsonify(
status=True,
msgs=msgs,
content=H.b("Please refresh the page", cls="good"),
)
Messages.info(logmsg=f"{head}Failure")
return jsonify(status=False, msgs=msgs)
try:
initTree(dirNm(fileFullPath), fresh=False)
with open(fileFullPath, "wb") as fh:
fh.write(fileContent)
except Exception as e:
logmsg = f"{head}Could not save uploaded file: {key}: {fileFullPath} because of {e}"
msg = f"Uploaded file not saved: {fileName}"
Messages.warning(logmsg=logmsg)
return jsonify(status=False, msgs=[["warning", msg]])
content = self.getUpload(
record, key, fileName=targetFileName, bust=fileName, wrapped=False
)
Messages.info(f"{head}Success")
return jsonify(status=True, msgs=[["good", "Done"]], content=content)
def checkFileContent(self, key, targetFileName, fileName, fileContent):
"""Performs checks on the name and content of an uploaded file before saving it.
Parameters
----------
key: string
The key of the upload. This key determines what kind of file we expect.
If None, we do not expect a particular mime type
targetFileName: string
The prescribed name to save the file under, if None, it will be saved under
the name mentioned in the request.
fileName: string
The name of the file as mentioned in the request.
fileContent: bytes
The content of the file as bytes
Returns
-------
tuple
A boolean that tells whether the file content looks OK plus a sequences of
messages indicating what is wrong with the content.
"""
Settings = self.Settings
datamodel = Settings.datamodel
mimeTypes = datamodel.mimeTypes
uploadConfig = self.getUploadConfig(key) or AttrDict()
acceptStr = uploadConfig.accept
accept = (
None
if acceptStr is None
else {acc[1:].strip() for acc in acceptStr.split(",")}
)
good = True
msgs = []
fileExt = extNm(fileName)
if targetFileName is not None:
targetExt = extNm(targetFileName)
if targetExt != fileExt:
good = False
msgs.append(
[
"error",
(
f"the uploaded file name {fileName} has an extension "
"different from that of the target "
f"file name {targetFileName}"
),
]
)
if accept is not None and targetExt not in accept:
good = False
msgs.append(
[
"error",
(
"Programming error: the prescribed file name "
f"{targetFileName} has an extension not in {acceptStr}"
),
]
)
return (good, msgs)
fileName = targetFileName
fileExt = extNm(fileName)
if accept is not None and fileExt not in accept:
good = False
msgs.append(
(
"error",
(
f"the uploaded file name {fileName} has an extension "
f"not in {acceptStr}"
),
)
)
return (good, msgs)
if fileExt == "gltf":
(thisGood, messages) = check(fileContent)
if thisGood:
mimeType = "model/gltf+json"
else:
good = False
msgs.extend([("error", msg) for msg in messages])
mimeType = None
else:
mimeType = magic.from_buffer(fileContent, mime=True)
if mimeType is None:
good = False
msgs.append(
(
"error",
(
f"could not determined the mime type of {fileName} "
"based on its uploaded content"
),
)
)
if mimeType is not None:
if (
fileExt not in mimeTypes.get(mimeType, [])
and mimeType.split("/", 1)[-1].split("+", 1)[0].lower()
!= fileExt.lower()
):
good = False
msgs.append(
(
"error",
(
f"the uploaded file content of {mimeType} file "
f"{fileName} does not fit its extension {fileExt}"
),
)
)
return (good, msgs)
def processModelZip(self, zf, destDir):
"""Processes zip data with a scene and model files.
All files in the zip file will be examined, and those with
extension svx.json will be saved as scene.svx.json at top level
and those with extensions glb of gltf will be saved under their
own names, also at top level.
All other files will be saved as is, unless they have extension .svx.json,
or .gltf or .glb.
These files can end up in subdirectories.
We do not check the file types of the member files other than the svx.json files
and the model files (glb, gltf).
If the file type for these files does not match their extensions, they will be
ignored.
The user is held responsible to submit a suitable file.
Parameters
----------
zf: bytes
The raw zip data
"""
Messages = self.Messages
msgs = []
good = True
try:
zf = BytesIO(zf)
z = ZipFile(zf)
allFiles = 0
sceneFiles = set()
modelFiles = set()
otherFiles = set()
goodFiles = []
for zInfo in z.infolist():
if zInfo.filename[-1] == "/":
continue
if zInfo.filename.startswith("__MACOS"):
continue
allFiles += 1
zName = zInfo.filename
zPath = zName.split("/")
if len(zPath) == 1:
zDir, zFile = "", zPath[0]
else:
zDir = "/".join(zPath[0:-1])
zFile = zPath[-1]
zTest = zFile.lower()
doFileTypeCheck = False
if zTest.endswith(".svx.json"):
if zDir == "":
sceneFiles.add(zName)
doFileTypeCheck = True
else:
msgs.append(
("warning", f"ignoring non-toplevel scene file {zName}")
)
continue
elif zTest.endswith(".glb") or zTest.endswith(".gltf"):
if zDir == "":
modelFiles.add(zName)
doFileTypeCheck = True
else:
msgs.append(
("warning", f"ignoring non-toplevel model file {zName}")
)
continue
else:
otherFiles.add(zName)
if doFileTypeCheck:
fileContent = z.read(zInfo)
(thisGood, theseMsgs) = self.checkFileContent(
None, None, zFile, fileContent
)
if thisGood:
goodFiles.append((zName, fileContent))
else:
good = False
msgs.extend(theseMsgs)
else:
goodFiles.append((zInfo, None))
if good:
for zName, fileContent in goodFiles:
if fileContent is None:
z.extract(zName, path=destDir)
else:
with open(f"{destDir}/{zName}", mode="wb") as fh:
fh.write(fileContent)
nScenes = len(sceneFiles)
sLabel = "info" if nScenes == 1 else "warning"
msgs.append(("info", f"All files in zip: {allFiles:>3}"))
msgs.append((sLabel, f"Scene files: {nScenes:>3} x"))
msgs.append(("info", f"Model files: {len(modelFiles):>3} x"))
msgs.append(("info", f"Other files: {len(otherFiles):>3} x"))
except Exception as e:
good = False
msgs.append(("error", "Something went wrong"))
Messages.warning(logmsg=str(e))
return (good, msgs)
def deleteFile(self, record, key, path, fileName, targetFileName=None):
"""Deletes a file in the context given by a record.
Parameters
----------
record: string | ObjectId | AttrDict | void
The relevant record.
key: string
The upload key
path: string
The path from the context directory to the file
fileName: string
Name of the file to be saved as mentioned in the request.
targetFileName: string, optional None
The name of the file as which the uploaded file will be saved;
if None, the file will be saved with the name from the request.
Return
------
response
A json response with the status of the save operation:
* a boolean: whether the save succeeded
* a message: messages to display
* content: new content for an upload control (only if successful)
"""
Settings = self.Settings
Messages = self.Messages
Mongo = self.Mongo
Auth = self.Auth
workingDir = Settings.workingDir
uploadConfig = self.getUploadConfig(key)
table = uploadConfig.table
(recordId, record) = Mongo.get(table, record)
if recordId is None:
return jsonify(status=False, msgs=[["warning", "record does not exist"]])
permitted = Auth.authorise(table, record, action="update")
sep = "/" if path else ""
filePath = f"{path}{sep}{fileName}"
fileFullPath = f"{workingDir}/{filePath}"
if not permitted:
logmsg = f"Delete file not permitted: {key}: {fileFullPath}"
msg = f"Delete not permitted: {fileName}"
Messages.warning(logmsg=logmsg)
return jsonify(status=False, msgs=[["warning", msg]])
if not fileExists(fileFullPath):
logmsg = f"File does not exist: {key}: {fileFullPath}"
msg = f"File does not exist: {fileName}"
Messages.warning(logmsg=logmsg)
return jsonify(status=False, msgs=[["warning", msg]])
try:
fileRemove(fileFullPath)
except Exception:
logmsg = f"Could not delete file: {key}: {fileFullPath}"
msg = f"File not deleted: {fileName}"
Messages.warning(logmsg=logmsg)
return jsonify(status=False, msgs=[["error", msg]])
content = self.getUpload(
record, key, fileName=targetFileName, bust=fileName, wrapped=False
)
return jsonify(status=True, msgs=[["good", "Done"]], content=content)
Classes
class Content (Settings, Messages, Viewers, Mongo, Wrap)
-
Retrieving content from database and file system.
This class has methods to retrieve various pieces of content from the data sources, and hand it over to the
Pages
class that will compose a response out of it.It is instantiated by a singleton object.
Parameters
Settings
:AttrDict
- App-wide configuration data obtained from
Config.Settings
. Viewers
:object
- Singleton instance of
Viewers
. Messages
:object
- Singleton instance of
Messages
. Mongo
:object
- Singleton instance of
Mongo
. Wrap
:object
- Singleton instance of
Wrap
.
Expand source code Browse git
class Content(Datamodel): def __init__(self, Settings, Messages, Viewers, Mongo, Wrap): """Retrieving content from database and file system. This class has methods to retrieve various pieces of content from the data sources, and hand it over to the `control.pages.Pages` class that will compose a response out of it. It is instantiated by a singleton object. Parameters ---------- Settings: AttrDict App-wide configuration data obtained from `control.config.Config.Settings`. Viewers: object Singleton instance of `control.viewers.Viewers`. Messages: object Singleton instance of `control.messages.Messages`. Mongo: object Singleton instance of `control.mongo.Mongo`. Wrap: object Singleton instance of `control.wrap.Wrap`. """ super().__init__(Settings, Messages, Mongo) self.Viewers = Viewers self.Wrap = Wrap def addAuth(self, Auth): """Give this object a handle to the Auth object. Because of cyclic dependencies some objects require to be given a handle to Auth after their initialization. """ self.Auth = Auth def addPublish(self, Publish): """Give this object a handle to the Publish object. Because of cyclic dependencies some objects require to be given a handle to Publish after their initialization. """ self.Publish = Publish def getProjects(self): """Get the list of all projects. Well, the list of all projects visible to the current user. Unpublished projects are only visible to users that belong to that project. Visible projects are each displayed by means of an icon and a title. Both link to a landing page for the project. Returns ------- string A list of captions of the projects, wrapped in a HTML string. """ Mongo = self.Mongo Wrap = self.Wrap (siteTable, siteId, site) = self.relevant() if siteId is None: return "" return Wrap.projectsMain(site, Mongo.getList("project", {}, sort="title")) def getEditions(self, project): """Get the list of the editions of a project. Well, only if the project is visible to the current user. See `Content.getProjects()`. Editions are each displayed by means of an icon and a title. Both link to a landing page for the edition. Parameters ---------- project: string | ObjectId | AttrDict The project in question. Returns ------- string A list of captions of the editions of the project, wrapped in a HTML string. """ Mongo = self.Mongo Wrap = self.Wrap (projectId, project) = Mongo.get("project", project) if projectId is None: return "" return Wrap.editionsMain( project, Mongo.getList("edition", dict(projectId=projectId), sort="title"), ) def getScene(self, projectId, edition, version=None, action=None): """Get the scene of an edition of a project. Well, only if the current user is authorised. A scene is displayed by means of an icon and a row of buttons. There are also buttons to upload model files and the scene file. If action is not None, the scene is loaded in a specific version of the viewer in a specific mode (`read` or `edit`). The edition knows which viewer to choose. Which version and which mode are used is determined by the parameters. If the parameters do not specify values, sensible defaults are chosen. Parameters ---------- projectId: ObjectId The id of the project to which the edition belongs. edition: string | ObjectId | AttrDict The edition in question. version: string, optional None The version of the chosen viewer that will be used. If no version or a non-existing version are specified, the latest existing version for that viewer will be chosen. action: string, optional read The mode in which the viewer should be opened. If the mode is `update`, the viewer is opened in edit mode, if the scene file exists, otherwise in create mode, which, in case of the Voyager viewer, means `dragdrop` mode, in older versions `standalone`. All other modes lead to the viewer being opened in read-only mode. If the mode is read-only, but the scene file is missing, no viewer will be opened. Returns ------- string A caption of the scene of the edition, with possibly a frame with the 3D viewer showing the scene. The result is wrapped in a HTML string. """ Settings = self.Settings H = Settings.H workingDir = Settings.workingDir modelzFile = Settings.modelzFile Mongo = self.Mongo Viewers = self.Viewers Wrap = self.Wrap (editionId, edition) = Mongo.get("edition", edition) if editionId is None: return "" (viewer, sceneFile) = Viewers.getViewInfo(edition) version = Viewers.check(viewer, version) if sceneFile is None: sceneExists = False baseResult = "" else: scenePath = ( f"{workingDir}/project/{projectId}/edition/{editionId}/{sceneFile}" ) sceneExists = fileExists(scenePath) baseResult = Wrap.sceneMain( projectId, edition, sceneFile, viewer, version, action, sceneExists ) if action is None: action = "read" zipUpload = ( "" if sceneExists or sceneFile is None else ( H.h(4, "Scene plus model files, zipped") + H.div(self.getUpload(edition, "modelz", fileName=modelzFile)) ) ) sceneUpload = ( "" if sceneFile is None else H.div(self.getUpload(edition, "scene", fileName=sceneFile)) ) return ( baseResult + H.h(4, "Scene" if sceneExists else "No scene yet") + sceneUpload + H.h(4, "Model files") + H.div(self.getUpload(edition, "model")) + zipUpload ) def getAdmin(self): """Get the controls on relevant projects, editions and users. Admin users get the list of all users. Normal users get the list of users associated with * the project of which they are organiser * the editions of which they are editor or reviewer Guests and not-logged-in users cannot see any user. If the user has rights to modify the association between users and projects/editions, he will get the controls to do so. Returns ------- string """ Settings = self.Settings H = Settings.H (adminContent, adminToc) = Admin(self).wrap() return ( adminContent, H.div( [ H.div( H.a( "Manual for admins", "https://github.com/CLARIAH/pure3dx" "/blob/main/docs/manual-admin.md", target=H.blank, ) ), H.div(adminToc), ], ), ) def createProject(self, site): """Creates a new project. Parameters ---------- site: AttrDict | string record that represents the site, or its id. It acts as a master record for all projects. Returns ------- ObjectId The id of the new project. """ Settings = self.Settings Messages = self.Messages Mongo = self.Mongo Auth = self.Auth workingDir = Settings.workingDir (siteId, site) = Mongo.get("site", site) if siteId is None: return None permitted = Auth.authorise("site", site, action="create", insertTable="project") if not permitted: return None User = Auth.myDetails() user = User.user name = User.nickname title = "Project without title" dcMeta = dict( title=title, creator=name, dateCreated=isonow(), ) projectId = Mongo.insertRecord( "project", dict(title=title, siteId=siteId, dc=dcMeta, isVisible=False) ) Mongo.insertRecord( "projectUser", dict(projectId=projectId, user=user, role="organiser") ) projectDir = f"{workingDir}/project/{projectId}" if dirExists(projectDir): Messages.warning( msg="The new project already exists on the file system", logmsg=f"New project {projectId} already exists on the filesystem.", ) else: dirMake(projectDir) return projectId def deleteItem(self, table, record): """Deletes an item, project or edition. Deletion means: mark as deleted. A record is marked as deleted if it has a field `markedDeleted: true` in it. Parameters ---------- table: string The kind of item: `project` or `edition`. record: string | ObjectId | AttrDict The item in question. Returns ------- boolean Whether the deletion was successful. """ Messages = self.Messages Mongo = self.Mongo Auth = self.Auth User = Auth.myDetails() name = User.nickname (recordId, record) = Mongo.get(table, record) projectRep = f"{record.projectId}/" if table == "edition" else "" itemRep = f"{table} {projectRep}{recordId}" head = f"DELETE (on behalf of {name}) {itemRep}: " if recordId is None: Messages.warning( msg=f"Delete {table}: no such {table}", logmsg=f"{head}Not found", ) return None permitted = Auth.authorise(table, record, action="delete") if not permitted: Messages.error( msg=f"Delete {table}: no such {table}", logmsg=f"{head}Not permitted", ) return None details = self.getDetailRecords(table, record) nDetails = len(details) if nDetails: plural = "" if nDetails == 1 else "s" Messages.warning( msg=f"Cannot delete {table} because it has " f"{nDetails} detail record{plural}", logmsg=f"{head}Prevented because of {nDetails} detail record{plural}", ) return None good = True links = self.getLinkedCrit(table, record) if links: for linkTable, linkCriteria in links.items(): (thisGood, count) = Mongo.deleteRecords(linkTable, linkCriteria, name) if not thisGood: good = False Messages.error( msg=f"Error during removing link records from {linkTable}", logmsg=( "{head}Cannot delete link records from " f"{linkTable} with {linkCriteria}" ), ) break plural = "" if count == 1 else "s" Messages.info( logmsg=( f"{head}Deleted {count} link record{plural} from {linkTable}" ) ) if not good: return False good = self.deleteItemFiles(table, record, recordId, name) if good: Messages.info(logmsg=f"{head}deleted the {table} record") else: Messages.error(logmsg=f"{head}could not delete this {table} record") return False good = Mongo.deleteRecord(table, dict(_id=recordId), name) if good: Messages.info( msg=f"Deleted {table}", logmsg=f"{head}Success", ) else: Messages.error( msg=f"Failed to delete {table}", logmsg=f"{head}Failed", ) return good def deleteItemFiles(self, table, record, recordId, name): """Deletes the files that correspond to a project or edition. Deletion means: mark as deleted. A directory is marked as deleted if it has a file `__deleted__.txt` in it. Parameters ---------- table: string The kind of item: `project` or `edition`. record: AttrDict The item record in question. recordId: AttrDict The the id of the item record in question. name: string The user name of the user that triggered the delete action. Returns ------- boolean Whether the deletion was successful. """ Messages = self.Messages Settings = self.Settings workingDir = Settings.workingDir if table == "project": itemDirTail = f"{table}/{recordId}" elif table == "edition": projectId = record.projectId itemDirTail = f"/project/{projectId}/{table}/{recordId}" itemDir = f"{workingDir}/{itemDirTail}" head = f"DELETE DIRECTORY (on behalf of {name}) {itemDirTail}: " if dirExists(itemDir): Messages.info(logmsg=f"{head}by adding {FDEL}") with open(f"{itemDir}/{FDEL}", "w") as fh: fh.write("deleted\n") else: Messages.info(logmsg=f"{head}no such directory") return True def createEdition(self, project): """Creates a new edition. Parameters ---------- project: AttrDict | string record that represents the maste project, or its id. Returns ------- ObjectId The id of the new edition. """ Mongo = self.Mongo Messages = self.Messages Auth = self.Auth Settings = self.Settings workingDir = Settings.workingDir def fillin(template, values): typ = type(template) if typ is str: for k, v in values.items(): template = template.replace(f"«{k}»", v) return template if typ in {list, tuple}: return [fillin(e, values) for e in template] if typ in {dict, AttrDict}: return {k: fillin(v, values) for (k, v) in template.items()} return template editionSettingsTemplate = Settings.editionSettingsTemplate viewerDefault = Settings.viewerDefault viewerInfo = Settings.viewers[viewerDefault] or AttrDict() versionDefault = viewerInfo.defaultVersion sceneFile = viewerInfo.sceneFile values = dict(viewer=viewerDefault, version=versionDefault, scene=sceneFile) editionSettings = fillin(editionSettingsTemplate, values) (projectId, project) = Mongo.get("project", project) if projectId is None: return None permitted = Auth.authorise( "project", project, action="create", insertTable="edition" ) if not permitted: return None User = Auth.myDetails() user = User.user name = User.nickname title = "Edition without title" dcMeta = dict( title=title, creator=name, dateCreated=isonow(), ) editionId = Mongo.insertRecord( "edition", dict( title=title, projectId=projectId, dc=dcMeta, settings=editionSettings, isPublished=False, ), ) Mongo.insertRecord( "editionUser", dict(editionId=editionId, user=user, role="editor") ) editionDir = f"{workingDir}/project/{projectId}/edition/{editionId}" if dirExists(editionDir): Messages.warning( msg="The new edition already exists on the file system", logmsg=f"New edition {editionId} already exists on the filesystem.", ) else: dirMake(editionDir) return editionId def saveValue(self, table, record, key): """Saves a value of into a record. A record is a document, which is a (nested) dict. A value is inserted somewhere (deep) in that dict. The value is given by the request. Where exactly is given by a path that is stored in the field information, which is accessible by the key. If we save a value to an edition record, and the record has not yet a date created, we fill in today for the date created. Parameters ---------- table: string The relevant table. record: string | ObjectId | AttrDict | void The relevant record. key: string an identifier for the meta data field. Returns ------- dict Contains the following keys: * `status`: whether the save action was successful * `messages`: messages issued during the process * `readonly`: the html of the updated formatted value, this will replace the currently displayed value. """ Auth = self.Auth Mongo = self.Mongo fieldPaths = self.fieldPaths value = json.loads(requestData()) F = self.makeField(key, table) readonly = F.readonly nameSpace = F.nameSpace permitted = Auth.authorise(table, record, nameSpace=nameSpace, action="update") if not permitted: return dict(stat=False, messages=[["error", "update not allowed"]]) if readonly: return dict(stat=False, messages=[["error", f"{key} is not updatable"]]) tp = F.tp multiple = F.multiple fieldPath = fieldPaths[key] (recordId, record) = Mongo.get(table, record) if recordId is None: return dict( stat=False, messages=[["error", "record does not exist"]], ) sValue = ( value if tp == "text" or tp == "string" and not multiple else ( value if tp == "keyword" else plainify(readYaml(value, plain=True, ignore=True)) ) ) update = {fieldPath: sValue} if key == "title": update[key] = sValue if Mongo.updateRecord(table, dict(_id=recordId), update) is None: return dict( stat=False, messages=[["error", "could not update the record in the database"]], ) else: (recordId, record) = Mongo.get(table, recordId) now = isonow() dateCreatedPath = fieldPaths["dateCreated"] dateModifiedPath = fieldPaths["dateModified"] if not self.getValue(table, record, "dateCreated", manner="logical"): Mongo.updateRecord(table, dict(_id=recordId), {dateCreatedPath: now}) Mongo.updateRecord(table, dict(_id=recordId), {dateModifiedPath: now}) return dict( stat=True, messages=[], readonly=F.formatted(record, editable=False, level=None), ) def saveRole(self, user, table, recordId): """Saves a role into a user or cross table record. The role is given by the request. Parameters ---------- user: string The eppn of the user. table: string | void The relevant table. If not None, it indicates whether we are updating site-wide roles, otherwise project/edition roles. recordId: string | void The id of the relevant record. If not None, it is a project/edition record Id, which can be used to locate the cross record between the user table and the project/edition record where the user's role is stored. If None, the user's role is inside the user record. Returns ------- dict Contains the following keys: * `status`: whether the save action was successful * `messages`: messages issued during the process * `updated`: if the action was successful, all user management info will be passed back and will replace the currently displayed material. """ newRole = json.loads(requestData()) return Admin(self).saveRole(user, newRole, table, recordId) def createUser(self, user): """Creates a new user with a given user name. Parameters ---------- user: string The user name of the user. This should be different from the user names of existing users. Returns ------- dict Contains the following keys: * `status`: whether the create action was successful * `messages`: messages issued during the process * `name`: the name under which the new user has been saved """ return Admin(self).createUser(user) def deleteUser(self, user): """Deletes a test user with a given user name. Parameters ---------- user: string The user name of the user. This should be a test user, not linked to any project or edition. Returns ------- dict Contains the following keys: * `status`: whether the create action was successful * `messages`: messages issued during the process """ return Admin(self).deleteUser(user) def linkUser(self, table, recordId): """Links a user in certain role to a project/edition record. The user and role are given by the request. Parameters ---------- table: string The relevant table. recordId: string The id of the relevant record, which can be used to locate the cross record between the user table and the project/edition record where the user's role is stored. Returns ------- dict Containsthe following keys: * `status`: whether the save action was successful * `messages`: messages issued during the process * `updated`: if the action was successful, all user management info will be passed back and will replace the currently displayed material. """ (newRole, newUser) = json.loads(requestData()) return Admin(self).linkUser(newUser, newRole, table, recordId) def getValue(self, table, record, key, level=None, manner="formatted"): """Retrieve a metadata value. Metadata sits in a big, potentially deeply nested dictionary of keys and values. These locations are known to the system (based on `fields.yml`). This function retrieves the information from those known locations. If a value is in fact composed of multiple values, it will be handled accordingly. If the user may edit the value, an edit button is added. Parameters ---------- key: string an identifier for the meta data field. table: string The relevant table. record: string | ObjectId | AttrDict | void The relevant record. level: string, optional None The heading level with which the value should be formatted. * `0`: No heading level * `None`: no formatting at all manner: string, optional wrapped If it is "formatted", the value is represented fully wrapped in HTML, possibly with edit/save controls. If it is "bare", the value is represented as a simple string. If it is "barex", the value is represented as a compact simple string. If it is "logical", the logical value is returned. Returns ------- string It is assumed that the metadata value that is addressed exists. If not, we return the empty string. """ Auth = self.Auth actions = Auth.authorise(table, record) if "read" not in actions: return None F = self.makeField(key, table) nameSpace = F.nameSpace isBare = manner in {"bare", "barex"} compact = manner == "barex" isLogical = manner == "logical" if isBare or isLogical: return F.bare(record, compact=compact) if isBare else F.logical(record) editable = Auth.authorise(table, record, nameSpace=nameSpace, action="update") return F.formatted(record, editable=editable, level=level) def getValues(self, table, record, fieldSpecs): """Puts several pieces of metadata on the web page. Parameters ---------- fieldSpecs: string `,`-separated list of fieldSpecs table: string The relevant table record: string | ObjectId | AttrDict | void The relevant record Returns ------- string The join of the individual results of retrieving metadata value. """ Settings = self.Settings H = Settings.H return H.content( self.getValue(table, record, key, level=level) or "" for (key, level) in ( fieldSpec.strip().split("@", 1) for fieldSpec in fieldSpecs.split("+") if fieldSpec ) ) def getUpload(self, record, key, fileName=None, bust=None, wrapped=True): """Display the name and/or upload controls of an uploaded file. The user may upload model files and a scene file to an edition, and various png files as icons for projects, edtions, and scenes. Here we produce the control to do so. Only if the user has `update` authorisation, an upload/delete widget will be returned. Parameters ---------- record: string | ObjectId | AttrDict | void The relevant record. key: string an identifier for the upload field. 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. 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. That will bust the cache for the image, so that uploaded images replace the existing images. This is useful when this function is called to provide udated content for an file upload widget after it has been used to successfully upload a file. The file name of the uploaded file is known, and that is the one that gets a cache buster appended. 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 file that is currently present, or the indication that no file is present. If the user has edit permission for the edition, we display widgets to upload a new file or to delete the existing file. """ Auth = self.Auth uploadConfig = self.getUploadConfig(key) table = uploadConfig.table actions = Auth.authorise(table, record) if "read" not in actions: return None F = self.makeUpload(key, fileName=fileName) return F.formatted(record, "update" in actions, bust=bust, wrapped=wrapped) def getDownload(self, table, record): """Display the name and/or upload controls of an uploaded file. The user may upload model files and a scene file to an edition, and various png files as icons for projects, edtions, and scenes. Here we produce the control to do so. Only if the user has `update` authorisation, an upload/delete widget will be returned. Parameters ---------- table: string The table in which the relevant record sits record: string | ObjectId | AttrDict The relevant record. Returns ------- string The name of the file that is currently present, or the indication that no file is present. If the user has edit permission for the edition, we display widgets to upload a new file or to delete the existing file. """ Settings = self.Settings H = Settings.H Mongo = self.Mongo Auth = self.Auth (recordId, record) = Mongo.get(table, record) if recordId is None: return "" actions = Auth.authorise(table, record) if "read" not in actions: return "" return H.p( H.iconx( "download", text="Download", href=f"/download/{table}/{recordId}", cls="button small", ) ) def getPublishInfo(self, table, record): """Display the number under which a project/edition is published. Editions of a project may have been published. If that is the case, the project has been assigned a sequence number, under which it can be found on the static site with published material. Here we collect that number, and, for editions, we may put a publish button here. Parameters ---------- table: string The table in which the relevant record sits record: string | ObjectId | AttrDict The relevant record. Returns ------- string In case of a project: the number of the project on the static site. In case of an edition: the number of the project and the number of the edition on the static site. If the edition is not yet published, and the user is allowed to publish the edition, then a publish button is also added. """ Settings = self.Settings H = Settings.H Mongo = self.Mongo Auth = self.Auth pubUrl = Settings.pubUrl published = Settings.published (recordId, record) = Mongo.get(table, record) if recordId is None: return "" actions = Auth.authorise(table, record) if "read" not in actions or table not in {"project", "edition"}: return "" (site, siteId, projectId, project, editionId, edition) = self.context( table, record ) pPubNum = project.pubNum projectPubStr = ( H.i("No published editions") if pPubNum is None else H.span("Published: ") + H.a( f"{pPubNum} ⌲", f"{pubUrl}/project/{pPubNum}/index.html", target=published, cls="button large", ) ) if table == "project": return H.p(projectPubStr) ePubNum = edition.pubNum editionIsPublished = edition.isPublished editionPubRow = ( ( H.p(H.i("Published:") + H.nbsp), H.p( H.a( f"{pPubNum}/{ePubNum} ⌲", f"{pubUrl}/project/{pPubNum}/edition/{ePubNum}/index.html", target=published, cls="button large", ) ), H.p(self.getValue("edition", edition, "datePublished", manner="barex")), ) if editionIsPublished else ( ( H.p(H.i("Unpublished") + H.nbsp), H.p(dateUnPublished), H.p( H.i("last published") + H.nbsp + H.span( self.getValue( "edition", edition, "datePublished", manner="barex" ) ) ), ) if ( dateUnPublished := self.getValue( "edition", edition, "dateUnPublished", manner="barex" ) ) else ( H.p(H.i("Never published") + H.nbsp), "", "", ) ) ) can = dict( precheck=True, publish=not editionIsPublished, unpublish=editionIsPublished, republish=editionIsPublished, ) rows = [] for kind, kindRep, tbRecRoles in ( ( "precheck", "check (meta)data", ( ("edition", edition, "editor"), ("project", project, "organiser"), ("site", site, "admin"), ("site", site, "root"), ), ), ("publish", "publish", (("project", project, "organiser"),)), ( ("publishf", "publish"), "force-publish", (("project", project, "organiser"),), ), ( "republish", "re-publish", (("site", site, "admin"), ("site", site, "root")), ), ( ("republishf", "republish"), "force-re-publish", (("site", site, "admin"), ("site", site, "root")), ), ( "unpublish", "un-publish", (("site", site, "admin"), ("site", site, "root")), ), ): asKind = kind if type(kind) is tuple: (kind, asKind) = kind if can[asKind]: rows.append( ( H.p("You may"), H.p( H.a( kindRep, href=f"/{kind}/{recordId}", cls="button medium" ) ), ) if asKind in actions else ( H.p("You may not"), H.p(H.i(kindRep)), H.p(Auth.getInvolvedUsers(tbRecRoles, asString=True)), ) ) return H.table( [([(cell, {}) for cell in editionPubRow], {})], [([(cell, {}) for cell in row], {}) for row in rows], ) def getViewerFile(self, path): """Gets a viewer-related file from the file system. This is about files that are part of the viewer software. The viewer software is located in a specific directory on the server. This is the viewer base. Parameters ---------- path: string The path of the viewer file within viewer base. Returns ------- string The full path to the viewer file, if it exists. Otherwise, we raise an error that will lead to a 404 response. """ Settings = self.Settings Messages = self.Messages viewerDir = Settings.viewerDir viewerPath = f"{viewerDir}/{path}" if not fileExists(viewerPath): logmsg = f"Accessing {viewerPath}: " logmsg += "does not exist. " Messages.error( msg="Accessing a file", logmsg=logmsg, ) return viewerPath def getDataFile(self, table, record, path, content=False, lenient=False): """Gets a data file from the file system. All data files are located under a specific directory on the server. This is the data directory. Below that the files are organized by projects and editions. Projects and editions corresponds to records in tables in MongoDB. Parameters ---------- path: string The path of the data file within site/project/edition directory within the data directory. project: string | ObjectId | AttrDict The id of the project in question. edition: string | ObjectId | AttrDict The id of the edition in question. content: boolean, optional False If True, delivers the content of the file, instead of the path lenient: boolean, optional False If True, do not complain if the file does not exist. Returns ------- string The full path of the data file, if it exists. But if the `content` parameter is True, we deliver the content of the file. Otherwise, we raise an error that will lead to a 404 response, except when `lenient` is True. """ Settings = self.Settings H = Settings.H Messages = self.Messages Auth = self.Auth workingDir = Settings.workingDir (site, siteId, projectId, project, editionId, edition) = self.context( table, record ) urlBase = ( "" if project is None else ( f"project/{projectId}" if edition is None else f"project/{projectId}/edition/{editionId}" ) ) sep = "/" if urlBase else "" base = f"{workingDir}{sep}{urlBase}" dataPath = base if path is None else f"{base}/{path}" (table, recordId, record) = self.relevant(project=project, edition=edition) if recordId is None: Messages.error(msg="record does not exist") return "" permitted = Auth.authorise(table, record, action="read") fexists = fileExists(dataPath) if permitted and fexists: if content: with open(dataPath) as fh: result = fh.read() else: result = dataPath else: result = ( "" if content else dataPath if lenient else H.span(dataPath, cls="error") ) if not lenient: logmsg = f"Accessing {dataPath}: " if not permitted: logmsg += "not allowed. " if not fexists: logmsg += "does not exist. " Messages.error( msg=f"Accessing file {path}", logmsg=logmsg, ) result = "" if content else dataPath return result def breadCrumb(self, project): """Makes a link to the landing page of a project. Parameters ---------- project: string | ObjectId | AttrDict The project in question. """ Settings = self.Settings H = Settings.H Mongo = self.Mongo (projectId, project) = Mongo.get("project", project) if not project: return "" projectUrl = f"/project/{projectId}" text = self.getValue("project", project, "title", manner="bare") if not text: text = H.i("no title") return H.p( [ "Project: ", H.a( text, projectUrl, cls="button medium", title="back to the project page", ), ] ) def precheck(self, record): """Check the articles of an edition prior to publishing. Parameters ---------- record: string The record of the edition to be checked. Return ------ response A status response. It will also generate a a bunch of toc files in the edition. """ Messages = self.Messages Mongo = self.Mongo Auth = self.Auth Publish = self.Publish (recordId, record) = Mongo.get("edition", record) if recordId is None: Messages.error( msg="record does not exist", logmsg=f"edition {recordId} does not exist" ) return False permitted = Auth.authorise("edition", record, action="precheck") if not permitted: logmsg = f"Checking articles not permitted: edition: {recordId}" msg = "Checking articles of edition not permitted" Messages.warning(msg=msg, logmsg=logmsg) return False (siteId, site, projectId, project, editionId, edition) = self.context( "edition", record ) return Publish.Precheck.checkEdition(site, project, editionId, edition) def publish(self, record, force): """Publish an edition. Parameters ---------- record: string The record of the item to be published. force: boolean If True, ignore when some checks fail Return ------ response A publish status response. """ Messages = self.Messages Mongo = self.Mongo Auth = self.Auth Publish = self.Publish (recordId, record) = Mongo.get("edition", record) if recordId is None: Messages.error( msg="record does not exist", logmsg=f"edition {recordId} does not exist" ) return False permitted = Auth.authorise("edition", record, action="publish") if not permitted: logmsg = f"Publish not permitted: edition: {recordId}" msg = "Publishing of edition not permitted" Messages.warning(msg=msg, logmsg=logmsg) return False (siteId, site, projectId, project, editionId, edition) = self.context( "edition", record ) return Publish.updateEdition(site, project, edition, "add", force=force) def republish(self, record, force): """Re-ublish an edition. Parameters ---------- record: string The record of the item to be re-published. force: boolean If True, ignore when some checks fail Return ------ response A re-publish status response. """ Messages = self.Messages Mongo = self.Mongo Auth = self.Auth Publish = self.Publish (recordId, record) = Mongo.get("edition", record) if recordId is None: Messages.error( msg="record does not exist", logmsg=f"edition {recordId} does not exist" ) return False permitted = Auth.authorise("edition", record, action="republish") if not permitted: logmsg = f"Re-publish not permitted: edition: {recordId}" msg = "Re-publishing of edition not permitted" Messages.warning(msg=msg, logmsg=logmsg) return False (siteId, site, projectId, project, editionId, edition) = self.context( "edition", record ) return Publish.updateEdition( site, project, edition, "add", force=force, again=True ) def unpublish(self, record): """Unpublish an edition. Parameters ---------- record: string The record of the item to be unpublished. Return ------ response An unpublish status response. """ Messages = self.Messages Mongo = self.Mongo Auth = self.Auth Publish = self.Publish (recordId, record) = Mongo.get("edition", record) if recordId is None: Messages.error( msg="record does not exist", logmsg=f"edition {recordId} does not exist" ) return False permitted = Auth.authorise("edition", record, action="unpublish") if not permitted: logmsg = f"Unpublish not permitted: edition: {recordId}" msg = "Unpublishing of edition not permitted" Messages.warning(msg=msg, logmsg=logmsg) return False (siteId, site, projectId, project, editionId, edition) = self.context( "edition", record ) return Publish.updateEdition(site, project, edition, "remove") def generate(self): """Regenerate the HTML for the published site. Not only the site wide files, but also all projects and editions. Return ------ response A publish status response. """ Messages = self.Messages Auth = self.Auth Publish = self.Publish site = self.relevant()[-1] permitted = Auth.authorise("site", site, action="republish") if not permitted: logmsg = "Generate pages is not permitted" msg = "Generate pages is not permitted" Messages.warning(msg=msg, logmsg=logmsg) return False return Publish.generatePages(True, True) def download(self, table, record): """Responds with a download of a project or edition. The following measures have been taken to facilitate big downloads: 1. the zipfile is now created in a temporary directory; 1. the zipfile is now streamed to the client; 1. when we add files to the zip file, we do not compress glb, mp4 files etc. This reduces the zipping time greatly. Without this, the request would time out after 60 seconds, and the browser would fire a new request; Still, despite these measures, the response itself may too much time. What we see is that for a certain download of 1.25 GB, the request is answered after 20-30 seconds with a response (200). Then streaming starts, but after 3 minutes, at 1.08GB the client reports that the server has disconnected. This happens in Safari, Firefox, en curl. So, with this issue unresolved, we try to avoid it from happening. Before preparing the download, we examine the total uncompressed file size. If that is more than 1 GB, we refuse to download and give a warning. Parameters ---------- table: string The table where the item to be downloaded sits. record: string The record of the item to be downloaded. Return ------ response A download response. The download data will be streamed. """ Settings = self.Settings Messages = self.Messages Mongo = self.Mongo Auth = self.Auth tempDir = Settings.tempDir workingDir = Settings.workingDir User = Auth.myDetails() name = User.nickname dontCompress = set(Settings.dontCompress) limits = Settings.limits sizeUnit = limits.sizeUnit sizeUnitSize = limits.sizeUnitSize sizeUnits = limits.sizeUnits # sizeUnits = limits.sizeUnitsOptimistic sizeLimit = sizeUnits * sizeUnitSize (recordId, record) = Mongo.get(table, record) projectRep = f"{record.projectId}/" if table == "edition" else "" itemRep = f"{table} {projectRep}{recordId}" head = f"DOWNLOAD (on behalf of {name}) {itemRep}: " if recordId is None: Messages.error(logmsg=f"{head}record does not exist") return jsonify(status=False, msgs=[["warning", "record does not exist"]]) permitted = Auth.authorise(table, record, action="read") if not permitted: logmsg = f"{head}not permitted" msg = f"Download of {table}/{recordId} not permitted" Messages.warning(msg=msg, logmsg=logmsg) return jsonify(status=False, msgs=[["warning", msg]]) (siteId, site, projectId, project, editionId, edition) = self.context( table, record ) ref = getReferrer().removeprefix("/") src = f"{workingDir}/project/{projectId}" if edition is not None: src += f"/edition/{editionId}" def sizeOf(path): sep = "/" if path else "" size = 0 with os.scandir(f"{src}{sep}{path}") as dh: for entry in dh: name = entry.name if entry.is_file(): srcFile = f"{src}/{path}{sep}{name}" size += fSize(srcFile) elif entry.is_dir(): size += sizeOf(f"{path}{sep}{name}") return size totalSize = sizeOf("") if totalSize > sizeLimit: totalUnits = int(round(totalSize / sizeUnitSize)) logmsg = ( f"{head}too big: " f"{totalUnits} {sizeUnit} is more than {sizeUnits} {sizeUnit}" ) msg = ( "too big: " f"{totalUnits} {sizeUnit} is more than {sizeUnits} {sizeUnit}" ) Messages.error(msg=msg, logmsg=logmsg) return redirectStatus(f"/{ref}", False) dst = mkdtemp(dir=tempDir) Messages.info(logmsg=f"{head}CREATE TEMP DIR {dst}") def deleteTmpDir(): dirRemove(dst) Messages.info(logmsg=f"{head}DELETE TEMP DIR {dst}") good = True try: landing = f"{dst}/{recordId}" fileName = f"{table}-{recordId}.zip" zipFilePath = f"{dst}/{fileName}" if edition is None: yamlDest = f"{landing}/project.yaml" else: yamlDest = f"{landing}/edition.yaml" dirCopy(src, landing) with open(yamlDest, "w") as yh: yaml.dump(Mongo.consolidate(project), yh, allow_unicode=True) if edition is None: editions = Mongo.getList( "edition", dict(projectId=projectId), sort="title" ) for ed in editions: edId = ed._id yamlDest = f"{landing}/edition/{edId}/edition.yaml" with open(yamlDest, "w") as yh: yaml.dump(Mongo.consolidate(ed), yh, allow_unicode=True) with ZipFile(zipFilePath, "w", compression=ZIP_DEFLATED) as zipFile: def compress(path): sep = "/" if path else "" with os.scandir(f"{landing}{sep}{path}") as dh: for entry in dh: name = entry.name if entry.is_file(): ext = extNm(name).lower() method = ( ZIP_STORED if ext in dontCompress else ZIP_DEFLATED ) arcFile = f"{path}{sep}{name}" srcFile = f"{landing}/{arcFile}" zipFile.write(srcFile, arcFile, compress_type=method) elif entry.is_dir(): compress(f"{path}{sep}{name}") compress("") dirRemove(landing) Messages.info(logmsg=f"{head}zipped the data") except Exception as e: msg = f"Could not zip the {table}/{recordId} data" logmsg = f"{head}could not zip the data" Messages.error(msg=msg, logmsg=msg) Messages.error(logmsg="".join(format_exception(e))) deleteTmpDir() result = redirectStatus(f"/{ref}", False) good = False if good: zipLength = fSize(zipFilePath) headers = { "Expires": "0", "Cache-Control": "no-cache, no-store, must-revalidate", "Content-Type": "application/zip", "Content-Disposition": f'attachment; filename="{fileName}"', # "Content-Encoding": "identity", "Content-Length": zipLength, } CHUNK_SIZE = 8192 def readFileChunks(): try: with open(zipFilePath, "rb") as fd: while 1: buf = fd.read(CHUNK_SIZE) if buf: yield buf else: break except Exception as e: msg = "Could not stream the {table}/{recordId} data" logmsg = f"{head}could not stream the data" Messages.error(msg=msg, logmsg=logmsg) Messages.error(logmsg="".join(format_exception(e))) Messages.info(logmsg=f"{head}streaming done") deleteTmpDir() result = (stream(readFileChunks()), headers) return result def saveFile(self, record, key, path, fileName, targetFileName=None): """Saves a file in the context given by a record. The parameter `key` refers to a configuration section in the datamodel. This determines what file type to expect. We only accept files whose name has an extension that matches the expected file type. The key `modelz` expects a zip file with the files of an edition, in particular a scene file and model files. We make sure that these files have the proper type, and we also perform checks on the other parts of the zip file, namely whether they have decent paths. Parameters ---------- record: string | ObjectId | AttrDict | void The relevant record. key: string The upload key path: string The path from the context directory to the file fileName: string Name of the file to be saved as mentioned in the request. targetFileName: string, optional None The name of the file as which the uploaded file will be saved; if None, the file will be saved with the name from the request. Return ------ response A json response with the status of the save operation: * a boolean: whether the save succeeded * a list of messages to display * content: new content for an upload control (only if successful) """ fileContent = requestData() # essential to have this early on in the body # if not, the error responses might go wrong in some browsers Settings = self.Settings H = Settings.H Messages = self.Messages Mongo = self.Mongo Auth = self.Auth workingDir = Settings.workingDir User = Auth.myDetails() name = User.nickname uploadConfig = self.getUploadConfig(key) table = uploadConfig.table (recordId, record) = Mongo.get(table, record) projectRep = f"{record.projectId}/" if table == "edition" else "" itemRep = f"{table} {projectRep}{recordId}" head = f"UPLOAD (on behalf of {name}) {itemRep}: " if recordId is None: Messages.error(logmsg=f"{head}record does not exist") return jsonify(status=False, msgs=[["warning", "record does not exist"]]) permitted = Auth.authorise(table, record, action="update") saveName = fileName if targetFileName is not None: saveName = targetFileName filePath = f"{path}{saveName}" fileFullPath = f"{workingDir}/{filePath}" if not permitted: logmsg = f"{head}Upload not permitted: {key}: {fileFullPath}" msg = f"Upload not permitted: {fileName}" Messages.warning(logmsg=logmsg) return jsonify(status=False, msgs=[["warning", msg]]) (good, msgs) = self.checkFileContent(key, targetFileName, fileName, fileContent) if not good: return jsonify(status=False, msgs=msgs) if key == "modelz": destDir = f"{workingDir}/{path}" (good, msgs) = self.processModelZip(fileContent, destDir) if good: Messages.info(logmsg=f"{head}Success") return jsonify( status=True, msgs=msgs, content=H.b("Please refresh the page", cls="good"), ) Messages.info(logmsg=f"{head}Failure") return jsonify(status=False, msgs=msgs) try: initTree(dirNm(fileFullPath), fresh=False) with open(fileFullPath, "wb") as fh: fh.write(fileContent) except Exception as e: logmsg = f"{head}Could not save uploaded file: {key}: {fileFullPath} because of {e}" msg = f"Uploaded file not saved: {fileName}" Messages.warning(logmsg=logmsg) return jsonify(status=False, msgs=[["warning", msg]]) content = self.getUpload( record, key, fileName=targetFileName, bust=fileName, wrapped=False ) Messages.info(f"{head}Success") return jsonify(status=True, msgs=[["good", "Done"]], content=content) def checkFileContent(self, key, targetFileName, fileName, fileContent): """Performs checks on the name and content of an uploaded file before saving it. Parameters ---------- key: string The key of the upload. This key determines what kind of file we expect. If None, we do not expect a particular mime type targetFileName: string The prescribed name to save the file under, if None, it will be saved under the name mentioned in the request. fileName: string The name of the file as mentioned in the request. fileContent: bytes The content of the file as bytes Returns ------- tuple A boolean that tells whether the file content looks OK plus a sequences of messages indicating what is wrong with the content. """ Settings = self.Settings datamodel = Settings.datamodel mimeTypes = datamodel.mimeTypes uploadConfig = self.getUploadConfig(key) or AttrDict() acceptStr = uploadConfig.accept accept = ( None if acceptStr is None else {acc[1:].strip() for acc in acceptStr.split(",")} ) good = True msgs = [] fileExt = extNm(fileName) if targetFileName is not None: targetExt = extNm(targetFileName) if targetExt != fileExt: good = False msgs.append( [ "error", ( f"the uploaded file name {fileName} has an extension " "different from that of the target " f"file name {targetFileName}" ), ] ) if accept is not None and targetExt not in accept: good = False msgs.append( [ "error", ( "Programming error: the prescribed file name " f"{targetFileName} has an extension not in {acceptStr}" ), ] ) return (good, msgs) fileName = targetFileName fileExt = extNm(fileName) if accept is not None and fileExt not in accept: good = False msgs.append( ( "error", ( f"the uploaded file name {fileName} has an extension " f"not in {acceptStr}" ), ) ) return (good, msgs) if fileExt == "gltf": (thisGood, messages) = check(fileContent) if thisGood: mimeType = "model/gltf+json" else: good = False msgs.extend([("error", msg) for msg in messages]) mimeType = None else: mimeType = magic.from_buffer(fileContent, mime=True) if mimeType is None: good = False msgs.append( ( "error", ( f"could not determined the mime type of {fileName} " "based on its uploaded content" ), ) ) if mimeType is not None: if ( fileExt not in mimeTypes.get(mimeType, []) and mimeType.split("/", 1)[-1].split("+", 1)[0].lower() != fileExt.lower() ): good = False msgs.append( ( "error", ( f"the uploaded file content of {mimeType} file " f"{fileName} does not fit its extension {fileExt}" ), ) ) return (good, msgs) def processModelZip(self, zf, destDir): """Processes zip data with a scene and model files. All files in the zip file will be examined, and those with extension svx.json will be saved as scene.svx.json at top level and those with extensions glb of gltf will be saved under their own names, also at top level. All other files will be saved as is, unless they have extension .svx.json, or .gltf or .glb. These files can end up in subdirectories. We do not check the file types of the member files other than the svx.json files and the model files (glb, gltf). If the file type for these files does not match their extensions, they will be ignored. The user is held responsible to submit a suitable file. Parameters ---------- zf: bytes The raw zip data """ Messages = self.Messages msgs = [] good = True try: zf = BytesIO(zf) z = ZipFile(zf) allFiles = 0 sceneFiles = set() modelFiles = set() otherFiles = set() goodFiles = [] for zInfo in z.infolist(): if zInfo.filename[-1] == "/": continue if zInfo.filename.startswith("__MACOS"): continue allFiles += 1 zName = zInfo.filename zPath = zName.split("/") if len(zPath) == 1: zDir, zFile = "", zPath[0] else: zDir = "/".join(zPath[0:-1]) zFile = zPath[-1] zTest = zFile.lower() doFileTypeCheck = False if zTest.endswith(".svx.json"): if zDir == "": sceneFiles.add(zName) doFileTypeCheck = True else: msgs.append( ("warning", f"ignoring non-toplevel scene file {zName}") ) continue elif zTest.endswith(".glb") or zTest.endswith(".gltf"): if zDir == "": modelFiles.add(zName) doFileTypeCheck = True else: msgs.append( ("warning", f"ignoring non-toplevel model file {zName}") ) continue else: otherFiles.add(zName) if doFileTypeCheck: fileContent = z.read(zInfo) (thisGood, theseMsgs) = self.checkFileContent( None, None, zFile, fileContent ) if thisGood: goodFiles.append((zName, fileContent)) else: good = False msgs.extend(theseMsgs) else: goodFiles.append((zInfo, None)) if good: for zName, fileContent in goodFiles: if fileContent is None: z.extract(zName, path=destDir) else: with open(f"{destDir}/{zName}", mode="wb") as fh: fh.write(fileContent) nScenes = len(sceneFiles) sLabel = "info" if nScenes == 1 else "warning" msgs.append(("info", f"All files in zip: {allFiles:>3}")) msgs.append((sLabel, f"Scene files: {nScenes:>3} x")) msgs.append(("info", f"Model files: {len(modelFiles):>3} x")) msgs.append(("info", f"Other files: {len(otherFiles):>3} x")) except Exception as e: good = False msgs.append(("error", "Something went wrong")) Messages.warning(logmsg=str(e)) return (good, msgs) def deleteFile(self, record, key, path, fileName, targetFileName=None): """Deletes a file in the context given by a record. Parameters ---------- record: string | ObjectId | AttrDict | void The relevant record. key: string The upload key path: string The path from the context directory to the file fileName: string Name of the file to be saved as mentioned in the request. targetFileName: string, optional None The name of the file as which the uploaded file will be saved; if None, the file will be saved with the name from the request. Return ------ response A json response with the status of the save operation: * a boolean: whether the save succeeded * a message: messages to display * content: new content for an upload control (only if successful) """ Settings = self.Settings Messages = self.Messages Mongo = self.Mongo Auth = self.Auth workingDir = Settings.workingDir uploadConfig = self.getUploadConfig(key) table = uploadConfig.table (recordId, record) = Mongo.get(table, record) if recordId is None: return jsonify(status=False, msgs=[["warning", "record does not exist"]]) permitted = Auth.authorise(table, record, action="update") sep = "/" if path else "" filePath = f"{path}{sep}{fileName}" fileFullPath = f"{workingDir}/{filePath}" if not permitted: logmsg = f"Delete file not permitted: {key}: {fileFullPath}" msg = f"Delete not permitted: {fileName}" Messages.warning(logmsg=logmsg) return jsonify(status=False, msgs=[["warning", msg]]) if not fileExists(fileFullPath): logmsg = f"File does not exist: {key}: {fileFullPath}" msg = f"File does not exist: {fileName}" Messages.warning(logmsg=logmsg) return jsonify(status=False, msgs=[["warning", msg]]) try: fileRemove(fileFullPath) except Exception: logmsg = f"Could not delete file: {key}: {fileFullPath}" msg = f"File not deleted: {fileName}" Messages.warning(logmsg=logmsg) return jsonify(status=False, msgs=[["error", msg]]) content = self.getUpload( record, key, fileName=targetFileName, bust=fileName, wrapped=False ) return jsonify(status=True, msgs=[["good", "Done"]], content=content)
Ancestors
Methods
def addAuth(self, Auth)
-
Give this object a handle to the Auth object.
Because of cyclic dependencies some objects require to be given a handle to Auth after their initialization.
def addPublish(self, Publish)
-
Give this object a handle to the Publish object.
Because of cyclic dependencies some objects require to be given a handle to Publish after their initialization.
def breadCrumb(self, project)
-
Makes a link to the landing page of a project.
Parameters
project
:string | ObjectId | AttrDict
- The project in question.
def checkFileContent(self, key, targetFileName, fileName, fileContent)
-
Performs checks on the name and content of an uploaded file before saving it.
Parameters
key
:string
- The key of the upload. This key determines what kind of file we expect. If None, we do not expect a particular mime type
targetFileName
:string
- The prescribed name to save the file under, if None, it will be saved under the name mentioned in the request.
fileName
:string
- The name of the file as mentioned in the request.
fileContent
:bytes
- The content of the file as bytes
Returns
tuple
- A boolean that tells whether the file content looks OK plus a sequences of messages indicating what is wrong with the content.
def createEdition(self, project)
-
Creates a new edition.
Parameters
project
:AttrDict | string
- record that represents the maste project, or its id.
Returns
ObjectId
- The id of the new edition.
def createProject(self, site)
-
Creates a new project.
Parameters
site
:AttrDict | string
- record that represents the site, or its id. It acts as a master record for all projects.
Returns
ObjectId
- The id of the new project.
def createUser(self, user)
-
Creates a new user with a given user name.
Parameters
user
:string
- The user name of the user. This should be different from the user names of existing users.
Returns
dict
-
Contains the following keys:
status
: whether the create action was successfulmessages
: messages issued during the processname
: the name under which the new user has been saved
def deleteFile(self, record, key, path, fileName, targetFileName=None)
-
Deletes a file in the context given by a record.
Parameters
record
:string | ObjectId | AttrDict | void
- The relevant record.
key
:string
- The upload key
path
:string
- The path from the context directory to the file
fileName
:string
- Name of the file to be saved as mentioned in the request.
targetFileName
:string
, optionalNone
- The name of the file as which the uploaded file will be saved; if None, the file will be saved with the name from the request.
Return
response A json response with the status of the save operation:
* a boolean: whether the save succeeded * a message: messages to display * content: new content for an upload control (only if successful)
def deleteItem(self, table, record)
-
Deletes an item, project or edition.
Deletion means: mark as deleted. A record is marked as deleted if it has a field
markedDeleted: true
in it.Parameters
table
:string
- The kind of item:
project
oredition
. record
:string | ObjectId | AttrDict
- The item in question.
Returns
boolean
- Whether the deletion was successful.
def deleteItemFiles(self, table, record, recordId, name)
-
Deletes the files that correspond to a project or edition.
Deletion means: mark as deleted. A directory is marked as deleted if it has a file
__deleted__.txt
in it.Parameters
table
:string
- The kind of item:
project
oredition
. record
:AttrDict
- The item record in question.
recordId
:AttrDict
- The the id of the item record in question.
name
:string
- The user name of the user that triggered the delete action.
Returns
boolean
- Whether the deletion was successful.
def deleteUser(self, user)
-
Deletes a test user with a given user name.
Parameters
user
:string
- The user name of the user. This should be a test user, not linked to any project or edition.
Returns
dict
-
Contains the following keys:
status
: whether the create action was successfulmessages
: messages issued during the process
def download(self, table, record)
-
Responds with a download of a project or edition.
The following measures have been taken to facilitate big downloads:
- the zipfile is now created in a temporary directory;
- the zipfile is now streamed to the client;
- when we add files to the zip file, we do not compress glb, mp4 files etc. This reduces the zipping time greatly. Without this, the request would time out after 60 seconds, and the browser would fire a new request;
Still, despite these measures, the response itself may too much time. What we see is that for a certain download of 1.25 GB, the request is answered after 20-30 seconds with a response (200). Then streaming starts, but after 3 minutes, at 1.08GB the client reports that the server has disconnected. This happens in Safari, Firefox, en curl.
So, with this issue unresolved, we try to avoid it from happening. Before preparing the download, we examine the total uncompressed file size. If that is more than 1 GB, we refuse to download and give a warning.
Parameters
table
:string
- The table where the item to be downloaded sits.
record
:string
- The record of the item to be downloaded.
Return
response A download response. The download data will be streamed.
def generate(self)
-
Regenerate the HTML for the published site.
Not only the site wide files, but also all projects and editions.
Return
response A publish status response.
def getAdmin(self)
-
Get the controls on relevant projects, editions and users.
Admin users get the list of all users.
Normal users get the list of users associated with
- the project of which they are organiser
- the editions of which they are editor or reviewer
Guests and not-logged-in users cannot see any user.
If the user has rights to modify the association between users and projects/editions, he will get the controls to do so.
Returns
string
def getDataFile(self, table, record, path, content=False, lenient=False)
-
Gets a data file from the file system.
All data files are located under a specific directory on the server. This is the data directory. Below that the files are organized by projects and editions. Projects and editions corresponds to records in tables in MongoDB.
Parameters
path
:string
- The path of the data file within site/project/edition directory within the data directory.
project
:string | ObjectId | AttrDict
- The id of the project in question.
edition
:string | ObjectId | AttrDict
- The id of the edition in question.
content
:boolean
, optionalFalse
- If True, delivers the content of the file, instead of the path
lenient
:boolean
, optionalFalse
- If True, do not complain if the file does not exist.
Returns
string
-
The full path of the data file, if it exists. But if the
content
parameter is True, we deliver the content of the file.Otherwise, we raise an error that will lead to a 404 response, except when
lenient
is True.
def getDownload(self, table, record)
-
Display the name and/or upload controls of an uploaded file.
The user may upload model files and a scene file to an edition, and various png files as icons for projects, edtions, and scenes. Here we produce the control to do so.
Only if the user has
update
authorisation, an upload/delete widget will be returned.Parameters
table
:string
- The table in which the relevant record sits
record
:string | ObjectId | AttrDict
- The relevant record.
Returns
string
-
The name of the file that is currently present, or the indication that no file is present.
If the user has edit permission for the edition, we display widgets to upload a new file or to delete the existing file.
def getEditions(self, project)
-
Get the list of the editions of a project.
Well, only if the project is visible to the current user. See
Content.getProjects()
.Editions are each displayed by means of an icon and a title. Both link to a landing page for the edition.
Parameters
project
:string | ObjectId | AttrDict
- The project in question.
Returns
string
- A list of captions of the editions of the project, wrapped in a HTML string.
def getProjects(self)
-
Get the list of all projects.
Well, the list of all projects visible to the current user. Unpublished projects are only visible to users that belong to that project.
Visible projects are each displayed by means of an icon and a title. Both link to a landing page for the project.
Returns
string
- A list of captions of the projects, wrapped in a HTML string.
def getPublishInfo(self, table, record)
-
Display the number under which a project/edition is published.
Editions of a project may have been published. If that is the case, the project has been assigned a sequence number, under which it can be found on the static site with published material.
Here we collect that number, and, for editions, we may put a publish button here.
Parameters
table
:string
- The table in which the relevant record sits
record
:string | ObjectId | AttrDict
- The relevant record.
Returns
string
- In case of a project: the number of the project on the static site. In case of an edition: the number of the project and the number of the edition on the static site. If the edition is not yet published, and the user is allowed to publish the edition, then a publish button is also added.
def getScene(self, projectId, edition, version=None, action=None)
-
Get the scene of an edition of a project.
Well, only if the current user is authorised.
A scene is displayed by means of an icon and a row of buttons.
There are also buttons to upload model files and the scene file.
If action is not None, the scene is loaded in a specific version of the viewer in a specific mode (
read
oredit
). The edition knows which viewer to choose.Which version and which mode are used is determined by the parameters. If the parameters do not specify values, sensible defaults are chosen.
Parameters
projectId
:ObjectId
- The id of the project to which the edition belongs.
edition
:string | ObjectId | AttrDict
- The edition in question.
version
:string
, optionalNone
- The version of the chosen viewer that will be used. If no version or a non-existing version are specified, the latest existing version for that viewer will be chosen.
action
:string
, optionalread
- The mode in which the viewer should be opened.
If the mode is
update
, the viewer is opened in edit mode, if the scene file exists, otherwise in create mode, which, in case of the Voyager viewer, meansdragdrop
mode, in older versionsstandalone
. All other modes lead to the viewer being opened in read-only mode. If the mode is read-only, but the scene file is missing, no viewer will be opened.
Returns
string
- A caption of the scene of the edition, with possibly a frame with the 3D viewer showing the scene. The result is wrapped in a HTML string.
def getUpload(self, record, key, fileName=None, bust=None, wrapped=True)
-
Display the name and/or upload controls of an uploaded file.
The user may upload model files and a scene file to an edition, and various png files as icons for projects, edtions, and scenes. Here we produce the control to do so.
Only if the user has
update
authorisation, an upload/delete widget will be returned.Parameters
record
:string | ObjectId | AttrDict | void
- The relevant record.
key
:string
- an identifier for the upload field.
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.
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. That will bust the cache for the image, so that uploaded images replace the existing images.This is useful when this function is called to provide udated content for an file upload widget after it has been used to successfully upload a file. The file name of the uploaded file is known, and that is the one that gets a cache buster appended.
wrapped
:boolean
, optionalTrue
- Whether the content should be wrapped in a container element.
See
HtmlElements.finput()
.
Returns
string
-
The name of the file that is currently present, or the indication that no file is present.
If the user has edit permission for the edition, we display widgets to upload a new file or to delete the existing file.
def getValue(self, table, record, key, level=None, manner='formatted')
-
Retrieve a metadata value.
Metadata sits in a big, potentially deeply nested dictionary of keys and values. These locations are known to the system (based on
fields.yml
). This function retrieves the information from those known locations.If a value is in fact composed of multiple values, it will be handled accordingly.
If the user may edit the value, an edit button is added.
Parameters
key
:string
- an identifier for the meta data field.
table
:string
- The relevant table.
record
:string | ObjectId | AttrDict | void
- The relevant record.
level
:string
, optionalNone
-
The heading level with which the value should be formatted.
0
: No heading levelNone
: no formatting at all
manner
:string
, optionalwrapped
- If it is "formatted", the value is represented fully wrapped in HTML, possibly with edit/save controls. If it is "bare", the value is represented as a simple string. If it is "barex", the value is represented as a compact simple string. If it is "logical", the logical value is returned.
Returns
string
- It is assumed that the metadata value that is addressed exists. If not, we return the empty string.
def getValues(self, table, record, fieldSpecs)
-
Puts several pieces of metadata on the web page.
Parameters
fieldSpecs
:string
,
-separated list of fieldSpecstable
:string
- The relevant table
record
:string | ObjectId | AttrDict | void
- The relevant record
Returns
string
- The join of the individual results of retrieving metadata value.
def getViewerFile(self, path)
-
Gets a viewer-related file from the file system.
This is about files that are part of the viewer software.
The viewer software is located in a specific directory on the server. This is the viewer base.
Parameters
path
:string
- The path of the viewer file within viewer base.
Returns
string
- The full path to the viewer file, if it exists. Otherwise, we raise an error that will lead to a 404 response.
def linkUser(self, table, recordId)
-
Links a user in certain role to a project/edition record.
The user and role are given by the request.
Parameters
table
:string
- The relevant table.
recordId
:string
- The id of the relevant record, which can be used to locate the cross record between the user table and the project/edition record where the user's role is stored.
Returns
dict
-
Containsthe following keys:
status
: whether the save action was successfulmessages
: messages issued during the processupdated
: if the action was successful, all user management info will be passed back and will replace the currently displayed material.
def precheck(self, record)
-
Check the articles of an edition prior to publishing.
Parameters
record
:string
- The record of the edition to be checked.
Return
response A status response.
It will also generate a a bunch of toc files in the edition.
def processModelZip(self, zf, destDir)
-
Processes zip data with a scene and model files.
All files in the zip file will be examined, and those with extension svx.json will be saved as scene.svx.json at top level and those with extensions glb of gltf will be saved under their own names, also at top level.
All other files will be saved as is, unless they have extension .svx.json, or .gltf or .glb.
These files can end up in subdirectories.
We do not check the file types of the member files other than the svx.json files and the model files (glb, gltf). If the file type for these files does not match their extensions, they will be ignored.
The user is held responsible to submit a suitable file.
Parameters
zf
:bytes
- The raw zip data
def publish(self, record, force)
-
Publish an edition.
Parameters
record
:string
- The record of the item to be published.
force
:boolean
- If True, ignore when some checks fail
Return
response A publish status response.
def republish(self, record, force)
-
Re-ublish an edition.
Parameters
record
:string
- The record of the item to be re-published.
force
:boolean
- If True, ignore when some checks fail
Return
response A re-publish status response.
def saveFile(self, record, key, path, fileName, targetFileName=None)
-
Saves a file in the context given by a record.
The parameter
key
refers to a configuration section in the datamodel. This determines what file type to expect. We only accept files whose name has an extension that matches the expected file type.The key
modelz
expects a zip file with the files of an edition, in particular a scene file and model files. We make sure that these files have the proper type, and we also perform checks on the other parts of the zip file, namely whether they have decent paths.Parameters
record
:string | ObjectId | AttrDict | void
- The relevant record.
key
:string
- The upload key
path
:string
- The path from the context directory to the file
fileName
:string
- Name of the file to be saved as mentioned in the request.
targetFileName
:string
, optionalNone
- The name of the file as which the uploaded file will be saved; if None, the file will be saved with the name from the request.
Return
response A json response with the status of the save operation:
* a boolean: whether the save succeeded * a list of messages to display * content: new content for an upload control (only if successful)
def saveRole(self, user, table, recordId)
-
Saves a role into a user or cross table record.
The role is given by the request.
Parameters
user
:string
- The eppn of the user.
table
:string | void
- The relevant table. If not None, it indicates whether we are updating site-wide roles, otherwise project/edition roles.
recordId
:string | void
- The id of the relevant record. If not None, it is a project/edition record Id, which can be used to locate the cross record between the user table and the project/edition record where the user's role is stored. If None, the user's role is inside the user record.
Returns
dict
-
Contains the following keys:
status
: whether the save action was successfulmessages
: messages issued during the processupdated
: if the action was successful, all user management info will be passed back and will replace the currently displayed material.
def saveValue(self, table, record, key)
-
Saves a value of into a record.
A record is a document, which is a (nested) dict. A value is inserted somewhere (deep) in that dict.
The value is given by the request.
Where exactly is given by a path that is stored in the field information, which is accessible by the key.
If we save a value to an edition record, and the record has not yet a date created, we fill in today for the date created.
Parameters
table
:string
- The relevant table.
record
:string | ObjectId | AttrDict | void
- The relevant record.
key
:string
- an identifier for the meta data field.
Returns
dict
-
Contains the following keys:
status
: whether the save action was successfulmessages
: messages issued during the processreadonly
: the html of the updated formatted value, this will replace the currently displayed value.
def unpublish(self, record)
-
Unpublish an edition.
Parameters
record
:string
- The record of the item to be unpublished.
Return
response An unpublish status response.
Inherited members