multiuser/subscriptions/notifications

This commit is contained in:
2025-08-10 17:48:15 +02:00
parent d4b811dbad
commit fb0c7da252
49 changed files with 3676 additions and 1034 deletions

0
accounts/__init__.py Normal file
View File

3
accounts/admin.py Normal file
View File

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

6
accounts/apps.py Normal file
View File

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

24
accounts/forms.py Normal file
View File

@@ -0,0 +1,24 @@
from django import forms
from django.contrib.auth.forms import UserCreationForm, UserChangeForm
from .models import User
class CustomUserCreationForm(UserCreationForm):
email = forms.EmailField(required=True)
class Meta:
model = User
fields = ('username', 'email', 'password1', 'password2')
class CustomUserChangeForm(UserChangeForm):
password = None # Passwort-Änderung über extra Formular
class Meta:
model = User
fields = ('email',)
widgets = {
'email': forms.EmailInput(attrs={'class': 'text-input', 'placeholder': 'E-Mail-Adresse'}),
}
class JellyfinLoginForm(forms.Form):
username = forms.CharField(label='Benutzername', widget=forms.TextInput(attrs={'class': 'form-control'}))
password = forms.CharField(label='Passwort', widget=forms.PasswordInput(attrs={'class': 'form-control'}))

View File

@@ -0,0 +1,45 @@
# Generated by Django 5.2.5 on 2025-08-10 11:59
import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')),
('bio', models.TextField(blank=True, max_length=500)),
('is_admin', models.BooleanField(default=False)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.5 on 2025-08-10 12:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='user',
name='jellyfin_server',
field=models.CharField(blank=True, max_length=200, null=True),
),
migrations.AddField(
model_name='user',
name='jellyfin_token',
field=models.CharField(blank=True, max_length=500, null=True),
),
migrations.AddField(
model_name='user',
name='jellyfin_user_id',
field=models.CharField(blank=True, max_length=100, null=True),
),
]

View File

39
accounts/models.py Normal file
View File

@@ -0,0 +1,39 @@
from django.db import models
from django.contrib.auth.models import AbstractUser
from django.utils.translation import gettext_lazy as _
class User(AbstractUser):
"""
Custom User Model mit zusätzlichen Feldern und Berechtigungen.
Normale User können nur ihre eigenen Daten bearbeiten.
Admin-User können alles.
"""
email = models.EmailField(_("email address"), unique=True)
bio = models.TextField(max_length=500, blank=True)
is_admin = models.BooleanField(default=False)
# Jellyfin fields
jellyfin_user_id = models.CharField(max_length=100, blank=True, null=True)
jellyfin_token = models.CharField(max_length=500, blank=True, null=True)
jellyfin_server = models.CharField(max_length=200, blank=True, null=True)
def check_jellyfin_admin(self):
"""Check if user is Jellyfin admin on the server"""
from accounts.utils import JellyfinClient
if not self.jellyfin_user_id or not self.jellyfin_token:
return False
try:
client = JellyfinClient()
return client.is_admin(self.jellyfin_user_id, self.jellyfin_token)
except:
# Im Fehlerfall den lokalen Status verwenden
return self.is_admin
@property
def is_jellyfin_admin(self):
"""Check if user is admin either locally or on Jellyfin server"""
return self.is_admin or self.check_jellyfin_admin()
class Meta:
verbose_name = _("user")
verbose_name_plural = _("users")

View File

@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block content %}
<div class="auth-container">
<h2>Anmelden</h2>
<form method="post" class="auth-form">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn-primary">Anmelden</button>
</form>
<div class="auth-links">
<p>Noch kein Konto? <a href="{% url 'accounts:register' %}">Jetzt registrieren</a></p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block content %}
<div class="auth-container">
<h2>Passwort ändern</h2>
<form method="post" class="auth-form">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn-primary">Passwort ändern</button>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block content %}
<div class="auth-container">
<h2>Passwort geändert</h2>
<p>Ihr Passwort wurde erfolgreich geändert.</p>
<p><a href="{% url 'accounts:profile' %}">Zurück zum Profil</a></p>
</div>
{% endblock %}

