Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
714ee43
implements Task model and POST route
aigerimdev May 1, 2025
fa1eac9
Fix Task to_dict to match required response format (no completed_at)
aigerimdev May 1, 2025
3677f3e
implements validate_model helper function
aigerimdev May 3, 2025
3694f34
created new file for helper functions: validate_model, create_model_f…
aigerimdev May 3, 2025
31a6e68
Add GET /books route to retrieve all books with optional query filtering
aigerimdev May 5, 2025
faf51bc
Add GET /books/<book_id> route to retrieve a single book by ID
aigerimdev May 5, 2025
1aae7b3
feat: fix route name conflict and add update/delete task functionality
aigerimdev May 5, 2025
1df77bd
Fix Task model and helper methods to pass all test cases
aigerimdev May 5, 2025
7527ada
implement wave 2: sorting tasks by title (asc/desc)
aigerimdev May 5, 2025
3bbd21c
implements mark task complete
aigerimdev May 7, 2025
42eac36
implements route helper functions
aigerimdev May 7, 2025
5b5b454
fix: mark_incomplete now sets completed_at to None, mark_complete set…
aigerimdev May 7, 2025
275ae6a
implements PATCH endpoint for incomplete task
aigerimdev May 7, 2025
68dfbbc
feat: add Slack notification on task completion
aigerimdev May 8, 2025
3ce09d1
creates a Goal model and classmethod: from_dict, instance method: to_…
aigerimdev May 8, 2025
8ed113b
implements get_models_with_filters helper function
aigerimdev May 8, 2025
dea2dc5
Refactor: load Slack channel from .env and clean up unused code
aigerimdev May 8, 2025
9911b3c
"Complete: add missing assertions to test cases for mark_complete and…
aigerimdev May 8, 2025
260e329
fix: return correct model name in create_model_from_dict response
aigerimdev May 8, 2025
5bc8c03
test: add full CRUD test coverage for goals in Wave 5
aigerimdev May 8, 2025
868d87e
refactor: rename Blueprint from tasks_bp to bp
aigerimdev May 8, 2025
1278e4b
remove unnecessary code
aigerimdev May 8, 2025
a617c1d
add id for to_dict method
aigerimdev May 8, 2025
25c6d12
feat: add one-to-many relationship between goals and tasks
aigerimdev May 8, 2025
2d6b6a5
new chnages to make tests to pass
aigerimdev May 9, 2025
e4214ed
remove draf parts
aigerimdev May 9, 2025
3e9441c
remove draft parts
aigerimdev May 9, 2025
9b5cb98
clear old task-goal assignments before assigning new ones
aigerimdev May 9, 2025
9645e19
conditionally include goal_id in task response
aigerimdev May 9, 2025
ed16e78
adds helper function
aigerimdev May 9, 2025
ebf77ff
Finish goal-task nested routes and helper refactor
aigerimdev May 9, 2025
27b5273
Complete all task routes with Slack integration
aigerimdev May 9, 2025
23e26f8
test: add Wave 4 tests for marking tasks complete/incomplete
aigerimdev May 9, 2025
1b3ac45
finalize project
aigerimdev May 9, 2025
83e07de
test: add edge case tests to achieve 100% coverage
aigerimdev May 9, 2025
b9ae773
add wsgi entry point for gunicorn
aigerimdev May 9, 2025
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
5 changes: 5 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
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

def create_app(config=None):
Expand All @@ -18,5 +20,8 @@ def create_app(config=None):
migrate.init_app(app, db)

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


return app
21 changes: 19 additions & 2 deletions app/models/goal.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
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]
tasks: Mapped[list["Task"]] = relationship(back_populates="goal")

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

def to_dict(self, include_tasks=False):
goal_dict = {
"id": self.id,
"title": self.title
}
# # Add list of tasks only if requested
if include_tasks:
goal_dict["tasks"] = [task.to_dict(include_goal_id=True) for task in self.tasks]
return goal_dict
32 changes: 31 additions & 1 deletion app/models/task.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import ForeignKey
from ..db import db
from typing import Optional
from sqlalchemy import DateTime
from datetime import datetime

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)
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):
return cls(
title=task_data["title"],
description=task_data["description"],
completed_at=task_data.get("completed_at") # True if task is done
)

def to_dict(self, include_goal_id=False):
task_dict = {
"id": self.id,
"title": self.title,
"description": self.description,
"is_complete": self.completed_at is not None
}

# Only include goal_id if requested
if include_goal_id:
task_dict["goal_id"] = self.goal_id
return task_dict
74 changes: 73 additions & 1 deletion app/routes/goal_routes.py
Original file line number Diff line number Diff line change
@@ -1 +1,73 @@
from flask import Blueprint
from flask import Blueprint, request, Response
from app.models.task import Task
from app.models.goal import Goal
from app import db
from .route_utilities import validate_model, get_models_with_filters, create_model_from_dict, assign_tasks
import requests
import os

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

