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.
- basic_python_project-0.0.0.dist-info/METADATA +124 -0
- basic_python_project-0.0.0.dist-info/RECORD +23 -0
- basic_python_project-0.0.0.dist-info/WHEEL +5 -0
- basic_python_project-0.0.0.dist-info/licenses/LICENSE +21 -0
- basic_python_project-0.0.0.dist-info/top_level.txt +1 -0
- meinewaldki_citizen_rest_service/__init__.py +0 -0
- meinewaldki_citizen_rest_service/constants.py +55 -0
- meinewaldki_citizen_rest_service/database/__init__.py +0 -0
- meinewaldki_citizen_rest_service/database/engine.py +35 -0
- meinewaldki_citizen_rest_service/database/models.py +276 -0
- meinewaldki_citizen_rest_service/dependencies.py +133 -0
- meinewaldki_citizen_rest_service/keycloak_clients.py +55 -0
- meinewaldki_citizen_rest_service/main.py +63 -0
- meinewaldki_citizen_rest_service/minio_client.py +52 -0
- meinewaldki_citizen_rest_service/routers/__init__.py +0 -0
- meinewaldki_citizen_rest_service/routers/guest_user_access.py +44 -0
- meinewaldki_citizen_rest_service/routers/records.py +268 -0
- meinewaldki_citizen_rest_service/routers/users.py +92 -0
- meinewaldki_citizen_rest_service/schemas/__init__.py +49 -0
- meinewaldki_citizen_rest_service/schemas/citizen_science.py +61 -0
- meinewaldki_citizen_rest_service/schemas/image.py +48 -0
- meinewaldki_citizen_rest_service/schemas/record.py +59 -0
- meinewaldki_citizen_rest_service/schemas/user.py +25 -0
|
@@ -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
|