View File

@@ -0,0 +1,97 @@
{% extends "base.html" %}
{% load static %}
{% block extra_style %}
<link rel="stylesheet" href="{% static 'css/profile.css' %}">
{% endblock %}
{% block content %}
<div class="profile-container">
<h2>Hallo, {{ user.username }}</h2>
{% if messages %}
<div class="messages">
{% for message in messages %}
<div class="message {{ message.tags }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
<div class="profile-section">
<h3>E-Mail-Adresse</h3>
<form method="post" class="profile-form compact-form">
{% csrf_token %}
<div class="form-row">
<label for="id_email">E-Mail</label>
{{ form.email }}
</div>
<button type="submit" class="btn-primary">Speichern</button>
</form>
{% if user.jellyfin_server %}
<div class="jellyfin-info">
<h4>Jellyfin-Verbindung</h4>
<p>
Server: {{ user.jellyfin_server }}<br>
Status: {% if user.jellyfin_token %}Verbunden{% else %}Nicht verbunden{% endif %}<br>
{% if user.is_jellyfin_admin %}
<span class="badge badge-admin">Jellyfin Administrator</span>
{% endif %}
</p>
</div>
{% endif %}
</div>
<div class="profile-section">
<h3>Meine Abonnements</h3>
<h4>Serien</h4>
{% if series_subs %}
<div class="subscription-list">
{% for sub in series_subs %}
<div class="subscription-item">
{% if sub.series_poster %}
<img src="{{ sub.series_poster }}" alt="{{ sub.series_title }}" class="subscription-poster">
{% else %}
<img src="https://via.placeholder.com/80x120?text=Kein+Poster" alt="" class="subscription-poster">
{% endif %}
<div class="subscription-info">
<div class="subscription-title">{{ sub.series_title }}</div>
<div class="subscription-date">Abonniert am {{ sub.created_at|date:"d.m.Y" }}</div>
{% if sub.series_overview %}
<div class="subscription-overview">{{ sub.series_overview|truncatechars:100 }}</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="muted">Keine Serien abonniert.</p>
{% endif %}
<h4>Filme</h4>
{% if movie_subs %}
<div class="subscription-list">
{% for sub in movie_subs %}
<div class="subscription-item">
{% if sub.poster %}
<img src="{{ sub.poster }}" alt="{{ sub.title }}" class="subscription-poster">
{% else %}
<img src="https://via.placeholder.com/80x120?text=Kein+Poster" alt="" class="subscription-poster">
{% endif %}
<div class="subscription-info">
<div class="subscription-title">{{ sub.title }}</div>
<div class="subscription-date">Abonniert am {{ sub.created_at|date:"d.m.Y" }}</div>
{% if sub.overview %}
<div class="subscription-overview">{{ sub.overview|truncatechars:100 }}</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="muted">Keine Filme abonniert.</p>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block content %}
<div class="auth-container">
<h2>Registrieren</h2>
<form method="post" class="auth-form">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn-primary">Registrieren</button>
</form>
<div class="auth-links">
<p>Bereits ein Konto? <a href="{% url 'accounts:login' %}">Jetzt anmelden</a></p>
</div>
</div>
{% endblock %}

3
accounts/tests.py Normal file
View File

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

19
accounts/urls.py Normal file
View File

@@ -0,0 +1,19 @@
from django.urls import path
from django.contrib.auth import views as auth_views
from . import views
app_name = 'accounts'
urlpatterns = [
path('login/', views.jellyfin_login, name='login'),
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
path('register/', views.RegisterView.as_view(), name='register'),
path('profile/', views.profile, name='profile'),
path('password_change/', auth_views.PasswordChangeView.as_view(
template_name='accounts/password_change.html',
success_url='done/'
), name='password_change'),
path('password_change/done/', auth_views.PasswordChangeDoneView.as_view(
template_name='accounts/password_change_done.html'
), name='password_change_done'),
]

