diff --git a/OUR_README.md b/OUR_README.md new file mode 100644 index 00000000..d120d896 --- /dev/null +++ b/OUR_README.md @@ -0,0 +1,54 @@ +# Mystical Mycology Muse + +The Mystical Mycology Muse server is to be used with the [M^3 Dashboard](https://github.com/lindsaybolz/front-end-inspiration-board) + +## Quick Start + +1. Clone this repository. **You do not need to fork it first.** + - `git clone https://github.com/lindsaybolz/back-end-inspiration-board.git` + +1. Create and activate a virtual environment + - `python3 -m venv venv` + - `source venv/bin/activate` +1. Install the `requirements.txt` + - `pip install -r requirements.txt` +1. Create a `.env` file with your API keys + ```bash + # .env + + # SQLALCHEMY_DATABASE_URI + SQLALCHEMY_DATABASE_URI=postgresql+psycopg2://postgres:postgres@localhost:5432/inspiration_board_development + + # SQLALCHEMY_TEST_DATABASE_URI + SQLALCHEMY_TEST_DATABASE_URI=postgresql+psycopg2://postgres:postgres@localhost:5432/inspiration_board_test + + # SLACK_API_KEY + SLACK_API_KEY=Bearer "replace_with_your_api_key" + ``` +1. Create Databases on personal psql: + ``` + $ psql -U postgres + > CREATE DATABASE inspiration_board_test; + > CREATE DATABASE inspiration_board_development; + ``` +2. Create flask database connection + ``` + $ flask db init + $ flask db migrate + $ flask db upgrade + ``` +3. Run the server + - `flask run` + +## Endpoints + +| Route | Query Parameter(s) | Query Parameter(s) Description | +|--|--|--| +|`POST` `/boards`| `owner` & `title` | Owner and title of board strings in json ex: `{"id": 1, "owner": "Lindsay", "title": "Fungi's 101"}` | +|`GET` `/boards` | None | Returns list of boards as dictionaries `[{"id": 1, "owner": "Lindsay", "title": "Fungi's 101"}, {...}]`| +|`DELETE` `/boards/` | None | Returns: `Board successfully deleted`| +|`POST` `/boards//cards` | `message` | Returns: `{"id": 1 "owner": Stacy "title": "Fungi's 101"}`| +|`GET` `/boards//cards` | None | Returns list of cards for ``: `[{'id': 1, message': "I like fungi" 'likes': 1 'board': "Fungi's 101"}, {...}]`. This also posts a message to slack.| +|`DELETE` `/cards/` | None | Returns: `Card successfully deleted`| +|`PATCH` `/cards/` | None | Returns: dictionary of the card that was changed with updated like count by +1: `{'id': 1,'message': "I like fungi" 'likes': 2, 'board': "Fungi's 101"}`| + diff --git a/app/__init__.py b/app/__init__.py index 1c821436..bad2aa49 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -10,22 +10,31 @@ 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 not test_config: + #app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get("SQLALCHEMY_DATABASE_URI") + app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get("RENDER_DATABASE_URI") + else: + app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get( + "SQLALCHEMY_TEST_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) # Register Blueprints here - # from .routes import example_bp - # app.register_blueprint(example_bp) + from .routes import boards_bp, cards_bp + app.register_blueprint(boards_bp) + app.register_blueprint(cards_bp) + CORS(app) return app diff --git a/app/models/board.py b/app/models/board.py index 147eb748..63a01e63 100644 --- a/app/models/board.py +++ b/app/models/board.py @@ -1 +1,18 @@ from app import db + +class Board(db.Model): + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + owner = db.Column(db.String) + title = db.Column(db.String) + cards = db.relationship("Card", back_populates='board',cascade = "all, delete-orphan") + + def to_dict(self): + return { + "id": self.id, + "owner": self.owner, + "title": self.title + } + + @classmethod + def from_dict(cls, dict_data): + return Board(owner = dict_data['owner'], title = dict_data['title']) diff --git a/app/models/card.py b/app/models/card.py index 147eb748..b72ddffd 100644 --- a/app/models/card.py +++ b/app/models/card.py @@ -1 +1,16 @@ from app import db + +class Card(db.Model): + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + message = db.Column(db.String) + likes = db.Column(db.Integer, default=0) + board_id = db.Column(db.Integer, db.ForeignKey('board.id')) + board = db.relationship("Board", back_populates='cards') + + def to_dict(self): + return { + 'id': self.id, + 'message': self.message, + 'likes': self.likes, + 'board': self.board.title, + } \ No newline at end of file diff --git a/app/routes.py b/app/routes.py index 480b8c4b..c9a8160a 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,4 +1,120 @@ -from flask import Blueprint, request, jsonify, make_response +from flask import Blueprint, request, jsonify, abort, make_response from app import db +from app.models.board import Board +from app.models.card import Card +import requests +import os -# example_bp = Blueprint('example_bp', __name__) + +# Helper Functions: +def validate_model(model_class, model_id): + try: + model_id = int(model_id) + except: + abort(make_response({'message': f"{model_id} is not a valid type. It must be an integer"}, 400)) + + model = model_class.query.get(model_id) + + if not model: + abort(make_response({'message': f'{model_id} does not exist'}, 404)) + + return model + +def post_to_slack(card): + path = 'https://slack.com/api/chat.postMessage' + header = {"Authorization": os.environ.get("SLACK_API_KEY")} + data = { + 'channel': 'new-card-updates', + 'text': f"Card with message \"{card.message}\" for board \"{card.board.title}\" was created!" + } + response = requests.post(path, headers=header, data=data) + return response + +#example_bp = Blueprint('example_bp', __name__) +boards_bp = Blueprint("boards", __name__, url_prefix="/boards") +cards_bp = Blueprint("cards", __name__, url_prefix="/cards") + +# create a board +@boards_bp.route("", methods=["POST"]) +def create_board(): + request_body = request.get_json() + try: + if request_body.get('owner') and request_body.get('title'): + new_board = Board.from_dict(request_body) + + db.session.add(new_board) + db.session.commit() + + return jsonify(new_board.to_dict()), 201 + except: + abort(make_response({"message": "Board input data incomplete"}, 400)) + + + +# get all boards +@boards_bp.route("", methods = ["GET"]) +def get_boards(): + boards = Board.query.all() + + board_response = [] + for board in boards: + board_response.append(board.to_dict()) + + return jsonify(board_response), 200 + +# delete board +@boards_bp.route('/', methods=['DELETE']) +def delete_board(board_id): + board = validate_model(Board, board_id) + + db.session.delete(board) + db.session.commit() + + return jsonify('Board successfully deleted'), 201 + +# Create a new card for the selected board +@boards_bp.route("//cards", methods=["POST"]) +def create_card_for_board(board_id): + board = validate_model(Board, board_id) + request_body = request.get_json() + if len(request_body['message']) > 40: + abort(make_response({"message": "Message was too long, keep it under 40 characters please"}, 400)) + + new_card = Card(message=request_body['message'], board=board) + + db.session.add(new_card) + db.session.commit() + + post_to_slack(new_card) + + return jsonify(new_card.to_dict()), 201 + +# get cards for board_id +@boards_bp.route("//cards", methods=["GET"]) +def get_cards_for_board(board_id): + board = validate_model(Board, board_id) + cards = [] + for card in board.cards: + cards.append(card.to_dict()) + + return jsonify(cards), 200 + +# delete card +@cards_bp.route('/', methods=['DELETE']) +def delete_card(card_id): + card = validate_model(Card, card_id) + + db.session.delete(card) + db.session.commit() + + return jsonify('Card successfully deleted'), 201 + +# Updating like by +1 on card +@cards_bp.route('/', methods=['PATCH']) +def increment_like_on_card(card_id): + card = validate_model(Card, card_id) + + card.likes = card.likes + 1 + db.session.commit() + + return card.to_dict(), 200 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/tests/conftest.py b/tests/conftest.py index 2b7296d5..e6624c12 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,12 +1,19 @@ import pytest from app import create_app from app import db +from app.models.board import Board +from app.models.card import Card +#from flask.signals import request_finished @pytest.fixture def app(): # create the app with a test config dictionary - app = create_app({"TESTING": True}) + app = create_app(test_config=True) + + #@request_finished.connect_via(app) + #def expire_session(sender, response, **extra): + # db.session.remove() with app.app_context(): db.create_all() @@ -20,3 +27,21 @@ def app(): @pytest.fixture def client(app): return app.test_client() + +@pytest.fixture +def two_saved_boards(client): + board_1 = Board(owner = "Lindsay", title = "Shroomies") + board_2 = Board(owner = "Stacy", title = "idk") + + db.session.add_all([board_1, board_2]) + db.session.commit() + +@pytest.fixture +def one_saved_boards_with_two_cards(client): + card_1 = Card(message = "card 1 message") + card_2 = Card(message = "card 2 message") + + board_1 = Board(owner = "Lindsay", title = "Shroomies", cards = [card_1, card_2]) + + db.session.add_all([board_1]) + db.session.commit() diff --git a/tests/test_routes.py b/tests/test_routes.py index e69de29b..ce7b6171 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -0,0 +1,148 @@ +from app.models.board import Board +from app.models.card import Card +from app.routes import validate_model, post_to_slack +from werkzeug.exceptions import HTTPException +import pytest + + +def test_create_board_no_owner(client): + response = client.post('/boards', json = { + "title": "stacy" + }) + response_body = response.get_json() + + assert response_body == {"message": "Board input data incomplete"} + assert response.status_code == 400 + +def test_create_board_no_title(client): + response = client.post('/boards', json = { + "owner": "stacy" + }) + response_body = response.get_json() + + assert response_body == {"message": "Board input data incomplete"} + assert response.status_code == 400 + +def test_create_board_success(client): + response = client.post('/boards', json = { + "owner": "Lindsay", + "title": "capstone" + }) + response_body = response.get_json() + + assert response_body == "Board 1 successfully created." + assert response.status_code == 201 + +def test_create_board_no_data(client): + response = client.post('/boards') + response_body = response.get_json() + + assert response_body == {"message": "Board input data incomplete"} + assert response.status_code == 400 + +def test_get_all_boards_with_no_records(client): + response = client.get('/boards') + response_body = response.get_json() + + assert response_body == [] + assert response.status_code == 200 + +def test_get_all_boards_with_two_boards(client, two_saved_boards): + response = client.get('/boards') + response_body = response.get_json() + + assert response_body == [{"id" : 1, "owner" : "Lindsay", "title" : "Shroomies"}, + {"id": 2, "owner": "Stacy", "title":"idk"}] + assert response.status_code == 200 + +def test_create_card_for_board_1(client, two_saved_boards): + response = client.post('/boards/1/cards', json = { + "message": "card 1 message" + }) + response_body = response.get_json() + board_response = client.get('/boards/1/cards') + board_response_body = board_response.get_json() + + assert board_response_body == [{'board': 'Shroomies', 'id': 1, "message": "card 1 message", "likes": 0}] + assert response_body == "Card was successfully created" + assert response.status_code == 201 + +def test_create_card_for_board_1_over_40_characters(client, two_saved_boards): + response = client.post('/boards/1/cards', json = { + "message": "card 1 message jdhfjkdshfjhghskfjhahfjkshgjrkhsfkgjrsskjdfhasjkhfjkadhfjkhad" + }) + response_body = response.get_json() + + board_response = client.get('/boards/1/cards') + board_response_body = board_response.get_json() + + assert board_response_body == [] + assert response_body == {"message": "Message was too long, keep it under 40 characters please"} + assert response.status_code == 400 + +def test_get_all_cards_from_board_1_with_no_cards(client, two_saved_boards): + response = client.get('/boards/1/cards') + response_body = response.get_json() + + assert response_body == [] + assert response.status_code == 200 + +def test_get_all_cards_from_board_one_with_two_cards(client, one_saved_boards_with_two_cards): + response = client.get('/boards/1/cards') + response_body = response.get_json() + + assert response_body == [{'board': 'Shroomies', 'id': 1, 'message': 'card 1 message', 'likes': 0}, + {'board': 'Shroomies', 'id': 2, 'message': 'card 2 message', 'likes': 0}] + assert response.status_code == 200 + +def test_delete_card(client, one_saved_boards_with_two_cards): + response = client.delete('/cards/1') + response_body = response.get_json() + + card_response = client.get('/boards/1/cards') + card_response_body = card_response.get_json() + + assert card_response_body == [{'board': 'Shroomies', 'id': 2, 'message': 'card 2 message', 'likes': 0}] + assert response_body == "Card successfully deleted" + assert response.status_code == 201 + +def test_card_like_increases_by_1(client, one_saved_boards_with_two_cards): + response = client.patch('/cards/1') + response_body = response.get_json() + + card_response = client.get('/boards/1/cards') + card_response_body = card_response.get_json() + print(card_response_body) + assert card_response_body == [{'board': 'Shroomies', 'id': 2, 'message': 'card 2 message', 'likes': 0}, + {'board': 'Shroomies', 'id': 1, 'message': 'card 1 message', 'likes': 1}] + assert response_body == {'board': 'Shroomies', 'id': 1, 'message': 'card 1 message', 'likes': 1} + assert response.status_code == 200 + + +def test_validate_model_returns_model(two_saved_boards): + model_id = 1 + model = Board + + output = validate_model(model, model_id) + + assert output == Board.query.get(model_id) + +def test_validate_model_returns_invalid_id(two_saved_boards): + with pytest.raises(HTTPException): + result = validate_model(Board, "cat") + +def test_validate_model_returns_not_found(two_saved_boards): + with pytest.raises(HTTPException): + result = validate_model(Board, 3) + +def test_post_to_slack_success(client, one_saved_boards_with_two_cards): + # Set Up + card_1 = Card(message = "card 1 message") + board_1 = Board(owner = "Lindsay", title = "Shroomies", cards = [card_1]) + + # Act + response = post_to_slack(card_1) + + # Assert + assert response.json()['message']['text'] == 'Card with message \"card 1 message\" for board \"Shroomies\" was created!' + assert response.status_code == 200 \ No newline at end of file