diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..b5d35eb2e8521a84161b2407ca4a2480b99b34d5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# Base Image +FROM python:3.11-slim + +# Set work directory +WORKDIR /app + +# Install Dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + + +# Copy application code +COPY . . + +# Expose the port Hugging Face expects +EXPOSE 7860 + +# Command to run FastAPI with uvicorn +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "7860"] diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000000000000000000000000000000000000..9f6c3ea9fcc033a267e75b97eb4814d6ccc2e471 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,145 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = postgresql://neondb_owner:npg_LsojKQF8bGn2@ep-mute-pine-a4g0wfsu-pooler.us-east-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000000000000000000000000000000000000..98e4f9c44effe479ed38c66ba922e7bcc672916f --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/__pycache__/env.cpython-312.pyc b/alembic/__pycache__/env.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7b10965b6fe573974bf8180a1e1fe712ab436d3d Binary files /dev/null and b/alembic/__pycache__/env.cpython-312.pyc differ diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000000000000000000000000000000000000..e8bacb416b09d204a29a27a005ede54954c32950 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,87 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from alembic import context + +# Import SQLModel and models +from sqlmodel import SQLModel + +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(__file__))) + +from src.models.user import User # Import your models +from src.models.task import Task # Import your models +from src.models.project import Project # Import your models + + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = SQLModel.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000000000000000000000000000000000000..11016301e749297acb67822efc7974ee53c905c6 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/3b6c60669e48_add_project_model_and_relationship_to_.py b/alembic/versions/3b6c60669e48_add_project_model_and_relationship_to_.py new file mode 100644 index 0000000000000000000000000000000000000000..6e80e783bede0b40a85fcd19d45e3a4826e0c935 --- /dev/null +++ b/alembic/versions/3b6c60669e48_add_project_model_and_relationship_to_.py @@ -0,0 +1,52 @@ +"""Add Project model and relationship to Task + +Revision ID: 3b6c60669e48 +Revises: ec70eaafa7b6 +Create Date: 2025-12-19 03:46:01.389687 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '3b6c60669e48' +down_revision: Union[str, Sequence[str], None] = 'ec70eaafa7b6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('project', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('description', sa.String(length=1000), nullable=True), + sa.Column('color', sa.String(length=7), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('deadline', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_project_user_id'), 'project', ['user_id'], unique=False) + op.add_column('task', sa.Column('project_id', sa.Uuid(), nullable=True)) + op.create_index(op.f('ix_task_project_id'), 'task', ['project_id'], unique=False) + op.create_foreign_key(None, 'task', 'project', ['project_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'task', type_='foreignkey') + op.drop_index(op.f('ix_task_project_id'), table_name='task') + op.drop_column('task', 'project_id') + op.drop_index(op.f('ix_project_user_id'), table_name='project') + op.drop_table('project') + # ### end Alembic commands ### \ No newline at end of file diff --git a/alembic/versions/4ac448e3f100_add_due_date_field_to_task_model.py b/alembic/versions/4ac448e3f100_add_due_date_field_to_task_model.py new file mode 100644 index 0000000000000000000000000000000000000000..10712c46bb989bb92a3a63d6292f60a808c96967 --- /dev/null +++ b/alembic/versions/4ac448e3f100_add_due_date_field_to_task_model.py @@ -0,0 +1,32 @@ +"""Add due_date field to Task model + +Revision ID: 4ac448e3f100 +Revises: 3b6c60669e48 +Create Date: 2025-12-19 03:50:35.687835 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '4ac448e3f100' +down_revision: Union[str, Sequence[str], None] = '3b6c60669e48' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('task', sa.Column('due_date', sa.DateTime(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('task', 'due_date') + # ### end Alembic commands ### diff --git a/alembic/versions/__pycache__/3b6c60669e48_add_project_model_and_relationship_to_.cpython-312.pyc b/alembic/versions/__pycache__/3b6c60669e48_add_project_model_and_relationship_to_.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0fb02a19fe59eb4b718b298b996f211af1a3f7bc Binary files /dev/null and b/alembic/versions/__pycache__/3b6c60669e48_add_project_model_and_relationship_to_.cpython-312.pyc differ diff --git a/alembic/versions/__pycache__/4ac448e3f100_add_due_date_field_to_task_model.cpython-312.pyc b/alembic/versions/__pycache__/4ac448e3f100_add_due_date_field_to_task_model.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..da1c24a6a03267b8d0b51c4b5ec109ab69578ff5 Binary files /dev/null and b/alembic/versions/__pycache__/4ac448e3f100_add_due_date_field_to_task_model.cpython-312.pyc differ diff --git a/alembic/versions/__pycache__/ec70eaafa7b6_initial_schema_with_users_and_tasks_.cpython-312.pyc b/alembic/versions/__pycache__/ec70eaafa7b6_initial_schema_with_users_and_tasks_.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8fd443d2add9dba0a429d1752a194ef00a99c945 Binary files /dev/null and b/alembic/versions/__pycache__/ec70eaafa7b6_initial_schema_with_users_and_tasks_.cpython-312.pyc differ diff --git a/alembic/versions/ec70eaafa7b6_initial_schema_with_users_and_tasks_.py b/alembic/versions/ec70eaafa7b6_initial_schema_with_users_and_tasks_.py new file mode 100644 index 0000000000000000000000000000000000000000..89a2b88b7b3a86e9cd85acf5d3b2697ebebbb6a4 --- /dev/null +++ b/alembic/versions/ec70eaafa7b6_initial_schema_with_users_and_tasks_.py @@ -0,0 +1,54 @@ +"""Initial schema with users and tasks tables + +Revision ID: ec70eaafa7b6 +Revises: +Create Date: 2025-12-16 05:07:24.251683 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +# revision identifiers, used by Alembic. +revision: str = 'ec70eaafa7b6' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('user', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('password_hash', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True) + op.create_table('task', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('title', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=1000), nullable=True), + sa.Column('completed', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_task_user_id'), 'task', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_task_user_id'), table_name='task') + op.drop_table('task') + op.drop_index(op.f('ix_user_email'), table_name='user') + op.drop_table('user') + # ### end Alembic commands ### diff --git a/main.py b/main.py new file mode 100644 index 0000000000000000000000000000000000000000..9d76cc45981c02e26cfffc110824068568630650 --- /dev/null +++ b/main.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from task-api!") + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..93eece1417ea20529d8645ace01c482b4f239b3d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,16 @@ +alembic>=1.17.2 +fastapi>=0.124.4 +passlib[bcrypt]>=1.7.4 +psycopg2-binary>=2.9.11 +pydantic-settings>=2.12.0 +pydantic[email]>=2.12.5 +python-jose[cryptography]>=3.5.0 +python-multipart>=0.0.20 +sqlmodel>=0.0.27 +uvicorn>=0.38.0 +httpx>=0.28.1 +pytest>=9.0.2 +pytest-asyncio>=1.3.0 +python-dotenv>=1.0.1 +bcrypt>=4.2.1 +cryptography>=45.0.0 \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/__pycache__/__init__.cpython-312.pyc b/src/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..43f802a4b6c7400331d5d573667c0461848bc3de Binary files /dev/null and b/src/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/__pycache__/config.cpython-312.pyc b/src/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..51206c670ce26912e21cc3f7da356c8b87f0ad8a Binary files /dev/null and b/src/__pycache__/config.cpython-312.pyc differ diff --git a/src/__pycache__/database.cpython-312.pyc b/src/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1406e8fdf07501cf7ebfe8d24a2c8224c33a15b9 Binary files /dev/null and b/src/__pycache__/database.cpython-312.pyc differ diff --git a/src/__pycache__/main.cpython-312.pyc b/src/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7c6b3398a377041a7c8085f6b9c69a15281432d4 Binary files /dev/null and b/src/__pycache__/main.cpython-312.pyc differ diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000000000000000000000000000000000000..04c2e4a2456092ff49c64929401e42185c9b3a6b --- /dev/null +++ b/src/config.py @@ -0,0 +1,23 @@ +from pydantic_settings import BaseSettings +from typing import Optional + + +class Settings(BaseSettings): + # Database + 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" + + # Auth + BETTER_AUTH_SECRET: str = "your-secret-key-change-in-production" + JWT_SECRET_KEY: str = "your-jwt-secret-change-in-production" + JWT_ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_DAYS: int = 7 + JWT_COOKIE_SECURE: bool = False # Set to True in production + + # CORS + FRONTEND_URL: str = "http://localhost:3000" + + class Config: + env_file = ".env" + + +settings = Settings() \ No newline at end of file diff --git a/src/database.py b/src/database.py new file mode 100644 index 0000000000000000000000000000000000000000..7ef326a0de4da7d7dd3630ee3a8dfe13105f4e5f --- /dev/null +++ b/src/database.py @@ -0,0 +1,24 @@ +from sqlmodel import create_engine, Session +from contextlib import contextmanager +from .config import settings +# Create the database engine +engine = create_engine( + settings.DATABASE_URL, + echo=False, # Set to True for SQL query logging + pool_pre_ping=True, + pool_size=5, + max_overflow=10 +) + + +@contextmanager +def get_session(): + """Context manager for database sessions.""" + with Session(engine) as session: + yield session + + +def get_session_dep(): + """Dependency for FastAPI to get database session.""" + with get_session() as session: + yield session \ No newline at end of file diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000000000000000000000000000000000000..ffc4433df61307e31d7c18f36d1cff819daeeafb --- /dev/null +++ b/src/main.py @@ -0,0 +1,34 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from .routers import auth, tasks, projects + +app = FastAPI( + title="Task API", + description="Task management API with authentication", + version="1.0.0" +) + +# Include routers +app.include_router(auth.router) +app.include_router(tasks.router) +app.include_router(projects.router) + +# CORS configuration (development and production) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Allow all origins for development + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + # Expose headers for auth + expose_headers=["Access-Control-Allow-Origin", "Set-Cookie"] +) + +@app.get("/api/health") +async def health_check(): + return {"status": "healthy"} + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/src/middleware/__pycache__/auth.cpython-312.pyc b/src/middleware/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bd534fe5307b9f4ed847f7ee20bf2915ae2221f2 Binary files /dev/null and b/src/middleware/__pycache__/auth.cpython-312.pyc differ diff --git a/src/middleware/auth.py b/src/middleware/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..1c164d9cc188e6f571d44df055cbee81bb3abc03 --- /dev/null +++ b/src/middleware/auth.py @@ -0,0 +1,41 @@ +from fastapi import HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from typing import Optional +from sqlmodel import Session +import uuid + +from ..models.user import User +from ..utils.security import verify_user_id_from_token +from ..database import get_session_dep +from fastapi import Depends + + +# Security scheme for JWT +security = HTTPBearer() + + +async def verify_jwt_token( + credentials: HTTPAuthorizationCredentials = Depends(security), + session: Session = Depends(get_session_dep) +): + """Verify JWT token and return user_id if valid.""" + token = credentials.credentials + user_id = verify_user_id_from_token(token) + + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token or expired token.", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Get user from database to ensure they still exist + user = session.get(User, user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User no longer exists.", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return user_id \ No newline at end of file diff --git a/src/models/__pycache__/project.cpython-312.pyc b/src/models/__pycache__/project.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4238cd028ae7736d0b375b085db01268e454c42f Binary files /dev/null and b/src/models/__pycache__/project.cpython-312.pyc differ diff --git a/src/models/__pycache__/task.cpython-312.pyc b/src/models/__pycache__/task.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5535d1ec758e791996f6097a0ea0da372345b92e Binary files /dev/null and b/src/models/__pycache__/task.cpython-312.pyc differ diff --git a/src/models/__pycache__/user.cpython-312.pyc b/src/models/__pycache__/user.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2442877b1a065f1306c4905e2a18e151ee632454 Binary files /dev/null and b/src/models/__pycache__/user.cpython-312.pyc differ diff --git a/src/models/project.py b/src/models/project.py new file mode 100644 index 0000000000000000000000000000000000000000..351e3ee501b1a9d133c9dfa0e7e9ce3cff512327 --- /dev/null +++ b/src/models/project.py @@ -0,0 +1,49 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional, List +import uuid +from datetime import datetime +from sqlalchemy import Column, DateTime + + +class ProjectBase(SQLModel): + name: str = Field(min_length=1, max_length=200) + description: Optional[str] = Field(default=None, max_length=1000) + color: Optional[str] = Field(default="#3b82f6", max_length=7) # Hex color code + + +class Project(ProjectBase, table=True): + id: Optional[uuid.UUID] = Field(default_factory=uuid.uuid4, primary_key=True) + user_id: uuid.UUID = Field(foreign_key="user.id", index=True) + name: str = Field(min_length=1, max_length=200) + description: Optional[str] = Field(default=None, max_length=1000) + color: Optional[str] = Field(default="#3b82f6", max_length=7) + created_at: datetime = Field(sa_column=Column(DateTime, default=datetime.utcnow)) + updated_at: datetime = Field(sa_column=Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)) + deadline: Optional[datetime] = None + + # Relationship to user + owner: Optional["User"] = Relationship(back_populates="projects") + + # Relationship to tasks + tasks: List["Task"] = Relationship(back_populates="project") + + +class ProjectCreate(ProjectBase): + pass + + +class ProjectRead(ProjectBase): + id: uuid.UUID + user_id: uuid.UUID + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class ProjectUpdate(SQLModel): + name: Optional[str] = Field(default=None, min_length=1, max_length=200) + description: Optional[str] = Field(default=None, max_length=1000) + color: Optional[str] = Field(default=None, max_length=7) + deadline: Optional[datetime] = None \ No newline at end of file diff --git a/src/models/task.py b/src/models/task.py new file mode 100644 index 0000000000000000000000000000000000000000..697d642b92db0e3a6994feef5d6aaa1e66d9e7c7 --- /dev/null +++ b/src/models/task.py @@ -0,0 +1,53 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional +from datetime import datetime +import uuid +from sqlalchemy import Column, DateTime + + +class TaskBase(SQLModel): + title: str = Field(min_length=1, max_length=200) + description: Optional[str] = Field(default=None, max_length=1000) + completed: bool = Field(default=False) + due_date: Optional[datetime] = None + + +class Task(TaskBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + user_id: uuid.UUID = Field(foreign_key="user.id", index=True) + project_id: Optional[uuid.UUID] = Field(default=None, foreign_key="project.id", index=True) + title: str = Field(min_length=1, max_length=200) + description: Optional[str] = Field(default=None, max_length=1000) + completed: bool = Field(default=False) + due_date: Optional[datetime] = None + created_at: datetime = Field(sa_column=Column(DateTime, default=datetime.utcnow)) + updated_at: datetime = Field(sa_column=Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)) + + # Relationship to user + owner: Optional["User"] = Relationship(back_populates="tasks") + + # Relationship to project + project: Optional["Project"] = Relationship(back_populates="tasks") + + +class TaskCreate(TaskBase): + project_id: Optional[uuid.UUID] = None + + +class TaskRead(TaskBase): + id: int + user_id: uuid.UUID + project_id: Optional[uuid.UUID] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class TaskUpdate(SQLModel): + title: Optional[str] = Field(default=None, min_length=1, max_length=200) + description: Optional[str] = Field(default=None, max_length=1000) + completed: Optional[bool] = None + project_id: Optional[uuid.UUID] = None + due_date: Optional[datetime] = None \ No newline at end of file diff --git a/src/models/user.py b/src/models/user.py new file mode 100644 index 0000000000000000000000000000000000000000..b6adcefbae41a0ab56e5947c097d391237b91207 --- /dev/null +++ b/src/models/user.py @@ -0,0 +1,33 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional, List +import uuid +from datetime import datetime +from sqlalchemy import Column, DateTime + + +class UserBase(SQLModel): + email: str = Field(unique=True, index=True, max_length=255) + + +class User(UserBase, table=True): + id: Optional[uuid.UUID] = Field(default_factory=uuid.uuid4, primary_key=True) + email: str = Field(unique=True, index=True, max_length=255) + password_hash: str = Field(max_length=255) + created_at: datetime = Field(sa_column=Column(DateTime, default=datetime.utcnow)) + updated_at: datetime = Field(sa_column=Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)) + + # Relationship to tasks + tasks: List["Task"] = Relationship(back_populates="owner") + + # Relationship to projects + projects: List["Project"] = Relationship(back_populates="owner") + + +class UserCreate(UserBase): + password: str + + +class UserRead(UserBase): + id: uuid.UUID + created_at: datetime + updated_at: datetime \ No newline at end of file diff --git a/src/routers/__init__.py b/src/routers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..42c5ad12e4d39896e2574a89c1681aab8c930dc2 --- /dev/null +++ b/src/routers/__init__.py @@ -0,0 +1,3 @@ +from . import auth, tasks + +__all__ = ["auth", "tasks"] \ No newline at end of file diff --git a/src/routers/__pycache__/__init__.cpython-312.pyc b/src/routers/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..97ecfac906a7c933884986ed7eeba55205870986 Binary files /dev/null and b/src/routers/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/routers/__pycache__/auth.cpython-312.pyc b/src/routers/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2b74898160a572bf38008f0525d5b1ab4292cc3e Binary files /dev/null and b/src/routers/__pycache__/auth.cpython-312.pyc differ diff --git a/src/routers/__pycache__/projects.cpython-312.pyc b/src/routers/__pycache__/projects.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..91445a8cb9fdb8187f7330446ff48a351441f39a Binary files /dev/null and b/src/routers/__pycache__/projects.cpython-312.pyc differ diff --git a/src/routers/__pycache__/tasks.cpython-312.pyc b/src/routers/__pycache__/tasks.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c3d46ae7e7274a482c81d3bf8eb3e1368fa8f260 Binary files /dev/null and b/src/routers/__pycache__/tasks.cpython-312.pyc differ diff --git a/src/routers/auth.py b/src/routers/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..2d9c6c7e29bfe532e75e9c3ceb5f888a362a98ac --- /dev/null +++ b/src/routers/auth.py @@ -0,0 +1,189 @@ +from fastapi import APIRouter, HTTPException, status, Depends, Response, Request +from sqlmodel import Session, select +from typing import Annotated +from datetime import datetime, timedelta +from uuid import uuid4 +import secrets + +from ..models.user import User, UserCreate, UserRead +from ..schemas.auth import RegisterRequest, RegisterResponse, LoginRequest, LoginResponse, ForgotPasswordRequest, ResetPasswordRequest +from ..utils.security import hash_password, create_access_token, verify_password +from ..utils.deps import get_current_user +from ..database import get_session_dep +from ..config import settings + + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED) +def register(user_data: RegisterRequest, response: Response, session: Session = Depends(get_session_dep)): + """Register a new user with email and password.""" + + # Check if user already exists + existing_user = session.exec(select(User).where(User.email == user_data.email)).first() + if existing_user: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="An account with this email already exists" + ) + + # Validate password length + if len(user_data.password) < 8: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Password must be at least 8 characters" + ) + + # Hash the password + password_hash = hash_password(user_data.password) + + # Create new user + user = User( + email=user_data.email, + password_hash=password_hash + ) + + session.add(user) + session.commit() + session.refresh(user) + + # Create access token + access_token = create_access_token(data={"sub": str(user.id)}) + + # Set the token as an httpOnly cookie + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, + secure=settings.JWT_COOKIE_SECURE, # True in production, False in development + samesite="lax", + max_age=settings.ACCESS_TOKEN_EXPIRE_DAYS * 24 * 60 * 60, # Convert days to seconds + path="/" + ) + + # Return response + return RegisterResponse( + id=user.id, + email=user.email, + message="Account created successfully" + ) + + +@router.post("/login", response_model=LoginResponse) +def login(login_data: LoginRequest, response: Response, session: Session = Depends(get_session_dep)): + """Authenticate user with email and password, return JWT token.""" + + # Find user by email + user = session.exec(select(User).where(User.email == login_data.email)).first() + + if not user or not verify_password(login_data.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Create access token + access_token = create_access_token(data={"sub": str(user.id)}) + + # Set the token as an httpOnly cookie + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, + secure=settings.JWT_COOKIE_SECURE, # True in production, False in development + samesite="lax", + max_age=settings.ACCESS_TOKEN_EXPIRE_DAYS * 24 * 60 * 60, # Convert days to seconds + path="/" + ) + + # Debug: Print the cookie being set + print(f"Setting cookie: access_token={access_token}") + print(f"Cookie attributes: httponly={True}, secure={settings.JWT_COOKIE_SECURE}, samesite=lax, max_age={settings.ACCESS_TOKEN_EXPIRE_DAYS * 24 * 60 * 60}") + + # Return response + return LoginResponse( + access_token=access_token, + token_type="bearer", + user=RegisterResponse( + id=user.id, + email=user.email, + message="Login successful" + ) + ) + + +@router.post("/logout") +def logout(response: Response): + """Logout user by clearing the access token cookie.""" + # Clear the access_token cookie + response.set_cookie( + key="access_token", + value="", + httponly=True, + secure=settings.JWT_COOKIE_SECURE, + samesite="lax", + max_age=0, # Expire immediately + path="/" + ) + + return {"message": "Logged out successfully"} + + +@router.get("/me", response_model=RegisterResponse) +def get_current_user_profile(request: Request, current_user: User = Depends(get_current_user)): + """Get the current authenticated user's profile.""" + # Debug: Print the cookies received + print(f"Received cookies: {request.cookies}") + print(f"Access token cookie: {request.cookies.get('access_token')}") + + return RegisterResponse( + id=current_user.id, + email=current_user.email, + message="User profile retrieved successfully" + ) + + +@router.post("/forgot-password") +def forgot_password(forgot_data: ForgotPasswordRequest, session: Session = Depends(get_session_dep)): + """Initiate password reset process by verifying email exists.""" + # Check if user exists + user = session.exec(select(User).where(User.email == forgot_data.email)).first() + + if not user: + # For security reasons, we don't reveal if the email exists or not + return {"message": "If the email exists, a reset link would be sent"} + + # In a real implementation, we would send an email here + # But as per requirements, we're just simulating the process + return {"message": "If the email exists, a reset link would be sent"} + + +@router.post("/reset-password") +def reset_password(reset_data: ResetPasswordRequest, session: Session = Depends(get_session_dep)): + """Reset user password after verification.""" + # Check if user exists + user = session.exec(select(User).where(User.email == reset_data.email)).first() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + # Validate password length + if len(reset_data.new_password) < 8: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Password must be at least 8 characters" + ) + + # Hash the new password + user.password_hash = hash_password(reset_data.new_password) + + # Update the user + session.add(user) + session.commit() + + return {"message": "Password reset successfully"} \ No newline at end of file diff --git a/src/routers/projects.py b/src/routers/projects.py new file mode 100644 index 0000000000000000000000000000000000000000..cf49633350b57428836796051622f3821d84fc15 --- /dev/null +++ b/src/routers/projects.py @@ -0,0 +1,259 @@ +from fastapi import APIRouter, HTTPException, status, Depends +from sqlmodel import Session, select, and_, func +from typing import List +from uuid import UUID +from datetime import datetime + +from ..models.user import User +from ..models.project import Project, ProjectCreate, ProjectUpdate, ProjectRead +from ..models.task import Task +from ..database import get_session_dep +from ..utils.deps import get_current_user + + +router = APIRouter(prefix="/api/{user_id}/projects", tags=["projects"]) + + +@router.get("/", response_model=List[ProjectRead]) +def list_projects( + user_id: UUID, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session_dep) +): + """List all projects for the authenticated user.""" + + # Verify that the user_id in the URL matches the authenticated user + if current_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Project not found" + ) + + # Build the query with user_id filter + query = select(Project).where(Project.user_id == user_id) + + # Apply ordering (newest first) + query = query.order_by(Project.created_at.desc()) + + projects = session.exec(query).all() + return projects + + +@router.post("/", response_model=ProjectRead, status_code=status.HTTP_201_CREATED) +def create_project( + *, + user_id: UUID, + project_data: ProjectCreate, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session_dep) +): + """Create a new project for the authenticated user.""" + + # Verify that the user_id in the URL matches the authenticated user + if current_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + # Create the project + project = Project( + name=project_data.name, + description=project_data.description, + color=project_data.color, + user_id=user_id + ) + + session.add(project) + session.commit() + session.refresh(project) + + return project + + +@router.get("/{project_id}", response_model=ProjectRead) +def get_project( + *, + user_id: UUID, + project_id: UUID, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session_dep) +): + """Get a specific project by ID for the authenticated user.""" + + # Verify that the user_id in the URL matches the authenticated user + if current_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Project not found" + ) + + # Fetch the project + project = session.get(Project, project_id) + + # Check if project exists and belongs to the user + if not project or project.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Project not found" + ) + + return project + + +@router.put("/{project_id}", response_model=ProjectRead) +def update_project( + *, + user_id: UUID, + project_id: UUID, + project_data: ProjectUpdate, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session_dep) +): + """Update an existing project for the authenticated user.""" + + # Verify that the user_id in the URL matches the authenticated user + if current_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Project not found" + ) + + # Fetch the project + project = session.get(Project, project_id) + + # Check if project exists and belongs to the user + if not project or project.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Project not found" + ) + + # Update the project + project_data_dict = project_data.dict(exclude_unset=True) + for key, value in project_data_dict.items(): + setattr(project, key, value) + + session.add(project) + session.commit() + session.refresh(project) + + return project + + +@router.delete("/{project_id}") +def delete_project( + *, + user_id: UUID, + project_id: UUID, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session_dep) +): + """Delete a project for the authenticated user.""" + + # Verify that the user_id in the URL matches the authenticated user + if current_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Project not found" + ) + + # Fetch the project + project = session.get(Project, project_id) + + # Check if project exists and belongs to the user + if not project or project.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Project not found" + ) + + # Delete the project + session.delete(project) + session.commit() + + return {"message": "Project deleted successfully"} + + +@router.get("/{project_id}/tasks", response_model=List[Task]) +def list_project_tasks( + *, + user_id: UUID, + project_id: UUID, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session_dep) +): + """List all tasks for a specific project.""" + + # Verify that the user_id in the URL matches the authenticated user + if current_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Project not found" + ) + + # Fetch the project + project = session.get(Project, project_id) + + # Check if project exists and belongs to the user + if not project or project.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Project not found" + ) + + # Build the query with project_id filter + query = select(Task).where(Task.project_id == project_id) + + # Apply ordering (newest first) + query = query.order_by(Task.created_at.desc()) + + tasks = session.exec(query).all() + return tasks + + +@router.get("/{project_id}/progress") +def get_project_progress( + *, + user_id: UUID, + project_id: UUID, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session_dep) +): + """Get progress statistics for a specific project.""" + + # Verify that the user_id in the URL matches the authenticated user + if current_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Project not found" + ) + + # Fetch the project + project = session.get(Project, project_id) + + # Check if project exists and belongs to the user + if not project or project.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Project not found" + ) + + # Get task counts + total_tasks_query = select(func.count()).where(Task.project_id == project_id) + completed_tasks_query = select(func.count()).where(and_(Task.project_id == project_id, Task.completed == True)) + + total_tasks = session.exec(total_tasks_query).first() + completed_tasks = session.exec(completed_tasks_query).first() + + # Calculate progress + progress = 0 + if total_tasks > 0: + progress = round((completed_tasks / total_tasks) * 100, 2) + + return { + "total_tasks": total_tasks, + "completed_tasks": completed_tasks, + "pending_tasks": total_tasks - completed_tasks, + "progress": progress + } \ No newline at end of file diff --git a/src/routers/tasks.py b/src/routers/tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..0e9b6745c724edf9ad6ce9f29ae649d2cbb882cd --- /dev/null +++ b/src/routers/tasks.py @@ -0,0 +1,397 @@ +from fastapi import APIRouter, HTTPException, status, Depends +from sqlmodel import Session, select, and_, func +from typing import List +from uuid import UUID +from datetime import datetime + +from ..models.user import User +from ..models.task import Task, TaskCreate, TaskUpdate, TaskRead +from ..schemas.task import TaskListResponse +from ..database import get_session_dep +from ..utils.deps import get_current_user + + +router = APIRouter(prefix="/api/{user_id}/tasks", tags=["tasks"]) + + +@router.get("/", response_model=TaskListResponse) +def list_tasks( + user_id: UUID, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session_dep), + completed: bool = None, + offset: int = 0, + limit: int = 50 +): + """List all tasks for the authenticated user with optional filtering.""" + + # Verify that the user_id in the URL matches the authenticated user + if current_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Task not found" + ) + + # Build the query with user_id filter + query = select(Task).where(Task.user_id == user_id) + + # Apply completed filter if specified + if completed is not None: + query = query.where(Task.completed == completed) + + # Apply ordering (newest first) + query = query.order_by(Task.created_at.desc()) + + # Apply pagination + query = query.offset(offset).limit(limit) + + tasks = session.exec(query).all() + + # Get total count for pagination info + total_query = select(func.count()).select_from(Task).where(Task.user_id == user_id) + if completed is not None: + total_query = total_query.where(Task.completed == completed) + total = session.exec(total_query).one() + + # Convert to response format + task_responses = [] + for task in tasks: + task_dict = { + "id": task.id, + "user_id": str(task.user_id), + "title": task.title, + "description": task.description, + "completed": task.completed, + "due_date": task.due_date.isoformat() if task.due_date else None, + "project_id": str(task.project_id) if task.project_id else None, + "created_at": task.created_at.isoformat(), + "updated_at": task.updated_at.isoformat() + } + task_responses.append(task_dict) + + return TaskListResponse( + tasks=task_responses, + total=total, + offset=offset, + limit=limit + ) + + +@router.post("/", response_model=TaskRead, status_code=status.HTTP_201_CREATED) +def create_task( + user_id: UUID, + task_data: TaskCreate, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session_dep) +): + """Create a new task for the authenticated user.""" + + # Verify that the user_id in the URL matches the authenticated user + if current_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + # Validate title length + if len(task_data.title) < 1 or len(task_data.title) > 200: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Title must be between 1 and 200 characters" + ) + + # Validate description length if provided + if task_data.description and len(task_data.description) > 1000: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Description must be 1000 characters or less" + ) + + # Create new task + task = Task( + title=task_data.title, + description=task_data.description, + completed=task_data.completed, + due_date=task_data.due_date, + project_id=task_data.project_id, + user_id=user_id + ) + + session.add(task) + session.commit() + session.refresh(task) + + return TaskRead( + id=task.id, + user_id=task.user_id, + title=task.title, + description=task.description, + completed=task.completed, + due_date=task.due_date, + project_id=task.project_id, + created_at=task.created_at, + updated_at=task.updated_at + ) + + +@router.get("/{task_id}", response_model=TaskRead) +def get_task( + user_id: UUID, + task_id: int, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session_dep) +): + """Get a specific task by ID for the authenticated user.""" + + # Verify that the user_id in the URL matches the authenticated user + if current_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Task not found" + ) + + # Get the task + task = session.get(Task, task_id) + + # Verify the task exists and belongs to the user + if not task or task.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Task not found" + ) + + return TaskRead( + id=task.id, + user_id=task.user_id, + title=task.title, + description=task.description, + completed=task.completed, + due_date=task.due_date, + project_id=task.project_id, + created_at=task.created_at, + updated_at=task.updated_at + ) + + +@router.put("/{task_id}", response_model=TaskRead) +def update_task( + user_id: UUID, + task_id: int, + task_data: TaskUpdate, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session_dep) +): + """Update an existing task for the authenticated user.""" + + # Verify that the user_id in the URL matches the authenticated user + if current_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Task not found" + ) + + # Get the task + task = session.get(Task, task_id) + + # Verify the task exists and belongs to the user + if not task or task.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Task not found" + ) + + # Update fields if provided + if task_data.title is not None: + if len(task_data.title) < 1 or len(task_data.title) > 200: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Title must be between 1 and 200 characters" + ) + task.title = task_data.title + + if task_data.description is not None: + if len(task_data.description) > 1000: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Description must be 1000 characters or less" + ) + task.description = task_data.description + + if task_data.completed is not None: + task.completed = task_data.completed + + if task_data.due_date is not None: + task.due_date = task_data.due_date + + if task_data.project_id is not None: + task.project_id = task_data.project_id + + # Update the timestamp + task.updated_at = datetime.utcnow() + + session.add(task) + session.commit() + session.refresh(task) + + return TaskRead( + id=task.id, + user_id=task.user_id, + title=task.title, + description=task.description, + completed=task.completed, + due_date=task.due_date, + project_id=task.project_id, + created_at=task.created_at, + updated_at=task.updated_at + ) + + +@router.patch("/{task_id}", response_model=TaskRead) +def patch_task( + user_id: UUID, + task_id: int, + task_data: TaskUpdate, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session_dep) +): + """Partially update an existing task for the authenticated user.""" + + # Verify that the user_id in the URL matches the authenticated user + if current_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Task not found" + ) + + # Get the task + task = session.get(Task, task_id) + + # Verify the task exists and belongs to the user + if not task or task.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Task not found" + ) + + # Update fields if provided + if task_data.title is not None: + if len(task_data.title) < 1 or len(task_data.title) > 200: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Title must be between 1 and 200 characters" + ) + task.title = task_data.title + + if task_data.description is not None: + if len(task_data.description) > 1000: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Description must be 1000 characters or less" + ) + task.description = task_data.description + + if task_data.completed is not None: + task.completed = task_data.completed + + if task_data.due_date is not None: + task.due_date = task_data.due_date + + if task_data.project_id is not None: + task.project_id = task_data.project_id + + # Update the timestamp + task.updated_at = datetime.utcnow() + + session.add(task) + session.commit() + session.refresh(task) + + return TaskRead( + id=task.id, + user_id=task.user_id, + title=task.title, + description=task.description, + completed=task.completed, + due_date=task.due_date, + project_id=task.project_id, + created_at=task.created_at, + updated_at=task.updated_at + ) + + +@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_task( + user_id: UUID, + task_id: int, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session_dep) +): + """Delete a task for the authenticated user.""" + + # Verify that the user_id in the URL matches the authenticated user + if current_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Task not found" + ) + + # Get the task + task = session.get(Task, task_id) + + # Verify the task exists and belongs to the user + if not task or task.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Task not found" + ) + + session.delete(task) + session.commit() + + # Return 204 No Content + return + + +@router.patch("/{task_id}/toggle", response_model=TaskRead) +def toggle_task_completion( + user_id: UUID, + task_id: int, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session_dep) +): + """Toggle the completion status of a task.""" + + # Verify that the user_id in the URL matches the authenticated user + if current_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Task not found" + ) + + # Get the task + task = session.get(Task, task_id) + + # Verify the task exists and belongs to the user + if not task or task.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Task not found" + ) + + # Toggle the completion status + task.completed = not task.completed + task.updated_at = datetime.utcnow() + + session.add(task) + session.commit() + session.refresh(task) + + return TaskRead( + id=task.id, + user_id=task.user_id, + title=task.title, + description=task.description, + completed=task.completed, + created_at=task.created_at, + updated_at=task.updated_at + ) \ No newline at end of file diff --git a/src/schemas/__pycache__/auth.cpython-312.pyc b/src/schemas/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ce1c0cf94bded14586e830315e7f86c187c3e647 Binary files /dev/null and b/src/schemas/__pycache__/auth.cpython-312.pyc differ diff --git a/src/schemas/__pycache__/task.cpython-312.pyc b/src/schemas/__pycache__/task.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a2068391f62ba5f4ccf96930f6cb749477e01f39 Binary files /dev/null and b/src/schemas/__pycache__/task.cpython-312.pyc differ diff --git a/src/schemas/auth.py b/src/schemas/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..c221bbbb5a031871ae70c3cfee05477f7a3696db --- /dev/null +++ b/src/schemas/auth.py @@ -0,0 +1,41 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional +from datetime import datetime +from uuid import UUID + + +class RegisterRequest(BaseModel): + email: EmailStr + password: str + + +class RegisterResponse(BaseModel): + id: UUID + email: EmailStr + message: str + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class LoginResponse(BaseModel): + access_token: str + token_type: str + user: RegisterResponse + + +class ErrorResponse(BaseModel): + detail: str + status_code: Optional[int] = None + errors: Optional[list] = None + + +class ForgotPasswordRequest(BaseModel): + email: EmailStr + + +class ResetPasswordRequest(BaseModel): + email: EmailStr + new_password: str \ No newline at end of file diff --git a/src/schemas/task.py b/src/schemas/task.py new file mode 100644 index 0000000000000000000000000000000000000000..1a395fa211b352e5cf705bb23a5444530970cd39 --- /dev/null +++ b/src/schemas/task.py @@ -0,0 +1,39 @@ +from pydantic import BaseModel +from typing import List, Optional +from datetime import datetime +from uuid import UUID + + +class TaskBase(BaseModel): + title: str + description: Optional[str] = None + completed: bool = False + due_date: Optional[datetime] = None + project_id: Optional[UUID] = None + + +class TaskCreate(TaskBase): + title: str + description: Optional[str] = None + + +class TaskUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + completed: Optional[bool] = None + + +class TaskRead(TaskBase): + id: int + user_id: UUID + due_date: Optional[datetime] = None + project_id: Optional[UUID] = None + created_at: datetime + updated_at: datetime + + +class TaskListResponse(BaseModel): + tasks: List[TaskRead] + total: int + offset: int + limit: int \ No newline at end of file diff --git a/src/task_api.egg-info/PKG-INFO b/src/task_api.egg-info/PKG-INFO new file mode 100644 index 0000000000000000000000000000000000000000..1c7c47c051182e9ac90b7ba5da1ed827aaee376e --- /dev/null +++ b/src/task_api.egg-info/PKG-INFO @@ -0,0 +1,16 @@ +Metadata-Version: 2.4 +Name: task-api +Version: 0.1.0 +Summary: Add your description here +Requires-Python: >=3.12 +Description-Content-Type: text/markdown +Requires-Dist: alembic>=1.17.2 +Requires-Dist: fastapi>=0.124.4 +Requires-Dist: passlib[bcrypt]>=1.7.4 +Requires-Dist: psycopg2-binary>=2.9.11 +Requires-Dist: pydantic-settings>=2.12.0 +Requires-Dist: pydantic[email]>=2.12.5 +Requires-Dist: python-jose[cryptography]>=3.5.0 +Requires-Dist: python-multipart>=0.0.20 +Requires-Dist: sqlmodel>=0.0.27 +Requires-Dist: uvicorn>=0.38.0 diff --git a/src/task_api.egg-info/SOURCES.txt b/src/task_api.egg-info/SOURCES.txt new file mode 100644 index 0000000000000000000000000000000000000000..462007061bce7601d7b8a22c46ac70f3d8439b26 --- /dev/null +++ b/src/task_api.egg-info/SOURCES.txt @@ -0,0 +1,21 @@ +README.md +pyproject.toml +src/__init__.py +src/config.py +src/database.py +src/main.py +src/middleware/auth.py +src/models/task.py +src/models/user.py +src/routers/__init__.py +src/routers/auth.py +src/routers/tasks.py +src/schemas/auth.py +src/schemas/task.py +src/task_api.egg-info/PKG-INFO +src/task_api.egg-info/SOURCES.txt +src/task_api.egg-info/dependency_links.txt +src/task_api.egg-info/requires.txt +src/task_api.egg-info/top_level.txt +src/utils/deps.py +src/utils/security.py \ No newline at end of file diff --git a/src/task_api.egg-info/dependency_links.txt b/src/task_api.egg-info/dependency_links.txt new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/src/task_api.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/task_api.egg-info/requires.txt b/src/task_api.egg-info/requires.txt new file mode 100644 index 0000000000000000000000000000000000000000..e602cbe22e4dd2c7109353f698ee00d598a5a425 --- /dev/null +++ b/src/task_api.egg-info/requires.txt @@ -0,0 +1,10 @@ +alembic>=1.17.2 +fastapi>=0.124.4 +passlib[bcrypt]>=1.7.4 +psycopg2-binary>=2.9.11 +pydantic-settings>=2.12.0 +pydantic[email]>=2.12.5 +python-jose[cryptography]>=3.5.0 +python-multipart>=0.0.20 +sqlmodel>=0.0.27 +uvicorn>=0.38.0 diff --git a/src/task_api.egg-info/top_level.txt b/src/task_api.egg-info/top_level.txt new file mode 100644 index 0000000000000000000000000000000000000000..4c63ae26e92270f778cff08099c97ead89b451d2 --- /dev/null +++ b/src/task_api.egg-info/top_level.txt @@ -0,0 +1,9 @@ +__init__ +config +database +main +middleware +models +routers +schemas +utils diff --git a/src/utils/__pycache__/deps.cpython-312.pyc b/src/utils/__pycache__/deps.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..24d805157662c72a0788ffb2150aefc125d017b8 Binary files /dev/null and b/src/utils/__pycache__/deps.cpython-312.pyc differ diff --git a/src/utils/__pycache__/security.cpython-312.pyc b/src/utils/__pycache__/security.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1a9f71987a6ee23de78e2399d7700dac0079a389 Binary files /dev/null and b/src/utils/__pycache__/security.cpython-312.pyc differ diff --git a/src/utils/deps.py b/src/utils/deps.py new file mode 100644 index 0000000000000000000000000000000000000000..6f886cb954c58dd4baaa4f84d55a0ec19b6689d2 --- /dev/null +++ b/src/utils/deps.py @@ -0,0 +1,66 @@ +from fastapi import Depends, HTTPException, status, Request +from sqlmodel import Session +from typing import Generator +from ..database import get_session_dep +from ..models.user import User +from .security import verify_user_id_from_token +from uuid import UUID + + +def get_current_user( + request: Request, + session: Session = Depends(get_session_dep) +) -> User: + """Dependency to get the current authenticated user from JWT token in cookie.""" + # Debug: Print all cookies + print(f"All cookies received: {request.cookies}") + + # Get the token from the cookie + token = request.cookies.get("access_token") + print(f"Access token from cookie: {token}") + + if not token: + print("No access token found in cookies") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user_id = verify_user_id_from_token(token) + print(f"User ID from token: {user_id}") + + if not user_id: + print("Invalid user ID from token") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user = session.get(User, user_id) + print(f"User from database: {user}") + + if not user: + print("User not found in database") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return user + + +def get_user_by_id( + user_id: UUID, + session: Session = Depends(get_session_dep) +) -> User: + """Dependency to get a user by ID from the database.""" + user = session.get(User, user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + return user \ No newline at end of file diff --git a/src/utils/security.py b/src/utils/security.py new file mode 100644 index 0000000000000000000000000000000000000000..036bef2a8aef2cee9f967e2709a11482745555e0 --- /dev/null +++ b/src/utils/security.py @@ -0,0 +1,57 @@ +from passlib.context import CryptContext +from datetime import datetime, timedelta +from typing import Optional, Union +import uuid +from jose import JWTError, jwt +from ..config import settings + +# Password hashing context +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password: str) -> str: + """Hash a password using bcrypt.""" + return pwd_context.hash(password) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a plain password against its hash.""" + return pwd_context.verify(plain_password, hashed_password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """Create a JWT access token.""" + to_encode = data.copy() + + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + # Default to 7 days if no expiration is provided + expire = datetime.utcnow() + timedelta(days=settings.ACCESS_TOKEN_EXPIRE_DAYS) + + to_encode.update({"exp": expire, "iat": datetime.utcnow()}) + + encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + return encoded_jwt + + +def verify_token(token: str) -> Optional[dict]: + """Verify a JWT token and return the payload if valid.""" + try: + payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) + return payload + except JWTError: + return None + + +def verify_user_id_from_token(token: str) -> Optional[uuid.UUID]: + """Extract user_id from JWT token.""" + payload = verify_token(token) + if payload: + user_id_str = payload.get("sub") + if user_id_str: + try: + return uuid.UUID(user_id_str) + except ValueError: + return None + return None \ No newline at end of file