basic-python-project 0.0.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.
@@ -0,0 +1,55 @@
1
+ """Keycloak clients for user and admin operations."""
2
+
3
+ from keycloak import KeycloakOpenID, KeycloakOpenIDConnection, KeycloakAdmin
4
+
5
+ from meinewaldki_citizen_rest_service.constants import (
6
+ CLIENT_NAME,
7
+ GUEST_ADMIN_PASSWORD,
8
+ GUEST_ADMIN_REALM_NAME,
9
+ GUEST_ADMIN_USERNAME,
10
+ GUEST_USER_REALM_NAME,
11
+ KEYCLOAK_CERT_PATH,
12
+ KEYCLOAK_SERVER_URL,
13
+ REGISTERED_ADMIN_PASSWORD,
14
+ REGISTERED_ADMIN_REALM_NAME,
15
+ REGISTERED_ADMIN_USERNAME,
16
+ REGISTERED_USER_REALM_NAME,
17
+ )
18
+
19
+ _keycloak_verify: bool | str = KEYCLOAK_CERT_PATH if KEYCLOAK_CERT_PATH else True
20
+
21
+ guest_user_client = KeycloakOpenID(
22
+ server_url=KEYCLOAK_SERVER_URL,
23
+ client_id=CLIENT_NAME,
24
+ realm_name=GUEST_USER_REALM_NAME,
25
+ verify=_keycloak_verify,
26
+ )
27
+
28
+ guest_user_admin_connection = KeycloakOpenIDConnection(
29
+ server_url=KEYCLOAK_SERVER_URL,
30
+ username=GUEST_ADMIN_USERNAME,
31
+ password=GUEST_ADMIN_PASSWORD,
32
+ realm_name=GUEST_USER_REALM_NAME,
33
+ user_realm_name=GUEST_ADMIN_REALM_NAME,
34
+ verify=_keycloak_verify,
35
+ )
36
+
37
+ guest_user_admin = KeycloakAdmin(connection=guest_user_admin_connection)
38
+
39
+ registered_user_client = KeycloakOpenID(
40
+ server_url=KEYCLOAK_SERVER_URL,
41
+ client_id=CLIENT_NAME,
42
+ realm_name=REGISTERED_USER_REALM_NAME,
43
+ verify=_keycloak_verify,
44
+ )
45
+
46
+ registered_user_admin_connection = KeycloakOpenIDConnection(
47
+ server_url=KEYCLOAK_SERVER_URL,
48
+ username=REGISTERED_ADMIN_USERNAME,
49
+ password=REGISTERED_ADMIN_PASSWORD,
50
+ realm_name=REGISTERED_USER_REALM_NAME,
51
+ user_realm_name=REGISTERED_ADMIN_REALM_NAME,
52
+ verify=_keycloak_verify,
53
+ )
54
+
55
+ registered_user_admin = KeycloakAdmin(connection=registered_user_admin_connection)
@@ -0,0 +1,63 @@
1
+ """Main entry point for the MeineWaldKI Citizen REST API Service."""
2
+
3
+ import logging
4
+ import os
5
+
6
+ import sentry_sdk
7
+ import uvicorn
8
+ from fastapi import FastAPI
9
+ from sentry_sdk.integrations.fastapi import FastApiIntegration
10
+ from sentry_sdk.integrations.starlette import StarletteIntegration
11
+ from starlette.middleware import Middleware
12
+ from starlette.middleware.cors import CORSMiddleware
13
+
14
+ from meinewaldki_citizen_rest_service.constants import GLITCHTIP_DSN
15
+ from meinewaldki_citizen_rest_service.routers import guest_user_access, records, users
16
+
17
+ if GLITCHTIP_DSN:
18
+ sentry_sdk.init(
19
+ dsn=GLITCHTIP_DSN,
20
+ integrations=[
21
+ StarletteIntegration(),
22
+ FastApiIntegration(),
23
+ ],
24
+ # Capture 100 % of transactions in dev; lower this in production
25
+ traces_sample_rate=float(os.environ.get("SENTRY_TRACES_SAMPLE_RATE", "1.0")),
26
+ environment=os.environ.get("ENVIRONMENT", "development"),
27
+ release=os.environ.get("VERSION", "dev"),
28
+ )
29
+
30
+ app = FastAPI(
31
+ title="MeineWaldKI REST API Service",
32
+ description="TODO",
33
+ root_path="/citizen-api",
34
+ version=os.environ.get("VERSION", "dev"),
35
+ middleware=[
36
+ Middleware(
37
+ CORSMiddleware,
38
+ allow_origins=[
39
+ # Unsure what flutter uses as origin
40
+ "http://localhost:8100", # Unsecure http for local testing
41
+ "https://localhost:8100", # Secure https for local testing
42
+ "http://localhost", # Unsecure http for local testing
43
+ "https://localhost", # Secure https for local testing
44
+ "capacitor://localhost", # Capacitor (Ionics) apps
45
+ ],
46
+ allow_credentials=True,
47
+ allow_methods=["*"],
48
+ allow_headers=["*"],
49
+ )
50
+ ],
51
+ )
52
+
53
+ app.include_router(guest_user_access.router)
54
+ app.include_router(users.router)
55
+ app.include_router(records.router)
56
+
57
+ if __name__ == "__main__":
58
+ logger = logging.getLogger(__name__)
59
+
60
+ HOST = "localhost"
61
+ PORT = 5001
62
+ logger.info("Swagger UI is listening at: http://%s:%s/docs", HOST, PORT)
63
+ uvicorn.run(app, host="0.0.0.0", port=PORT)
@@ -0,0 +1,52 @@
1
+ """MinIO client and upload helper for image storage."""
2
+
3
+ import hashlib
4
+ import io
5
+
6
+ from minio import Minio
7
+ from PIL import Image
8
+
9
+ from meinewaldki_citizen_rest_service.constants import (
10
+ MINIO_ACCESS_KEY,
11
+ MINIO_BUCKET,
12
+ MINIO_ENDPOINT,
13
+ MINIO_SECRET_KEY,
14
+ MINIO_SECURE,
15
+ )
16
+
17
+ _client = Minio(
18
+ MINIO_ENDPOINT,
19
+ access_key=MINIO_ACCESS_KEY,
20
+ secret_key=MINIO_SECRET_KEY,
21
+ secure=MINIO_SECURE,
22
+ )
23
+
24
+
25
+ def upload_image(data: bytes, content_type: str) -> str:
26
+ """Uploads raw image bytes to MinIO.
27
+
28
+ The object name is the SHA-256 hash of the image pixel data, giving
29
+ content-addressable storage and natural deduplication.
30
+
31
+ Args:
32
+ data: Raw image bytes.
33
+ content_type: MIME type of the image (e.g. ``image/jpeg``).
34
+
35
+ Returns:
36
+ The SHA-256 hex digest used as the object name in MinIO.
37
+ """
38
+ img = Image.open(io.BytesIO(data))
39
+
40
+ sha256 = hashlib.sha256()
41
+ sha256.update(img.tobytes())
42
+ img_hash = sha256.hexdigest()
43
+
44
+ _client.put_object(
45
+ MINIO_BUCKET,
46
+ img_hash,
47
+ io.BytesIO(data),
48
+ length=len(data),
49
+ content_type=content_type,
50
+ )
51
+
52
+ return img_hash
File without changes
@@ -0,0 +1,44 @@
1
+ """Router for managing guest user access in the MeineWaldki Citizen REST Service."""
2
+
3
+ import uuid
4
+
5
+ from fastapi import APIRouter
6
+
7
+ from meinewaldki_citizen_rest_service.keycloak_clients import (
8
+ guest_user_admin,
9
+ guest_user_client,
10
+ )
11
+
12
+ router = APIRouter(
13
+ prefix="/guest-user",
14
+ tags=["guest-user"],
15
+ )
16
+
17
+
18
+ @router.get("/create")
19
+ async def create_guest_user():
20
+ """Creates a guest user in Keycloak and returns credentials.
21
+
22
+ Returns:
23
+ A dict with access and refresh tokens.
24
+ """
25
+ user_uuid = uuid.uuid4()
26
+ password_uuid = uuid.uuid4()
27
+ email = uuid.uuid4()
28
+
29
+ payload = {
30
+ "username": str(user_uuid),
31
+ "email": str(email) + "@" + str(email) + ".de",
32
+ "enabled": True,
33
+ "emailVerified": True,
34
+ "credentials": [{"value": str(password_uuid), "type": "password"}],
35
+ }
36
+
37
+ guest_user_admin.create_user(payload)
38
+
39
+ token = guest_user_client.token(str(user_uuid), str(password_uuid))
40
+
41
+ return {
42
+ "access_token": token["access_token"],
43
+ "refresh_token": token["refresh_token"],
44
+ }
@@ -0,0 +1,268 @@
1
+ """Router for submitting forest observation records."""
2
+
3
+ import asyncio
4
+ import uuid
5
+ from datetime import UTC, datetime
6
+ from typing import Annotated
7
+
8
+ import sentry_sdk
9
+ from fastapi import APIRouter, Depends, HTTPException, Response, UploadFile, status
10
+ from geoalchemy2.elements import WKTElement
11
+ from sqlalchemy import select, update
12
+ from sqlalchemy.dialects.postgresql import insert as pg_insert
13
+ from sqlalchemy.ext.asyncio import AsyncSession
14
+
15
+ from meinewaldki_citizen_rest_service.constants import (
16
+ ALLOWED_IMAGE_MIME_TYPES,
17
+ WGS84_SRID,
18
+ )
19
+ from meinewaldki_citizen_rest_service.database.engine import get_session
20
+ from meinewaldki_citizen_rest_service.database.models import Image, Record
21
+ from meinewaldki_citizen_rest_service.dependencies import get_current_user_id
22
+ from meinewaldki_citizen_rest_service.minio_client import upload_image as minio_upload
23
+ from meinewaldki_citizen_rest_service.schemas import (
24
+ ImageRead,
25
+ ImageSyncStatus,
26
+ RecordCreate,
27
+ RecordRead,
28
+ RecordSyncStatus,
29
+ )
30
+
31
+ router = APIRouter(
32
+ prefix="/records",
33
+ tags=["records"],
34
+ )
35
+
36
+
37
+ @router.post("", status_code=status.HTTP_201_CREATED)
38
+ async def create_record(
39
+ body: RecordCreate,
40
+ response: Response,
41
+ user_id: Annotated[uuid.UUID, Depends(get_current_user_id)],
42
+ session: Annotated[AsyncSession, Depends(get_session)],
43
+ ) -> RecordRead:
44
+ """Submits a forest observation record with image placeholders.
45
+
46
+ Idempotent — safe to retry after a lost response. Returns 201 on first
47
+ creation, 200 if the record already exists (identified by the unique
48
+ combination of client_record_id, user_id, and created_at).
49
+ The images are not uploaded yet — their sync_status signals that.
50
+ A separate upload endpoint will handle the binary data later.
51
+
52
+ Args:
53
+ body: Record data including image placeholders.
54
+ response: FastAPI response object for status code override.
55
+ user_id: Authenticated user's UUID from JWT.
56
+ session: Async database session.
57
+
58
+ Returns:
59
+ RecordRead with server-assigned IDs and image sync statuses.
60
+ """
61
+ original_location = None
62
+ if body.original_location is not None:
63
+ loc = body.original_location
64
+ original_location = WKTElement(f"POINT({loc.lon} {loc.lat})", srid=WGS84_SRID)
65
+
66
+ record_statement = (
67
+ pg_insert(Record) # type: ignore
68
+ .values(
69
+ client_record_id=body.client_record_id,
70
+ created_at=body.created_at,
71
+ hardware_data=body.hardware_data,
72
+ user_id=user_id,
73
+ original_location=original_location,
74
+ uploaded_at=datetime.now(UTC),
75
+ is_anonymized=False,
76
+ sync_status=RecordSyncStatus.pending,
77
+ )
78
+ .on_conflict_do_nothing(constraint="uq_record_client_user_created")
79
+ .returning(Record.id)
80
+ )
81
+ result = await session.execute(record_statement)
82
+
83
+ server_record_id = result.scalar_one_or_none()
84
+
85
+ if server_record_id is None:
86
+ # Record already exists — fetch and return it
87
+ response.status_code = status.HTTP_200_OK
88
+ existing = await session.execute(
89
+ select(Record).where(
90
+ Record.client_record_id == body.client_record_id, # type: ignore
91
+ Record.user_id == user_id, # type: ignore
92
+ Record.created_at == body.created_at, # type: ignore
93
+ )
94
+ )
95
+ record = existing.scalar_one()
96
+ imgs = await session.execute(
97
+ select(Image).where(Image.record_id == record.id) # type: ignore[arg-type]
98
+ )
99
+ images = imgs.scalars().all()
100
+ return RecordRead(
101
+ id=record.id,
102
+ client_record_id=record.client_record_id,
103
+ images=[
104
+ ImageRead(
105
+ id=img.id,
106
+ client_image_id=img.client_image_id,
107
+ image_orientation=img.image_orientation,
108
+ sync_status=img.sync_status,
109
+ )
110
+ for img in images
111
+ ],
112
+ )
113
+
114
+ images = [
115
+ Image(
116
+ record_id=server_record_id,
117
+ client_image_id=img.client_image_id,
118
+ image_orientation=img.image_orientation,
119
+ exif_metadata=img.exif_metadata,
120
+ minio_path=None,
121
+ sync_status=ImageSyncStatus.pending,
122
+ )
123
+ for img in body.images
124
+ ]
125
+ session.add_all(images)
126
+ await session.commit()
127
+
128
+ return RecordRead(
129
+ id=server_record_id,
130
+ client_record_id=body.client_record_id,
131
+ images=[
132
+ ImageRead(
133
+ id=img.id,
134
+ client_image_id=img.client_image_id,
135
+ image_orientation=img.image_orientation,
136
+ sync_status=img.sync_status,
137
+ )
138
+ for img in images
139
+ ],
140
+ )
141
+
142
+
143
+ @router.post(
144
+ "/images/{server_image_id}",
145
+ status_code=status.HTTP_201_CREATED,
146
+ )
147
+ async def upload_image(
148
+ server_image_id: int,
149
+ file: UploadFile,
150
+ response: Response,
151
+ user_id: Annotated[uuid.UUID, Depends(get_current_user_id)],
152
+ session: Annotated[AsyncSession, Depends(get_session)],
153
+ ) -> ImageRead:
154
+ """Uploads the binary image for a previously created image placeholder.
155
+
156
+ Idempotent — safe to retry after a lost response. Returns 201 on first
157
+ upload, 200 if the image was already uploaded.
158
+ The client uses the server-assigned image id returned by POST /records.
159
+ Ownership is verified by joining against the parent record's user_id.
160
+
161
+ Args:
162
+ server_image_id: Server-assigned image ID from record creation.
163
+ file: Uploaded image file.
164
+ response: FastAPI response object for status code override.
165
+ user_id: Authenticated user's UUID from JWT.
166
+ session: Async database session.
167
+
168
+ Returns:
169
+ ImageRead with updated sync status.
170
+
171
+ Raises:
172
+ HTTPException: 404 if image not found or not owned by user,
173
+ 415 if unsupported MIME type, 422 if file cannot be read,
174
+ 503 if MinIO is unavailable.
175
+ """
176
+ result = await session.execute(
177
+ select(Image)
178
+ .join(Record, Image.record_id == Record.id) # type: ignore[arg-type]
179
+ .where(Image.id == server_image_id, Record.user_id == user_id) # type: ignore
180
+ )
181
+ image = result.scalar_one_or_none()
182
+ if image is None:
183
+ raise HTTPException(
184
+ status_code=status.HTTP_404_NOT_FOUND, detail="Image not found"
185
+ )
186
+
187
+ if image.sync_status == ImageSyncStatus.synced:
188
+ response.status_code = status.HTTP_200_OK
189
+ return ImageRead(
190
+ id=image.id,
191
+ client_image_id=image.client_image_id,
192
+ image_orientation=image.image_orientation,
193
+ sync_status=image.sync_status,
194
+ )
195
+
196
+ if file.content_type not in ALLOWED_IMAGE_MIME_TYPES:
197
+ sentry_sdk.capture_message(
198
+ f"Rejected upload with disallowed MIME type: {file.content_type}",
199
+ level="warning",
200
+ )
201
+ raise HTTPException(
202
+ status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
203
+ detail="Unsupported file type",
204
+ )
205
+
206
+ try:
207
+ data = await file.read()
208
+ except Exception as e:
209
+ sentry_sdk.capture_exception(e)
210
+ raise HTTPException(
211
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
212
+ detail="Could not read uploaded file",
213
+ )
214
+
215
+ try:
216
+ minio_path = await asyncio.to_thread(
217
+ minio_upload,
218
+ data=data,
219
+ content_type=file.content_type,
220
+ )
221
+ except Exception as e:
222
+ sentry_sdk.capture_exception(e)
223
+ raise HTTPException(
224
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
225
+ detail="Image storage is currently unavailable",
226
+ )
227
+
228
+ image.minio_path = minio_path
229
+ image.sync_status = ImageSyncStatus.synced
230
+ session.add(image)
231
+ await session.commit()
232
+
233
+ return ImageRead(
234
+ id=image.id,
235
+ client_image_id=image.client_image_id,
236
+ image_orientation=image.image_orientation,
237
+ sync_status=image.sync_status,
238
+ )
239
+
240
+
241
+ @router.delete("/{server_record_id}", status_code=status.HTTP_204_NO_CONTENT)
242
+ async def delete_record(
243
+ server_record_id: int,
244
+ user_id: Annotated[uuid.UUID, Depends(get_current_user_id)],
245
+ session: Annotated[AsyncSession, Depends(get_session)],
246
+ ) -> None:
247
+ """Anonymizes a record owned by the authenticated user.
248
+
249
+ Sets is_anonymized to True, which fires the DB-side trigger that
250
+ anonymizes all associated images and logs the action to the audit table.
251
+ Idempotent — if the record is already anonymized or not found, 204 is
252
+ returned without error.
253
+
254
+ Args:
255
+ server_record_id: Server-assigned record ID.
256
+ user_id: Authenticated user's UUID from JWT.
257
+ session: Async database session.
258
+ """
259
+ await session.execute(
260
+ update(Record)
261
+ .where(
262
+ Record.id == server_record_id, # type: ignore[arg-type]
263
+ Record.user_id == user_id, # type: ignore[arg-type]
264
+ Record.is_anonymized.is_(False), # type: ignore[attr-defined]
265
+ )
266
+ .values(is_anonymized=True)
267
+ )
268
+ await session.commit()
@@ -0,0 +1,92 @@
1
+ """Router for user provisioning."""
2
+
3
+ import asyncio
4
+ import uuid
5
+ from typing import Annotated
6
+
7
+ from fastapi import APIRouter, Depends, Response, status
8
+ from sqlalchemy import update
9
+ from sqlalchemy.dialects.postgresql import insert
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+
12
+ from meinewaldki_citizen_rest_service.database.engine import get_session
13
+ from meinewaldki_citizen_rest_service.database.models import User
14
+ from meinewaldki_citizen_rest_service.dependencies import (
15
+ TokenData,
16
+ get_current_keycloak_admin,
17
+ get_current_token_data,
18
+ get_current_user_id,
19
+ )
20
+ from keycloak import KeycloakAdmin
21
+ from keycloak.exceptions import KeycloakDeleteError
22
+
23
+ from meinewaldki_citizen_rest_service.schemas import UserStatus
24
+
25
+ router = APIRouter(
26
+ prefix="/users",
27
+ tags=["users"],
28
+ )
29
+
30
+
31
+ @router.post("/me", status_code=status.HTTP_201_CREATED)
32
+ async def provision_user(
33
+ response: Response,
34
+ token_data: Annotated[TokenData, Depends(get_current_token_data)],
35
+ session: Annotated[AsyncSession, Depends(get_session)],
36
+ ) -> None:
37
+ """Provisions the authenticated user into core.users.
38
+
39
+ Safe to call multiple times. Returns 201 if the user was created, 200 if
40
+ they already existed — allowing the client to distinguish a first-time
41
+ registration from a retry after a lost response.
42
+
43
+ Args:
44
+ response: FastAPI response object for status code override.
45
+ token_data: Decoded JWT token data with user ID, type, and email.
46
+ session: Async database session.
47
+ """
48
+ statement = (
49
+ insert(User) # type: ignore
50
+ .values(
51
+ id=token_data.user_id, status=token_data.user_type, email=token_data.email
52
+ )
53
+ .on_conflict_do_nothing(index_elements=["id"])
54
+ .returning(User.id)
55
+ )
56
+ result = await session.execute(statement)
57
+ await session.commit()
58
+
59
+ if result.scalar_one_or_none() is None:
60
+ response.status_code = status.HTTP_200_OK
61
+
62
+
63
+ @router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
64
+ async def delete_user(
65
+ user_id: Annotated[uuid.UUID, Depends(get_current_user_id)],
66
+ keycloak_admin: Annotated[KeycloakAdmin, Depends(get_current_keycloak_admin)],
67
+ session: Annotated[AsyncSession, Depends(get_session)],
68
+ ) -> None:
69
+ """Deletes the authenticated user.
70
+
71
+ Idempotent — safe to retry after a lost response. Sets the user's status
72
+ to deleted in the DB (firing the DB-side cleanup trigger), then removes
73
+ the user from Keycloak. A 404 from Keycloak is silently ignored so that
74
+ retries where the DB trigger already ran still complete cleanly.
75
+
76
+ Args:
77
+ user_id: Authenticated user's UUID from JWT.
78
+ keycloak_admin: Keycloak admin client for the user's realm.
79
+ session: Async database session.
80
+ """
81
+ await session.execute(
82
+ update(User)
83
+ .where(User.id == user_id, User.status != UserStatus.deleted) # type: ignore
84
+ .values(status=UserStatus.deleted)
85
+ )
86
+ await session.commit()
87
+
88
+ try:
89
+ await asyncio.to_thread(keycloak_admin.delete_user, str(user_id))
90
+ except KeycloakDeleteError as e:
91
+ if e.response_code != 404:
92
+ raise
@@ -0,0 +1,49 @@
1
+ """
2
+ Shared schemas package.
3
+
4
+ Base models defined here are reused by both the SQLModel table models
5
+ (database/models.py) and the FastAPI Pydantic request / response schemas.
6
+ """
7
+
8
+ from .citizen_science import (
9
+ CitizenScienceFeatureBase,
10
+ CitizenScienceFeatureOptionBase,
11
+ CitizenScienceUserAssignmentBase,
12
+ CsAssignmentSource,
13
+ CsAssignmentType,
14
+ CsValueType,
15
+ )
16
+ from .image import (
17
+ ImageBase,
18
+ ImageOrientation,
19
+ ImageRead,
20
+ ImageSyncStatus,
21
+ )
22
+ from .record import (
23
+ LocationSchema,
24
+ RecordBase,
25
+ RecordCreate,
26
+ RecordRead,
27
+ RecordSyncStatus,
28
+ )
29
+ from .user import UserBase, UserStatus
30
+
31
+ __all__ = [
32
+ "UserStatus",
33
+ "UserBase",
34
+ "RecordSyncStatus",
35
+ "RecordBase",
36
+ "LocationSchema",
37
+ "RecordCreate",
38
+ "RecordRead",
39
+ "ImageSyncStatus",
40
+ "ImageOrientation",
41
+ "ImageBase",
42
+ "ImageRead",
43
+ "CsValueType",
44
+ "CsAssignmentType",
45
+ "CsAssignmentSource",
46
+ "CitizenScienceFeatureBase",
47
+ "CitizenScienceFeatureOptionBase",
48
+ "CitizenScienceUserAssignmentBase",
49
+ ]
@@ -0,0 +1,61 @@
1
+ """Shared citizen science schemas.
2
+
3
+ Base models defined here are reused by both the SQLModel table models
4
+ (database/models.py) and future Pydantic request / response schemas.
5
+ """
6
+
7
+ import enum
8
+ from typing import Optional
9
+
10
+ from sqlmodel import SQLModel
11
+
12
+
13
+ class CsValueType(str, enum.Enum):
14
+ """Maps to the core.cs_value_type PostgreSQL enum."""
15
+
16
+ boolean = "boolean"
17
+ enum = "enum"
18
+ int = "int"
19
+ text = "text"
20
+
21
+
22
+ class CsAssignmentType(str, enum.Enum):
23
+ """Maps to the core.cs_assignment_type PostgreSQL enum."""
24
+
25
+ user_chosen = "user_chosen"
26
+ system_assigned = "system_assigned"
27
+ hybrid = "hybrid"
28
+
29
+
30
+ class CsAssignmentSource(str, enum.Enum):
31
+ """Maps to the core.cs_assignment_source PostgreSQL enum."""
32
+
33
+ user = "user"
34
+ system = "system"
35
+ override = "override"
36
+
37
+
38
+ class CitizenScienceFeatureBase(SQLModel):
39
+ """Shared fields for CitizenScienceFeature schemas and DB model."""
40
+
41
+ name: str
42
+ description: Optional[str] = None
43
+ value_type: CsValueType
44
+ assignment_type: CsAssignmentType
45
+ track_history: bool = False
46
+ is_active: bool = True
47
+
48
+
49
+ class CitizenScienceFeatureOptionBase(SQLModel):
50
+ """Shared fields for CitizenScienceFeatureOption schemas and DB model."""
51
+
52
+ feature_value: str
53
+ feature_label: str
54
+ is_default: bool = False
55
+
56
+
57
+ class CitizenScienceUserAssignmentBase(SQLModel):
58
+ """Shared fields for CitizenScienceUserAssignment schemas and DB model."""
59
+
60
+ source: CsAssignmentSource
61
+ has_changed: bool = False