Skip to content

Entry Points

Main

Main export entry points and RDMO provider classes for MaRDMO.

Provides:

  • :class:BaseMaRDMOExportProvider — abstract base carrying the MaRDI Portal OAuth2 credentials and callback wiring shared by all providers that need to post to the portal.
  • :class:MaRDMOExportProvider — "Export to MaRDI Portal" button; handles preview and authenticated upload for the model, algorithm, and workflow documentation catalogs.
  • :class:MaRDMOQueryProvider — "Query MaRDI Portal" button; handles the search-catalog preview and result rendering without requiring OAuth2.

BaseMaRDMOExportProvider

Bases: OauthProviderMixin, Export, ABC

Base export provider wiring MaRDMO to the MaRDI Portal OAuth2 flow.

Provides the concrete MaRDI Portal URLs and credentials needed by :class:~MaRDMO.oauth2.OauthProviderMixin. Subclasses implement the catalog-specific :meth:render and :meth:submit_* methods.

Source code in MaRDMO/main.py
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
class BaseMaRDMOExportProvider(OauthProviderMixin, Export, ABC):
    '''Base export provider wiring MaRDMO to the MaRDI Portal OAuth2 flow.

    Provides the concrete MaRDI Portal URLs and credentials needed by
    :class:`~MaRDMO.oauth2.OauthProviderMixin`.  Subclasses implement
    the catalog-specific :meth:`render` and :meth:`submit_*` methods.
    '''
    @property
    def oauth2_client_id(self):
        '''OAuth2 client ID read from ``settings.MARDMO_PROVIDER["mardi"]``.'''
        return settings.MARDMO_PROVIDER['mardi']['oauth2_client_id']

    @property
    def oauth2_client_secret(self):
        '''OAuth2 client secret read from ``settings.MARDMO_PROVIDER["mardi"]``.'''
        return settings.MARDMO_PROVIDER['mardi']['oauth2_client_secret']

    @property
    def authorize_url(self):
        '''MaRDI Portal OAuth2 authorization endpoint URL.'''
        return f"{get_url('mardi', 'uri')}/w/rest.php/oauth2/authorize"

    @property
    def token_url(self):
        '''MaRDI Portal OAuth2 token exchange endpoint URL.'''
        return f"{get_url('mardi', 'uri')}/w/rest.php/oauth2/access_token"

    @property
    def redirect_path(self):
        '''Callback path registered with the MaRDI Portal OAuth2 application.'''
        return reverse('oauth_callback', args=['wikibase'])

    def get_authorize_params(self, request, state):
        '''Return OAuth2 authorization query parameters for the MaRDI Portal.

        Args:
            request: Django HTTP request (unused — client ID is from settings).
            state:   CSRF state token generated by the OAuth2 flow.

        Returns:
            Dict with ``response_type``, ``client_id``, and ``state`` keys.
        '''
        return {
            'response_type': 'code',
            'client_id': self.oauth2_client_id,
            'state': state
        }

    def get_callback_data(self, request):
        '''Return POST body fields for the MaRDI Portal token exchange request.

        Args:
            request: Django HTTP request supplying the ``code`` GET parameter.

        Returns:
            Dict with ``client_id``, ``client_secret``, ``grant_type``, and
            ``code`` fields.
        '''
        return {
            'client_id': self.oauth2_client_id,
            'client_secret': self.oauth2_client_secret,
            'grant_type': 'authorization_code',
            'code': request.GET.get('code')
        }

authorize_url property

MaRDI Portal OAuth2 authorization endpoint URL.

oauth2_client_id property

OAuth2 client ID read from settings.MARDMO_PROVIDER["mardi"].

oauth2_client_secret property

OAuth2 client secret read from settings.MARDMO_PROVIDER["mardi"].

redirect_path property

Callback path registered with the MaRDI Portal OAuth2 application.

token_url property

MaRDI Portal OAuth2 token exchange endpoint URL.

get_authorize_params(request, state)

Return OAuth2 authorization query parameters for the MaRDI Portal.

Parameters:

Name Type Description Default
request

Django HTTP request (unused — client ID is from settings).

required
state

CSRF state token generated by the OAuth2 flow.

required

Returns:

Type Description

Dict with response_type, client_id, and state keys.

Source code in MaRDMO/main.py
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
def get_authorize_params(self, request, state):
    '''Return OAuth2 authorization query parameters for the MaRDI Portal.

    Args:
        request: Django HTTP request (unused — client ID is from settings).
        state:   CSRF state token generated by the OAuth2 flow.

    Returns:
        Dict with ``response_type``, ``client_id``, and ``state`` keys.
    '''
    return {
        'response_type': 'code',
        'client_id': self.oauth2_client_id,
        'state': state
    }

get_callback_data(request)

Return POST body fields for the MaRDI Portal token exchange request.

Parameters:

Name Type Description Default
request

Django HTTP request supplying the code GET parameter.

required

Returns:

Type Description

Dict with client_id, client_secret, grant_type, and

code fields.

Source code in MaRDMO/main.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def get_callback_data(self, request):
    '''Return POST body fields for the MaRDI Portal token exchange request.

    Args:
        request: Django HTTP request supplying the ``code`` GET parameter.

    Returns:
        Dict with ``client_id``, ``client_secret``, ``grant_type``, and
        ``code`` fields.
    '''
    return {
        'client_id': self.oauth2_client_id,
        'client_secret': self.oauth2_client_secret,
        'grant_type': 'authorization_code',
        'code': request.GET.get('code')
    }

MaRDMOExportProvider

Bases: BaseMaRDMOExportProvider

Export provider for the "Export to MaRDI Portal" button.

Handles preview and authenticated Wikibase upload for the model, algorithm, and workflow documentation catalogs. Search is handled separately by :class:MaRDMOQueryProvider.

Attributes:

Name Type Description
request HttpRequest

The Django HttpRequest associated with the provider.

Source code in MaRDMO/main.py
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
class MaRDMOExportProvider(BaseMaRDMOExportProvider):
    '''Export provider for the "Export to MaRDI Portal" button.

    Handles preview and authenticated Wikibase upload for the model, algorithm,
    and workflow documentation catalogs.  Search is handled separately by
    :class:`MaRDMOQueryProvider`.

    Attributes:
        request: The Django HttpRequest associated with the provider.
    '''

    request: HttpRequest

    class ExportForm(forms.Form):
        '''Empty form class required by the RDMO Export provider interface.'''

    def render(self):
        '''Render the catalog-appropriate documentation preview page.

        Dispatches to the correct template based on the active catalog
        (model, algorithm, or workflow).

        Returns:
            HTTP response rendering the preview page, or an error page for
            unsupported catalogs.
        '''

        catalog_slug = str(self.project.catalog).rsplit('/', 1)[-1]
        template = CATALOG_TEMPLATE_MAP.get(catalog_slug)

        if template:
            answers, options = self.get_post_data('preview')
            return render_preview(
                self=self,
                template=template,
                answers=answers,
                option=options,
                submit_label=_('Export to MaRDI Portal'),
        )

        return render(
            self.request,
            'core/error.html',
            {
                'title': _('Catalog Error'),
                'errors': [_('The catalog is not supported by the MaRDMO Plugin.')]
            },
            status=200
        )

    def submit(self):
        '''Dispatch the form submission to the catalog-appropriate export handler.

        Handles the cancel action, checks OAuth2 credentials, then routes to
        :meth:`_submit_catalog` based on the active project catalog.

        Returns:
            HTTP redirect or rendered response from the selected submit method.
        '''
        if 'cancel' in self.request.POST:
            return redirect('project', self.project.id)

        if not (self.oauth2_client_id and self.oauth2_client_secret):
            return render(
                self.request,
                'core/error.html',
                {
                    'title': _('Missing Credentials'),
                    'errors': [_('Credentials for MaRDI Portal are missing!')]
                },
                status=200
            )

        catalog_slug = str(self.project.catalog).rsplit('/', 1)[-1]

        if catalog_slug not in _CATALOG_PREPARE_MAP:
            return render(
                self.request,
                'core/error.html',
                {
                    'title': _('Unknown catalog'),
                    'errors': [_('Cannot handle this catalog type.')]
                },
                status=400
            )

        return self._submit_catalog(catalog_slug)

    def _submit_catalog(self, catalog_slug):
        '''Validate and submit documentation for any supported catalog.

        Runs the catalog-appropriate consistency checks, assembles the Wikibase
        payload, and delegates to :meth:`~.oauth2.OauthProviderMixin.post` for
        the authenticated upload.

        Args:
            catalog_slug: Trailing slug of the active catalog URI.

        Returns:
            HTTP response — either an error page (failed checks) or a redirect
            to the OAuth authorization flow.
        '''
        answers, __ = self.get_post_data()

        __, prepare_class, check_method = _CATALOG_PREPARE_MAP[catalog_slug]

        err = getattr(Checks(), check_method)(
            project = self.project,
            data    = answers,
            catalog = str(self.project.catalog),
        )
        if err:
            return render(
                self.request,
                'core/error.html',
                {
                    'title': _('Incomplete or Inconsistent Documentation'),
                    'errors': err
                },
                status=200
            )

        try:
            payload, dependency = prepare_class().export(
                answers,
                get_url('mardi', 'uri'),
            )
        except (ValueError, KeyError) as err:
            return render(
                self.request,
                'core/error.html',
                {
                    'title': _('Value Error'),
                    'errors': [err]
                },
                status=200
            )

        if is_cyclic(dependency):
            return render(
                self.request,
                'core/error.html',
                {
                    'title': _('Inconsistent Documentation'),
                    'errors': ['Cyclic Dependency Graph']
                },
                status=200
            )

        return self.post(self.request, payload, topological_order(dependency))

    def get_post_data(self, mode = 'submit'):
        '''Collect and pre-process user answers for the documentation catalogs.

        Reads all RDMO project values, applies catalog-specific processing
        (relation resolution, publication retrieval, etc.), and returns the
        structured answers dict alongside the global options dict.

        Supports the model, algorithm, and workflow documentation catalogs.
        The search catalog is handled by :class:`MaRDMOQueryProvider`.

        Args:
            mode: ``'preview'`` additionally fetches publication metadata and
                  runs relation resolution for display; ``'submit'`` (default)
                  skips those steps for a leaner export payload.

        Returns:
            Tuple ``(answers, options)`` where *answers* is a nested dict
            organised by entity type (e.g. ``{"field": {...}, "model": {...}}``)
            and *options* maps RDMO option URIs to their string values.
        '''
        options = get_options()

        catalog_slug = str(self.project.catalog).rsplit('/', 1)[-1]
        entry = _CATALOG_PREPARE_MAP.get(catalog_slug)

        if not entry:
            return render(
                self.request,
                'core/error.html',
                {
                    'title': _('Unknown catalog'),
                    'errors': [_('Cannot handle this catalog type.')]
                },
                status=400
            )

        question_set, PrepareClass, _ = entry
        questions = get_questions(question_set) | get_questions('publication')
        answers = process_question_dict(
            project=self.project,
            questions=questions,
            get_answer=get_answers
        )

        if mode == 'preview':
            publication = PublicationRetriever()
            answers = publication.get_information(
                project=self.project,
                snapshot=self.snapshot,
                answers=answers,
                options=options
            )

        prepare = PrepareClass()
        answers = prepare.preview(answers)

        return answers, options

ExportForm

Bases: Form

Empty form class required by the RDMO Export provider interface.

Source code in MaRDMO/main.py
134
135
class ExportForm(forms.Form):
    '''Empty form class required by the RDMO Export provider interface.'''

get_post_data(mode='submit')

Collect and pre-process user answers for the documentation catalogs.

Reads all RDMO project values, applies catalog-specific processing (relation resolution, publication retrieval, etc.), and returns the structured answers dict alongside the global options dict.

Supports the model, algorithm, and workflow documentation catalogs. The search catalog is handled by :class:MaRDMOQueryProvider.

Parameters:

Name Type Description Default
mode

'preview' additionally fetches publication metadata and runs relation resolution for display; 'submit' (default) skips those steps for a leaner export payload.

'submit'

Returns:

Type Description

Tuple (answers, options) where answers is a nested dict

organised by entity type (e.g. {"field": {...}, "model": {...}})

and options maps RDMO option URIs to their string values.

Source code in MaRDMO/main.py
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
def get_post_data(self, mode = 'submit'):
    '''Collect and pre-process user answers for the documentation catalogs.

    Reads all RDMO project values, applies catalog-specific processing
    (relation resolution, publication retrieval, etc.), and returns the
    structured answers dict alongside the global options dict.

    Supports the model, algorithm, and workflow documentation catalogs.
    The search catalog is handled by :class:`MaRDMOQueryProvider`.

    Args:
        mode: ``'preview'`` additionally fetches publication metadata and
              runs relation resolution for display; ``'submit'`` (default)
              skips those steps for a leaner export payload.

    Returns:
        Tuple ``(answers, options)`` where *answers* is a nested dict
        organised by entity type (e.g. ``{"field": {...}, "model": {...}}``)
        and *options* maps RDMO option URIs to their string values.
    '''
    options = get_options()

    catalog_slug = str(self.project.catalog).rsplit('/', 1)[-1]
    entry = _CATALOG_PREPARE_MAP.get(catalog_slug)

    if not entry:
        return render(
            self.request,
            'core/error.html',
            {
                'title': _('Unknown catalog'),
                'errors': [_('Cannot handle this catalog type.')]
            },
            status=400
        )

    question_set, PrepareClass, _ = entry
    questions = get_questions(question_set) | get_questions('publication')
    answers = process_question_dict(
        project=self.project,
        questions=questions,
        get_answer=get_answers
    )

    if mode == 'preview':
        publication = PublicationRetriever()
        answers = publication.get_information(
            project=self.project,
            snapshot=self.snapshot,
            answers=answers,
            options=options
        )

    prepare = PrepareClass()
    answers = prepare.preview(answers)

    return answers, options

