mirror of
https://github.com/velocitatem/cvfs.git
synced 2026-05-31 08:43:37 +00:00
feat: allow updating existing CV branches
This commit is contained in:
@@ -4,8 +4,12 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_current_user, get_db
|
from app.api.deps import get_current_user, get_db
|
||||||
from app.schemas import BranchCreateRequest, VersionResponse
|
from app.schemas import BranchCreateRequest, PatchApplyRequest, VersionResponse
|
||||||
from app.services.versions import create_branch, delete_version
|
from app.services.versions import (
|
||||||
|
append_patches_to_version,
|
||||||
|
create_branch,
|
||||||
|
delete_version,
|
||||||
|
)
|
||||||
from dlib.auth import AuthenticatedUser
|
from dlib.auth import AuthenticatedUser
|
||||||
from dlib.cv.ats_guard import PatchValidationError
|
from dlib.cv.ats_guard import PatchValidationError
|
||||||
|
|
||||||
@@ -48,3 +52,26 @@ async def delete_version_branch(
|
|||||||
raise HTTPException(status_code=400, detail="Cannot delete root version")
|
raise HTTPException(status_code=400, detail="Cannot delete root version")
|
||||||
if result == "has_children":
|
if result == "has_children":
|
||||||
raise HTTPException(status_code=409, detail="Delete child branches first")
|
raise HTTPException(status_code=409, detail="Delete child branches first")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{version_id}/patches", response_model=VersionResponse)
|
||||||
|
async def append_patches(
|
||||||
|
version_id: str,
|
||||||
|
payload: PatchApplyRequest,
|
||||||
|
session: AsyncSession = Depends(get_db),
|
||||||
|
user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if not payload.patches:
|
||||||
|
raise HTTPException(status_code=400, detail="No patches provided")
|
||||||
|
try:
|
||||||
|
version = await append_patches_to_version(
|
||||||
|
session,
|
||||||
|
owner_id=user.sub,
|
||||||
|
version_id=version_id,
|
||||||
|
patches=payload.patches,
|
||||||
|
)
|
||||||
|
except PatchValidationError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
if not version:
|
||||||
|
raise HTTPException(status_code=404, detail="Version not found")
|
||||||
|
return VersionResponse.model_validate(version)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from .cv import (
|
|||||||
DocumentCreateResult,
|
DocumentCreateResult,
|
||||||
DocumentListResponse,
|
DocumentListResponse,
|
||||||
DocumentResponse,
|
DocumentResponse,
|
||||||
|
PatchApplyRequest,
|
||||||
PublicAssetAnalyticsResponse,
|
PublicAssetAnalyticsResponse,
|
||||||
PublicAssetLookupResponse,
|
PublicAssetLookupResponse,
|
||||||
PublicAssetResponse,
|
PublicAssetResponse,
|
||||||
@@ -21,6 +22,7 @@ __all__ = [
|
|||||||
"DocumentCreateResult",
|
"DocumentCreateResult",
|
||||||
"VersionResponse",
|
"VersionResponse",
|
||||||
"BranchCreateRequest",
|
"BranchCreateRequest",
|
||||||
|
"PatchApplyRequest",
|
||||||
"SubmissionCreateRequest",
|
"SubmissionCreateRequest",
|
||||||
"SubmissionResponse",
|
"SubmissionResponse",
|
||||||
"AiSuggestionRequest",
|
"AiSuggestionRequest",
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ class BranchCreateRequest(BaseModel):
|
|||||||
patches: list[dict[str, Any]] = Field(default_factory=list)
|
patches: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PatchApplyRequest(BaseModel):
|
||||||
|
patches: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class SubmissionResponse(BaseModel):
|
class SubmissionResponse(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,66 @@ async def create_branch(
|
|||||||
return result.scalars().one()
|
return result.scalars().one()
|
||||||
|
|
||||||
|
|
||||||
|
async def append_patches_to_version(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
owner_id: str,
|
||||||
|
version_id: str,
|
||||||
|
patches: list[dict],
|
||||||
|
) -> CvVersion | None:
|
||||||
|
stmt = (
|
||||||
|
select(CvVersion)
|
||||||
|
.join(CvVersion.document)
|
||||||
|
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
|
||||||
|
.options(selectinload(CvVersion.patches))
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
version = result.scalars().one_or_none()
|
||||||
|
if not version:
|
||||||
|
return None
|
||||||
|
|
||||||
|
patch_models = [PatchPayload.model_validate(item) for item in patches]
|
||||||
|
if not patch_models:
|
||||||
|
return version
|
||||||
|
|
||||||
|
base_doc = StructuredDocument(
|
||||||
|
version_label=version.version_label,
|
||||||
|
blocks=[
|
||||||
|
StructuredBlock.model_validate(block)
|
||||||
|
for block in version.structured_blocks or []
|
||||||
|
],
|
||||||
|
)
|
||||||
|
validate_patchset(base_doc, patch_models)
|
||||||
|
updated_doc = apply_patchset(base_doc, patch_models)
|
||||||
|
|
||||||
|
version.structured_blocks = [block.model_dump() for block in updated_doc.blocks]
|
||||||
|
metadata = version.metadata_json or {}
|
||||||
|
metadata["patch_count"] = int(metadata.get("patch_count") or 0) + len(patch_models)
|
||||||
|
version.metadata_json = metadata
|
||||||
|
|
||||||
|
for patch in patch_models:
|
||||||
|
session.add(
|
||||||
|
CvPatch(
|
||||||
|
version_id=version.id,
|
||||||
|
target_path=patch.target_path,
|
||||||
|
operation=patch.operation.value,
|
||||||
|
old_value=patch.old_value,
|
||||||
|
new_value=patch.new_value,
|
||||||
|
metadata_json=patch.metadata,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
stmt_refresh = (
|
||||||
|
select(CvVersion)
|
||||||
|
.where(CvVersion.id == version_id)
|
||||||
|
.options(selectinload(CvVersion.patches))
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt_refresh)
|
||||||
|
return result.scalars().one()
|
||||||
|
|
||||||
|
|
||||||
async def delete_version(
|
async def delete_version(
|
||||||
session: AsyncSession, owner_id: str, version_id: str
|
session: AsyncSession, owner_id: str, version_id: str
|
||||||
) -> bool | str:
|
) -> bool | str:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import CVTree from '@/components/cv/CVTree';
|
|||||||
import DiffViewer from '@/components/cv/DiffViewer';
|
import DiffViewer from '@/components/cv/DiffViewer';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {
|
import {
|
||||||
|
appendPatches,
|
||||||
createBranch, createSubmission, deleteDocument, deleteVersion,
|
createBranch, createSubmission, deleteDocument, deleteVersion,
|
||||||
Document, downloadVersionUrl,
|
Document, downloadVersionUrl,
|
||||||
fetchDocuments, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl,
|
fetchDocuments, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl,
|
||||||
@@ -488,6 +489,8 @@ export default function Dashboard() {
|
|||||||
const [pendingEdits, setPendingEdits] = useState<Map<string, { old_value: string; new_value: string }>>(new Map());
|
const [pendingEdits, setPendingEdits] = useState<Map<string, { old_value: string; new_value: string }>>(new Map());
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [docHovered, setDocHovered] = useState<string | null>(null);
|
const [docHovered, setDocHovered] = useState<string | null>(null);
|
||||||
|
const [applyLoading, setApplyLoading] = useState(false);
|
||||||
|
const [applyError, setApplyError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDocuments()
|
fetchDocuments()
|
||||||
@@ -501,6 +504,8 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPendingEdits(new Map());
|
setPendingEdits(new Map());
|
||||||
|
setApplyError('');
|
||||||
|
setApplyLoading(false);
|
||||||
}, [selectedVersionId]);
|
}, [selectedVersionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -554,6 +559,21 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
const discardEdits = () => setPendingEdits(new Map());
|
const discardEdits = () => setPendingEdits(new Map());
|
||||||
|
|
||||||
|
const applyStagedEdits = async () => {
|
||||||
|
if (!selectedVersionId || !stagedPatches.length) return;
|
||||||
|
setApplyLoading(true);
|
||||||
|
setApplyError('');
|
||||||
|
try {
|
||||||
|
await appendPatches(selectedVersionId, stagedPatches as Record<string, unknown>[]);
|
||||||
|
await refreshDocs();
|
||||||
|
setPendingEdits(new Map());
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setApplyError(e instanceof Error ? e.message : 'Failed to apply edits');
|
||||||
|
} finally {
|
||||||
|
setApplyLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteDoc = async (docId: string) => {
|
const handleDeleteDoc = async (docId: string) => {
|
||||||
if (!confirm('Delete this CV and all its branches? This cannot be undone.')) return;
|
if (!confirm('Delete this CV and all its branches? This cannot be undone.')) return;
|
||||||
try {
|
try {
|
||||||
@@ -809,13 +829,22 @@ export default function Dashboard() {
|
|||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
style={{ fontSize: 12, padding: '3px 10px', background: '#92400e', borderColor: '#92400e' }}
|
style={{ fontSize: 12, padding: '3px 10px', background: '#92400e', borderColor: '#92400e' }}
|
||||||
onClick={() => setModal('branch')}
|
onClick={applyStagedEdits}
|
||||||
|
disabled={applyLoading}
|
||||||
>
|
>
|
||||||
Save as branch
|
{applyLoading ? 'Applying…' : 'Apply to branch'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '3px 8px' }} onClick={() => setModal('branch')}>
|
||||||
|
Save as new branch
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '3px 8px' }} onClick={discardEdits}>
|
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '3px 8px' }} onClick={discardEdits}>
|
||||||
Discard
|
Discard
|
||||||
</button>
|
</button>
|
||||||
|
{applyError && (
|
||||||
|
<span style={{ color: '#b91c1c', fontSize: 12, flexBasis: '100%' }}>
|
||||||
|
{applyError}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,17 @@ export async function createBranch(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function appendPatches(
|
||||||
|
versionId: string,
|
||||||
|
patches: Record<string, unknown>[],
|
||||||
|
): Promise<Version> {
|
||||||
|
return req<Version>(`/api/v1/versions/${versionId}/patches`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ patches }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function createSubmission(
|
export async function createSubmission(
|
||||||
versionId: string,
|
versionId: string,
|
||||||
companyName: string,
|
companyName: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user