from datetime import timedelta from django.conf import settings from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager from django.db import models from django.db.models.functions import Lower from django.urls import reverse from django.utils import timezone from .tokens import hash_token, new_token def normalize_email(email): return (email or "").strip().lower() class UserManager(BaseUserManager): def create_user(self, email, password=None, display_name="", role="user", **extra): email = normalize_email(email) if not email: raise ValueError("E-Mail-Adresse fehlt.") user = self.model(email=email, display_name=display_name or email.split("@")[0], role=role, **extra) user.set_password(password) user.save(using=self._db) return user def create_admin(self, email, password=None, display_name="", **extra): return self.create_user(email, password, display_name, role=User.Role.ADMIN, **extra) def get_by_natural_key(self, email): return self.get(email=normalize_email(email)) class User(AbstractBaseUser): class Role(models.TextChoices): USER = "user", "Nutzer" ADMIN = "admin", "Admin" email = models.EmailField("E-Mail", unique=True) display_name = models.CharField("Anzeigename", max_length=80) role = models.CharField("Rolle", max_length=10, choices=Role.choices, default=Role.USER) is_active = models.BooleanField("Aktiv", default=True) must_change_password = models.BooleanField(default=False) date_joined = models.DateTimeField("Registriert am", default=timezone.now) objects = UserManager() USERNAME_FIELD = "email" EMAIL_FIELD = "email" REQUIRED_FIELDS = ["display_name"] class Meta: verbose_name = "Nutzer" verbose_name_plural = "Nutzer" ordering = ["display_name"] constraints = [models.UniqueConstraint(Lower("email"), name="user_email_ci_unique")] def __str__(self): return self.display_name def save(self, *args, **kwargs): self.email = normalize_email(self.email) super().save(*args, **kwargs) @property def is_admin(self): return self.is_active and self.role == self.Role.ADMIN class TokenQuerySet(models.QuerySet): def usable(self): return self.filter(used_at__isnull=True, revoked_at__isnull=True, expires_at__gt=timezone.now()) def get_usable(self, raw_token): """Return the usable token object for a raw token, or None.""" if not raw_token: return None return self.usable().filter(token_hash=hash_token(raw_token)).first() class OneTimeToken(models.Model): token_hash = models.CharField(max_length=64, unique=True, editable=False) created_at = models.DateTimeField(auto_now_add=True) expires_at = models.DateTimeField() used_at = models.DateTimeField(null=True, blank=True) revoked_at = models.DateTimeField(null=True, blank=True) objects = TokenQuerySet.as_manager() class Meta: abstract = True ordering = ["-created_at"] @property def status(self): if self.used_at: return "used" if self.revoked_at: return "revoked" if self.expires_at <= timezone.now(): return "expired" return "open" @property def status_label(self): return {"used": "verwendet", "revoked": "widerrufen", "expired": "abgelaufen", "open": "offen"}[self.status] class Invitation(OneTimeToken): email = models.EmailField("E-Mail (optional)", blank=True, help_text="Wenn gesetzt, kann nur diese Adresse sich registrieren.") role = models.CharField("Rolle", max_length=10, choices=User.Role.choices, default=User.Role.USER) note = models.CharField("Notiz", max_length=120, blank=True, help_text="Nur für dich, z. B. für wen die Einladung ist.") created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="+") used_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+") class Meta(OneTimeToken.Meta): verbose_name = "Einladung" verbose_name_plural = "Einladungen" @classmethod def issue(cls, created_by, email="", role=User.Role.USER, note="", valid_days=None): """Create an invitation and return (invitation, absolute_link). The link is not stored.""" raw, token_hash = new_token() days = valid_days or settings.INVITATION_VALID_DAYS invitation = cls.objects.create( token_hash=token_hash, email=normalize_email(email), role=role, note=note, created_by=created_by, expires_at=timezone.now() + timedelta(days=days), ) return invitation, settings.APP_BASE_URL + reverse("accounts:register", args=[raw]) class PasswordResetToken(OneTimeToken): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reset_tokens") created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="+") class Meta(OneTimeToken.Meta): verbose_name = "Passwort-Reset" verbose_name_plural = "Passwort-Resets" @classmethod def issue(cls, user, created_by): """Create a reset link for a user. Earlier open links for that user are revoked.""" now = timezone.now() cls.objects.usable().filter(user=user).update(revoked_at=now) raw, token_hash = new_token() token = cls.objects.create( token_hash=token_hash, user=user, created_by=created_by, expires_at=now + timedelta(hours=settings.PASSWORD_RESET_VALID_HOURS), ) return token, settings.APP_BASE_URL + reverse("accounts:password_reset", args=[raw])