Skip to content

Commit 9e2efd1

Browse files
✨(feature) add config endpoint (#38)
2 parents 00e5784 + bb1c976 commit 9e2efd1

20 files changed

Lines changed: 379 additions & 151 deletions

File tree

backend/app/clients/drive.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ def __init__(self, base_url: str, token: str) -> None:
1111
headers={"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"}
1212
)
1313

14-
1514
def get_documents(
1615
self, path: str = "api/v1.0/items/", page: int = 1, title: str | None = None, favorite: bool = False
1716
) -> list[Document]:
@@ -29,20 +28,18 @@ def get_documents(
2928
if len(results) < 1:
3029
return TypeAdapter(list[Document]).validate_python([])
3130

32-
33-
workspace_id = results[0]['id']
31+
workspace_id = results[0]["id"]
3432

3533
item_url = f"{self.base_url}/api/v1.0/items/{workspace_id}/children/"
3634

3735
response = self.client.request("GET", item_url)
3836
if response.status_code != 200:
3937
return TypeAdapter(list[Document]).validate_python([])
40-
38+
4139
results = response.json().get("results", [])
4240

4341
print(results)
4442

45-
4643
notes: list[Document] = TypeAdapter(list[Document]).validate_python(results)
4744

48-
return notes
45+
return notes

backend/app/clients/meet.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,4 @@ def get_meetings(
3030

3131
notes: list[Meeting] = TypeAdapter(list[Meeting]).validate_python(results)
3232

33-
return notes
33+
return notes

backend/app/clients/zaken.py

Lines changed: 0 additions & 46 deletions
This file was deleted.

backend/app/config.py

Lines changed: 138 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,168 @@
1+
import json
12
import secrets
2-
from typing import Literal
3+
from typing import Annotated, Any, Literal
34

45
from jose.constants import ALGORITHMS
6+
from pydantic import AnyUrl, BeforeValidator, HttpUrl, computed_field
57
from pydantic_settings import BaseSettings, SettingsConfigDict
68

79

10+
def parse_cors_origins(v: Any) -> list[str] | str: # noqa: ANN401
11+
if isinstance(v, str):
12+
if v == "*":
13+
return "*"
14+
if not v.startswith("["):
15+
return [i.strip() for i in v.split(",") if i.strip()]
16+
if isinstance(v, list):
17+
for idx, item in enumerate(v): # type: ignore[misc]
18+
if not isinstance(item, str):
19+
raise TypeError(f"CORS_ALLOW_ORIGINS list item at index {idx} must be string, got {type(item)}") # type: ignore[arg-type]
20+
return v # type: ignore[return-value]
21+
raise ValueError(f"CORS_ALLOW_ORIGINS must be string or list, got {type(v)}")
22+
23+
24+
def parse_sidebar_links(v: Any) -> list[dict[str, str]]: # noqa: ANN401
25+
if isinstance(v, str):
26+
if not v.strip():
27+
return []
28+
try:
29+
parsed: Any = json.loads(v)
30+
except json.JSONDecodeError as e:
31+
raise ValueError(f"Invalid JSON in SIDEBAR_LINKS_JSON: {e}")
32+
33+
if not isinstance(parsed, list):
34+
raise ValueError("SIDEBAR_LINKS_JSON must be a JSON array")
35+
36+
for idx, link in enumerate(parsed): # type: ignore[misc]
37+
if not isinstance(link, dict):
38+
raise ValueError(f"Link at index {idx} must be an object")
39+
required = {"icon", "url", "title"}
40+
if not required.issubset(link.keys()): # type: ignore[arg-type]
41+
raise ValueError(f"Link at index {idx} missing fields: {required - link.keys()}")
42+
43+
# Validate URL is valid http/https
44+
try:
45+
HttpUrl(link["url"]) # type: ignore[arg-type]
46+
except Exception as e:
47+
raise ValueError(f"Link at index {idx} has invalid URL: {e}")
48+
49+
return parsed # type: ignore[return-value]
50+
51+
if isinstance(v, list):
52+
return v # type: ignore[return-value]
53+
54+
raise ValueError(f"SIDEBAR_LINKS_JSON must be string or list, got {type(v)}")
55+
56+
857
class Settings(BaseSettings):
9-
model_config = SettingsConfigDict(env_file=("dev.env", ".env", "prod.env"), extra="ignore")
58+
model_config = SettingsConfigDict(
59+
env_file=("dev.env", ".env", "prod.env"),
60+
env_file_encoding="utf-8",
61+
extra="ignore",
62+
case_sensitive=True,
63+
)
64+
1065
API_V1_STR: str = "/api/v1"
1166
SECRET_KEY: str = secrets.token_urlsafe(32)
12-
1367
DEBUG: bool = False
14-
1568
ENVIRONMENT: Literal["dev", "prod"] = "prod"
1669

70+
# OpenID Connect
1771
OIDC_CLIENT_ID: str = "bureaublad-frontend"
1872
OIDC_CLIENT_SECRET: str | None = None
19-
OIDC_AUTHORIZATION_ENDPOINT: str = (
20-
"https://id.la-suite.apps.digilab.network/realms/lasuite/protocol/openid-connect/auth"
21-
)
22-
OIDC_TOKEN_ENDPOINT: str = "https://id.la-suite.apps.digilab.network/realms/lasuite/protocol/openid-connect/token" # noqa: S105
23-
OIDC_JWKS_ENDPOINT: str = "https://id.la-suite.apps.digilab.network/realms/lasuite/protocol/openid-connect/certs"
73+
OIDC_AUTHORIZATION_ENDPOINT: str = ""
74+
OIDC_TOKEN_ENDPOINT: str = ""
75+
OIDC_JWKS_ENDPOINT: str = ""
76+
OIDC_USERNAME_CLAIM: str = "preferred_username"
2477
OIDC_SCOPES: dict[str, str] = {"openid": "", "profile": "", "email": ""}
2578
OIDC_AUDIENCE: str = "account"
26-
OIDC_ISSUER: str = "https://id.la-suite.apps.digilab.network/realms/lasuite"
79+
OIDC_ISSUER: str = ""
2780
OIDC_SIGNATURE_ALGORITM: str | list[str] = [ALGORITHMS.RS256, ALGORITHMS.HS256]
2881

29-
OCS_URL: str = "https://files.la-suite.apps.digilab.network"
82+
# La Suite Services
83+
OCS_URL: str | None = None
3084
OCS_AUDIENCE: str = "files"
31-
DOCS_URL: str = "https://docs.la-suite.apps.digilab.network"
85+
DOCS_URL: str | None = None
3286
DOCS_AUDIENCE: str = "docs"
33-
DRIVE_URL: str = "http://localhost:3001"
34-
DRIVE_AUDIENCE: str = "drive"
35-
MEET_URL: str = "http://localhost:8085"
36-
MEET_AUDIENCE: str = "meet"
37-
CALENDAR_URL: str = "https://files.la-suite.apps.digilab.network"
87+
CALENDAR_URL: str | None = None
3888
CALENDAR_AUDIENCE: str = "files"
39-
TASK_URL: str = "https://files.la-suite.apps.digilab.network"
89+
TASK_URL: str | None = None
4090
TASK_AUDIENCE: str = "files"
41-
AI_BASE_URL: str = "https://api.openai.com/v1/"
42-
AI_MODEL: str = "gpt-4o"
91+
DRIVE_URL: str | None = None
92+
DRIVE_AUDIENCE: str = "drive"
93+
MEET_URL: str | None = None
94+
MEET_AUDIENCE: str = "meet"
95+
96+
# AI Integration
97+
AI_BASE_URL: str | None = "https://api.openai.com/v1/"
98+
AI_MODEL: str | None = "gpt-4o"
4399
AI_API_KEY: str | None = None
44-
ZAKEN_URL: str = "https://open-zaak.commonground.apps.digilab.network"
45-
ZAKEN_AUDIENCE: str = "openzaak"
46-
OPENZAAK_CLIENT_ID: str = ""
47-
OPENZAAK_SECRET: str = ""
48100

49-
CORS_ALLOW_ORIGINS: str | list[str] = "*"
101+
# Grist
102+
GRIST_BASE_URL: str | None = None
103+
104+
THEME_CSS_URL: str = ""
105+
106+
SIDEBAR_LINKS_JSON: Annotated[list[dict[str, str]], BeforeValidator(parse_sidebar_links)] = []
107+
108+
CORS_ALLOW_ORIGINS: Annotated[list[AnyUrl] | str, BeforeValidator(parse_cors_origins)] = []
50109
CORS_ALLOW_CREDENTIALS: bool = False
51110
CORS_ALLOW_METHODS: list[str] = ["*"]
52111
CORS_ALLOW_HEADERS: list[str] = ["*"]
53112

113+
@computed_field # type: ignore[prop-decorator]
114+
@property
115+
def ocs_enabled(self) -> bool:
116+
return self.OCS_URL is not None
117+
118+
@computed_field # type: ignore[prop-decorator]
119+
@property
120+
def docs_enabled(self) -> bool:
121+
return self.DOCS_URL is not None
122+
123+
@computed_field # type: ignore[prop-decorator]
124+
@property
125+
def calendar_enabled(self) -> bool:
126+
return self.CALENDAR_URL is not None
127+
128+
@computed_field # type: ignore[prop-decorator]
129+
@property
130+
def task_enabled(self) -> bool:
131+
return self.TASK_URL is not None
132+
133+
@computed_field # type: ignore[prop-decorator]
134+
@property
135+
def drive_enabled(self) -> bool:
136+
return self.DRIVE_URL is not None
137+
138+
@computed_field # type: ignore[prop-decorator]
139+
@property
140+
def meet_enabled(self) -> bool:
141+
return self.MEET_URL is not None
142+
143+
@computed_field # type: ignore[prop-decorator]
144+
@property
145+
def ai_enabled(self) -> bool:
146+
return self.AI_BASE_URL is not None and self.AI_MODEL is not None and self.AI_API_KEY is not None
147+
148+
@computed_field # type: ignore[prop-decorator]
149+
@property
150+
def grist_enabled(self) -> bool:
151+
return self.GRIST_BASE_URL is not None
152+
153+
@computed_field # type: ignore[prop-decorator]
154+
@property
155+
def oidc_discovery_endpoint(self) -> str:
156+
if self.OIDC_ISSUER:
157+
return f"{self.OIDC_ISSUER.rstrip('/')}/.well-known/openid-configuration"
158+
return ""
159+
160+
@computed_field # type: ignore[prop-decorator]
161+
@property
162+
def all_cors_origins(self) -> list[str]:
163+
if isinstance(self.CORS_ALLOW_ORIGINS, str):
164+
return [self.CORS_ALLOW_ORIGINS]
165+
return [str(origin).rstrip("/") for origin in self.CORS_ALLOW_ORIGINS]
166+
54167

55168
settings = Settings() # type: ignore[reportCallIssue]

backend/app/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030

3131
app.add_middleware(
3232
CORSMiddleware,
33-
allow_origins=settings.CORS_ALLOW_ORIGINS,
33+
allow_origins=settings.all_cors_origins,
3434
allow_credentials=settings.CORS_ALLOW_CREDENTIALS,
3535
allow_methods=settings.CORS_ALLOW_METHODS,
3636
allow_headers=settings.CORS_ALLOW_HEADERS,

backend/app/models/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
from .activity import Activity # noqa: F401
44
from .ai import ChatCompletionRequest # noqa: F401
55
from .calendar import Calendar # noqa: F401
6+
from .config import Component # noqa: F401
7+
from .document import Document # noqa: F401
8+
from .meeting import Meeting # noqa: F401
69
from .note import Note # noqa: F401
710
from .search import FileSearchResult, SearchResults # noqa: F401
811
from .task import Task # noqa: F401
912
from .user import User # noqa: F401
10-
from .zaak import Zaak # noqa: F401

backend/app/models/config.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from pydantic import BaseModel
2+
3+
4+
class Component(BaseModel):
5+
name: str
6+
enabled: bool
7+
8+
9+
class Service(BaseModel):
10+
name: str
11+
enabled: bool
12+
13+
14+
class SidebarLink(BaseModel):
15+
icon: str
16+
url: str
17+
title: str
18+
19+
20+
class OIDCConfig(BaseModel):
21+
discovery_endpoint: str
22+
username_claim: str
23+
24+
25+
class ApplicationsConfig(BaseModel):
26+
ai: bool = False
27+
docs: bool = False
28+
drive: bool = False
29+
calendar: bool = False
30+
task: bool = False
31+
meet: bool = False
32+
ocs: bool = False
33+
grist: bool = False
34+
35+
36+
class ConfigResponse(BaseModel):
37+
sidebar_links: list[SidebarLink]
38+
theme_css: str
39+
applications: ApplicationsConfig
40+
oidc: OIDCConfig

backend/app/models/meeting.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33

44
class Meeting(BaseModel):
5-
title: str
5+
title: str

backend/app/models/zaak.py

Lines changed: 0 additions & 20 deletions
This file was deleted.

backend/app/routes/ai.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22

3-
from fastapi import APIRouter, Request
3+
from fastapi import APIRouter, HTTPException, Request
44
from openai import OpenAI
55

66
from app.config import settings
@@ -13,8 +13,9 @@
1313

1414
@router.post("/chat/completions")
1515
async def ai_post_chat_completions(request: Request, chat_request: ChatCompletionRequest) -> str:
16-
if settings.AI_API_KEY is None:
17-
return "AI niet beschikbaar"
16+
# Redundant checks needed to satisfy the type system.
17+
if not settings.ai_enabled or not settings.AI_MODEL:
18+
raise HTTPException(status_code=503, detail="AI service is not configured")
1819

1920
client = OpenAI(base_url=settings.AI_BASE_URL, api_key=settings.AI_API_KEY)
2021

0 commit comments

Comments
 (0)