Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AC2 - gp2 (Nad, Dalia) #18

Open
wants to merge 52 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
6b0ae4e
wave1: added video and customer models, created the .env file
dnabilali Jan 3, 2023
7066edc
changed postal code type to string
dnabilali Jan 3, 2023
9ccf6b9
updated customer with default value
nadxelleHernandez Jan 3, 2023
24dc9bb
created the videos blueprint
nadxelleHernandez Jan 3, 2023
4a606fa
Created get all videos route
nadxelleHernandez Jan 3, 2023
4d27307
finished get video by id
nadxelleHernandez Jan 3, 2023
c63609b
Made the create video post handler
nadxelleHernandez Jan 4, 2023
2aaef4c
Better json message after post
nadxelleHernandez Jan 4, 2023
4ecc307
make changes to allow test for creating video to passs
nadxelleHernandez Jan 4, 2023
3ab2512
implemented wave 1 for the customer_routes, tests are passing
dnabilali Jan 4, 2023
45e1414
added validation for each of the video attributes needed to create a …
nadxelleHernandez Jan 4, 2023
c2363b5
Merge pull request #1 from dnabilali/customer_routes
nadxelleHernandez Jan 4, 2023
5c5ee9f
changed registered_at in Customer to type DateTime
dnabilali Jan 4, 2023
ddc388b
Finished all the crud for video model with all tests passing
nadxelleHernandez Jan 4, 2023
a7f8099
Merge branch 'main' into nadbranch with Dalias code
nadxelleHernandez Jan 4, 2023
fc4a2ab
Merge pull request #2 from dnabilali/nadbranch
dnabilali Jan 4, 2023
28f07e5
fixed a typo
dnabilali Jan 4, 2023
4191ce4
Merge pull request #3 from dnabilali/customer_routes
dnabilali Jan 4, 2023
cc5f7b8
changed error message for consistency
nadxelleHernandez Jan 4, 2023
0ac5d5b
Merge branch 'main' into nadbranch
nadxelleHernandez Jan 4, 2023
dd641d5
Merge pull request #4 from dnabilali/nadbranch
nadxelleHernandez Jan 4, 2023
8529910
added the rental model and included a videos attr to customer model
dnabilali Jan 5, 2023
3f9a52e
added POST /rentals/check-out, refactored validate_model function in …
dnabilali Jan 5, 2023
1e2d665
new models with relationships
nadxelleHernandez Jan 5, 2023
8d701be
implemented GET /videos/<video_id>/rentals and changed some code in P…
dnabilali Jan 5, 2023
7d71887
changed helper functions to a new folder. Refactored video and custom…
nadxelleHernandez Jan 6, 2023
779e559
added helper functions to rental routes
nadxelleHernandez Jan 6, 2023
55e2681
deleted the check in video. Not working
nadxelleHernandez Jan 6, 2023
ac67ffe
unstashed the check in
nadxelleHernandez Jan 6, 2023
a9345bc
check-in finished. Tests passing
nadxelleHernandez Jan 6, 2023
756c406
Merge pull request #5 from dnabilali/DALIA_wave_2_last_route
nadxelleHernandez Jan 6, 2023
4390699
Merge branch 'main' into nadbranch
nadxelleHernandez Jan 6, 2023
8eea1f7
Finished the last route with all tests passing
nadxelleHernandez Jan 6, 2023
e411bd5
Merge pull request #7 from dnabilali/nadbranch
dnabilali Jan 6, 2023
f67dce1
add query parameters
nadxelleHernandez Jan 6, 2023
b4ab9a4
fixed somethin in what we were validating
nadxelleHernandez Jan 6, 2023
2ff8adc
created sort query parameter to customers endpoing
nadxelleHernandez Jan 7, 2023
7104446
added kyra tests modifications
nadxelleHernandez Jan 7, 2023
7f35bab
customers pagination and tests done!!
nadxelleHernandez Jan 7, 2023
cd25f63
Merge pull request #8 from dnabilali/nadbranch
dnabilali Jan 8, 2023
945e9e6
finished implementing wave 3 query param for route GET customers/<id>…
dnabilali Jan 8, 2023
e26ab26
added query param to GET /videos/<id>/rentals, commented out the test…
dnabilali Jan 8, 2023
46dac1c
Merge pull request #9 from dnabilali/DALIA_wave_3
nadxelleHernandez Jan 9, 2023
e8bbe82
refactored customer routes to use the function to_dict()
nadxelleHernandez Jan 9, 2023
cf2b426
added query parameters to all videos
nadxelleHernandez Jan 9, 2023
3c21e3d
Merge pull request #10 from dnabilali/nadbranch
nadxelleHernandez Jan 9, 2023
3b13760
added extra rental route for overdue
nadxelleHernandez Jan 9, 2023
f752f44
added route for overdue rentals. Pass one test
nadxelleHernandez Jan 9, 2023
26e509e
added gunicorn to requirements.txt
dnabilali Jan 9, 2023
f08512f
Merge remote-tracking branch 'origin/main' into wave-4-nad
nadxelleHernandez Jan 9, 2023
975391f
added query params for get_all_videos
nadxelleHernandez Jan 9, 2023
dd1f614
Merge pull request #11 from dnabilali/wave-4-nad
dnabilali Jan 9, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from dotenv import load_dotenv

