Implement Task Manager API with FastAPI and SQLite

- Setup project structure and FastAPI application
- Configure SQLite database with SQLAlchemy ORM
- Setup Alembic for database migrations
- Implement user authentication with JWT
- Create task models and CRUD operations
- Implement task assignment functionality
- Add detailed API documentation
- Create comprehensive README with usage instructions
- Lint code with Ruff
This commit is contained in:
Automated Action 2025-06-08 18:02:43 +00:00
parent c06ee091dc
commit d4b0ceed9c
38 changed files with 1659 additions and 2 deletions

168
README.md
View File

@ -1,3 +1,167 @@
# FastAPI Application
# Task Manager API
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform.
A comprehensive task management API built with FastAPI and SQLite. This API provides a full-featured backend for task management applications, including user authentication, task CRUD operations, and task assignment functionality.
## Features
- **User Management**
- User registration and authentication
- JWT-based authentication
- User profile management
- **Task Management**
- Create, read, update, and delete tasks
- Task filtering and search
- Task status tracking (todo, in_progress, completed, cancelled)
- Task priority levels (low, medium, high, urgent)
- **Task Assignment**
- Assign tasks to multiple users
- View tasks assigned to you
- Task ownership and permission controls
- **API Documentation**
- Interactive API documentation with Swagger UI and ReDoc
- Detailed endpoint descriptions
## Technical Stack
- **Framework**: FastAPI
- **Database**: SQLite with SQLAlchemy ORM
- **Migration Tool**: Alembic
- **Authentication**: JWT with OAuth2
- **Password Hashing**: Bcrypt
## Project Structure
```
taskmanagerapi/
├── alembic.ini # Alembic configuration
├── main.py # Application entry point
├── migrations/ # Database migrations
│ ├── env.py
│ ├── README
│ ├── script.py.mako
│ └── versions/ # Migration versions
│ └── 001_initial_tables.py
├── app/ # Application package
│ ├── api/ # API endpoints
│ │ └── v1/ # API version 1
│ │ ├── api.py # API router
│ │ └── endpoints/ # API endpoint modules
│ │ ├── auth.py
│ │ ├── health.py
│ │ ├── task_assignments.py
│ │ ├── tasks.py
│ │ └── users.py
│ ├── core/ # Core modules
│ │ ├── config.py # Application configuration
│ │ ├── deps.py # Dependencies
│ │ ├── docs.py # API documentation
│ │ └── security.py # Security utilities
│ ├── db/ # Database
│ │ ├── base.py # Base models
│ │ ├── base_class.py # Base class for models
│ │ ├── base_models.py # Import all models
│ │ └── session.py # Database session
│ ├── models/ # SQLAlchemy models
│ │ ├── task.py
│ │ └── user.py
│ ├── schemas/ # Pydantic schemas
│ │ ├── task.py
│ │ ├── token.py
│ │ └── user.py
│ ├── services/ # Business logic
│ └── utils/ # Utility functions
└── requirements.txt # Project dependencies
```
## Getting Started
### Prerequisites
- Python 3.8+
- pip (Python package installer)
### Installation
1. Clone the repository:
```bash
git clone <repository-url>
cd taskmanagerapi
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Set environment variables:
```bash
# For production use, set a secure secret key
export SECRET_KEY="your-secure-secret-key"
# Optional: Set the JWT token expiration time in minutes
export ACCESS_TOKEN_EXPIRE_MINUTES=30
```
4. Run database migrations:
```bash
alembic upgrade head
```
5. Start the application:
```bash
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
```
6. Access the API documentation:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| SECRET_KEY | JWT secret key | "your-super-secret-key-for-development-only" |
| ACCESS_TOKEN_EXPIRE_MINUTES | JWT token expiration in minutes | 30 |
## API Endpoints
### Authentication
- **POST /api/v1/auth/login** - Login and get access token
### Users
- **POST /api/v1/users** - Register a new user
- **GET /api/v1/users/me** - Get current user info
- **PUT /api/v1/users/me** - Update current user
- **GET /api/v1/users/{user_id}** - Get user by ID (admin or self only)
- **PUT /api/v1/users/{user_id}** - Update user by ID (admin only)
### Tasks
- **GET /api/v1/tasks** - List tasks (owned or assigned to current user)
- **POST /api/v1/tasks** - Create a new task
- **GET /api/v1/tasks/{task_id}** - Get task by ID
- **PUT /api/v1/tasks/{task_id}** - Update task by ID
- **DELETE /api/v1/tasks/{task_id}** - Delete task by ID (soft delete)
### Task Assignments
- **GET /api/v1/tasks/{task_id}/assignees** - Get task assignees
- **POST /api/v1/tasks/{task_id}/assignees/{user_id}** - Assign user to task
- **DELETE /api/v1/tasks/{task_id}/assignees/{user_id}** - Remove user from task
### Health
- **GET /api/v1/health** - API health check
- **GET /health** - General health check
## License
This project is licensed under the MIT License - see the LICENSE file for details.
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.

85
alembic.ini Normal file
View File

@ -0,0 +1,85 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# timezone to use when rendering the date
# within the migration file as well as the filename.
# string value is passed to dateutil.tz.gettz()
# 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 migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path
# version_locations = %(here)s/bar %(here)s/bat migrations/versions
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# SQLite URL - using absolute path
sqlalchemy.url = sqlite:////app/storage/db/db.sqlite
[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
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[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

1
app/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file exists to make the directory a package

1
app/api/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file exists to make the directory a package

1
app/api/v1/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file exists to make the directory a package

15
app/api/v1/api.py Normal file
View File

@ -0,0 +1,15 @@
from fastapi import APIRouter
from app.api.v1.endpoints import auth, health, task_assignments, tasks, users
api_router = APIRouter()
api_router.include_router(health.router, prefix="/health", tags=["Health"])
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
api_router.include_router(users.router, prefix="/users", tags=["Users"])
api_router.include_router(tasks.router, prefix="/tasks", tags=["Tasks"])
api_router.include_router(
task_assignments.router,
prefix="/tasks",
tags=["Task Assignments"]
)

View File

@ -0,0 +1 @@
# This file exists to make the directory a package

View File

@ -0,0 +1,40 @@
from datetime import timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.deps import authenticate_user, get_db
from app.core.security import create_access_token
from app.schemas.token import Token
router = APIRouter()
@router.post("/login", response_model=Token)
def login_access_token(
db: Session = Depends(get_db),
form_data: OAuth2PasswordRequestForm = Depends()
) -> Any:
"""
OAuth2 compatible token login, get an access token for future requests
"""
user = authenticate_user(db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Create access token
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return {
"access_token": create_access_token(
user.id, expires_delta=access_token_expires
),
"token_type": "bearer",
}

View File

@ -0,0 +1,20 @@
from fastapi import APIRouter
from starlette.responses import JSONResponse
from app.core.config import settings
router = APIRouter()
@router.get("/")
async def health_check():
"""
Check if the API service is running properly
"""
return JSONResponse(
content={
"status": "healthy",
"version": settings.VERSION,
"project": settings.PROJECT_NAME,
}
)

View File

@ -0,0 +1,149 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import and_
from sqlalchemy.orm import Session
from app.core.deps import get_current_user, get_db
from app.models.task import Task
from app.models.user import User
from app.schemas.user import User as UserSchema
router = APIRouter()
@router.get("/{task_id}/assignees", response_model=List[UserSchema])
def read_task_assignees(
*,
db: Session = Depends(get_db),
task_id: int,
current_user: User = Depends(get_current_user),
) -> Any:
"""
Get all assignees for a specific task.
"""
task = db.query(Task).filter(
and_(
Task.id == task_id,
not Task.is_deleted
)
).first()
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found"
)
# Check if user has access to task
if task.owner_id != current_user.id and current_user not in task.assignees:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this task"
)
return task.assignees
@router.post("/{task_id}/assignees/{user_id}", status_code=status.HTTP_201_CREATED)
def assign_user_to_task(
*,
db: Session = Depends(get_db),
task_id: int,
user_id: int,
current_user: User = Depends(get_current_user),
) -> Any:
"""
Assign a user to a task.
"""
# Get task
task = db.query(Task).filter(
and_(
Task.id == task_id,
not Task.is_deleted
)
).first()
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found"
)
# Only task owner can assign users
if task.owner_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the task owner can assign users"
)
# Get user to assign
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Check if user is already assigned
if user in task.assignees:
return {"message": "User is already assigned to this task"}
# Assign user to task
task.assignees.append(user)
db.commit()
return {"message": "User assigned to task successfully"}
@router.delete("/{task_id}/assignees/{user_id}", status_code=status.HTTP_200_OK)
def remove_user_from_task(
*,
db: Session = Depends(get_db),
task_id: int,
user_id: int,
current_user: User = Depends(get_current_user),
) -> Any:
"""
Remove a user from a task.
"""
# Get task
task = db.query(Task).filter(
and_(
Task.id == task_id,
not Task.is_deleted
)
).first()
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found"
)
# Only task owner can remove assignments
if task.owner_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the task owner can remove assignments"
)
# Get user to remove
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Check if user is assigned
if user not in task.assignees:
return {"message": "User is not assigned to this task"}
# Remove user from task
task.assignees.remove(user)
db.commit()
return {"message": "User removed from task successfully"}

View File

@ -0,0 +1,222 @@
from datetime import datetime
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from app.core.deps import get_current_user, get_db
from app.models.task import Task, TaskPriority, TaskStatus
from app.models.user import User
from app.schemas.task import (
Task as TaskSchema,
)
from app.schemas.task import (
TaskCreate,
TaskUpdate,
TaskWithAssignees,
)
router = APIRouter()
@router.get("/", response_model=List[TaskSchema])
def read_tasks(
*,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
skip: int = 0,
limit: int = 100,
status: Optional[TaskStatus] = None,
priority: Optional[TaskPriority] = None,
search: Optional[str] = None,
) -> Any:
"""
Retrieve tasks owned by current user or assigned to current user.
Optionally filter by status, priority, or search term.
"""
query = db.query(Task).filter(
and_(
or_(
Task.owner_id == current_user.id,
Task.assignees.any(id=current_user.id)
),
not Task.is_deleted
)
)
# Apply filters if provided
if status:
query = query.filter(Task.status == status)
if priority:
query = query.filter(Task.priority == priority)
if search:
query = query.filter(
or_(
Task.title.ilike(f"%{search}%"),
Task.description.ilike(f"%{search}%"),
)
)
# Apply pagination
tasks = query.order_by(Task.updated_at.desc()).offset(skip).limit(limit).all()
return tasks
@router.post("/", response_model=TaskSchema)
def create_task(
*,
db: Session = Depends(get_db),
task_in: TaskCreate,
current_user: User = Depends(get_current_user),
) -> Any:
"""
Create a new task.
"""
# Create task object
task = Task(
**task_in.model_dump(),
owner_id=current_user.id,
)
# Save to database
db.add(task)
db.commit()
db.refresh(task)
return task
@router.get("/{task_id}", response_model=TaskWithAssignees)
def read_task(
*,
db: Session = Depends(get_db),
task_id: int,
current_user: User = Depends(get_current_user),
) -> Any:
"""
Get details of a specific task.
"""
task = db.query(Task).filter(
and_(
Task.id == task_id,
not Task.is_deleted
)
).first()
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found"
)
# Check if user has access to task
if task.owner_id != current_user.id and current_user not in task.assignees:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this task"
)
# Convert to TaskWithAssignees schema
result = TaskWithAssignees.model_validate(task)
result.assignee_ids = [user.id for user in task.assignees]
return result
@router.put("/{task_id}", response_model=TaskSchema)
def update_task(
*,
db: Session = Depends(get_db),
task_id: int,
task_in: TaskUpdate,
current_user: User = Depends(get_current_user),
) -> Any:
"""
Update a task.
"""
task = db.query(Task).filter(
and_(
Task.id == task_id,
not Task.is_deleted
)
).first()
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found"
)
# Only task owner can update a task
if task.owner_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the task owner can update the task"
)
# Update with new data
update_data = task_in.model_dump(exclude_unset=True)
# If status is being changed to completed, set completed_at
if "status" in update_data and update_data["status"] == TaskStatus.COMPLETED:
update_data["completed_at"] = datetime.utcnow()
# If status is being changed from completed to another status, clear completed_at
elif "status" in update_data and task.status == TaskStatus.COMPLETED:
update_data["completed_at"] = None
# Update task object
for field, value in update_data.items():
setattr(task, field, value)
# Save to database
db.add(task)
db.commit()
db.refresh(task)
return task
@router.delete(
"/{task_id}",
status_code=status.HTTP_204_NO_CONTENT,
response_model=None
)
def delete_task(
*,
db: Session = Depends(get_db),
task_id: int,
current_user: User = Depends(get_current_user),
) -> Any:
"""
Delete a task (soft delete).
"""
task = db.query(Task).filter(
and_(
Task.id == task_id,
not Task.is_deleted
)
).first()
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found"
)
# Only task owner can delete a task
if task.owner_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the task owner can delete the task"
)
# Soft delete
task.is_deleted = True
# Save to database
db.add(task)
db.commit()
return None

View File

@ -0,0 +1,176 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.encoders import jsonable_encoder
from sqlalchemy.orm import Session
from app.core.deps import get_current_active_superuser, get_current_user, get_db
from app.core.security import get_password_hash
from app.models.user import User
from app.schemas.user import User as UserSchema
from app.schemas.user import UserCreate, UserUpdate
router = APIRouter()
@router.get("/", response_model=List[UserSchema])
def read_users(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_current_active_superuser),
) -> Any:
"""
Retrieve all users (superuser only).
"""
users = db.query(User).offset(skip).limit(limit).all()
return users
@router.post("/", response_model=UserSchema)
def create_user(
*,
db: Session = Depends(get_db),
user_in: UserCreate,
) -> Any:
"""
Create a new user.
"""
# Check if user with same email exists
user = db.query(User).filter(User.email == user_in.email).first()
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A user with this email already exists.",
)
# Check if user with same username exists
user = db.query(User).filter(User.username == user_in.username).first()
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A user with this username already exists.",
)
# Create user object
user_data = user_in.model_dump(exclude={"password"})
user = User(
**user_data,
hashed_password=get_password_hash(user_in.password),
)
# Save to database
db.add(user)
db.commit()
db.refresh(user)
return user
@router.get("/me", response_model=UserSchema)
def read_user_me(
current_user: User = Depends(get_current_user),
) -> Any:
"""
Get current user.
"""
return current_user
@router.put("/me", response_model=UserSchema)
def update_user_me(
*,
db: Session = Depends(get_db),
user_in: UserUpdate,
current_user: User = Depends(get_current_user),
) -> Any:
"""
Update current user.
"""
# Convert model to dict
user_data = jsonable_encoder(current_user)
# Update with new data
update_data = user_in.model_dump(exclude_unset=True)
# If password in update data, hash it
if "password" in update_data and update_data["password"]:
update_data["hashed_password"] = get_password_hash(update_data.pop("password"))
# Update user object
for field in user_data:
if field in update_data:
setattr(current_user, field, update_data[field])
# Save to database
db.add(current_user)
db.commit()
db.refresh(current_user)
return current_user
@router.get("/{user_id}", response_model=UserSchema)
def read_user_by_id(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Any:
"""
Get a specific user by id.
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Only superusers can access other users' data
if user.id != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to access this user",
)
return user
@router.put("/{user_id}", response_model=UserSchema)
def update_user(
*,
db: Session = Depends(get_db),
user_id: int,
user_in: UserUpdate,
current_user: User = Depends(get_current_active_superuser),
) -> Any:
"""
Update a user (superuser only).
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Update with new data
update_data = user_in.model_dump(exclude_unset=True)
# If password in update data, hash it
if "password" in update_data and update_data["password"]:
update_data["hashed_password"] = get_password_hash(update_data.pop("password"))
# Update user object
for field, value in update_data.items():
if hasattr(user, field):
setattr(user, field, value)
# Save to database
db.add(user)
db.commit()
db.refresh(user)
return user

1
app/core/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file exists to make the directory a package

39
app/core/config.py Normal file
View File

@ -0,0 +1,39 @@
import os
from pathlib import Path
from typing import Optional
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# API Information
PROJECT_NAME: str = "Task Manager API"
DESCRIPTION: str = "A FastAPI-based task management system"
VERSION: str = "0.1.0"
API_V1_STR: str = "/api/v1"
# Authentication settings
SECRET_KEY: str = os.environ.get(
"SECRET_KEY", "your-super-secret-key-for-development-only"
)
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# Database settings
DB_DIR: Path = Path("/app") / "storage" / "db"
SQLALCHEMY_DATABASE_URL: Optional[str] = None
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = True
settings = Settings()
# Ensure database directory exists
settings.DB_DIR.mkdir(parents=True, exist_ok=True)
# Set database URL after ensuring directory exists
if settings.SQLALCHEMY_DATABASE_URL is None:
settings.SQLALCHEMY_DATABASE_URL = f"sqlite:///{settings.DB_DIR}/db.sqlite"

83
app/core/deps.py Normal file
View File

@ -0,0 +1,83 @@
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from pydantic import ValidationError
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.security import verify_password
from app.db.session import get_db
from app.models.user import User
from app.schemas.token import TokenPayload
# OAuth2 token URL
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/auth/login"
)
def get_current_user(
db: Session = Depends(get_db),
token: str = Depends(oauth2_scheme)
) -> User:
"""
Get current user from token
"""
try:
# Decode the token
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
token_data = TokenPayload(**payload)
except (JWTError, ValidationError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
# Get user from database
user = db.query(User).filter(User.id == token_data.sub).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user"
)
return user
def get_current_active_superuser(
current_user: User = Depends(get_current_user),
) -> User:
"""
Get current superuser
"""
if not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The user doesn't have enough privileges"
)
return current_user
def authenticate_user(
db: Session, username: str, password: str
) -> Optional[User]:
"""
Authenticate a user by username and password
"""
user = db.query(User).filter(User.username == username).first()
if not user or not verify_password(password, user.hashed_password):
return None
return user

66
app/core/docs.py Normal file
View File

@ -0,0 +1,66 @@
from fastapi.openapi.models import ExternalDocumentation
# External documentation
external_docs = ExternalDocumentation(
description="Task Manager API Documentation",
url="/redoc",
)
# API description
api_description = """
# Task Manager API
A RESTful API for managing tasks and assignments built with FastAPI and SQLite.
## Features
* User authentication and registration
* Task creation and management
* Task status tracking
* Task assignment to users
* Filtering and searching tasks
## Authentication
This API uses OAuth2 with JWT tokens for authentication.
Most endpoints require authentication.
To authenticate:
1. Use the `/api/v1/auth/login` endpoint with your username and password
2. Use the returned token in the Authorization header as `Bearer {token}`
## Rate Limiting
There is no rate limiting implemented in this version of the API.
## Pagination
List endpoints support pagination with `skip` and `limit` parameters.
"""
# Tags metadata
tags_metadata = [
{
"name": "Health",
"description": "Health check endpoints to verify API status.",
},
{
"name": "Authentication",
"description": "Operations for authentication, including login.",
},
{
"name": "Users",
"description": "Operations with users, including registration, profile "
"and user management.",
},
{
"name": "Tasks",
"description": "CRUD operations for tasks, including creation, updates, "
"and retrieval.",
},
{
"name": "Task Assignments",
"description": "Operations for assigning tasks to users and managing "
"task assignments.",
},
]

44
app/core/security.py Normal file
View File

@ -0,0 +1,44 @@
from datetime import datetime, timedelta
from typing import Any, Union
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def create_access_token(
subject: Union[str, Any], expires_delta: timedelta = None
) -> str:
"""
Create a JWT access token
"""
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode = {"exp": expire, "sub": str(subject)}
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
)
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a password against a hash
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""
Hash a password
"""
return pwd_context.hash(password)

1
app/db/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file exists to make the directory a package

4
app/db/base.py Normal file
View File

@ -0,0 +1,4 @@
from sqlalchemy.ext.declarative import declarative_base
# Create a Base class for SQLAlchemy models
Base = declarative_base()

15
app/db/base_class.py Normal file
View File

@ -0,0 +1,15 @@
from typing import Any
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import as_declarative
@as_declarative()
class Base:
id: Any
__name__: str
# Generate tablename automatically based on class name
@declared_attr
def __tablename__(self) -> str:
return self.__name__.lower()

1
app/db/base_models.py Normal file
View File

@ -0,0 +1 @@
# Import all the models here so that Alembic can discover them

22
app/db/session.py Normal file
View File

@ -0,0 +1,22 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
# Create SQLAlchemy engine
engine = create_engine(
settings.SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False} # Only needed for SQLite
)
# Create sessionmaker
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Dependency for getting DB session
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

1
app/models/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file exists to make the directory a package

66
app/models/task.py Normal file
View File

@ -0,0 +1,66 @@
from datetime import datetime
from enum import Enum as PyEnum
from sqlalchemy import (
Boolean,
Column,
DateTime,
Enum,
ForeignKey,
Integer,
String,
Table,
Text,
)
from sqlalchemy.orm import relationship
from app.db.base import Base
class TaskStatus(str, PyEnum):
TODO = "todo"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
CANCELLED = "cancelled"
class TaskPriority(str, PyEnum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
URGENT = "urgent"
# Association table for task assignments
task_assignments = Table(
"task_assignments",
Base.metadata,
Column("task_id", Integer, ForeignKey("tasks.id"), primary_key=True),
Column("user_id", Integer, ForeignKey("users.id"), primary_key=True),
Column("assigned_at", DateTime, default=datetime.utcnow),
)
class Task(Base):
__tablename__ = "tasks"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False, index=True)
description = Column(Text, nullable=True)
status = Column(Enum(TaskStatus), default=TaskStatus.TODO, nullable=False)
priority = Column(Enum(TaskPriority), default=TaskPriority.MEDIUM, nullable=False)
due_date = Column(DateTime, nullable=True)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
is_deleted = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
completed_at = Column(DateTime, nullable=True)
# Relationships
owner = relationship("User", back_populates="tasks")
assignees = relationship("User",
secondary="task_assignments",
back_populates="assigned_tasks")
def __repr__(self):
return f"<Task {self.title} ({self.status})>"

29
app/models/user.py Normal file
View File

@ -0,0 +1,29 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from sqlalchemy.orm import relationship
from app.db.base import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
full_name = Column(String, nullable=True)
is_active = Column(Boolean, default=True)
is_superuser = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
tasks = relationship("Task", back_populates="owner")
assigned_tasks = relationship("Task",
secondary="task_assignments",
back_populates="assignees")
def __repr__(self):
return f"<User {self.username}>"

1
app/schemas/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file exists to make the directory a package

56
app/schemas/task.py Normal file
View File

@ -0,0 +1,56 @@
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, ConfigDict
from app.models.task import TaskPriority, TaskStatus
# Shared properties
class TaskBase(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
status: Optional[TaskStatus] = None
priority: Optional[TaskPriority] = None
due_date: Optional[datetime] = None
# Properties to receive via API on creation
class TaskCreate(TaskBase):
title: str
status: TaskStatus = TaskStatus.TODO
priority: TaskPriority = TaskPriority.MEDIUM
# Properties to receive via API on update
class TaskUpdate(TaskBase):
pass
# Properties shared by models stored in DB
class TaskInDBBase(TaskBase):
id: int
title: str
status: TaskStatus
priority: TaskPriority
owner_id: int
created_at: datetime
updated_at: datetime
completed_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
# Properties to return via API
class Task(TaskInDBBase):
pass
# Properties for tasks with assignee information
class TaskWithAssignees(Task):
assignee_ids: List[int] = []
# Properties stored in DB but not returned by API
class TaskInDB(TaskInDBBase):
is_deleted: bool = False

13
app/schemas/token.py Normal file
View File

@ -0,0 +1,13 @@
from typing import Optional
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
token_type: str
class TokenPayload(BaseModel):
sub: Optional[int] = None
exp: Optional[int] = None

44
app/schemas/user.py Normal file
View File

@ -0,0 +1,44 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, EmailStr
# Shared properties
class UserBase(BaseModel):
email: Optional[EmailStr] = None
username: Optional[str] = None
is_active: Optional[bool] = True
is_superuser: bool = False
full_name: Optional[str] = None
# Properties to receive via API on creation
class UserCreate(UserBase):
email: EmailStr
username: str
password: str
# Properties to receive via API on update
class UserUpdate(UserBase):
password: Optional[str] = None
# Properties shared by models stored in DB
class UserInDBBase(UserBase):
id: int
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
# Properties to return via API
class User(UserInDBBase):
pass
# Additional properties stored in DB but not returned by API
class UserInDB(UserInDBBase):
hashed_password: str

1
app/services/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file exists to make the directory a package

1
app/utils/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file exists to make the directory a package

61
main.py Normal file
View File

@ -0,0 +1,61 @@
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.responses import JSONResponse
from app.api.v1.api import api_router
from app.core.config import settings
from app.core.docs import api_description, external_docs, tags_metadata
app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.VERSION,
description=api_description,
openapi_url="/openapi.json",
docs_url="/docs",
redoc_url="/redoc",
openapi_tags=tags_metadata,
openapi_extra={"externalDocs": external_docs.dict()},
)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix=settings.API_V1_STR)
@app.get("/", tags=["Root"])
async def root():
"""
Root endpoint that returns basic API information
"""
return JSONResponse(
content={
"title": settings.PROJECT_NAME,
"docs": "/docs",
"health": "/health",
}
)
@app.get("/health", tags=["Health"])
async def health_check():
"""
Health check endpoint to verify the service is running
"""
return JSONResponse(
content={
"status": "healthy",
"version": settings.VERSION,
}
)
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

17
migrations/README Normal file
View File

@ -0,0 +1,17 @@
# Alembic Migrations
This directory contains database migrations for the application.
To apply migrations:
```
alembic upgrade head
```
To create a new migration:
```
alembic revision --autogenerate -m "description of changes"
```
For SQLite, we use the `render_as_batch=True` option to enable
batch migrations, which allows for more complex schema changes
that would otherwise not be supported by SQLite.

84
migrations/env.py Normal file
View File

@ -0,0 +1,84 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
# Import all models for Alembic to detect
# Import Base from our app for auto-generating migrations
from app.db.base import Base
# 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.
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.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():
"""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"},
# Use batch mode for SQLite (required for certain operations)
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""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:
# Use batch mode for SQLite (required for certain operations)
is_sqlite = connection.dialect.name == 'sqlite'
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=is_sqlite,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,86 @@
"""Initial tables
Revision ID: 001
Revises:
Create Date: 2023-07-01
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# Create enum types
task_status = sa.Enum(
'todo', 'in_progress', 'completed', 'cancelled',
name='taskstatus'
)
task_priority = sa.Enum('low', 'medium', 'high', 'urgent', name='taskpriority')
# Create tables
op.create_table(
'users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('username', sa.String(), nullable=False),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.Column('full_name', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), default=True),
sa.Column('is_superuser', sa.Boolean(), default=False),
sa.Column('created_at', sa.DateTime(), default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(),
default=sa.func.now(), onupdate=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email'),
sa.UniqueConstraint('username')
)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
op.create_table(
'tasks',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('status', task_status, nullable=False, default='todo'),
sa.Column('priority', task_priority, nullable=False, default='medium'),
sa.Column('due_date', sa.DateTime(), nullable=True),
sa.Column('owner_id', sa.Integer(), nullable=False),
sa.Column('is_deleted', sa.Boolean(), default=False),
sa.Column('created_at', sa.DateTime(), default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(),
default=sa.func.now(), onupdate=sa.func.now()),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tasks_id'), 'tasks', ['id'], unique=False)
op.create_index(op.f('ix_tasks_title'), 'tasks', ['title'], unique=False)
op.create_table(
'task_assignments',
sa.Column('task_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('assigned_at', sa.DateTime(), default=sa.func.now()),
sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('task_id', 'user_id')
)
def downgrade():
# Drop tables
op.drop_table('task_assignments')
op.drop_table('tasks')
op.drop_table('users')
# Drop enum types
op.execute('DROP TYPE taskstatus')
op.execute('DROP TYPE taskpriority')

10
pyproject.toml Normal file
View File

@ -0,0 +1,10 @@
[tool.ruff]
line-length = 88
target-version = "py38"
[tool.ruff.lint]
select = ["E", "F", "I", "W", "N", "UP"]
ignore = []
[tool.ruff.lint.isort]
known-third-party = ["fastapi", "pydantic", "sqlalchemy", "starlette", "uvicorn"]

12
requirements.txt Normal file
View File

@ -0,0 +1,12 @@
fastapi>=0.100.0
uvicorn>=0.23.0
sqlalchemy>=2.0.0
alembic>=1.11.0
pydantic>=2.0.0
pydantic[email]>=2.0.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.6
ruff>=0.0.270
email-validator>=2.0.0
python-dotenv>=1.0.0