Skip to content

Commit

Permalink
Merge pull request #7 from gablooge/6-feat-implement-database-model
Browse files Browse the repository at this point in the history
feat: model bills
  • Loading branch information
gablooge authored Apr 8, 2024
2 parents 02ba2c4 + 759f743 commit a533995
Show file tree
Hide file tree
Showing 17 changed files with 403 additions and 6 deletions.
11 changes: 11 additions & 0 deletions .github/workflows/actions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ name: Uselevers Backend

on: [push]

env:
USELEVERS_DB_USER: ${{ secrets.USELEVERS_DB_USER }}
USELEVERS_DB_NAME: ${{ secrets.USELEVERS_DB_NAME }}
USELEVERS_DB_PASSWORD: ${{ secrets.USELEVERS_DB_PASSWORD }}

jobs:
tests:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -71,3 +76,9 @@ jobs:
export $(grep -v '^#' .env | xargs -0)
export SQLALCHEMY_DATABASE_URI=postgresql://$USELEVERS_DB_USER:$USELEVERS_DB_PASSWORD@localhost/$USELEVERS_DB_NAME
poetry run coverage run -m pytest $@ && coverage xml
- name: Check generate openapi
run: |
cp .env.example .env
export $(grep -v '^#' .env | xargs -0)
export SQLALCHEMY_DATABASE_URI=postgresql://$USELEVERS_DB_USER:$USELEVERS_DB_PASSWORD@localhost/$USELEVERS_DB_NAME
poetry run generate-openapi && git diff --exit-code
1 change: 1 addition & 0 deletions scripts/format.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ set -x
autoflake --remove-all-unused-imports --recursive --remove-unused-variables --in-place . --exclude=__init__.py
black .
ruff . --fix
ruff format .
4 changes: 2 additions & 2 deletions uselevers/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
from sqlalchemy import Column, DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship

from uselevers.core.models import Base, id_gen, id_len
from uselevers.core.models import Base, MixinCreatedUpdated, id_gen, id_len


