mirror of
https://github.com/wizarrrr/wizarr.git
synced 2026-08-02 00:06:25 -04:00
313 lines
14 KiB
Python
313 lines
14 KiB
Python
from datetime import datetime, timezone
|
||
from .extensions import db
|
||
from flask_login import UserMixin
|
||
|
||
invite_libraries = db.Table(
|
||
"invite_library",
|
||
db.Column("invite_id", db.Integer, db.ForeignKey("invitation.id"), primary_key=True),
|
||
db.Column("library_id", db.Integer, db.ForeignKey("library.id"), primary_key=True),
|
||
)
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# New association table to enable multi-server invitations (2025-06)
|
||
invitation_servers = db.Table(
|
||
"invitation_server",
|
||
db.Column("invite_id", db.Integer, db.ForeignKey("invitation.id"), primary_key=True),
|
||
db.Column("server_id", db.Integer, db.ForeignKey("media_server.id"), primary_key=True),
|
||
# Track per-server usage so a single invite can be consumed independently
|
||
db.Column("used", db.Boolean, default=False, nullable=False),
|
||
db.Column("used_at", db.DateTime, nullable=True),
|
||
)
|
||
|
||
|
||
class Invitation(db.Model):
|
||
__tablename__ = 'invitation'
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
code = db.Column(db.String, nullable=False)
|
||
used = db.Column(db.Boolean, default=False, nullable=False)
|
||
used_at = db.Column(db.DateTime, nullable=True)
|
||
created = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
||
used_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||
used_by = db.relationship('User', backref=db.backref('invitations', lazy=True))
|
||
expires = db.Column(db.DateTime, nullable=True)
|
||
unlimited = db.Column(db.Boolean, nullable=True)
|
||
duration = db.Column(db.String, nullable=True)
|
||
specific_libraries = db.Column(db.String, nullable=True)
|
||
plex_allow_sync = db.Column(db.Boolean, default=False, nullable=True)
|
||
plex_home = db.Column(db.Boolean, default=False, nullable=True)
|
||
plex_allow_channels = db.Column(db.Boolean, default=False, nullable=True)
|
||
server_id = db.Column(db.Integer, db.ForeignKey('media_server.id'), nullable=True)
|
||
server = db.relationship('MediaServer', backref=db.backref('primary_invites', lazy=True))
|
||
|
||
libraries = db.relationship(
|
||
"Library",
|
||
secondary=invite_libraries,
|
||
back_populates="invites",
|
||
)
|
||
|
||
# Link to one or many MediaServer rows (multi-server invites)
|
||
servers = db.relationship(
|
||
"MediaServer",
|
||
secondary=invitation_servers,
|
||
back_populates="invites",
|
||
)
|
||
|
||
# ── NEW: link invitation to an explicit wizard bundle ────────────
|
||
wizard_bundle_id = db.Column(db.Integer, db.ForeignKey("wizard_bundle.id"), nullable=True)
|
||
wizard_bundle = db.relationship(
|
||
"WizardBundle", backref=db.backref("invitations", lazy=True)
|
||
)
|
||
|
||
# ── NEW: Jellyfin invite toggles ───────────────────────────────
|
||
jellyfin_allow_downloads = db.Column(db.Boolean, default=False, nullable=True)
|
||
jellyfin_allow_live_tv = db.Column(db.Boolean, default=False, nullable=True)
|
||
|
||
|
||
class Settings(db.Model):
|
||
__tablename__ = 'settings'
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
key = db.Column(db.String, unique=True, nullable=False)
|
||
value = db.Column(db.String, nullable=True)
|
||
|
||
|
||
class User(db.Model, UserMixin):
|
||
__tablename__ = 'user'
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
token = db.Column(db.String, nullable=False)
|
||
username = db.Column(db.String, nullable=False)
|
||
email = db.Column(db.String, nullable=True)
|
||
code = db.Column(db.String, nullable=False)
|
||
photo = db.Column(db.String, nullable=True)
|
||
expires = db.Column(db.DateTime, nullable=True)
|
||
server_id = db.Column(db.Integer, db.ForeignKey('media_server.id'), nullable=True)
|
||
server = db.relationship('MediaServer', backref=db.backref('users', lazy=True))
|
||
identity_id = db.Column(db.Integer, db.ForeignKey('identity.id'), nullable=True)
|
||
identity = db.relationship('Identity', backref=db.backref('accounts', lazy=True))
|
||
|
||
# ───────────────────────────────────────────────────────────────────────────────
|
||
# Multi-admin support (2025-07)
|
||
# ───────────────────────────────────────────────────────────────────────────────
|
||
class AdminAccount(db.Model, UserMixin):
|
||
"""Dedicated model for administrator accounts.
|
||
|
||
Replaces the legacy single-admin credentials that were stored as plain
|
||
settings rows (``admin_username`` / ``admin_password``). Each admin has a
|
||
unique *username* and a hashed *password* (scrypt). Because we inherit
|
||
:class:`flask_login.UserMixin`, instances can be returned directly from
|
||
``login_user`` / ``user_loader``.
|
||
"""
|
||
|
||
__tablename__ = "admin_account"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
username = db.Column(db.String, unique=True, nullable=False)
|
||
password_hash = db.Column(db.String, nullable=False)
|
||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
||
|
||
# ── helpers ────────────────────────────────────────────────────────────
|
||
def set_password(self, raw_password: str):
|
||
"""Hash *raw_password* with *scrypt* and store it."""
|
||
from werkzeug.security import generate_password_hash # local import to avoid circular
|
||
|
||
self.password_hash = generate_password_hash(raw_password, "scrypt")
|
||
|
||
def check_password(self, raw_password: str) -> bool:
|
||
"""Validate *raw_password* against the stored *password_hash*."""
|
||
from werkzeug.security import check_password_hash # local import to avoid circular
|
||
|
||
return check_password_hash(self.password_hash, raw_password)
|
||
|
||
|
||
class Notification(db.Model):
|
||
__tablename__ = 'notification'
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
name = db.Column(db.String, nullable=False)
|
||
type = db.Column(db.String, nullable=False)
|
||
url = db.Column(db.String, nullable=False)
|
||
username = db.Column(db.String, nullable=True)
|
||
password = db.Column(db.String, nullable=True)
|
||
|
||
|
||
class AdminUser(UserMixin):
|
||
id = "admin"
|
||
|
||
@property
|
||
def username(self):
|
||
return Settings.query.filter_by(key="admin_username").first().value
|
||
|
||
|
||
class MediaServer(db.Model):
|
||
__tablename__ = 'media_server'
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
name = db.Column(db.String, nullable=False)
|
||
server_type = db.Column(db.String, nullable=False) # plex, jellyfin, emby, etc.
|
||
url = db.Column(db.String, nullable=False)
|
||
api_key = db.Column(db.String, nullable=True)
|
||
external_url = db.Column(db.String, nullable=True) # Optional public address
|
||
|
||
# Plex‐specific toggles (ignored by other server types)
|
||
allow_downloads_plex = db.Column(db.Boolean, default=False, nullable=False)
|
||
allow_tv_plex = db.Column(db.Boolean, default=False, nullable=False)
|
||
|
||
# Jellyfin-specific toggles (ignored by other server types)
|
||
allow_downloads_jellyfin = db.Column(db.Boolean, default=False, nullable=False)
|
||
allow_tv_jellyfin = db.Column(db.Boolean, default=False, nullable=False)
|
||
|
||
# Whether the connection credentials were validated successfully
|
||
verified = db.Column(db.Boolean, default=False, nullable=False)
|
||
|
||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
||
|
||
# Reverse relationship for multi-server invites
|
||
invites = db.relationship(
|
||
"Invitation",
|
||
secondary=invitation_servers,
|
||
back_populates="servers",
|
||
)
|
||
|
||
|
||
class Library(db.Model):
|
||
__tablename__ = "library"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
external_id = db.Column(db.String, nullable=False) # e.g. Plex folder ID
|
||
name = db.Column(db.String, nullable=False)
|
||
enabled = db.Column(db.Boolean, default=True, nullable=False)
|
||
server_id = db.Column(db.Integer, db.ForeignKey('media_server.id'), nullable=True)
|
||
server = db.relationship('MediaServer', backref=db.backref('libraries', lazy=True))
|
||
|
||
# backref gives Invitation.libraries automatically
|
||
invites = db.relationship(
|
||
"Invitation",
|
||
secondary=invite_libraries,
|
||
back_populates="libraries",
|
||
)
|
||
|
||
__table_args__ = (
|
||
db.UniqueConstraint("external_id", "server_id", name="uq_library_external_server"),
|
||
)
|
||
|
||
|
||
class Identity(db.Model):
|
||
__tablename__ = 'identity'
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
primary_email = db.Column(db.String, nullable=True)
|
||
primary_username = db.Column(db.String, nullable=True)
|
||
nickname = db.Column(db.String, nullable=True)
|
||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
||
|
||
|
||
class WizardStep(db.Model):
|
||
"""Markdown wizard page stored in the database instead of loose files.
|
||
|
||
Each *server_type* (plex, jellyfin, …) owns an ordered list of steps with
|
||
an integer *position* starting at 0. A `(server_type, position)` unique
|
||
constraint guarantees a stable order without gaps.
|
||
"""
|
||
__tablename__ = "wizard_step"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
|
||
# Target backend this step is meant for (plex / emby / etc.)
|
||
server_type = db.Column(db.String, nullable=False)
|
||
|
||
# Sort index within the server group – lower numbers appear first
|
||
position = db.Column(db.Integer, nullable=False)
|
||
|
||
# Optional page title – if omitted we will derive it from the first H1 in
|
||
# the markdown when serving the wizard.
|
||
title = db.Column(db.String, nullable=True)
|
||
|
||
# Markdown source (front-end will render client-side preview)
|
||
markdown = db.Column(db.Text, nullable=False)
|
||
|
||
# List of setting keys that must evaluate to truthy for the step to show.
|
||
# Mirrors the existing `requires:` front-matter array in the legacy files.
|
||
requires = db.Column(db.JSON, nullable=True)
|
||
|
||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
||
updated_at = db.Column(
|
||
db.DateTime,
|
||
default=lambda: datetime.now(timezone.utc),
|
||
onupdate=lambda: datetime.now(timezone.utc),
|
||
nullable=False,
|
||
)
|
||
|
||
__table_args__ = (
|
||
db.UniqueConstraint("server_type", "position", name="uq_step_server_pos"),
|
||
)
|
||
|
||
# ── convenience helpers ─────────────────────────────────────────────
|
||
def to_dict(self):
|
||
"""Return serialisable representation (for JSON responses)."""
|
||
return {
|
||
"id": self.id,
|
||
"server_type": self.server_type,
|
||
"position": self.position,
|
||
"title": self.title,
|
||
"markdown": self.markdown,
|
||
"requires": self.requires or [],
|
||
}
|
||
|
||
|
||
# ───────────────────────────────────────────────────────────────────────────────
|
||
# New models powering custom "Wizard Bundles" (2025-07)
|
||
# ───────────────────────────────────────────────────────────────────────────────
|
||
class WizardBundle(db.Model):
|
||
"""A named collection of WizardStep pages shown in fixed order."""
|
||
|
||
__tablename__ = "wizard_bundle"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
name = db.Column(db.String, nullable=False)
|
||
description = db.Column(db.String, nullable=True)
|
||
|
||
# Ordered list of steps belonging to this bundle
|
||
steps = db.relationship(
|
||
"WizardBundleStep",
|
||
back_populates="bundle",
|
||
cascade="all, delete-orphan",
|
||
order_by="WizardBundleStep.position",
|
||
)
|
||
|
||
|
||
class WizardBundleStep(db.Model):
|
||
"""Mapping table assigning a WizardStep to a Bundle at a given position."""
|
||
|
||
__tablename__ = "wizard_bundle_step"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
bundle_id = db.Column(
|
||
db.Integer, db.ForeignKey("wizard_bundle.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
step_id = db.Column(
|
||
db.Integer, db.ForeignKey("wizard_step.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
position = db.Column(db.Integer, nullable=False)
|
||
|
||
# Relationships
|
||
bundle = db.relationship("WizardBundle", back_populates="steps")
|
||
step = db.relationship("WizardStep")
|
||
|
||
__table_args__ = (
|
||
db.UniqueConstraint("bundle_id", "position", name="uq_bundle_pos"),
|
||
)
|
||
|
||
|
||
class WebAuthnCredential(db.Model):
|
||
"""WebAuthn credential storage for passkey authentication."""
|
||
|
||
__tablename__ = "webauthn_credential"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
admin_account_id = db.Column(db.Integer, db.ForeignKey("admin_account.id"), nullable=False)
|
||
credential_id = db.Column(db.LargeBinary, nullable=False, unique=True)
|
||
public_key = db.Column(db.LargeBinary, nullable=False)
|
||
sign_count = db.Column(db.Integer, default=0, nullable=False)
|
||
name = db.Column(db.String, nullable=False)
|
||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
||
last_used_at = db.Column(db.DateTime, nullable=True)
|
||
|
||
admin_account = db.relationship("AdminAccount", backref=db.backref("webauthn_credentials", lazy=True, cascade="all, delete-orphan"))
|