-
Notifications
You must be signed in to change notification settings - Fork 36
Ada C24 Crow Tatiana D. #40
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?
Changes from all commits
cd7fc6c
603837f
e6657c3
76c39ca
e0b42b3
fc02340
2aa3bd8
1518ee9
17e3a02
98fd7f4
ddaa85e
7583779
6de4483
cf34ff2
074e911
13e714e
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,22 +1,27 @@ | ||
| from flask import Flask | ||
| from .db import db, migrate | ||
| from .models import task, goal | ||
| import os | ||
| from app.models.task import Task | ||
| from app.models.goal import Goal | ||
| from app.routes.task_routes import bp as tasks_bp | ||
| from app.routes.goal_routes import bp as goal_bp | ||
|
|
||
|
|
||
| def create_app(config=None): | ||
| app = Flask(__name__) | ||
|
|
||
| app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False | ||
| app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('SQLALCHEMY_DATABASE_URI') | ||
| app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False | ||
| app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get( | ||
| "SQLALCHEMY_DATABASE_URI" | ||
| ) | ||
|
|
||
| if config: | ||
| # Merge `config` into the app's configuration | ||
| # to override the app's default settings for testing | ||
| app.config.update(config) | ||
|
|
||
| db.init_app(app) | ||
| migrate.init_app(app, db) | ||
|
|
||
| # Register Blueprints here | ||
| app.register_blueprint(tasks_bp) | ||
| app.register_blueprint(goal_bp) | ||
|
|
||
| return app | ||
| return app | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,29 @@ | ||
| from sqlalchemy.orm import Mapped, mapped_column | ||
| from ..db import db | ||
| from typing import List | ||
| from sqlalchemy.orm import Mapped, mapped_column, relationship | ||
| from app import db | ||
|
|
||
|
|
||
| class Goal(db.Model): | ||
| id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) | ||
| __tablename__ = "goal" | ||
|
|
||
| id: Mapped[int] = mapped_column(primary_key=True) | ||
| title: Mapped[str] = mapped_column(db.String, nullable=False) | ||
| tasks: Mapped[List["Task"]] = relationship(back_populates="goal") | ||
|
|
||
| def to_dict(self, with_tasks: bool = False): | ||
| goal_dict = { | ||
| "id": self.id, | ||
| "title": self.title, | ||
| } | ||
|
|
||
| if with_tasks: | ||
| goal_dict["tasks"] = [task.to_dict() for task in self.tasks] | ||
|
|
||
| return goal_dict | ||
|
|
||
| @classmethod | ||
| def from_dict(cls, data): | ||
| title = data.get("title") | ||
| if not title: | ||
| raise KeyError("title is required") | ||
| return cls(title=title) | ||
|
Comment on lines
+24
to
+29
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,41 @@ | ||
| from sqlalchemy.orm import Mapped, mapped_column | ||
| from ..db import db | ||
| from datetime import datetime | ||
| from typing import Optional | ||
| from sqlalchemy.orm import Mapped, mapped_column, relationship | ||
| from app import db | ||
|
|
||
|
|
||
| class Task(db.Model): | ||
| id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) | ||
| __tablename__ = "task" | ||
|
|
||
| id: Mapped[int] = mapped_column(primary_key=True) | ||
| title: Mapped[str] = mapped_column(db.String, nullable=False) | ||
| description: Mapped[Optional[str]] = mapped_column(db.String, nullable=True) | ||
| completed_at: Mapped[Optional[datetime]] = mapped_column(db.DateTime, nullable=True) | ||
|
|
||
| goal_id: Mapped[Optional[int]] = mapped_column( | ||
| db.Integer, | ||
| db.ForeignKey("goal.id"), | ||
| nullable=True, | ||
| ) | ||
| goal: Mapped["Goal"] = relationship(back_populates="tasks") | ||
|
|
||
| def to_dict(self): | ||
| return { | ||
| "id": self.id, | ||
| "title": self.title, | ||
| "description": self.description, | ||
| "is_complete": bool(self.completed_at), | ||
| **({"goal_id": self.goal_id} if self.goal_id is not None else {}) | ||
| } | ||
|
|
||
| @classmethod | ||
| def from_dict(cls, data): | ||
| if "title" not in data: | ||
| raise KeyError("title") | ||
| if "description" not in data: | ||
| raise KeyError("description") | ||
|
|
||
| return cls( | ||
| title=data["title"], | ||
| description=data["description"], | ||
| ) | ||
|
Comment on lines
+31
to
+41
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 +1,75 @@ | ||
| from flask import Blueprint | ||
| from flask import Blueprint, request, make_response, Response | ||
| from app import db | ||
| from app.models.goal import Goal | ||
| from app.models.task import Task | ||
| from app.routes.route_utilities import validate_model, create_model, update_model | ||
|
|
||
| bp = Blueprint("goal_bp", __name__, url_prefix="/goals") | ||
|
|
||
|
|
||
| @bp.post("") | ||
| def create_goal(): | ||
| request_body = request.get_json() or {} | ||
| response_body, status_code = create_model(Goal, request_body) | ||
| return response_body, status_code | ||
|
|
||
|
|
||
| @bp.get("") | ||
| def get_goals(): | ||
| goals_stmt = db.select(Goal) | ||
| goals = db.session.scalars(goals_stmt).all() | ||
| response = [goal.to_dict() for goal in goals] | ||
| return response, 200 | ||
|
|
||
|
|
||
| @bp.get("/<goal_id>") | ||
| def read_goal(goal_id): | ||
| goal = validate_model(Goal, goal_id) | ||
| return goal.to_dict(), 200 | ||
|
|
||
|
|
||
| @bp.put("/<goal_id>") | ||
| def update_goal(goal_id): | ||
| goal = validate_model(Goal, goal_id) | ||
| request_body = request.get_json() or {} | ||
| title = request_body.get("title") | ||
| if not title: | ||
| return make_response({"details": "Invalid data"}, 400) | ||
|
|
||
| return update_model(goal, {"title": title}) | ||
|
|
||
|
|
||
| @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) | ||
|
|
||
|
|
||
| @bp.post("/<goal_id>/tasks") | ||
| def assign_tasks_to_goal(goal_id): | ||
| goal = validate_model(Goal, goal_id) | ||
| request_body = request.get_json() or {} | ||
| task_ids = request_body.get("task_ids") | ||
| if not isinstance(task_ids, list): | ||
| return make_response({"details": "Invalid data"}, 400) | ||
|
|
||
| for task in goal.tasks: | ||
| task.goal_id = None | ||
|
|
||
| validated_ids = [] | ||
| for tid in task_ids: | ||
| task = validate_model(Task, tid) | ||
| task.goal_id = goal.id | ||
| validated_ids.append(task.id) | ||
|
Comment on lines
+58
to
+65
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 leveraging the relationship attribute established between the two models. You could have alternatively done: new_tasks = []
for id in request_body['task_ids']:
task = validate_model(Task, id)
new_tasks.append(task)
goal.tasks = new_tasks
Author
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. Thank you! I appreciate the smarter approach |
||
|
|
||
| db.session.commit() | ||
|
|
||
| return {"id": goal.id, "task_ids": validated_ids}, 200 | ||
|
|
||
|
|
||
| @bp.get("/<goal_id>/tasks") | ||
| def get_tasks_of_goal(goal_id): | ||
| goal = validate_model(Goal, goal_id) | ||
| return goal.to_dict(with_tasks=True), 200 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| from flask import abort, make_response, Response | ||
| from app import db | ||
|
|
||
|
|
||
| def validate_model(model_class, model_id): | ||
| model_name = model_class.__name__ | ||
| error_key = "details" if model_name == "Task" else "message" | ||
|
|
||
| try: | ||
| model_id_int = int(model_id) | ||
| except ValueError: | ||
| abort(make_response( | ||
| {error_key: f"{model_name} {model_id} invalid"}, | ||
| 400, | ||
| )) | ||
|
|
||
| model = db.session.get(model_class, model_id_int) | ||
|
|
||
| if model is None: | ||
| abort(make_response( | ||
| {error_key: f"{model_name} {model_id_int} not found"}, | ||
| 404, | ||
| )) | ||
|
|
||
| return model | ||
|
|
||
|
|
||
| def create_model(model_class, request_body): | ||
| try: | ||
| new_model = model_class.from_dict(request_body) | ||
| except KeyError: | ||
| abort(make_response( | ||
| {"details": "Invalid data"}, | ||
| 400, | ||
| )) | ||
|
|
||
| db.session.add(new_model) | ||
| db.session.commit() | ||
|
|
||
| return new_model.to_dict(), 201 | ||
|
|
||
|
|
||
| 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) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,93 @@ | ||
| from flask import Blueprint | ||
| from flask import Blueprint, request, make_response | ||
| from app import db | ||
| from app.models.task import Task | ||
| from app.routes.route_utilities import validate_model, create_model, update_model | ||
| import os | ||
| import requests | ||
|
|
||
| bp = Blueprint("task_bp", __name__, url_prefix="/tasks") | ||
|
|
||
| SLACK_API_URL = "https://slack.com/api/chat.postMessage" | ||
| SLACK_BOT_TOKEN = os.getenv("SLACK_BOT_TOKEN") | ||
| SLACK_CHANNEL = os.getenv("SLACK_CHANNEL", "task-notifications") | ||
|
|
||
|
|
||
| def send_task_completed_notification(task): | ||
| if not SLACK_BOT_TOKEN: | ||
| return | ||
|
|
||
| message = f"Someone just completed the task: {task.title}" | ||
| headers = {"Authorization": f"Bearer {SLACK_BOT_TOKEN}"} | ||
| data = { | ||
| "channel": SLACK_CHANNEL, | ||
| "text": message, | ||
| } | ||
| requests.post(SLACK_API_URL, data=data, headers=headers) | ||
|
|
||
|
|
||
| @bp.post("") | ||
| def create_task(): | ||
| request_body = request.get_json() or {} | ||
| response_body, status_code = create_model(Task, request_body) | ||
| return response_body, status_code | ||
|
|
||
|
|
||
| @bp.get("") | ||
| def get_all_tasks(): | ||
| sort_query = request.args.get("sort") | ||
|
|
||
| tasks_stmt = db.select(Task) | ||
|
|
||
| if sort_query == "asc": | ||
|
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 handling the filtering logic for this route and the goal route. To D.R.Y. this up some more we could use similar logic to the
Author
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. Thank you! I’ll keep the helper approach in mind for future refactors. |
||
| tasks_stmt = tasks_stmt.order_by(Task.title.asc()) | ||
| elif sort_query == "desc": | ||
| tasks_stmt = tasks_stmt.order_by(Task.title.desc()) | ||
|
|
||
| tasks = db.session.scalars(tasks_stmt).all() | ||
| response = [task.to_dict() for task in tasks] | ||
| return response, 200 | ||
|
|
||
|
|
||
| @bp.get("/<task_id>") | ||
| def get_task(task_id): | ||
| task = validate_model(Task, task_id) | ||
| return task.to_dict(), 200 | ||
|
|
||
|
|
||
| @bp.put("/<task_id>") | ||
| def update_task(task_id): | ||
| task = validate_model(Task, task_id) | ||
| request_body = request.get_json() or {} | ||
|
|
||
| title = request_body.get("title") | ||
| description = request_body.get("description") | ||
|
Comment on lines
+62
to
+63
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!
Author
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 refactored the update logic using a helper function as suggested. It definitely makes the code cleaner. |
||
|
|
||
| if not title or not description: | ||
| return make_response({"details": "Invalid data"}, 400) | ||
|
|
||
| return update_model(task, {"title": title, "description": description}) | ||
|
|
||
|
|
||
| @bp.delete("/<task_id>") | ||
| def delete_task(task_id): | ||
| task = validate_model(Task, task_id) | ||
| db.session.delete(task) | ||
| db.session.commit() | ||
| return make_response("", 204) | ||
|
|
||
|
|
||
| @bp.patch("/<task_id>/mark_complete") | ||
| def mark_complete(task_id): | ||
| task = validate_model(Task, task_id) | ||
| task.completed_at = task.completed_at or db.func.now() | ||
| db.session.commit() | ||
| send_task_completed_notification(task) | ||
| return make_response("", 204) | ||
|
|
||
|
|
||
| @bp.patch("/<task_id>/mark_incomplete") | ||
| def mark_incomplete(task_id): | ||
| task = validate_model(Task, task_id) | ||
| task.completed_at = None | ||
| db.session.commit() | ||
| return make_response("", 204) | ||
| 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.
✅