Skip to content

C22 Phoenix - Liubov D. #46

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

Open
wants to merge 30 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f71d8f2
Created migration, registered blueprint, created POST endpoint
LiubovDav Nov 3, 2024
6ffaa32
Fixed POST endpoint logic, enabled test for POST, recreated migration
LiubovDav Nov 3, 2024
5cf4d3c
Added endpoints GET(tasks), GET (task), PUT, DELETE. Enabled tests.
LiubovDav Nov 4, 2024
d36600e
Fixed the issues in PUT endpoint.
LiubovDav Nov 4, 2024
c184c18
Fixed the issues in POST and DELETE implementation.
LiubovDav Nov 4, 2024
d3b9a04
Fixed POST endpoint.
LiubovDav Nov 4, 2024
e1c34d9
Changed Blueprint @, to_dict function
LiubovDav Nov 5, 2024
4a93336
Created from_dict function, changed POST endpoint
LiubovDav Nov 5, 2024
413cb82
Implemented sort by title.
LiubovDav Nov 5, 2024
4170310
Implemented wave_03.
LiubovDav Nov 5, 2024
357c86b
Created goal model, goal_routes, changed task_routes, update migration.
LiubovDav Nov 6, 2024
8d49583
Added slack_bot
LiubovDav Nov 7, 2024
72703ba
Fixed goal and goal_routes
LiubovDav Nov 7, 2024
12b2671
Implemented test_wave_5
LiubovDav Nov 7, 2024
e2a4241
Completed tests wave_5
LiubovDav Nov 7, 2024
7fcb7dc
Created test wave_05
LiubovDav Nov 7, 2024
c775d41
Created one-to-many relationship goal-task, upgrade migration
LiubovDav Nov 7, 2024
84ca5c4
Added parameter to send or not messages in Slack
LiubovDav Nov 8, 2024
d042708
Changed models task and goal, goals_routes.pu, task_routes.py
LiubovDav Nov 8, 2024
87586e0
Created test wave_04
LiubovDav Nov 8, 2024
f6eff49
Improved test wave_04
LiubovDav Nov 8, 2024
a61d5c7
Added goal_id to task to_dict
LiubovDav Nov 8, 2024
19de496
Implemented get all task of a goal
LiubovDav Nov 8, 2024
c92f8fa
Fixed some typos
LiubovDav Nov 8, 2024
de57375
changed facts
LiubovDav Nov 18, 2024
b815c8e
Rolled back to slack_sdk usage
LiubovDav Nov 18, 2024
7ec9c0f
Changed send messages in Slack implementation.
LiubovDav Nov 19, 2024
472b7a2
Restored initial tests, fixed some issues discovered on code review
LiubovDav Dec 19, 2024
92bb214
Changed migration, added Cors
LiubovDav Dec 28, 2024
33086d8
Fixed typos
LiubovDav Jan 5, 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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,14 @@ This project is designed to fulfill the features described in detail in each wav
1. [Wave 6: Establishing a one-to-many relationship between two models](ada-project-docs/wave_06.md)
1. [Wave 7: Deployment](ada-project-docs/wave_07.md)
1. [Optional Enhancements](ada-project-docs/optional-enhancements.md)

psql -U postgres postgres
drop database task_list_api_development;
create database task_list_api_development;
drop database task_list_api_test;
create database task_list_api_test;
\q
rm -rf migrations
flask db init
flask db migrate -m "Recreate model migrations"
flask db upgrade
2 changes: 1 addition & 1 deletion ada-project-docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ SQLALCHEMY_TEST_DATABASE_URI=postgresql+psycopg2://postgres:postgres@localhost:5

Run `$ flask db init`.

**_After you make your first model in Wave 1_**, run the other commands `migrate` and `upgrade`.
**_After you make your first model in Wave 1_**, run the other commands `$ flask db migrate` and `$ flask db upgrade`.

## Run `$ flask run` or `$ flask run --debug`

Expand Down
4 changes: 2 additions & 2 deletions ada-project-docs/wave_01.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ Tasks should contain these attributes. **The tests require the following columns

### Tips

- Pay attention to the exact shape of the expected JSON. Double-check nested data structures and the names of the keys for any mispellings.
- That said, remember that dictionaries do not have an implied order. This is still true in JSON with objects. When you make Postman requests, the order of the key/value pairings within the response JSON object does not need to match the order specified in this document. (The term "object" in JSON is analagous to "dictionary" in Python.)
- Pay attention to the exact shape of the expected JSON. Double-check nested data structures and the names of the keys for any misspellings.
- That said, remember that dictionaries do not have an implied order. This is still true in JSON with objects. When you make Postman requests, the order of the key/value pairings within the response JSON object does not need to match the order specified in this document. (The term "object" in JSON is analogous to "dictionary" in Python.)
- Use the tests in `tests/test_wave_01.py` to guide your implementation.
- You may feel that there are missing tests and missing edge cases considered in this wave. This is intentional.
- You have fulfilled wave 1 requirements if all of the wave 1 tests pass.
Expand Down
2 changes: 1 addition & 1 deletion ada-project-docs/wave_02.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ The following are required routes for wave 2. Feel free to implement the routes

