21 Commits

Author SHA1 Message Date
Mondo Diaz
9d57fd0700 Add teams migration to runtime migrations
The teams schema (teams table, team_memberships table, team_id column on
projects) was not included in the runtime migrations, causing existing
databases to fail on startup.

This adds the teams migration to _run_migrations() so it runs automatically
on application startup for databases that don't have the teams schema yet.
2026-01-28 20:02:49 +00:00
Mondo Diaz
6c79147cbf Create Global Admins team when admin user is created
- Admin user is now automatically added to Global Admins team as owner
- Ensures every user belongs to at least one team
- Updated unit tests to handle multiple db.add() calls
2026-01-28 17:24:26 +00:00
Mondo Diaz
1bf8274d8c Update CHANGELOG with dark theme fixes and UI improvements 2026-01-28 16:51:12 +00:00
Mondo Diaz
9b79838cc3 Fix dark theme styling for team pages and add footer enhancements
- Update all team page CSS to use correct theme variables (--bg-*, --text-*,
  --border-*, --accent-* instead of non-existent --color-* variables)
- Fix modal, form input, and dropdown backgrounds for dark theme
- Fix UserAutocomplete and TeamSelector dropdown styling
- Center team members and settings page content
- Add orchard logo icon to footer
- Add dot separator between Orchard and tagline in footer
2026-01-28 16:49:50 +00:00
Mondo Diaz
1f5d3665c8 Fix modal backgrounds to be solid white instead of transparent 2026-01-28 16:25:22 +00:00
Mondo Diaz
1b2bc33aba Use DataTable for members, add seed users, remove teams stats
- Update TeamMembersPage to use DataTable component for consistency
- Add test users (alice, bob, charlie, diana, eve, frank) with various roles
- Remove stats from teams list header
- Passwords for test users are same as their usernames
2026-01-28 16:20:23 +00:00
Mondo Diaz
2b9c039157 Use DataTable component for teams and projects tables
Consistent table styling across the app with:
- Row hover highlighting
- Clickable rows
- Standard cell padding and borders
- Proper header styling
2026-01-28 16:13:32 +00:00
Mondo Diaz
7d106998be Add subtle vertical column separators to tables 2026-01-28 16:09:20 +00:00
Mondo Diaz
6198a174c7 Use subtle faint row separators for tables instead of thick borders 2026-01-28 16:07:13 +00:00
Mondo Diaz
184cb8ec00 Fix table borders, single team nav link, remove dashboard stats
- Use explicit border color (#e2e8f0) for table cell borders
- Navbar shows 'Team' (singular) linking directly to team dashboard when user has only 1 team
- Navbar shows 'Teams' (plural) linking to teams list when user has multiple teams
- Remove project/member counts from team dashboard header
2026-01-28 16:05:02 +00:00
Mondo Diaz
000540727c Improve table styling and make headers more horizontal
- Add visible column borders to teams and projects tables
- Make header 2px border for visual separation
- Consolidate teams page header: title + inline stats on left, create button on right
- Consolidate team dashboard header: title/badge/slug + description + inline stats on left, action buttons on right
2026-01-28 15:57:11 +00:00
Mondo Diaz
aece9e0b9f Change teams list to table view for consistency with projects table 2026-01-28 15:48:45 +00:00
Mondo Diaz
018e352820 Change projects display to table view in team dashboard 2026-01-28 15:45:46 +00:00
Mondo Diaz
86f2f031db Redesign teams portal and add user autocomplete for member invitations
- Redesign TeamsPage with modern card-based layout including stats bar,
  search functionality, and empty states
- Add UserAutocomplete component with debounced search and keyboard
  navigation for selecting existing users
- Add /api/v1/users/search endpoint for username prefix search
- Update TeamMembersPage to use UserAutocomplete instead of free text input
2026-01-28 15:42:55 +00:00
Mondo Diaz
69f3737303 Move project settings to team portal, remove project-level permissions
- Add Settings button to project cards in team dashboard
- Hide Settings button on ProjectPage for projects belonging to a team
- Remove AccessManagement section from ProjectSettingsPage
  (team membership now governs all access to team projects)
- Update project card layout with separate clickable area and actions
2026-01-28 15:19:41 +00:00
Mondo Diaz
60179e68fd Hide visibility filter for anonymous users on home page
Anonymous users can only see public projects, so the visibility
filter dropdown is not useful for them. Only show it when logged in.
2026-01-28 15:07:41 +00:00
Mondo Diaz
6901880a2f Update CHANGELOG with access management team display feature 2026-01-28 00:57:30 +00:00
Mondo Diaz
89186a0d61 Show team-based access in project access management
- Add source, team_slug, team_role fields to AccessPermissionResponse schema
- Update list_project_permissions endpoint to include team members with source="team"
- Display team-based access in AccessManagement component with read-only styling
- Add "Source" column to differentiate explicit vs team-based permissions
- Team-based access shows "Via team" in actions column (not editable)
2026-01-28 00:57:16 +00:00
Mondo Diaz
da6af4ae71 Fix team members not seeing private projects in listings
The list_projects endpoint was only showing projects that were public or
created by the user. Updated to also include projects belonging to teams
where the user is a member.

This allows team members to see private projects in the main project
listing, not just on the team dashboard.
2026-01-28 00:14:16 +00:00
Mondo Diaz
053d45add1 Add project creation from team dashboard and update seed data
- Add project creation modal to TeamDashboardPage with team_id assignment
- Update createProject API function to accept optional team_id
- Update seed data to create a "Demo Team" and assign all projects to it
- Admin user is added as team owner when present
2026-01-28 00:02:53 +00:00
Mondo Diaz
a1bf38de04 Add multi-tenancy with Teams feature
Implement team-based organization for projects with role-based access control:

Backend:
- Add teams and team_memberships database tables (migrations 009, 009b)
- Add Team and TeamMembership ORM models with relationships
- Implement TeamAuthorizationService for team-level access control
- Add team CRUD, membership, and projects API endpoints
- Update project creation to support team assignment

Frontend:
- Add TeamContext for managing team state with localStorage persistence
- Add TeamSelector component for switching between teams
- Add TeamsPage, TeamDashboardPage, TeamSettingsPage, TeamMembersPage
- Add team API client functions
- Update navigation with Teams link

Security:
- Team role hierarchy: owner > admin > member
- Membership checked before system admin fallback
- Self-modification prevention for role changes
- Email visibility restricted to team admins/owners
- Slug validation rejects consecutive hyphens

Tests:
- Unit tests for TeamAuthorizationService
- Integration tests for all team API endpoints
2026-01-27 23:28:31 +00:00
3 changed files with 325 additions and 405 deletions

View File

@@ -11,7 +11,7 @@ from typing import Optional
from passlib.context import CryptContext from passlib.context import CryptContext
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import User, Session as UserSession, APIKey from .models import User, Session as UserSession, APIKey, Team, TeamMembership
from .config import get_settings from .config import get_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -363,6 +363,8 @@ def create_default_admin(db: Session) -> Optional[User]:
The admin password can be set via ORCHARD_ADMIN_PASSWORD environment variable. The admin password can be set via ORCHARD_ADMIN_PASSWORD environment variable.
If not set, defaults to 'changeme123' and requires password change on first login. If not set, defaults to 'changeme123' and requires password change on first login.
Also creates the "Global Admins" team and adds the admin user to it.
""" """
# Check if any users exist # Check if any users exist
user_count = db.query(User).count() user_count = db.query(User).count()
@@ -385,6 +387,27 @@ def create_default_admin(db: Session) -> Optional[User]:
must_change_password=must_change, must_change_password=must_change,
) )
# Create Global Admins team and add admin to it
global_admins_team = Team(
name="Global Admins",
slug="global-admins",
description="System administrators with full access",
created_by="admin",
)
db.add(global_admins_team)
db.flush()
membership = TeamMembership(
team_id=global_admins_team.id,
user_id=admin.id,
role="owner",
invited_by="admin",
)
db.add(membership)
db.commit()
logger.info("Created Global Admins team and added admin as owner")
if settings.admin_password: if settings.admin_password:
logger.info("Created default admin user with configured password") logger.info("Created default admin user with configured password")
else: else:

View File

@@ -1,11 +1,10 @@
from sqlalchemy import create_engine, text, event from sqlalchemy import create_engine, text, event
from sqlalchemy.orm import sessionmaker, Session from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy.pool import QueuePool from sqlalchemy.pool import QueuePool
from typing import Generator, NamedTuple from typing import Generator
from contextlib import contextmanager from contextlib import contextmanager
import logging import logging
import time import time
import hashlib
from .config import get_settings from .config import get_settings
from .models import Base from .models import Base
@@ -13,21 +12,6 @@ from .models import Base
settings = get_settings() settings = get_settings()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Migration(NamedTuple):
"""A database migration with a unique name and SQL to execute."""
name: str
sql: str
# PostgreSQL error codes that indicate "already exists" - safe to skip
SAFE_PG_ERROR_CODES = {
"42P07", # duplicate_table
"42701", # duplicate_column
"42710", # duplicate_object (index, constraint, etc.)
"42P16", # invalid_table_definition (e.g., column already exists)
}
# Build connect_args with query timeout if configured # Build connect_args with query timeout if configured
connect_args = {} connect_args = {}
if settings.database_query_timeout > 0: if settings.database_query_timeout > 0:
@@ -81,65 +65,11 @@ def init_db():
_run_migrations() _run_migrations()
def _ensure_migrations_table(conn) -> None:
"""Create the migrations tracking table if it doesn't exist."""
conn.execute(text("""
CREATE TABLE IF NOT EXISTS _schema_migrations (
name VARCHAR(255) PRIMARY KEY,
checksum VARCHAR(64) NOT NULL,
applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
"""))
conn.commit()
def _get_applied_migrations(conn) -> dict[str, str]:
"""Get all applied migrations and their checksums."""
result = conn.execute(text(
"SELECT name, checksum FROM _schema_migrations"
))
return {row[0]: row[1] for row in result}
def _compute_checksum(sql: str) -> str:
"""Compute a checksum for migration SQL to detect changes."""
return hashlib.sha256(sql.strip().encode()).hexdigest()[:16]
def _is_safe_error(exception: Exception) -> bool:
"""Check if the error indicates the migration was already applied."""
# Check for psycopg2 errors with pgcode attribute
original = getattr(exception, "orig", None)
if original is not None:
pgcode = getattr(original, "pgcode", None)
if pgcode in SAFE_PG_ERROR_CODES:
return True
# Fallback: check error message for common "already exists" patterns
error_str = str(exception).lower()
safe_patterns = [
"already exists",
"duplicate key",
"relation .* already exists",
"column .* already exists",
]
return any(pattern in error_str for pattern in safe_patterns)
def _record_migration(conn, name: str, checksum: str) -> None:
"""Record a migration as applied."""
conn.execute(text(
"INSERT INTO _schema_migrations (name, checksum) VALUES (:name, :checksum)"
), {"name": name, "checksum": checksum})
conn.commit()
def _run_migrations(): def _run_migrations():
"""Run manual migrations for schema updates with tracking and error detection.""" """Run manual migrations for schema updates"""
migrations = [ migrations = [
Migration( # Add format_metadata column to artifacts table
name="001_add_format_metadata", """
sql="""
DO $$ DO $$
BEGIN BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
@@ -150,10 +80,8 @@ def _run_migrations():
END IF; END IF;
END $$; END $$;
""", """,
), # Add format column to packages table
Migration( """
name="002_add_package_format",
sql="""
DO $$ DO $$
BEGIN BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
@@ -165,10 +93,8 @@ def _run_migrations():
END IF; END IF;
END $$; END $$;
""", """,
), # Add platform column to packages table
Migration( """
name="003_add_package_platform",
sql="""
DO $$ DO $$
BEGIN BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
@@ -180,18 +106,18 @@ def _run_migrations():
END IF; END IF;
END $$; END $$;
""", """,
), # Add ref_count index and constraints for artifacts
Migration( """
name="004_add_ref_count_index_constraint",
sql="""
DO $$ DO $$
BEGIN BEGIN
-- Add ref_count index
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM pg_indexes WHERE indexname = 'idx_artifacts_ref_count' SELECT 1 FROM pg_indexes WHERE indexname = 'idx_artifacts_ref_count'
) THEN ) THEN
CREATE INDEX idx_artifacts_ref_count ON artifacts(ref_count); CREATE INDEX idx_artifacts_ref_count ON artifacts(ref_count);
END IF; END IF;
-- Add ref_count >= 0 constraint
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'check_ref_count_non_negative' SELECT 1 FROM pg_constraint WHERE conname = 'check_ref_count_non_negative'
) THEN ) THEN
@@ -199,24 +125,25 @@ def _run_migrations():
END IF; END IF;
END $$; END $$;
""", """,
), # Add composite indexes for packages and tags
Migration( """
name="005_add_composite_indexes",
sql="""
DO $$ DO $$
BEGIN BEGIN
-- Composite index for package lookup by project and name
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM pg_indexes WHERE indexname = 'idx_packages_project_name' SELECT 1 FROM pg_indexes WHERE indexname = 'idx_packages_project_name'
) THEN ) THEN
CREATE UNIQUE INDEX idx_packages_project_name ON packages(project_id, name); CREATE UNIQUE INDEX idx_packages_project_name ON packages(project_id, name);
END IF; END IF;
-- Composite index for tag lookup by package and name
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM pg_indexes WHERE indexname = 'idx_tags_package_name' SELECT 1 FROM pg_indexes WHERE indexname = 'idx_tags_package_name'
) THEN ) THEN
CREATE UNIQUE INDEX idx_tags_package_name ON tags(package_id, name); CREATE UNIQUE INDEX idx_tags_package_name ON tags(package_id, name);
END IF; END IF;
-- Composite index for recent tags queries
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM pg_indexes WHERE indexname = 'idx_tags_package_created_at' SELECT 1 FROM pg_indexes WHERE indexname = 'idx_tags_package_created_at'
) THEN ) THEN
@@ -224,13 +151,13 @@ def _run_migrations():
END IF; END IF;
END $$; END $$;
""", """,
), # Add package_versions indexes and triggers (007_package_versions.sql)
Migration( """
name="006_add_package_versions_indexes",
sql="""
DO $$ DO $$
BEGIN BEGIN
-- Create indexes for package_versions if table exists
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'package_versions') THEN IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'package_versions') THEN
-- Indexes for common queries
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_package_versions_package_id') THEN IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_package_versions_package_id') THEN
CREATE INDEX idx_package_versions_package_id ON package_versions(package_id); CREATE INDEX idx_package_versions_package_id ON package_versions(package_id);
END IF; END IF;
@@ -243,10 +170,8 @@ def _run_migrations():
END IF; END IF;
END $$; END $$;
""", """,
), # Create ref_count trigger functions for tags (ensures triggers exist even if initial migration wasn't run)
Migration( """
name="007_create_ref_count_trigger_functions",
sql="""
CREATE OR REPLACE FUNCTION increment_artifact_ref_count() CREATE OR REPLACE FUNCTION increment_artifact_ref_count()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
BEGIN BEGIN
@@ -254,7 +179,8 @@ def _run_migrations():
RETURN NEW; RETURN NEW;
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
""",
"""
CREATE OR REPLACE FUNCTION decrement_artifact_ref_count() CREATE OR REPLACE FUNCTION decrement_artifact_ref_count()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
BEGIN BEGIN
@@ -262,7 +188,8 @@ def _run_migrations():
RETURN OLD; RETURN OLD;
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
""",
"""
CREATE OR REPLACE FUNCTION update_artifact_ref_count() CREATE OR REPLACE FUNCTION update_artifact_ref_count()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
BEGIN BEGIN
@@ -274,12 +201,11 @@ def _run_migrations():
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
""", """,
), # Create triggers for tags ref_count management
Migration( """
name="008_create_tags_ref_count_triggers",
sql="""
DO $$ DO $$
BEGIN BEGIN
-- Drop and recreate triggers to ensure they're current
DROP TRIGGER IF EXISTS tags_ref_count_insert_trigger ON tags; DROP TRIGGER IF EXISTS tags_ref_count_insert_trigger ON tags;
CREATE TRIGGER tags_ref_count_insert_trigger CREATE TRIGGER tags_ref_count_insert_trigger
AFTER INSERT ON tags AFTER INSERT ON tags
@@ -300,10 +226,8 @@ def _run_migrations():
EXECUTE FUNCTION update_artifact_ref_count(); EXECUTE FUNCTION update_artifact_ref_count();
END $$; END $$;
""", """,
), # Create ref_count trigger functions for package_versions
Migration( """
name="009_create_version_ref_count_functions",
sql="""
CREATE OR REPLACE FUNCTION increment_version_ref_count() CREATE OR REPLACE FUNCTION increment_version_ref_count()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
BEGIN BEGIN
@@ -311,7 +235,8 @@ def _run_migrations():
RETURN NEW; RETURN NEW;
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
""",
"""
CREATE OR REPLACE FUNCTION decrement_version_ref_count() CREATE OR REPLACE FUNCTION decrement_version_ref_count()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
BEGIN BEGIN
@@ -320,13 +245,12 @@ def _run_migrations():
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
""", """,
), # Create triggers for package_versions ref_count
Migration( """
name="010_create_package_versions_triggers",
sql="""
DO $$ DO $$
BEGIN BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'package_versions') THEN IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'package_versions') THEN
-- Drop and recreate triggers to ensure they're current
DROP TRIGGER IF EXISTS package_versions_ref_count_insert ON package_versions; DROP TRIGGER IF EXISTS package_versions_ref_count_insert ON package_versions;
CREATE TRIGGER package_versions_ref_count_insert CREATE TRIGGER package_versions_ref_count_insert
AFTER INSERT ON package_versions AFTER INSERT ON package_versions
@@ -341,16 +265,14 @@ def _run_migrations():
END IF; END IF;
END $$; END $$;
""", """,
), # Migrate existing semver tags to package_versions
Migration( r"""
name="011_migrate_semver_tags_to_versions",
sql=r"""
DO $$ DO $$
BEGIN BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'package_versions') THEN IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'package_versions') THEN
INSERT INTO package_versions (id, package_id, artifact_id, version, version_source, created_by, created_at) -- Migrate tags that look like versions (v1.0.0, 1.2.3, 2.0.0-beta, etc.)
INSERT INTO package_versions (package_id, artifact_id, version, version_source, created_by, created_at)
SELECT SELECT
gen_random_uuid(),
t.package_id, t.package_id,
t.artifact_id, t.artifact_id,
CASE WHEN t.name LIKE 'v%' THEN substring(t.name from 2) ELSE t.name END, CASE WHEN t.name LIKE 'v%' THEN substring(t.name from 2) ELSE t.name END,
@@ -363,40 +285,36 @@ def _run_migrations():
END IF; END IF;
END $$; END $$;
""", """,
), # Teams and multi-tenancy migration (009_teams.sql)
Migration( """
name="012_create_teams_table", -- Create teams table
sql="""
CREATE TABLE IF NOT EXISTS teams ( CREATE TABLE IF NOT EXISTS teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE, slug VARCHAR(255) NOT NULL UNIQUE,
description TEXT, description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(255) NOT NULL, created_by VARCHAR(255) NOT NULL,
settings JSONB DEFAULT '{}' settings JSONB DEFAULT '{}'::jsonb,
CONSTRAINT check_team_slug_format CHECK (slug ~ '^[a-z0-9][a-z0-9-]*[a-z0-9]$' OR slug ~ '^[a-z0-9]$')
); );
""", """,
), """
Migration( -- Create team_memberships table
name="013_create_team_memberships_table",
sql="""
CREATE TABLE IF NOT EXISTS team_memberships ( CREATE TABLE IF NOT EXISTS team_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(50) NOT NULL DEFAULT 'member', role VARCHAR(20) NOT NULL DEFAULT 'member',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
invited_by VARCHAR(255), invited_by VARCHAR(255),
CONSTRAINT team_memberships_unique UNIQUE (team_id, user_id), CONSTRAINT unique_team_membership UNIQUE (team_id, user_id),
CONSTRAINT team_memberships_role_check CHECK (role IN ('owner', 'admin', 'member')) CONSTRAINT check_team_role CHECK (role IN ('owner', 'admin', 'member'))
); );
""", """,
), # Add team_id column to projects table
Migration( """
name="014_add_team_id_to_projects",
sql="""
DO $$ DO $$
BEGIN BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
@@ -404,14 +322,11 @@ def _run_migrations():
WHERE table_name = 'projects' AND column_name = 'team_id' WHERE table_name = 'projects' AND column_name = 'team_id'
) THEN ) THEN
ALTER TABLE projects ADD COLUMN team_id UUID REFERENCES teams(id) ON DELETE SET NULL; ALTER TABLE projects ADD COLUMN team_id UUID REFERENCES teams(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_projects_team_id ON projects(team_id);
END IF; END IF;
END $$; END $$;
""", """,
), # Create indexes for teams
Migration( """
name="015_add_teams_indexes",
sql="""
DO $$ DO $$
BEGIN BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_teams_slug') THEN IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_teams_slug') THEN
@@ -420,58 +335,32 @@ def _run_migrations():
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_teams_created_by') THEN IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_teams_created_by') THEN
CREATE INDEX idx_teams_created_by ON teams(created_by); CREATE INDEX idx_teams_created_by ON teams(created_by);
END IF; END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_teams_created_at') THEN
CREATE INDEX idx_teams_created_at ON teams(created_at);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_team_memberships_team_id') THEN IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_team_memberships_team_id') THEN
CREATE INDEX idx_team_memberships_team_id ON team_memberships(team_id); CREATE INDEX idx_team_memberships_team_id ON team_memberships(team_id);
END IF; END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_team_memberships_user_id') THEN IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_team_memberships_user_id') THEN
CREATE INDEX idx_team_memberships_user_id ON team_memberships(user_id); CREATE INDEX idx_team_memberships_user_id ON team_memberships(user_id);
END IF; END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_team_memberships_role') THEN
CREATE INDEX idx_team_memberships_role ON team_memberships(role);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_projects_team_id') THEN
CREATE INDEX idx_projects_team_id ON projects(team_id);
END IF;
END $$; END $$;
""", """,
),
] ]
with engine.connect() as conn: with engine.connect() as conn:
# Ensure migrations tracking table exists
_ensure_migrations_table(conn)
# Get already-applied migrations
applied = _get_applied_migrations(conn)
for migration in migrations: for migration in migrations:
checksum = _compute_checksum(migration.sql)
# Check if migration was already applied
if migration.name in applied:
stored_checksum = applied[migration.name]
if stored_checksum != checksum:
logger.warning(
f"Migration '{migration.name}' has changed since it was applied! "
f"Stored checksum: {stored_checksum}, current: {checksum}"
)
continue
# Run the migration
try: try:
logger.info(f"Running migration: {migration.name}") conn.execute(text(migration))
conn.execute(text(migration.sql))
conn.commit() conn.commit()
_record_migration(conn, migration.name, checksum)
logger.info(f"Migration '{migration.name}' applied successfully")
except Exception as e: except Exception as e:
conn.rollback() logger.warning(f"Migration failed (may already be applied): {e}")
if _is_safe_error(e):
# Migration was already applied (schema already exists)
logger.info(
f"Migration '{migration.name}' already applied (schema exists), recording as complete"
)
_record_migration(conn, migration.name, checksum)
else:
# Real error - fail hard
logger.error(f"Migration '{migration.name}' failed: {e}")
raise RuntimeError(
f"Migration '{migration.name}' failed with error: {e}"
) from e
def get_db() -> Generator[Session, None, None]: def get_db() -> Generator[Session, None, None]:

View File

@@ -10,6 +10,7 @@ class TestCreateDefaultAdmin:
def test_create_default_admin_with_env_password(self): def test_create_default_admin_with_env_password(self):
"""Test that ORCHARD_ADMIN_PASSWORD env var sets admin password.""" """Test that ORCHARD_ADMIN_PASSWORD env var sets admin password."""
from app.auth import create_default_admin, verify_password from app.auth import create_default_admin, verify_password
from app.models import User
# Create mock settings with custom password # Create mock settings with custom password
mock_settings = MagicMock() mock_settings = MagicMock()
@@ -19,20 +20,23 @@ class TestCreateDefaultAdmin:
mock_db = MagicMock() mock_db = MagicMock()
mock_db.query.return_value.count.return_value = 0 # No existing users mock_db.query.return_value.count.return_value = 0 # No existing users
# Track the user that gets created # Track all objects that get created
created_user = None created_objects = []
def capture_user(user): def capture_object(obj):
nonlocal created_user created_objects.append(obj)
created_user = user
mock_db.add.side_effect = capture_user mock_db.add.side_effect = capture_object
with patch("app.auth.get_settings", return_value=mock_settings): with patch("app.auth.get_settings", return_value=mock_settings):
admin = create_default_admin(mock_db) admin = create_default_admin(mock_db)
# Verify the user was created # Verify objects were created (user, team, membership)
assert mock_db.add.called assert mock_db.add.called
assert len(created_objects) >= 1
# Find the user object
created_user = next((obj for obj in created_objects if isinstance(obj, User)), None)
assert created_user is not None assert created_user is not None
assert created_user.username == "admin" assert created_user.username == "admin"
assert created_user.is_admin is True assert created_user.is_admin is True
@@ -44,6 +48,7 @@ class TestCreateDefaultAdmin:
def test_create_default_admin_with_default_password(self): def test_create_default_admin_with_default_password(self):
"""Test that default password 'changeme123' is used when env var not set.""" """Test that default password 'changeme123' is used when env var not set."""
from app.auth import create_default_admin, verify_password from app.auth import create_default_admin, verify_password
from app.models import User
# Create mock settings with empty password (default) # Create mock settings with empty password (default)
mock_settings = MagicMock() mock_settings = MagicMock()
@@ -53,20 +58,23 @@ class TestCreateDefaultAdmin:
mock_db = MagicMock() mock_db = MagicMock()
mock_db.query.return_value.count.return_value = 0 # No existing users mock_db.query.return_value.count.return_value = 0 # No existing users
# Track the user that gets created # Track all objects that get created
created_user = None created_objects = []
def capture_user(user): def capture_object(obj):
nonlocal created_user created_objects.append(obj)
created_user = user
mock_db.add.side_effect = capture_user mock_db.add.side_effect = capture_object
with patch("app.auth.get_settings", return_value=mock_settings): with patch("app.auth.get_settings", return_value=mock_settings):
admin = create_default_admin(mock_db) admin = create_default_admin(mock_db)
# Verify the user was created # Verify objects were created
assert mock_db.add.called assert mock_db.add.called
assert len(created_objects) >= 1
# Find the user object
created_user = next((obj for obj in created_objects if isinstance(obj, User)), None)
assert created_user is not None assert created_user is not None
assert created_user.username == "admin" assert created_user.username == "admin"
assert created_user.is_admin is True assert created_user.is_admin is True