Skip to content

Commit 8eac3b4

Browse files
authored
Merge pull request #5 from a-luna/part-5
Part 5
2 parents 16b94bb + 195a1fb commit 8eac3b4

File tree

7 files changed

+254
-1
lines changed

7 files changed

+254
-1
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""add widget model
2+
3+
Revision ID: 7c66df0e878f
4+
Revises: 079d26d45cc9
5+
Create Date: 2020-02-29 04:35:12.651711
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "7c66df0e878f"
14+
down_revision = "079d26d45cc9"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
op.create_table(
22+
"widget",
23+
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
24+
sa.Column("name", sa.String(length=100), nullable=False),
25+
sa.Column("info_url", sa.String(length=255), nullable=True),
26+
sa.Column("created_at", sa.DateTime(), nullable=True),
27+
sa.Column("deadline", sa.DateTime(), nullable=True),
28+
sa.Column("owner_id", sa.Integer(), nullable=False),
29+
sa.ForeignKeyConstraint(["owner_id"], ["site_user.id"],),
30+
sa.PrimaryKeyConstraint("id"),
31+
sa.UniqueConstraint("name"),
32+
)
33+
# ### end Alembic commands ###
34+
35+
36+
def downgrade():
37+
# ### commands auto generated by Alembic - please adjust! ###
38+
op.drop_table("widget")
39+
# ### end Alembic commands ###

run.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,42 @@
11
"""Flask CLI/Application entry point."""
22
import os
33

4+
import click
5+
46
from flask_api_tutorial import create_app, db
57
from flask_api_tutorial.models.token_blacklist import BlacklistedToken
68
from flask_api_tutorial.models.user import User
9+
from flask_api_tutorial.models.widget import Widget
710

811
app = create_app(os.getenv("FLASK_ENV", "development"))
912

1013

1114
@app.shell_context_processor
1215
def shell():
13-
return {"db": db, "User": User, "BlacklistedToken": BlacklistedToken}
16+
return {
17+
"db": db,
18+
"User": User,
19+
"BlacklistedToken": BlacklistedToken,
20+
"Widget": Widget,
21+
}
22+
23+
24+
@app.cli.command("add-user", short_help="Add a new user")
25+
@click.argument("email")
26+
@click.option(
27+
"--admin", is_flag=True, default=False, help="New user has administrator role"
28+
)
29+
@click.password_option(help="Do not set password on the command line!")
30+
def add_user(email, admin, password):
31+
"""Add a new user to the database with email address = EMAIL."""
32+
if User.find_by_email(email):
33+
error = f"Error: {email} is already registered"
34+
click.secho(f"{error}\n", fg="red", bold=True)
35+
return 1
36+
new_user = User(email=email, password=password, admin=admin)
37+
db.session.add(new_user)
38+
db.session.commit()
39+
user_type = "admin user" if admin else "user"
40+
message = f"Successfully added new {user_type}:\n {new_user}"
41+
click.secho(message, fg="blue", bold=True)
42+
return 0

src/flask_api_tutorial/api/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from flask_restx import Api
44

55
from flask_api_tutorial.api.auth.endpoints import auth_ns
6+
from flask_api_tutorial.api.widgets.endpoints import widget_ns
67

78
api_bp = Blueprint("api", __name__, url_prefix="/api/v1")
89
authorizations = {"Bearer": {"type": "apiKey", "in": "header", "name": "Authorization"}}
@@ -17,3 +18,4 @@
1718
)
1819

