Compare commits

..

No commits in common. "65d0a32cb50e8a7c45dce770c4d3d5b9ac9a80fc" and "7b32a8a6ec1a7180e725ea55684a72eaf4b8ab72" have entirely different histories.

31 changed files with 2304 additions and 340 deletions

View file

@ -0,0 +1,338 @@
# Epic & Wiki Feature Implementation Summary
## Overview
This document summarizes the implementation of the Epic and Wiki features for the Kanban application.
## What Was Implemented
### Backend (Flask)
#### 1. Database Models
**Epic Model** (`backend/app/models/epic.py`)
- Rich text content support (JSON for Slate.js)
- Hierarchical structure (parent_epic_id for nesting)
- Color coding
- Position ordering
- Board-level scoping
- Soft delete support (closed field)
- Depth limit for hierarchy control
**Wiki Model** (`backend/app/models/wiki.py`)
- Rich text content (JSON for Slate.js)
- URL-friendly slugs
- Summary field
- Categories for organization
- Tags support
- Created by / Updated by tracking
- Board-level scoping
**Card Model Update** (`backend/app/models/card.py`)
- Added `epic_id` foreign key to link cards to epics
**Association Table** (`wiki_entity_links`)
- Many-to-many relationship between Wikis and entity types
- Supports linking Wikis to Cards, Epics, and future entity types
#### 2. Database Migration
- Created migration file: `6fc439155ced_add_epic_and_wiki_models.py`
- Adds all new tables and relationships
### Frontend (React/TypeScript)
#### 1. TypeScript Types (`frontend/src/types/epic.ts`)
```typescript
- Epic interface
- Wiki interface
- CreateEpicRequest / UpdateEpicRequest
- CreateWikiRequest / UpdateWikiRequest
- WikiEntityLink interface
```
#### 2. Components
**RichTextEditor** (`frontend/src/components/RichTextEditor.tsx`)
- Slate.js-based rich text editor
- Editable with proper styling
- Dark mode support
- Placeholder support
- Read-only mode option
**RichTextContent** (`frontend/src/components/RichTextContent.tsx`)
- Read-only renderer for Slate.js content
- Supports paragraphs, lists, blockquotes
- Text formatting (bold, italic, underline, code)
- Dark mode styling
#### 3. Custom Hook
**useEpics** (`frontend/src/hooks/useEpics.ts`)
- Fetch all epics for a board
- Create new epic
- Update existing epic
- Delete epic
- Integrated with global loader and toast notifications
- Error handling with user-friendly messages
#### 4. API Integration
Updated `useApi` hook with epic methods:
```typescript
- getEpics(boardId)
- createEpic(boardId, epicData)
- getEpic(epicId)
- updateEpic(epicId, epicData)
- deleteEpic(epicId)
- addEpicToCard(cardId, epicId)
- removeEpicFromCard(cardId, epicId)
```
## Key Design Decisions
### 1. Epic Hierarchy
- **Decision**: Self-referential foreign key (`parent_epic_id`)
- **Rationale**: Allows flexible nesting of epics with arbitrary depth
- **Feature**: `depth_limit` field to control maximum nesting depth
### 2. Rich Text Storage
- **Decision**: Store as JSON (compatible with Slate.js)
- **Rationale**:
- Slate.js natively uses JSON format
- No serialization/deserialization overhead
- Easy to query and modify content structure
### 3. Wiki vs Document Naming
- **Decision**: Use "Wiki" instead of "Document"
- **Rationale**: Avoids confusion with file attachments
- **Meaning**: Wiki implies knowledge base / documentation repository
### 4. Entity Linking Strategy
- **Decision**: Polymorphic association table (`wiki_entity_links`)
- **Rationale**:
- Single table handles all entity types
- Easy to add new entity types in future
- Avoids circular imports and complex schema changes
### 5. Epic-Card Relationship
- **Decision**: One-way reference (Card → Epic)
- **Rationale**:
- Simpler than many-to-many (epics contain cards)
- Cards can belong to one epic at a time
- Consistent with Jira's parent/child pattern
## Architecture
### Backend Data Flow
```
Board (1) ───────┬─────── (1) Epic
│ │
│ │─── (0..*) Card (via epic_id)
└───────────────┬─────── (0..*) Wiki
└─── wiki_entity_links ───┬── Card
├── Epic
└── (future entities)
```
### Frontend Component Structure
```
BoardEpics (page)
├── EpicList
│ └── EpicCard
└── CreateEpicModal
├── EpicForm
│ ├── EpicNameInput
│ ├── EpicDescriptionInput
│ └── RichTextEditor (content)
└── ColorPicker
```
## Next Steps
### Immediate Actions Required
1. **Run Database Migration**
```bash
cd backend
flask db upgrade
```
2. **Create Backend Routes** (`backend/app/routes/kanban/epics.py`)
- CRUD operations for Epics
- Epic-Card linking endpoints
- Wiki CRUD operations
- Wiki-Entity linking endpoints
3. **Create Backend Schemas** (`backend/app/schemas/epic.py`, `wiki.py`)
- Marshmallow schemas for serialization
- Input validation
4. **Update BoardEpics Page** (`frontend/src/pages/BoardEpics.tsx`)
- Implement epic list view
- Add create epic modal
- Add epic detail view
- Include RichTextEditor for epic content
5. **Update Card Detail Page** (`frontend/src/pages/CardDetail.tsx`)
- Add epic selector dropdown
- Display linked epic information
- Add wiki links
### Future Enhancements
1. **Wiki Features**
- Wiki page with sidebar navigation
- Markdown export/import
- Wiki search functionality
- Version history
2. **Epic Features**
- Epic progress tracking (cards completed vs total)
- Epic metrics dashboard
- Epic drag-and-drop reordering
- Epic timeline view
3. **UI/UX Improvements**
- Epic color picker with presets
- Epic hierarchy tree view
- Inline card epic assignment
- Epic templates
4. **Analytics**
- Epic completion rate
- Time spent on epic
- Epic size distribution
- Wiki usage statistics
## Database Schema
### Epic Table
```sql
CREATE TABLE epics (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
description TEXT,
content JSONB,
color VARCHAR(7),
closed BOOLEAN DEFAULT FALSE,
pos FLOAT,
depth_limit INTEGER,
board_id INTEGER REFERENCES boards(id),
parent_epic_id INTEGER REFERENCES epics(id),
date_last_activity TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### Wiki Table
```sql
CREATE TABLE wikis (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
slug VARCHAR(200) UNIQUE,
content JSONB NOT NULL,
summary TEXT,
category VARCHAR(100),
board_id INTEGER REFERENCES boards(id),
created_by INTEGER REFERENCES users(id),
updated_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### Wiki Entity Links Table
```sql
CREATE TABLE wiki_entity_links (
wiki_id INTEGER REFERENCES wikis(id) ON DELETE CASCADE,
entity_type VARCHAR(50) NOT NULL,
entity_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
linked_by INTEGER REFERENCES users(id),
PRIMARY KEY (wiki_id, entity_type, entity_id)
);
```
## API Endpoints (To Be Implemented)
### Epic Endpoints
```
GET /api/boards/<board_id>/epics
POST /api/boards/<board_id>/epics
GET /api/epics/<epic_id>
PUT /api/epics/<epic_id>
DELETE /api/epics/<epic_id>
```
### Epic-Card Linking
```
POST /api/cards/<card_id>/epics
DELETE /api/cards/<card_id>/epics/<epic_id>
```
### Wiki Endpoints
```
GET /api/boards/<board_id>/wikis
POST /api/boards/<board_id>/wikis
GET /api/wikis/<wiki_id>
PUT /api/wikis/<wiki_id>
DELETE /api/wikis/<wiki_id>
GET /api/wikis/<wiki_id>/content
```
### Wiki-Entity Linking
```
POST /api/wikis/<wiki_id>/links
DELETE /api/wikis/<wiki_id>/links/<entity_type>/<entity_id>
```
## Testing Considerations
### Unit Tests
- Epic model creation and relationships
- Wiki model creation and relationships
- Epic hierarchy validation
- Wiki slug generation
- Rich text content serialization
### Integration Tests
- Epic CRUD operations
- Wiki CRUD operations
- Epic-Card linking
- Wiki-Entity linking
- Epic hierarchy operations
### Frontend Tests
- RichTextEditor component
- RichTextContent component
- useEpics hook
- Epic list rendering
- Epic creation form
## Notes
- **Slate.js Content**: Content is stored as raw Slate.js JSON format, which includes element types, text nodes, and formatting information
- **Hierarchical Epics**: While supported, UI for nesting epics is not yet implemented
- **Wiki Slugs**: Slugs should be auto-generated from wiki names and checked for uniqueness
- **Color Codes**: Epic colors should be valid hex codes (e.g., "#ef4444")
- **Position**: Epic positions use float values for flexible reordering (similar to Trello)
## Related Files
### Backend
- `backend/app/models/epic.py` - Epic model
- `backend/app/models/wiki.py` - Wiki model
- `backend/app/models/card.py` - Updated Card model
- `backend/app/models/__init__.py` - Model imports
- `backend/migrations/versions/6fc439155ced_add_epic_and_wiki_models.py` - Migration
### Frontend
- `frontend/src/types/epic.ts` - TypeScript interfaces
- `frontend/src/components/RichTextEditor.tsx` - Editor component
- `frontend/src/components/RichTextContent.tsx` - Content renderer
- `frontend/src/hooks/useEpics.ts` - Epic custom hook
- `frontend/src/hooks/useApi.ts` - Updated API methods

238
EPIC_WIKI_MODELS_SUMMARY.md Normal file
View file

@ -0,0 +1,238 @@
# Epic and Wiki Models - Implementation Summary
## Overview
This document summarizes the implementation of Epic and Wiki models for the Kanban application.
## What Was Implemented
### 1. Epic Model (`backend/app/models/epic.py`)
- Tracks large features across multiple cards
- Hierarchical structure (parent-child epics)
- Rich text content support (Slate.js JSON)
- Color-coded badges for visual identification
- Metrics tracking (card count)
**Key Fields:**
- `id`, `name`, `description`, `content` (JSONB)
- `color` (hex code for epic badge)
- `closed`, `pos`, `depth_limit` (default 5)
- `board_id`, `parent_epic_id`
- `date_last_activity`, `created_at`, `updated_at`
- `metrics` (JSONB - stores card_count)
**Relationships:**
- Board: One-to-many (Board has many Epics)
- Cards: One-to-many (Epic has many Cards)
- Parent Epic: Self-referential (hierarchical)
- File Attachments: Polymorphic (like Cards)
### 2. Wiki Model (`backend/app/models/wiki.py`)
- Reusable rich text content within a board
- Board-scoped (not global across all boards)
- Polymorphic links to entities (Card, Epic, etc.)
- Categorization and tagging support
**Key Fields:**
- `id`, `name`, `slug` (URL-friendly)
- `content` (JSONB - rich text)
- `summary`, `category`, `tags` (JSONB)
- `board_id`, `created_by`, `updated_by`
- `created_at`, `updated_at`
**Relationships:**
- Board: One-to-many (Board has many Wikis)
- Entities: Many-to-many polymorphic (via wiki_entity_links)
### 3. Card Model Updates (`backend/app/models/card.py`)
- Added `epic_id` foreign key (nullable)
- Updated `to_dict()` to include `epic_id`
- One-to-one relationship: Card belongs to one Epic
### 4. Association Table (`wiki_entity_links`)
- Polymorphic many-to-many table
- Links wikis to any entity type
- Fields: `wiki_id`, `entity_type`, `entity_id`, `created_at`, `linked_by`
## Database Schema
### New Tables
1. **epics** - Epic records
2. **wikis** - Wiki content records
3. **wiki_entity_links** - Wiki-to-entity associations
### Modified Tables
1. **cards** - Added `epic_id` foreign key
### Relationships Diagram
```
Board (1) ----< (N) Epic
Epic (1) ----< (N) Card (each card belongs to one epic)
Epic (1) ----< (N) Epic (parent-child hierarchy)
Board (1) ----< (N) Wiki
Wiki (M) ----> (M) Entity (polymorphic: Card, Epic)
```
## Database Migration
**File:** `backend/migrations/versions/add_epic_and_wiki_models.py`
**Creates:**
- `epics` table with indexes on `board_id`, `closed`, `name`
- `wikis` table with indexes on `board_id`, `name`, `slug`
- `wiki_entity_links` table with composite primary key
- `epic_id` column in `cards` table with index and foreign key
**To apply migration:**
```bash
cd backend
flask db upgrade
```
**To rollback:**
```bash
flask db downgrade
```
## Model Exports
Updated `backend/app/models/__init__.py` to include:
- `from app.models.epic import Epic`
- `from app.models.wiki import Wiki`
Added to `__all__` list: `"Epic"`, `"Wiki"`
## Design Decisions
### Why "Wiki" instead of "Document"?
- Avoids confusion with file attachments
- Emphasizes reusable knowledge content
- Better semantic meaning for rich text resources
### One-to-Many Epic-Card Relationship
- Simpler, clearer ownership
- Each card belongs to one epic
- Easier to query and display
### Board-Scoped Wikis
- Wikis belong to a specific board
- Not global across all boards
- Better organization and access control
### Epic Hierarchy Depth
- Default depth limit: 5 levels
- Configurable per epic
- Backend should enforce when creating child epics
### Simplified Metrics
- Currently only tracks `card_count`
- Stored in JSONB field: `{"card_count": 10}`
- Easy to extend with more metrics later
## Next Steps
### Backend Implementation
1. ✅ Create models - **DONE**
2. ✅ Create database migration - **DONE**
3. ⏭️ Create schemas for serialization
4. ⏭️ Create API routes (CRUD operations)
5. ⏭️ Create services for business logic
6. ⏭️ Add validation for epic depth limit
7. ⏭️ Update epic metrics when cards change
8. ⏭️ Write tests for models and routes
### Frontend Implementation
1. ⏭️ Update TypeScript types
2. ⏭️ Create Epic page/component
3. ⏭️ Create Wiki page/component
4. ⏭️ Add epic dropdown to card detail
5. ⏭️ Create epic list on board detail
6. ⏭️ Implement rich text editor (Slate.js)
7. ⏭️ Add wiki linking UI
### API Endpoints (Future)
#### Epic Endpoints
```
GET /api/boards/{board_id}/epics # List all epics for board
POST /api/boards/{board_id}/epics # Create epic
GET /api/epics/{epic_id} # Get epic details with cards
PUT /api/epics/{epic_id} # Update epic
DELETE /api/epics/{epic_id} # Delete epic
POST /api/epics/{epic_id}/cards # Create card directly in epic
GET /api/epics/{epic_id}/tree # Get epic hierarchy tree
```
#### Wiki Endpoints
```
GET /api/boards/{board_id}/wikis # List all wikis for board
POST /api/boards/{board_id}/wikis # Create wiki
GET /api/wikis/{wiki_id} # Get wiki details
PUT /api/wikis/{wiki_id} # Update wiki
DELETE /api/wikis/{wiki_id} # Delete wiki
POST /api/wikis/{wiki_id}/links # Link wiki to entity
DELETE /api/wikis/{wiki_id}/links/{link_id} # Unlink from entity
GET /api/{entity_type}/{entity_id}/wikis # Get wikis for entity
```
#### Card Endpoints (Updated)
```
PUT /api/cards/{card_id}/epic # Link card to epic (or null to unlink)
GET /api/cards/{card_id}/epic # Get card's epic
```
## Files Created/Modified
### Created
- `backend/app/models/epic.py`
- `backend/app/models/wiki.py`
- `backend/migrations/versions/add_epic_and_wiki_models.py`
### Modified
- `backend/app/models/card.py` (added epic_id)
- `backend/app/models/__init__.py` (added Epic, Wiki imports)
## Testing the Implementation
1. **Apply migration:**
```bash
cd backend
source venv/bin/activate
flask db upgrade
```
2. **Verify tables created:**
```bash
flask dbcurrent
# Should show: add_epic_and_wiki_models
```
3. **Test in Python shell:**
```python
from app import create_app, db
from app.models import Epic, Wiki, Card, Board
app = create_app()
with app.app_context():
# Create an epic
epic = Epic(name="My Epic", board_id=1, color="#3b82f6")
db.session.add(epic)
db.session.commit()
# Create a wiki
wiki = Wiki(name="Security Guide", board_id=1, content={})
db.session.add(wiki)
db.session.commit()
print(f"Epic created: {epic.to_dict()}")
print(f"Wiki created: {wiki.to_dict()}")
```
## Notes
- All models follow the existing project patterns
- Uses `db` from `app` module (not `flask_sqlalchemy` directly)
- Proper foreign key constraints with CASCADE/SET NULL
- Timestamps use UTC timezone
- JSONB fields for flexible data storage
- Indexed for optimal query performance

View file

@ -88,7 +88,7 @@ class TestingConfig(Config):
# Conservative connection pool settings for testing # Conservative connection pool settings for testing
SQLALCHEMY_ENGINE_OPTIONS = { SQLALCHEMY_ENGINE_OPTIONS = {
"pool_size": 1, # Only one connection in the pool "pool_size": 4, # Only one connection in the pool
"max_overflow": 0, # No overflow connections allowed "max_overflow": 0, # No overflow connections allowed
"pool_timeout": 30, "pool_timeout": 30,
"pool_recycle": 3600, # Recycle after 1 hour "pool_recycle": 3600, # Recycle after 1 hour

View file

@ -3,7 +3,8 @@ from functools import wraps
from flask import abort, g from flask import abort, g
from flask_jwt_extended import get_jwt_identity from flask_jwt_extended import get_jwt_identity
from app.models import Board, Card, Comment, FileAttachment from app.models import (Board, Card, CheckItem, Checklist, Comment,
FileAttachment, List)
def get_current_user_id(): def get_current_user_id():
@ -14,6 +15,176 @@ def get_current_user_id():
return g.jwt_identity return g.jwt_identity
def load_board_owned(f):
"""
Loads a Board and ensures it belongs to the current user.
Injects 'board' into the route kwargs.
Aborts with 404 if not found or not owned.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
board_id = kwargs.get("board_id")
# SECURE QUERY: Filter by ID *and* User ID in the DB
board = Board.query.filter_by(id=board_id, user_id=user_id).first()
if not board:
abort(404)
kwargs["board"] = board
return f(*args, **kwargs)
return decorated_function
def load_card_owned(f):
"""
Loads a Card and ensures its Parent Board belongs to the current user.
Injects 'card' into the route kwargs.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
card_id = kwargs.get("card_id")
# Join Board to check ownership securely in one query
card = (
Card.query.join(Board)
.filter(Card.id == card_id, Board.user_id == user_id)
.first()
)
if not card:
abort(404)
kwargs["card"] = card
return f(*args, **kwargs)
return decorated_function
def load_list_owned(f):
"""Loads a List ensuring Parent Board ownership."""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
list_id = kwargs.get("list_id")
lst = (
List.query.join(Board)
.filter(List.id == list_id, Board.user_id == user_id)
.first()
)
if not lst:
abort(404)
kwargs["lst"] = lst
return f(*args, **kwargs)
return decorated_function
def load_checklist_owned(f):
"""Loads a Checklist ensuring Parent Board ownership."""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
checklist_id = kwargs.get("checklist_id")
checklist = (
Checklist.query.join(Card)
.join(Board)
.filter(Checklist.id == checklist_id, Board.user_id == user_id)
.first()
)
if not checklist:
abort(404)
kwargs["checklist"] = checklist
return f(*args, **kwargs)
return decorated_function
def load_check_item_owned(f):
"""Loads a CheckItem ensuring Parent Board ownership."""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
item_id = kwargs.get("item_id")
check_item = (
CheckItem.query.join(Checklist)
.join(Card)
.join(Board)
.filter(CheckItem.id == item_id, Board.user_id == user_id)
.first()
)
if not check_item:
abort(404)
kwargs["check_item"] = check_item
return f(*args, **kwargs)
return decorated_function
def load_comment_owned(f):
"""
Loads a Comment ensuring the Comment itself belongs to the user.
(Based on schema where Comment has user_id)
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
comment_id = kwargs.get("comment_id")
comment = Comment.query.filter_by(id=comment_id, user_id=user_id).first()
if not comment:
abort(404)
kwargs["comment"] = comment
return f(*args, **kwargs)
return decorated_function
def load_file_owned(f):
"""
Loads a FileAttachment ensuring it belongs to the user.
Aborts with 404 if not found or not owned.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
file_id = kwargs.get("file_id")
# Filter by ID and user ID
attachment = FileAttachment.query.filter_by(
id=file_id, uploaded_by=user_id
).first()
if not attachment:
abort(404)
kwargs["file"] = attachment
return f(*args, **kwargs)
return decorated_function
def load_file_accessible(f): def load_file_accessible(f):
""" """
Loads a FileAttachment ensuring the user can access it. Loads a FileAttachment ensuring the user can access it.

View file

@ -1,21 +0,0 @@
from app.decorators.owned import (load_board_owned, load_card_owned,
load_check_item_owned, load_checklist_owned,
load_comment_owned, load_file_owned,
load_list_owned)
from .decorators import get_current_user_id, load_file_accessible_by_uuid
from .schema_validations import serialize_list_response, serialize_response
__all__ = [
"load_board_owned",
"load_card_owned",
"load_list_owned",
"load_checklist_owned",
"load_check_item_owned",
"load_comment_owned",
"load_file_owned",
"get_current_user_id",
"load_file_accessible_by_uuid",
"serialize_response",
"serialize_list_response",
]

View file

@ -1,177 +0,0 @@
from functools import wraps
from flask import abort
from app.decorators.decorators import get_current_user_id
from app.models import (Board, Card, CheckItem, Checklist, Comment,
FileAttachment, List)
def load_board_owned(f):
"""
Loads a Board and ensures it belongs to the current user.
Injects 'board' into the route kwargs.
Aborts with 404 if not found or not owned.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
board_id = kwargs.get("board_id")
# SECURE QUERY: Filter by ID *and* User ID in the DB
board = Board.query.filter_by(id=board_id, user_id=user_id).first()
if not board:
abort(404)
kwargs["board"] = board
return f(*args, **kwargs)
return decorated_function
def load_card_owned(f):
"""
Loads a Card and ensures its Parent Board belongs to the current user.
Injects 'card' into the route kwargs.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
card_id = kwargs.get("card_id")
# Join Board to check ownership securely in one query
card = (
Card.query.join(Board)
.filter(Card.id == card_id, Board.user_id == user_id)
.first()
)
if not card:
abort(404)
kwargs["card"] = card
return f(*args, **kwargs)
return decorated_function
def load_list_owned(f):
"""Loads a List ensuring Parent Board ownership."""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
list_id = kwargs.get("list_id")
lst = (
List.query.join(Board)
.filter(List.id == list_id, Board.user_id == user_id)
.first()
)
if not lst:
abort(404)
kwargs["lst"] = lst
return f(*args, **kwargs)
return decorated_function
def load_checklist_owned(f):
"""Loads a Checklist ensuring Parent Board ownership."""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
checklist_id = kwargs.get("checklist_id")
checklist = (
Checklist.query.join(Card)
.join(Board)
.filter(Checklist.id == checklist_id, Board.user_id == user_id)
.first()
)
if not checklist:
abort(404)
kwargs["checklist"] = checklist
return f(*args, **kwargs)
return decorated_function
def load_check_item_owned(f):
"""Loads a CheckItem ensuring Parent Board ownership."""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
item_id = kwargs.get("item_id")
check_item = (
CheckItem.query.join(Checklist)
.join(Card)
.join(Board)
.filter(CheckItem.id == item_id, Board.user_id == user_id)
.first()
)
if not check_item:
abort(404)
kwargs["check_item"] = check_item
return f(*args, **kwargs)
return decorated_function
def load_comment_owned(f):
"""
Loads a Comment ensuring the Comment itself belongs to the user.
(Based on schema where Comment has user_id)
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
comment_id = kwargs.get("comment_id")
comment = Comment.query.filter_by(id=comment_id, user_id=user_id).first()
if not comment:
abort(404)
kwargs["comment"] = comment
return f(*args, **kwargs)
return decorated_function
def load_file_owned(f):
"""
Loads a FileAttachment ensuring it belongs to the user.
Aborts with 404 if not found or not owned.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
file_id = kwargs.get("file_id")
# Filter by ID and user ID
attachment = FileAttachment.query.filter_by(
id=file_id, uploaded_by=user_id
).first()
if not attachment:
abort(404)
kwargs["file"] = attachment
return f(*args, **kwargs)
return decorated_function

View file

@ -1,38 +0,0 @@
from functools import wraps
from flask import jsonify
def serialize_response(model):
"""Decorator to serialize single entity response"""
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
result, status = f(*args, **kwargs)
if status not in [200, 201]:
return result, status
return jsonify(model.model_validate(result).model_dump()), status
return wrapper
return decorator
def serialize_list_response(model):
"""Decorator to serialize list response"""
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
results, status = f(*args, **kwargs)
if status not in [200, 201]:
return results, status
return (
jsonify([model.model_validate(item).model_dump() for item in results]),
status,
)
return wrapper
return decorator

View file

@ -8,11 +8,16 @@ from app.models.epic import Epic
from app.models.file_attachment import FileAttachment from app.models.file_attachment import FileAttachment
from app.models.label import Label from app.models.label import Label
from app.models.list_model import List from app.models.list_model import List
from app.models.order import Order, OrderItem
from app.models.product import Product
from app.models.user import User from app.models.user import User
from app.models.wiki import Wiki, wiki_entity_links from app.models.wiki import Wiki, wiki_entity_links
__all__ = [ __all__ = [
"User", "User",
"Product",
"Order",
"OrderItem",
"Board", "Board",
"List", "List",
"Card", "Card",

View file

@ -0,0 +1,75 @@
from datetime import UTC, datetime
from app import db
class Order(db.Model):
"""Order model"""
__tablename__ = "orders"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
status = db.Column(db.String(20), default="pending", index=True)
total_amount = db.Column(db.Numeric(10, 2), nullable=False)
shipping_address = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
updated_at = db.Column(
db.DateTime,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
# Relationships
user = db.relationship("User", back_populates="orders")
items = db.relationship(
"OrderItem",
back_populates="order",
lazy="dynamic",
cascade="all, delete-orphan",
)
def to_dict(self):
"""Convert order to dictionary"""
return {
"id": self.id,
"user_id": self.user_id,
"status": self.status,
"total_amount": float(self.total_amount) if self.total_amount else None,
"shipping_address": self.shipping_address,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"items": [item.to_dict() for item in self.items],
}
def __repr__(self):
return f"<Order {self.id}>"
class OrderItem(db.Model):
"""Order Item model"""
__tablename__ = "order_items"
id = db.Column(db.Integer, primary_key=True)
order_id = db.Column(db.Integer, db.ForeignKey("orders.id"), nullable=False)
product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False)
quantity = db.Column(db.Integer, nullable=False)
price = db.Column(db.Numeric(10, 2), nullable=False)
# Relationships
order = db.relationship("Order", back_populates="items")
product = db.relationship("Product", back_populates="order_items")
def to_dict(self):
"""Convert order item to dictionary"""
return {
"id": self.id,
"order_id": self.order_id,
"product_id": self.product_id,
"quantity": self.quantity,
"price": float(self.price) if self.price else None,
}
def __repr__(self):
return f"<OrderItem {self.id}>"

View file

@ -0,0 +1,43 @@
from datetime import UTC, datetime
from app import db
class Product(db.Model):
"""Product model"""
__tablename__ = "products"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False, index=True)
description = db.Column(db.Text)
price = db.Column(db.Numeric(10, 2), nullable=False)
stock = db.Column(db.Integer, default=0)
image_url = db.Column(db.String(500))
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
updated_at = db.Column(
db.DateTime,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
# Relationships
order_items = db.relationship("OrderItem", back_populates="product", lazy="dynamic")
def to_dict(self):
"""Convert product to dictionary"""
return {
"id": self.id,
"name": self.name,
"description": self.description,
"price": float(self.price) if self.price else None,
"stock": self.stock,
"image_url": self.image_url,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f"<Product {self.name}>"

View file

@ -26,6 +26,7 @@ class User(db.Model):
) )
# Relationships # Relationships
orders = db.relationship("Order", back_populates="user", lazy="dynamic")
boards = db.relationship( boards = db.relationship(
"Board", backref="user", cascade="all, delete-orphan", lazy="dynamic" "Board", backref="user", cascade="all, delete-orphan", lazy="dynamic"
) )

View file

@ -76,3 +76,336 @@ def get_current_user():
return jsonify({"error": "User not found"}), 404 return jsonify({"error": "User not found"}), 404
return jsonify(user.to_dict()), 200 return jsonify(user.to_dict()), 200
# # Product Routes
# @api_bp.route("/products", methods=["GET"])
# def get_products():
# """Get all products"""
# # time.sleep(5) # This adds a 5 second delay
# products = Product.query.filter_by(is_active=True).all()
# return jsonify([product.to_dict() for product in products]), 200
# @api_bp.route("/products/<int:product_id>", methods=["GET"])
# def get_product(product_id):
# """Get a single product"""
# product = db.session.get(Product, product_id)
# if not product:
# return jsonify({"error": "Product not found"}), 404
# return jsonify(product.to_dict()), 200
# @api_bp.route("/products", methods=["POST"])
# @jwt_required()
# def create_product():
# """Create a new product (admin only)"""
# user_id = int(get_jwt_identity())
# user = db.session.get(User, user_id)
# if not user or not user.is_admin:
# return jsonify({"error": "Admin access required"}), 403
# try:
# # Validate request data using Pydantic schema
# product_data = ProductCreateRequest(**request.get_json())
# product = Product(
# name=product_data.name,
# description=product_data.description,
# price=product_data.price,
# stock=product_data.stock,
# image_url=product_data.image_url,
# )
# db.session.add(product)
# db.session.commit()
# # Use Pydantic schema for response
# response = ProductResponse.model_validate(product)
# return jsonify(response.model_dump()), 201
# except ValidationError as e:
# print(f"Pydantic Validation Error: {e.errors()}")
# return jsonify({"error": "Validation error", "details": e.errors()}), 400
# @api_bp.route("/products/<int:product_id>", methods=["PUT"])
# @jwt_required()
# def update_product(product_id):
# """Update a product (admin only)"""
# user_id = int(get_jwt_identity())
# user = db.session.get(User, user_id)
# if not user or not user.is_admin:
# return jsonify({"error": "Admin access required"}), 403
# product = db.session.get(Product, product_id)
# if not product:
# return jsonify({"error": "Product not found"}), 404
# data = request.get_json()
# product.name = data.get("name", product.name)
# product.description = data.get("description", product.description)
# product.price = data.get("price", product.price)
# product.stock = data.get("stock", product.stock)
# product.image_url = data.get("image_url", product.image_url)
# product.is_active = data.get("is_active", product.is_active)
# db.session.commit()
# return jsonify(product.to_dict()), 200
# @api_bp.route("/products/<int:product_id>", methods=["DELETE"])
# @jwt_required()
# def delete_product(product_id):
# """Delete a product (admin only)"""
# user_id = int(get_jwt_identity())
# user = db.session.get(User, user_id)
# if not user or not user.is_admin:
# return jsonify({"error": "Admin access required"}), 403
# product = db.session.get(Product, product_id)
# if not product:
# return jsonify({"error": "Product not found"}), 404
# db.session.delete(product)
# db.session.commit()
# return jsonify({"message": "Product deleted"}), 200
# # Order Routes
# @api_bp.route("/orders", methods=["GET"])
# @jwt_required()
# def get_orders():
# """Get all orders for current user"""
# user_id = int(get_jwt_identity())
# orders = Order.query.filter_by(user_id=user_id).all()
# return jsonify([order.to_dict() for order in orders]), 200
# @api_bp.route("/orders", methods=["POST"])
# @jwt_required()
# def create_order():
# """Create a new order"""
# user_id = int(get_jwt_identity())
# data = request.get_json()
# if not data or not data.get("items"):
# return jsonify({"error": "Order items are required"}), 400
# total_amount = 0
# order_items = []
# for item_data in data["items"]:
# product = db.session.get(Product, item_data["product_id"])
# if not product:
# return (
# jsonify({"error": f'Product {item_data["product_id"]} not found'}),
# 404,
# )
# if product.stock < item_data["quantity"]:
# return jsonify({"error": f"Insufficient stock for {product.name}"}), 400
# item_total = product.price * item_data["quantity"]
# total_amount += item_total
# order_items.append(
# {
# "product": product,
# "quantity": item_data["quantity"],
# "price": product.price,
# }
# )
# order = Order(
# user_id=user_id,
# total_amount=total_amount,
# shipping_address=data.get("shipping_address"),
# )
# db.session.add(order)
# db.session.flush()
# for item_data in order_items:
# order_item = OrderItem(
# order_id=order.id,
# product_id=item_data["product"].id,
# quantity=item_data["quantity"],
# price=item_data["price"],
# )
# item_data["product"].stock -= item_data["quantity"]
# db.session.add(order_item)
# db.session.commit()
# return jsonify(order.to_dict()), 201
# @api_bp.route("/orders/<int:order_id>", methods=["GET"])
# @jwt_required()
# def get_order(order_id):
# """Get a single order"""
# user_id = int(get_jwt_identity())
# order = db.session.get(Order, order_id)
# if not order:
# return jsonify({"error": "Order not found"}), 404
# if order.user_id != user_id:
# user = db.session.get(User, user_id)
# if not user or not user.is_admin:
# return jsonify({"error": "Access denied"}), 403
# return jsonify(order.to_dict()), 200
# # Celery Task Routes
# @api_bp.route("/tasks/hello", methods=["POST"])
# @jwt_required()
# def trigger_hello_task():
# """Trigger the hello task"""
# data = request.get_json() or {}
# name = data.get("name", "World")
# task = celery.send_task("tasks.print_hello", args=[name])
# return (
# jsonify(
# {"message": "Hello task triggered", "task_id": task.id, "status":
# "pending"}
# ),
# 202,
# )
# @api_bp.route("/tasks/divide", methods=["POST"])
# @jwt_required()
# def trigger_divide_task():
# """Trigger the divide numbers task"""
# data = request.get_json() or {}
# x = data.get("x", 10)
# y = data.get("y", 2)
# task = celery.send_task("tasks.divide_numbers", args=[x, y])
# return (
# jsonify(
# {
# "message": "Divide task triggered",
# "task_id": task.id,
# "operation": f"{x} / {y}",
# "status": "pending",
# }
# ),
# 202,
# )
# @api_bp.route("/tasks/report", methods=["POST"])
# @jwt_required()
# def trigger_report_task():
# """Trigger the daily report task"""
# task = celery.send_task("tasks.send_daily_report")
# return (
# jsonify(
# {
# "message": "Daily report task triggered",
# "task_id": task.id,
# "status": "pending",
# }
# ),
# 202,
# )
# @api_bp.route("/tasks/stats", methods=["POST"])
# @jwt_required()
# def trigger_stats_task():
# """Trigger product statistics update task"""
# data = request.get_json() or {}
# product_id = data.get("product_id")
# if product_id:
# task = celery.send_task("tasks.update_product_statistics", args=[product_id])
# message = f"Product statistics update triggered for product {product_id}"
# else:
# task = celery.send_task("tasks.update_product_statistics", args=[None])
# message = "Product statistics update triggered for all products"
# return jsonify({"message": message, "task_id": task.id, "status": "pending"}), 202
# @api_bp.route("/tasks/long-running", methods=["POST"])
# @jwt_required()
# def trigger_long_running_task():
# """Trigger a long-running task"""
# data = request.get_json() or {}
# iterations = data.get("iterations", 10)
# task = celery.send_task("tasks.long_running_task", args=[iterations])
# return (
# jsonify(
# {
# "message": f"Long-running task triggered with {iterations}
# iterations",
# "task_id": task.id,
# "status": "pending",
# }
# ),
# 202,
# )
# @api_bp.route("/tasks/<task_id>", methods=["GET"])
# @jwt_required()
# def get_task_status(task_id):
# """Get the status of a Celery task"""
# task_result = celery.AsyncResult(task_id)
# response = {
# "task_id": task_id,
# "status": task_result.status,
# "ready": task_result.ready(),
# }
# if task_result.ready():
# if task_result.successful():
# response["result"] = task_result.result
# else:
# response["error"] = str(task_result.result)
# response["traceback"] = task_result.traceback
# return jsonify(response), 200
# @api_bp.route("/tasks/health", methods=["GET"])
# def celery_health():
# """Check Celery health"""
# try:
# # Try to ping the worker
# inspector = celery.control.inspect()
# stats = inspector.stats()
# if stats:
# return (
# jsonify(
# {"status": "healthy", "workers": len(stats), "workers_info":
# stats}
# ),
# 200,
# )
# else:
# return (
# jsonify({"status": "unhealthy", "message": "No workers available"}),
# 503,
# )
# except Exception as e:
# return jsonify({"status": "error", "message": str(e)}), 500

View file

@ -5,8 +5,7 @@ from flask_jwt_extended import jwt_required
from flask_pydantic import validate from flask_pydantic import validate
from app import db from app import db
from app.decorators import load_board_owned from app.decorators import get_current_user_id, load_board_owned
from app.decorators.decorators import get_current_user_id
from app.models import Board, Card, CardLabel, Label, List from app.models import Board, Card, CardLabel, Label, List
from app.schemas import (BoardCreateRequest, BoardResponse, from app.schemas import (BoardCreateRequest, BoardResponse,
BoardWithDetailsResponse) BoardWithDetailsResponse)

View file

@ -5,17 +5,14 @@ from flask_jwt_extended import jwt_required
from flask_pydantic import validate from flask_pydantic import validate
from app import db from app import db
from app.decorators import serialize_list_response, serialize_response
from app.models import Board, Card, Epic from app.models import Board, Card, Epic
from app.schemas import (CardResponse, EpicCreateRequest, EpicResponse, from app.schemas import EpicCreateRequest, EpicResponse, EpicUpdateRequest
EpicUpdateRequest)
from . import kanban_bp from . import kanban_bp
@kanban_bp.route("/boards/<int:board_id>/epics", methods=["GET"]) @kanban_bp.route("/boards/<int:board_id>/epics", methods=["GET"])
@jwt_required() @jwt_required()
@serialize_list_response(EpicResponse)
def get_board_epics(board_id): def get_board_epics(board_id):
"""Get all epics for a board.""" """Get all epics for a board."""
board = db.session.get(Board, board_id) board = db.session.get(Board, board_id)
@ -23,13 +20,12 @@ def get_board_epics(board_id):
return {"error": "Board not found"}, 404 return {"error": "Board not found"}, 404
epics = Epic.query.filter_by(board_id=board_id).all() epics = Epic.query.filter_by(board_id=board_id).all()
return epics, 200 return [EpicResponse.model_validate(epic).model_dump() for epic in epics], 200
@kanban_bp.route("/boards/<int:board_id>/epics", methods=["POST"]) @kanban_bp.route("/boards/<int:board_id>/epics", methods=["POST"])
@jwt_required() @jwt_required()
@validate(body=EpicCreateRequest) @validate(body=EpicCreateRequest)
@serialize_response(EpicResponse)
def create_epic(board_id, body: EpicCreateRequest): def create_epic(board_id, body: EpicCreateRequest):
"""Create a new epic for a board.""" """Create a new epic for a board."""
board = db.session.get(Board, board_id) board = db.session.get(Board, board_id)
@ -53,7 +49,7 @@ def create_epic(board_id, body: EpicCreateRequest):
db.session.add(epic) db.session.add(epic)
db.session.commit() db.session.commit()
return epic, 201 return EpicResponse.model_validate(epic).model_dump(), 201
@kanban_bp.route("/epics/<int:epic_id>", methods=["GET"]) @kanban_bp.route("/epics/<int:epic_id>", methods=["GET"])
@ -97,7 +93,6 @@ def _update_epic_fields(epic: Epic, body: EpicUpdateRequest) -> None:
@kanban_bp.route("/epics/<int:epic_id>", methods=["PUT"]) @kanban_bp.route("/epics/<int:epic_id>", methods=["PUT"])
@jwt_required() @jwt_required()
@validate(body=EpicUpdateRequest) @validate(body=EpicUpdateRequest)
@serialize_response(EpicResponse)
def update_epic(epic_id, body: EpicUpdateRequest): def update_epic(epic_id, body: EpicUpdateRequest):
"""Update an epic.""" """Update an epic."""
epic = db.session.get(Epic, epic_id) epic = db.session.get(Epic, epic_id)
@ -107,7 +102,7 @@ def update_epic(epic_id, body: EpicUpdateRequest):
_update_epic_fields(epic, body) _update_epic_fields(epic, body)
db.session.commit() db.session.commit()
return epic, 200 return EpicResponse.model_validate(epic).model_dump(), 200
@kanban_bp.route("/epics/<int:epic_id>", methods=["DELETE"]) @kanban_bp.route("/epics/<int:epic_id>", methods=["DELETE"])
@ -130,7 +125,6 @@ def delete_epic(epic_id):
@kanban_bp.route("/cards/<int:card_id>/epics", methods=["POST"]) @kanban_bp.route("/cards/<int:card_id>/epics", methods=["POST"])
@jwt_required() @jwt_required()
@serialize_response(CardResponse)
def add_epic_to_card(card_id): def add_epic_to_card(card_id):
"""Link an epic to a card.""" """Link an epic to a card."""
card = db.session.get(Card, card_id) card = db.session.get(Card, card_id)
@ -150,7 +144,7 @@ def add_epic_to_card(card_id):
card.epic_id = epic_id card.epic_id = epic_id
db.session.commit() db.session.commit()
return card, 200 return card.to_dict(), 200
@kanban_bp.route("/cards/<int:card_id>/epics/<int:epic_id>", methods=["DELETE"]) @kanban_bp.route("/cards/<int:card_id>/epics/<int:epic_id>", methods=["DELETE"])

View file

@ -9,7 +9,6 @@ from sqlalchemy import delete as sql_delete
from sqlalchemy import select from sqlalchemy import select
from app import db from app import db
from app.decorators import serialize_list_response, serialize_response
from app.models import Board, Wiki, wiki_entity_links from app.models import Board, Wiki, wiki_entity_links
from app.models.card import Card from app.models.card import Card
from app.models.epic import Epic from app.models.epic import Epic
@ -30,7 +29,6 @@ def generate_slug(name):
@kanban_bp.route("/boards/<int:board_id>/wikis", methods=["GET"]) @kanban_bp.route("/boards/<int:board_id>/wikis", methods=["GET"])
@jwt_required() @jwt_required()
@serialize_list_response(WikiResponse)
def get_board_wikis(board_id): def get_board_wikis(board_id):
"""Get all wikis for a board.""" """Get all wikis for a board."""
board = db.session.get(Board, board_id) board = db.session.get(Board, board_id)
@ -38,13 +36,12 @@ def get_board_wikis(board_id):
return {"error": "Board not found"}, 404 return {"error": "Board not found"}, 404
wikis = Wiki.query.filter_by(board_id=board_id).all() wikis = Wiki.query.filter_by(board_id=board_id).all()
return wikis, 200 return [WikiResponse.model_validate(wiki).model_dump() for wiki in wikis], 200
@kanban_bp.route("/boards/<int:board_id>/wikis", methods=["POST"]) @kanban_bp.route("/boards/<int:board_id>/wikis", methods=["POST"])
@jwt_required() @jwt_required()
@validate(body=WikiCreateRequest) @validate(body=WikiCreateRequest)
@serialize_response(WikiResponse)
def create_wiki(board_id, body: WikiCreateRequest): def create_wiki(board_id, body: WikiCreateRequest):
"""Create a new wiki for a board.""" """Create a new wiki for a board."""
board = db.session.get(Board, board_id) board = db.session.get(Board, board_id)
@ -78,7 +75,7 @@ def create_wiki(board_id, body: WikiCreateRequest):
db.session.add(wiki) db.session.add(wiki)
db.session.commit() db.session.commit()
return wiki, 201 return WikiResponse.model_validate(wiki).model_dump(), 201
@kanban_bp.route("/wikis/<int:wiki_id>", methods=["GET"]) @kanban_bp.route("/wikis/<int:wiki_id>", methods=["GET"])
@ -111,7 +108,6 @@ def get_wiki(wiki_id):
@kanban_bp.route("/wikis/<int:wiki_id>", methods=["PUT"]) @kanban_bp.route("/wikis/<int:wiki_id>", methods=["PUT"])
@jwt_required() @jwt_required()
@validate(body=WikiUpdateRequest) @validate(body=WikiUpdateRequest)
@serialize_response(WikiResponse)
def update_wiki(wiki_id, body: WikiUpdateRequest): def update_wiki(wiki_id, body: WikiUpdateRequest):
"""Update a wiki.""" """Update a wiki."""
wiki = db.session.get(Wiki, wiki_id) wiki = db.session.get(Wiki, wiki_id)
@ -135,7 +131,7 @@ def update_wiki(wiki_id, body: WikiUpdateRequest):
wiki.updated_by = get_jwt_identity() wiki.updated_by = get_jwt_identity()
db.session.commit() db.session.commit()
return wiki, 200 return WikiResponse.model_validate(wiki).model_dump(), 200
@kanban_bp.route("/wikis/<int:wiki_id>", methods=["DELETE"]) @kanban_bp.route("/wikis/<int:wiki_id>", methods=["DELETE"])

View file

@ -15,11 +15,14 @@ from app.schemas.file_attachment import (FileAttachmentWithUrlResponse,
ValidationError) ValidationError)
from app.schemas.label import LabelCreateRequest, LabelResponse from app.schemas.label import LabelCreateRequest, LabelResponse
from app.schemas.list_model import ListCreateRequest, ListResponse from app.schemas.list_model import ListCreateRequest, ListResponse
from app.schemas.product import ProductCreateRequest, ProductResponse
from app.schemas.wiki import (WikiCreateRequest, WikiEntityLink, from app.schemas.wiki import (WikiCreateRequest, WikiEntityLink,
WikiLinkRequest, WikiResponse, WikiUpdateRequest) WikiLinkRequest, WikiResponse, WikiUpdateRequest)
__all__ = [ __all__ = [
# Product schemas # Product schemas
"ProductCreateRequest",
"ProductResponse",
# Board schemas # Board schemas
"BoardCreateRequest", "BoardCreateRequest",
"BoardResponse", "BoardResponse",

View file

@ -47,7 +47,6 @@ class CardResponse(BaseModel):
"pos": 65535.0, "pos": 65535.0,
"list_id": 1, "list_id": 1,
"board_id": 1, "board_id": 1,
"epic_id": 1,
"due": "2024-12-31T23:59:59", "due": "2024-12-31T23:59:59",
"due_complete": False, "due_complete": False,
"badges": {"votes": 0}, "badges": {"votes": 0},
@ -65,7 +64,6 @@ class CardResponse(BaseModel):
pos: float pos: float
list_id: int list_id: int
board_id: int board_id: int
epic_id: Optional[int]
due: Optional[datetime] = None due: Optional[datetime] = None
due_complete: bool due_complete: bool
badges: Optional[Dict[str, Any]] = None badges: Optional[Dict[str, Any]] = None

View file

@ -0,0 +1,71 @@
"""Pydantic schemas for Product model"""
from datetime import datetime
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
class ProductCreateRequest(BaseModel):
"""Schema for creating a new product"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "Handcrafted Wooden Bowl",
"description": "A beautiful handcrafted bowl made from oak",
"price": 45.99,
"stock": 10,
"image_url": "https://example.com/bowl.jpg",
}
}
)
name: str = Field(..., min_length=1, max_length=200, description="Product name")
description: Optional[str] = Field(None, description="Product description")
price: Decimal = Field(
..., gt=0, description="Product price (must be greater than 0)"
)
stock: int = Field(default=0, ge=0, description="Product stock quantity")
image_url: Optional[str] = Field(
None, max_length=500, description="Product image URL"
)
@field_validator("price")
@classmethod
def validate_price(cls, v: Decimal) -> Decimal:
"""Validate that price has at most 2 decimal places"""
if v.as_tuple().exponent < -2:
raise ValueError("Price must have at most 2 decimal places")
return v
class ProductResponse(BaseModel):
"""Schema for product response"""
model_config = ConfigDict(
from_attributes=True,
json_schema_extra={
"example": {
"id": 1,
"name": "Handcrafted Wooden Bowl",
"description": "A beautiful handcrafted bowl made from oak",
"price": 45.99,
"stock": 10,
"image_url": "https://example.com/bowl.jpg",
"is_active": True,
"created_at": "2024-01-15T10:30:00",
"updated_at": "2024-01-15T10:30:00",
}
},
)
id: int
name: str
description: Optional[str] = None
price: float
stock: int
image_url: Optional[str] = None
is_active: bool
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None

View file

@ -1,74 +0,0 @@
"""remove products tables
Revision ID: a9709e7ed22d
Revises: 84551733ccb0
Create Date: 2026-04-12 12:55:32.064593
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'a9709e7ed22d'
down_revision = '84551733ccb0'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('products', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_products_name'))
op.drop_table('products')
op.drop_table('order_items')
with op.batch_alter_table('orders', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_orders_status'))
op.drop_table('orders')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('orders',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('status', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
sa.Column('total_amount', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=False),
sa.Column('shipping_address', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('orders_user_id_fkey')),
sa.PrimaryKeyConstraint('id', name=op.f('orders_pkey'))
)
with op.batch_alter_table('orders', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_orders_status'), ['status'], unique=False)
op.create_table('order_items',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('order_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('product_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('quantity', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('price', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(['order_id'], ['orders.id'], name=op.f('order_items_order_id_fkey')),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], name=op.f('order_items_product_id_fkey')),
sa.PrimaryKeyConstraint('id', name=op.f('order_items_pkey'))
)
op.create_table('products',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('name', sa.VARCHAR(length=200), autoincrement=False, nullable=False),
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('price', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=False),
sa.Column('stock', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('image_url', sa.VARCHAR(length=500), autoincrement=False, nullable=True),
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('products_pkey'))
)
with op.batch_alter_table('products', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_products_name'), ['name'], unique=False)
# ### end Alembic commands ###

View file

@ -1,6 +1,5 @@
"""Pytest configuration and fixtures""" """Pytest configuration and fixtures"""
import logging import logging
import time
from io import BytesIO from io import BytesIO
import pytest import pytest
@ -8,7 +7,8 @@ from faker import Faker
from PIL import Image from PIL import Image
from app import create_app, db from app import create_app, db
from app.models import Board, Card, Comment, List, User from app.models import (Board, Card, Comment, List, Order, OrderItem, Product,
User)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
fake = Faker() fake = Faker()
@ -116,7 +116,7 @@ def regular_user(db_session):
"""Create a regular user for testing""" """Create a regular user for testing"""
user = User( user = User(
email=fake.email(), email=fake.email(),
username=f"{fake.user_name()}_{int(time.time() * 1000)}", username=fake.user_name(),
first_name=fake.first_name(), first_name=fake.first_name(),
last_name=fake.last_name(), last_name=fake.last_name(),
is_admin=False, is_admin=False,
@ -145,6 +145,39 @@ def inactive_user(db_session):
return user return user
@pytest.fixture
def product(db_session):
"""Create a product for testing"""
product = Product(
name=fake.sentence(nb_words=4)[:-1], # Remove period
description=fake.paragraph(),
price=fake.pydecimal(left_digits=2, right_digits=2, positive=True),
stock=fake.pyint(min_value=0, max_value=100),
image_url=fake.url(),
)
db_session.add(product)
db_session.commit()
return product
@pytest.fixture
def products(db_session):
"""Create multiple products for testing"""
products = []
for _ in range(5):
product = Product(
name=fake.sentence(nb_words=4)[:-1],
description=fake.paragraph(),
price=fake.pydecimal(left_digits=2, right_digits=2, positive=True),
stock=fake.pyint(min_value=20, max_value=100),
image_url=fake.url(),
)
db_session.add(product)
products.append(product)
db_session.commit()
return products
@pytest.fixture @pytest.fixture
def auth_headers(client, regular_user): def auth_headers(client, regular_user):
"""Get authentication headers for a regular user""" """Get authentication headers for a regular user"""
@ -170,6 +203,33 @@ def admin_headers(client, admin_user):
return {"Authorization": f"Bearer {token}"} return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def order(db_session, regular_user, products):
print("-----order-created------")
"""Create an order for testing"""
order = Order(
user_id=regular_user.id, total_amount=0.0, shipping_address=fake.address()
)
db_session.add(order)
db_session.flush()
total_amount = 0
for i, product in enumerate(products[:2]):
quantity = fake.pyint(min_value=1, max_value=5)
order_item = OrderItem(
order_id=order.id,
product_id=product.id,
quantity=quantity,
price=product.price,
)
total_amount += float(product.price) * quantity
db_session.add(order_item)
order.total_amount = total_amount
db_session.commit()
return order
# ============ File Upload Fixtures ============ # ============ File Upload Fixtures ============

View file

@ -1,8 +1,9 @@
"""Test models""" """Test models"""
from decimal import Decimal
import pytest import pytest
from app.models import User from app.models import Order, OrderItem, Product, User
class TestUserModel: class TestUserModel:
@ -68,3 +69,132 @@ class TestUserModel:
db_session.commit() db_session.commit()
assert repr(user) == "<User testuser3>" assert repr(user) == "<User testuser3>"
class TestProductModel:
"""Test Product model"""
@pytest.mark.unit
def test_product_creation(self, db_session):
"""Test creating a product"""
product = Product(
name="Test Product",
description="A test product",
price=Decimal("99.99"),
stock=10,
image_url="https://example.com/product.jpg",
)
db_session.add(product)
db_session.commit()
assert product.id is not None
assert product.name == "Test Product"
assert product.price == Decimal("99.99")
assert product.stock == 10
assert product.is_active is True
@pytest.mark.unit
def test_product_to_dict(self, db_session):
"""Test product serialization to dictionary"""
product = Product(
name="Test Product",
description="A test product",
price=Decimal("99.99"),
stock=10,
)
db_session.add(product)
db_session.commit()
product_dict = product.to_dict()
assert product_dict["name"] == "Test Product"
assert product_dict["price"] == 99.99
assert isinstance(product_dict["created_at"], str)
assert isinstance(product_dict["updated_at"], str)
@pytest.mark.unit
def test_product_defaults(self, db_session):
"""Test product default values"""
product = Product(name="Test Product", price=Decimal("9.99"))
db_session.add(product)
db_session.commit()
assert product.stock == 0
assert product.is_active is True
assert product.description is None
assert product.image_url is None
@pytest.mark.unit
def test_product_repr(self, db_session):
"""Test product string representation"""
product = Product(name="Test Product", price=Decimal("9.99"))
db_session.add(product)
db_session.commit()
assert repr(product) == "<Product Test Product>"
class TestOrderModel:
"""Test Order model"""
@pytest.mark.unit
def test_order_creation(self, db_session, regular_user):
"""Test creating an order"""
order = Order(
user_id=regular_user.id,
total_amount=Decimal("199.99"),
shipping_address="123 Test St",
)
db_session.add(order)
db_session.commit()
assert order.id is not None
assert order.user_id == regular_user.id
assert order.total_amount == Decimal("199.99")
@pytest.mark.unit
def test_order_to_dict(self, db_session, regular_user):
"""Test order serialization to dictionary"""
order = Order(
user_id=regular_user.id,
total_amount=Decimal("199.99"),
shipping_address="123 Test St",
)
db_session.add(order)
db_session.commit()
order_dict = order.to_dict()
assert order_dict["user_id"] == regular_user.id
assert order_dict["total_amount"] == 199.99
assert isinstance(order_dict["created_at"], str)
class TestOrderItemModel:
"""Test OrderItem model"""
@pytest.mark.unit
def test_order_item_creation(self, db_session, order, product):
"""Test creating an order item"""
order_item = OrderItem(
order_id=order.id, product_id=product.id, quantity=2, price=product.price
)
db_session.add(order_item)
db_session.commit()
assert order_item.id is not None
assert order_item.order_id == order.id
assert order_item.product_id == product.id
assert order_item.quantity == 2
@pytest.mark.unit
def test_order_item_to_dict(self, db_session, order, product):
"""Test order item serialization to dictionary"""
order_item = OrderItem(
order_id=order.id, product_id=product.id, quantity=2, price=product.price
)
db_session.add(order_item)
db_session.commit()
item_dict = order_item.to_dict()
assert item_dict["order_id"] == order.id
assert item_dict["product_id"] == product.id
assert item_dict["quantity"] == 2

View file

@ -112,3 +112,235 @@ class TestAuthRoutes:
"""Test getting current user without authentication""" """Test getting current user without authentication"""
response = client.get("/api/users/me") response = client.get("/api/users/me")
assert response.status_code == 401 assert response.status_code == 401
# class TestProductRoutes:
# """Test product routes"""
# @pytest.mark.product
# def test_get_products(self, app, client, products):
# """Test getting all products"""
# from app.models import Product
# before_count = Product.query.count()
# response = client.get("/api/products")
# assert response.status_code == 200
# data = response.get_json()
# assert len(data) == before_count
# @pytest.mark.product
# def test_get_products_empty(self, client):
# """Test getting products when none exist"""
# from app.models import Product
# before_count = Product.query.count()
# response = client.get("/api/products")
# assert response.status_code == 200
# data = response.get_json()
# assert len(data) == before_count
# @pytest.mark.product
# def test_get_single_product(self, client, product):
# """Test getting a single product"""
# response = client.get(f"/api/products/{product.id}")
# assert response.status_code == 200
# data = response.get_json()
# assert data["id"] == product.id
# assert data["name"] == product.name
# @pytest.mark.product
# def test_get_product_not_found(self, client):
# """Test getting non-existent product"""
# response = client.get("/api/products/999")
# assert response.status_code == 404
# @pytest.mark.product
# def test_create_product_admin(self, client, admin_headers):
# """Test creating product as admin"""
# response = client.post(
# "/api/products",
# headers=admin_headers,
# json={
# "name": "New Product",
# "description": "A new product",
# "price": 29.99,
# "stock": 10,
# },
# )
# assert response.status_code == 201
# data = response.get_json()
# assert data["name"] == "New Product"
# assert data["price"] == 29.99
# @pytest.mark.product
# def test_create_product_regular_user(self, client, auth_headers):
# """Test creating product as regular user (should fail)"""
# response = client.post(
# "/api/products",
# headers=auth_headers,
# json={"name": "New Product", "price": 29.99},
# )
# assert response.status_code == 403
# data = response.get_json()
# assert "admin" in data["error"].lower()
# @pytest.mark.product
# def test_create_product_unauthorized(self, client):
# """Test creating product without authentication"""
# response = client.post(
# "/api/products", json={"name": "New Product", "price": 29.99}
# )
# assert response.status_code == 401
# @pytest.mark.product
# def test_create_product_validation_error(self, client, admin_headers):
# """Test creating product with invalid data"""
# response = client.post(
# "/api/products",
# headers=admin_headers,
# json={"name": "New Product", "price": -10.99},
# )
# assert response.status_code == 400
# data = response.get_json()
# assert "Validation error" in data["error"]
# @pytest.mark.product
# def test_create_product_missing_required_fields(self, client, admin_headers):
# """Test creating product with missing required fields"""
# response = client.post(
# "/api/products",
# headers=admin_headers,
# json={"description": "Missing name and price"},
# )
# assert response.status_code == 400
# data = response.get_json()
# assert "Validation error" in data["error"]
# @pytest.mark.product
# def test_create_product_minimal_data(self, client, admin_headers):
# """Test creating product with minimal valid data"""
# response = client.post(
# "/api/products",
# headers=admin_headers,
# json={"name": "Minimal Product", "price": 19.99},
# )
# assert response.status_code == 201
# data = response.get_json()
# assert data["name"] == "Minimal Product"
# assert data["stock"] == 0 # Default value
# @pytest.mark.product
# def test_update_product_admin(self, client, admin_headers, product):
# """Test updating product as admin"""
# response = client.put(
# f"/api/products/{product.id}",
# headers=admin_headers,
# json={"name": "Updated Product", "price": 39.99},
# )
# assert response.status_code == 200
# data = response.get_json()
# assert data["name"] == "Updated Product"
# assert data["price"] == 39.99
# @pytest.mark.product
# def test_delete_product_admin(self, client, admin_headers, product):
# """Test deleting product as admin"""
# response = client.delete(f"/api/products/{product.id}", headers=admin_headers)
# assert response.status_code == 200
# # Verify product is deleted
# response = client.get(f"/api/products/{product.id}")
# assert response.status_code == 404
# class TestOrderRoutes:
# """Test order routes"""
# @pytest.mark.order
# def test_get_orders(self, client, auth_headers, order):
# """Test getting orders for current user"""
# response = client.get("/api/orders", headers=auth_headers)
# assert response.status_code == 200
# data = response.get_json()
# assert len(data) >= 1
# @pytest.mark.order
# def test_get_orders_unauthorized(self, client):
# """Test getting orders without authentication"""
# response = client.get("/api/orders")
# assert response.status_code == 401
# @pytest.mark.order
# def test_create_order(self, client, auth_headers, products):
# """Test creating an order"""
# response = client.post(
# "/api/orders",
# headers=auth_headers,
# json={
# "items": [
# {"product_id": products[0].id, "quantity": 2},
# {"product_id": products[1].id, "quantity": 1},
# ],
# "shipping_address": "123 Test St",
# },
# )
# assert response.status_code == 201
# data = response.get_json()
# assert "id" in data
# assert len(data["items"]) == 2
# @pytest.mark.order
# def test_create_order_insufficient_stock(
# self, client, auth_headers, db_session, products
# ):
# """Test creating order with insufficient stock"""
# # Set stock to 0
# products[0].stock = 0
# db_session.commit()
# response = client.post(
# "/api/orders",
# headers=auth_headers,
# json={"items": [{"product_id": products[0].id, "quantity": 2}]},
# )
# assert response.status_code == 400
# data = response.get_json()
# assert "insufficient" in data["error"].lower()
# @pytest.mark.order
# def test_get_single_order(self, client, auth_headers, order):
# """Test getting a single order"""
# response = client.get(f"/api/orders/{order.id}", headers=auth_headers)
# print("test_get_single_order", response.get_json())
# assert response.status_code == 200
# data = response.get_json()
# assert data["id"] == order.id
# @pytest.mark.order
# def test_get_other_users_order(self, client, admin_headers, regular_user,
# products):
# """Test admin accessing another user's order"""
# # Create an order for regular_user
# client.post(
# "/api/auth/login",
# json={"email": regular_user.email, "password": "password123"},
# )
# # Admin should be able to access any order
# # This test assumes order exists, adjust as needed
# pass

View file

@ -0,0 +1,249 @@
"""Test Pydantic schemas"""
from decimal import Decimal
import pytest
from pydantic import ValidationError
from app.schemas import ProductCreateRequest, ProductResponse
class TestProductCreateRequestSchema:
"""Test ProductCreateRequest schema"""
@pytest.mark.unit
def test_valid_product_request(self):
"""Test valid product creation request"""
data = {
"name": "Handcrafted Wooden Bowl",
"description": "A beautiful handcrafted bowl",
"price": 45.99,
"stock": 10,
"image_url": "https://example.com/bowl.jpg",
}
product = ProductCreateRequest(**data)
assert product.name == data["name"]
assert product.description == data["description"]
assert product.price == Decimal("45.99")
assert product.stock == 10
assert product.image_url == data["image_url"]
@pytest.mark.unit
def test_minimal_valid_request(self):
"""Test minimal valid request (only required fields)"""
data = {"name": "Simple Product", "price": 19.99}
product = ProductCreateRequest(**data)
assert product.name == "Simple Product"
assert product.price == Decimal("19.99")
assert product.stock == 0
assert product.description is None
assert product.image_url is None
@pytest.mark.unit
def test_missing_name(self):
"""Test request with missing name"""
data = {"price": 19.99}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["loc"] == ("name",) for error in errors)
@pytest.mark.unit
def test_missing_price(self):
"""Test request with missing price"""
data = {"name": "Test Product"}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["loc"] == ("price",) for error in errors)
@pytest.mark.unit
def test_invalid_price_negative(self):
"""Test request with negative price"""
data = {"name": "Test Product", "price": -10.99}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["type"] == "greater_than" for error in errors)
@pytest.mark.unit
def test_invalid_price_zero(self):
"""Test request with zero price"""
data = {"name": "Test Product", "price": 0.0}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["type"] == "greater_than" for error in errors)
@pytest.mark.unit
def test_invalid_price_too_many_decimals(self):
"""Test request with too many decimal places"""
data = {"name": "Test Product", "price": 10.999}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any("decimal places" in str(error).lower() for error in errors)
@pytest.mark.unit
def test_invalid_stock_negative(self):
"""Test request with negative stock"""
data = {"name": "Test Product", "price": 19.99, "stock": -5}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["type"] == "greater_than_equal" for error in errors)
@pytest.mark.unit
def test_name_too_long(self):
"""Test request with name exceeding max length"""
data = {"name": "A" * 201, "price": 19.99} # Exceeds 200 character limit
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["loc"] == ("name",) for error in errors)
@pytest.mark.unit
def test_image_url_too_long(self):
"""Test request with image_url exceeding max length"""
data = {
"name": "Test Product",
"price": 19.99,
"image_url": "A" * 501, # Exceeds 500 character limit
}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["loc"] == ("image_url",) for error in errors)
@pytest.mark.unit
def test_price_string_conversion(self):
"""Test price string to Decimal conversion"""
data = {"name": "Test Product", "price": "29.99"}
product = ProductCreateRequest(**data)
assert product.price == Decimal("29.99")
@pytest.mark.unit
def test_stock_string_conversion(self):
"""Test stock string to int conversion"""
data = {"name": "Test Product", "price": 19.99, "stock": "10"}
product = ProductCreateRequest(**data)
assert product.stock == 10
assert isinstance(product.stock, int)
class TestProductResponseSchema:
"""Test ProductResponse schema"""
@pytest.mark.unit
def test_valid_product_response(self):
"""Test valid product response"""
data = {
"id": 1,
"name": "Test Product",
"description": "A test product",
"price": 45.99,
"stock": 10,
"image_url": "https://example.com/product.jpg",
"is_active": True,
"created_at": "2024-01-15T10:30:00",
"updated_at": "2024-01-15T10:30:00",
}
product = ProductResponse(**data)
assert product.id == 1
assert product.name == "Test Product"
assert product.price == 45.99
assert product.stock == 10
assert product.is_active is True
@pytest.mark.unit
def test_product_response_with_none_fields(self):
"""Test product response with optional None fields"""
data = {
"id": 1,
"name": "Test Product",
"price": 19.99,
"stock": 0,
"is_active": True,
}
product = ProductResponse(**data)
assert product.description is None
assert product.image_url is None
assert product.created_at is None
assert product.updated_at is None
@pytest.mark.unit
def test_model_validate_from_sqlalchemy(self, db_session):
"""Test validating SQLAlchemy model to Pydantic schema"""
from app.models import Product
db_product = Product(
name="Test Product",
description="A test product",
price=Decimal("45.99"),
stock=10,
)
db_session.add(db_product)
db_session.commit()
# Validate using model_validate (for SQLAlchemy models)
response = ProductResponse.model_validate(db_product)
assert response.name == "Test Product"
assert response.price == 45.99
assert response.stock == 10
@pytest.mark.unit
def test_model_dump(self):
"""Test model_dump method"""
data = {
"id": 1,
"name": "Test Product",
"price": 19.99,
"stock": 5,
"is_active": True,
}
product = ProductResponse(**data)
dumped = product.model_dump()
assert isinstance(dumped, dict)
assert dumped["id"] == 1
assert dumped["name"] == "Test Product"
assert dumped["price"] == 19.99
@pytest.mark.unit
def test_model_dump_json(self):
"""Test model_dump_json method"""
data = {
"id": 1,
"name": "Test Product",
"price": 19.99,
"stock": 5,
"is_active": True,
}
product = ProductResponse(**data)
json_str = product.model_dump_json()
assert isinstance(json_str, str)
assert "Test Product" in json_str

View file

@ -133,7 +133,7 @@ export function KanbanColumn({
> >
<div <div
ref={setNodeRef} ref={setNodeRef}
className={`min-h-[200px] flex-1 transition-colors ${isOver ? 'bg-gray-750' : ''} maxc-h-[calc(100vh-280px)] overflow-auto scrollbar-custom pl-4 pr-2`} className={`min-h-[200px] flex-1 transition-colors ${isOver ? 'bg-gray-750' : ''} maxc-h-[calc(100vh-280px)] overflow-auto scrollbar-custom px-4`}
> >
{cards.map((card) => ( {cards.map((card) => (
<KanbanCard key={card.id} card={card} onOpenModal={() => onOpenCardModal(card)} /> <KanbanCard key={card.id} card={card} onOpenModal={() => onOpenCardModal(card)} />

View file

@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { RegisterData, UserData, AuthResponse } from '../types'; import { RegisterData, UserData, ProductData, OrderData, AuthResponse } from '../types';
import { import {
Board, Board,
BoardWithDetails, BoardWithDetails,
@ -69,6 +69,44 @@ export function useApi() {
return response.data; return response.data;
}, },
// Products
getProducts: async (): Promise<ProductData[]> => {
const response = await api.get<ProductData[]>('/products');
return response.data;
},
getProduct: async (id: string): Promise<ProductData> => {
const response = await api.get<ProductData>(`/products/${id}`);
return response.data;
},
createProduct: async (productData: Omit<ProductData, 'id'>): Promise<ProductData> => {
const response = await api.post<ProductData>('/products', productData);
return response.data;
},
updateProduct: async (
id: string,
productData: Partial<ProductData>
): Promise<ProductData> => {
const response = await api.put<ProductData>(`/products/${id}`, productData);
return response.data;
},
deleteProduct: async (id: string): Promise<void> => {
await api.delete(`/products/${id}`);
},
// Orders
getOrders: async (): Promise<OrderData[]> => {
const response = await api.get<OrderData[]>('/orders');
return response.data;
},
getOrder: async (id: string): Promise<OrderData> => {
const response = await api.get<OrderData>(`/orders/${id}`);
return response.data;
},
createOrder: async (orderData: Omit<OrderData, 'id'>): Promise<OrderData> => {
const response = await api.post<OrderData>('/orders', orderData);
return response.data;
},
// Boards // Boards
getBoards: async (): Promise<Board[]> => { getBoards: async (): Promise<Board[]> => {
const response = await api.get<Board[]>('/boards'); const response = await api.get<Board[]>('/boards');

View file

@ -0,0 +1,61 @@
import { useState, useEffect } from 'react';
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import { ProductData } from '../types';
export function useProducts() {
const [products, setProducts] = useState<ProductData[]>([]);
const [error, setError] = useState<string | null>(null);
const { getProducts } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const fetchProducts = async () => {
try {
setError(null);
// Use withLoader to show loading state and handle errors
const data = await withLoader(() => getProducts(), 'Loading products...');
setProducts(data);
// // Show success toast
// addNotification({
// type: 'success',
// title: 'Products Loaded',
// message: `Successfully loaded ${data.length} products.`,
// duration: 3000,
// });
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load products';
setError(errorMessage);
// Show error toast
addNotification({
type: 'error',
title: 'Error Loading Products',
message: errorMessage,
duration: 5000,
});
return [];
}
};
// Optionally auto-fetch on mount
useEffect(() => {
fetchProducts();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
products,
error,
loading: false, // Loading is handled by the global loader
refetch: fetchProducts,
};
}

View file

@ -0,0 +1,122 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApp } from '../context/AppContext';
import { useApi } from '../hooks/useApi';
import { OrderData } from '../types';
export function Orders() {
const [orders, setOrders] = useState<OrderData[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const navigate = useNavigate();
const { user } = useApp();
const { getOrders } = useApi();
useEffect(() => {
if (!user) {
navigate('/login');
return;
}
fetchOrders();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, navigate]);
const fetchOrders = async () => {
try {
const data = await getOrders();
setOrders(data);
} catch (error) {
console.error('Error fetching orders:', error);
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string): string => {
const colors: Record<string, string> = {
pending: 'bg-yellow-900 text-yellow-200 border-yellow-700',
processing: 'bg-blue-900 text-blue-200 border-blue-700',
shipped: 'bg-purple-900 text-purple-200 border-purple-700',
delivered: 'bg-green-900 text-green-200 border-green-700',
cancelled: 'bg-red-900 text-red-200 border-red-700',
};
return colors[status] || 'bg-gray-900 text-gray-200 border-gray-700';
};
if (loading) {
return (
<div className="text-center py-12">
<div className="text-gray-400">Loading orders...</div>
</div>
);
}
return (
<div>
<h1 className="text-3xl font-bold text-white mb-8">My Orders</h1>
{orders.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-400 mb-8">You have no orders yet</p>
<button
onClick={() => navigate('/products')}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition-colors"
>
Browse Products
</button>
</div>
) : (
<div className="space-y-6">
{orders.map((order) => (
<div
key={order.id}
className="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden"
>
<div className="p-4 border-b border-gray-700 flex justify-between items-center">
<div>
<h3 className="text-lg font-semibold text-white">Order #{order.id}</h3>
<p className="text-sm text-gray-400">
{new Date(order.created_at).toLocaleDateString()}
</p>
</div>
<span
className={`px-3 py-1 rounded-full text-sm font-medium border ${getStatusColor(
order.status
)}`}
>
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
</span>
</div>
<div className="p-4">
{order.items.map((item) => (
<div
key={item.id}
className="flex justify-between items-center py-2 border-b border-gray-700 last:border-b-0"
>
<div>
<p className="text-white font-medium">Product #{item.product_id}</p>
<p className="text-sm text-gray-400">Quantity: {item.quantity}</p>
</div>
<p className="text-white font-bold">
${(item.price * item.quantity).toFixed(2)}
</p>
</div>
))}
</div>
<div className="p-4 bg-gray-750 border-t border-gray-700 flex justify-between items-center">
<div className="text-sm text-gray-400">
{order.shipping_address && <span>Ship to: {order.shipping_address}</span>}
</div>
<div className="text-xl">
<span className="text-gray-400">Total:</span>{' '}
<span className="text-white font-bold">${order.total_amount}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,66 @@
import { useApp } from '../context/AppContext';
import { useProducts } from '../hooks/useProducts';
import { CartItem } from '../types';
export function Products() {
const { products, refetch } = useProducts();
const { addToCart } = useApp();
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-white">Products</h1>
<button
onClick={() => refetch()}
className="bg-gray-700 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors"
>
Refresh
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{products.map((product) => (
<div
key={product.id}
className="bg-gray-800 rounded-lg overflow-hidden border border-gray-700 hover:border-blue-500 transition-colors"
>
{product.image_url && (
<img
src={product.image_url}
alt={product.name}
className="w-full h-48 object-cover"
/>
)}
<div className="p-4">
<h3 className="text-lg font-semibold text-white mb-2">{product.name}</h3>
<p className="text-gray-400 text-sm mb-3 line-clamp-2">{product.description}</p>
<div className="flex items-center justify-between">
<span className="text-xl font-bold text-blue-400">${product.price}</span>
<span className="text-sm text-gray-400">Stock: {product.stock}</span>
</div>
<button
onClick={() => {
const cartItem: CartItem = {
id: parseInt(product.id!),
name: product.name,
price: product.price,
quantity: 1,
image_url: product.image_url,
};
addToCart(cartItem);
}}
className="mt-4 w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg transition-colors"
>
Add to Cart
</button>
</div>
</div>
))}
</div>
{products.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-400">No products available</p>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,24 @@
export interface OrderItem {
id: string;
product_id: string;
quantity: number;
price: number;
}
export interface OrderData {
id: string;
status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
total_amount: number;
created_at: string;
shipping_address?: string;
items: OrderItem[];
}
export interface Order {
id: number;
created_at: string;
status: string;
total_amount: number;
shipping_address: string;
items: OrderItem[];
}

View file

@ -0,0 +1,26 @@
export interface Product {
id: string;
name: string;
description: string;
price: number;
stock: number;
image_url?: string;
}
export interface ProductData {
id?: string;
name: string;
description: string;
price: number;
stock: number;
image_url?: string;
}
export interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
image_url?: string;
[key: string]: any;
}

View file

@ -21,6 +21,7 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
/* Path Aliases */ /* Path Aliases */
"baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }