Skip to content

Commit fb463ae

Browse files
Add models & routes to solution
1 parent 64b3ad4 commit fb463ae

File tree

9 files changed

+393
-3
lines changed

9 files changed

+393
-3
lines changed

app/models/goal.py

+14-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
1-
from sqlalchemy.orm import Mapped, mapped_column
1+
from sqlalchemy.orm import Mapped, mapped_column, relationship
22
from ..db import db
33

44
class Goal(db.Model):
55
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
6+
title: Mapped[str]
7+
tasks: Mapped[list["Task"]] = relationship(back_populates="goal")
8+
9+
def to_dict(self):
10+
return {
11+
"id": self.id,
12+
"title": self.title,
13+
}
14+
15+
@classmethod
16+
def from_dict(cls, goal_data):
17+
new_goal = cls(title=goal_data["title"])
18+
return new_goal

app/models/task.py

+42-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,46 @@
1-
from sqlalchemy.orm import Mapped, mapped_column
1+
from sqlalchemy.orm import Mapped, mapped_column, relationship
22
from ..db import db
3+
from datetime import datetime
4+
from typing import Optional
5+
from sqlalchemy import ForeignKey
6+
37

48
class Task(db.Model):
59
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
10+
title: Mapped[str]
11+
description: Mapped[str]
12+
completed_at: Mapped[Optional[datetime]]
13+
goal_id: Mapped[Optional[int]] = mapped_column(ForeignKey("goal.id"))
14+
goal: Mapped[Optional["Goal"]] = relationship(back_populates="tasks")
15+
16+
def to_dict(self):
17+
if self.completed_at:
18+
completed = True
19+
else:
20+
completed = False
21+
22+
task_dict = {
23+
"id": self.id,
24+
"title": self.title,
25+
"description": self.description,
26+
"is_complete": completed,
27+
}
28+
29+
if self.goal_id:
30+
task_dict["goal_id"] = self.goal_id
31+
32+
return task_dict
33+
34+
@classmethod
35+
def from_dict(cls, task_data):
36+
completed_at = task_data.get("completed_at")
37+
goal_id = task_data.get("goal_id")
38+
39+
new_task = cls(
40+
title=task_data["title"],
41+
description=task_data["description"],
42+
completed_at=completed_at,
43+
goal_id=goal_id
44+
)
45+
46+
return new_task

app/routes/goal_routes.py

+89-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,89 @@
1-
from flask import Blueprint
1+
from flask import Blueprint, request, abort, make_response, Response
2+
from .route_utilities import validate_model
3+
from app.models.goal import Goal
4+
from app.models.task import Task
5+
from ..db import db
6+
7+
8+
bp = Blueprint("goals_bp", __name__, url_prefix="/goals")
9+
10+
@bp.get("")
11+
def get_all_goals():
12+
query = db.select(Goal)
13+
goals = db.session.scalars(query)
14+
goals_response = [goal.to_dict() for goal in goals]
15+
return goals_response
16+
17+
18+
@bp.get("/<goal_id>")
19+
def get_one_goal(goal_id):
20+
goal = validate_model(Goal, goal_id)
21+
return {"goal": goal.to_dict()}
22+
23+
24+
@bp.put("/<goal_id>")
25+
def update_goal(goal_id):
26+
goal = validate_model(Goal, goal_id)
27+
request_body = request.get_json()
28+
goal.title = request_body["title"]
29+
30+
db.session.add(goal)
31+
db.session.commit()
32+
33+
return {"goal": goal.to_dict()}
34+
35+
36+
@bp.post("")
37+
def create_goal():
38+
request_body = request.get_json()
39+
40+
try:
41+
new_goal = Goal.from_dict(request_body)
42+
43+
except KeyError as error:
44+
response = {"details": f"Invalid data"}
45+
abort(make_response(response, 400))
46+
47+
db.session.add(new_goal)
48+
db.session.commit()
49+
50+
return {"goal": new_goal.to_dict()}, 201
51+
52+
53+
@bp.delete("/<goal_id>")
54+
def delete_goal(goal_id):
55+
goal = validate_model(Goal, goal_id)
56+
db.session.delete(goal)
57+
db.session.commit()
58+
59+
message = f"Goal {goal_id} \"{goal.title}\" successfully deleted"
60+
return {"details": message}
61+
62+
63+
@bp.get("/<goal_id>/tasks")
64+
def get_tasks_from_goal(goal_id):
65+
goal = validate_model(Goal, goal_id)
66+
tasks = [task.to_dict() for task in goal.tasks]
67+
68+
response = goal.to_dict()
69+
response["tasks"] = tasks
70+
return response
71+
72+
73+
@bp.post("/<goal_id>/tasks")
74+
def add_tasks_tp_goal(goal_id):
75+
goal = validate_model(Goal, goal_id)
76+
request_body = request.get_json()
77+
task_ids = request_body["task_ids"]
78+
79+
for task_id in task_ids:
80+
task = validate_model(Task, task_id)
81+
goal.tasks.append(task)
82+
83+
db.session.commit()
84+
85+
response = {
86+
"id": goal.id,
87+
"task_ids": task_ids,
88+
}
89+
return response

app/routes/route_utilities.py

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from flask import abort, make_response
2+
from ..db import db
3+
4+
def validate_model(cls, model_id):
5+
try:
6+
model_id = int(model_id)
7+
except:
8+
response = {"message": f"{cls.__name__} {model_id} invalid"}
9+
abort(make_response(response , 400))
10+
11+
query = db.select(cls).where(cls.id == model_id)
12+
model = db.session.scalar(query)
13+
14+
if not model:
15+
response = {"message": f"{cls.__name__} {model_id} not found"}
16+
abort(make_response(response, 404))
17+
18+
return model

migrations/README

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Single-database configuration for Flask.

migrations/alembic.ini

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# A generic, single database configuration.
2+
3+
[alembic]
4+
# template used to generate migration files
5+
# file_template = %%(rev)s_%%(slug)s
6+
7+
# set to 'true' to run the environment during
8+
# the 'revision' command, regardless of autogenerate
9+
# revision_environment = false
10+
11+
12+
# Logging configuration
13+
[loggers]
14+
keys = root,sqlalchemy,alembic,flask_migrate
15+
16+
[handlers]
17+
keys = console
18+
19+
[formatters]
20+
keys = generic
21+
22+
[logger_root]
23+
level = WARN
24+
handlers = console
25+
qualname =
26+
27+
[logger_sqlalchemy]
28+
level = WARN
29+
handlers =
30+
qualname = sqlalchemy.engine
31+
32+
[logger_alembic]
33+
level = INFO
34+
handlers =
35+
qualname = alembic
36+
37+
[logger_flask_migrate]
38+
level = INFO
39+
handlers =
40+
qualname = flask_migrate
41+
42+
[handler_console]
43+
class = StreamHandler
44+
args = (sys.stderr,)
45+
level = NOTSET
46+
formatter = generic
47+
48+
[formatter_generic]
49+
format = %(levelname)-5.5s [%(name)s] %(message)s
50+
datefmt = %H:%M:%S

migrations/env.py

+113
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import logging
2+
from logging.config import fileConfig
3+
4+
from flask import current_app
5+
6+
from alembic import context
7+
8+
# this is the Alembic Config object, which provides
9+
# access to the values within the .ini file in use.
10+
config = context.config
11+
12+
# Interpret the config file for Python logging.
13+
# This line sets up loggers basically.
14+
fileConfig(config.config_file_name)
15+
logger = logging.getLogger('alembic.env')
16+
17+
18+
def get_engine():
19+
try:
20+
# this works with Flask-SQLAlchemy<3 and Alchemical
21+
return current_app.extensions['migrate'].db.get_engine()
22+
except (TypeError, AttributeError):
23+
# this works with Flask-SQLAlchemy>=3
24+
return current_app.extensions['migrate'].db.engine
25+
26+
27+
def get_engine_url():
28+
try:
29+
return get_engine().url.render_as_string(hide_password=False).replace(
30+
'%', '%%')
31+
except AttributeError:
32+
return str(get_engine().url).replace('%', '%%')
33+
34+
35+
# add your model's MetaData object here
36+
# for 'autogenerate' support
37+
# from myapp import mymodel
38+
# target_metadata = mymodel.Base.metadata
39+
config.set_main_option('sqlalchemy.url', get_engine_url())
40+
target_db = current_app.extensions['migrate'].db
41+
42+
# other values from the config, defined by the needs of env.py,
43+
# can be acquired:
44+
# my_important_option = config.get_main_option("my_important_option")
45+
# ... etc.
46+
47+
48+
def get_metadata():
49+
if hasattr(target_db, 'metadatas'):
50+
return target_db.metadatas[None]
51+
return target_db.metadata
52+
53+
54+
def run_migrations_offline():
55+
"""Run migrations in 'offline' mode.
56+
57+
This configures the context with just a URL
58+
and not an Engine, though an Engine is acceptable
59+
here as well. By skipping the Engine creation
60+
we don't even need a DBAPI to be available.
61+
62+
Calls to context.execute() here emit the given string to the
63+
script output.
64+
65+
"""
66+
url = config.get_main_option("sqlalchemy.url")
67+
context.configure(
68+
url=url, target_metadata=get_metadata(), literal_binds=True
69+
)
70+
71+
with context.begin_transaction():
72+
context.run_migrations()
73+
74+
75+
def run_migrations_online():
76+
"""Run migrations in 'online' mode.
77+
78+
In this scenario we need to create an Engine
79+
and associate a connection with the context.
80+
81+
"""
82+
83+
# this callback is used to prevent an auto-migration from being generated
84+
# when there are no changes to the schema
85+
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
86+
def process_revision_directives(context, revision, directives):
87+
if getattr(config.cmd_opts, 'autogenerate', False):
88+
script = directives[0]
89+
if script.upgrade_ops.is_empty():
90+
directives[:] = []
91+
logger.info('No changes in schema detected.')
92+
93+
conf_args = current_app.extensions['migrate'].configure_args
94+
if conf_args.get("process_revision_directives") is None:
95+
conf_args["process_revision_directives"] = process_revision_directives
96+
97+
connectable = get_engine()
98+
99+
with connectable.connect() as connection:
100+
context.configure(
101+
connection=connection,
102+
target_metadata=get_metadata(),
103+
**conf_args
104+
)
105+
106+
with context.begin_transaction():
107+
context.run_migrations()
108+
109+
110+
if context.is_offline_mode():
111+
run_migrations_offline()
112+
else:
113+
run_migrations_online()

migrations/script.py.mako

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""${message}
2+
3+
Revision ID: ${up_revision}
4+
Revises: ${down_revision | comma,n}
5+
Create Date: ${create_date}
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
${imports if imports else ""}
11+
12+
# revision identifiers, used by Alembic.
13+
revision = ${repr(up_revision)}
14+
down_revision = ${repr(down_revision)}
15+
branch_labels = ${repr(branch_labels)}
16+
depends_on = ${repr(depends_on)}
17+
18+
19+
def upgrade():
20+
${upgrades if upgrades else "pass"}
21+
22+
23+
def downgrade():
24+
${downgrades if downgrades else "pass"}

0 commit comments

Comments
 (0)