1
0
Fork 1
mirror of https://github.com/bookwyrm-social/bookwyrm.git synced 2025-04-23 02:34:19 +00:00

Enable connectors to be managed in admin interface

This patch creates a "connectors" page in the admin interface.

Admins can activate new connectors from here after they are added in a version of BookWyrm. The can also disable and re-enable connectors, and change the priority order for all connectors.

I have created a connector.update() function for future use. This will allow admins to easily update an existing connector when e.g. the API endpoints change.
This commit is contained in:
Hugh Rundle 2025-03-30 13:18:29 +11:00
parent a709121846
commit 90cad57dba
No known key found for this signature in database
GPG key ID: A7E35779918253F9
12 changed files with 446 additions and 2 deletions

View file

@ -3,4 +3,4 @@ from .settings import CONNECTORS
from .abstract_connector import ConnectorException
from .abstract_connector import get_data, get_image, maybe_isbn
from .connector_manager import search, first_search_result
from .connector_manager import search, first_search_result, create_finna_connector

View file

@ -206,3 +206,26 @@ def raise_not_valid_url(url: str) -> None:
if models.FederatedServer.is_blocked(url):
raise ConnectorException(f"Attempting to load data from blocked url: {url}")
def create_finna_connector() -> None:
"""create a Finna connector"""
models.Connector.objects.create(
identifier="api.finna.fi",
name="Finna API",
connector_file="finna",
base_url="https://www.finna.fi",
books_url="https://api.finna.fi/api/v1/record" "?id=",
covers_url="https://api.finna.fi",
search_url="https://api.finna.fi/api/v1/search?limit=20"
"&filter[]=format%3a%220%2fBook%2f%22"
"&field[]=title&field[]=recordPage&field[]=authors"
"&field[]=year&field[]=id&field[]=formats&field[]=images"
"&lookfor=",
isbn_search_url="https://api.finna.fi/api/v1/search?limit=1"
"&filter[]=format%3a%220%2fBook%2f%22"
"&field[]=title&field[]=recordPage&field[]=authors&field[]=year"
"&field[]=id&field[]=formats&field[]=images"
"&lookfor=isbn:",
)

View file

@ -1,3 +1,8 @@
""" settings book data connectors """
CONNECTORS = ["openlibrary", "inventaire", "bookwyrm_connector", "finna"]
CONNECTORS = [
"openlibrary",
"inventaire",
"bookwyrm_connector",
"finna",
]

View file

@ -1,4 +1,6 @@
""" manages interfaces with external sources of book data """
from typing import Optional
from django.db import models
from bookwyrm.connectors.settings import CONNECTORS
@ -29,3 +31,34 @@ class Connector(BookWyrmModel):
def __str__(self):
return f"{self.identifier} ({self.id})"
def deactivate(self, reason: Optional[str] = None) -> None:
"""Make an active connector inactive. We do not delete connectors
because they have books and authors associated with them."""
self.active = False
self.deactivation_reason = reason
self.save(update_fields=["active", "deactivation_reason"])
def activate(self) -> None:
"""Make an inactive connector active again"""
self.active = True
self.deactivation_reason = None
self.save(update_fields=["active", "deactivation_reason"])
def change_priority(self, priority: int) -> None:
"""Change the priority value for a connector
This determines the order they appear in book search"""
self.priority = priority
self.save(update_fields=["priority"])
def update(self) -> None:
"""Update the settings for this connector. e.g. if the
API endpoints change."""
# example
# if self.identifier == "openlibrary.org":
# self.isbn_search_url = "https://openlibrary.org/search.json?isbn="
# self.save(update_fields=["isbn_search_url"])

View file