render()

Render the catalog-appropriate documentation preview page.

Dispatches to the correct template based on the active catalog (model, algorithm, or workflow).

Returns:

Type Description

HTTP response rendering the preview page, or an error page for

unsupported catalogs.

Source code in MaRDMO/main.py
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
def render(self):
    '''Render the catalog-appropriate documentation preview page.

    Dispatches to the correct template based on the active catalog
    (model, algorithm, or workflow).

    Returns:
        HTTP response rendering the preview page, or an error page for
        unsupported catalogs.
    '''

    catalog_slug = str(self.project.catalog).rsplit('/', 1)[-1]
    template = CATALOG_TEMPLATE_MAP.get(catalog_slug)

    if template:
        answers, options = self.get_post_data('preview')
        return render_preview(
            self=self,
            template=template,
            answers=answers,
            option=options,
            submit_label=_('Export to MaRDI Portal'),
    )

    return render(
        self.request,
        'core/error.html',
        {
            'title': _('Catalog Error'),
            'errors': [_('The catalog is not supported by the MaRDMO Plugin.')]
        },
        status=200
    )

submit()

Dispatch the form submission to the catalog-appropriate export handler.

Handles the cancel action, checks OAuth2 credentials, then routes to :meth:_submit_catalog based on the active project catalog.

Returns:

Type Description

HTTP redirect or rendered response from the selected submit method.

Source code in MaRDMO/main.py
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
def submit(self):
    '''Dispatch the form submission to the catalog-appropriate export handler.

    Handles the cancel action, checks OAuth2 credentials, then routes to
    :meth:`_submit_catalog` based on the active project catalog.

    Returns:
        HTTP redirect or rendered response from the selected submit method.
    '''
    if 'cancel' in self.request.POST:
        return redirect('project', self.project.id)

    if not (self.oauth2_client_id and self.oauth2_client_secret):
        return render(
            self.request,
            'core/error.html',
            {
                'title': _('Missing Credentials'),
                'errors': [_('Credentials for MaRDI Portal are missing!')]
            },
            status=200
        )

    catalog_slug = str(self.project.catalog).rsplit('/', 1)[-1]

    if catalog_slug not in _CATALOG_PREPARE_MAP:
        return render(
            self.request,
            'core/error.html',
            {
                'title': _('Unknown catalog'),
                'errors': [_('Cannot handle this catalog type.')]
            },
            status=400
        )

    return self._submit_catalog(catalog_slug)

MaRDMOQueryProvider

Bases: Export

Export provider for the "Query MaRDI Portal" button.

Handles the search-catalog preview (showing a form to configure the query) and the submit action (executing the search and rendering matching results). Does not require OAuth2 — no portal writes are performed.

Attributes:

Name Type Description
request HttpRequest

The Django HttpRequest associated with the provider.

Source code in MaRDMO/main.py
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
class MaRDMOQueryProvider(Export):
    '''Export provider for the "Query MaRDI Portal" button.

    Handles the search-catalog preview (showing a form to configure the query)
    and the submit action (executing the search and rendering matching results).
    Does not require OAuth2 — no portal writes are performed.

    Attributes:
        request: The Django HttpRequest associated with the provider.
    '''

    request: HttpRequest

    class ExportForm(forms.Form):
        '''Empty form class required by the RDMO Export provider interface.'''

    def render(self):
        '''Render the search configuration preview page.

        Returns:
            HTTP response rendering ``MaRDMO/serachTemplate.html``, or an error
            page if the active catalog is not the search catalog.
        '''
        if str(self.project.catalog).endswith('mardmo-search-catalog'):
            answers, options = self.get_post_data()
            return render_preview(
                self = self,
                template = 'MaRDMO/searchTemplate.html',
                answers = answers,
                option = options,
                submit_label = _('Query MaRDI Portal'),
            )

        return render(
            self.request,
            'core/error.html',
            {
                'title': _('Catalog Error'),
                'errors': [_('The catalog is not supported by the MaRDMO Query Plugin.')]
            },
            status=200
        )

    def submit(self):
        '''Execute the MaRDI Portal query and render the results page.

        Handles the cancel action (redirects to the project page), then calls
        :meth:`submit_mardmo_search` for the search catalog.

        Returns:
            HTTP redirect or rendered results response.
        '''
        if 'cancel' in self.request.POST:
            return redirect('project', self.project.id)

        if str(self.project.catalog).endswith('mardmo-search-catalog'):
            return self.submit_mardmo_search()

        return render(
            self.request,
            'core/error.html',
            {
                'title': _('Unknown catalog'),
                'errors': [_('Cannot handle this catalog type.')]
            },
            status=400
        )

    def submit_mardmo_search(self):
        '''Execute a MaRDI Portal search and render the matching results.

        Reads the search type from the answers dict to set the result datatype label.

        Returns:
            Rendered ``MaRDMO/searchResults.html`` response with the list of
            matching portal items.
        '''
        answers, options = self.get_post_data()

        if answers['search']['options'] == options['InterdisciplinaryWorkflow']:
            datatype = "Workflow(s)"
        elif answers['search']['options'] == options['MathematicalModel']:
            datatype = "Mathematical Model(s)"
        elif answers['search']['options'] == options['Algorithm']:
            datatype = "Algorithm(s)"
        else:
            datatype = "Unknown"

        return render(
            self.request,
            'MaRDMO/searchResults.html',
            {
                'datatype': datatype,
                'noResults': answers['no_results'],
                'links': answers['links']
            },
            status=200
        )

    def get_post_data(self):
        '''Load and execute the search query for the search catalog.

        Reads the search configuration from RDMO project values and dispatches
        to the :func:`~MaRDMO.search.worker.search` function to retrieve
        matching items from the MaRDI Portal, MathModDB KG, or MathAlgoDB KG.

        Returns:
            Tuple ``(answers, options)`` where *answers* contains the search
            results and *options* maps RDMO option URIs to their string values.
        '''
        options = get_options()

        if str(self.project.catalog).endswith('mardmo-search-catalog'):
            questions = get_questions('search')

            answers = process_question_dict(
                project = self.project,
                questions = questions,
                get_answer = get_answers
            )

            answers = search(answers, options)

            return answers, options

        return render(
            self.request,
            'core/error.html',
            {
                'title': _('Unknown catalog'),
                'errors': [_('Cannot handle this catalog type.')]
            },
            status=400
        )

ExportForm

Bases: Form

Empty form class required by the RDMO Export provider interface.

Source code in MaRDMO/main.py
344
345
class ExportForm(forms.Form):
    '''Empty form class required by the RDMO Export provider interface.'''

get_post_data()

Load and execute the search query for the search catalog.

Reads the search configuration from RDMO project values and dispatches to the :func:~MaRDMO.search.worker.search function to retrieve matching items from the MaRDI Portal, MathModDB KG, or MathAlgoDB KG.

Returns:

Type Description

Tuple (answers, options) where answers contains the search

results and options maps RDMO option URIs to their string values.

Source code in MaRDMO/main.py
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
def get_post_data(self):
    '''Load and execute the search query for the search catalog.

    Reads the search configuration from RDMO project values and dispatches
    to the :func:`~MaRDMO.search.worker.search` function to retrieve
    matching items from the MaRDI Portal, MathModDB KG, or MathAlgoDB KG.

    Returns:
        Tuple ``(answers, options)`` where *answers* contains the search
        results and *options* maps RDMO option URIs to their string values.
    '''
    options = get_options()

    if str(self.project.catalog).endswith('mardmo-search-catalog'):
        questions = get_questions('search')

        answers = process_question_dict(
            project = self.project,
            questions = questions,
            get_answer = get_answers
        )

        answers = search(answers, options)

        return answers, options

    return render(
        self.request,
        'core/error.html',
        {
            'title': _('Unknown catalog'),
            'errors': [_('Cannot handle this catalog type.')]
        },
        status=400
    )

render()

Render the search configuration preview page.

Returns:

Type Description

HTTP response rendering MaRDMO/serachTemplate.html, or an error

page if the active catalog is not the search catalog.

Source code in MaRDMO/main.py
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
def render(self):
    '''Render the search configuration preview page.

    Returns:
        HTTP response rendering ``MaRDMO/serachTemplate.html``, or an error
        page if the active catalog is not the search catalog.
    '''
    if str(self.project.catalog).endswith('mardmo-search-catalog'):
        answers, options = self.get_post_data()
        return render_preview(
            self = self,
            template = 'MaRDMO/searchTemplate.html',
            answers = answers,
            option = options,
            submit_label = _('Query MaRDI Portal'),
        )

    return render(
        self.request,
        'core/error.html',
        {
            'title': _('Catalog Error'),
            'errors': [_('The catalog is not supported by the MaRDMO Query Plugin.')]
        },
        status=200
    )

submit()

Execute the MaRDI Portal query and render the results page.

Handles the cancel action (redirects to the project page), then calls :meth:submit_mardmo_search for the search catalog.

Returns:

Type Description

HTTP redirect or rendered results response.

Source code in MaRDMO/main.py
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
def submit(self):
    '''Execute the MaRDI Portal query and render the results page.

    Handles the cancel action (redirects to the project page), then calls
    :meth:`submit_mardmo_search` for the search catalog.

    Returns:
        HTTP redirect or rendered results response.
    '''
    if 'cancel' in self.request.POST:
        return redirect('project', self.project.id)

    if str(self.project.catalog).endswith('mardmo-search-catalog'):
        return self.submit_mardmo_search()

    return render(
        self.request,
        'core/error.html',
        {
            'title': _('Unknown catalog'),
            'errors': [_('Cannot handle this catalog type.')]
        },
        status=400
    )

Execute a MaRDI Portal search and render the matching results.

Reads the search type from the answers dict to set the result datatype label.

Returns:

Type Description

Rendered MaRDMO/searchResults.html response with the list of

matching portal items.

Source code in MaRDMO/main.py
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
def submit_mardmo_search(self):
    '''Execute a MaRDI Portal search and render the matching results.

    Reads the search type from the answers dict to set the result datatype label.

    Returns:
        Rendered ``MaRDMO/searchResults.html`` response with the list of
        matching portal items.
    '''
    answers, options = self.get_post_data()

    if answers['search']['options'] == options['InterdisciplinaryWorkflow']:
        datatype = "Workflow(s)"
    elif answers['search']['options'] == options['MathematicalModel']:
        datatype = "Mathematical Model(s)"
    elif answers['search']['options'] == options['Algorithm']:
        datatype = "Algorithm(s)"
    else:
        datatype = "Unknown"

    return render(
        self.request,
        'MaRDMO/searchResults.html',
        {
            'datatype': datatype,
            'noResults': answers['no_results'],
            'links': answers['links']
        },
        status=200
    )

OAuth2

OAuth2-based export provider with live progress tracking for MaRDMO.

Provides :class:OauthProviderMixin, the abstract base class that handles:

  • OAuth2 authorisation flow against the MaRDI Portal (/authorize/callback → token exchange).
  • Background export job execution with progress reported via :mod:~MaRDMO.store and streamed to the browser.
  • Wikibase REST API posting: :meth:~OauthProviderMixin._post_data retries failed requests, handles duplicate-item policy violations, and substitutes temporary Item<n> placeholders with the real Wikibase QIDs.

Subclasses must implement :meth:~OauthProviderMixin.get_authorize_params and :meth:~OauthProviderMixin.post_success to supply catalog-specific export logic.

OauthProviderMixin

Mixin providing a full OAuth2 authorization-code flow with async Wikibase posting.

Subclasses must implement :meth:get_authorize_params, :meth:get_callback_auth, :meth:get_callback_headers, :meth:get_callback_params, :meth:get_callback_data, and :meth:post_success. The mixin handles state validation, token exchange, background upload, and progress tracking.

