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
2 changes: 1 addition & 1 deletion ada-project-docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ We will need to complete part – or all – of some of the tests for this proje

You may wish to review details about how to run tests [here](https://github.com/AdaGold/viewing-party#details-about-how-to-run-tests).

Recall that it is always a good idea to search the file for any `@pytest.mark.skip` decorators you may have missed before moving to the next wave.
Recall that it is always a good idea to search the file for any `# @pytest.mark.skip` decorators you may have missed before moving to the next wave.

### Code Coverage

Expand Down
2 changes: 1 addition & 1 deletion ada-project-docs/wave_05.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Our plan for this wave is to be able to create, read, update, and delete differe

This wave requires more test writing. The tests you need to write are scaffolded in the `test_wave_05.py` file.
- As with incomplete tests in other waves, you should comment out the `Exception` when implementing a test.
- These tests are currently skipped with `@pytest.mark.skip(reason="test to be completed by student")` and the function body has `pass` in it.
- These tests are currently skipped with `# @pytest.mark.skip(reason="test to be completed by student")` and the function body has `pass` in it.
- Once you implement these tests you should remove the `skip` decorator and the `pass`.

For the tests you write, use the requirements in this document to guide your test writing.
Expand Down
10 changes: 5 additions & 5 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 task_bp
from .routes.goal_routes import bp as goal_bp

import os

def create_app(config=None):
Expand All @@ -10,13 +12,11 @@ def create_app(config=None):
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(task_bp)
app.register_blueprint(goal_bp)
return app
16 changes: 15 additions & 1 deletion app/models/goal.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
from sqlalchemy.orm import Mapped, mapped_column
from ..db import db
from sqlalchemy.orm import Mapped, mapped_column, relationship

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")

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

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

31 changes: 30 additions & 1 deletion app/models/task.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
from sqlalchemy.orm import Mapped, mapped_column
from ..db import db
from datetime import datetime
from typing import Optional
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship

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")

def to_dict(self):
return {
"id": self.id,
"title": self.title,
"description": self.description,
"is_complete": bool(self.completed_at)
}

@classmethod
def from_dict(cls, task_data):
# If `is_complete` is True, set completed_at to now; otherwise keep it None.
is_complete = task_data.get("is_complete", False)

completed_at = datetime.now() if is_complete else None

return cls(title=task_data["title"],
description=task_data["description"],
completed_at=completed_at,
goal_id= task_data.get("goal_id", None)
)
59 changes: 58 additions & 1 deletion app/routes/goal_routes.py
Original file line number Diff line number Diff line change
@@ -1 +1,58 @@
from flask import Blueprint
from flask import Blueprint, request
from ..routes.routes_utilities import (
validate_model,
create_model,
get_models_with_filters,
update_model_fields,
delete_model,
assign_related_by_ids,
)
from ..models.goal import Goal
from ..models.task import Task
from ..db import db

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

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

@bp.get("/<id>")
def get_single_goal(id):
goal = validate_model(Goal, id)

return goal.to_dict()

@bp.get("/<id>/tasks")
def get_all_goal_tasks(id):
goal = validate_model(Goal, id)
tasks = []
for task in goal.tasks:
t = task.to_dict()
# Tests expect tasks returned for a goal to include the goal_id
t["goal_id"] = goal.id
tasks.append(t)

return {"id": goal.id, "title": goal.title, "tasks": tasks}

@bp.post("")
def create_goal():
model_dict, status_code = create_model(Goal, request.get_json())
return model_dict, status_code

@bp.post("/<id>/tasks")
def post_task_ids_to_goal(id):
goal = validate_model(Goal, id)
data = request.get_json() or {}
return assign_related_by_ids(goal, "tasks", Task, data.get("task_ids"))

@bp.put("/<id>")
def update_goal(id):
goal = validate_model(Goal, id)
request_data = request.get_json()
return update_model_fields(goal, request_data, ["title"])

@bp.delete("/<id>")
def delete_goal(id):
goal = validate_model(Goal, id)
return delete_model(goal)
71 changes: 71 additions & 0 deletions app/routes/routes_utilities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from flask import abort, make_response
from ..db import db

def validate_model(cls, id):
try:
id = int(id)
except (ValueError, TypeError):
abort(make_response({"details": "Invalid id"}, 400))

model = db.session.get(cls, id)

if not model:
abort(make_response({"details": "Not found"}, 404))

return model

def create_model(cls, model_data):
try:
new_model = cls.from_dict(model_data)
except Exception:
abort(make_response({"details": "Invalid data"}, 400))

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

return new_model.to_dict(), 201

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

# Handle sorting
sort = args.get("sort") if args else None
if sort == "asc":
query = query.order_by(cls.title.asc())
elif sort == "desc":
query = query.order_by(cls.title.desc())
else:
query = query.order_by(cls.id)

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

def update_model_fields(model, data, allowed_fields):
if not isinstance(data, dict):
abort(make_response({"details": "Invalid data"}, 400))

for field in allowed_fields:
if field in data:
setattr(model, field, data[field])

db.session.commit()
return make_response("", 204)

def delete_model(model):
db.session.delete(model)
db.session.commit()
return make_response("", 204)

def assign_related_by_ids(parent, relation_name, child_cls, ids, response_key="task_ids"):
if not isinstance(ids, list):
abort(make_response({"details": "Invalid data"}, 400))

related = [validate_model(child_cls, cid) for cid in ids]
setattr(parent, relation_name, related)
db.session.commit()

return {
"id": parent.id,
response_key: [getattr(obj, "id") for obj in related]
}, 200
94 changes: 93 additions & 1 deletion app/routes/task_routes.py
Original file line number Diff line number Diff line change
@@ -1 +1,93 @@
from flask import Blueprint
from flask import Blueprint, request, Response
from ..models.task import Task
from ..db import db
from ..routes.routes_utilities import (
validate_model,
create_model,
get_models_with_filters,
update_model_fields,
delete_model,
)
from datetime import datetime
from dotenv import load_dotenv
import os

load_dotenv()
SLACK_TOKEN = os.getenv("SLACK_TOKEN")
SLACK_CHANNEL = os.getenv("SLACK_CHANNEL")

bp = Blueprint("task_bp", __name__, url_prefix='/tasks')

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

@bp.get("/<id>")
def get_single_tasks(id):
task = validate_model(Task, id)
task_dict = task.to_dict()
# Include goal_id in the single-task response when applicable (Wave 6)
if task.goal_id is not None:
task_dict["goal_id"] = task.goal_id
return task_dict

@bp.patch("/<id>/mark_complete")
def mark_task_complete(id):
task = validate_model(Task, id)
# No request body is expected for marking a task complete; simply set
# the completed timestamp.
task.completed_at = datetime.now()

db.session.commit()

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

def send_completed_task_to_slack(task):
import requests

slack_message_url = "https://slack.com/api/chat.postMessage"
# channel is required by Slack API; allow configuration via SLACK_CHANNEL env var
channel = SLACK_CHANNEL or os.getenv("SLACK_CHANNEL")

message = {
"channel": channel,
"text": f"Someone just completed the task '{task.title}'!"
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {SLACK_TOKEN}"
}

response = requests.post(slack_message_url, json=message, headers=headers)
# print(response.status_code, response.text) # debug output
# print("SLACK_TOKEN:", SLACK_TOKEN) # debug output

response.raise_for_status()

@bp.patch("/<id>/mark_incomplete")
def mark_task_incomplete(id):
task = validate_model(Task, id)

task.completed_at = None

db.session.commit()

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

@bp.post("")
def create_task():
model_dict, status_code = create_model(Task, request.get_json())
return model_dict, status_code

@bp.put("/<id>")
def replace_task(id):
task = validate_model(Task, id)

request_body = request.get_json()
return update_model_fields(task, request_body, ["title", "description", "completed_at"])

@bp.delete("/<id>")
def delete_task(id):
task = validate_model(Task, id)
return delete_model(task)
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