db = SQLAlchemy()
migrate = Migrate()
migrate = Migrate(compare_type=True)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious, was this addition to manage issues you had updating models?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we found out that if we add this optional parameter changes made to the data types of attributes for our models will be reflected in the migration. here the documentation for that: https://alembic.sqlalchemy.org/en/latest/autogenerate.html

load_dotenv()

def create_app(test_config=None):
Expand All @@ -32,5 +32,15 @@ def create_app(test_config=None):
migrate.init_app(app, db)

#Register Blueprints Here
from app.routes.customer_routes import customers_bp
app.register_blueprint(customers_bp)

from app.routes.video_routes import videos_bp
app.register_blueprint(videos_bp)

from app.routes.rental_routes import rentals_bp
app.register_blueprint(rentals_bp)

return app

return app
20 changes: 19 additions & 1 deletion app/models/customer.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,22 @@
from app import db
import datetime

class Customer(db.Model):
id = db.Column(db.Integer, primary_key=True)
id = db.Column(db.Integer, primary_key=True, nullable=False, autoincrement=True)
name = db.Column(db.String, nullable=False)
postal_code = db.Column(db.String, nullable=False)
phone = db.Column(db.String, nullable=False)
Comment on lines +7 to +8

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It isn't necessary for this project, but out in the wild we'd likely look at what the max size these strings could possibly be is and give character limits in the definition. The top answers here give some explanation of possible performance impacts: https://stackoverflow.com/questions/1962310/importance-of-varchar-length-in-mysql-table

registered_at = db.Column(db.DateTime, default=datetime.datetime.now().strftime("%a, %d %b %Y %X %z"))
videos_checked_out_count = db.Column(db.Integer, nullable=False, default=0)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In most situations, if we can derive the data we want from something else we are already storing, we should choose to do that over storing a new piece of data. If we store a piece of derivable data, we need to manually keep our related data in sync which introduces places for potential bugs, and we take up more memory for each record.

In this case, we hold videos which is a list of the videos the user currently has checked out and videos_checked_out_count. We could remove videos_checked_out_count and call len on videos anywhere we need the count.

videos = db.relationship("Video", secondary="rental", back_populates="customers")

def to_dict(self):
customer_dict = {
"id": self.id,
"name": self.name,
"postal_code": self.postal_code,
"phone": self.phone,
"registered_at": self.registered_at,
"videos_checked_out_count": self.videos_checked_out_count
}
return customer_dict
6 changes: 5 additions & 1 deletion app/models/rental.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
from app import db
# from datetime import datetime, timedelta

class Rental(db.Model):
id = db.Column(db.Integer, primary_key=True)
id = db.Column(db.Integer, primary_key=True)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another option could be to create a composite primary key from the customer_id and video_id columns. The benefit is we can store one less column for each record, but the limitation is there can only ever be one Rental record with that combination of customer and video ids.

# One possible set up
video_id = db.Column(db.Integer, db.ForeignKey('video.id'), primary_key=True, nullable=False)
customer_id = db.Column(db.Integer, db.ForeignKey('customer.id'), primary_key=True, nullable=False)

customer_id = db.Column(db.Integer, db.ForeignKey("customer.id"), nullable=False)
video_id = db.Column(db.Integer, db.ForeignKey("video.id"), nullable=False)
due_date = db.Column(db.DateTime)
19 changes: 18 additions & 1 deletion app/models/video.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,21 @@
from app import db


def available_inventory_default(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, nullable=False)
title = db.Column(db.String, nullable=False)
total_inventory = db.Column(db.Integer, nullable=False)
available_inventory = db.Column(db.Integer, default=available_inventory_default)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is another field that we could derive, this time using total_inventory and the count of current rentals with this video id.

release_date = db.Column(db.DateTime, nullable=False)
customers = db.relationship("Customer",secondary="rental",back_populates="videos")

def to_dict(self):
return {
"id": self.id,
"title": self.title,
"total_inventory": self.total_inventory,
"release_date": self.release_date
}

Empty file removed app/routes.py
Empty file.
157 changes: 157 additions & 0 deletions app/routes/customer_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
from app import db
from app.models.customer import Customer
from app.models.rental import Rental
from app.models.video import Video
from app.routes.helpers import validate_model, validate_request_body
from flask import Blueprint, jsonify, request, make_response, abort
import datetime


customers_bp = Blueprint("customers_bp", __name__, url_prefix="/customers")


@customers_bp.route("", methods=["GET"])
def get_all_customers():
customer_query = Customer.query

sort_param = request.args.get("sort")
if sort_param:
if sort_param == "name":
customer_query = customer_query.order_by(Customer.name)
elif sort_param == "registered_at":
customer_query = customer_query.order_by(Customer.registered_at)
elif sort_param == "postal_code":
customer_query = customer_query.order_by(Customer.postal_code)
else:
customer_query = customer_query.order_by(Customer.id)
else:
customer_query = customer_query.order_by(Customer.id)
Comment on lines +17 to +28

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a little redundancy here in our calls to customer_query.order_by and filtering by id. There's several ways we could reduce repetition, but one option would be something like:

    sort_param = request.args.get("sort")
    sort_options = {
        "name": Customer.name,
        "registered_at": Customer.registered_at,
        "postal_code": Customer.postal_code,
    }
    if sort_param in sort_options:
        customer_query = customer_query.order_by(sort_options[sort_param])
    else:
        customer_query = customer_query.order_by(Customer.id)


count_param = request.args.get("count")
page_num_param = request.args.get("page_num")
Comment on lines +30 to +31

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something neat I just learned: in request.args.get we can pass a parameter type that will be used to try to cast the value to that type and will return a default if it cannot perform the conversion.
Link to docs with the relevant section highlighted

count_query = request.args.get("count", type=int)
page_num_param = request.args.get("page_num", type=int)


pagination = False
count = None
page_num = None
if count_param and count_param.isdigit():
count = int(count_param)
pagination = True

if page_num_param and page_num_param.isdigit():
page_num = int(page_num_param)
pagination = True

customers = []
if pagination:
if page_num is None:
page_num = 1
Comment on lines +46 to +47

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm seeing a little inconsistency in how page_num and count are treated. We are explicitly setting a default for page if it isn't provided, but we don't do the same for count. Both of these values have defaults if None is provided:

If page or per_page are None, they will be retrieved from the request query... If there is no request or they aren’t in the query, they default to 1 and 20 respectively. https://flask-sqlalchemy.palletsprojects.com/en/2.x/api/#flask_sqlalchemy.BaseQuery.paginate

Since this code doesn't change the default from Flask's default value, to be consistent I recommend explicitly setting defaults for both page_num and count so it's easy to see what those values should be from the code, or not manually setting defaults and relying on the Flask behavior.


page = customer_query.paginate(per_page=count,page=page_num)
customers = page.items
else:
customers = customer_query.all()

customers_response = [customer.to_dict() for customer in customers]

return make_response(jsonify(customers_response), 200)


@customers_bp.route("/<customer_id>", methods=["GET"])
def get_one_customer(customer_id):
customer = validate_model(Customer, customer_id)
customer_data = customer.to_dict()

return make_response(jsonify(customer_data), 200)


@customers_bp.route("", methods=["POST"])
def add_one_customer():
request_body = request.get_json()
required_attributes = ["name", "postal_code", "phone"]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since these attributes are used in more than one function to validate request bodies, it might be worth pulling this list out and making it a constant at the top of the file to avoid duplication.


