Package: oarepo-records-draft
Invenio Records DraftBeta version, use at your own risk!!!What the library doesEasily adds draft (a.k.a deposit) to all your invenio data models. REST API stays the same with extra operations
for publish, unpublish, edit published record. REST example (some links omitted for brevity):$curl--header"Content-Type: application/json"\--requestPOST\--data'{"title":"blah"}'\https://localhost:5000/api/draft/records/

returns:{"metadata":{"title":"blah","oarepo:validity":{"valid":false,"marshmallow":["field":"title","message":"too short"]}}}$curl--header"Content-Type: application/json"\--requestPUT\--data'{"title":"longer blah"}'\https://localhost:5000/api/draft/records/1

returns:{"links":{"publish":"https://localhost:5000/api/draft/records/1/publish/"},"metadata":{"title":"longer blah","oarepo:validity":{"valid":true}}}$curl--requestPOST\https://localhost:5000/api/draft/records/1/publish/

returns:302Locationhttps://localhost:5000/api/records/1Installationpipinstalloarepo-records-draftoarepo-validateConfigurationTo enable the library for your data model, you have to perform the following steps:write the "published" version of your model, including marshmallow, mapping and json schemas.Inherit the record fromSchemaKeepingRecordMixinandMarshmallowValidatedRecordMixinfromoarepo-validatemodules. Seeoarepo-validatelibrary for details on on-record validation vs. rest-access validation.Have a look ata sample recordDrop marshmallow loader & serializer from loaders and serializers, see oarepo-validate for detailsMove the configuration of rest endpoint fromRECORDS_REST_ENDPOINTStoRECORDS_DRAFT_ENDPOINTS.
Add"draft": "<draftpid>"to the configuration, wheredraftpidis any unused pid type
less or equal 6 chars in length.RECORDS_DRAFT_ENDPOINTS={# <--- moved here'recid':dict(draft='drecid',# <--- added herepid_type='recid',pid_minter='recid',pid_fetcher='recid',list_route='/records/',item_route='/records/<{0}:pid_value>'.format(RECORD_PID),# rest of the stuff here),}RECORDS_REST_ENDPOINTS={}# <--- made emptyDo not forget to propagate this variable to the application's config (for example, in ext/init_config)Add endpoint for draft to the same dict:RECORDS_DRAFT_ENDPOINTS={'recid':dict(draft='drecid',# as above),'drecid':dict(# <--- added here)}move create/update/delete permission factories from the published endpoint to the draft onepublished record will automatically getdeny_allfor all modification operationsin the example below everyone can create/update/delete draft record, which is probably not what you wantRECORDS_DRAFT_ENDPOINTS={'recid':dict(# as above),'drecid':dict(# <--- moved herecreate_permission_factory_imp=allow_all,delete_permission_factory_imp=allow_all,update_permission_factory_imp=allow_all,)}on published endpoint, add permission factories for publish/unpublish/editRECORDS_DRAFT_ENDPOINTS={'recid':dict(# as above                      # <--- added herepublish_permission_factory_imp=allow_logged_in,unpublish_permission_factory_imp=allow_logged_in,edit_permission_factory_imp=allow_logged_in,),'drecid':dict(create_permission_factory_imp=allow_all,delete_permission_factory_imp=allow_all,update_permission_factory_imp=allow_all,)}Runinvenio index init/create, start server and you're done. A new endpoint has been created for you
and is at/api/draft/records. The whole configuration is insample appLibrary principles:Draft records follow the same json schema as published records with the exception
that:invalid records are stored and indexedall properties are not required even though they are marked as such.Extra properties not defined in marshmallow/json schema can be stored but are
marked invalid and the properties are not indexed in ES."Draft" records live at a different endpoint and different ES index
than published ones. The recommended URL is/api/recordsfor the
published records and/api/draft/recordsfor draftsDraft and published records share the same value of pid but have two
different pid typesPublished records can not be directly created/updated/patched. Draft
records can be created/updated/patched.Invenio record containsLinkheader andlinkssection in the
JSON payload. Links of a published record contain (apart fromself):draft- a url that links to the "draft" version of the
record. This url is present only if the draft version of the
record exists and the caller has the rights to edit the draftedit- URL to a handler that creates a draft version of the
record and then returns HTTP 302 redirect to the draft
version. This url is present only if the draft version does
not existunpublish- URL to a handler that creates a draft version of
the record if it does not exist, deletes the published version
and then returns HTTP 302 to the draft.On a draft record thelinkscontain (apart fromself):published- a url that links to the "published" version of
the record. This url is present only if the published version
of the record existspublish- a POST to this url publishes the record. The
JSONSchema and marshmallow schema of the published record must
pass. After the publishing the draft record is deleted. HTTP
302 is returned pointing to the published record.The serialized representation of a draft record contains a section
namedoarepo:validity. This section contains the result
of marshmallow and JSONSchema validation against original schemas.Deletion of a published record does not delete the draft record.Deletion of a draft record does not delete the published record.All properties on published RECORDS_REST_ENDPOINTS are propagated to the draft endpoint,
some of those modified. For a complete list/algorithm, seesetup_draft_endpoint method in endpoints.pyREST APINormal invenio-records-rest API is available both ondraftandpublishedendpoints,
with the exception that published endpoints have the implicit permissions ofcreate/update/deleteoperations set todeny_all.Publishing draft recordLet's create a record (security not shown here):$curl--header"Content-Type: application/json"\--requestPOST\--data'{"title":"blah"}'\https://localhost:5000/api/draft/records/201Locationhttps://localhost:5000/api/draft/records/1And get it to see, if it is valid:$curlhttps://localhost:5000/api/draft/records/1{links:{self:https://localhost:5000/api/draft/records/1,publish:https://localhost:5000/api/draft/records/1/publish/},metadata:{"title":"blah","oarepo:validity":{"valid":true}}}As it is valid, we can publish the record (security not shown here):$curl--requestPOST\https://localhost:5000/api/draft/records/1/publish/302Locationhttps://localhost:5000/api/records/1And when retrieved, it does not contain the validation section:$curlhttps://localhost:5000/api/records/1{links:{self:https://localhost:5000/api/records/1,unpublish:https://localhost:5000/api/records/1/unpublish/,edit:https://localhost:5000/api/records/1/edit/},metadata:{"title":"blah"}}Editing published recordPublished record can not be edited in place, at first a draft record should be created. See the links
section above for edit url:$curl--requestPOST\https://localhost:5000/api/records/1/edit/302Locationhttps://localhost:5000/api/draft/records/1Now the published record is still available (and not modified) and you have a url for the
draft record for making your changes. When finished, runpublishaction above to publish
your changes.Unpublishing published recordTo remove a published record and "move" it to draft, call unpublish:$curl--requestPOST\https://localhost:5000/api/records/1/unpublish/302Locationhttps://localhost:5000/api/draft/records/1After this action, the published record is deleted (and says 410 gone) and draft record is created.
You can delete the draft record if desired or update and publish it again.FilesIfinvenio-files-restis installed, the configuration ofRECORDS_DRAFT_ENDPOINTSmight contain afilesection:RECORDS_DRAFT_ENDPOINTS={'recid':dict(draft='drecid',# ...),'drecid':dict(# ...files=dict(put_file_factory=Permission(RoleNeed('role1')),get_file_factory=Permission(RoleNeed('role1')),delete_file_factory=Permission(RoleNeed('role1')),# restricted=False,# as_attachment=False))}Permission factories have a signature:permission_factory(view: [oarepo_records_draft.actions.files.FileResource|FileListResource],
                   record: Record, 
                   key: str, 
                   file_object: invenio_record_files.api.FileObject)and returns an object withcan()method.Adding the factory creates urls in the same manner asinvenio-records-rest:/api/<records or draft records>/<recid>/files/
    GET  ... lists all the files uploaded to a record
    POST<form data with a file> ... uploads a file and associates metadata with the file

/api/<records>/<recid>/files/<key>
   GET  ... downloads file with the given key
   PUT  ... creates or updates file at the given key, payload is the file stream
   DELETE ... removes file at the given keyExtra REST actionsYou can add extra rest actions for each resource that are registered within draft blueprint.
They can be registered either globally via entry points (see python api later)
or locally in the configuration:RECORDS_DRAFT_ENDPOINTS={'recid':dict(draft='drecid',# ...),'drecid':dict(# ...actions={'path':<handler_function_registered_to_blueprint>,# ...})}Python APIThe entrypoint to python API is atoarepo_records_draft.current_drafts. It has the following
public methods:publish(record: Record, record_pid: PersistentIdentifier)Publishes an instance of draft record with draft pidrecord_pid.
Raises InvalidRecordException if the record is not valid (according to jsonschema/marshmallow)
and can not be published. Returns a list of(draft_record: Record, published_record_context: RecordContext)tuples.What it does:invokescollect_records_for_actionsignal to collect all records that should be published
(sometimes linked records should be published as well)callscheck_can_publishsignal for each collected recordcallsbefore_publishsignalfor each record in reversed collected records publishes the record and deletes draft onecallsafter_publishsignalfor each record removes draft record from elasticsearch and indexes published onerefreshes affected ES indicesunpublish(record: Record, record_pid: PersistentIdentifier)Removes published instance and creates a draft one.recordis the published record being
unpublished,record_pidis its persistent identifier.Returns a list of(published_record: Record, draft_record_context: RecordContext)tuples.What it does:invokescollect_records_for_actionsignal to collect all records that should be published
(sometimes linked records should be published as well)callscheck_can_unpublishsignal for each collected recordcallsbefore_unpublishsignalfor each record in reversed collected records removes the published record and creates draftcallsafter_unpublishsignalfor each record removes published record from elasticsearch and indexes draft onerefreshes affected ES indicesedit(record: Record, record_pid: PersistentIdentifier)Keeps published instance and creates a draft one.recordis the published record to be edited
,record_pidis its persistent identifier.Returns a list of(published_record: Record, draft_record_context: RecordContext)tuples.What it does:invokescollect_records_for_actionsignal to collect all records that should be published
(sometimes linked records should be published as well)callscheck_can_editsignal for each collected recordcallsbefore_editsignalfor each record in reversed collected records removes creates draft recordcallsafter_editsignalIndexes each created draft record in ESrefreshes affected ES indicesSignalsSeesignals.pyfor the exhaustive list of signalsGlobally-defined actionsTo define actions on all draft-managed resources, create your view and a factory
function. The function is called for  that returns a dictionary with actions for the given endpoint:classTestResource(MethodView):view_name='{endpoint}_test'@pass_recorddefget(self,pid,record):returnjsonify({'status':'ok'})defaction_factory(code,files,rest_endpoint,extra,is_draft):# decide if view should be created on this resource and return blueprint mapping# rest path -> viewreturn{'files/_test':TestResource.as_view(TestResource.view_name.format(endpoint=code))}Register the factory to entry points and all records will have an additional rest action
at path/api/<records>/1/files/_testentry_points={'oarepo_records_draft.extra_actions':['sample = sample.test:extras']}Custom uploadersSometimes you might want to define a custom uploader that createsrecord.files[key]instead
of the default implementation ofrecord.files[key] = <stream from request>. This might be
usable for example when the stream will be provided in a different way (direct link to S3,
for example).Create an uploader function (this one is from tests and replaces content fortest-uploaderkey):def uploader(record, key, files, pid, request, resolver, endpoint, **kwargs):
    if key == 'test-uploader':
        bt = BytesIO(b'blah')
        files[key] = bt
        return lambda: ({
            'test-uploader': True,
            'url': resolver(TestResource.view_name)
        })The function returns eitherNoneif it did not handle the upload or a no-arg function
that is used later to return JSON that will be serialized as HTTP 200/201 response.Register the function to entry points:entry_points={'oarepo_records_draft.uploaders':['sample = sample.test:uploader']}Facets and filtersTo add facets and filters for validation errors, import and useDRAFT_FACETSandDRAFT_FILTERSfromoarepo_records_draftimportDRAFT_FACETS,DRAFT_FILTERSRECORDS_REST_FACETS={'draft-records-record-v1.0.0':{'aggs':{# ...**DRAFT_FACETS},'filters':{# ...**DRAFT_FILTERS}}}Q&ACan I provide my own record class for draft records?Yes. The class has to inheritDraftRecordMixin,SchemaKeepingRecordMixinandMarshmallowValidatedRecordMixin.DraftRecordMixinshould be the first mixin.Setrecord_classproperty inRECORDS_DRAFT_ENDPOINTS/<your draft endpoint>Can I provide my own link factory for either draft or published record?Yes. Providelinks_factory_impas usual and it will get called. URLs for
publishing/unpublishing/editing will be added automaticallyCan I change XXX in rest config?Yes, you can. Some of the values are automatically provided for drafts, please
consultendpoints.pyto see if the value
should be modified for draft record or not.I have my ownrecord_to_indeximplementation. Is this library compatible?Might be. Seerecord.pyto see which index must be used
for draft records.I really need to raise exception when user submits incorrect dataThink twice if you really want to do it, as it prevents users to pass incomplete
record and work on it later.If you really want to abort the process, raise an instance ofFatalDraftExceptionfrom within the marshmallow validation. This exception will cause the validation to terminate
and the draft record will not be modified/created.You can use the exception to re-raise a custom exception, for example:fromoarepo_records_draftimportFatalDraftExceptionfromflaskimportaborttry:# do something that can lead toabort(403)exceptExceptionase:raiseFatalDraftException()fromeThe wrapped exception will later be retrieved and passed on to later stages of processing,
and the correct HTTP 403 error page/json will be sent to the user.