Skip to content

Commit 2fe204b

Browse files
authored
Merge pull request #6 from tanzilahmed0/task-b9-project-model-database
Implemented Task B9 - Project Model and Database
2 parents 295c060 + d063508 commit 2fe204b

4 files changed

Lines changed: 441 additions & 4 deletions

File tree

backend/models/__init__.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,78 @@
11
# Models package for SmartQuery backend
2+
3+
# Import all models to ensure they are registered with SQLAlchemy
4+
from models.base import Base
5+
from models.project import (
6+
ColumnMetadata,
7+
ProjectBase,
8+
ProjectCreate,
9+
ProjectInDB,
10+
ProjectPublic,
11+
ProjectStatusEnum,
12+
ProjectTable,
13+
ProjectUpdate,
14+
)
15+
from models.response_schemas import (
16+
ApiResponse,
17+
AuthResponse,
18+
ChatMessage,
19+
CreateProjectRequest,
20+
CreateProjectResponse,
21+
CSVPreview,
22+
HealthChecks,
23+
HealthStatus,
24+
PaginatedResponse,
25+
PaginationParams,
26+
Project,
27+
QueryResult,
28+
QuerySuggestion,
29+
UploadStatusResponse,
30+
User,
31+
ValidationError,
32+
)
33+
from models.user import (
34+
GoogleOAuthData,
35+
UserBase,
36+
UserCreate,
37+
UserInDB,
38+
UserTable,
39+
UserUpdate,
40+
)
41+
42+
__all__ = [
43+
# Base
44+
"Base",
45+
# User models
46+
"UserTable",
47+
"UserBase",
48+
"UserCreate",
49+
"UserUpdate",
50+
"UserInDB",
51+
"GoogleOAuthData",
52+
# Project models
53+
"ProjectTable",
54+
"ProjectStatusEnum",
55+
"ColumnMetadata",
56+
"ProjectBase",
57+
"ProjectCreate",
58+
"ProjectUpdate",
59+
"ProjectInDB",
60+
"ProjectPublic",
61+
# Response schemas
62+
"ApiResponse",
63+
"HealthStatus",
64+
"HealthChecks",
65+
"ValidationError",
66+
"User",
67+
"AuthResponse",
68+
"Project",
69+
"CreateProjectRequest",
70+
"CreateProjectResponse",
71+
"PaginationParams",
72+
"PaginatedResponse",
73+
"UploadStatusResponse",
74+
"ChatMessage",
75+
"QueryResult",
76+
"CSVPreview",
77+
"QuerySuggestion",
78+
]

backend/models/project.py

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import uuid
2+
from datetime import datetime
3+
from enum import Enum
4+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
5+
6+
from pydantic import BaseModel, Field, field_validator
7+
from sqlalchemy import (
8+
JSON,
9+
Boolean,
10+
Column,
11+
DateTime,
12+
)
13+
from sqlalchemy import Enum as SQLEnum
14+
from sqlalchemy import (
15+
ForeignKey,
16+
Integer,
17+
String,
18+
Text,
19+
TypeDecorator,
20+
func,
21+
)
22+
from sqlalchemy.dialects.postgresql import JSONB
23+
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
24+
from sqlalchemy.orm import Mapped, mapped_column, relationship
25+
26+
from models.base import Base
27+
28+
if TYPE_CHECKING:
29+
from models.user import UserTable
30+
31+
32+
class UUID(TypeDecorator):
33+
"""
34+
Platform-independent UUID type.
35+
36+
Uses PostgreSQL's UUID type, otherwise uses
37+
CHAR(32), storing as string.
38+
"""
39+
40+
impl = PG_UUID
41+
cache_ok = True
42+
43+
def load_dialect_impl(self, dialect):
44+
if dialect.name == "postgresql":
45+
return dialect.type_descriptor(PG_UUID())
46+
else:
47+
return dialect.type_descriptor(String(32))
48+
49+
def process_bind_param(self, value, dialect):
50+
if value is None:
51+
return value
52+
elif dialect.name == "postgresql":
53+
return str(value)
54+
else:
55+
if not isinstance(value, uuid.UUID):
56+
return "%.32x" % uuid.UUID(value).int
57+
else:
58+
# hexstring
59+
return "%.32x" % value.int
60+
61+
def process_result_value(self, value, dialect):
62+
if value is None:
63+
return value
64+
else:
65+
if not isinstance(value, uuid.UUID):
66+
value = uuid.UUID(value)
67+
return value
68+
69+
70+
class CrossDatabaseJSON(TypeDecorator):
71+
"""
72+
Platform-independent JSON type.
73+
74+
Uses PostgreSQL's JSONB type for better performance,
75+
otherwise uses standard JSON type.
76+
"""
77+
78+
impl = JSON
79+
cache_ok = True
80+
81+
def load_dialect_impl(self, dialect):
82+
if dialect.name == "postgresql":
83+
return dialect.type_descriptor(JSONB())
84+
else:
85+
return dialect.type_descriptor(JSON())
86+
87+
88+
class ProjectStatusEnum(str, Enum):
89+
"""Project status enumeration"""
90+
91+
UPLOADING = "uploading"
92+
PROCESSING = "processing"
93+
READY = "ready"
94+
ERROR = "error"
95+
96+
97+
class ProjectTable(Base):
98+
"""SQLAlchemy Project table model for PostgreSQL"""
99+
100+
__tablename__ = "projects"
101+
102+
id: Mapped[uuid.UUID] = mapped_column(UUID, primary_key=True, default=uuid.uuid4)
103+
user_id: Mapped[uuid.UUID] = mapped_column(
104+
UUID, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
105+
)
106+
name: Mapped[str] = mapped_column(String(255), nullable=False)
107+
description = Column(Text, nullable=True)
108+
csv_filename: Mapped[str] = mapped_column(String(255), nullable=False)
109+
csv_path: Mapped[str] = mapped_column(Text, nullable=False)
110+
row_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
111+
column_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
112+
columns_metadata = Column(CrossDatabaseJSON, nullable=True)
113+
status: Mapped[ProjectStatusEnum] = mapped_column(
114+
SQLEnum(ProjectStatusEnum), nullable=False, default=ProjectStatusEnum.UPLOADING
115+
)
116+
117+
# Timestamps
118+
created_at: Mapped[datetime] = mapped_column(
119+
DateTime(timezone=True), server_default=func.now()
120+
)
121+
updated_at: Mapped[datetime] = mapped_column(
122+
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
123+
)
124+
125+
# Relationships
126+
user: Mapped["UserTable"] = relationship(back_populates="projects")
127+
# chat_messages: Mapped[List["ChatMessageTable"]] = relationship(
128+
# back_populates="project", cascade="all, delete-orphan"
129+
# )
130+
131+
def __repr__(self):
132+
return f"<Project(id={self.id}, name='{self.name}', user_id={self.user_id}, status='{self.status}')>"
133+
134+
135+
# Pydantic models for API validation and serialization
136+
137+
138+
class ColumnMetadata(BaseModel):
139+
"""Column metadata model"""
140+
141+
name: str
142+
type: str
143+
nullable: bool = True
144+
sample_values: List[Any] = Field(default_factory=list)
145+
unique_count: Optional[int] = None
146+
min_value: Optional[float] = None
147+
max_value: Optional[float] = None
148+
149+
class Config:
150+
from_attributes = True
151+
152+
153+
class ProjectBase(BaseModel):
154+
"""Base project model with common fields"""
155+
156+
name: str
157+
description: Optional[str] = None
158+
csv_filename: str
159+
csv_path: str
160+
row_count: int = 0
161+
column_count: int = 0
162+
columns_metadata: List[ColumnMetadata] = Field(default_factory=list)
163+
status: ProjectStatusEnum = ProjectStatusEnum.UPLOADING
164+
165+
class Config:
166+
from_attributes = True
167+
168+
169+
class ProjectCreate(BaseModel):
170+
"""Project creation model"""
171+
172+
name: str
173+
description: Optional[str] = None
174+
175+
@field_validator("name")
176+
@classmethod
177+
def validate_name(cls, v):
178+
if not v or not v.strip():
179+
raise ValueError("Project name cannot be empty")
180+
if len(v.strip()) > 255:
181+
raise ValueError("Project name cannot exceed 255 characters")
182+
return v.strip()
183+
184+
@field_validator("description")
185+
@classmethod
186+
def validate_description(cls, v):
187+
if v is not None and len(v.strip()) > 1000:
188+
raise ValueError("Description cannot exceed 1000 characters")
189+
return v.strip() if v else None
190+
191+
class Config:
192+
from_attributes = True
193+
194+
195+
class ProjectUpdate(BaseModel):
196+
"""Project update model"""
197+
198+
name: Optional[str] = None
199+
description: Optional[str] = None
200+
csv_filename: Optional[str] = None
201+
csv_path: Optional[str] = None
202+
row_count: Optional[int] = None
203+
column_count: Optional[int] = None
204+
columns_metadata: Optional[List[ColumnMetadata]] = None
205+
status: Optional[ProjectStatusEnum] = None
206+
207+
@field_validator("name")
208+
@classmethod
209+
def validate_name(cls, v):
210+
if v is not None:
211+
if not v or not v.strip():
212+
raise ValueError("Project name cannot be empty")
213+
if len(v.strip()) > 255:
214+
raise ValueError("Project name cannot exceed 255 characters")
215+
return v.strip()
216+
return v
217+
218+
@field_validator("description")
219+
@classmethod
220+
def validate_description(cls, v):
221+
if v is not None and len(v.strip()) > 1000:
222+
raise ValueError("Description cannot exceed 1000 characters")
223+
return v.strip() if v else None
224+
225+
@field_validator("row_count", "column_count")
226+
@classmethod
227+
def validate_counts(cls, v):
228+
if v is not None and v < 0:
229+
raise ValueError("Counts cannot be negative")
230+
return v
231+
232+
class Config:
233+
from_attributes = True
234+
235+
236+
class ProjectInDB(ProjectBase):
237+
"""Project model as stored in database"""
238+
239+
id: uuid.UUID
240+
user_id: uuid.UUID
241+
created_at: datetime
242+
updated_at: datetime
243+
244+
class Config:
245+
from_attributes = True
246+
247+
248+
class ProjectPublic(BaseModel):
249+
"""Public project model for API responses"""
250+
251+
id: str
252+
user_id: str
253+
name: str
254+
description: Optional[str] = None
255+
csv_filename: str
256+
csv_path: str
257+
row_count: int
258+
column_count: int
259+
columns_metadata: List[ColumnMetadata]
260+
status: ProjectStatusEnum
261+
created_at: str
262+
updated_at: str
263+
264+
@classmethod
265+
def from_db_project(cls, project: ProjectInDB) -> "ProjectPublic":
266+
"""Convert ProjectInDB to ProjectPublic"""
267+
return cls(
268+
id=str(project.id),
269+
user_id=str(project.user_id),
270+
name=project.name,
271+
description=project.description,
272+
csv_filename=project.csv_filename,
273+
csv_path=project.csv_path,
274+
row_count=project.row_count,
275+
column_count=project.column_count,
276+
columns_metadata=project.columns_metadata,
277+
status=project.status,
278+
created_at=project.created_at.isoformat(),
279+
updated_at=project.updated_at.isoformat(),
280+
)
281+
282+
class Config:
283+
from_attributes = True

backend/models/user.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import uuid
22
from datetime import datetime
3-
from typing import List, Optional
3+
from typing import TYPE_CHECKING, List, Optional
44

55
from pydantic import BaseModel, EmailStr, Field, field_validator
66
from sqlalchemy import Boolean, Column, DateTime, String, Text, TypeDecorator, func
@@ -9,6 +9,9 @@
99

1010
from models.base import Base
1111

12+
if TYPE_CHECKING:
13+
from models.project import ProjectTable
14+
1215

1316
class UUID(TypeDecorator):
1417
"""
@@ -70,9 +73,9 @@ class UserTable(Base):
7073
)
7174

7275
# Relationships
73-
# projects: Mapped[List["ProjectTable"]] = relationship(
74-
# back_populates="user", cascade="all, delete-orphan"
75-
# )
76+
projects: Mapped[List["ProjectTable"]] = relationship(
77+
back_populates="user", cascade="all, delete-orphan"
78+
)
7679
# chat_messages: Mapped[List["ChatMessageTable"]] = relationship(
7780
# back_populates="user", cascade="all, delete-orphan"
7881
# )

0 commit comments

Comments
 (0)