diff --git a/app/__init__.py b/app/__init__.py index 1c821436..291f65b1 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -10,22 +10,38 @@ 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") + if test_config is None: + app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get("RENDER_DATABASE_URI") + # 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") # Import models here for Alembic setup # from app.models.ExampleModel import ExampleModel - db.init_app(app) migrate.init_app(app, db) + from app.models.board import Board + from app.models.card import Card + # Register Blueprints here # from .routes import example_bp + from .cards_routes import cards_bp + from .boards_routes import boards_bp + # app.register_blueprint(example_bp) + app.register_blueprint(cards_bp) + app.register_blueprint(boards_bp) + + CORS(app) return app diff --git a/app/boards_routes.py b/app/boards_routes.py new file mode 100644 index 00000000..e2e983ef --- /dev/null +++ b/app/boards_routes.py @@ -0,0 +1,97 @@ +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 sqlalchemy.types import DateTime +from sqlalchemy.sql.functions import now +from app.routes_helpers import validate_model +import requests, json +import os + +boards_bp = Blueprint("boards", __name__, url_prefix="/boards") + +@boards_bp.route("", methods=["POST"]) +def create_board(): + request_body = request.get_json() + + is_valid_board_title = "title" in request_body + is_valid_board_owner = "owner" in request_body + if not is_valid_board_title: + abort(make_response({"details": "Please include title"}, 400)) + if not is_valid_board_owner: + abort(make_response({"details": "Please include owner"}, 400)) + + new_board = Board.board_from_dict(request_body) + + db.session.add(new_board) + db.session.commit() + + return make_response(jsonify(f"{new_board} successfully created!"), 201) + +@boards_bp.route("", methods=["GET"]) +def read_all_boards(): + boards = Board.query.all() + boards_response = [board.board_to_dict() for board in boards] + + return jsonify(boards_response) + +@boards_bp.route("/", methods=["GET"]) +def read_one_board(board_id): + board = validate_model(Board, board_id) + + response_body = { + "board": board.board_to_dict() + } + + return jsonify(response_body) + +@boards_bp.route("/", methods=["DELETE"]) +def delete_one_board(board_id): + board = validate_model(Board, board_id) + + response_body = { + "details": f"Board {board.id} \"{board.title}\" successfully deleted" + } + + db.session.delete(board) + db.session.commit() + + return jsonify(response_body) + +@boards_bp.route("", methods=["DELETE"]) +def delete_all_boards(): + boards = Board.query.all() + for board in boards: + db.session.delete(board) + db.session.commit() + + return make_response(jsonify("All boards successfully deleted!"), 200) + +@boards_bp.route("//cards", methods=["POST"]) +def create_one_card_for_board(board_id): + board = validate_model(Board, board_id) + + request_body = request.get_json() + + card = Card.query.get(request_body["id"]) + board.cards.append(card) + + db.session.add(board) + db.session.commit() + + return make_response({ + "id": board.id, + "card_id": request_body["id"]}, 201) + +@boards_bp.route("//cards", methods=["GET"]) +def get_cards_for_one_board(board_id): + board = validate_model(Board, board_id) + + cards = board.cards + + cards_list = [card.card_to_dict() for card in cards] + + return make_response({ + "id": board.id, + "title": board.title, + "cards": cards_list}, 200) diff --git a/app/cards_routes.py b/app/cards_routes.py new file mode 100644 index 00000000..c44d3b70 --- /dev/null +++ b/app/cards_routes.py @@ -0,0 +1,58 @@ +from flask import Blueprint, request, jsonify, make_response +from app import db +from app.models.card import Card +from app.routes_helpers import validate_model + +cards_bp = Blueprint("cards", __name__, url_prefix="/cards") + +@cards_bp.route("", methods=["POST"]) +def create_card(): + request_body = request.get_json() + + new_card = Card.card_from_dict(request_body) + + db.session.add(new_card) + db.session.commit() + + new_card_id = new_card.id + + return make_response(jsonify({"id": new_card_id, "message": "Card successfully created"}), 201) + +@cards_bp.route("", methods=["GET"]) +def read_all_cards(): + cards = Card.query.all() + + cards_response = [card.card_to_dict() for card in cards] + + return make_response(jsonify(cards_response), 200) + +@cards_bp.route("/", methods=["DELETE"]) +def delete_one_card(card_id): + card = validate_model(Card, card_id) + + response_body = { + "details": f"Card {card.id} \"{card.message}\" successfully deleted" + } + + db.session.delete(card) + db.session.commit() + + return jsonify(response_body) + +@cards_bp.route("", methods=["DELETE"]) +def delete_all_cards(): + cards = Card.query.all() + for card in cards: + db.session.delete(card) + db.session.commit() + + return make_response(jsonify("All cards successfully deleted!"), 200) + +@cards_bp.route("/", methods=["PATCH"]) +def increase_like_count(card_id): + card = validate_model(Card, card_id) + + card.likes_count += 1 + + db.session.commit() + return make_response(card.card_to_dict(), 200) \ No newline at end of file diff --git a/app/models/board.py b/app/models/board.py index 147eb748..38873c7c 100644 --- a/app/models/board.py +++ b/app/models/board.py @@ -1 +1,22 @@ from app import db + +class Board(db.Model): + 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") + + @classmethod + def board_from_dict(cls, board_data): + new_board = Board(title=board_data["title"], owner=board_data["owner"]) + + return new_board + + def board_to_dict(self): + board_as_dict = {} + board_as_dict["id"] = self.id + board_as_dict["title"] = self.title + board_as_dict["owner"] = self.owner + + return board_as_dict + \ No newline at end of file diff --git a/app/models/card.py b/app/models/card.py index 147eb748..c6ae4eb1 100644 --- a/app/models/card.py +++ b/app/models/card.py @@ -1 +1,23 @@ from app import db + + +class Card(db.Model): + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + message = db.Column(db.String) + likes_count = db.Column(db.Integer) + board_id = db.Column(db.Integer, db.ForeignKey("board.id")) + board = db.relationship("Board", back_populates="cards") + + def card_to_dict(self): + card_as_dict = {} + card_as_dict["id"] = self.id + card_as_dict["message"] = self.message + card_as_dict["likes_count"] = self.likes_count + + return card_as_dict + + @classmethod + def card_from_dict(cls, card_data): + new_card = cls(message=card_data["message"], likes_count=card_data.get("likes_count", 0)) + + return new_card 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_helpers.py b/app/routes_helpers.py new file mode 100644 index 00000000..e69e7146 --- /dev/null +++ b/app/routes_helpers.py @@ -0,0 +1,14 @@ +from flask import make_response, abort + +def validate_model(cls, model_id): + try: + model_id = int(model_id) + except: + abort(make_response({"message": f"{cls.__name__} {model_id} invalid"}, 400)) + + model = cls.query.get(model_id) + + if not model: + abort(make_response({"message": f"{cls.__name__} {model_id} not found"}, 404)) + + return model 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/ca3d831c4542_adds_relationship_between_board_and_card.py b/migrations/versions/ca3d831c4542_adds_relationship_between_board_and_card.py new file mode 100644 index 00000000..2fceabff --- /dev/null +++ b/migrations/versions/ca3d831c4542_adds_relationship_between_board_and_card.py @@ -0,0 +1,30 @@ +"""adds relationship between Board and Card + +Revision ID: ca3d831c4542 +Revises: cdb4c3b0a792 +Create Date: 2023-06-26 17:49:32.575635 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ca3d831c4542' +down_revision = 'cdb4c3b0a792' +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'], ['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/migrations/versions/cdb4c3b0a792_adds_board_and_card_models.py b/migrations/versions/cdb4c3b0a792_adds_board_and_card_models.py new file mode 100644 index 00000000..b683189b --- /dev/null +++ b/migrations/versions/cdb4c3b0a792_adds_board_and_card_models.py @@ -0,0 +1,40 @@ +"""adds Board and Card models + +Revision ID: cdb4c3b0a792 +Revises: +Create Date: 2023-06-22 17:07:27.584747 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'cdb4c3b0a792' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('board', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('title', sa.String(), nullable=True), + sa.Column('owner', sa.String(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('card', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('message', sa.String(), nullable=True), + sa.Column('likes_count', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('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/requirements.txt b/requirements.txt index 76f1f000..253ec4a2 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.4 certifi==2020.12.5 chardet==4.0.0 click==7.1.2 diff --git a/seed_board.py b/seed_board.py new file mode 100644 index 00000000..decdfca0 --- /dev/null +++ b/seed_board.py @@ -0,0 +1,11 @@ +from app import create_app, db +from app.models.board import Board + +my_app = create_app() +with my_app.app_context(): + db.session.add(Board(title="reminders", owner="Amber")) + db.session.add(Board(title="capstone ideas", owner="Amber")) + db.session.add(Board(title="quotes",owner="Areeg")) + db.session.add(Board(title="advice",owner="Gabby")) + db.session.add(Board(title="jokes",owner="Angelica")) + db.session.commit() \ No newline at end of file diff --git a/seed_card.py b/seed_card.py new file mode 100644 index 00000000..5265c2de --- /dev/null +++ b/seed_card.py @@ -0,0 +1,23 @@ +from app import create_app, db +from app.models.card import Card +from app.models.board import Board + +my_app = create_app() +with my_app.app_context(): + # Get the "reminders" board from the database + reminders_board = Board.query.filter_by(title="reminders").first() + capstone_ideas_board = Board.query.filter_by(title="capstone ideas").first() + quotes_board = Board.query.filter_by(title="quotes").first() + advice_board = Board.query.filter_by(title="advice").first() + jokes_board = Board.query.filter_by(title="jokes").first() + + # Create and add cards associated with the "reminders" board + db.session.add(Card(message="Buy groceries", likes_count=0, board_id=reminders_board.id)) + db.session.add(Card(message="Call Mom", likes_count=0, board_id=reminders_board.id)) + db.session.add(Card(message="Finish homework", likes_count=0, board_id=reminders_board.id)) + db.session.add(Card(message="dont stay up late, it will make you old", likes_count=0, board_id=advice_board.id)) + db.session.add(Card(message="a social media platform called fakebook", likes_count=0, board_id=capstone_ideas_board.id)) + db.session.add(Card(message="I think then I code", likes_count=0,board_id=quotes_board.id)) + db.session.add(Card(message="never leave your keys in the car", likes_count=0, board_id=advice_board.id)) + db.session.add(Card(message="knock knock... nothing I dont know any jokes", likes_count=0, board_id=jokes_board.id)) + db.session.commit() \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 2b7296d5..f145175e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,19 @@ import pytest from app import create_app +from flask.signals import request_finished from app import db - +from app.models.board import Board +from app.models.card import Card @pytest.fixture 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 @@ -16,7 +22,53 @@ def app(): with app.app_context(): db.drop_all() - @pytest.fixture def client(app): return app.test_client() + +@pytest.fixture +def two_saved_boards(app): + shakespeare_board = Board(title="Shakespeare Quotes", + owner="William Shakespeare") + + movie_board = Board(title="Movie Quotes", + owner="Meryl Streep") + + db.session.add_all([shakespeare_board, movie_board]) + db.session.commit() + + return shakespeare_board, movie_board + +@pytest.fixture +def three_saved_cards(app): + card_1 = Card(message="To be, or not to be, that is the question", + likes_count=5) + card_2 = Card(message="Romeo, Romeo! Wherefore art thou Romeo?", + likes_count=3) + card_3 = Card(message="There's no place like home.", + likes_count=2) + + db.session.add_all([card_1, card_2, card_3]) + db.session.commit() + + return card_1, card_2, card_3 + +@pytest.fixture +def three_saved_cards_and_two_boards(three_saved_cards, two_saved_boards): + card_1, card_2, card_3 = three_saved_cards + shakespeare_board, movies_board = two_saved_boards + + shakespeare_board.cards.extend([card_1, card_2]) + + db.session.commit() + +@pytest.fixture +def one_saved_card(app): + card_1 = Card(message="To be, or not to be, that is the question", + likes_count=1) + + + db.session.add_all([card_1]) + db.session.commit() + + return card_1 \ No newline at end of file diff --git a/tests/test_routes.py b/tests/test_routes.py index e69de29b..cd1391e1 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -0,0 +1,113 @@ +from app.models.board import Board +import pytest + +# @pytest.mark.skip(reason="Feature not yet built") +def test_get_board_no_board_saved(client): + response = client.get("/boards") + response_body = response.get_json() + + assert response.status.code == 200 + assert response_body ==[] + +#@pytest.mark.skip(reason="Feature not yet built") +def test_create_board(client): + test_data = { + "id": 1, + "owner": "Angelica", + "title": "boardie" + } + response = client.post("/boards", json=test_data) + + response_body = response.get_json() + + assert response.status_code == 201 + assert response_body == " successfully created!" + +# @pytest.mark.skip(reason="Feature not yet built") +def test_get_one_saved_board(client): + response = client.get("/boards/1") + response_body = response.get_json() + + assert response.status_code == 200 + assert response_body == { + "id": 3, + "owner": "Amber", + "title": "The Keep on Keepin On Board" + } + +#@pytest.mark.skip(reason="Feature not yet built") +def test_delete_board(client): + response = client.delete("/boards/1") + + response_body = response.get_json() + + assert response.status_code == 200 + assert response_body == { + "details": "Board 1 \"boardie\" successfully deleted" + } + +#@pytest.mark.skip(reason="Feature not yet built") +def test_delete_board_not_found(client): + response = client.delete("/boards/333") + + response_body = response.get_json() + + assert response.status_code == 404 + assert response_body == { + "message": "Board 333 not found" + } + +# @pytest.mark.skip(reason="Feature not yet built") +def test_create_card(client, three_saved_cards): + test_data = {"message": "May the Force be with you.", "likes_count": 0} + + response = client.post("/cards", json=test_data) + + response_body = response.get_json() + + assert response.status_code == 201 + assert response_body == "Card successfully created" + +# @pytest.mark.skip(reason="Feature not yet built") +def test_create_card_for_one_board(client, three_saved_cards_and_two_boards): + test_data = {"id": 3} + + response = client.post("boards/2/cards", json=test_data) + + response_body = response.get_json() + + assert response.status_code == 201 + assert response_body == {"id": 2, "card_id": 3} + +# @pytest.mark.skip(reason="Feature not yet built") +def test_get_all_cards_for_one_board(client, three_saved_cards_and_two_boards): + response = client.get("boards/1/cards") + + response_body = response.get_json() + + assert response.status_code == 200 + assert response_body == { + "id": 1, + "title": "Shakespeare Quotes", + "cards": [{"id": 1, "message": "To be, or not to be, that is the question", + "likes_count": 5}, {"id": 2, "message": "Romeo, Romeo! Wherefore art thou Romeo?", + "likes_count": 3}] + } + +# @pytest.mark.skip(reason="Feature not yet built") +def test_get_cards_for_all_boards(client, three_saved_cards_and_two_boards): + response = client.get("/cards") + + response_body = response.get_json() + + assert response.status_code == 200 + assert len(response_body) == 3 + +# @pytest.mark.skip(reason="Feature not yet built") +def test_likes_increments_by_one(client, one_saved_card): + response = client.patch("/cards/1") + response_body = response.get_json() + updated_likes = response_body["likes_count"] + + assert response.status_code == 200 + assert updated_likes == 2 \ No newline at end of file