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,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: basic-python-project
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: Template: Basic Python Project
|
|
5
|
+
Author-email: Christian Hänig <christian.haenig@hs-anhalt.de>
|
|
6
|
+
Project-URL: Gitlab, https://gitlab.hs-anhalt.de/ki/templates/basic-python-project
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Description-Content-Type: text/markdown; charset=UTF-8
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: fastapi~=0.115.5
|
|
14
|
+
Requires-Dist: pytest~=8.3
|
|
15
|
+
Requires-Dist: pytest-asyncio~=0.24
|
|
16
|
+
Requires-Dist: starlette~=0.41.3
|
|
17
|
+
Requires-Dist: uvicorn~=0.32.1
|
|
18
|
+
Requires-Dist: python-keycloak~=4.7.3
|
|
19
|
+
Requires-Dist: python-dotenv~=1.0.1
|
|
20
|
+
Requires-Dist: sqlmodel~=0.0.34
|
|
21
|
+
Requires-Dist: sqlalchemy~=2.0
|
|
22
|
+
Requires-Dist: asyncpg~=0.30.0
|
|
23
|
+
Requires-Dist: geoalchemy2~=0.15.2
|
|
24
|
+
Requires-Dist: minio~=7.2
|
|
25
|
+
Requires-Dist: Pillow~=11.0
|
|
26
|
+
Requires-Dist: python-multipart~=0.0.20
|
|
27
|
+
Requires-Dist: sentry-sdk[fastapi]~=2.0
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# Meinewaldki Citizen Rest Service
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
## Installation for Kubernetes
|
|
34
|
+
|
|
35
|
+
You need to have helm and kubectl installed.
|
|
36
|
+
|
|
37
|
+
Your kubeconfig should be set to the correct cluster (meinewaldki).
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
kubectl config current-context
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Navigate to the configuration directory:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
cd config
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Keycloak
|
|
50
|
+
|
|
51
|
+
For keycloak you need to run the following command:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
helm install test-keycloak keycloak --namespace citizen-scientist-app
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The configurations are in the keycloak/values.yaml file.
|
|
58
|
+
|
|
59
|
+
Base configuration your endpoint will be `https://meinewaldki.anhalt.ai/dev-auth`.
|
|
60
|
+
|
|
61
|
+
To uninstall keycloak run the following command:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
helm uninstall test-keycloak --namespace citizen-scientist-app
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
#### Settings
|
|
68
|
+
|
|
69
|
+
Log into keycloak with the following credentials:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
Username: admin-dev
|
|
73
|
+
Password: PLS_CHG_ME
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Add a new realm for guest users.
|
|
77
|
+
|
|
78
|
+
In the new realm create a new client with the ClientID `MeineWaldKIApp`.
|
|
79
|
+
Enable OAuth 2.0 Device Authorization Grant.
|
|
80
|
+
|
|
81
|
+
After creating the client change the following settings:
|
|
82
|
+
|
|
83
|
+
Origin: `*` (should be more restrictive in production, but did not check if this works with a more restrictive setting)
|
|
84
|
+
Valid Redirect URIs: `*` (should be more restrictive in production, but did not check if this works with a more restrictive setting)
|
|
85
|
+
|
|
86
|
+
Add the role `guest` to the client roles.
|
|
87
|
+
|
|
88
|
+
Go to Realm Settings. Set SSO Session Idle very high and SSO Session also very high (Sessions).
|
|
89
|
+
|
|
90
|
+
Set Access Token Lifespan to a very high value (Tokens).
|
|
91
|
+
|
|
92
|
+
Remove the first name and last name from the required user attributes (User profile).
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
### Installation Rest Service
|
|
96
|
+
|
|
97
|
+
I will add the automatic deployment through CI/CD later.
|
|
98
|
+
|
|
99
|
+
The deployment helm is mostly written but the Docker image is not yet in a registry.
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
## Run locally
|
|
103
|
+
After Installing keycloak and creating your realms and changing the admin password and or admin user you can run the service locally.
|
|
104
|
+
|
|
105
|
+
Add the following environment variables to your .env file:
|
|
106
|
+
|
|
107
|
+
```env
|
|
108
|
+
KEYCLOAK_SERVER_URL=https://meinewaldki.anhalt.ai/dev-auth/ (if not changed)
|
|
109
|
+
ADMIN_USERNAME=admin_dev (if not changed)
|
|
110
|
+
ADMIN_PASSWORD=PLS_CHG_ME (if not changed)
|
|
111
|
+
ADMIN_REALM_NAME=master (if not changed)
|
|
112
|
+
GUEST_USER_REALM_NAME=YourRealm (I set it to guest-users)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Mark the src directory as source root in your IDE.
|
|
116
|
+
|
|
117
|
+
Run the main.py file with the working directory set to the root directory of the project.
|
|
118
|
+
|
|
119
|
+
### Endpoints
|
|
120
|
+
I did not setup an endpoint for cookies. This should make it easier to test the endpoints.
|
|
121
|
+
|
|
122
|
+
http://127.0.0.1:5001/citizen-api/guest-user/create -> Create a new guest user
|
|
123
|
+
http://127.0.0.1:5001/citizen-api/guest-user/check -> Check if Token is valid (Post request)
|
|
124
|
+
http://127.0.0.1:5001/citizen-api/docs -> Swagger UI (Easy to test the endpoints)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
basic_python_project-0.0.0.dist-info/licenses/LICENSE,sha256=5acGOP9i5gEE-QIwcpnp4V8yQn-VfGJEgausFBegr3M,1065
|
|
2
|
+
meinewaldki_citizen_rest_service/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
meinewaldki_citizen_rest_service/constants.py,sha256=Afv9zNuB-D1eyr1UgMqzbhHjcSPzstBcQWBUC7bo5BU,1883
|
|
4
|
+
meinewaldki_citizen_rest_service/dependencies.py,sha256=fkAbNY-Pv10YbXmRMnw58kFxXCSxBQUdgpCIXQVXRlk,3786
|
|
5
|
+
meinewaldki_citizen_rest_service/keycloak_clients.py,sha256=9r0l7cR5Ayhn5cfxM07Oo-OwbJU0PJeBz0Ma2Nh3m0Y,1658
|
|
6
|
+
meinewaldki_citizen_rest_service/main.py,sha256=pyUACl3_0XTl4spkFkVIlXt5deLecswBkJnzwPYbu30,2143
|
|
7
|
+
meinewaldki_citizen_rest_service/minio_client.py,sha256=dVccSyHyWHX-2UKSIkpBcrIlTjiTHQJwbkNrzlfe4MI,1165
|
|
8
|
+
meinewaldki_citizen_rest_service/database/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
meinewaldki_citizen_rest_service/database/engine.py,sha256=IqvRIjWMUkt7O0ZVH2ZBx5eyhu6LxfYDW_3nl8AKLnw,999
|
|
10
|
+
meinewaldki_citizen_rest_service/database/models.py,sha256=jzdDmFmg-_HmYmRIO7nEUL8eTZHGWxOnFmURCgWjZqw,8137
|
|
11
|
+
meinewaldki_citizen_rest_service/routers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
meinewaldki_citizen_rest_service/routers/guest_user_access.py,sha256=uYUPCXGJzYcRAmTBqviVvLAovSCI_yeDn9J01TwCNf0,1071
|
|
13
|
+
meinewaldki_citizen_rest_service/routers/records.py,sha256=P7NOxQACrpvr5pP1bZHniwdJTy6IlE7mVR1Iu-V0GzU,9185
|
|
14
|
+
meinewaldki_citizen_rest_service/routers/users.py,sha256=cydYSG-S5WkbCcFIp_GGVBVpaTZlgUXJUU50pjjoT1s,3153
|
|
15
|
+
meinewaldki_citizen_rest_service/schemas/__init__.py,sha256=7xpv5aDmPtSKvV4onttFeoBwuCCE3I3lJjvlq8G9FqM,1044
|
|
16
|
+
meinewaldki_citizen_rest_service/schemas/citizen_science.py,sha256=l8fpSFquoZIjNv-LlJAJ1O2tfeTiN7rQ2B_ktT51Xq0,1492
|
|
17
|
+
meinewaldki_citizen_rest_service/schemas/image.py,sha256=50g0cMPIPRExKrPrDZeHKsuRI7w_y-LZsAmKKU8aG-I,1253
|
|
18
|
+
meinewaldki_citizen_rest_service/schemas/record.py,sha256=RGGi8m525IYOmndAB4ehDDlvdGQbcS21TcLwVUNtOYg,1559
|
|
19
|
+
meinewaldki_citizen_rest_service/schemas/user.py,sha256=aoJisftU6KO_LqdVOCkfD6Sise_URj75l9GOq6RcOKU,588
|
|
20
|
+
basic_python_project-0.0.0.dist-info/METADATA,sha256=6hHJscVGS6Nwg0_QYu3w36D5AhrHB012nAx_or4HjEc,3756
|
|
21
|
+
basic_python_project-0.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
22
|
+
basic_python_project-0.0.0.dist-info/top_level.txt,sha256=jaOy8r_p7Vzk7IrDSiAsVvgaU_TJ0kVMdB7SBC4qwvI,33
|
|
23
|
+
basic_python_project-0.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 AnhaltAI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
meinewaldki_citizen_rest_service
|
|
File without changes
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Constants for the MeineWaldKI Citizen REST Service."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
|
|
7
|
+
if os.path.exists(".env"):
|
|
8
|
+
load_dotenv(dotenv_path=".env")
|
|
9
|
+
|
|
10
|
+
KEYCLOAK_SERVER_URL = os.environ["KEYCLOAK_SERVER_URL"]
|
|
11
|
+
# Optional path to a CA cert for Keycloak TLS verification.
|
|
12
|
+
# Set in dev when using a self-signed cert. Leave unset in production.
|
|
13
|
+
KEYCLOAK_CERT_PATH = os.environ.get("KEYCLOAK_CERT_PATH")
|
|
14
|
+
|
|
15
|
+
GUEST_ADMIN_USERNAME = os.environ["GUEST_ADMIN_USERNAME"]
|
|
16
|
+
GUEST_ADMIN_PASSWORD = os.environ["GUEST_ADMIN_PASSWORD"]
|
|
17
|
+
GUEST_ADMIN_REALM_NAME = os.environ["GUEST_ADMIN_REALM_NAME"]
|
|
18
|
+
|
|
19
|
+
REGISTERED_ADMIN_USERNAME = os.environ["REGISTERED_ADMIN_USERNAME"]
|
|
20
|
+
REGISTERED_ADMIN_PASSWORD = os.environ["REGISTERED_ADMIN_PASSWORD"]
|
|
21
|
+
REGISTERED_ADMIN_REALM_NAME = os.environ["REGISTERED_ADMIN_REALM_NAME"]
|
|
22
|
+
|
|
23
|
+
GUEST_USER_REALM_NAME = os.environ["GUEST_USER_REALM_NAME"]
|
|
24
|
+
REGISTERED_USER_REALM_NAME = os.environ["REGISTERED_USER_REALM_NAME"]
|
|
25
|
+
|
|
26
|
+
CLIENT_NAME = "MeineWaldKIApp"
|
|
27
|
+
|
|
28
|
+
DB_USER = os.environ.get("DB_USER", "")
|
|
29
|
+
DB_PASSWORD = os.environ.get("DB_PASSWORD", "")
|
|
30
|
+
DB_HOST = os.environ.get("DB_HOST", "")
|
|
31
|
+
DB_PORT = os.environ.get("DB_PORT", "5432")
|
|
32
|
+
DB_NAME = os.environ.get("DB_NAME", "")
|
|
33
|
+
|
|
34
|
+
MINIO_ENDPOINT = os.environ["MINIO_ENDPOINT"]
|
|
35
|
+
MINIO_ACCESS_KEY = os.environ["MINIO_ACCESS_KEY"]
|
|
36
|
+
MINIO_SECRET_KEY = os.environ["MINIO_SECRET_KEY"]
|
|
37
|
+
MINIO_BUCKET = os.environ["MINIO_BUCKET"]
|
|
38
|
+
MINIO_SECURE = os.environ.get("MINIO_SECURE", "true").lower() == "true"
|
|
39
|
+
|
|
40
|
+
# GlitchTip / Sentry DSN — leave unset to disable error reporting
|
|
41
|
+
GLITCHTIP_DSN = os.environ.get("GLITCHTIP_DSN")
|
|
42
|
+
|
|
43
|
+
# EPSG:4326 — World Geodetic System 1984. Lat/lon in decimal degrees on the
|
|
44
|
+
# WGS 84 ellipsoid; the reference system behind GPS and most web maps.
|
|
45
|
+
WGS84_SRID = 4326
|
|
46
|
+
|
|
47
|
+
ALLOWED_IMAGE_MIME_TYPES = frozenset(
|
|
48
|
+
{
|
|
49
|
+
"image/jpeg",
|
|
50
|
+
"image/png",
|
|
51
|
+
"image/webp",
|
|
52
|
+
"image/heic",
|
|
53
|
+
"image/heif",
|
|
54
|
+
}
|
|
55
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from collections.abc import AsyncGenerator
|
|
2
|
+
from urllib.parse import quote_plus
|
|
3
|
+
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
5
|
+
|
|
6
|
+
from meinewaldki_citizen_rest_service.constants import (
|
|
7
|
+
DB_HOST,
|
|
8
|
+
DB_NAME,
|
|
9
|
+
DB_PASSWORD,
|
|
10
|
+
DB_PORT,
|
|
11
|
+
DB_USER,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
_DATABASE_URL = (
|
|
15
|
+
f"postgresql+asyncpg://{quote_plus(DB_USER)}"
|
|
16
|
+
f":{quote_plus(DB_PASSWORD)}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
engine = create_async_engine(_DATABASE_URL, echo=False, pool_pre_ping=True)
|
|
20
|
+
|
|
21
|
+
SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|
25
|
+
"""Yields an async database session for use as a FastAPI dependency.
|
|
26
|
+
|
|
27
|
+
Yields:
|
|
28
|
+
An async SQLAlchemy session that is automatically rolled back on error.
|
|
29
|
+
"""
|
|
30
|
+
async with SessionLocal() as session:
|
|
31
|
+
try:
|
|
32
|
+
yield session
|
|
33
|
+
except Exception:
|
|
34
|
+
await session.rollback()
|
|
35
|
+
raise
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""SQLModel ORM table models for the MeineWaldKI citizen science database.
|
|
2
|
+
|
|
3
|
+
Each table model inherits from the corresponding base in schemas/ and adds:
|
|
4
|
+
- primary / foreign keys
|
|
5
|
+
- server-managed fields (uploaded_at, is_anonymized, sync_status)
|
|
6
|
+
- PostgreSQL-specific column types: schema-qualified SAEnum, JSONB, Geography.
|
|
7
|
+
These override the Python-native base-class fields via sa_column so that the
|
|
8
|
+
DB schema is mirrored exactly while the base remains Pydantic-compatible.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import uuid
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
from geoalchemy2 import Geography
|
|
16
|
+
from sqlalchemy import (
|
|
17
|
+
Column,
|
|
18
|
+
DateTime,
|
|
19
|
+
Enum as SAEnum,
|
|
20
|
+
Identity,
|
|
21
|
+
Integer,
|
|
22
|
+
UniqueConstraint,
|
|
23
|
+
)
|
|
24
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
25
|
+
from sqlmodel import Field
|
|
26
|
+
|
|
27
|
+
from meinewaldki_citizen_rest_service.constants import WGS84_SRID
|
|
28
|
+
from meinewaldki_citizen_rest_service.schemas import (
|
|
29
|
+
CitizenScienceFeatureBase,
|
|
30
|
+
CitizenScienceFeatureOptionBase,
|
|
31
|
+
CitizenScienceUserAssignmentBase,
|
|
32
|
+
CsAssignmentSource,
|
|
33
|
+
CsAssignmentType,
|
|
34
|
+
CsValueType,
|
|
35
|
+
ImageBase,
|
|
36
|
+
ImageOrientation,
|
|
37
|
+
ImageSyncStatus,
|
|
38
|
+
RecordBase,
|
|
39
|
+
RecordSyncStatus,
|
|
40
|
+
UserBase,
|
|
41
|
+
UserStatus,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class User(UserBase, table=True):
|
|
46
|
+
"""Maps to core.users."""
|
|
47
|
+
|
|
48
|
+
__tablename__ = "users"
|
|
49
|
+
__table_args__ = {"schema": "core"}
|
|
50
|
+
|
|
51
|
+
id: uuid.UUID = Field(primary_key=True)
|
|
52
|
+
created_at: datetime = Field(
|
|
53
|
+
default_factory=lambda: datetime.now(UTC),
|
|
54
|
+
sa_column=Column(DateTime(timezone=True), nullable=False),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Override: reference the existing core.user_status enum type
|
|
58
|
+
status: UserStatus = Field(
|
|
59
|
+
sa_column=Column(
|
|
60
|
+
SAEnum(UserStatus, name="user_status", schema="core", create_type=False),
|
|
61
|
+
nullable=False,
|
|
62
|
+
server_default="guest",
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Record(RecordBase, table=True):
|
|
68
|
+
"""Maps to core.records."""
|
|
69
|
+
|
|
70
|
+
__tablename__ = "records"
|
|
71
|
+
__table_args__ = (
|
|
72
|
+
UniqueConstraint(
|
|
73
|
+
"client_record_id",
|
|
74
|
+
"user_id",
|
|
75
|
+
"created_at",
|
|
76
|
+
name="uq_record_client_user_created",
|
|
77
|
+
),
|
|
78
|
+
{"schema": "core"},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# GENERATED BY DEFAULT AS IDENTITY in the DB
|
|
82
|
+
id: Optional[int] = Field(
|
|
83
|
+
default=None,
|
|
84
|
+
sa_column=Column(Integer, Identity(always=False), primary_key=True),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
user_id: uuid.UUID = Field(foreign_key="core.users.id", nullable=False)
|
|
88
|
+
|
|
89
|
+
created_at: datetime = Field(
|
|
90
|
+
sa_column=Column(DateTime(timezone=True), nullable=False),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
uploaded_at: datetime = Field(
|
|
94
|
+
default_factory=lambda: datetime.now(UTC),
|
|
95
|
+
sa_column=Column(DateTime(timezone=True), nullable=False),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
original_location: Optional[Any] = Field(
|
|
99
|
+
default=None,
|
|
100
|
+
sa_column=Column(
|
|
101
|
+
Geography(geometry_type="POINT", srid=WGS84_SRID), nullable=True
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
updated_location: Optional[Any] = Field(
|
|
105
|
+
default=None,
|
|
106
|
+
sa_column=Column(
|
|
107
|
+
Geography(geometry_type="POINT", srid=WGS84_SRID), nullable=True
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
is_anonymized: bool = Field(default=False)
|
|
112
|
+
sync_status: RecordSyncStatus = Field(
|
|
113
|
+
sa_column=Column(
|
|
114
|
+
SAEnum(
|
|
115
|
+
RecordSyncStatus,
|
|
116
|
+
name="record_sync_status",
|
|
117
|
+
schema="core",
|
|
118
|
+
create_type=False,
|
|
119
|
+
),
|
|
120
|
+
nullable=False,
|
|
121
|
+
server_default="pending",
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Override: use JSONB instead of plain JSON
|
|
126
|
+
hardware_data: Optional[dict] = Field(
|
|
127
|
+
default=None, sa_column=Column(JSONB, nullable=True)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Image(ImageBase, table=True):
|
|
132
|
+
"""Maps to core.images."""
|
|
133
|
+
|
|
134
|
+
__tablename__ = "images"
|
|
135
|
+
__table_args__ = (
|
|
136
|
+
UniqueConstraint(
|
|
137
|
+
"record_id",
|
|
138
|
+
"image_orientation",
|
|
139
|
+
name="uq_images_record_orientation",
|
|
140
|
+
),
|
|
141
|
+
{"schema": "core"},
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# GENERATED BY DEFAULT AS IDENTITY in the DB
|
|
145
|
+
id: Optional[int] = Field(
|
|
146
|
+
default=None,
|
|
147
|
+
sa_column=Column(Integer, Identity(always=False), primary_key=True),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
record_id: int = Field(foreign_key="core.records.id", nullable=False)
|
|
151
|
+
|
|
152
|
+
minio_path: Optional[str] = Field(default=None, nullable=True)
|
|
153
|
+
|
|
154
|
+
uploaded_at: datetime = Field(
|
|
155
|
+
default_factory=lambda: datetime.now(UTC),
|
|
156
|
+
sa_column=Column(DateTime(timezone=True), nullable=False),
|
|
157
|
+
)
|
|
158
|
+
is_anonymized: bool = Field(default=False)
|
|
159
|
+
sync_status: ImageSyncStatus = Field(
|
|
160
|
+
sa_column=Column(
|
|
161
|
+
SAEnum(
|
|
162
|
+
ImageSyncStatus,
|
|
163
|
+
name="image_sync_status",
|
|
164
|
+
schema="core",
|
|
165
|
+
create_type=False,
|
|
166
|
+
),
|
|
167
|
+
nullable=False,
|
|
168
|
+
server_default="pending",
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Override: reference the existing core.image_orientation enum type
|
|
173
|
+
image_orientation: ImageOrientation = Field(
|
|
174
|
+
sa_column=Column(
|
|
175
|
+
SAEnum(
|
|
176
|
+
ImageOrientation,
|
|
177
|
+
name="image_orientation",
|
|
178
|
+
schema="core",
|
|
179
|
+
create_type=False,
|
|
180
|
+
),
|
|
181
|
+
nullable=False,
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Override: use JSONB instead of plain JSON
|
|
186
|
+
exif_metadata: Optional[dict] = Field(
|
|
187
|
+
default=None, sa_column=Column(JSONB, nullable=True)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class CitizenScienceFeature(CitizenScienceFeatureBase, table=True):
|
|
192
|
+
"""Maps to core.citizen_science_features."""
|
|
193
|
+
|
|
194
|
+
__tablename__ = "citizen_science_features"
|
|
195
|
+
__table_args__ = {"schema": "core"}
|
|
196
|
+
|
|
197
|
+
# GENERATED BY DEFAULT AS IDENTITY in the DB
|
|
198
|
+
id: Optional[int] = Field(
|
|
199
|
+
default=None,
|
|
200
|
+
sa_column=Column(Integer, Identity(always=False), primary_key=True),
|
|
201
|
+
)
|
|
202
|
+
created_at: datetime = Field(
|
|
203
|
+
default_factory=lambda: datetime.now(UTC),
|
|
204
|
+
sa_column=Column(DateTime(timezone=True), nullable=False),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Override: reference existing core enum types
|
|
208
|
+
value_type: CsValueType = Field(
|
|
209
|
+
sa_column=Column(
|
|
210
|
+
SAEnum(CsValueType, name="cs_value_type", schema="core", create_type=False),
|
|
211
|
+
nullable=False,
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
assignment_type: CsAssignmentType = Field(
|
|
215
|
+
sa_column=Column(
|
|
216
|
+
SAEnum(
|
|
217
|
+
CsAssignmentType,
|
|
218
|
+
name="cs_assignment_type",
|
|
219
|
+
schema="core",
|
|
220
|
+
create_type=False,
|
|
221
|
+
),
|
|
222
|
+
nullable=False,
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class CitizenScienceFeatureOption(CitizenScienceFeatureOptionBase, table=True):
|
|
228
|
+
"""Maps to core.citizen_science_feature_options."""
|
|
229
|
+
|
|
230
|
+
__tablename__ = "citizen_science_feature_options"
|
|
231
|
+
__table_args__ = (
|
|
232
|
+
UniqueConstraint("feature_id", "feature_value", name="uq_feature_value"),
|
|
233
|
+
{"schema": "core"},
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# GENERATED BY DEFAULT AS IDENTITY in the DB
|
|
237
|
+
id: Optional[int] = Field(
|
|
238
|
+
default=None,
|
|
239
|
+
sa_column=Column(Integer, Identity(always=False), primary_key=True),
|
|
240
|
+
)
|
|
241
|
+
feature_id: int = Field(
|
|
242
|
+
foreign_key="core.citizen_science_features.id", nullable=False
|
|
243
|
+
)
|
|
244
|
+
created_at: datetime = Field(
|
|
245
|
+
default_factory=lambda: datetime.now(UTC),
|
|
246
|
+
sa_column=Column(DateTime(timezone=True), nullable=False),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class CitizenScienceUserAssignment(CitizenScienceUserAssignmentBase, table=True):
|
|
251
|
+
"""Maps to core.citizen_science_user_assignments."""
|
|
252
|
+
|
|
253
|
+
__tablename__ = "citizen_science_user_assignments"
|
|
254
|
+
__table_args__ = {"schema": "core"}
|
|
255
|
+
|
|
256
|
+
user_id: uuid.UUID = Field(primary_key=True, foreign_key="core.users.id")
|
|
257
|
+
option_id: int = Field(
|
|
258
|
+
primary_key=True, foreign_key="core.citizen_science_feature_options.id"
|
|
259
|
+
)
|
|
260
|
+
assigned_at: datetime = Field(
|
|
261
|
+
default_factory=lambda: datetime.now(UTC),
|
|
262
|
+
sa_column=Column(DateTime(timezone=True), nullable=False),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Override: reference the existing core.cs_assignment_source enum type
|
|
266
|
+
source: CsAssignmentSource = Field(
|
|
267
|
+
sa_column=Column(
|
|
268
|
+
SAEnum(
|
|
269
|
+
CsAssignmentSource,
|
|
270
|
+
name="cs_assignment_source",
|
|
271
|
+
schema="core",
|
|
272
|
+
create_type=False,
|
|
273
|
+
),
|
|
274
|
+
nullable=False,
|
|
275
|
+
)
|
|
276
|
+
)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Reusable FastAPI dependencies."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Annotated, Optional
|
|
8
|
+
|
|
9
|
+
from fastapi import Depends, HTTPException, status
|
|
10
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
11
|
+
from keycloak import KeycloakAdmin
|
|
12
|
+
|
|
13
|
+
from meinewaldki_citizen_rest_service.constants import (
|
|
14
|
+
GUEST_USER_REALM_NAME,
|
|
15
|
+
REGISTERED_USER_REALM_NAME,
|
|
16
|
+
)
|
|
17
|
+
from meinewaldki_citizen_rest_service.keycloak_clients import (
|
|
18
|
+
guest_user_admin,
|
|
19
|
+
guest_user_client,
|
|
20
|
+
registered_user_admin,
|
|
21
|
+
registered_user_client,
|
|
22
|
+
)
|
|
23
|
+
from meinewaldki_citizen_rest_service.schemas import UserStatus
|
|
24
|
+
|
|
25
|
+
_bearer = HTTPBearer()
|
|
26
|
+
|
|
27
|
+
_UNAUTHORIZED = HTTPException(
|
|
28
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
29
|
+
detail="Invalid or expired access token",
|
|
30
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class TokenData:
|
|
36
|
+
user_id: uuid.UUID
|
|
37
|
+
user_type: UserStatus
|
|
38
|
+
email: Optional[str]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _realm_from_token(token: str) -> str:
|
|
42
|
+
"""Extracts the realm name from the JWT iss claim without verifying the signature.
|
|
43
|
+
|
|
44
|
+
Keycloak sets iss to: ``{server_url}/realms/{realm_name}``.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
token: Raw JWT bearer token string.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The Keycloak realm name extracted from the ``iss`` claim.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
HTTPException: If the token cannot be decoded or lacks an ``iss`` claim.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
payload_b64 = token.split(".")[1]
|
|
57
|
+
payload_b64 += "=" * (4 - len(payload_b64) % 4)
|
|
58
|
+
payload = json.loads(base64.b64decode(payload_b64))
|
|
59
|
+
return payload["iss"].rstrip("/").split("/")[-1]
|
|
60
|
+
except Exception:
|
|
61
|
+
raise _UNAUTHORIZED
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_current_user_id(
|
|
65
|
+
credentials: Annotated[HTTPAuthorizationCredentials, Depends(_bearer)],
|
|
66
|
+
) -> uuid.UUID:
|
|
67
|
+
"""Verifies the bearer token against the correct Keycloak realm.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
credentials: HTTP Bearer credentials from the Authorization header.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
The authenticated user's UUID.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
HTTPException: If the token is invalid or the realm is unrecognised.
|
|
77
|
+
"""
|
|
78
|
+
token = credentials.credentials
|
|
79
|
+
realm = _realm_from_token(token)
|
|
80
|
+
|
|
81
|
+
if realm == GUEST_USER_REALM_NAME:
|
|
82
|
+
client = guest_user_client
|
|
83
|
+
elif realm == REGISTERED_USER_REALM_NAME:
|
|
84
|
+
client = registered_user_client
|
|
85
|
+
else:
|
|
86
|
+
raise _UNAUTHORIZED
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
user_info = client.decode_token(token)
|
|
90
|
+
return uuid.UUID(user_info["sub"])
|
|
91
|
+
except Exception:
|
|
92
|
+
raise _UNAUTHORIZED
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_current_token_data(
|
|
96
|
+
credentials: Annotated[HTTPAuthorizationCredentials, Depends(_bearer)],
|
|
97
|
+
) -> TokenData:
|
|
98
|
+
"""Verify the bearer token and return the user ID, user type, and email."""
|
|
99
|
+
token = credentials.credentials
|
|
100
|
+
realm = _realm_from_token(token)
|
|
101
|
+
|
|
102
|
+
if realm == GUEST_USER_REALM_NAME:
|
|
103
|
+
client = guest_user_client
|
|
104
|
+
user_type = UserStatus.guest
|
|
105
|
+
elif realm == REGISTERED_USER_REALM_NAME:
|
|
106
|
+
client = registered_user_client
|
|
107
|
+
user_type = UserStatus.registered
|
|
108
|
+
else:
|
|
109
|
+
raise _UNAUTHORIZED
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
user_info = client.decode_token(token)
|
|
113
|
+
return TokenData(
|
|
114
|
+
user_id=uuid.UUID(user_info["sub"]),
|
|
115
|
+
user_type=user_type,
|
|
116
|
+
email=user_info.get("email"),
|
|
117
|
+
)
|
|
118
|
+
except Exception:
|
|
119
|
+
raise _UNAUTHORIZED
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_current_keycloak_admin(
|
|
123
|
+
credentials: Annotated[HTTPAuthorizationCredentials, Depends(_bearer)],
|
|
124
|
+
) -> KeycloakAdmin:
|
|
125
|
+
"""Return the Keycloak admin client for the realm that issued the token."""
|
|
126
|
+
realm = _realm_from_token(credentials.credentials)
|
|
127
|
+
|
|
128
|
+
if realm == GUEST_USER_REALM_NAME:
|
|
129
|
+
return guest_user_admin
|
|
130
|
+
elif realm == REGISTERED_USER_REALM_NAME:
|
|
131
|
+
return registered_user_admin
|
|
132
|
+
else:
|
|
133
|
+
raise _UNAUTHORIZED
|