diff --git a/app/__init__.py b/app/__init__.py index 1c821436..004c2149 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -17,15 +17,23 @@ def create_app(): app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get( "SQLALCHEMY_DATABASE_URI") + # might not need? + app.config['CORS_HEADERS'] = 'Content-Type' # 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 app.routes.board_routes import boards_bp + app.register_blueprint(boards_bp) + + from app.routes.card_routes import cards_bp + app.register_blueprint(cards_bp) CORS(app) return app diff --git a/app/models/board.py b/app/models/board.py index 147eb748..f2b4de51 100644 --- a/app/models/board.py +++ b/app/models/board.py @@ -1 +1,35 @@ from app import db +from flask import make_response, abort, request +from .card import Card + +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", lazy = True) + + def to_json(self): + + return {"boardId" : self.board_id, + "title" : self.title, + "owner": self.owner} + + def link_card_to_board(self, request_body): + + new_card = Card.from_json(request_body) + if new_card not in self.cards: + self.cards.append(new_card) + + return new_card + + @classmethod + def from_json(cls, request_body): + + if "title" not in request_body or "owner" not in request_body: + abort(make_response({"details": "Invalid data"}, 400)) + + new_board = cls( + title=request_body["title"], + owner=request_body["owner"]) + + return new_board diff --git a/app/models/card.py b/app/models/card.py index 147eb748..72210b60 100644 --- a/app/models/card.py +++ b/app/models/card.py @@ -1 +1,37 @@ from app import db +from flask import make_response, abort + +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=True) + board = db.relationship("Board", back_populates="cards") + + + def to_json(self): + return {"cardId" : self.card_id, + "message" : self.message, + "likesCount": self.likes_count, + "boardId": self.board_id} + + def update_card(self, update_body): + + # self.card_id = update_body["cardId"] + # self.message = update_body["message"] + self.likes_count = update_body["likesCount"] + # self.board_id = update_body["boardId"] + + @classmethod + def from_json(cls, request_body): + + if len(request_body["message"]) > 40: + abort(make_response({"details": "Message is too long"}, 400)) + + new_card = cls( + message=request_body["message"], + likes_count = request_body["likesCount"], + board_id = request_body["boardId"], + ) + + 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..96106c39 --- /dev/null +++ b/app/routes/board_routes.py @@ -0,0 +1,76 @@ +from flask import Blueprint, request, jsonify, make_response +from app import db +from app.models.board import Board +from .helpers import validate_model_instance, send_slack_new_card_message +from app.models.card import Card + +# example_bp = Blueprint('example_bp', __name__) + +boards_bp = Blueprint("boards", __name__, url_prefix="/boards") + +#CREATE one board +@boards_bp.route("", methods=["POST"]) +def create_board(): + request_body = request.get_json() + + new_board = Board.from_json(request_body) + + db.session.add(new_board) + db.session.commit() + + return jsonify(new_board.to_json()), 201 + +#GET all boards +@boards_bp.route("", methods=["GET"]) +def read_board(): + Boards = Board.query.all() + + boards_response = [board.to_json() for board in Boards] + + return jsonify(boards_response), 200 + +#GET one board +@boards_bp.route("/", methods=["GET"]) +def get_one_board(board_id): + board = validate_model_instance(Board, board_id, "board") + return jsonify(board.to_json()), 200 + +#DELETE A BOARD-> optional +@boards_bp.route("/", methods=["DELETE"]) +def delete_board(board_id): + board = validate_model_instance(Board, board_id, "board") + db.session.delete(board) + db.session.commit() + + return jsonify({"details":f'Board {board_id} "{board.title}" successfully deleted'} ), 200 + +#POST /boards//cards +#we expect-> HTTP request body ({message, likesCount, boardId}) +@boards_bp.route("//cards", methods=["POST"]) +def add_card_to_board(board_id): + board = validate_model_instance(Board, board_id, "board") + + request_body = request.get_json() + + new_card = board.link_card_to_board(request_body) + + db.session.add(new_card) + db.session.commit() + send_slack_new_card_message(new_card) + #change return? + return jsonify(new_card.to_json()), 200 + +#GET /boards//cards +@boards_bp.route("//cards", methods=["GET"]) +def read_cards_of_board(board_id): + + board = validate_model_instance(Board, board_id, "board") + board_cards = [card.to_json() for card in board.cards] + + return jsonify({"boardId": board.board_id, + "title": board.title, + "owner": board.owner, + "cards": board_cards}), 200 + + + diff --git a/app/routes/card_routes.py b/app/routes/card_routes.py new file mode 100644 index 00000000..e9d9f8af --- /dev/null +++ b/app/routes/card_routes.py @@ -0,0 +1,32 @@ +from flask import Blueprint, request, jsonify, make_response +from app import db +from app.models.card import Card +from .helpers import validate_model_instance + +cards_bp = Blueprint("cards", __name__, url_prefix="/cards") + + +# DELETE /cards/ +@cards_bp.route("/", methods=["DELETE"]) +def delete_card(card_id): + card = validate_model_instance(Card, card_id, "card") + db.session.delete(card) + db.session.commit() + + return jsonify({"details":f'Card {card_id} "{card.message}" successfully deleted'} ), 200 + +# PUT /cards//like +# request body from front-end-> whole object of attributes +# ? change syntax +@cards_bp.route("//like", methods=["PUT"]) +def update_one_card(card_id): + card = validate_model_instance(Card, card_id, "card") + request_body = request.get_json() + + card.update_card(request_body) + + db.session.commit() + + return jsonify(card.to_json()), 200 + + diff --git a/app/routes/helpers.py b/app/routes/helpers.py new file mode 100644 index 00000000..81d6244e --- /dev/null +++ b/app/routes/helpers.py @@ -0,0 +1,27 @@ +from flask import abort, make_response +import requests +import os + +def validate_model_instance(cls, model_id, class_name_string): + try: + model_id = int(model_id) + except: + abort(make_response({"message":f"{class_name_string} {model_id} invalid"}, 400)) + + model = cls.query.get(model_id) + if not model: + return abort(make_response({"message":f"{class_name_string} {model_id} not found"}, 404)) + + return model + +def send_slack_new_card_message(new_card): + + PATH = "https://slack.com/api/chat.postMessage" + + BEARER_TOKEN = os.environ.get( + "AUTH_TOKEN_SLACK") + + query_params = {"channel" : "inspir-adies", "text": f"Someone just created the card with the message '{new_card.message}'" } + headers = {"authorization" : BEARER_TOKEN} + + response_body = requests.get(PATH, params=query_params, headers=headers) \ 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/577622fb4428_added_foreign_key_to_card_model.py b/migrations/versions/577622fb4428_added_foreign_key_to_card_model.py new file mode 100644 index 00000000..e0c1db93 --- /dev/null +++ b/migrations/versions/577622fb4428_added_foreign_key_to_card_model.py @@ -0,0 +1,30 @@ +"""Added foreign key to Card model + +Revision ID: 577622fb4428 +Revises: d01573c720c0 +Create Date: 2022-06-27 15:54:32.755068 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '577622fb4428' +down_revision = 'd01573c720c0' +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/migrations/versions/90d860cbf78d_.py b/migrations/versions/90d860cbf78d_.py new file mode 100644 index 00000000..9088fe95 --- /dev/null +++ b/migrations/versions/90d860cbf78d_.py @@ -0,0 +1,33 @@ +"""empty message + +Revision ID: 90d860cbf78d +Revises: +Create Date: 2022-06-27 15:49:27.832755 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '90d860cbf78d' +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') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('board') + # ### end Alembic commands ### diff --git a/migrations/versions/d01573c720c0_created_card_model.py b/migrations/versions/d01573c720c0_created_card_model.py new file mode 100644 index 00000000..e2032bdd --- /dev/null +++ b/migrations/versions/d01573c720c0_created_card_model.py @@ -0,0 +1,33 @@ +"""Created Card model + +Revision ID: d01573c720c0 +Revises: 90d860cbf78d +Create Date: 2022-06-27 15:51:23.424419 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd01573c720c0' +down_revision = '90d860cbf78d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + 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.PrimaryKeyConstraint('card_id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('card') + # ### end Alembic commands ###