Skip to content

Commit 3f50900

Browse files
authored
Merge pull request #53 from ScilifelabDataCentre/project-versioning-to-db
SQ-791: Move bucket/project versioning store from s3 to db
2 parents 260c19c + c135252 commit 3f50900

31 files changed

Lines changed: 1253 additions & 810 deletions

docs/using_divbase_cli.md

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -69,38 +69,61 @@ divbase-cli config show-default
6969

7070
## 3. Versioning the projects state
7171

72-
The DivBase server uses S3 buckets to store project files. Each project has its own assigned bucket. These buckets support versioning of files natively via S3. So individual files can be restored to prior versions if needed.
72+
DivBase allows you to create named versions of your project's state at specific points in time. This is useful for tracking changes, ensuring reproducibility, and marking important milestones (e.g., when running analysis for a publication).
7373

74-
On top of this we also support versioning of the entire project's state via a special file stored in the bucket. This allows users to create named versions of the entire project at a given timepoint.
74+
This means you can save the current state of your project as a named version, and later retrieve files as they were at that specific version (so you don't have to worry about having later updated files).
7575

76-
One potential use case for this could be:
76+
### Add a new version
7777

78-
- Marking a time point when you did analysis for the a publication.
78+
To save the current state of your project run:
7979

80-
At a later date, you could then download/upload all files from/to the bucket as they were at that timepoint to ensure reproducibility of results.
80+
```bash
81+
divbase-cli version add NAME [OPTIONS]
82+
```
83+
84+
- Replace `NAME` with a unique name for the version (e.g., v1.0.0).
85+
- You can optionally add a description using the --description flag.
86+
87+
**Note:** Versions are project wide, so you share them with all other members of the same project.
8188

82-
If you're in a new project, you can create the bucket versioning file by running
89+
### Listing Versions
90+
91+
To see all existing versions of your project, run:
8392

8493
```bash
85-
divbase-cli version create
94+
divbase-cli version list
8695
```
8796

88-
This will create a file in the bucket called `.bucket_versions.yaml`
97+
This will display a list of all saved versions, including their names, descriptions, and creation dates.
98+
99+
### Specific Version Details
89100

90-
An already existing bucket likely has this file, we can see the contents of this file by running:
101+
To get detailed information about a specific version, use:
91102

92103
```bash
93-
divbase-cli version list
104+
divbase-cli version info NAME
94105
```
95106

96-
If after working with the bucket for a while you want to version the current state of the bucket, you can run:
107+
This view will also include all the files associated with that version.
108+
109+
### Deleting Versions
110+
111+
To delete a specific version from your project, run:
97112

98113
```bash
99-
divbase-cli version add [OPTIONS] NAME
114+
divbase-cli version delete NAME
100115
```
101116

102-
To download files from bucket at a specific bucket version/state, we can use the --bucket-version option and specify the version name we want to download from:
117+
This will delete the version entry from the project. Deleted versions older than 30 days will be permanently deleted. You can ask a DivBase admin to restore a deleted version within that time period.
118+
119+
**NOTE:** The files associated with the version are never deleted by this operation.
120+
121+
### Downloading files from a specific version
122+
123+
To download files from your project as they were at a specific version, use the --project-version option:
103124

104125
```bash
105-
divbase-cli files download file1.vcf.gz file2.vcf.gz --bucket-version=v0.1.0
126+
divbase-cli files download file1.vcf.gz file2.vcf.gz --project-version=NAME
106127
```
128+
129+
Replacing `NAME` with the name of the version you want to download files from.

packages/divbase-api/src/divbase_api/admin_panel.py

Lines changed: 143 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
EmailField,
2525
HasOne,
2626
IntegerField,
27+
JSONField,
2728
StringField,
2829
TextAreaField,
2930
)
@@ -35,6 +36,7 @@
3536
from divbase_api.db import get_db
3637
from divbase_api.deps import _authenticate_frontend_user_from_tokens
3738
from divbase_api.frontend_routes.auth import get_login, post_logout
39+
from divbase_api.models.project_versions import ProjectVersionDB
3840
from divbase_api.models.projects import ProjectDB, ProjectMembershipDB
3941
from divbase_api.models.revoked_tokens import RevokedTokenDB
4042
from divbase_api.models.task_history import CeleryTaskMeta, TaskHistoryDB, TaskStartedAtDB
@@ -100,6 +102,14 @@ class UserView(ModelView):
100102
disabled=True,
101103
),
102104
"project_memberships",
105+
DateTimeField(
106+
"created_at", help_text="Timestamp when the entry was created. Value determined by system.", disabled=True
107+
),
108+
DateTimeField(
109+
"updated_at",
110+
help_text="Timestamp when the entry was last updated. Value determined by system.",
111+
disabled=True,
112+
),
103113
]
104114