# create goal
@bp.post("")
def create_goal():
goal_data = request.get_json()
return create_model_from_dict(Goal, goal_data)

# get all goals
@bp.get("")
def get_all_goals():
return get_models_with_filters(Goal, request.args)

# get one goal
@bp.get("/<goal_id>")
def get_one_goal(goal_id):
goal = validate_model(Goal, goal_id)

return {"goal": goal.to_dict()}

# update goal
@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")

# delete goal
@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")

# nested routes
@bp.post("/<goal_id>/tasks")
def assign_tasks_to_goal(goal_id):
goal = validate_model(Goal, goal_id)
request_body = request.get_json()
task_ids = request_body.get("task_ids", [])

tasks = assign_tasks(goal, task_ids)
db.session.commit()

return {
"id": goal.id,
"task_ids": [task.id for task in tasks]
}, 200


@bp.get("/<goal_id>/tasks")
def get_tasks_by_goal(goal_id):
goal = validate_model(Goal, goal_id)
return {
"id": goal.id,
"title": goal.title,
"tasks": [task.to_dict(include_goal_id=True) for task in goal.tasks]
}, 200
72 changes: 72 additions & 0 deletions app/routes/route_utilities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from ..db import db
from flask import abort, make_response
from app.models.task import Task


def validate_model(cls, model_id):
try:
model_id = int(model_id)
except ValueError:
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


def create_model_from_dict(cls, task_data):
try:
new_instance = cls.from_dict(task_data)
except KeyError as error:
response = {"details": f"Invalid data"}
abort(make_response(response, 400))
db.session.add(new_instance)
db.session.commit()
model_name = cls.__name__.lower()

return {model_name: new_instance.to_dict()}, 201


def get_models_with_filters(cls, filters=None):
query = db.select(cls)

sort_order = None
if filters:
# Pull out 'sort' and prevent it from becoming a filter
sort_order = filters.get("sort", None)

# Apply other filters
for attribute, value in filters.items():
if hasattr(cls, attribute):
query = query.where(getattr(cls, attribute).ilike(f"%{value}%"))

# Apply sorting
if sort_order == "asc":
query = query.order_by(cls.title.asc())
elif sort_order == "desc":
query = query.order_by(cls.title.desc())
else:
query = query.order_by(cls.id)

models = db.session.scalars(query)
return [model.to_dict() for model in models]


def assign_tasks(goal, task_ids):
# Clear current tasks
for task in goal.tasks:
task.goal_id = None

tasks = []
for task_id in task_ids:
task = validate_model(Task, task_id)
task.goal_id = goal.id
tasks.append(task)

return tasks
91 changes: 90 additions & 1 deletion app/routes/task_routes.py
Original file line number Diff line number Diff line change
@@ -1 +1,90 @@
from flask import Blueprint
from flask import Blueprint, abort, make_response, request, Response
from app.models.task import Task
from app import db
from .route_utilities import create_model_from_dict
from .route_utilities import validate_model, get_models_with_filters
from datetime import datetime, UTC
from dotenv import load_dotenv
import requests
import os

load_dotenv()


bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") # main rout

# create task
@bp.post("")
def create_task():
task_data = request.get_json()
return create_model_from_dict(Task, task_data)

# get all tasks
@bp.get("")
def get_all_tasks():
return get_models_with_filters(Task, request.args)

# get one task
@bp.get("/<task_id>")
def get_one_task(task_id):
task = validate_model(Task, task_id)
return {"task": task.to_dict(include_goal_id=(task.goal_id is not None))}

# update task
@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"]

db.session.commit()

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

# delete task
@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")

SLACK_TOKEN = os.environ.get("SLACK_BOT_TOKEN")
SLACK_CHANNEL = os.environ.get("SLACK_CHANNEL")

# Sends a Slack message when a task marked completed
def notify_slack(task_title):
headers = {
"Authorization": f"Bearer {SLACK_TOKEN}",
"Content-Type": "application/json"
}
payload = {
"channel": SLACK_CHANNEL,
"text": f"Someone just completed the task {task_title}"
}
response = requests.post("https://slack.com/api/chat.postMessage", headers=headers, json=payload)
return response.json()

# partially update mark complete
@bp.patch("/<task_id>/mark_complete")
def mark_complete(task_id):
task = validate_model(Task, task_id)

if task.completed_at is None:
task.completed_at = datetime.now(UTC)
db.session.commit()
notify_slack(task.title)

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

# partially update incompleted
@bp.patch("/<task_id>/mark_incomplete")
def mark_task_incomplete(task_id):
task = validate_model(Task, task_id)
task.completed_at = None
db.session.commit()

return Response(status=204, mimetype="application/json")
1 change: 1 addition & 0 deletions migrations/README
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Single-database configuration for Flask.
50 changes: 50 additions & 0 deletions migrations/alembic.ini
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
Loading