diff --git a/app/__init__.py b/app/__init__.py index 1c821436..0003bec8 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -10,22 +10,26 @@ 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( - "SQLALCHEMY_DATABASE_URI") - - # Import models here for Alembic setup - # from app.models.ExampleModel import ExampleModel + if test_config is None: + app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get( + "SQLALCHEMY_DATABASE_URI") + else: + app.config["TESTING"] = True + app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get( + "SQLALCHEMY_TEST_DATABASE_URI") db.init_app(app) migrate.init_app(app, db) # 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/helper_functions.py b/app/helper_functions.py new file mode 100644 index 00000000..a79eff32 --- /dev/null +++ b/app/helper_functions.py @@ -0,0 +1,47 @@ + +from flask import jsonify, abort, make_response +import os +import requests + + +def error_message(message, status_code): + abort(make_response(jsonify(dict(details=message)), status_code)) + +def success_message_info_as_list(message, status_code=200): + return make_response(jsonify(message), status_code) + +def return_database_info_list(return_value): + return make_response(jsonify(return_value)) + +def return_database_info_dict(category, return_value): + return_dict = {} + return_dict[category] = return_value + return make_response(jsonify(return_dict)) + +def get_record_by_id(cls, id): + try: + id = int(id) + except ValueError: + error_message(f"Invalid id: {id}", 400) + record = cls.query.get(id) + if record: + return record + else: + error_message(f"{cls.return_class_name()} id: {id} not found", 404) + +def create_record_safely(cls, data_dict): + try: + return cls.create_from_dict(data_dict) + except ValueError as err: + error_message(f"Invalid key(s): {err}. {cls.return_class_name()} not added to {cls.return_class_name()} List.", 400) + +def update_record_safely(cls, record, data_dict): + try: + record.update_self(data_dict) + except ValueError as err: + error_message(f"Invalid key(s): {err}. {cls.return_class_name()} not updated.", 400) + + + + + diff --git a/app/models/board.py b/app/models/board.py index 147eb748..6d5b3bc5 100644 --- a/app/models/board.py +++ b/app/models/board.py @@ -1 +1,58 @@ from app import db + +class Board(db.Model): + board_id = db.Column(db.Integer, primary_key=True, autoincrement=True) + title = db.Column(db.String, nullable=False) + owner = db.Column(db.String, nullable=False) + cards = db.relationship("Card", back_populates='board') + + + required_attributes = { + "title" : True, + "owner" : True + } + + # Instance Methods + + def self_to_dict(self): + instance_dict = dict( + board_id=self.board_id, + title=self.title, + owner=self.owner + ) + + card_list = [card.self_to_dict() for card in self.cards] if self.cards else [] + # sort card list by card_ids to prevent cards shifting when like numbers change + card_list.sort(key= lambda x: x["card_id"]) + instance_dict["cards"] = card_list + + return instance_dict + + + def update_self(self, data_dict): + dict_key_errors = [] + for key in data_dict.keys(): + if hasattr(self, key): + setattr(self, key, data_dict[key]) + else: + dict_key_errors.append(key) + if dict_key_errors: + raise ValueError(dict_key_errors) + + + + # Class Methods + + @classmethod + def create_from_dict(cls, data_dict): + + if data_dict.keys() == cls.required_attributes.keys(): + return cls(title=data_dict["title"], owner=data_dict["owner"]) + else: + remaining_keys= set(data_dict.keys())-set("title", "owner") + response=list(remaining_keys) + raise ValueError(response) + + @classmethod + def return_class_name(cls): + return cls.__name__ \ No newline at end of file diff --git a/app/models/card.py b/app/models/card.py index 147eb748..5dbab0d7 100644 --- a/app/models/card.py +++ b/app/models/card.py @@ -1 +1,57 @@ from app import db + +class Card(db.Model): + card_id = db.Column(db.Integer, primary_key=True, autoincrement=True) + message = db.Column(db.String, nullable=False) + likes_count = db.Column(db.Integer, nullable=False) + board_id = db.Column(db.Integer, db.ForeignKey('board.board_id'), nullable=False) + board = db.relationship("Board", back_populates='cards') + + + required_attributes = { + "message" : True, + "board_id" : True, + } + + # Instance Methods + def self_to_dict(self): + instance_dict = dict( + card_id=self.card_id, + message=self.message, + board_id=self.board_id, + likes_count=self.likes_count + ) + + return instance_dict + + + def update_self(self, data_dict): + dict_key_errors = [] + for key in data_dict.keys(): + if hasattr(self, key): + setattr(self, key, data_dict[key]) + else: + dict_key_errors.append(key) + if dict_key_errors: + raise ValueError(dict_key_errors) + + + # Class Methods + + + @classmethod + def create_from_dict(cls, data_dict): + if data_dict.keys() == cls.required_attributes.keys(): + return cls(message=data_dict["message"], + board_id = data_dict["board_id"], + likes_count = 0 + ) + + else: + remaining_keys= set(data_dict.keys())-set(cls.required_attributes.keys()) + response=list(remaining_keys) + raise ValueError(response) + + @classmethod + def return_class_name(cls): + return cls.__name__ 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/board_routes.py b/app/routes/board_routes.py new file mode 100644 index 00000000..ab18f2f2 --- /dev/null +++ b/app/routes/board_routes.py @@ -0,0 +1,67 @@ +from flask import Blueprint, request, jsonify, make_response +from app import db +from app.models.board import Board +from app.helper_functions import success_message_info_as_list, get_record_by_id, return_database_info_dict, error_message, create_record_safely +from app.models.card import Card + +board_bp = Blueprint('Boards', __name__, url_prefix='/boards') + +# create one board +@board_bp.route("", methods=["POST"]) +def create_new_board(): + request_body = request.get_json() + new_board = create_record_safely(Board, request_body) + + db.session.add(new_board) + db.session.commit() + + return success_message_info_as_list(dict(board=new_board.self_to_dict()), 201) + +# read all boards +@board_bp.route("", methods=["GET"]) +def get_boards(): + boards = Board.query.all() + boards_response = [board.self_to_dict() for board in boards] + return success_message_info_as_list(boards_response, status_code=200) + +# reading one board +@board_bp.route("/", methods=["GET"]) +def get_one_board(board_id): + board = get_record_by_id(Board, board_id) + return return_database_info_dict("board", board.self_to_dict()) + +# read all cards by board id +@board_bp.route("//cards", methods=["GET"]) +def get_cards_by_board_id(board_id): + board = get_record_by_id(Board, board_id) + + return success_message_info_as_list(board.self_to_dict()) + +# creating one card +@board_bp.route("//cards", methods=["POST"]) +def create_card(board_id): + request_body = request.get_json() + if "message" not in request_body: + error_message("Message not found", 400) + + request_body["board_id"] = board_id + card = Card.create_from_dict(request_body) + board = get_record_by_id(Board, board_id) + + db.session.add(card) + db.session.commit() + + return success_message_info_as_list(dict(board=board.self_to_dict()), 201) + +# Delete one board +@board_bp.route("/", methods=["DELETE"]) +def delete_one_board(board_id): + board = get_record_by_id(Board, board_id) + + for card in board.cards: + db.session.delete(card) + + db.session.delete(board) + db.session.commit() + + return success_message_info_as_list(dict(details=f'Board {board.board_id} "{board.title}" successfully deleted')) diff --git a/app/routes/card_routes.py b/app/routes/card_routes.py new file mode 100644 index 00000000..1cb6b3d0 --- /dev/null +++ b/app/routes/card_routes.py @@ -0,0 +1,29 @@ +from flask import Blueprint, request, jsonify, make_response +from app import db +from app.models.card import Card +from app.helper_functions import * + +card_bp = Blueprint('Cards', __name__, url_prefix='/cards') + + +@card_bp.route("/", methods=["PATCH"]) +def update_card(card_id): + card = get_record_by_id(Card, card_id) + + request_body = request.get_json() + + update_record_safely(Card, card, request_body) + + db.session.commit() + + return return_database_info_dict("card", card.self_to_dict()) + +#deleting one card +@card_bp.route("/", methods=["DELETE"]) +def delete_one_card(card_id): + card = get_record_by_id(Card, card_id) + db.session.delete(card) + db.session.commit() + + return success_message_info_as_list(dict(details=f'Card {card.card_id} \"{card.message}\" successfully deleted')) + 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/4c3221ec743b_.py b/migrations/versions/4c3221ec743b_.py new file mode 100644 index 00000000..df7521ba --- /dev/null +++ b/migrations/versions/4c3221ec743b_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: 4c3221ec743b +Revises: +Create Date: 2022-06-29 13:40:13.122137 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '4c3221ec743b' +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=False), + sa.Column('owner', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('board_id') + ) + op.create_table('card', + sa.Column('card_id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('message', sa.String(), nullable=False), + sa.Column('likes_count', sa.Integer(), nullable=False), + sa.Column('board_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['board_id'], ['board.board_id'], ), + 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/tests/conftest.py b/tests/conftest.py index 2b7296d5..ef0b034c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,8 @@ import pytest from app import create_app from app import db +from app.models.board import Board +from app.models.card import Card @pytest.fixture @@ -20,3 +22,51 @@ def app(): @pytest.fixture def client(app): return app.test_client() + +# This fixture creates one board and saves it in the database +@pytest.fixture +def one_board_no_cards(app): + new_board = Board( + title="Winter", owner= "Lili" + ) + db.session.add(new_board) + db.session.commit() + +# This fixture creates two boards with no cards and saves them in the database +@pytest.fixture +def two_boards_no_cards(app): + new_board_1 = Board( + title="Winter", owner= "Lili" + ) + new_board_2 = Board( + title="Spring", owner="Adriana" + ) + db.session.add(new_board_1) + db.session.add(new_board_2) + db.session.commit() + +@pytest.fixture +def one_board_two_cards(app): + new_board_1 = Board( + title="Winter", owner="Lili" + ) + new_board_2 = Board( + title="Spring", owner="Adriana" + ) + db.session.add(new_board_1) + db.session.add(new_board_2) + db.session.commit() + + new_card_1 = Card( + message="The woods are lovely, dark and deep...", + likes_count= 0, + board_id= 1 + ) + new_card_2 = Card( + message= "Las ramas de los árboles están envueltas en fundas de hielo.", + likes_count= 0, + board_id= 1 + ) + db.session.add(new_card_1) + db.session.add(new_card_2) + db.session.commit() \ No newline at end of file diff --git a/tests/test_routes.py b/tests/test_routes.py index e69de29b..07f8cd59 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -0,0 +1,203 @@ +import pytest +from app.models.board import Board +from app.models.card import Card + +#Board tests: + +def test_get_one_saved_board_no_cards(client, one_board_no_cards): + #Act + response = client.get("/boards/1") + response_body = response.get_json() + + #Assert + assert response.status_code == 200 + assert "board" in response_body + +def test_get_board_not_found(client): + #Act + response = client.get("/boards/1") + response_body = response.get_json() + + #Assert + assert response.status_code == 404 + assert response_body == {"details": "Board id: 1 not found"} + +def test_get_boards_no_boards(client): + #Act + response = client.get("/boards") + response_body = response.get_json() + + #Assert + assert response.status_code == 200 + assert response_body == [] + +def test_get_one_board_with_cards(client, one_board_no_cards): + #Arrange + card_1 = { + "message": "The woods are lovely, dark and deep..." + } + card_2 = { + "message": "Las ramas de los árboles están envueltas en fundas de hielo." + } + + #Act + client.post("/boards/1/cards", json=card_1) + client.post("/boards/1/cards", json=card_2) + + response = client.get("/boards/1") + response_body = response.get_json() + + #Assert + assert response.status_code == 200 + assert response_body["board"]["title"] == "Winter" + assert response_body["board"]["owner"] == "Lili" + assert response_body["board"]["cards"] == [ + { + "board_id": 1, + "card_id": 1, + "likes_count": 0, + "message": "The woods are lovely, dark and deep..." + }, + { + "board_id": 1, + "card_id": 2, + "likes_count": 0, + "message": "Las ramas de los árboles están envueltas en fundas de hielo." + } + ] + +def test_get_two_boards_no_cards(client, two_boards_no_cards): + response = client.get("/boards") + response_body = response.get_json() + + #Assert + assert response.status_code == 200 + assert len(response_body) == 2 + assert response_body[0]["title"] == "Winter" + assert response_body[0]["owner"] == "Lili" + assert response_body[0]["cards"] == [] + assert response_body[1]["title"] == "Spring" + assert response_body[1]["owner"] == "Adriana" + assert response_body[1]["cards"] == [] + +def test_get_cards_for_board_with_no_cards_returns_empty_list(client, one_board_no_cards): + response = client.get("/boards/1") + response_body = response.get_json() + + #Assert + assert response.status_code == 200 + assert response_body["board"]["cards"] == [] + +def test_get_cards_for_board_with_2_cards(client, one_board_no_cards): + #Arrange + card_1 = { + "message": "The woods are lovely, dark and deep..." + } + card_2 = { + "message": "Las ramas de los árboles están envueltas en fundas de hielo." + } + + #Act + client.post("/boards/1/cards", json=card_1) + client.post("/boards/1/cards", json=card_2) + + response = client.get("/boards/1/cards") + response_body = response.get_json() + + #Assert + assert response.status_code == 200 + assert "cards" in response_body + assert len(response_body["cards"]) == 2 + assert response_body["cards"] == [ + { + "board_id": 1, + "card_id": 1, + "likes_count": 0, + "message": "The woods are lovely, dark and deep..." + }, + { + "board_id": 1, + "card_id": 2, + "likes_count": 0, + "message": "Las ramas de los árboles están envueltas en fundas de hielo." + } + ] + +def test_get_cards_from_nonexistant_board_returns_error_board_id_not_found(client): + #Act + response = client.get("/boards/1/cards") + response_body = response.get_json() + + #Assert + assert "details" in response_body + assert response_body["details"] == "Board id: 1 not found" + +def test_get_cards_with_invalid_id_returns_error(client): + #Act + response = client.get("/boards/a/cards") + response_body = response.get_json() + + #Assert + assert "details" in response_body + assert response_body["details"] == "Invalid id: a" + +#Card tests +def test_create_card(client, one_board_no_cards): + #Act + response = client.post("/boards/1/cards", json={ + "message": "Peppermint mocha, yum" + }) + response_body = response.get_json() + + #Assert + assert response.status_code == 201 + assert response_body == "Card created successfully" + + new_card = Card.query.get(1) + assert new_card + assert new_card.message == "Peppermint mocha, yum" + assert new_card.likes_count == 0 + +def test_create_card_invalid_data(client, one_board_no_cards): + #Act + response = client.post("/boards/1/cards", json={ + "messag": "Peppermint mocha, yum" + }) + response_body = response.get_json() + + #Assert + assert response.status_code == 400 + assert response_body == {"details": "Message not found"} + +def test_increase_card_likes(client, one_board_no_cards): + #Arrange + card_1 = { + "message": "The woods are lovely, dark and deep..." + } + client.post("/boards/1/cards", json=card_1) + #Act + patch_request_body = {"likes_count":3} + response = client.patch("/cards/1", json=patch_request_body) + + response_body = response.get_json() + + assert "card" in response_body + assert response_body == { + "card" : { + "board_id": 1, + "card_id": 1, + "likes_count": 3, + "message": "The woods are lovely, dark and deep..." + } + } + +def test_delete_card(client, one_board_two_cards): + #Act + response = client.delete("/cards/1") + response_body = response.get_json() + + #Assert + assert response.status_code == 200 + assert response_body == {'details': 'Card 1 "The woods are lovely, dark and deep..." successfully deleted'} + + assert Card.query.get(1) == None \ No newline at end of file