spawner/models.py
XPS\Micro 79cf304ccf feat: implement multi-container MVP with dev and prod templates
Add full support for 2 container types (development and production):

Backend Changes:
- New UserContainer model with unique constraint on (user_id, container_type)
- Removed single-container fields from User model (container_id, container_port)
- Added CONTAINER_TEMPLATES config with dev and prod templates
- Implemented spawn_multi_container() method in ContainerManager
- Added 2 new API endpoints:
  * GET /api/user/containers - list all containers with status
  * POST /api/container/launch/<type> - on-demand container creation
- Multi-container container names and Traefik routing with type suffix

Frontend Changes:
- New Container, ContainersResponse, LaunchResponse types
- Implemented getUserContainers() and launchContainer() API functions
- Completely redesigned dashboard with 2 container cards
- Status display with icons for each container type
- "Create & Open" and "Open Service" buttons based on container status
- Responsive grid layout

Templates:
- user-template-next already configured with Tailwind CSS and Shadcn/UI

Documentation:
- Added IMPLEMENTATION_SUMMARY.md with complete feature list
- Added TEST_VERIFICATION.md with detailed testing guide
- Updated .env.example with USER_TEMPLATE_IMAGE_DEV/PROD variables

This MVP allows each user to manage 2 distinct containers with:
- On-demand lazy creation
- Status tracking per container
- Unique URLs: /{slug}-dev and /{slug}-prod
- Proper Traefik routing with StripPrefix middleware

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-31 20:33:07 +01:00

132 lines
5.2 KiB
Python

from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from datetime import datetime
from enum import Enum
db = SQLAlchemy()
class UserState(Enum):
"""Benutzer-Status fuer Email-Verifizierung und Aktivitaet"""
REGISTERED = 'registered' # Signup abgeschlossen, Email nicht verifiziert
VERIFIED = 'verified' # Email verifiziert, Container noch nie genutzt
ACTIVE = 'active' # Container mindestens einmal gestartet
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(120), unique=True, nullable=False)
slug = db.Column(db.String(12), unique=True, nullable=False, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Admin-Felder
is_admin = db.Column(db.Boolean, default=False, nullable=False)
# Sperr-Felder
is_blocked = db.Column(db.Boolean, default=False, nullable=False)
blocked_at = db.Column(db.DateTime, nullable=True)
blocked_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
# Email-Verifizierung und Status
state = db.Column(db.String(20), default=UserState.REGISTERED.value, nullable=False)
# Aktivitaetstracking
last_used = db.Column(db.DateTime, nullable=True)
# Beziehung fuer blocked_by
blocker = db.relationship('User', remote_side=[id], foreign_keys=[blocked_by])
# Multi-Container Support
containers = db.relationship('UserContainer', back_populates='user', cascade='all, delete-orphan')
def to_dict(self):
"""Konvertiert User zu Dictionary fuer API-Responses"""
return {
'id': self.id,
'email': self.email,
'slug': self.slug,
'is_admin': self.is_admin,
'is_blocked': self.is_blocked,
'blocked_at': self.blocked_at.isoformat() if self.blocked_at else None,
'state': self.state,
'last_used': self.last_used.isoformat() if self.last_used else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'container_id': self.container_id
}
class MagicLinkToken(db.Model):
"""Magic Link Tokens für Passwordless Authentication"""
__tablename__ = 'magic_link_token'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
token = db.Column(db.String(64), unique=True, nullable=False, index=True)
token_type = db.Column(db.String(20), nullable=False) # 'signup' oder 'login'
created_at = db.Column(db.DateTime, default=datetime.utcnow)
expires_at = db.Column(db.DateTime, nullable=False)
used_at = db.Column(db.DateTime, nullable=True)
ip_address = db.Column(db.String(45), nullable=True)
user = db.relationship('User', backref=db.backref('magic_tokens', lazy=True))
def is_valid(self):
"""Prüft ob Token noch gültig ist"""
if self.used_at is not None:
return False # Token bereits verwendet
if datetime.utcnow() > self.expires_at:
return False # Token abgelaufen
return True
def mark_as_used(self):
"""Markiert Token als verwendet"""
self.used_at = datetime.utcnow()
class UserContainer(db.Model):
"""Multi-Container pro User (dev und prod)"""
__tablename__ = 'user_container'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
container_type = db.Column(db.String(50), nullable=False) # 'dev' oder 'prod'
container_id = db.Column(db.String(100), unique=True)
container_port = db.Column(db.Integer)
template_image = db.Column(db.String(200), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_used = db.Column(db.DateTime)
# Relationship
user = db.relationship('User', back_populates='containers')
# Unique: Ein User kann nur einen Container pro Typ haben
__table_args__ = (
db.UniqueConstraint('user_id', 'container_type', name='uq_user_container_type'),
)
def to_dict(self):
"""Konvertiert UserContainer zu Dictionary"""
return {
'id': self.id,
'user_id': self.user_id,
'container_type': self.container_type,
'container_id': self.container_id,
'container_port': self.container_port,
'template_image': self.template_image,
'created_at': self.created_at.isoformat() if self.created_at else None,
'last_used': self.last_used.isoformat() if self.last_used else None
}
class AdminTakeoverSession(db.Model):
"""Protokolliert Admin-Zugriffe auf User-Container (Phase 2)"""
id = db.Column(db.Integer, primary_key=True)
admin_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
target_user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
started_at = db.Column(db.DateTime, default=datetime.utcnow)
ended_at = db.Column(db.DateTime, nullable=True)
reason = db.Column(db.String(500), nullable=True)
admin = db.relationship('User', foreign_keys=[admin_id])
target_user = db.relationship('User', foreign_keys=[target_user_id])