117
accounts/utils.py Normal file
View File

@@ -0,0 +1,117 @@
import requests
from django.conf import settings
from django.core.cache import cache
from functools import wraps
from django.shortcuts import redirect
from django.contrib import messages
class JellyfinClient:
def __init__(self):
# Basis-Einstellungen aus den Django-Settings
self.client = settings.JELLYFIN_CLIENT
self.version = settings.JELLYFIN_VERSION
self.device = settings.JELLYFIN_DEVICE
self.device_id = settings.JELLYFIN_DEVICE_ID
self.server_url = None # Wird später gesetzt
self.api_key = None # Optional, wird aus den AppSettings geholt wenn nötig
def authenticate(self, username, password):
"""Authenticate with Jellyfin and return user info if successful"""
if not self.server_url:
raise ValueError("Keine Server-URL angegeben")
# Stelle sicher, dass die URL ein Protokoll hat
if not self.server_url.startswith(('http://', 'https://')):
self.server_url = f'http://{self.server_url}'
# Entferne trailing slashes
self.server_url = self.server_url.rstrip('/')
headers = {
'X-Emby-Authorization': (
f'MediaBrowser Client="{self.client}", '
f'Device="{self.device}", '
f'DeviceId="{self.device_id}", '
f'Version="{self.version}"'
)
}
auth_data = {
'Username': username,
'Pw': password
}
try:
response = requests.post(
f'{self.server_url}/Users/AuthenticateByName',
json=auth_data,
headers=headers,
timeout=10
)
response.raise_for_status()
data = response.json()
return {
'user_id': data['User']['Id'],
'access_token': data['AccessToken'],
'is_admin': data['User'].get('Policy', {}).get('IsAdministrator', False)
}
except requests.exceptions.ConnectionError:
raise ValueError("Verbindung zum Server nicht möglich. Bitte überprüfen Sie die Server-URL.")
except requests.exceptions.Timeout:
raise ValueError("Zeitüberschreitung bei der Verbindung zum Server.")
except requests.exceptions.HTTPError as e:
if e.response.status_code == 401:
return None # Authentifizierung fehlgeschlagen
raise ValueError(f"HTTP-Fehler: {e.response.status_code}")
except Exception as e:
return None
def is_admin(self, user_id, token):
"""Check if user is admin in Jellyfin"""
cache_key = f'jellyfin_admin_{user_id}'
# Check cache first
cached = cache.get(cache_key)
if cached is not None:
return cached
headers = {
'X-Emby-Authorization': (
f'MediaBrowser Client="{self.client}", '
f'Device="{self.device}", '
f'DeviceId="{self.device_id}", '
f'Version="{self.version}", '
f'Token="{token}"'
)
}
try:
response = requests.get(
f'{self.server_url}/Users/{user_id}',
headers=headers
)
response.raise_for_status()
data = response.json()
is_admin = data.get('Policy', {}).get('IsAdministrator', False)
# Cache result for 5 minutes
cache.set(cache_key, is_admin, 300)
return is_admin
except:
return False
def jellyfin_admin_required(view_func):
@wraps(view_func)
def _wrapped_view(request, *args, **kwargs):
if not request.user.is_authenticated:
messages.error(request, 'Sie müssen angemeldet sein, um diese Seite zu sehen.')
return redirect('accounts:login')
if not request.user.is_jellyfin_admin:
messages.error(request, 'Sie benötigen Admin-Rechte, um diese Seite zu sehen.')
return redirect('index')
return view_func(request, *args, **kwargs)
return _wrapped_view

132
accounts/views.py Normal file
View File

