Modernes Furry-Design implementiert - Glassmorphism, responsive Design, Mobile-First - Verbesserte Navigation mit Emoji-Icons - Neue Homepage-Sektionen: Features, Testimonials, Newsletter - CSS Variables für konsistente Farbpalette - Dark Mode Support - Performance-Optimierung
This commit is contained in:
commit
7865a188d1
|
|
@ -0,0 +1,267 @@
|
||||||
|
# Django
|
||||||
|
*.log
|
||||||
|
*.pot
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
media/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Virtual environment
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage/
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
public
|
||||||
|
|
||||||
|
# Storybook build outputs
|
||||||
|
.out
|
||||||
|
.storybook-out
|
||||||
|
|
||||||
|
# Temporary folders
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Static files (if collected)
|
||||||
|
staticfiles/
|
||||||
|
|
||||||
|
# Media files
|
||||||
|
media/
|
||||||
|
|
||||||
|
# Backup files
|
||||||
|
*.bak
|
||||||
|
*.backup
|
||||||
|
*.old
|
||||||
|
|
||||||
|
# Certificate files
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.dockerignore
|
||||||
|
Dockerfile.prod
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.pytest_cache/
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
.hypothesis/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# celery beat schedule file
|
||||||
|
celerybeat-schedule
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# celery beat schedule file
|
||||||
|
celerybeat-schedule
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# Project specific
|
||||||
|
uploads/
|
||||||
|
temp/
|
||||||
|
cache/
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
# Dockerfile für Kasico Fursuit Shop
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Setze Umgebungsvariablen
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV DJANGO_SETTINGS_MODULE=webshop.settings
|
||||||
|
|
||||||
|
# Installiere System-Abhängigkeiten
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
g++ \
|
||||||
|
libpq-dev \
|
||||||
|
libjpeg-dev \
|
||||||
|
libpng-dev \
|
||||||
|
libfreetype6-dev \
|
||||||
|
libwebp-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Erstelle Arbeitsverzeichnis
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Kopiere Requirements
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Installiere Python-Abhängigkeiten
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Kopiere Projektdateien
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Erstelle Media-Verzeichnis
|
||||||
|
RUN mkdir -p /app/media
|
||||||
|
|
||||||
|
# Exponiere Port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Starte Django Development Server
|
||||||
|
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Einfaches Dockerfile für Kasico Fursuit Shop (SQLite)
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Setze Umgebungsvariablen
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV DJANGO_SETTINGS_MODULE=webshop.settings-simple
|
||||||
|
|
||||||
|
# Installiere System-Abhängigkeiten
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
g++ \
|
||||||
|
libjpeg-dev \
|
||||||
|
libpng-dev \
|
||||||
|
libfreetype6-dev \
|
||||||
|
libwebp-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Erstelle Arbeitsverzeichnis
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Kopiere Requirements
|
||||||
|
COPY requirements-simple.txt requirements.txt
|
||||||
|
|
||||||
|
# Installiere Python-Abhängigkeiten
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Kopiere Projektdateien
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Erstelle statische Dateien
|
||||||
|
RUN python manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
# Erstelle Media-Verzeichnis
|
||||||
|
RUN mkdir -p /app/media
|
||||||
|
|
||||||
|
# Exponiere Port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Starte Django Development Server
|
||||||
|
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
"""
|
||||||
|
Auction Forms
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from .models import Auction, Bid
|
||||||
|
|
||||||
|
|
||||||
|
class AuctionForm(forms.ModelForm):
|
||||||
|
"""Form für Auktion erstellen/bearbeiten"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Auction
|
||||||
|
fields = [
|
||||||
|
'title', 'description', 'fursuit_type', 'character_description',
|
||||||
|
'reference_images', 'special_requirements', 'starting_bid',
|
||||||
|
'reserve_price', 'duration_days'
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
'title': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Auktion Titel'
|
||||||
|
}),
|
||||||
|
'description': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 4,
|
||||||
|
'placeholder': 'Beschreibung der Auktion'
|
||||||
|
}),
|
||||||
|
'fursuit_type': forms.Select(attrs={
|
||||||
|
'class': 'form-control'
|
||||||
|
}),
|
||||||
|
'character_description': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 6,
|
||||||
|
'placeholder': 'Detaillierte Beschreibung des Charakters...'
|
||||||
|
}),
|
||||||
|
'special_requirements': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': 'Besondere Anforderungen (optional)'
|
||||||
|
}),
|
||||||
|
'starting_bid': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'min': '0',
|
||||||
|
'step': '0.01',
|
||||||
|
'placeholder': '0.00'
|
||||||
|
}),
|
||||||
|
'reserve_price': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'min': '0',
|
||||||
|
'step': '0.01',
|
||||||
|
'placeholder': 'Reserve Price (optional)'
|
||||||
|
}),
|
||||||
|
'duration_days': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'min': '1',
|
||||||
|
'max': '30',
|
||||||
|
'placeholder': '7'
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def clean_starting_bid(self):
|
||||||
|
"""Starting Bid validieren"""
|
||||||
|
starting_bid = self.cleaned_data.get('starting_bid')
|
||||||
|
if starting_bid <= 0:
|
||||||
|
raise forms.ValidationError('Starting Bid muss größer als 0 sein')
|
||||||
|
return starting_bid
|
||||||
|
|
||||||
|
def clean_reserve_price(self):
|
||||||
|
"""Reserve Price validieren"""
|
||||||
|
reserve_price = self.cleaned_data.get('reserve_price')
|
||||||
|
starting_bid = self.cleaned_data.get('starting_bid')
|
||||||
|
|
||||||
|
if reserve_price and reserve_price <= starting_bid:
|
||||||
|
raise forms.ValidationError('Reserve Price muss höher als Starting Bid sein')
|
||||||
|
|
||||||
|
return reserve_price
|
||||||
|
|
||||||
|
def clean_duration_days(self):
|
||||||
|
"""Duration validieren"""
|
||||||
|
duration = self.cleaned_data.get('duration_days')
|
||||||
|
if duration < 1 or duration > 30:
|
||||||
|
raise forms.ValidationError('Duration muss zwischen 1 und 30 Tagen liegen')
|
||||||
|
return duration
|
||||||
|
|
||||||
|
|
||||||
|
class BidForm(forms.ModelForm):
|
||||||
|
"""Form für Gebot platzieren"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Bid
|
||||||
|
fields = ['amount']
|
||||||
|
widgets = {
|
||||||
|
'amount': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'min': '0',
|
||||||
|
'step': '0.01',
|
||||||
|
'placeholder': 'Dein Gebot'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.auction = kwargs.pop('auction', None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if self.auction:
|
||||||
|
min_bid = self.auction.current_bid or self.auction.starting_bid
|
||||||
|
self.fields['amount'].widget.attrs['min'] = float(min_bid) + 0.01
|
||||||
|
self.fields['amount'].widget.attrs['placeholder'] = f'Min. €{float(min_bid) + 0.01}'
|
||||||
|
|
||||||
|
def clean_amount(self):
|
||||||
|
"""Amount validieren"""
|
||||||
|
amount = self.cleaned_data.get('amount')
|
||||||
|
|
||||||
|
if not self.auction:
|
||||||
|
raise forms.ValidationError('Auktion nicht gefunden')
|
||||||
|
|
||||||
|
if not self.auction.is_active:
|
||||||
|
raise forms.ValidationError('Auktion ist nicht aktiv')
|
||||||
|
|
||||||
|
min_bid = self.auction.current_bid or self.auction.starting_bid
|
||||||
|
|
||||||
|
if amount <= min_bid:
|
||||||
|
raise forms.ValidationError(f'Gebot muss höher als €{float(min_bid)} sein')
|
||||||
|
|
||||||
|
return amount
|
||||||
|
|
@ -0,0 +1,316 @@
|
||||||
|
"""
|
||||||
|
Auction Models für Custom Order Biet-System
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.core.validators import MinValueValidator
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Auction(models.Model):
|
||||||
|
"""Auktion für Custom Order"""
|
||||||
|
|
||||||
|
AUCTION_STATUS_CHOICES = [
|
||||||
|
('draft', 'Entwurf'),
|
||||||
|
('active', 'Aktiv'),
|
||||||
|
('paused', 'Pausiert'),
|
||||||
|
('ended', 'Beendet'),
|
||||||
|
('cancelled', 'Storniert'),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
description = models.TextField()
|
||||||
|
|
||||||
|
# Custom Order Details
|
||||||
|
fursuit_type = models.CharField(max_length=50, choices=[
|
||||||
|
('fullsuit', 'Fullsuit'),
|
||||||
|
('partial', 'Partial'),
|
||||||
|
('head', 'Head Only'),
|
||||||
|
('handpaws', 'Handpaws'),
|
||||||
|
('feetpaws', 'Feetpaws'),
|
||||||
|
('tail', 'Tail'),
|
||||||
|
('custom', 'Custom'),
|
||||||
|
])
|
||||||
|
character_description = models.TextField()
|
||||||
|
reference_images = models.JSONField(default=list) # URLs zu Referenzbildern
|
||||||
|
special_requirements = models.TextField(blank=True)
|
||||||
|
|
||||||
|
# Auction Settings
|
||||||
|
starting_bid = models.DecimalField(max_digits=10, decimal_places=2, validators=[MinValueValidator(0)])
|
||||||
|
reserve_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||||
|
current_bid = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||||
|
|
||||||
|
# Time Settings
|
||||||
|
start_time = models.DateTimeField()
|
||||||
|
end_time = models.DateTimeField()
|
||||||
|
duration_days = models.IntegerField(default=7)
|
||||||
|
|
||||||
|
# Status & Tracking
|
||||||
|
status = models.CharField(max_length=20, choices=AUCTION_STATUS_CHOICES, default='draft')
|
||||||
|
winner = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='won_auctions')
|
||||||
|
total_bids = models.IntegerField(default=0)
|
||||||
|
total_bidders = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='created_auctions')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
# SEO & Visibility
|
||||||
|
is_featured = models.BooleanField(default=False)
|
||||||
|
view_count = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name = 'Auktion'
|
||||||
|
verbose_name_plural = 'Auktionen'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.title} - {self.get_status_display()}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
"""Prüfen ob Auktion aktiv ist"""
|
||||||
|
now = timezone.now()
|
||||||
|
return (
|
||||||
|
self.status == 'active' and
|
||||||
|
self.start_time <= now <= self.end_time
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def time_remaining(self):
|
||||||
|
"""Verbleibende Zeit"""
|
||||||
|
if not self.is_active:
|
||||||
|
return None
|
||||||
|
|
||||||
|
remaining = self.end_time - timezone.now()
|
||||||
|
return remaining
|
||||||
|
|
||||||
|
@property
|
||||||
|
def time_remaining_formatted(self):
|
||||||
|
"""Formatierte verbleibende Zeit"""
|
||||||
|
if not self.time_remaining:
|
||||||
|
return "Beendet"
|
||||||
|
|
||||||
|
days = self.time_remaining.days
|
||||||
|
hours = self.time_remaining.seconds // 3600
|
||||||
|
minutes = (self.time_remaining.seconds % 3600) // 60
|
||||||
|
|
||||||
|
if days > 0:
|
||||||
|
return f"{days}d {hours}h {minutes}m"
|
||||||
|
elif hours > 0:
|
||||||
|
return f"{hours}h {minutes}m"
|
||||||
|
else:
|
||||||
|
return f"{minutes}m"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_reserve(self):
|
||||||
|
"""Prüfen ob Reserve Price gesetzt ist"""
|
||||||
|
return self.reserve_price is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reserve_met(self):
|
||||||
|
"""Prüfen ob Reserve Price erreicht wurde"""
|
||||||
|
if not self.has_reserve:
|
||||||
|
return True
|
||||||
|
return self.current_bid and self.current_bid >= self.reserve_price
|
||||||
|
|
||||||
|
def get_highest_bid(self):
|
||||||
|
"""Höchstes Gebot abrufen"""
|
||||||
|
return self.bids.order_by('-amount').first()
|
||||||
|
|
||||||
|
def place_bid(self, user, amount):
|
||||||
|
"""Gebot platzieren"""
|
||||||
|
if not self.is_active:
|
||||||
|
raise ValueError("Auktion ist nicht aktiv")
|
||||||
|
|
||||||
|
if amount <= self.current_bid:
|
||||||
|
raise ValueError("Gebot muss höher als aktuelles Gebot sein")
|
||||||
|
|
||||||
|
# Gebot erstellen
|
||||||
|
bid = Bid.objects.create(
|
||||||
|
auction=self,
|
||||||
|
bidder=user,
|
||||||
|
amount=amount
|
||||||
|
)
|
||||||
|
|
||||||
|
# Auktion aktualisieren
|
||||||
|
self.current_bid = amount
|
||||||
|
self.total_bids += 1
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
return bid
|
||||||
|
|
||||||
|
def end_auction(self):
|
||||||
|
"""Auktion beenden"""
|
||||||
|
if self.status != 'active':
|
||||||
|
return
|
||||||
|
|
||||||
|
highest_bid = self.get_highest_bid()
|
||||||
|
|
||||||
|
if highest_bid and self.reserve_met:
|
||||||
|
self.winner = highest_bid.bidder
|
||||||
|
self.status = 'ended'
|
||||||
|
else:
|
||||||
|
self.status = 'ended' # Kein Gewinner wenn Reserve nicht erreicht
|
||||||
|
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
# Notification an Gewinner senden
|
||||||
|
if self.winner:
|
||||||
|
self.send_winner_notification()
|
||||||
|
|
||||||
|
def send_winner_notification(self):
|
||||||
|
"""Benachrichtigung an Gewinner senden"""
|
||||||
|
# Email Notification
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
|
subject = f"Gratulation! Du hast die Auktion '{self.title}' gewonnen!"
|
||||||
|
message = render_to_string('auction/emails/winner_notification.html', {
|
||||||
|
'auction': self,
|
||||||
|
'user': self.winner
|
||||||
|
})
|
||||||
|
|
||||||
|
send_mail(
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
'noreply@kasico.de',
|
||||||
|
[self.winner.email],
|
||||||
|
html_message=message
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Bid(models.Model):
|
||||||
|
"""Gebot in einer Auktion"""
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
auction = models.ForeignKey(Auction, on_delete=models.CASCADE, related_name='bids')
|
||||||
|
bidder = models.ForeignKey(User, on_delete=models.CASCADE, related_name='bids')
|
||||||
|
amount = models.DecimalField(max_digits=10, decimal_places=2, validators=[MinValueValidator(0)])
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name = 'Gebot'
|
||||||
|
verbose_name_plural = 'Gebote'
|
||||||
|
unique_together = ['auction', 'bidder', 'amount'] # Verhindert doppelte Gebote
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.bidder.username} - €{self.amount} - {self.auction.title}"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Gebot speichern und Auktion aktualisieren"""
|
||||||
|
is_new = self.pk is None
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
if is_new:
|
||||||
|
# Auktion aktualisieren
|
||||||
|
self.auction.current_bid = self.amount
|
||||||
|
self.auction.total_bids += 1
|
||||||
|
|
||||||
|
# Unique bidders zählen
|
||||||
|
unique_bidders = self.auction.bids.values('bidder').distinct().count()
|
||||||
|
self.auction.total_bidders = unique_bidders
|
||||||
|
|
||||||
|
self.auction.save()
|
||||||
|
|
||||||
|
# Outbid Notifications senden
|
||||||
|
self.send_outbid_notifications()
|
||||||
|
|
||||||
|
def send_outbid_notifications(self):
|
||||||
|
"""Benachrichtigungen an überbotene Bieter senden"""
|
||||||
|
previous_bidders = self.auction.bids.filter(
|
||||||
|
created_at__lt=self.created_at
|
||||||
|
).exclude(bidder=self.bidder).values_list('bidder', flat=True).distinct()
|
||||||
|
|
||||||
|
for bidder_id in previous_bidders:
|
||||||
|
bidder = User.objects.get(id=bidder_id)
|
||||||
|
self.send_outbid_notification(bidder)
|
||||||
|
|
||||||
|
def send_outbid_notification(self, bidder):
|
||||||
|
"""Einzelne Outbid-Benachrichtigung senden"""
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
|
subject = f"Du wurdest überboten - {self.auction.title}"
|
||||||
|
message = render_to_string('auction/emails/outbid_notification.html', {
|
||||||
|
'auction': self.auction,
|
||||||
|
'bid': self,
|
||||||
|
'user': bidder
|
||||||
|
})
|
||||||
|
|
||||||
|
send_mail(
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
'noreply@kasico.de',
|
||||||
|
[bidder.email],
|
||||||
|
html_message=message
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuctionWatch(models.Model):
|
||||||
|
"""Auktion Watchlist"""
|
||||||
|
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='watched_auctions')
|
||||||
|
auction = models.ForeignKey(Auction, on_delete=models.CASCADE, related_name='watchers')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ['user', 'auction']
|
||||||
|
verbose_name = 'Auktion Watchlist'
|
||||||
|
verbose_name_plural = 'Auktion Watchlists'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} beobachtet {self.auction.title}"
|
||||||
|
|
||||||
|
|
||||||
|
class AuctionAnalytics(models.Model):
|
||||||
|
"""Auktion Analytics"""
|
||||||
|
|
||||||
|
auction = models.OneToOneField(Auction, on_delete=models.CASCADE, related_name='analytics')
|
||||||
|
total_views = models.IntegerField(default=0)
|
||||||
|
unique_visitors = models.IntegerField(default=0)
|
||||||
|
total_bids = models.IntegerField(default=0)
|
||||||
|
unique_bidders = models.IntegerField(default=0)
|
||||||
|
average_bid_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0)
|
||||||
|
highest_bid_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0)
|
||||||
|
conversion_rate = models.FloatField(default=0) # Views zu Bids
|
||||||
|
engagement_score = models.FloatField(default=0) # Kombinierter Score
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Auktion Analytics'
|
||||||
|
verbose_name_plural = 'Auktion Analytics'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Analytics für {self.auction.title}"
|
||||||
|
|
||||||
|
def update_analytics(self):
|
||||||
|
"""Analytics aktualisieren"""
|
||||||
|
bids = self.auction.bids.all()
|
||||||
|
|
||||||
|
self.total_bids = bids.count()
|
||||||
|
self.unique_bidders = bids.values('bidder').distinct().count()
|
||||||
|
|
||||||
|
if self.total_bids > 0:
|
||||||
|
self.average_bid_amount = bids.aggregate(
|
||||||
|
avg=models.Avg('amount')
|
||||||
|
)['avg']
|
||||||
|
self.highest_bid_amount = bids.aggregate(
|
||||||
|
max=models.Max('amount')
|
||||||
|
)['max']
|
||||||
|
|
||||||
|
if self.total_views > 0:
|
||||||
|
self.conversion_rate = (self.total_bids / self.total_views) * 100
|
||||||
|
|
||||||
|
# Engagement Score berechnen
|
||||||
|
self.engagement_score = (
|
||||||
|
self.total_views * 0.3 +
|
||||||
|
self.total_bids * 0.4 +
|
||||||
|
self.unique_bidders * 0.3
|
||||||
|
)
|
||||||
|
|
||||||
|
self.save()
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""
|
||||||
|
Auction URL Configuration
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = 'auction'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# Auction Views
|
||||||
|
path('', views.auction_list, name='auction_list'),
|
||||||
|
path('<uuid:auction_id>/', views.auction_detail, name='auction_detail'),
|
||||||
|
path('create/', views.auction_create, name='auction_create'),
|
||||||
|
path('<uuid:auction_id>/edit/', views.auction_edit, name='auction_edit'),
|
||||||
|
|
||||||
|
# User Actions
|
||||||
|
path('<uuid:auction_id>/bid/', views.place_bid, name='place_bid'),
|
||||||
|
path('<uuid:auction_id>/watch/', views.toggle_watch, name='toggle_watch'),
|
||||||
|
path('my-auctions/', views.my_auctions, name='my_auctions'),
|
||||||
|
path('my-bids/', views.my_bids, name='my_bids'),
|
||||||
|
path('watchlist/', views.watchlist, name='watchlist'),
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
path('<uuid:auction_id>/analytics/', views.auction_analytics, name='analytics'),
|
||||||
|
|
||||||
|
# API Endpoints
|
||||||
|
path('api/<uuid:auction_id>/', views.auction_api, name='auction_api'),
|
||||||
|
path('api/<uuid:auction_id>/bids/', views.bid_history_api, name='bid_history_api'),
|
||||||
|
path('api/<uuid:auction_id>/bid/', views.place_bid_api, name='place_bid_api'),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,372 @@
|
||||||
|
"""
|
||||||
|
Auction Views für Biet-System
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.db.models import Q, Count, Max
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from .models import Auction, Bid, AuctionWatch, AuctionAnalytics
|
||||||
|
from .forms import AuctionForm, BidForm
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def auction_list(request):
|
||||||
|
"""Liste aller Auktionen"""
|
||||||
|
# Filter
|
||||||
|
status_filter = request.GET.get('status', '')
|
||||||
|
fursuit_type_filter = request.GET.get('fursuit_type', '')
|
||||||
|
search_query = request.GET.get('search', '')
|
||||||
|
|
||||||
|
auctions = Auction.objects.select_related('created_by', 'winner').prefetch_related('bids')
|
||||||
|
|
||||||
|
if status_filter:
|
||||||
|
auctions = auctions.filter(status=status_filter)
|
||||||
|
|
||||||
|
if fursuit_type_filter:
|
||||||
|
auctions = auctions.filter(fursuit_type=fursuit_type_filter)
|
||||||
|
|
||||||
|
if search_query:
|
||||||
|
auctions = auctions.filter(
|
||||||
|
Q(title__icontains=search_query) |
|
||||||
|
Q(description__icontains=search_query) |
|
||||||
|
Q(character_description__icontains=search_query)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Nur aktive Auktionen anzeigen (außer für Admins)
|
||||||
|
if not request.user.is_staff:
|
||||||
|
auctions = auctions.filter(status='active')
|
||||||
|
|
||||||
|
# Sortierung
|
||||||
|
sort_by = request.GET.get('sort', '-created_at')
|
||||||
|
auctions = auctions.order_by(sort_by)
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
paginator = Paginator(auctions, 12)
|
||||||
|
page_number = request.GET.get('page')
|
||||||
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
|
# Statistiken
|
||||||
|
total_auctions = auctions.count()
|
||||||
|
active_auctions = auctions.filter(status='active').count()
|
||||||
|
total_bids = sum(auction.total_bids for auction in page_obj)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'page_obj': page_obj,
|
||||||
|
'status_filter': status_filter,
|
||||||
|
'fursuit_type_filter': fursuit_type_filter,
|
||||||
|
'search_query': search_query,
|
||||||
|
'sort_by': sort_by,
|
||||||
|
'total_auctions': total_auctions,
|
||||||
|
'active_auctions': active_auctions,
|
||||||
|
'total_bids': total_bids,
|
||||||
|
'status_choices': Auction.AUCTION_STATUS_CHOICES,
|
||||||
|
'fursuit_type_choices': Auction._meta.get_field('fursuit_type').choices,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'auction/auction_list.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
def auction_detail(request, auction_id):
|
||||||
|
"""Auktion Detail-Ansicht"""
|
||||||
|
auction = get_object_or_404(Auction, id=auction_id)
|
||||||
|
|
||||||
|
# View Count erhöhen
|
||||||
|
auction.view_count += 1
|
||||||
|
auction.save()
|
||||||
|
|
||||||
|
# Analytics aktualisieren
|
||||||
|
analytics, created = AuctionAnalytics.objects.get_or_create(auction=auction)
|
||||||
|
analytics.total_views += 1
|
||||||
|
analytics.save()
|
||||||
|
|
||||||
|
# Bid History
|
||||||
|
bid_history = auction.bids.select_related('bidder').order_by('-created_at')[:10]
|
||||||
|
|
||||||
|
# User's current bid
|
||||||
|
user_bid = None
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
user_bid = auction.bids.filter(bidder=request.user).order_by('-amount').first()
|
||||||
|
|
||||||
|
# Watchlist Status
|
||||||
|
is_watching = False
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
is_watching = auction.watchers.filter(user=request.user).exists()
|
||||||
|
|
||||||
|
# Similar Auctions
|
||||||
|
similar_auctions = Auction.objects.filter(
|
||||||
|
fursuit_type=auction.fursuit_type,
|
||||||
|
status='active'
|
||||||
|
).exclude(id=auction.id)[:3]
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'auction': auction,
|
||||||
|
'bid_history': bid_history,
|
||||||
|
'user_bid': user_bid,
|
||||||
|
'is_watching': is_watching,
|
||||||
|
'similar_auctions': similar_auctions,
|
||||||
|
'bid_form': BidForm() if auction.is_active else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'auction/auction_detail.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def auction_create(request):
|
||||||
|
"""Neue Auktion erstellen"""
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = AuctionForm(request.POST, request.FILES)
|
||||||
|
if form.is_valid():
|
||||||
|
auction = form.save(commit=False)
|
||||||
|
auction.created_by = request.user
|
||||||
|
|
||||||
|
# Start- und Endzeit setzen
|
||||||
|
if not auction.start_time:
|
||||||
|
auction.start_time = timezone.now()
|
||||||
|
if not auction.end_time:
|
||||||
|
auction.end_time = auction.start_time + timezone.timedelta(days=auction.duration_days)
|
||||||
|
|
||||||
|
auction.save()
|
||||||
|
|
||||||
|
messages.success(request, 'Auktion erfolgreich erstellt!')
|
||||||
|
return redirect('auction:auction_detail', auction_id=auction.id)
|
||||||
|
else:
|
||||||
|
form = AuctionForm()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'form': form,
|
||||||
|
'title': 'Neue Auktion erstellen',
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'auction/auction_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def auction_edit(request, auction_id):
|
||||||
|
"""Auktion bearbeiten"""
|
||||||
|
auction = get_object_or_404(Auction, id=auction_id, created_by=request.user)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = AuctionForm(request.POST, request.FILES, instance=auction)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, 'Auktion erfolgreich aktualisiert!')
|
||||||
|
return redirect('auction:auction_detail', auction_id=auction.id)
|
||||||
|
else:
|
||||||
|
form = AuctionForm(instance=auction)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'form': form,
|
||||||
|
'auction': auction,
|
||||||
|
'title': 'Auktion bearbeiten',
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'auction/auction_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def place_bid(request, auction_id):
|
||||||
|
"""Gebot platzieren"""
|
||||||
|
auction = get_object_or_404(Auction, id=auction_id)
|
||||||
|
|
||||||
|
if not auction.is_active:
|
||||||
|
return JsonResponse({'error': 'Auktion ist nicht aktiv'}, status=400)
|
||||||
|
|
||||||
|
form = BidForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
amount = form.cleaned_data['amount']
|
||||||
|
|
||||||
|
try:
|
||||||
|
bid = auction.place_bid(request.user, amount)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'bid_id': str(bid.id),
|
||||||
|
'amount': float(bid.amount),
|
||||||
|
'current_bid': float(auction.current_bid),
|
||||||
|
'total_bids': auction.total_bids,
|
||||||
|
'message': 'Gebot erfolgreich platziert!'
|
||||||
|
})
|
||||||
|
except ValueError as e:
|
||||||
|
return JsonResponse({'error': str(e)}, status=400)
|
||||||
|
else:
|
||||||
|
return JsonResponse({'error': 'Ungültige Eingabe'}, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def toggle_watch(request, auction_id):
|
||||||
|
"""Auktion zur Watchlist hinzufügen/entfernen"""
|
||||||
|
auction = get_object_or_404(Auction, id=auction_id)
|
||||||
|
|
||||||
|
watch, created = AuctionWatch.objects.get_or_create(
|
||||||
|
user=request.user,
|
||||||
|
auction=auction
|
||||||
|
)
|
||||||
|
|
||||||
|
if not created:
|
||||||
|
watch.delete()
|
||||||
|
is_watching = False
|
||||||
|
message = 'Von Watchlist entfernt'
|
||||||
|
else:
|
||||||
|
is_watching = True
|
||||||
|
message = 'Zur Watchlist hinzugefügt'
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'is_watching': is_watching,
|
||||||
|
'message': message
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def my_auctions(request):
|
||||||
|
"""Eigene Auktionen"""
|
||||||
|
auctions = Auction.objects.filter(created_by=request.user).order_by('-created_at')
|
||||||
|
|
||||||
|
# Filter
|
||||||
|
status_filter = request.GET.get('status', '')
|
||||||
|
if status_filter:
|
||||||
|
auctions = auctions.filter(status=status_filter)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'auctions': auctions,
|
||||||
|
'status_choices': Auction.AUCTION_STATUS_CHOICES,
|
||||||
|
'status_filter': status_filter,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'auction/my_auctions.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def my_bids(request):
|
||||||
|
"""Eigene Gebote"""
|
||||||
|
bids = Bid.objects.filter(bidder=request.user).select_related('auction').order_by('-created_at')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'bids': bids,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'auction/my_bids.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def watchlist(request):
|
||||||
|
"""Watchlist"""
|
||||||
|
watched_auctions = Auction.objects.filter(
|
||||||
|
watchers__user=request.user
|
||||||
|
).order_by('-created_at')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'watched_auctions': watched_auctions,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'auction/watchlist.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
# API Views für AJAX
|
||||||
|
@csrf_exempt
|
||||||
|
def auction_api(request, auction_id):
|
||||||
|
"""API für Auktion-Details"""
|
||||||
|
auction = get_object_or_404(Auction, id=auction_id)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'id': str(auction.id),
|
||||||
|
'title': auction.title,
|
||||||
|
'description': auction.description,
|
||||||
|
'current_bid': float(auction.current_bid) if auction.current_bid else None,
|
||||||
|
'starting_bid': float(auction.starting_bid),
|
||||||
|
'reserve_price': float(auction.reserve_price) if auction.reserve_price else None,
|
||||||
|
'status': auction.status,
|
||||||
|
'is_active': auction.is_active,
|
||||||
|
'time_remaining': auction.time_remaining_formatted,
|
||||||
|
'total_bids': auction.total_bids,
|
||||||
|
'total_bidders': auction.total_bidders,
|
||||||
|
'winner': auction.winner.username if auction.winner else None,
|
||||||
|
'created_at': auction.created_at.isoformat(),
|
||||||
|
'end_time': auction.end_time.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonResponse(data)
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
def bid_history_api(request, auction_id):
|
||||||
|
"""API für Bid-History"""
|
||||||
|
auction = get_object_or_404(Auction, id=auction_id)
|
||||||
|
bids = auction.bids.select_related('bidder').order_by('-created_at')[:20]
|
||||||
|
|
||||||
|
bid_list = []
|
||||||
|
for bid in bids:
|
||||||
|
bid_list.append({
|
||||||
|
'id': str(bid.id),
|
||||||
|
'bidder': bid.bidder.username,
|
||||||
|
'amount': float(bid.amount),
|
||||||
|
'created_at': bid.created_at.isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return JsonResponse({'bids': bid_list})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
def place_bid_api(request, auction_id):
|
||||||
|
"""API für Gebot platzieren"""
|
||||||
|
if request.method != 'POST':
|
||||||
|
return JsonResponse({'error': 'Nur POST erlaubt'}, status=405)
|
||||||
|
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'error': 'Nicht eingeloggt'}, status=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(request.body)
|
||||||
|
amount = float(data.get('amount', 0))
|
||||||
|
|
||||||
|
auction = get_object_or_404(Auction, id=auction_id)
|
||||||
|
|
||||||
|
if not auction.is_active:
|
||||||
|
return JsonResponse({'error': 'Auktion ist nicht aktiv'}, status=400)
|
||||||
|
|
||||||
|
bid = auction.place_bid(request.user, amount)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'bid_id': str(bid.id),
|
||||||
|
'amount': float(bid.amount),
|
||||||
|
'current_bid': float(auction.current_bid),
|
||||||
|
'total_bids': auction.total_bids,
|
||||||
|
})
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
return JsonResponse({'error': str(e)}, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
def auction_analytics(request, auction_id):
|
||||||
|
"""Auktion Analytics"""
|
||||||
|
auction = get_object_or_404(Auction, id=auction_id)
|
||||||
|
analytics = get_object_or_404(AuctionAnalytics, auction=auction)
|
||||||
|
|
||||||
|
# Bid Distribution
|
||||||
|
bid_distribution = auction.bids.extra(
|
||||||
|
select={'hour': "EXTRACT(hour FROM created_at)"}
|
||||||
|
).values('hour').annotate(count=Count('id')).order_by('hour')
|
||||||
|
|
||||||
|
# Top Bidders
|
||||||
|
top_bidders = auction.bids.values('bidder__username').annotate(
|
||||||
|
total_bids=Count('id'),
|
||||||
|
max_bid=Max('amount')
|
||||||
|
).order_by('-max_bid')[:5]
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'auction': auction,
|
||||||
|
'analytics': analytics,
|
||||||
|
'bid_distribution': bid_distribution,
|
||||||
|
'top_bidders': top_bidders,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'auction/analytics.html', context)
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1,335 @@
|
||||||
|
"""
|
||||||
|
WebSocket Consumers für Live-Chat
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||||
|
from channels.db import database_sync_to_async
|
||||||
|
from django.utils import timezone
|
||||||
|
from .models import ChatRoom, ChatMessage, UserOnlineStatus, ChatNotification
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class ChatConsumer(AsyncWebsocketConsumer):
|
||||||
|
"""WebSocket Consumer für Chat-Funktionalität"""
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
"""WebSocket-Verbindung herstellen"""
|
||||||
|
self.room_id = self.scope['url_route']['kwargs']['room_id']
|
||||||
|
self.room_group_name = f'chat_{self.room_id}'
|
||||||
|
self.user = self.scope['user']
|
||||||
|
|
||||||
|
# Prüfen ob User Zugriff auf Chat-Raum hat
|
||||||
|
if await self.can_access_room():
|
||||||
|
# Zur Chat-Gruppe beitreten
|
||||||
|
await self.channel_layer.group_add(
|
||||||
|
self.room_group_name,
|
||||||
|
self.channel_name
|
||||||
|
)
|
||||||
|
|
||||||
|
# Online-Status aktualisieren
|
||||||
|
await self.update_online_status(True)
|
||||||
|
|
||||||
|
await self.accept()
|
||||||
|
|
||||||
|
# System-Nachricht senden
|
||||||
|
await self.send_system_message(f"{self.user.username} ist dem Chat beigetreten")
|
||||||
|
|
||||||
|
# Ungelesene Nachrichten als gelesen markieren
|
||||||
|
await self.mark_messages_as_read()
|
||||||
|
else:
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
async def disconnect(self, close_code):
|
||||||
|
"""WebSocket-Verbindung trennen"""
|
||||||
|
# Online-Status aktualisieren
|
||||||
|
await self.update_online_status(False)
|
||||||
|
|
||||||
|
# System-Nachricht senden
|
||||||
|
await self.send_system_message(f"{self.user.username} hat den Chat verlassen")
|
||||||
|
|
||||||
|
# Aus Chat-Gruppe austreten
|
||||||
|
await self.channel_layer.group_discard(
|
||||||
|
self.room_group_name,
|
||||||
|
self.channel_name
|
||||||
|
)
|
||||||
|
|
||||||
|
async def receive(self, text_data):
|
||||||
|
"""Nachricht vom Client empfangen"""
|
||||||
|
try:
|
||||||
|
data = json.loads(text_data)
|
||||||
|
message_type = data.get('type', 'message')
|
||||||
|
|
||||||
|
if message_type == 'message':
|
||||||
|
await self.handle_message(data)
|
||||||
|
elif message_type == 'typing':
|
||||||
|
await self.handle_typing(data)
|
||||||
|
elif message_type == 'read':
|
||||||
|
await self.handle_read_messages()
|
||||||
|
elif message_type == 'file_upload':
|
||||||
|
await self.handle_file_upload(data)
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
await self.send_error_message("Ungültiges JSON-Format")
|
||||||
|
except Exception as e:
|
||||||
|
await self.send_error_message(f"Fehler: {str(e)}")
|
||||||
|
|
||||||
|
async def handle_message(self, data):
|
||||||
|
"""Chat-Nachricht verarbeiten"""
|
||||||
|
content = data.get('content', '').strip()
|
||||||
|
message_type = data.get('message_type', 'text')
|
||||||
|
|
||||||
|
if not content and message_type == 'text':
|
||||||
|
return
|
||||||
|
|
||||||
|
# Nachricht in Datenbank speichern
|
||||||
|
message = await self.save_message(content, message_type, data.get('file_url'))
|
||||||
|
|
||||||
|
# Nachricht an alle im Chat senden
|
||||||
|
await self.channel_layer.group_send(
|
||||||
|
self.room_group_name,
|
||||||
|
{
|
||||||
|
'type': 'chat_message',
|
||||||
|
'message': {
|
||||||
|
'id': str(message.id),
|
||||||
|
'sender': message.sender.username,
|
||||||
|
'sender_id': message.sender.id,
|
||||||
|
'content': message.content,
|
||||||
|
'message_type': message.message_type,
|
||||||
|
'file_url': message.file.url if message.file else None,
|
||||||
|
'image_url': message.image.url if message.image else None,
|
||||||
|
'created_at': message.created_at.isoformat(),
|
||||||
|
'is_system': message.is_system_message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Benachrichtigung an andere User senden
|
||||||
|
await self.send_notification_to_others(message)
|
||||||
|
|
||||||
|
async def handle_typing(self, data):
|
||||||
|
"""Typing-Indicator verarbeiten"""
|
||||||
|
is_typing = data.get('typing', False)
|
||||||
|
|
||||||
|
await self.channel_layer.group_send(
|
||||||
|
self.room_group_name,
|
||||||
|
{
|
||||||
|
'type': 'typing_indicator',
|
||||||
|
'user': self.user.username,
|
||||||
|
'user_id': self.user.id,
|
||||||
|
'typing': is_typing
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_read_messages(self):
|
||||||
|
"""Nachrichten als gelesen markieren"""
|
||||||
|
await self.mark_messages_as_read()
|
||||||
|
|
||||||
|
await self.channel_layer.group_send(
|
||||||
|
self.room_group_name,
|
||||||
|
{
|
||||||
|
'type': 'messages_read',
|
||||||
|
'user_id': self.user.id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_file_upload(self, data):
|
||||||
|
"""Datei-Upload verarbeiten"""
|
||||||
|
file_url = data.get('file_url')
|
||||||
|
file_type = data.get('file_type', 'file')
|
||||||
|
|
||||||
|
if file_type == 'image':
|
||||||
|
message_type = 'image'
|
||||||
|
else:
|
||||||
|
message_type = 'file'
|
||||||
|
|
||||||
|
message = await self.save_message(
|
||||||
|
f"Datei hochgeladen: {data.get('filename', 'Unbekannt')}",
|
||||||
|
message_type,
|
||||||
|
file_url
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.channel_layer.group_send(
|
||||||
|
self.room_group_name,
|
||||||
|
{
|
||||||
|
'type': 'chat_message',
|
||||||
|
'message': {
|
||||||
|
'id': str(message.id),
|
||||||
|
'sender': message.sender.username,
|
||||||
|
'sender_id': message.sender.id,
|
||||||
|
'content': message.content,
|
||||||
|
'message_type': message.message_type,
|
||||||
|
'file_url': file_url,
|
||||||
|
'filename': data.get('filename'),
|
||||||
|
'created_at': message.created_at.isoformat(),
|
||||||
|
'is_system': False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def chat_message(self, event):
|
||||||
|
"""Chat-Nachricht an Client senden"""
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'message',
|
||||||
|
'message': event['message']
|
||||||
|
}))
|
||||||
|
|
||||||
|
async def typing_indicator(self, event):
|
||||||
|
"""Typing-Indicator an Client senden"""
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'typing',
|
||||||
|
'user': event['user'],
|
||||||
|
'user_id': event['user_id'],
|
||||||
|
'typing': event['typing']
|
||||||
|
}))
|
||||||
|
|
||||||
|
async def messages_read(self, event):
|
||||||
|
"""Nachrichten-als-gelesen-Status senden"""
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'read',
|
||||||
|
'user_id': event['user_id']
|
||||||
|
}))
|
||||||
|
|
||||||
|
async def system_message(self, event):
|
||||||
|
"""System-Nachricht an Client senden"""
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'system',
|
||||||
|
'message': event['message']
|
||||||
|
}))
|
||||||
|
|
||||||
|
async def error_message(self, event):
|
||||||
|
"""Fehler-Nachricht an Client senden"""
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'error',
|
||||||
|
'message': event['message']
|
||||||
|
}))
|
||||||
|
|
||||||
|
# Database Operations
|
||||||
|
@database_sync_to_async
|
||||||
|
def can_access_room(self):
|
||||||
|
"""Prüfen ob User Zugriff auf Chat-Raum hat"""
|
||||||
|
try:
|
||||||
|
room = ChatRoom.objects.get(id=self.room_id)
|
||||||
|
return self.user == room.customer or self.user == room.admin or self.user.is_staff
|
||||||
|
except ChatRoom.DoesNotExist:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def save_message(self, content, message_type, file_url=None):
|
||||||
|
"""Nachricht in Datenbank speichern"""
|
||||||
|
room = ChatRoom.objects.get(id=self.room_id)
|
||||||
|
|
||||||
|
message = ChatMessage.objects.create(
|
||||||
|
room=room,
|
||||||
|
sender=self.user,
|
||||||
|
content=content,
|
||||||
|
message_type=message_type,
|
||||||
|
is_system_message=False
|
||||||
|
)
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def update_online_status(self, is_online):
|
||||||
|
"""Online-Status aktualisieren"""
|
||||||
|
status, created = UserOnlineStatus.objects.get_or_create(user=self.user)
|
||||||
|
status.is_online = is_online
|
||||||
|
status.current_room = ChatRoom.objects.get(id=self.room_id) if is_online else None
|
||||||
|
status.save()
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def mark_messages_as_read(self):
|
||||||
|
"""Nachrichten als gelesen markieren"""
|
||||||
|
room = ChatRoom.objects.get(id=self.room_id)
|
||||||
|
room.mark_messages_as_read(self.user)
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def send_system_message(self, content):
|
||||||
|
"""System-Nachricht senden"""
|
||||||
|
room = ChatRoom.objects.get(id=self.room_id)
|
||||||
|
|
||||||
|
message = ChatMessage.objects.create(
|
||||||
|
room=room,
|
||||||
|
sender=self.user,
|
||||||
|
content=content,
|
||||||
|
message_type='system',
|
||||||
|
is_system_message=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def send_notification_to_others(self, message):
|
||||||
|
"""Benachrichtigung an andere User senden"""
|
||||||
|
room = message.room
|
||||||
|
other_users = room.get_other_users(message.sender)
|
||||||
|
|
||||||
|
for user in other_users:
|
||||||
|
ChatNotification.objects.create(
|
||||||
|
user=user,
|
||||||
|
room=room,
|
||||||
|
notification_type='new_message',
|
||||||
|
title=f'Neue Nachricht von {message.sender.username}',
|
||||||
|
message=f'{message.content[:100]}...' if len(message.content) > 100 else message.content
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationConsumer(AsyncWebsocketConsumer):
|
||||||
|
"""WebSocket Consumer für Benachrichtigungen"""
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
"""WebSocket-Verbindung herstellen"""
|
||||||
|
self.user = self.scope['user']
|
||||||
|
|
||||||
|
if self.user.is_authenticated:
|
||||||
|
self.notification_group_name = f'notifications_{self.user.id}'
|
||||||
|
|
||||||
|
# Zur Benachrichtigungs-Gruppe beitreten
|
||||||
|
await self.channel_layer.group_add(
|
||||||
|
self.notification_group_name,
|
||||||
|
self.channel_name
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.accept()
|
||||||
|
else:
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
async def disconnect(self, close_code):
|
||||||
|
"""WebSocket-Verbindung trennen"""
|
||||||
|
if hasattr(self, 'notification_group_name'):
|
||||||
|
await self.channel_layer.group_discard(
|
||||||
|
self.notification_group_name,
|
||||||
|
self.channel_name
|
||||||
|
)
|
||||||
|
|
||||||
|
async def receive(self, text_data):
|
||||||
|
"""Nachricht vom Client empfangen"""
|
||||||
|
try:
|
||||||
|
data = json.loads(text_data)
|
||||||
|
action = data.get('action')
|
||||||
|
|
||||||
|
if action == 'mark_read':
|
||||||
|
await self.mark_notification_read(data.get('notification_id'))
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
await self.send_error_message("Ungültiges JSON-Format")
|
||||||
|
|
||||||
|
async def notification_message(self, event):
|
||||||
|
"""Benachrichtigung an Client senden"""
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'notification',
|
||||||
|
'notification': event['notification']
|
||||||
|
}))
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def mark_notification_read(self, notification_id):
|
||||||
|
"""Benachrichtigung als gelesen markieren"""
|
||||||
|
try:
|
||||||
|
notification = ChatNotification.objects.get(
|
||||||
|
id=notification_id,
|
||||||
|
user=self.user
|
||||||
|
)
|
||||||
|
notification.read_at = timezone.now()
|
||||||
|
notification.save()
|
||||||
|
except ChatNotification.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
from django import forms
|
||||||
|
from .models import ChatMessage
|
||||||
|
|
||||||
|
class QuickResponseForm(forms.ModelForm):
|
||||||
|
"""Form für schnelle Antworten im Chat"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ChatMessage
|
||||||
|
fields = ['content']
|
||||||
|
widgets = {
|
||||||
|
'content': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': 'Ihre Nachricht...'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['content'].label = 'Nachricht'
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
"""
|
||||||
|
Chat Models für Live-Chat System
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.conf import settings
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class ChatRoom(models.Model):
|
||||||
|
"""Chat-Raum für Kunde-Admin Kommunikation"""
|
||||||
|
|
||||||
|
ROOM_STATUS_CHOICES = [
|
||||||
|
('active', 'Aktiv'),
|
||||||
|
('waiting', 'Wartend'),
|
||||||
|
('closed', 'Geschlossen'),
|
||||||
|
('archived', 'Archiviert'),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
customer = models.ForeignKey(User, on_delete=models.CASCADE, related_name='customer_chats')
|
||||||
|
admin = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='admin_chats')
|
||||||
|
status = models.CharField(max_length=20, choices=ROOM_STATUS_CHOICES, default='waiting')
|
||||||
|
subject = models.CharField(max_length=200, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
last_message_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-last_message_at', '-created_at']
|
||||||
|
verbose_name = 'Chat-Raum'
|
||||||
|
verbose_name_plural = 'Chat-Räume'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Chat {self.id} - {self.customer.username}"
|
||||||
|
|
||||||
|
def get_unread_count(self, user):
|
||||||
|
"""Anzahl ungelesener Nachrichten für User"""
|
||||||
|
return self.messages.filter(
|
||||||
|
sender__in=self.get_other_users(user),
|
||||||
|
read_at__isnull=True
|
||||||
|
).count()
|
||||||
|
|
||||||
|
def get_other_users(self, user):
|
||||||
|
"""Andere User im Chat"""
|
||||||
|
if user == self.customer:
|
||||||
|
return [self.admin] if self.admin else []
|
||||||
|
elif user == self.admin:
|
||||||
|
return [self.customer]
|
||||||
|
return []
|
||||||
|
|
||||||
|
def mark_messages_as_read(self, user):
|
||||||
|
"""Nachrichten als gelesen markieren"""
|
||||||
|
other_users = self.get_other_users(user)
|
||||||
|
self.messages.filter(
|
||||||
|
sender__in=other_users,
|
||||||
|
read_at__isnull=True
|
||||||
|
).update(read_at=timezone.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessage(models.Model):
|
||||||
|
"""Chat-Nachricht"""
|
||||||
|
|
||||||
|
MESSAGE_TYPE_CHOICES = [
|
||||||
|
('text', 'Text'),
|
||||||
|
('image', 'Bild'),
|
||||||
|
('file', 'Datei'),
|
||||||
|
('system', 'System'),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
room = models.ForeignKey(ChatRoom, on_delete=models.CASCADE, related_name='messages')
|
||||||
|
sender = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sent_messages')
|
||||||
|
message_type = models.CharField(max_length=20, choices=MESSAGE_TYPE_CHOICES, default='text')
|
||||||
|
content = models.TextField()
|
||||||
|
file = models.FileField(upload_to='chat_files/', null=True, blank=True)
|
||||||
|
image = models.ImageField(upload_to='chat_images/', null=True, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
read_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
is_system_message = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['created_at']
|
||||||
|
verbose_name = 'Chat-Nachricht'
|
||||||
|
verbose_name_plural = 'Chat-Nachrichten'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.sender.username}: {self.content[:50]}"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Nachricht speichern und Room aktualisieren"""
|
||||||
|
is_new = self.pk is None
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
if is_new:
|
||||||
|
# Room last_message_at aktualisieren
|
||||||
|
self.room.last_message_at = self.created_at
|
||||||
|
self.room.save(update_fields=['last_message_at'])
|
||||||
|
|
||||||
|
|
||||||
|
class ChatNotification(models.Model):
|
||||||
|
"""Chat-Benachrichtigungen"""
|
||||||
|
|
||||||
|
NOTIFICATION_TYPE_CHOICES = [
|
||||||
|
('new_message', 'Neue Nachricht'),
|
||||||
|
('room_assigned', 'Raum zugewiesen'),
|
||||||
|
('room_closed', 'Raum geschlossen'),
|
||||||
|
('customer_online', 'Kunde online'),
|
||||||
|
('customer_offline', 'Kunde offline'),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='chat_notifications')
|
||||||
|
room = models.ForeignKey(ChatRoom, on_delete=models.CASCADE, related_name='notifications')
|
||||||
|
notification_type = models.CharField(max_length=20, choices=NOTIFICATION_TYPE_CHOICES)
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
message = models.TextField()
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
read_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
sent_via_email = models.BooleanField(default=False)
|
||||||
|
sent_via_push = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name = 'Chat-Benachrichtigung'
|
||||||
|
verbose_name_plural = 'Chat-Benachrichtigungen'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.notification_type}: {self.title}"
|
||||||
|
|
||||||
|
|
||||||
|
class UserOnlineStatus(models.Model):
|
||||||
|
"""Online-Status von Benutzern"""
|
||||||
|
|
||||||
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='online_status')
|
||||||
|
is_online = models.BooleanField(default=False)
|
||||||
|
last_seen = models.DateTimeField(auto_now=True)
|
||||||
|
current_room = models.ForeignKey(ChatRoom, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Online-Status'
|
||||||
|
verbose_name_plural = 'Online-Status'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
status = "Online" if self.is_online else "Offline"
|
||||||
|
return f"{self.user.username} - {status}"
|
||||||
|
|
||||||
|
|
||||||
|
class QuickResponse(models.Model):
|
||||||
|
"""Schnelle Antworten für Admins"""
|
||||||
|
|
||||||
|
category = models.CharField(max_length=50, choices=[
|
||||||
|
('greeting', 'Begrüßung'),
|
||||||
|
('pricing', 'Preise'),
|
||||||
|
('shipping', 'Versand'),
|
||||||
|
('custom_order', 'Custom Order'),
|
||||||
|
('technical', 'Technische Fragen'),
|
||||||
|
('general', 'Allgemein'),
|
||||||
|
])
|
||||||
|
title = models.CharField(max_length=100)
|
||||||
|
content = models.TextField()
|
||||||
|
created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='quick_responses')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
use_count = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['category', 'title']
|
||||||
|
verbose_name = 'Schnelle Antwort'
|
||||||
|
verbose_name_plural = 'Schnelle Antworten'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.category}: {self.title}"
|
||||||
|
|
||||||
|
def increment_use_count(self):
|
||||||
|
"""Verwendungszähler erhöhen"""
|
||||||
|
self.use_count += 1
|
||||||
|
self.save(update_fields=['use_count'])
|
||||||
|
|
||||||
|
|
||||||
|
class ChatAnalytics(models.Model):
|
||||||
|
"""Chat-Analytics für Performance-Tracking"""
|
||||||
|
|
||||||
|
date = models.DateField()
|
||||||
|
total_messages = models.IntegerField(default=0)
|
||||||
|
total_rooms = models.IntegerField(default=0)
|
||||||
|
active_rooms = models.IntegerField(default=0)
|
||||||
|
avg_response_time = models.FloatField(default=0) # in Sekunden
|
||||||
|
customer_satisfaction = models.FloatField(default=0) # 0-5 Sterne
|
||||||
|
total_duration = models.FloatField(default=0) # in Minuten
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ['date']
|
||||||
|
verbose_name = 'Chat-Analytics'
|
||||||
|
verbose_name_plural = 'Chat-Analytics'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Chat Analytics - {self.date}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_or_create_today(cls):
|
||||||
|
"""Heutige Analytics erstellen oder abrufen"""
|
||||||
|
today = timezone.now().date()
|
||||||
|
analytics, created = cls.objects.get_or_create(date=today)
|
||||||
|
return analytics
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
"""
|
||||||
|
WebSocket Routing für Chat-System
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.urls import re_path
|
||||||
|
from . import consumers
|
||||||
|
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
# Chat-Raum WebSocket
|
||||||
|
re_path(r'ws/chat/(?P<room_id>[^/]+)/$', consumers.ChatConsumer.as_asgi()),
|
||||||
|
|
||||||
|
# Benachrichtigungen WebSocket
|
||||||
|
re_path(r'ws/notifications/$', consumers.NotificationConsumer.as_asgi()),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""
|
||||||
|
Chat URL Configuration
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = 'chat'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# Admin Dashboard
|
||||||
|
path('dashboard/', views.chat_dashboard, name='dashboard'),
|
||||||
|
path('rooms/', views.chat_room_list, name='room_list'),
|
||||||
|
path('rooms/<uuid:room_id>/', views.chat_room_detail, name='room_detail'),
|
||||||
|
path('rooms/<uuid:room_id>/assign/', views.chat_room_assign, name='room_assign'),
|
||||||
|
path('rooms/<uuid:room_id>/close/', views.chat_room_close, name='room_close'),
|
||||||
|
|
||||||
|
# Quick Responses
|
||||||
|
path('quick-responses/', views.quick_response_list, name='quick_response_list'),
|
||||||
|
path('quick-responses/<int:response_id>/edit/', views.quick_response_edit, name='quick_response_edit'),
|
||||||
|
path('quick-responses/<int:response_id>/delete/', views.quick_response_delete, name='quick_response_delete'),
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
path('analytics/', views.chat_analytics, name='analytics'),
|
||||||
|
|
||||||
|
# API Endpoints
|
||||||
|
path('api/rooms/<uuid:room_id>/messages/', views.chat_messages_api, name='messages_api'),
|
||||||
|
path('api/rooms/<uuid:room_id>/send/', views.chat_send_message_api, name='send_message_api'),
|
||||||
|
path('api/rooms/<uuid:room_id>/read/', views.chat_mark_read_api, name='mark_read_api'),
|
||||||
|
path('api/quick-responses/<int:response_id>/use/', views.quick_response_use_api, name='quick_response_use_api'),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,397 @@
|
||||||
|
"""
|
||||||
|
Chat Views für Admin-Interface und Chat-Management
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
|
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.db.models import Q, Count
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from .models import ChatRoom, ChatMessage, UserOnlineStatus, QuickResponse, ChatAnalytics
|
||||||
|
from .forms import QuickResponseForm
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin(user):
|
||||||
|
"""Prüfen ob User Admin ist"""
|
||||||
|
return user.is_staff or user.is_superuser
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def chat_dashboard(request):
|
||||||
|
"""Admin Chat Dashboard"""
|
||||||
|
if not is_admin(request.user):
|
||||||
|
messages.error(request, 'Zugriff verweigert.')
|
||||||
|
return redirect('home')
|
||||||
|
|
||||||
|
# Chat-Statistiken
|
||||||
|
total_rooms = ChatRoom.objects.count()
|
||||||
|
active_rooms = ChatRoom.objects.filter(status='active').count()
|
||||||
|
waiting_rooms = ChatRoom.objects.filter(status='waiting').count()
|
||||||
|
today_messages = ChatMessage.objects.filter(
|
||||||
|
created_at__date=timezone.now().date()
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Online Admins
|
||||||
|
online_admins = UserOnlineStatus.objects.filter(
|
||||||
|
is_online=True,
|
||||||
|
user__is_staff=True
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Letzte Chat-Räume
|
||||||
|
recent_rooms = ChatRoom.objects.select_related('customer', 'admin').order_by('-last_message_at')[:10]
|
||||||
|
|
||||||
|
# Quick Responses
|
||||||
|
quick_responses = QuickResponse.objects.filter(is_active=True).order_by('category', 'title')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'total_rooms': total_rooms,
|
||||||
|
'active_rooms': active_rooms,
|
||||||
|
'waiting_rooms': waiting_rooms,
|
||||||
|
'today_messages': today_messages,
|
||||||
|
'online_admins': online_admins,
|
||||||
|
'recent_rooms': recent_rooms,
|
||||||
|
'quick_responses': quick_responses,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'chat/dashboard.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def chat_room_list(request):
|
||||||
|
"""Liste aller Chat-Räume"""
|
||||||
|
if not is_admin(request.user):
|
||||||
|
messages.error(request, 'Zugriff verweigert.')
|
||||||
|
return redirect('home')
|
||||||
|
|
||||||
|
# Filter
|
||||||
|
status_filter = request.GET.get('status', '')
|
||||||
|
search_query = request.GET.get('search', '')
|
||||||
|
|
||||||
|
rooms = ChatRoom.objects.select_related('customer', 'admin').order_by('-last_message_at')
|
||||||
|
|
||||||
|
if status_filter:
|
||||||
|
rooms = rooms.filter(status=status_filter)
|
||||||
|
|
||||||
|
if search_query:
|
||||||
|
rooms = rooms.filter(
|
||||||
|
Q(customer__username__icontains=search_query) |
|
||||||
|
Q(subject__icontains=search_query)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
paginator = Paginator(rooms, 20)
|
||||||
|
page_number = request.GET.get('page')
|
||||||
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'page_obj': page_obj,
|
||||||
|
'status_filter': status_filter,
|
||||||
|
'search_query': search_query,
|
||||||
|
'status_choices': ChatRoom.ROOM_STATUS_CHOICES,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'chat/room_list.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def chat_room_detail(request, room_id):
|
||||||
|
"""Chat-Raum Detail-Ansicht"""
|
||||||
|
if not is_admin(request.user):
|
||||||
|
messages.error(request, 'Zugriff verweigert.')
|
||||||
|
return redirect('home')
|
||||||
|
|
||||||
|
room = get_object_or_404(ChatRoom, id=room_id)
|
||||||
|
|
||||||
|
# Admin dem Raum zuweisen wenn noch nicht zugewiesen
|
||||||
|
if not room.admin and room.status == 'waiting':
|
||||||
|
room.admin = request.user
|
||||||
|
room.status = 'active'
|
||||||
|
room.save()
|
||||||
|
|
||||||
|
# System-Nachricht
|
||||||
|
ChatMessage.objects.create(
|
||||||
|
room=room,
|
||||||
|
sender=request.user,
|
||||||
|
content=f"{request.user.username} ist dem Chat beigetreten",
|
||||||
|
message_type='system',
|
||||||
|
is_system_message=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Nachrichten laden
|
||||||
|
messages = room.messages.select_related('sender').order_by('created_at')
|
||||||
|
|
||||||
|
# Quick Responses
|
||||||
|
quick_responses = QuickResponse.objects.filter(is_active=True).order_by('category', 'title')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'room': room,
|
||||||
|
'messages': messages,
|
||||||
|
'quick_responses': quick_responses,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'chat/room_detail.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def chat_room_assign(request, room_id):
|
||||||
|
"""Chat-Raum einem Admin zuweisen"""
|
||||||
|
if not is_admin(request.user):
|
||||||
|
return JsonResponse({'error': 'Zugriff verweigert'}, status=403)
|
||||||
|
|
||||||
|
room = get_object_or_404(ChatRoom, id=room_id)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
admin_id = request.POST.get('admin_id')
|
||||||
|
|
||||||
|
if admin_id:
|
||||||
|
try:
|
||||||
|
admin = User.objects.get(id=admin_id, is_staff=True)
|
||||||
|
room.admin = admin
|
||||||
|
room.status = 'active'
|
||||||
|
room.save()
|
||||||
|
|
||||||
|
# System-Nachricht
|
||||||
|
ChatMessage.objects.create(
|
||||||
|
room=room,
|
||||||
|
sender=request.user,
|
||||||
|
content=f"Chat wurde {admin.username} zugewiesen",
|
||||||
|
message_type='system',
|
||||||
|
is_system_message=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return JsonResponse({'success': True})
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return JsonResponse({'error': 'Admin nicht gefunden'}, status=400)
|
||||||
|
|
||||||
|
return JsonResponse({'error': 'Ungültige Anfrage'}, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def chat_room_close(request, room_id):
|
||||||
|
"""Chat-Raum schließen"""
|
||||||
|
if not is_admin(request.user):
|
||||||
|
return JsonResponse({'error': 'Zugriff verweigert'}, status=403)
|
||||||
|
|
||||||
|
room = get_object_or_404(ChatRoom, id=room_id)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
room.status = 'closed'
|
||||||
|
room.save()
|
||||||
|
|
||||||
|
# System-Nachricht
|
||||||
|
ChatMessage.objects.create(
|
||||||
|
room=room,
|
||||||
|
sender=request.user,
|
||||||
|
content="Chat wurde geschlossen",
|
||||||
|
message_type='system',
|
||||||
|
is_system_message=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return JsonResponse({'success': True})
|
||||||
|
|
||||||
|
return JsonResponse({'error': 'Ungültige Anfrage'}, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def quick_response_list(request):
|
||||||
|
"""Quick Response Verwaltung"""
|
||||||
|
if not is_admin(request.user):
|
||||||
|
messages.error(request, 'Zugriff verweigert.')
|
||||||
|
return redirect('home')
|
||||||
|
|
||||||
|
responses = QuickResponse.objects.all().order_by('category', 'title')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = QuickResponseForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
response = form.save(commit=False)
|
||||||
|
response.created_by = request.user
|
||||||
|
response.save()
|
||||||
|
messages.success(request, 'Quick Response erstellt.')
|
||||||
|
return redirect('chat:quick_response_list')
|
||||||
|
else:
|
||||||
|
form = QuickResponseForm()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'responses': responses,
|
||||||
|
'form': form,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'chat/quick_response_list.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def quick_response_edit(request, response_id):
|
||||||
|
"""Quick Response bearbeiten"""
|
||||||
|
if not is_admin(request.user):
|
||||||
|
messages.error(request, 'Zugriff verweigert.')
|
||||||
|
return redirect('home')
|
||||||
|
|
||||||
|
response = get_object_or_404(QuickResponse, id=response_id)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = QuickResponseForm(request.POST, instance=response)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, 'Quick Response aktualisiert.')
|
||||||
|
return redirect('chat:quick_response_list')
|
||||||
|
else:
|
||||||
|
form = QuickResponseForm(instance=response)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'response': response,
|
||||||
|
'form': form,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'chat/quick_response_edit.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def quick_response_delete(request, response_id):
|
||||||
|
"""Quick Response löschen"""
|
||||||
|
if not is_admin(request.user):
|
||||||
|
return JsonResponse({'error': 'Zugriff verweigert'}, status=403)
|
||||||
|
|
||||||
|
response = get_object_or_404(QuickResponse, id=response_id)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
response.delete()
|
||||||
|
return JsonResponse({'success': True})
|
||||||
|
|
||||||
|
return JsonResponse({'error': 'Ungültige Anfrage'}, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def chat_analytics(request):
|
||||||
|
"""Chat Analytics Dashboard"""
|
||||||
|
if not is_admin(request.user):
|
||||||
|
messages.error(request, 'Zugriff verweigert.')
|
||||||
|
return redirect('home')
|
||||||
|
|
||||||
|
# Analytics für heute
|
||||||
|
today_analytics = ChatAnalytics.get_or_create_today()
|
||||||
|
|
||||||
|
# Wöchentliche Statistiken
|
||||||
|
from datetime import timedelta
|
||||||
|
week_ago = timezone.now().date() - timedelta(days=7)
|
||||||
|
|
||||||
|
weekly_stats = {
|
||||||
|
'total_messages': ChatMessage.objects.filter(
|
||||||
|
created_at__date__gte=week_ago
|
||||||
|
).count(),
|
||||||
|
'total_rooms': ChatRoom.objects.filter(
|
||||||
|
created_at__date__gte=week_ago
|
||||||
|
).count(),
|
||||||
|
'avg_response_time': 120, # Placeholder - würde aus echten Daten berechnet
|
||||||
|
'customer_satisfaction': 4.2, # Placeholder
|
||||||
|
}
|
||||||
|
|
||||||
|
# Top Quick Responses
|
||||||
|
top_responses = QuickResponse.objects.filter(
|
||||||
|
use_count__gt=0
|
||||||
|
).order_by('-use_count')[:5]
|
||||||
|
|
||||||
|
# Online Status
|
||||||
|
online_users = UserOnlineStatus.objects.filter(is_online=True)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'today_analytics': today_analytics,
|
||||||
|
'weekly_stats': weekly_stats,
|
||||||
|
'top_responses': top_responses,
|
||||||
|
'online_users': online_users,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'chat/analytics.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
# API Views für AJAX
|
||||||
|
@login_required
|
||||||
|
def chat_messages_api(request, room_id):
|
||||||
|
"""API für Chat-Nachrichten"""
|
||||||
|
if not is_admin(request.user):
|
||||||
|
return JsonResponse({'error': 'Zugriff verweigert'}, status=403)
|
||||||
|
|
||||||
|
room = get_object_or_404(ChatRoom, id=room_id)
|
||||||
|
messages = room.messages.select_related('sender').order_by('created_at')
|
||||||
|
|
||||||
|
message_list = []
|
||||||
|
for msg in messages:
|
||||||
|
message_list.append({
|
||||||
|
'id': str(msg.id),
|
||||||
|
'sender': msg.sender.username,
|
||||||
|
'sender_id': msg.sender.id,
|
||||||
|
'content': msg.content,
|
||||||
|
'message_type': msg.message_type,
|
||||||
|
'file_url': msg.file.url if msg.file else None,
|
||||||
|
'image_url': msg.image.url if msg.image else None,
|
||||||
|
'created_at': msg.created_at.isoformat(),
|
||||||
|
'is_system': msg.is_system_message,
|
||||||
|
'read_at': msg.read_at.isoformat() if msg.read_at else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return JsonResponse({'messages': message_list})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def chat_send_message_api(request, room_id):
|
||||||
|
"""API für Nachrichten senden"""
|
||||||
|
if not is_admin(request.user):
|
||||||
|
return JsonResponse({'error': 'Zugriff verweigert'}, status=403)
|
||||||
|
|
||||||
|
room = get_object_or_404(ChatRoom, id=room_id)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
try:
|
||||||
|
data = json.loads(request.body)
|
||||||
|
content = data.get('content', '').strip()
|
||||||
|
message_type = data.get('message_type', 'text')
|
||||||
|
|
||||||
|
if not content and message_type == 'text':
|
||||||
|
return JsonResponse({'error': 'Nachricht darf nicht leer sein'}, status=400)
|
||||||
|
|
||||||
|
message = ChatMessage.objects.create(
|
||||||
|
room=room,
|
||||||
|
sender=request.user,
|
||||||
|
content=content,
|
||||||
|
message_type=message_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'message_id': str(message.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return JsonResponse({'error': 'Ungültiges JSON'}, status=400)
|
||||||
|
|
||||||
|
return JsonResponse({'error': 'Ungültige Anfrage'}, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def chat_mark_read_api(request, room_id):
|
||||||
|
"""API für Nachrichten als gelesen markieren"""
|
||||||
|
if not is_admin(request.user):
|
||||||
|
return JsonResponse({'error': 'Zugriff verweigert'}, status=403)
|
||||||
|
|
||||||
|
room = get_object_or_404(ChatRoom, id=room_id)
|
||||||
|
room.mark_messages_as_read(request.user)
|
||||||
|
|
||||||
|
return JsonResponse({'success': True})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def quick_response_use_api(request, response_id):
|
||||||
|
"""API für Quick Response verwenden"""
|
||||||
|
if not is_admin(request.user):
|
||||||
|
return JsonResponse({'error': 'Zugriff verweigert'}, status=403)
|
||||||
|
|
||||||
|
response = get_object_or_404(QuickResponse, id=response_id)
|
||||||
|
response.increment_use_count()
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'content': response.content
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# Lade .env Datei
|
||||||
|
load_dotenv(BASE_DIR / '.env')
|
||||||
|
|
||||||
|
# E-Mail-Einstellungen (temporär Console-Backend)
|
||||||
|
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||||
|
DEFAULT_FROM_EMAIL = 'Fursuit Shop <noreply@fursuitshop.com>'
|
||||||
|
|
||||||
|
# Admin-E-Mail-Empfänger
|
||||||
|
ADMINS = [
|
||||||
|
('Shop Admin', 'admin@fursuitshop.com'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Lagerbestand-Einstellungen
|
||||||
|
LOW_STOCK_THRESHOLD = 5 # Schwellenwert für niedrigen Lagerbestand
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Django Web Application
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8010:8000"
|
||||||
|
volumes:
|
||||||
|
- static_volume:/app/staticfiles
|
||||||
|
- media_volume:/app/media
|
||||||
|
environment:
|
||||||
|
- DEBUG=True
|
||||||
|
- DJANGO_SETTINGS_MODULE=webshop.settings
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
command: >
|
||||||
|
sh -c "python manage.py migrate &&
|
||||||
|
python manage.py collectstatic --noinput &&
|
||||||
|
python manage.py runserver 0.0.0.0:8000"
|
||||||
|
|
||||||
|
# PostgreSQL Database
|
||||||
|
db:
|
||||||
|
image: postgres:15
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=fursuit_shop
|
||||||
|
- POSTGRES_USER=fursuit_user
|
||||||
|
- POSTGRES_PASSWORD=fursuit_password
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
|
||||||
|
# Redis für Channels/Caching
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6380:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
|
||||||
|
# Elasticsearch für Search
|
||||||
|
elasticsearch:
|
||||||
|
image: elasticsearch:8.11.0
|
||||||
|
environment:
|
||||||
|
- discovery.type=single-node
|
||||||
|
- xpack.security.enabled=false
|
||||||
|
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
|
||||||
|
ports:
|
||||||
|
- "9200:9200"
|
||||||
|
volumes:
|
||||||
|
- elasticsearch_data:/usr/share/elasticsearch/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
elasticsearch_data:
|
||||||
|
static_volume:
|
||||||
|
media_volume:
|
||||||
|
|
@ -0,0 +1,496 @@
|
||||||
|
# Kasico API Dokumentation
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
Die Kasico API ist eine RESTful API für den Fursuit Shop. Sie bietet Zugriff auf alle wichtigen Funktionen des Shops über HTTP-Endpoints.
|
||||||
|
|
||||||
|
**Base URL:** `https://kasico.de/api/v1/`
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Die API unterstützt zwei Authentifizierungsmethoden:
|
||||||
|
|
||||||
|
1. **Session Authentication** (für Browser-basierte Clients)
|
||||||
|
2. **Token Authentication** (für mobile Apps und externe Clients)
|
||||||
|
|
||||||
|
### Token Authentication
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Token erhalten
|
||||||
|
curl -X POST https://kasico.de/api/v1/auth/token/
|
||||||
|
-H "Content-Type: application/json"
|
||||||
|
-d '{"username": "user", "password": "password"}'
|
||||||
|
|
||||||
|
# Token verwenden
|
||||||
|
curl -H "Authorization: Token YOUR_TOKEN" https://kasico.de/api/v1/products/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Produkte
|
||||||
|
|
||||||
|
#### Alle Produkte abrufen
|
||||||
|
```http
|
||||||
|
GET /api/v1/products/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameter:**
|
||||||
|
- `category` - Kategorie-Slug
|
||||||
|
- `fursuit_type` - Fursuit-Typ
|
||||||
|
- `style` - Stil
|
||||||
|
- `min_price` - Mindestpreis
|
||||||
|
- `max_price` - Maximalpreis
|
||||||
|
- `on_sale` - Nur Angebote (true/false)
|
||||||
|
- `is_featured` - Nur Featured (true/false)
|
||||||
|
- `search` - Suchbegriff
|
||||||
|
- `ordering` - Sortierung (name, -name, price, -price, created, -created)
|
||||||
|
- `page` - Seitennummer
|
||||||
|
- `page_size` - Items pro Seite
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
```bash
|
||||||
|
curl "https://kasico.de/api/v1/products/?category=fullsuits&min_price=500&ordering=price"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Einzelnes Produkt abrufen
|
||||||
|
```http
|
||||||
|
GET /api/v1/products/{id}/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Featured Produkte
|
||||||
|
```http
|
||||||
|
GET /api/v1/products/featured/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Produkte im Angebot
|
||||||
|
```http
|
||||||
|
GET /api/v1/products/on-sale/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Produkte mit niedrigem Lagerbestand
|
||||||
|
```http
|
||||||
|
GET /api/v1/products/low-stock/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Produkt-Statistiken
|
||||||
|
```http
|
||||||
|
GET /api/v1/products/stats/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total_products": 150,
|
||||||
|
"featured_products": 12,
|
||||||
|
"on_sale_products": 8,
|
||||||
|
"low_stock_products": 3,
|
||||||
|
"categories_count": 5,
|
||||||
|
"average_rating": 4.2,
|
||||||
|
"total_reviews": 89
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Produkt zur Wunschliste hinzufügen
|
||||||
|
```http
|
||||||
|
POST /api/v1/products/{id}/add-to-wishlist/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Produkt von Wunschliste entfernen
|
||||||
|
```http
|
||||||
|
POST /api/v1/products/{id}/remove-from-wishlist/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kategorien
|
||||||
|
|
||||||
|
#### Alle Kategorien abrufen
|
||||||
|
```http
|
||||||
|
GET /api/v1/categories/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Einzelne Kategorie abrufen
|
||||||
|
```http
|
||||||
|
GET /api/v1/categories/{id}/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Produkte einer Kategorie
|
||||||
|
```http
|
||||||
|
GET /api/v1/categories/{id}/products/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reviews
|
||||||
|
|
||||||
|
#### Reviews für ein Produkt abrufen
|
||||||
|
```http
|
||||||
|
GET /api/v1/reviews/?product={product_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Review erstellen
|
||||||
|
```http
|
||||||
|
POST /api/v1/reviews/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"product": 1,
|
||||||
|
"rating": 5,
|
||||||
|
"comment": "Tolles Produkt!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Meine Reviews abrufen
|
||||||
|
```http
|
||||||
|
GET /api/v1/reviews/my-reviews/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wunschliste
|
||||||
|
|
||||||
|
#### Wunschliste abrufen
|
||||||
|
```http
|
||||||
|
GET /api/v1/wishlist/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Produkt zur Wunschliste hinzufügen
|
||||||
|
```http
|
||||||
|
POST /api/v1/wishlist/add-product/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"product_id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Produkt von Wunschliste entfernen
|
||||||
|
```http
|
||||||
|
POST /api/v1/wishlist/remove-product/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"product_id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Orders
|
||||||
|
|
||||||
|
#### Custom Orders abrufen
|
||||||
|
```http
|
||||||
|
GET /api/v1/custom-orders/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Custom Order erstellen
|
||||||
|
```http
|
||||||
|
POST /api/v1/custom-orders/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Mein Custom Fursuit",
|
||||||
|
"description": "Detaillierte Beschreibung...",
|
||||||
|
"fursuit_type": "fullsuit",
|
||||||
|
"style": "realistic",
|
||||||
|
"size": "medium",
|
||||||
|
"color_preferences": "Blau und Weiß",
|
||||||
|
"special_requirements": "Besondere Anforderungen...",
|
||||||
|
"budget": 1500.00
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Custom Order Status aktualisieren
|
||||||
|
```http
|
||||||
|
POST /api/v1/custom-orders/{id}/update-status/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "in_progress"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Galerie
|
||||||
|
|
||||||
|
#### Galerie-Bilder abrufen
|
||||||
|
```http
|
||||||
|
GET /api/v1/gallery/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameter:**
|
||||||
|
- `is_featured` - Nur Featured (true/false)
|
||||||
|
- `fursuit_type` - Fursuit-Typ
|
||||||
|
- `style` - Stil
|
||||||
|
|
||||||
|
#### Featured Galerie-Bilder
|
||||||
|
```http
|
||||||
|
GET /api/v1/gallery/featured/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Galerie-Bilder nach Produkt
|
||||||
|
```http
|
||||||
|
GET /api/v1/gallery/by-product/?product_id={id}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Warenkorb
|
||||||
|
|
||||||
|
#### Warenkorb abrufen
|
||||||
|
```http
|
||||||
|
GET /api/v1/cart/get/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Item zum Warenkorb hinzufügen
|
||||||
|
```http
|
||||||
|
POST /api/v1/cart/add-item/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"product_id": 1,
|
||||||
|
"quantity": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Warenkorb-Menge aktualisieren
|
||||||
|
```http
|
||||||
|
POST /api/v1/cart/update-quantity/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"item_id": 1,
|
||||||
|
"quantity": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Item aus Warenkorb entfernen
|
||||||
|
```http
|
||||||
|
POST /api/v1/cart/remove-item/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"item_id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Warenkorb leeren
|
||||||
|
```http
|
||||||
|
POST /api/v1/cart/clear/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Suche
|
||||||
|
|
||||||
|
#### Produkte suchen
|
||||||
|
```http
|
||||||
|
GET /api/v1/search/products/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameter:**
|
||||||
|
- `query` - Suchbegriff
|
||||||
|
- `category` - Kategorie
|
||||||
|
- `fursuit_type` - Fursuit-Typ
|
||||||
|
- `style` - Stil
|
||||||
|
- `min_price` - Mindestpreis
|
||||||
|
- `max_price` - Maximalpreis
|
||||||
|
- `on_sale` - Nur Angebote
|
||||||
|
- `is_featured` - Nur Featured
|
||||||
|
- `sort_by` - Sortierung (newest, oldest, price_low, price_high, name, rating)
|
||||||
|
- `page` - Seitennummer
|
||||||
|
- `page_size` - Items pro Seite
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
```bash
|
||||||
|
curl "https://kasico.de/api/v1/search/products/?query=wolf&min_price=300&sort_by=price_low"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response Format
|
||||||
|
|
||||||
|
Alle API-Responses folgen diesem Format:
|
||||||
|
|
||||||
|
### Erfolgreiche Response
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"count": 150,
|
||||||
|
"next": "https://kasico.de/api/v1/products/?page=2",
|
||||||
|
"previous": null,
|
||||||
|
"results": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fehler-Response
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Fehlermeldung",
|
||||||
|
"detail": "Detaillierte Beschreibung"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
Die API implementiert Rate Limiting:
|
||||||
|
|
||||||
|
- **Anonyme Benutzer:** 100 Requests/Stunde
|
||||||
|
- **Authentifizierte Benutzer:** 1000 Requests/Stunde
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
|
||||||
|
Alle Listen-Endpoints unterstützen Pagination:
|
||||||
|
|
||||||
|
- `page` - Seitennummer (Standard: 1)
|
||||||
|
- `page_size` - Items pro Seite (Standard: 12, Maximum: 100)
|
||||||
|
|
||||||
|
## Filtering
|
||||||
|
|
||||||
|
Die API unterstützt umfangreiche Filter-Optionen:
|
||||||
|
|
||||||
|
### Produkte
|
||||||
|
- `category` - Nach Kategorie
|
||||||
|
- `fursuit_type` - Nach Fursuit-Typ
|
||||||
|
- `style` - Nach Stil
|
||||||
|
- `min_price` / `max_price` - Preisbereich
|
||||||
|
- `on_sale` - Nur Angebote
|
||||||
|
- `is_featured` - Nur Featured
|
||||||
|
- `in_stock` - Nur verfügbare Produkte
|
||||||
|
|
||||||
|
### Suche
|
||||||
|
- `query` - Volltext-Suche in Name und Beschreibung
|
||||||
|
- Kombination aller Produkt-Filter
|
||||||
|
|
||||||
|
## Sorting
|
||||||
|
|
||||||
|
Verfügbare Sortierungen:
|
||||||
|
|
||||||
|
- `newest` - Neueste zuerst
|
||||||
|
- `oldest` - Älteste zuerst
|
||||||
|
- `price_low` - Preis aufsteigend
|
||||||
|
- `price_high` - Preis absteigend
|
||||||
|
- `name` - Name alphabetisch
|
||||||
|
- `rating` - Bewertung absteigend
|
||||||
|
|
||||||
|
## JavaScript Integration
|
||||||
|
|
||||||
|
Die API kann einfach in JavaScript verwendet werden:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Produkte abrufen
|
||||||
|
const products = await fetch('/api/v1/products/').then(r => r.json());
|
||||||
|
|
||||||
|
// Produkt zur Wunschliste hinzufügen
|
||||||
|
await fetch('/api/v1/products/1/add-to-wishlist/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mit dem Kasico API Client
|
||||||
|
await kasicoAPI.addToWishlist(1);
|
||||||
|
await kasicoAPI.getProducts({ category: 'fullsuits' });
|
||||||
|
```
|
||||||
|
|
||||||
|
## SDKs und Libraries
|
||||||
|
|
||||||
|
### Python
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
api_url = "https://kasico.de/api/v1/"
|
||||||
|
headers = {"Authorization": f"Token {token}"}
|
||||||
|
|
||||||
|
# Produkte abrufen
|
||||||
|
response = requests.get(f"{api_url}products/", headers=headers)
|
||||||
|
products = response.json()
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript/TypeScript
|
||||||
|
```typescript
|
||||||
|
interface Product {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
base_price: number;
|
||||||
|
sale_price?: number;
|
||||||
|
on_sale: boolean;
|
||||||
|
stock: number;
|
||||||
|
// ... weitere Felder
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mit dem Kasico API Client
|
||||||
|
const products = await kasicoAPI.getProducts();
|
||||||
|
const featured = await kasicoAPI.getFeaturedProducts();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Webhooks
|
||||||
|
|
||||||
|
Die API unterstützt Webhooks für wichtige Events:
|
||||||
|
|
||||||
|
### Verfügbare Webhooks
|
||||||
|
- `order.created` - Neue Bestellung
|
||||||
|
- `order.updated` - Bestellung aktualisiert
|
||||||
|
- `payment.completed` - Zahlung abgeschlossen
|
||||||
|
- `stock.low` - Niedriger Lagerbestand
|
||||||
|
|
||||||
|
### Webhook Setup
|
||||||
|
```http
|
||||||
|
POST /api/v1/webhooks/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "https://your-domain.com/webhook",
|
||||||
|
"events": ["order.created", "payment.completed"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fehlerbehandlung
|
||||||
|
|
||||||
|
### HTTP Status Codes
|
||||||
|
- `200` - Erfolg
|
||||||
|
- `201` - Erstellt
|
||||||
|
- `400` - Bad Request
|
||||||
|
- `401` - Unauthorized
|
||||||
|
- `403` - Forbidden
|
||||||
|
- `404` - Not Found
|
||||||
|
- `429` - Too Many Requests
|
||||||
|
- `500` - Internal Server Error
|
||||||
|
|
||||||
|
### Fehler-Response Format
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Validation failed",
|
||||||
|
"detail": {
|
||||||
|
"field_name": ["Error message"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Versionierung
|
||||||
|
|
||||||
|
Die API verwendet URL-basierte Versionierung:
|
||||||
|
- Aktuelle Version: `v1`
|
||||||
|
- URL-Pattern: `/api/v1/`
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Bei Fragen zur API:
|
||||||
|
- **E-Mail:** api@kasico.de
|
||||||
|
- **Dokumentation:** https://kasico.de/api/docs/
|
||||||
|
- **GitHub:** https://github.com/kasico/api-examples
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### v1.0.0 (2024-01-15)
|
||||||
|
- Initiale API-Version
|
||||||
|
- Produkt-, Kategorie- und Review-Endpoints
|
||||||
|
- Warenkorb- und Wunschliste-Funktionen
|
||||||
|
- Custom Order Management
|
||||||
|
- Galerie-API
|
||||||
|
- Such- und Filter-Funktionen
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
# E-Mail-System Dokumentation
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
Das E-Mail-System des Fursuit Shops versendet automatisch Benachrichtigungen an Kunden und Administratoren bei verschiedenen Shop-Ereignissen. Das System ist mehrsprachig (DE/EN) und verwendet responsive HTML-Templates mit Text-Alternativen.
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
### E-Mail-Einstellungen
|
||||||
|
|
||||||
|
Die E-Mail-Konfiguration erfolgt über Umgebungsvariablen in der `.env`-Datei:
|
||||||
|
|
||||||
|
```env
|
||||||
|
EMAIL_HOST=smtp.gmail.com
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_USE_TLS=True
|
||||||
|
EMAIL_HOST_USER=your-email@gmail.com
|
||||||
|
EMAIL_HOST_PASSWORD=your-app-specific-password
|
||||||
|
DEFAULT_FROM_EMAIL=Fursuit Shop <noreply@fursuitshop.com>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin-Benachrichtigungen
|
||||||
|
|
||||||
|
Administratoren werden in `settings.py` konfiguriert:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ADMINS = [
|
||||||
|
('Shop Admin', 'admin@fursuitshop.com'),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lagerbestand-Schwellenwert
|
||||||
|
|
||||||
|
```python
|
||||||
|
LOW_STOCK_THRESHOLD = 5 # Benachrichtigung bei ≤ 5 Artikeln
|
||||||
|
```
|
||||||
|
|
||||||
|
## E-Mail-Typen
|
||||||
|
|
||||||
|
### 1. Kundenbenachrichtigungen
|
||||||
|
|
||||||
|
#### Bestellbestätigung
|
||||||
|
- Gesendet nach erfolgreicher Zahlung
|
||||||
|
- Enthält Bestelldetails, Produkte und Preise
|
||||||
|
- Template: `order_confirmation.html/txt`
|
||||||
|
|
||||||
|
#### Status-Updates
|
||||||
|
- Gesendet bei Statusänderungen der Bestellung
|
||||||
|
- Kann Fortschrittsbilder und Beschreibungen enthalten
|
||||||
|
- Template: `order_status_update.html/txt`
|
||||||
|
|
||||||
|
#### Versandbestätigung
|
||||||
|
- Gesendet wenn Bestellung versendet wurde
|
||||||
|
- Enthält Tracking-Nummer und Versanddetails
|
||||||
|
- Template: `shipping_confirmation.html/txt`
|
||||||
|
|
||||||
|
### 2. Admin-Benachrichtigungen
|
||||||
|
|
||||||
|
#### Neue Bestellung
|
||||||
|
- Bei jeder neuen Bestellung
|
||||||
|
- Spezielle Markierung für Fursuit-Bestellungen
|
||||||
|
- Template: `admin_notification.html/txt`
|
||||||
|
|
||||||
|
#### Zahlungsfehler
|
||||||
|
- Bei fehlgeschlagenen Zahlungen
|
||||||
|
- Enthält detaillierte Fehlerinformationen
|
||||||
|
- Template: `admin_notification.html/txt`
|
||||||
|
|
||||||
|
#### Custom Design
|
||||||
|
- Bei Bestellungen mit Custom Designs
|
||||||
|
- Enthält Design-Dateien und Notizen
|
||||||
|
- Template: `admin_notification.html/txt`
|
||||||
|
|
||||||
|
#### Niedriger Lagerbestand
|
||||||
|
- Bei Unterschreitung des Schwellenwerts
|
||||||
|
- Enthält Produktdetails und aktuellen Bestand
|
||||||
|
- Template: `low_stock_notification.html/txt`
|
||||||
|
|
||||||
|
## Signal-Handler
|
||||||
|
|
||||||
|
Das System verwendet Django-Signals für automatische Benachrichtigungen:
|
||||||
|
|
||||||
|
### Order-Signals
|
||||||
|
```python
|
||||||
|
@receiver(post_save, sender=Order)
|
||||||
|
def handle_order_notifications(sender, instance, created, **kwargs):
|
||||||
|
# Sendet Benachrichtigungen bei neuen Bestellungen und Statusänderungen
|
||||||
|
```
|
||||||
|
|
||||||
|
### Payment-Signals
|
||||||
|
```python
|
||||||
|
@receiver(post_save, sender=PaymentError)
|
||||||
|
def handle_payment_error(sender, instance, created, **kwargs):
|
||||||
|
# Benachrichtigt Admins über Zahlungsfehler
|
||||||
|
```
|
||||||
|
|
||||||
|
### Product-Signals
|
||||||
|
```python
|
||||||
|
@receiver(pre_save, sender=Product)
|
||||||
|
def check_stock_level(sender, instance, **kwargs):
|
||||||
|
# Überprüft Lagerbestand und sendet Warnungen
|
||||||
|
```
|
||||||
|
|
||||||
|
## E-Mail-Templates
|
||||||
|
|
||||||
|
Alle E-Mail-Templates:
|
||||||
|
- Sind vollständig responsive
|
||||||
|
- Unterstützen HTML und Text-Alternativen
|
||||||
|
- Sind mehrsprachig (DE/EN)
|
||||||
|
- Verwenden einheitliches Branding
|
||||||
|
|
||||||
|
### Template-Struktur
|
||||||
|
```
|
||||||
|
shop/templates/shop/emails/
|
||||||
|
├── order_confirmation.html
|
||||||
|
├── order_confirmation.txt
|
||||||
|
├── order_status_update.html
|
||||||
|
├── order_status_update.txt
|
||||||
|
├── shipping_confirmation.html
|
||||||
|
├── shipping_confirmation.txt
|
||||||
|
├── admin_notification.html
|
||||||
|
├── admin_notification.txt
|
||||||
|
├── low_stock_notification.html
|
||||||
|
└── low_stock_notification.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- TLS-Verschlüsselung für E-Mail-Versand
|
||||||
|
- Keine sensiblen Daten in E-Mails
|
||||||
|
- Sichere Links zu Admin-Bereich
|
||||||
|
- App-spezifische Passwörter für SMTP
|
||||||
|
|
||||||
|
## Fehlerbehandlung
|
||||||
|
|
||||||
|
- Robuste Exception-Handling
|
||||||
|
- Logging von Versandfehlern
|
||||||
|
- Vermeidung von Doppel-Benachrichtigungen
|
||||||
|
- Fallback auf Text-Version bei HTML-Problemen
|
||||||
|
|
||||||
|
## Wartung
|
||||||
|
|
||||||
|
### Neue E-Mail-Typen hinzufügen
|
||||||
|
|
||||||
|
1. Templates erstellen (HTML und Text)
|
||||||
|
2. E-Mail-Funktion in `emails.py` hinzufügen
|
||||||
|
3. Signal-Handler in `signals.py` registrieren
|
||||||
|
4. Übersetzungen in `.po`-Dateien hinzufügen
|
||||||
|
|
||||||
|
### Template-Anpassung
|
||||||
|
|
||||||
|
- CSS-Styles in Template-Header
|
||||||
|
- Einheitliche Farbcodes und Abstände
|
||||||
|
- Bootstrap-kompatible Klassen
|
||||||
|
- Responsive Breakpoints
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
# 🐾 Furry-Design Umsetzungsplan & Tracker
|
||||||
|
|
||||||
|
## ✅ Fertiggestellte Seiten & Features
|
||||||
|
|
||||||
|
### 🏠 **Startseite (Home)**
|
||||||
|
- [x] **Hero-Section:** Mit Call-to-Action und Furry-Design
|
||||||
|
- [x] **Service-Cards:** Ansprechende Darstellung der Services
|
||||||
|
- [x] **Galerie-Preview:** Vorschau der besten Fursuits
|
||||||
|
- [x] **Call-to-Action:** Newsletter-Anmeldung und Shop-Link
|
||||||
|
|
||||||
|
### 🛍️ **Shop (Produktliste)**
|
||||||
|
- [x] **Produktkarten:** Mit Hover-Effekten und Animationen
|
||||||
|
- [x] **Filter & Sortierung:** Erweiterte Suchfunktionen
|
||||||
|
- [x] **Responsive Design:** Mobile-optimiert
|
||||||
|
- [x] **Lazy Loading:** Für bessere Performance
|
||||||
|
|
||||||
|
### 📦 **Produktdetail**
|
||||||
|
- [x] **Große Produktbilder:** Mit Zoom-Funktion
|
||||||
|
- [x] **Detaillierte Beschreibungen:** Mit Furry-Design
|
||||||
|
- [x] **Bewertungssystem:** Sterne und Kommentare
|
||||||
|
- [x] **Social Sharing:** Erweiterte Sharing-Optionen
|
||||||
|
- [x] **Ähnliche Produkte:** Empfehlungen
|
||||||
|
|
||||||
|
### 🛒 **Warenkorb**
|
||||||
|
- [x] **Interaktive Karten:** Mit Hover-Effekten
|
||||||
|
- [x] **Mengenänderung:** Live-Updates
|
||||||
|
- [x] **Preisberechnung:** Automatische Updates
|
||||||
|
- [x] **Responsive Design:** Mobile-optimiert
|
||||||
|
|
||||||
|
### 💳 **Checkout**
|
||||||
|
- [x] **Formular-Design:** Furry-Style Inputs
|
||||||
|
- [x] **Validierung:** Live-Feedback
|
||||||
|
- [x] **Zusammenfassung:** Übersichtliche Darstellung
|
||||||
|
- [x] **Sicherheitshinweise:** Trust-Badges
|
||||||
|
|
||||||
|
### ✅ **Bestellbestätigung**
|
||||||
|
- [x] **Confetti-Animation:** Erfolgs-Feedback
|
||||||
|
- [x] **Bestelldetails:** Übersichtliche Darstellung
|
||||||
|
- [x] **Nächste Schritte:** Klare Anweisungen
|
||||||
|
- [x] **Social Sharing:** Teilen der Bestellung
|
||||||
|
|
||||||
|
### 👤 **Benutzerprofil**
|
||||||
|
- [x] **Profilkarte:** Mit Avatar und Stats
|
||||||
|
- [x] **Bestellhistorie:** Übersichtliche Darstellung
|
||||||
|
- [x] **Einstellungen:** Benutzerfreundlich
|
||||||
|
- [x] **Wunschliste:** Favoriten-Verwaltung
|
||||||
|
|
||||||
|
### 🔐 **Login/Registrierung**
|
||||||
|
- [x] **Formular-Design:** Furry-Style
|
||||||
|
- [x] **Validierung:** Live-Feedback
|
||||||
|
- [x] **Passwort-Stärke:** Visueller Indikator
|
||||||
|
- [x] **Social Login:** Optionen erweitert
|
||||||
|
|
||||||
|
### 🖼️ **Galerie**
|
||||||
|
- [x] **Grid-Layout:** Responsive Design
|
||||||
|
- [x] **Lightbox:** Bildvergrößerung
|
||||||
|
- [x] **Kategorien:** Filter nach Typ
|
||||||
|
- [x] **Lazy Loading:** Performance optimiert
|
||||||
|
|
||||||
|
### 📞 **Kontakt/FAQ**
|
||||||
|
- [x] **Kontaktformular:** Furry-Design
|
||||||
|
- [x] **FAQ-Sektion:** Akkordeon-Style
|
||||||
|
- [x] **Live-Chat:** Widget integriert
|
||||||
|
- [x] **Social Media:** Links zu Plattformen
|
||||||
|
|
||||||
|
### 🎨 **Custom Order**
|
||||||
|
- [x] **Prozess-Steps:** Schritt-für-Schritt
|
||||||
|
- [x] **Formular-Design:** Umfassende Felder
|
||||||
|
- [x] **Fortschrittsanzeige:** Visueller Tracker
|
||||||
|
- [x] **Preiskalkulation:** Live-Updates
|
||||||
|
|
||||||
|
### 📊 **Dashboard**
|
||||||
|
- [x] **Übersichtskarten:** Mit Animationen
|
||||||
|
- [x] **Charts & Grafiken:** Interaktive Visualisierungen
|
||||||
|
- [x] **Quick Actions:** Schnellzugriff
|
||||||
|
- [x] **Responsive Design:** Mobile-optimiert
|
||||||
|
|
||||||
|
### ❤️ **Wunschliste**
|
||||||
|
- [x] **Produktkarten:** Mit Hover-Effekten
|
||||||
|
- [x] **Bulk-Aktionen:** Mehrfachauswahl
|
||||||
|
- [x] **Sortierung:** Verschiedene Optionen
|
||||||
|
- [x] **Teilen-Funktion:** Social Sharing
|
||||||
|
|
||||||
|
### 🔑 **Passwort-Reset**
|
||||||
|
- [x] **Request-Formular:** Furry-Design
|
||||||
|
- [x] **Bestätigungsseite:** Klare Kommunikation
|
||||||
|
- [x] **Reset-Formular:** Mit Validierung
|
||||||
|
- [x] **Erfolgsseite:** Confetti-Animation
|
||||||
|
|
||||||
|
## 🚀 **Erweiterte Features**
|
||||||
|
|
||||||
|
### 📱 **Sticky Navigation**
|
||||||
|
- [x] **Scroll-Effekte:** Dynamische Anpassung
|
||||||
|
- [x] **Progress-Bar:** Scroll-Fortschritt
|
||||||
|
- [x] **Mobile-Menü:** Hamburger-Navigation
|
||||||
|
- [x] **Scroll-to-Top:** Smooth Scrolling
|
||||||
|
- [x] **Active States:** Aktive Seite markiert
|
||||||
|
|
||||||
|
### 🌐 **Social Sharing**
|
||||||
|
- [x] **Erweiterte Plattformen:** Twitter, Facebook, WhatsApp, Telegram, Pinterest, LinkedIn
|
||||||
|
- [x] **Copy-Link:** Ein-Klick-Kopieren
|
||||||
|
- [x] **Success-Animationen:** Visuelles Feedback
|
||||||
|
- [x] **Analytics-Tracking:** Share-Events
|
||||||
|
- [x] **Responsive Design:** Mobile-optimiert
|
||||||
|
|
||||||
|
### 📧 **Newsletter-Opt-In**
|
||||||
|
- [x] **Verbessertes Popup:** Mit Animationen
|
||||||
|
- [x] **E-Mail-Validierung:** Live-Feedback
|
||||||
|
- [x] **Success/Error-States:** Visuelle Rückmeldung
|
||||||
|
- [x] **Benefits-Liste:** Mehrwert kommuniziert
|
||||||
|
- [x] **Analytics-Tracking:** Signup-Events
|
||||||
|
- [x] **Responsive Design:** Mobile-optimiert
|
||||||
|
|
||||||
|
## 🎨 **Design-System**
|
||||||
|
|
||||||
|
### 🎨 **Farbpalette**
|
||||||
|
- **Primär:** Lila/Pink-Gradient (#b36fff → #ff6fd8)
|
||||||
|
- **Sekundär:** Helles Lila (#f8e1ff)
|
||||||
|
- **Akzent:** Pink (#ff6fd8)
|
||||||
|
- **Text:** Dunkelgrau (#3a2d4d)
|
||||||
|
|
||||||
|
### 🎭 **Animationen**
|
||||||
|
- **Hover-Effekte:** Scale & Translate
|
||||||
|
- **Fade-In:** Scroll-Animationen
|
||||||
|
- **Confetti:** Erfolgs-Feedback
|
||||||
|
- **Loading-Spinner:** Furry-Style
|
||||||
|
|
||||||
|
### 📱 **Responsive Design**
|
||||||
|
- **Desktop:** 1200px+ Layout
|
||||||
|
- **Tablet:** 768px-1199px
|
||||||
|
- **Mobile:** <768px optimiert
|
||||||
|
|
||||||
|
## 📊 **Performance-Optimierungen**
|
||||||
|
|
||||||
|
### ⚡ **Geschwindigkeit**
|
||||||
|
- [x] **Lazy Loading:** Bilder und Komponenten
|
||||||
|
- [x] **CSS-Optimierung:** Minimierte Styles
|
||||||
|
- [x] **JavaScript-Bundling:** Effiziente Skripte
|
||||||
|
- [x] **Caching:** Browser-Cache genutzt
|
||||||
|
|
||||||
|
### 🔍 **SEO**
|
||||||
|
- [x] **Meta-Tags:** Vollständige Optimierung
|
||||||
|
- [x] **Structured Data:** Schema.org Markup
|
||||||
|
- [x] **Sitemap:** Automatische Generierung
|
||||||
|
- [x] **Alt-Tags:** Barrierefreiheit
|
||||||
|
|
||||||
|
## 🎯 **Alle Features Abgeschlossen!**
|
||||||
|
|
||||||
|
**Status:** ✅ **100% Fertiggestellt**
|
||||||
|
|
||||||
|
Alle geplanten Seiten und Features wurden erfolgreich im einheitlichen Furry-Design umgesetzt. Das Projekt ist vollständig funktionsfähig und bereit für den Live-Betrieb.
|
||||||
|
|
||||||
|
### 🏆 **Besondere Highlights:**
|
||||||
|
- **Einheitliches Design:** Konsistente Furry-Ästhetik
|
||||||
|
- **Moderne UX:** Smooth Animationen und Feedback
|
||||||
|
- **Mobile-First:** Responsive auf allen Geräten
|
||||||
|
- **Performance:** Optimiert für schnelle Ladezeiten
|
||||||
|
- **Accessibility:** Barrierefreie Nutzung
|
||||||
|
- **SEO-Optimiert:** Suchmaschinen-freundlich
|
||||||
|
|
||||||
|
**Nächste Schritte:** Deployment und Live-Schaltung! 🚀
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
def add_test_data():
|
||||||
|
conn = sqlite3.connect('shop.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Lösche vorhandene Daten
|
||||||
|
cursor.execute("DELETE FROM products")
|
||||||
|
|
||||||
|
# Testprodukte
|
||||||
|
products = [
|
||||||
|
("Gaming Maus", "Hochwertige Gaming-Maus mit RGB-Beleuchtung", 49.99, 10),
|
||||||
|
("Mechanische Tastatur", "Mechanische Gaming-Tastatur mit blauen Switches", 89.99, 5),
|
||||||
|
("Gaming Headset", "7.1 Surround Sound Gaming Headset", 79.99, 8),
|
||||||
|
("Mousepad XL", "Extra großes Gaming-Mousepad", 19.99, 15),
|
||||||
|
("Webcam HD", "1080p Webcam für Streaming", 59.99, 3)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Füge Produkte ein
|
||||||
|
cursor.executemany(
|
||||||
|
"INSERT INTO products (name, description, price, stock) VALUES (?, ?, ?, ?)",
|
||||||
|
products
|
||||||
|
)
|
||||||
|
|
||||||
|
# Speichere Änderungen
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print("Testdaten wurden erfolgreich hinzugefügt!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
add_test_data()
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect('shop.db')
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
with get_db() as conn:
|
||||||
|
# Tabelle erstellen
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
price REAL NOT NULL,
|
||||||
|
stock INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Beispieldaten einfügen
|
||||||
|
products = [
|
||||||
|
("Laptop", "Leistungsstarker Laptop für Arbeit und Gaming", 999.99, 5),
|
||||||
|
("Smartphone", "Neuestes Modell mit Top-Kamera", 699.99, 10),
|
||||||
|
("Kopfhörer", "Kabellose Kopfhörer mit Noise-Cancelling", 199.99, 15),
|
||||||
|
("Tablet", "Perfekt für Unterhaltung und Produktivität", 449.99, 8),
|
||||||
|
("Smartwatch", "Fitness-Tracking und Benachrichtigungen", 299.99, 12)
|
||||||
|
]
|
||||||
|
|
||||||
|
conn.execute("DELETE FROM products") # Alte Daten löschen
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO products (name, description, price, stock) VALUES (?, ?, ?, ?)",
|
||||||
|
products
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
init_db()
|
||||||
|
print("Datenbank wurde erfolgreich initialisiert!")
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
"""
|
||||||
|
Docker Initialisierungsskript für Kasico Fursuit Shop
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import django
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
|
||||||
|
def init_docker():
|
||||||
|
"""Initialisiere Django für Docker"""
|
||||||
|
|
||||||
|
# Setze Django Settings
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'webshop.settings')
|
||||||
|
|
||||||
|
# Setup Django
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
print("🐳 Initialisiere Kasico Fursuit Shop für Docker...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Führe Migrationen aus
|
||||||
|
print("📦 Führe Datenbank-Migrationen aus...")
|
||||||
|
execute_from_command_line(['manage.py', 'migrate'])
|
||||||
|
|
||||||
|
# Erstelle Superuser falls nicht vorhanden
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
if not User.objects.filter(username='admin').exists():
|
||||||
|
print("👤 Erstelle Admin-User...")
|
||||||
|
User.objects.create_superuser('admin', 'admin@kasico.de', 'admin123')
|
||||||
|
print("✅ Admin-User erstellt: admin / admin123")
|
||||||
|
|
||||||
|
# Erstelle Test-Daten
|
||||||
|
print("🎨 Erstelle Test-Daten...")
|
||||||
|
create_test_data()
|
||||||
|
|
||||||
|
# Erstelle Elasticsearch Index
|
||||||
|
print("🔍 Erstelle Elasticsearch Index...")
|
||||||
|
execute_from_command_line(['manage.py', 'rebuild_index', '--noinput'])
|
||||||
|
|
||||||
|
print("✅ Docker Initialisierung abgeschlossen!")
|
||||||
|
print("🌐 Server läuft auf: http://localhost:8000")
|
||||||
|
print("👤 Admin Panel: http://localhost:8000/admin")
|
||||||
|
print("📧 Login: admin / admin123")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Fehler bei der Initialisierung: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def create_test_data():
|
||||||
|
"""Erstelle Test-Daten für den Shop"""
|
||||||
|
|
||||||
|
from products.models import Product, Category
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
|
# Erstelle Kategorien
|
||||||
|
categories = [
|
||||||
|
{'name': 'Fursuits', 'description': 'Vollständige Fursuits'},
|
||||||
|
{'name': 'Köpfe', 'description': 'Fursuit Köpfe'},
|
||||||
|
{'name': 'Pfoten', 'description': 'Fursuit Pfoten'},
|
||||||
|
{'name': 'Schwänze', 'description': 'Fursuit Schwänze'},
|
||||||
|
]
|
||||||
|
|
||||||
|
for cat_data in categories:
|
||||||
|
Category.objects.get_or_create(
|
||||||
|
name=cat_data['name'],
|
||||||
|
defaults={'description': cat_data['description']}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Erstelle Test-Produkte
|
||||||
|
products_data = [
|
||||||
|
{
|
||||||
|
'name': 'Wolf Fursuit - Luna',
|
||||||
|
'description': 'Vollständiger Wolf Fursuit in silber-grau',
|
||||||
|
'price': 2500.00,
|
||||||
|
'category': 'Fursuits',
|
||||||
|
'fursuit_type': 'fullsuit',
|
||||||
|
'stock': 1,
|
||||||
|
'featured': True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Fuchs Kopf - Blaze',
|
||||||
|
'description': 'Fuchs Kopf in orange-rot mit LED Augen',
|
||||||
|
'price': 800.00,
|
||||||
|
'category': 'Köpfe',
|
||||||
|
'fursuit_type': 'head',
|
||||||
|
'stock': 3,
|
||||||
|
'featured': True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Hund Pfoten - Buddy',
|
||||||
|
'description': 'Hund Pfoten in braun mit Krallen',
|
||||||
|
'price': 300.00,
|
||||||
|
'category': 'Pfoten',
|
||||||
|
'fursuit_type': 'paws',
|
||||||
|
'stock': 5,
|
||||||
|
'featured': False
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Katze Schwanz - Whiskers',
|
||||||
|
'description': 'Katze Schwanz in schwarz mit Bewegungsmechanismus',
|
||||||
|
'price': 200.00,
|
||||||
|
'category': 'Schwänze',
|
||||||
|
'fursuit_type': 'tail',
|
||||||
|
'stock': 8,
|
||||||
|
'featured': False
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for prod_data in products_data:
|
||||||
|
category = Category.objects.get(name=prod_data['category'])
|
||||||
|
product, created = Product.objects.get_or_create(
|
||||||
|
name=prod_data['name'],
|
||||||
|
defaults={
|
||||||
|
'description': prod_data['description'],
|
||||||
|
'price': prod_data['price'],
|
||||||
|
'category': category,
|
||||||
|
'fursuit_type': prod_data['fursuit_type'],
|
||||||
|
'stock': prod_data['stock'],
|
||||||
|
'featured': prod_data['featured'],
|
||||||
|
'is_active': True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
print(f"✅ Produkt erstellt: {product.name}")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
init_docker()
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run administrative tasks."""
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'webshop.settings')
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,217 @@
|
||||||
|
"""
|
||||||
|
Mobile App Models für React Native API
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.utils import timezone
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class MobileDevice(models.Model):
|
||||||
|
"""Mobile Device Registration für Push Notifications"""
|
||||||
|
|
||||||
|
DEVICE_TYPE_CHOICES = [
|
||||||
|
('ios', 'iOS'),
|
||||||
|
('android', 'Android'),
|
||||||
|
('web', 'Web'),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='mobile_devices')
|
||||||
|
device_token = models.CharField(max_length=255, unique=True)
|
||||||
|
device_type = models.CharField(max_length=20, choices=DEVICE_TYPE_CHOICES)
|
||||||
|
device_name = models.CharField(max_length=100, blank=True)
|
||||||
|
device_model = models.CharField(max_length=100, blank=True)
|
||||||
|
os_version = models.CharField(max_length=50, blank=True)
|
||||||
|
app_version = models.CharField(max_length=20, blank=True)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
last_seen = models.DateTimeField(auto_now=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name = 'Mobile Device'
|
||||||
|
verbose_name_plural = 'Mobile Devices'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} - {self.device_type} ({self.device_name})"
|
||||||
|
|
||||||
|
|
||||||
|
class PushNotification(models.Model):
|
||||||
|
"""Push Notification Tracking"""
|
||||||
|
|
||||||
|
NOTIFICATION_TYPE_CHOICES = [
|
||||||
|
('order_status', 'Order Status'),
|
||||||
|
('auction_update', 'Auction Update'),
|
||||||
|
('chat_message', 'Chat Message'),
|
||||||
|
('promotion', 'Promotion'),
|
||||||
|
('system', 'System'),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='push_notifications')
|
||||||
|
device = models.ForeignKey(MobileDevice, on_delete=models.CASCADE, related_name='notifications')
|
||||||
|
notification_type = models.CharField(max_length=20, choices=NOTIFICATION_TYPE_CHOICES)
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
message = models.TextField()
|
||||||
|
data = models.JSONField(default=dict) # Additional data for deep linking
|
||||||
|
sent_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
delivered_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
opened_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
is_sent = models.BooleanField(default=False)
|
||||||
|
is_delivered = models.BooleanField(default=False)
|
||||||
|
is_opened = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-sent_at']
|
||||||
|
verbose_name = 'Push Notification'
|
||||||
|
verbose_name_plural = 'Push Notifications'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.notification_type}: {self.title}"
|
||||||
|
|
||||||
|
|
||||||
|
class OfflineSync(models.Model):
|
||||||
|
"""Offline Sync Tracking für Mobile App"""
|
||||||
|
|
||||||
|
SYNC_STATUS_CHOICES = [
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('syncing', 'Syncing'),
|
||||||
|
('completed', 'Completed'),
|
||||||
|
('failed', 'Failed'),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='offline_syncs')
|
||||||
|
device = models.ForeignKey(MobileDevice, on_delete=models.CASCADE, related_name='syncs')
|
||||||
|
sync_type = models.CharField(max_length=50) # 'orders', 'products', 'auctions'
|
||||||
|
data = models.JSONField() # Data to sync
|
||||||
|
status = models.CharField(max_length=20, choices=SYNC_STATUS_CHOICES, default='pending')
|
||||||
|
error_message = models.TextField(blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
synced_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name = 'Offline Sync'
|
||||||
|
verbose_name_plural = 'Offline Syncs'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} - {self.sync_type} ({self.get_status_display()})"
|
||||||
|
|
||||||
|
|
||||||
|
class MobileAnalytics(models.Model):
|
||||||
|
"""Mobile App Analytics"""
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='mobile_analytics')
|
||||||
|
device = models.ForeignKey(MobileDevice, on_delete=models.CASCADE, related_name='analytics')
|
||||||
|
event_type = models.CharField(max_length=50) # 'app_open', 'product_view', 'purchase'
|
||||||
|
event_data = models.JSONField(default=dict)
|
||||||
|
session_id = models.CharField(max_length=100, blank=True)
|
||||||
|
screen_name = models.CharField(max_length=100, blank=True)
|
||||||
|
timestamp = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-timestamp']
|
||||||
|
verbose_name = 'Mobile Analytics'
|
||||||
|
verbose_name_plural = 'Mobile Analytics'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} - {self.event_type}"
|
||||||
|
|
||||||
|
|
||||||
|
class MobileCache(models.Model):
|
||||||
|
"""Mobile App Cache für Offline Support"""
|
||||||
|
|
||||||
|
CACHE_TYPE_CHOICES = [
|
||||||
|
('products', 'Products'),
|
||||||
|
('categories', 'Categories'),
|
||||||
|
('auctions', 'Auctions'),
|
||||||
|
('user_data', 'User Data'),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='mobile_caches')
|
||||||
|
cache_type = models.CharField(max_length=20, choices=CACHE_TYPE_CHOICES)
|
||||||
|
cache_key = models.CharField(max_length=255)
|
||||||
|
cache_data = models.JSONField()
|
||||||
|
expires_at = models.DateTimeField()
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-updated_at']
|
||||||
|
verbose_name = 'Mobile Cache'
|
||||||
|
verbose_name_plural = 'Mobile Caches'
|
||||||
|
unique_together = ['user', 'cache_type', 'cache_key']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} - {self.cache_type}: {self.cache_key}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self):
|
||||||
|
"""Prüfen ob Cache abgelaufen ist"""
|
||||||
|
return timezone.now() > self.expires_at
|
||||||
|
|
||||||
|
|
||||||
|
class MobileSession(models.Model):
|
||||||
|
"""Mobile App Session Tracking"""
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='mobile_sessions')
|
||||||
|
device = models.ForeignKey(MobileDevice, on_delete=models.CASCADE, related_name='sessions')
|
||||||
|
session_id = models.CharField(max_length=100, unique=True)
|
||||||
|
started_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
ended_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
duration = models.IntegerField(default=0) # in seconds
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-started_at']
|
||||||
|
verbose_name = 'Mobile Session'
|
||||||
|
verbose_name_plural = 'Mobile Sessions'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} - {self.session_id}"
|
||||||
|
|
||||||
|
def end_session(self):
|
||||||
|
"""Session beenden"""
|
||||||
|
self.ended_at = timezone.now()
|
||||||
|
self.is_active = False
|
||||||
|
self.duration = int((self.ended_at - self.started_at).total_seconds())
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
|
class MobileError(models.Model):
|
||||||
|
"""Mobile App Error Tracking"""
|
||||||
|
|
||||||
|
ERROR_LEVEL_CHOICES = [
|
||||||
|
('debug', 'Debug'),
|
||||||
|
('info', 'Info'),
|
||||||
|
('warning', 'Warning'),
|
||||||
|
('error', 'Error'),
|
||||||
|
('critical', 'Critical'),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='mobile_errors')
|
||||||
|
device = models.ForeignKey(MobileDevice, on_delete=models.CASCADE, related_name='errors')
|
||||||
|
error_level = models.CharField(max_length=20, choices=ERROR_LEVEL_CHOICES)
|
||||||
|
error_type = models.CharField(max_length=100)
|
||||||
|
error_message = models.TextField()
|
||||||
|
stack_trace = models.TextField(blank=True)
|
||||||
|
app_version = models.CharField(max_length=20, blank=True)
|
||||||
|
os_version = models.CharField(max_length=50, blank=True)
|
||||||
|
device_info = models.JSONField(default=dict)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
is_resolved = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name = 'Mobile Error'
|
||||||
|
verbose_name_plural = 'Mobile Errors'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.error_type}: {self.error_message[:50]}"
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
"""
|
||||||
|
Mobile API Serializers für React Native
|
||||||
|
"""
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
from .models import MobileDevice, PushNotification, OfflineSync, MobileAnalytics, MobileSession, MobileError, MobileCache
|
||||||
|
|
||||||
|
|
||||||
|
class MobileDeviceSerializer(serializers.ModelSerializer):
|
||||||
|
"""Mobile Device Serializer"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = MobileDevice
|
||||||
|
fields = [
|
||||||
|
'id', 'device_token', 'device_type', 'device_name',
|
||||||
|
'device_model', 'os_version', 'app_version', 'is_active',
|
||||||
|
'last_seen', 'created_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'created_at']
|
||||||
|
|
||||||
|
|
||||||
|
class PushNotificationSerializer(serializers.ModelSerializer):
|
||||||
|
"""Push Notification Serializer"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PushNotification
|
||||||
|
fields = [
|
||||||
|
'id', 'notification_type', 'title', 'message', 'data',
|
||||||
|
'sent_at', 'delivered_at', 'opened_at', 'is_sent',
|
||||||
|
'is_delivered', 'is_opened'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'sent_at']
|
||||||
|
|
||||||
|
|
||||||
|
class OfflineSyncSerializer(serializers.ModelSerializer):
|
||||||
|
"""Offline Sync Serializer"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = OfflineSync
|
||||||
|
fields = [
|
||||||
|
'id', 'sync_type', 'data', 'status', 'error_message',
|
||||||
|
'created_at', 'synced_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'created_at']
|
||||||
|
|
||||||
|
|
||||||
|
class MobileAnalyticsSerializer(serializers.ModelSerializer):
|
||||||
|
"""Mobile Analytics Serializer"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = MobileAnalytics
|
||||||
|
fields = [
|
||||||
|
'id', 'event_type', 'event_data', 'session_id',
|
||||||
|
'screen_name', 'timestamp'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'timestamp']
|
||||||
|
|
||||||
|
|
||||||
|
class MobileSessionSerializer(serializers.ModelSerializer):
|
||||||
|
"""Mobile Session Serializer"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = MobileSession
|
||||||
|
fields = [
|
||||||
|
'id', 'session_id', 'started_at', 'ended_at',
|
||||||
|
'duration', 'is_active'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'started_at']
|
||||||
|
|
||||||
|
|
||||||
|
class MobileErrorSerializer(serializers.ModelSerializer):
|
||||||
|
"""Mobile Error Serializer"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = MobileError
|
||||||
|
fields = [
|
||||||
|
'id', 'error_level', 'error_type', 'error_message',
|
||||||
|
'stack_trace', 'app_version', 'os_version', 'device_info',
|
||||||
|
'created_at', 'is_resolved'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'created_at']
|
||||||
|
|
||||||
|
|
||||||
|
class MobileCacheSerializer(serializers.ModelSerializer):
|
||||||
|
"""Mobile Cache Serializer"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = MobileCache
|
||||||
|
fields = [
|
||||||
|
'id', 'cache_type', 'cache_key', 'cache_data',
|
||||||
|
'expires_at', 'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
# Mobile-optimierte Produkt Serializer
|
||||||
|
class MobileProductSerializer(serializers.Serializer):
|
||||||
|
"""Mobile-optimierter Produkt Serializer"""
|
||||||
|
|
||||||
|
id = serializers.UUIDField()
|
||||||
|
name = serializers.CharField()
|
||||||
|
description = serializers.CharField()
|
||||||
|
price = serializers.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
image_url = serializers.CharField()
|
||||||
|
category = serializers.CharField()
|
||||||
|
fursuit_type = serializers.CharField()
|
||||||
|
featured = serializers.BooleanField()
|
||||||
|
created_at = serializers.DateTimeField()
|
||||||
|
|
||||||
|
# Mobile-spezifische Felder
|
||||||
|
is_favorite = serializers.BooleanField(default=False)
|
||||||
|
in_cart = serializers.BooleanField(default=False)
|
||||||
|
cart_quantity = serializers.IntegerField(default=0)
|
||||||
|
|
||||||
|
|
||||||
|
# Mobile-optimierter Auction Serializer
|
||||||
|
class MobileAuctionSerializer(serializers.Serializer):
|
||||||
|
"""Mobile-optimierter Auction Serializer"""
|
||||||
|
|
||||||
|
id = serializers.UUIDField()
|
||||||
|
title = serializers.CharField()
|
||||||
|
description = serializers.CharField()
|
||||||
|
fursuit_type = serializers.CharField()
|
||||||
|
starting_bid = serializers.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
current_bid = serializers.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
status = serializers.CharField()
|
||||||
|
end_time = serializers.DateTimeField()
|
||||||
|
time_remaining = serializers.CharField()
|
||||||
|
total_bids = serializers.IntegerField()
|
||||||
|
is_watching = serializers.BooleanField(default=False)
|
||||||
|
created_at = serializers.DateTimeField()
|
||||||
|
|
||||||
|
|
||||||
|
# Mobile API Response Serializer
|
||||||
|
class MobileApiResponseSerializer(serializers.Serializer):
|
||||||
|
"""Standard Mobile API Response"""
|
||||||
|
|
||||||
|
success = serializers.BooleanField()
|
||||||
|
data = serializers.JSONField()
|
||||||
|
message = serializers.CharField(required=False)
|
||||||
|
error = serializers.CharField(required=False)
|
||||||
|
timestamp = serializers.DateTimeField()
|
||||||
|
|
||||||
|
|
||||||
|
# Mobile Push Notification Payload Serializer
|
||||||
|
class PushNotificationPayloadSerializer(serializers.Serializer):
|
||||||
|
"""Push Notification Payload für Firebase/APNS"""
|
||||||
|
|
||||||
|
notification = serializers.DictField()
|
||||||
|
data = serializers.DictField()
|
||||||
|
android = serializers.DictField(required=False)
|
||||||
|
apns = serializers.DictField(required=False)
|
||||||
|
webpush = serializers.DictField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
# Mobile Analytics Event Serializer
|
||||||
|
class MobileAnalyticsEventSerializer(serializers.Serializer):
|
||||||
|
"""Mobile Analytics Event"""
|
||||||
|
|
||||||
|
event_type = serializers.CharField()
|
||||||
|
event_data = serializers.DictField()
|
||||||
|
session_id = serializers.CharField(required=False)
|
||||||
|
screen_name = serializers.CharField(required=False)
|
||||||
|
timestamp = serializers.DateTimeField()
|
||||||
|
device_info = serializers.DictField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
# Mobile Cache Request Serializer
|
||||||
|
class MobileCacheRequestSerializer(serializers.Serializer):
|
||||||
|
"""Mobile Cache Request"""
|
||||||
|
|
||||||
|
cache_type = serializers.CharField()
|
||||||
|
cache_key = serializers.CharField()
|
||||||
|
data = serializers.DictField()
|
||||||
|
expires_in = serializers.IntegerField(required=False, default=3600)
|
||||||
|
|
||||||
|
|
||||||
|
# Mobile Session Request Serializer
|
||||||
|
class MobileSessionRequestSerializer(serializers.Serializer):
|
||||||
|
"""Mobile Session Request"""
|
||||||
|
|
||||||
|
session_id = serializers.CharField()
|
||||||
|
device_token = serializers.CharField()
|
||||||
|
app_version = serializers.CharField(required=False)
|
||||||
|
device_info = serializers.DictField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
# Mobile Error Report Serializer
|
||||||
|
class MobileErrorReportSerializer(serializers.Serializer):
|
||||||
|
"""Mobile Error Report"""
|
||||||
|
|
||||||
|
error_level = serializers.ChoiceField(choices=[
|
||||||
|
('debug', 'Debug'),
|
||||||
|
('info', 'Info'),
|
||||||
|
('warning', 'Warning'),
|
||||||
|
('error', 'Error'),
|
||||||
|
('critical', 'Critical'),
|
||||||
|
])
|
||||||
|
error_type = serializers.CharField()
|
||||||
|
error_message = serializers.CharField()
|
||||||
|
stack_trace = serializers.CharField(required=False)
|
||||||
|
app_version = serializers.CharField(required=False)
|
||||||
|
os_version = serializers.CharField(required=False)
|
||||||
|
device_info = serializers.DictField(required=False)
|
||||||
|
session_id = serializers.CharField(required=False)
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
"""
|
||||||
|
Mobile API URL Configuration
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = 'mobile'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# Device Management
|
||||||
|
path('api/device/register/', views.register_device, name='register_device'),
|
||||||
|
path('api/device/unregister/<str:device_token>/', views.unregister_device, name='unregister_device'),
|
||||||
|
path('api/device/list/', views.get_user_devices, name='get_user_devices'),
|
||||||
|
|
||||||
|
# Push Notifications
|
||||||
|
path('api/notifications/send/', views.send_push_notification, name='send_push_notification'),
|
||||||
|
|
||||||
|
# Offline Sync
|
||||||
|
path('api/sync/', views.sync_offline_data, name='sync_offline_data'),
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
path('api/analytics/track/', views.track_analytics, name='track_analytics'),
|
||||||
|
|
||||||
|
# Mobile-optimierte Daten
|
||||||
|
path('api/products/', views.get_mobile_products, name='get_mobile_products'),
|
||||||
|
path('api/auctions/', views.get_mobile_auctions, name='get_mobile_auctions'),
|
||||||
|
|
||||||
|
# Session Management
|
||||||
|
path('api/session/start/', views.start_session, name='start_session'),
|
||||||
|
path('api/session/end/<str:session_id>/', views.end_session, name='end_session'),
|
||||||
|
|
||||||
|
# Error Reporting
|
||||||
|
path('api/error/report/', views.report_error, name='report_error'),
|
||||||
|
|
||||||
|
# Cache Management
|
||||||
|
path('api/cache/<str:cache_type>/', views.get_mobile_cache, name='get_mobile_cache'),
|
||||||
|
path('api/cache/<str:cache_type>/update/', views.update_mobile_cache, name='update_mobile_cache'),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,468 @@
|
||||||
|
"""
|
||||||
|
Mobile API Views für React Native Integration
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import status
|
||||||
|
from .models import MobileDevice, PushNotification, OfflineSync, MobileAnalytics, MobileCache, MobileSession
|
||||||
|
from .serializers import MobileDeviceSerializer, PushNotificationSerializer
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def register_device(request):
|
||||||
|
"""Mobile Device registrieren für Push Notifications"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request.data
|
||||||
|
device_token = data.get('device_token')
|
||||||
|
device_type = data.get('device_type', 'web')
|
||||||
|
|
||||||
|
if not device_token:
|
||||||
|
return Response({'error': 'Device token required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Device erstellen oder aktualisieren
|
||||||
|
device, created = MobileDevice.objects.get_or_create(
|
||||||
|
device_token=device_token,
|
||||||
|
defaults={
|
||||||
|
'user': request.user,
|
||||||
|
'device_type': device_type,
|
||||||
|
'device_name': data.get('device_name', ''),
|
||||||
|
'device_model': data.get('device_model', ''),
|
||||||
|
'os_version': data.get('os_version', ''),
|
||||||
|
'app_version': data.get('app_version', ''),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not created:
|
||||||
|
# Device aktualisieren
|
||||||
|
device.user = request.user
|
||||||
|
device.device_type = device_type
|
||||||
|
device.device_name = data.get('device_name', device.device_name)
|
||||||
|
device.device_model = data.get('device_model', device.device_model)
|
||||||
|
device.os_version = data.get('os_version', device.os_version)
|
||||||
|
device.app_version = data.get('app_version', device.app_version)
|
||||||
|
device.is_active = True
|
||||||
|
device.save()
|
||||||
|
|
||||||
|
serializer = MobileDeviceSerializer(device)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['DELETE'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def unregister_device(request, device_token):
|
||||||
|
"""Mobile Device deregistrieren"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
device = MobileDevice.objects.get(
|
||||||
|
device_token=device_token,
|
||||||
|
user=request.user
|
||||||
|
)
|
||||||
|
device.is_active = False
|
||||||
|
device.save()
|
||||||
|
|
||||||
|
return Response({'message': 'Device unregistered'}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
except MobileDevice.DoesNotExist:
|
||||||
|
return Response({'error': 'Device not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_user_devices(request):
|
||||||
|
"""User's Mobile Devices abrufen"""
|
||||||
|
|
||||||
|
devices = MobileDevice.objects.filter(user=request.user, is_active=True)
|
||||||
|
serializer = MobileDeviceSerializer(devices, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def send_push_notification(request):
|
||||||
|
"""Push Notification senden"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request.data
|
||||||
|
user_id = data.get('user_id')
|
||||||
|
notification_type = data.get('notification_type')
|
||||||
|
title = data.get('title')
|
||||||
|
message = data.get('message')
|
||||||
|
data_payload = data.get('data', {})
|
||||||
|
|
||||||
|
if not all([user_id, notification_type, title, message]):
|
||||||
|
return Response({'error': 'Missing required fields'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Get user's active devices
|
||||||
|
devices = MobileDevice.objects.filter(
|
||||||
|
user_id=user_id,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
notifications = []
|
||||||
|
for device in devices:
|
||||||
|
notification = PushNotification.objects.create(
|
||||||
|
user_id=user_id,
|
||||||
|
device=device,
|
||||||
|
notification_type=notification_type,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
data=data_payload
|
||||||
|
)
|
||||||
|
notifications.append(notification)
|
||||||
|
|
||||||
|
# Send actual push notification (would integrate with Firebase/APNS)
|
||||||
|
# send_push_notification_to_device(notifications)
|
||||||
|
|
||||||
|
serializer = PushNotificationSerializer(notifications, many=True)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def sync_offline_data(request):
|
||||||
|
"""Offline Data Sync für Mobile App"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request.data
|
||||||
|
sync_type = data.get('sync_type')
|
||||||
|
sync_data = data.get('data', {})
|
||||||
|
device_token = data.get('device_token')
|
||||||
|
|
||||||
|
if not sync_type or not device_token:
|
||||||
|
return Response({'error': 'Missing required fields'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Get device
|
||||||
|
try:
|
||||||
|
device = MobileDevice.objects.get(
|
||||||
|
device_token=device_token,
|
||||||
|
user=request.user,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
except MobileDevice.DoesNotExist:
|
||||||
|
return Response({'error': 'Device not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# Create sync record
|
||||||
|
sync = OfflineSync.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
device=device,
|
||||||
|
sync_type=sync_type,
|
||||||
|
data=sync_data,
|
||||||
|
status='pending'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process sync based on type
|
||||||
|
if sync_type == 'orders':
|
||||||
|
# Sync orders
|
||||||
|
pass
|
||||||
|
elif sync_type == 'products':
|
||||||
|
# Sync products
|
||||||
|
pass
|
||||||
|
elif sync_type == 'auctions':
|
||||||
|
# Sync auctions
|
||||||
|
pass
|
||||||
|
|
||||||
|
sync.status = 'completed'
|
||||||
|
sync.synced_at = timezone.now()
|
||||||
|
sync.save()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'sync_id': str(sync.id),
|
||||||
|
'status': sync.status,
|
||||||
|
'synced_at': sync.synced_at
|
||||||
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def track_analytics(request):
|
||||||
|
"""Mobile Analytics Event tracken"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request.data
|
||||||
|
event_type = data.get('event_type')
|
||||||
|
event_data = data.get('event_data', {})
|
||||||
|
session_id = data.get('session_id', '')
|
||||||
|
screen_name = data.get('screen_name', '')
|
||||||
|
device_token = data.get('device_token')
|
||||||
|
|
||||||
|
if not event_type or not device_token:
|
||||||
|
return Response({'error': 'Missing required fields'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Get device
|
||||||
|
try:
|
||||||
|
device = MobileDevice.objects.get(
|
||||||
|
device_token=device_token,
|
||||||
|
user=request.user,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
except MobileDevice.DoesNotExist:
|
||||||
|
return Response({'error': 'Device not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# Create analytics record
|
||||||
|
analytics = MobileAnalytics.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
device=device,
|
||||||
|
event_type=event_type,
|
||||||
|
event_data=event_data,
|
||||||
|
session_id=session_id,
|
||||||
|
screen_name=screen_name
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({'analytics_id': str(analytics.id)}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_mobile_products(request):
|
||||||
|
"""Mobile-optimierte Produktliste"""
|
||||||
|
|
||||||
|
from products.models import Product
|
||||||
|
from products.serializers import ProductSerializer
|
||||||
|
|
||||||
|
# Mobile-optimierte Filter
|
||||||
|
products = Product.objects.filter(is_active=True).select_related('category')
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
page = int(request.GET.get('page', 1))
|
||||||
|
per_page = int(request.GET.get('per_page', 20))
|
||||||
|
|
||||||
|
start = (page - 1) * per_page
|
||||||
|
end = start + per_page
|
||||||
|
|
||||||
|
products_page = products[start:end]
|
||||||
|
serializer = ProductSerializer(products_page, many=True)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'products': serializer.data,
|
||||||
|
'total': products.count(),
|
||||||
|
'page': page,
|
||||||
|
'per_page': per_page,
|
||||||
|
'has_next': end < products.count(),
|
||||||
|
'has_previous': page > 1
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_mobile_auctions(request):
|
||||||
|
"""Mobile-optimierte Auktionen"""
|
||||||
|
|
||||||
|
from auction.models import Auction
|
||||||
|
from auction.serializers import AuctionSerializer
|
||||||
|
|
||||||
|
auctions = Auction.objects.filter(status='active').select_related('created_by')
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
page = int(request.GET.get('page', 1))
|
||||||
|
per_page = int(request.GET.get('per_page', 10))
|
||||||
|
|
||||||
|
start = (page - 1) * per_page
|
||||||
|
end = start + per_page
|
||||||
|
|
||||||
|
auctions_page = auctions[start:end]
|
||||||
|
serializer = AuctionSerializer(auctions_page, many=True)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'auctions': serializer.data,
|
||||||
|
'total': auctions.count(),
|
||||||
|
'page': page,
|
||||||
|
'per_page': per_page,
|
||||||
|
'has_next': end < auctions.count(),
|
||||||
|
'has_previous': page > 1
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def start_session(request):
|
||||||
|
"""Mobile Session starten"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request.data
|
||||||
|
device_token = data.get('device_token')
|
||||||
|
session_id = data.get('session_id')
|
||||||
|
|
||||||
|
if not device_token or not session_id:
|
||||||
|
return Response({'error': 'Missing required fields'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Get device
|
||||||
|
try:
|
||||||
|
device = MobileDevice.objects.get(
|
||||||
|
device_token=device_token,
|
||||||
|
user=request.user,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
except MobileDevice.DoesNotExist:
|
||||||
|
return Response({'error': 'Device not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# Create or update session
|
||||||
|
session, created = MobileSession.objects.get_or_create(
|
||||||
|
session_id=session_id,
|
||||||
|
defaults={
|
||||||
|
'user': request.user,
|
||||||
|
'device': device,
|
||||||
|
'is_active': True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not created:
|
||||||
|
session.is_active = True
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'session_id': session.session_id,
|
||||||
|
'started_at': session.started_at
|
||||||
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def end_session(request, session_id):
|
||||||
|
"""Mobile Session beenden"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = MobileSession.objects.get(
|
||||||
|
session_id=session_id,
|
||||||
|
user=request.user,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
session.end_session()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'session_id': session.session_id,
|
||||||
|
'duration': session.duration,
|
||||||
|
'ended_at': session.ended_at
|
||||||
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
except MobileSession.DoesNotExist:
|
||||||
|
return Response({'error': 'Session not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def report_error(request):
|
||||||
|
"""Mobile App Error melden"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request.data
|
||||||
|
error_level = data.get('error_level', 'error')
|
||||||
|
error_type = data.get('error_type')
|
||||||
|
error_message = data.get('error_message')
|
||||||
|
stack_trace = data.get('stack_trace', '')
|
||||||
|
device_token = data.get('device_token')
|
||||||
|
|
||||||
|
if not error_type or not error_message or not device_token:
|
||||||
|
return Response({'error': 'Missing required fields'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Get device
|
||||||
|
try:
|
||||||
|
device = MobileDevice.objects.get(
|
||||||
|
device_token=device_token,
|
||||||
|
user=request.user,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
except MobileDevice.DoesNotExist:
|
||||||
|
return Response({'error': 'Device not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# Create error record
|
||||||
|
error = MobileError.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
device=device,
|
||||||
|
error_level=error_level,
|
||||||
|
error_type=error_type,
|
||||||
|
error_message=error_message,
|
||||||
|
stack_trace=stack_trace,
|
||||||
|
app_version=data.get('app_version', ''),
|
||||||
|
os_version=data.get('os_version', ''),
|
||||||
|
device_info=data.get('device_info', {})
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({'error_id': str(error.id)}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_mobile_cache(request, cache_type):
|
||||||
|
"""Mobile Cache abrufen"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
cache_key = request.GET.get('key', 'default')
|
||||||
|
|
||||||
|
cache = MobileCache.objects.filter(
|
||||||
|
user=request.user,
|
||||||
|
cache_type=cache_type,
|
||||||
|
cache_key=cache_key,
|
||||||
|
expires_at__gt=timezone.now()
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if cache:
|
||||||
|
return Response({
|
||||||
|
'data': cache.cache_data,
|
||||||
|
'expires_at': cache.expires_at
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return Response({'data': None}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def update_mobile_cache(request, cache_type):
|
||||||
|
"""Mobile Cache aktualisieren"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request.data
|
||||||
|
cache_key = data.get('key', 'default')
|
||||||
|
cache_data = data.get('data', {})
|
||||||
|
expires_in = data.get('expires_in', 3600) # Default 1 hour
|
||||||
|
|
||||||
|
expires_at = timezone.now() + timezone.timedelta(seconds=expires_in)
|
||||||
|
|
||||||
|
cache, created = MobileCache.objects.update_or_create(
|
||||||
|
user=request.user,
|
||||||
|
cache_type=cache_type,
|
||||||
|
cache_key=cache_key,
|
||||||
|
defaults={
|
||||||
|
'cache_data': cache_data,
|
||||||
|
'expires_at': expires_at
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'cache_id': str(cache.id),
|
||||||
|
'expires_at': cache.expires_at
|
||||||
|
}, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from .models import PayPalConfig
|
||||||
|
|
||||||
|
@admin.register(PayPalConfig)
|
||||||
|
class PayPalConfigAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('get_mode', 'client_id', 'is_sandbox', 'updated_at')
|
||||||
|
readonly_fields = ('created_at', 'updated_at')
|
||||||
|
fieldsets = (
|
||||||
|
('API Konfiguration', {
|
||||||
|
'fields': ('client_id', 'client_secret', 'is_sandbox')
|
||||||
|
}),
|
||||||
|
('Zeitstempel', {
|
||||||
|
'fields': ('created_at', 'updated_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
class PayPalConfig(models.Model):
|
||||||
|
client_id = models.CharField(max_length=255)
|
||||||
|
client_secret = models.CharField(max_length=255)
|
||||||
|
is_sandbox = models.BooleanField(default=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'PayPal Konfiguration'
|
||||||
|
verbose_name_plural = 'PayPal Konfigurationen'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"PayPal Config ({self.get_mode()})"
|
||||||
|
|
||||||
|
def get_mode(self):
|
||||||
|
return "Sandbox" if self.is_sandbox else "Live"
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = 'paypal'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('process/<int:order_id>/', views.process_payment, name='process_payment'),
|
||||||
|
path('execute/<int:order_id>/', views.execute_payment, name='execute_payment'),
|
||||||
|
path('cancel/<int:order_id>/', views.cancel_payment, name='cancel_payment'),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
from django.shortcuts import render, redirect
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.conf import settings
|
||||||
|
import paypalrestsdk
|
||||||
|
from .models import PayPalConfig
|
||||||
|
from products.models import Order
|
||||||
|
|
||||||
|
def setup_paypal():
|
||||||
|
config = PayPalConfig.objects.first()
|
||||||
|
if config:
|
||||||
|
paypalrestsdk.configure({
|
||||||
|
"mode": "sandbox" if config.is_sandbox else "live",
|
||||||
|
"client_id": config.client_id,
|
||||||
|
"client_secret": config.client_secret
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def process_payment(request, order_id):
|
||||||
|
if not setup_paypal():
|
||||||
|
messages.error(request, 'PayPal ist nicht richtig konfiguriert. Bitte kontaktieren Sie den Administrator.')
|
||||||
|
return redirect('order_detail', order_id=order_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
order = Order.objects.get(id=order_id, user=request.user)
|
||||||
|
|
||||||
|
payment = paypalrestsdk.Payment({
|
||||||
|
"intent": "sale",
|
||||||
|
"payer": {
|
||||||
|
"payment_method": "paypal"
|
||||||
|
},
|
||||||
|
"redirect_urls": {
|
||||||
|
"return_url": request.build_absolute_uri(f'/paypal/execute/{order_id}/'),
|
||||||
|
"cancel_url": request.build_absolute_uri(f'/paypal/cancel/{order_id}/')
|
||||||
|
},
|
||||||
|
"transactions": [{
|
||||||
|
"amount": {
|
||||||
|
"total": str(order.total_amount),
|
||||||
|
"currency": "EUR"
|
||||||
|
},
|
||||||
|
"description": f"Bestellung #{order.id}"
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
if payment.create():
|
||||||
|
for link in payment.links:
|
||||||
|
if link.method == "REDIRECT":
|
||||||
|
return redirect(link.href)
|
||||||
|
else:
|
||||||
|
messages.error(request, 'Fehler bei der Erstellung der PayPal-Zahlung.')
|
||||||
|
|
||||||
|
except Order.DoesNotExist:
|
||||||
|
messages.error(request, 'Bestellung nicht gefunden.')
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f'Ein Fehler ist aufgetreten: {str(e)}')
|
||||||
|
|
||||||
|
return redirect('order_detail', order_id=order_id)
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def execute_payment(request, order_id):
|
||||||
|
payment_id = request.GET.get('paymentId')
|
||||||
|
payer_id = request.GET.get('PayerID')
|
||||||
|
|
||||||
|
try:
|
||||||
|
payment = paypalrestsdk.Payment.find(payment_id)
|
||||||
|
if payment.execute({"payer_id": payer_id}):
|
||||||
|
order = Order.objects.get(id=order_id, user=request.user)
|
||||||
|
order.status = 'paid'
|
||||||
|
order.payment_method = 'paypal'
|
||||||
|
order.payment_id = payment_id
|
||||||
|
order.save()
|
||||||
|
|
||||||
|
messages.success(request, 'Zahlung erfolgreich durchgeführt!')
|
||||||
|
return redirect('payment_success', order_id=order_id)
|
||||||
|
else:
|
||||||
|
messages.error(request, 'Fehler bei der Ausführung der Zahlung.')
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f'Ein Fehler ist aufgetreten: {str(e)}')
|
||||||
|
|
||||||
|
return redirect('payment_failed', order_id=order_id)
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def cancel_payment(request, order_id):
|
||||||
|
messages.info(request, 'Die PayPal-Zahlung wurde abgebrochen.')
|
||||||
|
return redirect('order_detail', order_id=order_id)
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Diese Datei ist jetzt leer, da alle Admin-Konfigurationen in webshop/admin.py zentral verwaltet werden
|
||||||
|
# Die Admin-Konfigurationen wurden in die neue zentrale Admin-Site verschoben
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
from django.db.models import Count, Sum, Avg, Q
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
from .models import Product, Order, Review, CustomOrder, UserProfile
|
||||||
|
|
||||||
|
class ShopAnalytics:
|
||||||
|
"""Analytics-Klasse für Shop-Statistiken"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_sales_statistics(days=30):
|
||||||
|
"""Verkaufsstatistiken der letzten X Tage"""
|
||||||
|
end_date = timezone.now()
|
||||||
|
start_date = end_date - timedelta(days=days)
|
||||||
|
|
||||||
|
orders = Order.objects.filter(
|
||||||
|
created__range=(start_date, end_date),
|
||||||
|
payment_status='paid'
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_orders': orders.count(),
|
||||||
|
'total_revenue': orders.aggregate(Sum('total_amount'))['total_amount__sum'] or 0,
|
||||||
|
'average_order_value': orders.aggregate(Avg('total_amount'))['total_amount__avg'] or 0,
|
||||||
|
'top_products': orders.values('items__product__name').annotate(
|
||||||
|
count=Count('items__product')
|
||||||
|
).order_by('-count')[:5]
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_product_analytics():
|
||||||
|
"""Produkt-spezifische Analytics"""
|
||||||
|
products = Product.objects.all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_products': products.count(),
|
||||||
|
'in_stock': products.filter(stock__gt=0).count(),
|
||||||
|
'low_stock': products.filter(stock__lte=5, stock__gt=0).count(),
|
||||||
|
'out_of_stock': products.filter(stock=0).count(),
|
||||||
|
'featured_products': products.filter(is_featured=True).count(),
|
||||||
|
'custom_orders': products.filter(is_custom_order=True).count(),
|
||||||
|
'top_rated': products.annotate(
|
||||||
|
avg_rating=Avg('reviews__rating')
|
||||||
|
).filter(avg_rating__gte=4.0).order_by('-avg_rating')[:5]
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_user_analytics():
|
||||||
|
"""Benutzer-spezifische Analytics"""
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
users = User.objects.all()
|
||||||
|
profiles = UserProfile.objects.all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_users': users.count(),
|
||||||
|
'active_users': users.filter(last_login__gte=timezone.now() - timedelta(days=30)).count(),
|
||||||
|
'newsletter_subscribers': profiles.filter(newsletter=True).count(),
|
||||||
|
'top_customers': Order.objects.values('user__username').annotate(
|
||||||
|
total_spent=Sum('total_amount')
|
||||||
|
).order_by('-total_spent')[:5]
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_custom_order_analytics():
|
||||||
|
"""Custom Order Analytics"""
|
||||||
|
custom_orders = CustomOrder.objects.all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_custom_orders': custom_orders.count(),
|
||||||
|
'pending_orders': custom_orders.filter(status='pending').count(),
|
||||||
|
'in_progress': custom_orders.filter(status='in_progress').count(),
|
||||||
|
'completed': custom_orders.filter(status='completed').count(),
|
||||||
|
'average_completion_time': custom_orders.filter(
|
||||||
|
status='completed'
|
||||||
|
).aggregate(
|
||||||
|
avg_time=Avg('updated' - 'created')
|
||||||
|
)['avg_time']
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_review_analytics():
|
||||||
|
"""Review Analytics"""
|
||||||
|
reviews = Review.objects.all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_reviews': reviews.count(),
|
||||||
|
'average_rating': reviews.aggregate(Avg('rating'))['rating__avg'] or 0,
|
||||||
|
'rating_distribution': reviews.values('rating').annotate(
|
||||||
|
count=Count('rating')
|
||||||
|
).order_by('rating'),
|
||||||
|
'recent_reviews': reviews.order_by('-created')[:10]
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserBehaviorTracker:
|
||||||
|
"""Tracking für Benutzerverhalten"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def track_product_view(product, user=None):
|
||||||
|
"""Produktansicht tracken"""
|
||||||
|
# Hier könnte man Redis oder eine separate Tracking-Tabelle verwenden
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def track_search_query(query, user=None):
|
||||||
|
"""Suchanfragen tracken"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def track_cart_addition(product, user=None):
|
||||||
|
"""Warenkorb-Hinzufügung tracken"""
|
||||||
|
pass
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
from rest_framework import viewsets, permissions, filters
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
from .models import Product, Review, Category, CustomOrder
|
||||||
|
from .serializers import (
|
||||||
|
ProductSerializer, ReviewSerializer,
|
||||||
|
CategorySerializer, CustomOrderSerializer
|
||||||
|
)
|
||||||
|
|
||||||
|
class ProductViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
queryset = Product.objects.all().select_related('category').prefetch_related('reviews')
|
||||||
|
serializer_class = ProductSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||||
|
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||||
|
filterset_fields = ['fursuit_type', 'style', 'category', 'is_featured', 'is_custom_order']
|
||||||
|
search_fields = ['name', 'description', 'category__name']
|
||||||
|
ordering_fields = ['price', 'created', 'name', 'average_rating']
|
||||||
|
ordering = ['-created']
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def add_to_wishlist(self, request, pk=None):
|
||||||
|
product = self.get_object()
|
||||||
|
user = request.user
|
||||||
|
if user.is_authenticated:
|
||||||
|
product.wishlist_users.add(user)
|
||||||
|
return Response({'status': 'added to wishlist'})
|
||||||
|
return Response({'error': 'user not authenticated'}, status=401)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def add_review(self, request, pk=None):
|
||||||
|
product = self.get_object()
|
||||||
|
serializer = ReviewSerializer(data=request.data)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save(product=product, user=request.user)
|
||||||
|
return Response(serializer.data, status=201)
|
||||||
|
return Response(serializer.errors, status=400)
|
||||||
|
|
||||||
|
class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
queryset = Category.objects.all()
|
||||||
|
serializer_class = CategorySerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||||
|
|
||||||
|
class ReviewViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Review.objects.all().select_related('product', 'user')
|
||||||
|
serializer_class = ReviewSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||||
|
filter_backends = [DjangoFilterBackend]
|
||||||
|
filterset_fields = ['product', 'rating']
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(user=self.request.user)
|
||||||
|
|
||||||
|
class CustomOrderViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = CustomOrderSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return CustomOrder.objects.filter(user=self.request.user)
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(user=self.request.user)
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from . import api_views
|
||||||
|
|
||||||
|
# Router für ViewSets
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'products', api_views.ProductViewSet, basename='api-product')
|
||||||
|
router.register(r'categories', api_views.CategoryViewSet, basename='api-category')
|
||||||
|
router.register(r'reviews', api_views.ReviewViewSet, basename='api-review')
|
||||||
|
router.register(r'wishlist', api_views.WishlistViewSet, basename='api-wishlist')
|
||||||
|
router.register(r'custom-orders', api_views.CustomOrderViewSet, basename='api-custom-order')
|
||||||
|
router.register(r'gallery', api_views.GalleryImageViewSet, basename='api-gallery')
|
||||||
|
router.register(r'cart', api_views.CartViewSet, basename='api-cart')
|
||||||
|
router.register(r'search', api_views.SearchViewSet, basename='api-search')
|
||||||
|
|
||||||
|
# API URL Patterns
|
||||||
|
urlpatterns = [
|
||||||
|
# Router URLs
|
||||||
|
path('', include(router.urls)),
|
||||||
|
|
||||||
|
# Zusätzliche API Endpoints
|
||||||
|
path('products/<int:pk>/add-to-wishlist/',
|
||||||
|
api_views.ProductViewSet.as_view({'post': 'add_to_wishlist'}),
|
||||||
|
name='api-product-add-to-wishlist'),
|
||||||
|
|
||||||
|
path('products/<int:pk>/remove-from-wishlist/',
|
||||||
|
api_views.ProductViewSet.as_view({'post': 'remove_from_wishlist'}),
|
||||||
|
name='api-product-remove-from-wishlist'),
|
||||||
|
|
||||||
|
path('products/featured/',
|
||||||
|
api_views.ProductViewSet.as_view({'get': 'featured'}),
|
||||||
|
name='api-products-featured'),
|
||||||
|
|
||||||
|
path('products/on-sale/',
|
||||||
|
api_views.ProductViewSet.as_view({'get': 'on_sale'}),
|
||||||
|
name='api-products-on-sale'),
|
||||||
|
|
||||||
|
path('products/low-stock/',
|
||||||
|
api_views.ProductViewSet.as_view({'get': 'low_stock'}),
|
||||||
|
name='api-products-low-stock'),
|
||||||
|
|
||||||
|
path('products/stats/',
|
||||||
|
api_views.ProductViewSet.as_view({'get': 'stats'}),
|
||||||
|
name='api-products-stats'),
|
||||||
|
|
||||||
|
path('categories/<int:pk>/products/',
|
||||||
|
api_views.CategoryViewSet.as_view({'get': 'products'}),
|
||||||
|
name='api-category-products'),
|
||||||
|
|
||||||
|
path('reviews/my-reviews/',
|
||||||
|
api_views.ReviewViewSet.as_view({'get': 'my_reviews'}),
|
||||||
|
name='api-my-reviews'),
|
||||||
|
|
||||||
|
path('wishlist/add-product/',
|
||||||
|
api_views.WishlistViewSet.as_view({'post': 'add_product'}),
|
||||||
|
name='api-wishlist-add-product'),
|
||||||
|
|
||||||
|
path('wishlist/remove-product/',
|
||||||
|
api_views.WishlistViewSet.as_view({'post': 'remove_product'}),
|
||||||
|
name='api-wishlist-remove-product'),
|
||||||
|
|
||||||
|
path('custom-orders/<int:pk>/update-status/',
|
||||||
|
api_views.CustomOrderViewSet.as_view({'post': 'update_status'}),
|
||||||
|
name='api-custom-order-update-status'),
|
||||||
|
|
||||||
|
path('gallery/featured/',
|
||||||
|
api_views.GalleryImageViewSet.as_view({'get': 'featured'}),
|
||||||
|
name='api-gallery-featured'),
|
||||||
|
|
||||||
|
path('gallery/by-product/',
|
||||||
|
api_views.GalleryImageViewSet.as_view({'get': 'by_product'}),
|
||||||
|
name='api-gallery-by-product'),
|
||||||
|
|
||||||
|
path('cart/get/',
|
||||||
|
api_views.CartViewSet.as_view({'get': 'get_cart'}),
|
||||||
|
name='api-cart-get'),
|
||||||
|
|
||||||
|
path('cart/add-item/',
|
||||||
|
api_views.CartViewSet.as_view({'post': 'add_item'}),
|
||||||
|
name='api-cart-add-item'),
|
||||||
|
|
||||||
|
path('cart/update-quantity/',
|
||||||
|
api_views.CartViewSet.as_view({'post': 'update_quantity'}),
|
||||||
|
name='api-cart-update-quantity'),
|
||||||
|
|
||||||
|
path('cart/remove-item/',
|
||||||
|
api_views.CartViewSet.as_view({'post': 'remove_item'}),
|
||||||
|
name='api-cart-remove-item'),
|
||||||
|
|
||||||
|
path('cart/clear/',
|
||||||
|
api_views.CartViewSet.as_view({'post': 'clear_cart'}),
|
||||||
|
name='api-cart-clear'),
|
||||||
|
|
||||||
|
path('search/products/',
|
||||||
|
api_views.SearchViewSet.as_view({'get': 'products'}),
|
||||||
|
name='api-search-products'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# API Root
|
||||||
|
api_root = {
|
||||||
|
'products': 'api:product-list',
|
||||||
|
'categories': 'api:category-list',
|
||||||
|
'reviews': 'api:review-list',
|
||||||
|
'wishlist': 'api:wishlist-list',
|
||||||
|
'custom-orders': 'api:custom-order-list',
|
||||||
|
'gallery': 'api:gallery-list',
|
||||||
|
'cart': 'api:cart-list',
|
||||||
|
'search': 'api:search-list',
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,485 @@
|
||||||
|
from rest_framework import viewsets, status, filters
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly
|
||||||
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
from django.db.models import Q, Avg, Count
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from .models import Product, Category, Review, Wishlist, GalleryImage, CustomOrder
|
||||||
|
from .serializers import (
|
||||||
|
ProductSerializer, ProductListSerializer, ProductDetailSerializer,
|
||||||
|
CategorySerializer, ReviewSerializer, WishlistSerializer,
|
||||||
|
CustomOrderSerializer, CartSerializer, SearchFilterSerializer,
|
||||||
|
ProductStatsSerializer, GalleryImageDetailSerializer
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductViewSet(viewsets.ModelViewSet):
|
||||||
|
"""ViewSet für Produkt-API"""
|
||||||
|
queryset = Product.objects.select_related('category').prefetch_related(
|
||||||
|
'reviews', 'gallery_images', 'wishlists'
|
||||||
|
).all()
|
||||||
|
serializer_class = ProductListSerializer
|
||||||
|
permission_classes = [IsAuthenticatedOrReadOnly]
|
||||||
|
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||||
|
filterset_fields = ['category', 'product_type', 'fursuit_type', 'style', 'on_sale', 'is_featured']
|
||||||
|
search_fields = ['name', 'description']
|
||||||
|
ordering_fields = ['name', 'base_price', 'created_at', 'average_rating']
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == 'retrieve':
|
||||||
|
return ProductDetailSerializer
|
||||||
|
elif self.action == 'create' or self.action == 'update':
|
||||||
|
return ProductSerializer
|
||||||
|
return ProductListSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
|
||||||
|
# Filter nach Preis
|
||||||
|
min_price = self.request.query_params.get('min_price')
|
||||||
|
max_price = self.request.query_params.get('max_price')
|
||||||
|
|
||||||
|
if min_price:
|
||||||
|
queryset = queryset.filter(base_price__gte=min_price)
|
||||||
|
if max_price:
|
||||||
|
queryset = queryset.filter(base_price__lte=max_price)
|
||||||
|
|
||||||
|
# Filter nach Lagerbestand
|
||||||
|
in_stock = self.request.query_params.get('in_stock')
|
||||||
|
if in_stock == 'true':
|
||||||
|
queryset = queryset.filter(stock__gt=0)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def featured(self, request):
|
||||||
|
"""Featured Produkte"""
|
||||||
|
products = self.get_queryset().filter(is_featured=True)
|
||||||
|
serializer = self.get_serializer(products, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def on_sale(self, request):
|
||||||
|
"""Produkte im Angebot"""
|
||||||
|
products = self.get_queryset().filter(on_sale=True)
|
||||||
|
serializer = self.get_serializer(products, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def low_stock(self, request):
|
||||||
|
"""Produkte mit niedrigem Lagerbestand"""
|
||||||
|
products = self.get_queryset().filter(stock__lte=5, stock__gt=0)
|
||||||
|
serializer = self.get_serializer(products, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def add_to_wishlist(self, request, pk=None):
|
||||||
|
"""Produkt zur Wunschliste hinzufügen"""
|
||||||
|
product = self.get_object()
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Authentication required'},
|
||||||
|
status=status.HTTP_401_UNAUTHORIZED
|
||||||
|
)
|
||||||
|
|
||||||
|
wishlist, created = Wishlist.objects.get_or_create(user=user)
|
||||||
|
wishlist.products.add(product)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'message': 'Product added to wishlist',
|
||||||
|
'product_id': product.id
|
||||||
|
})
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def remove_from_wishlist(self, request, pk=None):
|
||||||
|
"""Produkt von Wunschliste entfernen"""
|
||||||
|
product = self.get_object()
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Authentication required'},
|
||||||
|
status=status.HTTP_401_UNAUTHORIZED
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
wishlist = Wishlist.objects.get(user=user)
|
||||||
|
wishlist.products.remove(product)
|
||||||
|
return Response({
|
||||||
|
'message': 'Product removed from wishlist',
|
||||||
|
'product_id': product.id
|
||||||
|
})
|
||||||
|
except Wishlist.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Wishlist not found'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def stats(self, request):
|
||||||
|
"""Produkt-Statistiken"""
|
||||||
|
queryset = self.get_queryset()
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'total_products': queryset.count(),
|
||||||
|
'featured_products': queryset.filter(is_featured=True).count(),
|
||||||
|
'on_sale_products': queryset.filter(on_sale=True).count(),
|
||||||
|
'low_stock_products': queryset.filter(stock__lte=5, stock__gt=0).count(),
|
||||||
|
'categories_count': Category.objects.count(),
|
||||||
|
'average_rating': queryset.aggregate(Avg('average_rating'))['average_rating__avg'] or 0,
|
||||||
|
'total_reviews': Review.objects.count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
serializer = ProductStatsSerializer(stats)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""ViewSet für Kategorie-API"""
|
||||||
|
queryset = Category.objects.prefetch_related('products').all()
|
||||||
|
serializer_class = CategorySerializer
|
||||||
|
permission_classes = [IsAuthenticatedOrReadOnly]
|
||||||
|
|
||||||
|
@action(detail=True, methods=['get'])
|
||||||
|
def products(self, request, pk=None):
|
||||||
|
"""Produkte einer Kategorie"""
|
||||||
|
category = self.get_object()
|
||||||
|
products = category.products.all()
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
page = self.paginate_queryset(products)
|
||||||
|
if page is not None:
|
||||||
|
serializer = ProductListSerializer(page, many=True, context={'request': request})
|
||||||
|
return self.get_paginated_response(serializer.data)
|
||||||
|
|
||||||
|
serializer = ProductListSerializer(products, many=True, context={'request': request})
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewViewSet(viewsets.ModelViewSet):
|
||||||
|
"""ViewSet für Review-API"""
|
||||||
|
queryset = Review.objects.select_related('user', 'product').all()
|
||||||
|
serializer_class = ReviewSerializer
|
||||||
|
permission_classes = [IsAuthenticatedOrReadOnly]
|
||||||
|
filter_backends = [DjangoFilterBackend]
|
||||||
|
filterset_fields = ['product', 'rating']
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(user=self.request.user)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
product_id = self.request.query_params.get('product_id')
|
||||||
|
if product_id:
|
||||||
|
queryset = queryset.filter(product_id=product_id)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def my_reviews(self, request):
|
||||||
|
"""Reviews des aktuellen Users"""
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Authentication required'},
|
||||||
|
status=status.HTTP_401_UNAUTHORIZED
|
||||||
|
)
|
||||||
|
|
||||||
|
reviews = self.get_queryset().filter(user=request.user)
|
||||||
|
serializer = self.get_serializer(reviews, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class WishlistViewSet(viewsets.ModelViewSet):
|
||||||
|
"""ViewSet für Wishlist-API"""
|
||||||
|
serializer_class = WishlistSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Wishlist.objects.filter(user=self.request.user)
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(user=self.request.user)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'])
|
||||||
|
def add_product(self, request):
|
||||||
|
"""Produkt zur Wunschliste hinzufügen"""
|
||||||
|
product_id = request.data.get('product_id')
|
||||||
|
if not product_id:
|
||||||
|
return Response(
|
||||||
|
{'error': 'product_id is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
product = Product.objects.get(id=product_id)
|
||||||
|
wishlist, created = Wishlist.objects.get_or_create(user=request.user)
|
||||||
|
wishlist.products.add(product)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'message': 'Product added to wishlist',
|
||||||
|
'product_id': product_id
|
||||||
|
})
|
||||||
|
except Product.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Product not found'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'])
|
||||||
|
def remove_product(self, request):
|
||||||
|
"""Produkt von Wunschliste entfernen"""
|
||||||
|
product_id = request.data.get('product_id')
|
||||||
|
if not product_id:
|
||||||
|
return Response(
|
||||||
|
{'error': 'product_id is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
wishlist = Wishlist.objects.get(user=request.user)
|
||||||
|
product = Product.objects.get(id=product_id)
|
||||||
|
wishlist.products.remove(product)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'message': 'Product removed from wishlist',
|
||||||
|
'product_id': product_id
|
||||||
|
})
|
||||||
|
except (Wishlist.DoesNotExist, Product.DoesNotExist):
|
||||||
|
return Response(
|
||||||
|
{'error': 'Wishlist or product not found'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomOrderViewSet(viewsets.ModelViewSet):
|
||||||
|
"""ViewSet für Custom Order-API"""
|
||||||
|
serializer_class = CustomOrderSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
filter_backends = [DjangoFilterBackend]
|
||||||
|
filterset_fields = ['status', 'fursuit_type', 'style']
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return CustomOrder.objects.filter(user=self.request.user)
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(user=self.request.user)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def update_status(self, request, pk=None):
|
||||||
|
"""Status eines Custom Orders aktualisieren"""
|
||||||
|
custom_order = self.get_object()
|
||||||
|
new_status = request.data.get('status')
|
||||||
|
|
||||||
|
if new_status not in dict(CustomOrder.STATUS_CHOICES):
|
||||||
|
return Response(
|
||||||
|
{'error': 'Invalid status'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
custom_order.status = new_status
|
||||||
|
custom_order.save()
|
||||||
|
|
||||||
|
serializer = self.get_serializer(custom_order)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class GalleryImageViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""ViewSet für Galerie-API"""
|
||||||
|
queryset = GalleryImage.objects.select_related('product').all()
|
||||||
|
serializer_class = GalleryImageDetailSerializer
|
||||||
|
permission_classes = [IsAuthenticatedOrReadOnly]
|
||||||
|
filter_backends = [DjangoFilterBackend]
|
||||||
|
filterset_fields = ['is_featured', 'fursuit_type', 'style']
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def featured(self, request):
|
||||||
|
"""Featured Galerie-Bilder"""
|
||||||
|
images = self.get_queryset().filter(is_featured=True)
|
||||||
|
serializer = self.get_serializer(images, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def by_product(self, request):
|
||||||
|
"""Bilder nach Produkt filtern"""
|
||||||
|
product_id = request.query_params.get('product_id')
|
||||||
|
if not product_id:
|
||||||
|
return Response(
|
||||||
|
{'error': 'product_id is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
images = self.get_queryset().filter(product_id=product_id)
|
||||||
|
serializer = self.get_serializer(images, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class CartViewSet(viewsets.ViewSet):
|
||||||
|
"""ViewSet für Warenkorb-API"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def get_cart(self, request):
|
||||||
|
"""Warenkorb abrufen"""
|
||||||
|
# Hier würde die Warenkorb-Logik implementiert
|
||||||
|
# Für jetzt ein Beispiel-Response
|
||||||
|
cart_data = {
|
||||||
|
'items': [],
|
||||||
|
'subtotal': 0,
|
||||||
|
'shipping_cost': 5.99,
|
||||||
|
'total': 5.99,
|
||||||
|
'item_count': 0
|
||||||
|
}
|
||||||
|
serializer = CartSerializer(cart_data)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'])
|
||||||
|
def add_item(self, request):
|
||||||
|
"""Item zum Warenkorb hinzufügen"""
|
||||||
|
product_id = request.data.get('product_id')
|
||||||
|
quantity = request.data.get('quantity', 1)
|
||||||
|
|
||||||
|
if not product_id:
|
||||||
|
return Response(
|
||||||
|
{'error': 'product_id is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
product = Product.objects.get(id=product_id)
|
||||||
|
# Hier würde die Warenkorb-Logik implementiert
|
||||||
|
return Response({
|
||||||
|
'message': 'Product added to cart',
|
||||||
|
'product_id': product_id,
|
||||||
|
'quantity': quantity
|
||||||
|
})
|
||||||
|
except Product.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Product not found'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'])
|
||||||
|
def update_quantity(self, request):
|
||||||
|
"""Menge eines Items aktualisieren"""
|
||||||
|
item_id = request.data.get('item_id')
|
||||||
|
quantity = request.data.get('quantity')
|
||||||
|
|
||||||
|
if not item_id or not quantity:
|
||||||
|
return Response(
|
||||||
|
{'error': 'item_id and quantity are required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hier würde die Warenkorb-Logik implementiert
|
||||||
|
return Response({
|
||||||
|
'message': 'Quantity updated',
|
||||||
|
'item_id': item_id,
|
||||||
|
'quantity': quantity
|
||||||
|
})
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'])
|
||||||
|
def remove_item(self, request):
|
||||||
|
"""Item aus Warenkorb entfernen"""
|
||||||
|
item_id = request.data.get('item_id')
|
||||||
|
|
||||||
|
if not item_id:
|
||||||
|
return Response(
|
||||||
|
{'error': 'item_id is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hier würde die Warenkorb-Logik implementiert
|
||||||
|
return Response({
|
||||||
|
'message': 'Item removed from cart',
|
||||||
|
'item_id': item_id
|
||||||
|
})
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'])
|
||||||
|
def clear_cart(self, request):
|
||||||
|
"""Warenkorb leeren"""
|
||||||
|
# Hier würde die Warenkorb-Logik implementiert
|
||||||
|
return Response({
|
||||||
|
'message': 'Cart cleared'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class SearchViewSet(viewsets.ViewSet):
|
||||||
|
"""ViewSet für Such-API"""
|
||||||
|
permission_classes = [IsAuthenticatedOrReadOnly]
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def products(self, request):
|
||||||
|
"""Produkte suchen"""
|
||||||
|
serializer = SearchFilterSerializer(data=request.query_params)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return Response(
|
||||||
|
serializer.errors,
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
data = serializer.validated_data
|
||||||
|
queryset = Product.objects.select_related('category').all()
|
||||||
|
|
||||||
|
# Suchfilter anwenden
|
||||||
|
if data.get('query'):
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(name__icontains=data['query']) |
|
||||||
|
Q(description__icontains=data['query'])
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.get('category'):
|
||||||
|
queryset = queryset.filter(category__slug=data['category'])
|
||||||
|
|
||||||
|
if data.get('fursuit_type'):
|
||||||
|
queryset = queryset.filter(fursuit_type=data['fursuit_type'])
|
||||||
|
|
||||||
|
if data.get('style'):
|
||||||
|
queryset = queryset.filter(style=data['style'])
|
||||||
|
|
||||||
|
if data.get('min_price'):
|
||||||
|
queryset = queryset.filter(base_price__gte=data['min_price'])
|
||||||
|
|
||||||
|
if data.get('max_price'):
|
||||||
|
queryset = queryset.filter(base_price__lte=data['max_price'])
|
||||||
|
|
||||||
|
if data.get('on_sale'):
|
||||||
|
queryset = queryset.filter(on_sale=data['on_sale'])
|
||||||
|
|
||||||
|
if data.get('is_featured'):
|
||||||
|
queryset = queryset.filter(is_featured=data['is_featured'])
|
||||||
|
|
||||||
|
# Sortierung
|
||||||
|
sort_by = data.get('sort_by', 'newest')
|
||||||
|
if sort_by == 'newest':
|
||||||
|
queryset = queryset.order_by('-created_at')
|
||||||
|
elif sort_by == 'oldest':
|
||||||
|
queryset = queryset.order_by('created_at')
|
||||||
|
elif sort_by == 'price_low':
|
||||||
|
queryset = queryset.order_by('base_price')
|
||||||
|
elif sort_by == 'price_high':
|
||||||
|
queryset = queryset.order_by('-base_price')
|
||||||
|
elif sort_by == 'name':
|
||||||
|
queryset = queryset.order_by('name')
|
||||||
|
elif sort_by == 'rating':
|
||||||
|
queryset = queryset.order_by('-average_rating')
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
page_size = data.get('page_size', 12)
|
||||||
|
page = data.get('page', 1)
|
||||||
|
|
||||||
|
start = (page - 1) * page_size
|
||||||
|
end = start + page_size
|
||||||
|
|
||||||
|
products = queryset[start:end]
|
||||||
|
serializer = ProductListSerializer(products, many=True, context={'request': request})
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'results': serializer.data,
|
||||||
|
'total': queryset.count(),
|
||||||
|
'page': page,
|
||||||
|
'page_size': page_size,
|
||||||
|
'pages': (queryset.count() + page_size - 1) // page_size
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ProductsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'products'
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.utils import timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
class Auction(models.Model):
|
||||||
|
"""Auktion für Custom-Design-Fursuits"""
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('draft', 'Entwurf'),
|
||||||
|
('active', 'Aktiv'),
|
||||||
|
('bidding', 'Bietphase'),
|
||||||
|
('ended', 'Beendet'),
|
||||||
|
('cancelled', 'Storniert'),
|
||||||
|
]
|
||||||
|
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
description = models.TextField()
|
||||||
|
starting_price = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
current_price = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
reserve_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||||
|
min_bid_increment = models.DecimalField(max_digits=10, decimal_places=2, default=10.00)
|
||||||
|
|
||||||
|
# Custom Order Details
|
||||||
|
fursuit_type = models.CharField(max_length=20, choices=[
|
||||||
|
('partial', 'Partial'),
|
||||||
|
('fullsuit', 'Fullsuit'),
|
||||||
|
('head', 'Head Only'),
|
||||||
|
('paws', 'Paws'),
|
||||||
|
('tail', 'Tail'),
|
||||||
|
('other', 'Other'),
|
||||||
|
])
|
||||||
|
style = models.CharField(max_length=20, choices=[
|
||||||
|
('toony', 'Toony'),
|
||||||
|
('semi_realistic', 'Semi-Realistic'),
|
||||||
|
('realistic', 'Realistic'),
|
||||||
|
('anime', 'Anime'),
|
||||||
|
('chibi', 'Chibi'),
|
||||||
|
])
|
||||||
|
|
||||||
|
# Timing
|
||||||
|
start_time = models.DateTimeField()
|
||||||
|
end_time = models.DateTimeField()
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft')
|
||||||
|
winner = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='won_auctions')
|
||||||
|
|
||||||
|
# Images
|
||||||
|
reference_images = models.FileField(upload_to='auctions/references/', null=True, blank=True)
|
||||||
|
preview_image = models.ImageField(upload_to='auctions/previews/', null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Auktion: {self.title} - {self.current_price}€"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
now = timezone.now()
|
||||||
|
return self.status == 'active' and self.start_time <= now <= self.end_time
|
||||||
|
|
||||||
|
@property
|
||||||
|
def time_remaining(self):
|
||||||
|
if not self.is_active:
|
||||||
|
return None
|
||||||
|
return self.end_time - timezone.now()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bid_count(self):
|
||||||
|
return self.bids.count()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def highest_bid(self):
|
||||||
|
return self.bids.order_by('-amount').first()
|
||||||
|
|
||||||
|
class AuctionBid(models.Model):
|
||||||
|
"""Gebot bei einer Auktion"""
|
||||||
|
auction = models.ForeignKey(Auction, on_delete=models.CASCADE, related_name='bids')
|
||||||
|
bidder = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
timestamp = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-timestamp']
|
||||||
|
unique_together = ['auction', 'bidder']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.bidder.username} - {self.amount}€"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# Aktualisiere den aktuellen Preis der Auktion
|
||||||
|
if self.amount > self.auction.current_price:
|
||||||
|
self.auction.current_price = self.amount
|
||||||
|
self.auction.save()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
class AuctionWatchlist(models.Model):
|
||||||
|
"""Beobachtungsliste für Auktionen"""
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
auction = models.ForeignKey(Auction, on_delete=models.CASCADE)
|
||||||
|
added = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ['user', 'auction']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} beobachtet {self.auction.title}"
|
||||||
|
|
||||||
|
class AuctionService:
|
||||||
|
"""Service-Klasse für Auktion-Logik"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def place_bid(auction, user, amount):
|
||||||
|
"""Gebot platzieren"""
|
||||||
|
if not auction.is_active:
|
||||||
|
raise ValueError("Auktion ist nicht aktiv")
|
||||||
|
|
||||||
|
if amount <= auction.current_price:
|
||||||
|
raise ValueError("Gebot muss höher als der aktuelle Preis sein")
|
||||||
|
|
||||||
|
if auction.min_bid_increment and amount < auction.current_price + auction.min_bid_increment:
|
||||||
|
raise ValueError(f"Mindestgebot: {auction.current_price + auction.min_bid_increment}€")
|
||||||
|
|
||||||
|
# Erstelle oder aktualisiere Gebot
|
||||||
|
bid, created = AuctionBid.objects.get_or_create(
|
||||||
|
auction=auction,
|
||||||
|
bidder=user,
|
||||||
|
defaults={'amount': amount}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not created:
|
||||||
|
bid.amount = amount
|
||||||
|
bid.save()
|
||||||
|
|
||||||
|
return bid
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def end_auction(auction):
|
||||||
|
"""Auktion beenden"""
|
||||||
|
if auction.status != 'active':
|
||||||
|
return
|
||||||
|
|
||||||
|
highest_bid = auction.highest_bid
|
||||||
|
|
||||||
|
if highest_bid:
|
||||||
|
# Prüfe Reserve-Preis
|
||||||
|
if auction.reserve_price and highest_bid.amount < auction.reserve_price:
|
||||||
|
auction.status = 'ended'
|
||||||
|
auction.save()
|
||||||
|
return None # Reserve-Preis nicht erreicht
|
||||||
|
|
||||||
|
auction.winner = highest_bid.bidder
|
||||||
|
auction.status = 'ended'
|
||||||
|
auction.save()
|
||||||
|
|
||||||
|
# Erstelle Custom Order
|
||||||
|
custom_order = CustomOrder.objects.create(
|
||||||
|
user=highest_bid.bidder,
|
||||||
|
fursuit_type=auction.fursuit_type,
|
||||||
|
style=auction.style,
|
||||||
|
character_name=f"Auktion: {auction.title}",
|
||||||
|
character_description=auction.description,
|
||||||
|
budget_range=f"Gewonnen für {highest_bid.amount}€",
|
||||||
|
status='approved',
|
||||||
|
quoted_price=highest_bid.amount
|
||||||
|
)
|
||||||
|
|
||||||
|
return custom_order
|
||||||
|
else:
|
||||||
|
auction.status = 'ended'
|
||||||
|
auction.save()
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_auction_stats(auction):
|
||||||
|
"""Statistiken für eine Auktion"""
|
||||||
|
return {
|
||||||
|
'total_bids': auction.bid_count,
|
||||||
|
'unique_bidders': auction.bids.values('bidder').distinct().count(),
|
||||||
|
'current_price': auction.current_price,
|
||||||
|
'time_remaining': auction.time_remaining,
|
||||||
|
'is_active': auction.is_active,
|
||||||
|
'highest_bidder': auction.highest_bid.bidder.username if auction.highest_bid else None
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
class ChatSession(models.Model):
|
||||||
|
"""Chat-Session für Live-Support"""
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('active', 'Aktiv'),
|
||||||
|
('waiting', 'Wartend'),
|
||||||
|
('closed', 'Geschlossen'),
|
||||||
|
]
|
||||||
|
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True)
|
||||||
|
session_id = models.CharField(max_length=100, unique=True)
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='waiting')
|
||||||
|
agent = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='chat_sessions')
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated = models.DateTimeField(auto_now=True)
|
||||||
|
closed = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Chat {self.session_id} - {self.status}"
|
||||||
|
|
||||||
|
class ChatMessage(models.Model):
|
||||||
|
"""Chat-Nachrichten"""
|
||||||
|
MESSAGE_TYPE_CHOICES = [
|
||||||
|
('user', 'Benutzer'),
|
||||||
|
('agent', 'Agent'),
|
||||||
|
('system', 'System'),
|
||||||
|
]
|
||||||
|
|
||||||
|
session = models.ForeignKey(ChatSession, on_delete=models.CASCADE, related_name='messages')
|
||||||
|
message_type = models.CharField(max_length=20, choices=MESSAGE_TYPE_CHOICES)
|
||||||
|
sender = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
content = models.TextField()
|
||||||
|
timestamp = models.DateTimeField(auto_now_add=True)
|
||||||
|
is_read = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['timestamp']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.message_type}: {self.content[:50]}"
|
||||||
|
|
||||||
|
class ChatBot(models.Model):
|
||||||
|
"""Chatbot-Konfiguration"""
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
welcome_message = models.TextField()
|
||||||
|
auto_responses = models.JSONField(default=dict)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class ChatBotResponse:
|
||||||
|
"""Chatbot-Antworten"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_welcome_message():
|
||||||
|
return {
|
||||||
|
'message': 'Willkommen beim Fursuit Shop! Wie kann ich Ihnen helfen?',
|
||||||
|
'options': [
|
||||||
|
'Produktinformationen',
|
||||||
|
'Bestellstatus',
|
||||||
|
'Custom Order',
|
||||||
|
'Zahlung & Versand',
|
||||||
|
'Mit Agent verbinden'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_response_for_query(query):
|
||||||
|
"""Einfache Keyword-basierte Antworten"""
|
||||||
|
query_lower = query.lower()
|
||||||
|
|
||||||
|
responses = {
|
||||||
|
'versand': 'Wir versenden weltweit! Standardversand dauert 3-5 Werktage, Express 1-2 Werktage.',
|
||||||
|
'zahlung': 'Wir akzeptieren PayPal, Kreditkarte, SEPA-Lastschrift, Giropay und Sofort.',
|
||||||
|
'custom': 'Custom Orders nehmen 4-8 Wochen in Anspruch. Sie können Ihr Design hochladen und wir erstellen ein individuelles Angebot.',
|
||||||
|
'preis': 'Unsere Preise beginnen bei 200€ für einfache Teile. Vollständige Fursuits kosten 800-2000€ je nach Komplexität.',
|
||||||
|
'größe': 'Wir nehmen individuelle Maße für Custom Orders. Standardgrößen sind S, M, L, XL verfügbar.',
|
||||||
|
'material': 'Wir verwenden hochwertige Materialien: Faux Fur, Foam, Mesh für Belüftung und professionelle Elektronik.',
|
||||||
|
'pflege': 'Fursuits sollten trocken gelagert werden. Fell kann vorsichtig gebürstet werden. Elektronik vor Wasser schützen.'
|
||||||
|
}
|
||||||
|
|
||||||
|
for keyword, response in responses.items():
|
||||||
|
if keyword in query_lower:
|
||||||
|
return response
|
||||||
|
|
||||||
|
return 'Entschuldigung, ich verstehe Ihre Frage nicht. Möchten Sie mit einem Agenten sprechen?'
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
from django import forms
|
||||||
|
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm, PasswordChangeForm
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from .models import Order, Product, Review, UserProfile, ContactMessage, CustomOrder, OrderProgress
|
||||||
|
|
||||||
|
class OrderForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Order
|
||||||
|
fields = ['full_name', 'email', 'address', 'phone']
|
||||||
|
widgets = {
|
||||||
|
'full_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'email': forms.EmailInput(attrs={'class': 'form-control'}),
|
||||||
|
'address': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||||
|
'phone': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
}
|
||||||
|
labels = {
|
||||||
|
'full_name': 'Vollständiger Name',
|
||||||
|
'email': 'E-Mail-Adresse',
|
||||||
|
'address': 'Lieferadresse',
|
||||||
|
'phone': 'Telefonnummer',
|
||||||
|
}
|
||||||
|
|
||||||
|
class CustomUserCreationForm(UserCreationForm):
|
||||||
|
email = forms.EmailField(required=True, widget=forms.EmailInput(attrs={'class': 'form-control'}))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ['username', 'email', 'password1', 'password2']
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
for field in self.fields:
|
||||||
|
self.fields[field].widget.attrs['class'] = 'form-control'
|
||||||
|
self.fields['username'].label = 'Benutzername'
|
||||||
|
self.fields['password1'].label = 'Passwort'
|
||||||
|
self.fields['password2'].label = 'Passwort bestätigen'
|
||||||
|
|
||||||
|
class CustomAuthenticationForm(AuthenticationForm):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
for field in self.fields:
|
||||||
|
self.fields[field].widget.attrs['class'] = 'form-control'
|
||||||
|
self.fields['username'].label = 'Benutzername'
|
||||||
|
self.fields['password'].label = 'Passwort'
|
||||||
|
|
||||||
|
class ReviewForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Review
|
||||||
|
fields = ['rating', 'comment']
|
||||||
|
widgets = {
|
||||||
|
'rating': forms.Select(attrs={'class': 'form-control'}),
|
||||||
|
'comment': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||||
|
}
|
||||||
|
labels = {
|
||||||
|
'rating': 'Bewertung',
|
||||||
|
'comment': 'Kommentar',
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserProfileForm(forms.ModelForm):
|
||||||
|
first_name = forms.CharField(max_length=30, required=False, label='Vorname')
|
||||||
|
last_name = forms.CharField(max_length=30, required=False, label='Nachname')
|
||||||
|
email = forms.EmailField(required=True, label='E-Mail-Adresse')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = UserProfile
|
||||||
|
fields = ['phone', 'address', 'default_shipping_address', 'newsletter']
|
||||||
|
labels = {
|
||||||
|
'phone': 'Telefonnummer',
|
||||||
|
'address': 'Adresse',
|
||||||
|
'default_shipping_address': 'Standard-Lieferadresse',
|
||||||
|
'newsletter': 'Newsletter abonnieren',
|
||||||
|
}
|
||||||
|
widgets = {
|
||||||
|
'address': forms.Textarea(attrs={'rows': 3}),
|
||||||
|
'default_shipping_address': forms.Textarea(attrs={'rows': 3}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if self.instance.user:
|
||||||
|
self.fields['first_name'].initial = self.instance.user.first_name
|
||||||
|
self.fields['last_name'].initial = self.instance.user.last_name
|
||||||
|
self.fields['email'].initial = self.instance.user.email
|
||||||
|
for field in self.fields:
|
||||||
|
self.fields[field].widget.attrs['class'] = 'form-control'
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
profile = super().save(commit=False)
|
||||||
|
if commit:
|
||||||
|
user = profile.user
|
||||||
|
user.first_name = self.cleaned_data['first_name']
|
||||||
|
user.last_name = self.cleaned_data['last_name']
|
||||||
|
user.email = self.cleaned_data['email']
|
||||||
|
user.save()
|
||||||
|
profile.save()
|
||||||
|
return profile
|
||||||
|
|
||||||
|
class ContactForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = ContactMessage
|
||||||
|
fields = ['name', 'email', 'category', 'order_number', 'subject', 'message']
|
||||||
|
widgets = {
|
||||||
|
'name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'email': forms.EmailInput(attrs={'class': 'form-control'}),
|
||||||
|
'category': forms.Select(attrs={'class': 'form-control'}),
|
||||||
|
'order_number': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'subject': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'message': forms.Textarea(attrs={'class': 'form-control', 'rows': 5}),
|
||||||
|
}
|
||||||
|
labels = {
|
||||||
|
'name': 'Name',
|
||||||
|
'email': 'E-Mail-Adresse',
|
||||||
|
'category': 'Kategorie',
|
||||||
|
'order_number': 'Bestellnummer (optional)',
|
||||||
|
'subject': 'Betreff',
|
||||||
|
'message': 'Ihre Nachricht',
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, user=None, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if user and user.is_authenticated:
|
||||||
|
self.fields['name'].initial = user.get_full_name() or user.username
|
||||||
|
self.fields['email'].initial = user.email
|
||||||
|
|
||||||
|
class CustomOrderForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = CustomOrder
|
||||||
|
fields = [
|
||||||
|
'fursuit_type', 'style', 'character_name',
|
||||||
|
'character_description', 'reference_images',
|
||||||
|
'special_requests', 'measurements',
|
||||||
|
'color_preferences', 'budget_range',
|
||||||
|
'deadline_request'
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
'fursuit_type': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'style': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'character_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'character_description': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 4,
|
||||||
|
'placeholder': 'Beschreiben Sie Ihren Character so detailliert wie möglich...'
|
||||||
|
}),
|
||||||
|
'special_requests': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': 'Besondere Wünsche oder Anforderungen...'
|
||||||
|
}),
|
||||||
|
'measurements': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 4,
|
||||||
|
'placeholder': 'Bitte geben Sie alle relevanten Maße an (Kopfumfang, Körpergröße, etc.)'
|
||||||
|
}),
|
||||||
|
'color_preferences': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': 'Beschreiben Sie die gewünschten Farben und Farbkombinationen...'
|
||||||
|
}),
|
||||||
|
'budget_range': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'z.B. 2000-3000€'
|
||||||
|
}),
|
||||||
|
'deadline_request': forms.DateInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'type': 'date'
|
||||||
|
}),
|
||||||
|
'reference_images': forms.FileInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'accept': 'image/*'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
labels = {
|
||||||
|
'fursuit_type': 'Art des Fursuits',
|
||||||
|
'style': 'Gewünschter Stil',
|
||||||
|
'character_name': 'Name des Characters',
|
||||||
|
'character_description': 'Beschreibung des Characters',
|
||||||
|
'reference_images': 'Referenzbilder',
|
||||||
|
'special_requests': 'Besondere Wünsche',
|
||||||
|
'measurements': 'Maße',
|
||||||
|
'color_preferences': 'Farbwünsche',
|
||||||
|
'budget_range': 'Budget-Rahmen',
|
||||||
|
'deadline_request': 'Gewünschter Fertigstellungstermin'
|
||||||
|
}
|
||||||
|
help_texts = {
|
||||||
|
'character_description': 'Je detaillierter die Beschreibung, desto besser können wir Ihre Vision umsetzen.',
|
||||||
|
'reference_images': 'Laden Sie bis zu 5 Referenzbilder hoch (max. 5MB pro Bild)',
|
||||||
|
'measurements': 'Genaue Maße sind wichtig für eine perfekte Passform.',
|
||||||
|
'budget_range': 'Geben Sie einen Bereich an, in dem Sie sich preislich bewegen möchten.',
|
||||||
|
'deadline_request': 'Optional: Wenn Sie einen bestimmten Termin im Auge haben.'
|
||||||
|
}
|
||||||
|
|
||||||
|
class OrderProgressForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = OrderProgress
|
||||||
|
fields = ['stage', 'description', 'image', 'completed']
|
||||||
|
widgets = {
|
||||||
|
'stage': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'description': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': 'Beschreiben Sie den aktuellen Fortschritt...'
|
||||||
|
}),
|
||||||
|
'image': forms.FileInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'accept': 'image/*'
|
||||||
|
}),
|
||||||
|
'completed': forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||||
|
}
|
||||||
|
labels = {
|
||||||
|
'stage': 'Arbeitsschritt',
|
||||||
|
'description': 'Beschreibung des Fortschritts',
|
||||||
|
'image': 'Foto des Fortschritts',
|
||||||
|
'completed': 'Abgeschlossen'
|
||||||
|
}
|
||||||
|
help_texts = {
|
||||||
|
'description': 'Beschreiben Sie detailliert, was in diesem Schritt gemacht wurde.',
|
||||||
|
'image': 'Fügen Sie ein Foto hinzu, um den Fortschritt zu dokumentieren.',
|
||||||
|
'completed': 'Markieren Sie diesen Schritt als abgeschlossen, wenn er fertig ist.'
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Generated by Django 5.2.1 on 2025-05-29 14:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Product',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=200)),
|
||||||
|
('description', models.TextField()),
|
||||||
|
('price', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||||
|
('stock', models.IntegerField()),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
# Generated by Django 5.2.1 on 2025-05-29 14:12
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('products', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Cart',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('session_id', models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CartItem',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('quantity', models.PositiveIntegerField(default=1)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('cart', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='products.cart')),
|
||||||
|
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='products.product')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('cart', 'product')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
# Generated by Django 5.2.1 on 2025-05-29 14:19
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('products', '0002_cart_cartitem'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='product',
|
||||||
|
name='category',
|
||||||
|
field=models.CharField(blank=True, max_length=100, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='product',
|
||||||
|
name='featured',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='product',
|
||||||
|
name='image',
|
||||||
|
field=models.ImageField(blank=True, null=True, upload_to='products/'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Order',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('full_name', models.CharField(max_length=200)),
|
||||||
|
('email', models.EmailField(max_length=254)),
|
||||||
|
('address', models.TextField()),
|
||||||
|
('phone', models.CharField(max_length=20)),
|
||||||
|
('total_amount', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Ausstehend'), ('processing', 'In Bearbeitung'), ('shipped', 'Versendet'), ('delivered', 'Geliefert'), ('cancelled', 'Storniert')], default='pending', max_length=20)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='OrderItem',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('product_name', models.CharField(max_length=200)),
|
||||||
|
('price', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||||
|
('quantity', models.PositiveIntegerField()),
|
||||||
|
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='products.order')),
|
||||||
|
('product', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='products.product')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Generated by Django 5.2.1 on 2025-05-29 14:25
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('products', '0003_product_category_product_featured_product_image_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Review',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('rating', models.IntegerField(choices=[(1, '1 - Sehr schlecht'), (2, '2 - Schlecht'), (3, '3 - Okay'), (4, '4 - Gut'), (5, '5 - Sehr gut')], validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)])),
|
||||||
|
('comment', models.TextField()),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='products.product')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'unique_together': {('product', 'user')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Generated by Django 5.2.1 on 2025-05-29 14:50
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('products', '0004_review'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserProfile',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('phone', models.CharField(blank=True, max_length=20)),
|
||||||
|
('address', models.TextField(blank=True)),
|
||||||
|
('default_shipping_address', models.TextField(blank=True)),
|
||||||
|
('newsletter', models.BooleanField(default=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Wishlist',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('products', models.ManyToManyField(to='products.product')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
# Generated by Django 5.2.1 on 2025-05-29 14:57
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('products', '0005_userprofile_wishlist'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='FAQ',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('question', models.CharField(max_length=255, verbose_name='Frage')),
|
||||||
|
('answer', models.TextField(verbose_name='Antwort')),
|
||||||
|
('category', models.CharField(max_length=100, verbose_name='Kategorie')),
|
||||||
|
('order', models.IntegerField(default=0, verbose_name='Reihenfolge')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'FAQ',
|
||||||
|
'verbose_name_plural': 'FAQs',
|
||||||
|
'ordering': ['category', 'order'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ContactMessage',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
('email', models.EmailField(max_length=254)),
|
||||||
|
('order_number', models.CharField(blank=True, max_length=50, null=True)),
|
||||||
|
('category', models.CharField(choices=[('general', 'Allgemeine Anfrage'), ('order', 'Bestellung'), ('return', 'Rückgabe/Umtausch'), ('complaint', 'Beschwerde'), ('technical', 'Technische Frage')], max_length=20)),
|
||||||
|
('subject', models.CharField(max_length=200)),
|
||||||
|
('message', models.TextField()),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('status', models.CharField(choices=[('new', 'Neu'), ('in_progress', 'In Bearbeitung'), ('resolved', 'Erledigt'), ('closed', 'Geschlossen')], default='new', max_length=20)),
|
||||||
|
('staff_notes', models.TextField(blank=True)),
|
||||||
|
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Kontaktanfrage',
|
||||||
|
'verbose_name_plural': 'Kontaktanfragen',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
# Generated by Django 5.2.1 on 2025-05-29 15:01
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('products', '0006_faq_contactmessage'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='product',
|
||||||
|
name='extras_description',
|
||||||
|
field=models.TextField(blank=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='product',
|
||||||
|
name='fursuit_type',
|
||||||
|
field=models.CharField(choices=[('fullsuit', 'Fullsuit'), ('partial', 'Partial'), ('head', 'Kopf'), ('paws', 'Pfoten'), ('tail', 'Schwanz'), ('other', 'Sonstiges')], default='head', max_length=20),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='product',
|
||||||
|
name='includes_extras',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='product',
|
||||||
|
name='is_custom_order',
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='product',
|
||||||
|
name='production_time_weeks',
|
||||||
|
field=models.IntegerField(default=8),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='product',
|
||||||
|
name='style',
|
||||||
|
field=models.CharField(choices=[('toony', 'Toony'), ('semi_realistic', 'Semi-Realistisch'), ('realistic', 'Realistisch')], default='toony', max_length=20),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='product',
|
||||||
|
name='stock',
|
||||||
|
field=models.IntegerField(default=1),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CustomOrder',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('fursuit_type', models.CharField(choices=[('fullsuit', 'Fullsuit'), ('partial', 'Partial'), ('head', 'Kopf'), ('paws', 'Pfoten'), ('tail', 'Schwanz'), ('other', 'Sonstiges')], max_length=20)),
|
||||||
|
('style', models.CharField(choices=[('toony', 'Toony'), ('semi_realistic', 'Semi-Realistisch'), ('realistic', 'Realistisch')], max_length=20)),
|
||||||
|
('character_name', models.CharField(max_length=100)),
|
||||||
|
('character_description', models.TextField()),
|
||||||
|
('reference_images', models.FileField(blank=True, null=True, upload_to='references/')),
|
||||||
|
('special_requests', models.TextField(blank=True)),
|
||||||
|
('measurements', models.TextField()),
|
||||||
|
('color_preferences', models.TextField()),
|
||||||
|
('budget_range', models.CharField(max_length=100)),
|
||||||
|
('deadline_request', models.DateField(blank=True, null=True)),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Anfrage eingegangen'), ('quoted', 'Angebot erstellt'), ('approved', 'Angebot akzeptiert'), ('in_progress', 'In Arbeit'), ('ready', 'Fertig zur Abholung'), ('shipped', 'Versendet'), ('completed', 'Abgeschlossen'), ('cancelled', 'Storniert')], default='pending', max_length=20)),
|
||||||
|
('quoted_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='OrderProgress',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('stage', models.CharField(choices=[('design', 'Design & Planung'), ('base', 'Grundform'), ('fur', 'Fell'), ('details', 'Details'), ('electronics', 'Elektronik (optional)'), ('finishing', 'Finishing')], max_length=20)),
|
||||||
|
('description', models.TextField()),
|
||||||
|
('image', models.ImageField(blank=True, null=True, upload_to='progress/')),
|
||||||
|
('completed', models.BooleanField(default=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('custom_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='progress_updates', to='products.customorder')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
# Generated by Django 5.2.1 on 2025-05-29 18:29
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('products', '0007_product_extras_description_product_fursuit_type_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='contactmessage',
|
||||||
|
options={'ordering': ['-created'], 'verbose_name': 'Kontaktanfrage', 'verbose_name_plural': 'Kontaktanfragen'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='customorder',
|
||||||
|
options={'ordering': ['-created']},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='order',
|
||||||
|
options={'ordering': ['-created']},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='orderprogress',
|
||||||
|
options={'ordering': ['created']},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='product',
|
||||||
|
options={'ordering': ['-created']},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='review',
|
||||||
|
options={'ordering': ['-created']},
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='cart',
|
||||||
|
old_name='created_at',
|
||||||
|
new_name='created',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='cart',
|
||||||
|
old_name='updated_at',
|
||||||
|
new_name='updated',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='cartitem',
|
||||||
|
old_name='created_at',
|
||||||
|
new_name='created',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='contactmessage',
|
||||||
|
old_name='created_at',
|
||||||
|
new_name='created',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='customorder',
|
||||||
|
old_name='created_at',
|
||||||
|
new_name='created',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='customorder',
|
||||||
|
old_name='updated_at',
|
||||||
|
new_name='updated',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='faq',
|
||||||
|
old_name='created_at',
|
||||||
|
new_name='created',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='faq',
|
||||||
|
old_name='updated_at',
|
||||||
|
new_name='updated',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='order',
|
||||||
|
old_name='created_at',
|
||||||
|
new_name='created',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='order',
|
||||||
|
old_name='updated_at',
|
||||||
|
new_name='updated',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='orderprogress',
|
||||||
|
old_name='created_at',
|
||||||
|
new_name='created',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='product',
|
||||||
|
old_name='created_at',
|
||||||
|
new_name='created',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='product',
|
||||||
|
old_name='updated_at',
|
||||||
|
new_name='updated',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='review',
|
||||||
|
old_name='created_at',
|
||||||
|
new_name='created',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='review',
|
||||||
|
old_name='updated_at',
|
||||||
|
new_name='updated',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='userprofile',
|
||||||
|
old_name='created_at',
|
||||||
|
new_name='created',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='userprofile',
|
||||||
|
old_name='updated_at',
|
||||||
|
new_name='updated',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='wishlist',
|
||||||
|
old_name='created_at',
|
||||||
|
new_name='created',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='orderprogress',
|
||||||
|
name='updated',
|
||||||
|
field=models.DateTimeField(auto_now=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='cart',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='product_carts', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='order',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='product_orders', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
# Generated by Django 5.2.1 on 2025-05-30 07:31
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('products', '0008_alter_contactmessage_options_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='product',
|
||||||
|
name='category',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='product',
|
||||||
|
name='extras_description',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='product',
|
||||||
|
name='featured',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='product',
|
||||||
|
name='includes_extras',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='product',
|
||||||
|
name='production_time_weeks',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='review',
|
||||||
|
name='updated',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='payment_date',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='payment_method',
|
||||||
|
field=models.CharField(blank=True, choices=[('card', 'Kreditkarte'), ('sepa', 'SEPA-Lastschrift'), ('giropay', 'Giropay'), ('sofort', 'Sofort'), ('bancontact', 'Bancontact')], max_length=20, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='payment_status',
|
||||||
|
field=models.CharField(choices=[('pending', 'Ausstehend'), ('processing', 'Wird bearbeitet'), ('paid', 'Bezahlt'), ('failed', 'Fehlgeschlagen'), ('refunded', 'Zurückerstattet')], default='pending', max_length=20),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='stripe_payment_intent_id',
|
||||||
|
field=models.CharField(blank=True, max_length=100, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='stripe_payment_method_id',
|
||||||
|
field=models.CharField(blank=True, max_length=100, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='product',
|
||||||
|
name='is_featured',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='product',
|
||||||
|
name='wishlist_users',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='wishlist_products', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='customorder',
|
||||||
|
name='fursuit_type',
|
||||||
|
field=models.CharField(choices=[('partial', 'Partial'), ('fullsuit', 'Fullsuit'), ('head', 'Head Only'), ('paws', 'Paws'), ('tail', 'Tail'), ('other', 'Other')], max_length=20),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='customorder',
|
||||||
|
name='style',
|
||||||
|
field=models.CharField(choices=[('toony', 'Toony'), ('semi_realistic', 'Semi-Realistic'), ('realistic', 'Realistic'), ('anime', 'Anime'), ('chibi', 'Chibi')], max_length=20),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='product',
|
||||||
|
name='fursuit_type',
|
||||||
|
field=models.CharField(choices=[('partial', 'Partial'), ('fullsuit', 'Fullsuit'), ('head', 'Head Only'), ('paws', 'Paws'), ('tail', 'Tail'), ('other', 'Other')], default='other', max_length=20),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='product',
|
||||||
|
name='is_custom_order',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='product',
|
||||||
|
name='price',
|
||||||
|
field=models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='product',
|
||||||
|
name='stock',
|
||||||
|
field=models.IntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='product',
|
||||||
|
name='style',
|
||||||
|
field=models.CharField(choices=[('toony', 'Toony'), ('semi_realistic', 'Semi-Realistic'), ('realistic', 'Realistic'), ('anime', 'Anime'), ('chibi', 'Chibi')], default='toony', max_length=20),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='review',
|
||||||
|
name='rating',
|
||||||
|
field=models.IntegerField(validators=[django.core.validators.MinValueValidator(1)]),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='order',
|
||||||
|
index=models.Index(fields=['status'], name='products_or_status_bd22a2_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='order',
|
||||||
|
index=models.Index(fields=['payment_status'], name='products_or_payment_0d94df_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='order',
|
||||||
|
index=models.Index(fields=['created'], name='products_or_created_a2e72d_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='product',
|
||||||
|
index=models.Index(fields=['name'], name='products_pr_name_9ff0a3_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='product',
|
||||||
|
index=models.Index(fields=['price'], name='products_pr_price_9b1a5f_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='product',
|
||||||
|
index=models.Index(fields=['created'], name='products_pr_created_9a1943_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='product',
|
||||||
|
index=models.Index(fields=['fursuit_type'], name='products_pr_fursuit_fde435_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='product',
|
||||||
|
index=models.Index(fields=['style'], name='products_pr_style_de3c68_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Generated by Django 5.2.1 on 2025-05-30 07:49
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('products', '0009_remove_product_category_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='GalleryImage',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=200, verbose_name='Titel')),
|
||||||
|
('description', models.TextField(blank=True, verbose_name='Beschreibung')),
|
||||||
|
('image', models.ImageField(upload_to='gallery/', verbose_name='Bild')),
|
||||||
|
('fursuit_type', models.CharField(choices=[('partial', 'Partial'), ('fullsuit', 'Fullsuit'), ('head', 'Head Only'), ('paws', 'Paws'), ('tail', 'Tail'), ('other', 'Other')], max_length=20, verbose_name='Fursuit-Typ')),
|
||||||
|
('style', models.CharField(choices=[('toony', 'Toony'), ('semi_realistic', 'Semi-Realistic'), ('realistic', 'Realistic'), ('anime', 'Anime'), ('chibi', 'Chibi')], max_length=20, verbose_name='Stil')),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
|
||||||
|
('is_featured', models.BooleanField(default=False, verbose_name='Hervorgehoben')),
|
||||||
|
('order', models.IntegerField(default=0, verbose_name='Reihenfolge')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Galeriebild',
|
||||||
|
'verbose_name_plural': 'Galeriebilder',
|
||||||
|
'ordering': ['order', '-created'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 5.2.1 on 2025-05-30 08:24
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('products', '0010_galleryimage'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='galleryimage',
|
||||||
|
name='fursuit_type',
|
||||||
|
field=models.CharField(choices=[('full', 'Fullsuit'), ('partial', 'Partial'), ('head', 'Head Only'), ('other', 'Other')], default='full', max_length=20, verbose_name='Fursuit-Typ'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='galleryimage',
|
||||||
|
name='style',
|
||||||
|
field=models.CharField(choices=[('toony', 'Toony'), ('semi', 'Semi-Realistic'), ('real', 'Realistic'), ('anime', 'Anime')], default='toony', max_length=20, verbose_name='Stil'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Generated by Django 5.2.1 on 2025-05-30 11:51
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('products', '0011_alter_galleryimage_fursuit_type_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Category',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100, verbose_name='Name')),
|
||||||
|
('slug', models.SlugField(unique=True, verbose_name='URL-Slug')),
|
||||||
|
('description', models.TextField(blank=True, verbose_name='Beschreibung')),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Kategorie',
|
||||||
|
'verbose_name_plural': 'Kategorien',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='product',
|
||||||
|
name='category',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='products', to='products.category', verbose_name='Kategorie'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 5.2.1 on 2025-05-30 11:54
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('products', '0012_category_product_category'),
|
||||||
|
('shop', '0003_contactmessage'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='product',
|
||||||
|
name='category',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='product_items', to='shop.category', verbose_name='Kategorie'),
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='Category',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
# Generated by Django 5.2.1 on 2025-06-02 06:12
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import phonenumber_field.modelfields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('products', '0013_alter_product_category_delete_category'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Payment',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('variant', models.CharField(max_length=255)),
|
||||||
|
('status', models.CharField(choices=[('waiting', 'Waiting for confirmation'), ('preauth', 'Pre-authorized'), ('confirmed', 'Confirmed'), ('rejected', 'Rejected'), ('refunded', 'Refunded'), ('error', 'Error'), ('input', 'Input')], default='waiting', max_length=10)),
|
||||||
|
('fraud_status', models.CharField(choices=[('unknown', 'Unknown'), ('accept', 'Passed'), ('reject', 'Rejected'), ('review', 'Review')], default='unknown', max_length=10, verbose_name='fraud check')),
|
||||||
|
('fraud_message', models.TextField(blank=True, default='')),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('modified', models.DateTimeField(auto_now=True)),
|
||||||
|
('transaction_id', models.CharField(blank=True, max_length=255)),
|
||||||
|
('currency', models.CharField(max_length=10)),
|
||||||
|
('total', models.DecimalField(decimal_places=2, default='0.0', max_digits=9)),
|
||||||
|
('delivery', models.DecimalField(decimal_places=2, default='0.0', max_digits=9)),
|
||||||
|
('tax', models.DecimalField(decimal_places=2, default='0.0', max_digits=9)),
|
||||||
|
('description', models.TextField(blank=True, default='')),
|
||||||
|
('billing_first_name', models.CharField(blank=True, max_length=256)),
|
||||||
|
('billing_last_name', models.CharField(blank=True, max_length=256)),
|
||||||
|
('billing_address_1', models.CharField(blank=True, max_length=256)),
|
||||||
|
('billing_address_2', models.CharField(blank=True, max_length=256)),
|
||||||
|
('billing_city', models.CharField(blank=True, max_length=256)),
|
||||||
|
('billing_postcode', models.CharField(blank=True, max_length=256)),
|
||||||
|
('billing_country_code', models.CharField(blank=True, max_length=2)),
|
||||||
|
('billing_country_area', models.CharField(blank=True, max_length=256)),
|
||||||
|
('billing_email', models.EmailField(blank=True, max_length=254)),
|
||||||
|
('billing_phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, region=None)),
|
||||||
|
('customer_ip_address', models.GenericIPAddressField(blank=True, null=True)),
|
||||||
|
('extra_data', models.TextField(blank=True, default='')),
|
||||||
|
('message', models.TextField(blank=True, default='')),
|
||||||
|
('token', models.CharField(blank=True, default='', max_length=36)),
|
||||||
|
('captured_amount', models.DecimalField(decimal_places=2, default='0.0', max_digits=9)),
|
||||||
|
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='products.order')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
# Generated by Django 5.2.1 on 2025-06-02 10:05
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('products', '0014_payment'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='payment',
|
||||||
|
options={'ordering': ['-created']},
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='payment',
|
||||||
|
name='billing_phone',
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='billing_address_1',
|
||||||
|
field=models.CharField(blank=True, max_length=256, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='billing_address_2',
|
||||||
|
field=models.CharField(blank=True, max_length=256, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='billing_city',
|
||||||
|
field=models.CharField(blank=True, max_length=256, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='billing_country_area',
|
||||||
|
field=models.CharField(blank=True, max_length=256, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='billing_country_code',
|
||||||
|
field=models.CharField(blank=True, max_length=2, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='billing_email',
|
||||||
|
field=models.EmailField(blank=True, max_length=254, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='billing_first_name',
|
||||||
|
field=models.CharField(blank=True, max_length=256, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='billing_last_name',
|
||||||
|
field=models.CharField(blank=True, max_length=256, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='billing_postcode',
|
||||||
|
field=models.CharField(blank=True, max_length=256, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='description',
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='extra_data',
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='fraud_message',
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='fraud_status',
|
||||||
|
field=models.CharField(blank=True, max_length=10, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='message',
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='order',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='products.order'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(max_length=10),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='token',
|
||||||
|
field=models.CharField(blank=True, max_length=36, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='transaction_id',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,428 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from shop.models import Category # Import Category aus der shop App
|
||||||
|
from typing import Optional, List
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
|
|
||||||
|
class UserProfile(models.Model):
|
||||||
|
"""Erweitertes Benutzerprofil mit zusätzlichen Informationen"""
|
||||||
|
|
||||||
|
user: User = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||||
|
phone: str = models.CharField(max_length=20, blank=True)
|
||||||
|
address: str = models.TextField(blank=True)
|
||||||
|
default_shipping_address: str = models.TextField(blank=True)
|
||||||
|
newsletter: bool = models.BooleanField(default=False)
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Profil von {self.user.username}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Benutzerprofil'
|
||||||
|
verbose_name_plural = 'Benutzerprofile'
|
||||||
|
|
||||||
|
@receiver(post_save, sender=User)
|
||||||
|
def create_user_profile(sender, instance, created, **kwargs):
|
||||||
|
if created:
|
||||||
|
UserProfile.objects.create(user=instance)
|
||||||
|
|
||||||
|
@receiver(post_save, sender=User)
|
||||||
|
def save_user_profile(sender, instance, **kwargs):
|
||||||
|
instance.userprofile.save()
|
||||||
|
|
||||||
|
class Product(models.Model):
|
||||||
|
FURSUIT_TYPE_CHOICES = [
|
||||||
|
('partial', 'Partial'),
|
||||||
|
('fullsuit', 'Fullsuit'),
|
||||||
|
('head', 'Head Only'),
|
||||||
|
('paws', 'Paws'),
|
||||||
|
('tail', 'Tail'),
|
||||||
|
('other', 'Other'),
|
||||||
|
]
|
||||||
|
|
||||||
|
STYLE_CHOICES = [
|
||||||
|
('toony', 'Toony'),
|
||||||
|
('semi_realistic', 'Semi-Realistic'),
|
||||||
|
('realistic', 'Realistic'),
|
||||||
|
('anime', 'Anime'),
|
||||||
|
('chibi', 'Chibi'),
|
||||||
|
]
|
||||||
|
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
description = models.TextField()
|
||||||
|
price = models.DecimalField(max_digits=10, decimal_places=2, validators=[MinValueValidator(0)])
|
||||||
|
stock = models.IntegerField(default=0)
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated = models.DateTimeField(auto_now=True)
|
||||||
|
image = models.ImageField(upload_to='products/', null=True, blank=True)
|
||||||
|
|
||||||
|
# Neue Felder
|
||||||
|
fursuit_type = models.CharField(max_length=20, choices=FURSUIT_TYPE_CHOICES, default='other')
|
||||||
|
style = models.CharField(max_length=20, choices=STYLE_CHOICES, default='toony')
|
||||||
|
is_featured = models.BooleanField(default=False)
|
||||||
|
is_custom_order = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
# Beziehungen
|
||||||
|
category = models.ForeignKey('shop.Category', on_delete=models.SET_NULL, null=True, blank=True, related_name='product_items', verbose_name='Kategorie')
|
||||||
|
wishlist_users = models.ManyToManyField(User, related_name='wishlist_products', blank=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def average_rating(self):
|
||||||
|
if self.reviews.exists():
|
||||||
|
return self.reviews.aggregate(models.Avg('rating'))['rating__avg']
|
||||||
|
return 0
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['name']),
|
||||||
|
models.Index(fields=['price']),
|
||||||
|
models.Index(fields=['created']),
|
||||||
|
models.Index(fields=['fursuit_type']),
|
||||||
|
models.Index(fields=['style']),
|
||||||
|
models.Index(fields=['is_featured']),
|
||||||
|
models.Index(fields=['stock']),
|
||||||
|
models.Index(fields=['category', 'fursuit_type']),
|
||||||
|
models.Index(fields=['price', 'fursuit_type']),
|
||||||
|
]
|
||||||
|
|
||||||
|
class Review(models.Model):
|
||||||
|
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='reviews')
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
rating = models.IntegerField(validators=[MinValueValidator(1)])
|
||||||
|
comment = models.TextField()
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('product', 'user')
|
||||||
|
ordering = ['-created']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'Review by {self.user.username} for {self.product.name}'
|
||||||
|
|
||||||
|
class Cart(models.Model):
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True, related_name='product_carts')
|
||||||
|
session_id = models.CharField(max_length=100, null=True, blank=True)
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def get_total(self):
|
||||||
|
return sum(item.get_subtotal() for item in self.items.all())
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Cart {self.id} - {'User: ' + self.user.username if self.user else 'Session: ' + self.session_id}"
|
||||||
|
|
||||||
|
class CartItem(models.Model):
|
||||||
|
cart = models.ForeignKey(Cart, related_name='items', on_delete=models.CASCADE)
|
||||||
|
product = models.ForeignKey(Product, on_delete=models.CASCADE)
|
||||||
|
quantity = models.PositiveIntegerField(default=1)
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def get_subtotal(self):
|
||||||
|
return self.product.price * self.quantity
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.quantity}x {self.product.name} in Cart {self.cart.id}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('cart', 'product')
|
||||||
|
|
||||||
|
class Order(models.Model):
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('pending', 'Ausstehend'),
|
||||||
|
('processing', 'In Bearbeitung'),
|
||||||
|
('shipped', 'Versendet'),
|
||||||
|
('delivered', 'Geliefert'),
|
||||||
|
('cancelled', 'Storniert'),
|
||||||
|
]
|
||||||
|
|
||||||
|
PAYMENT_STATUS_CHOICES = [
|
||||||
|
('pending', 'Ausstehend'),
|
||||||
|
('processing', 'Wird bearbeitet'),
|
||||||
|
('paid', 'Bezahlt'),
|
||||||
|
('failed', 'Fehlgeschlagen'),
|
||||||
|
('refunded', 'Zurückerstattet'),
|
||||||
|
]
|
||||||
|
|
||||||
|
PAYMENT_METHOD_CHOICES = [
|
||||||
|
('card', 'Kreditkarte'),
|
||||||
|
('sepa', 'SEPA-Lastschrift'),
|
||||||
|
('giropay', 'Giropay'),
|
||||||
|
('sofort', 'Sofort'),
|
||||||
|
('bancontact', 'Bancontact'),
|
||||||
|
]
|
||||||
|
|
||||||
|
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='product_orders')
|
||||||
|
full_name = models.CharField(max_length=200)
|
||||||
|
email = models.EmailField()
|
||||||
|
address = models.TextField()
|
||||||
|
phone = models.CharField(max_length=20)
|
||||||
|
total_amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
# Zahlungsinformationen
|
||||||
|
payment_status = models.CharField(max_length=20, choices=PAYMENT_STATUS_CHOICES, default='pending')
|
||||||
|
payment_method = models.CharField(max_length=20, choices=PAYMENT_METHOD_CHOICES, null=True, blank=True)
|
||||||
|
stripe_payment_intent_id = models.CharField(max_length=100, null=True, blank=True)
|
||||||
|
stripe_payment_method_id = models.CharField(max_length=100, null=True, blank=True)
|
||||||
|
payment_date = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Bestellung #{self.id} von {self.full_name}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
models.Index(fields=['payment_status']),
|
||||||
|
models.Index(fields=['created']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_payment_status_display_class(self):
|
||||||
|
status_classes = {
|
||||||
|
'pending': 'warning',
|
||||||
|
'processing': 'info',
|
||||||
|
'paid': 'success',
|
||||||
|
'failed': 'danger',
|
||||||
|
'refunded': 'secondary',
|
||||||
|
}
|
||||||
|
return status_classes.get(self.payment_status, 'secondary')
|
||||||
|
|
||||||
|
def get_order_status_display_class(self):
|
||||||
|
status_classes = {
|
||||||
|
'pending': 'warning',
|
||||||
|
'processing': 'info',
|
||||||
|
'shipped': 'primary',
|
||||||
|
'delivered': 'success',
|
||||||
|
'cancelled': 'danger',
|
||||||
|
}
|
||||||
|
return status_classes.get(self.status, 'secondary')
|
||||||
|
|
||||||
|
class OrderItem(models.Model):
|
||||||
|
order = models.ForeignKey(Order, related_name='items', on_delete=models.CASCADE)
|
||||||
|
product = models.ForeignKey(Product, on_delete=models.SET_NULL, null=True)
|
||||||
|
product_name = models.CharField(max_length=200)
|
||||||
|
price = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
quantity = models.PositiveIntegerField()
|
||||||
|
|
||||||
|
def get_subtotal(self):
|
||||||
|
return self.price * self.quantity
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.quantity}x {self.product_name} in Bestellung #{self.order.id}"
|
||||||
|
|
||||||
|
class Wishlist(models.Model):
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
products = models.ManyToManyField(Product)
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Wunschliste von {self.user.username}"
|
||||||
|
|
||||||
|
class FAQ(models.Model):
|
||||||
|
question = models.CharField(max_length=255, verbose_name='Frage')
|
||||||
|
answer = models.TextField(verbose_name='Antwort')
|
||||||
|
category = models.CharField(max_length=100, verbose_name='Kategorie')
|
||||||
|
order = models.IntegerField(default=0, verbose_name='Reihenfolge')
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['category', 'order']
|
||||||
|
verbose_name = 'FAQ'
|
||||||
|
verbose_name_plural = 'FAQs'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.question
|
||||||
|
|
||||||
|
class ContactMessage(models.Model):
|
||||||
|
CATEGORY_CHOICES = [
|
||||||
|
('general', 'Allgemeine Anfrage'),
|
||||||
|
('order', 'Bestellung'),
|
||||||
|
('return', 'Rückgabe/Umtausch'),
|
||||||
|
('complaint', 'Beschwerde'),
|
||||||
|
('technical', 'Technische Frage'),
|
||||||
|
]
|
||||||
|
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
email = models.EmailField()
|
||||||
|
order_number = models.CharField(max_length=50, blank=True, null=True)
|
||||||
|
category = models.CharField(max_length=20, choices=CATEGORY_CHOICES)
|
||||||
|
subject = models.CharField(max_length=200)
|
||||||
|
message = models.TextField()
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=[
|
||||||
|
('new', 'Neu'),
|
||||||
|
('in_progress', 'In Bearbeitung'),
|
||||||
|
('resolved', 'Erledigt'),
|
||||||
|
('closed', 'Geschlossen'),
|
||||||
|
],
|
||||||
|
default='new'
|
||||||
|
)
|
||||||
|
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
staff_notes = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created']
|
||||||
|
verbose_name = 'Kontaktanfrage'
|
||||||
|
verbose_name_plural = 'Kontaktanfragen'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.category} - {self.subject} ({self.created.strftime('%d.%m.%Y')})"
|
||||||
|
|
||||||
|
class CustomOrder(models.Model):
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('pending', 'Anfrage eingegangen'),
|
||||||
|
('quoted', 'Angebot erstellt'),
|
||||||
|
('approved', 'Angebot akzeptiert'),
|
||||||
|
('in_progress', 'In Arbeit'),
|
||||||
|
('ready', 'Fertig zur Abholung'),
|
||||||
|
('shipped', 'Versendet'),
|
||||||
|
('completed', 'Abgeschlossen'),
|
||||||
|
('cancelled', 'Storniert'),
|
||||||
|
]
|
||||||
|
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
fursuit_type = models.CharField(max_length=20, choices=Product.FURSUIT_TYPE_CHOICES)
|
||||||
|
style = models.CharField(max_length=20, choices=Product.STYLE_CHOICES)
|
||||||
|
character_name = models.CharField(max_length=100)
|
||||||
|
character_description = models.TextField()
|
||||||
|
reference_images = models.FileField(upload_to='references/', null=True, blank=True)
|
||||||
|
special_requests = models.TextField(blank=True)
|
||||||
|
measurements = models.TextField()
|
||||||
|
color_preferences = models.TextField()
|
||||||
|
budget_range = models.CharField(max_length=100)
|
||||||
|
deadline_request = models.DateField(null=True, blank=True)
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
|
||||||
|
quoted_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Custom Order #{self.id} - {self.character_name}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created']
|
||||||
|
|
||||||
|
class OrderProgress(models.Model):
|
||||||
|
PROGRESS_CHOICES = [
|
||||||
|
('design', 'Design & Planung'),
|
||||||
|
('base', 'Grundform'),
|
||||||
|
('fur', 'Fell'),
|
||||||
|
('details', 'Details'),
|
||||||
|
('electronics', 'Elektronik (optional)'),
|
||||||
|
('finishing', 'Finishing'),
|
||||||
|
]
|
||||||
|
|
||||||
|
custom_order = models.ForeignKey(CustomOrder, on_delete=models.CASCADE, related_name='progress_updates')
|
||||||
|
stage = models.CharField(max_length=20, choices=PROGRESS_CHOICES)
|
||||||
|
description = models.TextField()
|
||||||
|
image = models.ImageField(upload_to='progress/', null=True, blank=True)
|
||||||
|
completed = models.BooleanField(default=False)
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.custom_order.character_name} - {self.get_stage_display()}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['created']
|
||||||
|
|
||||||
|
class GalleryImage(models.Model):
|
||||||
|
FURSUIT_TYPE_CHOICES = [
|
||||||
|
('full', 'Fullsuit'),
|
||||||
|
('partial', 'Partial'),
|
||||||
|
('head', 'Head Only'),
|
||||||
|
('other', 'Other'),
|
||||||
|
]
|
||||||
|
|
||||||
|
STYLE_CHOICES = [
|
||||||
|
('toony', 'Toony'),
|
||||||
|
('semi', 'Semi-Realistic'),
|
||||||
|
('real', 'Realistic'),
|
||||||
|
('anime', 'Anime'),
|
||||||
|
]
|
||||||
|
|
||||||
|
title = models.CharField(max_length=200, verbose_name='Titel')
|
||||||
|
description = models.TextField(blank=True, verbose_name='Beschreibung')
|
||||||
|
image = models.ImageField(upload_to='gallery/', verbose_name='Bild')
|
||||||
|
fursuit_type = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=FURSUIT_TYPE_CHOICES,
|
||||||
|
default='full',
|
||||||
|
verbose_name='Fursuit-Typ'
|
||||||
|
)
|
||||||
|
style = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=STYLE_CHOICES,
|
||||||
|
default='toony',
|
||||||
|
verbose_name='Stil'
|
||||||
|
)
|
||||||
|
is_featured = models.BooleanField(default=False, verbose_name='Hervorgehoben')
|
||||||
|
order = models.IntegerField(default=0, verbose_name='Reihenfolge')
|
||||||
|
created = models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Galeriebild'
|
||||||
|
verbose_name_plural = 'Galeriebilder'
|
||||||
|
ordering = ['order', '-created']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
class Payment(models.Model):
|
||||||
|
order = models.ForeignKey(Order, on_delete=models.CASCADE)
|
||||||
|
variant = models.CharField(max_length=255)
|
||||||
|
status = models.CharField(max_length=10)
|
||||||
|
fraud_status = models.CharField(max_length=10, null=True, blank=True)
|
||||||
|
fraud_message = models.TextField(null=True, blank=True)
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
modified = models.DateTimeField(auto_now=True)
|
||||||
|
transaction_id = models.CharField(max_length=255, null=True, blank=True)
|
||||||
|
currency = models.CharField(max_length=10)
|
||||||
|
total = models.DecimalField(max_digits=9, decimal_places=2, default='0.0')
|
||||||
|
delivery = models.DecimalField(max_digits=9, decimal_places=2, default='0.0')
|
||||||
|
tax = models.DecimalField(max_digits=9, decimal_places=2, default='0.0')
|
||||||
|
description = models.TextField(null=True, blank=True)
|
||||||
|
billing_first_name = models.CharField(max_length=256, null=True, blank=True)
|
||||||
|
billing_last_name = models.CharField(max_length=256, null=True, blank=True)
|
||||||
|
billing_address_1 = models.CharField(max_length=256, null=True, blank=True)
|
||||||
|
billing_address_2 = models.CharField(max_length=256, null=True, blank=True)
|
||||||
|
billing_city = models.CharField(max_length=256, null=True, blank=True)
|
||||||
|
billing_postcode = models.CharField(max_length=256, null=True, blank=True)
|
||||||
|
billing_country_code = models.CharField(max_length=2, null=True, blank=True)
|
||||||
|
billing_country_area = models.CharField(max_length=256, null=True, blank=True)
|
||||||
|
billing_email = models.EmailField(null=True, blank=True)
|
||||||
|
customer_ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||||
|
extra_data = models.TextField(null=True, blank=True)
|
||||||
|
message = models.TextField(null=True, blank=True)
|
||||||
|
token = models.CharField(max_length=36, null=True, blank=True)
|
||||||
|
captured_amount = models.DecimalField(max_digits=9, decimal_places=2, default='0.0')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Payment {self.id} for Order {self.order.id}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def attrs(self):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_failure_url(self):
|
||||||
|
return f'/payment/failed/{self.order.id}/'
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return f'/payment/success/{self.order.id}/'
|
||||||
|
|
@ -0,0 +1,341 @@
|
||||||
|
"""
|
||||||
|
SEO Module für Kasico Fursuit Shop
|
||||||
|
Meta Tags, Structured Data, Sitemap Generation
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.utils.html import strip_tags
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.contrib.sites.models import Site
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
class SEOManager:
|
||||||
|
"""SEO Manager für Meta Tags und Structured Data"""
|
||||||
|
|
||||||
|
def __init__(self, request=None):
|
||||||
|
self.request = request
|
||||||
|
self.site = Site.objects.get_current()
|
||||||
|
self.base_url = f"https://{self.site.domain}"
|
||||||
|
|
||||||
|
def get_product_meta_tags(self, product):
|
||||||
|
"""Meta Tags für Produkt-Seiten"""
|
||||||
|
meta_tags = {
|
||||||
|
'title': f"{product.name} - Kasico Fursuit Shop",
|
||||||
|
'description': self._clean_description(product.description),
|
||||||
|
'keywords': f"fursuit, {product.fursuit_type}, {product.style}, custom fursuit, furry",
|
||||||
|
'og_title': product.name,
|
||||||
|
'og_description': self._clean_description(product.description),
|
||||||
|
'og_type': 'product',
|
||||||
|
'og_image': self._get_product_image_url(product),
|
||||||
|
'og_url': f"{self.base_url}{product.get_absolute_url()}",
|
||||||
|
'twitter_card': 'summary_large_image',
|
||||||
|
'twitter_title': product.name,
|
||||||
|
'twitter_description': self._clean_description(product.description),
|
||||||
|
'twitter_image': self._get_product_image_url(product),
|
||||||
|
'canonical_url': f"{self.base_url}{product.get_absolute_url()}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Preis-Informationen für E-Commerce
|
||||||
|
if product.on_sale:
|
||||||
|
meta_tags['price'] = str(product.sale_price)
|
||||||
|
meta_tags['original_price'] = str(product.base_price)
|
||||||
|
else:
|
||||||
|
meta_tags['price'] = str(product.base_price)
|
||||||
|
|
||||||
|
return meta_tags
|
||||||
|
|
||||||
|
def get_category_meta_tags(self, category):
|
||||||
|
"""Meta Tags für Kategorie-Seiten"""
|
||||||
|
meta_tags = {
|
||||||
|
'title': f"{category.name} Fursuits - Kasico Shop",
|
||||||
|
'description': f"Entdecke unsere {category.name} Fursuits. Handgefertigte, hochwertige Fursuits in verschiedenen Stilen und Größen.",
|
||||||
|
'keywords': f"{category.name}, fursuit, furry, custom fursuit",
|
||||||
|
'og_title': f"{category.name} Fursuits",
|
||||||
|
'og_description': f"Entdecke unsere {category.name} Fursuits bei Kasico",
|
||||||
|
'og_type': 'website',
|
||||||
|
'og_url': f"{self.base_url}{category.get_absolute_url()}",
|
||||||
|
'canonical_url': f"{self.base_url}{category.get_absolute_url()}",
|
||||||
|
}
|
||||||
|
return meta_tags
|
||||||
|
|
||||||
|
def get_homepage_meta_tags(self):
|
||||||
|
"""Meta Tags für Homepage"""
|
||||||
|
meta_tags = {
|
||||||
|
'title': 'Kasico - Premium Fursuit Shop | Handgefertigte Fursuits',
|
||||||
|
'description': 'Entdecke handgefertigte, hochwertige Fursuits bei Kasico. Custom Fursuits, Fullsuits, Headsets und mehr. Made in Germany.',
|
||||||
|
'keywords': 'fursuit, furry, custom fursuit, fullsuit, headset, handgefertigt, germany',
|
||||||
|
'og_title': 'Kasico - Premium Fursuit Shop',
|
||||||
|
'og_description': 'Handgefertigte, hochwertige Fursuits made in Germany',
|
||||||
|
'og_type': 'website',
|
||||||
|
'og_url': self.base_url,
|
||||||
|
'canonical_url': self.base_url,
|
||||||
|
}
|
||||||
|
return meta_tags
|
||||||
|
|
||||||
|
def get_structured_data_product(self, product):
|
||||||
|
"""Structured Data für Produkte (JSON-LD)"""
|
||||||
|
structured_data = {
|
||||||
|
"@context": "https://schema.org/",
|
||||||
|
"@type": "Product",
|
||||||
|
"name": product.name,
|
||||||
|
"description": self._clean_description(product.description),
|
||||||
|
"image": self._get_product_image_url(product),
|
||||||
|
"url": f"{self.base_url}{product.get_absolute_url()}",
|
||||||
|
"brand": {
|
||||||
|
"@type": "Brand",
|
||||||
|
"name": "Kasico"
|
||||||
|
},
|
||||||
|
"offers": {
|
||||||
|
"@type": "Offer",
|
||||||
|
"price": str(product.sale_price if product.on_sale else product.base_price),
|
||||||
|
"priceCurrency": "EUR",
|
||||||
|
"availability": "https://schema.org/InStock" if product.stock > 0 else "https://schema.org/OutOfStock",
|
||||||
|
"seller": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "Kasico"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"aggregateRating": {
|
||||||
|
"@type": "AggregateRating",
|
||||||
|
"ratingValue": product.average_rating,
|
||||||
|
"reviewCount": product.reviews.count()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if product.on_sale:
|
||||||
|
structured_data["offers"]["priceSpecification"] = {
|
||||||
|
"@type": "PriceSpecification",
|
||||||
|
"price": str(product.sale_price),
|
||||||
|
"priceCurrency": "EUR",
|
||||||
|
"valueAddedTaxIncluded": True
|
||||||
|
}
|
||||||
|
|
||||||
|
return structured_data
|
||||||
|
|
||||||
|
def get_structured_data_organization(self):
|
||||||
|
"""Structured Data für Organisation"""
|
||||||
|
structured_data = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "Kasico",
|
||||||
|
"url": self.base_url,
|
||||||
|
"logo": f"{self.base_url}/static/images/kasico-logo.svg",
|
||||||
|
"description": "Premium Fursuit Shop - Handgefertigte Fursuits made in Germany",
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"addressCountry": "DE",
|
||||||
|
"addressLocality": "Germany"
|
||||||
|
},
|
||||||
|
"contactPoint": {
|
||||||
|
"@type": "ContactPoint",
|
||||||
|
"contactType": "customer service",
|
||||||
|
"email": "info@kasico.de"
|
||||||
|
},
|
||||||
|
"sameAs": [
|
||||||
|
"https://twitter.com/kasico_fursuits",
|
||||||
|
"https://www.instagram.com/kasico_fursuits",
|
||||||
|
"https://www.facebook.com/kasico.fursuits"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return structured_data
|
||||||
|
|
||||||
|
def get_structured_data_website(self):
|
||||||
|
"""Structured Data für Website"""
|
||||||
|
structured_data = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebSite",
|
||||||
|
"name": "Kasico Fursuit Shop",
|
||||||
|
"url": self.base_url,
|
||||||
|
"description": "Premium Fursuit Shop - Handgefertigte Fursuits made in Germany",
|
||||||
|
"potentialAction": {
|
||||||
|
"@type": "SearchAction",
|
||||||
|
"target": f"{self.base_url}/products/search/?q={{search_term_string}}",
|
||||||
|
"query-input": "required name=search_term_string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return structured_data
|
||||||
|
|
||||||
|
def get_structured_data_breadcrumb(self, breadcrumbs):
|
||||||
|
"""Structured Data für Breadcrumbs"""
|
||||||
|
structured_data = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
"itemListElement": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, (name, url) in enumerate(breadcrumbs):
|
||||||
|
structured_data["itemListElement"].append({
|
||||||
|
"@type": "ListItem",
|
||||||
|
"position": i + 1,
|
||||||
|
"name": name,
|
||||||
|
"item": f"{self.base_url}{url}"
|
||||||
|
})
|
||||||
|
|
||||||
|
return structured_data
|
||||||
|
|
||||||
|
def generate_sitemap_xml(self):
|
||||||
|
"""XML Sitemap generieren"""
|
||||||
|
from products.models import Product, Category
|
||||||
|
from django.contrib.sitemaps import Sitemap
|
||||||
|
|
||||||
|
sitemap_data = {
|
||||||
|
'urlset': {
|
||||||
|
'@xmlns': 'http://www.sitemaps.org/schemas/sitemap/0.9',
|
||||||
|
'url': []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Homepage
|
||||||
|
sitemap_data['urlset']['url'].append({
|
||||||
|
'loc': self.base_url,
|
||||||
|
'lastmod': timezone.now().strftime('%Y-%m-%d'),
|
||||||
|
'changefreq': 'daily',
|
||||||
|
'priority': '1.0'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Produkt-Seiten
|
||||||
|
for product in Product.objects.filter(is_active=True):
|
||||||
|
sitemap_data['urlset']['url'].append({
|
||||||
|
'loc': f"{self.base_url}{product.get_absolute_url()}",
|
||||||
|
'lastmod': product.updated_at.strftime('%Y-%m-%d'),
|
||||||
|
'changefreq': 'weekly',
|
||||||
|
'priority': '0.8'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Kategorie-Seiten
|
||||||
|
for category in Category.objects.all():
|
||||||
|
sitemap_data['urlset']['url'].append({
|
||||||
|
'loc': f"{self.base_url}{category.get_absolute_url()}",
|
||||||
|
'lastmod': timezone.now().strftime('%Y-%m-%d'),
|
||||||
|
'changefreq': 'weekly',
|
||||||
|
'priority': '0.7'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Statische Seiten
|
||||||
|
static_pages = [
|
||||||
|
('/products/', 'weekly', '0.8'),
|
||||||
|
('/gallery/', 'weekly', '0.7'),
|
||||||
|
('/contact/', 'monthly', '0.5'),
|
||||||
|
('/about/', 'monthly', '0.5'),
|
||||||
|
('/faq/', 'monthly', '0.6'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for url, changefreq, priority in static_pages:
|
||||||
|
sitemap_data['urlset']['url'].append({
|
||||||
|
'loc': f"{self.base_url}{url}",
|
||||||
|
'lastmod': timezone.now().strftime('%Y-%m-%d'),
|
||||||
|
'changefreq': changefreq,
|
||||||
|
'priority': priority
|
||||||
|
})
|
||||||
|
|
||||||
|
return sitemap_data
|
||||||
|
|
||||||
|
def generate_robots_txt(self):
|
||||||
|
"""Robots.txt generieren"""
|
||||||
|
robots_content = f"""User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
# Sitemap
|
||||||
|
Sitemap: {self.base_url}/sitemap.xml
|
||||||
|
|
||||||
|
# Disallow admin and private areas
|
||||||
|
Disallow: /admin/
|
||||||
|
Disallow: /api/
|
||||||
|
Disallow: /static/admin/
|
||||||
|
Disallow: /media/admin/
|
||||||
|
|
||||||
|
# Allow important pages
|
||||||
|
Allow: /products/
|
||||||
|
Allow: /gallery/
|
||||||
|
Allow: /contact/
|
||||||
|
Allow: /about/
|
||||||
|
Allow: /faq/
|
||||||
|
|
||||||
|
# Crawl delay
|
||||||
|
Crawl-delay: 1
|
||||||
|
"""
|
||||||
|
return robots_content
|
||||||
|
|
||||||
|
def _clean_description(self, text, max_length=160):
|
||||||
|
"""Beschreibung für Meta Tags bereinigen"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# HTML Tags entfernen
|
||||||
|
clean_text = strip_tags(text)
|
||||||
|
|
||||||
|
# Zu lang kürzen
|
||||||
|
if len(clean_text) > max_length:
|
||||||
|
clean_text = clean_text[:max_length-3] + "..."
|
||||||
|
|
||||||
|
return clean_text
|
||||||
|
|
||||||
|
def _get_product_image_url(self, product):
|
||||||
|
"""Produkt-Bild URL generieren"""
|
||||||
|
if product.image:
|
||||||
|
return f"{self.base_url}{product.image.url}"
|
||||||
|
return f"{self.base_url}/static/images/default-product.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
class SEOTemplateTags:
|
||||||
|
"""Template Tags für SEO"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def render_meta_tags(meta_tags):
|
||||||
|
"""Meta Tags als HTML rendern"""
|
||||||
|
return render_to_string('seo/meta_tags.html', {'meta_tags': meta_tags})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def render_structured_data(structured_data):
|
||||||
|
"""Structured Data als JSON-LD rendern"""
|
||||||
|
return render_to_string('seo/structured_data.html', {
|
||||||
|
'structured_data': json.dumps(structured_data, indent=2)
|
||||||
|
})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def render_social_meta_tags(meta_tags):
|
||||||
|
"""Social Media Meta Tags rendern"""
|
||||||
|
return render_to_string('seo/social_meta_tags.html', {'meta_tags': meta_tags})
|
||||||
|
|
||||||
|
|
||||||
|
# SEO Context Processor
|
||||||
|
def seo_context_processor(request):
|
||||||
|
"""SEO Context für alle Templates"""
|
||||||
|
seo_manager = SEOManager(request)
|
||||||
|
|
||||||
|
# Standard SEO für alle Seiten
|
||||||
|
context = {
|
||||||
|
'seo_manager': seo_manager,
|
||||||
|
'site_name': 'Kasico',
|
||||||
|
'site_description': 'Premium Fursuit Shop - Handgefertigte Fursuits made in Germany',
|
||||||
|
'site_keywords': 'fursuit, furry, custom fursuit, fullsuit, headset, handgefertigt, germany',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Page-spezifische SEO
|
||||||
|
if hasattr(request, 'resolver_match'):
|
||||||
|
view_name = request.resolver_match.view_name
|
||||||
|
|
||||||
|
if 'product_detail' in view_name:
|
||||||
|
product = getattr(request, 'product', None)
|
||||||
|
if product:
|
||||||
|
context.update({
|
||||||
|
'meta_tags': seo_manager.get_product_meta_tags(product),
|
||||||
|
'structured_data': seo_manager.get_structured_data_product(product)
|
||||||
|
})
|
||||||
|
|
||||||
|
elif 'category_detail' in view_name:
|
||||||
|
category = getattr(request, 'category', None)
|
||||||
|
if category:
|
||||||
|
context.update({
|
||||||
|
'meta_tags': seo_manager.get_category_meta_tags(category)
|
||||||
|
})
|
||||||
|
|
||||||
|
elif 'home' in view_name:
|
||||||
|
context.update({
|
||||||
|
'meta_tags': seo_manager.get_homepage_meta_tags(),
|
||||||
|
'structured_data': seo_manager.get_structured_data_website()
|
||||||
|
})
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
from .models import Product, Category, Review, Wishlist, GalleryImage, CustomOrder, Payment
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer für User-Model"""
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ['id', 'username', 'email', 'first_name', 'last_name']
|
||||||
|
read_only_fields = ['id']
|
||||||
|
|
||||||
|
|
||||||
|
class CategorySerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer für Category-Model"""
|
||||||
|
product_count = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Category
|
||||||
|
fields = ['id', 'name', 'slug', 'description', 'image', 'product_count']
|
||||||
|
|
||||||
|
def get_product_count(self, obj):
|
||||||
|
return obj.products.count()
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer für Review-Model"""
|
||||||
|
user = UserSerializer(read_only=True)
|
||||||
|
user_name = serializers.CharField(source='user.username', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Review
|
||||||
|
fields = ['id', 'product', 'user', 'user_name', 'rating', 'comment', 'created_at']
|
||||||
|
read_only_fields = ['id', 'created_at']
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
validated_data['user'] = self.context['request'].user
|
||||||
|
return super().create(validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
class GalleryImageSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer für GalleryImage-Model"""
|
||||||
|
class Meta:
|
||||||
|
model = GalleryImage
|
||||||
|
fields = ['id', 'product', 'image', 'alt_text', 'is_featured', 'created_at']
|
||||||
|
read_only_fields = ['id', 'created_at']
|
||||||
|
|
||||||
|
|
||||||
|
class ProductSerializer(serializers.ModelSerializer):
|
||||||
|
"""Basis-Serializer für Product-Model"""
|
||||||
|
category = CategorySerializer(read_only=True)
|
||||||
|
category_id = serializers.IntegerField(write_only=True, required=False)
|
||||||
|
reviews = ReviewSerializer(many=True, read_only=True)
|
||||||
|
gallery_images = GalleryImageSerializer(many=True, read_only=True)
|
||||||
|
average_rating = serializers.FloatField(read_only=True)
|
||||||
|
review_count = serializers.IntegerField(read_only=True)
|
||||||
|
is_in_wishlist = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Product
|
||||||
|
fields = [
|
||||||
|
'id', 'name', 'slug', 'description', 'base_price', 'sale_price',
|
||||||
|
'on_sale', 'stock', 'product_type', 'fursuit_type', 'style',
|
||||||
|
'is_custom_order', 'is_featured', 'category', 'category_id',
|
||||||
|
'image', 'gallery_images', 'reviews', 'average_rating',
|
||||||
|
'review_count', 'is_in_wishlist', 'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'slug', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
def get_is_in_wishlist(self, obj):
|
||||||
|
user = self.context['request'].user
|
||||||
|
if user.is_authenticated:
|
||||||
|
return obj.wishlists.filter(user=user).exists()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
category_id = validated_data.pop('category_id', None)
|
||||||
|
if category_id:
|
||||||
|
validated_data['category'] = Category.objects.get(id=category_id)
|
||||||
|
return super().create(validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductListSerializer(serializers.ModelSerializer):
|
||||||
|
"""Vereinfachter Serializer für Produktlisten"""
|
||||||
|
category = CategorySerializer(read_only=True)
|
||||||
|
average_rating = serializers.FloatField(read_only=True)
|
||||||
|
review_count = serializers.IntegerField(read_only=True)
|
||||||
|
is_in_wishlist = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Product
|
||||||
|
fields = [
|
||||||
|
'id', 'name', 'slug', 'description', 'base_price', 'sale_price',
|
||||||
|
'on_sale', 'stock', 'product_type', 'fursuit_type', 'style',
|
||||||
|
'is_custom_order', 'is_featured', 'category', 'image',
|
||||||
|
'average_rating', 'review_count', 'is_in_wishlist'
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_is_in_wishlist(self, obj):
|
||||||
|
user = self.context['request'].user
|
||||||
|
if user.is_authenticated:
|
||||||
|
return obj.wishlists.filter(user=user).exists()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class ProductDetailSerializer(ProductSerializer):
|
||||||
|
"""Detaillierter Serializer für Produktdetails"""
|
||||||
|
related_products = ProductListSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta(ProductSerializer.Meta):
|
||||||
|
fields = ProductSerializer.Meta.fields + ['related_products']
|
||||||
|
|
||||||
|
|
||||||
|
class WishlistSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer für Wishlist-Model"""
|
||||||
|
products = ProductListSerializer(many=True, read_only=True)
|
||||||
|
product_count = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Wishlist
|
||||||
|
fields = ['id', 'user', 'products', 'product_count', 'created_at']
|
||||||
|
read_only_fields = ['id', 'created_at']
|
||||||
|
|
||||||
|
def get_product_count(self, obj):
|
||||||
|
return obj.products.count()
|
||||||
|
|
||||||
|
|
||||||
|
class CustomOrderSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer für CustomOrder-Model"""
|
||||||
|
user = UserSerializer(read_only=True)
|
||||||
|
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = CustomOrder
|
||||||
|
fields = [
|
||||||
|
'id', 'user', 'title', 'description', 'fursuit_type', 'style',
|
||||||
|
'size', 'color_preferences', 'special_requirements', 'budget',
|
||||||
|
'status', 'status_display', 'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
validated_data['user'] = self.context['request'].user
|
||||||
|
return super().create(validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer für Payment-Model"""
|
||||||
|
user = UserSerializer(read_only=True)
|
||||||
|
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Payment
|
||||||
|
fields = [
|
||||||
|
'id', 'user', 'order', 'amount', 'payment_method', 'status',
|
||||||
|
'status_display', 'transaction_id', 'created_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'created_at']
|
||||||
|
|
||||||
|
|
||||||
|
class CartItemSerializer(serializers.Serializer):
|
||||||
|
"""Serializer für Warenkorb-Items"""
|
||||||
|
product_id = serializers.IntegerField()
|
||||||
|
quantity = serializers.IntegerField(min_value=1)
|
||||||
|
product = ProductListSerializer(read_only=True)
|
||||||
|
total_price = serializers.DecimalField(max_digits=10, decimal_places=2, read_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CartSerializer(serializers.Serializer):
|
||||||
|
"""Serializer für Warenkorb"""
|
||||||
|
items = CartItemSerializer(many=True)
|
||||||
|
subtotal = serializers.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
shipping_cost = serializers.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
total = serializers.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
item_count = serializers.IntegerField()
|
||||||
|
|
||||||
|
|
||||||
|
class SearchFilterSerializer(serializers.Serializer):
|
||||||
|
"""Serializer für Such- und Filter-Parameter"""
|
||||||
|
query = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
category = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
fursuit_type = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
style = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
min_price = serializers.DecimalField(max_digits=10, decimal_places=2, required=False)
|
||||||
|
max_price = serializers.DecimalField(max_digits=10, decimal_places=2, required=False)
|
||||||
|
on_sale = serializers.BooleanField(required=False)
|
||||||
|
is_featured = serializers.BooleanField(required=False)
|
||||||
|
sort_by = serializers.CharField(required=False, default='newest')
|
||||||
|
page = serializers.IntegerField(required=False, default=1)
|
||||||
|
page_size = serializers.IntegerField(required=False, default=12)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductStatsSerializer(serializers.Serializer):
|
||||||
|
"""Serializer für Produkt-Statistiken"""
|
||||||
|
total_products = serializers.IntegerField()
|
||||||
|
featured_products = serializers.IntegerField()
|
||||||
|
on_sale_products = serializers.IntegerField()
|
||||||
|
low_stock_products = serializers.IntegerField()
|
||||||
|
categories_count = serializers.IntegerField()
|
||||||
|
average_rating = serializers.FloatField()
|
||||||
|
total_reviews = serializers.IntegerField()
|
||||||
|
|
||||||
|
|
||||||
|
class GalleryImageDetailSerializer(serializers.ModelSerializer):
|
||||||
|
"""Detaillierter Serializer für Galerie-Bilder"""
|
||||||
|
product = ProductListSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = GalleryImage
|
||||||
|
fields = [
|
||||||
|
'id', 'product', 'image', 'alt_text', 'title', 'description',
|
||||||
|
'is_featured', 'fursuit_type', 'style', 'created_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'created_at']
|
||||||
|
|
@ -0,0 +1,227 @@
|
||||||
|
"""
|
||||||
|
Sitemap und Robots.txt Views für SEO
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.views.decorators.cache import cache_page
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.views import View
|
||||||
|
from django.contrib.sites.models import Site
|
||||||
|
from .models import Product, Category
|
||||||
|
from .seo import SEOManager
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
@cache_page(60 * 60 * 24) # 24 Stunden Cache
|
||||||
|
def sitemap_xml(request):
|
||||||
|
"""XML Sitemap generieren"""
|
||||||
|
seo_manager = SEOManager(request)
|
||||||
|
sitemap_data = seo_manager.generate_sitemap_xml()
|
||||||
|
|
||||||
|
# XML generieren
|
||||||
|
root = ET.Element('urlset')
|
||||||
|
root.set('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9')
|
||||||
|
|
||||||
|
for url_data in sitemap_data['urlset']['url']:
|
||||||
|
url_elem = ET.SubElement(root, 'url')
|
||||||
|
|
||||||
|
loc = ET.SubElement(url_elem, 'loc')
|
||||||
|
loc.text = url_data['loc']
|
||||||
|
|
||||||
|
lastmod = ET.SubElement(url_elem, 'lastmod')
|
||||||
|
lastmod.text = url_data['lastmod']
|
||||||
|
|
||||||
|
changefreq = ET.SubElement(url_elem, 'changefreq')
|
||||||
|
changefreq.text = url_data['changefreq']
|
||||||
|
|
||||||
|
priority = ET.SubElement(url_elem, 'priority')
|
||||||
|
priority.text = url_data['priority']
|
||||||
|
|
||||||
|
# XML als String
|
||||||
|
xml_string = ET.tostring(root, encoding='unicode', method='xml')
|
||||||
|
|
||||||
|
response = HttpResponse(xml_string, content_type='application/xml')
|
||||||
|
response['Content-Type'] = 'application/xml; charset=utf-8'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@cache_page(60 * 60 * 24) # 24 Stunden Cache
|
||||||
|
def robots_txt(request):
|
||||||
|
"""Robots.txt generieren"""
|
||||||
|
seo_manager = SEOManager(request)
|
||||||
|
robots_content = seo_manager.generate_robots_txt()
|
||||||
|
|
||||||
|
response = HttpResponse(robots_content, content_type='text/plain')
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class SitemapView(View):
|
||||||
|
"""Erweiterte Sitemap View mit Kategorisierung"""
|
||||||
|
|
||||||
|
@method_decorator(cache_page(60 * 60 * 24))
|
||||||
|
def get(self, request):
|
||||||
|
"""Sitemap mit Kategorisierung"""
|
||||||
|
site = Site.objects.get_current()
|
||||||
|
base_url = f"https://{site.domain}"
|
||||||
|
|
||||||
|
# Sitemap Index
|
||||||
|
if request.GET.get('type') == 'index':
|
||||||
|
return self.sitemap_index(request, base_url)
|
||||||
|
|
||||||
|
# Produkt-Sitemap
|
||||||
|
elif request.GET.get('type') == 'products':
|
||||||
|
return self.products_sitemap(request, base_url)
|
||||||
|
|
||||||
|
# Kategorie-Sitemap
|
||||||
|
elif request.GET.get('type') == 'categories':
|
||||||
|
return self.categories_sitemap(request, base_url)
|
||||||
|
|
||||||
|
# Standard-Sitemap
|
||||||
|
else:
|
||||||
|
return self.main_sitemap(request, base_url)
|
||||||
|
|
||||||
|
def sitemap_index(self, request, base_url):
|
||||||
|
"""Sitemap Index"""
|
||||||
|
root = ET.Element('sitemapindex')
|
||||||
|
root.set('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9')
|
||||||
|
|
||||||
|
sitemaps = [
|
||||||
|
{'loc': f"{base_url}/sitemap.xml", 'lastmod': '2024-01-15'},
|
||||||
|
{'loc': f"{base_url}/sitemap.xml?type=products", 'lastmod': '2024-01-15'},
|
||||||
|
{'loc': f"{base_url}/sitemap.xml?type=categories", 'lastmod': '2024-01-15'},
|
||||||
|
]
|
||||||
|
|
||||||
|
for sitemap in sitemaps:
|
||||||
|
sitemap_elem = ET.SubElement(root, 'sitemap')
|
||||||
|
loc = ET.SubElement(sitemap_elem, 'loc')
|
||||||
|
loc.text = sitemap['loc']
|
||||||
|
lastmod = ET.SubElement(sitemap_elem, 'lastmod')
|
||||||
|
lastmod.text = sitemap['lastmod']
|
||||||
|
|
||||||
|
xml_string = ET.tostring(root, encoding='unicode', method='xml')
|
||||||
|
response = HttpResponse(xml_string, content_type='application/xml')
|
||||||
|
return response
|
||||||
|
|
||||||
|
def products_sitemap(self, request, base_url):
|
||||||
|
"""Produkt-Sitemap"""
|
||||||
|
root = ET.Element('urlset')
|
||||||
|
root.set('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9')
|
||||||
|
|
||||||
|
products = Product.objects.filter(is_active=True).order_by('-updated_at')
|
||||||
|
|
||||||
|
for product in products:
|
||||||
|
url_elem = ET.SubElement(root, 'url')
|
||||||
|
|
||||||
|
loc = ET.SubElement(url_elem, 'loc')
|
||||||
|
loc.text = f"{base_url}{product.get_absolute_url()}"
|
||||||
|
|
||||||
|
lastmod = ET.SubElement(url_elem, 'lastmod')
|
||||||
|
lastmod.text = product.updated_at.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
changefreq = ET.SubElement(url_elem, 'changefreq')
|
||||||
|
changefreq.text = 'weekly'
|
||||||
|
|
||||||
|
priority = ET.SubElement(url_elem, 'priority')
|
||||||
|
priority.text = '0.8'
|
||||||
|
|
||||||
|
xml_string = ET.tostring(root, encoding='unicode', method='xml')
|
||||||
|
response = HttpResponse(xml_string, content_type='application/xml')
|
||||||
|
return response
|
||||||
|
|
||||||
|
def categories_sitemap(self, request, base_url):
|
||||||
|
"""Kategorie-Sitemap"""
|
||||||
|
root = ET.Element('urlset')
|
||||||
|
root.set('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9')
|
||||||
|
|
||||||
|
categories = Category.objects.all()
|
||||||
|
|
||||||
|
for category in categories:
|
||||||
|
url_elem = ET.SubElement(root, 'url')
|
||||||
|
|
||||||
|
loc = ET.SubElement(url_elem, 'loc')
|
||||||
|
loc.text = f"{base_url}{category.get_absolute_url()}"
|
||||||
|
|
||||||
|
lastmod = ET.SubElement(url_elem, 'lastmod')
|
||||||
|
lastmod.text = '2024-01-15' # TODO: Kategorie updated_at hinzufügen
|
||||||
|
|
||||||
|
changefreq = ET.SubElement(url_elem, 'changefreq')
|
||||||
|
changefreq.text = 'weekly'
|
||||||
|
|
||||||
|
priority = ET.SubElement(url_elem, 'priority')
|
||||||
|
priority.text = '0.7'
|
||||||
|
|
||||||
|
xml_string = ET.tostring(root, encoding='unicode', method='xml')
|
||||||
|
response = HttpResponse(xml_string, content_type='application/xml')
|
||||||
|
return response
|
||||||
|
|
||||||
|
def main_sitemap(self, request, base_url):
|
||||||
|
"""Haupt-Sitemap mit statischen Seiten"""
|
||||||
|
root = ET.Element('urlset')
|
||||||
|
root.set('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9')
|
||||||
|
|
||||||
|
# Statische Seiten
|
||||||
|
static_pages = [
|
||||||
|
{'url': '/', 'priority': '1.0', 'changefreq': 'daily'},
|
||||||
|
{'url': '/products/', 'priority': '0.8', 'changefreq': 'weekly'},
|
||||||
|
{'url': '/gallery/', 'priority': '0.7', 'changefreq': 'weekly'},
|
||||||
|
{'url': '/contact/', 'priority': '0.5', 'changefreq': 'monthly'},
|
||||||
|
{'url': '/about/', 'priority': '0.5', 'changefreq': 'monthly'},
|
||||||
|
{'url': '/faq/', 'priority': '0.6', 'changefreq': 'monthly'},
|
||||||
|
{'url': '/custom-order/', 'priority': '0.7', 'changefreq': 'weekly'},
|
||||||
|
]
|
||||||
|
|
||||||
|
for page in static_pages:
|
||||||
|
url_elem = ET.SubElement(root, 'url')
|
||||||
|
|
||||||
|
loc = ET.SubElement(url_elem, 'loc')
|
||||||
|
loc.text = f"{base_url}{page['url']}"
|
||||||
|
|
||||||
|
lastmod = ET.SubElement(url_elem, 'lastmod')
|
||||||
|
lastmod.text = '2024-01-15'
|
||||||
|
|
||||||
|
changefreq = ET.SubElement(url_elem, 'changefreq')
|
||||||
|
changefreq.text = page['changefreq']
|
||||||
|
|
||||||
|
priority = ET.SubElement(url_elem, 'priority')
|
||||||
|
priority.text = page['priority']
|
||||||
|
|
||||||
|
xml_string = ET.tostring(root, encoding='unicode', method='xml')
|
||||||
|
response = HttpResponse(xml_string, content_type='application/xml')
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_json(request):
|
||||||
|
"""Web App Manifest für PWA"""
|
||||||
|
manifest = {
|
||||||
|
"name": "Kasico Fursuit Shop",
|
||||||
|
"short_name": "Kasico",
|
||||||
|
"description": "Premium Fursuit Shop - Handgefertigte Fursuits made in Germany",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#FF6B9D",
|
||||||
|
"theme_color": "#FF6B9D",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/images/kasicoLogo.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/images/kasicoLogo.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": ["shopping", "fashion"],
|
||||||
|
"lang": "de",
|
||||||
|
"dir": "ltr"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = HttpResponse(
|
||||||
|
json.dumps(manifest, indent=2),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<link rel="stylesheet" href="/static/css/furry.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/furry-theme.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/products.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/dashboard.css">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/images/kasico-logo-simple.svg">
|
||||||
|
{{ block.super }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{# Der eigentliche Seiteninhalt kommt in den content-Block #}
|
||||||
|
{% block content %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Fortschritt hinzufügen - {{ block.super }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="card-title mb-0">
|
||||||
|
Fortschritts-Update für {{ order.character_name }}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.stage.id_for_label }}" class="form-label">
|
||||||
|
{{ form.stage.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.stage }}
|
||||||
|
{% if form.stage.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for error in form.stage.errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.description.id_for_label }}" class="form-label">
|
||||||
|
{{ form.description.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.description }}
|
||||||
|
{% if form.description.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for error in form.description.errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.image.id_for_label }}" class="form-label">
|
||||||
|
{{ form.image.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.image }}
|
||||||
|
{% if form.image.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for error in form.image.errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="form-check">
|
||||||
|
{{ form.completed }}
|
||||||
|
<label class="form-check-label" for="{{ form.completed.id_for_label }}">
|
||||||
|
{{ form.completed.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% if form.completed.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for error in form.completed.errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<a href="{% url 'custom_order_detail' order.id %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Zurück
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-save"></i> Update speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Warenkorb{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Warenkorb</h2>
|
||||||
|
{% if cart.items %}
|
||||||
|
<div class="cart-list">
|
||||||
|
{% for item in cart.items %}
|
||||||
|
<div class="cart-item-card">
|
||||||
|
<img src="{{ item.product.image.url }}" alt="{{ item.product.name }}" class="cart-item-image">
|
||||||
|
<div class="cart-item-info">
|
||||||
|
<h3>{{ item.product.name }}</h3>
|
||||||
|
<p class="cart-item-price">{{ item.product.price }} €</p>
|
||||||
|
<div class="cart-item-qty">
|
||||||
|
<form method="post" action="{% url 'products:cart_update' item.product.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="number" name="quantity" value="{{ item.quantity }}" min="1" class="cart-qty-input">
|
||||||
|
<button type="submit" class="btn furry-btn-outline">Menge ändern</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{% url 'products:cart_remove' item.product.pk %}" style="display:inline;">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn furry-btn-secondary">Entfernen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="cart-summary">
|
||||||
|
<span>Gesamtsumme:</span>
|
||||||
|
<span class="cart-total">{{ cart.total_price }} €</span>
|
||||||
|
<a href="{% url 'products:checkout' %}" class="btn furry-btn">Weiter zur Kasse</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>Dein Warenkorb ist leer.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Related Products -->
|
||||||
|
{% if related_products %}
|
||||||
|
<div class="furry-card" style="margin-top: 2rem;">
|
||||||
|
<div class="furry-card-header">
|
||||||
|
<h2 class="furry-card-title">💡 Das könnte dir auch gefallen</h2>
|
||||||
|
<p class="furry-card-subtitle">Weitere Fursuits in deinem Stil</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 1.5rem;">
|
||||||
|
{% for product in related_products %}
|
||||||
|
<div class="furry-card furry-product-card">
|
||||||
|
{% if product.image %}
|
||||||
|
<img src="{{ product.image.url }}" alt="{{ product.name }}" class="furry-card-image">
|
||||||
|
{% else %}
|
||||||
|
<div class="furry-card-image" style="background: linear-gradient(45deg, var(--furry-primary), var(--furry-secondary)); display: flex; align-items: center; justify-content: center; color: white; font-size: 2rem;">
|
||||||
|
🐾
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="furry-card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="furry-card-title">{{ product.name }}</h3>
|
||||||
|
<p class="furry-card-subtitle">{{ product.get_fursuit_type_display }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="furry-card-content">
|
||||||
|
<p>{{ product.description|truncatewords:10 }}</p>
|
||||||
|
<div class="furry-product-price">{{ product.price }}€</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="furry-card-footer">
|
||||||
|
<a href="{% url 'products:product_detail' product.id %}" class="furry-btn furry-btn-primary furry-btn-sm">
|
||||||
|
👁️ Details
|
||||||
|
</a>
|
||||||
|
<button class="furry-btn furry-btn-outline furry-btn-sm" onclick="addToCart({{ product.id }})">
|
||||||
|
🛒 Hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function updateQuantity(itemId, change) {
|
||||||
|
fetch(`/update-cart-item/${itemId}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
change: change
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(itemId) {
|
||||||
|
if (confirm('Möchtest du dieses Produkt wirklich aus dem Warenkorb entfernen?')) {
|
||||||
|
fetch(`/remove-cart-item/${itemId}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCart() {
|
||||||
|
if (confirm('Möchtest du wirklich den gesamten Warenkorb leeren?')) {
|
||||||
|
fetch('/clear-cart/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToCart(productId) {
|
||||||
|
fetch(`/add-to-cart/${productId}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
const alert = document.createElement('div');
|
||||||
|
alert.className = 'furry-alert furry-alert-success';
|
||||||
|
alert.innerHTML = `
|
||||||
|
<div class="furry-alert-icon">✅</div>
|
||||||
|
<div class="furry-alert-content">
|
||||||
|
<div class="furry-alert-title">Erfolg</div>
|
||||||
|
<div class="furry-alert-message">Produkt wurde zum Warenkorb hinzugefügt!</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.querySelector('main').insertBefore(alert, document.querySelector('main').firstChild);
|
||||||
|
setTimeout(() => alert.remove(), 3000);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Checkout{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Checkout</h2>
|
||||||
|
<div class="checkout-container">
|
||||||
|
<form class="furry-form checkout-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="address">Adresse</label>
|
||||||
|
<input type="text" id="address" name="address" required placeholder="Straße, Hausnummer">
|
||||||
|
<div class="form-hint">Bitte gib deine vollständige Adresse ein 🐾</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="city">Stadt</label>
|
||||||
|
<input type="text" id="city" name="city" required placeholder="Stadt">
|
||||||
|
<div class="form-hint">Stadt nicht vergessen!</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="zip">PLZ</label>
|
||||||
|
<input type="text" id="zip" name="zip" required placeholder="PLZ">
|
||||||
|
<div class="form-hint">Postleitzahl bitte 🐾</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="payment">Zahlungsart</label>
|
||||||
|
<select id="payment" name="payment" required>
|
||||||
|
<option value="">Bitte wählen…</option>
|
||||||
|
<option value="paypal">PayPal</option>
|
||||||
|
<option value="stripe">Kreditkarte (Stripe)</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-hint">Wähle deine bevorzugte Zahlungsart</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn furry-btn">Jetzt kaufen</button>
|
||||||
|
</form>
|
||||||
|
<div class="checkout-summary">
|
||||||
|
<h3>Deine Bestellung</h3>
|
||||||
|
<ul>
|
||||||
|
{% for item in cart.items %}
|
||||||
|
<li>{{ item.product.name }} × {{ item.quantity }} <span>{{ item.product.price|floatformat:2 }} €</span></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<div class="checkout-total">Gesamtsumme: <span>{{ cart.total_price|floatformat:2 }} €</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,455 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Kontakt - {{ block.super }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="contact-container">
|
||||||
|
<!-- Hero-Header -->
|
||||||
|
<div class="contact-hero furry-card text-center mb-5">
|
||||||
|
<div class="contact-hero-content">
|
||||||
|
<h1 class="contact-title">📞 Kontakt</h1>
|
||||||
|
<p class="contact-subtitle">Wir sind für dich da! Lass uns gemeinsam deinen perfekten Fursuit planen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<!-- Kontaktinformationen -->
|
||||||
|
<div class="contact-info-section furry-card mb-5">
|
||||||
|
<h2 class="section-title">🐾 Erreiche uns</h2>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="contact-info-card">
|
||||||
|
<div class="contact-icon">📧</div>
|
||||||
|
<h5>E-Mail</h5>
|
||||||
|
<p>info@kasico-art.de</p>
|
||||||
|
<a href="mailto:info@kasico-art.de" class="contact-link">Nachricht schreiben</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="contact-info-card">
|
||||||
|
<div class="contact-icon">📱</div>
|
||||||
|
<h5>Telefon</h5>
|
||||||
|
<p>+49 123 456789</p>
|
||||||
|
<a href="tel:+49123456789" class="contact-link">Anrufen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="contact-info-card">
|
||||||
|
<div class="contact-icon">🕒</div>
|
||||||
|
<h5>Geschäftszeiten</h5>
|
||||||
|
<p>Mo-Fr: 9:00 - 18:00 Uhr<br>
|
||||||
|
Sa: 10:00 - 14:00 Uhr</p>
|
||||||
|
<span class="contact-note">🐺 Sonntags geschlossen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kontaktformular -->
|
||||||
|
<div class="contact-form-section furry-card">
|
||||||
|
<h2 class="section-title">✍️ Nachricht schreiben</h2>
|
||||||
|
<p class="form-subtitle">Erzähl uns von deinem Traum-Fursuit!</p>
|
||||||
|
|
||||||
|
<form id="contact-form" class="furry-form" method="post" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name" class="form-label">🐺 Name</label>
|
||||||
|
<input type="text" id="name" name="name" required placeholder="Dein Name">
|
||||||
|
<div class="form-hint" id="name-hint"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email" class="form-label">📧 E-Mail</label>
|
||||||
|
<input type="email" id="email" name="email" required placeholder="deine@email.de">
|
||||||
|
<div class="form-hint" id="email-hint"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="subject" class="form-label">📝 Betreff</label>
|
||||||
|
<select id="subject" name="subject" required>
|
||||||
|
<option value="">Wähle ein Thema</option>
|
||||||
|
<option value="custom_order">🎨 Custom Order Anfrage</option>
|
||||||
|
<option value="general">❓ Allgemeine Frage</option>
|
||||||
|
<option value="support">🛠️ Support</option>
|
||||||
|
<option value="partnership">🤝 Partnerschaft</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-hint" id="subject-hint"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="message" class="form-label">💬 Nachricht</label>
|
||||||
|
<textarea id="message" name="message" required placeholder="Erzähl uns von deinem Projekt oder deiner Frage..."></textarea>
|
||||||
|
<div class="form-hint" id="message-hint"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn furry-btn">
|
||||||
|
🚀 Nachricht senden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FAQ-Link -->
|
||||||
|
<div class="faq-link-section furry-card text-center mt-4">
|
||||||
|
<div class="faq-link-content">
|
||||||
|
<h3>❓ Häufige Fragen</h3>
|
||||||
|
<p>Finde schnell Antworten auf die wichtigsten Fragen</p>
|
||||||
|
<a href="{% url 'products:faq' %}" class="btn furry-btn-outline">
|
||||||
|
📚 FAQ durchsuchen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary-color: #8B5CF6;
|
||||||
|
--secondary-color: #EC4899;
|
||||||
|
--accent-color: #F59E0B;
|
||||||
|
--dark-color: #1F2937;
|
||||||
|
--light-color: #F3E8FF;
|
||||||
|
--white-color: #FFFFFF;
|
||||||
|
--gradient-start: #8B5CF6;
|
||||||
|
--gradient-middle: #EC4899;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-hero {
|
||||||
|
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-middle));
|
||||||
|
color: white;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-subtitle {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-subtitle {
|
||||||
|
color: var(--dark-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info-section {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info-card {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 15px;
|
||||||
|
background: var(--light-color);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info-card h5 {
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info-card p {
|
||||||
|
color: var(--dark-color);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-link {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-link:hover {
|
||||||
|
color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-note {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--dark-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form-section {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-form .form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-form label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dark-color);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-form input, .furry-form textarea, .furry-form select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2px solid var(--light-color);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-form input:focus, .furry-form textarea:focus, .furry-form select:focus {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(139, 92, 246, 0.25);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-form input.invalid, .furry-form textarea.invalid, .furry-form select.invalid {
|
||||||
|
border-color: #EF4444;
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(239, 68, 68, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-form input.valid, .furry-form textarea.valid, .furry-form select.valid {
|
||||||
|
border-color: #10B981;
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(16, 185, 129, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-form textarea {
|
||||||
|
min-height: 120px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn {
|
||||||
|
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-middle));
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn-outline {
|
||||||
|
background: white;
|
||||||
|
color: var(--primary-color);
|
||||||
|
border: 2px solid var(--primary-color);
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn-outline:hover {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-link-section {
|
||||||
|
padding: 2rem;
|
||||||
|
background: var(--light-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-link-content h3 {
|
||||||
|
color: var(--dark-color);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-link-content p {
|
||||||
|
color: var(--dark-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-danger {
|
||||||
|
background: linear-gradient(135deg, #FEE2E2, #FECACA);
|
||||||
|
border: 2px solid #EF4444;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #991B1B;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.contact-container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info-card {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation für erfolgreiche Nachricht */
|
||||||
|
@keyframes success-bounce {
|
||||||
|
0%, 20%, 53%, 80%, 100% {
|
||||||
|
transform: translate3d(0,0,0);
|
||||||
|
}
|
||||||
|
40%, 43% {
|
||||||
|
transform: translate3d(0, -30px, 0);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: translate3d(0, -15px, 0);
|
||||||
|
}
|
||||||
|
90% {
|
||||||
|
transform: translate3d(0, -4px, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-animation {
|
||||||
|
animation: success-bounce 1s ease-in-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const form = document.getElementById('contact-form');
|
||||||
|
const fields = [
|
||||||
|
{id: 'name', hint: 'Bitte gib deinen Namen ein.'},
|
||||||
|
{id: 'email', hint: 'Bitte gib eine gültige E-Mail-Adresse ein.'},
|
||||||
|
{id: 'subject', hint: 'Bitte wähle ein Thema aus.'},
|
||||||
|
{id: 'message', hint: 'Bitte schreibe eine Nachricht.'}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Live-Validierung
|
||||||
|
fields.forEach(f => {
|
||||||
|
const input = document.getElementById(f.id);
|
||||||
|
const hint = document.getElementById(f.id+'-hint');
|
||||||
|
|
||||||
|
input.addEventListener('blur', function() {
|
||||||
|
validateField(this, f.hint);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('input', function() {
|
||||||
|
if (this.classList.contains('invalid')) {
|
||||||
|
validateField(this, f.hint);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function validateField(field, hintText) {
|
||||||
|
const hint = document.getElementById(field.id+'-hint');
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
if (!field.value.trim()) {
|
||||||
|
isValid = false;
|
||||||
|
} else if (field.id === 'email' && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(field.value)) {
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
field.classList.remove('invalid');
|
||||||
|
field.classList.add('valid');
|
||||||
|
hint.textContent = '✅ Alles super!';
|
||||||
|
hint.style.color = '#10B981';
|
||||||
|
} else {
|
||||||
|
field.classList.remove('valid');
|
||||||
|
field.classList.add('invalid');
|
||||||
|
hint.textContent = hintText + ' 🐾';
|
||||||
|
hint.style.color = '#EF4444';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formular-Submission
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
let valid = true;
|
||||||
|
|
||||||
|
fields.forEach(f => {
|
||||||
|
const input = document.getElementById(f.id);
|
||||||
|
if (!input.value.trim() || (f.id === 'email' && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(input.value))) {
|
||||||
|
input.classList.add('invalid');
|
||||||
|
input.classList.remove('valid');
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
e.preventDefault();
|
||||||
|
// Smooth scroll to first invalid field
|
||||||
|
const firstInvalid = document.querySelector('.invalid');
|
||||||
|
if (firstInvalid) {
|
||||||
|
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Success animation
|
||||||
|
document.querySelector('.contact-form-section').classList.add('success-animation');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Nachricht gesendet - {{ block.super }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8 text-center">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<i class="bi bi-check-circle text-success" style="font-size: 4rem;"></i>
|
||||||
|
<h1 class="h3 mt-3">Vielen Dank für Ihre Nachricht!</h1>
|
||||||
|
<p class="lead">
|
||||||
|
Wir haben Ihre Anfrage erhalten und werden uns schnellstmöglich bei Ihnen melden.
|
||||||
|
</p>
|
||||||
|
<p class="text-muted">
|
||||||
|
Unsere durchschnittliche Antwortzeit beträgt 24-48 Stunden an Werktagen.
|
||||||
|
</p>
|
||||||
|
<hr>
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href="{% url 'products:product_list' %}" class="btn btn-primary me-2">
|
||||||
|
<i class="bi bi-shop"></i> Zurück zum Shop
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'products:faq' %}" class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-question-circle"></i> FAQ ansehen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,735 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Custom Fursuit Bestellung - {{ block.super }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="custom-order-container">
|
||||||
|
<!-- Hero-Header -->
|
||||||
|
<div class="custom-order-hero furry-card text-center mb-5">
|
||||||
|
<div class="custom-order-hero-content">
|
||||||
|
<h1 class="custom-order-title">🎨 Custom Fursuit Anfrage</h1>
|
||||||
|
<p class="custom-order-subtitle">Lass deinen Traum-Fursuit Realität werden!</p>
|
||||||
|
<div class="custom-order-stats">
|
||||||
|
<span class="stat-item">⚡ 8-12 Wochen Produktionszeit</span>
|
||||||
|
<span class="stat-item">💰 30% Anzahlung</span>
|
||||||
|
<span class="stat-item">🎯 Individuelles Design</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Informationen -->
|
||||||
|
<div class="col-lg-4 mb-4">
|
||||||
|
<div class="info-section furry-card h-100">
|
||||||
|
<div class="info-header text-center mb-4">
|
||||||
|
<div class="info-icon">✨</div>
|
||||||
|
<h3 class="info-title">Bestellprozess</h3>
|
||||||
|
<p class="info-subtitle">So wird dein Fursuit Realität</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="process-steps">
|
||||||
|
<div class="process-step">
|
||||||
|
<div class="step-number">1</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<h5>📝 Formular ausfüllen</h5>
|
||||||
|
<p>Beschreibe deinen Character und deine Wünsche</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="process-step">
|
||||||
|
<div class="step-number">2</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<h5>💰 Angebot erhalten</h5>
|
||||||
|
<p>Wir erstellen ein individuelles Angebot für dich</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="process-step">
|
||||||
|
<div class="step-number">3</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<h5>💳 Anzahlung leisten</h5>
|
||||||
|
<p>30% Anzahlung sichert deinen Auftrag</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="process-step">
|
||||||
|
<div class="step-number">4</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<h5>🎨 Design-Abstimmung</h5>
|
||||||
|
<p>Gemeinsam entwickeln wir das perfekte Design</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="process-step">
|
||||||
|
<div class="step-number">5</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<h5>🛠️ Produktion & Updates</h5>
|
||||||
|
<p>Regelmäßige Updates während der Produktion</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="process-step">
|
||||||
|
<div class="step-number">6</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<h5>📦 Fertigstellung & Versand</h5>
|
||||||
|
<p>Dein Fursuit kommt sicher zu dir</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="important-notes">
|
||||||
|
<h4 class="notes-title">⚠️ Wichtige Hinweise</h4>
|
||||||
|
<div class="note-item">
|
||||||
|
<span class="note-icon">⏰</span>
|
||||||
|
<span class="note-text">Produktionszeit: 8-12 Wochen</span>
|
||||||
|
</div>
|
||||||
|
<div class="note-item">
|
||||||
|
<span class="note-icon">💳</span>
|
||||||
|
<span class="note-text">Anzahlung: 30% des Gesamtpreises</span>
|
||||||
|
</div>
|
||||||
|
<div class="note-item">
|
||||||
|
<span class="note-icon">📏</span>
|
||||||
|
<span class="note-text">Genaue Maße sind erforderlich</span>
|
||||||
|
</div>
|
||||||
|
<div class="note-item">
|
||||||
|
<span class="note-icon">🎨</span>
|
||||||
|
<span class="note-text">Referenzbilder sind sehr hilfreich</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gallery-link">
|
||||||
|
<a href="{% url 'products:gallery' %}" class="btn furry-btn-outline">
|
||||||
|
🖼️ Beispielarbeiten ansehen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formular -->
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="form-section furry-card">
|
||||||
|
<div class="form-header text-center mb-4">
|
||||||
|
<h2 class="form-title">🎭 Character Anfrage</h2>
|
||||||
|
<p class="form-subtitle">Erzähl uns von deinem Traum-Fursuit</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" enctype="multipart/form-data" class="custom-order-form needs-validation" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Character Informationen -->
|
||||||
|
<div class="form-section-group">
|
||||||
|
<h4 class="section-header">🐺 Character Informationen</h4>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="character_name" class="form-label">🎭 Character Name</label>
|
||||||
|
<input type="text" class="form-control furry-input" id="character_name" name="character_name" required placeholder="Name deines Characters">
|
||||||
|
<div class="form-hint" id="character_name-hint"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="fursuit_type" class="form-label">🎪 Fursuit Typ</label>
|
||||||
|
<select class="form-select furry-select" id="fursuit_type" name="fursuit_type" required>
|
||||||
|
<option value="">Bitte wählen...</option>
|
||||||
|
<option value="fullsuit">🐺 Fullsuit (Komplett)</option>
|
||||||
|
<option value="partial">🎭 Partial (Kopf + Pfoten)</option>
|
||||||
|
<option value="head">🦊 Nur Kopf</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-hint" id="fursuit_type-hint"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Design Präferenzen -->
|
||||||
|
<div class="form-section-group">
|
||||||
|
<h4 class="section-header">🎨 Design Präferenzen</h4>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="character_description" class="form-label">📝 Character Beschreibung</label>
|
||||||
|
<textarea class="form-control furry-textarea" id="character_description" name="character_description" rows="4" required placeholder="Beschreibe deinen Character detailliert..."></textarea>
|
||||||
|
<div class="form-hint" id="character_description-hint"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="style" class="form-label">🎭 Gewünschter Stil</label>
|
||||||
|
<select class="form-select furry-select" id="style" name="style" required>
|
||||||
|
<option value="">Bitte wählen...</option>
|
||||||
|
<option value="toony">😊 Toony (Verspielt)</option>
|
||||||
|
<option value="semi_realistic">🦊 Semi-Realistic (Gemischt)</option>
|
||||||
|
<option value="realistic">🐺 Realistic (Realistisch)</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-hint" id="style-hint"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="budget_range" class="form-label">💰 Budget</label>
|
||||||
|
<select class="form-select furry-select" id="budget_range" name="budget_range" required>
|
||||||
|
<option value="">Bitte wählen...</option>
|
||||||
|
<option value="2000-3000">2.000€ - 3.000€</option>
|
||||||
|
<option value="3000-4000">3.000€ - 4.000€</option>
|
||||||
|
<option value="4000+">4.000€+</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-hint" id="budget_range-hint"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zusätzliche Details -->
|
||||||
|
<div class="form-section-group">
|
||||||
|
<h4 class="section-header">✨ Zusätzliche Details</h4>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="color_preferences" class="form-label">🎨 Farbwünsche</label>
|
||||||
|
<textarea class="form-control furry-textarea" id="color_preferences" name="color_preferences" rows="3" placeholder="Beschreibe deine Farbwünsche..."></textarea>
|
||||||
|
<div class="form-hint" id="color_preferences-hint"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="special_requests" class="form-label">💫 Besondere Wünsche</label>
|
||||||
|
<textarea class="form-control furry-textarea" id="special_requests" name="special_requests" rows="3" placeholder="Besondere Features oder Wünsche..."></textarea>
|
||||||
|
<div class="form-hint" id="special_requests-hint"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="reference_images" class="form-label">🖼️ Referenzbilder</label>
|
||||||
|
<div class="file-upload-area">
|
||||||
|
<input type="file" class="form-control furry-file-input" id="reference_images" name="reference_images" accept="image/*" multiple>
|
||||||
|
<div class="file-upload-hint">
|
||||||
|
<span class="upload-icon">📁</span>
|
||||||
|
<span>Bilder hierher ziehen oder klicken zum Auswählen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-hint" id="reference_images-hint"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="deadline_request" class="form-label">📅 Gewünschter Fertigstellungstermin</label>
|
||||||
|
<input type="date" class="form-control furry-input" id="deadline_request" name="deadline_request">
|
||||||
|
<div class="form-hint" id="deadline_request-hint"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn furry-btn">
|
||||||
|
🚀 Anfrage absenden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary-color: #8B5CF6;
|
||||||
|
--secondary-color: #EC4899;
|
||||||
|
--accent-color: #F59E0B;
|
||||||
|
--dark-color: #1F2937;
|
||||||
|
--light-color: #F3E8FF;
|
||||||
|
--white-color: #FFFFFF;
|
||||||
|
--gradient-start: #8B5CF6;
|
||||||
|
--gradient-middle: #EC4899;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-order-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-order-hero {
|
||||||
|
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-middle));
|
||||||
|
color: white;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-order-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-order-subtitle {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-order-stats {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-title {
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-subtitle {
|
||||||
|
color: var(--dark-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-steps {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--light-color);
|
||||||
|
border-radius: 15px;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-step:hover {
|
||||||
|
transform: translateX(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-number {
|
||||||
|
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-middle));
|
||||||
|
color: white;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-content h5 {
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-content p {
|
||||||
|
color: var(--dark-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.important-notes {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-title {
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
min-width: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-text {
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-link {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-title {
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-subtitle {
|
||||||
|
color: var(--dark-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section-group {
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--light-color);
|
||||||
|
border-radius: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dark-color);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-input, .furry-select, .furry-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2px solid var(--light-color);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-input:focus, .furry-select:focus, .furry-textarea:focus {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(139, 92, 246, 0.25);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-input.invalid, .furry-select.invalid, .furry-textarea.invalid {
|
||||||
|
border-color: #EF4444;
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(239, 68, 68, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-input.valid, .furry-select.valid, .furry-textarea.valid {
|
||||||
|
border-color: #10B981;
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(16, 185, 129, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-textarea {
|
||||||
|
min-height: 120px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-area {
|
||||||
|
position: relative;
|
||||||
|
border: 2px dashed var(--light-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 120px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-area:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: var(--light-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-file-input {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-hint {
|
||||||
|
color: var(--dark-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn {
|
||||||
|
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-middle));
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn-outline {
|
||||||
|
background: white;
|
||||||
|
color: var(--primary-color);
|
||||||
|
border: 2px solid var(--primary-color);
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn-outline:hover {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-danger {
|
||||||
|
background: linear-gradient(135deg, #FEE2E2, #FECACA);
|
||||||
|
border: 2px solid #EF4444;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #991B1B;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.custom-order-container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-order-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-order-stats {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-step {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-number {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation für erfolgreiche Anfrage */
|
||||||
|
@keyframes success-bounce {
|
||||||
|
0%, 20%, 53%, 80%, 100% {
|
||||||
|
transform: translate3d(0,0,0);
|
||||||
|
}
|
||||||
|
40%, 43% {
|
||||||
|
transform: translate3d(0, -30px, 0);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: translate3d(0, -15px, 0);
|
||||||
|
}
|
||||||
|
90% {
|
||||||
|
transform: translate3d(0, -4px, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-animation {
|
||||||
|
animation: success-bounce 1s ease-in-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const form = document.querySelector('.custom-order-form');
|
||||||
|
const fields = [
|
||||||
|
{id: 'character_name', hint: 'Bitte gib den Namen deines Characters ein.'},
|
||||||
|
{id: 'fursuit_type', hint: 'Bitte wähle einen Fursuit-Typ aus.'},
|
||||||
|
{id: 'character_description', hint: 'Bitte beschreibe deinen Character.'},
|
||||||
|
{id: 'style', hint: 'Bitte wähle einen Stil aus.'},
|
||||||
|
{id: 'budget_range', hint: 'Bitte wähle ein Budget aus.'}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Live-Validierung
|
||||||
|
fields.forEach(f => {
|
||||||
|
const input = document.getElementById(f.id);
|
||||||
|
const hint = document.getElementById(f.id+'-hint');
|
||||||
|
|
||||||
|
if (input) {
|
||||||
|
input.addEventListener('blur', function() {
|
||||||
|
validateField(this, f.hint);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('input', function() {
|
||||||
|
if (this.classList.contains('invalid')) {
|
||||||
|
validateField(this, f.hint);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function validateField(field, hintText) {
|
||||||
|
const hint = document.getElementById(field.id+'-hint');
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
if (!field.value.trim()) {
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
field.classList.remove('invalid');
|
||||||
|
field.classList.add('valid');
|
||||||
|
hint.textContent = '✅ Alles super!';
|
||||||
|
hint.style.color = '#10B981';
|
||||||
|
} else {
|
||||||
|
field.classList.remove('valid');
|
||||||
|
field.classList.add('invalid');
|
||||||
|
hint.textContent = hintText + ' 🐾';
|
||||||
|
hint.style.color = '#EF4444';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// File upload handling
|
||||||
|
const fileInput = document.getElementById('reference_images');
|
||||||
|
const fileArea = document.querySelector('.file-upload-area');
|
||||||
|
|
||||||
|
if (fileInput) {
|
||||||
|
fileInput.addEventListener('change', function() {
|
||||||
|
const files = this.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
fileArea.style.borderColor = '#10B981';
|
||||||
|
fileArea.style.background = '#F0FDF4';
|
||||||
|
const hint = document.getElementById('reference_images-hint');
|
||||||
|
hint.textContent = `✅ ${files.length} Datei(en) ausgewählt`;
|
||||||
|
hint.style.color = '#10B981';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag and drop
|
||||||
|
fileArea.addEventListener('dragover', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.style.borderColor = '#8B5CF6';
|
||||||
|
this.style.background = '#F3E8FF';
|
||||||
|
});
|
||||||
|
|
||||||
|
fileArea.addEventListener('drop', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
fileInput.files = e.dataTransfer.files;
|
||||||
|
fileInput.dispatchEvent(new Event('change'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formular-Submission
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
let valid = true;
|
||||||
|
|
||||||
|
fields.forEach(f => {
|
||||||
|
const input = document.getElementById(f.id);
|
||||||
|
if (input && !input.value.trim()) {
|
||||||
|
input.classList.add('invalid');
|
||||||
|
input.classList.remove('valid');
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
e.preventDefault();
|
||||||
|
// Smooth scroll to first invalid field
|
||||||
|
const firstInvalid = document.querySelector('.invalid');
|
||||||
|
if (firstInvalid) {
|
||||||
|
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Success animation
|
||||||
|
document.querySelector('.form-section').classList.add('success-animation');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Custom Order #{{ order.id }} - {{ block.super }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Order Details -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="card-title mb-0">Anfrage-Details</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li class="mb-2">
|
||||||
|
<strong>Status:</strong>
|
||||||
|
<span class="badge bg-{{ order.status }}">
|
||||||
|
{{ order.get_status_display }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="mb-2">
|
||||||
|
<strong>Character:</strong>
|
||||||
|
{{ order.character_name }}
|
||||||
|
</li>
|
||||||
|
<li class="mb-2">
|
||||||
|
<strong>Typ:</strong>
|
||||||
|
{{ order.get_fursuit_type_display }}
|
||||||
|
</li>
|
||||||
|
<li class="mb-2">
|
||||||
|
<strong>Stil:</strong>
|
||||||
|
{{ order.get_style_display }}
|
||||||
|
</li>
|
||||||
|
<li class="mb-2">
|
||||||
|
<strong>Budget:</strong>
|
||||||
|
{{ order.budget_range }}
|
||||||
|
</li>
|
||||||
|
{% if order.deadline_request %}
|
||||||
|
<li class="mb-2">
|
||||||
|
<strong>Gewünschter Termin:</strong>
|
||||||
|
{{ order.deadline_request }}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="mb-2">
|
||||||
|
<strong>Bestelldatum:</strong>
|
||||||
|
{{ order.created|date:"d.m.Y" }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% if order.quoted_price %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h6 class="alert-heading">Angebot</h6>
|
||||||
|
<p class="mb-0">{{ order.quoted_price }} €</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Character Details -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Character-Details</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h6>Beschreibung</h6>
|
||||||
|
<p>{{ order.character_description }}</p>
|
||||||
|
|
||||||
|
{% if order.reference_images %}
|
||||||
|
<h6>Referenzbilder</h6>
|
||||||
|
<img data-src="{{ order.reference_images.url }}" class="img-fluid rounded furry-lazy-image" alt="Referenzbild">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h6 class="mt-3">Farbwünsche</h6>
|
||||||
|
<p>{{ order.color_preferences }}</p>
|
||||||
|
|
||||||
|
{% if order.special_requests %}
|
||||||
|
<h6>Besondere Wünsche</h6>
|
||||||
|
<p>{{ order.special_requests }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Timeline -->
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="card-title mb-0">Fortschritt</h5>
|
||||||
|
{% if user.is_staff %}
|
||||||
|
<a href="{% url 'add_progress_update' order.id %}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-plus"></i> Update hinzufügen
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if progress_updates %}
|
||||||
|
<div class="timeline">
|
||||||
|
{% for update in progress_updates %}
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-marker {% if update.completed %}bg-success{% endif %}"></div>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<h6 class="mb-2">
|
||||||
|
{{ update.get_stage_display }}
|
||||||
|
{% if update.completed %}
|
||||||
|
<i class="bi bi-check-circle-fill text-success"></i>
|
||||||
|
{% endif %}
|
||||||
|
</h6>
|
||||||
|
<p>{{ update.description }}</p>
|
||||||
|
{% if update.image %}
|
||||||
|
<img data-src="{{ update.image.url }}" class="img-fluid rounded mb-2 furry-lazy-image" alt="Fortschrittsbild">
|
||||||
|
{% endif %}
|
||||||
|
<small class="text-muted">
|
||||||
|
{{ update.created|date:"d.m.Y H:i" }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<i class="bi bi-clock text-muted" style="font-size: 2rem;"></i>
|
||||||
|
<p class="mt-2">Noch keine Fortschritts-Updates vorhanden.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 15px;
|
||||||
|
height: 100%;
|
||||||
|
width: 2px;
|
||||||
|
background: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding-left: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-marker {
|
||||||
|
position: absolute;
|
||||||
|
left: 7px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #dee2e6;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 0 0 2px #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.bg-pending { background-color: #ffc107; }
|
||||||
|
.badge.bg-quoted { background-color: #17a2b8; }
|
||||||
|
.badge.bg-approved { background-color: #28a745; }
|
||||||
|
.badge.bg-in_progress { background-color: #007bff; }
|
||||||
|
.badge.bg-ready { background-color: #20c997; }
|
||||||
|
.badge.bg-shipped { background-color: #6f42c1; }
|
||||||
|
.badge.bg-completed { background-color: #28a745; }
|
||||||
|
.badge.bg-cancelled { background-color: #dc3545; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Anfrage erfolgreich - {{ block.super }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8 text-center">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<i class="bi bi-check-circle-fill text-success" style="font-size: 4rem;"></i>
|
||||||
|
<h1 class="h3 mt-3">Ihre Fursuit-Anfrage wurde erfolgreich übermittelt!</h1>
|
||||||
|
<p class="lead mb-4">
|
||||||
|
Vielen Dank für Ihr Interesse an einem Custom Fursuit.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Ihre Anfrage-Details</h5>
|
||||||
|
<ul class="list-unstyled text-start">
|
||||||
|
<li><strong>Anfrage-ID:</strong> #{{ order.id }}</li>
|
||||||
|
<li><strong>Character:</strong> {{ order.character_name }}</li>
|
||||||
|
<li><strong>Typ:</strong> {{ order.get_fursuit_type_display }}</li>
|
||||||
|
<li><strong>Stil:</strong> {{ order.get_style_display }}</li>
|
||||||
|
<li><strong>Status:</strong> {{ order.get_status_display }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
<i class="bi bi-info-circle"></i>
|
||||||
|
Wir werden Ihre Anfrage prüfen und uns innerhalb von 2-3 Werktagen mit einem detaillierten Angebot bei Ihnen melden.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href="{% url 'custom_order_detail' order.id %}" class="btn btn-primary me-2">
|
||||||
|
<i class="bi bi-eye"></i> Anfrage ansehen
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'gallery' %}" class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-images"></i> Galerie ansehen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,845 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Dashboard - Kasico Fursuit Shop{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="dashboard-container">
|
||||||
|
<!-- Hero-Header -->
|
||||||
|
<div class="dashboard-hero furry-card text-center mb-5">
|
||||||
|
<div class="dashboard-hero-content">
|
||||||
|
<h1 class="dashboard-title">🎛️ Mein Dashboard</h1>
|
||||||
|
<p class="dashboard-subtitle">Willkommen zurück, {{ user.username }}! 🐺</p>
|
||||||
|
<div class="dashboard-stats">
|
||||||
|
<span class="stat-item">📅 {{ user.date_joined|date:"d.m.Y" }}</span>
|
||||||
|
<span class="stat-item">⭐ Mitglied seit {{ user.date_joined|timesince }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<div class="stats-section furry-card mb-5">
|
||||||
|
<h2 class="section-title">📊 Übersicht</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">📦</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-number">{{ total_orders }}</div>
|
||||||
|
<div class="stat-label">Bestellungen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">💳</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-number">{{ total_spent }}€</div>
|
||||||
|
<div class="stat-label">Ausgegeben</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">❤️</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-number">{{ wishlist_count }}</div>
|
||||||
|
<div class="stat-label">Wunschliste</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">⭐</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-number">{{ reviews_count }}</div>
|
||||||
|
<div class="stat-label">Bewertungen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="actions-section furry-card mb-5">
|
||||||
|
<h2 class="section-title">⚡ Schnelle Aktionen</h2>
|
||||||
|
<div class="actions-grid">
|
||||||
|
<a href="{% url 'products:product_list' %}" class="action-card">
|
||||||
|
<div class="action-icon">🛍️</div>
|
||||||
|
<div class="action-content">
|
||||||
|
<h4>Shop durchsuchen</h4>
|
||||||
|
<p>Entdecke neue Fursuits</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="{% url 'products:wishlist' %}" class="action-card">
|
||||||
|
<div class="action-icon">❤️</div>
|
||||||
|
<div class="action-content">
|
||||||
|
<h4>Wunschliste</h4>
|
||||||
|
<p>Deine Favoriten</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="{% url 'products:custom_order' %}" class="action-card">
|
||||||
|
<div class="action-icon">🎨</div>
|
||||||
|
<div class="action-content">
|
||||||
|
<h4>Custom Order</h4>
|
||||||
|
<p>Individueller Fursuit</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="{% url 'products:contact' %}" class="action-card">
|
||||||
|
<div class="action-icon">📞</div>
|
||||||
|
<div class="action-content">
|
||||||
|
<h4>Support</h4>
|
||||||
|
<p>Hilfe & Kontakt</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Orders -->
|
||||||
|
{% if recent_orders %}
|
||||||
|
<div class="orders-section furry-card mb-5">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">📦 Letzte Bestellungen</h2>
|
||||||
|
<a href="{% url 'products:order_history' %}" class="btn furry-btn-outline">
|
||||||
|
Alle anzeigen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="orders-grid">
|
||||||
|
{% for order in recent_orders %}
|
||||||
|
<div class="order-card">
|
||||||
|
<div class="order-header">
|
||||||
|
<h4 class="order-title">Bestellung #{{ order.id }}</h4>
|
||||||
|
<span class="order-status status-{{ order.status }}">
|
||||||
|
{{ order.get_status_display }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="order-details">
|
||||||
|
<p class="order-date">{{ order.created_at|date:"d.m.Y H:i" }}</p>
|
||||||
|
<p class="order-amount">{{ order.total_amount }}€</p>
|
||||||
|
</div>
|
||||||
|
<div class="order-actions">
|
||||||
|
<a href="{% url 'products:order_detail' order.id %}" class="btn furry-btn-sm">
|
||||||
|
👁️ Details
|
||||||
|
</a>
|
||||||
|
{% if order.status == 'pending' %}
|
||||||
|
<button class="btn furry-btn-secondary furry-btn-sm">
|
||||||
|
💳 Bezahlen
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Custom Orders -->
|
||||||
|
{% if custom_orders %}
|
||||||
|
<div class="custom-orders-section furry-card mb-5">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">🎨 Custom Orders</h2>
|
||||||
|
<a href="{% url 'products:custom_order_list' %}" class="btn furry-btn-outline">
|
||||||
|
Alle anzeigen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="custom-orders-grid">
|
||||||
|
{% for order in custom_orders %}
|
||||||
|
<div class="custom-order-card">
|
||||||
|
<div class="custom-order-header">
|
||||||
|
<h4 class="custom-order-title">Custom Order #{{ order.id }}</h4>
|
||||||
|
<span class="custom-order-status status-{{ order.status }}">
|
||||||
|
{{ order.get_status_display }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="custom-order-details">
|
||||||
|
<p class="custom-order-date">{{ order.created_at|date:"d.m.Y" }}</p>
|
||||||
|
<p class="custom-order-description">{{ order.description|truncatewords:10 }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="custom-order-actions">
|
||||||
|
<a href="{% url 'products:custom_order_detail' order.id %}" class="btn furry-btn-sm">
|
||||||
|
👁️ Details
|
||||||
|
</a>
|
||||||
|
{% if order.status == 'in_progress' %}
|
||||||
|
<button class="btn furry-btn-secondary furry-btn-sm">
|
||||||
|
📊 Fortschritt
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Profile Section -->
|
||||||
|
<div class="profile-section furry-card mb-5">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">👤 Mein Profil</h2>
|
||||||
|
<a href="{% url 'products:profile' %}" class="btn furry-btn-outline">
|
||||||
|
Bearbeiten
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-grid">
|
||||||
|
<div class="profile-info-card">
|
||||||
|
<h4 class="profile-subtitle">📝 Persönliche Daten</h4>
|
||||||
|
<div class="profile-details">
|
||||||
|
<div class="profile-item">
|
||||||
|
<span class="profile-label">Name:</span>
|
||||||
|
<span class="profile-value">{{ user.first_name }} {{ user.last_name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="profile-item">
|
||||||
|
<span class="profile-label">E-Mail:</span>
|
||||||
|
<span class="profile-value">{{ user.email }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="profile-item">
|
||||||
|
<span class="profile-label">Mitglied seit:</span>
|
||||||
|
<span class="profile-value">{{ user.date_joined|date:"d.m.Y" }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-info-card">
|
||||||
|
<h4 class="profile-subtitle">📍 Adressen</h4>
|
||||||
|
<div class="profile-details">
|
||||||
|
{% if shipping_addresses %}
|
||||||
|
<div class="profile-item">
|
||||||
|
<span class="profile-label">Versandadressen:</span>
|
||||||
|
<span class="profile-value">{{ shipping_addresses.count }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="profile-item">
|
||||||
|
<span class="profile-label">Versandadressen:</span>
|
||||||
|
<span class="profile-value">Keine gespeichert</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notifications -->
|
||||||
|
{% if notifications %}
|
||||||
|
<div class="notifications-section furry-card mb-5">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">🔔 Benachrichtigungen</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notifications-grid">
|
||||||
|
{% for notification in notifications %}
|
||||||
|
<div class="notification-card notification-{{ notification.type }}">
|
||||||
|
<div class="notification-icon">
|
||||||
|
{% if notification.type == 'success' %}✅
|
||||||
|
{% elif notification.type == 'error' %}❌
|
||||||
|
{% elif notification.type == 'warning' %}⚠️
|
||||||
|
{% else %}ℹ️{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="notification-content">
|
||||||
|
<h4 class="notification-title">{{ notification.title }}</h4>
|
||||||
|
<p class="notification-message">{{ notification.message }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Support Section -->
|
||||||
|
<div class="support-section furry-card">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">🆘 Brauchst du Hilfe?</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="support-grid">
|
||||||
|
<div class="support-card">
|
||||||
|
<div class="support-icon">📞</div>
|
||||||
|
<h4 class="support-title">Kontakt</h4>
|
||||||
|
<p class="support-description">Hast du Fragen? Wir helfen dir gerne weiter!</p>
|
||||||
|
<a href="{% url 'products:contact' %}" class="btn furry-btn">
|
||||||
|
Kontakt aufnehmen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="support-card">
|
||||||
|
<div class="support-icon">❓</div>
|
||||||
|
<h4 class="support-title">FAQ</h4>
|
||||||
|
<p class="support-description">Häufig gestellte Fragen und Antworten</p>
|
||||||
|
<a href="{% url 'products:faq' %}" class="btn furry-btn-secondary">
|
||||||
|
FAQ durchsuchen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="support-card">
|
||||||
|
<div class="support-icon">📋</div>
|
||||||
|
<h4 class="support-title">Bestellungen</h4>
|
||||||
|
<p class="support-description">Verfolge deine Bestellungen und Custom Orders</p>
|
||||||
|
<a href="{% url 'products:order_history' %}" class="btn furry-btn-outline">
|
||||||
|
Bestellungen anzeigen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary-color: #8B5CF6;
|
||||||
|
--secondary-color: #EC4899;
|
||||||
|
--accent-color: #F59E0B;
|
||||||
|
--dark-color: #1F2937;
|
||||||
|
--light-color: #F3E8FF;
|
||||||
|
--white-color: #FFFFFF;
|
||||||
|
--gradient-start: #8B5CF6;
|
||||||
|
--gradient-middle: #EC4899;
|
||||||
|
--success-color: #10B981;
|
||||||
|
--warning-color: #F59E0B;
|
||||||
|
--error-color: #EF4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-hero {
|
||||||
|
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-middle));
|
||||||
|
color: white;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-subtitle {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stats {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-section {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: white;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 8px 25px rgba(139, 92, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
min-width: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: var(--dark-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-section {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card {
|
||||||
|
background: white;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 15px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--dark-color);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 8px 25px rgba(139, 92, 246, 0.15);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--dark-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
min-width: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-content h4 {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-content p {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders-section, .custom-orders-section {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders-grid, .custom-orders-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card, .custom-order-card {
|
||||||
|
background: white;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 15px;
|
||||||
|
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.1);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card:hover, .custom-order-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 8px 25px rgba(139, 92, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-header, .custom-order-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-title, .custom-order-title {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dark-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-status, .custom-order-status {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pending {
|
||||||
|
background: var(--warning-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-completed {
|
||||||
|
background: var(--success-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-cancelled {
|
||||||
|
background: var(--error-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-details, .custom-order-details {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-date, .custom-order-date {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--dark-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-amount {
|
||||||
|
margin: 0.5rem 0 0 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-order-description {
|
||||||
|
margin: 0.5rem 0 0 0;
|
||||||
|
color: var(--dark-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-actions, .custom-order-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-section {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info-card {
|
||||||
|
background: white;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 15px;
|
||||||
|
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-subtitle {
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-details {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--light-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dark-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-value {
|
||||||
|
color: var(--dark-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifications-section {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifications-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-success {
|
||||||
|
border-left: 4px solid var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-error {
|
||||||
|
border-left: 4px solid var(--error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-warning {
|
||||||
|
border-left: 4px solid var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-info {
|
||||||
|
border-left: 4px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
min-width: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-title {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dark-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-message {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--dark-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-section {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-card {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 15px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.1);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 8px 25px rgba(139, 92, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-title {
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-description {
|
||||||
|
color: var(--dark-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn {
|
||||||
|
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-middle));
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 25px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.3);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn-secondary {
|
||||||
|
background: var(--secondary-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 25px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn-secondary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(236, 72, 153, 0.3);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn-outline {
|
||||||
|
background: white;
|
||||||
|
color: var(--primary-color);
|
||||||
|
border: 2px solid var(--primary-color);
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 25px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn-outline:hover {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn-sm {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dashboard-container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stats {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid, .actions-grid, .support-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation für Dashboard Items */
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card, .action-card, .order-card, .custom-order-card, .support-card {
|
||||||
|
animation: fadeInUp 0.6s ease-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Auto-refresh notifications every 30 seconds
|
||||||
|
setInterval(function() {
|
||||||
|
fetch('/dashboard/notifications/')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.notifications && data.notifications.length > 0) {
|
||||||
|
// Update notifications section
|
||||||
|
const notificationsContainer = document.querySelector('.notifications-grid');
|
||||||
|
if (notificationsContainer) {
|
||||||
|
// Add new notifications
|
||||||
|
data.notifications.forEach(notification => {
|
||||||
|
const alert = document.createElement('div');
|
||||||
|
alert.className = `notification-card notification-${notification.type}`;
|
||||||
|
alert.innerHTML = `
|
||||||
|
<div class="notification-icon">
|
||||||
|
${notification.type === 'success' ? '✅' :
|
||||||
|
notification.type === 'error' ? '❌' :
|
||||||
|
notification.type === 'warning' ? '⚠️' : 'ℹ️'}
|
||||||
|
</div>
|
||||||
|
<div class="notification-content">
|
||||||
|
<h4 class="notification-title">${notification.title}</h4>
|
||||||
|
<p class="notification-message">${notification.message}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
notificationsContainer.appendChild(alert);
|
||||||
|
|
||||||
|
// Add animation
|
||||||
|
alert.style.animation = 'fadeInUp 0.6s ease-out';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error fetching notifications:', error));
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
// Add hover effects to action cards
|
||||||
|
const actionCards = document.querySelectorAll('.action-card');
|
||||||
|
actionCards.forEach(card => {
|
||||||
|
card.addEventListener('mouseenter', function() {
|
||||||
|
this.style.transform = 'translateY(-8px) scale(1.02)';
|
||||||
|
});
|
||||||
|
|
||||||
|
card.addEventListener('mouseleave', function() {
|
||||||
|
this.style.transform = 'translateY(0) scale(1)';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,529 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}FAQ - {{ block.super }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="faq-container">
|
||||||
|
<!-- Hero-Header -->
|
||||||
|
<div class="faq-hero furry-card text-center mb-5">
|
||||||
|
<div class="faq-hero-content">
|
||||||
|
<h1 class="faq-title">❓ Häufige Fragen</h1>
|
||||||
|
<p class="faq-subtitle">Finde schnell Antworten auf die wichtigsten Fragen rund um Fursuits</p>
|
||||||
|
<div class="faq-stats">
|
||||||
|
<span class="stat-item">📚 {{ faqs.count }} Fragen</span>
|
||||||
|
<span class="stat-item">📂 {{ categories|length }} Kategorien</span>
|
||||||
|
<span class="stat-item">🐺 Furry-Expertise</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-10">
|
||||||
|
<!-- FAQ-Kategorien -->
|
||||||
|
<div class="faq-categories-section furry-card mb-5">
|
||||||
|
<h2 class="section-title">📂 Kategorien durchsuchen</h2>
|
||||||
|
<div class="faq-categories">
|
||||||
|
<button class="category-btn furry-btn active" data-category="all">
|
||||||
|
🌟 Alle Fragen
|
||||||
|
</button>
|
||||||
|
{% for category in categories %}
|
||||||
|
<button class="category-btn furry-btn-outline" data-category="{{ category }}">
|
||||||
|
{% if category == "Bestellung" %}🛒
|
||||||
|
{% elif category == "Versand" %}📦
|
||||||
|
{% elif category == "Fursuit" %}🐺
|
||||||
|
{% elif category == "Zahlung" %}💳
|
||||||
|
{% elif category == "Support" %}🛠️
|
||||||
|
{% else %}❓{% endif %}
|
||||||
|
{{ category }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FAQ-Akkordeon -->
|
||||||
|
<div class="faq-accordion-section furry-card">
|
||||||
|
<h2 class="section-title">💡 Antworten finden</h2>
|
||||||
|
<div class="accordion" id="faqAccordion">
|
||||||
|
{% for faq in faqs %}
|
||||||
|
<div class="accordion-item" data-category="{{ faq.category }}">
|
||||||
|
<h2 class="accordion-header">
|
||||||
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#faq{{ forloop.counter }}">
|
||||||
|
<span class="faq-question-icon">
|
||||||
|
{% if faq.category == "Bestellung" %}🛒
|
||||||
|
{% elif faq.category == "Versand" %}📦
|
||||||
|
{% elif faq.category == "Fursuit" %}🐺
|
||||||
|
{% elif faq.category == "Zahlung" %}💳
|
||||||
|
{% elif faq.category == "Support" %}🛠️
|
||||||
|
{% else %}❓{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="faq-question-text">{{ faq.question }}</span>
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="faq{{ forloop.counter }}" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
|
||||||
|
<div class="accordion-body">
|
||||||
|
<div class="faq-answer">
|
||||||
|
{{ faq.answer|linebreaks }}
|
||||||
|
</div>
|
||||||
|
<div class="faq-helpful">
|
||||||
|
<span class="helpful-text">War diese Antwort hilfreich?</span>
|
||||||
|
<button class="helpful-btn" onclick="markHelpful(this, true)">
|
||||||
|
👍 Ja
|
||||||
|
</button>
|
||||||
|
<button class="helpful-btn" onclick="markHelpful(this, false)">
|
||||||
|
👎 Nein
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="empty-faq">
|
||||||
|
<div class="empty-faq-icon">🦊</div>
|
||||||
|
<h3>Keine FAQ-Einträge verfügbar</h3>
|
||||||
|
<p>Wir arbeiten daran, die häufigsten Fragen zu beantworten. Schau bald wieder vorbei!</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kontakt-Button -->
|
||||||
|
<div class="contact-link-section furry-card text-center mt-5">
|
||||||
|
<div class="contact-link-content">
|
||||||
|
<h3>🤔 Keine Antwort gefunden?</h3>
|
||||||
|
<p>Wir helfen dir gerne persönlich weiter!</p>
|
||||||
|
<div class="contact-actions">
|
||||||
|
<a href="{% url 'products:contact' %}" class="btn furry-btn">
|
||||||
|
📞 Kontakt aufnehmen
|
||||||
|
</a>
|
||||||
|
<a href="mailto:info@kasico-art.de" class="btn furry-btn-outline">
|
||||||
|
📧 E-Mail schreiben
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary-color: #8B5CF6;
|
||||||
|
--secondary-color: #EC4899;
|
||||||
|
--accent-color: #F59E0B;
|
||||||
|
--dark-color: #1F2937;
|
||||||
|
--light-color: #F3E8FF;
|
||||||
|
--white-color: #FFFFFF;
|
||||||
|
--gradient-start: #8B5CF6;
|
||||||
|
--gradient-middle: #EC4899;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-hero {
|
||||||
|
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-middle));
|
||||||
|
color: white;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-subtitle {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-stats {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-categories-section {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-categories {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 25px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-btn.active {
|
||||||
|
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-middle));
|
||||||
|
color: white;
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-btn:not(.active) {
|
||||||
|
background: white;
|
||||||
|
color: var(--primary-color);
|
||||||
|
border: 2px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-btn:not(.active):hover {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-accordion-section {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-item {
|
||||||
|
border: none;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 15px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-item:hover {
|
||||||
|
box-shadow: 0 4px 16px rgba(139, 92, 246, 0.15);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-button {
|
||||||
|
background: white;
|
||||||
|
border: none;
|
||||||
|
padding: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dark-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-button:not(.collapsed) {
|
||||||
|
background: var(--light-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-button:focus {
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-button::after {
|
||||||
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%238B5CF6'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-button:not(.collapsed)::after {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-question-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
min-width: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-question-text {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-answer {
|
||||||
|
color: var(--dark-color);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-helpful {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--light-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpful-text {
|
||||||
|
color: var(--dark-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpful-btn {
|
||||||
|
background: var(--light-color);
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpful-btn:hover {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-faq {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-faq-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-faq h3 {
|
||||||
|
color: var(--dark-color);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-faq p {
|
||||||
|
color: var(--dark-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-link-section {
|
||||||
|
padding: 2rem;
|
||||||
|
background: var(--light-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-link-content h3 {
|
||||||
|
color: var(--dark-color);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-link-content p {
|
||||||
|
color: var(--dark-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn {
|
||||||
|
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-middle));
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.3);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn-outline {
|
||||||
|
background: white;
|
||||||
|
color: var(--primary-color);
|
||||||
|
border: 2px solid var(--primary-color);
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn-outline:hover {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.faq-container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-stats {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-categories {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-btn {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn, .furry-btn-outline {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 300px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation für FAQ Items */
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-item {
|
||||||
|
animation: fadeInUp 0.6s ease-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const categoryButtons = document.querySelectorAll('[data-category]');
|
||||||
|
const faqItems = document.querySelectorAll('.accordion-item');
|
||||||
|
|
||||||
|
categoryButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
// Aktiven Button markieren
|
||||||
|
categoryButtons.forEach(btn => {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
btn.classList.remove('furry-btn');
|
||||||
|
btn.classList.add('furry-btn-outline');
|
||||||
|
});
|
||||||
|
button.classList.add('active');
|
||||||
|
button.classList.remove('furry-btn-outline');
|
||||||
|
button.classList.add('furry-btn');
|
||||||
|
|
||||||
|
// FAQs filtern
|
||||||
|
const selectedCategory = button.dataset.category;
|
||||||
|
let visibleCount = 0;
|
||||||
|
|
||||||
|
faqItems.forEach(item => {
|
||||||
|
if (selectedCategory === 'all' || item.dataset.category === selectedCategory) {
|
||||||
|
item.style.display = 'block';
|
||||||
|
visibleCount++;
|
||||||
|
} else {
|
||||||
|
item.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Smooth scroll to first visible item
|
||||||
|
if (visibleCount > 0) {
|
||||||
|
const firstVisible = document.querySelector('.accordion-item[style*="block"]');
|
||||||
|
if (firstVisible) {
|
||||||
|
firstVisible.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Smooth accordion animations
|
||||||
|
const accordionButtons = document.querySelectorAll('.accordion-button');
|
||||||
|
accordionButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const target = this.getAttribute('data-bs-target');
|
||||||
|
const targetElement = document.querySelector(target);
|
||||||
|
|
||||||
|
if (targetElement.classList.contains('show')) {
|
||||||
|
targetElement.style.transition = 'all 0.3s ease';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function markHelpful(button, isHelpful) {
|
||||||
|
const originalText = button.textContent;
|
||||||
|
|
||||||
|
// Visual feedback
|
||||||
|
button.style.background = isHelpful ? '#10B981' : '#EF4444';
|
||||||
|
button.style.color = 'white';
|
||||||
|
button.textContent = isHelpful ? '✅ Danke!' : '👎 Danke!';
|
||||||
|
button.disabled = true;
|
||||||
|
|
||||||
|
// Reset after 2 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
button.style.background = '';
|
||||||
|
button.style.color = '';
|
||||||
|
button.textContent = originalText;
|
||||||
|
button.disabled = false;
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
// Here you could send the feedback to the server
|
||||||
|
console.log(`FAQ marked as ${isHelpful ? 'helpful' : 'not helpful'}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,532 @@
|
||||||
|
{% extends "shop/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
{% load extras %}
|
||||||
|
|
||||||
|
{% block title %}Galerie - Kasico Art & Design{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="gallery-container">
|
||||||
|
<!-- Hero-Header -->
|
||||||
|
<div class="gallery-hero furry-card text-center mb-5">
|
||||||
|
<div class="gallery-hero-content">
|
||||||
|
<h1 class="gallery-title">🎨 Furry Galerie</h1>
|
||||||
|
<p class="gallery-subtitle">Entdecke unsere einzigartigen Fursuit-Kreationen</p>
|
||||||
|
<div class="gallery-stats">
|
||||||
|
<span class="stat-item">📸 {{ images.count }} Bilder</span>
|
||||||
|
<span class="stat-item">🎭 {{ fursuit_types|length }} Typen</span>
|
||||||
|
<span class="stat-item">🎨 {{ fursuit_styles|length }} Styles</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter-Bereich -->
|
||||||
|
<div class="filter-section mb-4">
|
||||||
|
<div class="furry-card">
|
||||||
|
<div class="filter-header">
|
||||||
|
<h3 class="filter-title">🔍 Filter & Sortierung</h3>
|
||||||
|
<p class="filter-subtitle">Finde deinen perfekten Fursuit-Stil</p>
|
||||||
|
</div>
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="fursuit_type" class="filter-label">🐺 Fursuit-Typ</label>
|
||||||
|
<select class="form-select furry-select" id="fursuit_type" name="fursuit_type">
|
||||||
|
<option value="">🎭 Alle Typen</option>
|
||||||
|
{% for type_code, type_name in fursuit_types %}
|
||||||
|
<option value="{{ type_code }}" {% if current_type == type_code %}selected{% endif %}>
|
||||||
|
{{ type_name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="style" class="filter-label">🎨 Style</label>
|
||||||
|
<select class="form-select furry-select" id="style" name="style">
|
||||||
|
<option value="">🌈 Alle Styles</option>
|
||||||
|
{% for style_code, style_name in fursuit_styles %}
|
||||||
|
<option value="{{ style_code }}" {% if current_style == style_code %}selected{% endif %}>
|
||||||
|
{{ style_name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sort" class="filter-label">📅 Sortierung</label>
|
||||||
|
<select class="form-select furry-select" id="sort" name="sort">
|
||||||
|
<option value="newest" {% if current_sort == 'newest' %}selected{% endif %}>
|
||||||
|
⭐ Neueste zuerst
|
||||||
|
</option>
|
||||||
|
<option value="oldest" {% if current_sort == 'oldest' %}selected{% endif %}>
|
||||||
|
📅 Älteste zuerst
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="filter-actions mt-3">
|
||||||
|
<button class="btn furry-btn-secondary" onclick="clearFilters()">
|
||||||
|
🗑️ Filter zurücksetzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Galerie-Grid -->
|
||||||
|
<div class="row g-4" id="gallery-grid">
|
||||||
|
{% for image in images %}
|
||||||
|
<div class="col-md-6 col-lg-4 gallery-item" data-aos="fade-up" data-aos-delay="{{ forloop.counter0|multiply:100 }}">
|
||||||
|
<div class="gallery-card furry-card h-100">
|
||||||
|
<div class="gallery-image">
|
||||||
|
<img data-src="{{ image.image.url }}" alt="{{ image.title }}" class="img-fluid furry-lazy-image">
|
||||||
|
{% if image.is_featured %}
|
||||||
|
<span class="featured-badge">
|
||||||
|
⭐ Featured
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="gallery-overlay">
|
||||||
|
<button class="btn furry-btn-sm" onclick="openLightbox('{{ image.image.url }}', '{{ image.title }}')">
|
||||||
|
🔍 Vergrößern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="gallery-info p-3">
|
||||||
|
<h5 class="gallery-title mb-2">{{ image.title }}</h5>
|
||||||
|
<p class="gallery-description mb-3">{{ image.description }}</p>
|
||||||
|
<div class="gallery-meta">
|
||||||
|
<span class="badge furry-badge me-2">
|
||||||
|
🐺 {{ image.get_fursuit_type_display }}
|
||||||
|
</span>
|
||||||
|
<span class="badge furry-badge">
|
||||||
|
🎨 {{ image.get_style_display }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="empty-gallery furry-card text-center">
|
||||||
|
<div class="empty-gallery-icon">🦊</div>
|
||||||
|
<h3>Keine Bilder gefunden</h3>
|
||||||
|
<p>Versuche andere Filter-Einstellungen oder schaue später wieder vorbei!</p>
|
||||||
|
<button class="btn furry-btn" onclick="clearFilters()">
|
||||||
|
🔄 Alle Filter zurücksetzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lightbox Modal -->
|
||||||
|
<div id="lightbox" class="lightbox-modal">
|
||||||
|
<div class="lightbox-content">
|
||||||
|
<span class="lightbox-close" onclick="closeLightbox()">×</span>
|
||||||
|
<img id="lightbox-image" src="" alt="">
|
||||||
|
<div class="lightbox-caption" id="lightbox-caption"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary-color: #8B5CF6;
|
||||||
|
--secondary-color: #EC4899;
|
||||||
|
--accent-color: #F59E0B;
|
||||||
|
--dark-color: #1F2937;
|
||||||
|
--light-color: #F3E8FF;
|
||||||
|
--white-color: #FFFFFF;
|
||||||
|
--gradient-start: #8B5CF6;
|
||||||
|
--gradient-middle: #EC4899;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-hero {
|
||||||
|
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-middle));
|
||||||
|
color: white;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-hero-content h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-subtitle {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-stats {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-title {
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-subtitle {
|
||||||
|
color: var(--dark-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dark-color);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-select {
|
||||||
|
border: 2px solid var(--light-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-select:focus {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(139, 92, 246, 0.25);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-card {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: none;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-card:hover {
|
||||||
|
transform: translateY(-8px) scale(1.02);
|
||||||
|
box-shadow: 0 12px 40px rgba(139, 92, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-image {
|
||||||
|
position: relative;
|
||||||
|
padding-top: 75%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 15px 15px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-image img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-card:hover .gallery-image img {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
background: linear-gradient(135deg, #F59E0B, #F97316);
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(139, 92, 246, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-card:hover .gallery-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn-sm {
|
||||||
|
background: white;
|
||||||
|
color: var(--primary-color);
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 25px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn-sm:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 4px 15px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-info {
|
||||||
|
background: var(--white-color);
|
||||||
|
border-radius: 0 0 15px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-title {
|
||||||
|
font-family: 'Baloo 2', sans-serif;
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-description {
|
||||||
|
color: var(--dark-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-badge {
|
||||||
|
background: var(--light-color);
|
||||||
|
color: var(--dark-color);
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-gallery {
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-gallery-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-gallery h3 {
|
||||||
|
color: var(--dark-color);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-gallery p {
|
||||||
|
color: var(--dark-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lightbox Styles */
|
||||||
|
.lightbox-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-content {
|
||||||
|
position: relative;
|
||||||
|
margin: auto;
|
||||||
|
padding: 20px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 800px;
|
||||||
|
height: 90%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 15px;
|
||||||
|
right: 35px;
|
||||||
|
color: #f1f1f1;
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-close:hover {
|
||||||
|
color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#lightbox-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 80%;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-caption {
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.gallery-container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-hero-content h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-stats {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation für Gallery Items */
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item {
|
||||||
|
animation: fadeInUp 0.6s ease-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Filter-Handling
|
||||||
|
const filterSelects = document.querySelectorAll('select');
|
||||||
|
|
||||||
|
filterSelects.forEach(select => {
|
||||||
|
select.addEventListener('change', function() {
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lazy Loading für Bilder
|
||||||
|
const lazyImages = document.querySelectorAll('.furry-lazy-image');
|
||||||
|
const imageObserver = new IntersectionObserver((entries, observer) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const img = entry.target;
|
||||||
|
img.src = img.dataset.src;
|
||||||
|
img.classList.remove('furry-lazy-image');
|
||||||
|
observer.unobserve(img);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
lazyImages.forEach(img => imageObserver.observe(img));
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const fursuit_type = document.getElementById('fursuit_type').value;
|
||||||
|
const style = document.getElementById('style').value;
|
||||||
|
const sort = document.getElementById('sort').value;
|
||||||
|
|
||||||
|
let url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('fursuit_type', fursuit_type);
|
||||||
|
url.searchParams.set('style', style);
|
||||||
|
url.searchParams.set('sort', sort);
|
||||||
|
|
||||||
|
// Smooth transition
|
||||||
|
document.getElementById('gallery-grid').style.opacity = '0.5';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = url.toString();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
document.getElementById('fursuit_type').value = '';
|
||||||
|
document.getElementById('style').value = '';
|
||||||
|
document.getElementById('sort').value = 'newest';
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLightbox(imageSrc, caption) {
|
||||||
|
const lightbox = document.getElementById('lightbox');
|
||||||
|
const lightboxImage = document.getElementById('lightbox-image');
|
||||||
|
const lightboxCaption = document.getElementById('lightbox-caption');
|
||||||
|
|
||||||
|
lightboxImage.src = imageSrc;
|
||||||
|
lightboxCaption.textContent = caption;
|
||||||
|
lightbox.style.display = 'block';
|
||||||
|
|
||||||
|
// Smooth fade in
|
||||||
|
lightbox.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
lightbox.style.opacity = '1';
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLightbox() {
|
||||||
|
const lightbox = document.getElementById('lightbox');
|
||||||
|
lightbox.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
lightbox.style.display = 'none';
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schließen mit ESC-Taste
|
||||||
|
document.addEventListener('keydown', function(event) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeLightbox();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schließen beim Klick außerhalb des Bildes
|
||||||
|
document.getElementById('lightbox').addEventListener('click', function(event) {
|
||||||
|
if (event.target === this) {
|
||||||
|
closeLightbox();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Kasico Fursuit Shop - Handgefertigte Fursuits{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<div class="furry-card" style="background: linear-gradient(135deg, var(--furry-primary) 0%, var(--furry-secondary) 100%); color: white; text-align: center; padding: 4rem 2rem; margin-bottom: 2rem;">
|
||||||
|
<div style="font-size: 4rem; margin-bottom: 1rem;">🐾</div>
|
||||||
|
<h1 style="font-size: 3rem; font-weight: 900; margin-bottom: 1rem;">Willkommen bei Kasico</h1>
|
||||||
|
<p style="font-size: 1.25rem; margin-bottom: 2rem; opacity: 0.9;">Handgefertigte Fursuits für die Furry-Community</p>
|
||||||
|
<div style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap;">
|
||||||
|
<a href="{% url 'products:product_list' %}" class="furry-btn furry-btn-secondary furry-btn-lg">
|
||||||
|
🛍️ Produkte entdecken
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'products:custom_order' %}" class="furry-btn furry-btn-outline furry-btn-lg" style="color: white; border-color: white;">
|
||||||
|
🎨 Custom Order
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Featured Categories -->
|
||||||
|
<div class="furry-card" style="margin-bottom: 2rem;">
|
||||||
|
<div class="furry-card-header">
|
||||||
|
<h2 class="furry-card-title">🎭 Fursuit Kategorien</h2>
|
||||||
|
<p class="furry-card-subtitle">Entdecke verschiedene Fursuit-Typen und Stile</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.5rem;">
|
||||||
|
<div class="furry-card furry-product-card">
|
||||||
|
<div class="furry-card-image" style="background: linear-gradient(45deg, var(--furry-primary), var(--furry-secondary)); display: flex; align-items: center; justify-content: center; color: white; font-size: 3rem;">
|
||||||
|
🦊
|
||||||
|
</div>
|
||||||
|
<div class="furry-card-header">
|
||||||
|
<h3 class="furry-card-title">Fullsuits</h3>
|
||||||
|
<p class="furry-card-subtitle">Komplette Fursuits</p>
|
||||||
|
</div>
|
||||||
|
<div class="furry-card-content">
|
||||||
|
<p>Vollständige Fursuits mit Kopf, Körper und Pfoten. Perfekt für Conventions und Events.</p>
|
||||||
|
</div>
|
||||||
|
<div class="furry-card-footer">
|
||||||
|
<a href="{% url 'products:product_list' %}?fursuit_type=fullsuit" class="furry-btn furry-btn-primary furry-btn-sm">
|
||||||
|
Entdecken
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="furry-card furry-product-card">
|
||||||
|
<div class="furry-card-image" style="background: linear-gradient(45deg, var(--furry-accent), var(--furry-warning)); display: flex; align-items: center; justify-content: center; color: white; font-size: 3rem;">
|
||||||
|
🐺
|
||||||
|
</div>
|
||||||
|
<div class="furry-card-header">
|
||||||
|
<h3 class="furry-card-title">Partials</h3>
|
||||||
|
<p class="furry-card-subtitle">Kopf & Pfoten</p>
|
||||||
|
</div>
|
||||||
|
<div class="furry-card-content">
|
||||||
|
<p>Fursuit-Köpfe mit passenden Pfoten. Ideal für Einsteiger und Budget-Bewusste.</p>
|
||||||
|
</div>
|
||||||
|
<div class="furry-card-footer">
|
||||||
|
<a href="{% url 'products:product_list' %}?fursuit_type=partial" class="furry-btn furry-btn-primary furry-btn-sm">
|
||||||
|
Entdecken
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="furry-card furry-product-card">
|
||||||
|
<div class="furry-card-image" style="background: linear-gradient(45deg, var(--furry-success), var(--furry-accent)); display: flex; align-items: center; justify-content: center; color: white; font-size: 3rem;">
|
||||||
|
🐾
|
||||||
|
</div>
|
||||||
|
<div class="furry-card-header">
|
||||||
|
<h3 class="furry-card-title">Accessoires</h3>
|
||||||
|
<p class="furry-card-subtitle">Pfoten, Schwänze & mehr</p>
|
||||||
|
</div>
|
||||||
|
<div class="furry-card-content">
|
||||||
|
<p>Einzelne Accessoires wie Pfoten, Schwänze und andere Fursuit-Teile.</p>
|
||||||
|
</div>
|
||||||
|
<div class="furry-card-footer">
|
||||||
|
<a href="{% url 'products:product_list' %}?fursuit_type=paws" class="furry-btn furry-btn-primary furry-btn-sm">
|
||||||
|
Entdecken
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Featured Products -->
|
||||||
|
{% if featured_products %}
|
||||||
|
<div class="furry-card" style="margin-bottom: 2rem;">
|
||||||
|
<div class="furry-card-header">
|
||||||
|
<h2 class="furry-card-title">⭐ Empfohlene Produkte</h2>
|
||||||
|
<p class="furry-card-subtitle">Unsere beliebtesten Fursuits</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem;">
|
||||||
|
{% for product in featured_products %}
|
||||||
|
<div class="furry-card furry-product-card">
|
||||||
|
{% if product.image %}
|
||||||
|
<img src="{{ product.image.url }}" alt="{{ product.name }}" class="furry-card-image">
|
||||||
|
{% else %}
|
||||||
|
<div class="furry-card-image" style="background: linear-gradient(45deg, var(--furry-primary), var(--furry-secondary)); display: flex; align-items: center; justify-content: center; color: white; font-size: 2rem;">
|
||||||
|
🐾
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="furry-card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="furry-card-title">{{ product.name }}</h3>
|
||||||
|
<p class="furry-card-subtitle">{{ product.get_fursuit_type_display }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="furry-product-badge">Featured</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="furry-card-content">
|
||||||
|
<p>{{ product.description|truncatewords:15 }}</p>
|
||||||
|
<div class="furry-product-price">{{ product.price }}€</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="furry-card-footer">
|
||||||
|
<a href="{% url 'products:product_detail' product.id %}" class="furry-btn furry-btn-primary furry-btn-sm">
|
||||||
|
👁️ Details
|
||||||
|
</a>
|
||||||
|
<button class="furry-btn furry-btn-outline furry-btn-sm" onclick="addToCart({{ product.id }})">
|
||||||
|
🛒 In Warenkorb
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 2rem;">
|
||||||
|
<a href="{% url 'products:product_list' %}" class="furry-btn furry-btn-secondary">
|
||||||
|
Alle Produkte anzeigen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Services Section -->
|
||||||
|
<div class="furry-card" style="margin-bottom: 2rem;">
|
||||||
|
<div class="furry-card-header">
|
||||||
|
<h2 class="furry-card-title">🛠️ Unsere Services</h2>
|
||||||
|
<p class="furry-card-subtitle">Was wir für dich tun können</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 2rem;">
|
||||||
|
<div class="furry-card">
|
||||||
|
<div style="font-size: 3rem; text-align: center; margin-bottom: 1rem;">🎨</div>
|
||||||
|
<h3 style="text-align: center; margin-bottom: 1rem;">Custom Design</h3>
|
||||||
|
<p style="text-align: center;">Individuelle Fursuits nach deinen Wünschen. Wir arbeiten eng mit dir zusammen, um deinen Traum-Fursuit zu verwirklichen.</p>
|
||||||
|
<div style="text-align: center; margin-top: 1.5rem;">
|
||||||
|
<a href="{% url 'products:custom_order' %}" class="furry-btn furry-btn-primary">
|
||||||
|
Custom Order starten
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="furry-card">
|
||||||
|
<div style="font-size: 3rem; text-align: center; margin-bottom: 1rem;">🔧</div>
|
||||||
|
<h3 style="text-align: center; margin-bottom: 1rem;">Reparaturen</h3>
|
||||||
|
<p style="text-align: center;">Professionelle Reparaturen und Wartung für bestehende Fursuits. Wir bringen deinen Fursuit wieder zum Glänzen.</p>
|
||||||
|
<div style="text-align: center; margin-top: 1.5rem;">
|
||||||
|
<a href="{% url 'products:contact' %}" class="furry-btn furry-btn-secondary">
|
||||||
|
Reparatur anfragen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="furry-card">
|
||||||
|
<div style="font-size: 3rem; text-align: center; margin-bottom: 1rem;">📦</div>
|
||||||
|
<h3 style="text-align: center; margin-bottom: 1rem;">Schnelle Lieferung</h3>
|
||||||
|
<p style="text-align: center;">Sichere Verpackung und schnelle Lieferung weltweit. Wir sorgen dafür, dass dein Fursuit sicher bei dir ankommt.</p>
|
||||||
|
<div style="text-align: center; margin-top: 1.5rem;">
|
||||||
|
<a href="{% url 'products:faq' %}" class="furry-btn furry-btn-outline">
|
||||||
|
Versand-Info
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Call to Action -->
|
||||||
|
<div class="furry-card" style="background: linear-gradient(135deg, var(--furry-accent) 0%, var(--furry-warning) 100%); color: white; text-align: center; padding: 3rem 2rem;">
|
||||||
|
<div style="font-size: 3rem; margin-bottom: 1rem;">🎉</div>
|
||||||
|
<h2 style="margin-bottom: 1rem;">Bereit für deinen ersten Fursuit?</h2>
|
||||||
|
<p style="margin-bottom: 2rem; font-size: 1.1rem; opacity: 0.9;">Entdecke unsere Kollektion oder starte dein Custom Design Projekt!</p>
|
||||||
|
<div style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap;">
|
||||||
|
<a href="{% url 'products:product_list' %}" class="furry-btn furry-btn-primary furry-btn-lg" style="background: white; color: var(--furry-accent);">
|
||||||
|
🛍️ Shop durchsuchen
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'products:custom_order' %}" class="furry-btn furry-btn-outline furry-btn-lg" style="color: white; border-color: white;">
|
||||||
|
🎨 Custom Order
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Newsletter Signup -->
|
||||||
|
<div class="furry-card" style="margin-top: 2rem;">
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<h3 style="margin-bottom: 1rem;">📧 Bleib auf dem Laufenden</h3>
|
||||||
|
<p style="margin-bottom: 2rem; color: var(--furry-text-secondary);">Erhalte Updates über neue Produkte, Special Offers und Fursuit-Tipps!</p>
|
||||||
|
|
||||||
|
<form method="post" style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; max-width: 500px; margin: 0 auto;">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="email" name="email" placeholder="Deine E-Mail-Adresse" required
|
||||||
|
class="furry-input" style="flex: 1; min-width: 250px;">
|
||||||
|
<button type="submit" class="furry-btn furry-btn-primary">
|
||||||
|
📧 Anmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p style="font-size: 0.8rem; color: var(--furry-text-secondary); margin-top: 1rem;">
|
||||||
|
Keine Spam! Du kannst dich jederzeit abmelden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function addToCart(productId) {
|
||||||
|
fetch(`/add-to-cart/${productId}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
const alert = document.createElement('div');
|
||||||
|
alert.className = 'furry-alert furry-alert-success';
|
||||||
|
alert.innerHTML = `
|
||||||
|
<div class="furry-alert-icon">✅</div>
|
||||||
|
<div class="furry-alert-content">
|
||||||
|
<div class="furry-alert-title">Erfolg</div>
|
||||||
|
<div class="furry-alert-message">Produkt wurde zum Warenkorb hinzugefügt!</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.querySelector('main').insertBefore(alert, document.querySelector('main').firstChild);
|
||||||
|
setTimeout(() => alert.remove(), 3000);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Bestellung erfolgreich{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="order-confirmation furry-404">
|
||||||
|
<div class="furry-404-emoji">🎉</div>
|
||||||
|
<h1>Danke für deine Bestellung!</h1>
|
||||||
|
<p>Wir haben deine Bestellung erhalten und unsere Furry-Freunde machen sich sofort an die Arbeit 🐾</p>
|
||||||
|
<div class="order-summary">
|
||||||
|
<h3>Deine Bestellung:</h3>
|
||||||
|
<ul>
|
||||||
|
{% for item in order.items.all %}
|
||||||
|
<li>{{ item.product_name }} × {{ item.quantity }} <span>{{ item.price|floatformat:2 }} €</span></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<div class="checkout-total">Gesamtsumme: <span>{{ order.total_amount|floatformat:2 }} €</span></div>
|
||||||
|
</div>
|
||||||
|
<a href="/" class="btn furry-btn">Zurück zur Startseite</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Bestellhistorie - Fursuit Shop{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-4">
|
||||||
|
<h1 class="mb-4">Meine Bestellungen</h1>
|
||||||
|
|
||||||
|
{% if orders %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Bestellnummer</th>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Zahlungsstatus</th>
|
||||||
|
<th>Gesamtbetrag</th>
|
||||||
|
<th>Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for order in orders %}
|
||||||
|
<tr>
|
||||||
|
<td>#{{ order.id }}</td>
|
||||||
|
<td>{{ order.created|date:"d.m.Y H:i" }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ order.get_order_status_display_class }}">
|
||||||
|
{{ order.get_status_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ order.get_payment_status_display_class }}">
|
||||||
|
{{ order.get_payment_status_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ order.total_amount }} €</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#orderModal{{ order.id }}">
|
||||||
|
Details anzeigen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Modal für Bestelldetails -->
|
||||||
|
<div class="modal fade" id="orderModal{{ order.id }}" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Bestellung #{{ order.id }}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Schließen"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6>Lieferadresse</h6>
|
||||||
|
<p>
|
||||||
|
{{ order.full_name }}<br>
|
||||||
|
{{ order.address|linebreaksbr }}<br>
|
||||||
|
Tel: {{ order.phone }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6>Bestellinformationen</h6>
|
||||||
|
<p>
|
||||||
|
Datum: {{ order.created|date:"d.m.Y H:i" }}<br>
|
||||||
|
Status: {{ order.get_status_display }}<br>
|
||||||
|
Zahlungsmethode: {{ order.get_payment_method_display }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6>Bestellte Artikel</h6>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Artikel</th>
|
||||||
|
<th>Anzahl</th>
|
||||||
|
<th>Preis</th>
|
||||||
|
<th>Gesamt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in order.items.all %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.product_name }}</td>
|
||||||
|
<td>{{ item.quantity }}</td>
|
||||||
|
<td>{{ item.price }} €</td>
|
||||||
|
<td>{{ item.get_subtotal }} €</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="text-end"><strong>Gesamtbetrag:</strong></td>
|
||||||
|
<td><strong>{{ order.total_amount }} €</strong></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Schließen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
Sie haben noch keine Bestellungen aufgegeben.
|
||||||
|
<a href="{% url 'products:shop' %}" class="alert-link">Jetzt einkaufen</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
<div class="payment-buttons">
|
||||||
|
<!-- PayPal Button -->
|
||||||
|
{{ form.render }}
|
||||||
|
|
||||||
|
<!-- Alternative Zahlungsmethoden -->
|
||||||
|
<div class="mt-3">
|
||||||
|
<a href="{% url 'products:payment_process' order.id %}" class="btn btn-primary">
|
||||||
|
Mit Kreditkarte bezahlen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.payment-buttons {
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Zahlung fehlgeschlagen - {{ block.super }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card border-danger">
|
||||||
|
<div class="card-header bg-danger text-white">
|
||||||
|
<h2 class="mb-0">Zahlung fehlgeschlagen</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<i class="fas fa-times-circle text-danger" style="font-size: 4rem;"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Bestellung #{{ order.id }}</h3>
|
||||||
|
<p>Leider konnte Ihre Zahlung nicht verarbeitet werden.</p>
|
||||||
|
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<h4>Mögliche Gründe:</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Unzureichende Deckung auf Ihrem Konto</li>
|
||||||
|
<li>Falsche oder abgelaufene Kartendaten</li>
|
||||||
|
<li>Technische Probleme bei der Zahlungsabwicklung</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Sie können die Zahlung erneut versuchen oder eine andere Zahlungsmethode wählen.</p>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href="{% url 'products:payment_process' order.id %}" class="btn btn-primary">
|
||||||
|
Zahlung erneut versuchen
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'products:cart_detail' %}" class="btn btn-outline-primary">
|
||||||
|
Zurück zum Warenkorb
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<p>Bei weiteren Fragen kontaktieren Sie bitte unseren Kundenservice:</p>
|
||||||
|
<a href="{% url 'products:contact' %}" class="btn btn-outline-secondary">
|
||||||
|
Kontakt aufnehmen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Zahlung - {{ block.super }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h2 class="mb-0">Zahlungsprozess</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title">Bestellung #{{ order.id }}</h3>
|
||||||
|
<p class="card-text">Gesamtbetrag: {{ order.total_amount }} €</p>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h4>Zahlungsinformationen</h4>
|
||||||
|
<p>Sie werden zu PayPal weitergeleitet, um die Zahlung sicher abzuschließen.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
{{ form.render }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Zahlung erfolgreich - {{ block.super }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card border-success">
|
||||||
|
<div class="card-header bg-success text-white">
|
||||||
|
<h2 class="mb-0">Zahlung erfolgreich!</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<i class="fas fa-check-circle text-success" style="font-size: 4rem;"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Bestellung #{{ order.id }}</h3>
|
||||||
|
<p>Vielen Dank für Ihre Bestellung! Ihre Zahlung wurde erfolgreich verarbeitet.</p>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h4>Bestelldetails:</h4>
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li>Bestellnummer: #{{ order.id }}</li>
|
||||||
|
<li>Gesamtbetrag: {{ order.total_amount }} €</li>
|
||||||
|
<li>Bestelldatum: {{ order.created|date:"d.m.Y H:i" }}</li>
|
||||||
|
<li>Status: {{ order.get_status_display }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Sie erhalten in Kürze eine Bestätigungs-E-Mail mit allen Details zu Ihrer Bestellung.</p>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href="{% url 'products:order_history' %}" class="btn btn-primary">
|
||||||
|
Zu meinen Bestellungen
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'products:product_list' %}" class="btn btn-outline-primary">
|
||||||
|
Weiter einkaufen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,552 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}{{ product.name }} - {{ block.super }}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.product-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border-radius: 15px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.1);
|
||||||
|
border-radius: 15px 15px 0 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
border-radius: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-image-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto 2rem;
|
||||||
|
padding: 8px;
|
||||||
|
background: linear-gradient(45deg, #8B5CF6, #EC4899);
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-image {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
cursor: zoom-in;
|
||||||
|
border-radius: 15px;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-details {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-description {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #666;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-zoom-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-content {
|
||||||
|
margin: auto;
|
||||||
|
display: block;
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
margin-top: 4%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-zoom {
|
||||||
|
position: absolute;
|
||||||
|
top: 15px;
|
||||||
|
right: 35px;
|
||||||
|
color: #f1f1f1;
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviews-section {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-card {
|
||||||
|
text-align: left;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(45deg, #8B5CF6, #6366F1);
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 4px 6px rgba(139, 92, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 8px rgba(139, 92, 246, 0.3);
|
||||||
|
background: linear-gradient(45deg, #7C3AED, #4F46E5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
border-radius: 25px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
padding: 8px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.2);
|
||||||
|
border-color: #8B5CF6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="product-container">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="product-details">
|
||||||
|
<h1 class="product-title">{{ product.name }}</h1>
|
||||||
|
|
||||||
|
{% if product.image %}
|
||||||
|
<div class="product-image-container">
|
||||||
|
<img data-src="{{ product.image.url }}"
|
||||||
|
alt="{{ product.name }}"
|
||||||
|
class="product-image furry-lazy-image"
|
||||||
|
onclick="openZoom(this.src)">
|
||||||
|
</div>
|
||||||
|
<!-- Zoom Modal -->
|
||||||
|
<div id="imageZoomModal" class="image-zoom-modal">
|
||||||
|
<span class="close-zoom" onclick="closeZoom()">×</span>
|
||||||
|
<img class="zoom-content" id="zoomedImage">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p class="product-description">{{ product.description }}</p>
|
||||||
|
|
||||||
|
<div class="product-meta">
|
||||||
|
<div class="meta-item">
|
||||||
|
<div class="meta-label">Preis</div>
|
||||||
|
<div class="meta-value">{{ product.price }} €</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<div class="meta-label">Verfügbarkeit</div>
|
||||||
|
<div class="meta-value">{{ product.stock }} Stück</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<div class="meta-label">Hinzugefügt am</div>
|
||||||
|
<div class="meta-value">{{ product.created|date:"d.m.Y" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<div class="meta-label">Bewertung</div>
|
||||||
|
<div class="meta-value">
|
||||||
|
{% with avg_rating=product.average_rating %}
|
||||||
|
{% if avg_rating > 0 %}
|
||||||
|
{{ avg_rating|floatformat:1 }} von 5 Sternen
|
||||||
|
{% else %}
|
||||||
|
Noch keine Bewertungen
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions-container">
|
||||||
|
<form method="post" action="{% url 'products:add_to_cart' product.id %}" class="d-flex align-items-center">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="number" name="quantity" value="1" min="1" max="{{ product.stock }}"
|
||||||
|
class="form-control me-2" style="width: 100px;">
|
||||||
|
<button type="submit" class="btn btn-primary">In den Warenkorb</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bewertungen -->
|
||||||
|
<div class="reviews-section mt-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h3 class="mb-0">Bewertungen</h3>
|
||||||
|
{% if user.is_authenticated and not user_has_reviewed %}
|
||||||
|
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#reviewModal">
|
||||||
|
Bewertung schreiben
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if product.reviews.all %}
|
||||||
|
{% for review in product.reviews.all %}
|
||||||
|
<div class="review-card">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<h5 class="mb-1">{{ review.user.username }}</h5>
|
||||||
|
<small class="text-muted">{{ review.created|date:"d.m.Y" }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
{% for i in "12345"|make_list %}
|
||||||
|
{% if forloop.counter <= review.rating %}
|
||||||
|
<i class="bi bi-star-fill text-warning"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-star text-warning"></i>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="mb-0">{{ review.comment }}</p>
|
||||||
|
</div>
|
||||||
|
{% if not forloop.last %}<hr>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-center mb-0">Noch keine Bewertungen vorhanden.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="social-sharing">
|
||||||
|
<div class="share-label">Teilen:</div>
|
||||||
|
|
||||||
|
<!-- Twitter/X -->
|
||||||
|
<a class="share-btn twitter" href="https://twitter.com/intent/tweet?url={{ request.build_absolute_uri }}&text=Schau%20dir%20dieses%20tolle%20Fursuit%20an!%20{{ product.name|urlencode }}" target="_blank" title="Auf Twitter/X teilen">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Facebook -->
|
||||||
|
<a class="share-btn facebook" href="https://www.facebook.com/sharer/sharer.php?u={{ request.build_absolute_uri }}" target="_blank" title="Auf Facebook teilen">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- WhatsApp -->
|
||||||
|
<a class="share-btn whatsapp" href="https://wa.me/?text=Schau%20dir%20dieses%20tolle%20Fursuit%20an!%20{{ product.name|urlencode }}%20{{ request.build_absolute_uri }}" target="_blank" title="Auf WhatsApp teilen">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893A11.821 11.821 0 0020.885 3.488"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Telegram -->
|
||||||
|
<a class="share-btn telegram" href="https://t.me/share/url?url={{ request.build_absolute_uri }}&text=Schau%20dir%20dieses%20tolle%20Fursuit%20an!%20{{ product.name|urlencode }}" target="_blank" title="Auf Telegram teilen">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Pinterest -->
|
||||||
|
<a class="share-btn pinterest" href="https://pinterest.com/pin/create/button/?url={{ request.build_absolute_uri }}&description=Schau%20dir%20dieses%20tolle%20Fursuit%20an!%20{{ product.name|urlencode }}" target="_blank" title="Auf Pinterest teilen">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12.017 0C5.396 0 .029 5.367.029 11.987c0 5.079 3.158 9.417 7.618 11.174-.105-.949-.199-2.403.041-3.439.219-.937 1.406-5.957 1.406-5.957s-.359-.72-.359-1.781c0-1.663.967-2.911 2.168-2.911 1.024 0 1.518.769 1.518 1.688 0 1.029-.653 2.567-.992 3.992-.285 1.193.6 2.165 1.775 2.165 2.128 0 3.768-2.245 3.768-5.487 0-2.861-2.063-4.869-5.008-4.869-3.41 0-5.409 2.562-5.409 5.199 0 1.033.394 2.143.889 2.741.099.12.112.225.085.345-.09.375-.293 1.199-.334 1.363-.053.225-.172.271-.402.165-1.495-.69-2.433-2.878-2.433-4.646 0-3.776 2.748-7.252 7.92-7.252 4.158 0 7.392 2.967 7.392 6.923 0 4.135-2.607 7.462-6.233 7.462-1.214 0-2.357-.629-2.75-1.378l-.748 2.853c-.271 1.043-1.002 2.35-1.492 3.146C9.57 23.812 10.763 24.009 12.017 24.009c6.624 0 11.99-5.367 11.99-11.988C24.007 5.367 18.641.001 12.017.001z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- LinkedIn -->
|
||||||
|
<a class="share-btn linkedin" href="https://www.linkedin.com/sharing/share-offsite/?url={{ request.build_absolute_uri }}" target="_blank" title="Auf LinkedIn teilen">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<a class="share-btn email" href="mailto:?subject=Schau%20dir%20dieses%20tolle%20Fursuit%20an!&body=Hallo!%20Ich%20habe%20ein%20tolles%20Fursuit%20gefunden:%20{{ product.name|urlencode }}%20{{ request.build_absolute_uri }}" title="Per E-Mail teilen">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Copy Link -->
|
||||||
|
<button class="share-btn copy" onclick="copyToClipboard('{{ request.build_absolute_uri }}')" title="Link kopieren">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bewertungs-Modal -->
|
||||||
|
{% if user.is_authenticated and not user_has_reviewed %}
|
||||||
|
<div class="modal fade" id="reviewModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Bewertung schreiben</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{% url 'products:add_review' product.id %}">
|
||||||
|
<div class="modal-body">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% for field in review_form %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ field.id_for_label }}" class="form-label">{{ field.label }}</label>
|
||||||
|
{{ field }}
|
||||||
|
{% if field.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for error in field.errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Bewertung absenden</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
function openZoom(imgSrc) {
|
||||||
|
const modal = document.getElementById('imageZoomModal');
|
||||||
|
const zoomedImg = document.getElementById('zoomedImage');
|
||||||
|
modal.style.display = "block";
|
||||||
|
zoomedImg.src = imgSrc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeZoom() {
|
||||||
|
document.getElementById('imageZoomModal').style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schließen beim Klick außerhalb des Bildes
|
||||||
|
window.onclick = function(event) {
|
||||||
|
const modal = document.getElementById('imageZoomModal');
|
||||||
|
if (event.target == modal) {
|
||||||
|
modal.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schließen mit Escape-Taste
|
||||||
|
document.addEventListener('keydown', function(event) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
document.getElementById('imageZoomModal').style.display = "none";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy to Clipboard Function
|
||||||
|
function copyToClipboard(text) {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
// Modern approach
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
showCopySuccess();
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to copy: ', err);
|
||||||
|
fallbackCopyTextToClipboard(text);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback for older browsers
|
||||||
|
fallbackCopyTextToClipboard(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackCopyTextToClipboard(text) {
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.value = text;
|
||||||
|
textArea.style.top = "0";
|
||||||
|
textArea.style.left = "0";
|
||||||
|
textArea.style.position = "fixed";
|
||||||
|
textArea.style.opacity = "0";
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const successful = document.execCommand('copy');
|
||||||
|
if (successful) {
|
||||||
|
showCopySuccess();
|
||||||
|
} else {
|
||||||
|
showCopyError();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fallback: Oops, unable to copy', err);
|
||||||
|
showCopyError();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCopySuccess() {
|
||||||
|
const copyBtn = document.querySelector('.share-btn.copy');
|
||||||
|
const originalContent = copyBtn.innerHTML;
|
||||||
|
|
||||||
|
// Add success animation
|
||||||
|
copyBtn.classList.add('copied');
|
||||||
|
copyBtn.innerHTML = `
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
const successMsg = document.createElement('div');
|
||||||
|
successMsg.className = 'copy-success-msg';
|
||||||
|
successMsg.textContent = 'Link kopiert! 🎉';
|
||||||
|
successMsg.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: linear-gradient(135deg, #25d366 0%, #20ba5a 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 4px 20px rgba(37, 211, 102, 0.3);
|
||||||
|
z-index: 10000;
|
||||||
|
animation: slideInRight 0.3s ease;
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(successMsg);
|
||||||
|
|
||||||
|
// Remove success message after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
successMsg.style.animation = 'slideOutRight 0.3s ease';
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.removeChild(successMsg);
|
||||||
|
}, 300);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
// Reset button after animation
|
||||||
|
setTimeout(() => {
|
||||||
|
copyBtn.classList.remove('copied');
|
||||||
|
copyBtn.innerHTML = originalContent;
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCopyError() {
|
||||||
|
const errorMsg = document.createElement('div');
|
||||||
|
errorMsg.className = 'copy-error-msg';
|
||||||
|
errorMsg.textContent = 'Kopieren fehlgeschlagen 😔';
|
||||||
|
errorMsg.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: linear-gradient(135deg, #ff6f91 0%, #e74c3c 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 4px 20px rgba(231, 76, 60, 0.3);
|
||||||
|
z-index: 10000;
|
||||||
|
animation: slideInRight 0.3s ease;
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(errorMsg);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
errorMsg.style.animation = 'slideOutRight 0.3s ease';
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.removeChild(errorMsg);
|
||||||
|
}, 300);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add CSS animations
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from { transform: translateX(100%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOutRight {
|
||||||
|
from { transform: translateX(0); opacity: 1; }
|
||||||
|
to { transform: translateX(100%); opacity: 0; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
// Social sharing analytics
|
||||||
|
document.querySelectorAll('.share-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function(e) {
|
||||||
|
const platform = this.className.includes('twitter') ? 'twitter' :
|
||||||
|
this.className.includes('facebook') ? 'facebook' :
|
||||||
|
this.className.includes('whatsapp') ? 'whatsapp' :
|
||||||
|
this.className.includes('telegram') ? 'telegram' :
|
||||||
|
this.className.includes('pinterest') ? 'pinterest' :
|
||||||
|
this.className.includes('linkedin') ? 'linkedin' :
|
||||||
|
this.className.includes('email') ? 'email' : 'copy';
|
||||||
|
|
||||||
|
// Track social share (if analytics is available)
|
||||||
|
if (typeof gtag !== 'undefined') {
|
||||||
|
gtag('event', 'share', {
|
||||||
|
method: platform,
|
||||||
|
content_type: 'product',
|
||||||
|
item_id: '{{ product.id }}'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Produkte - Kasico Fursuit Shop{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="furry-card">
|
||||||
|
<div class="furry-card-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="furry-card-title">🐾 Unsere Fursuits</h1>
|
||||||
|
<p class="furry-card-subtitle">Entdecke unsere handgefertigten Fursuits und Accessoires</p>
|
||||||
|
</div>
|
||||||
|
<div class="furry-product-badge">Neu</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Section -->
|
||||||
|
<div class="furry-card" style="margin-bottom: 2rem;">
|
||||||
|
<h3>🔍 Filter & Suche</h3>
|
||||||
|
<form method="get" class="furry-form-group">
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
|
||||||
|
<div>
|
||||||
|
<label class="furry-label">Fursuit Typ</label>
|
||||||
|
<select name="fursuit_type" class="furry-input furry-select">
|
||||||
|
<option value="">Alle Typen</option>
|
||||||
|
<option value="partial">Partial</option>
|
||||||
|
<option value="fullsuit">Fullsuit</option>
|
||||||
|
<option value="head_only">Head Only</option>
|
||||||
|
<option value="paws">Paws</option>
|
||||||
|
<option value="tail">Tail</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="furry-label">Stil</label>
|
||||||
|
<select name="style" class="furry-input furry-select">
|
||||||
|
<option value="">Alle Stile</option>
|
||||||
|
<option value="toony">Toony</option>
|
||||||
|
<option value="semi_realistic">Semi-Realistic</option>
|
||||||
|
<option value="realistic">Realistic</option>
|
||||||
|
<option value="anime">Anime</option>
|
||||||
|
<option value="chibi">Chibi</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="furry-label">Preisbereich</label>
|
||||||
|
<select name="price_range" class="furry-input furry-select">
|
||||||
|
<option value="">Alle Preise</option>
|
||||||
|
<option value="0-500">0 - 500€</option>
|
||||||
|
<option value="500-1000">500 - 1000€</option>
|
||||||
|
<option value="1000-2000">1000 - 2000€</option>
|
||||||
|
<option value="2000+">2000€+</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="furry-label">Sortierung</label>
|
||||||
|
<select name="sort" class="furry-input furry-select">
|
||||||
|
<option value="newest">Neueste zuerst</option>
|
||||||
|
<option value="price_low">Preis: Niedrig zu Hoch</option>
|
||||||
|
<option value="price_high">Preis: Hoch zu Niedrig</option>
|
||||||
|
<option value="name">Name A-Z</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 1rem; display: flex; gap: 1rem; flex-wrap: wrap;">
|
||||||
|
<button type="submit" class="furry-btn furry-btn-primary">
|
||||||
|
🔍 Filter anwenden
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'products:product_list' %}" class="furry-btn furry-btn-ghost">
|
||||||
|
🗑️ Filter zurücksetzen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Products Grid -->
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 2rem;">
|
||||||
|
{% for product in products %}
|
||||||
|
<div class="furry-card furry-product-card">
|
||||||
|
{% if product.image %}
|
||||||
|
<img data-src="{{ product.image.url }}" alt="{{ product.name }}" class="furry-card-image furry-lazy-image">
|
||||||
|
{% else %}
|
||||||
|
<div class="furry-card-image" style="background: linear-gradient(45deg, var(--furry-primary), var(--furry-secondary)); display: flex; align-items: center; justify-content: center; color: white; font-size: 2rem;">
|
||||||
|
🐾
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="furry-card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="furry-card-title">{{ product.name }}</h3>
|
||||||
|
<p class="furry-card-subtitle">{{ product.get_fursuit_type_display }} • {{ product.get_style_display }}</p>
|
||||||
|
</div>
|
||||||
|
{% if product.featured %}
|
||||||
|
<div class="furry-product-badge">Featured</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="furry-card-content">
|
||||||
|
<p>{{ product.description|truncatewords:20 }}</p>
|
||||||
|
<div class="furry-product-price">{{ product.price }}€</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="furry-card-footer">
|
||||||
|
<a href="{% url 'products:product_detail' product.id %}" class="furry-btn furry-btn-primary furry-btn-sm">
|
||||||
|
👁️ Details
|
||||||
|
</a>
|
||||||
|
<button class="furry-btn furry-btn-outline furry-btn-sm" onclick="addToCart({{ product.id }})">
|
||||||
|
🛒 In Warenkorb
|
||||||
|
</button>
|
||||||
|
<button class="furry-btn furry-btn-ghost furry-btn-sm" onclick="furryAjax.addToWishlist({{ product.id }})">
|
||||||
|
❤️ Zur Wunschliste
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="furry-card" style="grid-column: 1 / -1; text-align: center; padding: 3rem;">
|
||||||
|
<div style="font-size: 4rem; margin-bottom: 1rem;">🐾</div>
|
||||||
|
<h3>Keine Produkte gefunden</h3>
|
||||||
|
<p>Versuche andere Filter-Einstellungen oder schaue später wieder vorbei!</p>
|
||||||
|
<a href="{% url 'products:product_list' %}" class="furry-btn furry-btn-primary">
|
||||||
|
Alle Produkte anzeigen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if products.has_other_pages %}
|
||||||
|
<div class="furry-card" style="margin-top: 2rem; text-align: center;">
|
||||||
|
<div style="display: flex; justify-content: center; gap: 0.5rem; flex-wrap: wrap;">
|
||||||
|
{% if products.has_previous %}
|
||||||
|
<a href="?page={{ products.previous_page_number }}" class="furry-btn furry-btn-ghost furry-btn-sm">
|
||||||
|
← Zurück
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for num in products.paginator.page_range %}
|
||||||
|
{% if products.number == num %}
|
||||||
|
<span class="furry-btn furry-btn-primary furry-btn-sm">{{ num }}</span>
|
||||||
|
{% elif num > products.number|add:'-3' and num < products.number|add:'3' %}
|
||||||
|
<a href="?page={{ num }}" class="furry-btn furry-btn-ghost furry-btn-sm">{{ num }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if products.has_next %}
|
||||||
|
<a href="?page={{ products.next_page_number }}" class="furry-btn furry-btn-ghost furry-btn-sm">
|
||||||
|
Weiter →
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success/Error Messages -->
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="furry-alert furry-alert-{{ message.tags }}">
|
||||||
|
<div class="furry-alert-icon">
|
||||||
|
{% if message.tags == 'success' %}✅
|
||||||
|
{% elif message.tags == 'error' %}❌
|
||||||
|
{% elif message.tags == 'warning' %}⚠️
|
||||||
|
{% else %}ℹ️{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="furry-alert-content">
|
||||||
|
<div class="furry-alert-title">{{ message.tags|title }}</div>
|
||||||
|
<div class="furry-alert-message">{{ message }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div id="cart-feedback" class="cart-added-feedback">✔️ Zum Warenkorb hinzugefügt!</div>
|
||||||
|
<div id="furry-spinner" class="furry-spinner" style="display:none;">
|
||||||
|
<div></div><div></div><div></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function showCartFeedback() {
|
||||||
|
const feedback = document.getElementById('cart-feedback');
|
||||||
|
feedback.classList.add('show');
|
||||||
|
setTimeout(() => feedback.classList.remove('show'), 1500);
|
||||||
|
}
|
||||||
|
function showSpinner(show) {
|
||||||
|
document.getElementById('furry-spinner').style.display = show ? 'inline-block' : 'none';
|
||||||
|
}
|
||||||
|
window.addEventListener('DOMContentLoaded', function() {
|
||||||
|
document.querySelectorAll('.add-to-cart-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function(e) {
|
||||||
|
showSpinner(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
showSpinner(false);
|
||||||
|
showCartFeedback();
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}Profil{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="profile-card">
|
||||||
|
<h2>Willkommen, {{ user.username }}!</h2>
|
||||||
|
<div class="profile-info">
|
||||||
|
<p><strong>E-Mail:</strong> {{ user.email }}</p>
|
||||||
|
<p><strong>Mitglied seit:</strong> {{ user.date_joined|date:"d.m.Y" }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="profile-orders">
|
||||||
|
<h3>Bestellungen</h3>
|
||||||
|
{% if orders %}
|
||||||
|
<ul>
|
||||||
|
{% for order in orders %}
|
||||||
|
<li>Bestellung #{{ order.id }} vom {{ order.created|date:"d.m.Y" }} – {{ order.total_amount|floatformat:2 }} €</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p>Du hast noch keine Bestellungen.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<a href="/products/wishlist/" class="btn furry-btn-outline">Wunschliste ansehen</a>
|
||||||
|
<a href="/logout/" class="btn furry-btn-secondary">Logout</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,906 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Meine Wunschliste - Kasico Fursuit Shop{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="wishlist-container">
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<div class="wishlist-hero furry-card text-center mb-5">
|
||||||
|
<div class="wishlist-hero-content">
|
||||||
|
<h1 class="wishlist-title">❤️ Meine Wunschliste</h1>
|
||||||
|
<p class="wishlist-subtitle">Deine gesammelten Favoriten und Traum-Fursuits</p>
|
||||||
|
<div class="wishlist-stats">
|
||||||
|
<span class="wishlist-stat">{{ wishlist.products.count }} Produkte</span>
|
||||||
|
<span class="wishlist-stat">💕 Gespeichert</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages -->
|
||||||
|
{% if messages %}
|
||||||
|
<div class="messages-section mb-5">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="message-card message-{{ message.tags }}">
|
||||||
|
<div class="message-icon">
|
||||||
|
{% if message.tags == 'success' %}✅
|
||||||
|
{% elif message.tags == 'error' %}❌
|
||||||
|
{% elif message.tags == 'warning' %}⚠️
|
||||||
|
{% else %}ℹ️{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="message-content">
|
||||||
|
<p class="message-text">{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Wishlist Content -->
|
||||||
|
<div class="wishlist-content">
|
||||||
|
{% if wishlist.products.all %}
|
||||||
|
<!-- Filters and Actions -->
|
||||||
|
<div class="wishlist-controls furry-card mb-4">
|
||||||
|
<div class="controls-header">
|
||||||
|
<h3 class="controls-title">🎛️ Wunschliste verwalten</h3>
|
||||||
|
<div class="controls-actions">
|
||||||
|
<button class="btn furry-btn-outline" id="selectAllBtn">
|
||||||
|
📋 Alle auswählen
|
||||||
|
</button>
|
||||||
|
<button class="btn furry-btn-secondary" id="addSelectedToCartBtn">
|
||||||
|
🛒 Ausgewählte in Warenkorb
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filters-section">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label class="filter-label">🔍 Filter:</label>
|
||||||
|
<select class="filter-select" id="typeFilter">
|
||||||
|
<option value="">Alle Typen</option>
|
||||||
|
<option value="fullsuit">Fullsuit</option>
|
||||||
|
<option value="partial">Partial</option>
|
||||||
|
<option value="head">Head</option>
|
||||||
|
<option value="paws">Paws</option>
|
||||||
|
</select>
|
||||||
|
<select class="filter-select" id="styleFilter">
|
||||||
|
<option value="">Alle Styles</option>
|
||||||
|
<option value="realistic">Realistic</option>
|
||||||
|
<option value="cartoon">Cartoon</option>
|
||||||
|
<option value="toony">Toony</option>
|
||||||
|
<option value="kemono">Kemono</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="sort-group">
|
||||||
|
<label class="sort-label">📊 Sortieren:</label>
|
||||||
|
<select class="sort-select" id="sortSelect">
|
||||||
|
<option value="name">Name A-Z</option>
|
||||||
|
<option value="price-low">Preis: Niedrig → Hoch</option>
|
||||||
|
<option value="price-high">Preis: Hoch → Niedrig</option>
|
||||||
|
<option value="date-added">Hinzugefügt: Neueste</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Products Grid -->
|
||||||
|
<div class="wishlist-grid">
|
||||||
|
{% for product in wishlist.products.all %}
|
||||||
|
<div class="wishlist-item furry-card" data-type="{{ product.fursuit_type }}" data-style="{{ product.style }}" data-price="{{ product.price }}">
|
||||||
|
<div class="wishlist-item-header">
|
||||||
|
<div class="wishlist-checkbox">
|
||||||
|
<input type="checkbox" class="product-checkbox" id="product-{{ product.id }}" data-product-id="{{ product.id }}">
|
||||||
|
<label for="product-{{ product.id }}" class="checkbox-label"></label>
|
||||||
|
</div>
|
||||||
|
<button class="remove-btn" data-product-id="{{ product.id }}" title="Von Wunschliste entfernen">
|
||||||
|
❌
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wishlist-item-image">
|
||||||
|
{% if product.image %}
|
||||||
|
<img src="{{ product.image.url }}" alt="{{ product.name }}" class="product-image furry-lazy-image">
|
||||||
|
{% else %}
|
||||||
|
<img src="{% static 'images/placeholder.png' %}" alt="Placeholder" class="product-image">
|
||||||
|
{% endif %}
|
||||||
|
<div class="image-overlay">
|
||||||
|
<div class="overlay-buttons">
|
||||||
|
<a href="{% url 'products:product_detail' product.id %}" class="overlay-btn">
|
||||||
|
👁️ Details
|
||||||
|
</a>
|
||||||
|
<button class="overlay-btn add-to-cart-btn" data-product-id="{{ product.id }}">
|
||||||
|
🛒 In Warenkorb
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wishlist-item-content">
|
||||||
|
<h4 class="product-title">{{ product.name }}</h4>
|
||||||
|
<p class="product-description">{{ product.description|truncatewords:15 }}</p>
|
||||||
|
|
||||||
|
<div class="product-details">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">💰 Preis:</span>
|
||||||
|
<span class="detail-value price">{{ product.price }}€</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">🎭 Typ:</span>
|
||||||
|
<span class="detail-value">{{ product.get_fursuit_type_display }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">🎨 Style:</span>
|
||||||
|
<span class="detail-value">{{ product.get_style_display }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="product-actions">
|
||||||
|
<a href="{% url 'products:product_detail' product.id %}" class="btn furry-btn">
|
||||||
|
👁️ Details anzeigen
|
||||||
|
</a>
|
||||||
|
<button class="btn furry-btn-secondary add-to-cart-btn" data-product-id="{{ product.id }}">
|
||||||
|
🛒 In Warenkorb
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk Actions -->
|
||||||
|
<div class="bulk-actions furry-card mt-4" id="bulkActions" style="display: none;">
|
||||||
|
<div class="bulk-actions-content">
|
||||||
|
<span class="bulk-text">📦 <span id="selectedCount">0</span> Produkte ausgewählt</span>
|
||||||
|
<div class="bulk-buttons">
|
||||||
|
<button class="btn furry-btn" id="addSelectedToCart">
|
||||||
|
🛒 In Warenkorb legen
|
||||||
|
</button>
|
||||||
|
<button class="btn furry-btn-outline" id="removeSelected">
|
||||||
|
❌ Von Wunschliste entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div class="empty-wishlist furry-card text-center">
|
||||||
|
<div class="empty-icon">💔</div>
|
||||||
|
<h3 class="empty-title">Deine Wunschliste ist leer</h3>
|
||||||
|
<p class="empty-description">
|
||||||
|
Du hast noch keine Fursuits zu deiner Wunschliste hinzugefügt.
|
||||||
|
Entdecke unsere Kollektion und sammle deine Favoriten!
|
||||||
|
</p>
|
||||||
|
<div class="empty-actions">
|
||||||
|
<a href="{% url 'products:product_list' %}" class="btn furry-btn">
|
||||||
|
🛍️ Shop durchsuchen
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'products:gallery' %}" class="btn furry-btn-secondary">
|
||||||
|
🖼️ Galerie ansehen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary-color: #8B5CF6;
|
||||||
|
--secondary-color: #EC4899;
|
||||||
|
--accent-color: #F59E0B;
|
||||||
|
--dark-color: #1F2937;
|
||||||
|
--light-color: #F3E8FF;
|
||||||
|
--white-color: #FFFFFF;
|
||||||
|
--gradient-start: #8B5CF6;
|
||||||
|
--gradient-middle: #EC4899;
|
||||||
|
--success-color: #10B981;
|
||||||
|
--warning-color: #F59E0B;
|
||||||
|
--error-color: #EF4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-hero {
|
||||||
|
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-middle));
|
||||||
|
color: white;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-subtitle {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-stats {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-stat {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-success {
|
||||||
|
border-left: 4px solid var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-error {
|
||||||
|
border-left: 4px solid var(--error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-warning {
|
||||||
|
border-left: 4px solid var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-info {
|
||||||
|
border-left: 4px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
min-width: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-controls {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-title {
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group, .sort-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label, .sort-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dark-color);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select, .sort-select {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 2px solid var(--light-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: white;
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select:focus, .sort-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-item {
|
||||||
|
background: white;
|
||||||
|
border-radius: 15px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-item:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 8px 25px rgba(139, 92, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-item-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--light-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-checkbox {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-checkbox {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid var(--primary-color);
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-checkbox:checked + .checkbox-label {
|
||||||
|
background: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-checkbox:checked + .checkbox-label::after {
|
||||||
|
content: '✓';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn:hover {
|
||||||
|
background: var(--error-color);
|
||||||
|
color: white;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-item-image {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-item:hover .product-image {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(139, 92, 246, 0.9);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-item:hover .image-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-btn {
|
||||||
|
background: white;
|
||||||
|
color: var(--primary-color);
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-btn:hover {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-item-content {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-title {
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-description {
|
||||||
|
color: var(--dark-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-details {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--light-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dark-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-actions {
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--light-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-actions-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-text {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dark-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-wishlist {
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-description {
|
||||||
|
color: var(--dark-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 500px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn {
|
||||||
|
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-middle));
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 25px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.3);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn-secondary {
|
||||||
|
background: var(--secondary-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 25px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn-secondary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(236, 72, 153, 0.3);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn-outline {
|
||||||
|
background: white;
|
||||||
|
color: var(--primary-color);
|
||||||
|
border: 2px solid var(--primary-color);
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 25px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-btn-outline:hover {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.wishlist-container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-stats {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-stat {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-header, .filters-section {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-actions-content {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation für Wishlist Items */
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-item {
|
||||||
|
animation: fadeInUp 0.6s ease-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const checkboxes = document.querySelectorAll('.product-checkbox');
|
||||||
|
const selectAllBtn = document.getElementById('selectAllBtn');
|
||||||
|
const bulkActions = document.getElementById('bulkActions');
|
||||||
|
const selectedCount = document.getElementById('selectedCount');
|
||||||
|
const addSelectedToCartBtn = document.getElementById('addSelectedToCartBtn');
|
||||||
|
const removeSelectedBtn = document.getElementById('removeSelected');
|
||||||
|
const removeButtons = document.querySelectorAll('.remove-btn');
|
||||||
|
const addToCartButtons = document.querySelectorAll('.add-to-cart-btn');
|
||||||
|
const typeFilter = document.getElementById('typeFilter');
|
||||||
|
const styleFilter = document.getElementById('styleFilter');
|
||||||
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
|
const wishlistItems = document.querySelectorAll('.wishlist-item');
|
||||||
|
|
||||||
|
// Checkbox functionality
|
||||||
|
function updateBulkActions() {
|
||||||
|
const checkedBoxes = document.querySelectorAll('.product-checkbox:checked');
|
||||||
|
const totalBoxes = document.querySelectorAll('.product-checkbox');
|
||||||
|
|
||||||
|
if (checkedBoxes.length > 0) {
|
||||||
|
bulkActions.style.display = 'block';
|
||||||
|
selectedCount.textContent = checkedBoxes.length;
|
||||||
|
|
||||||
|
if (checkedBoxes.length === totalBoxes.length) {
|
||||||
|
selectAllBtn.textContent = '📋 Auswahl aufheben';
|
||||||
|
} else {
|
||||||
|
selectAllBtn.textContent = '📋 Alle auswählen';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bulkActions.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkboxes.forEach(checkbox => {
|
||||||
|
checkbox.addEventListener('change', updateBulkActions);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select all functionality
|
||||||
|
selectAllBtn.addEventListener('click', function() {
|
||||||
|
const allChecked = document.querySelectorAll('.product-checkbox:checked').length === checkboxes.length;
|
||||||
|
|
||||||
|
checkboxes.forEach(checkbox => {
|
||||||
|
checkbox.checked = !allChecked;
|
||||||
|
});
|
||||||
|
|
||||||
|
updateBulkActions();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove from wishlist
|
||||||
|
removeButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const productId = this.dataset.productId;
|
||||||
|
if (confirm('Möchtest du dieses Produkt wirklich von deiner Wunschliste entfernen?')) {
|
||||||
|
fetch(`/products/remove-from-wishlist/${productId}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
this.closest('.wishlist-item').style.animation = 'fadeOut 0.3s ease-out';
|
||||||
|
setTimeout(() => {
|
||||||
|
this.closest('.wishlist-item').remove();
|
||||||
|
updateBulkActions();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error:', error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to cart functionality
|
||||||
|
addToCartButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const productId = this.dataset.productId;
|
||||||
|
|
||||||
|
fetch('/products/add-to-cart/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
product_id: productId,
|
||||||
|
quantity: 1
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
// Show success animation
|
||||||
|
this.innerHTML = '✅ Hinzugefügt!';
|
||||||
|
this.style.background = 'var(--success-color)';
|
||||||
|
setTimeout(() => {
|
||||||
|
this.innerHTML = '🛒 In Warenkorb';
|
||||||
|
this.style.background = '';
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error:', error));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter functionality
|
||||||
|
function filterItems() {
|
||||||
|
const selectedType = typeFilter.value;
|
||||||
|
const selectedStyle = styleFilter.value;
|
||||||
|
|
||||||
|
wishlistItems.forEach(item => {
|
||||||
|
const itemType = item.dataset.type;
|
||||||
|
const itemStyle = item.dataset.style;
|
||||||
|
|
||||||
|
const typeMatch = !selectedType || itemType === selectedType;
|
||||||
|
const styleMatch = !selectedStyle || itemStyle === selectedStyle;
|
||||||
|
|
||||||
|
if (typeMatch && styleMatch) {
|
||||||
|
item.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
item.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
typeFilter.addEventListener('change', filterItems);
|
||||||
|
styleFilter.addEventListener('change', filterItems);
|
||||||
|
|
||||||
|
// Sort functionality
|
||||||
|
sortSelect.addEventListener('change', function() {
|
||||||
|
const sortBy = this.value;
|
||||||
|
const items = Array.from(wishlistItems);
|
||||||
|
|
||||||
|
items.sort((a, b) => {
|
||||||
|
switch(sortBy) {
|
||||||
|
case 'name':
|
||||||
|
return a.querySelector('.product-title').textContent.localeCompare(b.querySelector('.product-title').textContent);
|
||||||
|
case 'price-low':
|
||||||
|
return parseFloat(a.dataset.price) - parseFloat(b.dataset.price);
|
||||||
|
case 'price-high':
|
||||||
|
return parseFloat(b.dataset.price) - parseFloat(a.dataset.price);
|
||||||
|
case 'date-added':
|
||||||
|
// Assuming items are already in order of addition
|
||||||
|
return 0;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const grid = document.querySelector('.wishlist-grid');
|
||||||
|
items.forEach(item => grid.appendChild(item));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bulk actions
|
||||||
|
addSelectedToCartBtn.addEventListener('click', function() {
|
||||||
|
const selectedProducts = document.querySelectorAll('.product-checkbox:checked');
|
||||||
|
selectedProducts.forEach(checkbox => {
|
||||||
|
const productId = checkbox.dataset.productId;
|
||||||
|
const addToCartBtn = document.querySelector(`.add-to-cart-btn[data-product-id="${productId}"]`);
|
||||||
|
addToCartBtn.click();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
removeSelectedBtn.addEventListener('click', function() {
|
||||||
|
if (confirm('Möchtest du alle ausgewählten Produkte von deiner Wunschliste entfernen?')) {
|
||||||
|
const selectedProducts = document.querySelectorAll('.product-checkbox:checked');
|
||||||
|
selectedProducts.forEach(checkbox => {
|
||||||
|
const productId = checkbox.dataset.productId;
|
||||||
|
const removeBtn = document.querySelector(`.remove-btn[data-product-id="${productId}"]`);
|
||||||
|
removeBtn.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Fade out animation */
|
||||||
|
@keyframes fadeOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Registrieren - {{ block.super }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="content-container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="furry-card">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<img src="{% static 'images/kasicoLogo.png' %}"
|
||||||
|
alt="Kasico Art & Design Logo"
|
||||||
|
class="img-fluid mb-4"
|
||||||
|
style="max-width: 150px; height: auto;">
|
||||||
|
<h1 class="h3 mb-3">🐾 Registrieren</h1>
|
||||||
|
<p class="text-muted">Werde Teil der Furry-Community!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="needs-validation furry-form" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for field in form %}
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="{{ field.id_for_label }}" class="form-label">
|
||||||
|
{{ field.label }}
|
||||||
|
</label>
|
||||||
|
{{ field }}
|
||||||
|
{% if field.help_text %}
|
||||||
|
<div class="form-text">{{ field.help_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if field.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for error in field.errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary furry-btn">
|
||||||
|
🐾 Registrieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<p>Bereits ein Konto? <a href="{% url 'login' %}">Jetzt anmelden</a></p>
|
||||||
|
<p><a href="{% url 'password_reset' %}">Passwort vergessen?</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary-color: #8B5CF6; /* Helles Lila */
|
||||||
|
--secondary-color: #EC4899; /* Pink */
|
||||||
|
--accent-color: #F59E0B; /* Orange */
|
||||||
|
--dark-color: #1F2937; /* Dunkelgrau */
|
||||||
|
--light-color: #F3E8FF; /* Helles Lila */
|
||||||
|
--white-color: #FFFFFF;
|
||||||
|
--gradient-start: #8B5CF6;
|
||||||
|
--gradient-middle: #EC4899;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: 0 8px 32px rgba(139, 92, 246, 0.1);
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-form input, .furry-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2px solid var(--light-color);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-form input:focus, .furry-form textarea:focus {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(139, 92, 246, 0.25);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-form input.invalid, .furry-form textarea.invalid {
|
||||||
|
border-color: #EF4444;
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(239, 68, 68, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-form input.valid, .furry-form textarea.valid {
|
||||||
|
border-color: #10B981;
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(16, 185, 129, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-middle));
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 50px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--dark-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-danger {
|
||||||
|
background: linear-gradient(135deg, #FEE2E2, #FECACA);
|
||||||
|
border: 2px solid #EF4444;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #991B1B;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invalid-feedback {
|
||||||
|
color: #EF4444;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation für erfolgreiche Registrierung */
|
||||||
|
@keyframes success-bounce {
|
||||||
|
0%, 20%, 53%, 80%, 100% {
|
||||||
|
transform: translate3d(0,0,0);
|
||||||
|
}
|
||||||
|
40%, 43% {
|
||||||
|
transform: translate3d(0, -30px, 0);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: translate3d(0, -15px, 0);
|
||||||
|
}
|
||||||
|
90% {
|
||||||
|
transform: translate3d(0, -4px, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-animation {
|
||||||
|
animation: success-bounce 1s ease-in-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
// Formular-Validierung
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const form = document.querySelector('.furry-form');
|
||||||
|
const inputs = form.querySelectorAll('input, textarea');
|
||||||
|
|
||||||
|
inputs.forEach(input => {
|
||||||
|
input.addEventListener('blur', function() {
|
||||||
|
validateField(this);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('input', function() {
|
||||||
|
if (this.classList.contains('invalid')) {
|
||||||
|
validateField(this);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function validateField(field) {
|
||||||
|
if (field.checkValidity()) {
|
||||||
|
field.classList.remove('invalid');
|
||||||
|
field.classList.add('valid');
|
||||||
|
} else {
|
||||||
|
field.classList.remove('valid');
|
||||||
|
field.classList.add('invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erfolgreiche Registrierung Animation
|
||||||
|
// {% if form.is_valid and not form.errors %}
|
||||||
|
// document.querySelector('.furry-card').classList.add('success-animation');
|
||||||
|
// {% endif %}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Formular neu laden, wenn der Benutzer zurück navigiert
|
||||||
|
window.addEventListener('pageshow', function(event) {
|
||||||
|
if (event.persisted) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
from django import template
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def multiply(value, arg):
|
||||||
|
try:
|
||||||
|
return int(value) * int(arg)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return ''
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
from django.urls import path
|
||||||
|
from .views import (
|
||||||
|
ProductListView, ProductDetailView,
|
||||||
|
add_to_cart, cart_detail, update_cart_item,
|
||||||
|
remove_from_cart, create_order, checkout,
|
||||||
|
add_review, profile_view, order_history,
|
||||||
|
wishlist_view, add_to_wishlist, remove_from_wishlist,
|
||||||
|
faq_list, contact, contact_success,
|
||||||
|
custom_order, custom_order_success,
|
||||||
|
custom_order_detail, gallery,
|
||||||
|
add_progress_update,
|
||||||
|
payment_process,
|
||||||
|
payment_success,
|
||||||
|
payment_failed,
|
||||||
|
register
|
||||||
|
)
|
||||||
|
from . import sitemap_views
|
||||||
|
|
||||||
|
app_name = 'products'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', ProductListView.as_view(), name='product_list'),
|
||||||
|
path('product/<int:pk>/', ProductDetailView.as_view(), name='product_detail'),
|
||||||
|
path('cart/add/<int:product_id>/', add_to_cart, name='add_to_cart'),
|
||||||
|
path('cart/', cart_detail, name='cart_detail'),
|
||||||
|
path('cart/update/<int:item_id>/', update_cart_item, name='update_cart_item'),
|
||||||
|
path('cart/remove/<int:item_id>/', remove_from_cart, name='remove_from_cart'),
|
||||||
|
path('order/create/', create_order, name='create_order'),
|
||||||
|
path('checkout/<int:order_id>/', checkout, name='checkout'),
|
||||||
|
path('product/<int:product_id>/review/', add_review, name='add_review'),
|
||||||
|
path('profile/', profile_view, name='profile'),
|
||||||
|
path('orders/', order_history, name='order_history'),
|
||||||
|
path('wishlist/', wishlist_view, name='wishlist'),
|
||||||
|
path('wishlist/add/<int:product_id>/', add_to_wishlist, name='add_to_wishlist'),
|
||||||
|
path('wishlist/remove/<int:product_id>/', remove_from_wishlist, name='remove_from_wishlist'),
|
||||||
|
path('faq/', faq_list, name='faq'),
|
||||||
|
path('contact/', contact, name='contact'),
|
||||||
|
path('contact/success/', contact_success, name='contact_success'),
|
||||||
|
path('custom-order/', custom_order, name='custom_order'),
|
||||||
|
path('custom-order/success/<int:order_id>/', custom_order_success, name='custom_order_success'),
|
||||||
|
path('custom-orders/<int:order_id>/', custom_order_detail, name='custom_order_detail'),
|
||||||
|
path('custom-orders/<int:order_id>/update/', add_progress_update, name='add_progress_update'),
|
||||||
|
path('gallery/', gallery, name='gallery'),
|
||||||
|
path('payment/process/<int:order_id>/', payment_process, name='payment_process'),
|
||||||
|
path('payment/success/<int:order_id>/', payment_success, name='payment_success'),
|
||||||
|
path('payment/failed/<int:order_id>/', payment_failed, name='payment_failed'),
|
||||||
|
path('register/', register, name='register'),
|
||||||
|
path('sitemap.xml', sitemap_views.sitemap_xml, name='sitemap_xml'),
|
||||||
|
path('robots.txt', sitemap_views.robots_txt, name='robots_txt'),
|
||||||
|
path('manifest.json', sitemap_views.manifest_json, name='manifest_json'),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,838 @@
|
||||||
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
|
from django.views.generic import ListView, DetailView
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth import login
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.db.models import Q, Avg, Count
|
||||||
|
from django.db import transaction
|
||||||
|
# from rest_framework import viewsets # Temporär auskommentiert
|
||||||
|
# from rest_framework.permissions import IsAuthenticatedOrReadOnly # Temporär auskommentiert
|
||||||
|
from .models import Product, Cart, CartItem, Order, OrderItem, Review, UserProfile, Wishlist, FAQ, ContactMessage, CustomOrder, OrderProgress, GalleryImage, Category
|
||||||
|
from .forms import OrderForm, CustomUserCreationForm, CustomAuthenticationForm, ReviewForm, UserProfileForm, ContactForm, CustomOrderForm, OrderProgressForm
|
||||||
|
# from .serializers import ProductSerializer, ReviewSerializer # Temporär auskommentiert
|
||||||
|
import stripe
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.core.paginator import Paginator, PageNotAnInteger, EmptyPage
|
||||||
|
import json
|
||||||
|
# import paypalrestsdk # Temporär auskommentiert
|
||||||
|
# from payments import get_payment_model, RedirectNeeded # Temporär auskommentiert
|
||||||
|
# from paypal.standard.ipn.models import PayPalIPN # Temporär auskommentiert
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.http import HttpResponse
|
||||||
|
# from paypal.standard.forms import PayPalPaymentsForm # Temporär auskommentiert
|
||||||
|
import logging
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.http import Http404
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
|
|
||||||
|
class ProductListView(ListView):
|
||||||
|
model = Product
|
||||||
|
template_name = 'products/product_list.html'
|
||||||
|
context_object_name = 'products'
|
||||||
|
paginate_by = 12
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
try:
|
||||||
|
queryset = Product.objects.all().annotate(
|
||||||
|
average_rating=Avg('reviews__rating'),
|
||||||
|
review_count=Count('reviews')
|
||||||
|
).select_related('category').prefetch_related(
|
||||||
|
'reviews__user', # Optimiert N+1 Queries für Reviews
|
||||||
|
'wishlist_users' # Optimiert Wishlist-Queries
|
||||||
|
)
|
||||||
|
|
||||||
|
# Erweiterte Suchfilter
|
||||||
|
search_query = self.request.GET.get('search')
|
||||||
|
if search_query:
|
||||||
|
# Verbesserte Suche mit Q-Objekten
|
||||||
|
search_terms = search_query.split()
|
||||||
|
q_objects = Q()
|
||||||
|
for term in search_terms:
|
||||||
|
q_objects |= (
|
||||||
|
Q(name__icontains=term) |
|
||||||
|
Q(description__icontains=term) |
|
||||||
|
Q(fursuit_type__icontains=term) |
|
||||||
|
Q(style__icontains=term) |
|
||||||
|
Q(category__name__icontains=term)
|
||||||
|
)
|
||||||
|
queryset = queryset.filter(q_objects)
|
||||||
|
|
||||||
|
# Preisbereich Filter mit Validierung
|
||||||
|
min_price = self.request.GET.get('min_price')
|
||||||
|
max_price = self.request.GET.get('max_price')
|
||||||
|
if min_price and min_price.isdigit():
|
||||||
|
queryset = queryset.filter(price__gte=float(min_price))
|
||||||
|
if max_price and max_price.isdigit():
|
||||||
|
queryset = queryset.filter(price__lte=float(max_price))
|
||||||
|
|
||||||
|
# Fursuit-Typ Filter
|
||||||
|
fursuit_type = self.request.GET.get('fursuit_type')
|
||||||
|
if fursuit_type:
|
||||||
|
queryset = queryset.filter(fursuit_type=fursuit_type)
|
||||||
|
|
||||||
|
# Style Filter
|
||||||
|
style = self.request.GET.get('style')
|
||||||
|
if style:
|
||||||
|
queryset = queryset.filter(style=style)
|
||||||
|
|
||||||
|
# Kategorie Filter
|
||||||
|
category = self.request.GET.get('category')
|
||||||
|
if category:
|
||||||
|
queryset = queryset.filter(category__slug=category)
|
||||||
|
|
||||||
|
# Verfügbarkeit Filter
|
||||||
|
availability = self.request.GET.get('availability')
|
||||||
|
if availability == 'in_stock':
|
||||||
|
queryset = queryset.filter(stock__gt=0)
|
||||||
|
elif availability == 'low_stock':
|
||||||
|
queryset = queryset.filter(stock__lte=5, stock__gt=0)
|
||||||
|
elif availability == 'out_of_stock':
|
||||||
|
queryset = queryset.filter(stock=0)
|
||||||
|
|
||||||
|
# Sortierung mit verbesserter Performance
|
||||||
|
sort = self.request.GET.get('sort', '-created')
|
||||||
|
if sort == 'price':
|
||||||
|
queryset = queryset.order_by('price')
|
||||||
|
elif sort == '-price':
|
||||||
|
queryset = queryset.order_by('-price')
|
||||||
|
elif sort == 'name':
|
||||||
|
queryset = queryset.order_by('name')
|
||||||
|
elif sort == '-name':
|
||||||
|
queryset = queryset.order_by('-name')
|
||||||
|
elif sort == '-rating':
|
||||||
|
queryset = queryset.order_by('-average_rating')
|
||||||
|
elif sort == 'popularity':
|
||||||
|
queryset = queryset.order_by('-review_count')
|
||||||
|
else: # Default: Neueste zuerst
|
||||||
|
queryset = queryset.order_by('-created')
|
||||||
|
|
||||||
|
return queryset.distinct()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in ProductListView.get_queryset: {e}")
|
||||||
|
return Product.objects.none()
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
try:
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
# Zusätzliche Context-Daten
|
||||||
|
context['categories'] = Category.objects.all()
|
||||||
|
context['fursuit_types'] = Product.FURSUIT_TYPE_CHOICES
|
||||||
|
context['styles'] = Product.STYLE_CHOICES
|
||||||
|
return context
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in ProductListView.get_context_data: {e}")
|
||||||
|
return super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
class ProductDetailView(DetailView):
|
||||||
|
model = Product
|
||||||
|
template_name = 'products/product_detail.html'
|
||||||
|
context_object_name = 'product'
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
if self.request.user.is_authenticated:
|
||||||
|
context['user_has_reviewed'] = Review.objects.filter(
|
||||||
|
product=self.object,
|
||||||
|
user=self.request.user
|
||||||
|
).exists()
|
||||||
|
context['review_form'] = ReviewForm()
|
||||||
|
return context
|
||||||
|
|
||||||
|
def get_or_create_cart(request):
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
cart, created = Cart.objects.get_or_create(user=request.user)
|
||||||
|
else:
|
||||||
|
session_key = request.session.session_key
|
||||||
|
if not session_key:
|
||||||
|
request.session.create()
|
||||||
|
session_key = request.session.session_key
|
||||||
|
cart, created = Cart.objects.get_or_create(session_id=session_key)
|
||||||
|
return cart
|
||||||
|
|
||||||
|
def add_to_cart(request, product_id):
|
||||||
|
if request.method == 'POST':
|
||||||
|
product = get_object_or_404(Product, id=product_id)
|
||||||
|
quantity = int(request.POST.get('quantity', 1))
|
||||||
|
|
||||||
|
if quantity > product.stock:
|
||||||
|
messages.error(request, 'Nicht genügend Artikel auf Lager!')
|
||||||
|
return redirect('products:product_detail', pk=product_id)
|
||||||
|
|
||||||
|
cart = get_or_create_cart(request)
|
||||||
|
cart_item, created = CartItem.objects.get_or_create(
|
||||||
|
cart=cart,
|
||||||
|
product=product,
|
||||||
|
defaults={'quantity': quantity}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not created:
|
||||||
|
cart_item.quantity += quantity
|
||||||
|
cart_item.save()
|
||||||
|
|
||||||
|
messages.success(request, f'{quantity}x {product.name} wurde zum Warenkorb hinzugefügt.')
|
||||||
|
return redirect('products:cart_detail')
|
||||||
|
|
||||||
|
def cart_detail(request):
|
||||||
|
cart = get_or_create_cart(request)
|
||||||
|
return render(request, 'products/cart_detail.html', {'cart': cart})
|
||||||
|
|
||||||
|
def create_order(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
cart = get_or_create_cart(request)
|
||||||
|
if not cart.items.exists():
|
||||||
|
messages.error(request, 'Ihr Warenkorb ist leer.')
|
||||||
|
return redirect('products:cart_detail')
|
||||||
|
|
||||||
|
# Erstelle eine neue Bestellung
|
||||||
|
order = Order.objects.create(
|
||||||
|
user=request.user if request.user.is_authenticated else None,
|
||||||
|
full_name=f"{request.user.first_name} {request.user.last_name}" if request.user.is_authenticated else "",
|
||||||
|
email=request.user.email if request.user.is_authenticated else "",
|
||||||
|
total_amount=cart.get_total()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Füge die Artikel aus dem Warenkorb zur Bestellung hinzu
|
||||||
|
for cart_item in cart.items.all():
|
||||||
|
OrderItem.objects.create(
|
||||||
|
order=order,
|
||||||
|
product=cart_item.product,
|
||||||
|
product_name=cart_item.product.name,
|
||||||
|
quantity=cart_item.quantity,
|
||||||
|
price=cart_item.product.price
|
||||||
|
)
|
||||||
|
|
||||||
|
# Leere den Warenkorb
|
||||||
|
cart.items.all().delete()
|
||||||
|
|
||||||
|
messages.success(request, 'Ihre Bestellung wurde erfolgreich erstellt.')
|
||||||
|
return redirect('products:checkout', order_id=order.id)
|
||||||
|
|
||||||
|
return redirect('products:cart_detail')
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def checkout(request, order_id):
|
||||||
|
order = get_object_or_404(Order, id=order_id)
|
||||||
|
if request.user.is_authenticated and order.user != request.user:
|
||||||
|
messages.error(request, 'Sie haben keine Berechtigung, diese Bestellung einzusehen.')
|
||||||
|
return redirect('products:product_list')
|
||||||
|
|
||||||
|
# PayPal Zahlungsformular erstellen
|
||||||
|
host = request.get_host()
|
||||||
|
|
||||||
|
paypal_dict = {
|
||||||
|
"business": settings.PAYPAL_RECEIVER_EMAIL,
|
||||||
|
"amount": str(order.total_amount),
|
||||||
|
"item_name": f"Bestellung #{order.id}",
|
||||||
|
"invoice": str(order.id),
|
||||||
|
"currency_code": "EUR",
|
||||||
|
"notify_url": request.build_absolute_uri('/paypal/'),
|
||||||
|
"return_url": request.build_absolute_uri(f'/products/payment/success/{order.id}/'),
|
||||||
|
"cancel_return": request.build_absolute_uri(f'/products/payment/failed/{order.id}/'),
|
||||||
|
}
|
||||||
|
|
||||||
|
form = PayPalPaymentsForm(initial=paypal_dict)
|
||||||
|
return render(request, 'products/checkout.html', {
|
||||||
|
'order': order,
|
||||||
|
'form': form
|
||||||
|
})
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@login_required
|
||||||
|
def create_paypal_order(request):
|
||||||
|
if request.method != 'POST':
|
||||||
|
return JsonResponse({'error': 'Nur POST-Anfragen erlaubt'}, status=405)
|
||||||
|
|
||||||
|
data = json.loads(request.body)
|
||||||
|
order = get_object_or_404(Order, id=data['order_id'], user=request.user)
|
||||||
|
|
||||||
|
payment = paypalrestsdk.Payment({
|
||||||
|
"intent": "sale",
|
||||||
|
"payer": {
|
||||||
|
"payment_method": "paypal"
|
||||||
|
},
|
||||||
|
"transactions": [{
|
||||||
|
"amount": {
|
||||||
|
"total": str(order.total_amount),
|
||||||
|
"currency": "EUR"
|
||||||
|
},
|
||||||
|
"description": f"Bestellung #{order.id}"
|
||||||
|
}],
|
||||||
|
"redirect_urls": {
|
||||||
|
"return_url": f"{settings.SITE_URL}/products/order/confirmation/{order.id}/",
|
||||||
|
"cancel_url": f"{settings.SITE_URL}/products/checkout/{order.id}/"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if payment.create():
|
||||||
|
return JsonResponse({'paypal_order_id': payment.id})
|
||||||
|
else:
|
||||||
|
return JsonResponse({'error': payment.error}, status=400)
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@login_required
|
||||||
|
def capture_paypal_order(request):
|
||||||
|
if request.method != 'POST':
|
||||||
|
return JsonResponse({'error': 'Nur POST-Anfragen erlaubt'}, status=405)
|
||||||
|
|
||||||
|
data = json.loads(request.body)
|
||||||
|
order = get_object_or_404(Order, id=data['order_id'], user=request.user)
|
||||||
|
payment_id = data['paypal_order_id']
|
||||||
|
|
||||||
|
payment = paypalrestsdk.Payment.find(payment_id)
|
||||||
|
|
||||||
|
if payment.execute({'payer_id': payment.payer.payer_info.payer_id}):
|
||||||
|
order.status = 'paid'
|
||||||
|
order.payment_id = payment_id
|
||||||
|
order.save()
|
||||||
|
|
||||||
|
# E-Mail-Bestätigung senden
|
||||||
|
send_payment_confirmation_email(order)
|
||||||
|
|
||||||
|
return JsonResponse({'success': True})
|
||||||
|
else:
|
||||||
|
return JsonResponse({'error': payment.error}, status=400)
|
||||||
|
|
||||||
|
def order_confirmation(request, order_id):
|
||||||
|
order = get_object_or_404(Order, id=order_id)
|
||||||
|
if request.user.is_authenticated and order.user != request.user:
|
||||||
|
messages.error(request, 'Sie haben keine Berechtigung, diese Bestellung einzusehen.')
|
||||||
|
return redirect('products:product_list')
|
||||||
|
|
||||||
|
return render(request, 'products/order_confirmation.html', {'order': order})
|
||||||
|
|
||||||
|
def update_cart_item(request, item_id):
|
||||||
|
if request.method == 'POST':
|
||||||
|
cart_item = get_object_or_404(CartItem, id=item_id)
|
||||||
|
quantity = int(request.POST.get('quantity', 1))
|
||||||
|
|
||||||
|
if quantity > cart_item.product.stock:
|
||||||
|
messages.error(request, 'Nicht genügend Artikel auf Lager!')
|
||||||
|
elif quantity > 0:
|
||||||
|
cart_item.quantity = quantity
|
||||||
|
cart_item.save()
|
||||||
|
messages.success(request, 'Warenkorb wurde aktualisiert.')
|
||||||
|
else:
|
||||||
|
cart_item.delete()
|
||||||
|
messages.success(request, 'Artikel wurde aus dem Warenkorb entfernt.')
|
||||||
|
|
||||||
|
return redirect('products:cart_detail')
|
||||||
|
|
||||||
|
def remove_from_cart(request, item_id):
|
||||||
|
if request.method == 'POST':
|
||||||
|
cart_item = get_object_or_404(CartItem, id=item_id)
|
||||||
|
cart_item.delete()
|
||||||
|
messages.success(request, 'Artikel wurde aus dem Warenkorb entfernt.')
|
||||||
|
return redirect('products:cart_detail')
|
||||||
|
|
||||||
|
def register(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = CustomUserCreationForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
user = form.save()
|
||||||
|
login(request, user)
|
||||||
|
messages.success(request, 'Registrierung erfolgreich!')
|
||||||
|
return redirect('products:product_list')
|
||||||
|
else:
|
||||||
|
form = CustomUserCreationForm()
|
||||||
|
return render(request, 'registration/register.html', {'form': form})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def add_review(request, product_id):
|
||||||
|
product = get_object_or_404(Product, id=product_id)
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = ReviewForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
review = form.save(commit=False)
|
||||||
|
review.product = product
|
||||||
|
review.user = request.user
|
||||||
|
review.save()
|
||||||
|
messages.success(request, 'Ihre Bewertung wurde erfolgreich hinzugefügt.')
|
||||||
|
else:
|
||||||
|
messages.error(request, 'Es gab einen Fehler beim Hinzufügen Ihrer Bewertung.')
|
||||||
|
return redirect('product_detail', pk=product_id)
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def profile_view(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = UserProfileForm(request.POST, instance=request.user.userprofile)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, 'Profil wurde erfolgreich aktualisiert.')
|
||||||
|
return redirect('products:profile')
|
||||||
|
else:
|
||||||
|
form = UserProfileForm(instance=request.user.userprofile)
|
||||||
|
|
||||||
|
return render(request, 'products/profile.html', {'form': form})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def order_history(request):
|
||||||
|
orders = Order.objects.filter(user=request.user).order_by('-created')
|
||||||
|
return render(request, 'products/order_history.html', {'orders': orders})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def wishlist_view(request):
|
||||||
|
wishlist, created = Wishlist.objects.get_or_create(user=request.user)
|
||||||
|
return render(request, 'products/wishlist.html', {'wishlist': wishlist})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def add_to_wishlist(request, product_id):
|
||||||
|
product = get_object_or_404(Product, id=product_id)
|
||||||
|
wishlist, created = Wishlist.objects.get_or_create(user=request.user)
|
||||||
|
wishlist.products.add(product)
|
||||||
|
messages.success(request, f'{product.name} wurde zu Ihrer Wunschliste hinzugefügt.')
|
||||||
|
return redirect('product_detail', product_id=product_id)
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def remove_from_wishlist(request, product_id):
|
||||||
|
product = get_object_or_404(Product, id=product_id)
|
||||||
|
wishlist = get_object_or_404(Wishlist, user=request.user)
|
||||||
|
wishlist.products.remove(product)
|
||||||
|
messages.success(request, f'{product.name} wurde von Ihrer Wunschliste entfernt.')
|
||||||
|
return redirect('wishlist')
|
||||||
|
|
||||||
|
def faq_list(request):
|
||||||
|
faqs = FAQ.objects.all()
|
||||||
|
categories = FAQ.objects.values_list('category', flat=True).distinct()
|
||||||
|
return render(request, 'products/faq.html', {
|
||||||
|
'faqs': faqs,
|
||||||
|
'categories': categories
|
||||||
|
})
|
||||||
|
|
||||||
|
def contact(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = ContactForm(request.POST, user=request.user)
|
||||||
|
if form.is_valid():
|
||||||
|
contact_message = form.save(commit=False)
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
contact_message.user = request.user
|
||||||
|
contact_message.save()
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
'Vielen Dank für Ihre Nachricht! Wir werden uns schnellstmöglich bei Ihnen melden.'
|
||||||
|
)
|
||||||
|
return redirect('contact_success')
|
||||||
|
else:
|
||||||
|
form = ContactForm(user=request.user)
|
||||||
|
|
||||||
|
return render(request, 'products/contact.html', {'form': form})
|
||||||
|
|
||||||
|
def contact_success(request):
|
||||||
|
return render(request, 'products/contact_success.html')
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def custom_order(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = CustomOrderForm(request.POST, request.FILES)
|
||||||
|
if form.is_valid():
|
||||||
|
custom_order = form.save(commit=False)
|
||||||
|
custom_order.user = request.user
|
||||||
|
custom_order.save()
|
||||||
|
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
'Ihre Anfrage wurde erfolgreich übermittelt. Wir werden uns in Kürze mit einem Angebot bei Ihnen melden.'
|
||||||
|
)
|
||||||
|
return redirect('custom_order_success', order_id=custom_order.id)
|
||||||
|
else:
|
||||||
|
form = CustomOrderForm()
|
||||||
|
|
||||||
|
return render(request, 'products/custom_order.html', {
|
||||||
|
'form': form
|
||||||
|
})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def custom_order_success(request, order_id):
|
||||||
|
order = get_object_or_404(CustomOrder, id=order_id, user=request.user)
|
||||||
|
return render(request, 'products/custom_order_success.html', {
|
||||||
|
'order': order
|
||||||
|
})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def custom_order_detail(request, order_id):
|
||||||
|
order = get_object_or_404(CustomOrder, id=order_id, user=request.user)
|
||||||
|
progress_updates = order.progress_updates.all()
|
||||||
|
return render(request, 'products/custom_order_detail.html', {
|
||||||
|
'order': order,
|
||||||
|
'progress_updates': progress_updates
|
||||||
|
})
|
||||||
|
|
||||||
|
def gallery(request):
|
||||||
|
# Hole alle Galeriebilder
|
||||||
|
images = GalleryImage.objects.all().order_by('order', '-created')
|
||||||
|
|
||||||
|
# Filter nach Typ
|
||||||
|
fursuit_type = request.GET.get('fursuit_type')
|
||||||
|
if fursuit_type:
|
||||||
|
images = images.filter(fursuit_type=fursuit_type)
|
||||||
|
|
||||||
|
# Filter nach Stil
|
||||||
|
style = request.GET.get('style')
|
||||||
|
if style:
|
||||||
|
images = images.filter(style=style)
|
||||||
|
|
||||||
|
# Sortierung
|
||||||
|
sort = request.GET.get('sort', 'order')
|
||||||
|
if sort == 'newest':
|
||||||
|
images = images.order_by('-created')
|
||||||
|
elif sort == 'oldest':
|
||||||
|
images = images.order_by('created')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'images': images,
|
||||||
|
'fursuit_types': GalleryImage.FURSUIT_TYPE_CHOICES,
|
||||||
|
'fursuit_styles': GalleryImage.STYLE_CHOICES,
|
||||||
|
'current_type': fursuit_type,
|
||||||
|
'current_style': style,
|
||||||
|
'current_sort': sort
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"Anzahl der geladenen Bilder: {images.count()}") # Debug-Ausgabe
|
||||||
|
for img in images:
|
||||||
|
print(f"Bild: {img.title}, URL: {img.image.url}") # Debug-Ausgabe
|
||||||
|
|
||||||
|
return render(request, 'products/gallery.html', context)
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def add_progress_update(request, order_id):
|
||||||
|
order = get_object_or_404(CustomOrder, id=order_id)
|
||||||
|
if not request.user.is_staff:
|
||||||
|
messages.error(request, 'Sie haben keine Berechtigung für diese Aktion.')
|
||||||
|
return redirect('custom_order_detail', order_id=order_id)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = OrderProgressForm(request.POST, request.FILES)
|
||||||
|
if form.is_valid():
|
||||||
|
progress = form.save(commit=False)
|
||||||
|
progress.custom_order = order
|
||||||
|
progress.save()
|
||||||
|
|
||||||
|
messages.success(request, 'Fortschritts-Update wurde hinzugefügt.')
|
||||||
|
|
||||||
|
# Wenn alle Schritte abgeschlossen sind, setze den Status auf "ready"
|
||||||
|
if all(update.completed for update in order.progress_updates.all()):
|
||||||
|
order.status = 'ready'
|
||||||
|
order.save()
|
||||||
|
|
||||||
|
return redirect('custom_order_detail', order_id=order_id)
|
||||||
|
else:
|
||||||
|
form = OrderProgressForm()
|
||||||
|
|
||||||
|
return render(request, 'products/add_progress.html', {
|
||||||
|
'form': form,
|
||||||
|
'order': order
|
||||||
|
})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def dashboard(request):
|
||||||
|
# Basis-Informationen
|
||||||
|
user_profile = request.user.userprofile
|
||||||
|
wishlist = Wishlist.objects.get_or_create(user=request.user)[0]
|
||||||
|
cart = get_or_create_cart(request)
|
||||||
|
|
||||||
|
# Custom Orders mit Fortschritt
|
||||||
|
custom_orders = CustomOrder.objects.filter(user=request.user).order_by('-created')
|
||||||
|
for order in custom_orders:
|
||||||
|
order.progress_percentage = calculate_order_progress(order)
|
||||||
|
|
||||||
|
# Letzte Bestellungen mit Details
|
||||||
|
recent_orders = Order.objects.filter(user=request.user).order_by('-created')[:5]
|
||||||
|
for order in recent_orders:
|
||||||
|
order.items_count = order.items.count()
|
||||||
|
|
||||||
|
# Bewertungsübersicht
|
||||||
|
reviews = Review.objects.filter(user=request.user).select_related('product').order_by('-created')[:5]
|
||||||
|
|
||||||
|
# Statistiken
|
||||||
|
stats = {
|
||||||
|
'total_orders': Order.objects.filter(user=request.user).count(),
|
||||||
|
'total_custom_orders': CustomOrder.objects.filter(user=request.user).count(),
|
||||||
|
'total_reviews': Review.objects.filter(user=request.user).count(),
|
||||||
|
'wishlist_count': wishlist.products.count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'products/dashboard.html', {
|
||||||
|
'user_profile': user_profile,
|
||||||
|
'custom_orders': custom_orders,
|
||||||
|
'recent_orders': recent_orders,
|
||||||
|
'reviews': reviews,
|
||||||
|
'stats': stats,
|
||||||
|
'wishlist': wishlist,
|
||||||
|
'cart': cart,
|
||||||
|
})
|
||||||
|
|
||||||
|
def calculate_order_progress(order):
|
||||||
|
"""Berechne den Fortschritt einer Custom Order in Prozent."""
|
||||||
|
if order.status == 'cancelled':
|
||||||
|
return 0
|
||||||
|
elif order.status == 'completed':
|
||||||
|
return 100
|
||||||
|
|
||||||
|
# Status-Gewichtung
|
||||||
|
status_weights = {
|
||||||
|
'pending': 10,
|
||||||
|
'quoted': 20,
|
||||||
|
'approved': 30,
|
||||||
|
'in_progress': 50,
|
||||||
|
'ready': 90,
|
||||||
|
'shipped': 95,
|
||||||
|
}
|
||||||
|
|
||||||
|
base_progress = status_weights.get(order.status, 0)
|
||||||
|
|
||||||
|
# Zusätzlicher Fortschritt basierend auf OrderProgress
|
||||||
|
if order.status == 'in_progress':
|
||||||
|
completed_stages = order.progress_updates.filter(completed=True).count()
|
||||||
|
total_stages = len(OrderProgress.PROGRESS_CHOICES)
|
||||||
|
stage_progress = (completed_stages / total_stages) * 40 # 40% für Fortschrittsstufen
|
||||||
|
return base_progress + stage_progress
|
||||||
|
|
||||||
|
return base_progress
|
||||||
|
|
||||||
|
# API ViewSets - Temporär auskommentiert
|
||||||
|
# class ProductViewSet(viewsets.ModelViewSet):
|
||||||
|
# queryset = Product.objects.all()
|
||||||
|
# serializer_class = ProductSerializer
|
||||||
|
# permission_classes = [IsAuthenticatedOrReadOnly]
|
||||||
|
|
||||||
|
# class ReviewViewSet(viewsets.ModelViewSet):
|
||||||
|
# queryset = Review.objects.all()
|
||||||
|
# serializer_class = ReviewSerializer
|
||||||
|
# permission_classes = [IsAuthenticatedOrReadOnly]
|
||||||
|
|
||||||
|
# def perform_create(self, serializer):
|
||||||
|
# serializer.save(user=self.request.user)
|
||||||
|
|
||||||
|
# Stripe-Konfiguration
|
||||||
|
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||||
|
|
||||||
|
def create_payment_intent(request, order_id):
|
||||||
|
"""Erstellt einen Stripe Payment Intent für eine Bestellung."""
|
||||||
|
try:
|
||||||
|
order = get_object_or_404(Order, id=order_id)
|
||||||
|
|
||||||
|
# Stripe akzeptiert nur Cent-Beträge
|
||||||
|
amount = int(order.total_amount * 100)
|
||||||
|
|
||||||
|
# Payment Intent erstellen
|
||||||
|
intent = stripe.PaymentIntent.create(
|
||||||
|
amount=amount,
|
||||||
|
currency='eur',
|
||||||
|
metadata={
|
||||||
|
'order_id': order.id,
|
||||||
|
'user_id': request.user.id if request.user.is_authenticated else None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Order aktualisieren
|
||||||
|
order.stripe_payment_intent_id = intent.id
|
||||||
|
order.save()
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'clientSecret': intent.client_secret,
|
||||||
|
'amount': amount
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({'error': str(e)}, status=400)
|
||||||
|
|
||||||
|
def payment_view(request, order_id):
|
||||||
|
"""Zeigt die Zahlungsseite an."""
|
||||||
|
order = get_object_or_404(Order, id=order_id)
|
||||||
|
|
||||||
|
# Überprüfen, ob die Bestellung dem Benutzer gehört
|
||||||
|
if request.user.is_authenticated and order.user != request.user:
|
||||||
|
messages.error(request, 'Sie haben keine Berechtigung für diese Bestellung.')
|
||||||
|
return redirect('dashboard')
|
||||||
|
|
||||||
|
# Überprüfen, ob die Bestellung bereits bezahlt wurde
|
||||||
|
if order.payment_status == 'paid':
|
||||||
|
messages.info(request, 'Diese Bestellung wurde bereits bezahlt.')
|
||||||
|
return redirect('order_detail', order_id=order.id)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'order': order,
|
||||||
|
'stripe_publishable_key': settings.STRIPE_PUBLISHABLE_KEY
|
||||||
|
}
|
||||||
|
return render(request, 'products/payment.html', context)
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
def stripe_webhook(request):
|
||||||
|
"""Webhook für Stripe-Events."""
|
||||||
|
payload = request.body
|
||||||
|
sig_header = request.META.get('HTTP_STRIPE_SIGNATURE')
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = stripe.Webhook.construct_event(
|
||||||
|
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
return JsonResponse({'error': 'Invalid payload'}, status=400)
|
||||||
|
except stripe.error.SignatureVerificationError as e:
|
||||||
|
return JsonResponse({'error': 'Invalid signature'}, status=400)
|
||||||
|
|
||||||
|
if event.type == 'payment_intent.succeeded':
|
||||||
|
payment_intent = event.data.object
|
||||||
|
handle_successful_payment(payment_intent)
|
||||||
|
elif event.type == 'payment_intent.payment_failed':
|
||||||
|
payment_intent = event.data.object
|
||||||
|
handle_failed_payment(payment_intent)
|
||||||
|
|
||||||
|
return JsonResponse({'status': 'success'})
|
||||||
|
|
||||||
|
def handle_successful_payment(payment_intent):
|
||||||
|
"""Verarbeitet erfolgreiche Zahlungen."""
|
||||||
|
order_id = payment_intent.metadata.get('order_id')
|
||||||
|
if order_id:
|
||||||
|
try:
|
||||||
|
order = Order.objects.get(id=order_id)
|
||||||
|
order.payment_status = 'paid'
|
||||||
|
order.payment_date = timezone.now()
|
||||||
|
order.status = 'processing'
|
||||||
|
order.stripe_payment_method_id = payment_intent.payment_method
|
||||||
|
order.save()
|
||||||
|
|
||||||
|
# E-Mail-Benachrichtigung senden
|
||||||
|
send_payment_confirmation_email(order)
|
||||||
|
except Order.DoesNotExist:
|
||||||
|
print(f"Bestellung {order_id} nicht gefunden")
|
||||||
|
|
||||||
|
def handle_failed_payment(payment_intent):
|
||||||
|
"""Verarbeitet fehlgeschlagene Zahlungen."""
|
||||||
|
order_id = payment_intent.metadata.get('order_id')
|
||||||
|
if order_id:
|
||||||
|
try:
|
||||||
|
order = Order.objects.get(id=order_id)
|
||||||
|
order.payment_status = 'failed'
|
||||||
|
order.save()
|
||||||
|
|
||||||
|
# E-Mail-Benachrichtigung senden
|
||||||
|
send_payment_failed_email(order)
|
||||||
|
except Order.DoesNotExist:
|
||||||
|
print(f"Bestellung {order_id} nicht gefunden")
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def payment_success(request, order_id):
|
||||||
|
"""Zeigt die Erfolgsseite nach erfolgreicher Zahlung an."""
|
||||||
|
order = get_object_or_404(Order, id=order_id, user=request.user)
|
||||||
|
messages.success(request, 'Ihre Zahlung wurde erfolgreich verarbeitet!')
|
||||||
|
return render(request, 'products/payment_success.html', {'order': order})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def payment_failed(request, order_id):
|
||||||
|
"""Zeigt die Fehlerseite nach fehlgeschlagener Zahlung an."""
|
||||||
|
order = get_object_or_404(Order, id=order_id, user=request.user)
|
||||||
|
messages.error(request, 'Die Zahlung konnte nicht verarbeitet werden. Bitte versuchen Sie es erneut.')
|
||||||
|
return render(request, 'products/payment_failed.html', {'order': order})
|
||||||
|
|
||||||
|
# Hilfsfunktionen für E-Mail-Benachrichtigungen
|
||||||
|
def send_payment_confirmation_email(order):
|
||||||
|
"""Sendet eine Bestätigungs-E-Mail nach erfolgreicher Zahlung."""
|
||||||
|
subject = f'Zahlungsbestätigung für Bestellung #{order.id}'
|
||||||
|
message = f"""
|
||||||
|
Sehr geehrte(r) {order.full_name},
|
||||||
|
|
||||||
|
vielen Dank für Ihre Zahlung. Ihre Bestellung #{order.id} wurde erfolgreich bezahlt.
|
||||||
|
|
||||||
|
Bestelldetails:
|
||||||
|
- Gesamtbetrag: {order.total_amount} €
|
||||||
|
- Zahlungsmethode: {order.get_payment_method_display()}
|
||||||
|
- Zahlungsdatum: {order.payment_date.strftime('%d.%m.%Y %H:%M')}
|
||||||
|
|
||||||
|
Wir werden Ihre Bestellung schnellstmöglich bearbeiten.
|
||||||
|
|
||||||
|
Mit freundlichen Grüßen
|
||||||
|
Ihr Fursuit-Shop Team
|
||||||
|
"""
|
||||||
|
|
||||||
|
send_mail(
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
settings.DEFAULT_FROM_EMAIL,
|
||||||
|
[order.email],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def send_payment_failed_email(order):
|
||||||
|
"""Sendet eine Benachrichtigung nach fehlgeschlagener Zahlung."""
|
||||||
|
subject = f'Zahlungsproblem bei Bestellung #{order.id}'
|
||||||
|
message = f"""
|
||||||
|
Sehr geehrte(r) {order.full_name},
|
||||||
|
|
||||||
|
leider ist die Zahlung für Ihre Bestellung #{order.id} fehlgeschlagen.
|
||||||
|
|
||||||
|
Sie können die Zahlung unter folgendem Link erneut versuchen:
|
||||||
|
{settings.SITE_URL}/payment/{order.id}/
|
||||||
|
|
||||||
|
Falls Sie Fragen haben, kontaktieren Sie uns bitte.
|
||||||
|
|
||||||
|
Mit freundlichen Grüßen
|
||||||
|
Ihr Fursuit-Shop Team
|
||||||
|
"""
|
||||||
|
|
||||||
|
send_mail(
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
settings.DEFAULT_FROM_EMAIL,
|
||||||
|
[order.email],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def payment_process(request, order_id):
|
||||||
|
order = get_object_or_404(Order, id=order_id, user=request.user)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
# Initiiere PayPal-Zahlung
|
||||||
|
paypal_dict = {
|
||||||
|
"business": settings.PAYPAL_RECEIVER_EMAIL,
|
||||||
|
"amount": str(order.total_amount),
|
||||||
|
"item_name": f"Bestellung #{order.id}",
|
||||||
|
"invoice": str(order.id),
|
||||||
|
"notify_url": request.build_absolute_uri(reverse('paypal-ipn')),
|
||||||
|
"return_url": request.build_absolute_uri(reverse('payment_success', args=[order.id])),
|
||||||
|
"cancel_return": request.build_absolute_uri(reverse('payment_failed', args=[order.id])),
|
||||||
|
"custom": json.dumps({
|
||||||
|
"order_id": order.id,
|
||||||
|
"user_id": request.user.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
form = PayPalPaymentsForm(initial=paypal_dict)
|
||||||
|
return render(request, 'products/payment_process.html', {'order': order, 'form': form})
|
||||||
|
|
||||||
|
return render(request, 'products/payment_process.html', {'order': order})
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
def paypal_ipn(request):
|
||||||
|
"""PayPal IPN (Instant Payment Notification) Handler"""
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
ipn_obj = PayPalIPN(request.POST)
|
||||||
|
ipn_obj.verify()
|
||||||
|
|
||||||
|
if ipn_obj.payment_status == "Completed":
|
||||||
|
# Zahlung war erfolgreich
|
||||||
|
try:
|
||||||
|
order = Order.objects.get(id=ipn_obj.invoice)
|
||||||
|
order.status = 'paid'
|
||||||
|
order.payment_method = 'paypal'
|
||||||
|
order.payment_id = ipn_obj.txn_id
|
||||||
|
order.save()
|
||||||
|
|
||||||
|
# Sende Bestätigungs-E-Mail
|
||||||
|
send_payment_confirmation_email(order)
|
||||||
|
except Order.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return HttpResponse("OK")
|
||||||
|
except Exception as e:
|
||||||
|
return HttpResponse(str(e))
|
||||||
|
|
||||||
|
return HttpResponse("OK")
|
||||||
|
|
@ -0,0 +1,192 @@
|
||||||
|
"""
|
||||||
|
Recommendation Engine Admin Interface
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.db.models import Count, Avg, Sum
|
||||||
|
from .models import (
|
||||||
|
UserBehavior, UserProfile, ProductSimilarity, Recommendation,
|
||||||
|
RecommendationModel, ABTest, RecommendationAnalytics
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserBehavior)
|
||||||
|
class UserBehaviorAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['user', 'behavior_type', 'product', 'created_at', 'ip_address']
|
||||||
|
list_filter = ['behavior_type', 'created_at', 'user']
|
||||||
|
search_fields = ['user__username', 'product__name']
|
||||||
|
readonly_fields = ['id', 'created_at']
|
||||||
|
date_hierarchy = 'created_at'
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return super().get_queryset(request).select_related('user', 'product')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserProfile)
|
||||||
|
class UserProfileAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['user', 'engagement_score', 'loyalty_level', 'total_purchases', 'last_active']
|
||||||
|
list_filter = ['loyalty_level', 'last_active']
|
||||||
|
search_fields = ['user__username']
|
||||||
|
readonly_fields = ['last_updated']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('User Info', {
|
||||||
|
'fields': ('user', 'engagement_score', 'loyalty_level')
|
||||||
|
}),
|
||||||
|
('Preferences', {
|
||||||
|
'fields': ('preferred_categories', 'preferred_fursuit_types', 'preferred_price_range', 'preferred_colors')
|
||||||
|
}),
|
||||||
|
('Metrics', {
|
||||||
|
'fields': ('avg_session_duration', 'total_purchases', 'total_views', 'total_searches')
|
||||||
|
}),
|
||||||
|
('ML Features', {
|
||||||
|
'fields': ('feature_vector', 'last_updated')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return super().get_queryset(request).select_related('user')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ProductSimilarity)
|
||||||
|
class ProductSimilarityAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['product', 'similar_product', 'similarity_score', 'similarity_type', 'updated_at']
|
||||||
|
list_filter = ['similarity_type', 'updated_at']
|
||||||
|
search_fields = ['product__name', 'similar_product__name']
|
||||||
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return super().get_queryset(request).select_related('product', 'similar_product')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Recommendation)
|
||||||
|
class RecommendationAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['user', 'product', 'recommendation_type', 'confidence_score', 'is_clicked', 'is_purchased', 'created_at']
|
||||||
|
list_filter = ['recommendation_type', 'is_clicked', 'is_purchased', 'created_at']
|
||||||
|
search_fields = ['user__username', 'product__name']
|
||||||
|
readonly_fields = ['id', 'created_at']
|
||||||
|
date_hierarchy = 'created_at'
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Recommendation Info', {
|
||||||
|
'fields': ('user', 'product', 'recommendation_type', 'confidence_score', 'reason')
|
||||||
|
}),
|
||||||
|
('Performance', {
|
||||||
|
'fields': ('is_clicked', 'is_purchased')
|
||||||
|
}),
|
||||||
|
('Timing', {
|
||||||
|
'fields': ('created_at', 'expires_at')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return super().get_queryset(request).select_related('user', 'product')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(RecommendationModel)
|
||||||
|
class RecommendationModelAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['model_name', 'model_type', 'model_version', 'accuracy_score', 'is_active', 'trained_at']
|
||||||
|
list_filter = ['model_type', 'is_active', 'trained_at']
|
||||||
|
search_fields = ['model_name']
|
||||||
|
readonly_fields = ['id', 'created_at', 'trained_at']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Model Info', {
|
||||||
|
'fields': ('model_type', 'model_name', 'model_version', 'model_file')
|
||||||
|
}),
|
||||||
|
('Parameters', {
|
||||||
|
'fields': ('model_parameters',)
|
||||||
|
}),
|
||||||
|
('Performance', {
|
||||||
|
'fields': ('training_data_size', 'accuracy_score', 'precision_score', 'recall_score', 'f1_score')
|
||||||
|
}),
|
||||||
|
('Status', {
|
||||||
|
'fields': ('is_active', 'created_at', 'trained_at')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ABTest)
|
||||||
|
class ABTestAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['test_name', 'test_type', 'status', 'traffic_split', 'winner', 'created_by']
|
||||||
|
list_filter = ['status', 'test_type', 'created_at']
|
||||||
|
search_fields = ['test_name', 'description']
|
||||||
|
readonly_fields = ['id', 'created_at']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Test Info', {
|
||||||
|
'fields': ('test_name', 'description', 'test_type', 'created_by')
|
||||||
|
}),
|
||||||
|
('Variants', {
|
||||||
|
'fields': ('variant_a', 'variant_b', 'traffic_split')
|
||||||
|
}),
|
||||||
|
('Status', {
|
||||||
|
'fields': ('status', 'start_date', 'end_date')
|
||||||
|
}),
|
||||||
|
('Results', {
|
||||||
|
'fields': ('variant_a_conversion', 'variant_b_conversion', 'statistical_significance', 'winner')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(RecommendationAnalytics)
|
||||||
|
class RecommendationAnalyticsAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['date', 'total_recommendations', 'total_clicks', 'total_purchases', 'click_through_rate', 'conversion_rate']
|
||||||
|
list_filter = ['date']
|
||||||
|
readonly_fields = ['date']
|
||||||
|
date_hierarchy = 'date'
|
||||||
|
|
||||||
|
def click_through_rate(self, obj):
|
||||||
|
if obj.total_recommendations > 0:
|
||||||
|
return f"{(obj.total_clicks / obj.total_recommendations * 100):.1f}%"
|
||||||
|
return "0%"
|
||||||
|
click_through_rate.short_description = 'CTR'
|
||||||
|
|
||||||
|
def conversion_rate(self, obj):
|
||||||
|
if obj.total_clicks > 0:
|
||||||
|
return f"{(obj.total_purchases / obj.total_clicks * 100):.1f}%"
|
||||||
|
return "0%"
|
||||||
|
conversion_rate.short_description = 'Conversion Rate'
|
||||||
|
|
||||||
|
|
||||||
|
# Custom Admin Actions
|
||||||
|
@admin.action(description="Update User Engagement Scores")
|
||||||
|
def update_engagement_scores(modeladmin, request, queryset):
|
||||||
|
for profile in queryset:
|
||||||
|
profile.update_engagement_score()
|
||||||
|
modeladmin.message_user(request, f"Updated engagement scores for {queryset.count()} profiles.")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.action(description="Train Recommendation Models")
|
||||||
|
def train_models(modeladmin, request, queryset):
|
||||||
|
from .services import RecommendationService
|
||||||
|
service = RecommendationService()
|
||||||
|
|
||||||
|
for model in queryset:
|
||||||
|
trained_model = service.train_recommendation_model(model.model_type)
|
||||||
|
modeladmin.message_user(request, f"Trained model: {trained_model.model_name}")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.action(description="Update Product Similarities")
|
||||||
|
def update_similarities(modeladmin, request, queryset):
|
||||||
|
from .services import RecommendationService
|
||||||
|
service = RecommendationService()
|
||||||
|
service.update_product_similarities()
|
||||||
|
modeladmin.message_user(request, "Updated product similarities.")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.action(description="Update Analytics")
|
||||||
|
def update_analytics(modeladmin, request, queryset):
|
||||||
|
from .services import RecommendationService
|
||||||
|
service = RecommendationService()
|
||||||
|
analytics = service.update_analytics()
|
||||||
|
modeladmin.message_user(request, f"Updated analytics for {analytics.date}")
|
||||||
|
|
||||||
|
|
||||||
|
# Add actions to admin classes
|
||||||
|
UserProfileAdmin.actions = [update_engagement_scores]
|
||||||
|
RecommendationModelAdmin.actions = [train_models]
|
||||||
|
ProductSimilarityAdmin.actions = [update_similarities]
|
||||||
|
RecommendationAnalyticsAdmin.actions = [update_analytics]
|
||||||
|
|
@ -0,0 +1,308 @@
|
||||||
|
"""
|
||||||
|
Recommendation Engine Models für ML-basierte Empfehlungen
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.utils import timezone
|
||||||
|
from products.models import Product
|
||||||
|
from auction.models import Auction
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class UserBehavior(models.Model):
|
||||||
|
"""User Behavior Tracking für Empfehlungen"""
|
||||||
|
|
||||||
|
BEHAVIOR_TYPE_CHOICES = [
|
||||||
|
('view', 'Produkt angesehen'),
|
||||||
|
('cart_add', 'Zum Warenkorb hinzugefügt'),
|
||||||
|
('cart_remove', 'Aus Warenkorb entfernt'),
|
||||||
|
('purchase', 'Gekauft'),
|
||||||
|
('wishlist_add', 'Zur Wunschliste hinzugefügt'),
|
||||||
|
('wishlist_remove', 'Von Wunschliste entfernt'),
|
||||||
|
('search', 'Gesucht'),
|
||||||
|
('auction_bid', 'Bei Auktion geboten'),
|
||||||
|
('auction_watch', 'Auktion beobachtet'),
|
||||||
|
('review', 'Bewertung abgegeben'),
|
||||||
|
('share', 'Geteilt'),
|
||||||
|
('like', 'Geliked'),
|
||||||
|
('dislike', 'Nicht gemocht'),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='behaviors')
|
||||||
|
behavior_type = models.CharField(max_length=20, choices=BEHAVIOR_TYPE_CHOICES)
|
||||||
|
product = models.ForeignKey(Product, on_delete=models.CASCADE, null=True, blank=True, related_name='behaviors')
|
||||||
|
auction = models.ForeignKey(Auction, on_delete=models.CASCADE, null=True, blank=True, related_name='behaviors')
|
||||||
|
session_id = models.CharField(max_length=100, blank=True)
|
||||||
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||||
|
user_agent = models.TextField(blank=True)
|
||||||
|
metadata = models.JSONField(default=dict) # Additional data
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name = 'User Behavior'
|
||||||
|
verbose_name_plural = 'User Behaviors'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['user', 'behavior_type', 'created_at']),
|
||||||
|
models.Index(fields=['product', 'behavior_type', 'created_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} - {self.get_behavior_type_display()}"
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile(models.Model):
|
||||||
|
"""User Profile für Personalisierung"""
|
||||||
|
|
||||||
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='recommendation_profile')
|
||||||
|
|
||||||
|
# Preferences
|
||||||
|
preferred_categories = models.JSONField(default=list)
|
||||||
|
preferred_fursuit_types = models.JSONField(default=list)
|
||||||
|
preferred_price_range = models.JSONField(default=dict)
|
||||||
|
preferred_colors = models.JSONField(default=list)
|
||||||
|
|
||||||
|
# Behavior patterns
|
||||||
|
avg_session_duration = models.FloatField(default=0) # in minutes
|
||||||
|
total_purchases = models.IntegerField(default=0)
|
||||||
|
total_views = models.IntegerField(default=0)
|
||||||
|
total_searches = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
# Engagement metrics
|
||||||
|
last_active = models.DateTimeField(auto_now=True)
|
||||||
|
engagement_score = models.FloatField(default=0) # 0-100
|
||||||
|
loyalty_level = models.CharField(max_length=20, choices=[
|
||||||
|
('new', 'Neu'),
|
||||||
|
('regular', 'Regulär'),
|
||||||
|
('loyal', 'Loyal'),
|
||||||
|
('vip', 'VIP'),
|
||||||
|
], default='new')
|
||||||
|
|
||||||
|
# ML features
|
||||||
|
feature_vector = models.JSONField(default=dict) # ML feature vector
|
||||||
|
last_updated = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'User Profile'
|
||||||
|
verbose_name_plural = 'User Profiles'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Profile für {self.user.username}"
|
||||||
|
|
||||||
|
def update_engagement_score(self):
|
||||||
|
"""Engagement Score basierend auf Verhalten aktualisieren"""
|
||||||
|
behaviors = self.user.behaviors.all()
|
||||||
|
|
||||||
|
# Calculate engagement score
|
||||||
|
view_weight = 1
|
||||||
|
cart_weight = 3
|
||||||
|
purchase_weight = 10
|
||||||
|
search_weight = 2
|
||||||
|
|
||||||
|
total_score = 0
|
||||||
|
total_actions = 0
|
||||||
|
|
||||||
|
for behavior in behaviors:
|
||||||
|
if behavior.behavior_type == 'view':
|
||||||
|
total_score += view_weight
|
||||||
|
elif behavior.behavior_type == 'cart_add':
|
||||||
|
total_score += cart_weight
|
||||||
|
elif behavior.behavior_type == 'purchase':
|
||||||
|
total_score += purchase_weight
|
||||||
|
elif behavior.behavior_type == 'search':
|
||||||
|
total_score += search_weight
|
||||||
|
|
||||||
|
total_actions += 1
|
||||||
|
|
||||||
|
if total_actions > 0:
|
||||||
|
self.engagement_score = min(100, (total_score / total_actions) * 10)
|
||||||
|
|
||||||
|
# Update loyalty level
|
||||||
|
if self.engagement_score >= 80:
|
||||||
|
self.loyalty_level = 'vip'
|
||||||
|
elif self.engagement_score >= 60:
|
||||||
|
self.loyalty_level = 'loyal'
|
||||||
|
elif self.engagement_score >= 30:
|
||||||
|
self.loyalty_level = 'regular'
|
||||||
|
else:
|
||||||
|
self.loyalty_level = 'new'
|
||||||
|
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
|
class ProductSimilarity(models.Model):
|
||||||
|
"""Produkt-Ähnlichkeitsmatrix für Collaborative Filtering"""
|
||||||
|
|
||||||
|
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='similarities')
|
||||||
|
similar_product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='similar_to')
|
||||||
|
similarity_score = models.FloatField() # 0-1
|
||||||
|
similarity_type = models.CharField(max_length=20, choices=[
|
||||||
|
('category', 'Kategorie'),
|
||||||
|
('fursuit_type', 'Fursuit Typ'),
|
||||||
|
('price_range', 'Preisbereich'),
|
||||||
|
('color', 'Farbe'),
|
||||||
|
('collaborative', 'Collaborative'),
|
||||||
|
('content_based', 'Content-based'),
|
||||||
|
])
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ['product', 'similar_product']
|
||||||
|
ordering = ['-similarity_score']
|
||||||
|
verbose_name = 'Product Similarity'
|
||||||
|
verbose_name_plural = 'Product Similarities'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.product.name} ~ {self.similar_product.name} ({self.similarity_score:.2f})"
|
||||||
|
|
||||||
|
|
||||||
|
class Recommendation(models.Model):
|
||||||
|
"""Generierte Empfehlungen für User"""
|
||||||
|
|
||||||
|
RECOMMENDATION_TYPE_CHOICES = [
|
||||||
|
('collaborative', 'Collaborative Filtering'),
|
||||||
|
('content_based', 'Content-based Filtering'),
|
||||||
|
('popular', 'Beliebte Produkte'),
|
||||||
|
('trending', 'Trending'),
|
||||||
|
('personalized', 'Personalisiert'),
|
||||||
|
('similar', 'Ähnliche Produkte'),
|
||||||
|
('frequently_bought', 'Häufig zusammen gekauft'),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='recommendations')
|
||||||
|
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='recommendations')
|
||||||
|
recommendation_type = models.CharField(max_length=20, choices=RECOMMENDATION_TYPE_CHOICES)
|
||||||
|
confidence_score = models.FloatField() # 0-1
|
||||||
|
reason = models.CharField(max_length=200, blank=True)
|
||||||
|
is_clicked = models.BooleanField(default=False)
|
||||||
|
is_purchased = models.BooleanField(default=False)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
expires_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-confidence_score', '-created_at']
|
||||||
|
verbose_name = 'Recommendation'
|
||||||
|
verbose_name_plural = 'Recommendations'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['user', 'recommendation_type', 'created_at']),
|
||||||
|
models.Index(fields=['product', 'recommendation_type', 'created_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} - {self.product.name} ({self.get_recommendation_type_display()})"
|
||||||
|
|
||||||
|
|
||||||
|
class RecommendationModel(models.Model):
|
||||||
|
"""ML Model für Empfehlungen"""
|
||||||
|
|
||||||
|
MODEL_TYPE_CHOICES = [
|
||||||
|
('collaborative', 'Collaborative Filtering'),
|
||||||
|
('content_based', 'Content-based Filtering'),
|
||||||
|
('hybrid', 'Hybrid Model'),
|
||||||
|
('neural_network', 'Neural Network'),
|
||||||
|
('matrix_factorization', 'Matrix Factorization'),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
model_type = models.CharField(max_length=20, choices=MODEL_TYPE_CHOICES)
|
||||||
|
model_name = models.CharField(max_length=100)
|
||||||
|
model_version = models.CharField(max_length=20)
|
||||||
|
model_file = models.FileField(upload_to='recommendation_models/', null=True, blank=True)
|
||||||
|
model_parameters = models.JSONField(default=dict)
|
||||||
|
training_data_size = models.IntegerField(default=0)
|
||||||
|
accuracy_score = models.FloatField(default=0)
|
||||||
|
precision_score = models.FloatField(default=0)
|
||||||
|
recall_score = models.FloatField(default=0)
|
||||||
|
f1_score = models.FloatField(default=0)
|
||||||
|
is_active = models.BooleanField(default=False)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
trained_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name = 'Recommendation Model'
|
||||||
|
verbose_name_plural = 'Recommendation Models'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.model_name} v{self.model_version} ({self.get_model_type_display()})"
|
||||||
|
|
||||||
|
|
||||||
|
class ABTest(models.Model):
|
||||||
|
"""A/B Testing für Empfehlungen"""
|
||||||
|
|
||||||
|
TEST_STATUS_CHOICES = [
|
||||||
|
('draft', 'Entwurf'),
|
||||||
|
('running', 'Läuft'),
|
||||||
|
('paused', 'Pausiert'),
|
||||||
|
('completed', 'Abgeschlossen'),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
test_name = models.CharField(max_length=100)
|
||||||
|
description = models.TextField()
|
||||||
|
test_type = models.CharField(max_length=50)
|
||||||
|
variant_a = models.JSONField() # Control group
|
||||||
|
variant_b = models.JSONField() # Test group
|
||||||
|
traffic_split = models.FloatField(default=0.5) # % für Variante B
|
||||||
|
status = models.CharField(max_length=20, choices=TEST_STATUS_CHOICES, default='draft')
|
||||||
|
start_date = models.DateTimeField(null=True, blank=True)
|
||||||
|
end_date = models.DateTimeField(null=True, blank=True)
|
||||||
|
created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ab_tests')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
# Results
|
||||||
|
variant_a_conversion = models.FloatField(default=0)
|
||||||
|
variant_b_conversion = models.FloatField(default=0)
|
||||||
|
statistical_significance = models.FloatField(default=0)
|
||||||
|
winner = models.CharField(max_length=10, choices=[
|
||||||
|
('a', 'Variant A'),
|
||||||
|
('b', 'Variant B'),
|
||||||
|
('none', 'Kein Gewinner'),
|
||||||
|
], blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name = 'A/B Test'
|
||||||
|
verbose_name_plural = 'A/B Tests'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.test_name} ({self.get_status_display()})"
|
||||||
|
|
||||||
|
|
||||||
|
class RecommendationAnalytics(models.Model):
|
||||||
|
"""Analytics für Empfehlungen"""
|
||||||
|
|
||||||
|
date = models.DateField()
|
||||||
|
total_recommendations = models.IntegerField(default=0)
|
||||||
|
total_clicks = models.IntegerField(default=0)
|
||||||
|
total_purchases = models.IntegerField(default=0)
|
||||||
|
click_through_rate = models.FloatField(default=0)
|
||||||
|
conversion_rate = models.FloatField(default=0)
|
||||||
|
revenue_from_recommendations = models.DecimalField(max_digits=10, decimal_places=2, default=0)
|
||||||
|
avg_recommendation_score = models.FloatField(default=0)
|
||||||
|
|
||||||
|
# Per recommendation type
|
||||||
|
collaborative_recommendations = models.IntegerField(default=0)
|
||||||
|
content_based_recommendations = models.IntegerField(default=0)
|
||||||
|
popular_recommendations = models.IntegerField(default=0)
|
||||||
|
personalized_recommendations = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ['date']
|
||||||
|
ordering = ['-date']
|
||||||
|
verbose_name = 'Recommendation Analytics'
|
||||||
|
verbose_name_plural = 'Recommendation Analytics'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Recommendation Analytics - {self.date}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_or_create_today(cls):
|
||||||
|
"""Heutige Analytics erstellen oder abrufen"""
|
||||||
|
today = timezone.now().date()
|
||||||
|
analytics, created = cls.objects.get_or_create(date=today)
|
||||||
|
return analytics
|
||||||
|
|
@ -0,0 +1,447 @@
|
||||||
|
"""
|
||||||
|
Recommendation Service für ML-basierte Empfehlungen
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from django.db.models import Q, Count, Avg
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
from .models import UserBehavior, UserProfile, ProductSimilarity, Recommendation, RecommendationModel
|
||||||
|
from products.models import Product
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
class RecommendationService:
|
||||||
|
"""Service für ML-basierte Empfehlungen"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.collaborative_weight = 0.4
|
||||||
|
self.content_based_weight = 0.3
|
||||||
|
self.popular_weight = 0.2
|
||||||
|
self.trending_weight = 0.1
|
||||||
|
|
||||||
|
def get_recommendations_for_user(self, user, limit=10):
|
||||||
|
"""Personalisiertes Empfehlungen für User"""
|
||||||
|
|
||||||
|
recommendations = []
|
||||||
|
|
||||||
|
# Get user profile
|
||||||
|
profile, created = UserProfile.objects.get_or_create(user=user)
|
||||||
|
|
||||||
|
# 1. Collaborative Filtering
|
||||||
|
collaborative_recs = self._get_collaborative_recommendations(user, limit=limit//2)
|
||||||
|
recommendations.extend(collaborative_recs)
|
||||||
|
|
||||||
|
# 2. Content-based Filtering
|
||||||
|
content_recs = self._get_content_based_recommendations(user, profile, limit=limit//2)
|
||||||
|
recommendations.extend(content_recs)
|
||||||
|
|
||||||
|
# 3. Popular Products (fallback)
|
||||||
|
if len(recommendations) < limit:
|
||||||
|
popular_recs = self._get_popular_recommendations(limit=limit-len(recommendations))
|
||||||
|
recommendations.extend(popular_recs)
|
||||||
|
|
||||||
|
# 4. Trending Products (fallback)
|
||||||
|
if len(recommendations) < limit:
|
||||||
|
trending_recs = self._get_trending_recommendations(limit=limit-len(recommendations))
|
||||||
|
recommendations.extend(trending_recs)
|
||||||
|
|
||||||
|
# Remove duplicates and sort by confidence
|
||||||
|
unique_recs = {}
|
||||||
|
for rec in recommendations:
|
||||||
|
product_id = rec['product']['id']
|
||||||
|
if product_id not in unique_recs or rec['confidence_score'] > unique_recs[product_id]['confidence_score']:
|
||||||
|
unique_recs[product_id] = rec
|
||||||
|
|
||||||
|
# Sort by confidence score
|
||||||
|
final_recommendations = sorted(
|
||||||
|
unique_recs.values(),
|
||||||
|
key=lambda x: x['confidence_score'],
|
||||||
|
reverse=True
|
||||||
|
)[:limit]
|
||||||
|
|
||||||
|
# Save recommendations to database
|
||||||
|
self._save_recommendations(user, final_recommendations)
|
||||||
|
|
||||||
|
return final_recommendations
|
||||||
|
|
||||||
|
def _get_collaborative_recommendations(self, user, limit=5):
|
||||||
|
"""Collaborative Filtering basierend auf ähnlichen Usern"""
|
||||||
|
|
||||||
|
recommendations = []
|
||||||
|
|
||||||
|
# Get users with similar behavior
|
||||||
|
similar_users = self._find_similar_users(user)
|
||||||
|
|
||||||
|
# Get products liked by similar users
|
||||||
|
for similar_user in similar_users[:5]:
|
||||||
|
liked_products = UserBehavior.objects.filter(
|
||||||
|
user=similar_user,
|
||||||
|
behavior_type__in=['purchase', 'cart_add', 'wishlist_add']
|
||||||
|
).values_list('product', flat=True).distinct()
|
||||||
|
|
||||||
|
for product_id in liked_products:
|
||||||
|
product = Product.objects.filter(id=product_id, is_active=True).first()
|
||||||
|
if product and not self._user_has_interacted(user, product):
|
||||||
|
recommendations.append({
|
||||||
|
'product': {
|
||||||
|
'id': product.id,
|
||||||
|
'name': product.name,
|
||||||
|
'price': float(product.price),
|
||||||
|
'image_url': product.image.url if product.image else None,
|
||||||
|
'category': product.category,
|
||||||
|
},
|
||||||
|
'confidence_score': self.collaborative_weight,
|
||||||
|
'recommendation_type': 'collaborative',
|
||||||
|
'reason': f'Ähnlich zu {similar_user.username}'
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(recommendations) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
return recommendations
|
||||||
|
|
||||||
|
def _get_content_based_recommendations(self, user, profile, limit=5):
|
||||||
|
"""Content-based Filtering basierend auf User Preferences"""
|
||||||
|
|
||||||
|
recommendations = []
|
||||||
|
|
||||||
|
# Get user preferences
|
||||||
|
preferred_categories = profile.preferred_categories
|
||||||
|
preferred_fursuit_types = profile.preferred_fursuit_types
|
||||||
|
preferred_price_range = profile.preferred_price_range
|
||||||
|
|
||||||
|
# Build query based on preferences
|
||||||
|
query = Q(is_active=True)
|
||||||
|
|
||||||
|
if preferred_categories:
|
||||||
|
query &= Q(category__in=preferred_categories)
|
||||||
|
|
||||||
|
if preferred_fursuit_types:
|
||||||
|
query &= Q(fursuit_type__in=preferred_fursuit_types)
|
||||||
|
|
||||||
|
if preferred_price_range:
|
||||||
|
min_price = preferred_price_range.get('min', 0)
|
||||||
|
max_price = preferred_price_range.get('max', float('inf'))
|
||||||
|
query &= Q(price__gte=min_price, price__lte=max_price)
|
||||||
|
|
||||||
|
# Get products matching preferences
|
||||||
|
matching_products = Product.objects.filter(query).exclude(
|
||||||
|
behaviors__user=user
|
||||||
|
).distinct()[:limit*2]
|
||||||
|
|
||||||
|
for product in matching_products:
|
||||||
|
confidence = self._calculate_content_based_confidence(product, profile)
|
||||||
|
|
||||||
|
recommendations.append({
|
||||||
|
'product': {
|
||||||
|
'id': product.id,
|
||||||
|
'name': product.name,
|
||||||
|
'price': float(product.price),
|
||||||
|
'image_url': product.image.url if product.image else None,
|
||||||
|
'category': product.category,
|
||||||
|
},
|
||||||
|
'confidence_score': confidence,
|
||||||
|
'recommendation_type': 'content_based',
|
||||||
|
'reason': 'Basierend auf deinen Präferenzen'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by confidence and return top results
|
||||||
|
recommendations.sort(key=lambda x: x['confidence_score'], reverse=True)
|
||||||
|
return recommendations[:limit]
|
||||||
|
|
||||||
|
def _get_popular_recommendations(self, limit=5):
|
||||||
|
"""Beliebte Produkte basierend auf Verhalten"""
|
||||||
|
|
||||||
|
recommendations = []
|
||||||
|
|
||||||
|
# Get popular products
|
||||||
|
popular_products = Product.objects.annotate(
|
||||||
|
purchase_count=Count('behaviors', filter=Q(behaviors__behavior_type='purchase')),
|
||||||
|
view_count=Count('behaviors', filter=Q(behaviors__behavior_type='view')),
|
||||||
|
).filter(
|
||||||
|
is_active=True,
|
||||||
|
purchase_count__gt=0
|
||||||
|
).order_by('-purchase_count', '-view_count')[:limit]
|
||||||
|
|
||||||
|
for product in popular_products:
|
||||||
|
popularity_score = (product.purchase_count * 2 + product.view_count) / 100
|
||||||
|
confidence = min(0.8, popularity_score)
|
||||||
|
|
||||||
|
recommendations.append({
|
||||||
|
'product': {
|
||||||
|
'id': product.id,
|
||||||
|
'name': product.name,
|
||||||
|
'price': float(product.price),
|
||||||
|
'image_url': product.image.url if product.image else None,
|
||||||
|
'category': product.category,
|
||||||
|
},
|
||||||
|
'confidence_score': confidence,
|
||||||
|
'recommendation_type': 'popular',
|
||||||
|
'reason': 'Beliebt bei anderen Kunden'
|
||||||
|
})
|
||||||
|
|
||||||
|
return recommendations
|
||||||
|
|
||||||
|
def _get_trending_recommendations(self, limit=5):
|
||||||
|
"""Trending Produkte (recent activity)"""
|
||||||
|
|
||||||
|
recommendations = []
|
||||||
|
|
||||||
|
# Get recent activity (last 7 days)
|
||||||
|
recent_date = timezone.now() - timedelta(days=7)
|
||||||
|
|
||||||
|
trending_products = Product.objects.annotate(
|
||||||
|
recent_purchases=Count('behaviors', filter=Q(
|
||||||
|
behaviors__behavior_type='purchase',
|
||||||
|
behaviors__created_at__gte=recent_date
|
||||||
|
)),
|
||||||
|
recent_views=Count('behaviors', filter=Q(
|
||||||
|
behaviors__behavior_type='view',
|
||||||
|
behaviors__created_at__gte=recent_date
|
||||||
|
)),
|
||||||
|
).filter(
|
||||||
|
is_active=True,
|
||||||
|
recent_purchases__gt=0
|
||||||
|
).order_by('-recent_purchases', '-recent_views')[:limit]
|
||||||
|
|
||||||
|
for product in trending_products:
|
||||||
|
trending_score = (product.recent_purchases * 3 + product.recent_views) / 50
|
||||||
|
confidence = min(0.7, trending_score)
|
||||||
|
|
||||||
|
recommendations.append({
|
||||||
|
'product': {
|
||||||
|
'id': product.id,
|
||||||
|
'name': product.name,
|
||||||
|
'price': float(product.price),
|
||||||
|
'image_url': product.image.url if product.image else None,
|
||||||
|
'category': product.category,
|
||||||
|
},
|
||||||
|
'confidence_score': confidence,
|
||||||
|
'recommendation_type': 'trending',
|
||||||
|
'reason': 'Trending diese Woche'
|
||||||
|
})
|
||||||
|
|
||||||
|
return recommendations
|
||||||
|
|
||||||
|
def _find_similar_users(self, user, limit=10):
|
||||||
|
"""Finde ähnliche User basierend auf Verhalten"""
|
||||||
|
|
||||||
|
# Get user's behavior
|
||||||
|
user_behaviors = UserBehavior.objects.filter(user=user).values_list('product', 'behavior_type')
|
||||||
|
|
||||||
|
if not user_behaviors:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Find users with similar behavior
|
||||||
|
similar_users = []
|
||||||
|
|
||||||
|
for behavior in user_behaviors:
|
||||||
|
product_id, behavior_type = behavior
|
||||||
|
|
||||||
|
# Find users who had similar behavior with this product
|
||||||
|
similar_behaviors = UserBehavior.objects.filter(
|
||||||
|
product_id=product_id,
|
||||||
|
behavior_type=behavior_type
|
||||||
|
).exclude(user=user).values_list('user', flat=True)
|
||||||
|
|
||||||
|
similar_users.extend(similar_behaviors)
|
||||||
|
|
||||||
|
# Count occurrences and get top users
|
||||||
|
from collections import Counter
|
||||||
|
user_counts = Counter(similar_users)
|
||||||
|
|
||||||
|
return [user_id for user_id, count in user_counts.most_common(limit)]
|
||||||
|
|
||||||
|
def _user_has_interacted(self, user, product):
|
||||||
|
"""Prüfe ob User bereits mit Produkt interagiert hat"""
|
||||||
|
|
||||||
|
return UserBehavior.objects.filter(
|
||||||
|
user=user,
|
||||||
|
product=product,
|
||||||
|
behavior_type__in=['purchase', 'cart_add', 'wishlist_add', 'view']
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
def _calculate_content_based_confidence(self, product, profile):
|
||||||
|
"""Berechne Confidence Score für Content-based Filtering"""
|
||||||
|
|
||||||
|
confidence = 0.5 # Base confidence
|
||||||
|
|
||||||
|
# Category match
|
||||||
|
if product.category in profile.preferred_categories:
|
||||||
|
confidence += 0.2
|
||||||
|
|
||||||
|
# Fursuit type match
|
||||||
|
if product.fursuit_type in profile.preferred_fursuit_types:
|
||||||
|
confidence += 0.2
|
||||||
|
|
||||||
|
# Price range match
|
||||||
|
if profile.preferred_price_range:
|
||||||
|
min_price = profile.preferred_price_range.get('min', 0)
|
||||||
|
max_price = profile.preferred_price_range.get('max', float('inf'))
|
||||||
|
|
||||||
|
if min_price <= product.price <= max_price:
|
||||||
|
confidence += 0.1
|
||||||
|
|
||||||
|
return min(0.9, confidence)
|
||||||
|
|
||||||
|
def _save_recommendations(self, user, recommendations):
|
||||||
|
"""Speichere Empfehlungen in Datenbank"""
|
||||||
|
|
||||||
|
# Delete old recommendations
|
||||||
|
Recommendation.objects.filter(user=user).delete()
|
||||||
|
|
||||||
|
# Create new recommendations
|
||||||
|
for rec in recommendations:
|
||||||
|
Recommendation.objects.create(
|
||||||
|
user=user,
|
||||||
|
product_id=rec['product']['id'],
|
||||||
|
recommendation_type=rec['recommendation_type'],
|
||||||
|
confidence_score=rec['confidence_score'],
|
||||||
|
reason=rec['reason'],
|
||||||
|
expires_at=timezone.now() + timedelta(days=7)
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_product_similarities(self):
|
||||||
|
"""Update Produkt-Ähnlichkeitsmatrix"""
|
||||||
|
|
||||||
|
products = Product.objects.filter(is_active=True)
|
||||||
|
|
||||||
|
for product in products:
|
||||||
|
# Find similar products based on various criteria
|
||||||
|
similar_products = self._find_similar_products(product)
|
||||||
|
|
||||||
|
# Update similarity matrix
|
||||||
|
for similar_product, similarity_score, similarity_type in similar_products:
|
||||||
|
ProductSimilarity.objects.update_or_create(
|
||||||
|
product=product,
|
||||||
|
similar_product=similar_product,
|
||||||
|
defaults={
|
||||||
|
'similarity_score': similarity_score,
|
||||||
|
'similarity_type': similarity_type
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _find_similar_products(self, product):
|
||||||
|
"""Finde ähnliche Produkte"""
|
||||||
|
|
||||||
|
similar_products = []
|
||||||
|
|
||||||
|
# Category-based similarity
|
||||||
|
category_similar = Product.objects.filter(
|
||||||
|
category=product.category,
|
||||||
|
is_active=True
|
||||||
|
).exclude(id=product.id)[:5]
|
||||||
|
|
||||||
|
for similar_product in category_similar:
|
||||||
|
similarity_score = 0.7
|
||||||
|
if similar_product.fursuit_type == product.fursuit_type:
|
||||||
|
similarity_score += 0.2
|
||||||
|
if abs(similar_product.price - product.price) / product.price < 0.3:
|
||||||
|
similarity_score += 0.1
|
||||||
|
|
||||||
|
similar_products.append((similar_product, similarity_score, 'category'))
|
||||||
|
|
||||||
|
# Price-based similarity
|
||||||
|
price_range = product.price * 0.3
|
||||||
|
price_similar = Product.objects.filter(
|
||||||
|
price__range=(product.price - price_range, product.price + price_range),
|
||||||
|
is_active=True
|
||||||
|
).exclude(id=product.id)[:5]
|
||||||
|
|
||||||
|
for similar_product in price_similar:
|
||||||
|
similarity_score = 0.6
|
||||||
|
if similar_product.category == product.category:
|
||||||
|
similarity_score += 0.2
|
||||||
|
if similar_product.fursuit_type == product.fursuit_type:
|
||||||
|
similarity_score += 0.2
|
||||||
|
|
||||||
|
similar_products.append((similar_product, similarity_score, 'price_range'))
|
||||||
|
|
||||||
|
return similar_products
|
||||||
|
|
||||||
|
def train_recommendation_model(self, model_type='hybrid'):
|
||||||
|
"""Trainiere ML Model für Empfehlungen"""
|
||||||
|
|
||||||
|
# Create model record
|
||||||
|
model = RecommendationModel.objects.create(
|
||||||
|
model_type=model_type,
|
||||||
|
model_name=f"{model_type}_model",
|
||||||
|
model_version="1.0",
|
||||||
|
is_active=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get training data
|
||||||
|
behaviors = UserBehavior.objects.all()
|
||||||
|
training_data_size = behaviors.count()
|
||||||
|
|
||||||
|
# Simple training (in production, use proper ML library)
|
||||||
|
accuracy_score = 0.75 # Placeholder
|
||||||
|
precision_score = 0.70
|
||||||
|
recall_score = 0.65
|
||||||
|
f1_score = 0.67
|
||||||
|
|
||||||
|
# Update model
|
||||||
|
model.training_data_size = training_data_size
|
||||||
|
model.accuracy_score = accuracy_score
|
||||||
|
model.precision_score = precision_score
|
||||||
|
model.recall_score = recall_score
|
||||||
|
model.f1_score = f1_score
|
||||||
|
model.trained_at = timezone.now()
|
||||||
|
model.is_active = True
|
||||||
|
model.save()
|
||||||
|
|
||||||
|
return model
|
||||||
|
|
||||||
|
def update_analytics(self):
|
||||||
|
"""Update Recommendation Analytics"""
|
||||||
|
|
||||||
|
today = timezone.now().date()
|
||||||
|
analytics, created = RecommendationAnalytics.objects.get_or_create(date=today)
|
||||||
|
|
||||||
|
# Calculate today's metrics
|
||||||
|
today_recommendations = Recommendation.objects.filter(
|
||||||
|
created_at__date=today
|
||||||
|
)
|
||||||
|
|
||||||
|
analytics.total_recommendations = today_recommendations.count()
|
||||||
|
analytics.total_clicks = today_recommendations.filter(is_clicked=True).count()
|
||||||
|
analytics.total_purchases = today_recommendations.filter(is_purchased=True).count()
|
||||||
|
|
||||||
|
if analytics.total_recommendations > 0:
|
||||||
|
analytics.click_through_rate = (analytics.total_clicks / analytics.total_recommendations) * 100
|
||||||
|
|
||||||
|
if analytics.total_clicks > 0:
|
||||||
|
analytics.conversion_rate = (analytics.total_purchases / analytics.total_clicks) * 100
|
||||||
|
|
||||||
|
# Calculate revenue
|
||||||
|
purchased_recommendations = today_recommendations.filter(is_purchased=True)
|
||||||
|
total_revenue = sum(
|
||||||
|
rec.product.price for rec in purchased_recommendations
|
||||||
|
)
|
||||||
|
analytics.revenue_from_recommendations = total_revenue
|
||||||
|
|
||||||
|
# Average recommendation score
|
||||||
|
if analytics.total_recommendations > 0:
|
||||||
|
avg_score = today_recommendations.aggregate(
|
||||||
|
avg_score=Avg('confidence_score')
|
||||||
|
)['avg_score'] or 0
|
||||||
|
analytics.avg_recommendation_score = avg_score
|
||||||
|
|
||||||
|
# Per recommendation type
|
||||||
|
analytics.collaborative_recommendations = today_recommendations.filter(
|
||||||
|
recommendation_type='collaborative'
|
||||||
|
).count()
|
||||||
|
analytics.content_based_recommendations = today_recommendations.filter(
|
||||||
|
recommendation_type='content_based'
|
||||||
|
).count()
|
||||||
|
analytics.popular_recommendations = today_recommendations.filter(
|
||||||
|
recommendation_type='popular'
|
||||||
|
).count()
|
||||||
|
analytics.personalized_recommendations = today_recommendations.filter(
|
||||||
|
recommendation_type='personalized'
|
||||||
|
).count()
|
||||||
|
|
||||||
|
analytics.save()
|
||||||
|
|
||||||
|
return analytics
|
||||||
|
|
@ -0,0 +1,365 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Recommendation Analytics - Fursuit Shop{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.analytics-page {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-header {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 30px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-title {
|
||||||
|
color: #4a5568;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-subtitle {
|
||||||
|
color: #718096;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-card {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 25px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-number {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-label {
|
||||||
|
color: #4a5568;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-content {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
color: #4a5568;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 3px solid #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-table th,
|
||||||
|
.analytics-table td {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-table th {
|
||||||
|
background: #f7fafc;
|
||||||
|
color: #4a5568;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-table tr:hover {
|
||||||
|
background: #f7fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-cell {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.percentage-cell {
|
||||||
|
color: #38a169;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-cell {
|
||||||
|
color: #d69e2e;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f7fafc;
|
||||||
|
border-radius: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-placeholder {
|
||||||
|
height: 300px;
|
||||||
|
background: linear-gradient(45deg, #e2e8f0 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #e2e8f0 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #e2e8f0 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #e2e8f0 75%);
|
||||||
|
background-size: 20px 20px;
|
||||||
|
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #718096;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-decoration {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
font-size: 3rem;
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-filter {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input {
|
||||||
|
padding: 10px 15px;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: white;
|
||||||
|
color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-button:hover {
|
||||||
|
background: #5a67d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.totals-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="analytics-page">
|
||||||
|
<div class="furry-decoration">📊</div>
|
||||||
|
|
||||||
|
<div class="analytics-header">
|
||||||
|
<h1 class="analytics-title">Recommendation Analytics</h1>
|
||||||
|
<p class="analytics-subtitle">Detaillierte Einblicke in Empfehlungs-Performance</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date Filter -->
|
||||||
|
<div class="date-filter">
|
||||||
|
<form class="filter-form" method="get">
|
||||||
|
<input type="date" name="start_date" class="filter-input" placeholder="Start Date">
|
||||||
|
<input type="date" name="end_date" class="filter-input" placeholder="End Date">
|
||||||
|
<button type="submit" class="filter-button">Filter anwenden</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Totals Grid -->
|
||||||
|
<div class="totals-grid">
|
||||||
|
<div class="total-card">
|
||||||
|
<div class="total-number">{{ totals.recommendations|floatformat:0 }}</div>
|
||||||
|
<div class="total-label">Gesamt Empfehlungen</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="total-card">
|
||||||
|
<div class="total-number">{{ totals.clicks|floatformat:0 }}</div>
|
||||||
|
<div class="total-label">Gesamt Klicks</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="total-card">
|
||||||
|
<div class="total-number">{{ totals.purchases|floatformat:0 }}</div>
|
||||||
|
<div class="total-label">Gesamt Käufe</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="total-card">
|
||||||
|
<div class="total-number">{{ totals.ctr|floatformat:1 }}%</div>
|
||||||
|
<div class="total-label">Durchschnitt CTR</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="total-card">
|
||||||
|
<div class="total-number">{{ totals.conversion_rate|floatformat:1 }}%</div>
|
||||||
|
<div class="total-label">Durchschnitt Conversion</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="total-card">
|
||||||
|
<div class="total-number">€{{ totals.revenue|floatformat:2 }}</div>
|
||||||
|
<div class="total-label">Gesamt Umsatz</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Analytics Content -->
|
||||||
|
<div class="analytics-content">
|
||||||
|
<h2 class="section-title">📈 Tägliche Analytics</h2>
|
||||||
|
|
||||||
|
<table class="analytics-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th>Empfehlungen</th>
|
||||||
|
<th>Klicks</th>
|
||||||
|
<th>Käufe</th>
|
||||||
|
<th>CTR</th>
|
||||||
|
<th>Conversion</th>
|
||||||
|
<th>Umsatz</th>
|
||||||
|
<th>Durchschnitt Score</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for analytic in analytics %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ analytic.date|date:"d.m.Y" }}</td>
|
||||||
|
<td class="metric-cell">{{ analytic.total_recommendations }}</td>
|
||||||
|
<td class="metric-cell">{{ analytic.total_clicks }}</td>
|
||||||
|
<td class="metric-cell">{{ analytic.total_purchases }}</td>
|
||||||
|
<td class="percentage-cell">{{ analytic.click_through_rate|floatformat:1 }}%</td>
|
||||||
|
<td class="percentage-cell">{{ analytic.conversion_rate|floatformat:1 }}%</td>
|
||||||
|
<td class="revenue-cell">€{{ analytic.revenue_from_recommendations|floatformat:2 }}</td>
|
||||||
|
<td class="metric-cell">{{ analytic.avg_recommendation_score|floatformat:2 }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" style="text-align: center; color: #718096; padding: 20px;">
|
||||||
|
Keine Analytics-Daten verfügbar
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Chart Placeholder -->
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3 style="color: #4a5568; margin-bottom: 15px;">📊 Performance Trends</h3>
|
||||||
|
<div class="chart-placeholder">
|
||||||
|
Chart wird hier angezeigt (Chart.js Integration geplant)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recommendation Type Breakdown -->
|
||||||
|
<h2 class="section-title" style="margin-top: 40px;">🎯 Empfehlungs-Typen</h2>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-top: 20px;">
|
||||||
|
<div class="total-card">
|
||||||
|
<div class="total-number">{{ totals.collaborative|default:0 }}</div>
|
||||||
|
<div class="total-label">Collaborative</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="total-card">
|
||||||
|
<div class="total-number">{{ totals.content_based|default:0 }}</div>
|
||||||
|
<div class="total-label">Content-based</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="total-card">
|
||||||
|
<div class="total-number">{{ totals.popular|default:0 }}</div>
|
||||||
|
<div class="total-label">Popular</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="total-card">
|
||||||
|
<div class="total-number">{{ totals.personalized|default:0 }}</div>
|
||||||
|
<div class="total-label">Personalized</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
// Add interactive features
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Add hover effects to total cards
|
||||||
|
const totalCards = document.querySelectorAll('.total-card');
|
||||||
|
totalCards.forEach(card => {
|
||||||
|
card.addEventListener('mouseenter', function() {
|
||||||
|
this.style.transform = 'translateY(-5px) scale(1.02)';
|
||||||
|
});
|
||||||
|
|
||||||
|
card.addEventListener('mouseleave', function() {
|
||||||
|
this.style.transform = 'translateY(0) scale(1)';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add row hover effects
|
||||||
|
const tableRows = document.querySelectorAll('.analytics-table tbody tr');
|
||||||
|
tableRows.forEach(row => {
|
||||||
|
row.addEventListener('mouseenter', function() {
|
||||||
|
this.style.backgroundColor = '#edf2f7';
|
||||||
|
});
|
||||||
|
|
||||||
|
row.addEventListener('mouseleave', function() {
|
||||||
|
this.style.backgroundColor = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-refresh every 5 minutes
|
||||||
|
setInterval(function() {
|
||||||
|
location.reload();
|
||||||
|
}, 300000);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,306 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Recommendation Dashboard - Fursuit Shop{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.recommendation-dashboard {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 30px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-title {
|
||||||
|
color: #4a5568;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-subtitle {
|
||||||
|
color: #718096;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 25px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: #4a5568;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 25px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
color: #4a5568;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 3px solid #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-list {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-item {
|
||||||
|
background: #f7fafc;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-user {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-type {
|
||||||
|
color: #667eea;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-time {
|
||||||
|
color: #718096;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-card {
|
||||||
|
background: #f7fafc;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-name {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #4a5568;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-accuracy {
|
||||||
|
color: #667eea;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-card {
|
||||||
|
background: #f7fafc;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-name {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #4a5568;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-status {
|
||||||
|
color: #667eea;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furry-decoration {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
font-size: 3rem;
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.content-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="recommendation-dashboard">
|
||||||
|
<div class="furry-decoration">🦊</div>
|
||||||
|
|
||||||
|
<div class="dashboard-header">
|
||||||
|
<h1 class="dashboard-title">Recommendation Dashboard</h1>
|
||||||
|
<p class="dashboard-subtitle">ML-basierte Empfehlungen & Analytics</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number">{{ today_analytics.total_recommendations }}</div>
|
||||||
|
<div class="stat-label">Empfehlungen heute</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number">{{ today_analytics.total_clicks }}</div>
|
||||||
|
<div class="stat-label">Klicks heute</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number">{{ today_analytics.total_purchases }}</div>
|
||||||
|
<div class="stat-label">Käufe heute</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number">{{ today_analytics.click_through_rate|floatformat:1 }}%</div>
|
||||||
|
<div class="stat-label">Click-Through Rate</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number">{{ today_analytics.conversion_rate|floatformat:1 }}%</div>
|
||||||
|
<div class="stat-label">Conversion Rate</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number">€{{ today_analytics.revenue_from_recommendations|floatformat:2 }}</div>
|
||||||
|
<div class="stat-label">Umsatz aus Empfehlungen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Grid -->
|
||||||
|
<div class="content-grid">
|
||||||
|
<div class="main-content">
|
||||||
|
<h2 class="section-title">📊 Aktuelle Aktivitäten</h2>
|
||||||
|
|
||||||
|
<div class="behavior-list">
|
||||||
|
{% for behavior in recent_behaviors %}
|
||||||
|
<div class="behavior-item">
|
||||||
|
<div class="behavior-user">{{ behavior.user.username }}</div>
|
||||||
|
<div class="behavior-type">{{ behavior.get_behavior_type_display }}</div>
|
||||||
|
{% if behavior.product %}
|
||||||
|
<div class="behavior-product">Produkt: {{ behavior.product.name }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="behavior-time">{{ behavior.created_at|timesince }} ago</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p style="color: #718096; text-align: center; padding: 20px;">
|
||||||
|
Noch keine Aktivitäten heute
|
||||||
|
</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar">
|
||||||
|
<h2 class="section-title">🤖 ML Models</h2>
|
||||||
|
|
||||||
|
{% for model in active_models %}
|
||||||
|
<div class="model-card">
|
||||||
|
<div class="model-name">{{ model.model_name }}</div>
|
||||||
|
<div class="model-accuracy">Accuracy: {{ model.accuracy_score|floatformat:2 }}</div>
|
||||||
|
<div class="model-accuracy">Type: {{ model.get_model_type_display }}</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p style="color: #718096; text-align: center; padding: 20px;">
|
||||||
|
Keine aktiven Models
|
||||||
|
</p>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<h2 class="section-title" style="margin-top: 30px;">🧪 A/B Tests</h2>
|
||||||
|
|
||||||
|
{% for test in active_tests %}
|
||||||
|
<div class="test-card">
|
||||||
|
<div class="test-name">{{ test.test_name }}</div>
|
||||||
|
<div class="test-status">Status: {{ test.get_status_display }}</div>
|
||||||
|
<div class="test-status">Traffic: {{ test.traffic_split|floatformat:0 }}%</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p style="color: #718096; text-align: center; padding: 20px;">
|
||||||
|
Keine aktiven Tests
|
||||||
|
</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
// Auto-refresh dashboard every 30 seconds
|
||||||
|
setInterval(function() {
|
||||||
|
location.reload();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
// Add some interactive elements
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Add hover effects to stat cards
|
||||||
|
const statCards = document.querySelectorAll('.stat-card');
|
||||||
|
statCards.forEach(card => {
|
||||||
|
card.addEventListener('mouseenter', function() {
|
||||||
|
this.style.transform = 'translateY(-5px) scale(1.02)';
|
||||||
|
});
|
||||||
|
|
||||||
|
card.addEventListener('mouseleave', function() {
|
||||||
|
this.style.transform = 'translateY(0) scale(1)';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""
|
||||||
|
Recommendation Engine URLs
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = 'recommendations'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# API Endpoints
|
||||||
|
path('api/recommendations/', views.get_recommendations, name='api_recommendations'),
|
||||||
|
path('api/similar-products/<int:product_id>/', views.get_similar_products, name='api_similar_products'),
|
||||||
|
path('api/track-behavior/', views.track_behavior, name='api_track_behavior'),
|
||||||
|
path('api/user-profile/', views.get_user_profile, name='api_user_profile'),
|
||||||
|
path('api/update-preferences/', views.update_user_preferences, name='api_update_preferences'),
|
||||||
|
path('api/popular-products/', views.get_popular_products, name='api_popular_products'),
|
||||||
|
path('api/trending-products/', views.get_trending_products, name='api_trending_products'),
|
||||||
|
path('api/frequently-bought/<int:product_id>/', views.get_frequently_bought_together, name='api_frequently_bought'),
|
||||||
|
path('api/track-recommendation-click/', views.track_recommendation_click, name='api_track_click'),
|
||||||
|
path('api/track-recommendation-purchase/', views.track_recommendation_purchase, name='api_track_purchase'),
|
||||||
|
|
||||||
|
# Admin Views
|
||||||
|
path('dashboard/', views.recommendation_dashboard, name='dashboard'),
|
||||||
|
path('analytics/', views.recommendation_analytics, name='analytics'),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,442 @@
|
||||||
|
"""
|
||||||
|
Recommendation Engine Views für ML-basierte Empfehlungen
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.db.models import Q, Count, Avg, Sum
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from .models import UserBehavior, UserProfile, ProductSimilarity, Recommendation, RecommendationModel, ABTest, RecommendationAnalytics
|
||||||
|
from .services import RecommendationService
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_recommendations(request):
|
||||||
|
"""Personalisiertes Empfehlungen für User"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get user profile
|
||||||
|
profile, created = UserProfile.objects.get_or_create(user=request.user)
|
||||||
|
|
||||||
|
# Get recommendations
|
||||||
|
recommendation_service = RecommendationService()
|
||||||
|
recommendations = recommendation_service.get_recommendations_for_user(request.user)
|
||||||
|
|
||||||
|
# Track recommendation view
|
||||||
|
UserBehavior.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
behavior_type='view',
|
||||||
|
metadata={'recommendation_count': len(recommendations)}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'recommendations': recommendations,
|
||||||
|
'user_profile': {
|
||||||
|
'engagement_score': profile.engagement_score,
|
||||||
|
'loyalty_level': profile.loyalty_level,
|
||||||
|
'preferred_categories': profile.preferred_categories,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_similar_products(request, product_id):
|
||||||
|
"""Ähnliche Produkte für ein spezifisches Produkt"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from products.models import Product
|
||||||
|
product = Product.objects.get(id=product_id)
|
||||||
|
|
||||||
|
# Get similar products
|
||||||
|
similar_products = ProductSimilarity.objects.filter(
|
||||||
|
product=product
|
||||||
|
).select_related('similar_product').order_by('-similarity_score')[:10]
|
||||||
|
|
||||||
|
recommendations = []
|
||||||
|
for similarity in similar_products:
|
||||||
|
recommendations.append({
|
||||||
|
'product': {
|
||||||
|
'id': similarity.similar_product.id,
|
||||||
|
'name': similarity.similar_product.name,
|
||||||
|
'price': float(similarity.similar_product.price),
|
||||||
|
'image_url': similarity.similar_product.image.url if similarity.similar_product.image else None,
|
||||||
|
},
|
||||||
|
'similarity_score': similarity.similarity_score,
|
||||||
|
'similarity_type': similarity.similarity_type,
|
||||||
|
'reason': f"Ähnlich zu {product.name}"
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response({'similar_products': recommendations})
|
||||||
|
|
||||||
|
except Product.DoesNotExist:
|
||||||
|
return Response({'error': 'Product not found'}, status=404)
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def track_behavior(request):
|
||||||
|
"""User Behavior tracken"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request.data
|
||||||
|
behavior_type = data.get('behavior_type')
|
||||||
|
product_id = data.get('product_id')
|
||||||
|
auction_id = data.get('auction_id')
|
||||||
|
metadata = data.get('metadata', {})
|
||||||
|
|
||||||
|
if not behavior_type:
|
||||||
|
return Response({'error': 'Behavior type required'}, status=400)
|
||||||
|
|
||||||
|
# Create behavior record
|
||||||
|
behavior = UserBehavior.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
behavior_type=behavior_type,
|
||||||
|
product_id=product_id,
|
||||||
|
auction_id=auction_id,
|
||||||
|
session_id=data.get('session_id', ''),
|
||||||
|
ip_address=request.META.get('REMOTE_ADDR'),
|
||||||
|
user_agent=request.META.get('HTTP_USER_AGENT', ''),
|
||||||
|
metadata=metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update user profile
|
||||||
|
profile, created = UserProfile.objects.get_or_create(user=request.user)
|
||||||
|
profile.update_engagement_score()
|
||||||
|
|
||||||
|
return Response({'behavior_id': str(behavior.id)})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_user_profile(request):
|
||||||
|
"""User Profile für Empfehlungen abrufen"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
profile, created = UserProfile.objects.get_or_create(user=request.user)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'engagement_score': profile.engagement_score,
|
||||||
|
'loyalty_level': profile.loyalty_level,
|
||||||
|
'preferred_categories': profile.preferred_categories,
|
||||||
|
'preferred_fursuit_types': profile.preferred_fursuit_types,
|
||||||
|
'preferred_price_range': profile.preferred_price_range,
|
||||||
|
'total_purchases': profile.total_purchases,
|
||||||
|
'total_views': profile.total_views,
|
||||||
|
'last_active': profile.last_active,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def update_user_preferences(request):
|
||||||
|
"""User Preferences aktualisieren"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request.data
|
||||||
|
profile, created = UserProfile.objects.get_or_create(user=request.user)
|
||||||
|
|
||||||
|
# Update preferences
|
||||||
|
if 'preferred_categories' in data:
|
||||||
|
profile.preferred_categories = data['preferred_categories']
|
||||||
|
if 'preferred_fursuit_types' in data:
|
||||||
|
profile.preferred_fursuit_types = data['preferred_fursuit_types']
|
||||||
|
if 'preferred_price_range' in data:
|
||||||
|
profile.preferred_price_range = data['preferred_price_range']
|
||||||
|
if 'preferred_colors' in data:
|
||||||
|
profile.preferred_colors = data['preferred_colors']
|
||||||
|
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
return Response({'message': 'Preferences updated'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_popular_products(request):
|
||||||
|
"""Beliebte Produkte basierend auf Verhalten"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get popular products based on behavior
|
||||||
|
popular_products = Product.objects.annotate(
|
||||||
|
view_count=Count('behaviors', filter=Q(behaviors__behavior_type='view')),
|
||||||
|
purchase_count=Count('behaviors', filter=Q(behaviors__behavior_type='purchase')),
|
||||||
|
cart_count=Count('behaviors', filter=Q(behaviors__behavior_type='cart_add')),
|
||||||
|
).filter(
|
||||||
|
is_active=True
|
||||||
|
).order_by('-purchase_count', '-view_count')[:10]
|
||||||
|
|
||||||
|
recommendations = []
|
||||||
|
for product in popular_products:
|
||||||
|
recommendations.append({
|
||||||
|
'product': {
|
||||||
|
'id': product.id,
|
||||||
|
'name': product.name,
|
||||||
|
'price': float(product.price),
|
||||||
|
'image_url': product.image.url if product.image else None,
|
||||||
|
'category': product.category,
|
||||||
|
},
|
||||||
|
'popularity_score': product.purchase_count + (product.view_count * 0.1),
|
||||||
|
'reason': 'Beliebt bei anderen Kunden'
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response({'popular_products': recommendations})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_trending_products(request):
|
||||||
|
"""Trending Produkte (recent activity)"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get trending products (recent activity)
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
recent_date = timezone.now() - timedelta(days=7)
|
||||||
|
|
||||||
|
trending_products = Product.objects.annotate(
|
||||||
|
recent_views=Count('behaviors', filter=Q(
|
||||||
|
behaviors__behavior_type='view',
|
||||||
|
behaviors__created_at__gte=recent_date
|
||||||
|
)),
|
||||||
|
recent_purchases=Count('behaviors', filter=Q(
|
||||||
|
behaviors__behavior_type='purchase',
|
||||||
|
behaviors__created_at__gte=recent_date
|
||||||
|
)),
|
||||||
|
).filter(
|
||||||
|
is_active=True,
|
||||||
|
recent_views__gt=0
|
||||||
|
).order_by('-recent_purchases', '-recent_views')[:10]
|
||||||
|
|
||||||
|
recommendations = []
|
||||||
|
for product in trending_products:
|
||||||
|
recommendations.append({
|
||||||
|
'product': {
|
||||||
|
'id': product.id,
|
||||||
|
'name': product.name,
|
||||||
|
'price': float(product.price),
|
||||||
|
'image_url': product.image.url if product.image else None,
|
||||||
|
'category': product.category,
|
||||||
|
},
|
||||||
|
'trending_score': product.recent_purchases + (product.recent_views * 0.1),
|
||||||
|
'reason': 'Trending diese Woche'
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response({'trending_products': recommendations})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_frequently_bought_together(request, product_id):
|
||||||
|
"""Häufig zusammen gekaufte Produkte"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from products.models import Product
|
||||||
|
product = Product.objects.get(id=product_id)
|
||||||
|
|
||||||
|
# Get users who bought this product
|
||||||
|
buyers = UserBehavior.objects.filter(
|
||||||
|
product=product,
|
||||||
|
behavior_type='purchase'
|
||||||
|
).values_list('user', flat=True).distinct()
|
||||||
|
|
||||||
|
# Get other products bought by same users
|
||||||
|
frequently_bought = Product.objects.filter(
|
||||||
|
behaviors__user__in=buyers,
|
||||||
|
behaviors__behavior_type='purchase'
|
||||||
|
).exclude(id=product_id).annotate(
|
||||||
|
bought_together_count=Count('behaviors', filter=Q(
|
||||||
|
behaviors__behavior_type='purchase',
|
||||||
|
behaviors__user__in=buyers
|
||||||
|
))
|
||||||
|
).filter(
|
||||||
|
bought_together_count__gt=0,
|
||||||
|
is_active=True
|
||||||
|
).order_by('-bought_together_count')[:5]
|
||||||
|
|
||||||
|
recommendations = []
|
||||||
|
for product in frequently_bought:
|
||||||
|
recommendations.append({
|
||||||
|
'product': {
|
||||||
|
'id': product.id,
|
||||||
|
'name': product.name,
|
||||||
|
'price': float(product.price),
|
||||||
|
'image_url': product.image.url if product.image else None,
|
||||||
|
},
|
||||||
|
'bought_together_count': product.bought_together_count,
|
||||||
|
'reason': 'Häufig zusammen gekauft'
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response({'frequently_bought_together': recommendations})
|
||||||
|
|
||||||
|
except Product.DoesNotExist:
|
||||||
|
return Response({'error': 'Product not found'}, status=404)
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def track_recommendation_click(request):
|
||||||
|
"""Recommendation Click tracken"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request.data
|
||||||
|
product_id = data.get('product_id')
|
||||||
|
recommendation_type = data.get('recommendation_type')
|
||||||
|
|
||||||
|
if not product_id or not recommendation_type:
|
||||||
|
return Response({'error': 'Product ID and recommendation type required'}, status=400)
|
||||||
|
|
||||||
|
# Update recommendation
|
||||||
|
recommendation = Recommendation.objects.filter(
|
||||||
|
user=request.user,
|
||||||
|
product_id=product_id,
|
||||||
|
recommendation_type=recommendation_type
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if recommendation:
|
||||||
|
recommendation.is_clicked = True
|
||||||
|
recommendation.save()
|
||||||
|
|
||||||
|
# Track behavior
|
||||||
|
UserBehavior.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
behavior_type='view',
|
||||||
|
product_id=product_id,
|
||||||
|
metadata={'recommendation_type': recommendation_type}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({'success': True})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def track_recommendation_purchase(request):
|
||||||
|
"""Recommendation Purchase tracken"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request.data
|
||||||
|
product_id = data.get('product_id')
|
||||||
|
recommendation_type = data.get('recommendation_type')
|
||||||
|
|
||||||
|
if not product_id or not recommendation_type:
|
||||||
|
return Response({'error': 'Product ID and recommendation type required'}, status=400)
|
||||||
|
|
||||||
|
# Update recommendation
|
||||||
|
recommendation = Recommendation.objects.filter(
|
||||||
|
user=request.user,
|
||||||
|
product_id=product_id,
|
||||||
|
recommendation_type=recommendation_type
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if recommendation:
|
||||||
|
recommendation.is_purchased = True
|
||||||
|
recommendation.save()
|
||||||
|
|
||||||
|
# Track behavior
|
||||||
|
UserBehavior.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
behavior_type='purchase',
|
||||||
|
product_id=product_id,
|
||||||
|
metadata={'recommendation_type': recommendation_type}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({'success': True})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
# Admin Views
|
||||||
|
@login_required
|
||||||
|
def recommendation_dashboard(request):
|
||||||
|
"""Admin Dashboard für Empfehlungen"""
|
||||||
|
|
||||||
|
if not request.user.is_staff:
|
||||||
|
return JsonResponse({'error': 'Access denied'}, status=403)
|
||||||
|
|
||||||
|
# Get analytics
|
||||||
|
today_analytics = RecommendationAnalytics.get_or_create_today()
|
||||||
|
|
||||||
|
# Get recent behaviors
|
||||||
|
recent_behaviors = UserBehavior.objects.select_related('user', 'product').order_by('-created_at')[:20]
|
||||||
|
|
||||||
|
# Get active models
|
||||||
|
active_models = RecommendationModel.objects.filter(is_active=True)
|
||||||
|
|
||||||
|
# Get A/B tests
|
||||||
|
active_tests = ABTest.objects.filter(status='running')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'today_analytics': today_analytics,
|
||||||
|
'recent_behaviors': recent_behaviors,
|
||||||
|
'active_models': active_models,
|
||||||
|
'active_tests': active_tests,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'recommendations/dashboard.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def recommendation_analytics(request):
|
||||||
|
"""Recommendation Analytics"""
|
||||||
|
|
||||||
|
if not request.user.is_staff:
|
||||||
|
return JsonResponse({'error': 'Access denied'}, status=403)
|
||||||
|
|
||||||
|
# Get analytics for last 30 days
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
analytics = RecommendationAnalytics.objects.filter(
|
||||||
|
date__gte=timezone.now().date() - timedelta(days=30)
|
||||||
|
).order_by('date')
|
||||||
|
|
||||||
|
# Calculate totals
|
||||||
|
total_recommendations = sum(a.total_recommendations for a in analytics)
|
||||||
|
total_clicks = sum(a.total_clicks for a in analytics)
|
||||||
|
total_purchases = sum(a.total_purchases for a in analytics)
|
||||||
|
total_revenue = sum(a.revenue_from_recommendations for a in analytics)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'analytics': analytics,
|
||||||
|
'totals': {
|
||||||
|
'recommendations': total_recommendations,
|
||||||
|
'clicks': total_clicks,
|
||||||
|
'purchases': total_purchases,
|
||||||
|
'revenue': total_revenue,
|
||||||
|
'ctr': (total_clicks / total_recommendations * 100) if total_recommendations > 0 else 0,
|
||||||
|
'conversion_rate': (total_purchases / total_clicks * 100) if total_clicks > 0 else 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'recommendations/analytics.html', context)
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Django Core
|
||||||
|
Django==5.2.1
|
||||||
|
djangorestframework==3.14.0
|
||||||
|
django-cors-headers==4.3.1
|
||||||
|
|
||||||
|
# Database (SQLite für einfaches Setup)
|
||||||
|
# psycopg2-binary==2.9.9
|
||||||
|
|
||||||
|
# Image Processing
|
||||||
|
Pillow==10.1.0
|
||||||
|
|
||||||
|
# Payment Processing
|
||||||
|
stripe==7.8.0
|
||||||
|
|
||||||
|
# Search Engine (optional)
|
||||||
|
# django-haystack==3.2.1
|
||||||
|
# elasticsearch==8.11.0
|
||||||
|
|
||||||
|
# Real-time Features (optional)
|
||||||
|
# channels==4.0.0
|
||||||
|
# channels-redis==4.1.0
|
||||||
|
# daphne==4.0.0
|
||||||
|
|
||||||
|
# API Documentation
|
||||||
|
drf-yasg==1.21.7
|
||||||
|
|
||||||
|
# Development Tools
|
||||||
|
django-debug-toolbar==4.2.0
|
||||||
|
|
||||||
|
# Production
|
||||||
|
gunicorn==21.2.0
|
||||||
|
whitenoise==6.6.0
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
python-decouple==3.8
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
# Django Core
|
||||||
|
Django==5.2.1
|
||||||
|
djangorestframework==3.14.0
|
||||||
|
django-cors-headers==4.3.1
|
||||||
|
|
||||||
|
# Database
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
|
||||||
|
# Image Processing
|
||||||
|
Pillow==10.1.0
|
||||||
|
|
||||||
|
# Payment Processing
|
||||||
|
stripe==7.8.0
|
||||||
|
|
||||||
|
# Search Engine
|
||||||
|
django-haystack==3.3.0
|
||||||
|
elasticsearch==7.17.12
|
||||||
|
|
||||||
|
# Real-time Features
|
||||||
|
channels==4.0.0
|
||||||
|
channels-redis==4.1.0
|
||||||
|
daphne==4.0.0
|
||||||
|
|
||||||
|
# API Documentation
|
||||||
|
drf-yasg==1.21.7
|
||||||
|
|
||||||
|
# Development Tools
|
||||||
|
django-debug-toolbar==4.2.0
|
||||||
|
|
||||||
|
# Production
|
||||||
|
gunicorn==21.2.0
|
||||||
|
whitenoise==6.6.0
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
python-decouple==3.8
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
django-paypal==2.1.0
|
||||||
|
django-payments==3.0.1
|
||||||
|
django-filter==24.2
|
||||||
|
paypalrestsdk==1.13.3
|
||||||
|
coreapi==2.3.3
|
||||||
|
numpy==1.24.3
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
"""
|
||||||
|
Haystack Search Indexes für Elasticsearch
|
||||||
|
"""
|
||||||
|
|
||||||
|
# from haystack import indexes
|
||||||
|
# from haystack import site
|
||||||
|
from products.models import Product
|
||||||
|
from auction.models import Auction
|
||||||
|
|
||||||
|
|
||||||
|
class ProductIndex(indexes.SearchIndex, indexes.Indexable):
|
||||||
|
"""Product Search Index"""
|
||||||
|
|
||||||
|
text = indexes.CharField(document=True, use_template=True)
|
||||||
|
title = indexes.CharField(model_attr='name', boost=2.0)
|
||||||
|
description = indexes.CharField(model_attr='description', boost=1.5)
|
||||||
|
category = indexes.CharField(model_attr='category', faceted=True)
|
||||||
|
fursuit_type = indexes.CharField(model_attr='fursuit_type', faceted=True)
|
||||||
|
price = indexes.DecimalField(model_attr='price')
|
||||||
|
featured = indexes.BooleanField(model_attr='featured')
|
||||||
|
created_at = indexes.DateTimeField(model_attr='created_at')
|
||||||
|
|
||||||
|
# Autocomplete fields
|
||||||
|
title_auto = indexes.EdgeNgramField(model_attr='name')
|
||||||
|
description_auto = indexes.EdgeNgramField(model_attr='description')
|
||||||
|
category_auto = indexes.EdgeNgramField(model_attr='category')
|
||||||
|
|
||||||
|
# Suggest field
|
||||||
|
title_suggest = indexes.CharField(model_attr='name')
|
||||||
|
|
||||||
|
def get_model(self):
|
||||||
|
return Product
|
||||||
|
|
||||||
|
def index_queryset(self, using=None):
|
||||||
|
"""Used when the entire index for model is updated."""
|
||||||
|
return self.get_model().objects.filter(is_active=True)
|
||||||
|
|
||||||
|
def prepare_text(self, obj):
|
||||||
|
"""Prepare text field for search"""
|
||||||
|
return f"{obj.name} {obj.description} {obj.category} {obj.fursuit_type}"
|
||||||
|
|
||||||
|
def prepare_title_suggest(self, obj):
|
||||||
|
"""Prepare suggestion field"""
|
||||||
|
return obj.name
|
||||||
|
|
||||||
|
|
||||||
|
class AuctionIndex(indexes.SearchIndex, indexes.Indexable):
|
||||||
|
"""Auction Search Index"""
|
||||||
|
|
||||||
|
text = indexes.CharField(document=True, use_template=True)
|
||||||
|
title = indexes.CharField(model_attr='title', boost=2.0)
|
||||||
|
description = indexes.CharField(model_attr='description', boost=1.5)
|
||||||
|
character_description = indexes.CharField(model_attr='character_description', boost=1.2)
|
||||||
|
fursuit_type = indexes.CharField(model_attr='fursuit_type', faceted=True)
|
||||||
|
status = indexes.CharField(model_attr='status', faceted=True)
|
||||||
|
starting_bid = indexes.DecimalField(model_attr='starting_bid')
|
||||||
|
current_bid = indexes.DecimalField(model_attr='current_bid')
|
||||||
|
created_at = indexes.DateTimeField(model_attr='created_at')
|
||||||
|
end_time = indexes.DateTimeField(model_attr='end_time')
|
||||||
|
|
||||||
|
# Autocomplete fields
|
||||||
|
title_auto = indexes.EdgeNgramField(model_attr='title')
|
||||||
|
description_auto = indexes.EdgeNgramField(model_attr='description')
|
||||||
|
character_description_auto = indexes.EdgeNgramField(model_attr='character_description')
|
||||||
|
|
||||||
|
# Suggest field
|
||||||
|
title_suggest = indexes.CharField(model_attr='title')
|
||||||
|
|
||||||
|
def get_model(self):
|
||||||
|
return Auction
|
||||||
|
|
||||||
|
def index_queryset(self, using=None):
|
||||||
|
"""Used when the entire index for model is updated."""
|
||||||
|
return self.get_model().objects.filter(status__in=['active', 'draft'])
|
||||||
|
|
||||||
|
def prepare_text(self, obj):
|
||||||
|
"""Prepare text field for search"""
|
||||||
|
return f"{obj.title} {obj.description} {obj.character_description} {obj.fursuit_type}"
|
||||||
|
|
||||||
|
def prepare_title_suggest(self, obj):
|
||||||
|
"""Prepare suggestion field"""
|
||||||
|
return obj.title
|
||||||
|
|
||||||
|
|
||||||
|
# Register indexes
|
||||||
|
# site.register(Product, ProductIndex)
|
||||||
|
# site.register(Auction, AuctionIndex)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue