From 64a9e063ec72816f8955da14f9dfcd6657f5b967 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 20:07:15 +0100
Subject: [PATCH] Wish requesters

---
 CHANGELOG.md                                  |  6 ++
 bricktracker/metadata_list.py                 |  4 +-
 bricktracker/record_list.py                   |  2 +
 bricktracker/reload.py                        |  4 ++
 bricktracker/sql/migrations/0013.sql          |  7 ++
 .../sql/set/metadata/owner/delete.sql         |  4 ++
 .../sql/set/metadata/owner/insert.sql         |  4 ++
 bricktracker/sql/wish/base/base.sql           |  8 +++
 bricktracker/sql/wish/delete/wish.sql         | 12 +++-
 .../sql/wish/metadata/owner/update/state.sql  | 10 +++
 bricktracker/views/wish.py                    | 71 ++++++++++++++++---
 bricktracker/wish.py                          | 19 +++--
 bricktracker/wish_list.py                     |  4 +-
 bricktracker/wish_owner.py                    | 70 ++++++++++++++++++
 bricktracker/wish_owner_list.py               | 21 ++++++
 templates/wish.html                           | 15 ++++
 templates/wish/card.html                      | 49 +++++++++++++
 templates/wish/table.html                     | 11 ++-
 templates/wishes.html                         |  2 +-
 19 files changed, 300 insertions(+), 23 deletions(-)
 create mode 100644 bricktracker/sql/wish/metadata/owner/update/state.sql
 create mode 100644 bricktracker/wish_owner.py
 create mode 100644 bricktracker/wish_owner_list.py
 create mode 100644 templates/wish.html
 create mode 100644 templates/wish/card.html

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 46c76b54..a6f89a8d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -55,6 +55,9 @@
     - Allow for advanced migration scenarios through companion python files
     - Add a bunch of the requested fields into the database for future implementation
 
+- Wish
+    - Requester
+
 ### UI
 
 - Add
@@ -101,6 +104,9 @@
     - Storage list
     - Storage content
 
+- Wish
+    - Requester
+
 ## 1.1.1: PDF Instructions Download
 
 ### Instructions
diff --git a/bricktracker/metadata_list.py b/bricktracker/metadata_list.py
index a7df7521..44bf18fe 100644
--- a/bricktracker/metadata_list.py
+++ b/bricktracker/metadata_list.py
@@ -11,6 +11,7 @@ from .set_purchase_location import BrickSetPurchaseLocation
 from .set_status import BrickSetStatus
 from .set_storage import BrickSetStorage
 from .set_tag import BrickSetTag
+from .wish_owner import BrickWishOwner
 
 logger = logging.getLogger(__name__)
 
@@ -20,7 +21,8 @@ T = TypeVar(
     BrickSetPurchaseLocation,
     BrickSetStatus,
     BrickSetStorage,
-    BrickSetTag
+    BrickSetTag,
+    BrickWishOwner
 )
 
 
diff --git a/bricktracker/record_list.py b/bricktracker/record_list.py
index 18dcb10c..8d862ed2 100644
--- a/bricktracker/record_list.py
+++ b/bricktracker/record_list.py
@@ -14,6 +14,7 @@ if TYPE_CHECKING:
     from .set_storage import BrickSetStorage
     from .set_tag import BrickSetTag
     from .wish import BrickWish
