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
8d9e14ee5a
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.
|
||||
"""
|
||||
import httpx
|
||||
from .fava_client import get_fava_client
|
||||
|
||||
fava = get_fava_client()
|
||||
|
|
@ -2644,7 +2645,6 @@ async def api_approve_expense_entry(
|
|||
all_entries = await fava.get_journal_entries()
|
||||
|
||||
# 2. Find the entry with matching castle ID in links
|
||||
target_entry_hash = None
|
||||
target_entry = None
|
||||
|
||||
for entry in all_entries:
|
||||
|
|
@ -2656,51 +2656,86 @@ async def api_approve_expense_entry(
|
|||
link_clean = link.lstrip('^')
|
||||
# Check if this entry has our castle 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
|
||||
break
|
||||
if target_entry_hash:
|
||||
if target_entry:
|
||||
break
|
||||
|
||||
if not target_entry_hash:
|
||||
if not target_entry:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail=f"Pending entry {entry_id} not found in Beancount ledger"
|
||||
)
|
||||
|
||||
# 3. Get the entry context (source text + sha256sum)
|
||||
context = await fava.get_entry_context(target_entry_hash)
|
||||
source = context.get("slice", "")
|
||||
sha256sum = context.get("sha256sum", "")
|
||||
|
||||
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
|
||||
# Get entry metadata for file location
|
||||
meta = target_entry.get("meta", {})
|
||||
filename = meta.get("filename")
|
||||
lineno = meta.get("lineno")
|
||||
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(
|
||||
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
|
||||
await fava.update_entry_source(target_entry_hash, new_source, sha256sum)
|
||||
sha256sum = source_data["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 {
|
||||
"message": f"Entry {entry_id} approved successfully",
|
||||
"entry_id": entry_id,
|
||||
"entry_hash": target_entry_hash,
|
||||
"date": date_str,
|
||||
"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.
|
||||
Voided transactions are excluded from balances but preserved in the ledger.
|
||||
"""
|
||||
import httpx
|
||||
from .fava_client import get_fava_client
|
||||
|
||||
fava = get_fava_client()
|
||||
|
|
@ -2725,7 +2761,6 @@ async def api_reject_expense_entry(
|
|||
all_entries = await fava.get_journal_entries()
|
||||
|
||||
# 2. Find the entry with matching castle ID in links
|
||||
target_entry_hash = None
|
||||
target_entry = None
|
||||
|
||||
for entry in all_entries:
|
||||
|
|
@ -2737,58 +2772,77 @@ async def api_reject_expense_entry(
|
|||
link_clean = link.lstrip('^')
|
||||
# Check if this entry has our castle 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
|
||||
break
|
||||
if target_entry_hash:
|
||||
if target_entry:
|
||||
break
|
||||
|
||||
if not target_entry_hash:
|
||||
if not target_entry:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail=f"Pending entry {entry_id} not found in Beancount ledger"
|
||||
)
|
||||
|
||||
# 3. Get the entry context (source text + sha256sum)
|
||||
context = await fava.get_entry_context(target_entry_hash)
|
||||
source = context.get("slice", "")
|
||||
sha256sum = context.get("sha256sum", "")
|
||||
|
||||
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)
|
||||
# Get entry metadata for file location
|
||||
meta = target_entry.get("meta", {})
|
||||
filename = meta.get("filename")
|
||||
lineno = meta.get("lineno")
|
||||
date_str = target_entry.get("date", "")
|
||||
|
||||
# Add #voided tag if not already present
|
||||
if "#voided" not in source:
|
||||
# Find the transaction line and add #voided to the tags
|
||||
# Pattern: date ! "narration" #existing-tags
|
||||
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
|
||||
if not filename or not lineno:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail="Entry metadata missing filename or lineno"
|
||||
)
|
||||
|
||||
# 5. Update the entry via Fava API
|
||||
await fava.update_entry_source(target_entry_hash, new_source, sha256sum)
|
||||
# 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"]
|
||||
|
||||
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 {
|
||||
"message": f"Entry {entry_id} rejected (marked as voided)",
|
||||
"entry_id": entry_id,
|
||||
"entry_hash": target_entry_hash,
|
||||
"date": date_str,
|
||||
"description": target_entry.get("narration", "")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue