Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
c9c0ecf
Added routes for main version control actions
be-smith Sep 23, 2025
ec6cfac
Added a robust get version number helper to make sure users can't try…
be-smith Oct 9, 2025
37bcd26
Changed to refocde rather than item id
be-smith Oct 10, 2025
9b015a2
Add deepdiff dependency for nested structure comparison
be-smith Oct 10, 2025
c87a441
Improve compare_versions to handle nested structures with DeepDiff
be-smith Oct 10, 2025
55d7c8c
Add data validation and field protection to restore_version
be-smith Oct 10, 2025
0098585
Add action field for version history audit trail
be-smith Oct 10, 2025
e76af1f
Fix transaction safety in save_item by reversing operation order
be-smith Oct 10, 2025
ed3584c
Add version field to HasRevisionControl trait and fix auto-increment
be-smith Oct 12, 2025
caf2b8f
Add comprehensive tests for version control endpoints
be-smith Oct 12, 2025
05b6771
Fix version control restore audit trail
be-smith Oct 13, 2025
0a33558
Add version control UI to webapp
be-smith Oct 13, 2025
9f4821c
Add comprehensive action field tests for version control
be-smith Oct 13, 2025
208402d
update uv lock
be-smith Oct 13, 2025
641fc69
Ensures json is serializable for difference displaying
be-smith Oct 15, 2025
b0a155e
Renamed old data to data to better reflect that the current snapshot …
be-smith Nov 4, 2025
c1fc203
Ensured an initial version is created upon sample creation.
be-smith Nov 4, 2025
cf7ea1b
Hiding UI for now
be-smith Nov 5, 2025
9f460bb
Added indexes to mongodb for efficient lookup
be-smith Nov 5, 2025
8714806
Changed how user info is stored, now has the object which is a snapsh…
be-smith Nov 5, 2025
5337003
Adds tests to ensure we are populating a user_id field in the version…
be-smith Nov 5, 2025
c26a75e
Fixed software versioning
be-smith Nov 5, 2025
3856bb4
Added comprehensive sample lifecycle test for creating, modifying and…
be-smith Nov 5, 2025
0929235
Ensured correct software_version is added to version data
be-smith Nov 5, 2025
4751bc2
Merge branch 'main' into bes/revision_history_clean_history
be-smith Nov 5, 2025
888445a
fixing pre commit
be-smith Nov 5, 2025
9c03bd1
Added pydantic models
be-smith Nov 6, 2025
5973cba
Updated how version number is found
be-smith Nov 11, 2025
3777519
Naming changes of version -> version_number, software_version -> data…
be-smith Nov 11, 2025
52e2cdf
Removed user snapshot related models. Updated everything to use versi…
be-smith Nov 11, 2025
d0d9591
Added validation to item end points
be-smith Nov 11, 2025
f4456d2
Added proper use of enum for version actions to pydantic models and t…
be-smith Nov 11, 2025
78921d3
Moved protected fields to its own helper function
be-smith Nov 11, 2025
ad19d74
Changed restored_from_version field to ObjectID not str
be-smith Nov 11, 2025
4a32d0d
Tidied up tests to remove "user" field and updated the field name cha…
be-smith Nov 11, 2025
94581fe
Removed user from list versions route
be-smith Nov 11, 2025
77b4f41
Added pydantic validation to _get_version_number, compare_versions, r…
be-smith Nov 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pydatalab/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ dependencies = [
"pint ~= 0.24",
"pandas[excel] ~= 2.2",
"pymongo ~= 4.7",
"deepdiff ~= 8.1",
]

