Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Press

Python web app (FastAPI + Jinja2 + Playwright for E2E).

## Testing

Run **only** the unit tests relevant to your changes:

```
uv run pytest -m "not e2e" tests/path/to/test_file.py
```

Do NOT run `just test`, `uv run pytest` with no arguments, or any E2E/playwright tests. E2E tests require a running server and are validated by CI after you push.
226 changes: 205 additions & 21 deletions app/routes/scrolls.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,35 @@ async def view_preview(request: Request, url_hash: str, db: AsyncSession = Depen
from app.auth.session import get_session

session_id = request.cookies.get("session_id")
upcoming_version = None
if session_id:
session = get_session(session_id)
session.pop("preview_form_data", None)
session.pop("current_preview_url_hash", None)

# Check if this is a new version of an existing scroll
revises_hash = session.get("revises_scroll")
if revises_hash:
from sqlalchemy import func

parent_result = await db.execute(
select(Scroll).where(
Scroll.url_hash == revises_hash,
Scroll.status == "published",
Scroll.user_id == current_user.id,
)
)
parent_scroll = parent_result.scalar_one_or_none()
if parent_scroll and parent_scroll.scroll_series_id:
max_version_result = await db.execute(
select(func.max(Scroll.version)).where(
Scroll.scroll_series_id == parent_scroll.scroll_series_id,
Scroll.status == "published",
)
)
max_version = max_version_result.scalar() or 1
upcoming_version = max_version + 1

# Update last_accessed_at
scroll.last_accessed_at = datetime.now(timezone.utc)
await db.commit()
Expand All @@ -147,7 +171,12 @@ async def view_preview(request: Request, url_hash: str, db: AsyncSession = Depen
return templates.TemplateResponse(
request,
"preview.html",
{"scroll": scroll, "current_user": current_user, "csrf_token": csrf_token},
{
"scroll": scroll,
"current_user": current_user,
"csrf_token": csrf_token,
"upcoming_version": upcoming_version,
},
)


Expand Down Expand Up @@ -191,11 +220,57 @@ async def confirm_preview(
if scroll.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized to publish this preview")

# Publish the scroll and assign year/slug
# Check if this is a new version of an existing scroll
from app.auth.session import get_session

session_id_for_revise = request.cookies.get("session_id")
revises_hash = None
if session_id_for_revise:
revise_session = get_session(session_id_for_revise)
revises_hash = revise_session.get("revises_scroll")

scroll.publish()
current_year = scroll.published_at.year
scroll.publication_year = current_year
scroll.slug = await generate_unique_slug(db, scroll.title, current_year)

if revises_hash:
# Publishing a new version -- inherit series metadata from parent
parent_result = await db.execute(
select(Scroll).where(
Scroll.url_hash == revises_hash,
Scroll.status == "published",
Scroll.user_id == current_user.id,
)
)
parent_scroll = parent_result.scalar_one_or_none()

if parent_scroll and parent_scroll.scroll_series_id:
# Find max version in this series
from sqlalchemy import func

max_version_result = await db.execute(
select(func.max(Scroll.version)).where(
Scroll.scroll_series_id == parent_scroll.scroll_series_id,
Scroll.status == "published",
)
)
max_version = max_version_result.scalar() or 1

scroll.version = max_version + 1
scroll.scroll_series_id = parent_scroll.scroll_series_id
scroll.slug = parent_scroll.slug
scroll.publication_year = parent_scroll.publication_year
else:
# Fallback to normal v1 flow if parent invalid
current_year = scroll.published_at.year
scroll.publication_year = current_year
scroll.slug = await generate_unique_slug(db, scroll.title, current_year)
scroll.scroll_series_id = uuid_module.uuid4()
else:
# Normal v1 flow
current_year = scroll.published_at.year
scroll.publication_year = current_year
scroll.slug = await generate_unique_slug(db, scroll.title, current_year)
scroll.scroll_series_id = uuid_module.uuid4()

await db.commit()

log_preview_event(
Expand All @@ -222,13 +297,12 @@ async def confirm_preview(
asyncio.create_task(mint_doi_safe(str(scroll.id)))

# Clear session data after publishing
from app.auth.session import get_session

session_id = request.cookies.get("session_id")
if session_id:
session = get_session(session_id)
session.pop("preview_form_data", None)
session.pop("current_preview_url_hash", None)
clear_session_id = request.cookies.get("session_id")
if clear_session_id:
clear_session = get_session(clear_session_id)
clear_session.pop("preview_form_data", None)
clear_session.pop("current_preview_url_hash", None)
clear_session.pop("revises_scroll", None)

# Redirect to published scroll via year/slug URL
return RedirectResponse(url=f"/{scroll.publication_year}/{scroll.slug}", status_code=303)
Expand Down Expand Up @@ -466,8 +540,11 @@ async def view_scroll(request: Request, identifier: str, db: AsyncSession = Depe
extra_data={"title": scroll.title, "url_hash": scroll.url_hash},
)

current_user = await get_current_user_from_session(request, db)
is_owner = current_user is not None and scroll.user_id == current_user.id

return templates.TemplateResponse(
request, "scroll.html", {"scroll": scroll, "base_url": get_base_url()}
request, "scroll.html", {"scroll": scroll, "base_url": get_base_url(), "is_owner": is_owner}
)


Expand Down Expand Up @@ -580,12 +657,16 @@ async def view_scroll_by_year_slug(
extra_data={"title": scroll.title, "url_hash": scroll.url_hash},
)

current_user = await get_current_user_from_session(request, db)
is_owner = current_user is not None and scroll.user_id == current_user.id

return templates.TemplateResponse(
request,
"scroll.html",
{
"scroll": scroll,
"base_url": get_base_url(),
"is_owner": is_owner,
"versions": versions,
"latest_version": latest_version,
"is_latest": True,
Expand Down Expand Up @@ -779,6 +860,8 @@ async def upload_page(request: Request, db: AsyncSession = Depends(get_db)):
scrolls. Unauthenticated users are redirected to login. Loads available
academic subjects for categorization.

Accepts optional ?revises={url_hash} to start a new version of an existing scroll.

"""
log_request(request)
current_user = await get_current_user_from_session(request, db)
Expand All @@ -793,6 +876,29 @@ async def upload_page(request: Request, db: AsyncSession = Depends(get_db)):
# Eagerly load user ID to avoid lazy-load issues
user_id = current_user.id

# Handle ?revises= query parameter for new versions
revises_hash = request.query_params.get("revises")
revising_scroll = None
if revises_hash:
from app.auth.session import get_session

result = await db.execute(
select(Scroll)
.options(selectinload(Scroll.subject))
.where(
Scroll.url_hash == revises_hash,
Scroll.status == "published",
Scroll.user_id == user_id,
)
)
parent = result.scalar_one_or_none()
if parent:
revising_scroll = parent
sid = request.cookies.get("session_id")
if sid:
sess = get_session(sid)
sess["revises_scroll"] = revises_hash

# Load available subjects
get_logger().info("Loading subjects for upload form...")
try:
Expand Down Expand Up @@ -869,6 +975,18 @@ async def upload_page(request: Request, db: AsyncSession = Depends(get_db)):
session.pop("current_preview_url_hash", None)
form_data = None

# Pre-fill from revising scroll if no existing form data
if not form_data and revising_scroll:
form_data = {
"title": revising_scroll.title,
"authors": revising_scroll.authors,
"subject_id": str(revising_scroll.subject_id),
"abstract": revising_scroll.abstract,
"keywords": ", ".join(revising_scroll.keywords) if revising_scroll.keywords else "",
"license": revising_scroll.license,
"original_filename": revising_scroll.original_filename,
}

# Re-query user to ensure it's attached to session (cleanup commit may have expired it)
from app.models.user import User

Expand All @@ -890,6 +1008,7 @@ async def upload_page(request: Request, db: AsyncSession = Depends(get_db)):
"form_data": form_data,
"current_drafts": current_drafts,
"session": session_data,
"revising_scroll": revising_scroll,
},
)

Expand Down Expand Up @@ -1070,13 +1189,14 @@ def _format_size(size_bytes):
"File must be UTF-8 encoded. Please save your HTML file with UTF-8 encoding."
)
else:
# No file uploaded - check if editing existing preview
# No file uploaded - check if editing existing preview or revising a scroll
from app.auth.session import get_session

session_id = request.cookies.get("session_id")
if session_id:
session = get_session(session_id)
current_preview_url_hash = session.get("current_preview_url_hash")
revises_hash_for_content = session.get("revises_scroll")

if current_preview_url_hash:
# Fetch existing preview scroll
Expand All @@ -1091,10 +1211,26 @@ def _format_size(size_bytes):

if existing_scroll:
html_content = existing_scroll.html_content
# Keep existing filename (will be used when creating/updating scroll)
original_filename = existing_scroll.original_filename
else:
raise ValueError("HTML file is required")
elif revises_hash_for_content:
# Revising an existing scroll without uploading a new file
# Use the parent scroll's HTML content (metadata-only update)
result = await db.execute(
select(Scroll).where(
Scroll.url_hash == revises_hash_for_content,
Scroll.status == "published",
Scroll.user_id == current_user.id,
)
)
parent_scroll = result.scalar_one_or_none()

if parent_scroll:
html_content = parent_scroll.html_content
original_filename = parent_scroll.original_filename
else:
raise ValueError("HTML file is required")
else:
raise ValueError("HTML file is required")
else:
Expand Down Expand Up @@ -1305,14 +1441,62 @@ def _format_size(size_bytes):
if session_id:
session = get_session(session_id)

# Check if this upload is revising an existing scroll (same series)
revises_series_id = None
if session_id:
revises_hash = session.get("revises_scroll")
if revises_hash:
parent_result = await db.execute(
select(Scroll.scroll_series_id).where(
Scroll.url_hash == revises_hash,
Scroll.status == "published",
Scroll.user_id == current_user.id,
)
)
parent_row = parent_result.first()
if parent_row:
revises_series_id = parent_row[0]

if existing:
if existing.status == "published":
scroll_link = f"{get_base_url()}/scroll/{existing.url_hash}"
raise ValueError(
f"This content has already been published. Each scroll must have unique content. "
f'<a href="{scroll_link}" target="_blank">View existing scroll</a>. '
f"If this is a mistake, please contact us at hello@aris.pub"
)
# Allow same content within the same series (metadata-only version update)
if revises_series_id and existing.scroll_series_id == revises_series_id:
get_logger().info(
f"Same content re-upload allowed for series {revises_series_id} "
f"(metadata-only version update)"
)
# Generate a unique url_hash for the new version by appending a nonce
import secrets

nonce = secrets.token_hex(4)
# Truncate url_hash so that "{hash}-{nonce}" fits VARCHAR(20)
max_url_len = 20 - len(nonce) - 1
new_url_hash = f"{url_hash[:max_url_len]}-{nonce}"
# Truncate content_hash so that "{hash}-{nonce}" fits VARCHAR(64)
max_base_len = 64 - len(nonce) - 1
truncated_hash = content_hash[:max_base_len]
scroll = Scroll(
user_id=current_user.id,
title=title,
authors=authors,
subject_id=subject.id,
abstract=abstract,
keywords=keyword_list,
html_content=html_content,
license=license,
content_hash=f"{truncated_hash}-{nonce}",
url_hash=new_url_hash,
status="preview",
original_filename=original_filename if original_filename else "document.html",
)
db.add(scroll)
else:
scroll_link = f"{get_base_url()}/scroll/{existing.url_hash}"
raise ValueError(
f"This content has already been published. Each scroll must have unique content. "
f'<a href="{scroll_link}" target="_blank">View existing scroll</a>. '
f"If this is a mistake, please contact us at hello@aris.pub"
)
elif existing.user_id == current_user.id and existing.status == "preview":
# User is resubmitting their own preview - update it instead of creating new
existing.title = title
Expand Down
4 changes: 2 additions & 2 deletions app/templates/preview.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
<div class="preview-banner-content">
<div class="preview-banner-left">
<span class="preview-banner-icon">⚠️</span>
<span class="preview-banner-text">PREVIEW MODE - Review your scroll below</span>
<span class="preview-banner-text">PREVIEW MODE{% if upcoming_version %} - This will be published as v{{ upcoming_version }}{% else %} - Review your scroll below{% endif %}</span>
</div>
<div class="preview-banner-actions">
<form method="POST" action="/preview/{{ scroll.url_hash }}/edit">
Expand Down Expand Up @@ -59,7 +59,7 @@ <h2 class="metadata-title">{{ scroll.title }}</h2>
{{ scroll.authors }} • {{ scroll.subject.name }}
</div>
<div class="metadata-info">
<span>Preview</span>
<span>Preview{% if upcoming_version %} (v{{ upcoming_version }}){% endif %}</span>
<span class="metadata-separator">•</span>
{% if scroll.license == 'cc-by-4.0' %}
<span>CC BY 4.0</span>
Expand Down
3 changes: 3 additions & 0 deletions app/templates/scroll.html
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,9 @@ <h2 id="modal-title">Scroll Details</h2>
</div>
<div class="modal-actions">
<a href="/" class="btn btn-secondary">Back to Scroll Press</a>
{% if is_owner %}
<a href="/upload?revises={{ scroll.url_hash }}" class="btn btn-secondary">New Version</a>
{% endif %}
<button class="btn btn-primary" id="download-btn">Download HTML</button>
</div>
</div>
Expand Down
Loading
Loading