From 5acff91bcdab743630ee29022e7cac3ddfacdd2a Mon Sep 17 00:00:00 2001 From: Orlando Alvarez Date: Mon, 4 May 2026 22:24:06 -0700 Subject: [PATCH 1/2] Add SQLAlchemy domain models --- app/db/__init__.py | 0 app/db/base.py | 5 +++++ app/models/__init__.py | 5 +++++ app/models/incident.py | 41 ++++++++++++++++++++++++++++++++++++ app/models/ticket.py | 48 ++++++++++++++++++++++++++++++++++++++++++ app/models/user.py | 45 +++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 ++ tests/test_models.py | 8 +++++++ 8 files changed, 154 insertions(+) create mode 100644 app/db/__init__.py create mode 100644 app/db/base.py create mode 100644 app/models/__init__.py create mode 100644 app/models/incident.py create mode 100644 app/models/ticket.py create mode 100644 app/models/user.py create mode 100644 tests/test_models.py diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..1c2dcc4 --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..ef7e601 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,5 @@ +from app.models.incident import Incident +from app.models.ticket import Ticket +from app.models.user import User + +__all__ = ["Incident", "Ticket", "User"] \ No newline at end of file diff --git a/app/models/incident.py b/app/models/incident.py new file mode 100644 index 0000000..e8bd14c --- /dev/null +++ b/app/models/incident.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, ForeignKey, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + +if TYPE_CHECKING: + from app.models.user import User + + +class Incident(Base): + __tablename__ = "incidents" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + title: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + + severity: Mapped[str] = mapped_column(String(50), default="medium", nullable=False) + status: Mapped[str] = mapped_column(String(50), default="open", nullable=False) + + reported_by_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + updated_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + onupdate=func.now(), + nullable=True, + ) + + reporter: Mapped["User"] = relationship( + back_populates="reported_incidents", + ) \ No newline at end of file diff --git a/app/models/ticket.py b/app/models/ticket.py new file mode 100644 index 0000000..0a05bfe --- /dev/null +++ b/app/models/ticket.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, ForeignKey, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + +if TYPE_CHECKING: + from app.models.user import User + + +class Ticket(Base): + __tablename__ = "tickets" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + title: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + + status: Mapped[str] = mapped_column(String(50), default="open", nullable=False) + priority: Mapped[str] = mapped_column(String(50), default="medium", nullable=False) + + requester_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) + assignee_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + updated_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + onupdate=func.now(), + nullable=True, + ) + + requester: Mapped["User"] = relationship( + back_populates="requested_tickets", + foreign_keys=[requester_id], + ) + + assignee: Mapped["User | None"] = relationship( + back_populates="assigned_tickets", + foreign_keys=[assignee_id], + ) \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..8f80c8e --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import Boolean, DateTime, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + +if TYPE_CHECKING: + from app.models.incident import Incident + from app.models.ticket import Ticket + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + full_name: Mapped[str] = mapped_column(String(255), nullable=False) + hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + + role: Mapped[str] = mapped_column(String(50), default="requester", nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + requested_tickets: Mapped[list["Ticket"]] = relationship( + back_populates="requester", + foreign_keys="Ticket.requester_id", + ) + + assigned_tickets: Mapped[list["Ticket"]] = relationship( + back_populates="assignee", + foreign_keys="Ticket.assignee_id", + ) + + reported_incidents: Mapped[list["Incident"]] = relationship( + back_populates="reporter", + ) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 1096180..5cbd0e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,8 @@ dependencies = [ "pydantic>=2.9.0,<3.0.0", "pydantic-settings>=2.5.0,<3.0.0", "uvicorn[standard]>=0.30.0,<1.0.0", + "sqlalchemy>=2.0.0,<3.0.0", + "psycopg[binary]>=3.2.0,<4.0.0", ] [build-system] diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..ef74996 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,8 @@ +from app.db.base import Base +import app.models # noqa: F401 + + +def test_database_models_are_registered(): + expected_tables = {"users", "tickets", "incidents"} + + assert expected_tables.issubset(Base.metadata.tables.keys()) \ No newline at end of file From 9ef808b8c011bccc778a6ae3fb07e915e9342f18 Mon Sep 17 00:00:00 2001 From: Orlando Alvarez Date: Fri, 15 May 2026 04:31:53 -0700 Subject: [PATCH 2/2] Add database configuration and SQLAlchemy session --- .env.example | 1 + app/core/config.py | 3 +++ app/db/session.py | 27 +++++++++++++++++++++++++++ tests/test_database_config.py | 10 ++++++++++ 4 files changed, 41 insertions(+) create mode 100644 app/db/session.py create mode 100644 tests/test_database_config.py diff --git a/.env.example b/.env.example index 79638be..49a3f0a 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,4 @@ APP_NAME=ticket-api APP_VERSION=0.1.0 DEBUG=false API_V1_PREFIX=/api/v1 +DATABASE_URL=postgresql+psycopg://ticket_user:ticket_password@localhost:5432/ticket_api diff --git a/app/core/config.py b/app/core/config.py index 0d3361e..be451f4 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -12,6 +12,9 @@ class Settings(BaseSettings): app_version: str = "0.1.0" debug: bool = False api_v1_prefix: str = "/api/v1" + database_url: str = ( + "postgresql+psycopg://ticket_user:ticket_password@localhost:5432/ticket_api" + ) settings = Settings() diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..2b2805c --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,27 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.core.config import settings + + +engine = create_engine( + settings.database_url, + pool_pre_ping=True, +) + +SessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine, +) + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + + try: + yield db + finally: + db.close() diff --git a/tests/test_database_config.py b/tests/test_database_config.py new file mode 100644 index 0000000..785572b --- /dev/null +++ b/tests/test_database_config.py @@ -0,0 +1,10 @@ +from app.core.config import settings +from app.db.session import engine + + +def test_database_url_uses_postgresql_psycopg_driver() -> None: + assert settings.database_url.startswith("postgresql+psycopg://") + + +def test_engine_uses_postgresql_psycopg_driver() -> None: + assert engine.url.drivername == "postgresql+psycopg"