Skip to content

Commit 8ace729

Browse files
committed
Renaming
1 parent 00fd397 commit 8ace729

File tree

5 files changed

+212
-202
lines changed

5 files changed

+212
-202
lines changed

api/src/workspaces/models.py

Lines changed: 127 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,100 +1,157 @@
1-
from enum import Enum
1+
import json
2+
from datetime import datetime
3+
from typing import Any, Optional
4+
from uuid import UUID
25

3-
from geoalchemy2 import Geometry
4-
from sqlalchemy import (
5-
JSON,
6-
UUID,
7-
Column,
8-
DateTime,
9-
ForeignKey,
10-
Integer,
11-
SmallInteger,
12-
Unicode,
13-
)
14-
from sqlalchemy.orm import Mapped, relationship
15-
from sqlalchemy.sql import func
6+
import requests
7+
from geoalchemy2 import WKBElement
8+
from jsonschema import ValidationError, validate
9+
from pydantic import BaseModel, ConfigDict, Field, Json, field_validator
10+
from typing_extensions import Annotated
1611

17-
from api.core.database import Base
12+
from api.core.config import Settings
13+
from api.src.workspaces.schemas import ExternalAppsDefinitionType, QuestDefinitionType
1814

15+
class WorkspaceLongQuestBase(BaseModel):
1916

20-
class ExternalAppsDefinitionType(Enum):
21-
NONE = 0
22-
PUBLIC = 1
23-
PROJECT_GROUP = 2
17+
workspace_id: int
2418

19+
definition: Optional[str]
20+
type: QuestDefinitionType
21+
url: Optional[str]
2522

26-
class Workspace(Base):
27-
"""Workspaces"""
23+
modifiedAt: datetime
24+
modifiedBy: UUID
25+
modifiedByName: str
2826

29-
__tablename__ = "workspaces"
27+
model_config = ConfigDict(from_attributes=True)
3028

31-
id = Column(Integer, primary_key=True)
32-
type = Column(Unicode, nullable=False)
29+
def validate_definition(self, data, value):
30+
if QuestDefinitionType[data["type"]] == QuestDefinitionType.NONE:
31+
if not value:
32+
return None
33+
raise ValidationError("'definition' field not allowed.")
3334

34-
title = Column(Unicode, nullable=False)
35-
description = Column(Unicode)
35+
if QuestDefinitionType[data["type"]] != QuestDefinitionType.JSON:
36+
return value
3637

37-
tdeiProjectGroupId = Column(UUID(as_uuid=True), nullable=False)
38-
tdeiRecordId = Column(UUID(as_uuid=True))
39-
tdeiServiceId = Column(UUID(as_uuid=True))
38+
if not value:
39+
raise ValidationError("This field is required.")
40+
if data["url"]:
41+
raise ValidationError("'url' field not allowed.")
4042

41-
tdeiMetadata = Column(JSON)
43+
try:
44+
parsed = json.loads(value)
45+
if not parsed or not isinstance(parsed, dict):
46+
raise ValidationError("must be a JSON object.")
47+
validate_json_against_schema(parsed, Settings.WS_LONGFORM_SCHEMA_URL)
48+
except json.JSONDecodeError as e:
49+
return ValidationError(f"{e}")
50+
except ValidationError as e:
51+
raise ValidationError(f"{e}")
4252

43-
createdAt = Column(DateTime, nullable=False, default=func.now())
44-
createdBy = Column(UUID(as_uuid=True), nullable=False)
45-
createdByName = Column(Unicode)
53+
return value
4654

47-
geometry = Column(Geometry("MULTIPOLYGON", srid=4326))
55+
def validate_url(self, data, value):
56+
if QuestDefinitionType[data["type"]] == QuestDefinitionType.NONE:
57+
if not value:
58+
return None
59+
raise ValidationError("'url' field not allowed.")
4860

49-
externalAppAccess = Column(
50-
SmallInteger, nullable=False, default=ExternalAppsDefinitionType.NONE.value
51-
)
61+
if QuestDefinitionType[data["type"]] != QuestDefinitionType.URL:
62+
return value
5263