[project.urls]
Expand Down
10 changes: 10 additions & 0 deletions pydatalab/schemas/cell.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@
"title": "Revisions",
"type": "object"
},
"version": {
"title": "Version",
"default": 1,
"type": "integer"
},
"creator_ids": {
"title": "Creator Ids",
"default": [],
Expand Down Expand Up @@ -525,6 +530,11 @@
"title": "Revisions",
"type": "object"
},
"version": {
"title": "Version",
"default": 1,
"type": "integer"
},
"creator_ids": {
"title": "Creator Ids",
"default": [],
Expand Down
10 changes: 10 additions & 0 deletions pydatalab/schemas/equipment.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@
"title": "Revisions",
"type": "object"
},
"version": {
"title": "Version",
"default": 1,
"type": "integer"
},
"creator_ids": {
"title": "Creator Ids",
"default": [],
Expand Down Expand Up @@ -489,6 +494,11 @@
"title": "Revisions",
"type": "object"
},
"version": {
"title": "Version",
"default": 1,
"type": "integer"
},
"creator_ids": {
"title": "Creator Ids",
"default": [],
Expand Down
10 changes: 10 additions & 0 deletions pydatalab/schemas/sample.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@
"title": "Revisions",
"type": "object"
},
"version": {
"title": "Version",
"default": 1,
"type": "integer"
},
"creator_ids": {
"title": "Creator Ids",
"default": [],
Expand Down Expand Up @@ -578,6 +583,11 @@
"title": "Revisions",
"type": "object"
},
"version": {
"title": "Version",
"default": 1,
"type": "integer"
},
"creator_ids": {
"title": "Creator Ids",
"default": [],
Expand Down
10 changes: 10 additions & 0 deletions pydatalab/schemas/startingmaterial.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@
"title": "Revisions",
"type": "object"
},
"version": {
"title": "Version",
"default": 1,
"type": "integer"
},
"creator_ids": {
"title": "Creator Ids",
"default": [],
Expand Down Expand Up @@ -631,6 +636,11 @@
"title": "Revisions",
"type": "object"
},
"version": {
"title": "Version",
"default": 1,
"type": "integer"
},
"creator_ids": {
"title": "Creator Ids",
"default": [],
Expand Down
13 changes: 13 additions & 0 deletions pydatalab/src/pydatalab/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@
from pydatalab.models.people import Person
from pydatalab.models.samples import Sample
from pydatalab.models.starting_materials import StartingMaterial
from pydatalab.models.versions import (
CompareVersionsQuery,
ItemVersion,
RestoreVersionRequest,
VersionAction,
VersionCounter,
)

ITEM_MODELS: dict[str, type[BaseModel]] = {
"samples": Sample,
Expand All @@ -24,4 +31,10 @@
"Collection",
"Equipment",
"ITEM_MODELS",
"ItemVersion",
"VersionCounter",
"UserSnapshot",
"VersionAction",
"RestoreVersionRequest",
"CompareVersionsQuery",
)
3 changes: 3 additions & 0 deletions pydatalab/src/pydatalab/models/traits.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ class HasRevisionControl(BaseModel):
revisions: dict[int, Any] | None = None
"""An optional mapping from old revision numbers to the model state at that revision."""

version: int = 1
"""The version number used by the version control system for tracking snapshots."""


class HasBlocks(BaseModel):
blocks_obj: dict[str, DataBlockResponse] = Field({})
Expand Down
117 changes: 117 additions & 0 deletions pydatalab/src/pydatalab/models/versions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Pydantic models for version control system."""

from datetime import datetime
from enum import Enum

from pydantic import BaseModel, Field, validator

from pydatalab.models.utils import PyObjectId, Refcode


class VersionAction(str, Enum):
"""Valid actions that can create a version snapshot."""

CREATED = "created"
MANUAL_SAVE = "manual_save"
AUTO_SAVE = "auto_save"
RESTORED = "restored"


class ItemVersion(BaseModel):
"""A complete snapshot of an item at a specific point in time.

This model represents a version entry in the `item_versions` collection.
Each version captures the complete state of an item, allowing users to
view history and restore previous states.
"""

refcode: Refcode = Field(..., description="The refcode of the item this version belongs to")
version: int = Field(..., ge=1, description="Sequential version number (1-indexed)")
timestamp: datetime = Field(
..., description="When this version was created (ISO format with timezone)"
)
action: VersionAction = Field(
...,
description="The action that triggered this version: 'created' (item creation), "
"'manual_save' (user save), 'auto_save' (system save), or 'restored' (version restore)",
)
user_id: PyObjectId | None = Field(
None, description="User's ObjectId for efficient querying and indexing"
)
datalab_version: str = Field(
..., description="Version of datalab-server that created this snapshot"
)
data: dict = Field(..., description="Complete snapshot of the item data at this version")
restored_from_version: PyObjectId | None = Field(
None,
description="ObjectId of the version that was restored from (only present if action='restored')",
)

@validator("restored_from_version")
def validate_restored_from_version(cls, v, values):
"""Ensure restored_from_version is only present when action='restored'."""
action = values.get("action")
if action == VersionAction.RESTORED and v is None:
raise ValueError("restored_from_version must be provided when action='restored'")
if action != VersionAction.RESTORED and v is not None:
raise ValueError(
f"restored_from_version should only be present when action='restored', got action='{action}'"
)
return v


class VersionCounter(BaseModel):
"""Atomic counter for tracking version numbers per item.

This model represents a document in the `version_counters` collection.
It ensures atomic increment of version numbers to prevent race conditions.
"""

refcode: Refcode = Field(..., description="The refcode this counter belongs to")
counter: int = Field(
1, ge=1, description="Current version counter value (1-indexed, matches version numbers)"
)

class Config:
extra = "ignore" # Allow MongoDB's _id field and other internal fields


class RestoreVersionRequest(BaseModel):
"""Request body for restoring a version."""

version_id: str = Field(..., description="ObjectId string of the version to restore to")

@validator("version_id")
def validate_version_id_format(cls, v):
"""Validate that version_id is a valid ObjectId string."""
try:
from bson import ObjectId

ObjectId(v)
except Exception as e:
raise ValueError(f"version_id must be a valid ObjectId string: {e}")
return v

class Config:
extra = "forbid"


class CompareVersionsQuery(BaseModel):
"""Query parameters for comparing two versions."""

v1: str = Field(..., description="ObjectId string of the first version")
v2: str = Field(..., description="ObjectId string of the second version")

@validator("v1", "v2")
def validate_version_ids(cls, v):
"""Validate that version IDs are valid ObjectId strings."""
try:
from bson import ObjectId

ObjectId(v)
except Exception as e:
raise ValueError(f"Version ID must be a valid ObjectId string: {e}")
return v

class Config:
extra = "forbid"
17 changes: 17 additions & 0 deletions pydatalab/src/pydatalab/mongo.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ def create_default_indices(
- An index over item type,
- A unique index over `item_id` and `refcode`.
- A text index over user names and identities.
- Version control indexes:
- Index on item_versions.refcode for fast version history lookup
- Index on item_versions.user_id for fast user contribution queries
- Compound index on (refcode, version) for sorted version history
- Unique index on version_counters.refcode for atomic version numbering

Parameters:
background: If true, indexes will be created as background jobs.
Expand Down Expand Up @@ -208,4 +213,16 @@ def create_user_fts():
db.users.drop_index(user_fts_name)
ret += create_user_fts()

# Version control indexes
ret += db.item_versions.create_index("refcode", name="version refcode", background=background)
ret += db.item_versions.create_index("user_id", name="version user_id", background=background)
ret += db.item_versions.create_index(
[("refcode", pymongo.ASCENDING), ("version", pymongo.DESCENDING)],
name="refcode and version",
background=background,
)
ret += db.version_counters.create_index(
"refcode", unique=True, name="unique refcode counter", background=background
)

return ret
Loading