This commit is contained in:
2025-08-10 12:43:48 +02:00
parent 5797ac22c8
commit d4b811dbad
31 changed files with 2004 additions and 172 deletions

View File

3
settingspanel/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
settingspanel/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class SettingspanelConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'settingspanel'

33
settingspanel/forms.py Normal file
View File

@@ -0,0 +1,33 @@
from django import forms
WIDE = {"class": "input-wide"}
class ArrSettingsForm(forms.Form):
sonarr_url = forms.URLField(label="Sonarr URL", required=False,
widget=forms.URLInput(attrs=WIDE))
sonarr_api_key = forms.CharField(label="Sonarr API Key", required=False,
widget=forms.PasswordInput(render_value=True, attrs=WIDE))
radarr_url = forms.URLField(label="Radarr URL", required=False,
widget=forms.URLInput(attrs=WIDE))
radarr_api_key = forms.CharField(label="Radarr API Key", required=False,
widget=forms.PasswordInput(render_value=True, attrs=WIDE))
class MailSettingsForm(forms.Form):
mail_host = forms.CharField(label="Mail Host", required=False)
mail_port = forms.IntegerField(label="Mail Port", required=False, min_value=1, max_value=65535)
mail_secure = forms.ChoiceField(
label="Sicherheit", required=False,
choices=[("", "Kein TLS/SSL"), ("starttls", "STARTTLS"), ("ssl", "SSL/TLS")]
)
mail_user = forms.CharField(label="Mail Benutzer", required=False)
mail_password = forms.CharField(
label="Mail Passwort", required=False,
widget=forms.PasswordInput(render_value=True)
)
mail_from = forms.EmailField(label="Absender (From)", required=False)
class AccountForm(forms.Form):
username = forms.CharField(label="Benutzername", required=False)
email = forms.EmailField(label="E-Mail", required=False)
new_password = forms.CharField(label="Neues Passwort", required=False, widget=forms.PasswordInput)
repeat_password = forms.CharField(label="Passwort wiederholen", required=False, widget=forms.PasswordInput)

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.2.5 on 2025-08-08 23:24
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='AppSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('singleton_id', models.PositiveSmallIntegerField(default=1, editable=False, unique=True)),
('sonarr_url', models.URLField(blank=True, null=True)),
('sonarr_api_key', models.CharField(blank=True, max_length=255, null=True)),
('radarr_url', models.URLField(blank=True, null=True)),
('radarr_api_key', models.CharField(blank=True, max_length=255, null=True)),
('mail_host', models.CharField(blank=True, max_length=255, null=True)),
('mail_port', models.PositiveIntegerField(blank=True, null=True)),
('mail_secure', models.CharField(blank=True, choices=[('', 'Kein TLS/SSL'), ('starttls', 'STARTTLS'), ('ssl', 'SSL/TLS')], max_length=10, null=True)),
('mail_user', models.CharField(blank=True, max_length=255, null=True)),
('mail_password', models.CharField(blank=True, max_length=255, null=True)),
('mail_from', models.EmailField(blank=True, max_length=254, null=True)),
('acc_username', models.CharField(blank=True, max_length=150, null=True)),
('acc_email', models.EmailField(blank=True, max_length=254, null=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
]

View File

36
settingspanel/models.py Normal file
View File

@@ -0,0 +1,36 @@
from django.db import models
class AppSettings(models.Model):
# Singleton-Pattern über feste ID
singleton_id = models.PositiveSmallIntegerField(default=1, unique=True, editable=False)
# Arr
sonarr_url = models.URLField(blank=True, null=True)
sonarr_api_key = models.CharField(max_length=255, blank=True, null=True)
radarr_url = models.URLField(blank=True, null=True)
radarr_api_key = models.CharField(max_length=255, blank=True, null=True)
# Mail
mail_host = models.CharField(max_length=255, blank=True, null=True)
mail_port = models.PositiveIntegerField(blank=True, null=True)
mail_secure = models.CharField(
max_length=10, blank=True, null=True,
choices=(("", "Kein TLS/SSL"), ("starttls", "STARTTLS"), ("ssl", "SSL/TLS"))
)
mail_user = models.CharField(max_length=255, blank=True, null=True)
mail_password = models.CharField(max_length=255, blank=True, null=True)
mail_from = models.EmailField(blank=True, null=True)
# „Account“
acc_username = models.CharField(max_length=150, blank=True, null=True)
acc_email = models.EmailField(blank=True, null=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return "AppSettings"
@classmethod
def current(cls):
obj, _ = cls.objects.get_or_create(singleton_id=1)
return obj

View File

@@ -0,0 +1,350 @@
{% load static %}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Einstellungen Subscribarr</title>
<style>
:root {
--bg: #0b0b10;
--panel: #12121a;
--panel-b: #1f2030;
--accent: #3b82f6;
--muted: #9aa0b4;
--text: #e6e6e6;
}
* {
box-sizing: border-box
}
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, Segoe UI, Roboto, Arial, sans-serif;
margin: 0
}
.wrap {
max-width: 1000px;
margin: 0 auto;
padding: 16px
}
a {
color: #cfd3ea;
text-decoration: none
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px
}
.btn {
padding: 10px 14px;
border-radius: 10px;
border: 1px solid #2a2a34;
background: #111119;
color: #fff;
cursor: pointer
}
.btn-primary {
background: var(--accent);
border-color: transparent
}
.grid {
display: grid;
gap: 16px
}
@media(min-width:900px) {
.grid {
grid-template-columns: 1fr 1fr
}
}
.card {
background: var(--panel);
border: 1px solid var(--panel-b);
border-radius: 12px;
padding: 14px
}
.card h2 {
margin: 0 0 10px;
font-size: 1.05rem
}
.row {
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
/* <= statt 160px 1fr */
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.row label {
color: #c9cbe3
}
.row input,
.row select {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #2a2a34;
background: #111119;
color: var(--text)
}
.help {
color: var(--muted);
font-size: .9rem
}
.msgs {
margin-bottom: 10px
}
.msg {
background: #0f1425;
border: 1px solid #283058;
border-radius: 10px;
padding: 10px;
margin-bottom: 8px
}
.input-wide {
width: 100% !important;
max-width: 100%;
min-width: 0;
display: block;
}
.row input,
.row select,
.row textarea {
width: 100%;
max-width: 100%;
min-width: 0;
display: block;
}
/* falls du passwort/URL Felder extra stylen willst, gleicher Fix */
.inline>input,
.inline>.django-url,
/* falls Widget eine Klasse rendert */
.inline>.django-password {
min-width: 0;
width: 100%;
}
.inline-actions {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 220px;
justify-content: flex-end;
}
.btn {
padding: 10px 14px;
border-radius: 10px;
border: 1px solid #2a2a34;
background: #111119;
color: #fff;
cursor: pointer;
}
.btn:disabled {
opacity: .6;
cursor: default;
}
.badge {
padding: 6px 10px;
border-radius: 999px;
font-size: .85rem;
border: 1px solid #2a2a34;
background: #111119;
color: #cfd3ea;
white-space: nowrap;
/* eine Zeile */
max-width: 140px;
/* begrenzt die Breite */
overflow: hidden;
text-overflow: ellipsis;
/* falls doch Text drin ist */
}
.badge.ok {
border-color: #1f6f3a;
background: #10331f;
color: #a7e3bd;
}
.badge.err {
border-color: #6f1f2a;
background: #341016;
color: #f1a3b0;
}
.badge.muted {
opacity: .8;
}
</style>
</head>
<body>
<div class="wrap">
<div class="topbar">
<div><a href="/" class="btn">← Zurück</a></div>
<div><strong>Einstellungen</strong></div>
<div></div>
</div>
{% if messages %}
<div class="msgs">{% for m in messages %}<div class="msg">{{ m }}</div>{% endfor %}</div>
{% endif %}
<form method="post">
{% csrf_token %}
<div class="grid">
<div class="card">
<h2>Sonarr & Radarr</h2>
<div class="row">
<label>Sonarr URL</label>
<div class="inline">
<div class="field">{{ arr_form.sonarr_url }}</div>
<div class="inline-actions">
<button class="btn" type="button" onclick="testConnection('sonarr', this)">Test
Sonarr</button>
<span id="sonarrStatus" class="badge muted"></span>
</div>
</div>
</div>
<div class="row">
<label>Sonarr API Key</label>
{{ arr_form.sonarr_api_key }}
</div>
<div class="row">
<label>Radarr URL</label>
<div class="inline">
<div class="field">{{ arr_form.radarr_url }}</div>
<div class="inline-actions">
<button class="btn" type="button" onclick="testConnection('radarr', this)">Test
Radarr</button>
<span id="radarrStatus" class="badge muted"></span>
</div>
</div>
</div>
<div class="row">
<label>Radarr API Key</label>
{{ arr_form.radarr_api_key }}
</div>
<div class="help">Klicke „Test …“, um die Verbindung gegen <code>/api/v3/system/status</code> zu
prüfen.</div>
</div>
<div class="card">
<h2>Mailserver</h2>
<div class="row"><label>Host</label>{{ mail_form.mail_host }}</div>
<div class="row"><label>Port</label>{{ mail_form.mail_port }}</div>
<div class="row"><label>Sicherheit</label>{{ mail_form.mail_secure }}</div>
<div class="row"><label>Benutzer</label>{{ mail_form.mail_user }}</div>
<div class="row"><label>Passwort</label>{{ mail_form.mail_password }}</div>
<div class="row"><label>Absender</label>{{ mail_form.mail_from }}</div>
</div>
<div class="card">
<h2>Konto</h2>
<div class="row"><label>Benutzername</label>{{ account_form.username }}</div>
<div class="row"><label>E-Mail</label>{{ account_form.email }}</div>
<div class="row"><label>Neues Passwort</label>{{ account_form.new_password }}</div>
<div class="row"><label>Passwort wiederholen</label>{{ account_form.repeat_password }}</div>
<div class="help">Nur Oberfläche Umsetzung Passwortänderung später.</div>
</div>
</div>
<div style="margin-top:16px">
<button class="btn btn-primary" type="submit">Speichern</button>
</div>
</form>
</div>
<script>
function testConnection(kind) {
const url = document.querySelector(`input[name="${kind}_url"]`).value;
const key = document.querySelector(`input[name="${kind}_api_key"]`).value;
fetch(`/settings/test-connection/?kind=${kind}&url=${encodeURIComponent(url)}&key=${encodeURIComponent(key)}`)
.then(r => r.json())
.then(data => {
if (data.ok) {
alert(kind + " Verbindung erfolgreich!");
} else {
alert(kind + " Fehler: " + data.error);
}
})
.catch(err => alert(kind + " Fehler: " + err));
}
function setBadge(kind, state, text, tooltip) {
const el = document.getElementById(kind + "Status");
if (!el) return;
el.classList.remove("ok", "err", "muted");
el.title = tooltip || ""; // voller Fehlertext im Tooltip
if (state === "ok") {
el.classList.add("ok");
el.textContent = "Verbunden";
} else if (state === "err") {
el.classList.add("err");
el.textContent = "Fehler";
} else {
el.classList.add("muted");
el.textContent = "—";
}
}
function testConnection(kind, btnEl) {
const urlEl = document.querySelector(`input[name="${kind}_url"]`);
const keyEl = document.querySelector(`input[name="${kind}_api_key"]`);
const url = urlEl ? urlEl.value.trim() : "";
const key = keyEl ? keyEl.value.trim() : "";
setBadge(kind, "muted", "Teste…");
if (btnEl) { btnEl.disabled = true; }
fetch(`/settings/test-connection/?kind=${encodeURIComponent(kind)}&url=${encodeURIComponent(url)}&key=${encodeURIComponent(key)}`)
.then(r => r.json())
.then(data => {
if (data.ok) {
setBadge(kind, "ok", "Verbunden", "");
} else {
setBadge(kind, "err", "Fehler", data.error || "Unbekannter Fehler");
}
})
.catch(err => setBadge(kind, "err", "Fehler", String(err)))
.finally(() => { if (btnEl) btnEl.disabled = false; });
}
</script>
</body>
</html>

3
settingspanel/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

8
settingspanel/urls.py Normal file
View File

@@ -0,0 +1,8 @@
from django.urls import path
from .views import SettingsView, test_connection
app_name = "settingspanel"
urlpatterns = [
path("", SettingsView.as_view(), name="index"),
path("test-connection/", test_connection, name="test_connection"),
]

83
settingspanel/views.py Normal file
View File

@@ -0,0 +1,83 @@
from django.views import View
from django.shortcuts import render, redirect
from django.contrib import messages
from .forms import ArrSettingsForm, MailSettingsForm, AccountForm
from .models import AppSettings
from django.http import JsonResponse
import requests
def test_connection(request):
kind = request.GET.get("kind") # "sonarr" | "radarr"
url = (request.GET.get("url") or "").strip()
key = (request.GET.get("key") or "").strip()
if kind not in ("sonarr", "radarr"):
return JsonResponse({"ok": False, "error": "Ungültiger Typ"}, status=400)
if not url or not key:
return JsonResponse({"ok": False, "error": "URL und API-Key erforderlich"}, status=400)
try:
r = requests.get(
f"{url.rstrip('/')}/api/v3/system/status",
headers={"X-Api-Key": key},
timeout=5
)
if r.status_code == 200:
return JsonResponse({"ok": True})
return JsonResponse({"ok": False, "error": f"HTTP {r.status_code}"})
except requests.RequestException as e:
return JsonResponse({"ok": False, "error": str(e)})
class SettingsView(View):
template_name = "settingspanel/settings.html"
def get(self, request):
cfg = AppSettings.current()
return render(request, self.template_name, {
"arr_form": ArrSettingsForm(initial={
"sonarr_url": cfg.sonarr_url or "",
"sonarr_api_key": cfg.sonarr_api_key or "",
"radarr_url": cfg.radarr_url or "",
"radarr_api_key": cfg.radarr_api_key or "",
}),
"mail_form": MailSettingsForm(initial={
"mail_host": cfg.mail_host or "",
"mail_port": cfg.mail_port or "",
"mail_secure": cfg.mail_secure or "",
"mail_user": cfg.mail_user or "",
"mail_password": cfg.mail_password or "",
"mail_from": cfg.mail_from or "",
}),
"account_form": AccountForm(initial={
"username": cfg.acc_username or "",
"email": cfg.acc_email or "",
}),
})
def post(self, request):
arr_form = ArrSettingsForm(request.POST)
mail_form = MailSettingsForm(request.POST)
acc_form = AccountForm(request.POST)
if not (arr_form.is_valid() and mail_form.is_valid() and acc_form.is_valid()):
return render(request, self.template_name, {
"arr_form": arr_form, "mail_form": mail_form, "account_form": acc_form
})
cfg = AppSettings.current()
cfg.sonarr_url = arr_form.cleaned_data["sonarr_url"] or None
cfg.sonarr_api_key = arr_form.cleaned_data["sonarr_api_key"] or None
cfg.radarr_url = arr_form.cleaned_data["radarr_url"] or None
cfg.radarr_api_key = arr_form.cleaned_data["radarr_api_key"] or None
cfg.mail_host = mail_form.cleaned_data["mail_host"] or None
cfg.mail_port = mail_form.cleaned_data["mail_port"] or None
cfg.mail_secure = mail_form.cleaned_data["mail_secure"] or ""
cfg.mail_user = mail_form.cleaned_data["mail_user"] or None
cfg.mail_password = mail_form.cleaned_data["mail_password"] or None
cfg.mail_from = mail_form.cleaned_data["mail_from"] or None
cfg.acc_username = acc_form.cleaned_data["username"] or None
cfg.acc_email = acc_form.cleaned_data["email"] or None
cfg.save()
messages.success(request, "Einstellungen gespeichert (DB).")
return redirect("settingspanel:index")