RedmineのフィールドをDATALISTっぽく入力補助する

Redmineのフィールドの値を他のDBの値と紐づけたいが自由入力でデータが揺らいで困るというケースがよくあると思います!そんな時、他のDBというのがREST-APIなんかで値が取ってこれるならばこんな感じで作ってみては?という事でメモを残しておこうと思います。

環境

環境は以下の通りです。

  • Redmine 6.0.4.stable
    • View Customize plugin 3.5.2
  • Django 5.0.4
    • djangorestframework 3.15.1

Redmineの設定

「障害対応」というプロジェクトを作って、「ホスト名」というカスタムフィールドを作ります。この「ホスト名」を設備管理のDBをDjangoのREST-APIを使って取得してきてDATALIST的に選択させるわけですが、まずカスタムフィールドを作成します。ここでカスタムフィールド番号はこの例では「1」で作られていますので、皆様の環境に合わせて修正をお願いします。

カスタムフィールドの作成

http://localhost/redmine/custom_fields/1/edit

チケットのイメージ

以下のようなチケットを作成するとします。

チケットを編集画面にした際のホスト名を入力するINPUT TEXTな部分は、カスタムフィールドの番号が「1」なので…

という事で、「id=”issue_custom_field_values_1″」となりますので、これを記憶しておいて、表示のカスタマイズを作成します。

表示のカスタマイズ

ここで、どこをカスタマイズするのかは、上記の通りですが、REST-APIからどんな値をどうやってとってくるかについては、後述することとして、host.name でホスト名が取得できるとして進めます。

コードの部分

$(document).ready(function () {
  const $input = $('#issue_custom_field_values_1');
  const $popup = $('<div id="host-search-popup"></div>').css({
    display: 'none',
    position: 'absolute',
    background: '#fff',
    border: '1px solid #ccc',
    'z-index': 9999,
    'max-height': '200px',
    overflow: 'auto',
    padding: '5px',
  });

  $('body').append($popup);

  let debounceTimer = null;

  $input.on('input', function () {
    const query = $(this).val().trim();

    // ✅ 4文字未満ならポップアップを閉じ、検索もしない
    if (query.length < 4) {
      $popup.hide();
      return;
    }

    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => {
      $.ajax({
        url: `/testapp/api/host/search/?q=${encodeURIComponent(query)}`,
        method: 'GET',
        success: function (data) {
          $popup.empty();

          if (!data || data.length === 0) {
            $popup.append('<div>一致するホストがありません</div>');
          } else {
            data.forEach(host => {
              const $item = $('<div></div>').text(host.name).css({
                padding: '4px',
                cursor: 'pointer'
              }).hover(function () {
                $(this).css('background', '#f0f0f0');
              }, function () {
                $(this).css('background', '');
              }).click(function () {
                $input.val(host.name);
                $popup.hide();
              });
              $popup.append($item);
            });
          }

          const offset = $input.offset();
          $popup.css({
            top: offset.top + $input.outerHeight(),
            left: offset.left,
            width: $input.outerWidth()
          }).show();
        },
        error: function () {
          $popup.text('検索エラー').show();
        }
      });
    }, 300); // debounce
  });

  $(document).on('click', function (e) {
    if (!$(e.target).closest('#host-search-popup, #issue_custom_field_values_1').length) {
      $popup.hide();
    }
  });
});

query.lengthとして5文字以上入力してからREST-APIに聞きに行くようにしています。1文字目からだとマッチしすぎて大量に引っかかるので…

url: /testapp/api/host/search/?q=${encodeURIComponent(query)},

の箇所はREST-APIのリスエスト用URLで、queryは入力された文字が入る。後述するDjangoのviews.pyで部分マッチさせるとリストが返ってくるのでforeachで回してホスト名だけを取得してDATALIST的にDIVで並べる。1つも待っちしない場合は、「一致するホストがありません」と表示させる。

URLにホスト名(FQDN)の記載がありませんが、CORS(Cross-origin resource sharing)制約に引っかかるので他のホスト名ではエラーになるのでお気を付けください。なので実はRedmineのルートパスを/redmine/ に変更しております!変更方法はこちら

DjangoのREST-API設定

Djangoの設定は、こちらのページで作ったものを利用することとします。その際には、REST-APIは設定していなかったため追加の手順を以下に記します。

パッケージのインストール

$ docker exec -it ap /bin/ash
/ # cd /code
/code # source venv/bin/activate
(venv) /code # pip install djangorestframework

各種ファイルの設定

/code/testprj/urls.py

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('testapp/', include('testapp.urls', namespace='api')),
]

/code/testapp/urls.py

from django.urls import path, include
from .views import *
from . import views
from rest_framework.routers import DefaultRouter

router = DefaultRouter()
router.register('host', HostViewSet)

app_name = 'testapp'
urlpatterns = [
        path('api/', include(router.urls), name='api'),
]

/code/testapp/views.py

from django.shortcuts import render

# Create your views here.
from .models import *
from rest_framework import viewsets
from rest_framework.pagination import PageNumberPagination
from rest_framework.decorators import action
from rest_framework.response import Response
from .serializers import *

# ページネーション用
class PostPagination(PageNumberPagination):
    page_size=15

class HostViewSet(viewsets.ModelViewSet):
    queryset = Host.objects.all().order_by('-id')
    serializer_class = HostSerializer
    pagination_class = PostPagination

    @action(detail=False, methods=['get'], url_path='search')
    def search(self, request):
        query = request.GET.get('q', '')
        queryset = Host.objects.filter(name__icontains=query)[:50]
        data = [{'name': s.name} for s in queryset]
        return Response(data)

/code/testapp/serializers.py

from rest_framework import serializers
from .models import *

class HostSerializer(serializers.ModelSerializer):
    class Meta:
        model = Host                                       
        fields = '__all__'

/code/testprj/settings.py

INSTALLED_APPS = [
    'rest_framework',

http://localhost:8010/testapp/api/host/search/?q=

実行イメージ


投稿日

カテゴリー:

投稿者: