arcshukla commited on
Commit
eee75d5
·
1 Parent(s): f0d6ad3
app/db/database.py CHANGED
@@ -17,29 +17,23 @@ logger = get_logger(__name__)
17
 
18
 
19
  def _build_engine(url: str):
20
- """
21
- Interface-style factory: returns the right async engine for the URL scheme.
22
-
23
- SQLite — minimal config, single-file local database.
24
- Postgres — connection pool tuned for Neon free-tier (2 connections, 5-min recycle, SSL).
25
- """
26
  if url.startswith("sqlite"):
27
  return create_async_engine(
28
  url,
29
  echo=False,
30
  connect_args={"check_same_thread": False},
31
  )
32
- # Postgres (Neon / standard) — psycopg_async (psycopg3), works on Windows & Linux
33
  return create_async_engine(
34
  url,
35
  echo=False,
36
  pool_size=2,
37
  max_overflow=3,
38
- pool_pre_ping=True, # detect stale connections
39
  pool_recycle=300, # recycle every 5 min — Neon closes idle connections
40
  connect_args={
41
- "sslmode": "require", # psycopg3 uses libpq-style params, not ssl.SSLContext
42
- "connect_timeout": 30, # seconds — allows Neon free-tier cold-start
43
  },
44
  )
45
 
@@ -57,8 +51,6 @@ async def init_db():
57
  import app.models # noqa: F401 — registers all models with Base.metadata
58
 
59
  if _is_sqlite:
60
- # Ensure the parent directory exists — critical when /data is a mounted
61
- # volume that gets overlaid after the Dockerfile RUN mkdir has executed.
62
  file_path = settings.database_url.split("///", 1)[-1]
63
  parent = os.path.dirname(file_path)
64
  if parent:
@@ -69,43 +61,6 @@ async def init_db():
69
 
70
  async with engine.begin() as conn:
71
  await conn.run_sync(Base.metadata.create_all)
72
- await _migrate_audit_columns(conn)
73
-
74
-
75
- async def _migrate_audit_columns(conn):
76
- """
77
- Safe, additive migration: add AuditMixin columns to tables that don't have them yet.
78
- Works for SQLite and Postgres (uses standard ALTER TABLE ADD COLUMN IF NOT EXISTS).
79
- Only adds columns — never drops or modifies existing ones.
80
- """
81
- from sqlalchemy import text, inspect
82
-
83
- _AUDIT_COLS = [
84
- ("created_at", "DATETIME"),
85
- ("updated_at", "DATETIME"),
86
- ("updated_by", "VARCHAR(320)"),
87
- ("deleted_at", "DATETIME"),
88
- ("deleted_by", "VARCHAR(320)"),
89
- ]
90
- _TABLES = ["students", "mentors", "courses", "batches", "sessions", "rooms"]
91
-
92
- def _add_columns_sync(sync_conn):
93
- insp = inspect(sync_conn)
94
- for table in _TABLES:
95
- existing = {col["name"] for col in insp.get_columns(table)}
96
- for col_name, col_type in _AUDIT_COLS:
97
- if col_name not in existing:
98
- if _is_sqlite:
99
- sync_conn.execute(
100
- text(f"ALTER TABLE {table} ADD COLUMN {col_name} {col_type}")
101
- )
102
- else:
103
- sync_conn.execute(
104
- text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {col_name} {col_type}")
105
- )
106
- logger.info("migration: added %s.%s", table, col_name)
107
-
108
- await conn.run_sync(_add_columns_sync)
109
 
110
 
111
  async def get_db() -> AsyncSession:
 
17
 
18
 
19
  def _build_engine(url: str):
 
 
 
 
 
 
20
  if url.startswith("sqlite"):
21
  return create_async_engine(
22
  url,
23
  echo=False,
24
  connect_args={"check_same_thread": False},
25
  )
