opencloning-db 1.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opencloning_db-1.4.0/.gitignore +30 -0
- opencloning_db-1.4.0/CHANGELOG.md +13 -0
- opencloning_db-1.4.0/PKG-INFO +91 -0
- opencloning_db-1.4.0/README.md +69 -0
- opencloning_db-1.4.0/pyproject.toml +48 -0
- opencloning_db-1.4.0/src/opencloning_db/__init__.py +3 -0
- opencloning_db-1.4.0/src/opencloning_db/api.py +60 -0
- opencloning_db-1.4.0/src/opencloning_db/apimodels.py +335 -0
- opencloning_db-1.4.0/src/opencloning_db/auth/__init__.py +1 -0
- opencloning_db-1.4.0/src/opencloning_db/auth/security.py +39 -0
- opencloning_db-1.4.0/src/opencloning_db/combined.py +28 -0
- opencloning_db-1.4.0/src/opencloning_db/config.py +92 -0
- opencloning_db-1.4.0/src/opencloning_db/context.py +15 -0
- opencloning_db-1.4.0/src/opencloning_db/db.py +149 -0
- opencloning_db-1.4.0/src/opencloning_db/deps.py +47 -0
- opencloning_db-1.4.0/src/opencloning_db/init_db/cre_lox_recombination.json +145 -0
- opencloning_db-1.4.0/src/opencloning_db/init_db/crispr_hdr.json +152 -0
- opencloning_db-1.4.0/src/opencloning_db/init_db/example_sequencing.json +31 -0
- opencloning_db-1.4.0/src/opencloning_db/init_db/gateway.json +198 -0
- opencloning_db-1.4.0/src/opencloning_db/init_db/gibson_assembly.json +188 -0
- opencloning_db-1.4.0/src/opencloning_db/init_db/golden_gate.json +313 -0
- opencloning_db-1.4.0/src/opencloning_db/init_db/homologous_recombination.json +141 -0
- opencloning_db-1.4.0/src/opencloning_db/init_db/restriction_ligation_assembly.json +139 -0
- opencloning_db-1.4.0/src/opencloning_db/init_db/restriction_then_ligation.json +195 -0
- opencloning_db-1.4.0/src/opencloning_db/init_db/sequencing_data/TN9W63_1_pREX0008.ab1 +0 -0
- opencloning_db-1.4.0/src/opencloning_db/init_db/sequencing_data/TN9W63_1_pREX0008.gbk +226 -0
- opencloning_db-1.4.0/src/opencloning_db/init_db/sequencing_data/TN9W63_1_pREX0008_mutations_added.fasta +71 -0
- opencloning_db-1.4.0/src/opencloning_db/init_db/templateless_PCR.json +71 -0
- opencloning_db-1.4.0/src/opencloning_db/init_db.py +199 -0
- opencloning_db-1.4.0/src/opencloning_db/models.py +831 -0
- opencloning_db-1.4.0/src/opencloning_db/routers/__init__.py +1 -0
- opencloning_db-1.4.0/src/opencloning_db/routers/auth.py +90 -0
- opencloning_db-1.4.0/src/opencloning_db/routers/lines.py +261 -0
- opencloning_db-1.4.0/src/opencloning_db/routers/primers.py +347 -0
- opencloning_db-1.4.0/src/opencloning_db/routers/sequence_samples.py +116 -0
- opencloning_db-1.4.0/src/opencloning_db/routers/sequences.py +823 -0
- opencloning_db-1.4.0/src/opencloning_db/routers/tags.py +246 -0
- opencloning_db-1.4.0/src/opencloning_db/routers/template_sequences.py +28 -0
- opencloning_db-1.4.0/src/opencloning_db/routers/test_tools.py +43 -0
- opencloning_db-1.4.0/src/opencloning_db/routers/workspaces.py +102 -0
- opencloning_db-1.4.0/src/opencloning_db/utils.py +22 -0
- opencloning_db-1.4.0/src/opencloning_db/workspace_auth.py +41 -0
- opencloning_db-1.4.0/src/opencloning_db/workspace_deps.py +161 -0
- opencloning_db-1.4.0/tests/__init__.py +1 -0
- opencloning_db-1.4.0/tests/cloning_strategy_examples.py +30 -0
- opencloning_db-1.4.0/tests/conftest.py +88 -0
- opencloning_db-1.4.0/tests/helpers.py +264 -0
- opencloning_db-1.4.0/tests/test_api_models.py +17 -0
- opencloning_db-1.4.0/tests/test_auth.py +231 -0
- opencloning_db-1.4.0/tests/test_combined.py +36 -0
- opencloning_db-1.4.0/tests/test_init_db.py +9 -0
- opencloning_db-1.4.0/tests/test_lines.py +703 -0
- opencloning_db-1.4.0/tests/test_models.py +968 -0
- opencloning_db-1.4.0/tests/test_primers.py +864 -0
- opencloning_db-1.4.0/tests/test_sequence_samples.py +380 -0
- opencloning_db-1.4.0/tests/test_sequences.py +1782 -0
- opencloning_db-1.4.0/tests/test_tags.py +530 -0
- opencloning_db-1.4.0/tests/test_template_sequences.py +110 -0
- opencloning_db-1.4.0/tests/test_test_tools.py +61 -0
- opencloning_db-1.4.0/tests/test_utils.py +26 -0
- opencloning_db-1.4.0/tests/test_workspace_deps.py +168 -0
- opencloning_db-1.4.0/tests/test_workspaces.py +191 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
*.pyc
|
|
2
|
+
/__pycache__
|
|
3
|
+
.DS_Store
|
|
4
|
+
.ipynb_checkpoints
|
|
5
|
+
/.venv
|
|
6
|
+
|
|
7
|
+
# Test coverage
|
|
8
|
+
.coverage
|
|
9
|
+
coverage.xml
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Frontend build
|
|
13
|
+
frontend/
|
|
14
|
+
|
|
15
|
+
# Env secrets
|
|
16
|
+
.env.secret
|
|
17
|
+
|
|
18
|
+
batch_cloning_output/
|
|
19
|
+
.pytest_cache/
|
|
20
|
+
|
|
21
|
+
# build artifacts
|
|
22
|
+
dist/
|
|
23
|
+
|
|
24
|
+
bin/
|
|
25
|
+
|
|
26
|
+
# dummy certs for testing
|
|
27
|
+
certs/
|
|
28
|
+
|
|
29
|
+
# development database
|
|
30
|
+
dev_database/
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [1.4.0](https://github.com/OpenCloning/OpenCloning_backend/compare/opencloning-db-v1.3.9...opencloning-db-v1.4.0) (2026-05-19)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* change opencloning-db docker-compose to mount file-storage ([#459](https://github.com/OpenCloning/OpenCloning_backend/issues/459)) ([e8653fd](https://github.com/OpenCloning/OpenCloning_backend/commit/e8653fd2c143018c1c232986a5258b2e38f811bc))
|
|
9
|
+
* use multiple workers in prod with gunicorn ([#461](https://github.com/OpenCloning/OpenCloning_backend/issues/461)) ([b9cc010](https://github.com/OpenCloning/OpenCloning_backend/commit/b9cc01024a9bcbfe33a657064f96cc0816052104))
|
|
10
|
+
|
|
11
|
+
## Changelog
|
|
12
|
+
|
|
13
|
+
All notable changes to this package will be documented in this file.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opencloning-db
|
|
3
|
+
Version: 1.4.0
|
|
4
|
+
Summary: Database for OpenCloning, a web application to generate molecular cloning strategies in json format, and share them with others.
|
|
5
|
+
Project-URL: Repository, https://github.com/OpenCloning/OpenCloning_backend
|
|
6
|
+
Author-email: Manuel Lera-Ramirez <manulera14@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Python: <4,>=3.11
|
|
9
|
+
Requires-Dist: fastapi-pagination<0.16,>=0.15.10
|
|
10
|
+
Requires-Dist: fastapi>=0.135.3
|
|
11
|
+
Requires-Dist: gunicorn>=26.0.0
|
|
12
|
+
Requires-Dist: opencloning-linkml>=1.0.0
|
|
13
|
+
Requires-Dist: opencloning==1.3.9
|
|
14
|
+
Requires-Dist: psycopg[binary]<4,>=3.2
|
|
15
|
+
Requires-Dist: pwdlib[argon2]<0.3,>=0.2.1
|
|
16
|
+
Requires-Dist: pydantic[email]>=2.7.1
|
|
17
|
+
Requires-Dist: pydna>=5.5.11
|
|
18
|
+
Requires-Dist: pyjwt<3,>=2.10.1
|
|
19
|
+
Requires-Dist: sqlalchemy<3,>=2.0.41
|
|
20
|
+
Requires-Dist: starlette>=1.0.0
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# opencloning-db
|
|
24
|
+
|
|
25
|
+
`opencloning-db` is the database/API companion package for the OpenCloning backend. It provides the app and local data workflows used for OpenCloning database features.
|
|
26
|
+
|
|
27
|
+
## Run locally
|
|
28
|
+
|
|
29
|
+
From the repository root:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Install or update workspace dependencies
|
|
33
|
+
uv sync
|
|
34
|
+
|
|
35
|
+
# If you are using mac, you may have to stop any local Postgres instances running on port 5432
|
|
36
|
+
brew services stop postgresql
|
|
37
|
+
|
|
38
|
+
# Start local Postgres with dev/test/e2e databases
|
|
39
|
+
docker compose -f docker/docker-compose.postgres.yml up -d
|
|
40
|
+
|
|
41
|
+
# Load required local runtime config
|
|
42
|
+
source .env.dev
|
|
43
|
+
|
|
44
|
+
# Seed the local baseline
|
|
45
|
+
uv run opencloning-cli db seed
|
|
46
|
+
|
|
47
|
+
# Run both the cloning and the database API - this what the OpenCloningDB frontend expects
|
|
48
|
+
uv run uvicorn opencloning_db.combined:app --reload --reload-exclude='.venv'
|
|
49
|
+
|
|
50
|
+
# Run the opencloning-db API (only database, not cloning. This is not used when running with the frontend)
|
|
51
|
+
uv run uvicorn opencloning_db.api:app --reload --reload-exclude='.venv'
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
That will serve the cloning API at [http://127.0.0.1:8000/cloning](http://127.0.0.1:8000/cloning) and the database API at [http://127.0.0.1:8001/db](http://127.0.0.1:8001/db). That's what the OpenCloningDB frontend expects.
|
|
55
|
+
|
|
56
|
+
## Running tests locally
|
|
57
|
+
|
|
58
|
+
From the repository root:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# Install or update workspace dependencies
|
|
62
|
+
uv sync
|
|
63
|
+
|
|
64
|
+
# Run the tests
|
|
65
|
+
uv run pytest packages/opencloning-db/tests -v -ks
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Frontend testing
|
|
69
|
+
|
|
70
|
+
Frontend testing using the database requires reseting after tests that modify the database. You can do this by calling the `/__test/reset-db` endpoint with the `X-Test-Reset-Token` header set to `RESET-TOKEN`. That endpoint is only available if the `OPENCLONING_TESTING` environment variable is set to `1`.
|
|
71
|
+
|
|
72
|
+
## Building and running the Docker image
|
|
73
|
+
|
|
74
|
+
The Dockerfile is shared with the cloning app, and the build arg `APP_TARGET` determines which app to build. So you can build the image by running:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
docker build -f docker/opencloning.Dockerfile --build-arg APP_TARGET=db -t manulera/opencloningbackend-db .
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Then run it for development:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
# Create the file storage directories
|
|
84
|
+
mkdir -p docker/file_storage/sequence_files docker/file_storage/sequencing_files
|
|
85
|
+
|
|
86
|
+
# Run the containers
|
|
87
|
+
docker compose \
|
|
88
|
+
-f docker/docker-compose.postgres.yml \
|
|
89
|
+
-f docker/docker-compose.opencloning-db.yml \
|
|
90
|
+
up -d
|
|
91
|
+
```
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# opencloning-db
|
|
2
|
+
|
|
3
|
+
`opencloning-db` is the database/API companion package for the OpenCloning backend. It provides the app and local data workflows used for OpenCloning database features.
|
|
4
|
+
|
|
5
|
+
## Run locally
|
|
6
|
+
|
|
7
|
+
From the repository root:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Install or update workspace dependencies
|
|
11
|
+
uv sync
|
|
12
|
+
|
|
13
|
+
# If you are using mac, you may have to stop any local Postgres instances running on port 5432
|
|
14
|
+
brew services stop postgresql
|
|
15
|
+
|
|
16
|
+
# Start local Postgres with dev/test/e2e databases
|
|
17
|
+
docker compose -f docker/docker-compose.postgres.yml up -d
|
|
18
|
+
|
|
19
|
+
# Load required local runtime config
|
|
20
|
+
source .env.dev
|
|
21
|
+
|
|
22
|
+
# Seed the local baseline
|
|
23
|
+
uv run opencloning-cli db seed
|
|
24
|
+
|
|
25
|
+
# Run both the cloning and the database API - this what the OpenCloningDB frontend expects
|
|
26
|
+
uv run uvicorn opencloning_db.combined:app --reload --reload-exclude='.venv'
|
|
27
|
+
|
|
28
|
+
# Run the opencloning-db API (only database, not cloning. This is not used when running with the frontend)
|
|
29
|
+
uv run uvicorn opencloning_db.api:app --reload --reload-exclude='.venv'
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
That will serve the cloning API at [http://127.0.0.1:8000/cloning](http://127.0.0.1:8000/cloning) and the database API at [http://127.0.0.1:8001/db](http://127.0.0.1:8001/db). That's what the OpenCloningDB frontend expects.
|
|
33
|
+
|
|
34
|
+
## Running tests locally
|
|
35
|
+
|
|
36
|
+
From the repository root:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Install or update workspace dependencies
|
|
40
|
+
uv sync
|
|
41
|
+
|
|
42
|
+
# Run the tests
|
|
43
|
+
uv run pytest packages/opencloning-db/tests -v -ks
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Frontend testing
|
|
47
|
+
|
|
48
|
+
Frontend testing using the database requires reseting after tests that modify the database. You can do this by calling the `/__test/reset-db` endpoint with the `X-Test-Reset-Token` header set to `RESET-TOKEN`. That endpoint is only available if the `OPENCLONING_TESTING` environment variable is set to `1`.
|
|
49
|
+
|
|
50
|
+
## Building and running the Docker image
|
|
51
|
+
|
|
52
|
+
The Dockerfile is shared with the cloning app, and the build arg `APP_TARGET` determines which app to build. So you can build the image by running:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
docker build -f docker/opencloning.Dockerfile --build-arg APP_TARGET=db -t manulera/opencloningbackend-db .
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Then run it for development:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# Create the file storage directories
|
|
62
|
+
mkdir -p docker/file_storage/sequence_files docker/file_storage/sequencing_files
|
|
63
|
+
|
|
64
|
+
# Run the containers
|
|
65
|
+
docker compose \
|
|
66
|
+
-f docker/docker-compose.postgres.yml \
|
|
67
|
+
-f docker/docker-compose.opencloning-db.yml \
|
|
68
|
+
up -d
|
|
69
|
+
```
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "opencloning-db"
|
|
3
|
+
version = "1.4.0"
|
|
4
|
+
description = "Database for OpenCloning, a web application to generate molecular cloning strategies in json format, and share them with others."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Manuel Lera-Ramirez", email = "manulera14@gmail.com" },
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.11,<4"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"opencloning==1.3.9", # x-release-please-version
|
|
13
|
+
"fastapi>=0.135.3",
|
|
14
|
+
"fastapi-pagination>=0.15.10,<0.16",
|
|
15
|
+
"opencloning-linkml>=1.0.0",
|
|
16
|
+
"pydantic[email]>=2.7.1",
|
|
17
|
+
"psycopg[binary]>=3.2,<4",
|
|
18
|
+
"sqlalchemy>=2.0.41,<3",
|
|
19
|
+
"PyJWT>=2.10.1,<3",
|
|
20
|
+
"pwdlib[argon2]>=0.2.1,<0.3",
|
|
21
|
+
"pydna>=5.5.11",
|
|
22
|
+
"starlette>=1.0.0",
|
|
23
|
+
"gunicorn>=26.0.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Repository = "https://github.com/OpenCloning/OpenCloning_backend"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["hatchling"]
|
|
31
|
+
build-backend = "hatchling.build"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/opencloning_db"]
|
|
35
|
+
|
|
36
|
+
[tool.uv.sources]
|
|
37
|
+
opencloning = { workspace = true }
|
|
38
|
+
|
|
39
|
+
[tool.black]
|
|
40
|
+
skip-string-normalization = true
|
|
41
|
+
line-length = 119
|
|
42
|
+
|
|
43
|
+
[tool.deptry]
|
|
44
|
+
known_first_party = ["opencloning_db"]
|
|
45
|
+
per_rule_ignores = { DEP002 = ["psycopg", "gunicorn"] }
|
|
46
|
+
|
|
47
|
+
[tool.hatch.metadata]
|
|
48
|
+
allow-direct-references = true
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenCloning API - main FastAPI application.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
7
|
+
from fastapi_pagination import add_pagination
|
|
8
|
+
import os
|
|
9
|
+
from starlette.types import ASGIApp
|
|
10
|
+
|
|
11
|
+
from opencloning.app_settings import settings as opencloning_settings
|
|
12
|
+
from opencloning_db.config import parse_bool
|
|
13
|
+
from opencloning_db.routers import (
|
|
14
|
+
auth,
|
|
15
|
+
lines,
|
|
16
|
+
primers,
|
|
17
|
+
sequence_samples,
|
|
18
|
+
sequences,
|
|
19
|
+
template_sequences,
|
|
20
|
+
tags,
|
|
21
|
+
test_tools,
|
|
22
|
+
workspaces,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_fastapi_app() -> FastAPI:
|
|
27
|
+
app = FastAPI(title='OpenCloningDB API')
|
|
28
|
+
|
|
29
|
+
app.include_router(auth.router)
|
|
30
|
+
app.include_router(workspaces.router)
|
|
31
|
+
app.include_router(tags.router)
|
|
32
|
+
app.include_router(primers.router)
|
|
33
|
+
app.include_router(sequences.router)
|
|
34
|
+
app.include_router(template_sequences.router)
|
|
35
|
+
app.include_router(lines.router)
|
|
36
|
+
app.include_router(sequence_samples.router)
|
|
37
|
+
if parse_bool(os.getenv('OPENCLONING_TESTING', False)):
|
|
38
|
+
app.include_router(test_tools.router)
|
|
39
|
+
|
|
40
|
+
# Register routes first so Page[...] endpoints get pagination_ctx.
|
|
41
|
+
add_pagination(app)
|
|
42
|
+
return app
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def wrap_with_cors(app: ASGIApp) -> ASGIApp:
|
|
46
|
+
return CORSMiddleware(
|
|
47
|
+
app,
|
|
48
|
+
allow_origins=opencloning_settings.ALLOWED_ORIGINS,
|
|
49
|
+
allow_credentials=True,
|
|
50
|
+
allow_methods=['*'],
|
|
51
|
+
allow_headers=['*'],
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def create_app() -> ASGIApp:
|
|
56
|
+
return wrap_with_cors(create_fastapi_app())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
fastapi_app = create_fastapi_app()
|
|
60
|
+
app = wrap_with_cors(fastapi_app)
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Shared Pydantic request/response models for the API."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
|
6
|
+
|
|
7
|
+
import opencloning_linkml.datamodel.models as opencloning_models
|
|
8
|
+
from opencloning_db.models import BaseSequence, SequenceType, Sequence, Primer, Line, SequenceInLine
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ApiModel(BaseModel):
|
|
12
|
+
"""Reject unknown JSON keys on all API request/response models in this module."""
|
|
13
|
+
|
|
14
|
+
model_config = ConfigDict(extra='forbid')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# --- Auth (OAuth2 password + JWT) ---
|
|
18
|
+
class Token(ApiModel):
|
|
19
|
+
access_token: str
|
|
20
|
+
token_type: str = 'bearer'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UserPublic(ApiModel):
|
|
24
|
+
id: int
|
|
25
|
+
email: str
|
|
26
|
+
display_name: str | None
|
|
27
|
+
is_instance_admin: bool
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UserRef(ApiModel):
|
|
31
|
+
"""Minimal user reference for embedding in resource responses."""
|
|
32
|
+
|
|
33
|
+
id: int
|
|
34
|
+
display_name: str | None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class WorkspaceRef(ApiModel):
|
|
38
|
+
id: int
|
|
39
|
+
name: str
|
|
40
|
+
role: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class WorkspaceCreate(ApiModel):
|
|
44
|
+
name: str = Field(min_length=1)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class WorkspaceRename(ApiModel):
|
|
48
|
+
name: str = Field(min_length=1)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RegisterBody(ApiModel):
|
|
52
|
+
email: EmailStr
|
|
53
|
+
password: str = Field(min_length=1)
|
|
54
|
+
display_name: str | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --- Sequence sample ---
|
|
58
|
+
class SequenceSampleCreate(ApiModel):
|
|
59
|
+
uid: str
|
|
60
|
+
sequence_id: int
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SequenceSampleUpdate(ApiModel):
|
|
64
|
+
sequence_id: int
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SequenceSampleRead(ApiModel):
|
|
68
|
+
id: int
|
|
69
|
+
uid: str
|
|
70
|
+
sequence_id: int
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SequenceSampleCreated(ApiModel):
|
|
74
|
+
id: int
|
|
75
|
+
uid: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# --- Tags ---
|
|
79
|
+
class TagCreate(ApiModel):
|
|
80
|
+
name: str = Field(min_length=1)
|
|
81
|
+
|
|
82
|
+
@field_validator('name', mode='before')
|
|
83
|
+
@classmethod
|
|
84
|
+
def strip_tag_name(cls, v: object) -> object:
|
|
85
|
+
# We do it before to strip before counting the length of the string
|
|
86
|
+
if isinstance(v, str):
|
|
87
|
+
return v.strip()
|
|
88
|
+
return v
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TagRead(ApiModel):
|
|
92
|
+
id: int
|
|
93
|
+
name: str
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class EntityTagAttach(ApiModel):
|
|
97
|
+
tag_id: int
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --- Entity refs ---
|
|
101
|
+
class InputEntityRef(ApiModel):
|
|
102
|
+
id: int
|
|
103
|
+
type: str
|
|
104
|
+
name: str | None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class SequencingFileRef(ApiModel):
|
|
108
|
+
id: int
|
|
109
|
+
original_name: str
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# --- Common responses ---
|
|
113
|
+
class IdResponse(ApiModel):
|
|
114
|
+
id: int
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class RemovedResponse(ApiModel):
|
|
118
|
+
removed: int
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class DeletedResponse(ApiModel):
|
|
122
|
+
deleted: int
|
|
123
|
+
data: dict | None = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# --- Cloning strategy ---
|
|
127
|
+
class CloningStrategyIdMapping(ApiModel):
|
|
128
|
+
localId: int
|
|
129
|
+
databaseId: int
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class CloningStrategyResponse(ApiModel):
|
|
133
|
+
id: int
|
|
134
|
+
mappings: list[CloningStrategyIdMapping]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# --- Sequence / primer refs ---
|
|
138
|
+
class SequenceRef(ApiModel):
|
|
139
|
+
id: int
|
|
140
|
+
type: str
|
|
141
|
+
name: str | None
|
|
142
|
+
sequence_type: SequenceType
|
|
143
|
+
tags: list[TagRead] = []
|
|
144
|
+
sample_uids: list[str] = []
|
|
145
|
+
seguid: str | None = None
|
|
146
|
+
created_at: datetime
|
|
147
|
+
created_by: UserRef
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class SequenceUpdate(ApiModel):
|
|
151
|
+
name: str | None = None
|
|
152
|
+
sequence_type: SequenceType | None = None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class TemplateSequenceCreate(ApiModel):
|
|
156
|
+
name: str = Field(min_length=1)
|
|
157
|
+
sequence_type: SequenceType
|
|
158
|
+
|
|
159
|
+
@field_validator('name', mode='before')
|
|
160
|
+
@classmethod
|
|
161
|
+
def strip_name(cls, v: object) -> object:
|
|
162
|
+
if isinstance(v, str):
|
|
163
|
+
return v.strip()
|
|
164
|
+
return v
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class PrimerUpdate(ApiModel):
|
|
168
|
+
name: str | None = None
|
|
169
|
+
uid: str | None = None
|
|
170
|
+
|
|
171
|
+
@field_validator('uid', mode='before')
|
|
172
|
+
@classmethod
|
|
173
|
+
def strip_uid(cls, v: object) -> object:
|
|
174
|
+
if isinstance(v, str):
|
|
175
|
+
return v.strip()
|
|
176
|
+
return v
|
|
177
|
+
|
|
178
|
+
@field_validator('name', mode='before')
|
|
179
|
+
@classmethod
|
|
180
|
+
def strip_name(cls, v: object) -> object:
|
|
181
|
+
if isinstance(v, str):
|
|
182
|
+
stripped_name = v.strip()
|
|
183
|
+
if len(stripped_name) < 2:
|
|
184
|
+
raise ValueError('Primer name must be at least 2 characters long')
|
|
185
|
+
return stripped_name
|
|
186
|
+
return v
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class PrimerCreate(ApiModel):
|
|
190
|
+
name: str
|
|
191
|
+
uid: str | None = None
|
|
192
|
+
sequence: str = Field(min_length=2, pattern=r'^[ACGTacgt]+$')
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class PrimerBulkSubmission(ApiModel):
|
|
196
|
+
name: str
|
|
197
|
+
uid: str | None = None
|
|
198
|
+
sequence: str
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class PrimerBulkRow(PrimerBulkSubmission):
|
|
202
|
+
sequence_invalid: bool
|
|
203
|
+
name_exists: bool
|
|
204
|
+
sequence_exists: bool
|
|
205
|
+
uid_exists: bool
|
|
206
|
+
name_duplicated: bool
|
|
207
|
+
sequence_duplicated: bool
|
|
208
|
+
uid_duplicated: bool
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class PrimerRef(ApiModel):
|
|
212
|
+
id: int
|
|
213
|
+
name: str | None
|
|
214
|
+
sequence: str
|
|
215
|
+
uid: str | None = None
|
|
216
|
+
tags: list[TagRead] = []
|
|
217
|
+
created_at: datetime
|
|
218
|
+
created_by: UserRef
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class SequenceSampleWithSequence(ApiModel):
|
|
222
|
+
id: int
|
|
223
|
+
uid: str
|
|
224
|
+
sequence_id: int
|
|
225
|
+
sequence: SequenceRef
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# --- Line ---
|
|
229
|
+
class SequenceInLineRef(ApiModel):
|
|
230
|
+
"""Sequence in a line, including the SequenceInLine instance id."""
|
|
231
|
+
|
|
232
|
+
id: int
|
|
233
|
+
sequence: SequenceRef
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class LineRef(ApiModel):
|
|
237
|
+
id: int
|
|
238
|
+
uid: str
|
|
239
|
+
sequences_in_line: list[SequenceInLineRef]
|
|
240
|
+
parent_ids: list[int]
|
|
241
|
+
tags: list[TagRead]
|
|
242
|
+
created_at: datetime
|
|
243
|
+
created_by: UserRef
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class LineCreate(ApiModel):
|
|
247
|
+
uid: str
|
|
248
|
+
allele_ids: list[int] = []
|
|
249
|
+
plasmid_ids: list[int] = []
|
|
250
|
+
parent_ids: list[int] = []
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class LineUpdate(ApiModel):
|
|
254
|
+
uid: str | None = None
|
|
255
|
+
allele_ids: list[int] | None = None
|
|
256
|
+
plasmid_ids: list[int] | None = None
|
|
257
|
+
parent_ids: list[int] | None = None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _user_ref(user) -> UserRef | None:
|
|
261
|
+
if user is None:
|
|
262
|
+
return None
|
|
263
|
+
return UserRef(id=user.id, display_name=user.display_name)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def sequence_ref(sequence: BaseSequence) -> SequenceRef:
|
|
267
|
+
return SequenceRef(
|
|
268
|
+
id=sequence.id,
|
|
269
|
+
type=sequence.type,
|
|
270
|
+
name=sequence.name,
|
|
271
|
+
sequence_type=sequence.sequence_type,
|
|
272
|
+
tags=[TagRead(id=t.id, name=t.name) for t in sequence.tags],
|
|
273
|
+
sample_uids=sequence.sample_uids,
|
|
274
|
+
seguid=sequence.seguid if isinstance(sequence, Sequence) else None,
|
|
275
|
+
created_at=sequence.created_at,
|
|
276
|
+
created_by=_user_ref(sequence.created_by),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def primer_ref(primer: Primer) -> PrimerRef:
|
|
281
|
+
return PrimerRef(
|
|
282
|
+
id=primer.id,
|
|
283
|
+
name=primer.name,
|
|
284
|
+
sequence=primer.sequence,
|
|
285
|
+
uid=primer.uid,
|
|
286
|
+
tags=[TagRead(id=t.id, name=t.name) for t in primer.tags],
|
|
287
|
+
created_at=primer.created_at,
|
|
288
|
+
created_by=_user_ref(primer.created_by),
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def sequence_in_line_ref(sil: SequenceInLine) -> SequenceInLineRef:
|
|
293
|
+
"""Build a SequenceInLineRef from a SequenceInLine ORM instance."""
|
|
294
|
+
seq = sil.sequence
|
|
295
|
+
return SequenceInLineRef(
|
|
296
|
+
id=sil.id,
|
|
297
|
+
sequence=sequence_ref(seq),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def line_ref(line: Line) -> LineRef:
|
|
302
|
+
return LineRef(
|
|
303
|
+
id=line.id,
|
|
304
|
+
uid=line.uid,
|
|
305
|
+
sequences_in_line=[sequence_in_line_ref(sil) for sil in line.sequences_in_line],
|
|
306
|
+
parent_ids=line.parent_ids,
|
|
307
|
+
tags=[TagRead(id=tag.id, name=tag.name) for tag in line.tags],
|
|
308
|
+
created_at=line.created_at,
|
|
309
|
+
created_by=_user_ref(line.created_by),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class SequenceSearchResult(ApiModel):
|
|
314
|
+
sequence_ref: SequenceRef
|
|
315
|
+
sequence: opencloning_models.TextFileSequence
|
|
316
|
+
shift: int = 0
|
|
317
|
+
reverse_complemented: bool = False
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class SequenceValidationRow(ApiModel):
|
|
321
|
+
file_name: str
|
|
322
|
+
reading_error: bool
|
|
323
|
+
|
|
324
|
+
name: str | None = None
|
|
325
|
+
length: int | None = None
|
|
326
|
+
circular: bool | None = None
|
|
327
|
+
seguid: str | None = None
|
|
328
|
+
circularised_seguid: str | None = None
|
|
329
|
+
|
|
330
|
+
sequence_exists: bool | None = None
|
|
331
|
+
sequence_circularised_exists: bool | None = None
|
|
332
|
+
name_exists: bool | None = None
|
|
333
|
+
|
|
334
|
+
duplicated_seguid: bool | None = None
|
|
335
|
+
duplicated_name: bool | None = None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Authentication helpers (password hashing, JWT)."""
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Password hashing (pwdlib) and JWT access tokens (PyJWT)."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import jwt
|
|
7
|
+
from pwdlib import PasswordHash
|
|
8
|
+
|
|
9
|
+
from opencloning_db.config import Config
|
|
10
|
+
|
|
11
|
+
password_hasher = PasswordHash.recommended()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_password_hash(password: str) -> str:
|
|
15
|
+
return password_hasher.hash(password)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
19
|
+
return password_hasher.verify(plain_password, hashed_password)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def create_access_token(data: dict[str, Any], config: Config, expires_delta: timedelta) -> str:
|
|
23
|
+
to_encode = data.copy()
|
|
24
|
+
expire = datetime.now(timezone.utc) + expires_delta
|
|
25
|
+
to_encode.update({'exp': expire})
|
|
26
|
+
encoded_jwt = jwt.encode(
|
|
27
|
+
to_encode,
|
|
28
|
+
config.jwt_secret,
|
|
29
|
+
algorithm=config.jwt_algorithm,
|
|
30
|
+
)
|
|
31
|
+
return encoded_jwt
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def decode_access_token(token: str, config: Config) -> dict[str, Any]:
|
|
35
|
+
return jwt.decode(
|
|
36
|
+
token,
|
|
37
|
+
config.jwt_secret,
|
|
38
|
+
algorithms=[config.jwt_algorithm],
|
|
39
|
+
)
|