1920
api.add_namespace(auth_ns, path="/auth")
21+
api.add_namespace(widget_ns, path="/widgets")
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Business logic for /widgets API endpoints."""
2+
from http import HTTPStatus
3+
4+
from flask import jsonify, url_for
5+
from flask_restx import abort
6+
7+
from flask_api_tutorial import db
8+
from flask_api_tutorial.api.auth.decorators import admin_token_required
9+
from flask_api_tutorial.models.user import User
10+
from flask_api_tutorial.models.widget import Widget
11+
12+
13+
@admin_token_required
14+
def create_widget(widget_dict):
15+
name = widget_dict["name"]
16+
if Widget.find_by_name(name):
17+
error = f"Widget name: {name} already exists, must be unique."
18+
abort(HTTPStatus.CONFLICT, error, status="fail")
19+
widget = Widget(**widget_dict)
20+
owner = User.find_by_public_id(create_widget.public_id)
21+
widget.owner_id = owner.id
22+
db.session.add(widget)
23+
db.session.commit()
24+
response = jsonify(status="success", message=f"New widget added: {name}.")
25+
response.status_code = HTTPStatus.CREATED
26+
response.headers["Location"] = url_for("api.widget", name=name)
27+
return response
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Parsers and serializers for /widgets API endpoints."""
2+
import re
3+
from datetime import date, datetime, time, timezone
4+
5+
from dateutil import parser
6+
from flask_restx.inputs import URL
7+
from flask_restx.reqparse import RequestParser
8+
9+
from flask_api_tutorial.util.datetime_util import make_tzaware, DATE_MONTH_NAME
10+
11+
12+
def widget_name(name):
13+
"""Validation method for a string containing only letters, numbers, '-' and '_'."""
14+
if not re.compile(r"^[\w-]+$").match(name):
15+
raise ValueError(
16+
f"'{name}' contains one or more invalid characters. Widget name must "
17+
"contain only letters, numbers, hyphen and underscore characters."
18+
)
19+
return name
20+
21+
22+
def future_date_from_string(date_str):
23+
"""Validation method for a date in the future, formatted as a string."""
24+
try:
25+
parsed_date = parser.parse(date_str)
26+
except ValueError:
27+
raise ValueError(
28+
f"Failed to parse '{date_str}' as a valid date. You can use any format "
29+
"recognized by dateutil.parser. For example, all of the strings below "
30+
"are valid ways to represent the same date: '2018-5-13' -or- '05/13/2018' "
31+
"-or- 'May 13 2018'."
32+
)
33+
34+
if parsed_date.date() < date.today():
35+
raise ValueError(
36+
f"Successfully parsed {date_str} as "
37+
f"{parsed_date.strftime(DATE_MONTH_NAME)}. However, this value must be a "
38+
f"date in the future and {parsed_date.strftime(DATE_MONTH_NAME)} is BEFORE "
39+
f"{datetime.now().strftime(DATE_MONTH_NAME)}"
40+
)
41+
deadline = datetime.combine(parsed_date.date(), time.max)
42+
deadline_utc = make_tzaware(deadline, use_tz=timezone.utc)
43+
return deadline_utc
44+
45+
46+
create_widget_reqparser = RequestParser(bundle_errors=True)
47+
create_widget_reqparser.add_argument(
48+
"name",
49+
type=widget_name,
50+
location="form",
51+
required=True,
52+
nullable=False,
53+
case_sensitive=True,
54+
)
55+
create_widget_reqparser.add_argument(
56+
"info_url",
57+
type=URL(schemes=["http", "https"]),
58+
location="form",
59+
required=True,
60+
nullable=False,
61+
)
62+
create_widget_reqparser.add_argument(
63+
"deadline",
64+
type=future_date_from_string,
65+
location="form",
66+
required=True,
67+
nullable=False,
68+
)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""API endpoint definitions for /widgets namespace."""
2+
from http import HTTPStatus
3+
4+
from flask_restx import Namespace, Resource
5+
6+
from flask_api_tutorial.api.widgets.dto import create_widget_reqparser
7+
from flask_api_tutorial.api.widgets.business import create_widget
8+
9+
widget_ns = Namespace(name="widgets", validate=True)
10+
11+
12+
@widget_ns.route("", endpoint="widget_list")
13+
@widget_ns.response(int(HTTPStatus.BAD_REQUEST), "Validation error.")
14+
@widget_ns.response(int(HTTPStatus.UNAUTHORIZED), "Unauthorized.")
15+
@widget_ns.response(int(HTTPStatus.INTERNAL_SERVER_ERROR), "Internal server error.")
16+
class WidgetList(Resource):
17+
"""Handles HTTP requests to URL: /widgets."""
18+
19+
@widget_ns.doc(security="Bearer")
20+
@widget_ns.response(int(HTTPStatus.CREATED), "Added new widget.")
21+
@widget_ns.response(int(HTTPStatus.FORBIDDEN), "Administrator token required.")
22+
@widget_ns.response(int(HTTPStatus.CONFLICT), "Widget name already exists.")
23+
@widget_ns.expect(create_widget_reqparser)
24+
def post(self):
25+
"""Create a widget."""
26+
widget_dict = create_widget_reqparser.parse_args()
27+
return create_widget(widget_dict)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Class definition for Widget model."""
2+
from datetime import datetime, timezone, timedelta
3+
4+
from sqlalchemy.ext.hybrid import hybrid_property
5+
6+
from flask_api_tutorial import db
7+
from flask_api_tutorial.util.datetime_util import (
8+
utc_now,
9+
format_timedelta_str,
10+
get_local_utcoffset,
11+
localized_dt_string,
12+
make_tzaware,
13+
)
14+
15+
16+
class Widget(db.Model):
17+
"""Widget model for a generic resource in a REST API."""
18+
19+
__tablename__ = "widget"
20+
21+
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
22+
name = db.Column(db.String(100), unique=True, nullable=False)
23+
info_url = db.Column(db.String(255))
24+
created_at = db.Column(db.DateTime, default=utc_now)
25+
deadline = db.Column(db.DateTime)
26+
27+
owner_id = db.Column(db.Integer, db.ForeignKey("site_user.id"), nullable=False)
28+
owner = db.relationship("User", backref=db.backref("widgets"))
29+
30+
def __repr__(self):
31+
return f"<Widget name={self.name}, info_url={self.info_url}>"
32+
33+
@hybrid_property
34+
def created_at_str(self):
35+
created_at_utc = make_tzaware(
36+
self.created_at, use_tz=timezone.utc, localize=False
37+
)
38+
return localized_dt_string(created_at_utc, use_tz=get_local_utcoffset())
39+
40+
@hybrid_property
41+
def deadline_str(self):
42+
deadline_utc = make_tzaware(self.deadline, use_tz=timezone.utc, localize=False)
43+
return localized_dt_string(deadline_utc, use_tz=get_local_utcoffset())
44+
45+
@hybrid_property
46+
def deadline_passed(self):
47+
return datetime.now(timezone.utc) > self.deadline.replace(tzinfo=timezone.utc)
48+
49+
@hybrid_property
50+
def time_remaining(self):
51+
time_remaining = self.deadline.replace(tzinfo=timezone.utc) - utc_now()
52+
return time_remaining if not self.deadline_passed else timedelta(0)
53+
54+
@hybrid_property
55+
def time_remaining_str(self):
56+
timedelta_str = format_timedelta_str(self.time_remaining)
57+
return timedelta_str if not self.deadline_passed else "No time remaining"
58+
59+
@classmethod
60+
def find_by_name(cls, name):
61+
return cls.query.filter_by(name=name).first()

0 commit comments

Comments
 (0)