Generate duplicate filenames from title and expose protocol rename
Some checks failed
Use-case regression gate / usecase-regressions (push) Failing after 3m18s
Some checks failed
Use-case regression gate / usecase-regressions (push) Failing after 3m18s
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a10e2d-a5ef-7e92-b03d-b1694e915680
This commit is contained in:
parent
afbaf11d39
commit
b13ba9c581
14 changed files with 192 additions and 22 deletions
2
SCOPE.md
2
SCOPE.md
|
|
@ -22,7 +22,7 @@ favorites, payments and real-time collaboration are later capabilities.
|
|||
| Identity | OIDC/PKCE login, logout, JIT local Member linked to user-engine; issuer+subject identity, session diagnostics and assurance handling. Development claims login is restricted to DEBUG with OIDC disabled. `apps/identity/`, identity tests. |
|
||||
| Registration entry | Configured NetKingdom registration redirect; this app does not issue passwords or send verification mail. CSOC-IN-0001 is closed with 2026-08-14 Case B evidence. |
|
||||
| Spaces | Tenant-filtered list/detail, member-facing create/update/archive, Title, Abstractor (`description`) and Visual metadata. `apps/spaces/services.py`, `tests/test_space_crud.py`. |
|
||||
| Pages | Markdown/frontmatter list, render, create/update/delete, independent copy and transfer with source/destination access checks. Index cannot be deleted or transferred. Separate clipboard copy exports the full title, italic subtitle, original Markdown and legacy Bubble `xxx` footer as plain text, plus sanitized rich HTML, for manual email distribution. `pageops.py`, PageOps tests. |
|
||||
| Pages | Markdown/frontmatter list, render, create/update/delete, independent copy and transfer with source/destination access checks. Duplicate filenames default to the new title; a visible Rename page action edits title/filename while preserving notes, Visual and source metadata and refusing overwrite. Index cannot be deleted or transferred. Separate clipboard copy exports the full title, italic subtitle, original Markdown and legacy Bubble `xxx` footer as plain text, plus sanitized rich HTML, for manual email distribution. `pageops.py`, PageOps tests. |
|
||||
| Content sources | Local `CONTENT_ROOT/spaces/<slug>/pages` and assets; optional Forgejo read/edit fallback, cache refresh and push webhook. Markdown is sanitized on render. Local writes do not automatically commit/push to Forgejo. |
|
||||
| Import | `import_content_tree` for one ADR-0004 tree; `import_content_corpus` for a preflighted JSON batch with explicit existing tenant+issuer+subject membership mappings. Dry-run, replacement opt-in, per-space staging/rollback and aggregate counts. Synthetic multi-space regression coverage; one historical private-space rehearsal. |
|
||||
| Delivery preparation | Docker/Gunicorn packaging, SQLite development / PostgreSQL URL support, migrations, health endpoint and deployment/runbook docs. Actual Helm values and cluster hosting belong to railiance-apps. |
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@
|
|||
| task | CSOC-WP-0008-T09 | progress | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
|
||||
| task | CSOC-WP-0008-T10 | done | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
|
||||
| task | CSOC-WP-0008-T11 | done | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
|
||||
| task | CSOC-WP-0008-T12 | progress | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
|
||||
| task | CSOC-WP-0009-T01 | done | — | workplans/CSOC-WP-0009-usecase-regression-infrastructure.md |
|
||||
| task | CSOC-WP-0009-T02 | done | — | workplans/CSOC-WP-0009-usecase-regression-infrastructure.md |
|
||||
| task | CSOC-WP-0009-T03 | done | — | workplans/CSOC-WP-0009-usecase-regression-infrastructure.md |
|
||||
|
|
|
|||
|
|
@ -143,8 +143,11 @@ def put_space_page(
|
|||
abstractor: str = "",
|
||||
visual: str = "",
|
||||
body: str = "",
|
||||
extra_meta: dict | None = None,
|
||||
) -> pageops.PageDoc:
|
||||
require_space_write(member, space)
|
||||
metadata = (pageops.read_page(space.slug, page_slug).meta
|
||||
if extra_meta is None else extra_meta)
|
||||
return pageops.write_page(
|
||||
space.slug,
|
||||
page_slug,
|
||||
|
|
@ -152,6 +155,8 @@ def put_space_page(
|
|||
abstractor=abstractor,
|
||||
visual=visual,
|
||||
body=body,
|
||||
extra_meta={k: v for k, v in metadata.items()
|
||||
if k not in {"title", "abstractor", "visual", "space"}},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -224,6 +224,7 @@ def page_edit(request: HttpRequest, slug: str, page_slug: str = "index") -> Http
|
|||
abstractor=abstractor,
|
||||
visual=visual,
|
||||
body=body,
|
||||
extra_meta=doc.meta,
|
||||
)
|
||||
pageops.delete_page(space.slug, page_slug)
|
||||
page_slug = target_slug
|
||||
|
|
@ -264,7 +265,7 @@ def page_edit(request: HttpRequest, slug: str, page_slug: str = "index") -> Http
|
|||
"space": space,
|
||||
"doc": doc,
|
||||
"is_new": not doc.exists,
|
||||
"form_title": "Edit page" if doc.exists else "New page",
|
||||
"form_title": "Edit or rename page" if doc.exists else "New page",
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -370,10 +371,11 @@ def page_copy(request: HttpRequest, slug: str, page_slug: str) -> HttpResponse:
|
|||
return HttpResponseForbidden("You have read-only access to this space.")
|
||||
all_spaces = [s for s in spaces_for_member(member) if can_write_space(member, s)]
|
||||
if request.method == "POST":
|
||||
dest_slug = (request.POST.get("dest_slug") or f"{page_slug}-copy").strip()
|
||||
dest_slug = (request.POST.get("dest_slug") or "").strip()
|
||||
dest_space_slug = (request.POST.get("dest_space") or space.slug).strip()
|
||||
dest_space = get_space_for_member(member, dest_space_slug)
|
||||
title = (request.POST.get("title") or "").strip() or None
|
||||
dest_slug = dest_slug or title or f"{page_slug}-copy"
|
||||
try:
|
||||
if dest_space is None:
|
||||
raise pageops.PageOpsError("Choose a destination space")
|
||||
|
|
@ -401,7 +403,9 @@ def page_copy(request: HttpRequest, slug: str, page_slug: str) -> HttpResponse:
|
|||
"space": space,
|
||||
"page_slug": page_slug,
|
||||
"all_spaces": all_spaces,
|
||||
"default_dest": f"{page_slug}-copy",
|
||||
"copy_title": request.POST.get("title", ""),
|
||||
"copy_slug": request.POST.get("dest_slug", ""),
|
||||
"copy_space": request.POST.get("dest_space") or space.slug,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@
|
|||
{% if can_write %}
|
||||
<a class="btn small" href="{% url 'spaces:page_edit' space.slug page.slug|default:'index' %}">Edit page</a>
|
||||
{% if page.slug and page.slug != 'index' %}
|
||||
<a class="btn secondary small" href="{% url 'spaces:page_edit' space.slug page.slug %}">Rename page</a>
|
||||
<a class="btn secondary small" href="{% url 'spaces:page_copy' space.slug page.slug %}">Duplicate page</a>
|
||||
<a class="btn secondary small" href="{% url 'spaces:page_transfer' space.slug page.slug %}">Transfer</a>
|
||||
<form method="post" action="{% url 'spaces:page_delete' space.slug page.slug %}"
|
||||
|
|
|
|||
|
|
@ -35,17 +35,20 @@
|
|||
<label for="id_dest_space">Destination space</label>
|
||||
<select id="id_dest_space" name="dest_space">
|
||||
{% for s in all_spaces %}
|
||||
<option value="{{ s.slug }}" {% if s.slug == space.slug %}selected{% endif %}>{{ s.title }} ({{ s.slug }})</option>
|
||||
<option value="{{ s.slug }}" {% if s.slug == copy_space %}selected{% endif %}>{{ s.title }} ({{ s.slug }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="id_dest_slug">New page slug</label>
|
||||
<input type="text" id="id_dest_slug" name="dest_slug" required maxlength="80"
|
||||
value="{{ default_dest }}"
|
||||
pattern="[a-z0-9]+(?:-[a-z0-9]+)*" autocomplete="off">
|
||||
|
||||
<label for="id_title">Title <span class="muted">(optional — defaults to “Original (copy)”)</span></label>
|
||||
<input type="text" id="id_title" name="title" maxlength="255" value="" autocomplete="off">
|
||||
<input type="text" id="id_title" name="title" maxlength="255" value="{{ copy_title }}" autocomplete="off">
|
||||
|
||||
<label for="id_dest_slug">New page slug <span class="muted">(optional filename)</span></label>
|
||||
<input type="text" id="id_dest_slug" name="dest_slug" maxlength="80"
|
||||
value="{{ copy_slug }}" placeholder="Generated from the new title"
|
||||
pattern="[a-z0-9]+(?:-[a-z0-9]+)*" autocomplete="off">
|
||||
<p class="muted">Leave the filename empty to generate it from your new title.
|
||||
Spaces become hyphens and letters become lowercase. Or choose a filename
|
||||
without .md. If both fields are empty, the original filename gets -copy.</p>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn">Create duplicate</button>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
<a href="{% url 'spaces:detail' space.slug %}{% if doc.slug and doc.slug != 'index' and not is_new %}?page={{ doc.slug }}{% endif %}">← {{ space.title }}</a>
|
||||
</p>
|
||||
<h1>{{ form_title }}</h1>
|
||||
<p class="muted">Markdown body is the source of truth. Title, Abstract, and Visual are stored in frontmatter.</p>
|
||||
<p class="muted">Change the title to rename the displayed protocol. Change the file name below to rename its Markdown file and page address.</p>
|
||||
|
||||
<form method="post" class="card" style="margin-top:1rem;">
|
||||
{% csrf_token %}
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
value="{{ doc.title }}" autocomplete="off"
|
||||
placeholder="Page title">
|
||||
|
||||
<label for="id_slug">Slug {% if doc.slug == 'index' %}<span class="muted">(space index — fixed)</span>{% endif %}</label>
|
||||
<label for="id_slug">File name (slug) {% if doc.slug == 'index' %}<span class="muted">(space index — fixed)</span>{% endif %}</label>
|
||||
{% if doc.slug == 'index' %}
|
||||
<input type="text" id="id_slug" name="slug" value="index" readonly maxlength="80">
|
||||
{% else %}
|
||||
|
|
@ -50,6 +50,10 @@
|
|||
{% if is_new %}required{% endif %}>
|
||||
{% endif %}
|
||||
|
||||
<p class="muted">Enter the file name without .md, using lowercase letters,
|
||||
numbers and hyphens. Renaming keeps the protocol notes and attachments.
|
||||
An existing file will never be overwritten. The space index file is fixed.</p>
|
||||
|
||||
<label for="id_abstractor">Abstract</label>
|
||||
<textarea id="id_abstractor" name="abstractor" rows="2"
|
||||
placeholder="Short abstract">{{ doc.abstractor }}</textarea>
|
||||
|
|
|
|||
|
|
@ -11,15 +11,21 @@
|
|||
"fixture": "tests/usecases/fixtures/reichelag-weekly-call.json",
|
||||
"contract": [
|
||||
"Duplicate the complete previous protocol without changing the original",
|
||||
"Generate the duplicate filename from its new title unless explicitly chosen; allow later title/file rename without loss or overwrite",
|
||||
"Edit and save the next agenda/protocol by hand and reopen it",
|
||||
"Copy only rendered content with headings and bullet structure for external email",
|
||||
"Copy the complete title, italic subtitle, original Markdown and legacy footer, with rich HTML for external email",
|
||||
"Fall back to selected manual text when clipboard writes are denied",
|
||||
"Readers may read and copy but not modify; outsiders cannot read",
|
||||
"The app does not send the email or need Bubble for these steps"
|
||||
],
|
||||
"tests": {
|
||||
"fast": ["tests/test_reichelag_workflow.py::test_full_protocol_duplicate_edit_and_clipboard_control"],
|
||||
"browser": ["tests/usecases/test_reichelag_browser.py::test_weekly_call_through_real_ui"]
|
||||
"fast": [
|
||||
"tests/test_reichelag_workflow.py::test_full_protocol_duplicate_edit_and_clipboard_control",
|
||||
"tests/test_reichelag_workflow.py::test_protocol_naming_rename_and_collision_safety"
|
||||
],
|
||||
"browser": [
|
||||
"tests/usecases/test_reichelag_browser.py::test_weekly_call_through_real_ui"
|
||||
]
|
||||
},
|
||||
"manual_review": [
|
||||
"Reconcile the latest actual ReichelAg protocol and its links/assets",
|
||||
|
|
|
|||
|
|
@ -30,8 +30,10 @@ no AI transcription/recording or automated mail delivery is assumed.
|
|||
## Equivalent rebuild journey
|
||||
|
||||
1. Sign in and open the migrated ReichelAg space; open the latest protocol.
|
||||
2. Choose **Duplicate page**, give the next entry a distinct slug/title, and
|
||||
create it in the same space. Full markdown, metadata and relative Visual
|
||||
2. Choose **Duplicate page**, enter the next entry’s title, and
|
||||
create it in the same space. Leaving the optional filename empty derives
|
||||
its slug/Markdown filename from the title; an explicit filename overrides it.
|
||||
**Rename page** lets you correct the title or filename later. Full markdown, metadata and relative Visual
|
||||
asset are copied; arbitrary body-linked assets remain a fidelity gap.
|
||||
3. Open **Edit page** and edit the copy's agenda/protocol by hand. Save and
|
||||
reopen it. The old protocol must remain unchanged.
|
||||
|
|
@ -105,3 +107,17 @@ character for character. Only comparison receipts are recorded here. Tests use
|
|||
synthetic notes and verify exact plain text in rich/plain/manual fallback modes,
|
||||
sanitized metadata in HTML, and the actual duplicate/edit/reload user journey.
|
||||
Full gate: `make check`, **97 passed**. Actual email-client review remains T09.
|
||||
|
||||
## Protocol naming review — 2026-10-07
|
||||
|
||||
Founder confirms editing and clipboard copy work. Duplication still retained its
|
||||
prefilled default file name when a new title was entered; rename controls were
|
||||
not discoverable. CSOC-WP-0008-T12 fixes title-derived duplicate naming and adds
|
||||
a visible Rename page action, reusing the editable title and file-name fields.
|
||||
Changing a title alone on an existing page keeps its address; changing the file
|
||||
name changes both the .md file and page address. Index filename stays fixed.
|
||||
Renaming preserves notes, subtitle, Visual and existing source metadata, rejects
|
||||
collisions and requires authoring access. Old references are not rewritten.
|
||||
Mandatory fast and browser regressions protect these behaviors. User acceptance
|
||||
of this naming correction remains pending; successful editing/copy is recorded
|
||||
without treating it as complete weekly-workflow or Bubble-retirement acceptance.
|
||||
|
|
|
|||
|
|
@ -53,11 +53,18 @@ try {
|
|||
await page.locator(`a[href="/app/spaces/${scenario.space_slug}/"]`).click();
|
||||
await page.getByRole('link', {name: scenario.previous_title, exact: true}).click();
|
||||
await page.getByRole('link', {name: 'Duplicate page', exact: true}).click();
|
||||
await page.getByLabel('New page slug').fill(scenario.next_slug);
|
||||
assert.equal(await page.getByLabel('New page slug').inputValue(), '');
|
||||
await page.locator('input[name=title]').fill(scenario.next_title);
|
||||
await page.getByRole('button', {name: 'Create duplicate'}).click();
|
||||
await page.waitForURL(`**?page=${scenario.next_slug}`);
|
||||
await page.waitForURL(`**?page=${scenario.generated_slug}`);
|
||||
assert.match(await page.locator('#page-content').innerText(), /Completed point/);
|
||||
await page.getByRole('link', {name: 'Rename page', exact: true}).click();
|
||||
assert.equal(await page.getByLabel('Title', {exact: true}).inputValue(), scenario.next_title);
|
||||
assert.equal(await page.getByLabel('File name (slug)').inputValue(), scenario.generated_slug);
|
||||
assert.equal(await page.getByLabel('Body (markdown)').inputValue(), scenario.previous_body);
|
||||
await page.getByLabel('File name (slug)').fill(scenario.next_slug);
|
||||
await page.getByRole('button', {name: 'Save page'}).click();
|
||||
await page.waitForURL(`**?page=${scenario.next_slug}`);
|
||||
await page.getByRole('link', {name: 'Edit page', exact: true}).click();
|
||||
assert.equal(await page.getByLabel('Body (markdown)').inputValue(), scenario.previous_body);
|
||||
await page.getByLabel('Body (markdown)').fill(scenario.next_body);
|
||||
|
|
@ -118,6 +125,7 @@ try {
|
|||
await page.goto(`${detail}?page=${scenario.next_slug}`);
|
||||
assert.match(await page.locator('#page-content').innerText(), /New notes/);
|
||||
assert.equal(await page.getByRole('link', {name: 'Edit page', exact: true}).count(), 0);
|
||||
assert.equal(await page.getByRole('link', {name: 'Rename page', exact: true}).count(), 0);
|
||||
assert.equal(await page.getByRole('button', {name: 'Copy content to clipboard'}).count(), 1);
|
||||
await page.getByRole('button', {name: 'Copy content to clipboard'}).click();
|
||||
await page.waitForFunction(() => !document.getElementById('copy-page-content').disabled);
|
||||
|
|
@ -125,7 +133,7 @@ try {
|
|||
const csrf = await page.locator('input[name=csrfmiddlewaretoken]').first().inputValue();
|
||||
const deniedPost = await page.request.post(`${detail}pages/${scenario.next_slug}/edit/`, {
|
||||
form: {csrfmiddlewaretoken: csrf, title: 'Unauthorized change',
|
||||
slug: scenario.next_slug, body: 'Unauthorized reader edit'},
|
||||
slug: 'unauthorized-rename', body: 'Unauthorized reader edit'},
|
||||
});
|
||||
assert.equal(deniedPost.status(), 403);
|
||||
assert.match(await deniedPost.text(), /read-only/);
|
||||
|
|
|
|||
|
|
@ -60,3 +60,86 @@ def test_full_protocol_duplicate_edit_and_clipboard_control(tmp_path, settings,
|
|||
reverse("spaces:detail", kwargs={"slug": space.slug}) + "?page=missing"
|
||||
)
|
||||
assert b"Copy content to clipboard" not in unavailable.content
|
||||
|
||||
|
||||
@pytest.mark.usecase("CSOC-UC-008")
|
||||
@pytest.mark.django_db
|
||||
def test_protocol_naming_rename_and_collision_safety(tmp_path, settings, client):
|
||||
settings.CONTENT_ROOT = str(tmp_path / "content")
|
||||
user = User.objects.create_user(username="protocol-writer")
|
||||
member = Member.objects.create(
|
||||
user=user, tenant_id="tenant:synthetic", user_engine_user_id="protocol-writer",
|
||||
issuer="https://issuer.test", subject="protocol-writer",
|
||||
)
|
||||
space = create_space(member, title="Synthetic calls", slug="synthetic-calls")
|
||||
pageops.assets_dir(space.slug).joinpath("cover.png").write_bytes(b"synthetic-image")
|
||||
source = pageops.write_page(
|
||||
space.slug, "previous-call", title="Previous call", abstractor="Board call",
|
||||
body="## Agenda\n\n- Open point\n", visual="assets/cover.png",
|
||||
extra_meta={"source_id": "synthetic-record", "source_modified_at": 12345},
|
||||
)
|
||||
source_bytes = Path(source.path).read_bytes()
|
||||
client.force_login(user)
|
||||
copy_url = reverse("spaces:page_copy", kwargs={
|
||||
"slug": space.slug, "page_slug": source.slug,
|
||||
})
|
||||
# Enter a title once; the optional filename is generated on the server.
|
||||
response = client.post(copy_url, {"title": "Next Weekly Call", "dest_slug": ""})
|
||||
assert response.status_code == 302
|
||||
assert response.url.endswith("?page=next-weekly-call")
|
||||
duplicate = pageops.read_page(space.slug, "next-weekly-call")
|
||||
assert duplicate.title == "Next Weekly Call" and duplicate.body == source.body
|
||||
visual_path = pageops.space_dir(space.slug) / duplicate.visual
|
||||
assert visual_path.read_bytes() == b"synthetic-image"
|
||||
edit_url = reverse("spaces:page_edit", kwargs={
|
||||
"slug": space.slug, "page_slug": duplicate.slug,
|
||||
})
|
||||
fields = {"title": "Renamed call", "slug": source.slug,
|
||||
"abstractor": duplicate.abstractor, "visual": duplicate.visual,
|
||||
"body": duplicate.body}
|
||||
response = client.post(edit_url, fields)
|
||||
assert response.status_code == 200
|
||||
assert b"already exists" in response.content
|
||||
assert Path(source.path).read_bytes() == source_bytes
|
||||
assert pageops.read_page(space.slug, duplicate.slug).title == duplicate.title
|
||||
# A distinct filename renames the Markdown file and the return address.
|
||||
fields["slug"] = "renamed-call"
|
||||
response = client.post(edit_url, fields)
|
||||
assert response.status_code == 302 and response.url.endswith("?page=renamed-call")
|
||||
assert not pageops.read_page(space.slug, duplicate.slug).exists
|
||||
renamed = pageops.read_page(space.slug, "renamed-call")
|
||||
assert renamed.title == "Renamed call"
|
||||
assert renamed.body == source.body and renamed.abstractor == source.abstractor
|
||||
assert renamed.visual == duplicate.visual and visual_path.is_file()
|
||||
assert renamed.meta["source_id"] == "synthetic-record"
|
||||
assert renamed.meta["source_modified_at"] == "12345"
|
||||
assert Path(source.path).read_bytes() == source_bytes
|
||||
# Changing title alone preserves the filename and imported source metadata.
|
||||
fields.update(title="Corrected title", slug="renamed-call")
|
||||
response = client.post(reverse("spaces:page_edit", kwargs={
|
||||
"slug": space.slug, "page_slug": "renamed-call",
|
||||
}), fields)
|
||||
assert response.status_code == 302
|
||||
renamed = pageops.read_page(space.slug, "renamed-call")
|
||||
assert renamed.title == "Corrected title" and renamed.meta["source_id"] == "synthetic-record"
|
||||
# Explicit copy filename overrides the title-derived name.
|
||||
response = client.post(copy_url, {"title": "Another call", "dest_slug": "custom-name"})
|
||||
assert response.status_code == 302 and response.url.endswith("?page=custom-name")
|
||||
# Duplicate collision leaves the existing file and form input intact.
|
||||
protected = Path(renamed.path).read_bytes()
|
||||
response = client.post(copy_url, {"title": "Renamed call", "dest_slug": ""})
|
||||
assert response.status_code == 200 and b"already exists" in response.content
|
||||
assert b'value="Renamed call"' in response.content
|
||||
assert Path(renamed.path).read_bytes() == protected
|
||||
# Renaming to index is refused; changing an index title cannot move its file.
|
||||
fields["slug"] = "index"
|
||||
response = client.post(reverse("spaces:page_edit", kwargs={
|
||||
"slug": space.slug, "page_slug": renamed.slug,
|
||||
}), fields)
|
||||
assert response.status_code == 200 and b"already exists" in response.content
|
||||
response = client.post(reverse("spaces:page_edit", kwargs={
|
||||
"slug": space.slug, "page_slug": "index",
|
||||
}), {"title": "Index title", "slug": "move-index", "body": "Index notes\n"})
|
||||
assert response.status_code == 302
|
||||
assert pageops.read_page(space.slug, "index").title == "Index title"
|
||||
assert not pageops.read_page(space.slug, "move-index").exists
|
||||
|
|
|
|||
|
|
@ -13,5 +13,6 @@
|
|||
"reader_subject": "usecase-reader",
|
||||
"outsider_subject": "usecase-outsider",
|
||||
"tenant": "tenant:synthetic-usecases",
|
||||
"abstractor": "Synthetic founder and board call"
|
||||
"abstractor": "Synthetic founder and board call",
|
||||
"generated_slug": "next-weekly-call"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@ def test_weekly_call_through_real_ui(tmp_path, settings, request):
|
|||
put_space_page(actors["writer"], space, scenario["previous_slug"],
|
||||
title=scenario["previous_title"], body=scenario["previous_body"],
|
||||
abstractor=scenario["abstractor"])
|
||||
pageops.write_page(space.slug, scenario["previous_slug"],
|
||||
title=scenario["previous_title"], body=scenario["previous_body"],
|
||||
abstractor=scenario["abstractor"],
|
||||
extra_meta={"source_id": "synthetic-record"})
|
||||
SpaceMembership.objects.create(space=space, member=actors["reader"], role="viewer")
|
||||
server = request.getfixturevalue("live_server")
|
||||
result = subprocess.run(
|
||||
|
|
@ -50,3 +54,8 @@ def test_weekly_call_through_real_ui(tmp_path, settings, request):
|
|||
assert (pageops.read_page(space.slug, scenario["previous_slug"]).body
|
||||
== scenario["previous_body"])
|
||||
assert pageops.read_page(space.slug, scenario["next_slug"]).body == scenario["next_body"]
|
||||
|
||||
assert not pageops.read_page(space.slug, scenario["generated_slug"]).exists
|
||||
assert not pageops.read_page(space.slug, "unauthorized-rename").exists
|
||||
assert (pageops.read_page(space.slug, scenario["next_slug"]).meta["source_id"]
|
||||
== "synthetic-record")
|
||||
|
|
|
|||
|
|
@ -488,3 +488,32 @@ owner access remains valid. Public health/static delivery200, actual deployed
|
|||
JavaScript equals tested source. make check97passed; rich/plain/denied fallback
|
||||
checks pass. T10 implementation done; T09 remains pending user email-client
|
||||
review. The snapshot helper's expected image pin follows this release.
|
||||
|
||||
## T12 — Protocol title and filename naming
|
||||
|
||||
```task
|
||||
id: CSOC-WP-0008-T12
|
||||
status: progress
|
||||
priority: high
|
||||
state_hub_task_id: "95d06724-0c8e-5e5c-8f50-0954c920e104"
|
||||
```
|
||||
|
||||
Founder review confirms editing and clipboard copy work. Duplication leaves the
|
||||
old default filename when only the title changes; rename controls are hard to
|
||||
discover. Derive duplicate filenames from the entered title when no explicit
|
||||
slug is chosen. Keep explicit filename override and reject collisions. Add a
|
||||
visible Rename page entry to existing title/filename controls. Preserve notes,
|
||||
subtitle, Visual and source metadata during rename and subsequent editing.
|
||||
Protect index filename and deny viewer/outsider writes. Extend UC-008's mandatory
|
||||
fast/browser regressions and deploy within the approved personal-app upgrade.
|
||||
User milestone: I name the next weekly call once while duplicating, then can
|
||||
correct its title and file name later without losing notes or changing the
|
||||
previous call. Existing manual email distribution remains unchanged.
|
||||
|
||||
T12 implementation validation: make check98passed, including mandatory
|
||||
real-browser title-only duplication, visible Rename page route, file/address
|
||||
rename, edit/reopen/clipboard and viewer-denied rename. Fast regression also
|
||||
protects explicit filename override, copy/rename collisions, fixed index,
|
||||
source independence and preserved metadata/assets. Targeted Ruff and diff check
|
||||
pass. Founder confirms edit and content-copy behavior; naming correction
|
||||
acceptance remains pending after deployment.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue