diff --git a/app/__init__.py b/app/__init__.py index 1c821436..880ce7b8 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -10,15 +10,21 @@ load_dotenv() -def create_app(): +def create_app(test_config=None): app = Flask(__name__) app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False - app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get( + if test_config: + app.config["TESTING"] = True + app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get( + "SQLALCHEMY_TEST_DATABASE_URI") + else: + app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get( "SQLALCHEMY_DATABASE_URI") # Import models here for Alembic setup - # from app.models.ExampleModel import ExampleModel + from app.models.board import Board + from app.models.card import Card db.init_app(app) migrate.init_app(app, db) @@ -26,6 +32,11 @@ def create_app(): # Register Blueprints here # from .routes import example_bp # app.register_blueprint(example_bp) + from .routes.board_routes import board_bp + app.register_blueprint(board_bp) + + from .routes.card_routes import card_bp + app.register_blueprint(card_bp) CORS(app) return app diff --git a/app/models/board.py b/app/models/board.py index 147eb748..bc99cf54 100644 --- a/app/models/board.py +++ b/app/models/board.py @@ -1 +1,27 @@ from app import db + +class Board(db.Model): + board_id = db.Column(db.Integer, primary_key=True, autoincrement=True) + title = db.Column(db.String) + owner = db.Column(db.String) + cards = db.relationship("Card", back_populates="board", lazy=True) + + + def to_dict(self): + board_data = { + "board_id": self.board_id, + "title": self.title, + "owner": self.owner + } + if self.cards: + board_data["card_count"] = len(self.cards) + return board_data + + @classmethod + def from_dict(cls, board_data): + new_board = Board( + title=board_data["title"], + owner=board_data["owner"] + ) + return new_board + \ No newline at end of file diff --git a/app/models/card.py b/app/models/card.py index 147eb748..8dc2b451 100644 --- a/app/models/card.py +++ b/app/models/card.py @@ -1 +1,30 @@ from app import db +from .board import Board +from app.routes.routes_helpers import validate_model + +class Card(db.Model): + card_id = db.Column(db.Integer, primary_key=True, autoincrement=True) + message = db.Column(db.String) + likes_count = db.Column(db.Integer) + board = db.relationship("Board", back_populates="cards") + board_id = db.Column(db.Integer, db.ForeignKey('board.board_id')) + + + def to_dict(self): + card_data = { + "card_id": self.card_id, + "message": self.message, + "likes_count": self.likes_count + } + if self.board: + card_data["board_id"] = self.board_id + return card_data + + @classmethod + def from_dict(cls, card_data): + new_card = Card( + message=card_data["message"], + likes_count=card_data["likes_count"], + board=validate_model(Board, card_data.get("board_id")) + ) + return new_card \ No newline at end of file diff --git a/app/routes.py b/app/routes.py deleted file mode 100644 index 480b8c4b..00000000 --- a/app/routes.py +++ /dev/null @@ -1,4 +0,0 @@ -from flask import Blueprint, request, jsonify, make_response -from app import db - -# example_bp = Blueprint('example_bp', __name__) diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/routes/board_routes.py b/app/routes/board_routes.py new file mode 100644 index 00000000..0c449e0f --- /dev/null +++ b/app/routes/board_routes.py @@ -0,0 +1,64 @@ +from flask import Blueprint, request, jsonify, make_response, abort +from app import db +from app.models.board import Board +from app.models.card import Card +from .routes_helpers import validate_model + +# example_bp = Blueprint('example_bp', __name__) +board_bp = Blueprint("boards", __name__, url_prefix="/boards") + +@board_bp.route("", methods=["GET"]) +def get_all_boards(): + boards = Board.query.all() + boards_response = [board.to_dict() for board in boards] + return make_response(jsonify(boards_response), 200) + +@board_bp.route("", methods=["POST"]) +def create_board(): + request_body = request.get_json() + try: + new_board = Board.from_dict(request_body) + except KeyError: + abort(make_response(jsonify({"details": "Invalid data"}), 400)) + + db.session.add(new_board) + db.session.commit() + + return make_response(jsonify(new_board.to_dict()), 201) + + +@board_bp.route("/", methods=["GET"]) +def read_one_board(board_id): + board = validate_model(Board, board_id) + return make_response(jsonify({"board": board.to_dict()}), 200) + + +# GET ALL CARDS FOR BOARD +@board_bp.route("//cards", methods=["GET"]) +def get_board_cards(id): + board = validate_model(Board, id) + # just a list of cards, not all the board data + card_response = [card.to_dict() for card in board.cards] + return make_response(jsonify(card_response)) + +# create a route to add new card for selected board +@board_bp.route("//cards", methods=["POST"]) +def create_card(id): + request_body = request.get_json() + request_body["board_id"] = id + + # need to add char count feedback + try: + new_card = Card.from_dict(request_body) + except KeyError: + abort(make_response(jsonify({"details": "Invalid data"}), 400)) + + if len(new_card.message) > 40: + abort(make_response(jsonify({ + "details": "Message should be 40 characters or less." + }), 400)) + + db.session.add(new_card) + db.session.commit() + + return make_response(jsonify(new_card.to_dict()), 201) diff --git a/app/routes/card_routes.py b/app/routes/card_routes.py new file mode 100644 index 00000000..cb14be21 --- /dev/null +++ b/app/routes/card_routes.py @@ -0,0 +1,50 @@ +from flask import Blueprint, request, jsonify, make_response, abort +from app import db +from app.models.card import Card +from .routes_helpers import validate_model + +# example_bp = Blueprint('example_bp', __name__) +card_bp = Blueprint("cards", __name__, url_prefix="/cards") + + +@card_bp.route("/", methods=["DELETE"]) +def delete_one_card(id): + card = validate_model(Card, id) + + db.session.delete(card) + db.session.commit() + return make_response(jsonify({"details": f'Card {card.card_id} successfully deleted'}), 200) + +# ADD LIKE TO CARD +@card_bp.route("//add_like", methods=["PATCH"]) +def add_like(id): + card = validate_model(Card, id) + + card.likes_count += 1 + db.session.commit() + + return make_response(jsonify({"card_like_count": card.likes_count}), 200) + + +# create a route to add new card for selected board +@card_bp.route("", methods=["POST"]) +def create_card(): + request_body = request.get_json() + + try: + new_card = Card.from_dict(request_body) + except KeyError: + abort(make_response(jsonify({"details": "Invalid data"}), 400)) + + db.session.add(new_card) + db.session.commit() + + return make_response(jsonify(new_card.to_dict()), 201) + +# create a route to view all cards for selected board +@card_bp.route("", methods=["GET"]) +def get_all_cards(): + cards = Card.query.all() + cards_response = [card.to_dict() for card in cards] + return make_response(jsonify(cards_response), 200) + diff --git a/app/routes/routes_helpers.py b/app/routes/routes_helpers.py new file mode 100644 index 00000000..a0fdec47 --- /dev/null +++ b/app/routes/routes_helpers.py @@ -0,0 +1,15 @@ +from flask import abort, make_response + + +def validate_model(cls, id): + try: + id = int(id) + except: + abort(make_response({"message": f"{id} is invalid"}, 400)) + + model = cls.query.get(id) + + if not model: + abort(make_response({"message": f"{cls.__name__} with id {id} was not found."}, 404)) + + return model \ No newline at end of file diff --git a/migrations/README b/migrations/README new file mode 100644 index 00000000..98e4f9c4 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 00000000..f8ed4801 --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,45 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +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/migrations/env.py b/migrations/env.py new file mode 100644 index 00000000..8b3fb335 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,96 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option( + 'sqlalchemy.url', + str(current_app.extensions['migrate'].db.engine.url).replace('%', '%%')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=target_metadata, literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + 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, + process_revision_directives=process_revision_directives, + **current_app.extensions['migrate'].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/08b5d478a132_adds_board_and_card_models.py b/migrations/versions/08b5d478a132_adds_board_and_card_models.py new file mode 100644 index 00000000..06dac9b3 --- /dev/null +++ b/migrations/versions/08b5d478a132_adds_board_and_card_models.py @@ -0,0 +1,40 @@ +"""adds Board and Card models + +Revision ID: 08b5d478a132 +Revises: +Create Date: 2023-06-26 15:43:40.505384 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '08b5d478a132' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('board', + sa.Column('board_id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('title', sa.String(), nullable=True), + sa.Column('owner', sa.String(), nullable=True), + sa.PrimaryKeyConstraint('board_id') + ) + op.create_table('card', + sa.Column('card_id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('message', sa.String(), nullable=True), + sa.Column('likes_count', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('card_id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('card') + op.drop_table('board') + # ### end Alembic commands ### diff --git a/migrations/versions/9c35416d0d3a_connect_card_and_board_models.py b/migrations/versions/9c35416d0d3a_connect_card_and_board_models.py new file mode 100644 index 00000000..2336f479 --- /dev/null +++ b/migrations/versions/9c35416d0d3a_connect_card_and_board_models.py @@ -0,0 +1,30 @@ +"""connect Card and Board models + +Revision ID: 9c35416d0d3a +Revises: 08b5d478a132 +Create Date: 2023-06-28 13:29:49.922036 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9c35416d0d3a' +down_revision = '08b5d478a132' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('card', sa.Column('board_id', sa.Integer(), nullable=True)) + op.create_foreign_key(None, 'card', 'board', ['board_id'], ['board_id']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'card', type_='foreignkey') + op.drop_column('card', 'board_id') + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index 76f1f000..4e8f4ac3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ alembic==1.5.4 attrs==21.2.0 autopep8==1.5.5 +blinker==1.6.2 certifi==2020.12.5 chardet==4.0.0 click==7.1.2 @@ -29,5 +30,6 @@ requests==2.25.1 six==1.15.0 SQLAlchemy==1.3.23 toml==0.10.2 +tomli==2.0.1 urllib3==1.26.4 Werkzeug==1.0.1 diff --git a/seed.py b/seed.py new file mode 100644 index 00000000..a91bb9c3 --- /dev/null +++ b/seed.py @@ -0,0 +1,37 @@ +from app import create_app, db +from app.models.board import Board +from app.models.card import Card + +my_app = create_app() + +with my_app.app_context(): + db.session.add(Card(message = "A New Card", likes_count = 0, board_id = 1)) + db.session.add(Card(message = "A Newer Card", likes_count = 1, board_id = 1)) + db.session.add(Card(message = "A Cool Card", likes_count = 0, board_id = 2)) + db.session.add(Card(message = "A Cooler Card", likes_count = 2, board_id = 2)) + db.session.add(Card(message = "A Neat Card", likes_count = 0, board_id = 3)) + db.session.add(Card(message = "An Awesome Card", likes_count = 3, board_id = 3)) + db.session.add(Card(message = "An Uncreative Card", likes_count = 0, board_id = 4)) + db.session.add(Card(message = "An Old Card", likes_count = 4, board_id = 4)) + + db.session.add(Board(title = "Do Something", owner = "Alycia")) + db.session.add(Board(title = "Do Something Else", owner = "Alycia")) + db.session.add(Board(title = "Do Something More", owner = "Alycia")) + db.session.add(Board(title = "Do Something New", owner = "Alycia")) + + db.session.add(Board(title = "Say Something", owner = "Barbara")) + db.session.add(Board(title = "Say Something Else", owner = "Barbara")) + db.session.add(Board(title = "Say Something More", owner = "Barbara")) + db.session.add(Board(title = "Say Something New", owner = "Barbara")) + + db.session.add(Board(title = "Be Something", owner = "Danqing")) + db.session.add(Board(title = "Be Something Else", owner = "Danqing")) + db.session.add(Board(title = "Be Something More", owner = "Danqing")) + db.session.add(Board(title = "Be Something New", owner = "Danqing")) + + db.session.add(Board(title = "Want Something", owner = "Doris")) + db.session.add(Board(title = "Want Something Else", owner = "Doris")) + db.session.add(Board(title = "Want Something More", owner = "Doris")) + db.session.add(Board(title = "Want Something New", owner = "Doris")) + + db.session.commit() \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 2b7296d5..56941861 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,9 @@ import pytest from app import create_app from app import db +from flask.signals import request_finished +from app.models.card import Card +from app.models.board import Board @pytest.fixture @@ -8,6 +11,10 @@ def app(): # create the app with a test config dictionary app = create_app({"TESTING": True}) + @request_finished.connect_via(app) + def expire_session(sender, response, **extra): + db.session.remove() + with app.app_context(): db.create_all() yield app @@ -20,3 +27,54 @@ def app(): @pytest.fixture def client(app): return app.test_client() + +@pytest.fixture +def one_board(app): + new_board = Board(title = "Do Something", owner = "Alycia") + db.session.add(new_board) + db.session.commit() + +@pytest.fixture +def one_card(app): + new_board = Board(title = "Do Something", owner = "Alycia") + db.session.add(new_board) + db.session.commit() + + new_card = Card( + message = "A New Card", likes_count = 0, board_id = 1) + db.session.add(new_card) + db.session.commit() + +@pytest.fixture +def four_boards(app): + board1 = Board(title="Board 1", owner="Doris") + board2 = Board(title="Board 2", owner="Danqing") + board3 = Board(title="Board 3", owner="Alycia") + board4 = Board(title="Board 4", owner="Barbara") + + boards = [board1, board2, board3, board4] + + for board in boards: + db.session.add(board) + db.session.commit() + +@pytest.fixture +def two_boards_with_cards(app): + boards = [ + Board(title="Board with Card", owner="Cardboard"), + Board(title="Board with Cards Too", owner="Inspired") + ] + + for board in boards: + db.session.add(board) + db.session.commit() + + cards = [ + Card(message="Cardboard owns this", likes_count=0, board_id=1), + Card(message="Inspired owns this", likes_count=0, board_id=2), + Card(message="Cardboard made this", likes_count=4, board_id=1), + ] + + for card in cards: + db.session.add(card) + db.session.commit() diff --git a/tests/test_routes.py b/tests/test_routes.py index e69de29b..3a29fbaf 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -0,0 +1,185 @@ +import unittest +from unittest.mock import Mock, patch +from app.models.card import Card +import pytest + + +from app.models.board import Board +import pytest + + +def test_create_board(client): + response = client.post("/boards", json={ + "title": "New Board", + "owner": "John Doe" + }) + response_body = response.get_json() + + assert response.status_code == 201 + assert "board_id" in response_body + assert response_body["title"] == "New Board" + assert response_body["owner"] == "John Doe" + +def test_create_board_missing_data(client): + response = client.post("/boards", json={ + "title": "New Board" + }) + response_body = response.get_json() + + assert response.status_code == 400 + assert response_body == { + "details": "Invalid data" + } + + +def test_add_like_existing_card(client, one_card): + # Arrange + likes_count = 1 + + # Act + response = client.patch("/cards/1/add_like") + response_body = response.get_json() + + # Assert + assert response.status_code == 200 + assert response_body == {"card_like_count": likes_count} + assert Card.query.get(1).likes_count == likes_count + + +def test_add_like_missing_card(client): + # Act + response = client.patch("/cards/1/add_like") + response_body = response.get_json() + + # Assert + assert response.status_code == 404 + + # raise Exception("Complete test with assertion about response body") + assert response_body == { + "message": "Card with id 1 was not found." + } + + + +def test_get_board(client, one_board): + response = client.get("/boards/1") + response_body = response.get_json() + + assert response.status_code == 200 + assert "board" in response_body + assert response_body == {'board': + {'board_id': 1, 'owner': 'Alycia', 'title': 'Do Something'} + } + +def test_get_board_not_found(client): + response = client.get("/boards/1") + response_body = response.get_json() + + assert response.status_code == 404 + assert response_body == {'message': 'Board with id 1 was not found.'} + +def test_delete_one_card(client, one_card): + response = client.delete("/cards/1") + response_body = response.get_json() + print(response_body) + + assert response.status_code == 200 + assert response_body == {"details": "Card 1 successfully deleted"} + +def test_delete_invalid_card(client, one_card): + response = client.delete("/cards/hellothere") + response_body = response.get_json() + + assert response.status_code == 400 + assert response_body == {"message": f"hellothere is invalid"} + +def test_delete_nonexisting_card(client, one_card): + response = client.delete("/cards/59303594") + response_body = response.get_json() + + assert response.status_code == 404 + assert response_body == {"message": "Card with id 59303594 was not found."} + +def test_get_all_boards(client, four_boards): + response = client.get("/boards") + response_body = response.get_json() + + assert response.status_code == 200 + assert len(response_body) == 4 + assert response_body == [ + { + "board_id": 1, + "owner": "Doris", + "title": "Board 1" + }, + { + "board_id": 2, + "owner": "Danqing", + "title": "Board 2" + }, + { + "board_id": 3, + "owner": "Alycia", + "title": "Board 3" + }, + { + "board_id": 4, + "owner": "Barbara", + "title": "Board 4" + } + ] + +def test_get_boards_with_cards(client, two_boards_with_cards): + response = client.get("/boards") + response_body = response.get_json() + + assert response.status_code == 200 + assert len(response_body) == 2 + assert response_body == [ + { + "board_id": 1, + "card_count": 2, + "owner": "Cardboard", + "title": "Board with Card" + }, + { + "board_id": 2, + "card_count": 1, + "owner": "Inspired", + "title": "Board with Cards Too" + }, + ] + + + +def test_add_like_invalid_card(client): + # Act + response = client.patch("/cards/something/add_like") + response_body = response.get_json() + + # Assert + assert response.status_code == 400 + assert response_body == {"message": f"something is invalid"} + + +def test_create_one_card(client, one_board): + + response = client.post("/boards/1/cards", json={ + "likes_count": 0, + "message": "Test card", + }) + response_body = response.get_json() + + assert response.status_code == 201 + assert response_body == { + "board_id": 1, + "card_id": 1, + "likes_count": 0, + "message": "Test card" + } + + new_card = Card.query.get(1) + assert new_card + assert new_card.message == "Test card" + assert new_card.likes_count == 0 +