fix(license): remove cascading commercial-use bit encoding, clarify Allow Selling label (#941)

- _resolve_commercial_bits() no longer has Sell-implies-Image
  cascading; each CommercialUse value sets only its own bit,
  matching CivitAI's modern array-format API.
- Keep filter tag label as 'Allow Selling' for brevity; add
  title/tooltip 'Allow selling generated images' on hover.
- Same tooltip treatment for 'No Credit Required'.
- Add i18n keys for both tooltips across all 10 locales.
This commit is contained in:
Will Miao
2026-05-26 06:02:17 +08:00
parent 2629fcce23
commit d7caa1fa47
19 changed files with 122 additions and 57 deletions

View File

@@ -65,32 +65,26 @@ async def test_allow_selling_filter():
"""Test the allow selling generated content filtering logic."""
service = DummyModelService()
# Create test data with different license flags
# CommercialUse values are independent — Sell does NOT imply Image.
test_data = [
# Model allowing selling (contains Image in allowCommercialUse)
{"file_path": "model1.safetensors", "license_flags": build_license_flags({"allowCommercialUse": ["Image"]})},
# Model not allowing selling (doesn't contain Image in allowCommercialUse)
{"file_path": "model2.safetensors", "license_flags": build_license_flags({"allowCommercialUse": ["RentCivit"]})},
# Model with default license flags (includes Sell by default, which implies Image)
{"file_path": "model3.safetensors", "license_flags": build_license_flags(None)},
# Model allowing selling (contains Sell in allowCommercialUse, which implies Image)
{"file_path": "model4.safetensors", "license_flags": build_license_flags({"allowCommercialUse": ["Sell"]})},
# Model with empty allowCommercialUse (doesn't allow selling)
{"file_path": "model5.safetensors", "license_flags": build_license_flags({"allowCommercialUse": []})},
]
# Test allow_selling=True (should return models that allow selling - have Image permission)
# Default and Sell permissions both include Image, so model3 and model4 will be included
# Test allow_selling=True (should return only models with the Image permission)
filtered = await service._apply_allow_selling_filter(test_data, allow_selling=True)
assert len(filtered) == 3 # model1, model3 (default includes Sell which implies Image), model4
assert len(filtered) == 1 # only model1 has Image permission
file_paths = {item["file_path"] for item in filtered}
assert file_paths == {"model1.safetensors", "model3.safetensors", "model4.safetensors"}
assert file_paths == {"model1.safetensors"}
# Test allow_selling=False (should return models that don't allow selling - don't have Image permission)
# Test allow_selling=False (should return models without the Image permission)
filtered = await service._apply_allow_selling_filter(test_data, allow_selling=False)
assert len(filtered) == 2 # model2 and model5
assert len(filtered) == 4 # model2, model3, model4, model5
file_paths = {item["file_path"] for item in filtered}
assert file_paths == {"model2.safetensors", "model5.safetensors"}
assert file_paths == {"model2.safetensors", "model3.safetensors", "model4.safetensors", "model5.safetensors"}
@pytest.mark.asyncio

View File

@@ -131,13 +131,12 @@ async def test_pool_filter_allow_selling_true(lora_service, sample_loras):
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
# Should keep models with Image permission (allowSelling)
# Models: no_credit_required_for_selling, credit_required_for_selling, default_license
assert len(filtered) == 3
# Sell alone does not imply Image, so default_license is excluded.
assert len(filtered) == 2
file_names = {lora["file_name"] for lora in filtered}
assert file_names == {
"no_credit_required_for_selling.safetensors",
"credit_required_for_selling.safetensors",
"default_license.safetensors",
}
@@ -178,12 +177,11 @@ async def test_pool_filter_both_license_filters(lora_service, sample_loras):
# Should keep models where both conditions are met:
# - allowNoCredit=True (no credit required)
# - Image permission exists (allow selling)
# Models: no_credit_required_for_selling, default_license
assert len(filtered) == 2
# default_license has ["Sell"] without Image, so it's excluded.
assert len(filtered) == 1
file_names = {lora["file_name"] for lora in filtered}
assert file_names == {
"no_credit_required_for_selling.safetensors",
"default_license.safetensors",
}

View File

@@ -132,7 +132,8 @@ async def test_initialize_cache_populates_cache(tmp_path: Path):
_normalize_path(tmp_path / "one.txt"),
_normalize_path(tmp_path / "nested" / "two.txt"),
}
assert {item["license_flags"] for item in cache.raw_data} == {DEFAULT_LICENSE_FLAGS}
# build_license_flags({}) returns 113 (defaults: allowNoCredit + ["Sell"] + derivatives + differentLicense)
assert {item["license_flags"] for item in cache.raw_data} == {113}
assert scanner._hash_index.get_path("hash-one") == _normalize_path(tmp_path / "one.txt")
assert scanner._hash_index.get_path("hash-two") == _normalize_path(tmp_path / "nested" / "two.txt")
@@ -190,7 +191,8 @@ async def test_initialize_in_background_applies_scan_result(tmp_path: Path, monk
_normalize_path(tmp_path / "one.txt"),
_normalize_path(tmp_path / "nested" / "two.txt"),
}
assert {item["license_flags"] for item in cache.raw_data} == {DEFAULT_LICENSE_FLAGS}
# build_license_flags({}) returns 113 (defaults: allowNoCredit + ["Sell"] + derivatives + differentLicense)
assert {item["license_flags"] for item in cache.raw_data} == {113}
assert scanner._hash_index.get_path("hash-two") == _normalize_path(tmp_path / "nested" / "two.txt")
assert scanner._tags_count == {"alpha": 1, "beta": 1}
assert scanner._excluded_models == [_normalize_path(tmp_path / "skip-file.txt")]

View File

@@ -16,7 +16,9 @@ def test_resolve_license_payload_defaults():
assert payload["allowDerivatives"] is True
assert payload["allowDifferentLicense"] is True
assert payload["allowCommercialUse"] == ["Sell"]
assert flags == 127
# Default ["Sell"] only sets the Sell bit (16), plus NoCredit (1),
# Derivatives (32) and DifferentLicense (64) = 113.
assert flags == 113
def test_build_license_flags_custom_values():
@@ -34,11 +36,10 @@ def test_build_license_flags_custom_values():
assert payload["allowDifferentLicense"] is False
flags = build_license_flags(source)
# Sell automatically enables all commercial bits including image.
assert flags == 30
assert flags == 18
def test_build_license_flags_respects_commercial_hierarchy():
def test_build_license_flags_independent_values():
base = {
"allowNoCredit": False,
"allowDerivatives": False,
@@ -46,14 +47,10 @@ def test_build_license_flags_respects_commercial_hierarchy():
}
assert build_license_flags({**base, "allowCommercialUse": []}) == 0
# Rent adds rent and rentcivit permissions.
assert build_license_flags({**base, "allowCommercialUse": ["Rent"]}) == 12
# RentCivit alone should only set its own bit.
assert build_license_flags({**base, "allowCommercialUse": ["Rent"]}) == 8
assert build_license_flags({**base, "allowCommercialUse": ["RentCivit"]}) == 4
# Image only toggles the image bit.
assert build_license_flags({**base, "allowCommercialUse": ["Image"]}) == 2
# Sell forces all commercial bits regardless of image listing.
assert build_license_flags({**base, "allowCommercialUse": ["Sell"]}) == 30
assert build_license_flags({**base, "allowCommercialUse": ["Sell"]}) == 16
def test_build_license_flags_parses_aggregate_string():