Source code in MaRDMO/oauth2.py
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
class OauthProviderMixin:
    '''Mixin providing a full OAuth2 authorization-code flow with async Wikibase posting.

    Subclasses must implement :meth:`get_authorize_params`, :meth:`get_callback_auth`,
    :meth:`get_callback_headers`, :meth:`get_callback_params`,
    :meth:`get_callback_data`, and :meth:`post_success`.  The mixin handles
    state validation, token exchange, background upload, and progress tracking.
    '''

    # ------------------- OAUTH FLOW -------------------

    def post(self, request, jsons=None, dependency=None):
        '''Persist posting arguments in the session and start the OAuth flow.

        Args:
            request:    Django HTTP request.
            jsons:      Payload dict to upload (serialisable).
            dependency: Ordered iterable of item keys to post before relations.

        Returns:
            Redirect to the OAuth authorization endpoint.
        '''
        self.store_in_session(request, 'request', ('post', jsons, dependency))
        self.store_in_session(request, 'project_id', self.project.pk)
        return self.authorize(request)

    def authorize(self, request):
        '''Redirect the user to the OAuth authorization endpoint.

        Generates a random CSRF *state* token, stores it in the session, and
        builds the redirect URL from :meth:`get_authorize_params`.

        Args:
            request: Django HTTP request.

        Returns:
            :class:`~django.http.HttpResponseRedirect` to the authorization URL.
        '''
        state = get_random_string(length=32)
        self.store_in_session(request, 'state', state)
        url = self.authorize_url + '?' + urlencode(self.get_authorize_params(request, state))
        return HttpResponseRedirect(url)

    def callback(self, request):
        '''Handle the OAuth callback, exchange the code for a token, and start posting.

        Validates the *state* parameter, exchanges the authorization code for an
        access token, then launches :meth:`_background_post` in a daemon thread
        and redirects to the live-progress page.

        Args:
            request: Django HTTP request (must contain ``code`` and ``state``
                     GET parameters set by the authorization server).

        Returns:
            Redirect to the progress page on success, or an error page on
            state-mismatch or token-exchange failure.
        '''
        if request.GET.get('state') != self.pop_from_session(request, 'state'):
            return self.render_error(
                request,
                _('OAuth authorization not successful'),
                _('State parameter did not match.')
            )

        # Exchange the authorization code for an access token
        url = self.token_url + '?' + urlencode(self.get_callback_params(request))
        response = requests.post(
            url,
            self.get_callback_data(request),
            auth=self.get_callback_auth(request),
            headers=self.get_callback_headers(request),
            timeout=10
        )

        try:
            response.raise_for_status()
        except requests.HTTPError as e:
            logger.error('callback error: %s (%s)', response.content, response.status_code)
            return self.render_error(
                request,
                _('OAuth process failed'),
                e
            )

        response_data = response.json()
        access_token = response_data.get('access_token')

        # Retrieve the original post request data
        data = self.pop_from_session(request, 'request')
        if not data:
            return self.render_error(
                request,
                _('OAuth authorization successful'),
                _('But no redirect could be found.'),
            )

        _method, jsons, dependency = data

        # ------------------- ASYNC POSTING -------------------

        # Generate job ID and store initial progress in the cache
        job_id = get_random_string(16)
        _register_job_for_session(request, job_id)
        _progress_store[job_id] = {
            "progress": 0,
            "done": False,
            "phase": "starting",
            "error": None,
        }

        project_id = self.pop_from_session(request, 'project_id')
        project = Project.objects.get(pk=project_id) if project_id else None

        # Start posting thread
        thread = threading.Thread(
            target=self._background_post,
            args=(request, access_token, jsons, dependency, job_id, project),
            daemon=True,
        )
        thread.start()

        # Immediately render progress page
        return HttpResponseRedirect(
            reverse("show_progress", args=[job_id])
        )

    # ------------------- BACKGROUND PROCESS -------------------

    def _background_post(self, request, access_token, jsons, dependency, job_id, project):
        """
        Run the Wikibase posting in the background.
        Posts all 'Item*' payloads first, then all 'RELATION*' payloads.
        Updates _progress_store[job_id] continuously with phase and progress info.
        """

        logger.info("[%s] Background posting started", job_id)

        try:
            keys = list(jsons.keys())

            # --- Separate item and relation keys
            item_keys = list(dependency)
            relation_keys = [k for k in keys if k.startswith(("RELATION", "ALIAS"))]

            num_items = len(item_keys)
            num_relations = len(relation_keys)
            total = num_items + num_relations
            completed = 0

            init = copy.deepcopy(jsons)

            # --- Phase 1: Items
            _progress_store[job_id] = {"progress": 0, "done": False, "phase": "items"}
            logger.info("[%s] Starting item upload: %s items", job_id, num_items)

            for key in item_keys:
                try:
                    jsons = self._post_data(key, jsons, access_token)
                except RuntimeError as err:
                    label = jsons.get(key, {}).get('label', key)
                    logger.exception(
                        "[%s] Failed posting item %s (%s): %s",
                        job_id, key, label, err
                    )
                    _progress_store[job_id] = {
                        "progress": int((completed / total) * 100),
                        "done": True,
                        "phase": "error",
                        "error": f"Error posting item '{label}': {err}",
                    }
                    return

                completed += 1
                _progress_store[job_id] = {
                    "progress": int((completed / total) * 100),
                    "done": False,
                    "phase": "items",
                }
                time.sleep(0.1)

            # --- Phase 2: Relations
            _progress_store[job_id] = {
                "progress": int((completed / total) * 100),
                "done": False,
                "phase": "relations",
            }
            logger.info("[%s] Starting relation upload: %s relations", job_id, num_relations)

            for key in relation_keys:
                try:
                    jsons = self._post_data(key, jsons, access_token)
                except RuntimeError as err:
                    logger.exception("[%s] Failed posting relation %s: %s", job_id, key, err)
                    _progress_store[job_id] = {
                        "progress": int((completed / total) * 100),
                        "done": True,
                        "phase": "error",
                        "error": f"Error posting relation {key}: {err}",
                    }
                    return

                completed += 1
                _progress_store[job_id] = {
                    "progress": int((completed / total) * 100),
                    "done": False,
                    "phase": "relations",
                }
                time.sleep(0.1)

            # --- All done
            wikidata_qid_prop = get_properties().get('Wikidata QID')
            created = compare_items(init, jsons, wikidata_qid_prop)
            replace_ids(project, created, BASE_URI)
            ids = [
                [info.get('class', ''), f'{label} ({desc})' if desc else label, info['new_qid']]
                for (label, desc), info in created.items()
            ]
            pid_to_name = {qid: name for name, qid in get_properties().items()}
            qid_to_name = {qid: name for name, qid in get_items().items()}
            statements = collect_statements(init, jsons, pid_to_name, qid_to_name)
            _progress_store[job_id] = {
                "progress": 100,
                "done": True,
                "phase": "done",
                "error": None,
                "ids": ids,
                "statements": statements,
                "redirect": request.build_absolute_uri(
                    reverse("show_success", args=[job_id])
                ),
            }

            logger.info(
                "[%s] Posting complete. Redirect -> /services/success/%s/",
                job_id,
                job_id,
            )

        except Exception as e:
            logger.exception("[%s] Unexpected error: %s", job_id, e)
            _progress_store[job_id] = {
                "progress": int((completed / total) * 100) if total else 0,
                "done": True,
                "phase": "error",
                "error": str(e),
            }

    # ------------------- CORE POST LOGIC -------------------

    def _post_data(self, key, jsons, access_token):
        """Post the payload entry for *key* to the Wikibase REST API.

        Skips entries that already have an ID (items) or are marked as
        existing (relations).  Retries up to five times on timeout or
        connection errors with exponential back-off.

        Args:
            key:          Payload key string (e.g. ``'Item0000000001'`` or
                          ``'RELATION_…'``).
            jsons:        Full payload dict; may be mutated in place when a
                          placeholder is replaced by a real QID.
            access_token: OAuth2 bearer token string.

        Returns:
            Updated payload dict with the new QID substituted for *key*.

        Raises:
            RuntimeError: If all retry attempts fail with no response.
        """

        # No Post, if key not in Payload
        if not jsons.get(key):
            return jsons

        item = jsons[key]

        # No Post, if item already on Portal
        if key.startswith('Item') and item.get('id'):
            return replace_in_dict(jsons, key, item['id'])

        # No Post, if relation already on Portal
        if key.startswith('RELATION') and item.get('exists') == 'true':
            return jsons

        session = getattr(self, "_session", None)
        if session is None:
            session = requests.Session()
            self._session = session

        url = item['url']
        payload = item['payload']
        headers = self.get_authorization_headers(access_token)
        response = None

        for attempt in range(1, 6):
            try:
                response = session.post(url, json=payload, headers=headers, timeout=120)
                response.raise_for_status()
                wait = 0.1 + random.uniform(0, 0.5)
                time.sleep(wait)
                return self._handle_response(response, key, jsons)

            except requests.exceptions.Timeout:
                time.sleep(1.5 ** attempt + random.uniform(0, 0.5))
                continue

            except requests.exceptions.ConnectionError:
                time.sleep(1.5 ** attempt + random.uniform(0, 0.5))
                continue

            except requests.HTTPError as exc:
                resp = exc.response
                status = resp.status_code if resp is not None else "no_response"
                if status == 429:
                    retry_after = int(resp.headers.get("Retry-After", 5))
                    time.sleep(retry_after)
                    continue
                if status == 403:
                    time.sleep(1.5 ** attempt + random.uniform(0, 0.5))
                    continue
                if isinstance(status, int) and status >= 500:
                    time.sleep(1.5 ** attempt + random.uniform(0, 0.5))
                    continue
                if status == 422:
                    return self._handle_policy_violation(resp, key, jsons)
                # Extract detailed error message from response
                error_detail = self._extract_error_message(resp)
                raise RuntimeError(
                    _("POST request failed (HTTP %(status)s): %(detail)s") % {
                        'status': status,
                        'detail': error_detail
                    }
                ) from exc

        if response is not None:
            error_detail = self._extract_error_message(response)
            raise RuntimeError(
                _("POST request failed after %(attempts)s retries: %(detail)s") % {
                    'attempts': 5,
                    'detail': error_detail
                }
            )

        raise RuntimeError(_("POST request failed after multiple retries (no response)"))

    def _extract_error_message(self, response):
        """Extract a human-readable error message from an API response.

        Handles both the Wikibase REST API (``message``, ``code``/``context``)
        and the Wikibase Action API (``error.info``, ``error.code``) response
        formats.

        Args:
            response: :class:`requests.Response` object from a failed API call.

        Returns:
            Error message string; falls back to the raw response text (up to
            200 characters) or ``"Error details unavailable"`` if parsing fails.
        """
        try:
            error_data = response.json()

            # Try to get the most informative error message
            # Wikibase API returns 'message' (REST API) or 'info' (Action API)
            if 'message' in error_data:
                error_msg = error_data['message']
            elif 'error' in error_data:
                # Action API: error.info or error.code
                error_obj = error_data['error']
                error_msg = error_obj.get('info') or error_obj.get('code', 'Unknown error')
            elif 'code' in error_data:
                # REST API: code + optional context
                error_msg = error_data['code']
                if 'context' in error_data:
                    context = error_data['context']
                    if isinstance(context, dict):
                        try:
                            context_str = ', '.join(f"{k}: {v}" for k, v in context.items())
                            error_msg = f"{error_msg} ({context_str})"
                        except Exception:
                            pass
            else:
                error_msg = str(error_data)[:500]

            return error_msg
        except (ValueError, AttributeError, TypeError, KeyError):
            # If response is not JSON or has unexpected structure
            try:
                return response.text[:200] if response.text else "No error details available"
            except Exception:
                return "Error details unavailable"

    def _handle_response(self, response, key, jsons):
        """Process a successful POST response and replace the placeholder with the new QID.

        Args:
            response: Successful :class:`requests.Response` object.
            key:      Payload key that was just posted (e.g. ``'Item0000000001'``).
            jsons:    Full payload dict; mutated in place for non-alias keys.

        Returns:
            Updated payload dict with the new Wikibase ID substituted for *key*.
        """
        response.raise_for_status()
        if not key.startswith("ALIAS"):
            jsons[key]['id'] = response.json().get('id')
            jsons = replace_in_dict(jsons, key, jsons[key]['id'])
        return jsons

    def _handle_policy_violation(self, response, key, jsons):
        """Handle a ``data-policy-violation`` HTTP 422 error from the Wikibase API.

        When the violation is ``item-label-description-duplicate``, the
        conflicting existing item's ID is extracted and used as the resolved
        QID so the export can proceed without creating a duplicate.

        Args:
            response: :class:`requests.Response` object with a 422 status and a
                      JSON body containing ``code`` and ``context`` fields.
            key:      Payload key that caused the violation.
            jsons:    Full payload dict; mutated in place when a duplicate is resolved.

        Returns:
            Updated payload dict, or the original *jsons* if the violation
            could not be resolved.
        """
        error_json = response.json()
        if error_json.get("code") == "data-policy-violation":
            violation = error_json.get("context", {}).get("violation")
            if violation == 'item-label-description-duplicate':
                conflict_id = (
                    error_json["context"]
                    .get("violation_context", {})
                    .get("conflicting_item_id")
                )
                if conflict_id:
                    jsons[key]['id'] = conflict_id
                    return replace_in_dict(jsons, key, conflict_id)
        return jsons

    # ------------------- HELPERS -------------------

    def render_error(self, request, title, message):
        '''Render the ``core/error.html`` template with *title* and *message*.

        Args:
            request: Django HTTP request.
            title:   Short error heading (translated string).
            message: Detailed error description (translated string or exception).

        Returns:
            :class:`~django.http.HttpResponse` (status 200) rendering the error page.
        '''
        return render(
            request,
            'core/error.html',
            {
                'title': title,
                'errors': [message]
            },
            status=200
        )

    def get_session_key(self, key):
        '''Return the namespaced session key ``"<class_name>.<key>"``.

        Args:
            key: Logical key name (e.g. ``"state"`` or ``"request"``).

        Returns:
            String combining :attr:`class_name` and *key* with a dot separator.
        '''
        return f'{self.class_name}.{key}'

    def store_in_session(self, request, key, data):
        '''Write *data* under the namespaced *key* into the Django session.

        Args:
            request: Django HTTP request whose session is updated.
            key:     Logical key name passed through :meth:`get_session_key`.
            data:    Serialisable value to store.
        '''
        request.session[self.get_session_key(key)] = data

    def pop_from_session(self, request, key):
        '''Remove and return a value from the Django session.

        Args:
            request: Django HTTP request.
            key:     Logical key name passed through :meth:`get_session_key`.

        Returns:
            The stored value, or ``None`` if the key is absent.
        '''
        return request.session.pop(self.get_session_key(key), None)

    def get_from_session(self, request, key):
        '''Read (without removing) a value from the Django session.

        Args:
            request: Django HTTP request.
            key:     Logical key name passed through :meth:`get_session_key`.

        Returns:
            The stored value, or ``None`` if the key is absent.
        '''
        session_key = self.get_session_key(key)
        return request.session.get(session_key)

    def get_authorization_headers(self, access_token):
        '''Build the ``Authorization: Bearer …`` header dict.

        Args:
            access_token: OAuth2 access token string.

        Returns:
            Dict ``{"Authorization": "Bearer <access_token>"}``.
        '''
        return {'Authorization': f'Bearer {access_token}'}

    def get_authorize_params(self, request, state):
        '''Return query parameters for the OAuth authorization redirect URL.

        Must be overridden by subclasses to supply provider-specific parameters
        such as ``client_id``, ``redirect_uri``, ``response_type``, and ``scope``.

        Args:
            request: Django HTTP request.
            state:   CSRF state token generated by :meth:`authorize`.

        Returns:
            Dict of query-string parameters.

        Raises:
            NotImplementedError: Always — subclasses must implement this method.
        '''
        raise NotImplementedError

    def get_callback_auth(self, request):
        '''Return the HTTP Basic-Auth credentials for the token-exchange request.

        Default implementation returns ``None`` (no Basic-Auth).  Subclasses
        may override to supply ``(client_id, client_secret)``.

        Args:
            request: Django HTTP request.

        Returns:
            ``None`` or a ``(username, password)`` tuple.
        '''
        return None

    def get_callback_headers(self, request):
        '''Return HTTP headers for the token-exchange POST request.

        Default implementation returns ``Accept: application/json`` and a
        ``User-Agent`` identifying the MaRDMO plugin.  Subclasses may override.

        Args:
            request: Django HTTP request.

        Returns:
            Dict of HTTP header name → value pairs.
        '''
        return {'Accept': 'application/json',
                'User-Agent': 'MaRDMO (https://zib.de; reidelbach@zib.de)'}

    def get_callback_params(self, request):
        '''Return URL query parameters appended to the token-exchange endpoint.

        Default implementation returns an empty dict.  Subclasses may override
        to pass provider-specific parameters (e.g. ``grant_type``).

        Args:
            request: Django HTTP request.

        Returns:
            Dict of query-string parameters.
        '''
        return {}

    def get_callback_data(self, request):
        '''Return the POST body data for the token-exchange request.

        Default implementation returns an empty dict.  Subclasses may override
        to supply ``code``, ``redirect_uri``, or other required fields.

        Args:
            request: Django HTTP request.

        Returns:
            Dict of form-body fields.
        '''
        return {}

    def post_success(self, request, init, final):
        '''Handle a completed upload and render the success page.

        Called after all items and relations have been posted.  Must be
        overridden by subclasses to display provider-specific results.

        Args:
            request: Django HTTP request.
            init:    Deep copy of the original payload dict (before posting).
            final:   Updated payload dict containing assigned Wikibase IDs.

        Raises:
            NotImplementedError: Always — subclasses must implement this method.
        '''
        raise NotImplementedError

