diff --git a/Makefile b/Makefile index facda8b..1840953 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ help: ## Show this help message install: ## Install dependencies @echo "Installing backend dependencies..." - cd backend && python -m venv venv + cd backend && python3 -m venv venv . backend/venv/bin/activate && pip install -r backend/requirements/dev.txt @echo "Installing frontend dependencies..." cd frontend && npm install @@ -88,27 +88,6 @@ test-backend-product: ## Run backend product tests only test-backend-order: ## Run backend order tests only cd backend && . venv/bin/activate && pytest -m order -test-backend-watch: ## Run backend tests in watch mode (auto-rerun on changes) - cd backend && . venv/bin/activate && pip install pytest-watch && pytest-watch - -test-backend-parallel: ## Run backend tests in parallel (faster) - cd backend && . venv/bin/activate && pip install pytest-xdist && pytest -n auto - -test-backend-coverage-report: ## Open backend coverage report in browser - cd backend && . venv/bin/activate && pytest --cov=app --cov-report=html && python -m webbrowser htmlcov/index.html - -test-backend-failed: ## Re-run only failed backend tests - cd backend && . venv/bin/activate && pytest --lf - -test-backend-last-failed: ## Run the tests that failed in the last run - cd backend && . venv/bin/activate && pytest --lf - -test-backend-specific: ## Run specific backend test (usage: make test-backend-specific TEST=test_models.py) - cd backend && . venv/bin/activate && pytest tests/$(TEST) - -test-backend-marker: ## Run backend tests by marker (usage: make test-backend-marker MARKER=auth) - cd backend && . venv/bin/activate && pytest -m $(MARKER) - test-frontend: ## Run frontend tests only cd frontend && npm test diff --git a/backend/.env.example b/backend/.env.example index 916ff93..d0b6be8 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,7 +2,7 @@ FLASK_ENV=dev SECRET_KEY=your-secret-key-here JWT_SECRET_KEY=your-jwt-secret-key-here CORS_ORIGINS=* -DEV_DATABASE_URL=postgresql://crafting:devpassword@localhost:5432/crafting_shop +DATABASE_URL=postgresql://crafting:devpassword@localhost:5432/crafting_shop DATABASE_URL=postgresql://user:password@localhost/proddb TEST_DATABASE_URL=sqlite:///test.db diff --git a/backend/app/__init__.py b/backend/app/__init__.py index df448d6..667020f 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -49,13 +49,27 @@ def create_app(config_name=None): init_celery(app) # Import models (required for migrations) - from app.models import order, product, user # noqa: F401 + from app.models import ( + board, + card, + card_label, + check_item, + checklist, + comment, + label, + list_model, + order, + product, + user, + ) # noqa: F401 # Register blueprints from app.routes import api_bp, health_bp + from app.routes.kanban import kanban_bp app.register_blueprint(api_bp, url_prefix="/api") app.register_blueprint(health_bp) + app.register_blueprint(kanban_bp, url_prefix="/api") # Global error handlers @app.errorhandler(404) diff --git a/backend/app/config.py b/backend/app/config.py index 69fc836..be03ff3 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -30,20 +30,28 @@ class Config: "worker_max_tasks_per_child": 100, "broker_connection_retry_on_startup": True, } + + SQLALCHEMY_ENGINE_OPTIONS = { + 'pool_size': 5, # Reduce from default + 'max_overflow': 2, # Reduce overflow + 'pool_timeout': 30, + 'pool_recycle': 1800, # Recycle connections after 30 minutes + 'pool_pre_ping': True, # Verify connections before using + } class DevelopmentConfig(Config): """Development configuration""" DEBUG = True - SQLALCHEMY_DATABASE_URI = os.environ.get("DEV_DATABASE_URL") or "sqlite:///dev.db" + SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"] class TestingConfig(Config): """Testing configuration""" TESTING = True - SQLALCHEMY_DATABASE_URI = os.environ.get("TEST_DATABASE_URL") or "sqlite:///test.db" + SQLALCHEMY_DATABASE_URI = "postgresql://kanban:devpassword@localhost:5451/kanban_test" WTF_CSRF_ENABLED = False @@ -51,9 +59,7 @@ class ProductionConfig(Config): """Production configuration""" DEBUG = False - SQLALCHEMY_DATABASE_URI = ( - os.environ.get("DATABASE_URL") or "postgresql://user:password@localhost/proddb" - ) + SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"] # Security headers SESSION_COOKIE_SECURE = True diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 713196e..746547b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,5 +1,26 @@ +from app.models.board import Board +from app.models.card import Card +from app.models.card_label import CardLabel +from app.models.check_item import CheckItem +from app.models.checklist import Checklist +from app.models.comment import Comment +from app.models.label import Label +from app.models.list_model import List from app.models.order import Order, OrderItem from app.models.product import Product from app.models.user import User -__all__ = ["User", "Product", "Order", "OrderItem"] +__all__ = [ + "User", + "Product", + "Order", + "OrderItem", + "Board", + "List", + "Card", + "Label", + "CardLabel", + "Checklist", + "CheckItem", + "Comment", +] diff --git a/backend/app/models/board.py b/backend/app/models/board.py new file mode 100644 index 0000000..92f3c12 --- /dev/null +++ b/backend/app/models/board.py @@ -0,0 +1,64 @@ +from datetime import UTC, datetime +from sqlalchemy.dialects.postgresql import JSONB + +from app import db + + +class Board(db.Model): + """Board model for Kanban boards""" + + __tablename__ = "boards" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(200), nullable=False, index=True) + description = db.Column(db.Text) + closed = db.Column(db.Boolean, default=False, index=True) + url = db.Column(db.String(500)) + short_link = db.Column(db.String(10), unique=True) + short_url = db.Column(db.String(500)) + + # Foreign keys + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + + # Timestamps + date_last_activity = db.Column(db.DateTime) + date_last_view = db.Column(db.DateTime) + 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), + ) + + # JSON fields for complex data + prefs = db.Column(JSONB) # board preferences + label_names = db.Column(JSONB) # label color mappings + limits = db.Column(JSONB) # various limits + + # Relationships + lists = db.relationship("List", backref="board", cascade="all, delete-orphan", lazy="dynamic") + cards = db.relationship("Card", backref="board", cascade="all, delete-orphan", lazy="dynamic") + labels = db.relationship("Label", backref="board", cascade="all, delete-orphan", lazy="dynamic") + + def to_dict(self): + """Convert board to dictionary""" + return { + "id": self.id, + "name": self.name, + "description": self.description, + "closed": self.closed, + "url": self.url, + "short_link": self.short_link, + "short_url": self.short_url, + "user_id": self.user_id, + "date_last_activity": self.date_last_activity.isoformat() if self.date_last_activity else None, + "date_last_view": self.date_last_view.isoformat() if self.date_last_view else None, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "prefs": self.prefs, + "label_names": self.label_names, + "limits": self.limits, + } + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/backend/app/models/card.py b/backend/app/models/card.py new file mode 100644 index 0000000..b4d5568 --- /dev/null +++ b/backend/app/models/card.py @@ -0,0 +1,66 @@ +from datetime import UTC, datetime +from sqlalchemy.dialects.postgresql import JSONB + +from app import db + + +class Card(db.Model): + """Card model for Kanban cards""" + + __tablename__ = "cards" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(200), nullable=False) + description = db.Column(db.Text) + closed = db.Column(db.Boolean, default=False) + due = db.Column(db.DateTime) + due_complete = db.Column(db.Boolean, default=False) + pos = db.Column(db.Float) # position for sorting + id_short = db.Column(db.Integer) # short ID for URLs + + # Foreign keys + board_id = db.Column(db.Integer, db.ForeignKey("boards.id", ondelete="CASCADE"), nullable=False, index=True) + list_id = db.Column(db.Integer, db.ForeignKey("lists.id", ondelete="CASCADE"), nullable=False, index=True) + + # Timestamps + date_last_activity = db.Column(db.DateTime) + 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), + ) + + # JSON fields + badges = db.Column(JSONB) # card badges/stats + cover = db.Column(JSONB) # cover settings + desc_data = db.Column(JSONB) + + # Relationships + checklists = db.relationship("Checklist", backref="card", cascade="all, delete-orphan", lazy="dynamic") + labels = db.relationship("CardLabel", backref="card", cascade="all, delete-orphan", lazy="dynamic") + comments = db.relationship("Comment", backref="card", cascade="all, delete-orphan", lazy="dynamic") + + def to_dict(self): + """Convert card to dictionary""" + return { + "id": self.id, + "name": self.name, + "description": self.description, + "closed": self.closed, + "due": self.due.isoformat() if self.due else None, + "due_complete": self.due_complete, + "pos": self.pos, + "id_short": self.id_short, + "board_id": self.board_id, + "list_id": self.list_id, + "date_last_activity": self.date_last_activity.isoformat() if self.date_last_activity else None, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "badges": self.badges, + "cover": self.cover, + "desc_data": self.desc_data, + } + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/backend/app/models/card_label.py b/backend/app/models/card_label.py new file mode 100644 index 0000000..f4c49c3 --- /dev/null +++ b/backend/app/models/card_label.py @@ -0,0 +1,31 @@ +from datetime import UTC, datetime + +from app import db + + +class CardLabel(db.Model): + """Many-to-many relationship between cards and labels""" + + __tablename__ = "card_labels" + + id = db.Column(db.Integer, primary_key=True) + card_id = db.Column(db.Integer, db.ForeignKey("cards.id", ondelete="CASCADE"), nullable=False, index=True) + label_id = db.Column(db.Integer, db.ForeignKey("labels.id", ondelete="CASCADE"), nullable=False, index=True) + + # Timestamps + created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC)) + + # Unique constraint to prevent duplicate associations + __table_args__ = (db.UniqueConstraint("card_id", "label_id", name="_card_label_uc"),) + + def to_dict(self): + """Convert card label to dictionary""" + return { + "id": self.id, + "card_id": self.card_id, + "label_id": self.label_id, + "created_at": self.created_at.isoformat() if self.created_at else None, + } + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/backend/app/models/check_item.py b/backend/app/models/check_item.py new file mode 100644 index 0000000..dcbe712 --- /dev/null +++ b/backend/app/models/check_item.py @@ -0,0 +1,44 @@ +from datetime import UTC, datetime + +from app import db + + +class CheckItem(db.Model): + """CheckItem model for checklist items""" + + __tablename__ = "check_items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(500), nullable=False) + pos = db.Column(db.Float) # position for sorting + state = db.Column(db.String(20), default="incomplete") # 'complete' or 'incomplete' + due = db.Column(db.DateTime) + + # Foreign keys + checklist_id = db.Column(db.Integer, db.ForeignKey("checklists.id", ondelete="CASCADE"), nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) + + # Timestamps + 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), + ) + + def to_dict(self): + """Convert check item to dictionary""" + return { + "id": self.id, + "name": self.name, + "pos": self.pos, + "state": self.state, + "due": self.due.isoformat() if self.due else None, + "checklist_id": self.checklist_id, + "user_id": self.user_id, + "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"" \ No newline at end of file diff --git a/backend/app/models/checklist.py b/backend/app/models/checklist.py new file mode 100644 index 0000000..f04197a --- /dev/null +++ b/backend/app/models/checklist.py @@ -0,0 +1,43 @@ +from datetime import UTC, datetime + +from app import db + + +class Checklist(db.Model): + """Checklist model for Kanban checklists""" + + __tablename__ = "checklists" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(200), nullable=False) + pos = db.Column(db.Float) # position for sorting + + # Foreign keys + board_id = db.Column(db.Integer, db.ForeignKey("boards.id", ondelete="CASCADE"), nullable=False, index=True) + card_id = db.Column(db.Integer, db.ForeignKey("cards.id", ondelete="CASCADE"), nullable=False, index=True) + + # Timestamps + 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 + check_items = db.relationship("CheckItem", backref="checklist", cascade="all, delete-orphan", lazy="dynamic") + + def to_dict(self): + """Convert checklist to dictionary""" + return { + "id": self.id, + "name": self.name, + "pos": self.pos, + "board_id": self.board_id, + "card_id": self.card_id, + "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"" \ No newline at end of file diff --git a/backend/app/models/comment.py b/backend/app/models/comment.py new file mode 100644 index 0000000..bd873d1 --- /dev/null +++ b/backend/app/models/comment.py @@ -0,0 +1,38 @@ +from datetime import UTC, datetime + +from app import db + + +class Comment(db.Model): + """Comment model for card comments""" + + __tablename__ = "comments" + + id = db.Column(db.Integer, primary_key=True) + text = db.Column(db.Text, nullable=False) + + # Foreign keys + card_id = db.Column(db.Integer, db.ForeignKey("cards.id", ondelete="CASCADE"), nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + + # Timestamps + 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), + ) + + def to_dict(self): + """Convert comment to dictionary""" + return { + "id": self.id, + "text": self.text, + "card_id": self.card_id, + "user_id": self.user_id, + "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"" \ No newline at end of file diff --git a/backend/app/models/label.py b/backend/app/models/label.py new file mode 100644 index 0000000..0bdb205 --- /dev/null +++ b/backend/app/models/label.py @@ -0,0 +1,43 @@ +from datetime import UTC, datetime + +from app import db + + +class Label(db.Model): + """Label model for Kanban labels""" + + __tablename__ = "labels" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100)) + color = db.Column(db.String(50)) # color name like 'green', 'red', 'blue' + uses = db.Column(db.Integer, default=0) + + # Foreign keys + board_id = db.Column(db.Integer, db.ForeignKey("boards.id", ondelete="CASCADE"), nullable=False, index=True) + + # Timestamps + 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 + card_labels = db.relationship("CardLabel", backref="label", cascade="all, delete-orphan", lazy="dynamic") + + def to_dict(self): + """Convert label to dictionary""" + return { + "id": self.id, + "name": self.name, + "color": self.color, + "uses": self.uses, + "board_id": self.board_id, + "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"