Implemented. Sign in, then open Experience or Skills to:
- Add, edit, and delete experience entries and project links. - Manage skill categories and individual skill labels. - Edit the skills introduction and “How I work” text. Changes save directly to Supabase—no code edits or Git push needed. Existing content is preserved.
This commit is contained in:
@@ -4,7 +4,7 @@ import re
|
||||
from functools import lru_cache
|
||||
|
||||
import dropbox
|
||||
from dropbox.exceptions import AuthError, DropboxException
|
||||
from dropbox.exceptions import ApiError, AuthError, DropboxException
|
||||
from fastapi import HTTPException
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
@@ -58,3 +58,17 @@ def download(file_id: str, limit: int) -> bytes:
|
||||
raise HTTPException(503, "Dropbox needs to be reconnected by the site owner.") from exc
|
||||
except (DropboxException, RequestException) as exc:
|
||||
raise HTTPException(502, "This file is temporarily unavailable from Dropbox. Please retry.") from exc
|
||||
|
||||
|
||||
def delete(file_id: str) -> None:
|
||||
try:
|
||||
get_dropbox().files_delete_v2(file_id)
|
||||
except AuthError as exc:
|
||||
raise HTTPException(503, "Dropbox needs to be reconnected by the site owner.") from exc
|
||||
except ApiError as exc:
|
||||
# A retry may follow a successful Dropbox delete and failed database cleanup.
|
||||
if exc.error.is_path_lookup() and exc.error.get_path_lookup().is_not_found():
|
||||
return
|
||||
raise HTTPException(502, "Dropbox could not delete this file. Deletion will be retried.") from exc
|
||||
except (DropboxException, RequestException) as exc:
|
||||
raise HTTPException(502, "Dropbox could not delete this file. Deletion will be retried.") from exc
|
||||
|
||||
+34
-12
@@ -27,7 +27,7 @@ from pydantic import BaseModel, EmailStr, Field
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from httpx import TransportError
|
||||
from supabase import Client, ClientOptions, PostgrestAPIError, create_client
|
||||
from backend import dropbox_storage
|
||||
from backend import dropbox_storage, media_cleanup, portfolio_content
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
@@ -56,6 +56,7 @@ app = FastAPI(
|
||||
title="Alex Herlan Portfolio API",
|
||||
description="Supabase-ready content and contact delivery for Alex Herlan's portfolio.",
|
||||
version="1.1.0",
|
||||
lifespan=media_cleanup.lifespan,
|
||||
)
|
||||
|
||||
origins = [
|
||||
@@ -94,6 +95,7 @@ class NewPostPayload(BaseModel):
|
||||
content: dict[str, Any]
|
||||
banner: ArticleMedia | None = None
|
||||
attachments: list[ArticleMedia] = Field(default_factory=list, max_length=10)
|
||||
discarded_uploads: list[str] = Field(default_factory=list, max_length=100, exclude=True)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
@@ -260,14 +262,7 @@ def profile() -> dict[str, Any]:
|
||||
return read_data("profile.json")
|
||||
|
||||
|
||||
@app.get("/api/experience")
|
||||
def experience() -> list[dict[str, Any]]:
|
||||
return read_data("experience.json")
|
||||
|
||||
|
||||
@app.get("/api/skills")
|
||||
def skills() -> dict[str, Any]:
|
||||
return read_data("skills.json")
|
||||
portfolio_content.register_routes(app, require_admin)
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
@@ -425,7 +420,15 @@ def validated_article(payload: NewPostPayload, slug: str) -> dict[str, Any]:
|
||||
if media is None:
|
||||
return None
|
||||
stored = uploaded_media(media["url"].rsplit("/", 1)[-1])
|
||||
if ArticleMedia.model_validate(stored).model_dump() != media:
|
||||
canonical = ArticleMedia.model_validate(stored).model_dump()
|
||||
# An editor can remain open while its local files are migrated to Dropbox.
|
||||
# The upload URL and file details are unchanged; resolve the new storage
|
||||
# fields from the server without trusting a client-supplied Dropbox ID.
|
||||
if (media["storage"] is None and media["dropbox_file_id"] is None
|
||||
and canonical["storage"] == "dropbox" and canonical["dropbox_file_id"]):
|
||||
media = {**media, "storage": canonical["storage"],
|
||||
"dropbox_file_id": canonical["dropbox_file_id"]}
|
||||
if canonical != media:
|
||||
raise HTTPException(status_code=422, detail="An attachment is invalid. Please upload it again.")
|
||||
return stored
|
||||
article["banner"] = canonical_media(article["banner"])
|
||||
@@ -451,11 +454,13 @@ def save_local_posts(records: list[dict[str, Any]]) -> None:
|
||||
|
||||
|
||||
@app.post("/api/posts", status_code=status.HTTP_201_CREATED)
|
||||
@media_cleanup.serialized
|
||||
def create_post(
|
||||
payload: NewPostPayload, _: None = Depends(require_admin)
|
||||
) -> dict[str, Any]:
|
||||
slug = post_slug(payload.title)
|
||||
article = validated_article(payload, slug)
|
||||
media_cleanup.enqueue_urls(payload.discarded_uploads)
|
||||
|
||||
client = get_supabase()
|
||||
if client is not None:
|
||||
@@ -482,6 +487,7 @@ def create_post(
|
||||
) from exc
|
||||
if not created.data:
|
||||
raise HTTPException(status_code=502, detail="Supabase did not return the new article.")
|
||||
media_cleanup.cleanup_pending()
|
||||
return created.data[0]
|
||||
|
||||
with POSTS_LOCK:
|
||||
@@ -494,10 +500,12 @@ def create_post(
|
||||
current_posts.append(article)
|
||||
save_local_posts(current_posts)
|
||||
|
||||
media_cleanup.cleanup_pending()
|
||||
return article
|
||||
|
||||
|
||||
@app.put("/api/posts/{slug}")
|
||||
@media_cleanup.serialized
|
||||
def update_post(
|
||||
slug: str, payload: NewPostPayload, _: None = Depends(require_admin)
|
||||
) -> dict[str, Any]:
|
||||
@@ -505,6 +513,10 @@ def update_post(
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
# Keep published URLs stable when an admin changes the title.
|
||||
article = validated_article(payload, slug)
|
||||
previous = post_by_slug(slug)
|
||||
media_cleanup.enqueue_urls(payload.discarded_uploads)
|
||||
retained = {m["url"] for m in media_cleanup.media_items(article)}
|
||||
media_cleanup.enqueue([m for m in media_cleanup.media_items(previous) if m["url"] not in retained])
|
||||
client = get_supabase()
|
||||
if client is not None:
|
||||
try:
|
||||
@@ -513,6 +525,7 @@ def update_post(
|
||||
raise HTTPException(status_code=502, detail="The article could not be updated. Please try again.") from exc
|
||||
if not response.data:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
media_cleanup.cleanup_pending()
|
||||
return response.data[0]
|
||||
|
||||
with POSTS_LOCK:
|
||||
@@ -521,14 +534,21 @@ def update_post(
|
||||
if existing["slug"] == slug:
|
||||
records[index] = {**existing, **article}
|
||||
save_local_posts(records)
|
||||
return records[index]
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
saved = records[index]
|
||||
break
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
media_cleanup.cleanup_pending()
|
||||
return saved
|
||||
|
||||
|
||||
@app.delete("/api/posts/{slug}")
|
||||
@media_cleanup.serialized
|
||||
def delete_post(slug: str, _: None = Depends(require_admin)) -> dict[str, bool]:
|
||||
if not re.fullmatch(r"[a-z0-9-]+", slug):
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
previous = post_by_slug(slug)
|
||||
media_cleanup.enqueue(media_cleanup.media_items(previous))
|
||||
client = get_supabase()
|
||||
if client is not None:
|
||||
try:
|
||||
@@ -537,6 +557,7 @@ def delete_post(slug: str, _: None = Depends(require_admin)) -> dict[str, bool]:
|
||||
raise HTTPException(status_code=502, detail="The article could not be deleted. Please try again.") from exc
|
||||
if not response.data:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
media_cleanup.cleanup_pending()
|
||||
return {"deleted": True}
|
||||
|
||||
with POSTS_LOCK:
|
||||
@@ -545,6 +566,7 @@ def delete_post(slug: str, _: None = Depends(require_admin)) -> dict[str, bool]:
|
||||
if len(remaining) == len(records):
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
save_local_posts(remaining)
|
||||
media_cleanup.cleanup_pending()
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Durable cleanup of explicitly removed journal media after article writes."""
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from functools import wraps
|
||||
import json
|
||||
import inspect
|
||||
import re
|
||||
from threading import RLock
|
||||
|
||||
from fastapi import HTTPException
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
LOCK = RLock()
|
||||
TABLE = "article_media_deletions"
|
||||
|
||||
|
||||
def serialized(function):
|
||||
@wraps(function)
|
||||
def wrapped(*args, **kwargs):
|
||||
with LOCK:
|
||||
return function(*args, **kwargs)
|
||||
wrapped.__signature__ = inspect.signature(function, eval_str=True)
|
||||
return wrapped
|
||||
|
||||
|
||||
def media_items(article):
|
||||
return [m for m in [article.get("banner"), *(article.get("attachments") or [])] if m]
|
||||
|
||||
|
||||
def local_queue_path():
|
||||
from backend import main
|
||||
return main.DATA_DIR / "media-deletions.json"
|
||||
|
||||
|
||||
def pending():
|
||||
from backend import main
|
||||
client = main.get_supabase()
|
||||
if client is not None:
|
||||
return main.read_article_query(client.table(TABLE).select("upload_id,metadata").order("created_at")).data
|
||||
path = local_queue_path()
|
||||
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else []
|
||||
|
||||
|
||||
def save_local_queue(records):
|
||||
path = local_queue_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(records), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def enqueue(media):
|
||||
"""Persist intent before changing the article; referenced files stay protected."""
|
||||
from backend import main
|
||||
if not media:
|
||||
return
|
||||
records = {}
|
||||
for item in media:
|
||||
upload_id = item["url"].rsplit("/", 1)[-1]
|
||||
stored = main.uploaded_media(upload_id)
|
||||
records[upload_id] = {"upload_id": upload_id, "metadata": stored}
|
||||
client = main.get_supabase()
|
||||
try:
|
||||
if client is not None:
|
||||
result = client.table(TABLE).upsert(list(records.values())).execute()
|
||||
if not result.data:
|
||||
raise RuntimeError("Removal queue was not saved")
|
||||
else:
|
||||
save_local_queue(list({**{r["upload_id"]: r for r in pending()}, **records}.values()))
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, "The file removal could not be scheduled. Please save again.") from exc
|
||||
|
||||
|
||||
def enqueue_urls(urls):
|
||||
from backend import main
|
||||
media = []
|
||||
for url in set(urls):
|
||||
if not re.fullmatch(r"/api/uploads/[a-f0-9]{32}", url):
|
||||
raise HTTPException(422, "Invalid file removal reference.")
|
||||
try:
|
||||
media.append(main.uploaded_media(url.rsplit("/", 1)[-1]))
|
||||
except HTTPException as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
enqueue(media)
|
||||
|
||||
|
||||
def forget(upload_id):
|
||||
from backend import main
|
||||
client = main.get_supabase()
|
||||
if client is not None:
|
||||
client.table(TABLE).delete().eq("upload_id", upload_id).execute()
|
||||
else:
|
||||
save_local_queue([r for r in pending() if r["upload_id"] != upload_id])
|
||||
|
||||
|
||||
@serialized
|
||||
def cleanup_pending():
|
||||
from backend import main, dropbox_storage
|
||||
try:
|
||||
records = pending()
|
||||
if not records:
|
||||
return
|
||||
referenced = [m for p in main.article_records(include_content=False) for m in media_items(p)]
|
||||
active_urls = {m["url"] for m in referenced}
|
||||
active_ids = {m.get("dropbox_file_id") for m in referenced if m.get("dropbox_file_id")}
|
||||
client = main.get_supabase()
|
||||
for record in records:
|
||||
upload_id, media = record["upload_id"], record["metadata"]
|
||||
if not re.fullmatch(r"[a-f0-9]{32}", upload_id):
|
||||
continue
|
||||
if media["url"] in active_urls or media.get("dropbox_file_id") in active_ids:
|
||||
continue
|
||||
try:
|
||||
if media.get("storage") == "dropbox":
|
||||
dropbox_storage.delete(media["dropbox_file_id"])
|
||||
# Remove registry and migration backups only after Dropbox succeeds.
|
||||
if client is not None:
|
||||
client.table("article_media").delete().eq("upload_id", upload_id).execute()
|
||||
for suffix in ("", ".json"):
|
||||
path = main.UPLOAD_DIR / (upload_id + suffix)
|
||||
if path.resolve().parent != main.UPLOAD_DIR.resolve():
|
||||
raise ValueError("Invalid upload cleanup path")
|
||||
path.unlink(missing_ok=True)
|
||||
forget(upload_id)
|
||||
except Exception as exc:
|
||||
main.logger.warning("Journal file cleanup pending: type=%s", type(exc).__name__)
|
||||
except Exception as exc:
|
||||
main.logger.warning("Journal cleanup retry pending: type=%s", type(exc).__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
async def retry():
|
||||
while True:
|
||||
await run_in_threadpool(cleanup_pending)
|
||||
await asyncio.sleep(60)
|
||||
task = asyncio.create_task(retry())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Editable experience and skills, backed by Supabase with local development storage."""
|
||||
import json
|
||||
from threading import RLock
|
||||
from typing import Annotated, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, StringConstraints, model_validator
|
||||
|
||||
LOCK = RLock()
|
||||
TABLE = "portfolio_content"
|
||||
ShortText = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=100)]
|
||||
LongText = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=1000)]
|
||||
Month = Annotated[str, StringConstraints(pattern=r"^[1-9][0-9]{3}-(0[1-9]|1[0-2])$")]
|
||||
|
||||
|
||||
class ContentModel(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
|
||||
|
||||
|
||||
class Project(ContentModel):
|
||||
name: ShortText
|
||||
period: str = Field(default="", max_length=100)
|
||||
href: HttpUrl
|
||||
|
||||
|
||||
class ExperienceInput(ContentModel):
|
||||
company: ShortText
|
||||
role: str = Field(min_length=1, max_length=200)
|
||||
location: str = Field(min_length=1, max_length=200)
|
||||
start: Month
|
||||
end: Month | None = None
|
||||
summary: str = Field(min_length=1, max_length=4000)
|
||||
highlights: list[LongText] = Field(default_factory=list, max_length=30)
|
||||
technologies: list[ShortText] = Field(default_factory=list, max_length=60)
|
||||
projects: list[Project] = Field(default_factory=list, max_length=20)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def ordered_dates(self):
|
||||
if self.end and self.end < self.start:
|
||||
raise ValueError("End date must be on or after the start date.")
|
||||
return self
|
||||
|
||||
|
||||
class CategoryInput(ContentModel):
|
||||
name: ShortText
|
||||
description: str = Field(min_length=1, max_length=1000)
|
||||
accent: Literal["blue", "lavender", "peach", "yellow", "mint"] = "mint"
|
||||
items: list[ShortText] = Field(default_factory=list, max_length=100)
|
||||
|
||||
|
||||
class SkillsOverview(ContentModel):
|
||||
intro: str = Field(min_length=1, max_length=2000)
|
||||
principles: list[LongText] = Field(default_factory=list, max_length=20)
|
||||
|
||||
|
||||
def load_document(key):
|
||||
from backend import main
|
||||
client = main.get_supabase()
|
||||
if client is None:
|
||||
# Read the file on each edit; don't mutate objects held in the public cache.
|
||||
path = main.DATA_DIR / f"{key}.json"
|
||||
return {"payload": json.loads(path.read_text(encoding="utf-8")), "version": 0}
|
||||
response = main.read_article_query(client.table(TABLE).select("payload,version").eq("key", key).limit(1))
|
||||
if not response.data:
|
||||
raise HTTPException(503, "Portfolio content has not been initialized on the server.")
|
||||
return response.data[0]
|
||||
|
||||
|
||||
def save_document(key, payload, version):
|
||||
from backend import main
|
||||
client = main.get_supabase()
|
||||
if client is None:
|
||||
path = main.DATA_DIR / f"{key}.json"
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
main.read_data.cache_clear()
|
||||
return
|
||||
try:
|
||||
response = client.table(TABLE).update({"payload": payload, "version": version + 1}).eq("key", key).eq("version", version).execute()
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, "Your changes could not be saved. Please try again.") from exc
|
||||
if not response.data:
|
||||
raise HTTPException(409, "This content changed while saving. Please try saving again.")
|
||||
|
||||
|
||||
def experience():
|
||||
return sorted(load_document("experience")["payload"], key=lambda item: item["start"], reverse=True)
|
||||
|
||||
|
||||
def skills():
|
||||
content = load_document("skills")["payload"]
|
||||
# Stable fallback IDs support existing local JSON before running the seeder.
|
||||
for index, category in enumerate(content["categories"]):
|
||||
category.setdefault("id", f"category-{index + 1}")
|
||||
return content
|
||||
|
||||
|
||||
def mutate_item(key, collection, item_id=None, payload=None):
|
||||
with LOCK:
|
||||
document = load_document(key)
|
||||
content = document["payload"]
|
||||
items = content if collection is None else content[collection]
|
||||
if collection:
|
||||
for index, item in enumerate(items):
|
||||
item.setdefault("id", f"category-{index + 1}")
|
||||
if item_id is None:
|
||||
record = {**payload, "id": uuid4().hex}
|
||||
items.append(record)
|
||||
else:
|
||||
index = next((i for i, item in enumerate(items) if item["id"] == item_id), None)
|
||||
if index is None:
|
||||
raise HTTPException(404, "This entry no longer exists.")
|
||||
if payload is None:
|
||||
items.pop(index)
|
||||
record = {"deleted": True}
|
||||
else:
|
||||
record = {**payload, "id": item_id}
|
||||
items[index] = record
|
||||
save_document(key, content, document["version"])
|
||||
return record
|
||||
|
||||
|
||||
def register_routes(app, require_admin):
|
||||
@app.get("/api/experience")
|
||||
def get_experience():
|
||||
return experience()
|
||||
|
||||
@app.post("/api/experience", status_code=201)
|
||||
def create_experience(payload: ExperienceInput, _: None = Depends(require_admin)):
|
||||
return mutate_item("experience", None, payload=payload.model_dump(mode="json"))
|
||||
|
||||
@app.put("/api/experience/{item_id}")
|
||||
def update_experience(item_id: str, payload: ExperienceInput, _: None = Depends(require_admin)):
|
||||
return mutate_item("experience", None, item_id, payload.model_dump(mode="json"))
|
||||
|
||||
@app.delete("/api/experience/{item_id}")
|
||||
def delete_experience(item_id: str, _: None = Depends(require_admin)):
|
||||
return mutate_item("experience", None, item_id)
|
||||
|
||||
@app.get("/api/skills")
|
||||
def get_skills():
|
||||
return skills()
|
||||
|
||||
@app.patch("/api/skills")
|
||||
def update_overview(payload: SkillsOverview, _: None = Depends(require_admin)):
|
||||
with LOCK:
|
||||
document = load_document("skills")
|
||||
content = document["payload"]
|
||||
content.update(payload.model_dump(mode="json"))
|
||||
save_document("skills", content, document["version"])
|
||||
return content
|
||||
|
||||
@app.post("/api/skills/categories", status_code=201)
|
||||
def create_category(payload: CategoryInput, _: None = Depends(require_admin)):
|
||||
return mutate_item("skills", "categories", payload=payload.model_dump(mode="json"))
|
||||
|
||||
@app.put("/api/skills/categories/{item_id}")
|
||||
def update_category(item_id: str, payload: CategoryInput, _: None = Depends(require_admin)):
|
||||
return mutate_item("skills", "categories", item_id, payload.model_dump(mode="json"))
|
||||
|
||||
@app.delete("/api/skills/categories/{item_id}")
|
||||
def delete_category(item_id: str, _: None = Depends(require_admin)):
|
||||
return mutate_item("skills", "categories", item_id)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""One-time seed: python -m backend.seed_portfolio. Existing content is preserved."""
|
||||
import json
|
||||
from dotenv import load_dotenv
|
||||
from backend import main, portfolio_content
|
||||
|
||||
|
||||
def seed():
|
||||
client = main.get_supabase()
|
||||
if client is None:
|
||||
raise SystemExit("Configure Supabase before seeding portfolio content.")
|
||||
for key in ("experience", "skills"):
|
||||
payload = json.loads((main.DATA_DIR / f"{key}.json").read_text(encoding="utf-8"))
|
||||
if key == "skills":
|
||||
for index, category in enumerate(payload["categories"]):
|
||||
category.setdefault("id", f"category-{index + 1}")
|
||||
# ON CONFLICT DO NOTHING makes rerunning setup safe for admin edits.
|
||||
client.table(portfolio_content.TABLE).upsert(
|
||||
{"key": key, "payload": payload, "version": 1}, on_conflict="key", ignore_duplicates=True,
|
||||
).execute()
|
||||
print(f"{key}: initialized if missing; existing edits preserved.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
load_dotenv(main.BASE_DIR / ".env")
|
||||
seed()
|
||||
@@ -36,3 +36,24 @@ create table if not exists public.article_media (
|
||||
alter table public.article_media enable row level security;
|
||||
revoke all on table public.article_media from anon, authenticated;
|
||||
grant select, insert, update, delete on table public.article_media to service_role;
|
||||
|
||||
-- Persist removal intent before article changes so Dropbox outages can be retried.
|
||||
create table if not exists public.article_media_deletions (
|
||||
upload_id text primary key check (upload_id ~ '^[a-f0-9]{32}$'),
|
||||
metadata jsonb not null,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
alter table public.article_media_deletions enable row level security;
|
||||
revoke all on table public.article_media_deletions from anon, authenticated;
|
||||
grant select, insert, update, delete on table public.article_media_deletions to service_role;
|
||||
|
||||
-- Editable portfolio sections. Version checks prevent concurrent document writes
|
||||
-- from silently replacing each other's changes.
|
||||
create table if not exists public.portfolio_content (
|
||||
key text primary key check (key in ('experience', 'skills')),
|
||||
payload jsonb not null,
|
||||
version bigint not null default 1
|
||||
);
|
||||
alter table public.portfolio_content enable row level security;
|
||||
revoke all on table public.portfolio_content from anon, authenticated;
|
||||
grant select, insert, update, delete on table public.portfolio_content to service_role;
|
||||
|
||||
+116
-1
@@ -10,7 +10,8 @@ from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
from httpx import ReadTimeout, RemoteProtocolError
|
||||
from supabase import PostgrestAPIError
|
||||
from dropbox.exceptions import AuthError
|
||||
from dropbox.exceptions import ApiError, AuthError
|
||||
from dropbox.files import DeleteError, LookupError
|
||||
|
||||
from backend import main
|
||||
|
||||
@@ -126,6 +127,8 @@ class ArticleMediaTests(unittest.TestCase):
|
||||
table = client.table.return_value
|
||||
payload = self.article_payload()
|
||||
table.update.return_value.eq.return_value.execute.return_value.data = [{**payload, "slug": "original-url"}]
|
||||
table.select.return_value.eq.return_value.limit.return_value.retry.return_value.execute.return_value.data = [{**payload, "slug": "original-url"}]
|
||||
table.select.return_value.order.return_value.retry.return_value.execute.return_value.data = []
|
||||
table.delete.return_value.eq.return_value.execute.return_value.data = [{"slug": "original-url"}]
|
||||
with patch.object(main, "get_supabase", return_value=client):
|
||||
updated = self.client.put("/api/posts/original-url", json=payload, headers=self.headers)
|
||||
@@ -253,6 +256,118 @@ class ArticleMediaTests(unittest.TestCase):
|
||||
self.assertEqual(migrated["dropbox_file_id"], "id:migrated_file")
|
||||
self.assertEqual((main.UPLOAD_DIR / media["url"].rsplit("/", 1)[-1]).read_bytes(), b"original notes")
|
||||
|
||||
def test_edit_open_before_migration_keeps_old_media_and_new_uploads(self):
|
||||
from backend.migrate_media_dropbox import migrate
|
||||
image = io.BytesIO()
|
||||
Image.new("RGB", (20, 20), "green").save(image, format="PNG")
|
||||
banner = self.upload(image.getvalue(), "banner").json()
|
||||
attachment = self.upload(b"original notes", name="notes.txt").json()
|
||||
draft = {**self.article_payload(), "banner": banner, "attachments": [attachment]}
|
||||
created = self.client.post("/api/posts", headers=self.headers, json=draft)
|
||||
self.assertEqual(created.status_code, 201)
|
||||
path = "/api/posts/an-editable-article"
|
||||
dropbox = MagicMock()
|
||||
dropbox.files_upload.side_effect = [
|
||||
MagicMock(id="id:migrated_banner"), MagicMock(id="id:migrated_attachment"),
|
||||
MagicMock(id="id:new_attachment"),
|
||||
]
|
||||
with patch.object(main.dropbox_storage, "get_dropbox", return_value=dropbox):
|
||||
migrate(apply=True)
|
||||
with patch.dict(os.environ, {"JOURNAL_MEDIA_STORAGE": "dropbox"}):
|
||||
uploaded = self.upload(b"new notes", name="new.txt")
|
||||
self.assertEqual(uploaded.status_code, 201)
|
||||
draft["attachments"].append(uploaded.json())
|
||||
# The browser still has the pre-migration metadata for the old files.
|
||||
draft["title"] = "An edited article after migration"
|
||||
saved = self.client.put(path, headers=self.headers, json=draft)
|
||||
self.assertEqual(saved.status_code, 200, saved.text)
|
||||
result = self.client.get(path).json()
|
||||
self.assertEqual(result["title"], draft["title"])
|
||||
self.assertEqual(result["banner"]["dropbox_file_id"], "id:migrated_banner")
|
||||
self.assertEqual([m["dropbox_file_id"] for m in result["attachments"]],
|
||||
["id:migrated_attachment", "id:new_attachment"])
|
||||
self.assertEqual(result["banner"]["url"], banner["url"])
|
||||
self.assertEqual(result["attachments"][0]["url"], attachment["url"])
|
||||
# Compatibility must not accept altered file details or a substituted ID.
|
||||
for changes in ({"size": 999}, {"name": "different.txt"},
|
||||
{"media_type": "image/png"}, {"dropbox_file_id": "id:other"},
|
||||
{"storage": "dropbox"}):
|
||||
with self.subTest(changes=changes):
|
||||
invalid = {**draft, "attachments": [{**attachment, **changes}]}
|
||||
rejected = self.client.put(path, headers=self.headers, json=invalid)
|
||||
self.assertEqual(rejected.status_code, 422)
|
||||
|
||||
def test_removal_deletes_dropbox_files_after_save_and_preserves_shared_media(self):
|
||||
dropbox = MagicMock()
|
||||
dropbox.files_upload.side_effect = [MagicMock(id=f"id:file_{i}") for i in range(4)]
|
||||
image = io.BytesIO()
|
||||
Image.new("RGB", (8, 8), "blue").save(image, format="PNG")
|
||||
with patch.dict(os.environ, {"JOURNAL_MEDIA_STORAGE": "dropbox"}), patch.object(main.dropbox_storage, "get_dropbox", return_value=dropbox):
|
||||
banner = self.upload(image.getvalue(), "banner").json()
|
||||
attachment = self.upload(b"shared notes").json()
|
||||
replacement = self.upload(image.getvalue(), "banner").json()
|
||||
discarded = self.upload(b"removed before publishing").json()
|
||||
payload = {**self.article_payload(), "banner": banner, "attachments": [attachment]}
|
||||
self.assertEqual(self.client.post("/api/posts", headers=self.headers, json=payload).status_code, 201)
|
||||
second = {**self.article_payload(), "title": "Another article with shared notes", "attachments": [attachment]}
|
||||
self.assertEqual(self.client.post("/api/posts", headers=self.headers, json=second).status_code, 201)
|
||||
edited = {**payload, "banner": replacement, "attachments": [], "discarded_uploads": [discarded["url"]]}
|
||||
self.assertEqual(self.client.put("/api/posts/an-editable-article", headers=self.headers, json=edited).status_code, 200)
|
||||
deleted = [call.args[0] for call in dropbox.files_delete_v2.call_args_list]
|
||||
self.assertCountEqual(deleted, [banner["dropbox_file_id"], discarded["dropbox_file_id"]])
|
||||
self.assertEqual(self.client.get(banner["url"]).status_code, 404)
|
||||
self.assertEqual(main.uploaded_media(attachment["url"].rsplit("/", 1)[-1]), attachment)
|
||||
self.assertEqual(self.client.delete("/api/posts/another-article-with-shared-notes", headers=self.headers).status_code, 200)
|
||||
self.assertEqual(self.client.delete("/api/posts/an-editable-article", headers=self.headers).status_code, 200)
|
||||
self.assertCountEqual([call.args[0] for call in dropbox.files_delete_v2.call_args_list],
|
||||
[m["dropbox_file_id"] for m in [banner, attachment, replacement, discarded]])
|
||||
self.assertEqual(main.media_cleanup.pending(), [])
|
||||
|
||||
def test_failed_file_deletion_is_durable_and_retryable_after_article_deleted(self):
|
||||
dropbox = MagicMock()
|
||||
dropbox.files_upload.return_value.id = "id:retry_delete"
|
||||
dropbox.files_delete_v2.side_effect = AuthError("request", None)
|
||||
with patch.dict(os.environ, {"JOURNAL_MEDIA_STORAGE": "dropbox"}), patch.object(main.dropbox_storage, "get_dropbox", return_value=dropbox):
|
||||
media = self.upload(b"retry notes").json()
|
||||
payload = {**self.article_payload(), "attachments": [media]}
|
||||
self.client.post("/api/posts", headers=self.headers, json=payload)
|
||||
response = self.client.delete("/api/posts/an-editable-article", headers=self.headers)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(main.media_cleanup.pending()), 1)
|
||||
self.assertEqual(self.client.get("/api/auth/session", headers=self.headers).status_code, 200)
|
||||
self.assertEqual(main.uploaded_media(media["url"].rsplit("/", 1)[-1]), media)
|
||||
# Dropbox already removed the file, e.g. before a database outage.
|
||||
dropbox.files_delete_v2.side_effect = ApiError("request", DeleteError.path_lookup(LookupError.not_found), None, None)
|
||||
main.media_cleanup.cleanup_pending()
|
||||
self.assertEqual(main.media_cleanup.pending(), [])
|
||||
self.assertEqual(self.client.get(media["url"]).status_code, 404)
|
||||
|
||||
def test_failed_article_save_does_not_delete_referenced_file(self):
|
||||
media = self.upload(b"must survive failed save").json()
|
||||
payload = {**self.article_payload(), "attachments": [media]}
|
||||
self.client.post("/api/posts", headers=self.headers, json=payload)
|
||||
with patch.object(main, "save_local_posts", side_effect=OSError("disk unavailable")):
|
||||
with self.assertRaises(OSError):
|
||||
self.client.put("/api/posts/an-editable-article", headers=self.headers, json=self.article_payload())
|
||||
main.read_data.cache_clear()
|
||||
main.media_cleanup.cleanup_pending()
|
||||
self.assertEqual(self.client.get(media["url"]).content, b"must survive failed save")
|
||||
self.assertEqual(len(main.media_cleanup.pending()), 1)
|
||||
self.assertEqual(self.client.put("/api/posts/an-editable-article", headers=self.headers, json=self.article_payload()).status_code, 200)
|
||||
self.assertEqual(self.client.get(media["url"]).status_code, 404)
|
||||
self.assertEqual(main.media_cleanup.pending(), [])
|
||||
|
||||
def test_removing_new_upload_before_publish_cleans_it_after_success(self):
|
||||
media = self.upload(b"discarded draft upload").json()
|
||||
payload = {**self.article_payload(), "discarded_uploads": [media["url"]]}
|
||||
invalid = {**payload, "content": {"type": "doc", "content": []}}
|
||||
self.assertEqual(self.client.post("/api/posts", headers=self.headers, json=invalid).status_code, 422)
|
||||
self.assertEqual(self.client.get(media["url"]).status_code, 200)
|
||||
result = self.client.post("/api/posts", headers=self.headers, json=payload)
|
||||
self.assertEqual(result.status_code, 201)
|
||||
self.assertNotIn("discarded_uploads", result.json())
|
||||
self.assertEqual(self.client.get(media["url"]).status_code, 404)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from backend import main, portfolio_content
|
||||
|
||||
|
||||
class PortfolioContentTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
directory = TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
self.root = Path(directory.name)
|
||||
(self.root / "experience.json").write_text("[]", encoding="utf-8")
|
||||
(self.root / "skills.json").write_text(json.dumps({"intro": "Original intro", "principles": ["Keep it simple"], "categories": []}), encoding="utf-8")
|
||||
for mocked in (patch.object(main, "DATA_DIR", self.root), patch.object(main, "get_supabase", return_value=None),
|
||||
patch.dict(os.environ, {"JOURNAL_ADMIN_PASSWORD": "test-password", "JOURNAL_TOKEN_SECRET": "test-secret"})):
|
||||
mocked.start(); self.addCleanup(mocked.stop)
|
||||
self.client = TestClient(main.app)
|
||||
token = self.client.post("/api/auth/login", json={"password": "test-password"}).json()["access_token"]
|
||||
self.headers = {"Authorization": "Bearer " + token}
|
||||
self.experience = {"company": "Example", "role": "Engineer", "location": "Remote", "start": "2024-06", "end": None,
|
||||
"summary": "Build and maintain useful applications.", "highlights": ["Delivered a product"], "technologies": ["Python"],
|
||||
"projects": [{"name": "Project", "href": "https://example.com", "period": "2024"}]}
|
||||
self.category = {"name": "Engineering", "description": "Tools I use", "accent": "blue", "items": ["Python", "React"]}
|
||||
|
||||
def test_experience_crud_is_publicly_readable_and_persistent(self):
|
||||
created = self.client.post("/api/experience", json=self.experience, headers=self.headers)
|
||||
self.assertEqual(created.status_code, 201, created.text)
|
||||
item = created.json()
|
||||
older = self.client.post("/api/experience", json={**self.experience, "start": "2020-01", "end": "2023-12"}, headers=self.headers).json()
|
||||
self.assertEqual([entry["id"] for entry in self.client.get("/api/experience").json()], [item["id"], older["id"]])
|
||||
changed = {**self.experience, "role": "Lead engineer", "technologies": ["Go"], "projects": [], "highlights": []}
|
||||
response = self.client.put("/api/experience/" + item["id"], json=changed, headers=self.headers)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["id"], item["id"])
|
||||
saved = json.loads((self.root / "experience.json").read_text())
|
||||
self.assertEqual(saved[0]["role"], "Lead engineer")
|
||||
for entry in [item, older]:
|
||||
self.assertEqual(self.client.delete("/api/experience/" + entry["id"], headers=self.headers).status_code, 200)
|
||||
self.assertEqual(self.client.get("/api/experience").json(), [])
|
||||
|
||||
def test_skills_categories_labels_and_overview_crud(self):
|
||||
response = self.client.post("/api/skills/categories", json=self.category, headers=self.headers)
|
||||
self.assertEqual(response.status_code, 201, response.text)
|
||||
item = response.json()
|
||||
changed = {**self.category, "name": "Updated category", "items": ["TypeScript"]}
|
||||
response = self.client.put("/api/skills/categories/" + item["id"], json=changed, headers=self.headers)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
overview = {"intro": "A new introduction", "principles": ["One principle", "Another principle"]}
|
||||
self.assertEqual(self.client.patch("/api/skills", json=overview, headers=self.headers).status_code, 200)
|
||||
content = self.client.get("/api/skills").json()
|
||||
self.assertEqual(content["intro"], overview["intro"])
|
||||
self.assertEqual(content["categories"][0]["items"], ["TypeScript"])
|
||||
self.assertEqual(content["categories"][0]["id"], item["id"])
|
||||
self.assertEqual(self.client.delete("/api/skills/categories/" + item["id"], headers=self.headers).status_code, 200)
|
||||
self.client.patch("/api/skills", json={**overview, "principles": []}, headers=self.headers)
|
||||
content = self.client.get("/api/skills").json()
|
||||
self.assertEqual(content["categories"], [])
|
||||
self.assertEqual(content["principles"], [])
|
||||
|
||||
def test_all_mutations_require_admin(self):
|
||||
for method, path, body in [
|
||||
("post", "/api/experience", self.experience), ("put", "/api/experience/missing", self.experience),
|
||||
("delete", "/api/experience/missing", None), ("post", "/api/skills/categories", self.category),
|
||||
("put", "/api/skills/categories/missing", self.category), ("delete", "/api/skills/categories/missing", None),
|
||||
("patch", "/api/skills", {"intro": "Intro", "principles": []}),
|
||||
]:
|
||||
for headers in ({}, {"Authorization": "Bearer 1.invalid"}):
|
||||
response = self.client.request(method, path, json=body, headers=headers)
|
||||
self.assertEqual(response.status_code, 401, (method, path))
|
||||
self.assertEqual(self.client.get("/api/experience").status_code, 200)
|
||||
self.assertEqual(self.client.get("/api/skills").status_code, 200)
|
||||
|
||||
def test_invalid_dates_links_fields_and_missing_records(self):
|
||||
for changes in ({"start": "2024-13"}, {"end": "2020-01"}, {"role": " "},
|
||||
{"projects": [{"name": "Unsafe", "href": "javascript:alert(1)"}]}, {"id": "client-selected"}):
|
||||
self.assertEqual(self.client.post("/api/experience", json={**self.experience, **changes}, headers=self.headers).status_code, 422)
|
||||
self.assertEqual(self.client.post("/api/skills/categories", json={**self.category, "accent": "invalid"}, headers=self.headers).status_code, 422)
|
||||
for path, body in [("/api/experience/missing", self.experience), ("/api/skills/categories/missing", self.category)]:
|
||||
self.assertEqual(self.client.put(path, json=body, headers=self.headers).status_code, 404)
|
||||
self.assertEqual(self.client.delete(path, headers=self.headers).status_code, 404)
|
||||
|
||||
def test_supabase_writes_use_versions_and_fail_without_silent_fallback(self):
|
||||
client = MagicMock()
|
||||
table = client.table.return_value
|
||||
read = table.select.return_value.eq.return_value.limit.return_value.retry.return_value.execute
|
||||
read.return_value.data = [{"payload": [], "version": 7}]
|
||||
write = table.update.return_value.eq.return_value.eq.return_value.execute
|
||||
write.return_value.data = [{"key": "experience"}]
|
||||
with patch.object(main, "get_supabase", return_value=client):
|
||||
response = self.client.post("/api/experience", json=self.experience, headers=self.headers)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
written = table.update.call_args.args[0]
|
||||
self.assertEqual(written["version"], 8)
|
||||
self.assertEqual(written["payload"][0]["role"], "Engineer")
|
||||
table.update.return_value.eq.assert_called_with("key", "experience")
|
||||
table.update.return_value.eq.return_value.eq.assert_called_with("version", 7)
|
||||
write.return_value.data = []
|
||||
self.assertEqual(self.client.post("/api/experience", json=self.experience, headers=self.headers).status_code, 409)
|
||||
write.side_effect = RuntimeError("offline")
|
||||
self.assertEqual(self.client.post("/api/experience", json=self.experience, headers=self.headers).status_code, 502)
|
||||
read.return_value.data = []
|
||||
self.assertEqual(self.client.get("/api/experience").status_code, 503)
|
||||
self.assertEqual(json.loads((self.root / "experience.json").read_text()), [])
|
||||
|
||||
def test_local_category_ids_remain_stable_when_deleting_previous_category(self):
|
||||
content = {"intro": "Intro", "principles": [], "categories": [{**self.category, "name": name} for name in ["First", "Second"]]}
|
||||
(self.root / "skills.json").write_text(json.dumps(content))
|
||||
categories = self.client.get("/api/skills").json()["categories"]
|
||||
self.client.delete("/api/skills/categories/" + categories[0]["id"], headers=self.headers)
|
||||
self.assertEqual(self.client.get("/api/skills").json()["categories"][0]["id"], categories[1]["id"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user