53-
kartaViewToken = Column(Unicode)
64+
if not value:
65+
raise ValidationError("This field is required.")
66+
if data["definition"]:
67+
raise ValidationError("'definition' field not allowed.")
5468

55-
longFormQuestDef: Mapped[list["WorkspaceLongQuest"]] = relationship(
56-
"WorkspaceLongQuest", uselist=False, lazy="joined", cascade="all, delete"
57-
)
69+
return value
5870

59-
imageryListDef: Mapped[list["WorkspaceImagery"]] = relationship(
60-
"WorkspaceImagery", uselist=False, lazy="joined", cascade="all, delete"
61-
)
71+
class WorkspaceLongQuestUpdate(BaseModel):
72+
definition: Optional[str]
73+
url: Optional[str]
6274

75+
class WorkspaceImageryBase(BaseModel):
6376

64-
class QuestDefinitionType(Enum):
65-
NONE = 0
66-
JSON = 1
67-
URL = 2
77+
workspace_id: int
6878

79+
# Note the below column is of the JSON *database* type vs string type, so we're not
80+
# using pydantic's JSON mapping, hence this is not defined as Optional[Json[Any]]
81+
definition: Optional[list[Any]]
6982

70-
class WorkspaceLongQuest(Base):
71-
"""Stores mobile app quest definitions for a workspace"""
83+
modifiedAt: datetime
84+
modifiedBy: UUID
85+
modifiedByName: str
7286

73-
__tablename__ = "workspaces_long_quests"
87+
model_config = ConfigDict(from_attributes=True)
7488

75-
workspace_id = Column(Integer, ForeignKey(Workspace.id), primary_key=True)
89+
class WorkspaceImageryUpdate(BaseModel):
90+
definition: Optional[str]
7691

77-
definition = Column(Unicode, nullable=True, default=None)
78-
type = Column(Integer, nullable=False, default=QuestDefinitionType.NONE.value)
79-
url = Column(Unicode, nullable=True, default=None)
92+
class WorkspaceBase(BaseModel):
8093

81-
modifiedAt = Column(
82-
DateTime, nullable=False, default=func.now(), onupdate=func.now()
83-
)
84-
modifiedBy = Column(UUID(as_uuid=True), nullable=False)
85-
modifiedByName = Column(Unicode, nullable=False)
94+
id: int
95+
type: str = Field(...)
8696

97+
title: str = Field(...)
98+
description: Optional[str]
8799

88-
class WorkspaceImagery(Base):
89-
"""Stores imagery list for a workspace"""
100+
tdeiProjectGroupId: UUID
101+
tdeiRecordId: Optional[UUID]
102+
tdeiServiceId: Optional[UUID]
90103

91-
__tablename__ = "workspaces_imagery"
104+
tdeiMetadata: Optional[Json[Any]]
92105

93-
workspace_id = Column(Integer, ForeignKey(Workspace.id), primary_key=True)
94-
definition = Column(JSON, nullable=True, default=None)
106+
createdAt: datetime
107+
createdBy: UUID
108+
createdByName: str
95109

96-
modifiedAt = Column(
97-
DateTime, nullable=False, default=func.now(), onupdate=func.now()
98-
)
99-
modifiedBy = Column(UUID(as_uuid=True), nullable=False)
100-
modifiedByName = Column(Unicode, nullable=False)
110+
geometry: Optional[Annotated[str, WKBElement]]
111+
112+
externalAppAccess: ExternalAppsDefinitionType
113+
114+
kartaViewToken: Optional[str]
115+
116+
longFormQuestDef: Optional[WorkspaceLongQuestBase]
117+
118+
imageryListDef: Optional[WorkspaceImageryBase]
119+
120+
model_config = ConfigDict(from_attributes=True)
121+
122+
# there are some legacy records with '', which is not valid JSON, so map those to None
123+
@field_validator("*", mode="before")
124+
@classmethod
125+
def empty_str_to_none(cls, v):
126+
if v == "":
127+
return None
128+
return v
129+
130+
131+
class WorkspaceCreate(WorkspaceBase):
132+
pass
133+
134+
class WorkspaceUpdate(BaseModel):
135+
title: Optional[str] = None
136+
description: Optional[str] = None
137+
externalAppAccess: Optional[ExternalAppsDefinitionType] = None
138+
longFormQuestDef: Optional[WorkspaceLongQuestBase] = None
139+
imageryListDef: Optional[WorkspaceImageryBase] = None
140+
141+
class WorkspaceResponse(WorkspaceBase):
142+
pass
143+
144+
145+
def validate_json_against_schema(json, schema_url) -> bool:
146+
"""
147+
Validate a JSON string against a JSON schema from a URL.
148+
Returns True if valid, raises ValidationError if not.
149+
"""
150+
# Fetch the schema
151+
response = requests.get(schema_url)
152+
response.raise_for_status()
153+
schema = response.json()
154+
155+
# Validate
156+
validate(instance=json, schema=schema)
157+
return True

