Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
.vscode
.DS_Store

# log files
app.log

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
Expand Down
2 changes: 1 addition & 1 deletion ada-project-docs/wave_06.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Wave 6: Establishing a One-to-Many Relationship
# Wave 6: Establishing a One-to-Many Relationship

Choose a reason for hiding this comment

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

🔎 When adding files to a commit, be sure to check whether there are any unintentional changes that can be undone.


## Goal

Expand Down
54 changes: 51 additions & 3 deletions app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
from flask import Flask
from .db import db, migrate
from .models import task, goal
from .routes.task_routes import bp as tasks_bp
from .routes.goal_routes import bp as goals_bp
import os
from dotenv import load_dotenv
import logging
from logging.config import dictConfig
from flask_cors import CORS


load_dotenv()


def create_app(config=None):
app = Flask(__name__)
# CORS(app) # Enable CORS for all routes

# configure_logging(app)
configure_logging(app)
logger = logging.getLogger(__name__)

app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('SQLALCHEMY_DATABASE_URI')
Expand All @@ -14,9 +29,42 @@ def create_app(config=None):
# to override the app's default settings for testing
app.config.update(config)

db.init_app(app)
migrate.init_app(app, db)
try:
db.init_app(app)
migrate.init_app(app, db)
logger.info("Database initialized successfully.")

with app.app_context():
# db.session.execute("SELECT 1")
logger.info("DB connection test passed")
except Exception as e:
logger.exception("Error during DB initialization or connection")

# Register Blueprints here
# Register Blueprints
app.register_blueprint(tasks_bp)
app.register_blueprint(goals_bp)

# configure_logging(app)

configure_logging(app)
logger = logging.getLogger(__name__)

return app


def configure_logging(app):
# Clear any default Flask handlers (useful for avoiding duplicate logs)
for handler in app.logger.handlers:
app.logger.removeHandler(handler)

logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]',
handlers=[
logging.StreamHandler(), # logs to console
logging.FileHandler("app.log") # log to file
]
)

# log Flask startup
logging.getLogger().info("Logging is set up.")
36 changes: 35 additions & 1 deletion app/models/goal.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,39 @@
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..db import db
from sqlalchemy import ForeignKey
from typing import Optional

class Goal(db.Model):

Choose a reason for hiding this comment

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

👍 Your Goal implementation is consistent with your Task model. Check the Task feedback for anything that could apply here.

id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
title: Mapped[str]
tasks: Mapped[list["Task"]] = relationship(back_populates="goal")

@classmethod
def from_dict(cls, goal_data):
goal = cls(title=goal_data["title"])

return goal

def goal_with_tasks(self):
task_list = []

for task in self.tasks:
task = task.to_dict()
task["goal_id"] = self.id
task_list.append(task)

goal_with_tasks = self.to_dict()
goal_with_tasks["tasks"] = task_list

return goal_with_tasks

def to_dict(self):
goal_as_dict = {
"id": self.id,
"title": self.title,
}

# if self.tasks:
# goal_as_dict["task_ids"] = [task.id for task in self.tasks]
Comment on lines +36 to +37

Choose a reason for hiding this comment

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

Good call removing this. In the project, the task data isn't included by default. The client must explcitly ask for it through an endpoint. It's a common consideration in an API how much variable length data to include per record by default, and often the static length data and variable length data will be split as we see in this project.


return goal_as_dict
41 changes: 40 additions & 1 deletion app/models/task.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,44 @@
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, column_property, relationship
from sqlalchemy import ForeignKey
from typing import Optional
from sqlalchemy import DateTime
from datetime import datetime
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(DateTime, nullable=True)
is_complete = column_property(completed_at != None)
goal_id: Mapped[Optional[int]] = mapped_column(ForeignKey("goal.id"))
goal: Mapped[Optional["Goal"]] = relationship(back_populates="tasks")

@classmethod
def from_dict(cls, task_data):
goal_id=task_data.get("goal_id")
completed_at = task_data.get("completed_at", None)

return cls(
title=task_data["title"],
description=task_data["description"],
Comment on lines +23 to +24

Choose a reason for hiding this comment

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

👍 By reading title and description directly as keys we can trigger a KeyError if they are absent, giving us a way to indicate they are required.

completed_at = completed_at,
goal_id=goal_id
)


def to_dict(self, include_completed_at=False):

Choose a reason for hiding this comment

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

👍 Nice idea to include an optional parameter allowing the inclusion of completed_at if required. We didn't show customizing to_dict in the curriculum materials, but something like this can work very well, as can including separate variations of to_dict to be called in certain contexts.

In larger applications where there may need to be many "rendered" forms of the record types, the problem of converting from model instances to a rendered form may be delegated to an entirely separate group of classes, whose sole responsibility is generating output formats from that model type.

task_as_dict = {

Choose a reason for hiding this comment

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

👍 Not including the outer task "envelope" wrapper in our to_dict keeps it more general-purpose. We can use it in endpoints that require the envelope by embedding this result in an outer dictionary, or use in other cases where the wrapper is not called for in the specifications.

"id": self.id,
"title": self.title,
"description": self.description,
"is_complete": bool(self.is_complete)
}

Choose a reason for hiding this comment

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

🔎 Watch out that the end of the wrapped content doesn't extend before the indentation level we started at. It's still valid Python, but it's confusing to read jagged code.


if include_completed_at:
task_as_dict["completed_at"] = self.completed_at

if self.goal:
task_as_dict["goal_id"] = self.goal.id
Comment on lines +41 to +42

Choose a reason for hiding this comment

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

👍 Nice logic to add the goal data to the task dictionary only when it's present.


return task_as_dict
Empty file added app/routes/__init__.py
Empty file.
94 changes: 93 additions & 1 deletion app/routes/goal_routes.py
Original file line number Diff line number Diff line change
@@ -1 +1,93 @@
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 ..db import db
from .route_utilities import validate_model, create_model, delete_model
from datetime import datetime
from datetime import timezone
import requests
import os

bp = Blueprint("goals_bp", __name__, url_prefix="/goals")


@bp.post("")
def create_goal():

Choose a reason for hiding this comment

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

👍 Your Goal CRUD routes are consistent with your Task CRUD routes. Check the Task feedback for anything that could apply here.

request_body = request.get_json()
new_goal, status_code = create_model(Goal, request_body)

return {"goal": new_goal}, status_code


@bp.get("")
def get_all_goals():
query = db.select(Goal)

title_param = request.args.get("title")
if title_param:
query = query.where(Goal.title.ilike(f"%{title_param}%"))

query = query.order_by(Goal.id)
result = db.session.execute(query)
goals = result.scalars().all()

goals_response = []
for goal in goals:
goals_response.append(goal.to_dict())

return goals_response


@bp.get("/<goal_id>")
def get_one_goal(goal_id):
goal = validate_model(Goal, goal_id)
return {"goal": goal.to_dict()}


@bp.put("/<goal_id>")
def update_one_goal(goal_id):
task = validate_model(Goal, goal_id)
request_body = request.get_json()

task.title = request_body["title"]

db.session.commit()

return Response(status=204, mimetype="application/json")


@bp.delete("/<goal_id>")
def delete_one_goal(goal_id):

return delete_model(Goal, goal_id)


@bp.post("/<goal_id>/tasks")
def tasks_to_goal(goal_id):
goal = validate_model(Goal, goal_id)

request_body = request.get_json()

if "task_ids" in request_body:
for task in goal.tasks:
task.goal_id = None

task_list = request_body.get("task_ids")

for task_id in task_list:
task = validate_model(Task, task_id)
task.goal_id = goal.id
Comment on lines +71 to +79

Choose a reason for hiding this comment

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

Since we need to validate each Task id anyway, if we stored the resulting tasks in a list, the replacement of the tasks for the goal could be accomplished using the tasks property as

    goal.tasks = tasks

which avoids the need to manually clear the goal association from the existing tasks.


db.session.commit()

return {
"id": goal.id,
"task_ids": task_list
}


@bp.get("/<goal_id>/tasks")
def tasks_for_specific_goal(goal_id):
goal = validate_model(Goal, goal_id)

return goal.goal_with_tasks(), 200
78 changes: 78 additions & 0 deletions app/routes/route_utilities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from flask import abort, make_response, Response
from ..db import db
import os
import requests
import logging


def validate_model(cls, model_id):
try:
model_id = int(model_id)
except:
response = {"message": f":{cls.__name__} with {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__} with {model_id} does not exist"}
abort(make_response(response, 404))

return model


def create_model(cls, model_data):
try:
new_model = cls.from_dict(model_data)
except KeyError as error:
response = {"details": "Invalid data"}
# response = {"message": f"Invalid request: missing {error.args[0]}"}
abort(make_response(response, 400))

db.session.add(new_model)
db.session.commit()

return new_model.to_dict(), 201

def delete_model(cls, model_id):
model = validate_model(cls, model_id)

db.session.delete(model)
db.session.commit()

return Response(status=204, mimetype="application/json")


# Work in progress
def task_to_dict(Task, data):
{
"id": data.id,
"title": data.title,
"description": data.description,
"is_complete": bool(data.completed_at),
"goal": data.goal_id
}

return task_to_dict

def send_message_task_complete_slack(task_title):

Choose a reason for hiding this comment

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

👍 Nice to see this logic extracted from the route.

slack_url = os.environ.get("SLACK_BOT_PATH")
slack_token = os.environ.get("SLACK_BOT_TOKEN")
slack_channel = os.environ.get("SLACK_CHANNEL_ID") # Can also be channel ID like "C01ABCXYZ"

message = {
"channel": slack_channel,
"text": f"Someone just completed the task {task_title}"
}

headers = {
"Authorization": f"Bearer {slack_token}",
"Content-Type": "application/json"
}

response = requests.post(slack_url, json=message, headers=headers)

if not response.ok:
logger = logging.getLogger(__name__)
logger.error(f"Slack message failed: {response.json()}")
Comment on lines +77 to +78

Choose a reason for hiding this comment

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

👍 Oh cool. You got the logger instance.

Loading