validate_request_body(request_body, required_attributes)
Comment on lines +69 to +72

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny style nitpick: since validate_request_body is closely tied to the required_attributes, I'd consider removing the blank line on line 71


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()
db.session.refresh(new_customer, ["id"])

return make_response(jsonify({"id":new_customer.id}), 201)


@customers_bp.route("/<customer_id>", methods=["DELETE"])
def delete_one_customer(customer_id):
customer = validate_model(Customer, customer_id)
db.session.delete(customer)
db.session.commit()

return make_response(jsonify({"id":int(customer_id)}), 200)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To confirm to the user what action was taken, I recommend adding some text that describes what was done successfully to all of the routes that aren't returning dictionaries of data.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. It is just that the specifications in the readme asked for a that response. Perhaps we should have added and extra key: value pair to the response json with a message.



@customers_bp.route("/<customer_id>", methods=["PUT"])
def update_one_customer(customer_id):
customer = validate_model(Customer, customer_id)
request_body = request.get_json()
required_attributes = ["name", "phone", "postal_code"]

validate_request_body(request_body, required_attributes)

customer.name = request_body["name"]
customer.phone = request_body["phone"]
customer.postal_code = request_body["postal_code"]
db.session.commit()
response_body = request_body
response_body['registered_at'] = customer.registered_at

return make_response(jsonify(response_body), 200)


@customers_bp.route("/<customer_id>/rentals", methods=["GET"])
def get_customer_checked_out_videos(customer_id):
validate_model(Customer, customer_id)

possible_query_params = {"sort" : "",
"count": 0,
"page_num": 0}

for query_param in possible_query_params:
if query_param in request.args:
possible_query_params[query_param] = request.args.get(query_param)

join_query = db.session.query(Rental, Video)\
.join(Video, Rental.video_id==Video.id)\
.filter(Rental.customer_id == customer_id)

sort_params = ["title", "release_date"]
for param in sort_params:
if possible_query_params["sort"] == param:
join_query = join_query.order_by(param)
Comment on lines +128 to +131

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could simplify this just a little so we don't need to manually write the for-loop:

    sort_params = ["title", "release_date"]
    if possible_query_params["sort"] in sort_params:
        join_query = join_query.order_by(possible_query_params["sort"])


if possible_query_params["count"] and \
possible_query_params["count"].isdigit():
possible_query_params["count"] = int(possible_query_params["count"])
if possible_query_params["page_num"] and \
possible_query_params["page_num"].isdigit():
possible_query_params["page_num"] = \
int(possible_query_params["page_num"])
Comment on lines +133 to +139

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This chunk of code is a little dense. I suggest using variables to make it a little easier to read at a glance:

count_param = possible_query_params["count"]
if count_param and count_param.isdigit():
    possible_query_params["count"] = int(count_param)
        
    page_num_param = possible_query_params["page_num"]
    if page_num_param and page_num_param.isdigit():
        possible_query_params["page_num"] =  int(page_num_param)

else:
possible_query_params["page_num"] = 1
join_query = join_query.paginate(
page=possible_query_params["page_num"],
per_page=possible_query_params["count"]).items
else:
join_query = join_query.all()

response_body = []
for row in join_query:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would consider adding a new variable to this function that holds the results of the query for clarity. At this point join_query is no longer a query object, it is a list of models, so the naming could be confusing.

response_body.append({
"id": row.Video.id,
"title": row.Video.title,
"total_inventory": row.Video.total_inventory,
"release_date": row.Video.release_date
})
Comment on lines +150 to +155

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there something about how paginate returns items such that we need to access the video attributes in this way? I'm wondering if this could be rewritten to take advantage of the Video model's to_dict function.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was the only way I could access the results of a join query. I'm not sure how to do it in a different way to make use of the to_dict function.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was the only way I could access the result of the join query. I don't know how to make it in another way to make use of the to_dict function.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with Dalia! I tried to access them in the regular way and the attributes of the model object weren't recognized.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh, makes sense, because of the join the rows returned by the query are not just Video models. If we only need the video information for the response, we could see if SQLAlchemy has tools for getting the Videos associated with each row, but I think that's far more work than necessary for what we're trying to do here.

