diff --git a/README.md b/README.md index 18a19fd..9088f7c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Ticket-API is a backend API for a portfolio-oriented ticketing and incident management system. It is designed for junior backend, cloud, and platform engineering roles. -This project provides a clean FastAPI foundation with versioned routes, a health check endpoint, basic error handling, automated testing with `pytest`, SQLAlchemy models, PostgreSQL-ready configuration, Docker-based local development, and a GitHub Actions CI workflow. +This project provides a clean FastAPI foundation with versioned routes, a health check endpoint, basic error handling, automated testing with `pytest`, SQLAlchemy models, Alembic migrations, PostgreSQL-ready configuration, Docker-based local development, and a GitHub Actions CI workflow. ## Project Goals @@ -15,6 +15,7 @@ This project is being built to demonstrate practical backend and cloud-readiness - Automated testing with `pytest` - Continuous Integration with GitHub Actions - PostgreSQL persistence readiness with SQLAlchemy +- Database migrations with Alembic - Docker-based local development - Preparation for future cloud deployment @@ -95,7 +96,29 @@ Docker Compose passes this database URL to the API container: postgresql+psycopg://ticket_user:ticket_password@db:5432/ticket_api ``` -The application is not creating tables or running migrations yet. Alembic and database migrations are planned for a later phase. +The application does not create tables automatically. Use Alembic to apply database migrations. + +## Database Migrations + +Start the PostgreSQL container: + +```bash +docker compose up -d db +``` + +Apply migrations: + +```bash +.venv\Scripts\alembic.exe upgrade head +``` + +Create a new migration after changing SQLAlchemy models: + +```bash +.venv\Scripts\alembic.exe revision --autogenerate -m "describe change" +``` + +Alembic reads `DATABASE_URL` from the application settings. For Docker Compose, use the internal database host `db`. For local commands from Windows, use `localhost`. ## Available Endpoint @@ -182,12 +205,16 @@ app/ schemas/ main.py +alembic/ + versions/ + tests/ test_health.py test_models.py test_database_config.py Dockerfile +alembic.ini docker-compose.yml .github/ @@ -217,10 +244,12 @@ Completed: - SQLAlchemy domain models - Database configuration - SQLAlchemy engine and session setup +- Alembic migration setup +- Initial database migration for users, tickets, and incidents - Dockerfile and Docker Compose local development setup ## Next Phase -The next phase will add database migrations without breaking the current project structure. +The next phase will add API behavior on top of the database layer without breaking the current project structure. Future phases will include CRUD endpoints, authentication, role-based access control, CI/CD improvements, and cloud deployment. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..0420adc --- /dev/null +++ b/alembic.ini @@ -0,0 +1,40 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = postgresql+psycopg://ticket_user:ticket_password@localhost:5432/ticket_api + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..c50d7b6 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,53 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.core.config import settings +from app.db.base import Base +import app.models # noqa: F401 + + +config = context.config +config.set_main_option("sqlalchemy.url", settings.database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..b74f24c --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + + +revision: str = ${repr(up_revision)} +down_revision: str | Sequence[str] | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/20260523_0001_create_initial_tables.py b/alembic/versions/20260523_0001_create_initial_tables.py new file mode 100644 index 0000000..a7f5810 --- /dev/null +++ b/alembic/versions/20260523_0001_create_initial_tables.py @@ -0,0 +1,84 @@ +"""Create initial tables. + +Revision ID: 20260523_0001 +Revises: +Create Date: 2026-05-23 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260523_0001" +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("full_name", sa.String(length=255), nullable=False), + sa.Column("hashed_password", sa.String(length=255), nullable=False), + sa.Column("role", sa.String(length=50), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) + + op.create_table( + "incidents", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("title", sa.String(length=200), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("severity", sa.String(length=50), nullable=False), + sa.Column("status", sa.String(length=50), nullable=False), + sa.Column("reported_by_id", sa.Integer(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["reported_by_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_table( + "tickets", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("title", sa.String(length=200), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("status", sa.String(length=50), nullable=False), + sa.Column("priority", sa.String(length=50), nullable=False), + sa.Column("requester_id", sa.Integer(), nullable=False), + sa.Column("assignee_id", sa.Integer(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["assignee_id"], ["users.id"]), + sa.ForeignKeyConstraint(["requester_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + op.drop_table("tickets") + op.drop_table("incidents") + op.drop_index(op.f("ix_users_email"), table_name="users") + op.drop_table("users") diff --git a/app/models/incident.py b/app/models/incident.py index e8bd14c..68179dd 100644 --- a/app/models/incident.py +++ b/app/models/incident.py @@ -15,7 +15,7 @@ class Incident(Base): __tablename__ = "incidents" - id: Mapped[int] = mapped_column(primary_key=True, index=True) + id: Mapped[int] = mapped_column(primary_key=True) title: Mapped[str] = mapped_column(String(200), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) @@ -38,4 +38,4 @@ class Incident(Base): 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 index 0a05bfe..dfc280c 100644 --- a/app/models/ticket.py +++ b/app/models/ticket.py @@ -15,7 +15,7 @@ class Ticket(Base): __tablename__ = "tickets" - id: Mapped[int] = mapped_column(primary_key=True, index=True) + id: Mapped[int] = mapped_column(primary_key=True) title: Mapped[str] = mapped_column(String(200), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) @@ -45,4 +45,4 @@ class Ticket(Base): 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 index 8f80c8e..6b4f1db 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -16,7 +16,7 @@ class User(Base): __tablename__ = "users" - id: Mapped[int] = mapped_column(primary_key=True, index=True) + id: Mapped[int] = mapped_column(primary_key=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) @@ -42,4 +42,4 @@ class User(Base): reported_incidents: Mapped[list["Incident"]] = relationship( back_populates="reporter", - ) \ No newline at end of file + ) diff --git a/pyproject.toml b/pyproject.toml index 5cbd0e3..6fa40f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "uvicorn[standard]>=0.30.0,<1.0.0", "sqlalchemy>=2.0.0,<3.0.0", "psycopg[binary]>=3.2.0,<4.0.0", + "alembic>=1.13.0,<2.0.0", ] [build-system]