Go-live T05 + WP-0013/0014: first Phase and Control Plane completion
Accept WP-0008-T05 for trsl:phase:info-tech-canon-service-surface (history/260805-T05-GoLive-info-tech-canon.md). Finish WP-0013 remission automation and WP-0014 extension/breach/attestation Control Plane UI. Update SCOPE, README, and pilot-candidate notes for pilot Stage 1.
This commit is contained in:
parent
f56d82f09a
commit
3064c0fe0c
18 changed files with 1676 additions and 72 deletions
|
|
@ -18,7 +18,7 @@ from fastapi import Depends, FastAPI, HTTPException, Request
|
|||
from psycopg import Connection
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
from .. import attestation, breach_record, ledger, metrics, registry
|
||||
from .. import attestation, breach_record, ledger, metrics, registry, remission
|
||||
from . import keys
|
||||
|
||||
app = FastAPI(title="Target Revenue Trust Service — Registries", version="0.1.0")
|
||||
|
|
@ -152,7 +152,65 @@ def read_metrics(
|
|||
if manifest is None:
|
||||
raise HTTPException(status_code=404, detail="phase not found")
|
||||
entries = ledger.get_ledger(conn, phase_id)
|
||||
return metrics.compute_metrics(manifest, entries, metrics.utcnow())
|
||||
activated_at = remission.get_phase_registered_at(conn, phase_id)
|
||||
return metrics.compute_metrics(
|
||||
manifest, entries, metrics.utcnow(), activated_at=activated_at
|
||||
)
|
||||
|
||||
|
||||
@app.post("/phases/{phase_id}/remission", status_code=201)
|
||||
def apply_phase_remission(
|
||||
phase_id: str,
|
||||
licensor: registry.Licensor = Depends(get_licensor),
|
||||
conn: Connection = Depends(get_connection),
|
||||
signing_key=Depends(get_signing_key),
|
||||
) -> dict[str, Any]:
|
||||
"""On-demand linear-longstop remission apply (WP-0013).
|
||||
|
||||
Auth required (Operator+ of the Phase's Licensor). The ledger entry
|
||||
itself is attributed to `system:policy-engine`, not the calling human
|
||||
— the caller only authorizes the run. Returns 200-shaped body with
|
||||
`status: up_to_date` and no entry when the Phase is already current
|
||||
(idempotent).
|
||||
"""
|
||||
if not registry.has_right(licensor.rights, "operator"):
|
||||
raise HTTPException(status_code=403, detail="operator rights required")
|
||||
manifest = registry.get_phase_manifest(conn, phase_id)
|
||||
if manifest is None:
|
||||
raise HTTPException(status_code=404, detail="phase not found")
|
||||
owner = conn.execute(
|
||||
"SELECT licensor_id FROM phase_manifests WHERE phase_id = %s", (phase_id,)
|
||||
).fetchone()[0]
|
||||
if owner != licensor.licensor_id:
|
||||
raise HTTPException(status_code=403, detail="not authorized for this Phase")
|
||||
try:
|
||||
entry = remission.apply_remission_for_phase(conn, phase_id, signing_key)
|
||||
except registry.RegistrationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
if entry is None:
|
||||
return {"phase_id": phase_id, "status": "up_to_date", "entry": None}
|
||||
return {"phase_id": phase_id, "status": "appended", "entry": entry}
|
||||
|
||||
|
||||
@app.post("/remission/run", status_code=200)
|
||||
def run_remission_batch(
|
||||
licensor: registry.Licensor = Depends(get_licensor),
|
||||
conn: Connection = Depends(get_connection),
|
||||
signing_key=Depends(get_signing_key),
|
||||
) -> dict[str, Any]:
|
||||
"""Batch scheduled-style run across all Phases (WP-0013).
|
||||
|
||||
Intended for a cron job holding an Operator+ credential. Each Phase
|
||||
is planned independently; unsupported policies are skipped.
|
||||
"""
|
||||
if not registry.has_right(licensor.rights, "operator"):
|
||||
raise HTTPException(status_code=403, detail="operator rights required")
|
||||
written = remission.apply_remission_for_all_phases(conn, signing_key)
|
||||
return {
|
||||
"status": "ok",
|
||||
"entries_appended": len(written),
|
||||
"entry_ids": [e["id"] for e in written],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/phases/{phase_id}/attestation")
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ from psycopg import Connection
|
|||
from psycopg_pool import ConnectionPool
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from .. import control_plane, ledger, metrics, registry
|
||||
from .. import breach_record, control_plane, ledger, metrics, registry
|
||||
from . import keys, reference_docs
|
||||
|
||||
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
||||
|
|
@ -248,19 +248,56 @@ def phase_detail(
|
|||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
signing_key=Depends(get_signing_key),
|
||||
):
|
||||
from .. import remission
|
||||
|
||||
manifest = registry.get_phase_manifest(conn, phase_id)
|
||||
if manifest is None:
|
||||
raise HTTPException(status_code=404, detail="phase not found")
|
||||
entries = ledger.get_ledger(conn, phase_id)
|
||||
computed_metrics = metrics.compute_metrics(manifest, entries, metrics.utcnow())
|
||||
activated_at = remission.get_phase_registered_at(conn, phase_id)
|
||||
computed_metrics = metrics.compute_metrics(
|
||||
manifest, entries, metrics.utcnow(), activated_at=activated_at
|
||||
)
|
||||
breaches = breach_record.get_breach_records(conn, phase_id)
|
||||
phase_attestation = control_plane.get_or_publish_attestation(
|
||||
conn, phase_id, signing_key
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"phase_detail.html",
|
||||
_template_context(request, licensor, manifest=manifest, ledger=entries, metrics=computed_metrics),
|
||||
_template_context(
|
||||
request,
|
||||
licensor,
|
||||
manifest=manifest,
|
||||
ledger=entries,
|
||||
metrics=computed_metrics,
|
||||
breaches=breaches,
|
||||
attestation=phase_attestation,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/phases/{phase_id}/remission")
|
||||
def phase_remission_apply(
|
||||
phase_id: str,
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
signing_key=Depends(get_signing_key),
|
||||
):
|
||||
try:
|
||||
entry = control_plane.apply_policy_remission(conn, licensor, phase_id, signing_key)
|
||||
except (control_plane.ControlPlaneError, registry.RegistrationError) as exc:
|
||||
return _redirect(f"/phases/{phase_id}", request, str(exc), "danger")
|
||||
if entry is None:
|
||||
flash = "Remission already up to date — no new entry written."
|
||||
else:
|
||||
flash = f"Remission credit {entry['id']} appended ({entry['amount']})."
|
||||
return _redirect(f"/phases/{phase_id}", request, flash, "success")
|
||||
|
||||
|
||||
@app.post("/phases/{phase_id}/ledger")
|
||||
def phase_ledger_submit(
|
||||
phase_id: str,
|
||||
|
|
@ -298,6 +335,133 @@ def phase_ledger_submit(
|
|||
return _redirect(f"/phases/{phase_id}", request, flash, "success")
|
||||
|
||||
|
||||
@app.post("/phases/{phase_id}/breach")
|
||||
def phase_breach_publish(
|
||||
phase_id: str,
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
signing_key=Depends(get_signing_key),
|
||||
record_id: str = Form(...),
|
||||
case_id: str = Form(...),
|
||||
event_type: str = Form(...),
|
||||
category: str = Form(...),
|
||||
event_at: str = Form(...),
|
||||
evidence_reference: str = Form(""),
|
||||
anonymized: str = Form("true"),
|
||||
named_entitlement_holder: str = Form(""),
|
||||
named_disclosure_authorized: str = Form(""),
|
||||
):
|
||||
"""Operator+: publish a breach/compliance event (WP-0014-T02)."""
|
||||
is_anonymized = anonymized.lower() in ("true", "1", "on", "yes")
|
||||
event_input: dict[str, Any] = {
|
||||
"id": record_id,
|
||||
"case_id": case_id,
|
||||
"event_type": event_type,
|
||||
"category": category,
|
||||
"event_at": event_at,
|
||||
"anonymized": is_anonymized,
|
||||
}
|
||||
if evidence_reference:
|
||||
event_input["evidence_reference"] = evidence_reference
|
||||
if not is_anonymized:
|
||||
event_input["named_entitlement_holder"] = named_entitlement_holder
|
||||
event_input["named_disclosure_authorized_under_cua"] = (
|
||||
named_disclosure_authorized.lower() in ("true", "1", "on", "yes")
|
||||
)
|
||||
try:
|
||||
stored = control_plane.publish_breach_event(
|
||||
conn, licensor, phase_id, event_input, signing_key
|
||||
)
|
||||
except (control_plane.ControlPlaneError, registry.RegistrationError) as exc:
|
||||
return _redirect(f"/phases/{phase_id}", request, str(exc), "danger")
|
||||
return _redirect(
|
||||
f"/phases/{phase_id}",
|
||||
request,
|
||||
f"Breach record {stored['id']} published ({stored['event_type']}).",
|
||||
"success",
|
||||
)
|
||||
|
||||
|
||||
# --- Extension Registry (WP-0014-T01) ----------------------------------------
|
||||
|
||||
|
||||
@app.get("/extensions")
|
||||
def extensions_list(
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
):
|
||||
extensions = registry.list_extensions(conn)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"extensions.html",
|
||||
_template_context(request, licensor, extensions=extensions),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/extensions")
|
||||
def extensions_register(
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
extension_id: str = Form(...),
|
||||
version: str = Form(...),
|
||||
value_description: str = Form(...),
|
||||
pricing_method: str = Form(...),
|
||||
allocation_rule: str = Form(...),
|
||||
default_rate: str = Form(""),
|
||||
recognition_event: str = Form(...),
|
||||
reversal_rule: str = Form(...),
|
||||
evidence_requirement: str = Form(...),
|
||||
):
|
||||
extension: dict[str, Any] = {
|
||||
"id": extension_id,
|
||||
"version": version,
|
||||
"value": {"description": value_description},
|
||||
"pricing": {"method": pricing_method},
|
||||
"allocation": {"rule": allocation_rule},
|
||||
"recognition": {"event": recognition_event},
|
||||
"reversal": {"rule": reversal_rule},
|
||||
"evidence": {"requirement": evidence_requirement},
|
||||
# Author-submitted status is always `registered`; the hosting column
|
||||
# is authoritative and starts registered regardless (registry.py).
|
||||
"status": "registered",
|
||||
}
|
||||
if default_rate.strip():
|
||||
extension["allocation"]["default_rate"] = float(default_rate)
|
||||
try:
|
||||
control_plane.register_extension(conn, licensor, extension)
|
||||
except (control_plane.ControlPlaneError, registry.RegistrationError) as exc:
|
||||
return _redirect("/extensions", request, str(exc), "danger")
|
||||
return _redirect(
|
||||
"/extensions",
|
||||
request,
|
||||
f"Extension {extension_id}@{version} registered.",
|
||||
"success",
|
||||
)
|
||||
|
||||
|
||||
@app.post("/extensions/promote")
|
||||
def extensions_promote(
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
extension_id: str = Form(...),
|
||||
version: str = Form(...),
|
||||
):
|
||||
try:
|
||||
control_plane.promote_extension_canonical(conn, licensor, extension_id, version)
|
||||
except (control_plane.ControlPlaneError, registry.RegistrationError) as exc:
|
||||
return _redirect("/extensions", request, str(exc), "danger")
|
||||
return _redirect(
|
||||
"/extensions",
|
||||
request,
|
||||
f"Extension {extension_id}@{version} promoted to canonical.",
|
||||
"success",
|
||||
)
|
||||
|
||||
|
||||
# --- Proposals (Operator+) --------------------------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Extensions — Target Revenue Control Plane{% endblock %}
|
||||
{% block content %}
|
||||
<wn-page-header>
|
||||
<span slot="title">Monetization Extension Registry</span>
|
||||
</wn-page-header>
|
||||
|
||||
<p style="color:#888;font-size:0.9rem;">
|
||||
Rights: <strong>Operator+</strong> may register; <strong>Admin</strong> may
|
||||
promote to <code>canonical</code> (governance action, never automated).
|
||||
See <code>specs/TargetRevenueControlPlaneConcept.md</code> §2.
|
||||
</p>
|
||||
|
||||
{% if extensions %}
|
||||
<table class="wn-plain">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>id</th>
|
||||
<th>version</th>
|
||||
<th>status</th>
|
||||
<th>licensor</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ext in extensions %}
|
||||
<tr>
|
||||
<td><code>{{ ext.extension_id }}</code></td>
|
||||
<td>{{ ext.version }}</td>
|
||||
<td><wn-tag>{{ ext.status }}</wn-tag></td>
|
||||
<td>{{ ext.licensor_id }}</td>
|
||||
<td>
|
||||
{% if session_rights == "admin" and ext.status == "registered" %}
|
||||
<form method="post" action="/extensions/promote" style="display:inline" class="wn-form">
|
||||
<input type="hidden" name="extension_id" value="{{ ext.extension_id }}">
|
||||
<input type="hidden" name="version" value="{{ ext.version }}">
|
||||
<wn-button type="submit" variant="secondary">Promote to canonical</wn-button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="5" style="color:#666;font-size:0.85rem;padding-top:0;">
|
||||
{{ ext.contract.value.description }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<wn-empty-state>No extensions registered yet.</wn-empty-state>
|
||||
{% endif %}
|
||||
|
||||
{% if session_rights in ("operator", "admin") %}
|
||||
<h3>Register a new extension</h3>
|
||||
<form class="wn-form" method="post" action="/extensions">
|
||||
<wn-field-row label="Extension id (trsl:extension:...)">
|
||||
<wn-input name="extension_id" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Version (e.g. 1.0)">
|
||||
<wn-input name="version" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Value description">
|
||||
<wn-input name="value_description" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Pricing method">
|
||||
<wn-input name="pricing_method" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Allocation rule">
|
||||
<wn-input name="allocation_rule" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Default rate (0–1, optional)">
|
||||
<wn-input type="number" step="0.01" min="0" max="1" name="default_rate"></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Recognition event">
|
||||
<wn-input name="recognition_event" value="payment-settled" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Reversal rule">
|
||||
<wn-input name="reversal_rule" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Evidence requirement">
|
||||
<wn-input name="evidence_requirement" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-button type="submit" variant="primary">Register extension</wn-button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -31,8 +31,47 @@
|
|||
· <a href="/reference/policies/{{ policy_slug(manifest.phase.degeneration_policy) }}">view spec</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td>Longstop</td><td>{{ metrics.facts.longstop_at or "—" }}</td></tr>
|
||||
<tr><td>Activated (t0)</td><td>{{ metrics.facts.activated_at or "—" }}</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Remission forecast <wn-tag>forecast</wn-tag></h3>
|
||||
<table class="wn-plain">
|
||||
<tr>
|
||||
<td>If applied now</td>
|
||||
<td>
|
||||
{% if metrics.forecasts.remission_if_applied_now is not none %}
|
||||
{{ metrics.forecasts.remission_if_applied_now }} {{ metrics.facts.initial_target_currency }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Next scheduled (monthly UTC / longstop)</td>
|
||||
<td>{{ metrics.forecasts.next_scheduled_remission_at or "—" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Amount at next schedule</td>
|
||||
<td>
|
||||
{% if metrics.forecasts.next_scheduled_remission_amount is not none %}
|
||||
{{ metrics.forecasts.next_scheduled_remission_amount }} {{ metrics.facts.initial_target_currency }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% if session_rights in ("operator", "admin") %}
|
||||
<form class="wn-form" method="post" action="/phases/{{ manifest.phase.id }}/remission" style="margin-bottom:1.5rem;">
|
||||
<wn-button type="submit" variant="secondary">Apply policy remission now</wn-button>
|
||||
<p style="color:#888;font-size:0.85rem;margin-top:0.5rem;">
|
||||
Writes a <code>remission-credit</code> delta under <code>system:policy-engine</code>
|
||||
(idempotent — no double-remit if already current).
|
||||
</p>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<h3>Ledger ({{ ledger | length }} entries)</h3>
|
||||
{% if ledger %}
|
||||
<table class="wn-plain">
|
||||
|
|
@ -78,4 +117,98 @@
|
|||
</wn-button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<h3>Conversion Attestation</h3>
|
||||
{% if attestation %}
|
||||
<table class="wn-plain">
|
||||
<tr><td>Phase</td><td>{{ attestation.phase }}</td></tr>
|
||||
<tr><td>Milestone release</td><td>{{ attestation.milestone_release }}</td></tr>
|
||||
<tr><td>Converted at</td><td>{{ attestation.conversion_timestamp }}</td></tr>
|
||||
<tr><td>Future License</td><td><strong>{{ attestation.future_license }}</strong></td></tr>
|
||||
<tr><td>Final development credit</td><td>{{ attestation.final_development_credit }}</td></tr>
|
||||
<tr><td>Final remission credit</td><td>{{ attestation.final_remission_credit }}</td></tr>
|
||||
<tr><td>Outstanding at conversion</td><td>{{ attestation.final_outstanding_target }}</td></tr>
|
||||
<tr><td>Ledger checkpoint</td><td><code>{{ attestation.ledger_checkpoint }}</code></td></tr>
|
||||
<tr><td>Signature</td><td><code style="font-size:0.75rem;word-break:break-all;">{{ attestation.signature }}</code></td></tr>
|
||||
</table>
|
||||
{% elif metrics.facts.is_converted %}
|
||||
<p>Phase is converted; attestation will publish on next observation.</p>
|
||||
{% else %}
|
||||
<wn-empty-state>Not converted — no attestation yet.</wn-empty-state>
|
||||
{% endif %}
|
||||
|
||||
<h3>Breach / Compliance Records ({{ breaches | length }})</h3>
|
||||
{% if breaches %}
|
||||
<table class="wn-plain">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>id</th>
|
||||
<th>case</th>
|
||||
<th>type</th>
|
||||
<th>category</th>
|
||||
<th>event_at</th>
|
||||
<th>named?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in breaches %}
|
||||
<tr>
|
||||
<td>{{ b.id }}</td>
|
||||
<td>{{ b.case_id }}</td>
|
||||
<td>{{ b.event_type }}</td>
|
||||
<td>{{ b.category }}</td>
|
||||
<td>{{ b.event_at }}</td>
|
||||
<td>
|
||||
{% if b.anonymized %}
|
||||
anonymized
|
||||
{% else %}
|
||||
{{ b.named_entitlement_holder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<wn-empty-state>No breach/compliance records published for this Phase.</wn-empty-state>
|
||||
{% endif %}
|
||||
|
||||
{% if session_rights in ("operator", "admin") %}
|
||||
<h3>Publish a breach/compliance event</h3>
|
||||
<p style="color:#888;font-size:0.85rem;">
|
||||
Anonymized by default (Phase + category only). Named disclosure requires
|
||||
an affirmative CUA authorization check (License V1C1 §7.4) — this form
|
||||
records that assertion; it does not verify the CUA text.
|
||||
</p>
|
||||
<form class="wn-form" method="post" action="/phases/{{ manifest.phase.id }}/breach">
|
||||
<wn-field-row label="Record id (unique)">
|
||||
<wn-input name="record_id" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Case id">
|
||||
<wn-input name="case_id" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Event type (alleged|cured|determined|terminated)">
|
||||
<wn-input name="event_type" value="alleged" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Category">
|
||||
<wn-input name="category" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Event at (ISO 8601)">
|
||||
<wn-input name="event_at" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Evidence reference (optional)">
|
||||
<wn-input name="evidence_reference"></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Anonymized (true/false)">
|
||||
<wn-input name="anonymized" value="true" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Named entitlement holder (only if anonymized=false)">
|
||||
<wn-input name="named_entitlement_holder"></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Named disclosure authorized under CUA (true if named)">
|
||||
<wn-input name="named_disclosure_authorized" value="false"></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-button type="submit" variant="secondary">Publish breach record</wn-button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue