Skip to content

Commit d5648e6

Browse files
committed
A2-3: Сохранение слепка документа (JSONB) по схеме + версионность
В рамках задачи провели работу с версиями, управлением ими, интерфейсом. - Каждый раз при сохранении формы создается новая версия документа. - Вновь созданная версия документа автоматически помечается как текущая. - Изменения через редактор JSON в окне версий сохраняются В ТЕКУЩУЮ версию - При открытии формы она заполняется данными из ТЕКУЩЕЙ версии - В интерфейсе окна версий есть возможность переключиться на любую сохраненную версию для ее изменения или заполнения формы на ее основе. - Храним 20 версий, при создании новой версии при достижении этого предела, самая старая версия автоматически удаляется - В интерфейсе окна версий есть возможность "защитить" любую версию от автоматического удаления. - Версия в статусе final считается защищенной и является неизменяемой! Ее нельзя откорректировать через редактирование JSON в окне версий. - При отдаче версии клиенту проводится ее серверная валидация. В дополнительные данные json РЯДОМ с payload отдается информация о результате валидации, включая количество ошибок и описание ошибок - Версия не имеющая ошибок при сохранении помечается флагом clean, в противном случае помечается флагом draft. - В финальную версию можно перевести только версию с флагом clean. - Краткая справка по статусам версий, их хранении и отображению в интерфейсе приведена в подсказке в окне версий на клиенте. - Проработан интерфейс окна версий.
1 parent 01c1406 commit d5648e6

File tree

12 files changed

+760
-269
lines changed

12 files changed

+760
-269
lines changed

app/api/routes/document_versions.py

Lines changed: 193 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
from pydantic import BaseModel
44
from sqlalchemy.orm import Session
55
from app.db import get_db
6-
from app.models_sqlalchemy import DocumentRow, DocumentVersionRow
6+
from app.models_sqlalchemy import DocumentRow, DocumentVersionRow, Schema # Schema есть в models_sqlalchemy
7+
from app.services.validate_model import validate_model # серверная валидация по internal-model
8+
from app.services import xsd_internal # build_internal_model(content)
9+
from app.storage import load_file_minio # чтение XSD из MinIO
710

811
router = APIRouter(prefix="/documents", tags=["documents"])
912

@@ -18,19 +21,89 @@ class VersionOut(BaseModel):
1821
document_id: int
1922
payload: dict
2023
created_at: datetime | None = None
24+
# new fields for UI clarity
25+
status: str | None = None # 'draft' | 'clean' | 'final'
26+
is_protected: bool | None = None
27+
is_selected: bool | None = None
28+
validation: dict | None = None # {"source":"server","checked_at":ISO,"errors_count":N,"errors":{path:[...]}}
29+
30+
RETAIN_VERSIONS = 20 # could be from settings/env
31+
32+
def get_internal_model_for_document(doc: DocumentRow) -> dict:
33+
"""
34+
Возвращает internal-model для документа, используя связанную схему:
35+
1) document.schema_rel → Schema
36+
2) Schema.file_path → чтение XSD из MinIO
37+
3) xsd_internal.build_internal_model(content) → dict
38+
"""
39+
schema: Schema | None = getattr(doc, "schema_rel", None)
40+
if not schema or not getattr(schema, "file_path", None):
41+
return {}
42+
content = load_file_minio(schema.file_path)
43+
if not content:
44+
return {}
45+
return xsd_internal.build_internal_model(content)
2146

2247
@router.post("/{document_id}/versions", response_model=VersionOut, status_code=201)
23-
def save_draft(document_id: int, body: SaveDraftIn, db: Session = Depends(get_db)):
48+
def save_version(document_id: int, body: SaveDraftIn, db: Session = Depends(get_db)):
2449
doc = db.get(DocumentRow, document_id)
2550
if not doc:
2651
raise HTTPException(404, "Документ не найден")
27-
v = DocumentVersionRow(document_id=doc.id, payload=body.payload, created_at=datetime.utcnow())
52+
# server-side validation using the same internal-model as client
53+
internal_model = get_internal_model_for_document(doc)
54+
errs_dict = validate_model(body.payload, internal_model) if internal_model else {}
55+
errors: list[dict] = [{"path": k, "messages": v} for k, v in errs_dict.items()]
56+
status = "clean" if not errs_dict else "draft"
57+
v = DocumentVersionRow(
58+
document_id=doc.id,
59+
payload=body.payload,
60+
created_at=datetime.utcnow(),
61+
status=status,
62+
errors={"items": errors} if errors else None,
63+
errors_count=len(errors),
64+
is_protected=False,
65+
)
66+
try:
67+
db.query(DocumentVersionRow).filter(
68+
DocumentVersionRow.document_id == document_id,
69+
getattr(DocumentVersionRow, "is_selected", False) == True
70+
).update({"is_selected": False})
71+
except Exception:
72+
# поле может отсутствовать до миграции — просто игнорируем
73+
pass
74+
if hasattr(v, "is_selected"):
75+
v.is_selected = True
2876
db.add(v)
2977
# updated_at документа держим актуальным
3078
doc.updated_at = datetime.utcnow()
3179
db.commit()
3280
db.refresh(v)
33-
return VersionOut(id=v.id, document_id=v.document_id, payload=v.payload, created_at=v.created_at)
81+
# retention: keep last N unprotected & non-final
82+
to_delete = (
83+
db.query(DocumentVersionRow.id)
84+
.filter(DocumentVersionRow.document_id == document_id,
85+
DocumentVersionRow.is_protected == False,
86+
DocumentVersionRow.status != "final")
87+
.order_by(DocumentVersionRow.id.desc())
88+
.offset(RETAIN_VERSIONS)
89+
.all()
90+
)
91+
if to_delete:
92+
ids = [r.id for r in to_delete]
93+
db.query(DocumentVersionRow).filter(DocumentVersionRow.id.in_(ids)).delete(synchronize_session=False)
94+
db.commit()
95+
return VersionOut(
96+
id=v.id, document_id=v.document_id, payload=v.payload, created_at=v.created_at,
97+
status=getattr(v, "status", None),
98+
is_protected=getattr(v, "is_protected", None),
99+
is_selected=getattr(v, "is_selected", None),
100+
validation={
101+
"source": "server",
102+
"checked_at": datetime.utcnow().isoformat(),
103+
"errors_count": v.errors_count,
104+
"errors": errs_dict
105+
},
106+
)
34107

35108
@router.get("/{document_id}/versions", response_model=list[VersionOut])
36109
def list_versions(document_id: int, db: Session = Depends(get_db)):
@@ -43,7 +116,22 @@ def list_versions(document_id: int, db: Session = Depends(get_db)):
43116
.order_by(DocumentVersionRow.id.desc())
44117
.all()
45118
)
46-
return [VersionOut(id=r.id, document_id=r.document_id, payload=r.payload, created_at=r.created_at) for r in rows]
119+
out = []
120+
for r in rows:
121+
out.append(VersionOut(
122+
id=r.id, document_id=r.document_id, payload=r.payload, created_at=r.created_at,
123+
status=getattr(r, "status", None),
124+
is_protected=getattr(r, "is_protected", None),
125+
is_selected=getattr(r, "is_selected", None),
126+
validation={
127+
"source": "server",
128+
"checked_at": (r.created_at.isoformat() if r.created_at else None),
129+
"errors_count": getattr(r, "errors_count", None),
130+
# превратим JSON ошибок из БД в ожидаемый вид {path:[...]}
131+
"errors": { item["path"]: item["messages"] for item in (getattr(r, "errors", {}) or {}).get("items", []) }
132+
} if hasattr(r, "errors_count") else None
133+
))
134+
return out
47135

48136
# get one version by id (primary key)
49137
@router.get("/{document_id}/version/{version_id}", response_model=VersionOut)
@@ -73,39 +161,109 @@ def latest_version(document_id: int, db: Session = Depends(get_db)):
73161
raise HTTPException(404, "Версии отсутствуют")
74162
return VersionOut(id=v.id, document_id=v.document_id, payload=v.payload, created_at=v.created_at)
75163

76-
@router.put("/{document_id}/versions/latest", response_model=VersionOut)
77-
def upsert_latest_version(document_id: int, body: VersionIn, db: Session = Depends(get_db)):
78-
"""
79-
Create first version if none exists; otherwise update payload of the latest version.
80-
"""
164+
# @router.put("/{document_id}/versions/latest", response_model=VersionOut)
165+
# removed: versions are immutable; create a new one instead
166+
167+
class VersionStatusIn(BaseModel):
168+
status: str # only 'final'
169+
170+
@router.patch("/{document_id}/versions/{version_id}/status")
171+
def set_version_status(document_id: int, version_id: int, body: VersionStatusIn, db: Session = Depends(get_db)):
172+
if body.status != "final":
173+
raise HTTPException(400, "only transition to 'final' is allowed")
174+
v = db.get(DocumentVersionRow, version_id)
175+
if not v or v.document_id != document_id:
176+
raise HTTPException(404, "Версия не найдена")
177+
# каноническая проверка перед финализацией
81178
doc = db.get(DocumentRow, document_id)
82-
if not doc:
83-
raise HTTPException(404, "Документ не найден")
84-
v = (db.query(DocumentVersionRow)
85-
.filter(DocumentVersionRow.document_id == document_id)
86-
.order_by(DocumentVersionRow.id.desc())
87-
.first())
88-
if not v:
89-
v = DocumentVersionRow(document_id=document_id, payload=body.payload, created_at=datetime.utcnow())
90-
db.add(v)
91-
db.commit(); db.refresh(v)
92-
return VersionOut(id=v.id, document_id=v.document_id, payload=v.payload, created_at=v.created_at)
93-
# overwrite latest
94-
v.payload = body.payload
95-
db.commit(); db.refresh(v)
96-
return VersionOut(id=v.id, document_id=v.document_id, payload=v.payload, created_at=v.created_at)
179+
internal_model = get_internal_model_for_document(doc) if doc else {}
180+
errs_dict = validate_model(v.payload, internal_model) if internal_model else {}
181+
v.errors = {"items": [{"path": k, "messages": m} for k, m in errs_dict.items()]} if errs_dict else None
182+
v.errors_count = sum(len(m) for m in errs_dict.values())
183+
v.status = "clean" if not errs_dict else "draft"
184+
if v.status != "clean":
185+
db.commit()
186+
raise HTTPException(400, "only 'clean' can become 'final'")
187+
v.status = "final"
188+
v.is_protected = True
189+
db.commit()
190+
return {
191+
"ok": True, "id": v.id, "status": v.status,
192+
"validation": {
193+
"source": "server",
194+
"checked_at": datetime.utcnow().isoformat(),
195+
"errors_count": v.errors_count,
196+
"errors": errs_dict
197+
}
198+
}
97199

98-
class PatchStatusIn(BaseModel):
99-
status: str
200+
@router.post("/{document_id}/versions/{version_id}/freeze")
201+
def freeze_version(document_id: int, version_id: int, db: Session = Depends(get_db)):
202+
v = db.get(DocumentVersionRow, version_id)
203+
if not v or v.document_id != document_id:
204+
raise HTTPException(404, "Версия не найдена")
205+
if v.status != "final":
206+
v.is_protected = True
207+
db.commit()
208+
return {"ok": True, "protected": True}
209+
210+
@router.post("/{document_id}/versions/{version_id}/unfreeze")
211+
def unfreeze_version(document_id: int, version_id: int, db: Session = Depends(get_db)):
212+
v = db.get(DocumentVersionRow, version_id)
213+
if not v or v.document_id != document_id:
214+
raise HTTPException(404, "Версия не найдена")
215+
if v.status == "final":
216+
raise HTTPException(400, "final versions cannot be unprotected")
217+
v.is_protected = False
218+
db.commit()
219+
return {"ok": True, "protected": False}
220+
221+
@router.post("/{document_id}/versions/{version_id}/select")
222+
def select_version(document_id: int, version_id: int, db: Session = Depends(get_db)):
223+
v = db.get(DocumentVersionRow, version_id)
224+
if not v or v.document_id != document_id:
225+
raise HTTPException(404, "Версия не найдена")
226+
# allow selecting clean/final; allow draft too — по договорённости (можно ограничить)
227+
db.query(DocumentVersionRow).filter(
228+
DocumentVersionRow.document_id == document_id,
229+
DocumentVersionRow.is_selected == True
230+
).update({"is_selected": False})
231+
v.is_selected = True
232+
db.commit()
233+
return {"ok": True, "selected_version_id": v.id}
234+
235+
class PatchVersionIn(BaseModel):
236+
payload: dict
100237

101-
@router.patch("/{document_id}")
102-
def patch_status(document_id: int, body: PatchStatusIn, db: Session = Depends(get_db)):
103-
if body.status not in ("draft", "final"):
104-
raise HTTPException(400, "status must be 'draft'|'final'")
238+
@router.patch("/{document_id}/versions/{version_id}", response_model=VersionOut)
239+
def update_version(document_id: int, version_id: int, body: PatchVersionIn, db: Session = Depends(get_db)):
240+
"""Overwrite selected version payload in-place; forbid if final; set status=draft."""
105241
doc = db.get(DocumentRow, document_id)
106242
if not doc:
107243
raise HTTPException(404, "Документ не найден")
108-
doc.status = body.status
109-
doc.updated_at = datetime.utcnow()
110-
db.commit()
111-
return {"ok": True, "id": doc.id, "status": doc.status}
244+
v = db.get(DocumentVersionRow, version_id)
245+
if not v or v.document_id != document_id:
246+
raise HTTPException(404, "Версия не найдена")
247+
# forbid editing finals
248+
if getattr(v, "status", None) == "final":
249+
raise HTTPException(400, "final-версию редактировать нельзя")
250+
# validate new payload on server
251+
internal_model = get_internal_model_for_document(doc)
252+
errs_dict = validate_model(body.payload, internal_model) if internal_model else {}
253+
v.payload = body.payload
254+
v.errors = {"items": [{"path": k, "messages": m} for k, m in errs_dict.items()]} if errs_dict else None
255+
v.errors_count = sum(len(m) for m in errs_dict.values())
256+
v.status = "clean" if not errs_dict else "draft"
257+
db.commit(); db.refresh(v)
258+
return VersionOut(
259+
id=v.id, document_id=v.document_id, payload=v.payload, created_at=v.created_at,
260+
status=getattr(v, "status", None),
261+
is_protected=getattr(v, "is_protected", None),
262+
is_selected=getattr(v, "is_selected", None),
263+
validation={
264+
"source": "server",
265+
"checked_at": datetime.utcnow().isoformat(),
266+
"errors_count": v.errors_count,
267+
"errors": errs_dict
268+
},
269+
)

app/api/routes/documents_crud.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,17 @@ def get_document(doc_id: int, db: Session = Depends(get_db)):
7272
if not d: raise HTTPException(404, "not found")
7373
obj = db.get(ObjectRow, d.object_id) if d.object_id else None
7474
sch = db.get(Schema, int(d.schema_id)) if d.schema_id else None
75-
# latest version (id + payload)
76-
v = (
77-
db.query(DocumentVersionRow)
78-
.filter(DocumentVersionRow.document_id == d.id)
79-
.order_by(DocumentVersionRow.id.desc())
80-
.first()
81-
)
75+
76+
# chosen (is_selected) or latest
77+
v = (db.query(DocumentVersionRow)
78+
.filter(DocumentVersionRow.document_id == d.id, DocumentVersionRow.is_selected == True)
79+
.order_by(DocumentVersionRow.id.desc())
80+
.first())
81+
if not v:
82+
v = (db.query(DocumentVersionRow)
83+
.filter(DocumentVersionRow.document_id == d.id)
84+
.order_by(DocumentVersionRow.id.desc())
85+
.first())
8286
base = _pack(d, obj, sch).dict()
8387
return DocumentWithLatestOut(**base, latest_version_id=(v.id if v else None), payload=(v.payload if v else None))
8488

app/models_sqlalchemy.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from datetime import datetime
33
from typing import Optional
44

5-
from sqlalchemy import String, Integer, JSON, DateTime, ForeignKey, UniqueConstraint, cast
5+
from sqlalchemy import String, Integer, JSON, DateTime, ForeignKey, UniqueConstraint, cast, Boolean, Index
66
from sqlalchemy.dialects.postgresql import JSONB
77
from sqlalchemy.orm import Mapped, mapped_column, relationship, foreign
88

@@ -59,12 +59,27 @@ class ObjectRow(Base):
5959
name: Mapped[str] = mapped_column(String) # user-facing name
6060
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
6161

62+
6263
class DocumentVersionRow(Base):
6364
__tablename__ = "document_versions"
6465
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
6566
document_id: Mapped[int] = mapped_column(ForeignKey("documents.id"), nullable=False)
6667
payload: Mapped[dict] = mapped_column(JSONB) # JSONB for GIN/jsonb_path_ops
6768
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
69+
# new:
70+
status: Mapped[str] = mapped_column(String(16), default="draft") # 'draft' | 'clean' | 'final'
71+
errors: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {items:[...]} or None
72+
errors_count: Mapped[int] = mapped_column(Integer, default=0)
73+
is_protected: Mapped[bool] = mapped_column(Boolean, default=False) # manual “freeze”; final is implicitly protected
74+
is_selected: Mapped[bool] = mapped_column(Boolean, default=False) # chosen for editing/view
75+
76+
# one selected version per document (partial unique index)
77+
Index(
78+
"uq_document_versions_selected_once",
79+
DocumentVersionRow.document_id,
80+
unique=True,
81+
postgresql_where=(DocumentVersionRow.is_selected == True),
82+
)
6883

6984
class FileRow(Base):
7085
__tablename__ = "files"

0 commit comments

Comments
 (0)