authorize(request)

Redirect the user to the OAuth authorization endpoint.

Generates a random CSRF state token, stores it in the session, and builds the redirect URL from :meth:get_authorize_params.

Parameters:

Name Type Description Default
request

Django HTTP request.

required

Returns:

Type Description

class:~django.http.HttpResponseRedirect to the authorization URL.

Source code in MaRDMO/oauth2.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
def authorize(self, request):
    '''Redirect the user to the OAuth authorization endpoint.

    Generates a random CSRF *state* token, stores it in the session, and
    builds the redirect URL from :meth:`get_authorize_params`.

    Args:
        request: Django HTTP request.

    Returns:
        :class:`~django.http.HttpResponseRedirect` to the authorization URL.
    '''
    state = get_random_string(length=32)
    self.store_in_session(request, 'state', state)
    url = self.authorize_url + '?' + urlencode(self.get_authorize_params(request, state))
    return HttpResponseRedirect(url)

callback(request)

Handle the OAuth callback, exchange the code for a token, and start posting.

Validates the state parameter, exchanges the authorization code for an access token, then launches :meth:_background_post in a daemon thread and redirects to the live-progress page.

Parameters:

Name Type Description Default
request

Django HTTP request (must contain code and state GET parameters set by the authorization server).

required

Returns:

Type Description

Redirect to the progress page on success, or an error page on

state-mismatch or token-exchange failure.

Source code in MaRDMO/oauth2.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
def callback(self, request):
    '''Handle the OAuth callback, exchange the code for a token, and start posting.

    Validates the *state* parameter, exchanges the authorization code for an
    access token, then launches :meth:`_background_post` in a daemon thread
    and redirects to the live-progress page.

    Args:
        request: Django HTTP request (must contain ``code`` and ``state``
                 GET parameters set by the authorization server).

    Returns:
        Redirect to the progress page on success, or an error page on
        state-mismatch or token-exchange failure.
    '''
    if request.GET.get('state') != self.pop_from_session(request, 'state'):
        return self.render_error(
            request,
            _('OAuth authorization not successful'),
            _('State parameter did not match.')
        )

    # Exchange the authorization code for an access token
    url = self.token_url + '?' + urlencode(self.get_callback_params(request))
    response = requests.post(
        url,
        self.get_callback_data(request),
        auth=self.get_callback_auth(request),
        headers=self.get_callback_headers(request),
        timeout=10
    )

    try:
        response.raise_for_status()
    except requests.HTTPError as e:
        logger.error('callback error: %s (%s)', response.content, response.status_code)
        return self.render_error(
            request,
            _('OAuth process failed'),
            e
        )

    response_data = response.json()
    access_token = response_data.get('access_token')

    # Retrieve the original post request data
    data = self.pop_from_session(request, 'request')
    if not data:
        return self.render_error(
            request,
            _('OAuth authorization successful'),
            _('But no redirect could be found.'),
        )

    _method, jsons, dependency = data

    # ------------------- ASYNC POSTING -------------------

    # Generate job ID and store initial progress in the cache
    job_id = get_random_string(16)
    _register_job_for_session(request, job_id)
    _progress_store[job_id] = {
        "progress": 0,
        "done": False,
        "phase": "starting",
        "error": None,
    }

    project_id = self.pop_from_session(request, 'project_id')
    project = Project.objects.get(pk=project_id) if project_id else None

    # Start posting thread
    thread = threading.Thread(
        target=self._background_post,
        args=(request, access_token, jsons, dependency, job_id, project),
        daemon=True,
    )
    thread.start()

    # Immediately render progress page
    return HttpResponseRedirect(
        reverse("show_progress", args=[job_id])
    )

get_authorization_headers(access_token)

Build the Authorization: Bearer … header dict.

Parameters:

Name Type Description Default
access_token

OAuth2 access token string.

required

Returns:

Type Description

Dict {"Authorization": "Bearer <access_token>"}.

Source code in MaRDMO/oauth2.py
556
557
558
559
560
561
562
563
564
565
def get_authorization_headers(self, access_token):
    '''Build the ``Authorization: Bearer …`` header dict.

    Args:
        access_token: OAuth2 access token string.

    Returns:
        Dict ``{"Authorization": "Bearer <access_token>"}``.
    '''
    return {'Authorization': f'Bearer {access_token}'}

get_authorize_params(request, state)

Return query parameters for the OAuth authorization redirect URL.

Must be overridden by subclasses to supply provider-specific parameters such as client_id, redirect_uri, response_type, and scope.

Parameters:

Name Type Description Default
request

Django HTTP request.

required
state

CSRF state token generated by :meth:authorize.

required

Returns:

Type Description

Dict of query-string parameters.

Raises:

Type Description
NotImplementedError

Always — subclasses must implement this method.

Source code in MaRDMO/oauth2.py
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
def get_authorize_params(self, request, state):
    '''Return query parameters for the OAuth authorization redirect URL.

    Must be overridden by subclasses to supply provider-specific parameters
    such as ``client_id``, ``redirect_uri``, ``response_type``, and ``scope``.

    Args:
        request: Django HTTP request.
        state:   CSRF state token generated by :meth:`authorize`.

    Returns:
        Dict of query-string parameters.

    Raises:
        NotImplementedError: Always — subclasses must implement this method.
    '''
    raise NotImplementedError

get_callback_auth(request)

Return the HTTP Basic-Auth credentials for the token-exchange request.

Default implementation returns None (no Basic-Auth). Subclasses may override to supply (client_id, client_secret).

Parameters:

Name Type Description Default
request

Django HTTP request.

required

Returns:

Type Description

None or a (username, password) tuple.

Source code in MaRDMO/oauth2.py
585
586
587
588
589
590
591
592
593
594
595
596
597
def get_callback_auth(self, request):
    '''Return the HTTP Basic-Auth credentials for the token-exchange request.

    Default implementation returns ``None`` (no Basic-Auth).  Subclasses
    may override to supply ``(client_id, client_secret)``.

    Args:
        request: Django HTTP request.

    Returns:
        ``None`` or a ``(username, password)`` tuple.
    '''
    return None

get_callback_data(request)

Return the POST body data for the token-exchange request.

Default implementation returns an empty dict. Subclasses may override to supply code, redirect_uri, or other required fields.

Parameters:

Name Type Description Default
request

Django HTTP request.

required

Returns:

Type Description

Dict of form-body fields.

Source code in MaRDMO/oauth2.py
628
629
630
631
632
633
634
635
636
637
638
639
640
def get_callback_data(self, request):
    '''Return the POST body data for the token-exchange request.

    Default implementation returns an empty dict.  Subclasses may override
    to supply ``code``, ``redirect_uri``, or other required fields.

    Args:
        request: Django HTTP request.

    Returns:
        Dict of form-body fields.
    '''
    return {}

get_callback_headers(request)

Return HTTP headers for the token-exchange POST request.

Default implementation returns Accept: application/json and a User-Agent identifying the MaRDMO plugin. Subclasses may override.

Parameters:

Name Type Description Default
request

Django HTTP request.

required

Returns:

Type Description

Dict of HTTP header name → value pairs.

Source code in MaRDMO/oauth2.py
599
600
601
602
603
604
605
606
607
608
609
610
611
612
def get_callback_headers(self, request):
    '''Return HTTP headers for the token-exchange POST request.

    Default implementation returns ``Accept: application/json`` and a
    ``User-Agent`` identifying the MaRDMO plugin.  Subclasses may override.

    Args:
        request: Django HTTP request.

    Returns:
        Dict of HTTP header name → value pairs.
    '''
    return {'Accept': 'application/json',
            'User-Agent': 'MaRDMO (https://zib.de; reidelbach@zib.de)'}

get_callback_params(request)

Return URL query parameters appended to the token-exchange endpoint.

Default implementation returns an empty dict. Subclasses may override to pass provider-specific parameters (e.g. grant_type).

Parameters:

Name Type Description Default
request

Django HTTP request.

required

Returns:

Type Description

Dict of query-string parameters.

Source code in MaRDMO/oauth2.py
614
615
616
617
618
619
620
621
622
623
624
625
626
def get_callback_params(self, request):
    '''Return URL query parameters appended to the token-exchange endpoint.

    Default implementation returns an empty dict.  Subclasses may override
    to pass provider-specific parameters (e.g. ``grant_type``).

    Args:
        request: Django HTTP request.

    Returns:
        Dict of query-string parameters.
    '''
    return {}

get_from_session(request, key)

Read (without removing) a value from the Django session.

Parameters:

Name Type Description Default
request

Django HTTP request.

required
key

Logical key name passed through :meth:get_session_key.

required

Returns:

Type Description

The stored value, or None if the key is absent.

Source code in MaRDMO/oauth2.py
543
544
545
546
547
548
549
550
551
552
553
554
def get_from_session(self, request, key):
    '''Read (without removing) a value from the Django session.

    Args:
        request: Django HTTP request.
        key:     Logical key name passed through :meth:`get_session_key`.

    Returns:
        The stored value, or ``None`` if the key is absent.
    '''
    session_key = self.get_session_key(key)
    return request.session.get(session_key)

get_session_key(key)

Return the namespaced session key "<class_name>.<key>".

Parameters:

Name Type Description Default
key

Logical key name (e.g. "state" or "request").

required

Returns:

Type Description

String combining :attr:class_name and key with a dot separator.

Source code in MaRDMO/oauth2.py
510
511
512
513
514
515
516
517
518
519
def get_session_key(self, key):
    '''Return the namespaced session key ``"<class_name>.<key>"``.

    Args:
        key: Logical key name (e.g. ``"state"`` or ``"request"``).

    Returns:
        String combining :attr:`class_name` and *key* with a dot separator.
    '''
    return f'{self.class_name}.{key}'

pop_from_session(request, key)

Remove and return a value from the Django session.

Parameters:

Name Type Description Default
request

Django HTTP request.

required
key

Logical key name passed through :meth:get_session_key.

required

Returns:

Type Description

The stored value, or None if the key is absent.

Source code in MaRDMO/oauth2.py
531
532
533
534
535
536
537
538
539
540
541
def pop_from_session(self, request, key):
    '''Remove and return a value from the Django session.

    Args:
        request: Django HTTP request.
        key:     Logical key name passed through :meth:`get_session_key`.

    Returns:
        The stored value, or ``None`` if the key is absent.
    '''
    return request.session.pop(self.get_session_key(key), None)

post(request, jsons=None, dependency=None)

Persist posting arguments in the session and start the OAuth flow.

Parameters:

Name Type Description Default
request

Django HTTP request.

required
jsons

Payload dict to upload (serialisable).

None
dependency

Ordered iterable of item keys to post before relations.

None

Returns:

Type Description

Redirect to the OAuth authorization endpoint.

Source code in MaRDMO/oauth2.py
57
58
59
60
61
62
63
64
65
66
67
68
69
70
def post(self, request, jsons=None, dependency=None):
    '''Persist posting arguments in the session and start the OAuth flow.

    Args:
        request:    Django HTTP request.
        jsons:      Payload dict to upload (serialisable).
        dependency: Ordered iterable of item keys to post before relations.

    Returns:
        Redirect to the OAuth authorization endpoint.
    '''
    self.store_in_session(request, 'request', ('post', jsons, dependency))
    self.store_in_session(request, 'project_id', self.project.pk)
    return self.authorize(request)

post_success(request, init, final)

Handle a completed upload and render the success page.

Called after all items and relations have been posted. Must be overridden by subclasses to display provider-specific results.

Parameters:

Name Type Description Default
request

Django HTTP request.

required
init

Deep copy of the original payload dict (before posting).

required
final

Updated payload dict containing assigned Wikibase IDs.

required

Raises:

Type Description
NotImplementedError

Always — subclasses must implement this method.

Source code in MaRDMO/oauth2.py
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
def post_success(self, request, init, final):
    '''Handle a completed upload and render the success page.

    Called after all items and relations have been posted.  Must be
    overridden by subclasses to display provider-specific results.

    Args:
        request: Django HTTP request.
        init:    Deep copy of the original payload dict (before posting).
        final:   Updated payload dict containing assigned Wikibase IDs.

    Raises:
        NotImplementedError: Always — subclasses must implement this method.
    '''
    raise NotImplementedError

render_error(request, title, message)

Render the core/error.html template with title and message.

Parameters:

Name Type Description Default
request

Django HTTP request.

required
title

Short error heading (translated string).

required
message

Detailed error description (translated string or exception).

required

Returns:

Type Description

class:~django.http.HttpResponse (status 200) rendering the error page.

Source code in MaRDMO/oauth2.py
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
def render_error(self, request, title, message):
    '''Render the ``core/error.html`` template with *title* and *message*.

    Args:
        request: Django HTTP request.
        title:   Short error heading (translated string).
        message: Detailed error description (translated string or exception).

    Returns:
        :class:`~django.http.HttpResponse` (status 200) rendering the error page.
    '''
    return render(
        request,
        'core/error.html',
        {
            'title': title,
            'errors': [message]
        },
        status=200
    )

store_in_session(request, key, data)

Write data under the namespaced key into the Django session.

Parameters:

Name Type Description Default
request

Django HTTP request whose session is updated.

required
key

Logical key name passed through :meth:get_session_key.

required
data

Serialisable value to store.

required
Source code in MaRDMO/oauth2.py
521
522
523
524
525
526
527
528
529
def store_in_session(self, request, key, data):
    '''Write *data* under the namespaced *key* into the Django session.

    Args:
        request: Django HTTP request whose session is updated.
        key:     Logical key name passed through :meth:`get_session_key`.
        data:    Serialisable value to store.
    '''
    request.session[self.get_session_key(key)] = data

Providers

General RDMO optionset providers shared across all MaRDMO catalogs.

Provides:

  • :class:Software — searches MaRDI Portal and Wikidata for software items; refreshes questionnaire fields upon selection.
  • :class:RelatedSoftwareWithCreation — like :class:Software but also surfaces user-created software entries from the current project and offers a "create new" option when the search term is not found.

These providers are catalog-agnostic and are referenced from the model, algorithm, or workflow catalog configurations.

RelatedSoftwareWithCreation

Bases: Provider

Software Provider (MaRDI Portal / Wikidata / MathAlgoDB), User Creation, No Refresh Upon Selection

Source code in MaRDMO/providers.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
class RelatedSoftwareWithCreation(Provider):
    '''Software Provider (MaRDI Portal / Wikidata / MathAlgoDB),
       User Creation, No Refresh Upon Selection
    '''

    search = True
    refresh = True

    def get_options(self, project, search=None, user=None, site=None):
        '''Query external knowledge-graph source(s) and return matching options.

        Args:
            project: RDMO project instance (used for user-entry lookups when applicable).
            search:  Search string entered by the user; returns empty list when
                     fewer than 3 characters.
            user:    Requesting user (unused).
            site:    Current site (unused).

        Returns:
            List of ``{"id": …, "text": …}`` option dicts sorted by relevance.
        '''
        if not search or len(search) < 3:
            return []

        setup = define_setup(
            query_attributes = ['software'],
            creation = True,
            item_class = _ITEMS['software'],
        )

        return query_sources_with_user_additions(
            search = search,
            project = project,
            setup = setup
        )

get_options(project, search=None, user=None, site=None)

Query external knowledge-graph source(s) and return matching options.

Parameters:

Name Type Description Default
project

RDMO project instance (used for user-entry lookups when applicable).

required
search

Search string entered by the user; returns empty list when fewer than 3 characters.

None
user

Requesting user (unused).

None
site

Current site (unused).

None

Returns:

Type Description

List of {"id": …, "text": …} option dicts sorted by relevance.

Source code in MaRDMO/providers.py
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
def get_options(self, project, search=None, user=None, site=None):
    '''Query external knowledge-graph source(s) and return matching options.

    Args:
        project: RDMO project instance (used for user-entry lookups when applicable).
        search:  Search string entered by the user; returns empty list when
                 fewer than 3 characters.
        user:    Requesting user (unused).
        site:    Current site (unused).

    Returns:
        List of ``{"id": …, "text": …}`` option dicts sorted by relevance.
    '''
    if not search or len(search) < 3:
        return []

    setup = define_setup(
        query_attributes = ['software'],
        creation = True,
        item_class = _ITEMS['software'],
    )

    return query_sources_with_user_additions(
        search = search,
        project = project,
        setup = setup
    )

Software

Bases: Provider

Software Provider (MaRDI Portal / Wikidata), No User Creation, Refresh Upon Selection

Source code in MaRDMO/providers.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class Software(Provider):
    '''Software Provider (MaRDI Portal / Wikidata),
       No User Creation, Refresh Upon Selection
    '''

    search = True
    refresh = True

    def get_options(self, project, search=None, user=None, site=None):
        '''Query external knowledge-graph source(s) and return matching options.

        Args:
            project: RDMO project instance (used for user-entry lookups when applicable).
            search:  Search string entered by the user; returns empty list when
                     fewer than 3 characters.
            user:    Requesting user (unused).
            site:    Current site (unused).

        Returns:
            List of ``{"id": …, "text": …}`` option dicts sorted by relevance.
        '''
        if not search or len(search) < 3:
            return []

        return query_sources(
            search = search,
            item_class = _ITEMS['software'],
        )

get_options(project, search=None, user=None, site=None)

Query external knowledge-graph source(s) and return matching options.

Parameters:

Name Type Description Default
project

RDMO project instance (used for user-entry lookups when applicable).

required
search

Search string entered by the user; returns empty list when fewer than 3 characters.

None
user

Requesting user (unused).

None
site

Current site (unused).

None

Returns:

Type Description

List of {"id": …, "text": …} option dicts sorted by relevance.

Source code in MaRDMO/providers.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
def get_options(self, project, search=None, user=None, site=None):
    '''Query external knowledge-graph source(s) and return matching options.

    Args:
        project: RDMO project instance (used for user-entry lookups when applicable).
        search:  Search string entered by the user; returns empty list when
                 fewer than 3 characters.
        user:    Requesting user (unused).
        site:    Current site (unused).

    Returns:
        List of ``{"id": …, "text": …}`` option dicts sorted by relevance.
    '''
    if not search or len(search) < 3:
        return []

    return query_sources(
        search = search,
        item_class = _ITEMS['software'],
    )

Handlers

General cross-catalog relation handler for MaRDMO.

Provides :class:Information, whose :meth:~Information.relation method is called whenever a relation value is saved to any MaRDMO questionnaire. It dispatches to the appropriate sub-handler (:mod:~MaRDMO.model.handlers, :mod:~MaRDMO.algorithm.handlers, or :mod:~MaRDMO.workflow.handlers) based on the active project catalog, then registers and hydrates the related entity in the questionnaire section.

Information

Class containing functions, querying external sources for specific entities and integrating the related metadata into the questionnaire.

Source code in MaRDMO/handlers.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
class Information:  # pylint: disable=too-few-public-methods
    '''Class containing functions, querying external sources for specific
       entities and integrating the related metadata into the questionnaire.'''

    def __init__(self):
        pass

    def relation(self, instance):
        '''Handle a saved relation value by registering and hydrating the entity.

        Dispatches on the project's catalog to the appropriate sub-handler, then:

        1. Adds the related entity to the correct questionnaire section.
        2. Hydrates the entity via ``fill_entity`` on the appropriate
           :class:`~MaRDMO.handler_base.BaseInformation` subclass.

        Args:
            instance: RDMO Value instance carrying ``project``, ``text``,
                      ``external_id``, and ``attribute`` attributes.
        '''
        catalog_key = str(instance.project.catalog).rsplit('/', maxsplit=1)[-1]

        dispatch = _CATALOG_DISPATCH.get(catalog_key)
        if dispatch is None:
            return
        get_uri_prefix_map, prefix_map, get_info = dispatch

        if not instance.text:
            return

        label, description, source = extract_parts(instance.text)
        config = get_uri_prefix_map()[instance.attribute.uri]
        datas  = [Relatant.from_triple(instance.external_id, label, description)]

        # --- Step 1: add entity to questionnaire section ---
        if source in ('mardi', 'wikidata'):
            add_entities(
                project      = instance.project,
                question_set = config["question_set"],
                datas        = datas,
                source       = source,
                prefix       = config["prefix"],
            )
        elif source == 'user':
            add_new_entities(
                project      = instance.project,
                question_set = config["question_set"],
                datas        = datas,
                prefix       = config["prefix"],
            )
            return  # user-defined entities have no external data to hydrate

        # --- Step 2: explicitly hydrate the entity ---
        # Only mardi/wikidata-sourced entities carry SPARQL-queryable metadata.
        if source not in ('mardi', 'wikidata'):
            return

        entry = prefix_map.get(config["prefix"])
        if not entry:
            return
        item_type, batch_method_name = entry

        info     = get_info()
        batch_fn = getattr(info, batch_method_name)

        info.fill_entity(
            project           = instance.project,
            text              = instance.text,
            external_id       = instance.external_id,
            question_id       = config["question_id"],
            item_type         = item_type,
            batch_fill_method = batch_fn,
            catalog           = catalog_key,
        )

relation(instance)

Handle a saved relation value by registering and hydrating the entity.

Dispatches on the project's catalog to the appropriate sub-handler, then:

  1. Adds the related entity to the correct questionnaire section.
  2. Hydrates the entity via fill_entity on the appropriate :class:~MaRDMO.handler_base.BaseInformation subclass.

Parameters:

Name Type Description Default
instance

RDMO Value instance carrying project, text, external_id, and attribute attributes.

required
Source code in MaRDMO/handlers.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
def relation(self, instance):
    '''Handle a saved relation value by registering and hydrating the entity.

    Dispatches on the project's catalog to the appropriate sub-handler, then:

    1. Adds the related entity to the correct questionnaire section.
    2. Hydrates the entity via ``fill_entity`` on the appropriate
       :class:`~MaRDMO.handler_base.BaseInformation` subclass.

    Args:
        instance: RDMO Value instance carrying ``project``, ``text``,
                  ``external_id``, and ``attribute`` attributes.
    '''
    catalog_key = str(instance.project.catalog).rsplit('/', maxsplit=1)[-1]

    dispatch = _CATALOG_DISPATCH.get(catalog_key)
    if dispatch is None:
        return
    get_uri_prefix_map, prefix_map, get_info = dispatch

    if not instance.text:
        return

    label, description, source = extract_parts(instance.text)
    config = get_uri_prefix_map()[instance.attribute.uri]
    datas  = [Relatant.from_triple(instance.external_id, label, description)]

    # --- Step 1: add entity to questionnaire section ---
    if source in ('mardi', 'wikidata'):
        add_entities(
            project      = instance.project,
            question_set = config["question_set"],
            datas        = datas,
            source       = source,
            prefix       = config["prefix"],
        )
    elif source == 'user':
        add_new_entities(
            project      = instance.project,
            question_set = config["question_set"],
            datas        = datas,
            prefix       = config["prefix"],
        )
        return  # user-defined entities have no external data to hydrate

    # --- Step 2: explicitly hydrate the entity ---
    # Only mardi/wikidata-sourced entities carry SPARQL-queryable metadata.
    if source not in ('mardi', 'wikidata'):
        return

    entry = prefix_map.get(config["prefix"])
    if not entry:
        return
    item_type, batch_method_name = entry

    info     = get_info()
    batch_fn = getattr(info, batch_method_name)

    info.fill_entity(
        project           = instance.project,
        text              = instance.text,
        external_id       = instance.external_id,
        question_id       = config["question_id"],
        item_type         = item_type,
        batch_fill_method = batch_fn,
        catalog           = catalog_key,
    )

Handler Base

Shared base class for MaRDMO entity handlers.

Provides the four methods that are identical between the Model and Algorithm Information classes: - _entry - _collect_existing_ids - _hydrate_relatants - _fill

Both handlers pass catalog through every call. The algorithm handler simply uses the default catalog='' everywhere.

BaseInformation

Shared infrastructure for Model and Algorithm handlers.

Subclasses must set self.questions and self.base in init, and declare _ENTITY_KEYS as a tuple of question-group keys whose ID URIs are collected by _collect_existing_ids.

Source code in MaRDMO/handler_base.py
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
class BaseInformation:  # pylint: disable=too-few-public-methods
    '''Shared infrastructure for Model and Algorithm handlers.

    Subclasses must set self.questions and self.base in __init__, and
    declare _ENTITY_KEYS as a tuple of question-group keys whose ID URIs
    are collected by _collect_existing_ids.
    '''

    # Declared here so pylint and type checkers know these attributes exist;
    # concrete values are assigned by each subclass __init__.
    base: str
    questions: dict

    _ENTITY_KEYS: tuple = ()

    # Subclass-provided attributes; None signals "not implemented by this handler".
    mathalgodb = None
    _fill_problem_batch = None
    _fill_benchmark_batch = None

    def _entry(self, instance, item_type, batch_fill_method):
        '''Common signal entry-point: build visited set, then delegate to :meth:`_fill`.

        Args:
            instance:          RDMO Value instance that triggered the signal,
                               carrying ``project``, ``text``, ``external_id``,
                               and ``set_index`` attributes.
            item_type:         Questionnaire item type string (e.g. ``'Task'``).
            batch_fill_method: Bound method (e.g. ``_fill_task_batch``) used to
                               hydrate the entity from the knowledge graph.
        '''
        visited = self._collect_existing_ids(instance.project)
        self._fill(
            project           = instance.project,
            text              = instance.text,
            external_id       = instance.external_id,
            set_index         = instance.set_index,
            item_type         = item_type,
            batch_fill_method = batch_fill_method,
            catalog           = str(getattr(instance.project, 'catalog', '')),
            visited           = visited,
        )

    def _collect_existing_ids(self, project):
        '''Return the set of external IDs already recorded in the questionnaire.

        Issues a single batched DB query across all entity sections defined in
        :attr:`_ENTITY_KEYS` so that hydration can skip already-present items.

        Args:
            project: RDMO project instance whose questionnaire values are queried.

        Returns:
            Set of external ID strings (e.g. ``{'mardi:Q42', 'wikidata:Q7'}``).
        '''
        from rdmo.domain.models import Attribute  # pylint: disable=import-outside-toplevel
        id_uris = [
            f'{self.base}{self.questions[k]["ID"]["uri"]}'
            for k in self._ENTITY_KEYS
        ]
        attr_ids = Attribute.objects.filter(uri__in=id_uris).values_list('id', flat=True)
        return set(
            project.values.filter(
                snapshot=None,
                attribute_id__in=attr_ids,
                external_id__isnull=False,
            ).exclude(external_id='').values_list('external_id', flat=True)
        )

    def _hydrate_relatants(self, project, data, prop_keys, spec):
        '''Register and hydrate all relatants found under the given property keys.

        Skips IDs already in ``spec.visited``.  When ``spec.batch_fill_method``
        is set, MaRDI and Wikidata items are collected and dispatched in a single
        SPARQL query; otherwise ``spec.fill_method`` is called per relatant.
        See :class:`_RelatantSpec` for full parameter documentation.

        Args:
            project:   RDMO project instance.
            data:      Dataclass instance whose attributes are iterated over
                       ``prop_keys`` to yield :class:`~MaRDMO.models.Relatant` items.
            prop_keys: Sequence of attribute names on *data* to iterate.
            spec:      :class:`_RelatantSpec` instance bundling context parameters.
        '''
        if spec.section_indices is not None and spec.question_set_uri in spec.section_indices:
            next_idx = spec.section_indices[spec.question_set_uri]
        else:
            existing = get_id(project, spec.question_set_uri, ['set_index'])
            next_idx = max((e for e in existing if e is not None), default=-1) + 1

        batch_items = []

        for prop in prop_keys:
            for relatant in getattr(data, prop, []):
                if relatant.id in spec.visited:
                    continue
                spec.visited.add(relatant.id)

                source = relatant.id.split(':')[0]
                text   = f'{relatant.label} ({relatant.description}) [{source}]'

                value_editor(project=project, uri=spec.question_set_uri,
                             info={'text': f'{spec.prefix}{next_idx + 1}',
                                   'set_index': next_idx})
                value_editor(project=project, uri=spec.question_id_uri,
                             info={'text': text, 'external_id': relatant.id,
                                   'set_index': next_idx})

                if spec.batch_fill_method and source in ('mardi', 'wikidata'):
                    batch_items.append((text, relatant.id, next_idx))
                else:
                    spec.fill_method(project=project, text=text,
                                     external_id=relatant.id, set_index=next_idx,
                                     catalog=spec.catalog, visited=spec.visited)

                next_idx += 1

        if spec.section_indices is not None:
            spec.section_indices[spec.question_set_uri] = next_idx

        if batch_items and spec.batch_fill_method:
            spec.batch_fill_method(project=project, items=batch_items,
                                   catalog=spec.catalog, visited=spec.visited)

    def _hydrate_qualifier_entities(self, project, data, prop_keys, spec, attr='qualifier'):
        '''Register and hydrate entities embedded as qualifier values on relatants.

        Iterates over the relatant lists named by *prop_keys*, inspects the
        ``qualifier`` attribute of each :class:`~MaRDMO.models.RelatantWithQualifier`,
        and for every qualifier item that has not yet been visited, creates a
        new set entry and calls the appropriate fill method.

        The ``qualifier`` string is parsed by
        :func:`~MaRDMO.helpers.process_qualifier` and must follow the
        ``"id || label || description"`` format (``' <<||>> '``-separated for
        multiple entries).  Entities already in ``spec.visited`` are skipped.

        When ``spec.batch_fill_method`` is set, MaRDI and Wikidata items are
        collected and dispatched as a single batch query after the loop;
        otherwise ``spec.fill_method`` is called per item.

        Args:
            project:   RDMO project instance.
            data:      Dataclass instance whose attributes are iterated over
                       *prop_keys* to yield relatant objects.
            prop_keys: Sequence of attribute names on *data* to inspect for
                       qualifier values.
            spec:      :class:`_RelatantSpec` instance bundling the target
                       question URIs, prefix, fill methods, ``catalog``,
                       ``visited``, and optional ``section_indices``.
        '''
        if spec.section_indices is not None and spec.question_set_uri in spec.section_indices:
            next_idx = spec.section_indices[spec.question_set_uri]
        else:
            existing = get_id(project, spec.question_set_uri, ['set_index'])
            next_idx = max((e for e in existing if e is not None), default=-1) + 1

        batch_items = []

        for prop in prop_keys:
            for relatant in getattr(data, prop, []):
                qualifier = getattr(relatant, attr, None)
                if not qualifier:
                    continue

                if isinstance(relatant, ProcessStepUsage):
                    source = qualifier.split(':')[0]
                    qualifier_items = [{
                        'id': qualifier,
                        'label': getattr(relatant, f'{attr}_label', '') or '',
                        'description': getattr(relatant, f'{attr}_description', '') or '',
                        'source': source,
                    }]
                else:
                    qualifier_items = process_qualifier(qualifier).values()

                for item in qualifier_items:
                    ext_id = item['id']
                    if ext_id in spec.visited:
                        continue
                    spec.visited.add(ext_id)
                    source = ext_id.split(':')[0]
                    text   = f'{item["label"]} ({item["description"]}) [{source}]'

                    value_editor(project=project, uri=spec.question_set_uri,
                                 info={'text': f'{spec.prefix}{next_idx + 1}',
                                       'set_index': next_idx})
                    value_editor(project=project, uri=spec.question_id_uri,
                                 info={'text': text, 'external_id': ext_id,
                                       'set_index': next_idx})

                    if spec.batch_fill_method and source in ('mardi', 'wikidata'):
                        batch_items.append((text, ext_id, next_idx))
                    else:
                        spec.fill_method(project=project, text=text,
                                         external_id=ext_id, set_index=next_idx,
                                         catalog=spec.catalog, visited=spec.visited)

                    next_idx += 1

        if spec.section_indices is not None:
            spec.section_indices[spec.question_set_uri] = next_idx

        if batch_items and spec.batch_fill_method:
            spec.batch_fill_method(project=project, items=batch_items,
                                   catalog=spec.catalog, visited=spec.visited)

    def _hydrate_publications(self, project, publications, catalog, visited):
        '''Register and hydrate related publications via the publication handler.

        Args:
            project:      RDMO project instance.
            publications: Iterable of :class:`~MaRDMO.models.Relatant` items
                          representing publications to register.
            catalog:      Current project catalog string, forwarded to the
                          publication handler.
            visited:      Mutable set of already-processed external IDs;
                          updated in place to prevent duplicate processing.
        '''
        pub_info    = _get_pub_info()
        pub_id_uri  = f'{self.base}{self.questions["Publication"]["ID"]["uri"]}'
        pub_set_uri = f'{self.base}{self.questions["Publication"]["uri"]}'

        existing = get_id(project, pub_set_uri, ['set_index'])
        next_idx = max((e for e in existing if e is not None), default=-1) + 1

        for pub in publications:
            if pub.id in visited:
                continue
            visited.add(pub.id)

            source = pub.id.split(':')[0]
            text   = f'{pub.label} ({pub.description}) [{source}]'
            value_editor(project=project, uri=pub_set_uri,
                         info={'text': f'P{next_idx + 1}', 'set_index': next_idx})
            value_editor(project=project, uri=pub_id_uri,
                         info={'text': text, 'external_id': pub.id,
                               'set_index': next_idx})

            pub_info.fill_citation(project=project, text=text,
                                   external_id=pub.id, set_index=next_idx,
                                   catalog=catalog)
            next_idx += 1

    def _fill_algorithm_batch(self, project, items, catalog='', visited=None):
        '''Hydrate multiple Algorithm pages with a single SPARQL query per source.

        Available in both the Algorithm and Workflow catalogs.  The Problem
        cascade and intra-class relations are skipped when the catalog does
        not have a Problem section or ``mathalgodb`` is not initialised.

        Args:
            project:  RDMO project instance.
            items:    List of ``(text, external_id, set_index)`` tuples to process.
            catalog:  Active catalog URI suffix (default ``""``).
            visited:  Set of external IDs already processed (mutated to avoid cycles).
        '''
        from functools import partial  # pylint: disable=import-outside-toplevel

        if not items:
            return
        if visited is None:
            visited = set()

        algorithm  = self.questions['Algorithm']
        data_by_id = _fetch_by_source(
            items,
            'queries/algorithm_mardi.sparql',
            'queries/algorithm_wikidata.sparql',
            Algorithm,
        )
        if not data_by_id:
            return

        section_indices = {}
        for text, external_id, set_index in items:
            data = data_by_id.get(external_id)
            if not data:
                continue

            add_basics(project=project, text=text, questions=self.questions,
                       item_type='Algorithm', index=(0, set_index))

            add_relations_static(
                project=project, data=data,
                props={'keys': ALGORITHM_PROPS['A2P']},
                index={'set_prefix': set_index},
                statement={'relatant': f'{self.base}{algorithm["PRelatant"]["uri"]}'})

            if 'Problem' in self.questions:
                self._hydrate_relatants(
                    project=project, data=data, prop_keys=ALGORITHM_PROPS['A2P'],
                    spec=_RelatantSpec(
                        question_id_uri=f'{self.base}{self.questions["Problem"]["ID"]["uri"]}',
                        question_set_uri=f'{self.base}{self.questions["Problem"]["uri"]}',
                        prefix='AT',
                        fill_method=partial(self._fill, item_type='Problem',
                                            batch_fill_method=self._fill_problem_batch),
                        catalog=catalog, visited=visited,
                        batch_fill_method=self._fill_problem_batch,
                        section_indices=section_indices,
                    ))

            add_relations_static(
                project=project, data=data,
                props={'keys': ALGORITHM_PROPS['A2S']},
                index={'set_prefix': set_index},
                statement={'relatant': f'{self.base}{algorithm["SRelatant"]["uri"]}'})

            self._hydrate_relatants(
                project=project, data=data, prop_keys=ALGORITHM_PROPS['A2S'],
                spec=_RelatantSpec(
                    question_id_uri=f'{self.base}{self.questions["Software"]["ID"]["uri"]}',
                    question_set_uri=f'{self.base}{self.questions["Software"]["uri"]}',
                    prefix='S',
                    fill_method=partial(self._fill, item_type='Software',
                                        batch_fill_method=self._fill_software_batch),
                    catalog=catalog, visited=visited,
                    batch_fill_method=self._fill_software_batch,
                    section_indices=section_indices,
                ))

            if self.mathalgodb is not None and 'IntraClassRelation' in algorithm:
                add_relations_flexible(
                    project=project, data=data,
                    props={'keys': ALGORITHM_PROPS['Algorithm'], 'mapping': self.mathalgodb},
                    index={'set_prefix': set_index},
                    statement={
                        'relation': f'{self.base}{algorithm["IntraClassRelation"]["uri"]}',
                        'relatant': f'{self.base}{algorithm["IntraClassElement"]["uri"]}',
                    })

            self._hydrate_publications(project, data.publications, catalog, visited)

    def _fill_software_batch(self, project, items, catalog='', visited=None):
        '''Hydrate multiple Software pages with a single SPARQL query per source.

        Writes references, programming languages, and dependencies for all
        catalogs.  The benchmark cascade and publication hydration are only
        executed when the active catalog contains a ``Benchmark`` or
        ``Publication`` section respectively (i.e. the algorithm catalog).

        Args:
            project:  RDMO project instance.
            items:    List of ``(text, external_id, set_index)`` tuples to process.
            catalog:  Active catalog URI suffix (default ``""``).
            visited:  Set of external IDs already processed (mutated to avoid cycles).
        '''
        from functools import partial  # pylint: disable=import-outside-toplevel

        if not items:
            return
        if visited is None:
            visited = set()

        software   = self.questions['Software']
        data_by_id = _fetch_by_source(
            items,
            'queries/software_mardi.sparql',
            'queries/software_wikidata.sparql',
            Software,
        )
        if not data_by_id:
            return

        section_indices = {}
        for text, external_id, set_index in items:
            data = data_by_id.get(external_id)
            if not data:
                continue

            add_basics(project=project, text=text, questions=self.questions,
                       item_type='Software', index=(0, set_index))

            add_references(project=project, data=data,
                           uri=f'{self.base}{software["Reference"]["uri"]}',
                           set_prefix=set_index)

            add_relations_static(
                project=project, data=data,
                props={'keys': SOFTWARE_PROPS['S2PL']},
                index={'set_prefix': set_index},
                statement={'relatant': f'{self.base}{software["Programming Language"]["uri"]}'})

            add_relations_static(
                project=project, data=data,
                props={'keys': SOFTWARE_PROPS['S2DP']},
                index={'set_prefix': set_index},
                statement={'relatant': f'{self.base}{software["Dependency"]["uri"]}'})

            if 'Benchmark' in self.questions:
                add_relations_static(
                    project=project, data=data,
                    props={'keys': SOFTWARE_PROPS['S2B']},
                    index={'set_prefix': set_index},
                    statement={'relatant': f'{self.base}{software["BRelatant"]["uri"]}'})

                self._hydrate_relatants(
                    project=project, data=data, prop_keys=SOFTWARE_PROPS['S2B'],
                    spec=_RelatantSpec(
                        question_id_uri=f'{self.base}{self.questions["Benchmark"]["ID"]["uri"]}',
                        question_set_uri=f'{self.base}{self.questions["Benchmark"]["uri"]}',
                        prefix='B',
                        fill_method=partial(self._fill, item_type='Benchmark',
                                            batch_fill_method=self._fill_benchmark_batch),
                        catalog=catalog, visited=visited,
                        batch_fill_method=self._fill_benchmark_batch,
                        section_indices=section_indices,
                    ))

            if 'Publication' in self.questions:
                self._hydrate_publications(project, data.publications, catalog, visited)

    def fill_entity(self, project, text, external_id, question_id,
                    item_type, batch_fill_method, catalog):
        '''Look up the set_index for *external_id* and hydrate the entity via :meth:`_fill`.

        Called from the top-level handlers dispatcher when a relation value is
        saved to the questionnaire.

        Args:
            project:           RDMO project instance.
            text:              Display text for the entity (label + description + source).
            external_id:       External ID string (e.g. ``'mardi:Q42'``).
            question_id:       Full RDMO attribute URI used to locate the entity's
                               set_index in the questionnaire.
            item_type:         Questionnaire item type string (e.g. ``'Task'``).
            batch_fill_method: Bound ``_fill_*_batch`` method for SPARQL hydration.
            catalog:           Current project catalog string.
        '''
        visited    = self._collect_existing_ids(project)
        id_entries = get_id(project, question_id, ['set_index', 'external_id'])
        for set_index, ext_id in id_entries:
            if ext_id == external_id:
                self._fill(
                    project           = project,
                    text              = text,
                    external_id       = external_id,
                    set_index         = set_index,
                    item_type         = item_type,
                    batch_fill_method = batch_fill_method,
                    catalog           = catalog,
                    visited           = visited,
                )
                break

    def _fill(
        self, project, text, external_id, set_index,
        item_type, batch_fill_method, catalog='', visited=None
    ):
        '''Write basic questionnaire fields and delegate SPARQL hydration.

        Skips empty or ``'not found'`` entries, calls :func:`~MaRDMO.adders.add_basics`
        for all entities, then delegates to *batch_fill_method* for MaRDI and
        Wikidata entities.

        Args:
            project:           RDMO project instance.
            text:              Display text for the entity.
            external_id:       External ID string (e.g. ``'mardi:Q42'``).
            set_index:         Questionnaire set index for this entity.
            item_type:         Questionnaire item type string (e.g. ``'Task'``).
            batch_fill_method: Bound ``_fill_*_batch`` method for SPARQL hydration.
            catalog:           Current project catalog string (default ``''``).
            visited:           Mutable set of already-processed external IDs
                               (default ``None``; a new set is created if omitted).
        '''
        if not text or text == 'not found':
            return
        if visited is None:
            visited = set()
        visited.add(external_id)
        add_basics(project=project, text=text, questions=self.questions,
                   item_type=item_type, index=(0, set_index))
        if external_id.split(':')[0] not in ('mardi', 'wikidata'):
            return
        batch_fill_method(project=project, items=[(text, external_id, set_index)],
                          catalog=catalog, visited=visited)

