Fix approve/reject endpoints to use Fava source API correctly
The Fava /context endpoint returns structured entry data, not raw source text with slice/sha256sum as expected. Updated both endpoints to: 1. Get entry metadata (filename, lineno) from the parsed entry 2. Read the full source file via GET /source 3. Modify the specific line at the entry's line number 4. Write back via PUT /source with sha256sum for concurrency control - Approve: Changes flag from '!' to '*' at the entry line - Reject: Adds #voided tag to the entry line 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
cb9bc2d658
commit
54dd6537b4
1 changed files with 117 additions and 63 deletions
180
views_api.py
180
views_api.py
|
|
@ -2636,6 +2636,7 @@ async def api_approve_expense_entry(
|
||||||
|
|
||||||
This updates the transaction in the Beancount file via Fava API.
|
This updates the transaction in the Beancount file via Fava API.
|
||||||
"""
|
"""
|
||||||
|
import httpx
|
||||||
from .fava_client import get_fava_client
|
from .fava_client import get_fava_client
|
||||||
|
|
||||||
fava = get_fava_client()
|
fava = get_fava_client()
|
||||||
|
|
@ -2644,7 +2645,6 @@ async def api_approve_expense_entry(
|
||||||
all_entries = await fava.get_journal_entries()
|
all_entries = await fava.get_journal_entries()
|
||||||
|
|
||||||
# 2. Find the entry with matching castle ID in links
|
# 2. Find the entry with matching castle ID in links
|
||||||
target_entry_hash = None
|
|
||||||
target_entry = None
|
target_entry = None
|
||||||
|
|
||||||
for entry in all_entries:
|
for entry in all_entries:
|
||||||
|
|
@ -2656,51 +2656,86 @@ async def api_approve_expense_entry(
|
||||||
link_clean = link.lstrip('^')
|
link_clean = link.lstrip('^')
|
||||||
# Check if this entry has our castle ID
|
# Check if this entry has our castle ID
|
||||||
if link_clean == f"castle-{entry_id}" or link_clean.endswith(f"-{entry_id}"):
|
if link_clean == f"castle-{entry_id}" or link_clean.endswith(f"-{entry_id}"):
|
||||||
target_entry_hash = entry.get("entry_hash")
|
|
||||||
target_entry = entry
|
target_entry = entry
|
||||||
break
|
break
|
||||||
if target_entry_hash:
|
if target_entry:
|
||||||
break
|
break
|
||||||
|
|
||||||
if not target_entry_hash:
|
if not target_entry:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
detail=f"Pending entry {entry_id} not found in Beancount ledger"
|
detail=f"Pending entry {entry_id} not found in Beancount ledger"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Get the entry context (source text + sha256sum)
|
# Get entry metadata for file location
|
||||||
context = await fava.get_entry_context(target_entry_hash)
|
meta = target_entry.get("meta", {})
|
||||||
source = context.get("slice", "")
|
filename = meta.get("filename")
|
||||||
sha256sum = context.get("sha256sum", "")
|
lineno = meta.get("lineno")
|
||||||
|
|
||||||
if not source:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Could not retrieve entry source from Fava"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. Change flag from ! to *
|
|
||||||
# Replace the first occurrence of the date + ! pattern
|
|
||||||
import re
|
|
||||||
date_str = target_entry.get("date", "")
|
date_str = target_entry.get("date", "")
|
||||||
old_pattern = f"{date_str} !"
|
|
||||||
new_pattern = f"{date_str} *"
|
|
||||||
|
|
||||||
if old_pattern not in source:
|
if not filename or not lineno:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail=f"Could not find pending flag pattern '{old_pattern}' in entry source"
|
detail="Entry metadata missing filename or lineno"
|
||||||
)
|
)
|
||||||
|
|
||||||
new_source = source.replace(old_pattern, new_pattern, 1)
|
# 3. Get the source file from Fava
|
||||||
|
async with httpx.AsyncClient(timeout=fava.timeout) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{fava.base_url}/source",
|
||||||
|
params={"filename": filename}
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
source_data = response.json()["data"]
|
||||||
|
|
||||||
# 5. Update the entry via Fava API
|
sha256sum = source_data["sha256sum"]
|
||||||
await fava.update_entry_source(target_entry_hash, new_source, sha256sum)
|
source = source_data["source"]
|
||||||
|
lines = source.split('\n')
|
||||||
|
|
||||||
|
# 4. Find and modify the entry at the specified line
|
||||||
|
# Line numbers are 1-indexed, list is 0-indexed
|
||||||
|
entry_line_idx = lineno - 1
|
||||||
|
|
||||||
|
if entry_line_idx >= len(lines):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Line {lineno} not found in source file"
|
||||||
|
)
|
||||||
|
|
||||||
|
entry_line = lines[entry_line_idx]
|
||||||
|
|
||||||
|
# Check if the line contains the pending flag pattern
|
||||||
|
old_pattern = f"{date_str} !"
|
||||||
|
if old_pattern not in entry_line:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Line {lineno} does not contain expected pattern '{old_pattern}'. Found: {entry_line}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Replace the flag
|
||||||
|
new_pattern = f"{date_str} *"
|
||||||
|
new_line = entry_line.replace(old_pattern, new_pattern, 1)
|
||||||
|
lines[entry_line_idx] = new_line
|
||||||
|
|
||||||
|
# 5. Write back the modified source
|
||||||
|
new_source = '\n'.join(lines)
|
||||||
|
|
||||||
|
update_response = await client.put(
|
||||||
|
f"{fava.base_url}/source",
|
||||||
|
json={
|
||||||
|
"file_path": filename,
|
||||||
|
"source": new_source,
|
||||||
|
"sha256sum": sha256sum
|
||||||
|
},
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
update_response.raise_for_status()
|
||||||
|
|
||||||
|
logger.info(f"Entry {entry_id} approved (flag changed to *)")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message": f"Entry {entry_id} approved successfully",
|
"message": f"Entry {entry_id} approved successfully",
|
||||||
"entry_id": entry_id,
|
"entry_id": entry_id,
|
||||||
"entry_hash": target_entry_hash,
|
|
||||||
"date": date_str,
|
"date": date_str,
|
||||||
"description": target_entry.get("narration", "")
|
"description": target_entry.get("narration", "")
|
||||||
}
|
}
|
||||||
|
|
@ -2717,6 +2752,7 @@ async def api_reject_expense_entry(
|
||||||
Adds #voided tag for audit trail while keeping the '!' flag.
|
Adds #voided tag for audit trail while keeping the '!' flag.
|
||||||
Voided transactions are excluded from balances but preserved in the ledger.
|
Voided transactions are excluded from balances but preserved in the ledger.
|
||||||
"""
|
"""
|
||||||
|
import httpx
|
||||||
from .fava_client import get_fava_client
|
from .fava_client import get_fava_client
|
||||||
|
|
||||||
fava = get_fava_client()
|
fava = get_fava_client()
|
||||||
|
|
@ -2725,7 +2761,6 @@ async def api_reject_expense_entry(
|
||||||
all_entries = await fava.get_journal_entries()
|
all_entries = await fava.get_journal_entries()
|
||||||
|
|
||||||
# 2. Find the entry with matching castle ID in links
|
# 2. Find the entry with matching castle ID in links
|
||||||
target_entry_hash = None
|
|
||||||
target_entry = None
|
target_entry = None
|
||||||
|
|
||||||
for entry in all_entries:
|
for entry in all_entries:
|
||||||
|
|
@ -2737,58 +2772,77 @@ async def api_reject_expense_entry(
|
||||||
link_clean = link.lstrip('^')
|
link_clean = link.lstrip('^')
|
||||||
# Check if this entry has our castle ID
|
# Check if this entry has our castle ID
|
||||||
if link_clean == f"castle-{entry_id}" or link_clean.endswith(f"-{entry_id}"):
|
if link_clean == f"castle-{entry_id}" or link_clean.endswith(f"-{entry_id}"):
|
||||||
target_entry_hash = entry.get("entry_hash")
|
|
||||||
target_entry = entry
|
target_entry = entry
|
||||||
break
|
break
|
||||||
if target_entry_hash:
|
if target_entry:
|
||||||
break
|
break
|
||||||
|
|
||||||
if not target_entry_hash:
|
if not target_entry:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
detail=f"Pending entry {entry_id} not found in Beancount ledger"
|
detail=f"Pending entry {entry_id} not found in Beancount ledger"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Get the entry context (source text + sha256sum)
|
# Get entry metadata for file location
|
||||||
context = await fava.get_entry_context(target_entry_hash)
|
meta = target_entry.get("meta", {})
|
||||||
source = context.get("slice", "")
|
filename = meta.get("filename")
|
||||||
sha256sum = context.get("sha256sum", "")
|
lineno = meta.get("lineno")
|
||||||
|
|
||||||
if not source:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Could not retrieve entry source from Fava"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. Add #voided tag (keep ! flag as per convention)
|
|
||||||
date_str = target_entry.get("date", "")
|
date_str = target_entry.get("date", "")
|
||||||
|
|
||||||
# Add #voided tag if not already present
|
if not filename or not lineno:
|
||||||
if "#voided" not in source:
|
raise HTTPException(
|
||||||
# Find the transaction line and add #voided to the tags
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
# Pattern: date ! "narration" #existing-tags
|
detail="Entry metadata missing filename or lineno"
|
||||||
lines = source.split('\n')
|
)
|
||||||
for i, line in enumerate(lines):
|
|
||||||
if date_str in line and '"' in line and '!' in line:
|
|
||||||
# Add #voided tag to the transaction line
|
|
||||||
if '#' in line:
|
|
||||||
# Already has tags, append voided
|
|
||||||
lines[i] = line.rstrip() + ' #voided'
|
|
||||||
else:
|
|
||||||
# No tags yet, add after narration
|
|
||||||
lines[i] = line.rstrip() + ' #voided'
|
|
||||||
break
|
|
||||||
new_source = '\n'.join(lines)
|
|
||||||
else:
|
|
||||||
new_source = source
|
|
||||||
|
|
||||||
# 5. Update the entry via Fava API
|
# 3. Get the source file from Fava
|
||||||
await fava.update_entry_source(target_entry_hash, new_source, sha256sum)
|
async with httpx.AsyncClient(timeout=fava.timeout) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{fava.base_url}/source",
|
||||||
|
params={"filename": filename}
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
source_data = response.json()["data"]
|
||||||
|
|
||||||
|
sha256sum = source_data["sha256sum"]
|
||||||
|
source = source_data["source"]
|
||||||
|
lines = source.split('\n')
|
||||||
|
|
||||||
|
# 4. Find and modify the entry at the specified line - add #voided tag
|
||||||
|
entry_line_idx = lineno - 1
|
||||||
|
|
||||||
|
if entry_line_idx >= len(lines):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Line {lineno} not found in source file"
|
||||||
|
)
|
||||||
|
|
||||||
|
entry_line = lines[entry_line_idx]
|
||||||
|
|
||||||
|
# Add #voided tag if not already present
|
||||||
|
if "#voided" not in entry_line:
|
||||||
|
# Add #voided tag to the transaction line
|
||||||
|
new_line = entry_line.rstrip() + ' #voided'
|
||||||
|
lines[entry_line_idx] = new_line
|
||||||
|
|
||||||
|
# 5. Write back the modified source
|
||||||
|
new_source = '\n'.join(lines)
|
||||||
|
|
||||||
|
update_response = await client.put(
|
||||||
|
f"{fava.base_url}/source",
|
||||||
|
json={
|
||||||
|
"file_path": filename,
|
||||||
|
"source": new_source,
|
||||||
|
"sha256sum": sha256sum
|
||||||
|
},
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
update_response.raise_for_status()
|
||||||
|
logger.info(f"Entry {entry_id} rejected (added #voided tag)")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message": f"Entry {entry_id} rejected (marked as voided)",
|
"message": f"Entry {entry_id} rejected (marked as voided)",
|
||||||
"entry_id": entry_id,
|
"entry_id": entry_id,
|
||||||
"entry_hash": target_entry_hash,
|
|
||||||
"date": date_str,
|
"date": date_str,
|
||||||
"description": target_entry.get("narration", "")
|
"description": target_entry.get("narration", "")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue