Page Menu
Home
Search
Configure Global Search
Log In
Files
F13081272
routes.py
No One
Temporary
Actions
Download File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Size
23 KB
Subscribers
None
routes.py
View Options
import
os
import
json
import
logging
from
datetime
import
datetime
import
pillarsdk
from
pillarsdk
import
Node
from
pillarsdk
import
Project
from
pillarsdk.exceptions
import
ResourceNotFound
from
pillarsdk.exceptions
import
ForbiddenAccess
from
flask
import
Blueprint
,
current_app
from
flask
import
redirect
from
flask
import
render_template
from
flask
import
url_for
from
flask
import
request
from
flask
import
jsonify
from
flask
import
abort
from
flask_login
import
current_user
from
werkzeug.exceptions
import
NotFound
from
wtforms
import
SelectMultipleField
from
flask.ext.login
import
login_required
from
jinja2.exceptions
import
TemplateNotFound
from
pillar.web.utils
import
caching
from
pillar.web.nodes.forms
import
get_node_form
from
pillar.web.nodes.forms
import
process_node_form
from
pillar.web.nodes.custom.storage
import
StorageNode
from
pillar.web.projects.routes
import
project_update_nodes_list
from
pillar.web.utils
import
get_file
from
pillar.web.utils.jstree
import
jstree_build_children
from
pillar.web.utils.jstree
import
jstree_build_from_node
from
pillar.web.utils.forms
import
ProceduralFileSelectForm
from
pillar.web.utils.forms
import
build_file_select_form
from
pillar.web
import
system_util
blueprint
=
Blueprint
(
'nodes'
,
__name__
)
log
=
logging
.
getLogger
(
__name__
)
def
get_node
(
node_id
,
user_id
):
api
=
system_util
.
pillar_api
()
node
=
Node
.
find
(
node_id
+
'/?embedded={"node_type":1}'
,
api
=
api
)
return
node
.
to_dict
()
def
get_node_children
(
node_id
,
node_type_name
,
user_id
):
"""This function is currently unused since it does not give significant
performance improvements.
"""
api
=
system_util
.
pillar_api
()
if
node_type_name
==
'group'
:
published_status
=
',"properties.status": "published"'
else
:
published_status
=
''
children
=
Node
.
all
({
'where'
:
'{"parent": "
%s
"
%s
}'
%
(
node_id
,
published_status
),
'embedded'
:
'{"node_type": 1}'
},
api
=
api
)
return
children
.
to_dict
()
@blueprint.route
(
"/<node_id>/jstree"
)
def
jstree
(
node_id
):
"""JsTree view.
This return a lightweight version of the node, to be used by JsTree in the
frontend. We have two possible cases:
- https://pillar/<node_id>/jstree (construct the whole
expanded tree starting from the node_id. Use only once)
- https://pillar/<node_id>/jstree&children=1 (deliver the
children of a node - use in the navigation of the tree)
"""
# Get node with basic embedded data
api
=
system_util
.
pillar_api
()
node
=
Node
.
find
(
node_id
,
{
'projection'
:
{
'name'
:
1
,
'node_type'
:
1
,
'parent'
:
1
,
'project'
:
1
,
'properties.content_type'
:
1
,
}
},
api
=
api
)
if
request
.
args
.
get
(
'children'
)
!=
'1'
:
return
jsonify
(
items
=
jstree_build_from_node
(
node
))
if
node
.
node_type
==
'storage'
:
storage
=
StorageNode
(
node
)
# Check if we specify a path within the storage
path
=
request
.
args
.
get
(
'path'
)
# Generate the storage listing
listing
=
storage
.
browse
(
path
)
# Inject the current node id in the response, so that JsTree can
# expose the storage_node property and use it for further queries
listing
[
'storage_node'
]
=
node
.
_id
if
'children'
in
listing
:
for
child
in
listing
[
'children'
]:
child
[
'storage_node'
]
=
node
.
_id
return
jsonify
(
listing
)
return
jsonify
(
jstree_build_children
(
node
))
@blueprint.route
(
"/<node_id>/view"
)
def
view
(
node_id
):
api
=
system_util
.
pillar_api
()
# Get node, we'll embed linked objects later.
try
:
node
=
Node
.
find
(
node_id
,
api
=
api
)
except
ResourceNotFound
:
return
render_template
(
'errors/404_embed.html'
)
except
ForbiddenAccess
:
return
render_template
(
'errors/403_embed.html'
)
node_type_name
=
node
.
node_type
if
node_type_name
==
'post'
:
# Posts shouldn't be shown at this route, redirect to the correct one.
return
redirect
(
url_for_node
(
node
=
node
))
# Set the default name of the template path based on the node name
template_path
=
os
.
path
.
join
(
'nodes'
,
'custom'
,
node_type_name
)
# Set the default action for a template. By default is view and we override
# it only if we are working storage nodes, where an 'index' is also possible
template_action
=
'view'
def
allow_link
():
"""Helper function to cross check if the user is authenticated, and it
is has the 'subscriber' role. Also, we check if the node has world GET
permissions, which means it's free.
"""
# Check if node permissions for the world exist (if node is free)
if
node
.
permissions
and
node
.
permissions
.
world
:
return
'GET'
in
node
.
permissions
.
world
if
current_user
.
is_authenticated
:
allowed_roles
=
{
u'subscriber'
,
u'demo'
,
u'admin'
}
return
bool
(
allowed_roles
.
intersection
(
current_user
.
roles
or
()))
return
False
link_allowed
=
allow_link
()
node_type_handlers
=
{
'asset'
:
_view_handler_asset
,
'storage'
:
_view_handler_storage
,
'texture'
:
_view_handler_texture
,
'hdri'
:
_view_handler_hdri
,
}
if
node_type_name
in
node_type_handlers
:
handler
=
node_type_handlers
[
node_type_name
]
template_path
,
template_action
=
handler
(
node
,
template_path
,
template_action
,
link_allowed
)
# Fetch linked resources.
node
.
picture
=
get_file
(
node
.
picture
,
api
=
api
)
node
.
user
=
node
.
user
and
pillarsdk
.
User
.
find
(
node
.
user
,
api
=
api
)
try
:
node
.
parent
=
node
.
parent
and
pillarsdk
.
Node
.
find
(
node
.
parent
,
api
=
api
)
except
ForbiddenAccess
:
# This can happen when a node has world-GET, but the parent doesn't.
node
.
parent
=
None
# Get children
children_projection
=
{
'project'
:
1
,
'name'
:
1
,
'picture'
:
1
,
'parent'
:
1
,
'node_type'
:
1
,
'properties.order'
:
1
,
'properties.status'
:
1
,
'user'
:
1
,
'properties.content_type'
:
1
}
children_where
=
{
'parent'
:
node
.
_id
}
if
node_type_name
==
'group'
:
children_where
[
'properties.status'
]
=
'published'
children_projection
[
'permissions.world'
]
=
1
else
:
children_projection
[
'properties.files'
]
=
1
children_projection
[
'properties.is_tileable'
]
=
1
try
:
children
=
Node
.
all
({
'projection'
:
children_projection
,
'where'
:
children_where
,
'sort'
:
[(
'properties.order'
,
1
),
(
'name'
,
1
)]},
api
=
api
)
except
ForbiddenAccess
:
return
render_template
(
'errors/403_embed.html'
)
children
=
children
.
_items
for
child
in
children
:
child
.
picture
=
get_file
(
child
.
picture
,
api
=
api
)
if
request
.
args
.
get
(
'format'
)
==
'json'
:
node
=
node
.
to_dict
()
node
[
'url_edit'
]
=
url_for
(
'nodes.edit'
,
node_id
=
node
[
'_id'
])
return
jsonify
({
'node'
:
node
,
'children'
:
children
.
to_dict
()
if
children
else
{},
'parent'
:
node
[
'parent'
]
if
'parent'
in
node
else
{}
})
if
't'
in
request
.
args
:
template_path
=
os
.
path
.
join
(
'nodes'
,
'custom'
,
'asset'
)
template_action
=
'view_theatre'
template_path
=
'{0}/{1}_embed.html'
.
format
(
template_path
,
template_action
)
try
:
return
render_template
(
template_path
,
node_id
=
node
.
_id
,
node
=
node
,
parent
=
node
.
parent
,
children
=
children
,
config
=
current_app
.
config
,
api
=
api
)
except
TemplateNotFound
:
log
.
error
(
'Template
%s
does not exist for node type
%s
'
,
template_path
,
node_type_name
)
return
render_template
(
'nodes/error_type_not_found.html'
,
node_id
=
node
.
_id
,
node
=
node
,
parent
=
node
.
parent
,
children
=
children
,
config
=
current_app
.
config
,
api
=
api
)
def
_view_handler_asset
(
node
,
template_path
,
template_action
,
link_allowed
):
# Attach the file document to the asset node
node_file
=
get_file
(
node
.
properties
.
file
)
node
.
file
=
node_file
# Remove the link to the file if it's not allowed.
if
node_file
and
not
link_allowed
:
node
.
file
.
link
=
None
if
node_file
and
node_file
.
content_type
is
not
None
:
asset_type
=
node_file
.
content_type
.
split
(
'/'
)[
0
]
else
:
asset_type
=
None
if
asset_type
==
'video'
:
# Process video type and select video template
if
link_allowed
:
sources
=
[]
if
node_file
and
node_file
.
variations
:
for
f
in
node_file
.
variations
:
sources
.
append
({
'type'
:
f
.
content_type
,
'src'
:
f
.
link
})
# Build a link that triggers download with proper filename
# TODO: move this to Pillar
if
f
.
backend
==
'cdnsun'
:
f
.
link
=
"{0}&name={1}.{2}"
.
format
(
f
.
link
,
node
.
name
,
f
.
format
)
node
.
video_sources
=
json
.
dumps
(
sources
)
node
.
file_variations
=
node_file
.
variations
else
:
node
.
video_sources
=
None
node
.
file_variations
=
None
elif
asset_type
!=
'image'
:
# Treat it as normal file (zip, blend, application, etc)
asset_type
=
'file'
template_path
=
os
.
path
.
join
(
template_path
,
asset_type
)
return
template_path
,
template_action
def
_view_handler_storage
(
node
,
template_path
,
template_action
,
link_allowed
):
storage
=
StorageNode
(
node
)
path
=
request
.
args
.
get
(
'path'
)
listing
=
storage
.
browse
(
path
)
node
.
name
=
listing
[
'name'
]
listing
[
'storage_node'
]
=
node
.
_id
# If the item has children we are working with a group
if
'children'
in
listing
:
for
child
in
listing
[
'children'
]:
child
[
'storage_node'
]
=
node
.
_id
child
[
'name'
]
=
child
[
'text'
]
child
[
'content_type'
]
=
os
.
path
.
dirname
(
child
[
'type'
])
node
.
children
=
listing
[
'children'
]
template_action
=
'index'
else
:
node
.
status
=
'published'
node
.
length
=
listing
[
'size'
]
node
.
download_link
=
listing
[
'signed_url'
]
return
template_path
,
template_action
def
_view_handler_texture
(
node
,
template_path
,
template_action
,
link_allowed
):
for
f
in
node
.
properties
.
files
:
f
.
file
=
get_file
(
f
.
file
)
# Remove the link to the file if it's not allowed.
if
f
.
file
and
not
link_allowed
:
f
.
file
.
link
=
None
return
template_path
,
template_action
def
_view_handler_hdri
(
node
,
template_path
,
template_action
,
link_allowed
):
if
not
link_allowed
:
node
.
properties
.
files
=
None
else
:
for
f
in
node
.
properties
.
files
:
f
.
file
=
get_file
(
f
.
file
)
return
template_path
,
template_action
@blueprint.route
(
"/<node_id>/edit"
,
methods
=
[
'GET'
,
'POST'
])
@login_required
def
edit
(
node_id
):
"""Generic node editing form
"""
def
set_properties
(
dyn_schema
,
form_schema
,
node_properties
,
form
,
prefix
=
""
,
set_data
=
True
):
"""Initialize custom properties for the form. We run this function once
before validating the function with set_data=False, so that we can set
any multiselect field that was originally specified empty and fill it
with the current choices.
"""
for
prop
in
dyn_schema
:
schema_prop
=
dyn_schema
[
prop
]
form_prop
=
form_schema
.
get
(
prop
,
{})
prop_name
=
"{0}{1}"
.
format
(
prefix
,
prop
)
if
schema_prop
[
'type'
]
==
'dict'
:
set_properties
(
schema_prop
[
'schema'
],
form_prop
[
'schema'
],
node_properties
[
prop_name
],
form
,
"{0}__"
.
format
(
prop_name
))
continue
if
prop_name
not
in
form
:
continue
try
:
db_prop_value
=
node_properties
[
prop
]
except
KeyError
:
log
.
debug
(
'
%s
not found in form for node
%s
'
,
prop_name
,
node_id
)
continue
if
schema_prop
[
'type'
]
==
'datetime'
:
db_prop_value
=
datetime
.
strptime
(
db_prop_value
,
current_app
.
config
[
'RFC1123_DATE_FORMAT'
])
if
isinstance
(
form
[
prop_name
],
SelectMultipleField
):
# If we are dealing with a multiselect field, check if
# it's empty (usually because we can't query the whole
# database to pick all the choices). If it's empty we
# populate the choices with the actual data.
if
not
form
[
prop_name
]
.
choices
:
form
[
prop_name
]
.
choices
=
[(
d
,
d
)
for
d
in
db_prop_value
]
# Choices should be a tuple with value and name
# Assign data to the field
if
set_data
:
if
prop_name
==
'attachments'
:
for
attachment_collection
in
db_prop_value
:
for
a
in
attachment_collection
[
'files'
]:
attachment_form
=
ProceduralFileSelectForm
()
attachment_form
.
file
=
a
[
'file'
]
attachment_form
.
slug
=
a
[
'slug'
]
attachment_form
.
size
=
'm'
form
[
prop_name
]
.
append_entry
(
attachment_form
)
elif
prop_name
==
'files'
:
schema
=
schema_prop
[
'schema'
][
'schema'
]
# Extra entries are caused by min_entries=1 in the form
# creation.
field_list
=
form
[
prop_name
]
if
len
(
db_prop_value
)
>
0
:
while
len
(
field_list
):
field_list
.
pop_entry
()
for
file_data
in
db_prop_value
:
file_form_class
=
build_file_select_form
(
schema
)
subform
=
file_form_class
()
for
key
,
value
in
file_data
.
iteritems
():
setattr
(
subform
,
key
,
value
)
field_list
.
append_entry
(
subform
)
# elif prop_name == 'tags':
# form[prop_name].data = ', '.join(data)
else
:
form
[
prop_name
]
.
data
=
db_prop_value
else
:
# Default population of multiple file form list (only if
# we are getting the form)
if
request
.
method
==
'POST'
:
continue
if
prop_name
==
'attachments'
:
if
not
db_prop_value
:
attachment_form
=
ProceduralFileSelectForm
()
attachment_form
.
file
=
'file'
attachment_form
.
slug
=
''
attachment_form
.
size
=
''
form
[
prop_name
]
.
append_entry
(
attachment_form
)
api
=
system_util
.
pillar_api
()
node
=
Node
.
find
(
node_id
,
api
=
api
)
project
=
Project
.
find
(
node
.
project
,
api
=
api
)
node_type
=
project
.
get_node_type
(
node
.
node_type
)
form
=
get_node_form
(
node_type
)
user_id
=
current_user
.
objectid
dyn_schema
=
node_type
[
'dyn_schema'
]
.
to_dict
()
form_schema
=
node_type
[
'form_schema'
]
.
to_dict
()
error
=
""
node_properties
=
node
.
properties
.
to_dict
()
ensure_lists_exist_as_empty
(
node
.
to_dict
(),
node_type
)
set_properties
(
dyn_schema
,
form_schema
,
node_properties
,
form
,
set_data
=
False
)
if
form
.
validate_on_submit
():
if
process_node_form
(
form
,
node_id
=
node_id
,
node_type
=
node_type
,
user
=
user_id
):
# Handle the specific case of a blog post
if
node_type
.
name
==
'post'
:
project_update_nodes_list
(
node
,
list_name
=
'blog'
)
else
:
project_update_nodes_list
(
node
)
# Emergency hardcore cache flush
# cache.clear()
return
redirect
(
url_for
(
'nodes.view'
,
node_id
=
node_id
,
embed
=
1
,
_external
=
True
,
_scheme
=
current_app
.
config
[
'SCHEME'
]))
else
:
log
.
debug
(
'Error sending data to Pillar, see Pillar logs.'
)
error
=
'Server error'
else
:
if
form
.
errors
:
log
.
debug
(
'Form errors:
%s
'
,
form
.
errors
)
# Populate Form
form
.
name
.
data
=
node
.
name
form
.
description
.
data
=
node
.
description
if
'picture'
in
form
:
form
.
picture
.
data
=
node
.
picture
if
node
.
parent
:
form
.
parent
.
data
=
node
.
parent
set_properties
(
dyn_schema
,
form_schema
,
node_properties
,
form
)
# Get previews
node
.
picture
=
get_file
(
node
.
picture
,
api
=
api
)
if
node
.
picture
else
None
# Get Parent
try
:
parent
=
Node
.
find
(
node
[
'parent'
],
api
=
api
)
except
KeyError
:
parent
=
None
except
ResourceNotFound
:
parent
=
None
embed_string
=
''
# Check if we want to embed the content via an AJAX call
if
request
.
args
.
get
(
'embed'
):
if
request
.
args
.
get
(
'embed'
)
==
'1'
:
# Define the prefix for the embedded template
embed_string
=
'_embed'
template
=
'{0}/edit{1}.html'
.
format
(
node_type
[
'name'
],
embed_string
)
# We should more simply check if the template file actually exsists on
# the filesystem level
try
:
return
render_template
(
template
,
node
=
node
,
parent
=
parent
,
form
=
form
,
errors
=
form
.
errors
,
error
=
error
,
api
=
api
)
except
TemplateNotFound
:
template
=
'nodes/edit{1}.html'
.
format
(
node_type
[
'name'
],
embed_string
)
return
render_template
(
template
,
node
=
node
,
parent
=
parent
,
form
=
form
,
errors
=
form
.
errors
,
error
=
error
,
api
=
api
)
def
ensure_lists_exist_as_empty
(
node_doc
,
node_type
):
"""Ensures that any properties of type 'list' exist as empty lists.
This allows us to iterate over lists without worrying that they
are set to None. Only works for top-level list properties.
"""
node_properties
=
node_doc
.
setdefault
(
'properties'
,
{})
for
prop
,
schema
in
node_type
.
dyn_schema
.
to_dict
()
.
iteritems
():
if
schema
[
'type'
]
!=
'list'
:
continue
if
node_properties
.
get
(
prop
)
is
None
:
node_properties
[
prop
]
=
[]
@blueprint.route
(
'/create'
,
methods
=
[
'POST'
])
@login_required
def
create
():
"""Create a node. Requires a number of params:
- project id
- node_type
- parent node (optional)
"""
if
request
.
method
!=
'POST'
:
return
abort
(
403
)
project_id
=
request
.
form
[
'project_id'
]
parent_id
=
request
.
form
.
get
(
'parent_id'
)
node_type_name
=
request
.
form
[
'node_type_name'
]
api
=
system_util
.
pillar_api
()
# Fetch the Project or 404
try
:
project
=
Project
.
find
(
project_id
,
api
=
api
)
except
ResourceNotFound
:
return
abort
(
404
)
node_type
=
project
.
get_node_type
(
node_type_name
)
node_type_name
=
'folder'
if
node_type
[
'name'
]
==
'group'
else
\
node_type
[
'name'
]
node_props
=
dict
(
name
=
'New {}'
.
format
(
node_type_name
),
project
=
project
[
'_id'
],
user
=
current_user
.
objectid
,
node_type
=
node_type
[
'name'
],
properties
=
{}
)
if
parent_id
:
node_props
[
'parent'
]
=
parent_id
ensure_lists_exist_as_empty
(
node_props
,
node_type
)
node
=
Node
(
node_props
)
node
.
create
(
api
=
api
)
return
jsonify
(
status
=
'success'
,
data
=
dict
(
asset_id
=
node
[
'_id'
]))
@blueprint.route
(
"/<node_id>/redir"
)
def
redirect_to_context
(
node_id
):
"""Redirects to the context URL of the node.
Comment: redirects to whatever the comment is attached to + #node_id
(unless 'whatever the comment is attached to' already contains '#', then
'#node_id' isn't appended)
Post: redirects to main or project-specific blog post
Other: redirects to project.url + #node_id
"""
if
node_id
.
lower
()
==
'{{objectid}}'
:
log
.
warning
(
"JavaScript should have filled in the ObjectID placeholder, but didn't. "
"URL=
%s
and referrer=
%s
"
,
request
.
url
,
request
.
referrer
)
raise
NotFound
(
'Invalid ObjectID'
)
try
:
url
=
url_for_node
(
node_id
)
except
ValueError
as
ex
:
log
.
warning
(
"
%s
: URL=
%s
and referrer=
%s
"
,
str
(
ex
),
request
.
url
,
request
.
referrer
)
raise
NotFound
(
'Invalid ObjectID'
)
return
redirect
(
url
)
def
url_for_node
(
node_id
=
None
,
node
=
None
):
assert
isinstance
(
node_id
,
(
basestring
,
type
(
None
)))
api
=
system_util
.
pillar_api
()
# Find node by its ID, or the ID by the node, depending on what was passed
# as parameters.
if
node
is
None
:
try
:
node
=
Node
.
find
(
node_id
,
api
=
api
)
except
ResourceNotFound
:
log
.
warning
(
'url_for_node(node_id=
%r
, node=None): Unable to find node.'
,
node_id
)
raise
ValueError
(
'Unable to find node
%r
'
%
node_id
)
elif
node_id
is
None
:
node_id
=
node
[
'_id'
]
else
:
raise
ValueError
(
'Either node or node_id must be given'
)
return
_find_url_for_node
(
node_id
,
node
=
node
)
@caching.cache_for_request
()
def
project_url
(
project_id
,
project
):
"""Returns the project, raising a ValueError if it can't be found.
Uses the "urler" service endpoint.
"""
if
project
is
not
None
:
return
project
urler_api
=
system_util
.
pillar_api
(
token
=
current_app
.
config
[
'URLER_SERVICE_AUTH_TOKEN'
])
return
Project
.
find_from_endpoint
(
'/service/urler/
%s
'
%
project_id
,
api
=
urler_api
)
# Cache the actual URL based on the node ID, for the duration of the request.
@caching.cache_for_request
()
def
_find_url_for_node
(
node_id
,
node
):
api
=
system_util
.
pillar_api
()
# Find the node's project, or its ID, depending on whether a project
# was embedded. This is needed in two of the three finder functions.
project_id
=
node
.
project
if
isinstance
(
project_id
,
pillarsdk
.
Resource
):
# Embedded project
project
=
project_id
project_id
=
project
[
'_id'
]
else
:
project
=
None
def
find_for_comment
():
"""Returns the URL for a comment."""
parent
=
node
while
parent
.
node_type
==
'comment'
:
if
isinstance
(
parent
.
parent
,
pillarsdk
.
Resource
):
parent
=
parent
.
parent
continue
try
:
parent
=
Node
.
find
(
parent
.
parent
,
api
=
api
)
except
ResourceNotFound
:
log
.
warning
(
'url_for_node(node_id=
%r
): Unable to find parent node
%r
'
,
node_id
,
parent
.
parent
)
raise
ValueError
(
'Unable to find parent node
%r
'
%
parent
.
parent
)
# Find the redirection URL for the parent node.
parent_url
=
url_for_node
(
node
=
parent
)
if
'#'
in
parent_url
:
# We can't attach yet another fragment, so just don't link to
# the comment for now.
return
parent_url
return
parent_url
+
'#{}'
.
format
(
node_id
)
def
find_for_post
():
"""Returns the URL for a blog post."""
if
str
(
project_id
)
==
current_app
.
config
[
'MAIN_PROJECT_ID'
]:
return
url_for
(
'main.main_blog'
,
url
=
node
.
properties
.
url
)
the_project
=
project_url
(
project_id
,
project
=
project
)
return
url_for
(
'main.project_blog'
,
project_url
=
the_project
.
url
,
url
=
node
.
properties
.
url
)
# Fallback: Assets, textures, and other node types.
def
find_for_other
():
the_project
=
project_url
(
project_id
,
project
=
project
)
return
url_for
(
'projects.view_node'
,
project_url
=
the_project
.
url
,
node_id
=
node_id
)
# Determine which function to use to find the correct URL.
url_finders
=
{
'comment'
:
find_for_comment
,
'post'
:
find_for_post
,
}
finder
=
url_finders
.
get
(
node
.
node_type
,
find_for_other
)
return
finder
()
# Import of custom modules (using the same nodes decorator)
import
custom.comments
import
custom.groups
import
custom.storage
import
custom.posts
File Metadata
Details
Attached
Mime Type
text/x-python
Expires
Thu, May 19, 10:45 AM (1 d, 23 h)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
96/fe/cbab57d5cdf302c804f583c29616
Attached To
rPS Pillar
Event Timeline
Log In to Comment