class User(Base):
class User(Base, MixinCreatedUpdated):
__tablename__ = "users" # type: ignore[assignment]
id: Mapped[str] = mapped_column(
String(id_len),
Expand Down
Empty file added uselevers/bills/__init__.py
Empty file.
45 changes: 45 additions & 0 deletions uselevers/bills/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from sqlalchemy import CheckConstraint, Column, Float, ForeignKey, Index, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func

from uselevers.core.models import Base, MixinCreatedUpdated, id_gen, id_len


class Bill(Base, MixinCreatedUpdated):
__tablename__ = "bills" # type: ignore[assignment]
id: Mapped[str] = mapped_column(
String(id_len),
default=id_gen("B"),
primary_key=True,
)
total = mapped_column(Float, nullable=False)

sub_bills = relationship(
"SubBill",
back_populates="bill",
cascade="all, delete-orphan",
lazy="dynamic",
passive_deletes=True,
foreign_keys="SubBill.bill_id",
)

# Add a CheckConstraint to enforce that total must be positive
__table_args__ = (CheckConstraint("total >= 0", name="check_positive_total"),)


class SubBill(Base, MixinCreatedUpdated):
__tablename__ = "sub_bills" # type: ignore[assignment]
id: Mapped[str] = mapped_column(
String(id_len),
default=id_gen("SB"),
primary_key=True,
)
bill_id = Column(String, ForeignKey("bills.id", ondelete="CASCADE"), nullable=False)
amount = mapped_column(Float, nullable=False)
reference = Column(String, nullable=True)

bill = relationship("Bill", back_populates="sub_bills", foreign_keys=[bill_id])

__table_args__ = (
Index("unique_reference_case_insensitive", func.lower(reference), unique=True),
)
20 changes: 20 additions & 0 deletions uselevers/bills/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from typing import Annotated

from pydantic import BaseModel, Field

from uselevers.core.models import id_len
from uselevers.core.schemas import MixinCreatedUpdated


class BillSpec(BaseModel):
total: Annotated[float, Field(description="Total")]


class Bill(MixinCreatedUpdated, BillSpec, BaseModel):
id: Annotated[
str,
Field(
description="Bill ID",
max_length=id_len,
),
]
Empty file.
85 changes: 85 additions & 0 deletions uselevers/bills/tests/test_models_bills.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from typing import Annotated

import pytest
from fastapi import Depends
from fastapi.encoders import jsonable_encoder
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from uselevers.bills.models import Bill
from uselevers.bills.schemas import BillSpec
from uselevers.core import deps
from uselevers.tests.conftest import SessionTest, alembic_engine, db # noqa: F401,F811


@pytest.mark.parametrize(
"total",
[
(0),
(0.5),
(1.00),
(1.05),
],
)
def test_crud_bills(
db: Annotated[Session, Depends(deps.get_db)], # noqa: F811
total: float,
) -> None:
bill_create = BillSpec(
total=total,
)
with db.begin():
# test create
assert db.query(Bill).count() == 0
instance = Bill(**jsonable_encoder(bill_create))
db.add(instance)
# write the object to the database
db.flush()
assert db.query(Bill).count() == 1

# test read
created_bill = db.query(Bill).filter(Bill.id == instance.id).first()
assert created_bill is not None
assert created_bill.total == total
assert created_bill.id[0] == "B"

# test update with invalid total
with pytest.raises(IntegrityError):
created_bill.total = -1
db.flush()

# test update and delete
with db.begin():
created_bill = db.query(Bill).first()
assert created_bill is not None
# test update with valid total
new_total: float = 1.10
created_bill.total = new_total
db.flush()
updated_bill = db.query(Bill).filter(Bill.id == created_bill.id).first()
assert updated_bill is not None
assert updated_bill.total == new_total
assert updated_bill.id[0] == "B"

# test delete
db.delete(updated_bill)
db.flush()
assert db.query(Bill).count() == 0


def test_create_bill_with_invalid_total(
db: Annotated[Session, Depends(deps.get_db)], # noqa: F811
) -> None:
bill_create = BillSpec(
total=-1,
)
with db.begin():
# test create
assert db.query(Bill).count() == 0
with pytest.raises(IntegrityError):
instance = Bill(**jsonable_encoder(bill_create))
db.add(instance)
db.flush()

with db.begin():
assert db.query(Bill).count() == 0
1 change: 1 addition & 0 deletions uselevers/core/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# For detecting the alembic `autogenerate` revisions from core.models.Base.metadata

from uselevers.auth import models as auth_models # noqa: F401
from uselevers.bills import models as bills_models # noqa: F401
9 changes: 9 additions & 0 deletions uselevers/core/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from datetime import datetime
from typing import Annotated

from pydantic import BaseModel, Field


class MixinCreatedUpdated(BaseModel):
created: Annotated[datetime, Field(description="Created at")]
updated: Annotated[datetime, Field(description="Updated at")]
28 changes: 28 additions & 0 deletions uselevers/migrations/README
Original file line number Diff line number Diff line change
@@ -1 +1,29 @@
Generic single-database configuration.


Some alembic example commands

```sh
# generate migration
docker compose -f docker-compose.dev.yml run --rm app alembic revision --autogenerate -m "Describe this migration"
# Look the generated migrations versions, check if there's new ENUM, make sure add drop type e.g op.execute("DROP TYPE enumname") in downgrade()

# alternatively to generate the alembic migration locally with poetry
poetry run alembic revision --autogenerate -m "Describe this migration"

# show histories
docker compose -f docker-compose.dev.yml run --rm app alembic history
# or
poetry run alembic history

# Running migration
docker compose -f docker-compose.dev.yml run --rm app alembic upgrade head
# or
poetry run alembic upgrade head

# Rollout
poetry run alembic upgrade +1

# Rollback
poetry run alembic downgrade -1
```
77 changes: 77 additions & 0 deletions uselevers/migrations/versions/0b8a85b2cfc4_init_bill_tables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""
init_bill_tables
Revision ID: 0b8a85b2cfc4
Revises: 109d2489d83f
Create Date: 2024-04-08 05:36:44.651645
"""

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "0b8a85b2cfc4"
down_revision = "109d2489d83f"
branch_labels = None
depends_on = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"bills",
sa.Column("id", sa.String(length=32), nullable=False),
sa.Column("total", sa.Float(), nullable=False),
sa.Column(
"updated",
sa.DateTime(),
server_default=sa.text("NOW()::timestamp"),
nullable=False,
),
sa.Column(
"created",
sa.DateTime(),
server_default=sa.text("NOW()::timestamp"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"sub_bills",
sa.Column("id", sa.String(length=32), nullable=False),
sa.Column("bill_id", sa.String(), nullable=False),
sa.Column("amount", sa.Float(), nullable=False),
sa.Column("reference", sa.String(), nullable=True),
sa.Column(
"updated",
sa.DateTime(),
server_default=sa.text("NOW()::timestamp"),
nullable=False,
),
sa.Column(
"created",
sa.DateTime(),
server_default=sa.text("NOW()::timestamp"),
nullable=False,
),
sa.ForeignKeyConstraint(["bill_id"], ["bills.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"unique_reference_case_insensitive",
"sub_bills",
[sa.text("lower(reference)")],
unique=True,
)
op.create_check_constraint("check_positive_total", "bills", "total >= 0")
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint("check_positive_total", "bills")
op.drop_index("unique_reference_case_insensitive", table_name="sub_bills")
op.drop_table("sub_bills")
op.drop_table("bills")
# ### end Alembic commands ###
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
"""
init auth tables
init auth table
Revision ID: b5449c8b9a2c
Revision ID: 109d2489d83f
Revises:
Create Date: 2024-04-05 23:00:02.591077
Create Date: 2024-04-06 14:03:23.969625
"""

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "b5449c8b9a2c"
revision = "109d2489d83f"
down_revision = None
branch_labels = None
depends_on = None
Expand All @@ -25,6 +25,18 @@ def upgrade() -> None:
sa.Column("username", sa.String(length=50), nullable=False),
sa.Column("email", sa.String(length=100), nullable=False),
sa.Column("password", sa.String(length=100), nullable=False),
sa.Column(
"updated",
sa.DateTime(),
server_default=sa.text("NOW()::timestamp"),
nullable=False,
),
sa.Column(
"created",
sa.DateTime(),
server_default=sa.text("NOW()::timestamp"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
)
Expand Down
Loading

0 comments on commit a533995

Please sign in to comment.