33from pydantic import BaseModel
44from sqlalchemy .orm import Session
55from 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
811router = 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 ])
36109def 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+ )
0 commit comments