fill_entity(project, text, external_id, question_id, item_type, batch_fill_method, catalog)

Look up the set_index for external_id and hydrate the entity via :meth:_fill.

Called from the top-level handlers dispatcher when a relation value is saved to the questionnaire.

Parameters:

Name Type Description Default
project

RDMO project instance.

required
text

Display text for the entity (label + description + source).

required
external_id

External ID string (e.g. 'mardi:Q42').

required
question_id

Full RDMO attribute URI used to locate the entity's set_index in the questionnaire.

required
item_type

Questionnaire item type string (e.g. 'Task').

required
batch_fill_method

Bound _fill_*_batch method for SPARQL hydration.

required
catalog

Current project catalog string.

required
Source code in MaRDMO/handler_base.py
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
def fill_entity(self, project, text, external_id, question_id,
                item_type, batch_fill_method, catalog):
    '''Look up the set_index for *external_id* and hydrate the entity via :meth:`_fill`.

    Called from the top-level handlers dispatcher when a relation value is
    saved to the questionnaire.

    Args:
        project:           RDMO project instance.
        text:              Display text for the entity (label + description + source).
        external_id:       External ID string (e.g. ``'mardi:Q42'``).
        question_id:       Full RDMO attribute URI used to locate the entity's
                           set_index in the questionnaire.
        item_type:         Questionnaire item type string (e.g. ``'Task'``).
        batch_fill_method: Bound ``_fill_*_batch`` method for SPARQL hydration.
        catalog:           Current project catalog string.
    '''
    visited    = self._collect_existing_ids(project)
    id_entries = get_id(project, question_id, ['set_index', 'external_id'])
    for set_index, ext_id in id_entries:
        if ext_id == external_id:
            self._fill(
                project           = project,
                text              = text,
                external_id       = external_id,
                set_index         = set_index,
                item_type         = item_type,
                batch_fill_method = batch_fill_method,
                catalog           = catalog,
                visited           = visited,
            )
            break

Router

Django signal router that dispatches RDMO value saves and deletes to MaRDMO handlers.

On every value_created or value_updated signal the post-save router checks whether the project's catalog is a MaRDMO catalog and, if so, looks up the saved attribute URI in HANDLER_MAP to call the matching handler method.

On every Django post_delete signal on Value the post-delete router does the same lookup in DELETE_HANDLER_MAP. Using post_delete (rather than RDMO's custom value_deleted) ensures the handler fires for both individual REST deletions and set deletions via RDMO's delete_set, which calls value.delete() directly.

Both maps are assembled once at startup via :func:~MaRDMO.builders.build_handler_map and :func:~MaRDMO.builders.build_delete_handler_map.

Provides:

  • HANDLER_MAP{catalog: {uri: handler}} dispatch dict for saves
  • DELETE_HANDLER_MAP{catalog: {uri: handler}} dispatch dict for deletes
  • mardmo_router_post_save — receiver wired to value_created and value_updated
  • mardmo_router_post_delete — receiver wired to Django's post_delete on Value

mardmo_router_post_delete(sender, instance, **kwargs)

Post-delete router: dispatch Value deletions to the correct MaRDMO handler.

Connected to Django's post_delete signal on Value. This covers both individual REST deletions (via perform_destroy) and set deletions (via RDMO's delete_set, which calls value.delete() directly without sending value_deleted). Looks up the project catalog and attribute URI in DELETE_HANDLER_MAP and calls the matching handler, if any.

Parameters:

Name Type Description Default
sender

Signal sender class (Value).

required
instance

The deleted :class:~rdmo.projects.models.Value instance.

required
**kwargs

Additional signal keyword arguments (unused).

{}
Source code in MaRDMO/router.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
@receiver(post_delete, sender=Value)
def mardmo_router_post_delete(sender, instance, **kwargs):  # pylint: disable=unused-argument
    """Post-delete router: dispatch Value deletions to the correct MaRDMO handler.

    Connected to Django's ``post_delete`` signal on ``Value``.  This covers both
    individual REST deletions (via ``perform_destroy``) and set deletions (via
    RDMO's ``delete_set``, which calls ``value.delete()`` directly without sending
    ``value_deleted``).
    Looks up the project catalog and attribute URI in ``DELETE_HANDLER_MAP`` and
    calls the matching handler, if any.

    Args:
        sender:   Signal sender class (``Value``).
        instance: The deleted :class:`~rdmo.projects.models.Value` instance.
        **kwargs: Additional signal keyword arguments (unused).
    """
    if not instance:
        return

    catalog_name = _catalog_name(instance)
    if not catalog_name:
        return

    attr_uri = getattr(instance.attribute, "uri", None)
    if not attr_uri:
        return

    handler = DELETE_HANDLER_MAP.get(catalog_name, {}).get(attr_uri)
    if handler:
        handler(instance)

mardmo_router_post_save(sender, instance, update_fields=None, **kwargs)

Post-save router: dispatch Value saves to the correct MaRDMO handler.

Connected to both value_created and value_updated signals. Looks up the project catalog and attribute URI in HANDLER_MAP and calls the matching handler, if any.

Parameters:

Name Type Description Default
sender

Signal sender class (Value).

required
instance

The saved :class:~rdmo.projects.models.Value instance.

required
update_fields

Fields that were updated (unused; present for signal compatibility).

None
**kwargs

Additional signal keyword arguments (unused).

{}
Source code in MaRDMO/router.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
@receiver(value_created, sender=Value)
@receiver(value_updated, sender=Value)
def mardmo_router_post_save(sender, instance, update_fields=None, **kwargs):  # pylint: disable=unused-argument
    """Post-save router: dispatch Value saves to the correct MaRDMO handler.

    Connected to both ``value_created`` and ``value_updated`` signals.
    Looks up the project catalog and attribute URI in ``HANDLER_MAP`` and
    calls the matching handler, if any.

    Args:
        sender:        Signal sender class (``Value``).
        instance:      The saved :class:`~rdmo.projects.models.Value` instance.
        update_fields: Fields that were updated (unused; present for signal compatibility).
        **kwargs:      Additional signal keyword arguments (unused).
    """
    if not instance:
        return

    catalog_name = _catalog_name(instance)
    if not catalog_name:
        return

    attr_uri = getattr(instance.attribute, "uri", None)
    if not attr_uri:
        return

    handler = HANDLER_MAP.get(catalog_name, {}).get(attr_uri)
    if handler:
        handler(instance)