+    from .wish_owner import BrickWishOwner
 
 T = TypeVar(
     'T',
@@ -26,6 +27,7 @@ T = TypeVar(
     'BrickSetStorage',
     'BrickSetTag',
     'BrickWish',
+    'BrickWishOwner',
     'RebrickableSet'
 )
 
diff --git a/bricktracker/reload.py b/bricktracker/reload.py
index 38929f68..99d95bbf 100644
--- a/bricktracker/reload.py
+++ b/bricktracker/reload.py
@@ -6,6 +6,7 @@ from .set_status_list import BrickSetStatusList
 from .set_storage_list import BrickSetStorageList
 from .set_tag_list import BrickSetTagList
 from .theme_list import BrickThemeList
+from .wish_owner_list import BrickWishOwnerList
 
 
 # Reload everything related to a database after an operation
@@ -35,5 +36,8 @@ def reload() -> None:
 
         # Reload themes
         BrickThemeList(force=True)
+
+        # Reload the wish owners
+        BrickWishOwnerList.new(force=True)
     except Exception:
         pass
diff --git a/bricktracker/sql/migrations/0013.sql b/bricktracker/sql/migrations/0013.sql
index 33f8a6f0..469649d5 100644
--- a/bricktracker/sql/migrations/0013.sql
+++ b/bricktracker/sql/migrations/0013.sql
@@ -16,4 +16,11 @@ CREATE TABLE "bricktracker_set_owners" (
     FOREIGN KEY("id") REFERENCES "bricktracker_sets"("id")
 );
 
+-- Create a table for the wish owners
+CREATE TABLE "bricktracker_wish_owners" (
+    "set" TEXT NOT NULL,
+    PRIMARY KEY("set"),
+    FOREIGN KEY("set") REFERENCES "bricktracker_wishes"("set")
+);
+
 COMMIT;
diff --git a/bricktracker/sql/set/metadata/owner/delete.sql b/bricktracker/sql/set/metadata/owner/delete.sql
index e9df18d8..5927bfdb 100644
--- a/bricktracker/sql/set/metadata/owner/delete.sql
+++ b/bricktracker/sql/set/metadata/owner/delete.sql
@@ -3,6 +3,10 @@ BEGIN TRANSACTION;
 ALTER TABLE "bricktracker_set_owners"
 DROP COLUMN "owner_{{ id }}";
 
+-- Also drop from wishes
+ALTER TABLE "bricktracker_wish_owners"
+DROP COLUMN "owner_{{ id }}";
+
 DELETE FROM "bricktracker_metadata_owners"
 WHERE "bricktracker_metadata_owners"."id" IS NOT DISTINCT FROM '{{ id }}';
 
diff --git a/bricktracker/sql/set/metadata/owner/insert.sql b/bricktracker/sql/set/metadata/owner/insert.sql
index cc54a2a5..6b2de775 100644
--- a/bricktracker/sql/set/metadata/owner/insert.sql
+++ b/bricktracker/sql/set/metadata/owner/insert.sql
@@ -3,6 +3,10 @@ BEGIN TRANSACTION;
 ALTER TABLE "bricktracker_set_owners"
 ADD COLUMN "owner_{{ id }}" BOOLEAN NOT NULL DEFAULT 0;
 
+-- Also inject into wishes
+ALTER TABLE "bricktracker_wish_owners"
+ADD COLUMN "owner_{{ id }}" BOOLEAN NOT NULL DEFAULT 0;
+
 INSERT INTO "bricktracker_metadata_owners" (
     "id",
     "name"
diff --git a/bricktracker/sql/wish/base/base.sql b/bricktracker/sql/wish/base/base.sql
index b06c66fd..62f5a7cb 100644
--- a/bricktracker/sql/wish/base/base.sql
+++ b/bricktracker/sql/wish/base/base.sql
@@ -5,9 +5,17 @@ SELECT
     "bricktracker_wishes"."theme_id",
     "bricktracker_wishes"."number_of_parts",
     "bricktracker_wishes"."image",
+    {% block owners %}
+        {% if owners %}{{ owners }},{% endif %}
+    {% endblock %}
     "bricktracker_wishes"."url"
 FROM "bricktracker_wishes"
 
+{% if owners %}
+LEFT JOIN "bricktracker_wish_owners"
+ON "bricktracker_wishes"."set" IS NOT DISTINCT FROM "bricktracker_wish_owners"."set"
+{% endif %}
+
 {% block where %}{% endblock %}
 
 {% if order %}
diff --git a/bricktracker/sql/wish/delete/wish.sql b/bricktracker/sql/wish/delete/wish.sql
index e60b2e48..1adcfc11 100644
--- a/bricktracker/sql/wish/delete/wish.sql
+++ b/bricktracker/sql/wish/delete/wish.sql
@@ -1,2 +1,12 @@
+-- A bit unsafe as it does not use a prepared statement but it
+-- should not be possible to inject anything through the {{ set }} context
+
+BEGIN TRANSACTION;
+
 DELETE FROM "bricktracker_wishes"
-WHERE "bricktracker_wishes"."set" IS NOT DISTINCT FROM :set
\ No newline at end of file
+WHERE "bricktracker_wishes"."set" IS NOT DISTINCT FROM '{{ set }}';
+
+DELETE FROM "bricktracker_wish_owners"
+WHERE "bricktracker_wish_owners"."set" IS NOT DISTINCT FROM '{{ set }}';
+
+COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/wish/metadata/owner/update/state.sql b/bricktracker/sql/wish/metadata/owner/update/state.sql
new file mode 100644
index 00000000..9191ca8d
--- /dev/null
+++ b/bricktracker/sql/wish/metadata/owner/update/state.sql
@@ -0,0 +1,10 @@
+INSERT INTO "bricktracker_wish_owners" (
+    "set",
+    "{{name}}"
+) VALUES (
+    :set,
+    :state
+)
+ON CONFLICT("set")
+DO UPDATE SET "{{name}}" = :state
+WHERE "bricktracker_wish_owners"."set" IS NOT DISTINCT FROM :set
diff --git a/bricktracker/views/wish.py b/bricktracker/views/wish.py
index b0c763b2..416b9005 100644
--- a/bricktracker/views/wish.py
+++ b/bricktracker/views/wish.py
@@ -1,4 +1,11 @@
-from flask import Blueprint, redirect, render_template, request, url_for
+from flask import (
+    Blueprint,
+    jsonify,
+    redirect,
+    render_template,
+    request,
+    url_for
+)
 from flask_login import login_required
 from werkzeug.wrappers.response import Response
 
@@ -6,8 +13,10 @@ from .exceptions import exception_handler
 from ..retired_list import BrickRetiredList
 from ..wish import BrickWish
 from ..wish_list import BrickWishList
+from ..wish_owner_list import BrickWishOwnerList
 
-wish_page = Blueprint('wish', __name__, url_prefix='/wishlist')
+
+wish_page = Blueprint('wish', __name__, url_prefix='/wishes')
 
 
 # Index
@@ -18,7 +27,8 @@ def list() -> str:
         'wishes.html',
         table_collection=BrickWishList().all(),
         retired=BrickRetiredList(),
-        error=request.args.get('error')
+        error=request.args.get('error'),
+        owners=BrickWishOwnerList.list(),
     )
 
 
@@ -27,21 +37,60 @@ def list() -> str:
 @login_required
 @exception_handler(__file__, post_redirect='wish.list')
 def add() -> Response:
-    # Grab the set number
-    number: str = request.form.get('number', '')
+    # Grab the set
+    set: str = request.form.get('set', '')
 
-    if number != '':
-        BrickWishList.add(number)
+    if set != '':
+        BrickWishList.add(set)
 
     return redirect(url_for('wish.list'))
 
 
-# Delete a set from the wishlit
-@wish_page.route('/delete/<number>', methods=['POST'])
+# Ask for deletion of a wish
+@wish_page.route('/<set>/delete', methods=['GET'])
+@login_required
+@exception_handler(__file__)
+def delete(*, set: str) -> str:
+    return render_template(
+        'wish.html',
+        delete=True,
+        item=BrickWish().select_specific(set),
+        error=request.args.get('error'),
+        owners=BrickWishOwnerList.list(),
+    )
+
+
+# Actually delete of a set
+@wish_page.route('/<set>/delete', methods=['POST'])
 @login_required
 @exception_handler(__file__, post_redirect='wish.list')
-def delete(*, number: str) -> Response:
-    brickwish = BrickWish().select_specific(number)
+def do_delete(*, set: str) -> Response:
+    brickwish = BrickWish().select_specific(set)
     brickwish.delete()
 
     return redirect(url_for('wish.list'))
+
+
+# Details
+@wish_page.route('/<set>/details', methods=['GET'])
+@exception_handler(__file__)
+def details(*, set: str) -> str:
+    return render_template(
+        'wish.html',
+        item=BrickWish().select_specific(set),
+        retired=BrickRetiredList(),
+        owners=BrickWishOwnerList.list(),
+    )
+
+
+# Change the state of a owner
+@wish_page.route('/<set>/owner/<metadata_id>', methods=['POST'])
+@login_required
+@exception_handler(__file__, json=True)
+def update_owner(*, set: str, metadata_id: str) -> Response:
+    brickwish = BrickWish().select_specific(set)
+    owner = BrickWishOwnerList.get(metadata_id)
+
+    state = owner.update_wish_state(brickwish, json=request.json)
+
+    return jsonify({'value': state})
diff --git a/bricktracker/wish.py b/bricktracker/wish.py
index def41e28..502792fe 100644
--- a/bricktracker/wish.py
+++ b/bricktracker/wish.py
@@ -5,6 +5,7 @@ from flask import url_for
 from .exceptions import NotFoundException
 from .rebrickable_set import RebrickableSet
 from .sql import BrickSQL
+from .wish_owner_list import BrickWishOwnerList
 
 
 # Lego brick wished set
@@ -16,11 +17,11 @@ class BrickWish(RebrickableSet):
     select_query: str = 'wish/select'
     insert_query: str = 'wish/insert'
 
-    # Delete a wished set
+    # Delete a wish
     def delete(self, /) -> None:
-        BrickSQL().execute_and_commit(
+        BrickSQL().executescript(
             'wish/delete/wish',
-            parameters=self.sql_parameters()
+            set=self.fields.set
         )
 
     # Select a specific part (with a set and an id)
@@ -29,7 +30,7 @@ class BrickWish(RebrickableSet):
         self.fields.set = set
 
         # Load from database
-        if not self.select():
+        if not self.select(owners=BrickWishOwnerList.as_columns()):
             raise NotFoundException(
                 'Wish for set {set} was not found in the database'.format(  # noqa: E501
                     set=self.fields.set,
@@ -38,6 +39,14 @@ class BrickWish(RebrickableSet):
 
         return self
 
+    # Self url
+    def url(self, /) -> str:
+        return url_for('wish.details', set=self.fields.set)
+
     # Deletion url
     def url_for_delete(self, /) -> str:
-        return url_for('wish.delete', number=self.fields.set)
+        return url_for('wish.delete', set=self.fields.set)
+
+    # Actual deletion url
+    def url_for_do_delete(self, /) -> str:
+        return url_for('wish.do_delete', set=self.fields.set)
diff --git a/bricktracker/wish_list.py b/bricktracker/wish_list.py
index 880021b7..d3038b8a 100644
--- a/bricktracker/wish_list.py
+++ b/bricktracker/wish_list.py
@@ -9,6 +9,7 @@ from .rebrickable import Rebrickable
 from .rebrickable_image import RebrickableImage
 from .record_list import BrickRecordList
 from .wish import BrickWish
+from .wish_owner_list import BrickWishOwnerList
 
 logger = logging.getLogger(__name__)
 
@@ -22,7 +23,8 @@ class BrickWishList(BrickRecordList[BrickWish]):
     def all(self, /) -> Self:
         # Load the wished sets from the database
         for record in self.select(
-            order=current_app.config['WISHES_DEFAULT_ORDER']
+            order=current_app.config['WISHES_DEFAULT_ORDER'],
+            owners=BrickWishOwnerList.as_columns(),
         ):
             brickwish = BrickWish(record=record)
 
diff --git a/bricktracker/wish_owner.py b/bricktracker/wish_owner.py
new file mode 100644
index 00000000..c31d2c9f
--- /dev/null
+++ b/bricktracker/wish_owner.py
@@ -0,0 +1,70 @@
+import logging
+from typing import Any, TYPE_CHECKING
+
+from flask import url_for
+
+from .exceptions import DatabaseException
+from .metadata import BrickMetadata
+from .sql import BrickSQL
+if TYPE_CHECKING:
+    from .wish import BrickWish
+
+logger = logging.getLogger(__name__)
+
+
+# Lego wish owner metadata
+class BrickWishOwner(BrickMetadata):
+    kind: str = 'owner'
+
+    # Wish state endpoint
+    wish_state_endpoint: str = 'wish.update_owner'
+
+    # Queries
+    update_wish_state_query: str = 'wish/metadata/owner/update/state'
+
+    # Update the selected state of this metadata item for a wish
+    def update_wish_state(
+        self,
+        brickset: 'BrickWish',
+        /,
+        *,
+        json: Any | None = None,
+        state: Any | None = None
+    ) -> Any:
+        if state is None and json is not None:
+            state = json.get('value', False)
+
+        parameters = self.sql_parameters()
+        parameters['set'] = brickset.fields.set
+        parameters['state'] = state
+
+        rows, _ = BrickSQL().execute_and_commit(
+            self.update_wish_state_query,
+            parameters=parameters,
+            name=self.as_column(),
+        )
+
+        if rows != 1:
+            raise DatabaseException('Could not update the {kind} "{name}" state for wish {set}'.format(  # noqa: E501
+                kind=self.kind,
+                name=self.fields.name,
+                set=brickset.fields.set,
+            ))
+
+        # Info
+        logger.info('{kind} "{name}" state changed to "{state}" for wish {set}'.format(  # noqa: E501
+            kind=self.kind,
+            name=self.fields.name,
+            state=state,
+            set=brickset.fields.set,
+        ))
+
+        return state
+
+    # URL to change the selected state of this metadata item for a wish
+    def url_for_wish_state(self, set: str, /) -> str:
+        return url_for(
+            self.wish_state_endpoint,
+            set=set,
+            metadata_id=self.fields.id
+        )
diff --git a/bricktracker/wish_owner_list.py b/bricktracker/wish_owner_list.py
new file mode 100644
index 00000000..719fc6f5
--- /dev/null
+++ b/bricktracker/wish_owner_list.py
@@ -0,0 +1,21 @@
+from typing import Self
+
+from .metadata_list import BrickMetadataList
+from .wish_owner import BrickWishOwner
+
+
+# Lego sets owner list
+class BrickWishOwnerList(BrickMetadataList[BrickWishOwner]):
+    kind: str = 'wish owners'
+
+    # Database
+    table: str = 'bricktracker_wish_owners'
+    order: str = '"bricktracker_metadata_owners"."name"'
+
+    # Queries
+    select_query = 'set/metadata/owner/list'
+
+    # Instantiate the list with the proper class
+    @classmethod
+    def new(cls, /, *, force: bool = False) -> Self:
+        return cls(BrickWishOwner, force=force)
diff --git a/templates/wish.html b/templates/wish.html
new file mode 100644
index 00000000..6c0f3990
--- /dev/null
+++ b/templates/wish.html
@@ -0,0 +1,15 @@
+{% extends 'base.html' %}
+
+{% block title %} - {% if delete %}Delete a wish{% else %}Wish{% endif %} {{ item.fields.name}} ({{ item.fields.set }}){% endblock %}
+
+{% block main %}
+<div class="container">
+  <div class="row">
+    <div class="col-12">
+      {% with solo=true, delete=delete %}
+        {% include 'wish/card.html' %}
+      {% endwith %}
+    </div>
+  </div>
+</div>
+{% endblock %}
diff --git a/templates/wish/card.html b/templates/wish/card.html
new file mode 100644
index 00000000..fc16d9b9
--- /dev/null
+++ b/templates/wish/card.html
@@ -0,0 +1,49 @@
+{% import 'macro/accordion.html' as accordion %}
+{% import 'macro/badge.html' as badge %}
+{% import 'macro/card.html' as card %}
+{% import 'macro/form.html' as form %}
+
+<div class="card mb-3 flex-fill {% if solo %}card-solo{% endif %}">
+  {{ card.header(item, item.fields.name, solo=solo, identifier=item.fields.set) }}
+  {{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.set) }}
+  <div class="card-body border-bottom-0 {% if not solo %}p-1{% endif %}">
+    {{ badge.theme(item.theme.name, solo=solo, last=last) }}
+    {{ badge.parts(item.fields.number_of_parts, solo=solo, last=last) }}
+    {% for owner in owners %}
+      {{ badge.owner(item, owner, solo=solo, last=last) }}
+    {% endfor %}
+  </div>
+  {% if solo and g.login.is_authenticated() %}
+    <div class="accordion accordion-flush border-top" id="wish-details">
+      {% if not delete %}
+        {{ accordion.header('Requester', 'owner', 'wish-details', icon='group-line', class='p-0') }}
+        <ul class="list-group list-group-flush">
+        {% if owners | length %}
+          {% for owner in owners %}
+            <li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(owner.fields.name, item.fields.set, owner.as_dataset(), owner.url_for_wish_state(item.fields.set), item.fields[owner.as_column()], delete=delete) }}</li>
+          {% endfor %}
+        {% else %}
+          <li class="list-group-item list-group-item-action text-center"><i class="ri-error-warning-line"></i> No requester found.</li>
+        {% endif %}
+        </ul>
+        <div class="list-group list-group-flush border-top">
+          <a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_owner=true) }}"><i class="ri-settings-4-line"></i> Manage the set owners</a>
+        </div>
+        {{ accordion.footer() }}
+      {% endif %}
+      {{ accordion.header('Danger zone', 'danger-zone', 'wish-details', expanded=delete, danger=true, class='text-end') }}
+        {% if delete %}
+          <form action="{{ item.url_for_do_delete() }}" method="post">
+            {% if error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ error }}.</div>{% endif %}
+            <div class="alert alert-danger text-center" role="alert">You are about to delete a wish. This action is irreversible.</div>
+            <a class="btn btn-primary" href="{{ item.url() }}" role="button"><i class="ri-arrow-left-long-line"></i> Back to the wish</a>
+            <button type="submit" class="btn btn-danger"><i class="ri-close-line"></i> Delete the wish</button>
+          </form>
+        {% else %}
+          <a href="{{ item.url_for_delete() }}" class="btn btn-danger" role="button"><i class="ri-close-line"></i> Delete the wish</a>
+        {% endif %}
+      {{ accordion.footer() }}
+    </div>
+    <div class="card-footer"></div>
+  {% endif %}
+</div>
diff --git a/templates/wish/table.html b/templates/wish/table.html
index ca222427..41783ca9 100644
--- a/templates/wish/table.html
+++ b/templates/wish/table.html
@@ -12,6 +12,7 @@
         <th scope="col"><i class="ri-calendar-line fw-normal"></i> Year</th>
         <th scope="col"><i class="ri-shapes-line fw-normal"></i> Parts</th>
         <th scope="col"><i class="ri-calendar-close-line fw-normal"></i> Retirement</th>
+        <th data-table-no-sort="true" class="no-sort" scope="col"><i class="ri-user-line fw-normal"></i> Requesters</th>
         {% if g.login.is_authenticated() %}
           <th data-table-no-sort-and-search="true" class="no-sort" scope="col"><i class="ri-settings-4-line fw-normal"></i> Actions</th>
         {% endif %}
@@ -28,11 +29,15 @@
             <td>{{ item.fields.year }}</td>
             <td>{{ item.fields.number_of_parts }}</td>
             <td>{% if retirement_date %}{{ retirement_date }}{% else %}<span class="badge rounded-pill text-bg-light border">Not found</span>{% endif %}</td>
+            <td>
+              {% for owner in owners %}
+                {{ badge.owner(item, owner) }}
+              {% endfor %}
+            </td>
             {% if g.login.is_authenticated() %}
             <td>
-              <form action="{{ item.url_for_delete() }}" method="post">
-                <button type="submit" class="btn btn-sm btn-danger"><i class="ri-delete-bin-2-line"></i> Delete</button>
-              </form>
+              <a href="{{ item.url() }}" class="btn btn-sm btn-primary mb-1" role="button"><i class="ri-gift-line"></i> Details</a>
+              <a href="{{ item.url_for_delete() }}" class="btn btn-sm btn-danger mb-1" role="button"><i class="ri-delete-bin-2-line"></i> Delete</a>
             </td>
             {% endif %}
           </tr>
diff --git a/templates/wishes.html b/templates/wishes.html
index eac8255f..2c2b2ab9 100644
--- a/templates/wishes.html
+++ b/templates/wishes.html
@@ -12,7 +12,7 @@
         <label class="visually-hidden" for="number">Set number</label>
         <div class="input-group">
           <div class="input-group-text"><i class="ri-hashtag"></i></div>
-          <input type="text" class="form-control" id="number" name="number" placeholder="107-1 or 1642-1 or ..." value="" autocomplete="off">
+          <input type="text" class="form-control" id="set" name="set" placeholder="107-1 or 1642-1 or ..." value="" autocomplete="off">
         </div>
       </div>
       <div class="col-12">