mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 16:11:13 +00:00
[AZ-329] [AZ-330] [AZ-523] [AZ-524] Batch 44 atomic refactor
Implements two new C12 services and rebalances the C11/C12 boundary in one atomic commit: * AZ-329 PostLandingUploadOrchestrator — gates C11 upload on the `flight_footer` FDR record's `clean_shutdown` field; 4 refusal modes; new FdrFooterReader Protocol + LocalFdrFooterReader. * AZ-330 OperatorReLocService — AC-3.4 visual-loss re-localization hint; reuses shared LatLonAlt; OperatorCommandTransport Protocol cut (E-C8 owns the future pymavlink concrete); new FDR record kind `c12.reloc.requested`; log redaction (lat/lon 5 decimals, reason 200 chars). * AZ-523 C11 internal flight-state gate removed (SRP refactor): `confirm_flight_state` / `FlightStateSignal` use / `FlightStateNotOnGroundError` deleted from C11; TileUploader contract bumped to v2.0.0 (frozen) with migration note; AZ-317 superseded. * AZ-524 Package rename `c12_operator_tooling` → `c12_operator_orchestrator` across source, tests, pyproject, CMake, Dockerfile, compose, CI, runtime-root services class (`OperatorOrchestratorServices`) + factory function (`build_operator_orchestrator`), logger namespaces, config slug, docs, and the E-C12 epic title. Tests: 1543 passed, 80 skipped (all environment gates). Targeted AC suite (AZ-329 + AZ-330 + FdrFooterReader): 37 passed. Cold-start NFR-perf still ≤ 500 ms p99. Tracker: AZ-317 → Done (superseded); AZ-319 v2.0.0 contract bump comment; AZ-329/AZ-330 → In Testing; AZ-253 epic renamed; AZ-523 + AZ-524 created and closed as audit-trail tickets. See `_docs/03_implementation/batch_44_cycle1_report.md`. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -409,15 +409,15 @@ def compose_root(config: Config) -> RuntimeRoot:
|
||||
|
||||
|
||||
def compose_operator(config: Config) -> OperatorRoot:
|
||||
"""Compose the operator-tooling runtime graph (per contract v1.0.0)."""
|
||||
"""Compose the operator-orchestrator runtime graph (per contract v1.0.0)."""
|
||||
components, order = _compose(
|
||||
config,
|
||||
binary="operator-tooling",
|
||||
binary="operator-orchestrator",
|
||||
allowed_tiers=frozenset({"operator", "shared"}),
|
||||
extra_required_env=("SATELLITE_PROVIDER_URL",),
|
||||
)
|
||||
return OperatorRoot(
|
||||
binary="operator-tooling",
|
||||
binary="operator-orchestrator",
|
||||
profile=os.environ["GPS_DENIED_FC_PROFILE"],
|
||||
components=components,
|
||||
construction_order=order,
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
"""C11 TileManager composition-root factories (AZ-316, AZ-317, AZ-318, AZ-319).
|
||||
"""C11 TileManager composition-root factories (AZ-316, AZ-318, AZ-319).
|
||||
|
||||
Wires the operator-side services:
|
||||
|
||||
* :func:`build_flight_state_gate` (AZ-317) — adapts an injected
|
||||
``FlightStateSource`` (typically an E-C8 FC adapter wrapper) into
|
||||
the C11 ``FlightStateGate``.
|
||||
* :func:`build_per_flight_key_manager` (AZ-318) — wires the AZ-273
|
||||
:class:`FdrClient` and the project ``Clock`` strategy into the
|
||||
ephemeral signing-key manager.
|
||||
* :func:`build_tile_uploader` (AZ-319) — composes the gate, the
|
||||
key manager, the c6 storage cuts, an :class:`httpx.Client`, and
|
||||
the :class:`C11Config` block into the production
|
||||
:class:`HttpTileUploader`.
|
||||
* :func:`build_tile_uploader` (AZ-319) — composes the key manager,
|
||||
the c6 storage cuts, an :class:`httpx.Client`, and the
|
||||
:class:`C11Config` block into the production
|
||||
:class:`HttpTileUploader`. Flight-state confirmation is the
|
||||
caller's responsibility (C12 ``PostLandingUploadOrchestrator``).
|
||||
* :func:`build_tile_downloader` (AZ-316) — composes the c6 store +
|
||||
metadata-store + budget-enforcer (wrapped in a single
|
||||
composition-root adapter that hides c6's :class:`TileMetadata`
|
||||
@@ -32,8 +30,6 @@ import httpx
|
||||
|
||||
from gps_denied_onboard.components.c11_tile_manager import (
|
||||
C11Config,
|
||||
FlightStateGate,
|
||||
FlightStateSource,
|
||||
HttpTileDownloader,
|
||||
HttpTileUploader,
|
||||
IdempotentRetryTileUploader,
|
||||
@@ -50,14 +46,12 @@ if TYPE_CHECKING:
|
||||
from gps_denied_onboard.config.schema import Config
|
||||
|
||||
__all__ = [
|
||||
"build_flight_state_gate",
|
||||
"build_per_flight_key_manager",
|
||||
"build_tile_downloader",
|
||||
"build_tile_uploader",
|
||||
]
|
||||
|
||||
|
||||
_C11_GATE_LOGGER = "c11_tile_manager.flight_state_gate"
|
||||
_C11_SIGNING_LOGGER = "c11_tile_manager.signing_key"
|
||||
_C11_SIGNING_PRODUCER_ID = "c11_tile_manager.signing_key"
|
||||
_C11_UPLOADER_LOGGER = "c11_tile_manager.tile_uploader"
|
||||
@@ -65,19 +59,6 @@ _C11_UPLOADER_PRODUCER_ID = "c11_tile_manager.tile_uploader"
|
||||
_C11_DOWNLOADER_LOGGER = "c11_tile_manager.tile_downloader"
|
||||
|
||||
|
||||
def build_flight_state_gate(*, source: FlightStateSource) -> FlightStateGate:
|
||||
"""Construct a wired :class:`FlightStateGate` (AZ-317).
|
||||
|
||||
The ``source`` argument is the consumer-side cut over E-C8's FC
|
||||
adapter; the composition root supplies a concrete adapter wrapping
|
||||
the actual C8 instance once E-C8 ships. Until then operator
|
||||
tooling tests inject a fake source that returns a fixed signal.
|
||||
"""
|
||||
|
||||
logger = get_logger(_C11_GATE_LOGGER)
|
||||
return FlightStateGate(source=source, logger=logger)
|
||||
|
||||
|
||||
def build_per_flight_key_manager(
|
||||
config: Config,
|
||||
*,
|
||||
@@ -108,7 +89,6 @@ def build_tile_uploader(
|
||||
http_client: httpx.Client,
|
||||
tile_store: Any,
|
||||
tile_metadata_store: Any,
|
||||
flight_state_gate: FlightStateGate,
|
||||
key_manager: PerFlightKeyManager,
|
||||
clock: ClockProtocol | None = None,
|
||||
fdr_client: FdrClient | None = None,
|
||||
@@ -162,7 +142,6 @@ def build_tile_uploader(
|
||||
http_client=http_client,
|
||||
tile_store=tile_store,
|
||||
tile_metadata_store=tile_metadata_store,
|
||||
flight_state_gate=flight_state_gate,
|
||||
key_manager=key_manager,
|
||||
fdr_client=fdr_client,
|
||||
logger=logger,
|
||||
@@ -235,7 +214,7 @@ def build_tile_downloader(
|
||||
if not block.service_api_key:
|
||||
raise ConfigError(
|
||||
"build_tile_downloader: C11Config.service_api_key must be "
|
||||
"set; the operator-tooling deploy MUST inject the bearer "
|
||||
"set; the operator-orchestrator deploy MUST inject the bearer "
|
||||
"token via env override"
|
||||
)
|
||||
logger = get_logger(_C11_DOWNLOADER_LOGGER)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Composition-root factories for C12 operator-tooling services.
|
||||
"""Composition-root factories for C12 operator-orchestrator services.
|
||||
|
||||
* :func:`build_flights_api_client` — AZ-489 ``FlightsApiClient`` (online +
|
||||
offline path).
|
||||
@@ -12,14 +12,14 @@
|
||||
AZ-327 / AZ-489 services. The AZ-507 cross-component cut means we
|
||||
translate c11's real ``DownloadRequest`` / ``DownloadBatchReport`` to
|
||||
the local ``DownloadRequestCut`` / ``DownloadBatchReportCut`` here.
|
||||
* :func:`build_operator_tool` — aggregator that returns the
|
||||
:class:`OperatorToolServices` dataclass the AZ-326 CLI consumes.
|
||||
* :func:`build_operator_orchestrator` — aggregator that returns the
|
||||
:class:`OperatorOrchestratorServices` dataclass the AZ-326 CLI consumes.
|
||||
|
||||
Each ``build_*`` function is intentionally tiny — there is one
|
||||
production strategy per service today and the CLI wiring just plugs
|
||||
the concrete instance into the same composition root method. Sibling
|
||||
tasks AZ-329 / AZ-330 will each add a single field to
|
||||
:class:`OperatorToolServices` without renaming or moving the
|
||||
:class:`OperatorOrchestratorServices` without renaming or moving the
|
||||
dataclass.
|
||||
"""
|
||||
|
||||
@@ -30,60 +30,80 @@ from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from gps_denied_onboard.clock import Clock
|
||||
from gps_denied_onboard.components.c12_operator_tooling.build_cache import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.build_cache import (
|
||||
BuildCacheOrchestrator,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.companion_bringup import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.companion_bringup import (
|
||||
CompanionBringup,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.file_lock import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.fdr_footer_reader import (
|
||||
LocalFdrFooterReader,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.file_lock import (
|
||||
FilelockFileLockFactory,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api import (
|
||||
FlightsApiClient,
|
||||
HttpxFlightsApiClient,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.paramiko_ssh_session import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.operator_command_transport import (
|
||||
OperatorCommandTransport,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.operator_reloc_service import (
|
||||
OperatorReLocService,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.paramiko_ssh_session import (
|
||||
ParamikoSshSessionFactory,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.remote_c10_invoker import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.post_landing_upload import (
|
||||
PostLandingUploadOrchestrator,
|
||||
)
|
||||
from gps_denied_onboard.fdr_client import FdrClient
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.remote_c10_invoker import (
|
||||
RemoteCacheProvisionerInvoker,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.remote_sidecar_verifier import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.remote_sidecar_verifier import (
|
||||
RemoteSidecarVerifier,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.sector_classification_store import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.sector_classification_store import (
|
||||
SectorClassificationStore,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.tile_downloader_cut import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.tile_downloader_cut import (
|
||||
TileDownloaderCut,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.tile_uploader_cut import (
|
||||
TileUploaderCut,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard.components.c12_operator_tooling.config import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.config import (
|
||||
C12Config,
|
||||
)
|
||||
from gps_denied_onboard.config import Config
|
||||
|
||||
__all__ = [
|
||||
"OperatorToolServices",
|
||||
"OperatorOrchestratorServices",
|
||||
"build_build_cache_orchestrator",
|
||||
"build_companion_bringup",
|
||||
"build_flights_api_client",
|
||||
"build_operator_tool",
|
||||
"build_operator_reloc_service",
|
||||
"build_operator_orchestrator",
|
||||
"build_post_landing_upload_orchestrator",
|
||||
"build_sector_classification_store",
|
||||
]
|
||||
|
||||
|
||||
_C12_LOGGER_NAME = "c12_operator_tooling"
|
||||
_COMPANION_LOGGER_NAME = "c12_operator_tooling.companion_bringup"
|
||||
_BUILD_CACHE_LOGGER_NAME = "c12_operator_tooling.build_cache"
|
||||
_REMOTE_C10_LOGGER_NAME = "c12_operator_tooling.remote_c10_invoker"
|
||||
_C12_LOGGER_NAME = "c12_operator_orchestrator"
|
||||
_COMPANION_LOGGER_NAME = "c12_operator_orchestrator.companion_bringup"
|
||||
_BUILD_CACHE_LOGGER_NAME = "c12_operator_orchestrator.build_cache"
|
||||
_REMOTE_C10_LOGGER_NAME = "c12_operator_orchestrator.remote_c10_invoker"
|
||||
_POST_LANDING_LOGGER_NAME = "c12_operator_orchestrator.post_landing_upload"
|
||||
_OPERATOR_RELOC_LOGGER_NAME = "c12_operator_orchestrator.operator_reloc_service"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperatorToolServices:
|
||||
"""Aggregated service handles the operator-tool CLI consumes (AZ-326).
|
||||
class OperatorOrchestratorServices:
|
||||
"""Aggregated service handles the operator-orchestrator CLI consumes (AZ-326).
|
||||
|
||||
AZ-326 introduced the dataclass and now owns three services
|
||||
(``flights_api_client``, ``sector_classification_store``,
|
||||
@@ -103,6 +123,8 @@ class OperatorToolServices:
|
||||
sector_classification_store: SectorClassificationStore
|
||||
companion_bringup: CompanionBringup
|
||||
build_cache_orchestrator: BuildCacheOrchestrator | None = None
|
||||
post_landing_upload_orchestrator: PostLandingUploadOrchestrator | None = None
|
||||
operator_reloc_service: OperatorReLocService | None = None
|
||||
|
||||
|
||||
def build_flights_api_client(config: Config) -> FlightsApiClient:
|
||||
@@ -162,7 +184,7 @@ def build_companion_bringup(
|
||||
def build_build_cache_orchestrator(
|
||||
config: Config,
|
||||
*,
|
||||
services: OperatorToolServices,
|
||||
services: OperatorOrchestratorServices,
|
||||
tile_downloader: TileDownloaderCut,
|
||||
clock: Clock,
|
||||
logger: logging.Logger | None = None,
|
||||
@@ -171,7 +193,7 @@ def build_build_cache_orchestrator(
|
||||
|
||||
Caller (production runtime root) is responsible for translating the
|
||||
real c11 ``TileDownloader`` to a :class:`TileDownloaderCut` adapter
|
||||
here — ``c12_operator_tooling`` cannot import c11 directly per
|
||||
here — ``c12_operator_orchestrator`` cannot import c11 directly per
|
||||
AZ-507. The lockfile factory + remote-C10 invoker + SSH factory are
|
||||
constructed in-place; the SSH factory MUST be the same instance as
|
||||
the one wired into ``services.companion_bringup`` (single
|
||||
@@ -207,51 +229,142 @@ def build_build_cache_orchestrator(
|
||||
)
|
||||
|
||||
|
||||
def build_operator_tool(
|
||||
def build_post_landing_upload_orchestrator(
|
||||
config: Config,
|
||||
*,
|
||||
tile_uploader: TileUploaderCut,
|
||||
logger: logging.Logger | None = None,
|
||||
) -> PostLandingUploadOrchestrator:
|
||||
"""Build the AZ-329 :class:`PostLandingUploadOrchestrator` from config + a c11 uploader cut.
|
||||
|
||||
Caller (production suite-level runtime root) is responsible for
|
||||
translating the real c11 ``HttpTileUploader`` to a
|
||||
:class:`TileUploaderCut` adapter here — ``c12_operator_orchestrator``
|
||||
cannot import c11 directly per AZ-507. The adapter maps
|
||||
:class:`UploadRequestCut` ↔ c11's ``UploadRequest`` and
|
||||
:class:`UploadBatchReportCut` ↔ c11's ``UploadBatchReport``.
|
||||
"""
|
||||
c12_config = _resolve_c12_config(config)
|
||||
return PostLandingUploadOrchestrator(
|
||||
tile_uploader=tile_uploader,
|
||||
fdr_footer_reader=LocalFdrFooterReader(c12_config.post_landing.fdr_root),
|
||||
logger=logger or logging.getLogger(_POST_LANDING_LOGGER_NAME),
|
||||
config=c12_config.post_landing,
|
||||
)
|
||||
|
||||
|
||||
def build_operator_reloc_service(
|
||||
config: Config,
|
||||
*,
|
||||
transport: OperatorCommandTransport,
|
||||
fdr_client: FdrClient,
|
||||
clock: Clock,
|
||||
logger: logging.Logger | None = None,
|
||||
) -> OperatorReLocService:
|
||||
"""Build the AZ-330 :class:`OperatorReLocService`.
|
||||
|
||||
The :class:`OperatorCommandTransport` (E-C8's pymavlink-backed
|
||||
``MavlinkOperatorCommandTransport`` in production; a
|
||||
``LoggingOnlyOperatorCommandTransport`` in dev environments without
|
||||
a companion) is resolved by the suite-level runtime root and
|
||||
injected here — c12 cannot import c8 directly per AZ-507. The
|
||||
``fdr_client`` is the shared AZ-273 instance keyed to producer
|
||||
``c12_operator_orchestrator`` so the post-flight FDR captures the
|
||||
operator's re-loc actions chronologically alongside other onboard
|
||||
records.
|
||||
"""
|
||||
_ = config # reserved for future composition-time tuning
|
||||
return OperatorReLocService(
|
||||
transport=transport,
|
||||
fdr_client=fdr_client,
|
||||
logger=logger or logging.getLogger(_OPERATOR_RELOC_LOGGER_NAME),
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
|
||||
def build_operator_orchestrator(
|
||||
config: Config,
|
||||
*,
|
||||
tile_downloader: TileDownloaderCut | None = None,
|
||||
tile_uploader: TileUploaderCut | None = None,
|
||||
clock: Clock | None = None,
|
||||
) -> OperatorToolServices:
|
||||
"""Aggregate the AZ-326 / AZ-327 / AZ-328 / AZ-489 service handles.
|
||||
operator_command_transport: OperatorCommandTransport | None = None,
|
||||
fdr_client: FdrClient | None = None,
|
||||
) -> OperatorOrchestratorServices:
|
||||
"""Aggregate the AZ-326 / AZ-327 / AZ-328 / AZ-329 / AZ-330 / AZ-489 service handles.
|
||||
|
||||
``tile_downloader`` and ``clock`` are optional — without them, the
|
||||
``build_cache_orchestrator`` field is left as ``None`` and the CLI's
|
||||
``build-cache`` subcommand short-circuits gracefully. Production
|
||||
wiring (the suite-level runtime root) supplies real instances.
|
||||
Optional collaborators (each gates one service field):
|
||||
|
||||
* ``tile_downloader`` + ``clock`` → ``build_cache_orchestrator``
|
||||
(AZ-328); CLI ``build-cache`` short-circuits when missing.
|
||||
* ``tile_uploader`` → ``post_landing_upload_orchestrator`` (AZ-329);
|
||||
CLI ``upload-pending`` short-circuits when missing.
|
||||
* ``operator_command_transport`` + ``fdr_client`` + ``clock`` →
|
||||
``operator_reloc_service`` (AZ-330); CLI ``reloc-confirm``
|
||||
short-circuits when missing. AC-10: lazy construction — when the
|
||||
transport is not supplied, no transport instance is created
|
||||
(pymavlink stays unimported).
|
||||
"""
|
||||
base = OperatorToolServices(
|
||||
flights_api_client=build_flights_api_client(config),
|
||||
sector_classification_store=build_sector_classification_store(config),
|
||||
companion_bringup=build_companion_bringup(config),
|
||||
flights_api_client = build_flights_api_client(config)
|
||||
sector_store = build_sector_classification_store(config)
|
||||
companion_bringup = build_companion_bringup(config)
|
||||
|
||||
base_for_build_cache = OperatorOrchestratorServices(
|
||||
flights_api_client=flights_api_client,
|
||||
sector_classification_store=sector_store,
|
||||
companion_bringup=companion_bringup,
|
||||
)
|
||||
if tile_downloader is None or clock is None:
|
||||
return base
|
||||
orchestrator = build_build_cache_orchestrator(
|
||||
config,
|
||||
services=base,
|
||||
tile_downloader=tile_downloader,
|
||||
clock=clock,
|
||||
)
|
||||
return OperatorToolServices(
|
||||
flights_api_client=base.flights_api_client,
|
||||
sector_classification_store=base.sector_classification_store,
|
||||
companion_bringup=base.companion_bringup,
|
||||
build_cache_orchestrator=orchestrator,
|
||||
|
||||
build_cache_orchestrator: BuildCacheOrchestrator | None = None
|
||||
if tile_downloader is not None and clock is not None:
|
||||
build_cache_orchestrator = build_build_cache_orchestrator(
|
||||
config,
|
||||
services=base_for_build_cache,
|
||||
tile_downloader=tile_downloader,
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
post_landing_orchestrator: PostLandingUploadOrchestrator | None = None
|
||||
if tile_uploader is not None:
|
||||
post_landing_orchestrator = build_post_landing_upload_orchestrator(
|
||||
config,
|
||||
tile_uploader=tile_uploader,
|
||||
)
|
||||
|
||||
operator_reloc_service: OperatorReLocService | None = None
|
||||
if (
|
||||
operator_command_transport is not None
|
||||
and fdr_client is not None
|
||||
and clock is not None
|
||||
):
|
||||
operator_reloc_service = build_operator_reloc_service(
|
||||
config,
|
||||
transport=operator_command_transport,
|
||||
fdr_client=fdr_client,
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
return OperatorOrchestratorServices(
|
||||
flights_api_client=flights_api_client,
|
||||
sector_classification_store=sector_store,
|
||||
companion_bringup=companion_bringup,
|
||||
build_cache_orchestrator=build_cache_orchestrator,
|
||||
post_landing_upload_orchestrator=post_landing_orchestrator,
|
||||
operator_reloc_service=operator_reloc_service,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_c12_config(config: Config) -> C12Config:
|
||||
from gps_denied_onboard.components.c12_operator_tooling.config import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.config import (
|
||||
C12Config,
|
||||
)
|
||||
|
||||
block = config.components.get("c12_operator_tooling")
|
||||
block = config.components.get("c12_operator_orchestrator")
|
||||
if block is None:
|
||||
return C12Config()
|
||||
if not isinstance(block, C12Config):
|
||||
raise TypeError(
|
||||
"config.components['c12_operator_tooling'] must be a C12Config; got "
|
||||
"config.components['c12_operator_orchestrator'] must be a C12Config; got "
|
||||
f"{type(block).__name__}"
|
||||
)
|
||||
return block
|
||||
|
||||
Reference in New Issue
Block a user