api/src/workspaces/repository.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from typing import Any
12
from sqlalchemy import delete, select, update
23
from sqlalchemy.exc import IntegrityError
34
from sqlalchemy.ext.asyncio import AsyncSession
@@ -57,7 +58,7 @@ async def update(
5758
self,
5859
projectGroupIds: list[str],
5960
workspace_id: int,
60-
workspace_data: WorkspaceUpdate,
61+
workspace_data: Any,
6162
) -> Workspace:
6263
update_data = workspace_data.model_dump(exclude_unset=True)
6364
if not update_data:

api/src/workspaces/routes.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
1+
from typing import Any
12
from fastapi import APIRouter, Depends, HTTPException, status
23
from sqlalchemy.ext.asyncio import AsyncSession
34

45
from api.core.database import get_session
56
from api.core.logging import get_logger
67
from api.core.security import UserInfo, validate_token
8+
from api.src.workspaces.models import WorkspaceCreate, WorkspaceLongQuestBase, WorkspaceLongQuestUpdate, WorkspaceResponse, WorkspaceUpdate
79
from api.src.workspaces.repository import WorkspaceRepository
8-
from api.src.workspaces.schemas import (
9-
WorkspaceCreate,
10-
WorkspaceLongQuestBase,
11-
WorkspaceResponse,
12-
WorkspaceUpdate,
13-
)
10+
1411
from api.src.workspaces.service import WorkspaceService
1512

1613
# Set up logger for this module
@@ -79,8 +76,11 @@ async def update_workspace(
7976
current_user: UserInfo = Depends(validate_token),
8077
) -> WorkspaceResponse:
8178
try:
79+
newWorkspaceRecord = await service.get_workspace(current_user.projectGroups, workspace_id)
80+
newWorkspaceRecord.model_copy(update=workspace_data.model_dump(exclude_unset=True))
81+
8282
workspace = await service.update_workspace(
83-
current_user.projectGroups, workspace_id, workspace_data
83+
current_user.projectGroups, workspace_id, newWorkspaceRecord
8484
)
8585
return workspace
8686
except Exception as e:
@@ -142,7 +142,7 @@ async def get_long_quest_settings(
142142
@router.patch("/{workspace_id}/quests/long/settings", response_model=WorkspaceLongQuestBase)
143143
async def update_long_quest_settings(
144144
workspace_id: int,
145-
workspace_data: WorkspaceUpdate,
145+
longform_quest_data: WorkspaceLongQuestUpdate,
146146
service: WorkspaceService = Depends(get_workspace_service),
147147
current_user: UserInfo = Depends(validate_token),
148148
) -> WorkspaceLongQuestBase | None:
@@ -151,7 +151,10 @@ async def update_long_quest_settings(
151151
current_user.projectGroups, workspace_id
152152
) # type: ignore
153153

154-
# workspace.longFormQuestDef = longform_quest_data
154+
update_data = longform_quest_data.model_dump(exclude_unset=True)
155+
156+
if(workspace.longFormQuestDef is not None):
157+
workspace.longFormQuestDef.model_copy(update=update_data)
155158

156159
updatedWorkspace = await service.update_workspace(
157160
current_user.projectGroups, workspace_id, workspace

0 commit comments

Comments
 (0)