-
Notifications
You must be signed in to change notification settings - Fork 43
QT_task-list-api #44
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
base: main
Are you sure you want to change the base?
QT_task-list-api #44
Changes from all commits
60f4542
04e6a2e
cb1b3c5
9d19004
b4b3d11
a61cb76
06440f2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,12 @@ | ||
| from flask import Flask | ||
| from .db import db, migrate | ||
| from .models import task, goal | ||
| from .routes.task_routes import tasks_bp | ||
| from .routes.goal_routes import goals_bp | ||
| import os | ||
| from dotenv import load_dotenv | ||
| load_dotenv() | ||
|
Comment on lines
+7
to
+8
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice, using |
||
|
|
||
|
|
||
| def create_app(config=None): | ||
| app = Flask(__name__) | ||
|
|
@@ -18,5 +23,7 @@ def create_app(config=None): | |
| migrate.init_app(app, db) | ||
|
|
||
| # Register Blueprints here | ||
| app.register_blueprint(tasks_bp) | ||
| app.register_blueprint(goals_bp) | ||
|
Comment on lines
+26
to
+27
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ⭐️ |
||
|
|
||
| return app | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,21 @@ | ||
| from sqlalchemy.orm import Mapped, mapped_column | ||
| from sqlalchemy.orm import Mapped, mapped_column, relationship | ||
| from ..db import db | ||
|
|
||
|
|
||
| class Goal(db.Model): | ||
| id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) | ||
| title: Mapped[str] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Love this! This is the pattern that we showed you in class! Leveraging the Declarative Mapping Annotation to declare this column as a non-nullable string. |
||
| tasks: Mapped[list["Task"]] = relationship(back_populates="goal") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Perfect! You are making a relationship attribute on the |
||
|
|
||
| @classmethod | ||
| def from_dict(cls, goal_data): | ||
| new_goal = cls(title=goal_data["title"]) | ||
| return new_goal | ||
|
|
||
| def to_dict(self): | ||
| goal_as_dict = { | ||
| "id": self.id, | ||
| "title": self.title | ||
| } | ||
|
|
||
| return goal_as_dict | ||
|
Comment on lines
+10
to
+21
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍🏿 |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,46 @@ | ||
| from sqlalchemy.orm import Mapped, mapped_column | ||
| from sqlalchemy.orm import Mapped, mapped_column, relationship | ||
| from sqlalchemy import ForeignKey | ||
| from datetime import datetime, UTC | ||
| from typing import Optional | ||
| from ..db import db | ||
|
|
||
|
|
||
| class Task(db.Model): | ||
| id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) | ||
| title: Mapped[str] | ||
| description: Mapped[str] | ||
| completed_at: Mapped[Optional[datetime]] = mapped_column(nullable=True) | ||
| goal_id: Mapped[Optional[int]] = mapped_column(ForeignKey("goal.id")) | ||
| goal: Mapped[Optional["Goal"]] = relationship(back_populates="tasks") | ||
|
Comment on lines
+10
to
+14
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Perfect! |
||
|
|
||
| @classmethod | ||
| def from_dict(cls, task_data): | ||
| goal_id = task_data.get("goal_id") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Great work, this lets you dynamically populate your foreign key attribute on your |
||
| new_task = cls( | ||
| title=task_data["title"], | ||
| description=task_data["description"], | ||
| completed_at=datetime.utcnow() if task_data.get("is_complete") else None, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🫡 |
||
| goal_id = goal_id | ||
| ) | ||
|
|
||
| return new_task | ||
|
|
||
| def to_dict(self): | ||
| task_as_dict = { | ||
| "id": self.id, | ||
| "title": self.title, | ||
| "description": self.description, | ||
| "is_complete": bool(self.completed_at), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍🏿 |
||
| } | ||
|
|
||
| if self.goal_id: | ||
| task_as_dict["goal_id"] = self.goal_id | ||
|
|
||
| return task_as_dict | ||
|
|
||
|
|
||
| def mark_complete(self): | ||
| self.completed_at = datetime.now(UTC) | ||
|
|
||
| def mark_incomplete(self): | ||
| self.completed_at = None | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1 +1,85 @@ | ||||||||||||||||||||||||||
| from flask import Blueprint | ||||||||||||||||||||||||||
| from flask import Blueprint, abort, make_response, request, Response | ||||||||||||||||||||||||||
| from app.models.goal import Goal | ||||||||||||||||||||||||||
| from app.models.task import Task | ||||||||||||||||||||||||||
| from .route_utilities import validate_model, create_model | ||||||||||||||||||||||||||
| from ..db import db | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| goals_bp = Blueprint("goals_bp", __name__, url_prefix="/goals") | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @goals_bp.post("") | ||||||||||||||||||||||||||
| def create_goal(): | ||||||||||||||||||||||||||
| request_body = request.get_json() | ||||||||||||||||||||||||||
| return create_model(Goal, request_body, "goal") | ||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice, using this helper function let's this function be more concise and modular—abstracting the logic of model creation into its own function. |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @goals_bp.get("") | ||||||||||||||||||||||||||
| def get_all_goals(): | ||||||||||||||||||||||||||
| query = db.select(Goal) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| title_param = request.args.get("title") | ||||||||||||||||||||||||||
| if title_param: | ||||||||||||||||||||||||||
| query = query.where(Goal.name.ilike(f"%{title_param}%")) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| goals = db.session.scalars(query) | ||||||||||||||||||||||||||
| goals_response = [] | ||||||||||||||||||||||||||
| for goal in goals: | ||||||||||||||||||||||||||
| goals_response.append(goal.to_dict()) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return goals_response | ||||||||||||||||||||||||||
|
Comment on lines
+16
to
+27
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could D.R.Y. this up by using our |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @goals_bp.get("/<goal_id>") | ||||||||||||||||||||||||||
| def get_one_goal(goal_id): | ||||||||||||||||||||||||||
| goal = validate_model(Goal, goal_id) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return {"goal": goal.to_dict()} | ||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @goals_bp.put("/<goal_id>") | ||||||||||||||||||||||||||
| def update_goal(goal_id): | ||||||||||||||||||||||||||
| goal = validate_model(Goal, goal_id) | ||||||||||||||||||||||||||
| request_body = request.get_json() | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| goal.title = request_body["title"] | ||||||||||||||||||||||||||
| db.session.commit() | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return Response(status=204, mimetype="application/json") | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @goals_bp.delete("/<goal_id>") | ||||||||||||||||||||||||||
| def delete_goal(goal_id): | ||||||||||||||||||||||||||
| goal = validate_model(Goal, goal_id) | ||||||||||||||||||||||||||
| db.session.delete(goal) | ||||||||||||||||||||||||||
| db.session.commit() | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return Response(status=204, mimetype="application/json") | ||||||||||||||||||||||||||
|
Comment on lines
+48
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍🏿 |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @goals_bp.post("/<goal_id>/tasks") | ||||||||||||||||||||||||||
| def post_task_ids_to_goal(goal_id): | ||||||||||||||||||||||||||
| goal = validate_model(Goal, goal_id) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| request_body = request.get_json() | ||||||||||||||||||||||||||
| task_ids = request_body.get("task_ids", []) | ||||||||||||||||||||||||||
| goal.tasks = [] | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| for task_id in task_ids: | ||||||||||||||||||||||||||
| task = validate_model(Task, task_id) | ||||||||||||||||||||||||||
| goal.tasks.append(task) | ||||||||||||||||||||||||||
|
Comment on lines
+63
to
+67
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd suggest validating the
Suggested change
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| db.session.commit() | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return {"id": goal.id, "task_ids": task_ids}, 200 | ||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @goals_bp.get("/<goal_id>/tasks") | ||||||||||||||||||||||||||
| def get_tasks_for_one_goal(goal_id): | ||||||||||||||||||||||||||
| goal = validate_model(Goal, goal_id) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| task_list = [task.to_dict() for task in goal.tasks] | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| response = { | ||||||||||||||||||||||||||
| "id": goal.id, | ||||||||||||||||||||||||||
| "title": goal.title, | ||||||||||||||||||||||||||
| "tasks": task_list | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Comment on lines
+80
to
+84
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A way to D.R.Y. up your implementations for converting your def to_dict(self, with_tasks=False):
goal_dict = {
self.id,
self.title
}
if with_tasks:
goal_dict["tasks"] = [task.to_dict() for task in self.tasks]
return goal_dict |
||||||||||||||||||||||||||
| return response, 200 | ||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,69 @@ | ||||||
| from flask import abort, make_response | ||||||
| from ..db import db | ||||||
| import logging | ||||||
| import requests | ||||||
| import os | ||||||
| from dotenv import load_dotenv | ||||||
|
|
||||||
| load_dotenv() | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since you are calling this at the entry point of your application,
Suggested change
|
||||||
|
|
||||||
|
|
||||||
| def validate_model(cls, model_id): | ||||||
| try: | ||||||
| model_id = int(model_id) | ||||||
| except: | ||||||
| response = {"message": f"{cls.__name__} {model_id} invalid"} | ||||||
| abort(make_response(response, 400)) | ||||||
|
|
||||||
| query = db.select(cls).where(cls.id == model_id) | ||||||
| model = db.session.scalar(query) | ||||||
|
|
||||||
| if not model: | ||||||
| response = {"message": f"{cls.__name__} {model_id} not found"} | ||||||
| abort(make_response(response, 404)) | ||||||
|
|
||||||
| return model | ||||||
|
|
||||||
|
|
||||||
| logger = logging.getLogger(__name__) | ||||||
|
|
||||||
|
|
||||||
| def chat_post_message(task): | ||||||
| token = os.environ.get('SLACK_BOT_TOKEN') | ||||||
| channel = os.environ.get('SLACK_CHANNEL_ID') | ||||||
|
|
||||||
| try: | ||||||
| response = requests.post( | ||||||
| "https://slack.com/api/chat.postMessage", | ||||||
| headers={ | ||||||
| "Authorization": f"Bearer {token}", | ||||||
| "Content-Type": "application/json" | ||||||
| }, | ||||||
|
|
||||||
| json={ | ||||||
| "channel": channel, | ||||||
| "text": f"Someone just completed the task {task.title}" | ||||||
| } | ||||||
| ) | ||||||
| if response.status_code == 200 and response.json().get("ok"): | ||||||
| logger.info("Slack message sent successfully.") | ||||||
| else: | ||||||
| logger.error(f"Slack API error: {response.json()}") | ||||||
| except Exception as e: | ||||||
| logger.error(f"Failed to send Slack message: {e}") | ||||||
|
Comment on lines
+31
to
+53
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice work on making this a helper function! It moves the logic out of the route function--Single Responsibility Principle! |
||||||
|
|
||||||
|
|
||||||
| def create_model(cls, model_data, response_key): | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Notice that the only difference is we need to have either return {f"{cls.__name__.lower()}": new_model.to_dict()}, 201This change will allow us simply call the function and return the response that it builds us. |
||||||
| try: | ||||||
| new_model = cls.from_dict(model_data) | ||||||
|
|
||||||
| except KeyError: | ||||||
| response = {"details": "Invalid data"} | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| abort(make_response(response, 400)) | ||||||
|
|
||||||
| db.session.add(new_model) | ||||||
| db.session.commit() | ||||||
|
|
||||||
| return {response_key: new_model.to_dict()}, 201 | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
|
||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1 +1,85 @@ | ||||||
| from flask import Blueprint | ||||||
| from flask import Blueprint, abort, make_response, request, Response | ||||||
| from app.models.task import Task | ||||||
| from .route_utilities import validate_model, chat_post_message, create_model | ||||||
| from sqlalchemy import asc, desc | ||||||
| from ..db import db | ||||||
|
|
||||||
| tasks_bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") | ||||||
|
|
||||||
| @tasks_bp.post("") | ||||||
| def create_task(): | ||||||
| request_body = request.get_json() | ||||||
| return create_model(Task, request_body, "task") | ||||||
|
|
||||||
|
|
||||||
| @tasks_bp.get("") | ||||||
| def get_all_tasks(): | ||||||
| query = db.select(Task) | ||||||
|
|
||||||
| title_param = request.args.get("title") | ||||||
| if title_param: | ||||||
| query = query.where(Task.title.ilike(f"%{title_param}%")) | ||||||
|
|
||||||
| sort_param = request.args.get("sort") | ||||||
| if sort_param: | ||||||
| if sort_param.lower() == "asc": | ||||||
| query = query.order_by(asc(Task.title)) | ||||||
| elif sort_param.lower() == "desc": | ||||||
| query = query.order_by(desc(Task.title)) | ||||||
|
|
||||||
| query = query.order_by(Task.id) | ||||||
|
|
||||||
| tasks = db.session.scalars(query) | ||||||
|
|
||||||
| tasks_response = [] | ||||||
| for task in tasks: | ||||||
| tasks_response.append(task.to_dict()) | ||||||
| return tasks_response | ||||||
|
Comment on lines
+19
to
+37
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could D.R.Y. this up as well with |
||||||
|
|
||||||
|
|
||||||
| @tasks_bp.get("/<task_id>") | ||||||
| def get_one_task(task_id): | ||||||
| task = validate_model(Task, task_id) | ||||||
|
|
||||||
| return {"task": task.to_dict()} | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
|
||||||
|
|
||||||
| @tasks_bp.put("/<task_id>") | ||||||
| def update_task(task_id): | ||||||
| task = validate_model(Task, task_id) | ||||||
| request_body = request.get_json() | ||||||
|
|
||||||
| task.title = request_body["title"] | ||||||
| task.description = request_body["description"] | ||||||
|
Comment on lines
+52
to
+53
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is another opportunity to D.R.Y. up our code! Notice how in this route and the object.ATTRIBUTE = request_body["ATTRIBUTE"]We use def update_model(obj, data):
for attr, value in data.items():
if hasattr(obj, attr):
setattr(obj, attr, value)
db.session.commit()
return Response(status=204, mimetype="application/json")This refactor not only makes our code D.R.Y but shows that we recognize logic that has higher level usability while handling cases of keys not being found! |
||||||
| db.session.commit() | ||||||
|
|
||||||
| return Response(status=204, mimetype="application/json") | ||||||
|
|
||||||
|
|
||||||
| @tasks_bp.delete("/<task_id>") | ||||||
| def delete_task(task_id): | ||||||
| task = validate_model(Task, task_id) | ||||||
| db.session.delete(task) | ||||||
| db.session.commit() | ||||||
|
|
||||||
| return Response(status=204, mimetype="application/json") | ||||||
|
|
||||||
|
|
||||||
| @tasks_bp.patch("/<task_id>/mark_complete") | ||||||
| def mark_task_complete(task_id): | ||||||
| task = validate_model(Task, task_id) | ||||||
| task.mark_complete() | ||||||
| db.session.commit() | ||||||
|
|
||||||
| chat_post_message(task) | ||||||
|
|
||||||
| return Response(status=204, mimetype="application/json") | ||||||
|
|
||||||
| @tasks_bp.patch("/<task_id>/mark_incomplete") | ||||||
| def mark_task_incomplete(task_id): | ||||||
| task = validate_model(Task, task_id) | ||||||
|
|
||||||
| task.mark_incomplete() | ||||||
| db.session.commit() | ||||||
|
|
||||||
| return Response(status=204, mimetype="application/json") | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Single-database configuration for Flask. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| # 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,flask_migrate | ||
|
|
||
| [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 | ||
|
|
||
| [logger_flask_migrate] | ||
| level = INFO | ||
| handlers = | ||
| qualname = flask_migrate | ||
|
|
||
| [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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't forget that Flask convention is to name your
Blueprintsbpand then inside of our__init__.pyfile we can import withas.