Compare commits
No commits in common. "65d0a32cb50e8a7c45dce770c4d3d5b9ac9a80fc" and "7b32a8a6ec1a7180e725ea55684a72eaf4b8ab72" have entirely different histories.
65d0a32cb5
...
7b32a8a6ec
31 changed files with 2304 additions and 340 deletions
338
EPIC_WIKI_IMPLEMENTATION_SUMMARY.md
Normal file
338
EPIC_WIKI_IMPLEMENTATION_SUMMARY.md
Normal 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
238
EPIC_WIKI_MODELS_SUMMARY.md
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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",
|
|
||||||
]
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
75
backend/app/models/order.py
Normal file
75
backend/app/models/order.py
Normal 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}>"
|
||||||
43
backend/app/models/product.py
Normal file
43
backend/app/models/product.py
Normal 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}>"
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
71
backend/app/schemas/product.py
Normal file
71
backend/app/schemas/product.py
Normal 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
|
||||||
|
|
@ -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 ###
|
|
||||||
|
|
@ -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 ============
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
249
backend/tests/test_schemas.py
Normal file
249
backend/tests/test_schemas.py
Normal 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
|
||||||
|
|
@ -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)} />
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
61
frontend/src/hooks/useProducts.ts
Normal file
61
frontend/src/hooks/useProducts.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
122
frontend/src/pages/Orders.tsx
Normal file
122
frontend/src/pages/Orders.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
frontend/src/pages/Products.tsx
Normal file
66
frontend/src/pages/Products.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
frontend/src/types/order.ts
Normal file
24
frontend/src/types/order.ts
Normal 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[];
|
||||||
|
}
|
||||||
26
frontend/src/types/product.ts
Normal file
26
frontend/src/types/product.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
/* Path Aliases */
|
/* Path Aliases */
|
||||||
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue