Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -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.
40 changes: 40 additions & 0 deletions alembic.ini
Original file line number Diff line number Diff line change
@@ -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
53 changes: 53 additions & 0 deletions alembic/env.py
Original file line number Diff line number Diff line change
@@ -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()
26 changes: 26 additions & 0 deletions alembic/script.py.mako
Original file line number Diff line number Diff line change
@@ -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"}
84 changes: 84 additions & 0 deletions alembic/versions/20260523_0001_create_initial_tables.py
Original file line number Diff line number Diff line change
@@ -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")
4 changes: 2 additions & 2 deletions app/models/incident.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -38,4 +38,4 @@ class Incident(Base):

reporter: Mapped["User"] = relationship(
back_populates="reported_incidents",
)
)
4 changes: 2 additions & 2 deletions app/models/ticket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -45,4 +45,4 @@ class Ticket(Base):
assignee: Mapped["User | None"] = relationship(
back_populates="assigned_tickets",
foreign_keys=[assignee_id],
)
)
4 changes: 2 additions & 2 deletions app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -42,4 +42,4 @@ class User(Base):

reported_incidents: Mapped[list["Incident"]] = relationship(
back_populates="reporter",
)
)
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading