usermanagement/translation/calendar
This commit is contained in:
		| @@ -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) | ||||
|   | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
| @@ -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 %} | ||||
| @@ -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">Click “Test …” 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; }); | ||||
|         } | ||||
|  | ||||
|   | ||||
							
								
								
									
										218
									
								
								settingspanel/templates/settingspanel/subscriptions.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										218
									
								
								settingspanel/templates/settingspanel/subscriptions.html
									
									
									
									
									
										Normal 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">×</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 %} | ||||
| @@ -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"), | ||||
| ] | ||||
|   | ||||
| @@ -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, | ||||
|     }) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 jschaufuss@leitwerk.de
					jschaufuss@leitwerk.de