diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index b475078..307810a 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -59,11 +59,11 @@ jobs: - name: Run tests env: + TEST_DATABASE_URL: postgresql://test:test@postgres:5432/test_db DATABASE_URL: postgresql://test:test@postgres:5432/test_db SECRET_KEY: test-secret-key JWT_SECRET_KEY: test-jwt-secret - FLASK_ENV: testing + FLASK_ENV: test run: | cd backend pytest --cov=app --cov-report=xml --cov-report=term - \ No newline at end of file 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/.flake8 b/backend/.flake8 new file mode 100644 index 0000000..48d9e6b --- /dev/null +++ b/backend/.flake8 @@ -0,0 +1,10 @@ +[flake8] +max-line-length = 88 +extend-ignore = E203, W503 +exclude = + .git, + __pycache__, + .venv, + venv, + migrations, + alembic \ No newline at end of file diff --git a/backend/app/__init__.py b/backend/app/__init__.py index df448d6..4c4f203 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -29,10 +29,7 @@ def create_app(config_name=None): app.config.from_object(config_by_name[config_name]) print("----------------------------------------------------------") - print( - f"------------------ENVIRONMENT: {config_name}-------------------------------------" - ) - # print(F'------------------CONFIG: {app.config}-------------------------------------') + print(f"------------------ENVIRONMENT: {config_name}-----------------------") print(json.dumps(dict(app.config), indent=2, default=str)) print("----------------------------------------------------------") # Initialize extensions with app @@ -49,13 +46,14 @@ def create_app(config_name=None): init_celery(app) # Import models (required for migrations) - from app.models import 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) @@ -73,4 +71,9 @@ def create_app(config_name=None): print(f"422 Error: {error}") return jsonify({"error": "Validation error"}), 422 + @app.teardown_appcontext + def shutdown_session(exception=None): + """Remove session at end of request to return connection to pool""" + db.session.remove() + return app diff --git a/backend/app/celery/tasks/__init__.py b/backend/app/celery/tasks/__init__.py index f58ad37..56da22b 100644 --- a/backend/app/celery/tasks/__init__.py +++ b/backend/app/celery/tasks/__init__.py @@ -5,11 +5,10 @@ Tasks are organized by domain/functionality. # Import all task modules here to ensure they're registered with Celery from . import example_tasks # noqa: F401 - # Re-export tasks for easier imports -from .example_tasks import ( # noqa: F401 - divide_numbers, - print_hello, - send_daily_report, - update_product_statistics, -) +# fmt: off +# noqa: F401 +from .example_tasks import (divide_numbers, print_hello, # noqa: F401 + send_daily_report, update_product_statistics) + +# fmt: on diff --git a/backend/app/config.py b/backend/app/config.py index 69fc836..1e8478b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,7 +8,7 @@ class Config: SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-key-change-in-production" SQLALCHEMY_TRACK_MODIFICATIONS = False JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"] - JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1) + JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=12) JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30) CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*") @@ -31,29 +31,44 @@ class Config: "broker_connection_retry_on_startup": True, } + SQLALCHEMY_ENGINE_OPTIONS = { + "pool_size": 20, # Reduce from default + "max_overflow": 10, # 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 = os.environ["TEST_DATABASE_URL"] WTF_CSRF_ENABLED = False + # Conservative connection pool settings for testing + SQLALCHEMY_ENGINE_OPTIONS = { + "pool_size": 20, # Only one connection in the pool + "max_overflow": 10, # No overflow connections allowed + "pool_timeout": 30, + "pool_recycle": 3600, # Recycle after 1 hour + "pool_pre_ping": True, # Verify connections before using + } + 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/decorators.py b/backend/app/decorators.py new file mode 100644 index 0000000..dc5543c --- /dev/null +++ b/backend/app/decorators.py @@ -0,0 +1,159 @@ +from functools import wraps + +from flask import abort, g +from flask_jwt_extended import get_jwt_identity + +from app.models import Board, Card, CheckItem, Checklist, Comment, List + + +def get_current_user_id(): + """Helper to consistently get user ID""" + # Cache in g if you want to avoid decoding JWT multiple times per request + if not hasattr(g, "jwt_identity"): + g.jwt_identity = int(get_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 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..a640f2d --- /dev/null +++ b/backend/app/models/board.py @@ -0,0 +1,80 @@ +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"" diff --git a/backend/app/models/card.py b/backend/app/models/card.py new file mode 100644 index 0000000..0cd2f9a --- /dev/null +++ b/backend/app/models/card.py @@ -0,0 +1,85 @@ +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"" diff --git a/backend/app/models/card_label.py b/backend/app/models/card_label.py new file mode 100644 index 0000000..17acde3 --- /dev/null +++ b/backend/app/models/card_label.py @@ -0,0 +1,43 @@ +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"" diff --git a/backend/app/models/check_item.py b/backend/app/models/check_item.py new file mode 100644 index 0000000..bc8c35d --- /dev/null +++ b/backend/app/models/check_item.py @@ -0,0 +1,51 @@ +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"" diff --git a/backend/app/models/checklist.py b/backend/app/models/checklist.py new file mode 100644 index 0000000..90808cf --- /dev/null +++ b/backend/app/models/checklist.py @@ -0,0 +1,55 @@ +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"" diff --git a/backend/app/models/comment.py b/backend/app/models/comment.py new file mode 100644 index 0000000..60f42f4 --- /dev/null +++ b/backend/app/models/comment.py @@ -0,0 +1,45 @@ +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"" diff --git a/backend/app/models/label.py b/backend/app/models/label.py new file mode 100644 index 0000000..b8482ef --- /dev/null +++ b/backend/app/models/label.py @@ -0,0 +1,50 @@ +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"