feat: unify order-line render invocation paths
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -17,28 +17,30 @@ from app.domains.products.models import CadFile, Product
|
||||
from app.domains.rendering.models import OutputType, RenderTemplate
|
||||
from app.domains.rendering.workflow_runtime_services import (
|
||||
auto_populate_materials_for_cad,
|
||||
build_order_line_render_invocation,
|
||||
emit_order_line_render_notifications,
|
||||
MaterialResolutionResult,
|
||||
OrderLineRenderSetupResult,
|
||||
persist_order_line_media_asset,
|
||||
persist_order_line_output,
|
||||
resolve_cad_bbox,
|
||||
prepare_order_line_render_context,
|
||||
resolve_order_line_material_map,
|
||||
resolve_order_line_template_context,
|
||||
RenderPositionContext,
|
||||
TemplateResolutionResult,
|
||||
)
|
||||
from app.domains.tenants.models import Tenant
|
||||
|
||||
import app.models # noqa: F401
|
||||
|
||||
|
||||
TEST_DB_URL = os.environ.get(
|
||||
"TEST_DATABASE_URL",
|
||||
"postgresql+asyncpg://hartomat:hartomat@localhost:5432/hartomat_test",
|
||||
).replace("+asyncpg", "")
|
||||
from tests.db_test_utils import reset_public_schema_sync, resolve_test_db_url
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sync_session():
|
||||
engine = create_engine(TEST_DB_URL)
|
||||
engine = create_engine(resolve_test_db_url(async_driver=False))
|
||||
with engine.begin() as conn:
|
||||
reset_public_schema_sync(conn)
|
||||
Base.metadata.create_all(conn)
|
||||
|
||||
session = Session(engine)
|
||||
@@ -47,8 +49,7 @@ def sync_session():
|
||||
finally:
|
||||
session.close()
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("DROP SCHEMA public CASCADE"))
|
||||
conn.execute(text("CREATE SCHEMA public"))
|
||||
reset_public_schema_sync(conn)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@@ -121,6 +122,13 @@ def test_prepare_order_line_render_context_marks_line_processing_and_prefers_usd
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
line = _seed_order_line_graph(sync_session, tmp_path)
|
||||
line.product.cad_file.resolved_material_assignments = {
|
||||
"inner_ring": {
|
||||
"source_name": "InnerRing",
|
||||
"prim_path": "/Root/Assembly/inner_ring",
|
||||
"canonical_material": "HARTOMAT_010101_Steel-Bare",
|
||||
}
|
||||
}
|
||||
usd_asset_path = upload_dir / "usd" / "bearing.usd"
|
||||
usd_asset_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
usd_asset_path.write_text("USD", encoding="utf-8")
|
||||
@@ -159,6 +167,82 @@ def test_prepare_order_line_render_context_marks_line_processing_and_prefers_usd
|
||||
assert any("Using USD master for render" in message for message in messages)
|
||||
|
||||
|
||||
def test_prepare_order_line_render_context_queues_refresh_for_legacy_usd(sync_session, tmp_path, monkeypatch):
|
||||
from app.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "upload_dir", str(tmp_path / "uploads"))
|
||||
upload_dir = Path(settings.upload_dir)
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
line = _seed_order_line_graph(sync_session, tmp_path)
|
||||
line.product.cad_file.resolved_material_assignments = {
|
||||
"inner_ring": {
|
||||
"source_name": "InnerRing",
|
||||
"prim_path": "/Root/Assembly/inner_ring",
|
||||
"canonical_material": "SCHAEFFLER_010101_Steel-Bare",
|
||||
}
|
||||
}
|
||||
|
||||
usd_asset_path = upload_dir / "usd" / "bearing.usd"
|
||||
usd_asset_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
usd_asset_path.write_text("USD", encoding="utf-8")
|
||||
glb_asset_path = upload_dir / "step_files" / "bearing_thumbnail.glb"
|
||||
glb_asset_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
glb_asset_path.write_text("GLB", encoding="utf-8")
|
||||
|
||||
sync_session.add_all(
|
||||
[
|
||||
MediaAsset(
|
||||
id=uuid.uuid4(),
|
||||
cad_file_id=line.product.cad_file_id,
|
||||
product_id=line.product_id,
|
||||
asset_type=MediaAssetType.usd_master,
|
||||
storage_key="usd/bearing.usd",
|
||||
),
|
||||
MediaAsset(
|
||||
id=uuid.uuid4(),
|
||||
cad_file_id=line.product.cad_file_id,
|
||||
product_id=line.product_id,
|
||||
asset_type=MediaAssetType.gltf_geometry,
|
||||
storage_key="step_files/bearing_thumbnail.glb",
|
||||
),
|
||||
]
|
||||
)
|
||||
sync_session.commit()
|
||||
|
||||
queued: list[str] = []
|
||||
messages: list[str] = []
|
||||
|
||||
class _Task:
|
||||
@staticmethod
|
||||
def delay(cad_file_id: str) -> None:
|
||||
queued.append(cad_file_id)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.tasks.step_tasks.generate_usd_master_task",
|
||||
_Task(),
|
||||
)
|
||||
|
||||
result = prepare_order_line_render_context(
|
||||
sync_session,
|
||||
str(line.id),
|
||||
emit=lambda order_line_id, message, level=None: messages.append(message),
|
||||
)
|
||||
|
||||
sync_session.refresh(line)
|
||||
|
||||
expected_glb = tmp_path / "parts" / "bearing_thumbnail.glb"
|
||||
assert result.is_ready
|
||||
assert result.usd_render_path is None
|
||||
assert result.glb_reuse_path == expected_glb
|
||||
assert expected_glb.exists()
|
||||
assert queued == [str(line.product.cad_file_id)]
|
||||
assert any("stale" in message for message in messages)
|
||||
assert any("Queued USD master regeneration" in message for message in messages)
|
||||
assert any("Reusing cached GLB geometry" in message for message in messages)
|
||||
assert line.render_status == "processing"
|
||||
|
||||
|
||||
def test_prepare_order_line_render_context_skips_closed_orders(sync_session, tmp_path, monkeypatch):
|
||||
from app.config import settings
|
||||
|
||||
@@ -175,18 +259,262 @@ def test_prepare_order_line_render_context_skips_closed_orders(sync_session, tmp
|
||||
assert line.render_status == "cancelled"
|
||||
|
||||
|
||||
def test_build_order_line_render_invocation_applies_output_and_line_overrides(tmp_path):
|
||||
step_path = tmp_path / "parts" / "bearing.step"
|
||||
step_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
step_path.write_text("STEP", encoding="utf-8")
|
||||
|
||||
output_type = OutputType(
|
||||
id=uuid.uuid4(),
|
||||
name="Still Preview",
|
||||
renderer="blender",
|
||||
output_format="png",
|
||||
render_settings={"width": 1600, "height": 900},
|
||||
transparent_bg=False,
|
||||
cycles_device="cpu",
|
||||
)
|
||||
output_type.invocation_overrides = {
|
||||
"engine": "cycles",
|
||||
"samples": 128,
|
||||
"bg_color": "#202020",
|
||||
"turntable_axis": "world_y",
|
||||
"noise_threshold": "0.05",
|
||||
}
|
||||
cad_file = CadFile(
|
||||
id=uuid.uuid4(),
|
||||
original_name="bearing.step",
|
||||
stored_path=str(step_path),
|
||||
file_hash="hash-1",
|
||||
parsed_objects={"objects": ["InnerRing", "OuterRing"]},
|
||||
)
|
||||
product = Product(
|
||||
id=uuid.uuid4(),
|
||||
pim_id="P-1000",
|
||||
name="Bearing A",
|
||||
category_key="bearings",
|
||||
cad_file_id=cad_file.id,
|
||||
cad_file=cad_file,
|
||||
)
|
||||
line = OrderLine(
|
||||
id=uuid.uuid4(),
|
||||
order_id=uuid.uuid4(),
|
||||
product_id=product.id,
|
||||
product=product,
|
||||
output_type_id=output_type.id,
|
||||
output_type=output_type,
|
||||
render_overrides={
|
||||
"height": 800,
|
||||
"samples": 48,
|
||||
"transparent_bg": True,
|
||||
"cycles_device": "cuda",
|
||||
"denoiser": "OPENIMAGEDENOISE",
|
||||
"output_format": "webp",
|
||||
},
|
||||
)
|
||||
setup = OrderLineRenderSetupResult(
|
||||
status="ready",
|
||||
order_line=line,
|
||||
cad_file=cad_file,
|
||||
part_colors={"InnerRing": "Steel raw", "OuterRing": "Steel raw"},
|
||||
)
|
||||
template = RenderTemplate(
|
||||
id=uuid.uuid4(),
|
||||
name="Studio",
|
||||
blend_file_path="/templates/studio.blend",
|
||||
original_filename="studio.blend",
|
||||
target_collection="Assembly",
|
||||
lighting_only=True,
|
||||
shadow_catcher_enabled=True,
|
||||
camera_orbit=False,
|
||||
)
|
||||
invocation = build_order_line_render_invocation(
|
||||
setup,
|
||||
template_context=TemplateResolutionResult(
|
||||
template=template,
|
||||
material_library="/libraries/materials.blend",
|
||||
material_map={"InnerRing": "SteelPolished"},
|
||||
use_materials=True,
|
||||
override_material="Studio White",
|
||||
category_key="bearings",
|
||||
output_type_id=str(output_type.id),
|
||||
),
|
||||
position_context=RenderPositionContext(
|
||||
rotation_x=12.0,
|
||||
rotation_y=24.0,
|
||||
rotation_z=36.0,
|
||||
focal_length_mm=50.0,
|
||||
sensor_width_mm=36.0,
|
||||
),
|
||||
)
|
||||
|
||||
assert invocation.output_extension == "webp"
|
||||
assert invocation.output_filename.endswith(".webp")
|
||||
assert invocation.width == 1600
|
||||
assert invocation.height == 800
|
||||
assert invocation.engine == "cycles"
|
||||
assert invocation.samples == 48
|
||||
assert invocation.noise_threshold == "0.05"
|
||||
assert invocation.denoiser == "OPENIMAGEDENOISE"
|
||||
assert invocation.transparent_bg is True
|
||||
assert invocation.cycles_device == "cuda"
|
||||
assert invocation.bg_color == "#202020"
|
||||
assert invocation.turntable_axis == "world_y"
|
||||
assert invocation.template_path == "/templates/studio.blend"
|
||||
assert invocation.target_collection == "Assembly"
|
||||
assert invocation.material_library_path == "/libraries/materials.blend"
|
||||
assert invocation.material_map == {"InnerRing": "SteelPolished"}
|
||||
assert invocation.material_override == "Studio White"
|
||||
assert invocation.lighting_only is True
|
||||
assert invocation.shadow_catcher is True
|
||||
assert invocation.camera_orbit is False
|
||||
assert invocation.part_names_ordered == ["InnerRing", "OuterRing"]
|
||||
assert invocation.rotation_x == 12.0
|
||||
assert invocation.focal_length_mm == 50.0
|
||||
|
||||
still_kwargs = invocation.as_still_renderer_kwargs(
|
||||
step_path=str(step_path),
|
||||
output_path=str(tmp_path / "renders" / "bearing.webp"),
|
||||
job_id="job-1",
|
||||
order_line_id="line-1",
|
||||
)
|
||||
|
||||
assert still_kwargs["step_path"] == str(step_path)
|
||||
assert still_kwargs["output_path"].endswith("bearing.webp")
|
||||
assert still_kwargs["width"] == 1600
|
||||
assert still_kwargs["height"] == 800
|
||||
assert still_kwargs["engine"] == "cycles"
|
||||
assert still_kwargs["samples"] == 48
|
||||
assert still_kwargs["cycles_device"] == "cuda"
|
||||
assert still_kwargs["material_library_path"] == "/libraries/materials.blend"
|
||||
assert still_kwargs["material_override"] == "Studio White"
|
||||
assert still_kwargs["job_id"] == "job-1"
|
||||
assert still_kwargs["order_line_id"] == "line-1"
|
||||
|
||||
|
||||
def test_build_order_line_render_invocation_autoscales_samples_and_prefers_material_context(
|
||||
tmp_path,
|
||||
):
|
||||
step_path = tmp_path / "parts" / "bearing.step"
|
||||
step_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
step_path.write_text("STEP", encoding="utf-8")
|
||||
|
||||
output_type = OutputType(
|
||||
id=uuid.uuid4(),
|
||||
name="Still Preview",
|
||||
renderer="blender",
|
||||
output_format="png",
|
||||
render_settings={"width": 1024, "height": 512},
|
||||
)
|
||||
output_type.invocation_overrides = {"samples": 128, "engine": "eevee"}
|
||||
cad_file = CadFile(
|
||||
id=uuid.uuid4(),
|
||||
original_name="bearing.step",
|
||||
stored_path=str(step_path),
|
||||
file_hash="hash-2",
|
||||
parsed_objects={"objects": ["InnerRing", "OuterRing"]},
|
||||
)
|
||||
product = Product(
|
||||
id=uuid.uuid4(),
|
||||
pim_id="P-1001",
|
||||
name="Bearing B",
|
||||
category_key="bearings",
|
||||
cad_file_id=cad_file.id,
|
||||
cad_file=cad_file,
|
||||
)
|
||||
line = OrderLine(
|
||||
id=uuid.uuid4(),
|
||||
order_id=uuid.uuid4(),
|
||||
product_id=product.id,
|
||||
product=product,
|
||||
output_type_id=output_type.id,
|
||||
output_type=output_type,
|
||||
)
|
||||
setup = OrderLineRenderSetupResult(
|
||||
status="ready",
|
||||
order_line=line,
|
||||
cad_file=cad_file,
|
||||
part_colors={"InnerRing": "Steel raw"},
|
||||
)
|
||||
template = RenderTemplate(
|
||||
id=uuid.uuid4(),
|
||||
name="Studio",
|
||||
blend_file_path="/templates/studio.blend",
|
||||
original_filename="studio.blend",
|
||||
target_collection="Product",
|
||||
)
|
||||
invocation = build_order_line_render_invocation(
|
||||
setup,
|
||||
template_context=TemplateResolutionResult(
|
||||
template=template,
|
||||
material_library="/libraries/materials.blend",
|
||||
material_map={"InnerRing": "TemplateSteel"},
|
||||
use_materials=True,
|
||||
override_material="Template White",
|
||||
category_key="bearings",
|
||||
output_type_id=str(output_type.id),
|
||||
),
|
||||
material_context=MaterialResolutionResult(
|
||||
material_map={"InnerRing": "ResolvedSteel"},
|
||||
use_materials=False,
|
||||
override_material="Resolved White",
|
||||
source_material_count=2,
|
||||
resolved_material_count=1,
|
||||
),
|
||||
)
|
||||
|
||||
assert invocation.engine == "eevee"
|
||||
assert invocation.samples == 64
|
||||
assert invocation.material_map == {"InnerRing": "ResolvedSteel"}
|
||||
assert invocation.material_override == "Resolved White"
|
||||
assert invocation.material_library_path is None
|
||||
|
||||
turntable_kwargs = invocation.as_turntable_renderer_kwargs(
|
||||
step_path=step_path,
|
||||
output_path=tmp_path / "renders" / "bearing.mp4",
|
||||
smooth_angle=30,
|
||||
default_width=1920,
|
||||
default_height=1920,
|
||||
default_engine="cycles",
|
||||
default_samples=256,
|
||||
)
|
||||
cinematic_kwargs = invocation.as_cinematic_renderer_kwargs(
|
||||
step_path=step_path,
|
||||
output_path=tmp_path / "renders" / "bearing-cinematic.mp4",
|
||||
smooth_angle=30,
|
||||
default_width=1920,
|
||||
default_height=1080,
|
||||
default_engine="cycles",
|
||||
default_samples=256,
|
||||
)
|
||||
|
||||
assert turntable_kwargs["width"] == 1024
|
||||
assert turntable_kwargs["height"] == 512
|
||||
assert turntable_kwargs["engine"] == "eevee"
|
||||
assert turntable_kwargs["samples"] == 64
|
||||
assert turntable_kwargs["material_map"] == {"InnerRing": "ResolvedSteel"}
|
||||
assert turntable_kwargs["material_library_path"] is None
|
||||
assert cinematic_kwargs["width"] == 1024
|
||||
assert cinematic_kwargs["height"] == 512
|
||||
assert cinematic_kwargs["engine"] == "eevee"
|
||||
assert cinematic_kwargs["samples"] == 64
|
||||
assert cinematic_kwargs["material_override"] == "Resolved White"
|
||||
|
||||
|
||||
def test_resolve_order_line_template_context_uses_exact_template_and_override(sync_session, tmp_path, monkeypatch):
|
||||
from app.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "upload_dir", str(tmp_path / "uploads"))
|
||||
line = _seed_order_line_graph(sync_session, tmp_path)
|
||||
line.material_override = "HARTOMAT_OVERRIDE"
|
||||
material_library_path = tmp_path / "libraries" / "materials.blend"
|
||||
material_library_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
material_library_path.write_text("BLEND", encoding="utf-8")
|
||||
|
||||
sync_session.add(
|
||||
AssetLibrary(
|
||||
id=uuid.uuid4(),
|
||||
name="Default Library",
|
||||
blend_file_path="/libraries/materials.blend",
|
||||
blend_file_path=str(material_library_path),
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
@@ -215,7 +543,7 @@ def test_resolve_order_line_template_context_uses_exact_template_and_override(sy
|
||||
|
||||
assert result.template is not None
|
||||
assert result.template.name == "Bearing Studio"
|
||||
assert result.material_library == "/libraries/materials.blend"
|
||||
assert result.material_library == str(material_library_path)
|
||||
assert result.override_material == "HARTOMAT_OVERRIDE"
|
||||
assert result.use_materials is True
|
||||
assert result.material_map == {
|
||||
@@ -522,6 +850,79 @@ def test_persist_order_line_output_reuses_existing_asset(sync_session, tmp_path,
|
||||
assert len(assets) == 1
|
||||
|
||||
|
||||
def test_persist_order_line_output_canonicalizes_step_file_outputs(sync_session, tmp_path, monkeypatch):
|
||||
from app.config import settings
|
||||
|
||||
upload_dir = tmp_path / "uploads"
|
||||
monkeypatch.setattr(settings, "upload_dir", str(upload_dir))
|
||||
line = _seed_order_line_graph(sync_session, tmp_path)
|
||||
step_render_path = upload_dir / "step_files" / "renders" / f"line_{line.id}.png"
|
||||
step_render_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
step_render_path.write_text("PNGDATA", encoding="utf-8")
|
||||
|
||||
existing = MediaAsset(
|
||||
id=uuid.uuid4(),
|
||||
order_line_id=line.id,
|
||||
product_id=line.product_id,
|
||||
asset_type=MediaAssetType.still,
|
||||
storage_key=f"renders/{line.id}/bearing.png",
|
||||
)
|
||||
sync_session.add(existing)
|
||||
sync_session.commit()
|
||||
|
||||
result = persist_order_line_output(
|
||||
sync_session,
|
||||
line,
|
||||
success=True,
|
||||
output_path=str(step_render_path),
|
||||
render_log={"renderer": "blender", "engine_used": "cycles"},
|
||||
workflow_run_id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
sync_session.refresh(line)
|
||||
expected_path = Path(result.result_path or "")
|
||||
asset = sync_session.execute(
|
||||
select(MediaAsset).where(MediaAsset.id == existing.id)
|
||||
).scalar_one()
|
||||
|
||||
assert expected_path.exists()
|
||||
assert expected_path.read_text(encoding="utf-8") == "PNGDATA"
|
||||
assert expected_path.parent == upload_dir / "renders" / str(line.id)
|
||||
assert expected_path.name.startswith("Bearing_A_Still-")
|
||||
assert expected_path.suffix == ".png"
|
||||
assert result.result_path == str(expected_path)
|
||||
assert result.storage_key == f"renders/{line.id}/{expected_path.name}"
|
||||
assert line.result_path == str(expected_path)
|
||||
assert result.asset_id == str(existing.id)
|
||||
assert asset.storage_key == f"renders/{line.id}/{expected_path.name}"
|
||||
|
||||
|
||||
def test_persist_order_line_output_checks_order_completion(sync_session, tmp_path, monkeypatch):
|
||||
from app.config import settings
|
||||
|
||||
upload_dir = tmp_path / "uploads"
|
||||
monkeypatch.setattr(settings, "upload_dir", str(upload_dir))
|
||||
line = _seed_order_line_graph(sync_session, tmp_path)
|
||||
rendered = tmp_path / "rendered.png"
|
||||
rendered.write_text("PNGDATA", encoding="utf-8")
|
||||
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"app.domains.orders.service.check_order_completion",
|
||||
lambda order_id: calls.append(order_id) or True,
|
||||
)
|
||||
|
||||
persist_order_line_output(
|
||||
sync_session,
|
||||
line,
|
||||
success=True,
|
||||
output_path=str(rendered),
|
||||
render_log={"renderer": "blender"},
|
||||
)
|
||||
|
||||
assert calls == [str(line.order_id)]
|
||||
|
||||
|
||||
def test_persist_order_line_output_marks_failure_without_result_path(sync_session, tmp_path):
|
||||
line = _seed_order_line_graph(sync_session, tmp_path)
|
||||
|
||||
@@ -547,6 +948,47 @@ def test_persist_order_line_output_marks_failure_without_result_path(sync_sessio
|
||||
assert assets == []
|
||||
|
||||
|
||||
def test_persist_order_line_media_asset_creates_blend_asset_without_touching_order_line(sync_session, tmp_path, monkeypatch):
|
||||
from app.config import settings
|
||||
|
||||
upload_dir = tmp_path / "uploads"
|
||||
monkeypatch.setattr(settings, "upload_dir", str(upload_dir))
|
||||
line = _seed_order_line_graph(sync_session, tmp_path)
|
||||
line.render_status = "completed"
|
||||
line.result_path = str(upload_dir / "renders" / str(line.id) / "bearing.png")
|
||||
sync_session.commit()
|
||||
|
||||
output_path = upload_dir / "exports" / str(line.id) / "bearing_production.blend"
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text("BLENDDATA", encoding="utf-8")
|
||||
|
||||
result = persist_order_line_media_asset(
|
||||
sync_session,
|
||||
line,
|
||||
success=True,
|
||||
output_path=str(output_path),
|
||||
asset_type=MediaAssetType.blend_production,
|
||||
render_log={"artifact_type": "blend_production"},
|
||||
)
|
||||
|
||||
sync_session.refresh(line)
|
||||
asset = sync_session.execute(
|
||||
select(MediaAsset).where(MediaAsset.storage_key == f"exports/{line.id}/bearing_production.blend")
|
||||
).scalar_one_or_none()
|
||||
|
||||
assert result.status == "completed"
|
||||
assert result.result_path == str(output_path)
|
||||
assert result.storage_key == f"exports/{line.id}/bearing_production.blend"
|
||||
assert result.asset_type == MediaAssetType.blend_production
|
||||
assert line.render_status == "completed"
|
||||
assert line.result_path == str(upload_dir / "renders" / str(line.id) / "bearing.png")
|
||||
assert asset is not None
|
||||
assert asset.asset_type == MediaAssetType.blend_production
|
||||
assert asset.mime_type == "application/x-blender"
|
||||
assert asset.file_size_bytes == output_path.stat().st_size
|
||||
assert asset.render_config == {"artifact_type": "blend_production"}
|
||||
|
||||
|
||||
def test_emit_order_line_render_notifications_emits_websocket_and_activity(
|
||||
sync_session,
|
||||
tmp_path,
|
||||
|
||||
Reference in New Issue
Block a user