Views

Django views for the MaRDMO plugin.

Provides the three AJAX endpoints used by the questionnaire frontend to communicate with background workers, plus the render_preview helper used by export providers to build HTML previews.

Provides:

  • get_progress — JSON endpoint that returns current task progress for polling
  • show_progress — HTML view that renders a live progress page for a running task
  • show_success — HTML view that renders the completed-task result page
  • render_preview — helper that renders a Jinja2 template with questionnaire answers

Success page (show_success)

After a successful portal export the success page presents two views of the exported data, switchable via tab buttons:

List view — every newly created MaRDI Portal item, grouped by class, with all its statements shown as property → value lines. Values that are external identifiers (DOI, ORCID, swMath ID, …) or URLs are rendered as clickable links. Qualifier statements are shown indented beneath their parent. Mathematical expressions (math datatype) are typeset via MathJax <https://www.mathjax.org/>_ (Apache 2.0).

Graph view — an interactive network graph built with Cytoscape.js <https://js.cytoscape.org/>_ (MIT) showing items as labelled circles and literal values as rectangles, connected by directed, labelled edges. Features include zoom/pan controls, click-to-open portal links on nodes, a qualifier tooltip on edge click, and a filter panel that lets the user toggle individual node classes, literal/existing-item layers, and individual edge properties.

get_progress(request, job_id)

Return the current progress data for job_id as a JSON response.

Parameters:

Name Type Description Default
request

Django HTTP request; the user must own the job (checked via the session). Raises :exc:~django.http.Http404 otherwise.

required
job_id

Unique job identifier string.

required

Returns:

Type Description

class:~django.http.JsonResponse with keys progress (int 0–100)

and done (bool), defaulting to {"progress": 0, "done": False}

when no data is found.

Source code in MaRDMO/views.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
@login_required
def get_progress(request, job_id):
    """Return the current progress data for *job_id* as a JSON response.

    Args:
        request: Django HTTP request; the user must own the job (checked via
                 the session).  Raises :exc:`~django.http.Http404` otherwise.
        job_id:  Unique job identifier string.

    Returns:
        :class:`~django.http.JsonResponse` with keys ``progress`` (int 0–100)
        and ``done`` (bool), defaulting to ``{"progress": 0, "done": False}``
        when no data is found.
    """
    if not _job_belongs_to_session(request, job_id):
        raise Http404()

    data = _progress_store.get(job_id, {"progress": 0, "done": False})
    return JsonResponse(data)

render_preview(self, template, answers, option, submit_label)

Render the documentation preview page.

Parameters:

Name Type Description Default
self

Export provider instance with request, ExportForm, and project attributes.

required
template

Template name (relative path) to include in the preview.

required
answers

Prepared answers dict passed to the template context.

required
option

Option string controlling template behaviour (passed to context).

required

Returns:

Type Description

HTTP 200 response rendering MaRDMO/mardmoPreview.html.

Source code in MaRDMO/views.py
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
def render_preview(self, template, answers, option, submit_label):
    """Render the documentation preview page.

    Args:
        self:     Export provider instance with ``request``, ``ExportForm``,
                  and ``project`` attributes.
        template: Template name (relative path) to include in the preview.
        answers:  Prepared answers dict passed to the template context.
        option:   Option string controlling template behaviour (passed to context).

    Returns:
        HTTP 200 response rendering ``MaRDMO/mardmoPreview.html``.
    """
    return render(
        self.request,
        'MaRDMO/mardmoPreview.html', 
        {
            'form': self.ExportForm(),
            'include_file': template,
            'include_params': {
                'title': self.project.title
            },
            'answers': answers,
            'option': option,
            'submit_label': submit_label,
            'mardiURI': get_item_url('mardi'),
            'wikidataURI': get_item_url('wikidata'),
        },
        status=200
    )

show_progress(request, job_id)

Render the progress-bar page for the given job.

Parameters:

Name Type Description Default
request

Django HTTP request; the user must own the job (checked via the session). Raises :exc:~django.http.Http404 otherwise.

required
job_id

Unique job identifier string passed to the template.

required

Returns:

Type Description

Rendered MaRDMO/progress.html response with context {"job_id": job_id}.

Source code in MaRDMO/views.py
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
@login_required
@require_GET
def show_progress(request, job_id):
    """Render the progress-bar page for the given job.

    Args:
        request: Django HTTP request; the user must own the job (checked via
                 the session).  Raises :exc:`~django.http.Http404` otherwise.
        job_id:  Unique job identifier string passed to the template.

    Returns:
        Rendered ``MaRDMO/progress.html`` response with context ``{"job_id": job_id}``.
    """
    if not _job_belongs_to_session(request, job_id):
        raise Http404()

    return render(request, "MaRDMO/progress.html", {"job_id": job_id})

show_success(request, job_id)

Render the export-success page and clean up the job's progress entry.

Parameters:

Name Type Description Default
request

Django HTTP request; the user must own the job (checked via the session). Raises :exc:~django.http.Http404 otherwise.

required
job_id

Unique job identifier string; the associated progress data is cleared from the cache after being read.

required

Returns:

Type Description

Rendered MaRDMO/portalExport.html with the exported item IDs,

or an error page if the job data is missing.

Source code in MaRDMO/views.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
@login_required
@require_GET
def show_success(request, job_id):
    """Render the export-success page and clean up the job's progress entry.

    Args:
        request: Django HTTP request; the user must own the job (checked via
                 the session).  Raises :exc:`~django.http.Http404` otherwise.
        job_id:  Unique job identifier string; the associated progress data is
                 cleared from the cache after being read.

    Returns:
        Rendered ``MaRDMO/portalExport.html`` with the exported item IDs,
        or an error page if the job data is missing.
    """
    if not _job_belongs_to_session(request, job_id):
        raise Http404()

    job_data = _progress_store.get(job_id)
    if not job_data or "ids" not in job_data:
        return render(
            request,
            "core/error.html",
            {
                "title": "Not ready",
                "errors": ["Job not found."],
            },
        )

    # Once the success page is shown we can drop the progress entry
    clear_progress(job_id)
    _unregister_job_for_session(request, job_id)

    # Group ids by class in a fixed display order
    _CLASS_ORDER = [
        'Mathematical Model', 'Research Problem', 'Formula', 'Computational Task',
        'Quantity', 'Algorithm', 'Algorithmic Task', 'Software', 'Benchmark',
        'Workflow', 'Process Step', 'Hardware', 'CPU Model', 'Dataset',
        'Experimental Method', 'Experimental Equipment', 'Academic Discipline',
        'Programming Language', 'Publication', 'Author', 'Journal',
    ]
    grouped = defaultdict(list)
    for cls, name, qid in job_data["ids"]:
        grouped[cls].append({'name': name, 'qid': qid})
    # Split statements: those on newly created items vs. those on existing items
    created_qids = {qid for cls, name, qid in job_data["ids"]}

    _SKIP_PROPS = {'community', 'MaRDI profile type'}

    def _obj_url(prop, obj, obj_qid):
        """Return a clickable external URL for *obj* based on its property name.

        Returns an empty string when *obj_qid* is set (the object is already a
        portal item and needs no external URL) or when the property is not one
        of the known identifier/URL properties.
        """
        if obj_qid:
            return ''
        if prop == 'Wikidata QID':
            return f'{WIKIDATA["entity"]}{obj}'
        if prop == 'DOI':
            return f'{DOI_BASE_URL}{obj}'
        if prop == 'swMath work ID':
            return f'{SWMATH_BASE_URL}{obj}'
        if prop == 'MORwiki ID':
            return f'{MORWIKI_BASE_URL}{obj}'
        if prop in ('described at URL', 'source code repository URL', 'URL'):
            return obj
        if prop == 'QUDT quantity kind ID':
            return f'{QUDT_QUANTITYKIND_URL}{obj}'
        if prop == 'QUDT constant ID':
            return f'{QUDT_CONSTANT_URL}{obj}'
        if prop == 'ORCID iD':
            return f'{ORCID_BASE_URL}{obj}'
        if prop == 'zbMATH author ID':
            return f'{ZBMATH_AUTHOR_BASE_URL}{obj}'
        if prop == 'ISSN':
            return f'{ISSN_BASE_URL}{obj}'
        return ''

    stmt_map = defaultdict(list)
    relation_stmts = []
    for stmt in job_data.get("statements", []):
        subj, subj_qid, prop, obj, obj_qid = stmt[0], stmt[1], stmt[2], stmt[3], stmt[4]
        qualifiers  = stmt[5] if len(stmt) > 5 else []
        unit_label  = stmt[6] if len(stmt) > 6 else ''
        unit_qid    = stmt[7] if len(stmt) > 7 else ''
        obj_url = _obj_url(prop, obj, obj_qid)
        for q in qualifiers:
            q['obj_url'] = _obj_url(q['prop'], q['obj'], q.get('obj_qid', ''))
        if prop in _SKIP_PROPS:
            continue
        if subj_qid in created_qids:
            stmt_map[subj_qid].append(
                {'prop': prop, 'obj': obj, 'obj_qid': obj_qid, 'obj_url': obj_url,
                 'qualifiers': qualifiers, 'unit_label': unit_label, 'unit_qid': unit_qid}
            )
        else:
            relation_stmts.append({
                'subject': subj, 'subject_qid': subj_qid,
                'prop': prop, 'obj': obj, 'obj_qid': obj_qid, 'obj_url': obj_url,
                'qualifiers': qualifiers, 'unit_label': unit_label, 'unit_qid': unit_qid,
            })

    # Attach statements to each item and group by class
    items_by_class = defaultdict(list)
    for cls, name, qid in job_data["ids"]:
        items_by_class[cls].append({'name': name, 'qid': qid, 'stmts': stmt_map.get(qid, [])})

    grouped_ids = [
        (cls, items_by_class[cls]) for cls in _CLASS_ORDER if cls in items_by_class
    ] + [
        (cls, items) for cls, items in items_by_class.items() if cls not in _CLASS_ORDER
    ]

    mardi_uri = get_item_url('mardi')

    # Build graph data for Cytoscape
    graph_nodes = {}
    graph_edges = []
    _lit_idx = 0

    def _add_item_node(qid, label, node_type, cls=''):
        """Insert or upgrade a node entry in *graph_nodes*.

        *node_type* ``'new'`` always wins over ``'item'``; an existing ``'new'``
        node is never overwritten by an ``'item'`` entry for the same QID.
        """
        if not qid:
            return
        existing = graph_nodes.get(qid)
        # 'new' always wins; only insert 'item' if the node isn't already known
        if existing is None or (node_type == 'new' and existing['node_type'] != 'new'):
            graph_nodes[qid] = {
                'id': qid, 'label': label,
                'node_type': node_type, 'cls': cls,
                'url': mardi_uri + qid,
            }

    def _get_obj_node(obj_qid, obj_label, obj_url=''):
        """Return the graph node ID for a statement object, creating the node if needed.

        If *obj_qid* is set the object is a portal item (circle node).  Otherwise
        a fresh literal node (rectangle) is created with an auto-incremented ID
        and optional *obj_url* for click-through linking.
        """
        nonlocal _lit_idx
        if obj_qid:
            _add_item_node(obj_qid, obj_label, 'item')
            return obj_qid
        nid = f'_lit_{_lit_idx}'
        _lit_idx += 1
        graph_nodes[nid] = {
            'id': nid, 'label': obj_label, 'node_type': 'literal', 'cls': '', 'url': obj_url,
        }
        return nid

    for cls, items in grouped_ids:
        for item in items:
            qid = item['qid']
            _add_item_node(qid, item['name'], 'new', cls)
            for s in item['stmts']:
                obj_nid = _get_obj_node(s['obj_qid'], s['obj'], s.get('obj_url', ''))
                graph_edges.append({
                    'source': qid, 'target': obj_nid,
                    'label': s['prop'], 'qualifiers': s.get('qualifiers', []),
                })
                if s.get('unit_qid'):
                    _add_item_node(s['unit_qid'], s['unit_label'], 'item')
                    graph_edges.append({
                        'source': obj_nid, 'target': s['unit_qid'],
                        'label': 'unit', 'qualifiers': [],
                    })

    for r in relation_stmts:
        subj_qid = r['subject_qid']
        if subj_qid:
            _add_item_node(subj_qid, r['subject'], 'item')
            obj_nid = _get_obj_node(r['obj_qid'], r['obj'], r.get('obj_url', ''))
            graph_edges.append({
                'source': subj_qid, 'target': obj_nid,
                'label': r['prop'], 'qualifiers': r.get('qualifiers', []),
            })
            if r.get('unit_qid'):
                _add_item_node(r['unit_qid'], r['unit_label'], 'item')
                graph_edges.append({
                    'source': obj_nid, 'target': r['unit_qid'],
                    'label': 'unit', 'qualifiers': [],
                })

    graph_data = {'nodes': list(graph_nodes.values()), 'edges': graph_edges}

    return render(
        request,
        "MaRDMO/portalExport.html",
        {
            "grouped_ids": grouped_ids,
            "relation_stmts": relation_stmts,
            "mardi_uri": mardi_uri,
            "graph_data": graph_data,
        },
    )