diff --git a/EPIC_WIKI_IMPLEMENTATION_SUMMARY.md b/EPIC_WIKI_IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index f31d12e..0000000 --- a/EPIC_WIKI_IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,338 +0,0 @@ -# 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//epics -POST /api/boards//epics -GET /api/epics/ -PUT /api/epics/ -DELETE /api/epics/ -``` - -### Epic-Card Linking -``` -POST /api/cards//epics -DELETE /api/cards//epics/ -``` - -### Wiki Endpoints -``` -GET /api/boards//wikis -POST /api/boards//wikis -GET /api/wikis/ -PUT /api/wikis/ -DELETE /api/wikis/ -GET /api/wikis//content -``` - -### Wiki-Entity Linking -``` -POST /api/wikis//links -DELETE /api/wikis//links// -``` - -## 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 \ No newline at end of file diff --git a/EPIC_WIKI_MODELS_SUMMARY.md b/EPIC_WIKI_MODELS_SUMMARY.md deleted file mode 100644 index a0c261b..0000000 --- a/EPIC_WIKI_MODELS_SUMMARY.md +++ /dev/null @@ -1,238 +0,0 @@ -# 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 \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py index 1ba580c..2ccef83 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -88,7 +88,7 @@ class TestingConfig(Config): # Conservative connection pool settings for testing SQLALCHEMY_ENGINE_OPTIONS = { - "pool_size": 4, # Only one connection in the pool + "pool_size": 1, # Only one connection in the pool "max_overflow": 0, # No overflow connections allowed "pool_timeout": 30, "pool_recycle": 3600, # Recycle after 1 hour diff --git a/backend/app/decorators/__init__.py b/backend/app/decorators/__init__.py new file mode 100644 index 0000000..dee151f --- /dev/null +++ b/backend/app/decorators/__init__.py @@ -0,0 +1,21 @@ +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", +] diff --git a/backend/app/decorators.py b/backend/app/decorators/decorators.py similarity index 52% rename from backend/app/decorators.py rename to backend/app/decorators/decorators.py index 1cdc9ee..75a4e6a 100644 --- a/backend/app/decorators.py +++ b/backend/app/decorators/decorators.py @@ -3,8 +3,7 @@ from functools import wraps from flask import abort, g from flask_jwt_extended import get_jwt_identity -from app.models import (Board, Card, CheckItem, Checklist, Comment, - FileAttachment, List) +from app.models import Board, Card, Comment, FileAttachment def get_current_user_id(): @@ -15,176 +14,6 @@ def get_current_user_id(): 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): """ Loads a FileAttachment ensuring the user can access it. diff --git a/backend/app/decorators/owned.py b/backend/app/decorators/owned.py new file mode 100644 index 0000000..90f17d8 --- /dev/null +++ b/backend/app/decorators/owned.py @@ -0,0 +1,177 @@ +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 diff --git a/backend/app/decorators/schema_validations.py b/backend/app/decorators/schema_validations.py new file mode 100644 index 0000000..1612fb9 --- /dev/null +++ b/backend/app/decorators/schema_validations.py @@ -0,0 +1,38 @@ +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 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 385ce3b..d1fce73 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -8,16 +8,11 @@ from app.models.epic import Epic from app.models.file_attachment import FileAttachment from app.models.label import Label 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.wiki import Wiki, wiki_entity_links __all__ = [ "User", - "Product", - "Order", - "OrderItem", "Board", "List", "Card", diff --git a/backend/app/models/order.py b/backend/app/models/order.py deleted file mode 100644 index dd690e4..0000000 --- a/backend/app/models/order.py +++ /dev/null @@ -1,75 +0,0 @@ -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"" - - -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"" diff --git a/backend/app/models/product.py b/backend/app/models/product.py deleted file mode 100644 index 94c6303..0000000 --- a/backend/app/models/product.py +++ /dev/null @@ -1,43 +0,0 @@ -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"" diff --git a/backend/app/models/user.py b/backend/app/models/user.py index bcb7e06..cb32c79 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -26,7 +26,6 @@ class User(db.Model): ) # Relationships - orders = db.relationship("Order", back_populates="user", lazy="dynamic") boards = db.relationship( "Board", backref="user", cascade="all, delete-orphan", lazy="dynamic" ) diff --git a/backend/app/routes/api.py b/backend/app/routes/api.py index 6b289c1..1ea428a 100644 --- a/backend/app/routes/api.py +++ b/backend/app/routes/api.py @@ -76,336 +76,3 @@ def get_current_user(): return jsonify({"error": "User not found"}), 404 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/", 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/", 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/", 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/", 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/", 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 diff --git a/backend/app/routes/kanban/boards.py b/backend/app/routes/kanban/boards.py index 7953571..de9e5fb 100644 --- a/backend/app/routes/kanban/boards.py +++ b/backend/app/routes/kanban/boards.py @@ -5,7 +5,8 @@ from flask_jwt_extended import jwt_required from flask_pydantic import validate from app import db -from app.decorators import get_current_user_id, load_board_owned +from app.decorators import load_board_owned +from app.decorators.decorators import get_current_user_id from app.models import Board, Card, CardLabel, Label, List from app.schemas import (BoardCreateRequest, BoardResponse, BoardWithDetailsResponse) diff --git a/backend/app/routes/kanban/epics.py b/backend/app/routes/kanban/epics.py index 01ede3a..d328c52 100644 --- a/backend/app/routes/kanban/epics.py +++ b/backend/app/routes/kanban/epics.py @@ -5,14 +5,17 @@ from flask_jwt_extended import jwt_required from flask_pydantic import validate from app import db +from app.decorators import serialize_list_response, serialize_response from app.models import Board, Card, Epic -from app.schemas import EpicCreateRequest, EpicResponse, EpicUpdateRequest +from app.schemas import (CardResponse, EpicCreateRequest, EpicResponse, + EpicUpdateRequest) from . import kanban_bp @kanban_bp.route("/boards//epics", methods=["GET"]) @jwt_required() +@serialize_list_response(EpicResponse) def get_board_epics(board_id): """Get all epics for a board.""" board = db.session.get(Board, board_id) @@ -20,12 +23,13 @@ def get_board_epics(board_id): return {"error": "Board not found"}, 404 epics = Epic.query.filter_by(board_id=board_id).all() - return [EpicResponse.model_validate(epic).model_dump() for epic in epics], 200 + return epics, 200 @kanban_bp.route("/boards//epics", methods=["POST"]) @jwt_required() @validate(body=EpicCreateRequest) +@serialize_response(EpicResponse) def create_epic(board_id, body: EpicCreateRequest): """Create a new epic for a board.""" board = db.session.get(Board, board_id) @@ -49,7 +53,7 @@ def create_epic(board_id, body: EpicCreateRequest): db.session.add(epic) db.session.commit() - return EpicResponse.model_validate(epic).model_dump(), 201 + return epic, 201 @kanban_bp.route("/epics/", methods=["GET"]) @@ -93,6 +97,7 @@ def _update_epic_fields(epic: Epic, body: EpicUpdateRequest) -> None: @kanban_bp.route("/epics/", methods=["PUT"]) @jwt_required() @validate(body=EpicUpdateRequest) +@serialize_response(EpicResponse) def update_epic(epic_id, body: EpicUpdateRequest): """Update an epic.""" epic = db.session.get(Epic, epic_id) @@ -102,7 +107,7 @@ def update_epic(epic_id, body: EpicUpdateRequest): _update_epic_fields(epic, body) db.session.commit() - return EpicResponse.model_validate(epic).model_dump(), 200 + return epic, 200 @kanban_bp.route("/epics/", methods=["DELETE"]) @@ -125,6 +130,7 @@ def delete_epic(epic_id): @kanban_bp.route("/cards//epics", methods=["POST"]) @jwt_required() +@serialize_response(CardResponse) def add_epic_to_card(card_id): """Link an epic to a card.""" card = db.session.get(Card, card_id) @@ -144,7 +150,7 @@ def add_epic_to_card(card_id): card.epic_id = epic_id db.session.commit() - return card.to_dict(), 200 + return card, 200 @kanban_bp.route("/cards//epics/", methods=["DELETE"]) diff --git a/backend/app/routes/kanban/wikis.py b/backend/app/routes/kanban/wikis.py index 4a3d2b6..78373d9 100644 --- a/backend/app/routes/kanban/wikis.py +++ b/backend/app/routes/kanban/wikis.py @@ -9,6 +9,7 @@ from sqlalchemy import delete as sql_delete from sqlalchemy import select 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.card import Card from app.models.epic import Epic @@ -29,6 +30,7 @@ def generate_slug(name): @kanban_bp.route("/boards//wikis", methods=["GET"]) @jwt_required() +@serialize_list_response(WikiResponse) def get_board_wikis(board_id): """Get all wikis for a board.""" board = db.session.get(Board, board_id) @@ -36,12 +38,13 @@ def get_board_wikis(board_id): return {"error": "Board not found"}, 404 wikis = Wiki.query.filter_by(board_id=board_id).all() - return [WikiResponse.model_validate(wiki).model_dump() for wiki in wikis], 200 + return wikis, 200 @kanban_bp.route("/boards//wikis", methods=["POST"]) @jwt_required() @validate(body=WikiCreateRequest) +@serialize_response(WikiResponse) def create_wiki(board_id, body: WikiCreateRequest): """Create a new wiki for a board.""" board = db.session.get(Board, board_id) @@ -75,7 +78,7 @@ def create_wiki(board_id, body: WikiCreateRequest): db.session.add(wiki) db.session.commit() - return WikiResponse.model_validate(wiki).model_dump(), 201 + return wiki, 201 @kanban_bp.route("/wikis/", methods=["GET"]) @@ -108,6 +111,7 @@ def get_wiki(wiki_id): @kanban_bp.route("/wikis/", methods=["PUT"]) @jwt_required() @validate(body=WikiUpdateRequest) +@serialize_response(WikiResponse) def update_wiki(wiki_id, body: WikiUpdateRequest): """Update a wiki.""" wiki = db.session.get(Wiki, wiki_id) @@ -131,7 +135,7 @@ def update_wiki(wiki_id, body: WikiUpdateRequest): wiki.updated_by = get_jwt_identity() db.session.commit() - return WikiResponse.model_validate(wiki).model_dump(), 200 + return wiki, 200 @kanban_bp.route("/wikis/", methods=["DELETE"]) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 47c2fe0..3a49d8a 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -15,14 +15,11 @@ from app.schemas.file_attachment import (FileAttachmentWithUrlResponse, ValidationError) from app.schemas.label import LabelCreateRequest, LabelResponse from app.schemas.list_model import ListCreateRequest, ListResponse -from app.schemas.product import ProductCreateRequest, ProductResponse from app.schemas.wiki import (WikiCreateRequest, WikiEntityLink, WikiLinkRequest, WikiResponse, WikiUpdateRequest) __all__ = [ # Product schemas - "ProductCreateRequest", - "ProductResponse", # Board schemas "BoardCreateRequest", "BoardResponse", diff --git a/backend/app/schemas/card.py b/backend/app/schemas/card.py index 7a92870..81515b8 100644 --- a/backend/app/schemas/card.py +++ b/backend/app/schemas/card.py @@ -47,6 +47,7 @@ class CardResponse(BaseModel): "pos": 65535.0, "list_id": 1, "board_id": 1, + "epic_id": 1, "due": "2024-12-31T23:59:59", "due_complete": False, "badges": {"votes": 0}, @@ -64,6 +65,7 @@ class CardResponse(BaseModel): pos: float list_id: int board_id: int + epic_id: Optional[int] due: Optional[datetime] = None due_complete: bool badges: Optional[Dict[str, Any]] = None diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py deleted file mode 100644 index 7d9d501..0000000 --- a/backend/app/schemas/product.py +++ /dev/null @@ -1,71 +0,0 @@ -"""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 diff --git a/backend/migrations/versions/a9709e7ed22d_remove_products_tables.py b/backend/migrations/versions/a9709e7ed22d_remove_products_tables.py new file mode 100644 index 0000000..7224353 --- /dev/null +++ b/backend/migrations/versions/a9709e7ed22d_remove_products_tables.py @@ -0,0 +1,74 @@ +"""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 ### diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 67d42d1..d2e0b7d 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,5 +1,6 @@ """Pytest configuration and fixtures""" import logging +import time from io import BytesIO import pytest @@ -7,8 +8,7 @@ from faker import Faker from PIL import Image from app import create_app, db -from app.models import (Board, Card, Comment, List, Order, OrderItem, Product, - User) +from app.models import Board, Card, Comment, List, User log = logging.getLogger(__name__) fake = Faker() @@ -116,7 +116,7 @@ def regular_user(db_session): """Create a regular user for testing""" user = User( email=fake.email(), - username=fake.user_name(), + username=f"{fake.user_name()}_{int(time.time() * 1000)}", first_name=fake.first_name(), last_name=fake.last_name(), is_admin=False, @@ -145,39 +145,6 @@ def inactive_user(db_session): 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 def auth_headers(client, regular_user): """Get authentication headers for a regular user""" @@ -203,33 +170,6 @@ def admin_headers(client, admin_user): 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 ============ diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index aff1e86..db1af40 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -1,9 +1,8 @@ """Test models""" -from decimal import Decimal import pytest -from app.models import Order, OrderItem, Product, User +from app.models import User class TestUserModel: @@ -69,132 +68,3 @@ class TestUserModel: db_session.commit() assert repr(user) == "" - - -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) == "" - - -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 diff --git a/backend/tests/test_routes.py b/backend/tests/test_routes.py index d9f39b0..bb89a19 100644 --- a/backend/tests/test_routes.py +++ b/backend/tests/test_routes.py @@ -112,235 +112,3 @@ class TestAuthRoutes: """Test getting current user without authentication""" response = client.get("/api/users/me") 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 diff --git a/backend/tests/test_schemas.py b/backend/tests/test_schemas.py deleted file mode 100644 index 0fa7a90..0000000 --- a/backend/tests/test_schemas.py +++ /dev/null @@ -1,249 +0,0 @@ -"""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 diff --git a/frontend/src/components/kanban/KanbanColumn.tsx b/frontend/src/components/kanban/KanbanColumn.tsx index 04f85e5..1cbca5b 100644 --- a/frontend/src/components/kanban/KanbanColumn.tsx +++ b/frontend/src/components/kanban/KanbanColumn.tsx @@ -133,7 +133,7 @@ export function KanbanColumn({ >
{cards.map((card) => ( onOpenCardModal(card)} /> diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index c2b6a0e..8b6c90c 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1,5 +1,5 @@ import axios from 'axios'; -import { RegisterData, UserData, ProductData, OrderData, AuthResponse } from '../types'; +import { RegisterData, UserData, AuthResponse } from '../types'; import { Board, BoardWithDetails, @@ -69,44 +69,6 @@ export function useApi() { return response.data; }, - // Products - getProducts: async (): Promise => { - const response = await api.get('/products'); - return response.data; - }, - getProduct: async (id: string): Promise => { - const response = await api.get(`/products/${id}`); - return response.data; - }, - createProduct: async (productData: Omit): Promise => { - const response = await api.post('/products', productData); - return response.data; - }, - updateProduct: async ( - id: string, - productData: Partial - ): Promise => { - const response = await api.put(`/products/${id}`, productData); - return response.data; - }, - deleteProduct: async (id: string): Promise => { - await api.delete(`/products/${id}`); - }, - - // Orders - getOrders: async (): Promise => { - const response = await api.get('/orders'); - return response.data; - }, - getOrder: async (id: string): Promise => { - const response = await api.get(`/orders/${id}`); - return response.data; - }, - createOrder: async (orderData: Omit): Promise => { - const response = await api.post('/orders', orderData); - return response.data; - }, - // Boards getBoards: async (): Promise => { const response = await api.get('/boards'); diff --git a/frontend/src/hooks/useProducts.ts b/frontend/src/hooks/useProducts.ts deleted file mode 100644 index f4da397..0000000 --- a/frontend/src/hooks/useProducts.ts +++ /dev/null @@ -1,61 +0,0 @@ -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([]); - const [error, setError] = useState(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, - }; -} diff --git a/frontend/src/pages/Orders.tsx b/frontend/src/pages/Orders.tsx deleted file mode 100644 index 0d76ab1..0000000 --- a/frontend/src/pages/Orders.tsx +++ /dev/null @@ -1,122 +0,0 @@ -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([]); - const [loading, setLoading] = useState(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 = { - 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 ( -
-
Loading orders...
-
- ); - } - - return ( -
-

My Orders

- - {orders.length === 0 ? ( -
-

You have no orders yet

- -
- ) : ( -
- {orders.map((order) => ( -
-
-
-

Order #{order.id}

-

- {new Date(order.created_at).toLocaleDateString()} -

-
- - {order.status.charAt(0).toUpperCase() + order.status.slice(1)} - -
- -
- {order.items.map((item) => ( -
-
-

Product #{item.product_id}

-

Quantity: {item.quantity}

-
-

- ${(item.price * item.quantity).toFixed(2)} -

-
- ))} -
- -
-
- {order.shipping_address && Ship to: {order.shipping_address}} -
-
- Total:{' '} - ${order.total_amount} -
-
-
- ))} -
- )} -
- ); -} diff --git a/frontend/src/pages/Products.tsx b/frontend/src/pages/Products.tsx deleted file mode 100644 index ac36019..0000000 --- a/frontend/src/pages/Products.tsx +++ /dev/null @@ -1,66 +0,0 @@ -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 ( -
-
-

Products

- -
-
- {products.map((product) => ( -
- {product.image_url && ( - {product.name} - )} -
-

{product.name}

-

{product.description}

-
- ${product.price} - Stock: {product.stock} -
- -
-
- ))} -
- {products.length === 0 && ( -
-

No products available

-
- )} -
- ); -} diff --git a/frontend/src/types/order.ts b/frontend/src/types/order.ts deleted file mode 100644 index edce5cd..0000000 --- a/frontend/src/types/order.ts +++ /dev/null @@ -1,24 +0,0 @@ -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[]; -} diff --git a/frontend/src/types/product.ts b/frontend/src/types/product.ts deleted file mode 100644 index 4a4046a..0000000 --- a/frontend/src/types/product.ts +++ /dev/null @@ -1,26 +0,0 @@ -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; -} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 1a7cc11..296ef62 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -21,7 +21,6 @@ "noFallthroughCasesInSwitch": true, /* Path Aliases */ - "baseUrl": ".", "paths": { "@/*": ["./src/*"] }