### Tips

- Pay attention to the exact shape of the expected JSON. Double-check nested data structures and the names of the keys for any mispellings.
- Pay attention to the exact shape of the expected JSON. Double-check nested data structures and the names of the keys for any misspellings.
- Use the tests in `tests/test_wave_02.py` to guide your implementation.
- You may feel that there are missing tests and missing edge cases considered in this wave. This is intentional.
- You have fulfilled wave 2 requirements if all of the wave 2 tests pass.
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 @@ -17,7 +17,7 @@ This wave requires more test writing.
- The tests you need to write are scaffolded in the `test_wave_05.py` file.
- 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.
- Pay attention to the exact shape of the expected JSON. Double-check nested data structures and the names of the keys for any mispellings.
- Pay attention to the exact shape of the expected JSON. Double-check nested data structures and the names of the keys for any misspellings.
- You can model your tests off of the Wave 1 tests for Tasks.
- Some tests use a [fixture](https://docs.pytest.org/en/6.2.x/fixture.html) named `one_goal` that is defined in `tests/conftest.py`. This fixture saves a specific goal to the test database.

Expand Down
2 changes: 1 addition & 1 deletion ada-project-docs/wave_06.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Secondly, we should create our new route, `/goals/<goal_id>/tasks`, so that our

- Use lesson materials and independent research to review how to set up a one-to-many relationship in Flask.
- Remember to run `flask db migrate` and `flask db upgrade` whenever there is a change to the model.
- Pay attention to the exact shape of the expected JSON. Double-check nested data structures and the names of the keys for any mispellings.
- Pay attention to the exact shape of the expected JSON. Double-check nested data structures and the names of the keys for any misspellings.
- Use the tests in `tests/test_wave_06.py` to guide your implementation.
- Some tests use a fixture named `one_task_belongs_to_one_goal` that is defined in `tests/conftest.py`. This fixture saves a task and a goal to the test database, and uses SQLAlchemy to associate the goal and task together.

Expand Down
11 changes: 10 additions & 1 deletion app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
from flask import Flask
from .db import db, migrate
from .models import task, goal
from .models import goal, task

Choose a reason for hiding this comment

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

Once we have other import paths to our models (here, through the blueprints, which import the models), we technically don't need the imports here any more. It's fine to leave them for clarity (and a reminder that any other models we add would need to be included until other routes are setup), but if the VS Code warning is bothersome, feel free to remove these.

from .routes.task_routes import bp as tasks_bp
from .routes.goal_routes import bp as goals_bp
Comment on lines +4 to +5

Choose a reason for hiding this comment

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

Nice renamed importing of the bp Blueprints from each route file so that we can still properly register them.

Alternatively, we could import just the module itself, then access the bp values with dot access. So here

from .routes import task_routes
from .routes import goal_routes

And then at the registration

    app.register_blueprint(task_routes.bp)
    app.register_blueprint(goal_routes.bp)

import os
from flask_cors import CORS

def create_app(config=None):
app = Flask(__name__)

CORS(app)
app.config['CORS_HEADERS'] = 'Content-Type'

app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('SQLALCHEMY_DATABASE_URI')

Expand All @@ -18,5 +24,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
20 changes: 19 additions & 1 deletion app/models/goal.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
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")


def to_dict(self):
return dict(
goal=dict(
id=self.id,
title=self.title
)
)

# from JSON to model
@classmethod
def from_dict(cls, goal_data):
return cls(
title=goal_data["title"]
)
44 changes: 43 additions & 1 deletion app/models/task.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,47 @@
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import ForeignKey
from ..db import db
from datetime import datetime
from typing import Optional

class Task(db.Model):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
title: Mapped[str]
description: Mapped[str]
completed_at: Mapped[Optional[str]]

Choose a reason for hiding this comment

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

👀 There wasn't anything in our functionality that would have required storing the completed_at data as an actual datetime, but if we needed to do anything like determining how much time had passed since being completed, or sorting by completion time, then a string would not be appropriate.

goal_id: Mapped[Optional[int]] = mapped_column(ForeignKey("goal.id"))
goal: Mapped[Optional["Goal"]] = relationship(back_populates="tasks")


# from model to JSON
def to_dict(self):
if self.goal_id is None:
return dict(
id=self.id,
title=self.title,
description=self.description,
is_complete=self.completed_at!=None
)
else:
return dict(
id=self.id,
title=self.title,
description=self.description,
is_complete=self.completed_at!=None,
goal_id=self.goal_id
)
Comment on lines +18 to +32

Choose a reason for hiding this comment

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

Nice. This addresses the underlying test issue. Notice that there is a significant amount of repetition between the two halves of the conditional. Instead of duplicating so much of the dictionary creation, we could take an approach more like this. (Also remember that we prefer is/is not to compare with None)

        result = dict(
            id=self.id,
            title=self.title,
            description=self.description,
            is_complete=self.completed_at is not None
            )

        if self.goal_id:
            result["goal_id"] = self.goal_id

        return result


# from JSON to model
@classmethod
def from_dict(cls, task_data):
return cls(
title=task_data["title"],
description=task_data["description"]
)







147 changes: 146 additions & 1 deletion app/routes/goal_routes.py

Choose a reason for hiding this comment

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

Remember to always create a __init__.py in any new folders you create. It may appear that the imports are working correctly, but there are corner cases where this can become a problem. So the recommendation is to always make the __init__.py.

Original file line number Diff line number Diff line change
@@ -1 +1,146 @@
from flask import Blueprint
from flask import Blueprint, request, Response, make_response, abort
from app.models.goal import Goal
from ..db import db
from ..models.task import Task
from ..routes.task_routes import validate_task

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

@bp.post("")
def create_goal():
request_body = request.get_json()

try:
new_goal = Goal.from_dict(request_body)

except KeyError as e:
response = {
"details": "Invalid data"
}
abort(make_response(response, 400))
Comment on lines +13 to +20

Choose a reason for hiding this comment

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

Note how similar this error handling is to the POST route in Task. We could refactor this to have the shared logic in a helper. There's an example of this in Learn.


db.session.add(new_goal)
db.session.commit()

return new_goal.to_dict(), 201

@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}%"))

