diff --git a/app/__init__.py b/app/__init__.py index 4ab3975b8..672366df7 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -32,5 +32,11 @@ def create_app(test_config=None): migrate.init_app(app, db) #Register Blueprints Here + from app.routes.customer_routes import customers_bp + from app.routes.video_routes import video_bp + from app.routes.rental_routes import rentals_bp + app.register_blueprint(customers_bp) + app.register_blueprint(video_bp) + app.register_blueprint(rentals_bp) return app \ No newline at end of file diff --git a/app/models/customer.py b/app/models/customer.py index e3aece97b..7232dd317 100644 --- a/app/models/customer.py +++ b/app/models/customer.py @@ -1,4 +1,19 @@ +from flask import current_app from app import db class Customer(db.Model): - id = db.Column(db.Integer, primary_key=True) \ No newline at end of file + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + name = db.Column(db.String) + postal_code = db.Column(db.String) + phone = db.Column(db.String) + register_at = db.Column(db.DateTime) + + videos = db.relationship("Video", secondary="rentals", backref="checked_out") + + def to_json(self): + return { + "id": self.id, + "name": self.name, + "postal_code": self.postal_code, + "phone": self.phone + } \ No newline at end of file diff --git a/app/models/helper.py b/app/models/helper.py new file mode 100644 index 000000000..9f07d937a --- /dev/null +++ b/app/models/helper.py @@ -0,0 +1,53 @@ +from flask import request +from app.models.video import Video +from app.models.rental import Rental +from app.models.customer import Customer + +def invalid_cust_data(request_body): + if "name" not in request_body: + valid = {"details": "Request body must include name."} + elif "phone" not in request_body: + valid = {"details": "Request body must include phone."} + elif "postal_code" not in request_body: + valid = {"details": "Request body must include postal_code."} + else: + valid = False + return valid + +def invalid_customer(customer_id): + invalid = False + if not customer_id.isnumeric(): + invalid = {"message": f"Invalid customer id"}, 400 + elif customer_id.isnumeric(): + if Customer.query.get(customer_id) is None: + invalid = {"message": f"Customer {customer_id} was not found"}, 404 + return invalid + +def invalid_video_data(request_body): + if "title" not in request_body: + valid = {"details": "Request body must include title."} + elif "release_date" not in request_body: + valid = {"details": "Request body must include release_date."} + elif "total_inventory" not in request_body: + valid = {"details": "Request body must include total_inventory."} + else: + valid = False + return valid + +def invalid_video(video_id): + invalid = False + if not video_id.isnumeric(): + invalid = {"message": f"Invalid video id"}, 400 + elif video_id.isnumeric(): + if Video.query.get(video_id) is None: + invalid = {"message": f"Video {video_id} was not found"}, 404 + return invalid + +def invalid_rental_data(request_body): + if "customer_id" not in request_body: + invalid = {"details": "Request body must include customer_id."} + elif "video_id" not in request_body: + invalid = {"details": "Request body must include video_id."} + else: + invalid = False + return invalid \ No newline at end of file diff --git a/app/models/rental.py b/app/models/rental.py index 11009e593..9c91293ca 100644 --- a/app/models/rental.py +++ b/app/models/rental.py @@ -1,4 +1,24 @@ +from sqlalchemy.orm import backref from app import db +from app.models.video import Video +from app.models.customer import Customer class Rental(db.Model): - id = db.Column(db.Integer, primary_key=True) \ No newline at end of file + __tablename__ = "rentals" + id = db.Column(db.Integer, primary_key=True, autoincrement = True, nullable=False) + customer_id = db.Column(db.Integer, db.ForeignKey('customer.id'), primary_key=True, nullable=False) + video_id = db.Column(db.Integer, db.ForeignKey('video.id'), primary_key=True, nullable=False) + due_date = db.Column(db.DateTime) + checked_out = db.Column(db.Boolean, default = False) + + def rental_by_title(self): + video = Video.query.get(self.video_id) + return{ + "title": video.title + } + + def cust_by_name(self): + customer = Customer.query.get(self.customer_id) + return { + "name": customer.name + } \ No newline at end of file diff --git a/app/models/video.py b/app/models/video.py index 9893a6ef9..d9daceba3 100644 --- a/app/models/video.py +++ b/app/models/video.py @@ -1,4 +1,17 @@ from app import db class Video(db.Model): - id = db.Column(db.Integer, primary_key=True) \ No newline at end of file + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + title = db.Column(db.String, nullable=False) + release_date = db.Column(db.DateTime, nullable=False) + total_inventory = db.Column(db.Integer, nullable=False) + + # customers = db.relationship("Customer", secondary="rentals") + + def video_dict(self): + return { + "id": self.id, + "title": self.title, + "release_date": self.release_date, + "total_inventory": self.total_inventory + } diff --git a/app/routes.py b/app/routes.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/routes/customer_routes.py b/app/routes/customer_routes.py new file mode 100644 index 000000000..6e97e2733 --- /dev/null +++ b/app/routes/customer_routes.py @@ -0,0 +1,74 @@ +from app import db +from flask import Blueprint, jsonify, request +from app.models.customer import Customer +from app.models.rental import Rental +from app.models.video import Video +from app.models.helper import invalid_cust_data, invalid_customer + +customers_bp = Blueprint("customers", __name__, url_prefix="/customers") + +@customers_bp.route("", methods=["POST"]) +def post_customer(): + request_body = request.get_json() + invalid_response = invalid_cust_data(request_body) + if not invalid_response: + new_customer = Customer( + name=request_body["name"], + postal_code=request_body["postal_code"], + phone=request_body["phone"] + ) + db.session.add(new_customer) + db.session.commit() + return jsonify(new_customer.to_json()), 201 + return jsonify(invalid_response), 400 + +@customers_bp.route("", methods=["GET"]) +def get_customers(): + customers = Customer.query.all() + customer_list = [customer.to_json() for customer in customers] + return jsonify(customer_list), 200 + + +@customers_bp.route("/", methods=["GET"]) +def get_one_customer(customer_id): + invalid_response = invalid_customer(customer_id) + if invalid_response: + return invalid_response + one_customer = Customer.query.get(customer_id) + return jsonify(one_customer.to_json()), 200 + +@customers_bp.route("//rentals", methods=["GET"]) +def get_cust_rental(customer_id): + if Customer.query.get(customer_id) is None: + return jsonify({"message": f"Customer {customer_id} was not found"}), 404 + all_rentals = Rental.query.filter_by(customer_id = customer_id) # get rental by cust_id + response = [] + for rental in all_rentals: # to access formatted rental information + response.append(rental.rental_by_title()) + return jsonify(response), 200 + +@customers_bp.route("/", methods=["PUT"]) +def update_customer(customer_id): + one_customer = Customer.query.get(customer_id) + invalid_cust = invalid_customer(customer_id) + if invalid_cust: + return invalid_cust + request_body = request.get_json() + invalid_response = invalid_cust_data(request_body) + if invalid_response: + return jsonify(invalid_response), 400 + one_customer.name = request_body["name"] + one_customer.phone = request_body["phone"] + one_customer.postal_code = request_body["postal_code"] + db.session.commit() + return jsonify(one_customer.to_json()), 200 + +@customers_bp.route("/", methods=["DELETE"]) +def delete_customer(customer_id): + one_customer = Customer.query.get(customer_id) + invalid_cust = invalid_customer(customer_id) + if invalid_cust: + return invalid_cust + db.session.delete(one_customer) + db.session.commit() + return jsonify(one_customer.to_json()), 200 diff --git a/app/routes/rental_routes.py b/app/routes/rental_routes.py new file mode 100644 index 000000000..b621833fa --- /dev/null +++ b/app/routes/rental_routes.py @@ -0,0 +1,79 @@ +from app import db +from flask import Blueprint, jsonify, request +from app.models.rental import Rental +from app.models.customer import Customer +from app.models.video import Video +from datetime import timedelta, datetime +from app.models.helper import invalid_rental_data + +rentals_bp = Blueprint("rentals", __name__, url_prefix="/rentals") + +@rentals_bp.route("/check-out", methods=["POST"]) +def post_rental(): + request_body = request.get_json() + invalid_request = invalid_rental_data(request_body) + if invalid_request: + return jsonify(invalid_request), 400 + + one_video = Video.query.get(request_body["video_id"]) + one_customer = Customer.query.get(request_body["customer_id"]) + if one_video is None: + return jsonify({"Bad Request": f"Video not found."}), 404 + elif one_customer is None: + return jsonify({"Bad Request": f"Customer not found."}), 404 + video_current_checked_out = Rental.query.filter_by(checked_out=True, video_id=one_video.id).count() + available_inventory = one_video.total_inventory - video_current_checked_out + if available_inventory == 0: + return jsonify({"message": "Could not perform checkout"}), 400 + new_rental = Rental( + customer_id=one_customer.id, + video_id = one_video.id, + due_date = (datetime.now(tz=None) + timedelta(days=7)), + checked_out = True + ) + db.session.add(new_rental) + db.session.commit() + cust_current_checked_out = Rental.query.filter_by(customer_id=one_customer.id, checked_out=True).count() + available_inventory -=1 + response_body = { + "customer_id": one_customer.id, + "video_id": one_video.id, + "due_date": new_rental.due_date, + "videos_checked_out_count": cust_current_checked_out, + "available_inventory": available_inventory + } + return jsonify(response_body), 200 + +@rentals_bp.route("/check-in", methods=["POST"]) +def post_rental_return(): + request_body = request.get_json() + invalid_request = invalid_rental_data(request_body) + if invalid_request: + return jsonify(invalid_request), 400 + + one_video = Video.query.get(request_body["video_id"]) + one_customer = Customer.query.get(request_body["customer_id"]) + if one_video is None: + return jsonify({"Bad Request": f"Video not found."}), 404 + elif one_customer is None: + return jsonify({"Bad Request": f"Customer not found."}), 404 + video_current_checked_in = Rental.query.filter_by(checked_out=True).count() + video_checked_out = Rental.query.filter_by(checked_out=False).count() + if video_current_checked_in == 0: + return jsonify({"message": f"No outstanding rentals for customer {one_customer.id} and video {one_video.id}"}), 400 + returned_rental = Rental( + customer_id = one_customer.id, + video_id = one_video.id, + checked_out = False + ) + db.session.add(returned_rental) + db.session.commit() + available_inventory = one_video.total_inventory - video_checked_out + response_body = { + "customer_id": one_customer.id, + "video_id": one_video.id, + "videos_checked_out_count": video_checked_out, + "available_inventory": available_inventory + } + return jsonify(response_body), 200 + diff --git a/app/routes/video_routes.py b/app/routes/video_routes.py new file mode 100644 index 000000000..e7567929e --- /dev/null +++ b/app/routes/video_routes.py @@ -0,0 +1,86 @@ +from app import db +from flask import Blueprint, jsonify, request +from app.models.helper import invalid_video, invalid_video_data +from app.models.video import Video +from app.models.rental import Rental + +video_bp = Blueprint("video_bp", __name__, url_prefix="/videos") + + +@video_bp.route("", methods=["GET"]) +def get_videos(): + videos = Video.query.all() + video_list = [video.video_dict() for video in videos] + return jsonify(video_list), 200 + + +@video_bp.route("/", methods=["GET"]) +def get_one_video(video_id): + invalid_response = invalid_video(video_id) + if invalid_response: + return invalid_response + + one_video = Video.query.get(video_id) + return jsonify(one_video.video_dict()), 200 + + +@video_bp.route("//rentals", methods=["GET"]) +def get_vid_rental(video_id): + if Video.query.get(video_id) is None: + return jsonify({"message": f"Video {video_id} was not found"}), 404 + all_videos = Rental.query.filter_by(id=video_id) # get rental by cust_id + response = [] + for video in all_videos: # to access formatted rental information + response.append(video.cust_by_name()) + return jsonify(response), 200 + + +@video_bp.route("", methods=["POST"]) +def post_video(): + request_body = request.get_json() + invalid_response = invalid_video_data(request_body) + if not invalid_response: + new_video = Video( + title=request_body["title"], + release_date=request_body["release_date"], + total_inventory=request_body["total_inventory"] + ) + db.session.add(new_video) + db.session.commit() + + return jsonify(new_video.video_dict()), 201 + + return jsonify(invalid_response), 400 + + +@video_bp.route("/", methods=["PUT"]) +def update_video(video_id): + one_video = Video.query.get(video_id) + invalid_vid = invalid_video(video_id) + if invalid_vid: + return invalid_vid + + request_body = request.get_json() + invalid_response = invalid_video_data(request_body) + + if invalid_response: + return jsonify(invalid_response), 400 + + one_video.title = request_body["title"] + one_video.release_date = request_body["release_date"] + one_video.total_inventory = request_body["total_inventory"] + + db.session.commit() + return jsonify(one_video.video_dict()), 200 + + +@video_bp.route("/", methods=["DELETE"]) +def delete_video(video_id): + one_video = Video.query.get(video_id) + invalid_vid = invalid_video(video_id) + if invalid_vid: + return invalid_vid + + db.session.delete(one_video) + db.session.commit() + return jsonify(one_video.video_dict()), 200 diff --git a/migrations/README b/migrations/README new file mode 100644 index 000000000..98e4f9c44 --- /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 000000000..f8ed4801f --- /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 000000000..8b3fb3353 --- /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 000000000..2c0156303 --- /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/104391cc0a9a_created_rental_model.py b/migrations/versions/104391cc0a9a_created_rental_model.py new file mode 100644 index 000000000..f5ff67227 --- /dev/null +++ b/migrations/versions/104391cc0a9a_created_rental_model.py @@ -0,0 +1,40 @@ +"""created rental model + +Revision ID: 104391cc0a9a +Revises: dccfc2070c6e +Create Date: 2021-11-10 18:11:11.128671 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '104391cc0a9a' +down_revision = 'dccfc2070c6e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('rental', sa.Column('customer_id', sa.Integer(), nullable=True)) + op.add_column('rental', sa.Column('video_id', sa.Integer(), nullable=True)) + op.create_foreign_key(None, 'rental', 'customer', ['customer_id'], ['id']) + op.create_foreign_key(None, 'rental', 'video', ['video_id'], ['id']) + op.add_column('video', sa.Column('release_date', sa.DateTime(), nullable=False)) + op.add_column('video', sa.Column('title', sa.String(), nullable=False)) + op.add_column('video', sa.Column('total_inventory', sa.Integer(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('video', 'total_inventory') + op.drop_column('video', 'title') + op.drop_column('video', 'release_date') + op.drop_constraint(None, 'rental', type_='foreignkey') + op.drop_constraint(None, 'rental', type_='foreignkey') + op.drop_column('rental', 'video_id') + op.drop_column('rental', 'customer_id') + # ### end Alembic commands ### diff --git a/migrations/versions/b34f942ac597_fixed_rental_model.py b/migrations/versions/b34f942ac597_fixed_rental_model.py new file mode 100644 index 000000000..a1f103cbb --- /dev/null +++ b/migrations/versions/b34f942ac597_fixed_rental_model.py @@ -0,0 +1,43 @@ +"""fixed rental model + +Revision ID: b34f942ac597 +Revises: 104391cc0a9a +Create Date: 2021-11-10 18:17:06.382974 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b34f942ac597' +down_revision = '104391cc0a9a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('rentals', + sa.Column('customer_id', sa.Integer(), nullable=False), + sa.Column('video_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['customer_id'], ['customer.id'], ), + sa.ForeignKeyConstraint(['video_id'], ['video.id'], ), + sa.PrimaryKeyConstraint('customer_id', 'video_id') + ) + op.drop_table('rental') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('rental', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('customer_id', sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column('video_id', sa.INTEGER(), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(['customer_id'], ['customer.id'], name='rental_customer_id_fkey'), + sa.ForeignKeyConstraint(['video_id'], ['video.id'], name='rental_video_id_fkey'), + sa.PrimaryKeyConstraint('id', name='rental_pkey') + ) + op.drop_table('rentals') + # ### end Alembic commands ### diff --git a/migrations/versions/d2a4f37a624f_updated_rental_model_to_add_to_json_.py b/migrations/versions/d2a4f37a624f_updated_rental_model_to_add_to_json_.py new file mode 100644 index 000000000..fec49cf54 --- /dev/null +++ b/migrations/versions/d2a4f37a624f_updated_rental_model_to_add_to_json_.py @@ -0,0 +1,36 @@ +"""updated rental model to add to_json function + +Revision ID: d2a4f37a624f +Revises: b34f942ac597 +Create Date: 2021-11-12 12:13:19.595706 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd2a4f37a624f' +down_revision = 'b34f942ac597' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('rentals', sa.Column('available_inventory', sa.Integer(), nullable=True)) + op.add_column('rentals', sa.Column('checked_out', sa.Boolean(), nullable=True)) + op.add_column('rentals', sa.Column('due_date', sa.DateTime(), nullable=True)) + op.add_column('rentals', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False)) + op.add_column('rentals', sa.Column('videos_checked_out', sa.Integer(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('rentals', 'videos_checked_out') + op.drop_column('rentals', 'id') + op.drop_column('rentals', 'due_date') + op.drop_column('rentals', 'checked_out') + op.drop_column('rentals', 'available_inventory') + # ### end Alembic commands ### diff --git a/migrations/versions/dccfc2070c6e_created_customer_model.py b/migrations/versions/dccfc2070c6e_created_customer_model.py new file mode 100644 index 000000000..b2e478803 --- /dev/null +++ b/migrations/versions/dccfc2070c6e_created_customer_model.py @@ -0,0 +1,45 @@ +"""created customer model + +Revision ID: dccfc2070c6e +Revises: +Create Date: 2021-11-05 17:41:36.366274 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'dccfc2070c6e' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('customer', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(), nullable=True), + sa.Column('postal_code', sa.String(), nullable=True), + sa.Column('phone', sa.String(), nullable=True), + sa.Column('register_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('rental', + sa.Column('id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('video', + sa.Column('id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('video') + op.drop_table('rental') + op.drop_table('customer') + # ### end Alembic commands ###