docs(structure): govern .planning/ and register the ops runbook
- docs/STRUCTURE.md: add a .planning/ registry (vision/roadmap/history/breakdown
roles), broaden the roadmap role, add enforcement items 8-10.
- tools/check_requirements.py: scan .planning/ too - every .planning/**.md must be
registered, registered files must exist, requirement rows rejected there.
- .planning/: PROJECT.md, ROADMAP.md, MILESTONES.md, and milestones/v0.5/M3/
{roadmap.md,task_breakdown.md}. M3 breakdown holds only the tasks; decisions live
in the contracts.
- docs/DEPLOYMENT.md: add the ops runbook (registered as ops).
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
# Shipped milestones
|
||||||
|
|
||||||
|
Role: history. Historical log of shipped milestones. Cites requirement IDs; defines none.
|
||||||
|
|
||||||
|
Not yet written.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Project
|
||||||
|
|
||||||
|
Role: vision. Overall project vision and current state. Cites requirement IDs; defines none.
|
||||||
|
|
||||||
|
Not yet written.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
Role: roadmap. Phases across all milestones. Cites requirement IDs; defines none.
|
||||||
|
|
||||||
|
Not yet written.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# M3 — roadmap
|
||||||
|
|
||||||
|
Role: roadmap. Objectives for v0.5 / M3. Cites requirement IDs; defines none.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
A Photos-ready slab becomes submission-ready for WooCommerce, and a practice `SLAB-UAT-*` draft
|
||||||
|
matching the reviewed ready set exists in the store. Create works with inference off.
|
||||||
|
|
||||||
|
## Capabilities in scope
|
||||||
|
|
||||||
|
- Woo connection (WordPress application password) and taxonomy sync.
|
||||||
|
- Review to the full ready set, with per-slab pricing and override.
|
||||||
|
- Inference assist (Call 1 / Call 2), optional and never blocking create.
|
||||||
|
- Publish: create the Woo product; `SLAB-UAT-*` always a draft.
|
||||||
|
|
||||||
|
## Sources of truth
|
||||||
|
|
||||||
|
- Behavior and acceptance: `docs/capabilities/CREATE.md`.
|
||||||
|
- Scope and the M3 ID subset: `docs/MILESTONES.md`.
|
||||||
|
- Interfaces: `docs/contracts/` (`API.md`, `WOO-API.md`, `DATA-MODEL.md`, `PROMPTS.md`, `UX.md`,
|
||||||
|
`CONTENT-WOO.md`, `TEST-SPEC.md`).
|
||||||
|
|
||||||
|
## Exit
|
||||||
|
|
||||||
|
Per `docs/MILESTONES.md` and `docs/capabilities/CREATE.md` (QUALITY-110).
|
||||||
|
|
||||||
|
## Note
|
||||||
|
|
||||||
|
M3 implementation has not started. Decisions already ruled live in the contracts, not here.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# M3 — task breakdown
|
||||||
|
|
||||||
|
Role: breakdown. Ordered task list for v0.5 / M3. Cites requirement IDs; defines none.
|
||||||
|
|
||||||
|
Objectives: `roadmap.md` in this folder. Behavior and acceptance: `docs/capabilities/CREATE.md`.
|
||||||
|
Scope and IDs: `docs/MILESTONES.md`. Decisions live in `docs/contracts/`, not here.
|
||||||
|
|
||||||
|
TDD every slice: the failing test precedes its implementation; never parallelize RED and GREEN of
|
||||||
|
the same scenario.
|
||||||
|
|
||||||
|
| Slice | Delivers | M3 IDs |
|
||||||
|
|---|---|---|
|
||||||
|
| S0 | factory, config/startup guards, health/meta, JSON logs, Dockerfile | APP-30, APP-45, APP-55, APP-65, SETTINGS-10, SETTINGS-25, OPS-25 |
|
||||||
|
| S1 | AES-GCM crypto + Settings (masked, write-only secrets) | SETTINGS-5/15/20/25/30/35/75/85/90, QUALITY-125 |
|
||||||
|
| S2 | slabs CRUD, optimistic concurrency, soft delete | APP-5/15/20/35/40, REVIEW-45 |
|
||||||
|
| S3 | photos (multipart → BLOBs, original/processed, 410 after purge) | CAPTURE-95/105/130, PUBLISH-40 |
|
||||||
|
| S4 | Woo client (application-password Basic) + taxonomy sync + test-woo | METADATA-5/10/15/20/30, SETTINGS-40/50/51/55/60/80, QUALITY-35 |
|
||||||
|
| S5 | pricing, deterministic templates, server-derived ready | REVIEW-5/10/15/20/25/30/35/40/50, METADATA-70/80/85/90, QUALITY-90/95 |
|
||||||
|
| S6 | publish (sequence, UAT draft, duplicate/stale/partial failure) | PUBLISH-5/10/15/20/25/30/31/35/36/37/38/40/45/50/55/60 |
|
||||||
|
| S7 | inference last (Call 1 / Call 2, prompt revisions, template fallback) | METADATA-35/40/45/50/55/60/65/66/75/95/100/105/110/115, SETTINGS-45/70 |
|
||||||
|
| Cross | frontend API client; Review / Settings / Publish screens | UI for the slices above |
|
||||||
|
| Exit | inference-off suite, Woo-mock integration, Compose UI E2E, real UAT draft | QUALITY-20/35/50/105/110/120 |
|
||||||
|
|
||||||
|
## Frontend layout
|
||||||
|
|
||||||
|
New code stays outside the Capture union: `frontend/src/api/*`, `frontend/src/create/*`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Slice bar: `docs/contracts/TEST-SPEC.md`. Docs gate: `python tools/check_requirements.py`.
|
||||||
|
Exit bar: `docs/MILESTONES.md` and `docs/capabilities/CREATE.md`.
|
||||||
|
|
||||||
|
## Not yet validated
|
||||||
|
|
||||||
|
- Long-horizon multi-step agentic loops (more than one or two tool round-trips).
|
||||||
|
- M3 implementation has not started.
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Deployment
|
||||||
|
|
||||||
|
Role: ops. This is the canonical runbook for getting a build from a working tree to a live
|
||||||
|
dev/test host. `deploy/INVENTORY.md` is the only source for what runs where (hosts, ports,
|
||||||
|
container names). `docs/UAT-PRACTICE.md` is the human walk once the app is up; this doc is what
|
||||||
|
makes it up.
|
||||||
|
|
||||||
|
## 1. Topology
|
||||||
|
|
||||||
|
| Node | Role | Reach |
|
||||||
|
|---|---|---|
|
||||||
|
| Ty dev box (Windows) | code, git push to Gitea. No Docker CLI. | LAN + Tailscale |
|
||||||
|
| Gitea (`gitea.vps1.afterthedemo.com`) | source of truth for the repo. SSH port closed; HTTPS only. | Tailscale |
|
||||||
|
| `ubudual5060` (UAT host, LAN `192.168.1.202`, Tailscale `100.64.0.9`) | builds and runs Docker images for UAT. | LAN + Tailscale |
|
||||||
|
|
||||||
|
The dev box never builds images. The UAT host pulls source from Gitea and builds locally.
|
||||||
|
Do not install Docker on the dev box to work around this; the pull-build path is the plan.
|
||||||
|
|
||||||
|
## 2. Access (already provisioned; do not re-do)
|
||||||
|
|
||||||
|
- Dev box → UAT host: `ssh ubudual5060` (key `~/.ssh/ubudual5060`, wired in `~/.ssh/config`).
|
||||||
|
Unattended, passphrase-less.
|
||||||
|
- UAT host → Gitea: HTTPS with a read-only token named `5060-pull`, stored in
|
||||||
|
`~/.git-credentials` on the host. The host clones/pulls with the plain HTTPS URL;
|
||||||
|
no key material in the repo.
|
||||||
|
- Deploy checkout on the UAT host: `~/slab-deploy` (branch `main`).
|
||||||
|
The pre-existing `~/SlabUploader` on that host is a stale work clone — do not use it.
|
||||||
|
|
||||||
|
If access is broken, fix it here before touching compose. Do not commit credentials to the repo.
|
||||||
|
|
||||||
|
## 3. Build and deploy (M3)
|
||||||
|
|
||||||
|
Precondition: the M3 scaffold exists on the branch being deployed — `frontend/Dockerfile`,
|
||||||
|
`backend/Dockerfile`, and `deploy/.env` on the host. `main` currently
|
||||||
|
has `frontend/Dockerfile` only; `backend/` lands with M3 implementation.
|
||||||
|
Prompts live in SQLite (`docs/contracts/PROMPTS.md`); no prompt files ship with the image.
|
||||||
|
|
||||||
|
1. Push the branch to Gitea from the dev box.
|
||||||
|
2. On the UAT host:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd ~/slab-deploy
|
||||||
|
git fetch origin && git checkout <branch> && git pull
|
||||||
|
cp deploy/.env.example deploy/.env && chmod 600 deploy/.env # first time only
|
||||||
|
docker compose -f deploy/docker-compose.yml --env-file deploy/.env build
|
||||||
|
docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Verify with the smoke checks in §5.
|
||||||
|
|
||||||
|
### `.env` values
|
||||||
|
|
||||||
|
Generate on the UAT host, never in the repo:
|
||||||
|
|
||||||
|
- `SLAB_AES_KEY` — `openssl rand -base64 32`
|
||||||
|
- `WOO_BASE_URL` — the store, `https://` only. FastAPI refuses `http://` at startup.
|
||||||
|
- `APP_HOSTNAME` — from `deploy/INVENTORY.md`.
|
||||||
|
- `FRONTEND_PORT` / `FASTAPI_PORT` — see the port override below.
|
||||||
|
|
||||||
|
Woo Application Password and inference keys are not in `.env`; they are entered in the app's
|
||||||
|
Settings and stored AES-GCM encrypted in the app's SQLite.
|
||||||
|
|
||||||
|
### Port binding for the dev-test environment
|
||||||
|
|
||||||
|
`deploy/docker-compose.yml` binds `127.0.0.1` only (Caddy is the TLS edge in the full stack).
|
||||||
|
The dev-test environment is reached directly on a port from the project block
|
||||||
|
`10880`–`10889` (per `deploy/INVENTORY.md`) and **must be reachable from any machine on
|
||||||
|
`192.168.1.0/24`**, so the deploy must override the port bindings to `0.0.0.0`
|
||||||
|
(compose override file or `ports:` edit scoped to this deploy). Never bind `:10880`
|
||||||
|
(Ty's local Node/SvelteKit) or Ty's `:18080`.
|
||||||
|
|
||||||
|
M3 takes over port `10881` from the M2 container (`slabuploader-m2-overnight`),
|
||||||
|
per the replace-prior-container rule in `deploy/INVENTORY.md`.
|
||||||
|
Remove the M2 container when the M3 one is healthy.
|
||||||
|
|
||||||
|
## 4. Local loop (no UAT host needed)
|
||||||
|
|
||||||
|
- Frontend: `cd frontend && npm install && npm run dev` (Vite).
|
||||||
|
- Backend: `uvicorn` from `backend/` once the M3 scaffold lands; needs the same env vars as §3.
|
||||||
|
- Tests: frontend `npm test` (Playwright config in `frontend/`); backend tests land with M3.
|
||||||
|
|
||||||
|
Use the local loop for iteration; use §3 for anything Ty must see on a phone.
|
||||||
|
|
||||||
|
## 5. Smoke checks (after every deploy)
|
||||||
|
|
||||||
|
Run from the UAT host, then from a phone on the same network (LAN or Tailscale):
|
||||||
|
|
||||||
|
1. `curl -fsS http://127.0.0.1:<frontend-port>/api/health` on the host returns ok.
|
||||||
|
2. Phone browser opens the inventory URL (`deploy/INVENTORY.md`) and the app loads.
|
||||||
|
3. `docker compose -f deploy/docker-compose.yml --env-file deploy/.env ps` shows both services
|
||||||
|
up; `docker logs` for `fastapi` shows no startup refusal (WOO_BASE_URL is `https://`).
|
||||||
|
4. `test-woo` in app Settings reports ok (store reachable, taxonomy synced).
|
||||||
|
|
||||||
|
If a check fails, the deploy is not live. Roll back per §6 before debugging on the live port.
|
||||||
|
|
||||||
|
## 6. Rollback
|
||||||
|
|
||||||
|
1. `docker compose -f deploy/docker-compose.yml --env-file deploy/.env down` on the UAT host.
|
||||||
|
2. Check out the last known-good ref in `~/slab-deploy` and re-run §3 steps 2–3.
|
||||||
|
3. Re-run §5. Volumes `slab_db` and `slab_images` persist across rollback; do not remove them
|
||||||
|
unless the data itself is the problem.
|
||||||
|
|
||||||
|
## Where each rule lives
|
||||||
|
|
||||||
|
| Rule | Source |
|
||||||
|
|---|---|
|
||||||
|
| Hosts, ports, container names | `deploy/INVENTORY.md` |
|
||||||
|
| Compose shape, env contract | `deploy/docker-compose.yml`, `deploy/.env.example` |
|
||||||
|
| UAT walk once the app is up | `docs/UAT-PRACTICE.md` |
|
||||||
|
| M3 scope and exit | `docs/MILESTONES.md`, `docs/REQUIREMENTS.md` |
|
||||||
|
| Woo auth model | `AGENTS.md`, `docs/contracts/CONTENT-WOO.md` |
|
||||||
+23
-2
@@ -1,7 +1,7 @@
|
|||||||
# Structure contract
|
# Structure contract
|
||||||
|
|
||||||
This file defines the shape of the `docs/` system and how requirements enter it.
|
This file defines the shape of the `docs/` system and the `.planning/` tree, and how requirements
|
||||||
`tools/check_requirements.py` enforces it.
|
enter it. `tools/check_requirements.py` enforces it.
|
||||||
|
|
||||||
If the repo and this file disagree, fix one deliberately. Do not let them drift.
|
If the repo and this file disagree, fix one deliberately. Do not let them drift.
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ Every Markdown file under `docs/` is registered here. `docs/archive/**` and `doc
|
|||||||
| `docs/capabilities/CREATE.md` | capability |
|
| `docs/capabilities/CREATE.md` | capability |
|
||||||
| `docs/contracts/CONTENT-WOO.md` | contract |
|
| `docs/contracts/CONTENT-WOO.md` | contract |
|
||||||
| `docs/UAT-PRACTICE.md` | practice |
|
| `docs/UAT-PRACTICE.md` | practice |
|
||||||
|
| `docs/DEPLOYMENT.md` | ops |
|
||||||
| `docs/capabilities/README.md` | index |
|
| `docs/capabilities/README.md` | index |
|
||||||
| `docs/contracts/README.md` | index |
|
| `docs/contracts/README.md` | index |
|
||||||
| `docs/contracts/WOO-API.md` | contract |
|
| `docs/contracts/WOO-API.md` | contract |
|
||||||
@@ -30,6 +31,18 @@ Every Markdown file under `docs/` is registered here. `docs/archive/**` and `doc
|
|||||||
| `docs/contracts/UX.md` | contract |
|
| `docs/contracts/UX.md` | contract |
|
||||||
| `docs/contracts/TEST-SPEC.md` | contract |
|
| `docs/contracts/TEST-SPEC.md` | contract |
|
||||||
|
|
||||||
|
Every Markdown file under `.planning/` is registered here. `.planning/` holds planning artifacts
|
||||||
|
only and is **not** a second requirements home: `docs/REQUIREMENTS.md` and `docs/MILESTONES.md`
|
||||||
|
remain the point of record, and `.planning/` files cite requirement IDs without defining them.
|
||||||
|
|
||||||
|
| Path | Role |
|
||||||
|
|---|---|
|
||||||
|
| `.planning/PROJECT.md` | vision |
|
||||||
|
| `.planning/ROADMAP.md` | roadmap |
|
||||||
|
| `.planning/MILESTONES.md` | history |
|
||||||
|
| `.planning/milestones/v0.5/M3/roadmap.md` | roadmap |
|
||||||
|
| `.planning/milestones/v0.5/M3/task_breakdown.md` | breakdown |
|
||||||
|
|
||||||
Roles:
|
Roles:
|
||||||
|
|
||||||
- **index** — navigation only.
|
- **index** — navigation only.
|
||||||
@@ -40,6 +53,11 @@ Roles:
|
|||||||
- **capability** — behavior and acceptance for one capability; cites IDs, does not define them.
|
- **capability** — behavior and acceptance for one capability; cites IDs, does not define them.
|
||||||
- **contract** — cross-cutting interface detail; cites IDs, does not define them.
|
- **contract** — cross-cutting interface detail; cites IDs, does not define them.
|
||||||
- **practice** — human walkthrough.
|
- **practice** — human walkthrough.
|
||||||
|
- **ops** — runbook for hosts, builds, and release; cites inventory and contracts, defines no requirements.
|
||||||
|
- **vision** — overall project vision and current state; cites IDs, defines none.
|
||||||
|
- **roadmap** — version or milestone objectives; cites IDs, defines none.
|
||||||
|
- **breakdown** — ordered task list for a version or milestone; cites IDs, defines none.
|
||||||
|
- **history** — historical log of shipped milestones; cites IDs, defines none.
|
||||||
|
|
||||||
## ID namespaces
|
## ID namespaces
|
||||||
|
|
||||||
@@ -90,6 +108,9 @@ Nothing outside `REQUIREMENTS.md` may define a requirement. Capability and contr
|
|||||||
5. `Test` references resolve: a path given for a `shipped` requirement exists; an ID given resolves to a defined requirement; `—` is allowed.
|
5. `Test` references resolve: a path given for a `shipped` requirement exists; an ID given resolves to a defined requirement; `—` is allowed.
|
||||||
6. Requirement rows appear only in `REQUIREMENTS.md`.
|
6. Requirement rows appear only in `REQUIREMENTS.md`.
|
||||||
7. Every ID listed in `MILESTONES.md` exists in `REQUIREMENTS.md`.
|
7. Every ID listed in `MILESTONES.md` exists in `REQUIREMENTS.md`.
|
||||||
|
8. Every `.planning/` Markdown file is registered above.
|
||||||
|
9. Every registered `.planning/` file exists on disk.
|
||||||
|
10. Requirement rows appear nowhere outside `REQUIREMENTS.md`, including `.planning/`.
|
||||||
|
|
||||||
## Phase 2 migration (complete)
|
## Phase 2 migration (complete)
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,20 @@ from pathlib import Path
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
DOCS = ROOT / "docs"
|
DOCS = ROOT / "docs"
|
||||||
|
PLANNING = ROOT / ".planning"
|
||||||
STRUCTURE = DOCS / "STRUCTURE.md"
|
STRUCTURE = DOCS / "STRUCTURE.md"
|
||||||
REQUIREMENTS = DOCS / "REQUIREMENTS.md"
|
REQUIREMENTS = DOCS / "REQUIREMENTS.md"
|
||||||
MILESTONES = DOCS / "MILESTONES.md"
|
MILESTONES = DOCS / "MILESTONES.md"
|
||||||
|
|
||||||
EXEMPT_PREFIXES = (("docs", "archive"), ("docs", "_templates"))
|
EXEMPT_PREFIXES = (("docs", "archive"), ("docs", "_templates"))
|
||||||
|
|
||||||
ROLES = "index|intent|meta|requirements|scope|capability|contract|practice"
|
ROLES = (
|
||||||
REGISTRY_ROW = re.compile(rf"^\|\s*`(docs/[^`]+\.md)`\s*\|\s*({ROLES})\s*\|")
|
"index|intent|meta|requirements|scope|capability|contract|practice|ops"
|
||||||
|
"|vision|roadmap|history|breakdown"
|
||||||
|
)
|
||||||
|
REGISTRY_ROW = re.compile(
|
||||||
|
rf"^\|\s*`((?:docs|\.planning)/[^`]+\.md)`\s*\|\s*({ROLES})\s*\|"
|
||||||
|
)
|
||||||
NAMESPACE_ROW = re.compile(r"^\|\s*`([A-Z]+)`\s*\|")
|
NAMESPACE_ROW = re.compile(r"^\|\s*`([A-Z]+)`\s*\|")
|
||||||
REQUIREMENT_ROW = re.compile(
|
REQUIREMENT_ROW = re.compile(
|
||||||
r"^\|\s*([A-Z]+)-(\d+)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*$"
|
r"^\|\s*([A-Z]+)-(\d+)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*$"
|
||||||
@@ -58,13 +64,19 @@ def main() -> int:
|
|||||||
if not namespaces:
|
if not namespaces:
|
||||||
errors.append("STRUCTURE.md declares no ID namespaces")
|
errors.append("STRUCTURE.md declares no ID namespaces")
|
||||||
|
|
||||||
# 1. every non-exempt doc is registered
|
planning_files = sorted(PLANNING.rglob("*.md")) if PLANNING.is_dir() else []
|
||||||
|
|
||||||
|
# 1. every non-exempt doc is registered; every planning file is registered
|
||||||
for path in sorted(DOCS.rglob("*.md")):
|
for path in sorted(DOCS.rglob("*.md")):
|
||||||
rel = path.relative_to(ROOT).as_posix()
|
rel = path.relative_to(ROOT).as_posix()
|
||||||
if path.relative_to(ROOT).parts[:2] in EXEMPT_PREFIXES:
|
if path.relative_to(ROOT).parts[:2] in EXEMPT_PREFIXES:
|
||||||
continue
|
continue
|
||||||
if rel not in registered:
|
if rel not in registered:
|
||||||
errors.append(f"unregistered doc: {rel}")
|
errors.append(f"unregistered doc: {rel}")
|
||||||
|
for path in planning_files:
|
||||||
|
rel = path.relative_to(ROOT).as_posix()
|
||||||
|
if rel not in registered:
|
||||||
|
errors.append(f"unregistered planning file: {rel}")
|
||||||
|
|
||||||
# 2. every registered doc exists
|
# 2. every registered doc exists
|
||||||
for rel in registered:
|
for rel in registered:
|
||||||
@@ -110,7 +122,7 @@ def main() -> int:
|
|||||||
errors.append(f"{loc} {rid} invalid Test ref '{ref}' (use a path, an ID, or —)")
|
errors.append(f"{loc} {rid} invalid Test ref '{ref}' (use a path, an ID, or —)")
|
||||||
|
|
||||||
# 6. requirement rows appear only in REQUIREMENTS.md
|
# 6. requirement rows appear only in REQUIREMENTS.md
|
||||||
for path in sorted(DOCS.rglob("*.md")):
|
for path in sorted(DOCS.rglob("*.md")) + planning_files:
|
||||||
if path.relative_to(ROOT).parts[:2] in EXEMPT_PREFIXES or path in {
|
if path.relative_to(ROOT).parts[:2] in EXEMPT_PREFIXES or path in {
|
||||||
REQUIREMENTS,
|
REQUIREMENTS,
|
||||||
STRUCTURE,
|
STRUCTURE,
|
||||||
@@ -148,8 +160,10 @@ def main() -> int:
|
|||||||
print(f" - {error}")
|
print(f" - {error}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
planning_registered = sum(1 for rel in registered if rel.startswith(".planning/"))
|
||||||
print(
|
print(
|
||||||
f"PASS check_requirements: {len(registered)} docs, "
|
f"PASS check_requirements: {len(registered) - planning_registered} docs, "
|
||||||
|
f"{planning_registered} planning, "
|
||||||
f"{len(namespaces)} namespaces, {len(ids)} requirements"
|
f"{len(namespaces)} namespaces, {len(ids)} requirements"
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
Reference in New Issue
Block a user