@ -0,0 +1,36 @@
{% load i18n %}
<li class="block">
<div class="card ">
<div class="card-header has-background-info-light">
<h4 class="m-3"><strong>
{% if connector == "finna" %}
Finna
{% elif connector == "awesome_connector" %}
Awesome connector
{% endif %}
</strong></h4>
</div>
<div class="card-content">
<p>
{% if connector == "finna" %}
{% trans "Finna.fi is a search service that collects material from hundreds of Finnish organisations under one roof." %}
{% elif connector == "awesome_connector" %}
This new connector is really good, you'll like it.
{% endif %}
</p>
</div>
<div class="card-footer is-stacked-mobile is-align-items-stretch">
<form
name="create-connector"
method="post"
action="{% url 'settings-create-connector' %}"
class="card-footer-item m-2"
>
{% csrf_token %}
<input type="hidden" name="connector_file" value={{connector}}>
<button type="submit" class="button is-small is-info">{% trans "Create new connector" %}</button>
</form>
</div>
</div>
</li>

View file

@ -0,0 +1,66 @@
{% load i18n %}
<li class="block">
<div class="card">
<div class="card-header {% if connector.active %}has-background-success-light{%else%}has-background-danger-light{%endif%}">
<p class="m-2">
{% if connector.name %}<strong>{{connector.name}}</strong> - {% endif %}{{connector.identifier}}
</p>
</div>
<div class="card-footer is-stacked-mobile has-background-tertiary is-align-items-stretch">
{% if connector.active %}
<form
name="set-position-{{ connector.id }}"
method="post"
action="{% url 'settings-connector-priority' connector.id %}"
class="card-footer-item"
>
{% csrf_token %}
<div class="field has-addons mb-0">
<div class="control">
<label for="input-priority" class="button is-transparent is-small">{% trans "Priority" %}</label>
</div>
<div class="control">
<input id="input_priority_{{ connector.id }}" class="input is-small" type="number" min="1" name="priority" value="{{connector.priority}}">
</div>
<div class="control">
<button type="submit" class="button is-info is-small is-tablet">{% trans "Set" %}</button>
</div>
</div>
</form>
{% else %}
<div class="card-footer-item">
<div>
<h3 class="menu-label">{% trans 'Deactivation reason:' %}</h3>
<p>{{ connector.deactivation_reason }}</p>
</div>
</div>
{% endif %}
{% if connector.active %}
<form
name="deactivate-{{ connector.id }}"
method="post"
action="{% url 'settings-deactivate-connector' connector.id %}"
class="card-footer-item m-2"
>
{% csrf_token %}
<input type="hidden" name="item" value="{{ connector.id }}">
<button type="submit" class="button is-small is-danger">{% trans "Deactivate" %}</button>
</form>
{% else %}
<form
name="activate-{{ connector.id }}"
method="post"
action="{% url 'settings-activate-connector' connector.id %}"
class="card-footer-item"
>
{% csrf_token %}
<input type="hidden" name="item" value="{{ connector.id }}">
<button type="submit" class="button is-small is-success">{% trans "Activate" %}</button>
</form>
{% endif %}
</div>
</div>
</li>

View file

@ -0,0 +1,40 @@
{% extends 'settings/layout.html' %}
{% load i18n %}
{% block title %}{% trans "Connector Settings" %}{% endblock %}
{% block header %}{% trans "Connector Settings" %}{% endblock %}
{% block panel %}
<div class="columns mt-3">
<section class="column is-three-quarters">
{% blocktrans %}
<p class="mb-2">Connectors are sources of data about books and authors.</p>
<p class="mb-2">The priority determines the order in which search results appear. The highest priority is <code>1</code>. The default priority is <code>2</code>.</p>
{% endblocktrans %}
<p class="mb-2">
{% trans "Connector settings only determine whether a connector will be used to deliver search results. To manage more interactions with other federated servers, including domain blocks, see" %} <a href="{% url 'settings-federation' %}"> {% trans "Federated Instances" %}</a>.
</p>
<h4 class="title is-4 my-6">External Connectors</h4>
<ul class="ordered-list">
{% for connector in update_connectors %}
{% include "settings/connectors/update.html" %}
{% endfor %}
{% for connector in other_connectors %}
{% include "settings/connectors/connector.html" %}
{% endfor %}
{% if available_connectors %}
{% for connector in available_connectors %}
{% include "settings/connectors/available.html" %}
{% endfor %}
{% endif%}
</ul>
<h4 class="title is-4 my-6">BookWyrm Connectors</h4>
<ul class="ordered-list">
{% for connector in bookwyrm_connectors %}
{% include "settings/connectors/connector.html" %}
{% endfor %}
</ul>
</section>
</div>
{% endblock %}

View file

@ -0,0 +1,84 @@
{% load i18n %}
<li class="block">
<div class="card">
<div class="card-header has-background-warning p-3">
<span class="icon icon-warning is-size-4 p-3 is-pulled-right" aria-hidden="true"></span>
{% if connector.name %}<strong>{{connector.name}}</strong> - {% endif %}{{connector.identifier}}
</div>
<div class="card-footer is-stacked-mobile is-align-items-stretch">
{% if connector.active %}
<form
name="set-position-{{ connector.id }}"
method="post"
action="{% url 'settings-connector-priority' connector.id %}"
class="card-footer-item m-2"
>
{% csrf_token %}
<div class="field has-addons mb-0">
<div class="control">
<label for="input-priority" class="button is-transparent is-small">{% trans "Priority" %}</label>
</div>
<div class="control">
<input id="input_priority_{{ connector.id }}" class="input is-small" type="number" min="1" name="priority" value="{{connector.priority}}">
</div>
<div class="control">
<button type="submit" class="button is-info is-small is-tablet">{% trans "Set" %}</button>
</div>
</div>
</form>
{% else %}
<div>
<h3 class="menu-label">{% trans 'Deactivation reason:' %}</h3>
<p>{{ connector.deactivation_reason }}</p>
</div>
{% endif %}
{% if connector.active %}
<form
name="deactivate-{{ connector.id }}"
method="post"
action="{% url 'settings-deactivate-connector' connector.id %}"
class="card-footer-item"
>
{% csrf_token %}
<input type="hidden" name="item" value="{{ connector.id }}">
<button type="submit" class="button is-small is-danger">{% trans "Deactivate" %}</button>
</form>
{% else %}
<form
name="activate-{{ connector.id }}"
method="post"
action="{% url 'settings-activate-connector' connector.id %}"
class="card-footer-item"
>
{% csrf_token %}
<input type="hidden" name="item" value="{{ connector.id }}">
<button type="submit" class="button is-small is-success">{% trans "Activate" %}</button>
</form>
{% endif %}
</div>
<div class="card-footer is-stacked-mobile is-align-items-stretch">
<div class="card-footer-item">
<div>
<div>
<span class="has-text-weight-bold">{{ connector.identifier}}&nbsp;</span> {% trans 'should be updated. Check recent release notes for more information.' %}
</div>
<form
name="update-{{ connector.id }}"
method="post"
action="{% url 'settings-update-connector' connector.id %}"
class="card-footer-item"
>
{% csrf_token %}
<input type="hidden" name="item" value="{{ connector.id }}">
<button type="submit" class="button is-small is-warning">{% trans "Update" %}</button>
</form>
</div>
</div>
</div>
</div>
</li>

View file

