diff --git a/app/__init__.py b/app/__init__.py index 4ab3975b8..836723c29 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -21,7 +21,6 @@ def create_app(test_config=None): app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get( "SQLALCHEMY_TEST_DATABASE_URI") - # import models for Alembic Setup from app.models.customer import Customer from app.models.video import Video @@ -32,5 +31,13 @@ def create_app(test_config=None): migrate.init_app(app, db) #Register Blueprints Here + from .routes.customer_routes import customer_bp + app.register_blueprint(customer_bp) + + from .routes.video_routes import video_bp + app.register_blueprint(video_bp) + + from .routes.rental_routes import rental_bp + app.register_blueprint(rental_bp) return app \ No newline at end of file diff --git a/app/models/customer.py b/app/models/customer.py index 54d10b49a..bdec4da20 100644 --- a/app/models/customer.py +++ b/app/models/customer.py @@ -1,4 +1,30 @@ from app import db class Customer(db.Model): - id = db.Column(db.Integer, primary_key=True) + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + name = db.Column(db.String, nullable = False) + registered_at = db.Column(db.DateTime(timezone = True)) + postal_code = db.Column(db.String, nullable = False) + phone = db.Column(db.String, nullable = False) + videos_checked_out_count = db.Column(db.Integer, default=0) + videos_checked_in_count = db.Column(db.Integer, default=0) + videos = db.relationship("Rental") + + def to_dict(self): + customer_as_dict = {} + customer_as_dict["id"] = int(self.id) + customer_as_dict["name"] = self.name + customer_as_dict["postal_code"] = self.postal_code + customer_as_dict["phone"] = self.phone + customer_as_dict["registered_at"] = self.registered_at + customer_as_dict["videos_checked_out_count"] = self.videos_checked_out_count + return customer_as_dict + + @classmethod + def from_dict(cls, customer_data): + new_customer = Customer(name=customer_data["name"], + postal_code=customer_data["postal_code"], + phone=customer_data["phone"], + videos_checked_out_count=customer_data["videos_checked_out_count"] + ) + return new_customer diff --git a/app/models/rental.py b/app/models/rental.py index 11009e593..d4e404bc1 100644 --- a/app/models/rental.py +++ b/app/models/rental.py @@ -1,4 +1,33 @@ from app import db +from datetime import datetime, timedelta class Rental(db.Model): - id = db.Column(db.Integer, primary_key=True) \ No newline at end of file + #__tablename__ = 'rental_table' + id = db.Column(db.Integer, primary_key=True, autoincrement=True, nullable=False) + due_date = db.Column(db.DateTime, default=datetime.now()+timedelta(days=7), nullable=False) + status = db.Column(db.String, default="Checked out", nullable=False) + + customer_id = db.Column(db.Integer, db.ForeignKey('customer.id')) + customers = db.relationship("Customer", back_populates="videos") + + video_id = db.Column(db.Integer, db.ForeignKey('video.id')) + videos = db.relationship("Video", back_populates="customers") + + def to_dict(self): + rental_as_dict = {} + rental_as_dict["id"] = int(self.id) + rental_as_dict["customer_id"] = self.customer_id + rental_as_dict["video_id"] = self.video_id + rental_as_dict["due_date"] = self.due_date + + return rental_as_dict + + + @classmethod + def from_dict(cls, rental_data): + new_video = Rental( + customer_id=rental_data["customer_id"], + video_id=rental_data["video_id"] + ) + + return new_video \ No newline at end of file diff --git a/app/models/video.py b/app/models/video.py index db3bf3aeb..286b0f06b 100644 --- a/app/models/video.py +++ b/app/models/video.py @@ -1,4 +1,34 @@ from app import db +def mydefault(context): + return context.get_current_parameters()['total_inventory'] + + class Video(db.Model): - id = db.Column(db.Integer, primary_key=True) + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + title = db.Column(db.String, nullable=False) + release_date = db.Column(db.Date, nullable=False) + total_inventory = db.Column(db.Integer, default=0, nullable=False) + available_inventory = db.Column(db.Integer, default=mydefault) + + customers = db.relationship("Rental") + + def to_dict(self): + video_as_dict = dict() + video_as_dict["id"] = self.id + video_as_dict["title"] = self.title + video_as_dict["release_date"] = self.release_date + video_as_dict["total_inventory"] = self.total_inventory + video_as_dict["available_inventory"] = self.available_inventory + + return video_as_dict + + @classmethod + def from_dict(cls, video_data): + new_video = Video( + title=video_data["title"], + release_date=video_data["release_date"], + total_inventory = video_data["total_inventory"] + ) + + return new_video \ No newline at end of file diff --git a/app/routes.py b/app/routes/__init__.py similarity index 100% rename from app/routes.py rename to app/routes/__init__.py diff --git a/app/routes/customer_routes.py b/app/routes/customer_routes.py new file mode 100644 index 000000000..6beb7e82a --- /dev/null +++ b/app/routes/customer_routes.py @@ -0,0 +1,189 @@ +from app import db +from app.models.customer import Customer +from app.models.video import Video +from app.models.rental import Rental +from .validate_routes import validate_model, validate_customer_user_input +from flask import Blueprint, jsonify, abort, make_response, request +from datetime import date, timedelta + + +customer_bp = Blueprint("customer_bp", __name__, url_prefix = "/customers") + +# Get all customers info (GET /customers) +# Return JSON list +@customer_bp.route("", methods = ["GET"]) +def get_all_customers_with_query(): + + customer_query = Customer.query + number_of_customers = Customer.query.count() + + page_query = request.args.get("page_num") + count_query = request.args.get("count") + + sort_query = request.args.get("sort") + if sort_query: + if sort_query == "name": + customer_query = customer_query.order_by(Customer.name) + elif sort_query == "registered_at": + customer_query = customer_query.order_by(Customer.registered_at) + elif sort_query == "postal_code": + customer_query = customer_query.order_by(Customer.postal_code) + + if count_query and count_query.isdigit(): + if int(count_query) > 0: + if page_query and page_query.isdigit(): + if int(page_query)>0: + if number_of_customers - (int(page_query)-1)*int(count_query) >= 0: + customer_query = customer_query.paginate(page=int(page_query), per_page=int(count_query)).items + else: + customer_query = customer_query.limit(int(count_query)) + else: + customer_query = customer_query.limit(int(count_query)) + + if not page_query: + customers = customer_query.all() + else: + customers = customer_query + + customers_list = [] + for customer in customers: + customers_list.append(customer.to_dict()) + + return jsonify(customers_list), 200 + +# Get the customer info by id (GET /customers/) +# Return info in JSON format +@customer_bp.route("/",methods=["GET"] ) +def get_one_customer(customer_id): + customer = validate_model(Customer, customer_id) + return customer.to_dict() + +# Register a customer info (POST /customers) +# Return sussess message "Customer {name} successfully registered" +@customer_bp.route("", methods = ["POST"]) +def register_customer(): + customer_info = request.get_json() + check_invalid_dict = validate_customer_user_input(customer_info) + + if check_invalid_dict: + abort(make_response(jsonify(check_invalid_dict), 400)) + + customer_info["videos_checked_out_count"] = 0 + new_customer = Customer.from_dict(customer_info) + + new_customer.registered_at = date.today() + + db.session.add(new_customer) + db.session.commit() + db.session.refresh(new_customer) + + return new_customer.to_dict(), 201 + +# Update the customer info by id (PUT /customer/) +# Return sussess message "Customer {id} info successfully udated" +@customer_bp.route("/",methods=["PUT"] ) +def update_customer(customer_id): + customer = validate_model(Customer, customer_id) + request_body = request.get_json() + + check_invalid_dict = validate_customer_user_input(request_body) + if check_invalid_dict: + abort(make_response(jsonify(check_invalid_dict), 400)) + + customer.name = request_body["name"] + customer.postal_code = request_body["postal_code"] + customer.phone = request_body["phone"] + + db.session.commit() + db.session.refresh(customer) + + return customer.to_dict() + +# Delete the customer info by id (DELETE /customer/) +# Return sussess message "Customer {id} info successfully udated" +@customer_bp.route("/",methods=["DELETE"]) +def delete_customer(customer_id): + customer = validate_model(Customer, customer_id) + db.session.delete(customer) + db.session.commit() + return customer.to_dict() + +# Get customer rentals by customer_id (GET /customers//rentals) +# Return list the videos a customer currently has checked out - successful +# Return 404 if customer_id not exist (validate customer_id) + +@customer_bp.route("//rentals",methods=["GET"]) +def get_video_rentals_for_customer_with_query(customer_id): + customer = validate_model(Customer, customer_id) + + rentals_query = Rental.query.all() + video_query = Video.query + + number_of_videos = Video.query.count() + + page_query = request.args.get("page_num") + count_query = request.args.get("count") + + sort_query = request.args.get("sort") + if sort_query: + if sort_query == "title": + video_query = video_query.order_by(Video.title) + elif sort_query == "release_date": + video_query = video_query.order_by(Video.release_date) + else: + video_query = video_query.order_by(Video.id) + + if count_query and count_query.isdigit(): + if int(count_query) > 0: + if page_query and page_query.isdigit(): + if int(page_query)>0: + if number_of_videos - (int(page_query)-1)*int(count_query) >= 0: + video_query = video_query.paginate(page=int(page_query), per_page=int(count_query)).items + else: + video_query = video_query.limit(int(count_query)) + else: + video_query = video_query.limit(int(count_query)) + + video_list = [] + rental_list = [] + + if not page_query: + videos = video_query.all() + else: + videos = video_query + +# find all rentals of this customer + for rental in rentals_query: + if rental.customer_id == customer.id: + rental_list.append(rental) + + for video in videos: + for rental in rental_list: + if rental.video_id == video.id: + temp_dict = dict() + temp_dict["due_date"] = rental.due_date + temp_dict["title"] = video.title + temp_dict["release_date"] = video.release_date + temp_dict["id"] = video.id + temp_dict["total_inventory"] = video.total_inventory + video_list.append(temp_dict) + + return jsonify(video_list), 200 + + +@customer_bp.route("//history", methods=["GET"]) +def get_customers_rental_history(customer_id): + customer = validate_model(Customer, customer_id) + rentals_query = Rental.query.all() + history = list() + + for rental in rentals_query: + if rental.customer_id == customer.id and rental.status == "Checked in" and customer.videos_checked_in_count > 0: + temp_dict = dict() + video = validate_model(Video, rental.video_id) + temp_dict["title"] = video.title + temp_dict["due_date"] = rental.due_date + temp_dict["checkout_date"] = rental.due_date - timedelta(days=7) + history.append(temp_dict) + + return jsonify(history) diff --git a/app/routes/rental_routes.py b/app/routes/rental_routes.py new file mode 100644 index 000000000..11c0d59b4 --- /dev/null +++ b/app/routes/rental_routes.py @@ -0,0 +1,117 @@ +from app import db +from app.models.customer import Customer +from app.models.video import Video +from app.models.rental import Rental +from .validate_routes import validate_model, validate_rental_out, check_inventory, validate_rental_in, check_outstanding_videos, check_overdue +from flask import Blueprint, jsonify, abort, make_response, request +from datetime import datetime, timedelta + + +rental_bp = Blueprint("rental_bp", __name__, url_prefix="/rentals") + +# GET all rentals +@rental_bp.route("", methods=["GET"]) +def get_rentals(): + rentals_response = [] + rental_query = Rental.query + rentals = rental_query.all() + for rental in rentals: + rentals_response.append(rental.to_dict()) + return jsonify(rentals_response) + +# POST /rentals/check-out +@rental_bp.route("/check-out", methods = ["POST"]) +def create_rental_check_out(): + request_body = request.get_json() + check_rental_out = validate_rental_out(request_body) + + if check_rental_out: + abort(make_response(jsonify(check_rental_out), 400)) + + customer_id = request_body["customer_id"] + video_id = request_body["video_id"] + customer = validate_model(Customer, customer_id) + video = validate_model(Video, video_id) + verify_inventory = check_inventory(video) + + if verify_inventory: + abort(make_response(jsonify(verify_inventory), 400)) + + new_rental = Rental.from_dict(request_body) + customer.videos_checked_out_count += 1 + video.available_inventory -= 1 + + db.session.add(new_rental) + db.session.commit() + db.session.refresh(new_rental) + db.session.refresh(video) + db.session.refresh(customer) + + rental_response = new_rental.to_dict() + rental_response["videos_checked_out_count"] = customer.videos_checked_out_count + rental_response["available_inventory"] = video.available_inventory + + return rental_response, 200 + + +# POST /rentals/check-in +@rental_bp.route("/check-in", methods = ["POST"]) +def create_rental_check_in(): + request_body = request.get_json() + + check_rental_in = validate_rental_in(request_body) + if check_rental_in: + abort(make_response(jsonify(check_rental_in), 400)) + + customer_id = request_body["customer_id"] + video_id = request_body["video_id"] + customer = validate_model(Customer, customer_id) + video = validate_model(Video, video_id) + + check_outstanding = check_outstanding_videos(video, customer) + if check_outstanding: + abort(make_response(jsonify(check_outstanding), 400)) + + return_rental = Rental.query.filter_by(customer_id=customer_id, video_id=video_id, status="Checked out").order_by(Rental.due_date.asc()).first() + return_rental.status = "Checked in" + + customer.videos_checked_in_count += 1 + customer.videos_checked_out_count -= 1 + video.available_inventory += 1 + + db.session.commit() + db.session.refresh(return_rental) + db.session.refresh(video) + db.session.refresh(customer) + + rental_response = return_rental.to_dict() + rental_response["videos_checked_out_count"] = customer.videos_checked_out_count + rental_response["available_inventory"] = video.available_inventory + + return rental_response, 200 + + +# GET /rentals/overdue +@rental_bp.route("/overdue", methods=["GET"]) +def get_all_overdue_customers(): + rentals_query = Rental.query.all() + result_list = list() + + for rental in rentals_query: + if check_overdue(rental): + video = validate_model(Video, rental.video_id) + customer = validate_model(Customer, rental.customer_id) + temp_dict = dict() + temp_dict["video_id"] = video.id + temp_dict["title"] = video.title + temp_dict["customer_id"] = customer.id + temp_dict["name"] = customer.name + temp_dict["postal_code"] = customer.postal_code + temp_dict["checkout_date"] = rental.due_date - timedelta(days=7) + temp_dict["due_date"] = rental.due_date + result_list.append(temp_dict) + + return jsonify(result_list) + + + diff --git a/app/routes/validate_routes.py b/app/routes/validate_routes.py new file mode 100644 index 000000000..150933831 --- /dev/null +++ b/app/routes/validate_routes.py @@ -0,0 +1,120 @@ +from flask import abort, make_response +from app.models.video import Video +from datetime import datetime + + +# Validating the id of the customer: id needs to be int and exists the planet with the id. +# Returning the valid class instance if valid id +def validate_model(cls, model_id): + try: + model_id = int(model_id) + except: + abort(make_response({"message":f"{cls.__name__} {model_id} invalid"}, 400)) + + class_obj = cls.query.get(model_id) + if not class_obj: + abort(make_response({"message":f"{cls.__name__} {model_id} was not found"}, 404)) + + return class_obj + +# Validating the user input to create or update the customer +# Returning the valid JSON if valid input +def validate_customer_user_input(customer_value): + invalid_dict = {} + + if "name" not in customer_value \ + or not isinstance(customer_value["name"], str) \ + or customer_value["name"] == "": + invalid_dict["details"] = "Request body must include name." + + if "postal_code" not in customer_value \ + or not isinstance(customer_value["postal_code"], str) \ + or customer_value["postal_code"] == "": + invalid_dict["details"] = "Request body must include postal_code." + + if "phone" not in customer_value \ + or not isinstance(customer_value["phone"], str) \ + or customer_value["phone"] == "": + invalid_dict["details"] = "Request body must include phone." + + return invalid_dict + +def validate_record(video): + invalid_dict = dict() + + if "title" not in video or not isinstance(video["title"], str) or video["title"] is None: + invalid_dict["details"] = "Request body must include title." + + if "release_date" not in video or not isinstance(video["release_date"], str) \ + or video["release_date"] is None: + invalid_dict["details"] = "Request body must include release_date." + + if "total_inventory" not in video or not isinstance(video["total_inventory"], int) \ + or video["total_inventory"] < 0: + invalid_dict["details"] = "Request body must include total_inventory." + + return invalid_dict + +# Validate post rentals/check_out +# Required Request Body Parameters: customer_id, video_id +# Return 404: Not Found if eather not exist +# Return 400: Bad Request if the video does not have any available +# inventory before check out +def validate_rental_out(rental_out): + invalid_dict = dict() + + if "customer_id" not in rental_out or not isinstance(rental_out["customer_id"], int) or \ + rental_out["customer_id"] is None: + invalid_dict["detail"] = "Request body must include customer_id." + + if "video_id" not in rental_out or not isinstance(rental_out["video_id"], int) or \ + rental_out["video_id"] is None: + invalid_dict["detail"] = "Request body must include video_id." + + return invalid_dict + + +# Validate post rentals/check_in +# Required Request Body Parameters: customer_id, video_id +# Return 404: Not Found if eather not exist +# Return 400: Bad Request if the video and customer do not match +# a current rental +def validate_rental_in(rental_in): + invalid_dict = dict() + + if "customer_id" not in rental_in or not isinstance(rental_in["customer_id"], int) or \ + rental_in["customer_id"] is None: + invalid_dict["detail"] = "Request body must include customer_id." + + if "video_id" not in rental_in or not isinstance(rental_in["video_id"], int) or \ + rental_in["video_id"] is None: + invalid_dict["detail"] = "Request body must include video_id." + + return invalid_dict + +# Add check available_inventory function +# require video_id parameter +# return available numbers of copy +def check_inventory(video): + invalid_dict = dict() + + if video.available_inventory < 1: + invalid_dict["message"] = "Could not perform checkout" + return invalid_dict + + +def check_outstanding_videos(video, customer): + invalid_dict = {"message" : f"No outstanding rentals for customer {customer.id} and video {video.id}"} + available_videos = Video.query.all() + + for available_video in available_videos: + if available_video.id == video.id and video.available_inventory < video.total_inventory: + invalid_dict = {} + break + + return invalid_dict + + +# check a rental record if it is overdue +def check_overdue(rental_record): + return rental_record.due_date < datetime.now() diff --git a/app/routes/video_routes.py b/app/routes/video_routes.py new file mode 100644 index 000000000..c32f99c42 --- /dev/null +++ b/app/routes/video_routes.py @@ -0,0 +1,163 @@ +from app import db +from app.models.video import Video +from app.models.customer import Customer +from app.models.rental import Rental +from .validate_routes import validate_model, validate_record +from flask import Blueprint, jsonify, make_response, request, abort +from datetime import timedelta + +video_bp = Blueprint("video", __name__, url_prefix="/videos") + +# GET /videos +# The API should return an empty array and a status 200 if there are no videos. +@video_bp.route("", methods=["GET"]) +def get_videos(): + videos_response = [] + video_query = Video.query + videos = video_query.all() + for video in videos: + videos_response.append(video.to_dict()) + return jsonify(videos_response) + +# GET /vidoes/ +# The API should return back detailed errors and +# a status 404: Not Found if this video does not exist. +@video_bp.route("/", methods=["GET"]) +def get_video_by_id(id): + video = validate_model(Video, id) + return video.to_dict() + +# POST /videos +# The API should return back detailed errors and +# a status 400: Bad Request if the video does not have any of +# the required fields to be valid. +@video_bp.route("", methods=["POST"]) +def create_video(): + request_body = request.get_json() + check_invalid_record = validate_record(request_body) + + if check_invalid_record: + abort(make_response(jsonify(check_invalid_record), 400)) + request_body["available_inventory"] = int(request_body["total_inventory"]) + new_video = Video.from_dict(request_body) + + db.session.add(new_video) + db.session.commit() + db.session.refresh(new_video) + + return new_video.to_dict(), 201 + +# PUT /videos/ +# The API should return back detailed errors and +# a status 404: Not Found if this video does not exist. +# The API should return back a 400 Bad Request response for +# missing or invalid fields in the request body. +# For example, if total_inventory is missing or is not a number +@video_bp.route("/", methods=["PUT"]) +def update_video(id): + video = validate_model(Video, id) + request_body = request.get_json() + + check_invalid_record = validate_record(request_body) + if check_invalid_record: + return abort(make_response(jsonify(check_invalid_record), 400)) + video.title = request_body["title"] + video.release_date = request_body["release_date"] + video.total_inventory = request_body["total_inventory"] + + db.session.commit() + db.session.refresh(video) + + return video.to_dict() + +# DELETE /videos/ +# The API should return back detailed errors +# and a status 404: Not Found if this video does not exist. +@video_bp.route("/", methods=["DELETE"]) +def delete_video(id): + video = validate_model(Video, id) + db.session.delete(video) + db.session.commit() + + return video.to_dict() + +# GET /videos//rentals +# List the customers who currently have the video checked out +# validate video_id +@video_bp.route("//rentals",methods=["GET"]) +def get_customers_who_rent_the_video_with_query(video_id): + + video = validate_model(Video, video_id) + rentals_query = Rental.query.all() + customer_query = Customer.query + + number_of_customers = Customer.query.count() + + page_query = request.args.get("page_num") + count_query = request.args.get("count") + + sort_query = request.args.get("sort") + if sort_query: + if sort_query == "name": + customer_query = customer_query.order_by(Customer.name) + elif sort_query == "registered_at": + customer_query = customer_query.order_by(Customer.registered_at) + elif sort_query == "postal_code": + customer_query = customer_query.order_by(Customer.postal_code) + + if count_query and count_query.isdigit(): + if int(count_query) > 0: + if page_query and page_query.isdigit(): + if int(page_query)>0: + if number_of_customers - (int(page_query)-1)*int(count_query) >= 0: + customer_query = customer_query.paginate(page=int(page_query), per_page=int(count_query)).items + else: + customer_query = customer_query.limit(int(count_query)) + else: + customer_query = customer_query.limit(int(count_query)) + + customer_list = [] + rental_list = [] + + # find all rentals of this video + for rental in rentals_query: + if rental.video_id == video.id: + rental_list.append(rental) + + if not page_query: + customers = customer_query.all() + else: + customers = customer_query + + # find all customers rented the video + for customer in customers: + for rental in rental_list: + if rental.customer_id == customer.id: + temp_dict = customer.to_dict() + temp_dict["due_date"] = rental.due_date + customer_list.append(temp_dict) + + return jsonify(customer_list) + + +# GET /videos//history +@video_bp.route("//history", methods=["GET"]) +def get_videos_rental_history(video_id): + video = validate_model(Video, video_id) + rentals_query = Rental.query.all() + history = list() + + for rental in rentals_query: + if rental.video_id == video.id: + temp_dict = dict() + customer = validate_model(Customer, rental.customer_id) + temp_dict["customer_id"] = customer.id + temp_dict["name"] = customer.name + temp_dict["postal_code"] = customer.postal_code + temp_dict["checkout_date"] = rental.due_date - timedelta(days=7) + temp_dict["due_date"] = rental.due_date + temp_dict["status"] = rental.status + history.append(temp_dict) + + return jsonify(history) + diff --git a/customers_data.csv b/customers_data.csv new file mode 100644 index 000000000..f734fba1b --- /dev/null +++ b/customers_data.csv @@ -0,0 +1,18 @@ +{ + "name": "Tom", + "postal_code": "98007", + "phone": "(123)456-7890", + "videos_checked_out_count": 0 +} +{ + "name": "Jack", + "postal_code": "86789", + "phone": "(123)100-7890", + "videos_checked_out_count": 0 +} +{ + "name": "Molly", + "postal_code": "56789", + "phone": "(375)456-7890", + "videos_checked_out_count": 0 +} \ No newline at end of file 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/008378ca8aad_updated_rental_model_added_status_.py b/migrations/versions/008378ca8aad_updated_rental_model_added_status_.py new file mode 100644 index 000000000..653dc1977 --- /dev/null +++ b/migrations/versions/008378ca8aad_updated_rental_model_added_status_.py @@ -0,0 +1,57 @@ +"""Updated Rental model. Added status attribute. + +Revision ID: 008378ca8aad +Revises: +Create Date: 2023-01-09 11:30:48.665453 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '008378ca8aad' +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=False), + sa.Column('registered_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('postal_code', sa.String(), nullable=False), + sa.Column('phone', sa.String(), nullable=False), + sa.Column('videos_checked_out_count', sa.Integer(), nullable=True), + sa.Column('videos_checked_in_count', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('video', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('title', sa.String(), nullable=False), + sa.Column('release_date', sa.Date(), nullable=False), + sa.Column('total_inventory', sa.Integer(), nullable=False), + sa.Column('available_inventory', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('rental', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('due_date', sa.DateTime(), nullable=False), + sa.Column('status', sa.String(), nullable=False), + sa.Column('customer_id', sa.Integer(), nullable=True), + sa.Column('video_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['customer_id'], ['customer.id'], ), + sa.ForeignKeyConstraint(['video_id'], ['video.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('rental') + op.drop_table('video') + op.drop_table('customer') + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index 89e00b497..78e99328e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,9 +5,11 @@ blinker==1.4 certifi==2020.12.5 chardet==4.0.0 click==7.1.2 +coverage==7.0.4 Flask==1.1.2 Flask-Migrate==2.6.0 Flask-SQLAlchemy==2.4.4 +gunicorn==20.1.0 idna==2.10 iniconfig==1.1.1 itsdangerous==1.1.0 @@ -21,6 +23,7 @@ py==1.10.0 pycodestyle==2.6.0 pyparsing==2.4.7 pytest==7.1.1 +pytest-cov==4.0.0 python-dateutil==2.8.1 python-dotenv==0.15.0 python-editor==1.0.4 @@ -28,5 +31,6 @@ requests==2.25.1 six==1.15.0 SQLAlchemy==1.3.23 toml==0.10.2 +tomli==2.0.1 urllib3==1.26.5 Werkzeug==1.0.1 diff --git a/tests/conftest.py b/tests/conftest.py index 1b985181c..be5a2dac9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -138,21 +138,21 @@ def one_returned_video(app, client, one_customer, second_video): }) @pytest.fixture -def customer_one_video_three(app, client, one_customer, three_copies_video): +def customer_one_video_three(app, client, one_customer, five_copies_video): response = client.post("/rentals/check-out", json={ "customer_id": 1, "video_id": 1 }) @pytest.fixture -def customer_two_video_three(app, client, second_customer, three_copies_video): +def customer_two_video_three(app, client, second_customer, five_copies_video): response = client.post("/rentals/check-out", json={ "customer_id": 2, "video_id": 1 }) @pytest.fixture -def customer_three_video_three(app, client, third_customer, three_copies_video): +def customer_three_video_three(app, client, third_customer, five_copies_video): response = client.post("/rentals/check-out", json={ "customer_id": 3, "video_id": 1 diff --git a/tests/test_wave_01.py b/tests/test_wave_01.py index 8d32038f2..5a99aa407 100644 --- a/tests/test_wave_01.py +++ b/tests/test_wave_01.py @@ -1,6 +1,7 @@ from operator import contains from app.models.video import Video from app.models.customer import Customer +from app.models.rental import Rental VIDEO_TITLE = "A Brand New Video" VIDEO_ID = 1 diff --git a/tests/test_wave_03.py b/tests/test_wave_03.py index e26ec450c..a127b6677 100644 --- a/tests/test_wave_03.py +++ b/tests/test_wave_03.py @@ -107,7 +107,7 @@ def test_get_customers_sorted_by_postal_code(client, one_customer, second_custom assert response_body[2]["phone"] == CUSTOMER_2_PHONE assert response_body[2]["postal_code"] == CUSTOMER_2_POSTAL_CODE -def test_paginate_per_page_greater_than_num_customers(client, one_customer): +def test_paginate_count_greater_than_num_customers(client, one_customer): # Arrange data = {"count": 5, "page_num": 1} @@ -333,7 +333,7 @@ def test_get_rentals_sorted_by_title(client, one_checked_out_video, second_check def test_get_paginate_n_greater_than_rentals(client, one_checked_out_video): # Arrange - data = {"per_page": 5, "page": 1} + data = {"count": 5,"page_num": 1} # Act response = client.get("/customers/1/rentals", query_string = data) @@ -349,7 +349,7 @@ def test_get_paginate_n_greater_than_rentals(client, one_checked_out_video): def test_get_second_page_of_rentals(client, one_checked_out_video, second_checked_out_video): # Arrange - data = {"per_page": 1, "page": 2} + data = {"count": 1, "page_num": 2} # Act response = client.get("/customers/1/rentals", query_string = data) @@ -365,7 +365,7 @@ def test_get_second_page_of_rentals(client, one_checked_out_video, second_checke def test_get_first_page_of_rentals_grouped_by_two(client, one_checked_out_video, second_checked_out_video, third_checked_out_video): # Arrange - data = {"per_page": 2, "page": 1} + data = {"count": 2, "page_num": 1} # Act response = client.get("/customers/1/rentals", query_string = data) @@ -384,7 +384,7 @@ def test_get_first_page_of_rentals_grouped_by_two(client, one_checked_out_video, def test_get_second_page_of_rentals_grouped_by_two(client, one_checked_out_video, second_checked_out_video, third_checked_out_video): # Arrange - data = {"per_page": 2, "page": 2} + data = {"count": 2, "page_num": 2} # Act response = client.get("/customers/1/rentals", query_string = data) @@ -399,7 +399,7 @@ def test_get_second_page_of_rentals_grouped_by_two(client, one_checked_out_video def test_get_rentals_no_page(client, one_checked_out_video, second_checked_out_video, third_checked_out_video): # Arrange - data = {"per_page": 2} + data = {"count": 2} # Act response = client.get("/customers/1/rentals", query_string = data) @@ -418,7 +418,7 @@ def test_get_rentals_no_page(client, one_checked_out_video, second_checked_out_v def test_get_rentals_sorted_and_paginated(client, one_checked_out_video, second_checked_out_video, third_checked_out_video): # Arrange - data = {"per_page": 2, "sort": "title", "page": 2} + data = {"count": 2, "sort": "title", "page_num": 2} # Act response = client.get("/customers/1/rentals", query_string = data) @@ -452,7 +452,7 @@ def test_get_rentals_invalid_sort_param(client, one_checked_out_video, second_ch def test_get_rentals_invalid_n_param(client, one_checked_out_video, second_checked_out_video): # Arrange - data = {"per_page": "invalid"} + data = {"count": "invalid"} # Act response = client.get("/customers/1/rentals", query_string = data) @@ -471,7 +471,7 @@ def test_get_rentals_invalid_n_param(client, one_checked_out_video, second_check def test_get_rentals_invalid_p_param(client, one_checked_out_video, second_checked_out_video): # Arrange - data = {"page": "invalid"} + data = {"page_num": "invalid"} # Act response = client.get("/customers/1/rentals", query_string = data) @@ -566,9 +566,9 @@ def test_get_renters_sorted_by_postal_code(client, customer_one_video_three, cus assert response_body[2]["postal_code"] == CUSTOMER_2_POSTAL_CODE -def test_paginate_per_page_greater_than_num_renters(client, customer_one_video_three): +def test_paginate_count_greater_than_num_renters(client, customer_one_video_three): # Arrange - data = {"per_page": 5, "page": 1} + data = {"count": 5, "page_num": 1} # Act response = client.get("/videos/1/rentals", query_string = data) @@ -585,7 +585,7 @@ def test_paginate_per_page_greater_than_num_renters(client, customer_one_video_t def test_get_second_page_of_renters(client, customer_one_video_three, customer_two_video_three): # Arrange - data = {"per_page": 1, "page": 2} + data = {"count": 1, "page_num": 2} # Act response = client.get("/videos/1/rentals", query_string = data) @@ -602,7 +602,7 @@ def test_get_second_page_of_renters(client, customer_one_video_three, customer_t def test_get_first_page_of_renters_grouped_by_two(client, customer_one_video_three, customer_two_video_three, customer_three_video_three): # Arrange - data = {"per_page": 2, "page": 1} + data = {"count": 2, "page_num": 1} # Act response = client.get("/videos/1/rentals", query_string = data) @@ -623,7 +623,7 @@ def test_get_first_page_of_renters_grouped_by_two(client, customer_one_video_thr def test_get_second_page_of_renters_grouped_by_two(client, customer_one_video_three, customer_two_video_three, customer_three_video_three): # Arrange - data = {"per_page": 2, "page": 2} + data = {"count": 2, "page_num": 2} # Act response = client.get("/videos/1/rentals", query_string = data) @@ -638,9 +638,9 @@ def test_get_second_page_of_renters_grouped_by_two(client, customer_one_video_th assert response_body[0]["postal_code"] == CUSTOMER_3_POSTAL_CODE -def test_get_customers_no_page(client, customer_one_video_three, customer_two_video_three, customer_three_video_three): +def test_get_renters_no_page(client, customer_one_video_three, customer_two_video_three, customer_three_video_three): # Arrange - data = {"per_page": 2} + data = {"count": 2} # Act response = client.get("/videos/1/rentals", query_string = data) @@ -661,7 +661,7 @@ def test_get_customers_no_page(client, customer_one_video_three, customer_two_vi def test_get_renters_sorted_and_paginated(client, customer_one_video_three, customer_two_video_three, customer_three_video_three): # Arrange - data = {"per_page": 2, "sort": "name", "page": 1} + data = {"count": 2, "sort": "name", "page_num": 1} # Act response = client.get("/videos/1/rentals", query_string = data) @@ -704,7 +704,7 @@ def test_get_renters_invalid_sort_param(client, customer_one_video_three, custom def test_get_renters_invalid_n_param(client, customer_one_video_three, customer_two_video_three): # Arrange - data = {"per_page": "invalid"} + data = {"count": "invalid"} # Act response = client.get("/videos/1/rentals", query_string = data) @@ -725,7 +725,7 @@ def test_get_renters_invalid_n_param(client, customer_one_video_three, customer_ def test_get_renters_invalid_p_param(client, customer_one_video_three, customer_two_video_three): # Arrange - data = {"page": "invalid"} + data = {"page_num": "invalid"} # Act response = client.get("/customers", query_string = data) diff --git a/videos_data.csv b/videos_data.csv new file mode 100644 index 000000000..6ed058cd9 --- /dev/null +++ b/videos_data.csv @@ -0,0 +1,25 @@ +{ + "title": "2001: A Space Odyssey", + "release_date": "1968-11-04", + "total_inventory": 5 +}, +{ + "title": "The Godfather", + "release_date": "1972-05-09", + "total_inventory": 4 +}, +{ + "title": "Citizen Kane", + "release_date": "1941-09-11", + "total_inventory": 6 +}, +{ + "title": "Raiders of the Lost Ark", + "release_date": "1981-12-24", + "total_inventory": 2 +}, +{ + "title": "La Dolce Vita", + "release_date": "1960-10-05", + "total_inventory": 1 +} \ No newline at end of file