opencloning-db 1.4.0__py3-none-any.whl
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/__init__.py +3 -0
- opencloning_db/api.py +60 -0
- opencloning_db/apimodels.py +335 -0
- opencloning_db/auth/__init__.py +1 -0
- opencloning_db/auth/security.py +39 -0
- opencloning_db/combined.py +28 -0
- opencloning_db/config.py +92 -0
- opencloning_db/context.py +15 -0
- opencloning_db/db.py +149 -0
- opencloning_db/deps.py +47 -0
- opencloning_db/init_db/cre_lox_recombination.json +145 -0
- opencloning_db/init_db/crispr_hdr.json +152 -0
- opencloning_db/init_db/example_sequencing.json +31 -0
- opencloning_db/init_db/gateway.json +198 -0
- opencloning_db/init_db/gibson_assembly.json +188 -0
- opencloning_db/init_db/golden_gate.json +313 -0
- opencloning_db/init_db/homologous_recombination.json +141 -0
- opencloning_db/init_db/restriction_ligation_assembly.json +139 -0
- opencloning_db/init_db/restriction_then_ligation.json +195 -0
- opencloning_db/init_db/sequencing_data/TN9W63_1_pREX0008.ab1 +0 -0
- opencloning_db/init_db/sequencing_data/TN9W63_1_pREX0008.gbk +226 -0
- opencloning_db/init_db/sequencing_data/TN9W63_1_pREX0008_mutations_added.fasta +71 -0
- opencloning_db/init_db/templateless_PCR.json +71 -0
- opencloning_db/init_db.py +199 -0
- opencloning_db/models.py +831 -0
- opencloning_db/routers/__init__.py +1 -0
- opencloning_db/routers/auth.py +90 -0
- opencloning_db/routers/lines.py +261 -0
- opencloning_db/routers/primers.py +347 -0
- opencloning_db/routers/sequence_samples.py +116 -0
- opencloning_db/routers/sequences.py +823 -0
- opencloning_db/routers/tags.py +246 -0
- opencloning_db/routers/template_sequences.py +28 -0
- opencloning_db/routers/test_tools.py +43 -0
- opencloning_db/routers/workspaces.py +102 -0
- opencloning_db/utils.py +22 -0
- opencloning_db/workspace_auth.py +41 -0
- opencloning_db/workspace_deps.py +161 -0
- opencloning_db-1.4.0.dist-info/METADATA +91 -0
- opencloning_db-1.4.0.dist-info/RECORD +41 -0
- opencloning_db-1.4.0.dist-info/WHEEL +4 -0
opencloning_db/api.py
ADDED
|
@@ -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
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Parent application that serves OpenCloning and opencloning-db under separate roots."""
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
|
|
5
|
+
from opencloning.main import create_app as create_cloning_app
|
|
6
|
+
|
|
7
|
+
from opencloning_db.api import create_app as create_db_app
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
app = FastAPI(
|
|
11
|
+
title='OpenCloning Combined API',
|
|
12
|
+
docs_url=None,
|
|
13
|
+
redoc_url=None,
|
|
14
|
+
openapi_url=None,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
app.mount('/cloning', create_cloning_app())
|
|
18
|
+
app.mount('/db', create_db_app())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.get('/')
|
|
22
|
+
async def root() -> dict[str, str]:
|
|
23
|
+
return {
|
|
24
|
+
'cloning': '/cloning',
|
|
25
|
+
'cloning_docs': '/cloning/docs',
|
|
26
|
+
'db': '/db',
|
|
27
|
+
'db_docs': '/db/docs',
|
|
28
|
+
}
|
opencloning_db/config.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Application configuration.
|
|
3
|
+
|
|
4
|
+
Runtime config is loaded lazily from environment variables via ``get_config()``.
|
|
5
|
+
Tests and other callers can still instantiate ``Config`` directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field, field_validator
|
|
11
|
+
from sqlalchemy.engine import make_url
|
|
12
|
+
|
|
13
|
+
_ENV_TO_FIELD = {
|
|
14
|
+
'OPENCLONING_DATABASE_URL': 'database_url',
|
|
15
|
+
'OPENCLONING_SEQUENCE_FILES_DIR': 'sequence_files_dir',
|
|
16
|
+
'OPENCLONING_SEQUENCING_FILES_DIR': 'sequencing_files_dir',
|
|
17
|
+
'OPENCLONING_JWT_SECRET': 'jwt_secret',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_bool(value: str | bool) -> bool:
|
|
22
|
+
return value in {'1', 'TRUE', 'true', 'True', True}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load_config_from_env() -> 'Config':
|
|
26
|
+
missing_vars = [env_name for env_name in _ENV_TO_FIELD if not os.environ.get(env_name)]
|
|
27
|
+
if missing_vars:
|
|
28
|
+
missing = ', '.join(missing_vars)
|
|
29
|
+
raise RuntimeError(
|
|
30
|
+
'Missing required OpenCloning environment variables: ' f'{missing}. For local development load .env.dev'
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
return Config(
|
|
34
|
+
database_url=os.environ['OPENCLONING_DATABASE_URL'],
|
|
35
|
+
sequence_files_dir=os.environ['OPENCLONING_SEQUENCE_FILES_DIR'],
|
|
36
|
+
sequencing_files_dir=os.environ['OPENCLONING_SEQUENCING_FILES_DIR'],
|
|
37
|
+
jwt_secret=os.environ['OPENCLONING_JWT_SECRET'],
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Config(BaseModel):
|
|
42
|
+
"""OpenCloning database configuration."""
|
|
43
|
+
|
|
44
|
+
@field_validator('database_url')
|
|
45
|
+
@classmethod
|
|
46
|
+
def _validate_database_url(cls, value: str) -> str:
|
|
47
|
+
url = make_url(value)
|
|
48
|
+
if url.get_backend_name() != 'postgresql':
|
|
49
|
+
raise ValueError('Only PostgreSQL database URLs are supported.')
|
|
50
|
+
if url.drivername != 'postgresql+psycopg':
|
|
51
|
+
raise ValueError(
|
|
52
|
+
'Only PostgreSQL database URLs using the psycopg driver are supported ' '(postgresql+psycopg://...).'
|
|
53
|
+
)
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
database_url: str = Field(
|
|
57
|
+
description='SQLAlchemy PostgreSQL URL using the psycopg (v3) driver (postgresql+psycopg://...)',
|
|
58
|
+
)
|
|
59
|
+
sequence_files_dir: str = Field(
|
|
60
|
+
description='Directory for storing sequence GenBank files',
|
|
61
|
+
)
|
|
62
|
+
sequencing_files_dir: str = Field(
|
|
63
|
+
description='Directory for storing uploaded sequencing files (ab1, fasta, etc.)',
|
|
64
|
+
)
|
|
65
|
+
jwt_secret: str = Field(
|
|
66
|
+
description='HS256 signing key for JWT access tokens',
|
|
67
|
+
)
|
|
68
|
+
jwt_algorithm: str = Field(default='HS256', description='JWT signing algorithm')
|
|
69
|
+
access_token_expire_minutes: int = Field(
|
|
70
|
+
default=60,
|
|
71
|
+
ge=1,
|
|
72
|
+
description='Access token lifetime in minutes',
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
config: Config | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _peek_config() -> Config | None:
|
|
80
|
+
return config
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_config() -> Config:
|
|
84
|
+
global config
|
|
85
|
+
if config is None:
|
|
86
|
+
config = _load_config_from_env()
|
|
87
|
+
return config
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def set_config(new_config: Config | None) -> None:
|
|
91
|
+
global config
|
|
92
|
+
config = new_config
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Lightweight value objects shared between the auth, persistence, and model layers."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from opencloning_db.models import User
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class WriteContext:
|
|
12
|
+
"""Identity for any write/create operation: who is acting, in which workspace."""
|
|
13
|
+
|
|
14
|
+
user: 'User'
|
|
15
|
+
workspace_id: int
|