Skip to content

Commit da8c598

Browse files
committed
Implemented Task B4 - User Model and Database
1 parent be930e4 commit da8c598

7 files changed

Lines changed: 1025 additions & 0 deletions

File tree

backend/models/user.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import uuid
2+
from datetime import datetime
3+
from typing import Optional
4+
5+
from pydantic import BaseModel, EmailStr, Field, field_validator
6+
from sqlalchemy import Boolean, Column, DateTime, String, Text
7+
from sqlalchemy.dialects.postgresql import UUID
8+
from sqlalchemy.orm import declarative_base, relationship
9+
10+
Base = declarative_base()
11+
12+
13+
class UserTable(Base):
14+
"""SQLAlchemy User table model for PostgreSQL"""
15+
16+
__tablename__ = "users"
17+
18+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
19+
email = Column(String(255), unique=True, nullable=False, index=True)
20+
name = Column(String(255), nullable=False)
21+
avatar_url = Column(Text, nullable=True)
22+
google_id = Column(String(255), unique=True, nullable=True, index=True)
23+
is_active = Column(Boolean, default=True, nullable=False)
24+
is_verified = Column(Boolean, default=False, nullable=False)
25+
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
26+
updated_at = Column(
27+
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
28+
)
29+
last_sign_in_at = Column(DateTime, nullable=True)
30+
31+
# Relationships
32+
projects = relationship(
33+
"ProjectTable", back_populates="user", cascade="all, delete"
34+
)
35+
chat_messages = relationship(
36+
"ChatMessageTable", back_populates="user", cascade="all, delete"
37+
)
38+
39+
def __repr__(self):
40+
return f"<User(id={self.id}, email={self.email}, name={self.name})>"
41+
42+
43+
class UserCreate(BaseModel):
44+
"""Pydantic model for creating a user"""
45+
46+
email: EmailStr = Field(..., description="User email address")
47+
name: str = Field(..., min_length=1, max_length=255, description="User full name")
48+
avatar_url: Optional[str] = Field(None, description="User avatar URL")
49+
google_id: Optional[str] = Field(None, description="Google OAuth ID")
50+
51+
@field_validator("name")
52+
@classmethod
53+
def validate_name(cls, v):
54+
if not v or not v.strip():
55+
raise ValueError("Name cannot be empty or just whitespace")
56+
return v.strip()
57+
58+
@field_validator("avatar_url")
59+
@classmethod
60+
def validate_avatar_url(cls, v):
61+
if v and not v.startswith(("http://", "https://")):
62+
raise ValueError("Avatar URL must be a valid HTTP/HTTPS URL")
63+
return v
64+
65+
66+
class UserUpdate(BaseModel):
67+
"""Pydantic model for updating a user"""
68+
69+
name: Optional[str] = Field(None, min_length=1, max_length=255)
70+
avatar_url: Optional[str] = Field(None)
71+
is_active: Optional[bool] = Field(None)
72+
is_verified: Optional[bool] = Field(None)
73+
last_sign_in_at: Optional[datetime] = Field(None)
74+
75+
@field_validator("name")
76+
@classmethod
77+
def validate_name(cls, v):
78+
if v is not None and (not v or not v.strip()):
79+
raise ValueError("Name cannot be empty or just whitespace")
80+
return v.strip() if v else v
81+
82+
@field_validator("avatar_url")
83+
@classmethod
84+
def validate_avatar_url(cls, v):
85+
if v and not v.startswith(("http://", "https://")):
86+
raise ValueError("Avatar URL must be a valid HTTP/HTTPS URL")
87+
return v
88+
89+
90+
class UserInDB(BaseModel):
91+
"""Pydantic model for user data from database"""
92+
93+
id: uuid.UUID
94+
email: str
95+
name: str
96+
avatar_url: Optional[str] = None
97+
google_id: Optional[str] = None
98+
is_active: bool
99+
is_verified: bool
100+
created_at: datetime
101+
updated_at: datetime
102+
last_sign_in_at: Optional[datetime] = None
103+
104+
model_config = {"from_attributes": True}
105+
106+
107+
class UserPublic(BaseModel):
108+
"""Pydantic model for public user data (API responses)"""
109+
110+
id: str
111+
email: str
112+
name: str
113+
avatar_url: Optional[str] = None
114+
created_at: str
115+
last_sign_in_at: Optional[str] = None
116+
117+
@classmethod
118+
def from_db_user(cls, user: UserInDB) -> "UserPublic":
119+
"""Convert database user to public user model"""
120+
return cls(
121+
id=str(user.id),
122+
email=user.email,
123+
name=user.name,
124+
avatar_url=user.avatar_url,
125+
created_at=user.created_at.isoformat() + "Z",
126+
last_sign_in_at=(
127+
user.last_sign_in_at.isoformat() + "Z" if user.last_sign_in_at else None
128+
),
129+
)
130+
131+
132+
class GoogleOAuthData(BaseModel):
133+
"""Pydantic model for Google OAuth data"""
134+
135+
google_id: str
136+
email: EmailStr
137+
name: str
138+
avatar_url: Optional[str] = None
139+
email_verified: bool = False
140+
141+
@field_validator("google_id")
142+
@classmethod
143+
def validate_google_id(cls, v):
144+
if not v or not v.strip():
145+
raise ValueError("Google ID cannot be empty")
146+
return v.strip()

backend/requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ python-multipart==0.0.18
2828
# JWT authentication
2929
PyJWT==2.8.0
3030

31+
# Email validation
32+
email-validator==2.1.0
33+
3134
# Future dependencies (commented for now, will be added in later tasks)
3235
# langchain==0.1.0
3336
# openai==1.3.0

backend/services/database_service.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,50 @@ def get_session(self):
7979
self.connect()
8080
return self.SessionLocal()
8181

82+
def create_tables(self):
83+
"""Create database tables using SQLAlchemy models"""
84+
try:
85+
from models.user import Base
86+
87+
if not self.engine:
88+
self.connect()
89+
90+
# Create all tables
91+
Base.metadata.create_all(bind=self.engine)
92+
logger.info("Database tables created successfully")
93+
return True
94+
95+
except Exception as e:
96+
logger.error(f"Failed to create tables: {str(e)}")
97+
return False
98+
99+
def run_migration(self, migration_file: str) -> bool:
100+
"""Run a SQL migration file"""
101+
try:
102+
if not self.engine:
103+
self.connect()
104+
105+
migration_path = f"database/migrations/{migration_file}"
106+
107+
if not os.path.exists(migration_path):
108+
logger.error(f"Migration file not found: {migration_path}")
109+
return False
110+
111+
with open(migration_path, "r") as f:
112+
migration_sql = f.read()
113+
114+
with self.engine.connect() as conn:
115+
# Execute migration
116+
conn.execute(text(migration_sql))
117+
conn.commit()
118+
119+
logger.info(f"Migration {migration_file} executed successfully")
120+
return True
121+
122+
except Exception as e:
123+
logger.error(f"Failed to run migration {migration_file}: {str(e)}")
124+
return False
125+
82126

83127
# Global database service instance
84128
db_service = DatabaseService()

0 commit comments

Comments
 (0)