diff --git a/Procfile b/Procfile index 62e430ac..066ed31d 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: gunicorn 'app:create_app()' \ No newline at end of file +web: gunicorn 'app:create_app()' diff --git a/app/__init__.py b/app/__init__.py index 1c821436..7e3f1367 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -20,12 +20,18 @@ def create_app(): # 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 .card_routes import cards_bp + app.register_blueprint(cards_bp) + + from .board_routes import boards_bp + app.register_blueprint(boards_bp) CORS(app) return app diff --git a/app/board_routes.py b/app/board_routes.py new file mode 100644 index 00000000..b8884385 --- /dev/null +++ b/app/board_routes.py @@ -0,0 +1,202 @@ +from flask import Blueprint, request, jsonify, make_response +from sqlalchemy.orm.query import Query +from app import db +from app.models.board import Board +from app.models.card import Card +import requests +from dotenv import load_dotenv +load_dotenv() +from functools import wraps +import os + +boards_bp=Blueprint("board", __name__, url_prefix="/board") + +# Decorator for validation +# Can be used for any route that has +# Updated routes to use because was overlapping with model values. +# Challange someone to try something similar for GET by card_ID +# Will explain decorators, args/kwargs, wraps tomorrow. + +def validate_board(board_identity): + @wraps(board_identity) + def test_for_board (*args, board_ID, **kwargs): + if not board_ID.isnumeric(): + return ({"message":f"Board {board_ID} does not exist.",}), 404 + + board_check = Board.query.get(board_ID) + if board_check: + return board_identity (*args, board_ID, **kwargs) + else: + return ({"message":f"Board {board_ID} does not exist",}), 404 + return test_for_board + +def validate_card(card_identity): + @wraps(card_identity) + def test_for_card (*args, card_ID, **kwargs): + if not card_ID.isnumeric(): + return ({"message":f"Card {card_ID} does not exist.",}), 404 + + card_check = Card.query.get(card_ID) + if card_check: + return card_identity (*args, card_ID, **kwargs) + else: + return ({"message":f"Card {card_ID} does not exist",}), 404 + return test_for_card + + +#CREATE ONE BOARD +@boards_bp.route("", methods=["POST"]) +def create_board(): + request_body = request.get_json() + + if (("title" not in request_body.keys()) or + ("owner" not in request_body.keys())): + return jsonify("Board not created. Must supply title and owner."), 404 + + new_board = Board( + title = request_body["title"], + owner = request_body["owner"] + ) + + db.session.add(new_board) + db.session.commit() + + return jsonify({ + "title" : new_board.title, + "owner" : new_board.owner, + "id" : new_board.board_id + }), 201 + + +#CREATE ONE CARD ON A SPECIFIC BOARD +@boards_bp.route("/", methods=["POST"]) +@validate_board +def create_card(board_ID): + request_body = request.get_json() + + if ("message" not in request_body.keys()): + return jsonify () + + new_card= Card( + message = request_body["message"], + likes_count = 0, + board_id = board_ID + ) + url= 'https://slack.com/api/chat.postMessage' + header_values = {'AUTHORIZATION': os.environ.get("AUTHORIZATION")} + slack_values = {"text" : f"Something inspirational posted on board {new_card.board_id}", + "channel" : "winspo-board" + } + requests.post(url, headers=header_values, params=slack_values) + db.session.add(new_card) + db.session.commit() + + return jsonify({"message": new_card.message, + "board_id": new_card.board_id, + "likes_count":new_card.likes_count, + "card_id":new_card.card_id}), 201 + +#GET ALL CARDS FOR SPECIFIC BOARD BY ID +@boards_bp.route("//cards", methods=["GET"]) +@validate_board +def get_all_cards_from_a_board(board_id): + all_cards = Card.query.filter_by(board_id=board_id) + output_dicts_list = [] + for card in all_cards: + output_dicts_list.append({ + "card_id":card.card_id, + "message":card.message, + "board_id":card.board_id, + "likes_count":card.likes_count + #we want to return the likes_count in this dictionary here so that each card like count renders correctly + }) + return jsonify(output_dicts_list), 201 + + +#GET ONE CARD FOR SPECIFIC BOARD BY ID +@boards_bp.route("//cards/", methods=["GET"]) +@validate_board +@validate_card +def get_one_card_from_a_board(board_ID, card_ID): + #should we actually fetch this by board_ID and card_ID? Does it matter? + card = Card.query.get(card_ID) + + return jsonify({ + "card_id":card.card_id, + "message":card.message, + "board_id":card.board_id, + "likes_count":card.likes_count + }), 201 + +# GET ALL BOARDS +@boards_bp.route("", methods=["GET"]) +def get_all_boards(): + boards = Board.query.all() + + response_body = [] + + for board in boards: + response_body.append( + {"id" : board.board_id, + "title" : board.title, + "owner" : board.owner} + ) + + return jsonify(response_body), 200 + +# GET ONE BOARD BY SUPPLYING board_id +@boards_bp.route("/", methods=["GET"]) +@validate_board +def get_one_board(board_ID): + board = Board.query.get(board_ID) + + if board is None: + return jsonify(None),404 + + response_body = { + "id" : board.board_id, + "title" : board.title, + "owner" : board.owner + } + + return jsonify(response_body), 200 + +@boards_bp.route("/", methods=["DELETE"]) +@validate_board +def delete_one_whole_entire_board(board_ID): + board = Board.query.get(board_ID) + db.session.delete(board) + db.session.commit() + + #not sure if we need this? + all_cards = Card.query.filter_by(board_id=board_ID) + for card in all_cards: + db.session.delete(card) + db.session.commit() + + response = {"message": f"Board {board.title} was deleted."} + return response, 200 + +@boards_bp.route("//cards/", methods=["DELETE"]) +@validate_board +def delete_one_teeny_tiny_card(board_ID, card_ID): + card = Card.query.get(card_ID) + if card: + db.session.delete(card) + db.session.commit() + response = {"message": f"Card {card.card_id} was deleted."} + return response, 200 + return {"message": f"Card id {card_ID} isn't real."},400 + + +#PATCH REQUEST TO INCREMENT CARD.LIKES_COUNT BY 1 +@boards_bp.route("//cards/", methods=["PATCH"]) +@validate_board +def add_one_to_likes_count(board_ID, card_ID): + card = Card.query.get(card_ID) + if card: + card.likes_count = int(card.likes_count) + 1 + db.session.commit() + response = {"message": f"Card {card.card_id} likes count was updated to {card.likes_count}"} + return response, 200 + return {"message": f"Card id {card_ID} isn't real."},400 \ No newline at end of file diff --git a/app/card_routes.py b/app/card_routes.py new file mode 100644 index 00000000..a3e7e9ba --- /dev/null +++ b/app/card_routes.py @@ -0,0 +1,39 @@ +from flask import Blueprint, request, jsonify, make_response +from sqlalchemy.orm.query import Query +from app import db +from app.models.board import Board +from app.models.card import Card +import requests +from dotenv import load_dotenv +load_dotenv() + +cards_bp=Blueprint("card", __name__, url_prefix="/card") + +#I moved the create card blueprint to board_routes +#Because the endpoint for this should start with board +#like this: +#/board/board_id/card + +#I think we might want to move everything over to board, too. +#maybe we actually only want one file for routes? +#because all of the routes for card are written as add-ons to the board endpoint(s) + +#@cards_bp.route("", methods=["GET"]) +# Update enpoint with board id at a later date +# "/" +""" +def get_all_cards(): + all_cards = Card.query.all() + output_dicts_list = [] + for card in all_cards: + output_dicts_list.append( + {"id":card.card_id, + "message":card.message + }) + + return jsonify(output_dicts_list), 201 +""" + + + + diff --git a/app/models/board.py b/app/models/board.py index 147eb748..5babbf20 100644 --- a/app/models/board.py +++ b/app/models/board.py @@ -1 +1,10 @@ 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) + + card = db.relationship("Card", backref="board", passive_deletes=True) + + \ No newline at end of file diff --git a/app/models/card.py b/app/models/card.py index 147eb748..b93c1456 100644 --- a/app/models/card.py +++ b/app/models/card.py @@ -1 +1,8 @@ +from sqlalchemy.orm import backref from app import db + +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_id = db.Column(db.Integer, db.ForeignKey('board.board_id', ondelete='cascade')) \ 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/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/4b8fa1f327d5_.py b/migrations/versions/4b8fa1f327d5_.py new file mode 100644 index 00000000..2fa2fb85 --- /dev/null +++ b/migrations/versions/4b8fa1f327d5_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: 4b8fa1f327d5 +Revises: +Create Date: 2021-12-21 10:21:46.458774 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '4b8fa1f327d5' +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.String(), nullable=True), + sa.Column('board_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['board_id'], ['board.board_id'], ondelete='cascade'), + 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 ###