diff --git a/src/app.py b/src/app.py
index 247c423..0b1e072 100644
--- a/src/app.py
+++ b/src/app.py
@@ -55,12 +55,12 @@ def create_app(config_name: str | None = None) -> Flask:
app.config["SESSION_SQLALCHEMY"] = db
session.init_app(app)
- # Register blueprints (will be created later)
- # from src.routes import admin, auth, participant, public
- # app.register_blueprint(admin.bp)
- # app.register_blueprint(auth.bp)
- # app.register_blueprint(participant.bp)
- # app.register_blueprint(public.bp)
+ # Register blueprints
+ from src.routes.admin import admin_bp
+ from src.routes.setup import setup_bp
+
+ app.register_blueprint(setup_bp)
+ app.register_blueprint(admin_bp)
# Register error handlers
register_error_handlers(app)
@@ -127,17 +127,15 @@ def register_setup_check(app: Flask) -> None:
has been set up with an admin account.
"""
# Skip check for certain endpoints
- if request.endpoint in ["setup", "static", "health"]:
+ if request.endpoint in ["setup.setup", "static", "health"]:
return
- # Check if we've already determined setup is required
- if not hasattr(app, "_setup_checked"):
- from src.models.admin import Admin
+ # Check if admin exists (always check in testing mode)
+ from src.models.admin import Admin
- admin_count = db.session.query(Admin).count()
- app.config["REQUIRES_SETUP"] = admin_count == 0
- app._setup_checked = True
+ admin_count = db.session.query(Admin).count()
+ requires_setup = admin_count == 0
# Redirect to setup if needed
- if app.config.get("REQUIRES_SETUP") and request.endpoint != "setup":
- return redirect(url_for("setup"))
+ if requires_setup and request.endpoint != "setup.setup":
+ return redirect(url_for("setup.setup"))
diff --git a/src/forms/__init__.py b/src/forms/__init__.py
new file mode 100644
index 0000000..1491646
--- /dev/null
+++ b/src/forms/__init__.py
@@ -0,0 +1,5 @@
+"""Forms for Sneaky Klaus application."""
+
+from src.forms.setup import SetupForm
+
+__all__ = ["SetupForm"]
diff --git a/src/forms/setup.py b/src/forms/setup.py
new file mode 100644
index 0000000..9cfa69d
--- /dev/null
+++ b/src/forms/setup.py
@@ -0,0 +1,47 @@
+"""Setup form for initial admin account creation."""
+
+from flask_wtf import FlaskForm
+from wtforms import EmailField, PasswordField
+from wtforms.validators import DataRequired, Email, EqualTo, Length
+
+
+class SetupForm(FlaskForm):
+ """Form for initial admin setup.
+
+ Validates email format, password length, and password confirmation.
+
+ Attributes:
+ email: Email address for admin account.
+ password: Password for admin account (minimum 12 characters).
+ password_confirm: Password confirmation field (must match password).
+ """
+
+ email = EmailField(
+ "Email Address",
+ validators=[
+ DataRequired(message="Email address is required."),
+ Email(message="Please enter a valid email address."),
+ ],
+ render_kw={"placeholder": "admin@example.com"},
+ )
+
+ password = PasswordField(
+ "Password",
+ validators=[
+ DataRequired(message="Password is required."),
+ Length(
+ min=12,
+ message="Password must be at least 12 characters long.",
+ ),
+ ],
+ render_kw={"placeholder": "Enter a secure password"},
+ )
+
+ password_confirm = PasswordField(
+ "Confirm Password",
+ validators=[
+ DataRequired(message="Please confirm your password."),
+ EqualTo("password", message="Passwords must match."),
+ ],
+ render_kw={"placeholder": "Re-enter your password"},
+ )
diff --git a/src/routes/__init__.py b/src/routes/__init__.py
new file mode 100644
index 0000000..c68bff9
--- /dev/null
+++ b/src/routes/__init__.py
@@ -0,0 +1,5 @@
+"""Route blueprints for Sneaky Klaus application."""
+
+from src.routes.setup import setup_bp
+
+__all__ = ["setup_bp"]
diff --git a/src/routes/admin.py b/src/routes/admin.py
new file mode 100644
index 0000000..056e7bd
--- /dev/null
+++ b/src/routes/admin.py
@@ -0,0 +1,15 @@
+"""Admin routes for Sneaky Klaus application."""
+
+from flask import Blueprint, render_template
+
+admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
+
+
+@admin_bp.route("/dashboard")
+def dashboard():
+ """Display admin dashboard.
+
+ Returns:
+ Rendered admin dashboard template.
+ """
+ return render_template("admin/dashboard.html")
diff --git a/src/routes/setup.py b/src/routes/setup.py
new file mode 100644
index 0000000..e9c0154
--- /dev/null
+++ b/src/routes/setup.py
@@ -0,0 +1,53 @@
+"""Setup route for initial admin account creation."""
+
+from flask import Blueprint, abort, redirect, render_template, session, url_for
+
+from src.app import bcrypt, db
+from src.forms import SetupForm
+from src.models import Admin
+
+setup_bp = Blueprint("setup", __name__)
+
+
+@setup_bp.route("/setup", methods=["GET", "POST"])
+def setup():
+ """Handle initial admin account setup.
+
+ GET: Display the setup form if no admin exists.
+ POST: Process the setup form, create admin account, and log in.
+
+ Returns:
+ On GET: Rendered setup form template.
+ On POST success: Redirect to admin dashboard.
+ On POST error: Re-render form with validation errors.
+ 404 if admin already exists.
+ """
+ # Check if admin already exists
+ admin_count = db.session.query(Admin).count()
+ if admin_count > 0:
+ abort(404)
+
+ form = SetupForm()
+
+ if form.validate_on_submit():
+ # Create admin account
+ password_hash = bcrypt.generate_password_hash(form.password.data).decode(
+ "utf-8"
+ )
+
+ admin = Admin(
+ email=form.email.data,
+ password_hash=password_hash,
+ )
+
+ db.session.add(admin)
+ db.session.commit()
+
+ # Log in the admin by setting session
+ session["admin_id"] = admin.id
+ session["admin_email"] = admin.email
+
+ # Redirect to admin dashboard
+ return redirect(url_for("admin.dashboard"))
+
+ return render_template("setup.html", form=form)
diff --git a/src/templates/admin/dashboard.html b/src/templates/admin/dashboard.html
new file mode 100644
index 0000000..bb68aba
--- /dev/null
+++ b/src/templates/admin/dashboard.html
@@ -0,0 +1,15 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}Admin Dashboard - Sneaky Klaus{% endblock %}
+
+{% block content %}
+
+
+
+ Welcome to the Sneaky Klaus admin dashboard!
+
+ This is a placeholder for the admin dashboard. More features coming soon.
+
+{% endblock %}
diff --git a/src/templates/errors/404.html b/src/templates/errors/404.html
new file mode 100644
index 0000000..f747fac
--- /dev/null
+++ b/src/templates/errors/404.html
@@ -0,0 +1,12 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}Page Not Found - Sneaky Klaus{% endblock %}
+
+{% block content %}
+
+
+ The page you're looking for doesn't exist.
+
+{% endblock %}
diff --git a/src/templates/layouts/base.html b/src/templates/layouts/base.html
new file mode 100644
index 0000000..02f035c
--- /dev/null
+++ b/src/templates/layouts/base.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ {% block title %}Sneaky Klaus{% endblock %}
+
+
+
+
+ {% block extra_css %}{% endblock %}
+
+
+
+ {% block content %}{% endblock %}
+
+
+ {% block extra_js %}{% endblock %}
+
+
diff --git a/src/templates/setup.html b/src/templates/setup.html
new file mode 100644
index 0000000..32a97a1
--- /dev/null
+++ b/src/templates/setup.html
@@ -0,0 +1,54 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}Admin Setup - Sneaky Klaus{% endblock %}
+
+{% block content %}
+
+
+
+
+
+{% endblock %}
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..6ef8a86
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+"""Test package for Sneaky Klaus."""
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..e066452
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,77 @@
+"""Pytest configuration and shared fixtures for Sneaky Klaus tests."""
+
+import pytest
+
+from src.app import create_app
+from src.app import db as _db
+from src.models import Admin
+
+
+@pytest.fixture(scope="session")
+def app():
+ """Create and configure a test Flask application instance.
+
+ This fixture is scoped to the session so the app is created once
+ for all tests.
+
+ Yields:
+ Flask application configured for testing.
+ """
+ app = create_app("testing")
+ yield app
+
+
+@pytest.fixture(scope="function")
+def db(app):
+ """Create a clean database for each test.
+
+ This fixture creates all tables before each test and drops them
+ after each test to ensure isolation.
+
+ Args:
+ app: Flask application instance from app fixture.
+
+ Yields:
+ SQLAlchemy database instance.
+ """
+ with app.app_context():
+ _db.create_all()
+ yield _db
+ _db.session.remove()
+ _db.drop_all()
+
+
+@pytest.fixture(scope="function")
+def client(app, db): # noqa: ARG001
+ """Create a test client for the Flask application.
+
+ Args:
+ app: Flask application instance.
+ db: Database instance (ensures db is set up first).
+
+ Yields:
+ Flask test client.
+ """
+ with app.test_client() as client:
+ yield client
+
+
+@pytest.fixture
+def admin(db):
+ """Create an admin user for testing.
+
+ Args:
+ db: Database instance.
+
+ Returns:
+ Admin model instance.
+ """
+ from src.app import bcrypt
+
+ admin = Admin(
+ email="admin@example.com",
+ password_hash=bcrypt.generate_password_hash("testpassword123").decode("utf-8"),
+ )
+ db.session.add(admin)
+ db.session.commit()
+ return admin
diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/integration/test_setup.py b/tests/integration/test_setup.py
new file mode 100644
index 0000000..ac88baa
--- /dev/null
+++ b/tests/integration/test_setup.py
@@ -0,0 +1,219 @@
+"""Integration tests for Story 1.1: Initial Admin Setup."""
+
+from src.app import bcrypt
+from src.models import Admin
+
+
+class TestInitialAdminSetup:
+ """Test cases for initial admin setup flow (Story 1.1)."""
+
+ def test_setup_screen_appears_on_first_access(self, client, db): # noqa: ARG002
+ """Test that setup screen appears when no admin exists.
+
+ Acceptance Criteria:
+ - Setup screen appears on first application access
+ """
+ # Access root path - should redirect to setup
+ response = client.get("/")
+ assert response.status_code == 302
+ assert "/setup" in response.location
+
+ # Accessing setup directly should show setup form
+ response = client.get("/setup", follow_redirects=False)
+ assert response.status_code == 200
+ assert b"Admin Setup" in response.data or b"Create Admin" in response.data
+ assert b"email" in response.data.lower()
+ assert b"password" in response.data.lower()
+
+ def test_setup_requires_email_and_password(self, client, db): # noqa: ARG002
+ """Test that setup form requires email and password.
+
+ Acceptance Criteria:
+ - Requires email address and password
+ """
+ response = client.get("/setup")
+ assert response.status_code == 200
+
+ # Check form has email and password fields
+ assert b'type="email"' in response.data or b'name="email"' in response.data
+ assert (
+ b'type="password"' in response.data or b'name="password"' in response.data
+ )
+
+ def test_setup_with_valid_data_creates_admin(self, client, db):
+ """Test that valid setup data creates admin account.
+
+ Acceptance Criteria:
+ - After setup, admin account is created
+ """
+ response = client.post(
+ "/setup",
+ data={
+ "email": "admin@example.com",
+ "password": "validpassword123",
+ "password_confirm": "validpassword123",
+ },
+ follow_redirects=False,
+ )
+
+ # Should redirect after successful setup
+ assert response.status_code == 302
+
+ # Verify admin was created
+ admin = db.session.query(Admin).filter_by(email="admin@example.com").first()
+ assert admin is not None
+ assert admin.email == "admin@example.com"
+
+ # Verify password was hashed
+ assert admin.password_hash is not None
+ assert admin.password_hash != "validpassword123"
+ assert bcrypt.check_password_hash(admin.password_hash, "validpassword123")
+
+ def test_setup_validates_password_minimum_length(self, client, db):
+ """Test that password must meet minimum length requirement.
+
+ Acceptance Criteria:
+ - Password must meet minimum security requirements (12 characters)
+ """
+ # Try with password too short
+ response = client.post(
+ "/setup",
+ data={
+ "email": "admin@example.com",
+ "password": "short", # Less than 12 characters
+ "password_confirm": "short",
+ },
+ follow_redirects=True,
+ )
+
+ # Should show error and not create admin
+ assert (
+ b"at least 12 characters" in response.data.lower()
+ or b"too short" in response.data.lower()
+ )
+
+ # Verify no admin was created
+ admin_count = db.session.query(Admin).count()
+ assert admin_count == 0
+
+ def test_setup_validates_password_confirmation(self, client, db):
+ """Test that password confirmation must match.
+
+ Acceptance Criteria:
+ - Password confirmation must match password
+ """
+ response = client.post(
+ "/setup",
+ data={
+ "email": "admin@example.com",
+ "password": "validpassword123",
+ "password_confirm": "differentpassword123",
+ },
+ follow_redirects=True,
+ )
+
+ # Should show error about passwords not matching
+ assert b"match" in response.data.lower() or b"same" in response.data.lower()
+
+ # Verify no admin was created
+ admin_count = db.session.query(Admin).count()
+ assert admin_count == 0
+
+ def test_setup_validates_email_format(self, client, db):
+ """Test that email must be valid format.
+
+ Acceptance Criteria:
+ - Email address must be valid format
+ """
+ response = client.post(
+ "/setup",
+ data={
+ "email": "not-a-valid-email",
+ "password": "validpassword123",
+ "password_confirm": "validpassword123",
+ },
+ follow_redirects=True,
+ )
+
+ # Should show error about invalid email
+ assert (
+ b"valid email" in response.data.lower()
+ or b"invalid email" in response.data.lower()
+ )
+
+ # Verify no admin was created
+ admin_count = db.session.query(Admin).count()
+ assert admin_count == 0
+
+ def test_setup_logs_in_admin_after_creation(self, client, db): # noqa: ARG002
+ """Test that user is logged in after successful setup.
+
+ Acceptance Criteria:
+ - After setup, user is logged in as admin
+ """
+ response = client.post(
+ "/setup",
+ data={
+ "email": "admin@example.com",
+ "password": "validpassword123",
+ "password_confirm": "validpassword123",
+ },
+ follow_redirects=True,
+ )
+
+ # Should redirect to admin dashboard
+ assert response.status_code == 200
+ assert (
+ b"dashboard" in response.data.lower() or b"admin" in response.data.lower()
+ )
+
+ # Verify session is set (check that we can access admin routes)
+ response = client.get("/admin/dashboard", follow_redirects=False)
+ # 404 is OK if route not implemented yet
+ assert response.status_code in (200, 404)
+
+ def test_setup_not_accessible_after_admin_exists(
+ self,
+ client,
+ db,
+ admin, # noqa: ARG002
+ ):
+ """Test that setup page returns 404 after admin exists.
+
+ Acceptance Criteria:
+ - Setup screen is not accessible after initial admin creation
+ """
+ # Try to access setup when admin already exists
+ response = client.get("/setup", follow_redirects=False)
+ assert response.status_code == 404
+
+ # Try to POST to setup when admin already exists
+ response = client.post(
+ "/setup",
+ data={
+ "email": "another@example.com",
+ "password": "validpassword123",
+ "password_confirm": "validpassword123",
+ },
+ follow_redirects=False,
+ )
+ assert response.status_code == 404
+
+ # Verify no second admin was created
+ admin_count = db.session.query(Admin).count()
+ assert admin_count == 1
+
+ def test_root_redirects_to_admin_dashboard_when_admin_exists(
+ self,
+ client,
+ db, # noqa: ARG002
+ admin, # noqa: ARG002
+ ):
+ """Test that root path redirects normally when admin exists.
+
+ Once setup is complete, the root path should not redirect to setup.
+ """
+ response = client.get("/", follow_redirects=False)
+
+ # Should either render landing page or redirect to login (not to setup)
+ assert "/setup" not in (response.location or "")
diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py
new file mode 100644
index 0000000..e69de29