mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
feat(recipes): explain empty LoRA lists with collapsible "Why no LoRAs?" panel
Record import provenance on every recipe: a new import_info block (channel, machine-readable no-LoRA reason, diagnostic details) built at import time across all channels (batch import, single URL, local file, upload, widget save, re-imports) and persisted in the recipe JSON plus the SQLite persistent cache (new import_info_json column with ALTER TABLE migration). The recipe modal renders the empty LoRA list with a collapsed details panel showing the import method, the reason (CivitAI API returned no LoRA resource data, API meta missing, no embedded metadata, ComfyUI workflow metadata, video, unparsable format), and recorded diagnostics. Legacy recipes without import_info fall back to heuristics labeled as inferred. Genuine no-LoRA generations show no panel. CivitAI images are always classified by API meta shape: the onsite generator writes A1111-style EXIF without LoRA references, so parsed EXIF cannot prove "no LoRAs used". Adds recipes.resources.noLoras* i18n keys (all 10 locales) plus frontend vitest and backend pytest coverage.
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
||||
|
||||
const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key));
|
||||
|
||||
const loadingManagerStub = {
|
||||
showSimpleLoading: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
show: vi.fn(),
|
||||
restoreProgressBar: vi.fn(),
|
||||
};
|
||||
|
||||
const virtualScrollerStub = {
|
||||
updateSingleItem: vi.fn(),
|
||||
getNavigationState: vi.fn(() => ({
|
||||
index: 0,
|
||||
hasPrev: false,
|
||||
hasNext: false,
|
||||
loadedItems: 1,
|
||||
totalItems: 1,
|
||||
})),
|
||||
getAdjacentItemByFilePath: vi.fn(async () => null),
|
||||
};
|
||||
|
||||
const stateStub = {
|
||||
global: { settings: {}, loadingManager: loadingManagerStub },
|
||||
loadingManager: loadingManagerStub,
|
||||
virtualScroller: virtualScrollerStub,
|
||||
};
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: vi.fn(),
|
||||
copyToClipboard: vi.fn(),
|
||||
sendLoraToWorkflow: vi.fn(),
|
||||
sendModelPathToWorkflow: vi.fn(),
|
||||
openCivitaiByMetadata: vi.fn(),
|
||||
stripLoraTags: vi.fn((text) => text),
|
||||
sendPromptToWorkflow: vi.fn(),
|
||||
sendGenParamsToWorkflow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
translate: translateMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/state/index.js', () => ({
|
||||
state: stateStub,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||
setSessionItem: vi.fn(),
|
||||
removeSessionItem: vi.fn(),
|
||||
getStorageItem: vi.fn(() => null),
|
||||
setStorageItem: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||
fetchRecipeDetails: vi.fn(),
|
||||
updateRecipeMetadata: vi.fn(() => Promise.resolve({ success: true })),
|
||||
sendRecipeWorkflow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||
MODEL_TYPES: {
|
||||
LORA: 'loras',
|
||||
CHECKPOINT: 'checkpoints',
|
||||
EMBEDDING: 'embeddings',
|
||||
},
|
||||
}));
|
||||
|
||||
function recipeModalFixture() {
|
||||
return `
|
||||
<div id="recipeModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-media-column">
|
||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-bottom-section">
|
||||
<div class="recipe-section-actions">
|
||||
<span id="recipeLorasCount"></span>
|
||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn"></button>
|
||||
</div>
|
||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
describe('RecipeModal no-LoRA reason panel', () => {
|
||||
let recipeModal;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = recipeModalFixture();
|
||||
const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js');
|
||||
recipeModal = new RecipeModal();
|
||||
});
|
||||
|
||||
function sync(recipe) {
|
||||
recipeModal.syncResourcesSection(recipe);
|
||||
return document.getElementById('recipeLorasList');
|
||||
}
|
||||
|
||||
it('shows the base message only when generation genuinely used no LoRAs', () => {
|
||||
const list = sync({
|
||||
id: 'r1',
|
||||
loras: [],
|
||||
import_info: { channel: 'url', reason: 'no_loras_used' },
|
||||
});
|
||||
|
||||
expect(list.querySelector('.no-loras')).not.toBeNull();
|
||||
expect(list.textContent).toContain('No LoRAs associated with this recipe');
|
||||
expect(list.querySelector('details.no-loras-reason')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a collapsed reason panel from recorded import_info', () => {
|
||||
const list = sync({
|
||||
id: 'r2',
|
||||
loras: [],
|
||||
import_info: {
|
||||
channel: 'batch_import_url',
|
||||
reason: 'api_meta_no_lora_resources',
|
||||
details: {
|
||||
api_meta_keys: ['prompt'],
|
||||
api_model_version_ids: 0,
|
||||
exif_present: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const details = list.querySelector('details.no-loras-reason');
|
||||
expect(details).not.toBeNull();
|
||||
// Collapsed by default (no `open` attribute).
|
||||
expect(details.hasAttribute('open')).toBe(false);
|
||||
expect(details.querySelector('summary').textContent).toContain('Why no LoRAs?');
|
||||
|
||||
const body = details.querySelector('.no-loras-reason-body');
|
||||
expect(body.textContent).toContain('Batch import (image URL)');
|
||||
expect(body.textContent).toContain('The source API returned no LoRA resource data');
|
||||
expect(body.textContent).toContain('API metadata fields');
|
||||
expect(body.textContent).toContain('prompt');
|
||||
expect(body.textContent).toContain('Model version IDs reported');
|
||||
expect(body.textContent).toContain('Embedded metadata');
|
||||
// Recorded diagnostics are not labeled as inferred.
|
||||
expect(body.querySelector('.no-loras-inferred-note')).toBeNull();
|
||||
});
|
||||
|
||||
it('infers a possible reason for legacy URL recipes without import_info', () => {
|
||||
const list = sync({
|
||||
id: 'r3',
|
||||
loras: [],
|
||||
source_path: 'https://civitai.red/images/139995974',
|
||||
gen_params: { prompt: 'a castle' },
|
||||
});
|
||||
|
||||
const details = list.querySelector('details.no-loras-reason');
|
||||
expect(details).not.toBeNull();
|
||||
const body = details.querySelector('.no-loras-reason-body');
|
||||
expect(body.textContent).toContain('The source API returned no LoRA resource data');
|
||||
// Heuristic results must be labeled as inferred.
|
||||
expect(body.querySelector('.no-loras-inferred-note')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('reports missing embedded metadata for legacy local recipes with no params', () => {
|
||||
const list = sync({
|
||||
id: 'r4',
|
||||
loras: [],
|
||||
source_path: '/data/images/photo.png',
|
||||
gen_params: {},
|
||||
});
|
||||
|
||||
const details = list.querySelector('details.no-loras-reason');
|
||||
expect(details).not.toBeNull();
|
||||
expect(details.querySelector('.no-loras-reason-body').textContent).toContain(
|
||||
'The image has no embedded generation metadata'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not show the panel for legacy local recipes with complete params', () => {
|
||||
const list = sync({
|
||||
id: 'r5',
|
||||
loras: [],
|
||||
source_path: '/data/images/photo.png',
|
||||
gen_params: { prompt: 'a castle', steps: 20, seed: 42 },
|
||||
});
|
||||
|
||||
expect(list.querySelector('details.no-loras-reason')).toBeNull();
|
||||
});
|
||||
|
||||
it('flags ComfyUI workflow sources via has_workflow', () => {
|
||||
const list = sync({
|
||||
id: 'r6',
|
||||
loras: [],
|
||||
has_workflow: true,
|
||||
});
|
||||
|
||||
const details = list.querySelector('details.no-loras-reason');
|
||||
expect(details).not.toBeNull();
|
||||
expect(details.querySelector('.no-loras-reason-body').textContent).toContain(
|
||||
'ComfyUI workflow'
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes HTML in recorded diagnostic values', () => {
|
||||
const list = sync({
|
||||
id: 'r7',
|
||||
loras: [],
|
||||
import_info: {
|
||||
channel: 'url',
|
||||
reason: 'api_meta_no_lora_resources',
|
||||
details: { api_meta_keys: ['<img src=x onerror=alert(1)>'] },
|
||||
},
|
||||
});
|
||||
|
||||
const details = list.querySelector('details.no-loras-reason');
|
||||
expect(details).not.toBeNull();
|
||||
expect(details.innerHTML).not.toContain('<img src=x');
|
||||
expect(details.textContent).toContain('<img src=x onerror=alert(1)>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Tests for the recipe import_info helpers (no-LoRA reason computation)."""
|
||||
|
||||
from py.services.recipes.import_info import (
|
||||
CHANNEL_BATCH_IMPORT_LOCAL,
|
||||
CHANNEL_BATCH_IMPORT_URL,
|
||||
CHANNEL_LOCAL,
|
||||
CHANNEL_REIMPORT_URL,
|
||||
CHANNEL_UPLOAD,
|
||||
CHANNEL_URL,
|
||||
CHANNEL_WIDGET,
|
||||
REASON_API_META_MISSING,
|
||||
REASON_API_NO_LORA_RESOURCES,
|
||||
REASON_METADATA_UNSUPPORTED,
|
||||
REASON_NO_EMBEDDED_METADATA,
|
||||
REASON_NO_LORAS_USED,
|
||||
REASON_VIDEO_NO_METADATA,
|
||||
REASON_WORKFLOW_METADATA_LIMITED,
|
||||
build_import_info,
|
||||
compute_no_loras_reason,
|
||||
)
|
||||
|
||||
|
||||
class TestComputeNoLorasReason:
|
||||
def test_video_takes_priority(self):
|
||||
diag = {"is_video": True, "exif_parser": "ComfyMetadataParser"}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_BATCH_IMPORT_URL, diag)
|
||||
== REASON_VIDEO_NO_METADATA
|
||||
)
|
||||
|
||||
def test_comfy_workflow_parser(self):
|
||||
diag = {"exif_parser": "ComfyMetadataParser", "exif_present": True}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_UPLOAD, diag)
|
||||
== REASON_WORKFLOW_METADATA_LIMITED
|
||||
)
|
||||
|
||||
def test_merged_comfy_parser(self):
|
||||
# ComfyUI workflow parsed from the merged dict (no string EXIF).
|
||||
diag = {"parser": "ComfyMetadataParser"}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_LOCAL, diag)
|
||||
== REASON_WORKFLOW_METADATA_LIMITED
|
||||
)
|
||||
|
||||
def test_civitai_url_with_api_meta_but_no_lora_resources(self):
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": ["prompt"],
|
||||
"api_model_version_ids": 0,
|
||||
"exif_present": False,
|
||||
}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_BATCH_IMPORT_URL, diag)
|
||||
== REASON_API_NO_LORA_RESOURCES
|
||||
)
|
||||
|
||||
def test_civitai_url_with_model_version_ids_only(self):
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": [],
|
||||
"api_model_version_ids": 2,
|
||||
"exif_present": False,
|
||||
}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_URL, diag)
|
||||
== REASON_API_NO_LORA_RESOURCES
|
||||
)
|
||||
|
||||
def test_civitai_url_with_no_meta_at_all(self):
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": [],
|
||||
"api_model_version_ids": 0,
|
||||
"exif_present": False,
|
||||
}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_REIMPORT_URL, diag)
|
||||
== REASON_API_META_MISSING
|
||||
)
|
||||
|
||||
def test_civitai_url_with_parsed_exif_still_reports_api_gap(self):
|
||||
# CivitAI's onsite generator writes A1111-style EXIF WITHOUT LoRA
|
||||
# references (LoRA usage lives in CivitAI-internal data), so cleanly
|
||||
# parsed EXIF must NOT be read as "no LoRAs were used".
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": ["prompt", "steps", "seed", "resources"],
|
||||
"api_model_version_ids": 1,
|
||||
"exif_present": True,
|
||||
"exif_parser": "AutomaticMetadataParser",
|
||||
"parser": "CivitaiApiMetadataParser",
|
||||
}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_BATCH_IMPORT_URL, diag)
|
||||
== REASON_API_NO_LORA_RESOURCES
|
||||
)
|
||||
|
||||
def test_generic_url_without_embedded_metadata(self):
|
||||
diag = {"civitai_image": False, "exif_present": False}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_URL, diag) == REASON_NO_EMBEDDED_METADATA
|
||||
)
|
||||
|
||||
def test_generic_url_with_unsupported_metadata(self):
|
||||
diag = {"civitai_image": False, "exif_present": True}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_URL, diag) == REASON_METADATA_UNSUPPORTED
|
||||
)
|
||||
|
||||
def test_generic_url_with_parsed_metadata_means_no_loras(self):
|
||||
diag = {
|
||||
"civitai_image": False,
|
||||
"exif_present": True,
|
||||
"exif_parser": "AutomaticMetadataParser",
|
||||
}
|
||||
assert compute_no_loras_reason(CHANNEL_URL, diag) == REASON_NO_LORAS_USED
|
||||
|
||||
def test_widget(self):
|
||||
assert compute_no_loras_reason(CHANNEL_WIDGET, None) == REASON_NO_LORAS_USED
|
||||
|
||||
def test_local_without_embedded_metadata(self):
|
||||
diag = {"exif_present": False}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_LOCAL, diag)
|
||||
== REASON_NO_EMBEDDED_METADATA
|
||||
)
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_BATCH_IMPORT_LOCAL, diag)
|
||||
== REASON_NO_EMBEDDED_METADATA
|
||||
)
|
||||
|
||||
def test_local_with_unsupported_metadata(self):
|
||||
diag = {"exif_present": True}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_UPLOAD, diag)
|
||||
== REASON_METADATA_UNSUPPORTED
|
||||
)
|
||||
|
||||
def test_missing_diagnostics_falls_back_safely(self):
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_LOCAL, None)
|
||||
== REASON_NO_EMBEDDED_METADATA
|
||||
)
|
||||
# URL channel without diagnostics is treated as a generic URL (the
|
||||
# civitai_image flag defaults to False).
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_URL, None)
|
||||
== REASON_NO_EMBEDDED_METADATA
|
||||
)
|
||||
|
||||
|
||||
class TestBuildImportInfo:
|
||||
def test_channel_always_recorded(self):
|
||||
info = build_import_info(
|
||||
CHANNEL_URL, None, loras=[{"file_name": "x", "hash": "abc"}]
|
||||
)
|
||||
assert info == {"channel": CHANNEL_URL}
|
||||
|
||||
def test_reason_and_details_when_no_loras(self):
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": ["prompt"],
|
||||
"api_model_version_ids": 0,
|
||||
"exif_present": False,
|
||||
"exif_parser": None,
|
||||
}
|
||||
info = build_import_info(CHANNEL_BATCH_IMPORT_URL, diag, loras=[])
|
||||
assert info["channel"] == CHANNEL_BATCH_IMPORT_URL
|
||||
assert info["reason"] == REASON_API_NO_LORA_RESOURCES
|
||||
assert info["details"]["api_meta_keys"] == ["prompt"]
|
||||
assert info["details"]["api_model_version_ids"] == 0
|
||||
assert info["details"]["exif_present"] is False
|
||||
# Empty exif_parser must not leak into details.
|
||||
assert "exif_parser" not in info["details"]
|
||||
|
||||
def test_details_omitted_when_nothing_to_report(self):
|
||||
info = build_import_info(CHANNEL_WIDGET, None, loras=[])
|
||||
assert info == {"channel": CHANNEL_WIDGET, "reason": REASON_NO_LORAS_USED}
|
||||
|
||||
def test_api_meta_keys_capped(self):
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": [f"k{i}" for i in range(50)],
|
||||
"api_model_version_ids": 1,
|
||||
}
|
||||
info = build_import_info(CHANNEL_URL, diag, loras=[])
|
||||
assert len(info["details"]["api_meta_keys"]) == 12
|
||||
@@ -120,6 +120,76 @@ class TestPersistentRecipeCache:
|
||||
loaded = cache.load_cache()
|
||||
assert loaded is None
|
||||
|
||||
def test_import_info_roundtrip(self, temp_db_path, sample_recipes):
|
||||
"""import_info (import provenance + no-LoRA reason) survives the cache."""
|
||||
cache = PersistentRecipeCache(db_path=temp_db_path)
|
||||
|
||||
sample_recipes[0]["import_info"] = {
|
||||
"channel": "batch_import_url",
|
||||
"reason": "api_meta_no_lora_resources",
|
||||
"details": {"api_meta_keys": ["prompt"], "api_model_version_ids": 0},
|
||||
}
|
||||
cache.save_cache(sample_recipes)
|
||||
|
||||
loaded = cache.load_cache()
|
||||
assert loaded is not None
|
||||
r1 = next(r for r in loaded.raw_data if r["id"] == "recipe-001")
|
||||
assert r1["import_info"]["channel"] == "batch_import_url"
|
||||
assert r1["import_info"]["reason"] == "api_meta_no_lora_resources"
|
||||
assert r1["import_info"]["details"]["api_meta_keys"] == ["prompt"]
|
||||
|
||||
# Recipes without import_info simply omit the key.
|
||||
r2 = next(r for r in loaded.raw_data if r["id"] == "recipe-002")
|
||||
assert "import_info" not in r2
|
||||
|
||||
def test_import_info_column_migration(self, temp_db_path, sample_recipes):
|
||||
"""Existing databases gain the import_info_json column via ALTER TABLE."""
|
||||
import sqlite3
|
||||
|
||||
# Simulate a legacy database without the new column.
|
||||
conn = sqlite3.connect(temp_db_path)
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE recipes (
|
||||
recipe_id TEXT PRIMARY KEY,
|
||||
file_path TEXT,
|
||||
json_path TEXT,
|
||||
title TEXT,
|
||||
folder TEXT,
|
||||
source_path TEXT,
|
||||
base_model TEXT,
|
||||
fingerprint TEXT,
|
||||
created_date REAL,
|
||||
modified REAL,
|
||||
file_mtime REAL,
|
||||
file_size INTEGER,
|
||||
favorite INTEGER DEFAULT 0,
|
||||
repair_version INTEGER DEFAULT 0,
|
||||
preview_nsfw_level INTEGER DEFAULT 0,
|
||||
loras_json TEXT,
|
||||
checkpoint_json TEXT,
|
||||
gen_params_json TEXT,
|
||||
tags_json TEXT,
|
||||
has_workflow INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE TABLE cache_metadata (key TEXT PRIMARY KEY, value TEXT);
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
cache = PersistentRecipeCache(db_path=temp_db_path)
|
||||
cache.save_cache(sample_recipes)
|
||||
|
||||
conn = sqlite3.connect(temp_db_path)
|
||||
columns = {row[1] for row in conn.execute("PRAGMA table_info(recipes)")}
|
||||
conn.close()
|
||||
assert "import_info_json" in columns
|
||||
|
||||
loaded = cache.load_cache()
|
||||
assert loaded is not None
|
||||
assert len(loaded.raw_data) == 2
|
||||
|
||||
def test_update_single_recipe(self, temp_db_path, sample_recipes):
|
||||
"""Test updating a single recipe."""
|
||||
cache = PersistentRecipeCache(db_path=temp_db_path)
|
||||
@@ -595,15 +665,19 @@ class TestHasWorkflowColumn:
|
||||
assert by_id["wf-3"]["has_workflow"] is False
|
||||
|
||||
def test_prepare_recipe_row_matches_column_order(self, temp_db_path):
|
||||
"""The prepared row must append has_workflow in column order."""
|
||||
"""The prepared row must append has_workflow/import_info in column order."""
|
||||
cache = PersistentRecipeCache(db_path=temp_db_path)
|
||||
row_true = cache._prepare_recipe_row({"id": "r1", "has_workflow": True}, "")
|
||||
row_false = cache._prepare_recipe_row({"id": "r2", "has_workflow": False}, "")
|
||||
|
||||
assert row_true[-1] == 1
|
||||
assert row_false[-1] == 0
|
||||
assert row_true[-2] == 1
|
||||
assert row_false[-2] == 0
|
||||
# import_info_json is the trailing column, unset by default.
|
||||
assert row_true[-1] is None
|
||||
assert row_false[-1] is None
|
||||
assert len(row_true) == len(cache._RECIPE_COLUMNS)
|
||||
assert cache._RECIPE_COLUMNS[-1] == "has_workflow"
|
||||
assert cache._RECIPE_COLUMNS[-2] == "has_workflow"
|
||||
assert cache._RECIPE_COLUMNS[-1] == "import_info_json"
|
||||
|
||||
def test_update_recipe_preserves_has_workflow(self, temp_db_path):
|
||||
"""update_recipe() must write the has_workflow column correctly."""
|
||||
|
||||
Reference in New Issue
Block a user