@@ -0,0 +1,132 @@
from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.urls import reverse_lazy
from django.views.generic.edit import CreateView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth import login
from django.conf import settings
from .forms import CustomUserCreationForm, CustomUserChangeForm, JellyfinLoginForm
from .models import User
from .utils import JellyfinClient
class RegisterView(CreateView):
form_class = CustomUserCreationForm
template_name = 'accounts/register.html'
success_url = reverse_lazy('accounts:login')
def form_valid(self, form):
response = super().form_valid(form)
messages.success(self.request, 'Registrierung erfolgreich! Sie können sich jetzt anmelden.')
return response
@login_required
def profile(request):
if request.method == 'POST':
form = CustomUserChangeForm(request.POST, instance=request.user)
if form.is_valid():
form.save()
messages.success(request, 'E-Mail gespeichert.')
return redirect('accounts:profile')
else:
form = CustomUserChangeForm(instance=request.user)
# Lade Abonnements
series_subs = request.user.series_subscriptions.all()
movie_subs = request.user.movie_subscriptions.all()
# Best-effort Backfill fehlender Poster, damit die Profilseite Bilder zeigt
try:
from settingspanel.models import AppSettings
from arr_api.services import sonarr_get_series, radarr_lookup_movie_by_title
cfg = AppSettings.current()
# Serien
for sub in series_subs:
if not sub.series_poster and sub.series_id:
details = sonarr_get_series(sub.series_id, base_url=cfg.sonarr_url, api_key=cfg.sonarr_api_key)
if details and details.get('series_poster'):
sub.series_poster = details['series_poster']
if not sub.series_overview:
sub.series_overview = details.get('series_overview') or ''
if not sub.series_genres:
sub.series_genres = details.get('series_genres') or []
sub.save(update_fields=['series_poster', 'series_overview', 'series_genres'])
# Filme
for sub in movie_subs:
if not sub.poster:
details = radarr_lookup_movie_by_title(sub.title, base_url=cfg.radarr_url, api_key=cfg.radarr_api_key)
if details and details.get('poster'):
sub.poster = details['poster']
if not sub.overview:
sub.overview = details.get('overview') or ''
if not sub.genres:
sub.genres = details.get('genres') or []
sub.save(update_fields=['poster', 'overview', 'genres'])
except Exception:
# still show page even if lookups fail
pass
return render(request, 'accounts/profile.html', {
'form': form,
'series_subs': series_subs,
'movie_subs': movie_subs,
})
def jellyfin_login(request):
if request.method == 'POST':
form = JellyfinLoginForm(request.POST)
if form.is_valid():
username = form.cleaned_data['username']
password = form.cleaned_data['password']
# Jellyfin-URL aus AppSettings
from settingspanel.models import AppSettings
app_settings = AppSettings.current()
server_url = app_settings.get_jellyfin_url()
if not server_url:
messages.error(request, 'Jellyfin Server ist nicht konfiguriert. Bitte Setup abschließen.')
return render(request, 'accounts/login.html', {'form': form})
try:
client = JellyfinClient()
client.server_url = server_url
auth_result = client.authenticate(username, password)
if not auth_result:
messages.error(request, 'Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldedaten.')
return render(request, 'accounts/login.html', {'form': form})
# Existierenden User finden oder neu erstellen
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
user = User.objects.create_user(
username=username,
email=f"{username}@jellyfin.local"
)
# Jellyfin Daten aktualisieren
user.jellyfin_user_id = auth_result['user_id']
user.jellyfin_token = auth_result['access_token']
user.jellyfin_server = server_url
user.save()
if auth_result['is_admin']:
user.is_admin = True
user.save()
login(request, user)
messages.success(request, f'Willkommen, {username}!')
return redirect('arr_api:index')
except ValueError as e:
messages.error(request, str(e))
except Exception as e:
messages.error(request, f'Verbindungsfehler: {str(e)}')
# invalid form or error path
return render(request, 'accounts/login.html', {'form': form})
else:
form = JellyfinLoginForm()
return render(request, 'accounts/login.html', {'form': form})