Spaces:
Sleeping
Sleeping
'full-project'
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- Dockerfile +19 -0
- alembic.ini +145 -0
- alembic/README +1 -0
- alembic/__pycache__/env.cpython-312.pyc +0 -0
- alembic/env.py +87 -0
- alembic/script.py.mako +28 -0
- alembic/versions/3b6c60669e48_add_project_model_and_relationship_to_.py +52 -0
- alembic/versions/4ac448e3f100_add_due_date_field_to_task_model.py +32 -0
- alembic/versions/__pycache__/3b6c60669e48_add_project_model_and_relationship_to_.cpython-312.pyc +0 -0
- alembic/versions/__pycache__/4ac448e3f100_add_due_date_field_to_task_model.cpython-312.pyc +0 -0
- alembic/versions/__pycache__/ec70eaafa7b6_initial_schema_with_users_and_tasks_.cpython-312.pyc +0 -0
- alembic/versions/ec70eaafa7b6_initial_schema_with_users_and_tasks_.py +54 -0
- main.py +6 -0
- requirements.txt +16 -0
- src/__init__.py +0 -0
- src/__pycache__/__init__.cpython-312.pyc +0 -0
- src/__pycache__/config.cpython-312.pyc +0 -0
- src/__pycache__/database.cpython-312.pyc +0 -0
- src/__pycache__/main.cpython-312.pyc +0 -0
- src/config.py +23 -0
- src/database.py +24 -0
- src/main.py +34 -0
- src/middleware/__pycache__/auth.cpython-312.pyc +0 -0
- src/middleware/auth.py +41 -0
- src/models/__pycache__/project.cpython-312.pyc +0 -0
- src/models/__pycache__/task.cpython-312.pyc +0 -0
- src/models/__pycache__/user.cpython-312.pyc +0 -0
- src/models/project.py +49 -0
- src/models/task.py +53 -0
- src/models/user.py +33 -0
- src/routers/__init__.py +3 -0
- src/routers/__pycache__/__init__.cpython-312.pyc +0 -0
- src/routers/__pycache__/auth.cpython-312.pyc +0 -0
- src/routers/__pycache__/projects.cpython-312.pyc +0 -0
- src/routers/__pycache__/tasks.cpython-312.pyc +0 -0
- src/routers/auth.py +189 -0
- src/routers/projects.py +259 -0
- src/routers/tasks.py +397 -0
- src/schemas/__pycache__/auth.cpython-312.pyc +0 -0
- src/schemas/__pycache__/task.cpython-312.pyc +0 -0
- src/schemas/auth.py +41 -0
- src/schemas/task.py +39 -0
- src/task_api.egg-info/PKG-INFO +16 -0
- src/task_api.egg-info/SOURCES.txt +21 -0
- src/task_api.egg-info/dependency_links.txt +1 -0
- src/task_api.egg-info/requires.txt +10 -0
- src/task_api.egg-info/top_level.txt +9 -0
- src/utils/__pycache__/deps.cpython-312.pyc +0 -0
- src/utils/__pycache__/security.cpython-312.pyc +0 -0
- src/utils/deps.py +66 -0
Dockerfile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Base Image
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Set work directory
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Install Dependencies
|
| 8 |
+
COPY requirements.txt .
|
| 9 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# Copy application code
|
| 13 |
+
COPY . .
|
| 14 |
+
|
| 15 |
+
# Expose the port Hugging Face expects
|
| 16 |
+
EXPOSE 7860
|
| 17 |
+
|
| 18 |
+
# Command to run FastAPI with uvicorn
|
| 19 |
+
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
alembic.ini
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# A generic, single database configuration.
|
| 2 |
+
|
| 3 |
+
[alembic]
|
| 4 |
+
# path to migration scripts.
|
| 5 |
+
# this is typically a path given in POSIX (e.g. forward slashes)
|
| 6 |
+
# format, relative to the token %(here)s which refers to the location of this
|
| 7 |
+
# ini file
|
| 8 |
+
script_location = %(here)s/alembic
|
| 9 |
+
|
| 10 |
+
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
| 11 |
+
# Uncomment the line below if you want the files to be prepended with date and time
|
| 12 |
+
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
| 13 |
+
# for all available tokens
|
| 14 |
+
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
| 15 |
+
|
| 16 |
+
# sys.path path, will be prepended to sys.path if present.
|
| 17 |
+
# defaults to the current working directory. for multiple paths, the path separator
|
| 18 |
+
# is defined by "path_separator" below.
|
| 19 |
+
prepend_sys_path = .
|
| 20 |
+
|
| 21 |
+
# timezone to use when rendering the date within the migration file
|
| 22 |
+
# as well as the filename.
|
| 23 |
+
# If specified, requires the tzdata library which can be installed by adding
|
| 24 |
+
# `alembic[tz]` to the pip requirements.
|
| 25 |
+
# string value is passed to ZoneInfo()
|
| 26 |
+
# leave blank for localtime
|
| 27 |
+
# timezone =
|
| 28 |
+
|
| 29 |
+
# max length of characters to apply to the "slug" field
|
| 30 |
+
# truncate_slug_length = 40
|
| 31 |
+
|
| 32 |
+
# set to 'true' to run the environment during
|
| 33 |
+
# the 'revision' command, regardless of autogenerate
|
| 34 |
+
# revision_environment = false
|
| 35 |
+
|
| 36 |
+
# set to 'true' to allow .pyc and .pyo files without
|
| 37 |
+
# a source .py file to be detected as revisions in the
|
| 38 |
+
# versions/ directory
|
| 39 |
+
# sourceless = false
|
| 40 |
+
|
| 41 |
+
# version location specification; This defaults
|
| 42 |
+
# to <script_location>/versions. When using multiple version
|
| 43 |
+
# directories, initial revisions must be specified with --version-path.
|
| 44 |
+
# The path separator used here should be the separator specified by "path_separator"
|
| 45 |
+
# below.
|
| 46 |
+
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
| 47 |
+
|
| 48 |
+
# path_separator; This indicates what character is used to split lists of file
|
| 49 |
+
# paths, including version_locations and prepend_sys_path within configparser
|
| 50 |
+
# files such as alembic.ini.
|
| 51 |
+
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
| 52 |
+
# to provide os-dependent path splitting.
|
| 53 |
+
#
|
| 54 |
+
# Note that in order to support legacy alembic.ini files, this default does NOT
|
| 55 |
+
# take place if path_separator is not present in alembic.ini. If this
|
| 56 |
+
# option is omitted entirely, fallback logic is as follows:
|
| 57 |
+
#
|
| 58 |
+
# 1. Parsing of the version_locations option falls back to using the legacy
|
| 59 |
+
# "version_path_separator" key, which if absent then falls back to the legacy
|
| 60 |
+
# behavior of splitting on spaces and/or commas.
|
| 61 |
+
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
| 62 |
+
# behavior of splitting on spaces, commas, or colons.
|
| 63 |
+
#
|
| 64 |
+
# Valid values for path_separator are:
|
| 65 |
+
#
|
| 66 |
+
# path_separator = :
|
| 67 |
+
# path_separator = ;
|
| 68 |
+
# path_separator = space
|
| 69 |
+
# path_separator = newline
|
| 70 |
+
#
|
| 71 |
+
# Use os.pathsep. Default configuration used for new projects.
|
| 72 |
+
path_separator = os
|
| 73 |
+
|
| 74 |
+
# set to 'true' to search source files recursively
|
| 75 |
+
# in each "version_locations" directory
|
| 76 |
+
# new in Alembic version 1.10
|
| 77 |
+
# recursive_version_locations = false
|
| 78 |
+
|
| 79 |
+
# the output encoding used when revision files
|
| 80 |
+
# are written from script.py.mako
|
| 81 |
+
# output_encoding = utf-8
|
| 82 |
+
|
| 83 |
+
# database URL. This is consumed by the user-maintained env.py script only.
|
| 84 |
+
# other means of configuring database URLs may be customized within the env.py
|
| 85 |
+
# file.
|
| 86 |
+
sqlalchemy.url = postgresql://neondb_owner:npg_LsojKQF8bGn2@ep-mute-pine-a4g0wfsu-pooler.us-east-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require
|
| 87 |
+
|
| 88 |
+
[post_write_hooks]
|
| 89 |
+
# post_write_hooks defines scripts or Python functions that are run
|
| 90 |
+
# on newly generated revision scripts. See the documentation for further
|
| 91 |
+
# detail and examples
|
| 92 |
+
|
| 93 |
+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
| 94 |
+
# hooks = black
|
| 95 |
+
# black.type = console_scripts
|
| 96 |
+
# black.entrypoint = black
|
| 97 |
+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
| 98 |
+
|
| 99 |
+
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
| 100 |
+
# hooks = ruff
|
| 101 |
+
# ruff.type = module
|
| 102 |
+
# ruff.module = ruff
|
| 103 |
+
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
| 104 |
+
|
| 105 |
+
# Alternatively, use the exec runner to execute a binary found on your PATH
|
| 106 |
+
# hooks = ruff
|
| 107 |
+
# ruff.type = exec
|
| 108 |
+
# ruff.executable = ruff
|
| 109 |
+
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
| 110 |
+
|
| 111 |
+
# Logging configuration. This is also consumed by the user-maintained
|
| 112 |
+
# env.py script only.
|
| 113 |
+
[loggers]
|
| 114 |
+
keys = root,sqlalchemy,alembic
|
| 115 |
+
|
| 116 |
+
[handlers]
|
| 117 |
+
keys = console
|
| 118 |
+
|
| 119 |
+
[formatters]
|
| 120 |
+
keys = generic
|
| 121 |
+
|
| 122 |
+
[logger_root]
|
| 123 |
+
level = WARNING
|
| 124 |
+
handlers = console
|
| 125 |
+
qualname =
|
| 126 |
+
|
| 127 |
+
[logger_sqlalchemy]
|
| 128 |
+
level = WARNING
|
| 129 |
+
handlers =
|
| 130 |
+
qualname = sqlalchemy.engine
|
| 131 |
+
|
| 132 |
+
[logger_alembic]
|
| 133 |
+
level = INFO
|
| 134 |
+
handlers =
|
| 135 |
+
qualname = alembic
|
| 136 |
+
|
| 137 |
+
[handler_console]
|
| 138 |
+
class = StreamHandler
|
| 139 |
+
args = (sys.stderr,)
|
| 140 |
+
level = NOTSET
|
| 141 |
+
formatter = generic
|
| 142 |
+
|
| 143 |
+
[formatter_generic]
|
| 144 |
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
| 145 |
+
datefmt = %H:%M:%S
|
alembic/README
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Generic single-database configuration.
|
alembic/__pycache__/env.cpython-312.pyc
ADDED
|
Binary file (3.01 kB). View file
|
|
|
alembic/env.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from logging.config import fileConfig
|
| 2 |
+
|
| 3 |
+
from sqlalchemy import engine_from_config
|
| 4 |
+
from sqlalchemy import pool
|
| 5 |
+
from alembic import context
|
| 6 |
+
|
| 7 |
+
# Import SQLModel and models
|
| 8 |
+
from sqlmodel import SQLModel
|
| 9 |
+
|
| 10 |
+
import sys
|
| 11 |
+
import os
|
| 12 |
+
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
|
| 13 |
+
|
| 14 |
+
from src.models.user import User # Import your models
|
| 15 |
+
from src.models.task import Task # Import your models
|
| 16 |
+
from src.models.project import Project # Import your models
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# this is the Alembic Config object, which provides
|
| 20 |
+
# access to the values within the .ini file in use.
|
| 21 |
+
config = context.config
|
| 22 |
+
|
| 23 |
+
# Interpret the config file for Python logging.
|
| 24 |
+
# This line sets up loggers basically.
|
| 25 |
+
if config.config_file_name is not None:
|
| 26 |
+
fileConfig(config.config_file_name)
|
| 27 |
+
|
| 28 |
+
# add your model's MetaData object here
|
| 29 |
+
# for 'autogenerate' support
|
| 30 |
+
target_metadata = SQLModel.metadata
|
| 31 |
+
|
| 32 |
+
# other values from the config, defined by the needs of env.py,
|
| 33 |
+
# can be acquired:
|
| 34 |
+
# my_important_option = config.get_main_option("my_important_option")
|
| 35 |
+
# ... etc.
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def run_migrations_offline() -> None:
|
| 39 |
+
"""Run migrations in 'offline' mode.
|
| 40 |
+
|
| 41 |
+
This configures the context with just a URL
|
| 42 |
+
and not an Engine, though an Engine is acceptable
|
| 43 |
+
here as well. By skipping the Engine creation
|
| 44 |
+
we don't even need a DBAPI to be available.
|
| 45 |
+
|
| 46 |
+
Calls to context.execute() here emit the given string to the
|
| 47 |
+
script output.
|
| 48 |
+
|
| 49 |
+
"""
|
| 50 |
+
url = config.get_main_option("sqlalchemy.url")
|
| 51 |
+
context.configure(
|
| 52 |
+
url=url,
|
| 53 |
+
target_metadata=target_metadata,
|
| 54 |
+
literal_binds=True,
|
| 55 |
+
dialect_opts={"paramstyle": "named"},
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
with context.begin_transaction():
|
| 59 |
+
context.run_migrations()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def run_migrations_online() -> None:
|
| 63 |
+
"""Run migrations in 'online' mode.
|
| 64 |
+
|
| 65 |
+
In this scenario we need to create an Engine
|
| 66 |
+
and associate a connection with the context.
|
| 67 |
+
|
| 68 |
+
"""
|
| 69 |
+
connectable = engine_from_config(
|
| 70 |
+
config.get_section(config.config_ini_section),
|
| 71 |
+
prefix="sqlalchemy.",
|
| 72 |
+
poolclass=pool.NullPool,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
with connectable.connect() as connection:
|
| 76 |
+
context.configure(
|
| 77 |
+
connection=connection, target_metadata=target_metadata
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
with context.begin_transaction():
|
| 81 |
+
context.run_migrations()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
if context.is_offline_mode():
|
| 85 |
+
run_migrations_offline()
|
| 86 |
+
else:
|
| 87 |
+
run_migrations_online()
|
alembic/script.py.mako
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""${message}
|
| 2 |
+
|
| 3 |
+
Revision ID: ${up_revision}
|
| 4 |
+
Revises: ${down_revision | comma,n}
|
| 5 |
+
Create Date: ${create_date}
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
${imports if imports else ""}
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = ${repr(up_revision)}
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
${upgrades if upgrades else "pass"}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def downgrade() -> None:
|
| 27 |
+
"""Downgrade schema."""
|
| 28 |
+
${downgrades if downgrades else "pass"}
|
alembic/versions/3b6c60669e48_add_project_model_and_relationship_to_.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Add Project model and relationship to Task
|
| 2 |
+
|
| 3 |
+
Revision ID: 3b6c60669e48
|
| 4 |
+
Revises: ec70eaafa7b6
|
| 5 |
+
Create Date: 2025-12-19 03:46:01.389687
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
import sqlmodel
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# revision identifiers, used by Alembic.
|
| 16 |
+
revision: str = '3b6c60669e48'
|
| 17 |
+
down_revision: Union[str, Sequence[str], None] = 'ec70eaafa7b6'
|
| 18 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 19 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def upgrade() -> None:
|
| 23 |
+
"""Upgrade schema."""
|
| 24 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 25 |
+
op.create_table('project',
|
| 26 |
+
sa.Column('id', sa.Uuid(), nullable=False),
|
| 27 |
+
sa.Column('user_id', sa.Uuid(), nullable=False),
|
| 28 |
+
sa.Column('name', sa.String(length=200), nullable=False),
|
| 29 |
+
sa.Column('description', sa.String(length=1000), nullable=True),
|
| 30 |
+
sa.Column('color', sa.String(length=7), nullable=True),
|
| 31 |
+
sa.Column('created_at', sa.DateTime(), nullable=True),
|
| 32 |
+
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
| 33 |
+
sa.Column('deadline', sa.DateTime(), nullable=True),
|
| 34 |
+
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
| 35 |
+
sa.PrimaryKeyConstraint('id')
|
| 36 |
+
)
|
| 37 |
+
op.create_index(op.f('ix_project_user_id'), 'project', ['user_id'], unique=False)
|
| 38 |
+
op.add_column('task', sa.Column('project_id', sa.Uuid(), nullable=True))
|
| 39 |
+
op.create_index(op.f('ix_task_project_id'), 'task', ['project_id'], unique=False)
|
| 40 |
+
op.create_foreign_key(None, 'task', 'project', ['project_id'], ['id'])
|
| 41 |
+
# ### end Alembic commands ###
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def downgrade() -> None:
|
| 45 |
+
"""Downgrade schema."""
|
| 46 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 47 |
+
op.drop_constraint(None, 'task', type_='foreignkey')
|
| 48 |
+
op.drop_index(op.f('ix_task_project_id'), table_name='task')
|
| 49 |
+
op.drop_column('task', 'project_id')
|
| 50 |
+
op.drop_index(op.f('ix_project_user_id'), table_name='project')
|
| 51 |
+
op.drop_table('project')
|
| 52 |
+
# ### end Alembic commands ###
|
alembic/versions/4ac448e3f100_add_due_date_field_to_task_model.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Add due_date field to Task model
|
| 2 |
+
|
| 3 |
+
Revision ID: 4ac448e3f100
|
| 4 |
+
Revises: 3b6c60669e48
|
| 5 |
+
Create Date: 2025-12-19 03:50:35.687835
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = '4ac448e3f100'
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = '3b6c60669e48'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 24 |
+
op.add_column('task', sa.Column('due_date', sa.DateTime(), nullable=True))
|
| 25 |
+
# ### end Alembic commands ###
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def downgrade() -> None:
|
| 29 |
+
"""Downgrade schema."""
|
| 30 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 31 |
+
op.drop_column('task', 'due_date')
|
| 32 |
+
# ### end Alembic commands ###
|
alembic/versions/__pycache__/3b6c60669e48_add_project_model_and_relationship_to_.cpython-312.pyc
ADDED
|
Binary file (3.45 kB). View file
|
|
|
alembic/versions/__pycache__/4ac448e3f100_add_due_date_field_to_task_model.cpython-312.pyc
ADDED
|
Binary file (1.35 kB). View file
|
|
|
alembic/versions/__pycache__/ec70eaafa7b6_initial_schema_with_users_and_tasks_.cpython-312.pyc
ADDED
|
Binary file (3.82 kB). View file
|
|
|
alembic/versions/ec70eaafa7b6_initial_schema_with_users_and_tasks_.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Initial schema with users and tasks tables
|
| 2 |
+
|
| 3 |
+
Revision ID: ec70eaafa7b6
|
| 4 |
+
Revises:
|
| 5 |
+
Create Date: 2025-12-16 05:07:24.251683
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
import sqlmodel
|
| 13 |
+
# revision identifiers, used by Alembic.
|
| 14 |
+
revision: str = 'ec70eaafa7b6'
|
| 15 |
+
down_revision: Union[str, Sequence[str], None] = None
|
| 16 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 17 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def upgrade() -> None:
|
| 21 |
+
"""Upgrade schema."""
|
| 22 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 23 |
+
op.create_table('user',
|
| 24 |
+
sa.Column('id', sa.Uuid(), nullable=False),
|
| 25 |
+
sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
|
| 26 |
+
sa.Column('password_hash', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
|
| 27 |
+
sa.Column('created_at', sa.DateTime(), nullable=True),
|
| 28 |
+
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
| 29 |
+
sa.PrimaryKeyConstraint('id')
|
| 30 |
+
)
|
| 31 |
+
op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True)
|
| 32 |
+
op.create_table('task',
|
| 33 |
+
sa.Column('id', sa.Integer(), nullable=False),
|
| 34 |
+
sa.Column('user_id', sa.Uuid(), nullable=False),
|
| 35 |
+
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
| 36 |
+
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=1000), nullable=True),
|
| 37 |
+
sa.Column('completed', sa.Boolean(), nullable=False),
|
| 38 |
+
sa.Column('created_at', sa.DateTime(), nullable=True),
|
| 39 |
+
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
| 40 |
+
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
| 41 |
+
sa.PrimaryKeyConstraint('id')
|
| 42 |
+
)
|
| 43 |
+
op.create_index(op.f('ix_task_user_id'), 'task', ['user_id'], unique=False)
|
| 44 |
+
# ### end Alembic commands ###
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def downgrade() -> None:
|
| 48 |
+
"""Downgrade schema."""
|
| 49 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 50 |
+
op.drop_index(op.f('ix_task_user_id'), table_name='task')
|
| 51 |
+
op.drop_table('task')
|
| 52 |
+
op.drop_index(op.f('ix_user_email'), table_name='user')
|
| 53 |
+
op.drop_table('user')
|
| 54 |
+
# ### end Alembic commands ###
|
main.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
def main():
|
| 2 |
+
print("Hello from task-api!")
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
if __name__ == "__main__":
|
| 6 |
+
main()
|
requirements.txt
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
alembic>=1.17.2
|
| 2 |
+
fastapi>=0.124.4
|
| 3 |
+
passlib[bcrypt]>=1.7.4
|
| 4 |
+
psycopg2-binary>=2.9.11
|
| 5 |
+
pydantic-settings>=2.12.0
|
| 6 |
+
pydantic[email]>=2.12.5
|
| 7 |
+
python-jose[cryptography]>=3.5.0
|
| 8 |
+
python-multipart>=0.0.20
|
| 9 |
+
sqlmodel>=0.0.27
|
| 10 |
+
uvicorn>=0.38.0
|
| 11 |
+
httpx>=0.28.1
|
| 12 |
+
pytest>=9.0.2
|
| 13 |
+
pytest-asyncio>=1.3.0
|
| 14 |
+
python-dotenv>=1.0.1
|
| 15 |
+
bcrypt>=4.2.1
|
| 16 |
+
cryptography>=45.0.0
|
src/__init__.py
ADDED
|
File without changes
|
src/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (157 Bytes). View file
|
|
|
src/__pycache__/config.cpython-312.pyc
ADDED
|
Binary file (1.27 kB). View file
|
|
|
src/__pycache__/database.cpython-312.pyc
ADDED
|
Binary file (1.1 kB). View file
|
|
|
src/__pycache__/main.cpython-312.pyc
ADDED
|
Binary file (1.36 kB). View file
|
|
|
src/config.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic_settings import BaseSettings
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class Settings(BaseSettings):
|
| 6 |
+
# Database
|
| 7 |
+
DATABASE_URL: str = "postgresql://neondb_owner:npg_LsojKQF8bGn2@ep-mute-pine-a4g0wfsu-pooler.us-east-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require"
|
| 8 |
+
|
| 9 |
+
# Auth
|
| 10 |
+
BETTER_AUTH_SECRET: str = "your-secret-key-change-in-production"
|
| 11 |
+
JWT_SECRET_KEY: str = "your-jwt-secret-change-in-production"
|
| 12 |
+
JWT_ALGORITHM: str = "HS256"
|
| 13 |
+
ACCESS_TOKEN_EXPIRE_DAYS: int = 7
|
| 14 |
+
JWT_COOKIE_SECURE: bool = False # Set to True in production
|
| 15 |
+
|
| 16 |
+
# CORS
|
| 17 |
+
FRONTEND_URL: str = "http://localhost:3000"
|
| 18 |
+
|
| 19 |
+
class Config:
|
| 20 |
+
env_file = ".env"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
settings = Settings()
|
src/database.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlmodel import create_engine, Session
|
| 2 |
+
from contextlib import contextmanager
|
| 3 |
+
from .config import settings
|
| 4 |
+
# Create the database engine
|
| 5 |
+
engine = create_engine(
|
| 6 |
+
settings.DATABASE_URL,
|
| 7 |
+
echo=False, # Set to True for SQL query logging
|
| 8 |
+
pool_pre_ping=True,
|
| 9 |
+
pool_size=5,
|
| 10 |
+
max_overflow=10
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@contextmanager
|
| 15 |
+
def get_session():
|
| 16 |
+
"""Context manager for database sessions."""
|
| 17 |
+
with Session(engine) as session:
|
| 18 |
+
yield session
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def get_session_dep():
|
| 22 |
+
"""Dependency for FastAPI to get database session."""
|
| 23 |
+
with get_session() as session:
|
| 24 |
+
yield session
|
src/main.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
|
| 4 |
+
from .routers import auth, tasks, projects
|
| 5 |
+
|
| 6 |
+
app = FastAPI(
|
| 7 |
+
title="Task API",
|
| 8 |
+
description="Task management API with authentication",
|
| 9 |
+
version="1.0.0"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
# Include routers
|
| 13 |
+
app.include_router(auth.router)
|
| 14 |
+
app.include_router(tasks.router)
|
| 15 |
+
app.include_router(projects.router)
|
| 16 |
+
|
| 17 |
+
# CORS configuration (development and production)
|
| 18 |
+
app.add_middleware(
|
| 19 |
+
CORSMiddleware,
|
| 20 |
+
allow_origins=["*"], # Allow all origins for development
|
| 21 |
+
allow_credentials=True,
|
| 22 |
+
allow_methods=["*"],
|
| 23 |
+
allow_headers=["*"],
|
| 24 |
+
# Expose headers for auth
|
| 25 |
+
expose_headers=["Access-Control-Allow-Origin", "Set-Cookie"]
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
@app.get("/api/health")
|
| 29 |
+
async def health_check():
|
| 30 |
+
return {"status": "healthy"}
|
| 31 |
+
|
| 32 |
+
if __name__ == "__main__":
|
| 33 |
+
import uvicorn
|
| 34 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
src/middleware/__pycache__/auth.cpython-312.pyc
ADDED
|
Binary file (1.45 kB). View file
|
|
|
src/middleware/auth.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import HTTPException, status
|
| 2 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from sqlmodel import Session
|
| 5 |
+
import uuid
|
| 6 |
+
|
| 7 |
+
from ..models.user import User
|
| 8 |
+
from ..utils.security import verify_user_id_from_token
|
| 9 |
+
from ..database import get_session_dep
|
| 10 |
+
from fastapi import Depends
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# Security scheme for JWT
|
| 14 |
+
security = HTTPBearer()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
async def verify_jwt_token(
|
| 18 |
+
credentials: HTTPAuthorizationCredentials = Depends(security),
|
| 19 |
+
session: Session = Depends(get_session_dep)
|
| 20 |
+
):
|
| 21 |
+
"""Verify JWT token and return user_id if valid."""
|
| 22 |
+
token = credentials.credentials
|
| 23 |
+
user_id = verify_user_id_from_token(token)
|
| 24 |
+
|
| 25 |
+
if not user_id:
|
| 26 |
+
raise HTTPException(
|
| 27 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 28 |
+
detail="Invalid token or expired token.",
|
| 29 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
# Get user from database to ensure they still exist
|
| 33 |
+
user = session.get(User, user_id)
|
| 34 |
+
if not user:
|
| 35 |
+
raise HTTPException(
|
| 36 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 37 |
+
detail="User no longer exists.",
|
| 38 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
return user_id
|
src/models/__pycache__/project.cpython-312.pyc
ADDED
|
Binary file (3.28 kB). View file
|
|
|
src/models/__pycache__/task.cpython-312.pyc
ADDED
|
Binary file (3.52 kB). View file
|
|
|
src/models/__pycache__/user.cpython-312.pyc
ADDED
|
Binary file (2.16 kB). View file
|
|
|
src/models/project.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlmodel import SQLModel, Field, Relationship
|
| 2 |
+
from typing import Optional, List
|
| 3 |
+
import uuid
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from sqlalchemy import Column, DateTime
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ProjectBase(SQLModel):
|
| 9 |
+
name: str = Field(min_length=1, max_length=200)
|
| 10 |
+
description: Optional[str] = Field(default=None, max_length=1000)
|
| 11 |
+
color: Optional[str] = Field(default="#3b82f6", max_length=7) # Hex color code
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Project(ProjectBase, table=True):
|
| 15 |
+
id: Optional[uuid.UUID] = Field(default_factory=uuid.uuid4, primary_key=True)
|
| 16 |
+
user_id: uuid.UUID = Field(foreign_key="user.id", index=True)
|
| 17 |
+
name: str = Field(min_length=1, max_length=200)
|
| 18 |
+
description: Optional[str] = Field(default=None, max_length=1000)
|
| 19 |
+
color: Optional[str] = Field(default="#3b82f6", max_length=7)
|
| 20 |
+
created_at: datetime = Field(sa_column=Column(DateTime, default=datetime.utcnow))
|
| 21 |
+
updated_at: datetime = Field(sa_column=Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow))
|
| 22 |
+
deadline: Optional[datetime] = None
|
| 23 |
+
|
| 24 |
+
# Relationship to user
|
| 25 |
+
owner: Optional["User"] = Relationship(back_populates="projects")
|
| 26 |
+
|
| 27 |
+
# Relationship to tasks
|
| 28 |
+
tasks: List["Task"] = Relationship(back_populates="project")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class ProjectCreate(ProjectBase):
|
| 32 |
+
pass
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class ProjectRead(ProjectBase):
|
| 36 |
+
id: uuid.UUID
|
| 37 |
+
user_id: uuid.UUID
|
| 38 |
+
created_at: datetime
|
| 39 |
+
updated_at: datetime
|
| 40 |
+
|
| 41 |
+
class Config:
|
| 42 |
+
from_attributes = True
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class ProjectUpdate(SQLModel):
|
| 46 |
+
name: Optional[str] = Field(default=None, min_length=1, max_length=200)
|
| 47 |
+
description: Optional[str] = Field(default=None, max_length=1000)
|
| 48 |
+
color: Optional[str] = Field(default=None, max_length=7)
|
| 49 |
+
deadline: Optional[datetime] = None
|
src/models/task.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlmodel import SQLModel, Field, Relationship
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
import uuid
|
| 5 |
+
from sqlalchemy import Column, DateTime
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class TaskBase(SQLModel):
|
| 9 |
+
title: str = Field(min_length=1, max_length=200)
|
| 10 |
+
description: Optional[str] = Field(default=None, max_length=1000)
|
| 11 |
+
completed: bool = Field(default=False)
|
| 12 |
+
due_date: Optional[datetime] = None
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class Task(TaskBase, table=True):
|
| 16 |
+
id: Optional[int] = Field(default=None, primary_key=True)
|
| 17 |
+
user_id: uuid.UUID = Field(foreign_key="user.id", index=True)
|
| 18 |
+
project_id: Optional[uuid.UUID] = Field(default=None, foreign_key="project.id", index=True)
|
| 19 |
+
title: str = Field(min_length=1, max_length=200)
|
| 20 |
+
description: Optional[str] = Field(default=None, max_length=1000)
|
| 21 |
+
completed: bool = Field(default=False)
|
| 22 |
+
due_date: Optional[datetime] = None
|
| 23 |
+
created_at: datetime = Field(sa_column=Column(DateTime, default=datetime.utcnow))
|
| 24 |
+
updated_at: datetime = Field(sa_column=Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow))
|
| 25 |
+
|
| 26 |
+
# Relationship to user
|
| 27 |
+
owner: Optional["User"] = Relationship(back_populates="tasks")
|
| 28 |
+
|
| 29 |
+
# Relationship to project
|
| 30 |
+
project: Optional["Project"] = Relationship(back_populates="tasks")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class TaskCreate(TaskBase):
|
| 34 |
+
project_id: Optional[uuid.UUID] = None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class TaskRead(TaskBase):
|
| 38 |
+
id: int
|
| 39 |
+
user_id: uuid.UUID
|
| 40 |
+
project_id: Optional[uuid.UUID] = None
|
| 41 |
+
created_at: datetime
|
| 42 |
+
updated_at: datetime
|
| 43 |
+
|
| 44 |
+
class Config:
|
| 45 |
+
from_attributes = True
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class TaskUpdate(SQLModel):
|
| 49 |
+
title: Optional[str] = Field(default=None, min_length=1, max_length=200)
|
| 50 |
+
description: Optional[str] = Field(default=None, max_length=1000)
|
| 51 |
+
completed: Optional[bool] = None
|
| 52 |
+
project_id: Optional[uuid.UUID] = None
|
| 53 |
+
due_date: Optional[datetime] = None
|
src/models/user.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlmodel import SQLModel, Field, Relationship
|
| 2 |
+
from typing import Optional, List
|
| 3 |
+
import uuid
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from sqlalchemy import Column, DateTime
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class UserBase(SQLModel):
|
| 9 |
+
email: str = Field(unique=True, index=True, max_length=255)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class User(UserBase, table=True):
|
| 13 |
+
id: Optional[uuid.UUID] = Field(default_factory=uuid.uuid4, primary_key=True)
|
| 14 |
+
email: str = Field(unique=True, index=True, max_length=255)
|
| 15 |
+
password_hash: str = Field(max_length=255)
|
| 16 |
+
created_at: datetime = Field(sa_column=Column(DateTime, default=datetime.utcnow))
|
| 17 |
+
updated_at: datetime = Field(sa_column=Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow))
|
| 18 |
+
|
| 19 |
+
# Relationship to tasks
|
| 20 |
+
tasks: List["Task"] = Relationship(back_populates="owner")
|
| 21 |
+
|
| 22 |
+
# Relationship to projects
|
| 23 |
+
projects: List["Project"] = Relationship(back_populates="owner")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class UserCreate(UserBase):
|
| 27 |
+
password: str
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class UserRead(UserBase):
|
| 31 |
+
id: uuid.UUID
|
| 32 |
+
created_at: datetime
|
| 33 |
+
updated_at: datetime
|
src/routers/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from . import auth, tasks
|
| 2 |
+
|
| 3 |
+
__all__ = ["auth", "tasks"]
|
src/routers/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (246 Bytes). View file
|
|
|
src/routers/__pycache__/auth.cpython-312.pyc
ADDED
|
Binary file (7.97 kB). View file
|
|
|
src/routers/__pycache__/projects.cpython-312.pyc
ADDED
|
Binary file (8.38 kB). View file
|
|
|
src/routers/__pycache__/tasks.cpython-312.pyc
ADDED
|
Binary file (12.2 kB). View file
|
|
|
src/routers/auth.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, status, Depends, Response, Request
|
| 2 |
+
from sqlmodel import Session, select
|
| 3 |
+
from typing import Annotated
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from uuid import uuid4
|
| 6 |
+
import secrets
|
| 7 |
+
|
| 8 |
+
from ..models.user import User, UserCreate, UserRead
|
| 9 |
+
from ..schemas.auth import RegisterRequest, RegisterResponse, LoginRequest, LoginResponse, ForgotPasswordRequest, ResetPasswordRequest
|
| 10 |
+
from ..utils.security import hash_password, create_access_token, verify_password
|
| 11 |
+
from ..utils.deps import get_current_user
|
| 12 |
+
from ..database import get_session_dep
|
| 13 |
+
from ..config import settings
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED)
|
| 20 |
+
def register(user_data: RegisterRequest, response: Response, session: Session = Depends(get_session_dep)):
|
| 21 |
+
"""Register a new user with email and password."""
|
| 22 |
+
|
| 23 |
+
# Check if user already exists
|
| 24 |
+
existing_user = session.exec(select(User).where(User.email == user_data.email)).first()
|
| 25 |
+
if existing_user:
|
| 26 |
+
raise HTTPException(
|
| 27 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 28 |
+
detail="An account with this email already exists"
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# Validate password length
|
| 32 |
+
if len(user_data.password) < 8:
|
| 33 |
+
raise HTTPException(
|
| 34 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 35 |
+
detail="Password must be at least 8 characters"
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
# Hash the password
|
| 39 |
+
password_hash = hash_password(user_data.password)
|
| 40 |
+
|
| 41 |
+
# Create new user
|
| 42 |
+
user = User(
|
| 43 |
+
email=user_data.email,
|
| 44 |
+
password_hash=password_hash
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
session.add(user)
|
| 48 |
+
session.commit()
|
| 49 |
+
session.refresh(user)
|
| 50 |
+
|
| 51 |
+
# Create access token
|
| 52 |
+
access_token = create_access_token(data={"sub": str(user.id)})
|
| 53 |
+
|
| 54 |
+
# Set the token as an httpOnly cookie
|
| 55 |
+
response.set_cookie(
|
| 56 |
+
key="access_token",
|
| 57 |
+
value=access_token,
|
| 58 |
+
httponly=True,
|
| 59 |
+
secure=settings.JWT_COOKIE_SECURE, # True in production, False in development
|
| 60 |
+
samesite="lax",
|
| 61 |
+
max_age=settings.ACCESS_TOKEN_EXPIRE_DAYS * 24 * 60 * 60, # Convert days to seconds
|
| 62 |
+
path="/"
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
# Return response
|
| 66 |
+
return RegisterResponse(
|
| 67 |
+
id=user.id,
|
| 68 |
+
email=user.email,
|
| 69 |
+
message="Account created successfully"
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@router.post("/login", response_model=LoginResponse)
|
| 74 |
+
def login(login_data: LoginRequest, response: Response, session: Session = Depends(get_session_dep)):
|
| 75 |
+
"""Authenticate user with email and password, return JWT token."""
|
| 76 |
+
|
| 77 |
+
# Find user by email
|
| 78 |
+
user = session.exec(select(User).where(User.email == login_data.email)).first()
|
| 79 |
+
|
| 80 |
+
if not user or not verify_password(login_data.password, user.password_hash):
|
| 81 |
+
raise HTTPException(
|
| 82 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 83 |
+
detail="Invalid email or password",
|
| 84 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
# Create access token
|
| 88 |
+
access_token = create_access_token(data={"sub": str(user.id)})
|
| 89 |
+
|
| 90 |
+
# Set the token as an httpOnly cookie
|
| 91 |
+
response.set_cookie(
|
| 92 |
+
key="access_token",
|
| 93 |
+
value=access_token,
|
| 94 |
+
httponly=True,
|
| 95 |
+
secure=settings.JWT_COOKIE_SECURE, # True in production, False in development
|
| 96 |
+
samesite="lax",
|
| 97 |
+
max_age=settings.ACCESS_TOKEN_EXPIRE_DAYS * 24 * 60 * 60, # Convert days to seconds
|
| 98 |
+
path="/"
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
# Debug: Print the cookie being set
|
| 102 |
+
print(f"Setting cookie: access_token={access_token}")
|
| 103 |
+
print(f"Cookie attributes: httponly={True}, secure={settings.JWT_COOKIE_SECURE}, samesite=lax, max_age={settings.ACCESS_TOKEN_EXPIRE_DAYS * 24 * 60 * 60}")
|
| 104 |
+
|
| 105 |
+
# Return response
|
| 106 |
+
return LoginResponse(
|
| 107 |
+
access_token=access_token,
|
| 108 |
+
token_type="bearer",
|
| 109 |
+
user=RegisterResponse(
|
| 110 |
+
id=user.id,
|
| 111 |
+
email=user.email,
|
| 112 |
+
message="Login successful"
|
| 113 |
+
)
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@router.post("/logout")
|
| 118 |
+
def logout(response: Response):
|
| 119 |
+
"""Logout user by clearing the access token cookie."""
|
| 120 |
+
# Clear the access_token cookie
|
| 121 |
+
response.set_cookie(
|
| 122 |
+
key="access_token",
|
| 123 |
+
value="",
|
| 124 |
+
httponly=True,
|
| 125 |
+
secure=settings.JWT_COOKIE_SECURE,
|
| 126 |
+
samesite="lax",
|
| 127 |
+
max_age=0, # Expire immediately
|
| 128 |
+
path="/"
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
return {"message": "Logged out successfully"}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@router.get("/me", response_model=RegisterResponse)
|
| 135 |
+
def get_current_user_profile(request: Request, current_user: User = Depends(get_current_user)):
|
| 136 |
+
"""Get the current authenticated user's profile."""
|
| 137 |
+
# Debug: Print the cookies received
|
| 138 |
+
print(f"Received cookies: {request.cookies}")
|
| 139 |
+
print(f"Access token cookie: {request.cookies.get('access_token')}")
|
| 140 |
+
|
| 141 |
+
return RegisterResponse(
|
| 142 |
+
id=current_user.id,
|
| 143 |
+
email=current_user.email,
|
| 144 |
+
message="User profile retrieved successfully"
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@router.post("/forgot-password")
|
| 149 |
+
def forgot_password(forgot_data: ForgotPasswordRequest, session: Session = Depends(get_session_dep)):
|
| 150 |
+
"""Initiate password reset process by verifying email exists."""
|
| 151 |
+
# Check if user exists
|
| 152 |
+
user = session.exec(select(User).where(User.email == forgot_data.email)).first()
|
| 153 |
+
|
| 154 |
+
if not user:
|
| 155 |
+
# For security reasons, we don't reveal if the email exists or not
|
| 156 |
+
return {"message": "If the email exists, a reset link would be sent"}
|
| 157 |
+
|
| 158 |
+
# In a real implementation, we would send an email here
|
| 159 |
+
# But as per requirements, we're just simulating the process
|
| 160 |
+
return {"message": "If the email exists, a reset link would be sent"}
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@router.post("/reset-password")
|
| 164 |
+
def reset_password(reset_data: ResetPasswordRequest, session: Session = Depends(get_session_dep)):
|
| 165 |
+
"""Reset user password after verification."""
|
| 166 |
+
# Check if user exists
|
| 167 |
+
user = session.exec(select(User).where(User.email == reset_data.email)).first()
|
| 168 |
+
|
| 169 |
+
if not user:
|
| 170 |
+
raise HTTPException(
|
| 171 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 172 |
+
detail="User not found"
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
# Validate password length
|
| 176 |
+
if len(reset_data.new_password) < 8:
|
| 177 |
+
raise HTTPException(
|
| 178 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 179 |
+
detail="Password must be at least 8 characters"
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
# Hash the new password
|
| 183 |
+
user.password_hash = hash_password(reset_data.new_password)
|
| 184 |
+
|
| 185 |
+
# Update the user
|
| 186 |
+
session.add(user)
|
| 187 |
+
session.commit()
|
| 188 |
+
|
| 189 |
+
return {"message": "Password reset successfully"}
|
src/routers/projects.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, status, Depends
|
| 2 |
+
from sqlmodel import Session, select, and_, func
|
| 3 |
+
from typing import List
|
| 4 |
+
from uuid import UUID
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
from ..models.user import User
|
| 8 |
+
from ..models.project import Project, ProjectCreate, ProjectUpdate, ProjectRead
|
| 9 |
+
from ..models.task import Task
|
| 10 |
+
from ..database import get_session_dep
|
| 11 |
+
from ..utils.deps import get_current_user
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
router = APIRouter(prefix="/api/{user_id}/projects", tags=["projects"])
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@router.get("/", response_model=List[ProjectRead])
|
| 18 |
+
def list_projects(
|
| 19 |
+
user_id: UUID,
|
| 20 |
+
current_user: User = Depends(get_current_user),
|
| 21 |
+
session: Session = Depends(get_session_dep)
|
| 22 |
+
):
|
| 23 |
+
"""List all projects for the authenticated user."""
|
| 24 |
+
|
| 25 |
+
# Verify that the user_id in the URL matches the authenticated user
|
| 26 |
+
if current_user.id != user_id:
|
| 27 |
+
raise HTTPException(
|
| 28 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 29 |
+
detail="Project not found"
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
# Build the query with user_id filter
|
| 33 |
+
query = select(Project).where(Project.user_id == user_id)
|
| 34 |
+
|
| 35 |
+
# Apply ordering (newest first)
|
| 36 |
+
query = query.order_by(Project.created_at.desc())
|
| 37 |
+
|
| 38 |
+
projects = session.exec(query).all()
|
| 39 |
+
return projects
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@router.post("/", response_model=ProjectRead, status_code=status.HTTP_201_CREATED)
|
| 43 |
+
def create_project(
|
| 44 |
+
*,
|
| 45 |
+
user_id: UUID,
|
| 46 |
+
project_data: ProjectCreate,
|
| 47 |
+
current_user: User = Depends(get_current_user),
|
| 48 |
+
session: Session = Depends(get_session_dep)
|
| 49 |
+
):
|
| 50 |
+
"""Create a new project for the authenticated user."""
|
| 51 |
+
|
| 52 |
+
# Verify that the user_id in the URL matches the authenticated user
|
| 53 |
+
if current_user.id != user_id:
|
| 54 |
+
raise HTTPException(
|
| 55 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 56 |
+
detail="User not found"
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
# Create the project
|
| 60 |
+
project = Project(
|
| 61 |
+
name=project_data.name,
|
| 62 |
+
description=project_data.description,
|
| 63 |
+
color=project_data.color,
|
| 64 |
+
user_id=user_id
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
session.add(project)
|
| 68 |
+
session.commit()
|
| 69 |
+
session.refresh(project)
|
| 70 |
+
|
| 71 |
+
return project
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@router.get("/{project_id}", response_model=ProjectRead)
|
| 75 |
+
def get_project(
|
| 76 |
+
*,
|
| 77 |
+
user_id: UUID,
|
| 78 |
+
project_id: UUID,
|
| 79 |
+
current_user: User = Depends(get_current_user),
|
| 80 |
+
session: Session = Depends(get_session_dep)
|
| 81 |
+
):
|
| 82 |
+
"""Get a specific project by ID for the authenticated user."""
|
| 83 |
+
|
| 84 |
+
# Verify that the user_id in the URL matches the authenticated user
|
| 85 |
+
if current_user.id != user_id:
|
| 86 |
+
raise HTTPException(
|
| 87 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 88 |
+
detail="Project not found"
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
# Fetch the project
|
| 92 |
+
project = session.get(Project, project_id)
|
| 93 |
+
|
| 94 |
+
# Check if project exists and belongs to the user
|
| 95 |
+
if not project or project.user_id != user_id:
|
| 96 |
+
raise HTTPException(
|
| 97 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 98 |
+
detail="Project not found"
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
return project
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@router.put("/{project_id}", response_model=ProjectRead)
|
| 105 |
+
def update_project(
|
| 106 |
+
*,
|
| 107 |
+
user_id: UUID,
|
| 108 |
+
project_id: UUID,
|
| 109 |
+
project_data: ProjectUpdate,
|
| 110 |
+
current_user: User = Depends(get_current_user),
|
| 111 |
+
session: Session = Depends(get_session_dep)
|
| 112 |
+
):
|
| 113 |
+
"""Update an existing project for the authenticated user."""
|
| 114 |
+
|
| 115 |
+
# Verify that the user_id in the URL matches the authenticated user
|
| 116 |
+
if current_user.id != user_id:
|
| 117 |
+
raise HTTPException(
|
| 118 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 119 |
+
detail="Project not found"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# Fetch the project
|
| 123 |
+
project = session.get(Project, project_id)
|
| 124 |
+
|
| 125 |
+
# Check if project exists and belongs to the user
|
| 126 |
+
if not project or project.user_id != user_id:
|
| 127 |
+
raise HTTPException(
|
| 128 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 129 |
+
detail="Project not found"
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
# Update the project
|
| 133 |
+
project_data_dict = project_data.dict(exclude_unset=True)
|
| 134 |
+
for key, value in project_data_dict.items():
|
| 135 |
+
setattr(project, key, value)
|
| 136 |
+
|
| 137 |
+
session.add(project)
|
| 138 |
+
session.commit()
|
| 139 |
+
session.refresh(project)
|
| 140 |
+
|
| 141 |
+
return project
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@router.delete("/{project_id}")
|
| 145 |
+
def delete_project(
|
| 146 |
+
*,
|
| 147 |
+
user_id: UUID,
|
| 148 |
+
project_id: UUID,
|
| 149 |
+
current_user: User = Depends(get_current_user),
|
| 150 |
+
session: Session = Depends(get_session_dep)
|
| 151 |
+
):
|
| 152 |
+
"""Delete a project for the authenticated user."""
|
| 153 |
+
|
| 154 |
+
# Verify that the user_id in the URL matches the authenticated user
|
| 155 |
+
if current_user.id != user_id:
|
| 156 |
+
raise HTTPException(
|
| 157 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 158 |
+
detail="Project not found"
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
# Fetch the project
|
| 162 |
+
project = session.get(Project, project_id)
|
| 163 |
+
|
| 164 |
+
# Check if project exists and belongs to the user
|
| 165 |
+
if not project or project.user_id != user_id:
|
| 166 |
+
raise HTTPException(
|
| 167 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 168 |
+
detail="Project not found"
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
# Delete the project
|
| 172 |
+
session.delete(project)
|
| 173 |
+
session.commit()
|
| 174 |
+
|
| 175 |
+
return {"message": "Project deleted successfully"}
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
@router.get("/{project_id}/tasks", response_model=List[Task])
|
| 179 |
+
def list_project_tasks(
|
| 180 |
+
*,
|
| 181 |
+
user_id: UUID,
|
| 182 |
+
project_id: UUID,
|
| 183 |
+
current_user: User = Depends(get_current_user),
|
| 184 |
+
session: Session = Depends(get_session_dep)
|
| 185 |
+
):
|
| 186 |
+
"""List all tasks for a specific project."""
|
| 187 |
+
|
| 188 |
+
# Verify that the user_id in the URL matches the authenticated user
|
| 189 |
+
if current_user.id != user_id:
|
| 190 |
+
raise HTTPException(
|
| 191 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 192 |
+
detail="Project not found"
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
# Fetch the project
|
| 196 |
+
project = session.get(Project, project_id)
|
| 197 |
+
|
| 198 |
+
# Check if project exists and belongs to the user
|
| 199 |
+
if not project or project.user_id != user_id:
|
| 200 |
+
raise HTTPException(
|
| 201 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 202 |
+
detail="Project not found"
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
# Build the query with project_id filter
|
| 206 |
+
query = select(Task).where(Task.project_id == project_id)
|
| 207 |
+
|
| 208 |
+
# Apply ordering (newest first)
|
| 209 |
+
query = query.order_by(Task.created_at.desc())
|
| 210 |
+
|
| 211 |
+
tasks = session.exec(query).all()
|
| 212 |
+
return tasks
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
@router.get("/{project_id}/progress")
|
| 216 |
+
def get_project_progress(
|
| 217 |
+
*,
|
| 218 |
+
user_id: UUID,
|
| 219 |
+
project_id: UUID,
|
| 220 |
+
current_user: User = Depends(get_current_user),
|
| 221 |
+
session: Session = Depends(get_session_dep)
|
| 222 |
+
):
|
| 223 |
+
"""Get progress statistics for a specific project."""
|
| 224 |
+
|
| 225 |
+
# Verify that the user_id in the URL matches the authenticated user
|
| 226 |
+
if current_user.id != user_id:
|
| 227 |
+
raise HTTPException(
|
| 228 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 229 |
+
detail="Project not found"
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
# Fetch the project
|
| 233 |
+
project = session.get(Project, project_id)
|
| 234 |
+
|
| 235 |
+
# Check if project exists and belongs to the user
|
| 236 |
+
if not project or project.user_id != user_id:
|
| 237 |
+
raise HTTPException(
|
| 238 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 239 |
+
detail="Project not found"
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
# Get task counts
|
| 243 |
+
total_tasks_query = select(func.count()).where(Task.project_id == project_id)
|
| 244 |
+
completed_tasks_query = select(func.count()).where(and_(Task.project_id == project_id, Task.completed == True))
|
| 245 |
+
|
| 246 |
+
total_tasks = session.exec(total_tasks_query).first()
|
| 247 |
+
completed_tasks = session.exec(completed_tasks_query).first()
|
| 248 |
+
|
| 249 |
+
# Calculate progress
|
| 250 |
+
progress = 0
|
| 251 |
+
if total_tasks > 0:
|
| 252 |
+
progress = round((completed_tasks / total_tasks) * 100, 2)
|
| 253 |
+
|
| 254 |
+
return {
|
| 255 |
+
"total_tasks": total_tasks,
|
| 256 |
+
"completed_tasks": completed_tasks,
|
| 257 |
+
"pending_tasks": total_tasks - completed_tasks,
|
| 258 |
+
"progress": progress
|
| 259 |
+
}
|
src/routers/tasks.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, status, Depends
|
| 2 |
+
from sqlmodel import Session, select, and_, func
|
| 3 |
+
from typing import List
|
| 4 |
+
from uuid import UUID
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
from ..models.user import User
|
| 8 |
+
from ..models.task import Task, TaskCreate, TaskUpdate, TaskRead
|
| 9 |
+
from ..schemas.task import TaskListResponse
|
| 10 |
+
from ..database import get_session_dep
|
| 11 |
+
from ..utils.deps import get_current_user
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
router = APIRouter(prefix="/api/{user_id}/tasks", tags=["tasks"])
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@router.get("/", response_model=TaskListResponse)
|
| 18 |
+
def list_tasks(
|
| 19 |
+
user_id: UUID,
|
| 20 |
+
current_user: User = Depends(get_current_user),
|
| 21 |
+
session: Session = Depends(get_session_dep),
|
| 22 |
+
completed: bool = None,
|
| 23 |
+
offset: int = 0,
|
| 24 |
+
limit: int = 50
|
| 25 |
+
):
|
| 26 |
+
"""List all tasks for the authenticated user with optional filtering."""
|
| 27 |
+
|
| 28 |
+
# Verify that the user_id in the URL matches the authenticated user
|
| 29 |
+
if current_user.id != user_id:
|
| 30 |
+
raise HTTPException(
|
| 31 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 32 |
+
detail="Task not found"
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# Build the query with user_id filter
|
| 36 |
+
query = select(Task).where(Task.user_id == user_id)
|
| 37 |
+
|
| 38 |
+
# Apply completed filter if specified
|
| 39 |
+
if completed is not None:
|
| 40 |
+
query = query.where(Task.completed == completed)
|
| 41 |
+
|
| 42 |
+
# Apply ordering (newest first)
|
| 43 |
+
query = query.order_by(Task.created_at.desc())
|
| 44 |
+
|
| 45 |
+
# Apply pagination
|
| 46 |
+
query = query.offset(offset).limit(limit)
|
| 47 |
+
|
| 48 |
+
tasks = session.exec(query).all()
|
| 49 |
+
|
| 50 |
+
# Get total count for pagination info
|
| 51 |
+
total_query = select(func.count()).select_from(Task).where(Task.user_id == user_id)
|
| 52 |
+
if completed is not None:
|
| 53 |
+
total_query = total_query.where(Task.completed == completed)
|
| 54 |
+
total = session.exec(total_query).one()
|
| 55 |
+
|
| 56 |
+
# Convert to response format
|
| 57 |
+
task_responses = []
|
| 58 |
+
for task in tasks:
|
| 59 |
+
task_dict = {
|
| 60 |
+
"id": task.id,
|
| 61 |
+
"user_id": str(task.user_id),
|
| 62 |
+
"title": task.title,
|
| 63 |
+
"description": task.description,
|
| 64 |
+
"completed": task.completed,
|
| 65 |
+
"due_date": task.due_date.isoformat() if task.due_date else None,
|
| 66 |
+
"project_id": str(task.project_id) if task.project_id else None,
|
| 67 |
+
"created_at": task.created_at.isoformat(),
|
| 68 |
+
"updated_at": task.updated_at.isoformat()
|
| 69 |
+
}
|
| 70 |
+
task_responses.append(task_dict)
|
| 71 |
+
|
| 72 |
+
return TaskListResponse(
|
| 73 |
+
tasks=task_responses,
|
| 74 |
+
total=total,
|
| 75 |
+
offset=offset,
|
| 76 |
+
limit=limit
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@router.post("/", response_model=TaskRead, status_code=status.HTTP_201_CREATED)
|
| 81 |
+
def create_task(
|
| 82 |
+
user_id: UUID,
|
| 83 |
+
task_data: TaskCreate,
|
| 84 |
+
current_user: User = Depends(get_current_user),
|
| 85 |
+
session: Session = Depends(get_session_dep)
|
| 86 |
+
):
|
| 87 |
+
"""Create a new task for the authenticated user."""
|
| 88 |
+
|
| 89 |
+
# Verify that the user_id in the URL matches the authenticated user
|
| 90 |
+
if current_user.id != user_id:
|
| 91 |
+
raise HTTPException(
|
| 92 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 93 |
+
detail="User not found"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
# Validate title length
|
| 97 |
+
if len(task_data.title) < 1 or len(task_data.title) > 200:
|
| 98 |
+
raise HTTPException(
|
| 99 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 100 |
+
detail="Title must be between 1 and 200 characters"
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
# Validate description length if provided
|
| 104 |
+
if task_data.description and len(task_data.description) > 1000:
|
| 105 |
+
raise HTTPException(
|
| 106 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 107 |
+
detail="Description must be 1000 characters or less"
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# Create new task
|
| 111 |
+
task = Task(
|
| 112 |
+
title=task_data.title,
|
| 113 |
+
description=task_data.description,
|
| 114 |
+
completed=task_data.completed,
|
| 115 |
+
due_date=task_data.due_date,
|
| 116 |
+
project_id=task_data.project_id,
|
| 117 |
+
user_id=user_id
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
session.add(task)
|
| 121 |
+
session.commit()
|
| 122 |
+
session.refresh(task)
|
| 123 |
+
|
| 124 |
+
return TaskRead(
|
| 125 |
+
id=task.id,
|
| 126 |
+
user_id=task.user_id,
|
| 127 |
+
title=task.title,
|
| 128 |
+
description=task.description,
|
| 129 |
+
completed=task.completed,
|
| 130 |
+
due_date=task.due_date,
|
| 131 |
+
project_id=task.project_id,
|
| 132 |
+
created_at=task.created_at,
|
| 133 |
+
updated_at=task.updated_at
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
@router.get("/{task_id}", response_model=TaskRead)
|
| 138 |
+
def get_task(
|
| 139 |
+
user_id: UUID,
|
| 140 |
+
task_id: int,
|
| 141 |
+
current_user: User = Depends(get_current_user),
|
| 142 |
+
session: Session = Depends(get_session_dep)
|
| 143 |
+
):
|
| 144 |
+
"""Get a specific task by ID for the authenticated user."""
|
| 145 |
+
|
| 146 |
+
# Verify that the user_id in the URL matches the authenticated user
|
| 147 |
+
if current_user.id != user_id:
|
| 148 |
+
raise HTTPException(
|
| 149 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 150 |
+
detail="Task not found"
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
# Get the task
|
| 154 |
+
task = session.get(Task, task_id)
|
| 155 |
+
|
| 156 |
+
# Verify the task exists and belongs to the user
|
| 157 |
+
if not task or task.user_id != user_id:
|
| 158 |
+
raise HTTPException(
|
| 159 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 160 |
+
detail="Task not found"
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
return TaskRead(
|
| 164 |
+
id=task.id,
|
| 165 |
+
user_id=task.user_id,
|
| 166 |
+
title=task.title,
|
| 167 |
+
description=task.description,
|
| 168 |
+
completed=task.completed,
|
| 169 |
+
due_date=task.due_date,
|
| 170 |
+
project_id=task.project_id,
|
| 171 |
+
created_at=task.created_at,
|
| 172 |
+
updated_at=task.updated_at
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@router.put("/{task_id}", response_model=TaskRead)
|
| 177 |
+
def update_task(
|
| 178 |
+
user_id: UUID,
|
| 179 |
+
task_id: int,
|
| 180 |
+
task_data: TaskUpdate,
|
| 181 |
+
current_user: User = Depends(get_current_user),
|
| 182 |
+
session: Session = Depends(get_session_dep)
|
| 183 |
+
):
|
| 184 |
+
"""Update an existing task for the authenticated user."""
|
| 185 |
+
|
| 186 |
+
# Verify that the user_id in the URL matches the authenticated user
|
| 187 |
+
if current_user.id != user_id:
|
| 188 |
+
raise HTTPException(
|
| 189 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 190 |
+
detail="Task not found"
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
# Get the task
|
| 194 |
+
task = session.get(Task, task_id)
|
| 195 |
+
|
| 196 |
+
# Verify the task exists and belongs to the user
|
| 197 |
+
if not task or task.user_id != user_id:
|
| 198 |
+
raise HTTPException(
|
| 199 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 200 |
+
detail="Task not found"
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
# Update fields if provided
|
| 204 |
+
if task_data.title is not None:
|
| 205 |
+
if len(task_data.title) < 1 or len(task_data.title) > 200:
|
| 206 |
+
raise HTTPException(
|
| 207 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 208 |
+
detail="Title must be between 1 and 200 characters"
|
| 209 |
+
)
|
| 210 |
+
task.title = task_data.title
|
| 211 |
+
|
| 212 |
+
if task_data.description is not None:
|
| 213 |
+
if len(task_data.description) > 1000:
|
| 214 |
+
raise HTTPException(
|
| 215 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 216 |
+
detail="Description must be 1000 characters or less"
|
| 217 |
+
)
|
| 218 |
+
task.description = task_data.description
|
| 219 |
+
|
| 220 |
+
if task_data.completed is not None:
|
| 221 |
+
task.completed = task_data.completed
|
| 222 |
+
|
| 223 |
+
if task_data.due_date is not None:
|
| 224 |
+
task.due_date = task_data.due_date
|
| 225 |
+
|
| 226 |
+
if task_data.project_id is not None:
|
| 227 |
+
task.project_id = task_data.project_id
|
| 228 |
+
|
| 229 |
+
# Update the timestamp
|
| 230 |
+
task.updated_at = datetime.utcnow()
|
| 231 |
+
|
| 232 |
+
session.add(task)
|
| 233 |
+
session.commit()
|
| 234 |
+
session.refresh(task)
|
| 235 |
+
|
| 236 |
+
return TaskRead(
|
| 237 |
+
id=task.id,
|
| 238 |
+
user_id=task.user_id,
|
| 239 |
+
title=task.title,
|
| 240 |
+
description=task.description,
|
| 241 |
+
completed=task.completed,
|
| 242 |
+
due_date=task.due_date,
|
| 243 |
+
project_id=task.project_id,
|
| 244 |
+
created_at=task.created_at,
|
| 245 |
+
updated_at=task.updated_at
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
@router.patch("/{task_id}", response_model=TaskRead)
|
| 250 |
+
def patch_task(
|
| 251 |
+
user_id: UUID,
|
| 252 |
+
task_id: int,
|
| 253 |
+
task_data: TaskUpdate,
|
| 254 |
+
current_user: User = Depends(get_current_user),
|
| 255 |
+
session: Session = Depends(get_session_dep)
|
| 256 |
+
):
|
| 257 |
+
"""Partially update an existing task for the authenticated user."""
|
| 258 |
+
|
| 259 |
+
# Verify that the user_id in the URL matches the authenticated user
|
| 260 |
+
if current_user.id != user_id:
|
| 261 |
+
raise HTTPException(
|
| 262 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 263 |
+
detail="Task not found"
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
# Get the task
|
| 267 |
+
task = session.get(Task, task_id)
|
| 268 |
+
|
| 269 |
+
# Verify the task exists and belongs to the user
|
| 270 |
+
if not task or task.user_id != user_id:
|
| 271 |
+
raise HTTPException(
|
| 272 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 273 |
+
detail="Task not found"
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
# Update fields if provided
|
| 277 |
+
if task_data.title is not None:
|
| 278 |
+
if len(task_data.title) < 1 or len(task_data.title) > 200:
|
| 279 |
+
raise HTTPException(
|
| 280 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 281 |
+
detail="Title must be between 1 and 200 characters"
|
| 282 |
+
)
|
| 283 |
+
task.title = task_data.title
|
| 284 |
+
|
| 285 |
+
if task_data.description is not None:
|
| 286 |
+
if len(task_data.description) > 1000:
|
| 287 |
+
raise HTTPException(
|
| 288 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 289 |
+
detail="Description must be 1000 characters or less"
|
| 290 |
+
)
|
| 291 |
+
task.description = task_data.description
|
| 292 |
+
|
| 293 |
+
if task_data.completed is not None:
|
| 294 |
+
task.completed = task_data.completed
|
| 295 |
+
|
| 296 |
+
if task_data.due_date is not None:
|
| 297 |
+
task.due_date = task_data.due_date
|
| 298 |
+
|
| 299 |
+
if task_data.project_id is not None:
|
| 300 |
+
task.project_id = task_data.project_id
|
| 301 |
+
|
| 302 |
+
# Update the timestamp
|
| 303 |
+
task.updated_at = datetime.utcnow()
|
| 304 |
+
|
| 305 |
+
session.add(task)
|
| 306 |
+
session.commit()
|
| 307 |
+
session.refresh(task)
|
| 308 |
+
|
| 309 |
+
return TaskRead(
|
| 310 |
+
id=task.id,
|
| 311 |
+
user_id=task.user_id,
|
| 312 |
+
title=task.title,
|
| 313 |
+
description=task.description,
|
| 314 |
+
completed=task.completed,
|
| 315 |
+
due_date=task.due_date,
|
| 316 |
+
project_id=task.project_id,
|
| 317 |
+
created_at=task.created_at,
|
| 318 |
+
updated_at=task.updated_at
|
| 319 |
+
)
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 323 |
+
def delete_task(
|
| 324 |
+
user_id: UUID,
|
| 325 |
+
task_id: int,
|
| 326 |
+
current_user: User = Depends(get_current_user),
|
| 327 |
+
session: Session = Depends(get_session_dep)
|
| 328 |
+
):
|
| 329 |
+
"""Delete a task for the authenticated user."""
|
| 330 |
+
|
| 331 |
+
# Verify that the user_id in the URL matches the authenticated user
|
| 332 |
+
if current_user.id != user_id:
|
| 333 |
+
raise HTTPException(
|
| 334 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 335 |
+
detail="Task not found"
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
# Get the task
|
| 339 |
+
task = session.get(Task, task_id)
|
| 340 |
+
|
| 341 |
+
# Verify the task exists and belongs to the user
|
| 342 |
+
if not task or task.user_id != user_id:
|
| 343 |
+
raise HTTPException(
|
| 344 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 345 |
+
detail="Task not found"
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
session.delete(task)
|
| 349 |
+
session.commit()
|
| 350 |
+
|
| 351 |
+
# Return 204 No Content
|
| 352 |
+
return
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
@router.patch("/{task_id}/toggle", response_model=TaskRead)
|
| 356 |
+
def toggle_task_completion(
|
| 357 |
+
user_id: UUID,
|
| 358 |
+
task_id: int,
|
| 359 |
+
current_user: User = Depends(get_current_user),
|
| 360 |
+
session: Session = Depends(get_session_dep)
|
| 361 |
+
):
|
| 362 |
+
"""Toggle the completion status of a task."""
|
| 363 |
+
|
| 364 |
+
# Verify that the user_id in the URL matches the authenticated user
|
| 365 |
+
if current_user.id != user_id:
|
| 366 |
+
raise HTTPException(
|
| 367 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 368 |
+
detail="Task not found"
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
# Get the task
|
| 372 |
+
task = session.get(Task, task_id)
|
| 373 |
+
|
| 374 |
+
# Verify the task exists and belongs to the user
|
| 375 |
+
if not task or task.user_id != user_id:
|
| 376 |
+
raise HTTPException(
|
| 377 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 378 |
+
detail="Task not found"
|
| 379 |
+
)
|
| 380 |
+
|
| 381 |
+
# Toggle the completion status
|
| 382 |
+
task.completed = not task.completed
|
| 383 |
+
task.updated_at = datetime.utcnow()
|
| 384 |
+
|
| 385 |
+
session.add(task)
|
| 386 |
+
session.commit()
|
| 387 |
+
session.refresh(task)
|
| 388 |
+
|
| 389 |
+
return TaskRead(
|
| 390 |
+
id=task.id,
|
| 391 |
+
user_id=task.user_id,
|
| 392 |
+
title=task.title,
|
| 393 |
+
description=task.description,
|
| 394 |
+
completed=task.completed,
|
| 395 |
+
created_at=task.created_at,
|
| 396 |
+
updated_at=task.updated_at
|
| 397 |
+
)
|
src/schemas/__pycache__/auth.cpython-312.pyc
ADDED
|
Binary file (1.96 kB). View file
|
|
|
src/schemas/__pycache__/task.cpython-312.pyc
ADDED
|
Binary file (1.95 kB). View file
|
|
|
src/schemas/auth.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, EmailStr
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from uuid import UUID
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class RegisterRequest(BaseModel):
|
| 8 |
+
email: EmailStr
|
| 9 |
+
password: str
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class RegisterResponse(BaseModel):
|
| 13 |
+
id: UUID
|
| 14 |
+
email: EmailStr
|
| 15 |
+
message: str
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class LoginRequest(BaseModel):
|
| 19 |
+
email: EmailStr
|
| 20 |
+
password: str
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class LoginResponse(BaseModel):
|
| 24 |
+
access_token: str
|
| 25 |
+
token_type: str
|
| 26 |
+
user: RegisterResponse
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class ErrorResponse(BaseModel):
|
| 30 |
+
detail: str
|
| 31 |
+
status_code: Optional[int] = None
|
| 32 |
+
errors: Optional[list] = None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class ForgotPasswordRequest(BaseModel):
|
| 36 |
+
email: EmailStr
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class ResetPasswordRequest(BaseModel):
|
| 40 |
+
email: EmailStr
|
| 41 |
+
new_password: str
|
src/schemas/task.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from uuid import UUID
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class TaskBase(BaseModel):
|
| 8 |
+
title: str
|
| 9 |
+
description: Optional[str] = None
|
| 10 |
+
completed: bool = False
|
| 11 |
+
due_date: Optional[datetime] = None
|
| 12 |
+
project_id: Optional[UUID] = None
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class TaskCreate(TaskBase):
|
| 16 |
+
title: str
|
| 17 |
+
description: Optional[str] = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class TaskUpdate(BaseModel):
|
| 21 |
+
title: Optional[str] = None
|
| 22 |
+
description: Optional[str] = None
|
| 23 |
+
completed: Optional[bool] = None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class TaskRead(TaskBase):
|
| 27 |
+
id: int
|
| 28 |
+
user_id: UUID
|
| 29 |
+
due_date: Optional[datetime] = None
|
| 30 |
+
project_id: Optional[UUID] = None
|
| 31 |
+
created_at: datetime
|
| 32 |
+
updated_at: datetime
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class TaskListResponse(BaseModel):
|
| 36 |
+
tasks: List[TaskRead]
|
| 37 |
+
total: int
|
| 38 |
+
offset: int
|
| 39 |
+
limit: int
|
src/task_api.egg-info/PKG-INFO
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Metadata-Version: 2.4
|
| 2 |
+
Name: task-api
|
| 3 |
+
Version: 0.1.0
|
| 4 |
+
Summary: Add your description here
|
| 5 |
+
Requires-Python: >=3.12
|
| 6 |
+
Description-Content-Type: text/markdown
|
| 7 |
+
Requires-Dist: alembic>=1.17.2
|
| 8 |
+
Requires-Dist: fastapi>=0.124.4
|
| 9 |
+
Requires-Dist: passlib[bcrypt]>=1.7.4
|
| 10 |
+
Requires-Dist: psycopg2-binary>=2.9.11
|
| 11 |
+
Requires-Dist: pydantic-settings>=2.12.0
|
| 12 |
+
Requires-Dist: pydantic[email]>=2.12.5
|
| 13 |
+
Requires-Dist: python-jose[cryptography]>=3.5.0
|
| 14 |
+
Requires-Dist: python-multipart>=0.0.20
|
| 15 |
+
Requires-Dist: sqlmodel>=0.0.27
|
| 16 |
+
Requires-Dist: uvicorn>=0.38.0
|
src/task_api.egg-info/SOURCES.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
README.md
|
| 2 |
+
pyproject.toml
|
| 3 |
+
src/__init__.py
|
| 4 |
+
src/config.py
|
| 5 |
+
src/database.py
|
| 6 |
+
src/main.py
|
| 7 |
+
src/middleware/auth.py
|
| 8 |
+
src/models/task.py
|
| 9 |
+
src/models/user.py
|
| 10 |
+
src/routers/__init__.py
|
| 11 |
+
src/routers/auth.py
|
| 12 |
+
src/routers/tasks.py
|
| 13 |
+
src/schemas/auth.py
|
| 14 |
+
src/schemas/task.py
|
| 15 |
+
src/task_api.egg-info/PKG-INFO
|
| 16 |
+
src/task_api.egg-info/SOURCES.txt
|
| 17 |
+
src/task_api.egg-info/dependency_links.txt
|
| 18 |
+
src/task_api.egg-info/requires.txt
|
| 19 |
+
src/task_api.egg-info/top_level.txt
|
| 20 |
+
src/utils/deps.py
|
| 21 |
+
src/utils/security.py
|
src/task_api.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
src/task_api.egg-info/requires.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
alembic>=1.17.2
|
| 2 |
+
fastapi>=0.124.4
|
| 3 |
+
passlib[bcrypt]>=1.7.4
|
| 4 |
+
psycopg2-binary>=2.9.11
|
| 5 |
+
pydantic-settings>=2.12.0
|
| 6 |
+
pydantic[email]>=2.12.5
|
| 7 |
+
python-jose[cryptography]>=3.5.0
|
| 8 |
+
python-multipart>=0.0.20
|
| 9 |
+
sqlmodel>=0.0.27
|
| 10 |
+
uvicorn>=0.38.0
|
src/task_api.egg-info/top_level.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__init__
|
| 2 |
+
config
|
| 3 |
+
database
|
| 4 |
+
main
|
| 5 |
+
middleware
|
| 6 |
+
models
|
| 7 |
+
routers
|
| 8 |
+
schemas
|
| 9 |
+
utils
|
src/utils/__pycache__/deps.cpython-312.pyc
ADDED
|
Binary file (2.46 kB). View file
|
|
|
src/utils/__pycache__/security.cpython-312.pyc
ADDED
|
Binary file (2.93 kB). View file
|
|
|
src/utils/deps.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Depends, HTTPException, status, Request
|
| 2 |
+
from sqlmodel import Session
|
| 3 |
+
from typing import Generator
|
| 4 |
+
from ..database import get_session_dep
|
| 5 |
+
from ..models.user import User
|
| 6 |
+
from .security import verify_user_id_from_token
|
| 7 |
+
from uuid import UUID
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def get_current_user(
|
| 11 |
+
request: Request,
|
| 12 |
+
session: Session = Depends(get_session_dep)
|
| 13 |
+
) -> User:
|
| 14 |
+
"""Dependency to get the current authenticated user from JWT token in cookie."""
|
| 15 |
+
# Debug: Print all cookies
|
| 16 |
+
print(f"All cookies received: {request.cookies}")
|
| 17 |
+
|
| 18 |
+
# Get the token from the cookie
|
| 19 |
+
token = request.cookies.get("access_token")
|
| 20 |
+
print(f"Access token from cookie: {token}")
|
| 21 |
+
|
| 22 |
+
if not token:
|
| 23 |
+
print("No access token found in cookies")
|
| 24 |
+
raise HTTPException(
|
| 25 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 26 |
+
detail="Not authenticated",
|
| 27 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
user_id = verify_user_id_from_token(token)
|
| 31 |
+
print(f"User ID from token: {user_id}")
|
| 32 |
+
|
| 33 |
+
if not user_id:
|
| 34 |
+
print("Invalid user ID from token")
|
| 35 |
+
raise HTTPException(
|
| 36 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 37 |
+
detail="Invalid authentication credentials",
|
| 38 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
user = session.get(User, user_id)
|
| 42 |
+
print(f"User from database: {user}")
|
| 43 |
+
|
| 44 |
+
if not user:
|
| 45 |
+
print("User not found in database")
|
| 46 |
+
raise HTTPException(
|
| 47 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 48 |
+
detail="Invalid authentication credentials",
|
| 49 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
return user
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def get_user_by_id(
|
| 56 |
+
user_id: UUID,
|
| 57 |
+
session: Session = Depends(get_session_dep)
|
| 58 |
+
) -> User:
|
| 59 |
+
"""Dependency to get a user by ID from the database."""
|
| 60 |
+
user = session.get(User, user_id)
|
| 61 |
+
if not user:
|
| 62 |
+
raise HTTPException(
|
| 63 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 64 |
+
detail="User not found"
|
| 65 |
+
)
|
| 66 |
+
return user
|