Copy link

@kelsey-steven-ada kelsey-steven-ada Jan 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something for future projects: when I cloned down the code to test things out there was a migrations folder, but the versions folder had been deleted. In a larger project, we would want to keep the versions in source control so that everyone is sharing the same, most updated version of the database schema.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why the versions folder isn't there. I can see it in my local version ... and that's the version that we pushed to github. Do you have an idea what we might have done wrong?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like your local migrations folders might not have been in sync? I see a couple version files that it looks like were deleted in the last PR on the repo: https://github.com/dnabilali/retro-video-store/pull/11/files, check for files marked as deleted


return make_response(jsonify(response_body),200)
25 changes: 25 additions & 0 deletions app/routes/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from flask import abort, make_response,jsonify

def validate_model(model, id):
try:
int(id)
except:
abort(make_response({"message": f"{id} is an invalid {model.__name__} id"}, 400))

model_instance = model.query.get(id)

if not model_instance:
abort(make_response({"message": f"{model.__name__} {id} was not found"}, 404))

return model_instance

def validate_request_body(request_body,required_data):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the flexibility of this for checking data to create & update models!

if not request_body:
msg = "An empty or invalid json object was sent."
abort(make_response(jsonify({"details":msg}),400))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny style nitpick: for best practices we'd want a space after the comma as a visual separation between the make_response arguments


for data in required_data:
if data not in request_body:
msg = f"Request body must include {data}. Request failed"
abort(make_response(jsonify({"details":msg}),400))

75 changes: 75 additions & 0 deletions app/routes/rental_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from flask import Blueprint, request, make_response, jsonify, abort
from app.routes.customer_routes import validate_model, validate_request_body
from app.models.customer import Customer
from app.models.video import Video
from app.models.rental import Rental
from app import db
from datetime import datetime, timedelta

MAX_DAYS_RENTALS = 7

rentals_bp = Blueprint("rentals_bp", __name__, url_prefix="/rentals")

@rentals_bp.route("/check-out", methods=["POST"])
def checkout_video_to_customer():
request_body = request.get_json()
required_data = ["customer_id", "video_id"]

validate_request_body(request_body, required_data)

valid_customer = validate_model(Customer, request_body["customer_id"])
valid_video = validate_model(Video, request_body["video_id"])

if valid_video.available_inventory < 1:
abort(make_response(jsonify({"message":"Could not perform checkout"}), 400))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a real life scenario, we might want to add more specific info like "Could not perform checkout: no copies available"


if valid_video in valid_customer.videos:
abort(make_response(jsonify({"message":"This customer already has this video checked out"}), 400))

new_rental = Rental(
customer_id=valid_customer.id,
video_id=valid_video.id,
due_date=datetime.now() + timedelta(days=MAX_DAYS_RENTALS))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if this is something that could be set as a default on the column like the registered_at attribute of the Customer model.

valid_video.available_inventory -= 1
valid_customer.videos_checked_out_count += 1
db.session.add(new_rental)
db.session.commit()

response_body = {
"customer_id" : valid_customer.id,
"video_id" : valid_video.id,
"due_date" : new_rental.due_date,
"videos_checked_out_count" : valid_customer.videos_checked_out_count,
"available_inventory" : valid_video.available_inventory
}

return make_response(jsonify(response_body), 200)

@rentals_bp.route("/check-in", methods=["POST"])
def check_in_video():
request_body = request.get_json(silent=True)
required_data = ["customer_id", "video_id"]

validate_request_body(request_body,required_data)

customer = validate_model(Customer,request_body["customer_id"])
video = validate_model(Video,request_body["video_id"])
rental = Rental.query.filter_by(video_id=video.id, customer_id=customer.id).first()

if not rental:
msg = f"No outstanding rentals for customer {customer.id} and video {video.id}"
abort(make_response(jsonify({"message":msg}),400))

video.available_inventory += 1
customer.videos_checked_out_count -= 1

db.session.delete(rental)
db.session.commit()

response_data = {}
response_data["video_id"] = video.id
response_data["customer_id"] = customer.id
response_data["videos_checked_out_count"] = customer.videos_checked_out_count
response_data["available_inventory"] = video.available_inventory

return make_response(jsonify(response_data),200)
Loading