|
1 | | -from enum import Enum |
| 1 | +import json |
| 2 | +from datetime import datetime |
| 3 | +from typing import Any, Optional |
| 4 | +from uuid import UUID |
2 | 5 |
|
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 |
16 | 11 |
|
17 | | -from api.core.database import Base |
| 12 | +from api.core.config import Settings |
| 13 | +from api.src.workspaces.schemas import ExternalAppsDefinitionType, QuestDefinitionType |
18 | 14 |
|
19 | 15 |
|
20 | | -class ExternalAppsDefinitionType(Enum): |
21 | | - NONE = 0 |
22 | | - PUBLIC = 1 |
23 | | - PROJECT_GROUP = 2 |
| 16 | +class WorkspaceLongQuestBase(BaseModel): |
24 | 17 |
|
| 18 | + workspace_id: int |
25 | 19 |
|
26 | | -class Workspace(Base): |
27 | | - """Workspaces""" |
| 20 | + definition: Optional[str] |
| 21 | + type: QuestDefinitionType |
| 22 | + url: Optional[str] |
28 | 23 |
|
29 | | - __tablename__ = "workspaces" |
| 24 | + modifiedAt: datetime |
| 25 | + modifiedBy: UUID |
| 26 | + modifiedByName: str |
30 | 27 |
|
31 | | - id = Column(Integer, primary_key=True) |
32 | | - type = Column(Unicode, nullable=False) |
| 28 | + model_config = ConfigDict(from_attributes=True) |
33 | 29 |
|
34 | | - title = Column(Unicode, nullable=False) |
35 | | - description = Column(Unicode) |
| 30 | + def validate_definition(self, data, value): |
| 31 | + if QuestDefinitionType[data["type"]] == QuestDefinitionType.NONE: |
| 32 | + if not value: |
| 33 | + return None |
| 34 | + raise ValidationError("'definition' field not allowed.") |
36 | 35 |
|
37 | | - tdeiProjectGroupId = Column(UUID(as_uuid=True), nullable=False) |
38 | | - tdeiRecordId = Column(UUID(as_uuid=True)) |
39 | | - tdeiServiceId = Column(UUID(as_uuid=True)) |
| 36 | + if QuestDefinitionType[data["type"]] != QuestDefinitionType.JSON: |
| 37 | + return value |
40 | 38 |
|
41 | | - tdeiMetadata = Column(JSON) |
| 39 | + if not value: |
| 40 | + raise ValidationError("This field is required.") |
| 41 | + if data["url"]: |
| 42 | + raise ValidationError("'url' field not allowed.") |
42 | 43 |
|
43 | | - createdAt = Column(DateTime, nullable=False, default=func.now()) |
44 | | - createdBy = Column(UUID(as_uuid=True), nullable=False) |
45 | | - createdByName = Column(Unicode) |
| 44 | + try: |
| 45 | + parsed = json.loads(value) |
| 46 | + if not parsed or not isinstance(parsed, dict): |
| 47 | + raise ValidationError("must be a JSON object.") |
| 48 | + validate_json_against_schema(parsed, Settings.WS_LONGFORM_SCHEMA_URL) |
| 49 | + except json.JSONDecodeError as e: |
| 50 | + return ValidationError(f"{e}") |
| 51 | + except ValidationError as e: |
| 52 | + raise ValidationError(f"{e}") |
46 | 53 |
|
47 | | - geometry = Column(Geometry("MULTIPOLYGON", srid=4326)) |
| 54 | + return value |
48 | 55 |
|
49 | | - externalAppAccess = Column( |
50 | | - SmallInteger, nullable=False, default=ExternalAppsDefinitionType.NONE.value |
51 | | - ) |
| 56 | + def validate_url(self, data, value): |
| 57 | + if QuestDefinitionType[data["type"]] == QuestDefinitionType.NONE: |
| 58 | + if not value: |
| 59 | + return None |
| 60 | + raise ValidationError("'url' field not allowed.") |
52 | 61 |
|
53 | | - kartaViewToken = Column(Unicode) |
| 62 | + if QuestDefinitionType[data["type"]] != QuestDefinitionType.URL: |
| 63 | + return value |
54 | 64 |
|
55 | | - longFormQuestDef: Mapped[list["WorkspaceLongQuest"]] = relationship( |
56 | | - "WorkspaceLongQuest", uselist=False, lazy="joined", cascade="all, delete" |
57 | | - ) |
| 65 | + if not value: |
| 66 | + raise ValidationError("This field is required.") |
| 67 | + if data["definition"]: |
| 68 | + raise ValidationError("'definition' field not allowed.") |
58 | 69 |
|
59 | | - imageryListDef: Mapped[list["WorkspaceImagery"]] = relationship( |
60 | | - "WorkspaceImagery", uselist=False, lazy="joined", cascade="all, delete" |
61 | | - ) |
| 70 | + return value |
62 | 71 |
|
63 | 72 |
|
64 | | -class QuestDefinitionType(Enum): |
65 | | - NONE = 0 |
66 | | - JSON = 1 |
67 | | - URL = 2 |
| 73 | +class WorkspaceImageryBase(BaseModel): |
68 | 74 |
|
| 75 | + workspace_id: int |
69 | 76 |
|
70 | | -class WorkspaceLongQuest(Base): |
71 | | - """Stores mobile app quest definitions for a workspace""" |
| 77 | + # Note the below column is of the JSON *database* type vs string type, so we're not |
| 78 | + # using pydantic's JSON mapping, hence this is not defined as Optional[Json[Any]] |
| 79 | + definition: Optional[list[Any]] |
72 | 80 |
|
73 | | - __tablename__ = "workspaces_long_quests" |
| 81 | + modifiedAt: datetime |
| 82 | + modifiedBy: UUID |
| 83 | + modifiedByName: str |
74 | 84 |
|
75 | | - workspace_id = Column(Integer, ForeignKey(Workspace.id), primary_key=True) |
| 85 | + model_config = ConfigDict(from_attributes=True) |
76 | 86 |
|
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) |
80 | 87 |
|
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) |
| 88 | +class WorkspaceBase(BaseModel): |
86 | 89 |
|
| 90 | + id: int |
| 91 | + type: str = Field(...) |
87 | 92 |
|
88 | | -class WorkspaceImagery(Base): |
89 | | - """Stores imagery list for a workspace""" |
| 93 | + title: str = Field(...) |
| 94 | + description: Optional[str] |
90 | 95 |
|
91 | | - __tablename__ = "workspaces_imagery" |
| 96 | + tdeiProjectGroupId: UUID |
| 97 | + tdeiRecordId: Optional[UUID] |
| 98 | + tdeiServiceId: Optional[UUID] |
92 | 99 |
|
93 | | - workspace_id = Column(Integer, ForeignKey(Workspace.id), primary_key=True) |
94 | | - definition = Column(JSON, nullable=True, default=None) |
| 100 | + tdeiMetadata: Optional[Json[Any]] |
95 | 101 |
|
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) |
| 102 | + createdAt: datetime |
| 103 | + createdBy: UUID |
| 104 | + createdByName: str |
| 105 | + |
| 106 | + geometry: Optional[Annotated[str, WKBElement]] |
| 107 | + |
| 108 | + externalAppAccess: ExternalAppsDefinitionType |
| 109 | + |
| 110 | + kartaViewToken: Optional[str] |
| 111 | + |
| 112 | + longFormQuestDef: Optional[WorkspaceLongQuestBase] |
| 113 | + |
| 114 | + imageryListDef: Optional[WorkspaceImageryBase] |
| 115 | + |
| 116 | + model_config = ConfigDict(from_attributes=True) |
| 117 | + |
| 118 | + # there are some legacy records with '', which is not valid JSON, so map those to None |
| 119 | + @field_validator("*", mode="before") |
| 120 | + @classmethod |
| 121 | + def empty_str_to_none(cls, v): |
| 122 | + if v == "": |
| 123 | + return None |
| 124 | + return v |
| 125 | + |
| 126 | + |
| 127 | +class WorkspaceCreate(WorkspaceBase): |
| 128 | + pass |
| 129 | + |
| 130 | + |
| 131 | +class WorkspaceUpdate(WorkspaceBase): |
| 132 | + pass |
| 133 | + |
| 134 | + |
| 135 | +class WorkspaceResponse(WorkspaceBase): |
| 136 | + pass |
| 137 | + |
| 138 | + |
| 139 | +def validate_json_against_schema(json, schema_url) -> bool: |
| 140 | + """ |
| 141 | + Validate a JSON string against a JSON schema from a URL. |
| 142 | + Returns True if valid, raises ValidationError if not. |
| 143 | + """ |
| 144 | + # Fetch the schema |
| 145 | + response = requests.get(schema_url) |
| 146 | + response.raise_for_status() |
| 147 | + schema = response.json() |
| 148 | + |
| 149 | + # Validate |
| 150 | + validate(instance=json, schema=schema) |
| 151 | + return True |
0 commit comments