sort_param = request.args.get("sort")
if sort_param:
if sort_param == "asc":
query = query.order_by(Goal.title.asc())
elif sort_param == "desc":
query = query.order_by(Goal.title.desc())

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

goals_response = []
for goal in goals:
goals_response.append(
{
"id": goal.id,
"title": goal.title
}
Comment on lines +48 to +51

Choose a reason for hiding this comment

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

Prefer to use the to_dict logic here and throughout to avoid duplicating the dictionary structure logic.

)

return goals_response

@bp.get("/<goal_id>")
def get_one_goal(goal_id):
goal = validate_goal(goal_id)

return goal.to_dict()


@bp.put("/<goal_id>")
def update_goal(goal_id):
goal = validate_goal(goal_id)
request_body = request.get_json()

goal.title = request_body["title"]

Choose a reason for hiding this comment

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

As with the Task PUT route, this route could benefit from key error handling similar to the POST request.


db.session.commit()

response = {
"goal": {
"id": goal.id,
"title": goal.title
}
}

return response, 200

@bp.delete("/<goal_id>")
def delete_goal(goal_id):
goal = validate_goal(goal_id)
db.session.delete(goal)
db.session.commit()

response = {
"details": f"Goal {goal_id} \"{goal.title}\" successfully deleted"
}

return response, 200

def validate_goal(goal_id):

Choose a reason for hiding this comment

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

👀 Note that we could refactor this validate_goal and the validate_task functions to be able to handle both.

try:
goal_id = int(goal_id)
except:
response = {"details": "Invalid data"}

abort(make_response(response , 400))

query = db.select(Goal).where(Goal.id == goal_id)
goal = db.session.scalar(query)

if not goal:
response = {"message": f"Goal {goal_id} not found"}
abort(make_response(response, 404))

return goal

@bp.post("/<goal_id>/tasks")
def assign_tasks_to_goal(goal_id):
goal = validate_goal(goal_id)

request_body = request.get_json()
task_ids = request_body["task_ids"]

for task_id in task_ids:
task = validate_task(task_id)
task.goal_id = goal_id

Choose a reason for hiding this comment

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

Notice that by updating the goal id associated with the tasks, this effectively has "append" behavior (any existing tasks already associated with this Goal will remain unchanged). The tests don't provide enough specificity to determine whether append or replace behavior is desired, but we should be consistent between the operation we carry out here, and the result that we return (see below).

db.session.add(task)

db.session.commit()

response = {
"id": int(goal_id),
"task_ids": task_ids

Choose a reason for hiding this comment

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

Echoing back the task_ids that were supplied here effectively acts like a "replace" behavior. We assume that the only tasks belonging to the Goal after this endpoint runs are the tasks we were given in this call. But the logic above is taking an "append" approach, meaning there could still be other tasks associated with this goal, which we should list here (by fetching the ids of all tasks now associated with the goal rather than using the task ids only supplied to this call).

}

return response, 200

@bp.get("/<goal_id>/tasks")
def get_goal_with_assigned_tasks(goal_id):
goal = validate_goal(goal_id)

query = db.select(Task).where(Goal.id == goal_id)

tasks = db.session.scalars(query)

response = {}
response["id"] = goal.id
response["title"] = goal.title
Comment on lines +140 to +141

Choose a reason for hiding this comment

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

A dictionary with these two keys could be produced by the goal to_dict method (if it weren't wrapping the goal dict in an additional layer). Then we could graft in the additional required task data.

response["tasks"] = []

response["tasks"] = [task.to_dict() for task in tasks]

return response, 200
Loading