@ -93,6 +93,10 @@
{% url 'settings-email-config' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Email Configuration" %}</a>
</li>
<li>
{% url 'settings-connectors' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Connectors" %}</a>
</li>
</ul>
{% endif %}
{% if perms.bookwyrm.edit_instance_settings %}

View file

@ -139,6 +139,36 @@ urlpatterns = [
views.delete_announcement,
name="settings-announcements-delete",
),
re_path(
r"^settings/connectors/?$",
views.ConnectorSettings.as_view(),
name="settings-connectors",
),
re_path(
r"^settings/connectors/(?P<connector_id>\d+)/deactivate?$",
views.deactivate_connector,
name="settings-deactivate-connector",
),
re_path(
r"^settings/connectors/(?P<connector_id>\d+)/activate?$",
views.activate_connector,
name="settings-activate-connector",
),
re_path(
r"^settings/connectors/(?P<connector_id>\d+)/priority?$",
views.set_connector_priority,
name="settings-connector-priority",
),
re_path(
r"^settings/connectors/create?$",
views.create_connector,
name="settings-create-connector",
),
re_path(
r"^settings/connectors/(?P<connector_id>\d+)/update?$",
views.update_connector,
name="settings-update-connector",
),
re_path(
r"^settings/email-preview/?$",
views.admin.email_config.email_preview,

View file

@ -5,6 +5,14 @@ from .admin.announcements import EditAnnouncement, delete_announcement
from .admin.automod import AutoMod, automod_delete, run_automod
from .admin.automod import schedule_automod_task, unschedule_automod_task
from .admin.celery_status import CeleryStatus, celery_ping
from .admin.connectors import (
ConnectorSettings,
deactivate_connector,
activate_connector,
set_connector_priority,
create_connector,
update_connector,
)
from .admin.schedule import ScheduledTasks
from .admin.dashboard import Dashboard
from .admin.federation import Federation, FederatedServer

View file

@ -0,0 +1,115 @@
""" manage book data sources """
from django.contrib.auth.decorators import login_required, permission_required
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views import View
from bookwyrm.models import Connector
from bookwyrm.connectors import create_finna_connector
from bookwyrm.connectors.settings import CONNECTORS
@method_decorator(login_required, name="dispatch")
@method_decorator(
permission_required("bookwyrm.edit_instance_settings", raise_exception=True),
name="dispatch",
)
class ConnectorSettings(View):
"""show connector settings page"""
# pylint: disable=no-self-use
def get(self, request):
"""list of connectors"""
# connectors where the previous defaults need to be updated
# for example when an API endpoint changes and we want admins
# to update their connector settings
update_connectors = Connector.objects.none() # for now
# example - this would pick up all openlibrary connectors where
# the isbn_search_url is not "https://openlibrary.org/search.json?isbn="
# update_connectors = Connector.objects.filter(
# connector_file="openlibrary"
# ).exclude(isbn_search_url="https://openlibrary.org/search.json?isbn=")
# book API source like Open Library etc
other_connectors = (
Connector.objects.exclude(connector_file="bookwyrm_connector")
.order_by("name")
.difference(update_connectors)
)
# other BookWyrm instances
bookwrym_connectors = Connector.objects.filter(
connector_file="bookwyrm_connector"
).order_by("identifier")
# Optional and new connectors e.g. Finna
# These are not yet Connector objects so we have to describe them in the
# template at settings/connectors/available.html
available_connectors = []
for val in CONNECTORS:
if Connector.objects.filter(connector_file=val).count() == 0:
available_connectors.append(val)
data = {
"update_connectors": update_connectors,
"bookwyrm_connectors": bookwrym_connectors,
"other_connectors": other_connectors,
"available_connectors": available_connectors,
}
return TemplateResponse(request, "settings/connectors/connectors.html", data)
# pylint: disable=unused-argument
def deactivate_connector(request, connector_id):
"""we don't want to use this connector"""
connector = get_object_or_404(Connector, id=connector_id)
connector.deactivate(reason="manual")
# TODO: should we allow free text for a deactivation reason?
return redirect("/settings/connectors/")
# pylint: disable=unused-argument
def activate_connector(request, connector_id: int):
"""oops changed our mind"""
connector = get_object_or_404(Connector, id=connector_id)
connector.activate()
return redirect("/settings/connectors/")
def set_connector_priority(request, connector_id: int):
"""what order should connectors appear in?"""
priority = request.POST.get("priority")
connector = get_object_or_404(Connector, id=connector_id)
connector.change_priority(priority=priority)
return redirect("/settings/connectors/")
# pylint: disable=unused-argument
def update_connector(request, connector_id: int):
"""update connector info such as API endpoints"""
connector = get_object_or_404(Connector, id=connector_id)
# see models.Connector to determine what update() does
connector.update()
return redirect("/settings/connectors/")
def create_connector(request):
"""create a new optional connector"""
connector_file = request.POST.get("connector_file")
# if you add a new connector, add a check here
# and make a create_xxx_connector() function in connector_manager.py
if connector_file == "finna":
create_finna_connector()
return redirect("/settings/connectors/")