usermanagement/translation/calendar

This commit is contained in:
jschaufuss@leitwerk.de
2025-08-11 12:27:30 +02:00
parent 13a5286357
commit c2bb64d961
27 changed files with 998 additions and 240 deletions

View File

@@ -7,20 +7,20 @@ class FirstRunSetupForm(forms.Form):
jellyfin_server_url = forms.URLField(
label="Jellyfin Server URL",
required=True,
help_text="Die URL deines Jellyfin-Servers"
help_text="URL of your Jellyfin server"
)
jellyfin_api_key = forms.CharField(
label="Jellyfin API Key",
required=True,
widget=forms.PasswordInput(render_value=True),
help_text="Der API-Key aus den Jellyfin-Einstellungen"
help_text="API key from Jellyfin settings"
)
# Sonarr (Optional)
sonarr_url = forms.URLField(
label="Sonarr URL",
required=False,
help_text="Die URL deines Sonarr-Servers"
help_text="URL of your Sonarr server"
)
sonarr_api_key = forms.CharField(
label="Sonarr API Key",
@@ -32,7 +32,7 @@ class FirstRunSetupForm(forms.Form):
radarr_url = forms.URLField(
label="Radarr URL",
required=False,
help_text="Die URL deines Radarr-Servers"
help_text="URL of your Radarr server"
)
radarr_api_key = forms.CharField(
label="Radarr API Key",
@@ -45,13 +45,13 @@ class JellyfinSettingsForm(forms.Form):
label="Jellyfin Server URL",
required=False,
widget=forms.URLInput(attrs=WIDE),
help_text="z.B. http://localhost:8096"
help_text="e.g. http://localhost:8096"
)
jellyfin_api_key = forms.CharField(
label="Jellyfin API Key",
required=False,
widget=forms.PasswordInput(render_value=True, attrs=WIDE),
help_text="Admin API Key aus den Jellyfin Einstellungen"
help_text="Admin API key from Jellyfin settings"
)
class ArrSettingsForm(forms.Form):
@@ -68,18 +68,18 @@ 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")]
label="Security", required=False,
choices=[("", "No TLS/SSL"), ("starttls", "STARTTLS"), ("ssl", "SSL/TLS")]
)
mail_user = forms.CharField(label="Mail Benutzer", required=False)
mail_user = forms.CharField(label="Mail Username", required=False)
mail_password = forms.CharField(
label="Mail Passwort", required=False,
label="Mail Password", required=False,
widget=forms.PasswordInput(render_value=True)
)
mail_from = forms.EmailField(label="Absender (From)", required=False)
mail_from = forms.EmailField(label="Sender (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)
username = forms.CharField(label="Username", required=False)
email = forms.EmailField(label="Email", required=False)
new_password = forms.CharField(label="New password", required=False, widget=forms.PasswordInput)
repeat_password = forms.CharField(label="Repeat password", required=False, widget=forms.PasswordInput)

View File

@@ -1,7 +1,7 @@
from django.db import models
class AppSettings(models.Model):
# Singleton-Pattern über feste ID
# Singleton pattern via fixed ID
singleton_id = models.PositiveSmallIntegerField(default=1, unique=True, editable=False)
# Jellyfin
@@ -20,7 +20,7 @@ class AppSettings(models.Model):
mail_secure = models.CharField(
max_length=10, blank=True, null=True,
choices=(
("", "Kein TLS/SSL"),
("", "No TLS/SSL"),
("starttls", "STARTTLS (Port 587)"),
("ssl", "SSL/TLS (Port 465)"),
("tls", "TLS (alias STARTTLS)"),
@@ -30,7 +30,7 @@ class AppSettings(models.Model):
mail_password = models.CharField(max_length=255, blank=True, null=True)
mail_from = models.EmailField(blank=True, null=True)
# Account
# Account
acc_username = models.CharField(max_length=150, blank=True, null=True)
acc_email = models.EmailField(blank=True, null=True)

View File

@@ -7,28 +7,28 @@
{% block content %}
<div class="setup-container">
<h1>Willkommen bei Subscribarr</h1>
<p class="setup-intro">Lass uns deine Installation einrichten. Du brauchst mindestens einen Jellyfin-Server.</p>
<h1>Welcome to Subscribarr</h1>
<p class="setup-intro">Let's set up your installation. You need at least one Jellyfin server.</p>
<form method="post" class="setup-form">
{% csrf_token %}
<div class="setup-section">
<h2>Jellyfin Server (Erforderlich)</h2>
<h2>Jellyfin server (required)</h2>
<div class="form-group">
<label>Server URL</label>
{{ form.jellyfin_server_url }}
<div class="help">z.B. http://192.168.1.100:8096 oder http://jellyfin.local:8096</div>
<div class="help">e.g., http://192.168.1.100:8096 or http://jellyfin.local:8096</div>
</div>
<div class="form-group">
<label>API Key</label>
{{ form.jellyfin_api_key }}
<div class="help">Admin API Key aus den Jellyfin-Einstellungen</div>
<div class="help">Admin API key from Jellyfin settings</div>
</div>
</div>
<div class="setup-section">
<h2>Sonarr (Optional)</h2>
<h2>Sonarr (optional)</h2>
<div class="form-group">
<label>Server URL</label>
{{ form.sonarr_url }}
@@ -40,7 +40,7 @@
</div>
<div class="setup-section">
<h2>Radarr (Optional)</h2>
<h2>Radarr (optional)</h2>
<div class="form-group">
<label>Server URL</label>
{{ form.radarr_url }}
@@ -51,7 +51,7 @@
</div>
</div>
<button type="submit" class="setup-submit">Installation abschließen</button>
<button type="submit" class="setup-submit">Finish setup</button>
</form>
</div>
{% endblock %}

View File

@@ -1,19 +1,22 @@
{% load static %}
<!doctype html>
<html lang="de">
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Einstellungen Subscribarr</title>
<title>Settings Subscribarr</title>
<link rel="stylesheet" href="{% static 'css/settings.css' %}">
</head>
<body>
<div class="wrap">
<div class="topbar">
<div><a href="/" class="btn">← Zurück</a></div>
<div><strong>Einstellungen</strong></div>
<div style="display:flex; gap:8px; align-items:center;">
<a href="/" class="btn">← Back</a>
<a href="{% url 'settingspanel:subscriptions' %}" class="btn">👥 Subscriptions</a>
</div>
<div><strong>Settings</strong></div>
<div></div>
</div>
@@ -29,12 +32,12 @@
<div class="row">
<label>Jellyfin Server URL</label>
{{ jellyfin_form.jellyfin_server_url }}
<div class="help">z.B. http://localhost:8096</div>
<div class="help">e.g., http://localhost:8096</div>
</div>
<div class="row">
<label>Jellyfin API Key</label>
{{ jellyfin_form.jellyfin_api_key }}
<div class="help">Admin API Key aus den Jellyfin Einstellungen</div>
<div class="help">Admin API key from your Jellyfin settings</div>
</div>
</div>
@@ -46,8 +49,7 @@
<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>
<button class="btn" type="button" onclick="testConnection('sonarr', this)">Test Sonarr</button>
<span id="sonarrStatus" class="badge muted"></span>
</div>
</div>
@@ -64,8 +66,7 @@
<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>
<button class="btn" type="button" onclick="testConnection('radarr', this)">Test Radarr</button>
<span id="radarrStatus" class="badge muted"></span>
</div>
</div>
@@ -76,32 +77,31 @@
{{ 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 class="help">ClickTest …” to verify against <code>/api/v3/system/status</code>.</div>
</div>
<div class="card">
<h2>Mailserver</h2>
<h2>Mail server</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 class="row"><label>Security</label>{{ mail_form.mail_secure }}</div>
<div class="row"><label>User</label>{{ mail_form.mail_user }}</div>
<div class="row"><label>Password</label>{{ mail_form.mail_password }}</div>
<div class="row"><label>From</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>
<h2>Account</h2>
<div class="row"><label>Username</label>{{ account_form.username }}</div>
<div class="row"><label>Email</label>{{ account_form.email }}</div>
<div class="row"><label>New password</label>{{ account_form.new_password }}</div>
<div class="row"><label>Repeat password</label>{{ account_form.repeat_password }}</div>
<div class="help">Only UI implementing password change later.</div>
</div>
</div>
<div style="margin-top:16px">
<button class="btn btn-primary" type="submit">Speichern</button>
<button class="btn btn-primary" type="submit">Save</button>
</div>
</form>
</div>
@@ -116,25 +116,25 @@
.then(r => r.json())
.then(data => {
if (data.ok) {
alert(kind + " Verbindung erfolgreich!");
alert(kind + " connection successful!");
} else {
alert(kind + " Fehler: " + data.error);
alert(kind + " error: " + data.error);
}
})
.catch(err => alert(kind + " Fehler: " + err));
.catch(err => alert(kind + " error: " + 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
el.title = tooltip || ""; // full error text in tooltip
if (state === "ok") {
el.classList.add("ok");
el.textContent = "Verbunden";
el.textContent = "Connected";
} else if (state === "err") {
el.classList.add("err");
el.textContent = "Fehler";
el.textContent = "Error";
} else {
el.classList.add("muted");
el.textContent = "—";
@@ -147,19 +147,19 @@
const url = urlEl ? urlEl.value.trim() : "";
const key = keyEl ? keyEl.value.trim() : "";
setBadge(kind, "muted", "Teste…");
setBadge(kind, "muted", "Testing…");
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", "");
setBadge(kind, "ok", "Connected", "");
} else {
setBadge(kind, "err", "Fehler", data.error || "Unbekannter Fehler");
setBadge(kind, "err", "Error", data.error || "Unknown error");
}
})
.catch(err => setBadge(kind, "err", "Fehler", String(err)))
.catch(err => setBadge(kind, "err", "Error", String(err)))
.finally(() => { if (btnEl) btnEl.disabled = false; });
}

View File

@@ -0,0 +1,218 @@
{% extends "base.html" %}
{% block title %}Subscriptions Admin{% endblock %}
{% block extra_style %}
<style>
.wrap { max-width: 1200px; margin: 0 auto; padding: 16px; }
.filters { display:flex; gap:8px; margin: 10px 0 16px; }
.filters input { padding: 10px 12px; border-radius: 10px; border: 1px solid #2a2a34; background:#111119; color:#e6e6e6; min-width: 240px; }
.section { margin-top: 20px; }
.grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.card { background:#12121a; border:1px solid #1f2030; border-radius:12px; padding:12px; display:flex; gap:12px; }
.poster { width: 90px; height: 135px; border-radius:8px; overflow:hidden; background:#222233; flex:0 0 auto; }
.poster img { width:100%; height:100%; object-fit: cover; display:block; }
.meta { flex:1 1 auto; min-width:0; }
.title { font-weight:600; margin-bottom:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.muted { color:#9aa0b4; font-size:.9rem; }
.user { font-weight:600; }
/* user list */
.users { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap:10px; }
.user-item { background:#10121b; border:1px solid #1f2030; border-radius:10px; padding:10px 12px; display:flex; justify-content:space-between; align-items:center; cursor:pointer; transition: transform .08s ease, border-color .12s ease; }
.user-item:hover { transform: translateY(-1px); border-color:#2a2b44; }
.badge { background:#171a26; border:1px solid #2a2b44; color:#cfd3ea; border-radius:999px; padding:2px 8px; font-size:.85rem; }
/* modal fix */
.modal-backdrop { position:fixed; inset:0; display:none; align-items:center; justify-content:center; background: rgba(10,12,20,.55); backdrop-filter: blur(4px); z-index: 1000; }
.modal { width:min(720px, 100%); max-height:92vh; overflow:auto; background: linear-gradient(180deg, #10121b 0%, #0d0f16 100%); border:1px solid #2a2b44; border-radius: 14px; }
.modal-header { display:flex; align-items:center; gap:10px; padding:12px 14px; border-bottom:1px solid #20223a; background: rgba(13,15,22,.85); position:sticky; top:0; }
.modal-close { margin-left:auto; background:#1a1f33; color:#c9cbe3; border:1px solid #2a2b44; width:34px; height:34px; border-radius:10px; cursor:pointer; }
.section-block { background:#101327; border:1px solid #20223a; border-radius:12px; padding:14px; margin:12px; }
.section-title { font-weight:650; margin-bottom:8px; }
</style>
{% endblock %}
{% block content %}
<div class="wrap">
<h1>Subscriptions overview</h1>
<div class="filters">
<input type="text" id="q" placeholder="Search user/series/movies…">
</div>
<div class="section">
<h2>Users</h2>
<p class="muted">Tap a user to view all subscriptions.</p>
<div class="users" id="usersList">
{% for u in user_stats %}
<div class="user-item" data-user="{{ u.username_lower }}">
<div>{{ u.username }}</div>
<div class="badge">{{ u.total_count }}</div>
</div>
{% empty %}
<p class="muted">No subscriptions yet.</p>
{% endfor %}
</div>
</div>
<div class="section">
<h2>Series</h2>
<div class="grid" id="seriesGrid">
{% for s in series %}
<div class="card" data-user="{{ s.user.username|lower }}" data-title="{{ s.series_title|lower }}" data-id="{{ s.series_id }}">
<div class="poster">
{% if s.series_poster %}
<img src="{{ s.series_poster }}" alt="{{ s.series_title }}">
{% else %}
<img src="https://via.placeholder.com/90x135?text=No+Poster" alt="">
{% endif %}
</div>
<div class="meta">
<div class="title">{{ s.series_title }}</div>
<div class="muted">User: <span class="user">{{ s.user.username }}</span></div>
<div class="muted">SeriesId: {{ s.series_id }}</div>
<div class="muted">Since: {{ s.created_at }}</div>
</div>
</div>
{% empty %}
<p class="muted">No series subscriptions.</p>
{% endfor %}
</div>
</div>
<div class="section">
<h2>Movies</h2>
<div class="grid" id="moviesGrid">
{% for m in movies %}
<div class="card" data-user="{{ m.user.username|lower }}" data-title="{{ m.title|lower }}" data-id="{{ m.movie_id }}">
<div class="poster">
{% if m.poster %}
<img src="{{ m.poster }}" alt="{{ m.title }}">
{% else %}
<img src="https://via.placeholder.com/90x135?text=No+Poster" alt="">
{% endif %}
</div>
<div class="meta">
<div class="title">{{ m.title }}</div>
<div class="muted">User: <span class="user">{{ m.user.username }}</span></div>
<div class="muted">MovieId: {{ m.movie_id }}</div>
<div class="muted">Since: {{ m.created_at }}</div>
</div>
</div>
{% empty %}
<p class="muted">No movie subscriptions.</p>
{% endfor %}
</div>
</div>
</div>
<script>
(function(){
const q = document.getElementById('q');
function filter(){
const v = (q.value||'').toLowerCase();
[document.getElementById('seriesGrid'), document.getElementById('moviesGrid')].forEach(grid=>{
if(!grid) return; Array.from(grid.children).forEach(card=>{
const text = (card.dataset.user + ' ' + card.dataset.title).toLowerCase();
card.style.display = text.includes(v) ? '' : 'none';
});
});
const users = document.getElementById('usersList');
if(users){ Array.from(users.children).forEach(item=>{
const username = item.querySelector('div')?.textContent?.toLowerCase() || '';
item.style.display = username.includes(v) ? '' : 'none';
}); }
}
q.addEventListener('input', filter);
// Group-by-user popup
const modal = document.createElement('div');
modal.className = 'modal-backdrop';
modal.style.display = 'none';
modal.innerHTML = `
<div class="modal" style="max-width:720px">
<div class="modal-header">
<div style="font-weight:700" id="uTitle"></div>
<button class="modal-close" aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<div class="section-block"><div class="section-title">Series</div><div id="uSeries"></div></div>
<div class="section-block"><div class="section-title">Movies</div><div id="uMovies"></div></div>
</div>
</div>`;
document.body.appendChild(modal);
const closeBtn = modal.querySelector('.modal-close');
const uTitle = modal.querySelector('#uTitle');
const uSeries = modal.querySelector('#uSeries');
const uMovies = modal.querySelector('#uMovies');
function open(){ modal.style.display='flex'; document.body.style.overflow='hidden'; }
function close(){ modal.style.display='none'; document.body.style.overflow=''; }
closeBtn.addEventListener('click', close);
modal.addEventListener('click', e=>{ if(e.target===modal) close(); });
function renderList(container, items){
container.innerHTML = '';
if(!items.length){ container.innerHTML = '<div class="muted">—</div>'; return; }
items.forEach(it=>{
const row = document.createElement('div');
row.style.display = 'flex'; row.style.gap='10px'; row.style.alignItems='center'; row.style.margin='6px 0';
const poster = document.createElement('img');
poster.src = it.poster || 'https://via.placeholder.com/50x75?text=No+Poster';
poster.style.width='50px'; poster.style.height='75px'; poster.style.objectFit='cover'; poster.style.borderRadius='6px';
const meta = document.createElement('div');
meta.innerHTML = `<div style="font-weight:600">${it.title}</div><div class="muted">ID: ${it.id}</div>`;
row.appendChild(poster); row.appendChild(meta);
container.appendChild(row);
});
}
function gatherForUser(username){
const series = Array.from(document.querySelectorAll('#seriesGrid .card')).filter(c=>c.dataset.user===username).map(c=>{
const idFromAttr = c.dataset.id || '';
let idFromText = '';
if(!idFromAttr){
const text = Array.from(c.querySelectorAll('.muted')).map(el=>el.textContent).join(' ');
const m = text.match(/SeriesId:\s*(\d+)/i); idFromText = m ? m[1] : '';
}
return {
title: c.querySelector('.title')?.textContent || '',
id: idFromAttr || idFromText,
poster: c.querySelector('img')?.src || ''
};
});
const movies = Array.from(document.querySelectorAll('#moviesGrid .card')).filter(c=>c.dataset.user===username).map(c=>{
const idFromAttr = c.dataset.id || '';
let idFromText = '';
if(!idFromAttr){
const text = Array.from(c.querySelectorAll('.muted')).map(el=>el.textContent).join(' ');
const m = text.match(/MovieId:\s*(\d+)/i); idFromText = m ? m[1] : '';
}
return {
title: c.querySelector('.title')?.textContent || '',
id: idFromAttr || idFromText,
poster: c.querySelector('img')?.src || ''
};
});
return {series, movies};
}
function openForUser(usernameDisplay){
const username = usernameDisplay.trim().toLowerCase();
const {series, movies} = gatherForUser(username);
uTitle.textContent = `Subscriptions for ${usernameDisplay.trim()}`;
renderList(uSeries, series);
renderList(uMovies, movies);
open();
}
document.querySelectorAll('.user').forEach(uEl=>{
uEl.style.cursor='pointer';
uEl.title='Show all subscriptions for this user';
uEl.addEventListener('click', ()=> openForUser(uEl.textContent));
});
document.querySelectorAll('.user-item').forEach(item=>{
item.addEventListener('click', ()=>{
const name = item.querySelector('div')?.textContent || '';
openForUser(name);
});
});
})();
</script>
{% endblock %}

View File

@@ -1,9 +1,10 @@
from django.urls import path
from .views import SettingsView, test_connection, first_run
from .views import SettingsView, test_connection, first_run, subscriptions_overview
app_name = "settingspanel"
urlpatterns = [
path("", SettingsView.as_view(), name="index"),
path("test-connection/", test_connection, name="test_connection"),
path("setup/", first_run, name="setup"),
path("subscriptions/", subscriptions_overview, name="subscriptions"),
]

View File

@@ -7,6 +7,8 @@ from .models import AppSettings
from django.http import JsonResponse
from accounts.utils import jellyfin_admin_required
from django.contrib.auth import get_user_model
from arr_api.models import SeriesSubscription, MovieSubscription
from django.db.models import Count
import requests
def needs_setup():
@@ -32,7 +34,7 @@ def first_run(request):
settings.radarr_api_key = form.cleaned_data['radarr_api_key']
settings.save()
messages.success(request, 'Setup erfolgreich abgeschlossen!')
messages.success(request, 'Setup completed successfully!')
return redirect('accounts:login')
else:
form = FirstRunSetupForm()
@@ -53,9 +55,9 @@ def test_connection(request):
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)
return JsonResponse({"ok": False, "error": "Invalid type"}, status=400)
if not url or not key:
return JsonResponse({"ok": False, "error": "URL und API-Key erforderlich"}, status=400)
return JsonResponse({"ok": False, "error": "URL and API key required"}, status=400)
try:
r = requests.get(
@@ -105,35 +107,87 @@ class SettingsView(View):
arr_form = ArrSettingsForm(request.POST)
mail_form = MailSettingsForm(request.POST)
acc_form = AccountForm(request.POST)
if not (jellyfin_form.is_valid() and arr_form.is_valid() and mail_form.is_valid() and acc_form.is_valid()):
return render(request, self.template_name, {
"jellyfin_form": jellyfin_form,
"arr_form": arr_form,
"mail_form": mail_form,
"account_form": acc_form
"account_form": acc_form,
})
cfg = AppSettings.current()
# Update Jellyfin settings
cfg.jellyfin_server_url = jellyfin_form.cleaned_data["jellyfin_server_url"] or None
cfg.jellyfin_api_key = jellyfin_form.cleaned_data["jellyfin_api_key"] or None
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.jellyfin_server_url = jellyfin_form.cleaned_data.get("jellyfin_server_url") or None
cfg.jellyfin_api_key = jellyfin_form.cleaned_data.get("jellyfin_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
# Update Sonarr/Radarr settings
cfg.sonarr_url = arr_form.cleaned_data.get("sonarr_url") or None
cfg.sonarr_api_key = arr_form.cleaned_data.get("sonarr_api_key") or None
cfg.radarr_url = arr_form.cleaned_data.get("radarr_url") or None
cfg.radarr_api_key = arr_form.cleaned_data.get("radarr_api_key") or None
cfg.acc_username = acc_form.cleaned_data["username"] or None
cfg.acc_email = acc_form.cleaned_data["email"] or None
# Update Mail settings
cfg.mail_host = mail_form.cleaned_data.get("mail_host") or None
cfg.mail_port = mail_form.cleaned_data.get("mail_port") or None
cfg.mail_secure = mail_form.cleaned_data.get("mail_secure") or ""
cfg.mail_user = mail_form.cleaned_data.get("mail_user") or None
cfg.mail_password = mail_form.cleaned_data.get("mail_password") or None
cfg.mail_from = mail_form.cleaned_data.get("mail_from") or None
# Update account settings
cfg.acc_username = acc_form.cleaned_data.get("username") or None
cfg.acc_email = acc_form.cleaned_data.get("email") or None
cfg.save()
messages.success(request, "Einstellungen gespeichert (DB).")
messages.success(request, "Settings saved (DB).")
return redirect("settingspanel:index")
@jellyfin_admin_required
def subscriptions_overview(request):
series = SeriesSubscription.objects.select_related('user').order_by('user__username', 'series_title')
movies = MovieSubscription.objects.select_related('user').order_by('user__username', 'title')
# Aggregate counts per user
s_counts = SeriesSubscription.objects.values('user_id', 'user__username').annotate(series_count=Count('id'))
m_counts = MovieSubscription.objects.values('user_id', 'user__username').annotate(movie_count=Count('id'))
user_map = {}
for row in s_counts:
key = row['user_id']
user_map.setdefault(key, {
'user_id': key,
'username': row['user__username'],
'series_count': 0,
'movie_count': 0,
})
user_map[key]['series_count'] = row['series_count']
for row in m_counts:
key = row['user_id']
user_map.setdefault(key, {
'user_id': key,
'username': row['user__username'],
'series_count': 0,
'movie_count': 0,
})
user_map[key]['movie_count'] = row['movie_count']
user_stats = []
for key, val in user_map.items():
total = (val.get('series_count') or 0) + (val.get('movie_count') or 0)
user_stats.append({
'user_id': val['user_id'],
'username': val['username'],
'username_lower': (val['username'] or '').lower(),
'series_count': val.get('series_count') or 0,
'movie_count': val.get('movie_count') or 0,
'total_count': total,
})
user_stats.sort(key=lambda x: (-x['total_count'], x['username'].lower()))
return render(request, 'settingspanel/subscriptions.html', {
'series': series,
'movies': movies,
'user_stats': user_stats,
})