105115
exclude_fields_from_list = ["hashed_password", "password"]
@@ -132,13 +142,11 @@ async def edit(self, request: Request, pk: Any, data: dict) -> Any:
132142
Override the default edit method to ensure that the `date_deleted` field is updated
133143
when/if a users soft deletion status is changed.
134144
"""
135-
logger.info(f"Editing user with pk={pk}, data={data}")
136145
if "is_deleted" in data:
137146
if data["is_deleted"]:
138147
data["date_deleted"] = datetime.now(tz=timezone.utc)
139148
else:
140149
data["date_deleted"] = None
141-
logger.info(f"Editing user with pk={pk}, data={data}")
142150

143151
return await super().edit(request=request, pk=pk, data=data)
144152

@@ -202,6 +210,14 @@ class ProjectView(ModelView):
202210
help_text="Current storage usage for this project in bytes.",
203211
),
204212
BooleanField("is_active", required=True, label="Is Active", help_text="Mark the project as active or not."),
213+
DateTimeField(
214+
"created_at", help_text="Timestamp when the entry was created. Value determined by system.", disabled=True
215+
),
216+
DateTimeField(
217+
"updated_at",
218+
help_text="Timestamp when the entry was last updated. Value determined by system.",
219+
disabled=True,
220+
),
205221
]
206222

207223
exclude_fields_from_list = ["description", "storage_used_bytes"]
@@ -293,6 +309,65 @@ async def serialize_field_value(self, value: Any, field: Any, action: RequestAct
293309
return await super().serialize_field_value(value, field, action, request)
294310

295311

312+
class ProjectVersionsView(ModelView):
313+
"""
314+
Custom admin panel View for the ProjectVersionDB model.
315+
"""
316+
317+
fields = [
318+
"id",
319+
StringField("name", required=True, label="Version Name", help_text="Unique name for the version."),
320+
TextAreaField("description", required=False, label="Description"),
321+
HasOne("project", identity="project", label="Project"),
322+
IntegerField(
323+
"user_id", label="User ID"
324+
), # No relationship created for this field in db model as this is for auditing only (can be null if user deleted)
325+
BooleanField("is_deleted", required=True, label="Is Deleted", help_text="Mark the version as deleted or not."),
326+
DateTimeField(
327+
"date_deleted",
328+
help_text="Timestamp when the version was soft deleted (else None). Value determined by system, cannot be edited.",
329+
disabled=True,
330+
),
331+
JSONField(
332+
"files", required=True, label="Files", help_text="Mapping of file names to version IDs.", disabled=True
333+
),
334+
DateTimeField(
335+
"created_at", help_text="Timestamp when the entry was created. Value determined by system.", disabled=True
336+
),
337+
DateTimeField(
338+
"updated_at",
339+
help_text="Timestamp when the entry was last updated. Value determined by system.",
340+
disabled=True,
341+
),
342+
]
343+
344+
page_size_options = PAGINATION_DEFAULTS
345+
exclude_fields_from_list = ["files"]
346+
exclude_fields_from_edit = ["id", "created_at", "updated_at", "files", "project", "user_id"]
347+
exclude_fields_from_detail = []
348+
349+
def can_delete(self, request: Request) -> bool:
350+
"""Disable deletion of project versions. Project versions can be soft deleted instead."""
351+
return False
352+
353+
def can_create(self, request: Request) -> bool:
354+
"""Disable creation of project versions. This is something users can create instead."""
355+
return False
356+
357+
async def edit(self, request: Request, pk: Any, data: dict) -> Any:
358+
"""
359+
Override the default edit method to ensure that the `date_deleted` field is updated
360+
when/if a version's soft deletion status is changed.
361+
"""
362+
if "is_deleted" in data:
363+
if data["is_deleted"]:
364+
data["date_deleted"] = datetime.now(tz=timezone.utc)
365+
else:
366+
data["date_deleted"] = None
367+
368+
return await super().edit(request=request, pk=pk, data=data)
369+
370+
296371
class RevokedTokenView(ModelView):
297372
"""
298373
Custom admin panel View for the RevokedTokenDB model.
@@ -337,70 +412,6 @@ async def serialize_field_value(self, value: Any, field: Any, action: RequestAct
337412
return await super().serialize_field_value(value, field, action, request)
338413

339414

340-
class DivBaseAuthProvider(AuthProvider):
341-
"""
342-
This class enables starlette-admin to make use of DivBase's pre-existing auth system.
343-
344-
The methods below are overriding several existing methods in the AuthProvider class (and its parent BaseAuthProvider).
345-
"""
346-
347-
async def render_login(self, request: Request, admin: BaseAdmin) -> Response:
348-
"""Override the default starlette-admin login method to use our frontend get_login route/page."""
349-
return await get_login(request)
350-
351-
async def render_logout(self, request: Request, admin: BaseAdmin) -> Response:
352-
"""Override the default starlette-admin logout to use our frontend post_logout function/route."""
353-
# can't rely on dependency injection here like in FastAPI, so we manually obtain a db session
354-
async for db in get_db():
355-
logout_response = await post_logout(request, db)
356-
return logout_response
357-
358-
async def is_authenticated(self, request: Request) -> bool:
359-
"""
360-
Overrides the default implementation to use our pre-existing DivBase auth system.
361-
362-
In which we determine the user from their JWT tokens stored in httponly cookies.
363-
364-
As expected, we also confirm user.is_admin for access.
365-
"""
366-
access_token = request.cookies.get("access_token")
367-
refresh_token = request.cookies.get("refresh_token")
368-
if not access_token and not refresh_token:
369-
return False
370-
371-
try:
372-
# Starlette does not support dependency injection like FastAPI,
373-
# so we need to manually obtain the database session here.
374-
async for db in get_db():
375-
user = await _authenticate_frontend_user_from_tokens(
376-
access_token=access_token, refresh_token=refresh_token, db=db
377-
)
378-
379-
if user and user.is_admin and user.is_active:
380-
# Store user info in the request state so it can be accessed by e.g. get_admin_user
381-
request.state.user = {"id": user.id, "name": user.name, "is_admin": user.is_admin}
382-
return True
383-
except Exception as e:
384-
logger.warning(
385-
f"An error occurred while attempting to authenticate a user on the starlette-admin panel, details: {e}"
386-
)
387-
return False
388-
389-
return False
390-
391-
def get_admin_user(self, request: Request) -> AdminUser | None:
392-
"""
393-
Retrieve the current (admin) user for display on the admin panel.
394-
395-
This controls the display of the user info in the top right of the admin panel and makes having a logout button possible.
396-
"""
397-
user = request.state.user
398-
if not user:
399-
return None
400-
401-
return AdminUser(username=user["name"], photo_url=None)
402-
403-
404415
class TaskHistoryView(ModelView):
405416
page_size_options = PAGINATION_DEFAULTS
406417

@@ -556,6 +567,70 @@ def can_delete(self, request: Request) -> bool:
556567
return False
557568

558569

570+
class DivBaseAuthProvider(AuthProvider):
571+
"""
572+
This class enables starlette-admin to make use of DivBase's pre-existing auth system.
573+
574+
The methods below are overriding several existing methods in the AuthProvider class (and its parent BaseAuthProvider).
575+
"""
576+
577+
async def render_login(self, request: Request, admin: BaseAdmin) -> Response:
578+
"""Override the default starlette-admin login method to use our frontend get_login route/page."""
579+
return await get_login(request)
580+
581+
async def render_logout(self, request: Request, admin: BaseAdmin) -> Response:
582+
"""Override the default starlette-admin logout to use our frontend post_logout function/route."""
583+
# can't rely on dependency injection here like in FastAPI, so we manually obtain a db session
584+
async for db in get_db():
585+
logout_response = await post_logout(request, db)
586+
return logout_response
587+
588+
async def is_authenticated(self, request: Request) -> bool:
589+
"""
590+
Overrides the default implementation to use our pre-existing DivBase auth system.
591+
592+
In which we determine the user from their JWT tokens stored in httponly cookies.
593+
594+
As expected, we also confirm user.is_admin for access.
595+
"""
596+
access_token = request.cookies.get("access_token")
597+
refresh_token = request.cookies.get("refresh_token")
598+
if not access_token and not refresh_token:
599+
return False
600+
601+
try:
602+
# Starlette does not support dependency injection like FastAPI,
603+
# so we need to manually obtain the database session here.
604+
async for db in get_db():
605+
user = await _authenticate_frontend_user_from_tokens(
606+
access_token=access_token, refresh_token=refresh_token, db=db
607+
)
608+
609+
if user and user.is_admin and user.is_active:
610+
# Store user info in the request state so it can be accessed by e.g. get_admin_user
611+
request.state.user = {"id": user.id, "name": user.name, "is_admin": user.is_admin}
612+
return True
613+
except Exception as e:
614+
logger.warning(
615+
f"An error occurred while attempting to authenticate a user on the starlette-admin panel, details: {e}"
616+
)
617+
return False
618+
619+
return False
620+
621+
def get_admin_user(self, request: Request) -> AdminUser | None:
622+
"""
623+
Retrieve the current (admin) user for display on the admin panel.
624+
625+
This controls the display of the user info in the top right of the admin panel and makes having a logout button possible.
626+
"""
627+
user = request.state.user
628+
if not user:
629+
return None
630+
631+
return AdminUser(username=user["name"], photo_url=None)
632+
633+
559634
def register_admin_panel(app: FastAPI, engine: AsyncEngine) -> None:
560635
"""
561636
Create and register an admin panel for the FastAPI app.
@@ -565,11 +640,12 @@ def register_admin_panel(app: FastAPI, engine: AsyncEngine) -> None:
565640
admin.add_view(UserView(UserDB, icon="fas fa-user", label="Users", identity="user"))
566641
admin.add_view(ProjectView(ProjectDB, icon="fas fa-folder", label="Projects", identity="project"))
567642
admin.add_view(ProjectMembershipView(ProjectMembershipDB, icon="fas fa-link", label="Project Memberships"))
643+
admin.add_view(ProjectVersionsView(ProjectVersionDB, icon="fas fa-history", label="Project Versions"))
568644
admin.add_view(RevokedTokenView(RevokedTokenDB, icon="fas fa-ban", label="Revoked Tokens"))
569645
admin.add_view(TaskHistoryView(TaskHistoryDB, icon="fas fa-history", label="Task History"))
570646
admin.add_view(
571647
CeleryTaskMetaView(CeleryTaskMeta, icon="fas fa-tasks", label="Celery Task Meta", identity="celery-meta")
572648
)
573-
admin.add_view(TaskStartedAtView(TaskStartedAtDB, icon="fas fa-clock", label="Task Started At"))
574649

650+
admin.add_view(TaskStartedAtView(TaskStartedAtDB, icon="fas fa-clock", label="Task Started At"))
575651
admin.mount_to(app)

0 commit comments

Comments
 (0)