Hamdy005 commited on
Commit
28227c9
Β·
1 Parent(s): 742dee2

feat: integrate Alembic for automated database schema migrations and define ORM models

Browse files
Files changed (4) hide show
  1. config.py +2 -0
  2. db_models.py +305 -0
  3. main.py +10 -0
  4. requirements.txt +7 -0
config.py CHANGED
@@ -29,6 +29,8 @@ class Settings:
29
  os.getenv("SUPABASE_JWT_SECRET")
30
  or os.getenv("JWT_SECRET", "")
31
  )
 
 
32
  cloudinary_cloud_name: str = (
33
  os.getenv("CLOUDINARY_CLOUD_NAME")
34
  or os.getenv("CLOUD_NAME", "")
 
29
  os.getenv("SUPABASE_JWT_SECRET")
30
  or os.getenv("JWT_SECRET", "")
31
  )
32
+ database_url: str = os.getenv("DATABASE_URL", "")
33
+
34
  cloudinary_cloud_name: str = (
35
  os.getenv("CLOUDINARY_CLOUD_NAME")
36
  or os.getenv("CLOUD_NAME", "")
db_models.py ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SQLAlchemy ORM models matching the Supabase PostgreSQL database schema.
3
+
4
+ This file serves as the single source of truth for the database schema,
5
+ used by SQLAlchemy for query ORM mappings and Alembic for migration version control.
6
+ """
7
+
8
+ import uuid
9
+ from datetime import datetime, timezone
10
+ from typing import Any, List, Optional
11
+
12
+ from sqlalchemy import (
13
+ ARRAY,
14
+ Boolean,
15
+ Column,
16
+ DateTime,
17
+ Float,
18
+ ForeignKey,
19
+ Integer,
20
+ String,
21
+ Text,
22
+ func,
23
+ text,
24
+ )
25
+ from sqlalchemy.dialects.postgresql import JSONB, UUID
26
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
27
+
28
+ try:
29
+ from pgvector.sqlalchemy import Vector
30
+ except ImportError:
31
+ # Fallback type if pgvector package is not available
32
+ from sqlalchemy.types import UserDefinedType
33
+
34
+ class Vector(UserDefinedType): # type: ignore
35
+ def __init__(self, dim: Optional[int] = None, *args, **kwargs):
36
+ self.dim = dim
37
+
38
+ def get_col_spec(self, **kw):
39
+ return f"VECTOR({self.dim})" if self.dim else "VECTOR"
40
+
41
+
42
+
43
+ class Base(DeclarativeBase):
44
+ """Base class for all SQLAlchemy ORM models."""
45
+ pass
46
+
47
+
48
+ class Profile(Base):
49
+ __tablename__ = "profiles"
50
+
51
+ id: Mapped[uuid.UUID] = mapped_column(
52
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, server_default=text("gen_random_uuid()")
53
+ )
54
+ email: Mapped[Optional[str]] = mapped_column(String, nullable=True)
55
+ display_name: Mapped[Optional[str]] = mapped_column(String, nullable=True)
56
+ avatar_url: Mapped[Optional[str]] = mapped_column(String, nullable=True)
57
+ daily_requests: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
58
+ last_request_date: Mapped[Optional[str]] = mapped_column(String, nullable=True)
59
+ theme: Mapped[str] = mapped_column(String, default="system", server_default=text("'system'"))
60
+ created_at: Mapped[datetime] = mapped_column(
61
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), server_default=func.now()
62
+ )
63
+
64
+ # Relationships
65
+ materials: Mapped[List["Material"]] = relationship("Material", back_populates="user", cascade="all, delete-orphan")
66
+ quizzes: Mapped[List["Quiz"]] = relationship("Quiz", back_populates="user", cascade="all, delete-orphan")
67
+ quiz_attempts: Mapped[List["QuizAttempt"]] = relationship("QuizAttempt", back_populates="user", cascade="all, delete-orphan")
68
+ summaries: Mapped[List["Summary"]] = relationship("Summary", back_populates="user", cascade="all, delete-orphan")
69
+ chat_sessions: Mapped[List["ChatSession"]] = relationship("ChatSession", back_populates="user", cascade="all, delete-orphan")
70
+ refresh_tokens: Mapped[List["RefreshToken"]] = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
71
+
72
+
73
+ class Material(Base):
74
+ __tablename__ = "materials"
75
+
76
+ id: Mapped[uuid.UUID] = mapped_column(
77
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, server_default=text("gen_random_uuid()")
78
+ )
79
+ user_id: Mapped[uuid.UUID] = mapped_column(
80
+ UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False
81
+ )
82
+ source_type: Mapped[str] = mapped_column(String, nullable=False)
83
+ title: Mapped[str] = mapped_column(String, nullable=False)
84
+ file_path: Mapped[Optional[str]] = mapped_column(String, nullable=True)
85
+ url: Mapped[Optional[str]] = mapped_column(String, nullable=True)
86
+ status: Mapped[str] = mapped_column(String, default="pending", server_default=text("'pending'"))
87
+ error_message: Mapped[Optional[str]] = mapped_column(String, nullable=True)
88
+ vector_store_path: Mapped[Optional[str]] = mapped_column(String, nullable=True)
89
+ title_normalized: Mapped[Optional[str]] = mapped_column(String, nullable=True)
90
+ created_at: Mapped[datetime] = mapped_column(
91
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), server_default=func.now()
92
+ )
93
+ updated_at: Mapped[datetime] = mapped_column(
94
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), server_default=func.now()
95
+ )
96
+
97
+ # Relationships
98
+ user: Mapped["Profile"] = relationship("Profile", back_populates="materials")
99
+ chunks: Mapped[List["MaterialChunk"]] = relationship("MaterialChunk", back_populates="material", cascade="all, delete-orphan")
100
+ embeddings: Mapped[List["MaterialEmbedding"]] = relationship("MaterialEmbedding", back_populates="material", cascade="all, delete-orphan")
101
+ summaries: Mapped[List["Summary"]] = relationship("Summary", back_populates="material", cascade="all, delete-orphan")
102
+ quizzes: Mapped[List["Quiz"]] = relationship("Quiz", back_populates="material", cascade="all, delete-orphan")
103
+ chat_sessions: Mapped[List["ChatSession"]] = relationship("ChatSession", back_populates="material", cascade="all, delete-orphan")
104
+
105
+
106
+ class MaterialChunk(Base):
107
+ __tablename__ = "material_chunks"
108
+
109
+ id: Mapped[uuid.UUID] = mapped_column(
110
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, server_default=text("gen_random_uuid()")
111
+ )
112
+ material_id: Mapped[uuid.UUID] = mapped_column(
113
+ UUID(as_uuid=True), ForeignKey("materials.id", ondelete="CASCADE"), nullable=False
114
+ )
115
+ chunk_index: Mapped[int] = mapped_column(Integer, nullable=False)
116
+ content: Mapped[str] = mapped_column(Text, nullable=False)
117
+ token_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
118
+ created_at: Mapped[datetime] = mapped_column(
119
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), server_default=func.now()
120
+ )
121
+
122
+ # Relationships
123
+ material: Mapped["Material"] = relationship("Material", back_populates="chunks")
124
+ embeddings: Mapped[List["MaterialEmbedding"]] = relationship("MaterialEmbedding", back_populates="chunk", cascade="all, delete-orphan")
125
+
126
+
127
+ class MaterialEmbedding(Base):
128
+ __tablename__ = "material_embeddings"
129
+
130
+ id: Mapped[uuid.UUID] = mapped_column(
131
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, server_default=text("gen_random_uuid()")
132
+ )
133
+ material_id: Mapped[uuid.UUID] = mapped_column(
134
+ UUID(as_uuid=True), ForeignKey("materials.id", ondelete="CASCADE"), nullable=False
135
+ )
136
+ chunk_id: Mapped[uuid.UUID] = mapped_column(
137
+ UUID(as_uuid=True), ForeignKey("material_chunks.id", ondelete="CASCADE"), nullable=False
138
+ )
139
+ embedding: Mapped[Any] = mapped_column(Vector(384), nullable=True)
140
+ created_at: Mapped[datetime] = mapped_column(
141
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), server_default=func.now()
142
+ )
143
+
144
+ # Relationships
145
+ material: Mapped["Material"] = relationship("Material", back_populates="embeddings")
146
+ chunk: Mapped["MaterialChunk"] = relationship("MaterialChunk", back_populates="embeddings")
147
+
148
+
149
+ class Summary(Base):
150
+ __tablename__ = "summaries"
151
+
152
+ id: Mapped[uuid.UUID] = mapped_column(
153
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, server_default=text("gen_random_uuid()")
154
+ )
155
+ material_id: Mapped[uuid.UUID] = mapped_column(
156
+ UUID(as_uuid=True), ForeignKey("materials.id", ondelete="CASCADE"), nullable=False
157
+ )
158
+ user_id: Mapped[uuid.UUID] = mapped_column(
159
+ UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False
160
+ )
161
+ summary: Mapped[str] = mapped_column(Text, nullable=False)
162
+ status: Mapped[str] = mapped_column(String, default="completed", server_default=text("'completed'"))
163
+ model_name: Mapped[Optional[str]] = mapped_column(String, nullable=True)
164
+ error_message: Mapped[Optional[str]] = mapped_column(String, nullable=True)
165
+ time_taken: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
166
+ created_at: Mapped[datetime] = mapped_column(
167
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), server_default=func.now()
168
+ )
169
+
170
+ # Relationships
171
+ material: Mapped["Material"] = relationship("Material", back_populates="summaries")
172
+ user: Mapped["Profile"] = relationship("Profile", back_populates="summaries")
173
+
174
+
175
+ class Quiz(Base):
176
+ __tablename__ = "quizzes"
177
+
178
+ id: Mapped[uuid.UUID] = mapped_column(
179
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, server_default=text("gen_random_uuid()")
180
+ )
181
+ user_id: Mapped[uuid.UUID] = mapped_column(
182
+ UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False
183
+ )
184
+ material_id: Mapped[Optional[uuid.UUID]] = mapped_column(
185
+ UUID(as_uuid=True), ForeignKey("materials.id", ondelete="CASCADE"), nullable=True
186
+ )
187
+ source_type: Mapped[Optional[str]] = mapped_column(String, nullable=True)
188
+ difficulty: Mapped[Optional[str]] = mapped_column(String, nullable=True)
189
+ mcq_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
190
+ tf_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
191
+ quiz_data: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
192
+ status: Mapped[str] = mapped_column(String, default="completed", server_default=text("'completed'"))
193
+ model_name: Mapped[Optional[str]] = mapped_column(String, nullable=True)
194
+ error_message: Mapped[Optional[str]] = mapped_column(String, nullable=True)
195
+ created_at: Mapped[datetime] = mapped_column(
196
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), server_default=func.now()
197
+ )
198
+
199
+ # Relationships
200
+ user: Mapped["Profile"] = relationship("Profile", back_populates="quizzes")
201
+ material: Mapped[Optional["Material"]] = relationship("Material", back_populates="quizzes")
202
+ attempts: Mapped[List["QuizAttempt"]] = relationship("QuizAttempt", back_populates="quiz", cascade="all, delete-orphan")
203
+
204
+
205
+ class QuizAttempt(Base):
206
+ __tablename__ = "quiz_attempts"
207
+
208
+ id: Mapped[uuid.UUID] = mapped_column(
209
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, server_default=text("gen_random_uuid()")
210
+ )
211
+ quiz_id: Mapped[uuid.UUID] = mapped_column(
212
+ UUID(as_uuid=True), ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False
213
+ )
214
+ user_id: Mapped[uuid.UUID] = mapped_column(
215
+ UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False
216
+ )
217
+ score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
218
+ total: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
219
+ results: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
220
+ created_at: Mapped[datetime] = mapped_column(
221
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), server_default=func.now()
222
+ )
223
+
224
+ # Relationships
225
+ quiz: Mapped["Quiz"] = relationship("Quiz", back_populates="attempts")
226
+ user: Mapped["Profile"] = relationship("Profile", back_populates="quiz_attempts")
227
+
228
+
229
+ class ChatSession(Base):
230
+ __tablename__ = "chat_sessions"
231
+
232
+ id: Mapped[uuid.UUID] = mapped_column(
233
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, server_default=text("gen_random_uuid()")
234
+ )
235
+ user_id: Mapped[uuid.UUID] = mapped_column(
236
+ UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False
237
+ )
238
+ material_id: Mapped[uuid.UUID] = mapped_column(
239
+ UUID(as_uuid=True), ForeignKey("materials.id", ondelete="CASCADE"), nullable=False
240
+ )
241
+ title: Mapped[Optional[str]] = mapped_column(String, nullable=True)
242
+ created_at: Mapped[datetime] = mapped_column(
243
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), server_default=func.now()
244
+ )
245
+ updated_at: Mapped[datetime] = mapped_column(
246
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), server_default=func.now()
247
+ )
248
+
249
+ # Relationships
250
+ user: Mapped["Profile"] = relationship("Profile", back_populates="chat_sessions")
251
+ material: Mapped["Material"] = relationship("Material", back_populates="chat_sessions")
252
+ messages: Mapped[List["ChatMessage"]] = relationship("ChatMessage", back_populates="session", cascade="all, delete-orphan")
253
+
254
+
255
+ class ChatMessage(Base):
256
+ __tablename__ = "chat_messages"
257
+
258
+ id: Mapped[uuid.UUID] = mapped_column(
259
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, server_default=text("gen_random_uuid()")
260
+ )
261
+ session_id: Mapped[uuid.UUID] = mapped_column(
262
+ UUID(as_uuid=True), ForeignKey("chat_sessions.id", ondelete="CASCADE"), nullable=False
263
+ )
264
+ role: Mapped[Optional[str]] = mapped_column(String, nullable=True)
265
+ content: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
266
+ message_metadata: Mapped[Optional[dict]] = mapped_column("metadata", JSONB, nullable=True)
267
+ retrieved_chunk_ids: Mapped[Optional[list]] = mapped_column(JSONB, nullable=True)
268
+ created_at: Mapped[datetime] = mapped_column(
269
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), server_default=func.now()
270
+ )
271
+
272
+ # Relationships
273
+ session: Mapped["ChatSession"] = relationship("ChatSession", back_populates="messages")
274
+
275
+
276
+ class ChatMessageChunk(Base):
277
+ __tablename__ = "chat_message_chunks"
278
+
279
+ message_id: Mapped[uuid.UUID] = mapped_column(
280
+ UUID(as_uuid=True), ForeignKey("chat_messages.id", ondelete="CASCADE"), primary_key=True
281
+ )
282
+ chunk_id: Mapped[uuid.UUID] = mapped_column(
283
+ UUID(as_uuid=True), ForeignKey("material_chunks.id", ondelete="CASCADE"), primary_key=True
284
+ )
285
+ relevance_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
286
+
287
+
288
+ class RefreshToken(Base):
289
+ __tablename__ = "refresh_tokens"
290
+
291
+ id: Mapped[uuid.UUID] = mapped_column(
292
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, server_default=text("gen_random_uuid()")
293
+ )
294
+ user_id: Mapped[uuid.UUID] = mapped_column(
295
+ UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False
296
+ )
297
+ token_hash: Mapped[str] = mapped_column(String, unique=True, nullable=False)
298
+ expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
299
+ revoked: Mapped[bool] = mapped_column(Boolean, default=False, server_default=text("false"), nullable=False)
300
+ created_at: Mapped[datetime] = mapped_column(
301
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), server_default=func.now(), nullable=False
302
+ )
303
+
304
+ # Relationships
305
+ user: Mapped["Profile"] = relationship("Profile", back_populates="refresh_tokens")
main.py CHANGED
@@ -56,12 +56,22 @@ logger = logging.getLogger(__name__)
56
 
57
  @asynccontextmanager
58
  async def lifespan(app: FastAPI):
 
 
 
 
 
 
 
 
 
59
  try:
60
  from src.database import warmup_database
61
  warmup_database()
62
  except Exception as e:
63
  logger.warning(f"Database warmup failed: {e}")
64
 
 
65
  try:
66
  from src.rag.rag import get_embedder
67
  get_embedder()
 
56
 
57
  @asynccontextmanager
58
  async def lifespan(app: FastAPI):
59
+ # Run pending Alembic database migrations automatically on server startup
60
+ try:
61
+ import subprocess
62
+ logger.info("Running database migrations via Alembic...")
63
+ subprocess.run(["alembic", "upgrade", "head"], check=True)
64
+ logger.info("Database migrations completed successfully.")
65
+ except Exception as e:
66
+ logger.warning(f"Database migration step failed or skipped: {e}")
67
+
68
  try:
69
  from src.database import warmup_database
70
  warmup_database()
71
  except Exception as e:
72
  logger.warning(f"Database warmup failed: {e}")
73
 
74
+
75
  try:
76
  from src.rag.rag import get_embedder
77
  get_embedder()
requirements.txt CHANGED
@@ -42,3 +42,10 @@ omegaconf
42
  nemo_toolkit[asr]
43
  soundfile
44
  librosa
 
 
 
 
 
 
 
 
42
  nemo_toolkit[asr]
43
  soundfile
44
  librosa
45
+
46
+ # ── Database ORM & Migrations ───────────────────────────
47
+ sqlalchemy>=2.0.0
48
+ alembic>=1.13.0
49
+ psycopg2-binary>=2.9.9
50
+ pgvector>=0.2.5
51
+