26
+ # Postgres (Neon / standard) — psycopg_async (psycopg3)
27
  return create_async_engine(
28
  url,
29
  echo=False,
30
  pool_size=2,
31
  max_overflow=3,
32
+ pool_pre_ping=True,
33
  pool_recycle=300, # recycle every 5 min — Neon closes idle connections
34
  connect_args={
35
+ "sslmode": "require",
36
+ "connect_timeout": 30,
37
  },
38
  )
39
 
 
51
  import app.models # noqa: F401 — registers all models with Base.metadata
52
 
53
  if _is_sqlite:
 
 
54
  file_path = settings.database_url.split("///", 1)[-1]
55
  parent = os.path.dirname(file_path)
56
  if parent:
 
61
 
62
  async with engine.begin() as conn:
63
  await conn.run_sync(Base.metadata.create_all)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
 
66
  async def get_db() -> AsyncSession:
app/models/batch.py CHANGED
@@ -1,6 +1,6 @@
1
  import uuid
2
  from datetime import date
3
- from sqlalchemy import String, ForeignKey, Date
4
  from sqlalchemy.orm import Mapped, mapped_column, relationship
5
 
6
  from app.db.database import Base
@@ -11,6 +11,10 @@ from app.models.student_batch import student_batch_table
11
 
12
  class Batch(AuditMixin, Base):
13
  __tablename__ = "batches"
 
 
 
 
14
 
15
  id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
16
  name: Mapped[str] = mapped_column(String(200), nullable=False)
 
1
  import uuid
2
  from datetime import date
3
+ from sqlalchemy import String, ForeignKey, Date, Index
4
  from sqlalchemy.orm import Mapped, mapped_column, relationship
5
 
6
  from app.db.database import Base
 
11
 
12
  class Batch(AuditMixin, Base):
13
  __tablename__ = "batches"
14
+ __table_args__ = (
15
+ Index("ix_batch_mentor_deleted", "mentor_id", "deleted_at"),
16
+ Index("ix_batch_status_deleted", "status", "deleted_at"),
17
+ )
18
 
19
  id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
20
  name: Mapped[str] = mapped_column(String(200), nullable=False)
app/models/student_batch.py CHANGED
@@ -1,4 +1,4 @@
1
- from sqlalchemy import Table, Column, String, ForeignKey
2
  from app.db.database import Base
3
 
4
  # Pure association table — no ORM class needed
@@ -7,4 +7,5 @@ student_batch_table = Table(
7
  Base.metadata,
8
  Column("student_id", String(36), ForeignKey("students.id", ondelete="CASCADE"), primary_key=True),
9
  Column("batch_id", String(36), ForeignKey("batches.id", ondelete="CASCADE"), primary_key=True),
 
10
  )
 
1
+ from sqlalchemy import Table, Column, String, ForeignKey, Index
2
  from app.db.database import Base
3
 
4
  # Pure association table — no ORM class needed
 
7
  Base.metadata,
8
  Column("student_id", String(36), ForeignKey("students.id", ondelete="CASCADE"), primary_key=True),
9
  Column("batch_id", String(36), ForeignKey("batches.id", ondelete="CASCADE"), primary_key=True),
10
+ Index("ix_student_batch_batch_id", "batch_id"),
11
  )
app/routers/rooms.py CHANGED
@@ -9,6 +9,7 @@ from app.db.database import get_db
9
  from app.models.room import Room
10
  from app.schemas.room import RoomCreate, RoomUpdate, RoomRead
11
  from app.logging_config import get_logger
 
12
 
13
  router = APIRouter(prefix="/rooms", tags=["Rooms"])
14
  logger = get_logger(__name__)
@@ -52,6 +53,7 @@ async def create_room(
52
  db.add(obj)
53
  await db.commit()
54
  await db.refresh(obj)
 
55
  return obj
56
 
57
 
@@ -74,6 +76,7 @@ async def update_room(
74
  setattr(obj, field, value)
75
  await db.commit()
76
  await db.refresh(obj)
 
77
  return obj
78
 
79
 
@@ -93,5 +96,6 @@ async def delete_room(
93
  obj.deleted_by = user.email
94
  obj.updated_by = user.email
95
  await db.commit()
 
96
  logger.info("Soft-deleted room %s by %s", room_id, user.email)
97
  return {"message": "Room deleted"}
 
9
  from app.models.room import Room
10
  from app.schemas.room import RoomCreate, RoomUpdate, RoomRead
11
  from app.logging_config import get_logger
12
+ from app.routers.schedule import _invalidate_rooms_cache
13
 
14
  router = APIRouter(prefix="/rooms", tags=["Rooms"])
15
  logger = get_logger(__name__)
 
53
  db.add(obj)
54
  await db.commit()
55
  await db.refresh(obj)
56
+ _invalidate_rooms_cache()
57
  return obj
58
 
59
 
 
76
  setattr(obj, field, value)
77
  await db.commit()
78
  await db.refresh(obj)
79
+ _invalidate_rooms_cache()
80
  return obj
81
 
82
 
 
96
  obj.deleted_by = user.email
97
  obj.updated_by = user.email
98
  await db.commit()
99
+ _invalidate_rooms_cache()
100
  logger.info("Soft-deleted room %s by %s", room_id, user.email)
101
  return {"message": "Room deleted"}
app/routers/schedule.py CHANGED
@@ -5,6 +5,8 @@ POST /api/v1/schedule/auto-generate — run solver, return proposals (not saved
5
  POST /api/v1/schedule/confirm — save proposals as Confirmed sessions
6
  """
7
 
 
 
8
  from fastapi import APIRouter, Depends, HTTPException
9
  from sqlalchemy import select
10
  from sqlalchemy.orm import selectinload
@@ -28,6 +30,28 @@ from app.logging_config import get_logger
28
  router = APIRouter(prefix="/schedule", tags=["Schedule"])
29
  logger = get_logger(__name__)
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  @router.post("/auto-generate", response_model=AutoGenerateResponse)
33
  async def auto_generate(
@@ -71,20 +95,22 @@ async def auto_generate(
71
  reasons=[f"Batch already has all {batch.course.total_sessions} sessions scheduled"],
72
  )
73
 
74
- # Load all existing sessions (for occupation map)
 
75
  all_sessions_result = await db.execute(
76
  select(Session)
77
  .options(
78
  selectinload(Session.batch).selectinload(Batch.mentor),
79
  selectinload(Session.batch).selectinload(Batch.students),
80
  )
81
- .where(Session.status.in_([SESSION_STATUS_CONFIRMED, SESSION_STATUS_LOCKED]))
 
 
 
82
  )
83
  all_sessions = all_sessions_result.scalars().all()
84
 
85
- # Load all rooms
86
- rooms_result = await db.execute(select(Room).order_by(Room.name))
87
- rooms = rooms_result.scalars().all()
88
  if not rooms:
89
  raise HTTPException(422, "No rooms defined — add rooms before scheduling")
90
 
 
5
  POST /api/v1/schedule/confirm — save proposals as Confirmed sessions
6
  """
7
 
8
+ import time as _time
9
+
10
  from fastapi import APIRouter, Depends, HTTPException
11
  from sqlalchemy import select
12
  from sqlalchemy.orm import selectinload
 
30
  router = APIRouter(prefix="/schedule", tags=["Schedule"])
31
  logger = get_logger(__name__)
32
 
33
+ # ── Rooms TTL cache (60 s) — rooms change rarely; no need to re-query every call ─
34
+ _rooms_cache: list = []
35
+ _rooms_cache_ts: float = 0.0
36
+ _ROOMS_TTL = 60.0
37
+
38
+
39
+ async def _get_rooms(db: AsyncSession) -> list:
40
+ global _rooms_cache, _rooms_cache_ts
41
+ if not _rooms_cache or (_time.monotonic() - _rooms_cache_ts) > _ROOMS_TTL:
42
+ result = await db.execute(
43
+ select(Room).where(Room.deleted_at.is_(None)).order_by(Room.name)
44
+ )
45
+ _rooms_cache = result.scalars().all()
46
+ _rooms_cache_ts = _time.monotonic()
47
+ logger.debug("schedule.rooms_cache.refreshed count=%d", len(_rooms_cache))
48
+ return _rooms_cache
49
+
50
+
51
+ def _invalidate_rooms_cache() -> None:
52
+ global _rooms_cache_ts
53
+ _rooms_cache_ts = 0.0
54
+
55
 
56
  @router.post("/auto-generate", response_model=AutoGenerateResponse)
57
  async def auto_generate(
 
95
  reasons=[f"Batch already has all {batch.course.total_sessions} sessions scheduled"],
96
  )
97
 
98
+ # Load sessions from start_date forward only — past sessions cannot conflict
99
+ # with future placements, so loading them is wasted work.
100
  all_sessions_result = await db.execute(
101
  select(Session)
102
  .options(
103
  selectinload(Session.batch).selectinload(Batch.mentor),
104
  selectinload(Session.batch).selectinload(Batch.students),
105
  )
106
+ .where(
107
+ Session.status.in_([SESSION_STATUS_CONFIRMED, SESSION_STATUS_LOCKED]),
108
+ Session.date >= body.start_date,
109
+ )
110
  )
111
  all_sessions = all_sessions_result.scalars().all()
112
 
113
+ rooms = await _get_rooms(db)
 
 
114
  if not rooms:
115
  raise HTTPException(422, "No rooms defined — add rooms before scheduling")
116
 
app/routers/sessions.py CHANGED
@@ -24,6 +24,7 @@ from app.constants import (
24
  )
25
  from app.utils.session_helpers import enrich_session, session_load_options
26
  from app.logging_config import get_logger
 
27
 
28
  router = APIRouter(prefix="/sessions", tags=["Sessions"])
29
  logger = get_logger(__name__)
@@ -526,13 +527,12 @@ async def _do_reschedule(
526
  slot_counts: Counter = Counter(slot_result.scalars().all())
527
  preferred_slot = slot_counts.most_common(1)[0][0] if slot_counts else None
528
 
529
- # Load all rooms
530
- rooms_result = await db.execute(select(Room).order_by(Room.name))
531
- rooms = rooms_result.scalars().all()
532
  if not rooms:
533
  return {"status": "failed", "detail": "No rooms configured", "session_id": None}
534
 
535
- # Load all existing confirmed/locked sessions (globally for conflict detection)
 
536
  all_sessions_result = await db.execute(
537
  select(Session)
538
  .options(
@@ -542,6 +542,7 @@ async def _do_reschedule(
542
  .where(
543
  Session.status.in_([SESSION_STATUS_CONFIRMED, SESSION_STATUS_LOCKED]),
544
  Session.deleted_at.is_(None),
 
545
  )
546
  )
547
  all_sessions = all_sessions_result.scalars().all()
 
24
  )
25
  from app.utils.session_helpers import enrich_session, session_load_options
26
  from app.logging_config import get_logger
27
+ from app.routers.schedule import _get_rooms
28
 
29
  router = APIRouter(prefix="/sessions", tags=["Sessions"])
30
  logger = get_logger(__name__)
 
527
  slot_counts: Counter = Counter(slot_result.scalars().all())
528
  preferred_slot = slot_counts.most_common(1)[0][0] if slot_counts else None
529
 
530
+ rooms = await _get_rooms(db)
 
 
531
  if not rooms:
532
  return {"status": "failed", "detail": "No rooms configured", "session_id": None}
533
 
534
+ # Load confirmed/locked sessions from start_date forward only
535
+ # past sessions cannot conflict with future placements.
536
  all_sessions_result = await db.execute(
537
  select(Session)
538
  .options(
 
542
  .where(
543
  Session.status.in_([SESSION_STATUS_CONFIRMED, SESSION_STATUS_LOCKED]),
544
  Session.deleted_at.is_(None),
545
+ Session.date >= start_date,
546
  )
547
  )
548
  all_sessions = all_sessions_result.scalars().all()