mirror of
https://git.linux-kernel.at/oliver/ivatar.git
synced 2025-11-11 18:56:23 +00:00
Compare commits
3 Commits
87f4e45afa
...
ai-playgro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56cceb5724 | ||
|
|
5a005d6845 | ||
|
|
493f9405dd |
27
config.py
27
config.py
@@ -296,6 +296,33 @@ TRUSTED_DEFAULT_URLS = list(map(map_legacy_config, TRUSTED_DEFAULT_URLS))
|
||||
BLUESKY_IDENTIFIER = os.environ.get("BLUESKY_IDENTIFIER", None)
|
||||
BLUESKY_APP_PASSWORD = os.environ.get("BLUESKY_APP_PASSWORD", None)
|
||||
|
||||
# Celery Configuration
|
||||
# Try Redis first, fallback to memory broker for development
|
||||
try:
|
||||
import redis
|
||||
|
||||
redis.Redis(host="localhost", port=6379, db=0).ping()
|
||||
CELERY_BROKER_URL = "redis://localhost:6379/0"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# Fallback to memory broker for development
|
||||
CELERY_BROKER_URL = "memory://"
|
||||
print("Warning: Redis not available, using memory broker for development")
|
||||
|
||||
CELERY_RESULT_BACKEND = "django-db"
|
||||
CELERY_ACCEPT_CONTENT = ["json"]
|
||||
CELERY_TASK_SERIALIZER = "json"
|
||||
CELERY_RESULT_SERIALIZER = "json"
|
||||
CELERY_TIMEZONE = "UTC"
|
||||
CELERY_TASK_TRACK_STARTED = True
|
||||
CELERY_TASK_TIME_LIMIT = 300 # 5 minutes
|
||||
CELERY_TASK_SOFT_TIME_LIMIT = 240 # 4 minutes
|
||||
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
|
||||
CELERY_TASK_ACKS_LATE = True
|
||||
CELERY_RESULT_EXPIRES = 3600 # 1 hour
|
||||
CELERY_WORKER_CONCURRENCY = (
|
||||
1 # Max 1 parallel avatar generation task for local development
|
||||
)
|
||||
|
||||
# This MUST BE THE LAST!
|
||||
if os.path.isfile(os.path.join(BASE_DIR, "config_local.py")):
|
||||
from config_local import * # noqa # flake8: noqa # NOQA # pragma: no cover
|
||||
|
||||
@@ -3,4 +3,10 @@
|
||||
Module init
|
||||
"""
|
||||
|
||||
# This will make sure the app is always imported when
|
||||
# Django starts so that shared_task will use this app.
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ("celery_app",)
|
||||
|
||||
app_label = __name__ # pylint: disable=invalid-name
|
||||
|
||||
461
ivatar/ai_service.py
Normal file
461
ivatar/ai_service.py
Normal file
@@ -0,0 +1,461 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
AI service module for text-to-image avatar generation
|
||||
Supports Stable Diffusion (local and API) for professional avatar generation
|
||||
"""
|
||||
|
||||
import logging
|
||||
import requests
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AIServiceError(Exception):
|
||||
"""Custom exception for AI service errors"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class StableDiffusionService:
|
||||
"""
|
||||
Service for generating images using Stable Diffusion (local or API)
|
||||
"""
|
||||
|
||||
# Model-specific token limits
|
||||
TOKEN_LIMITS = {
|
||||
"stable_diffusion": 77, # CLIP tokenizer limit
|
||||
"stable_diffusion_v2": 77,
|
||||
"stable_diffusion_xl": 77,
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.api_url = getattr(settings, "STABLE_DIFFUSION_API_URL", None)
|
||||
self.api_key = getattr(settings, "STABLE_DIFFUSION_API_KEY", None)
|
||||
self.timeout = getattr(settings, "STABLE_DIFFUSION_TIMEOUT", 60)
|
||||
self._pipe = None # Cache for local model
|
||||
self._tokenizer = None # Cache for tokenizer
|
||||
|
||||
def generate_image(
|
||||
self, prompt, size=(512, 512), quality="medium", allow_nsfw=False
|
||||
):
|
||||
"""
|
||||
Generate an image from text prompt using Stable Diffusion
|
||||
|
||||
Args:
|
||||
prompt (str): Text description of the desired image
|
||||
size (tuple): Image dimensions (width, height)
|
||||
quality (str): Generation quality ('low', 'medium', 'high')
|
||||
allow_nsfw (bool): Whether to allow potentially NSFW content
|
||||
|
||||
Returns:
|
||||
PIL.Image: Generated image
|
||||
|
||||
Raises:
|
||||
AIServiceError: If generation fails or prompt is too long
|
||||
"""
|
||||
# Validate prompt length first
|
||||
validation = self.validate_prompt(prompt)
|
||||
if not validation["valid"]:
|
||||
raise AIServiceError(validation["warning"])
|
||||
|
||||
try:
|
||||
if self.api_url and self.api_key:
|
||||
return self._generate_via_api(prompt, size, quality, allow_nsfw)
|
||||
else:
|
||||
return self._generate_locally(prompt, size, quality, allow_nsfw)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate image: {e}")
|
||||
raise AIServiceError(f"Image generation failed: {str(e)}")
|
||||
|
||||
def validate_prompt(self, prompt, model="stable_diffusion"):
|
||||
"""
|
||||
Validate prompt length against model token limits
|
||||
|
||||
Args:
|
||||
prompt (str): Text prompt to validate
|
||||
model (str): Model name to check limits for
|
||||
|
||||
Returns:
|
||||
dict: Validation result with 'valid', 'token_count', 'limit', 'warning'
|
||||
"""
|
||||
try:
|
||||
token_count = self._count_tokens(prompt)
|
||||
limit = self.TOKEN_LIMITS.get(model, 77)
|
||||
|
||||
is_valid = token_count <= limit
|
||||
warning = None
|
||||
|
||||
if not is_valid:
|
||||
warning = f"Prompt too long: {token_count} tokens (limit: {limit}). Please shorten your prompt."
|
||||
|
||||
return {
|
||||
"valid": is_valid,
|
||||
"token_count": token_count,
|
||||
"limit": limit,
|
||||
"warning": warning,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Token counting failed: {e}")
|
||||
return {
|
||||
"valid": True, # Allow generation if counting fails
|
||||
"token_count": 0,
|
||||
"limit": 77,
|
||||
"warning": None,
|
||||
}
|
||||
|
||||
def _count_tokens(self, prompt):
|
||||
"""
|
||||
Count tokens in a prompt using CLIP tokenizer
|
||||
"""
|
||||
try:
|
||||
if self._tokenizer is None:
|
||||
from transformers import CLIPTokenizer
|
||||
|
||||
self._tokenizer = CLIPTokenizer.from_pretrained(
|
||||
"openai/clip-vit-base-patch32"
|
||||
)
|
||||
|
||||
tokens = self._tokenizer(prompt, return_tensors="pt", truncation=False)[
|
||||
"input_ids"
|
||||
]
|
||||
return tokens.shape[1]
|
||||
|
||||
except ImportError:
|
||||
# Fallback: more accurate estimation
|
||||
# CLIP tokenizer typically produces ~1.3 tokens per word for English
|
||||
words = len(prompt.split())
|
||||
return int(words * 1.3)
|
||||
except Exception as e:
|
||||
logger.warning(f"Token counting error: {e}")
|
||||
# Fallback: more accurate estimation
|
||||
words = len(prompt.split())
|
||||
return int(words * 1.3)
|
||||
|
||||
def _is_black_image(self, image):
|
||||
"""
|
||||
Check if an image is completely black (common NSFW response from APIs)
|
||||
|
||||
Args:
|
||||
image (PIL.Image): Image to check
|
||||
|
||||
Returns:
|
||||
bool: True if image is completely black
|
||||
"""
|
||||
# Convert to RGB if necessary
|
||||
if image.mode != "RGB":
|
||||
image = image.convert("RGB")
|
||||
|
||||
# Get image data
|
||||
pixels = list(image.getdata())
|
||||
|
||||
# Check if all pixels are black (0, 0, 0)
|
||||
black_pixels = sum(1 for r, g, b in pixels if r == 0 and g == 0 and b == 0)
|
||||
total_pixels = len(pixels)
|
||||
|
||||
# Consider it a black image if more than 95% of pixels are black
|
||||
return (black_pixels / total_pixels) > 0.95
|
||||
|
||||
def _generate_via_api(self, prompt, size, quality, allow_nsfw=False):
|
||||
"""
|
||||
Generate image via Stable Diffusion API (Replicate, Hugging Face, etc.)
|
||||
"""
|
||||
# Enhanced prompt for avatar generation
|
||||
enhanced_prompt = f"""professional avatar portrait, {prompt}, high quality, detailed, clean background, centered composition, profile picture style, photorealistic"""
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
payload = {
|
||||
"prompt": enhanced_prompt,
|
||||
"width": size[0],
|
||||
"height": size[1],
|
||||
"num_inference_steps": 25
|
||||
if quality == "high"
|
||||
else (20 if quality == "medium" else 15),
|
||||
"guidance_scale": 7.5, # Balanced for quality and speed
|
||||
"negative_prompt": "blurry, low quality, distorted, ugly, deformed, bad anatomy",
|
||||
}
|
||||
|
||||
# Add NSFW safety setting if supported by the API
|
||||
if allow_nsfw:
|
||||
payload["safety_tolerance"] = 2 # Some APIs support this
|
||||
payload["nsfw"] = True # Some APIs support this
|
||||
|
||||
response = requests.post(
|
||||
self.api_url, json=payload, headers=headers, timeout=self.timeout
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
error_msg = f"Stable Diffusion API request failed: {response.status_code}"
|
||||
try:
|
||||
error_detail = response.json()
|
||||
error_msg += f" - {error_detail}"
|
||||
|
||||
# Check for NSFW content detection
|
||||
if isinstance(error_detail, dict):
|
||||
error_text = str(error_detail).lower()
|
||||
if (
|
||||
"nsfw" in error_text
|
||||
or "inappropriate" in error_text
|
||||
or "black image" in error_text
|
||||
):
|
||||
if allow_nsfw:
|
||||
# If user allowed NSFW but still got blocked, provide a different message
|
||||
raise AIServiceError(
|
||||
"Content warning: The AI service still detected inappropriate content even with relaxed settings. Please try a different prompt or contact support if you believe this is an error."
|
||||
)
|
||||
else:
|
||||
raise AIServiceError(
|
||||
"Content warning: The AI service detected potentially inappropriate content in your prompt. Please modify your description to be more appropriate for all ages and try again."
|
||||
)
|
||||
elif isinstance(error_detail, str):
|
||||
if (
|
||||
"nsfw" in error_detail.lower()
|
||||
or "inappropriate" in error_detail.lower()
|
||||
or "black image" in error_detail.lower()
|
||||
):
|
||||
if allow_nsfw:
|
||||
raise AIServiceError(
|
||||
"Content warning: The AI service still detected inappropriate content even with relaxed settings. Please try a different prompt or contact support if you believe this is an error."
|
||||
)
|
||||
else:
|
||||
raise AIServiceError(
|
||||
"Content warning: The AI service detected potentially inappropriate content in your prompt. Please modify your description to be more appropriate for all ages and try again."
|
||||
)
|
||||
|
||||
except AIServiceError:
|
||||
# Re-raise our custom NSFW error
|
||||
raise
|
||||
except Exception: # pylint: disable=broad-except
|
||||
error_msg += f" - {response.text}"
|
||||
|
||||
# Also check response text for NSFW warnings
|
||||
if (
|
||||
"nsfw" in response.text.lower()
|
||||
or "inappropriate" in response.text.lower()
|
||||
or "black image" in response.text.lower()
|
||||
):
|
||||
if allow_nsfw:
|
||||
raise AIServiceError(
|
||||
"Content warning: The AI service still detected inappropriate content even with relaxed settings. Please try a different prompt or contact support if you believe this is an error."
|
||||
)
|
||||
else:
|
||||
raise AIServiceError(
|
||||
"Content warning: The AI service detected potentially inappropriate content in your prompt. Please modify your description to be more appropriate for all ages and try again."
|
||||
)
|
||||
|
||||
raise AIServiceError(error_msg)
|
||||
|
||||
result = response.json()
|
||||
|
||||
if "image" in result:
|
||||
# Decode base64 image
|
||||
image_data = base64.b64decode(result["image"])
|
||||
image = Image.open(BytesIO(image_data))
|
||||
|
||||
# Check if the image is completely black (common NSFW response)
|
||||
if not allow_nsfw and self._is_black_image(image):
|
||||
raise AIServiceError(
|
||||
"Content warning: The AI service detected potentially inappropriate content in your prompt and returned a black image. Please modify your description to be more appropriate for all ages and try again."
|
||||
)
|
||||
|
||||
return image
|
||||
else:
|
||||
raise AIServiceError("No image data in API response")
|
||||
|
||||
def _generate_locally(self, prompt, size, quality, allow_nsfw=False):
|
||||
"""
|
||||
Generate image using local Stable Diffusion installation
|
||||
This requires diffusers library and a local model
|
||||
"""
|
||||
try:
|
||||
from diffusers import StableDiffusionPipeline
|
||||
import torch
|
||||
|
||||
# Enhanced prompt for avatar generation
|
||||
enhanced_prompt = f"""professional avatar portrait, {prompt}, high quality, detailed, clean background, centered composition, profile picture style, photorealistic"""
|
||||
|
||||
# Use cached model if available, otherwise load it
|
||||
if self._pipe is None:
|
||||
logger.info("Loading Stable Diffusion model (first time or cache miss)")
|
||||
self._pipe = StableDiffusionPipeline.from_pretrained(
|
||||
"runwayml/stable-diffusion-v1-5",
|
||||
torch_dtype=torch.float16
|
||||
if torch.cuda.is_available()
|
||||
else torch.float32,
|
||||
)
|
||||
|
||||
if torch.cuda.is_available():
|
||||
self._pipe = self._pipe.to("cuda")
|
||||
else:
|
||||
logger.info("Using cached Stable Diffusion model")
|
||||
|
||||
pipe = self._pipe
|
||||
|
||||
# Disable safety checker if NSFW override is enabled
|
||||
if allow_nsfw:
|
||||
pipe.safety_checker = None
|
||||
pipe.requires_safety_checker = False
|
||||
|
||||
# Generate image with optimized settings for speed
|
||||
image = pipe(
|
||||
enhanced_prompt,
|
||||
height=size[1],
|
||||
width=size[0],
|
||||
num_inference_steps=25
|
||||
if quality == "high"
|
||||
else (20 if quality == "medium" else 15),
|
||||
guidance_scale=7.5, # Balanced for quality and speed
|
||||
negative_prompt="blurry, low quality, distorted, ugly, deformed, bad anatomy",
|
||||
).images[0]
|
||||
|
||||
return image
|
||||
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"diffusers library not installed, falling back to placeholder"
|
||||
)
|
||||
return self._generate_placeholder(prompt, size)
|
||||
except Exception as e:
|
||||
logger.error(f"Local Stable Diffusion generation failed: {e}")
|
||||
return self._generate_placeholder(prompt, size)
|
||||
|
||||
def _generate_placeholder(self, prompt, size):
|
||||
"""
|
||||
Generate a placeholder image when Stable Diffusion is not available
|
||||
"""
|
||||
logger.info("Generating placeholder image")
|
||||
|
||||
# Create a more sophisticated placeholder
|
||||
img = Image.new("RGBA", size, color=(240, 248, 255, 255))
|
||||
|
||||
from PIL import ImageDraw, ImageFont
|
||||
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
try:
|
||||
font = ImageFont.load_default()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
font = None
|
||||
|
||||
# Add title
|
||||
title = "AI Avatar (Stable Diffusion)"
|
||||
draw.text((10, 10), title, fill="darkblue", font=font)
|
||||
|
||||
# Add prompt
|
||||
prompt_text = f"Prompt: {prompt[:50]}..."
|
||||
draw.text((10, 40), prompt_text, fill="black", font=font)
|
||||
|
||||
# Add note
|
||||
note = "Install Stable Diffusion for real generation"
|
||||
draw.text((10, 70), note, fill="darkgreen", font=font)
|
||||
|
||||
# Create a more sophisticated avatar placeholder
|
||||
center_x, center_y = size[0] // 2, size[1] // 2 + 20
|
||||
radius = min(size) // 4
|
||||
|
||||
# Face circle
|
||||
draw.ellipse(
|
||||
[
|
||||
center_x - radius,
|
||||
center_y - radius,
|
||||
center_x + radius,
|
||||
center_y + radius,
|
||||
],
|
||||
outline="purple",
|
||||
width=3,
|
||||
fill=(255, 240, 245),
|
||||
)
|
||||
|
||||
# Eyes
|
||||
eye_radius = radius // 4
|
||||
draw.ellipse(
|
||||
[
|
||||
center_x - radius // 2 - eye_radius,
|
||||
center_y - radius // 2 - eye_radius,
|
||||
center_x - radius // 2 + eye_radius,
|
||||
center_y - radius // 2 + eye_radius,
|
||||
],
|
||||
fill="blue",
|
||||
)
|
||||
draw.ellipse(
|
||||
[
|
||||
center_x + radius // 2 - eye_radius,
|
||||
center_y - radius // 2 - eye_radius,
|
||||
center_x + radius // 2 + eye_radius,
|
||||
center_y - radius // 2 + eye_radius,
|
||||
],
|
||||
fill="blue",
|
||||
)
|
||||
|
||||
# Smile
|
||||
smile_y = center_y + radius // 3
|
||||
draw.arc(
|
||||
[
|
||||
center_x - radius // 2,
|
||||
smile_y - radius // 4,
|
||||
center_x + radius // 2,
|
||||
smile_y + radius // 4,
|
||||
],
|
||||
0,
|
||||
180,
|
||||
fill="red",
|
||||
width=3,
|
||||
)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def validate_avatar_prompt(prompt, model="stable_diffusion"):
|
||||
"""
|
||||
Convenience function to validate avatar prompts
|
||||
|
||||
Args:
|
||||
prompt (str): Text description of the avatar
|
||||
model (str): AI model to use
|
||||
|
||||
Returns:
|
||||
dict: Validation result with 'valid', 'token_count', 'limit', 'warning'
|
||||
"""
|
||||
if model == "stable_diffusion":
|
||||
service = StableDiffusionService()
|
||||
return service.validate_prompt(prompt, model)
|
||||
else:
|
||||
# For other models, assume they're valid
|
||||
return {"valid": True, "token_count": 0, "limit": 0, "warning": None}
|
||||
|
||||
|
||||
def generate_avatar_image(
|
||||
prompt,
|
||||
model="stable_diffusion",
|
||||
size=(512, 512),
|
||||
quality="medium",
|
||||
allow_nsfw=False,
|
||||
):
|
||||
"""
|
||||
Convenience function to generate avatar images
|
||||
|
||||
Args:
|
||||
prompt (str): Text description of the avatar
|
||||
model (str): AI model to use (currently only 'stable_diffusion')
|
||||
size (tuple): Image dimensions
|
||||
quality (str): Generation quality ('low', 'medium', 'high')
|
||||
allow_nsfw (bool): Whether to allow potentially NSFW content
|
||||
|
||||
Returns:
|
||||
PIL.Image: Generated avatar image
|
||||
"""
|
||||
if model == "stable_diffusion":
|
||||
service = StableDiffusionService()
|
||||
return service.generate_image(prompt, size, quality, allow_nsfw)
|
||||
else:
|
||||
raise AIServiceError(
|
||||
f"Unsupported model: {model}. Only 'stable_diffusion' is currently supported."
|
||||
)
|
||||
56
ivatar/celery.py
Normal file
56
ivatar/celery.py
Normal file
@@ -0,0 +1,56 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Celery configuration for ivatar
|
||||
"""
|
||||
|
||||
import os
|
||||
from celery import Celery
|
||||
from django.conf import settings
|
||||
|
||||
# Set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ivatar.settings")
|
||||
|
||||
app = Celery("ivatar")
|
||||
|
||||
# Using a string here means the worker doesn't have to serialize
|
||||
# the configuration object to child processes.
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
|
||||
# Load task modules from all registered Django apps.
|
||||
app.autodiscover_tasks()
|
||||
|
||||
# Celery configuration
|
||||
app.conf.update(
|
||||
# Task routing - use default queue for simplicity
|
||||
task_default_queue="default",
|
||||
task_routes={
|
||||
"ivatar.tasks.generate_avatar_task": {"queue": "default"},
|
||||
"ivatar.tasks.update_queue_positions": {"queue": "default"},
|
||||
"ivatar.tasks.cleanup_old_tasks": {"queue": "default"},
|
||||
},
|
||||
# Worker configuration
|
||||
worker_prefetch_multiplier=1,
|
||||
task_acks_late=True,
|
||||
# Result backend
|
||||
result_backend="django-db",
|
||||
result_expires=3600, # 1 hour
|
||||
# Task time limits
|
||||
task_time_limit=300, # 5 minutes
|
||||
task_soft_time_limit=240, # 4 minutes
|
||||
# Task serialization
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
# Timezone
|
||||
timezone="UTC",
|
||||
enable_utc=True,
|
||||
)
|
||||
|
||||
# Set worker concurrency from Django settings
|
||||
if hasattr(settings, "CELERY_WORKER_CONCURRENCY"):
|
||||
app.conf.worker_concurrency = settings.CELERY_WORKER_CONCURRENCY
|
||||
|
||||
|
||||
@app.task(bind=True)
|
||||
def debug_task(self):
|
||||
print(f"Request: {self.request!r}")
|
||||
@@ -217,6 +217,89 @@ class UploadLibravatarExportForm(forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class GenerateAvatarForm(forms.Form):
|
||||
"""
|
||||
Form for generating avatars using AI text-to-image
|
||||
"""
|
||||
|
||||
MODEL_CHOICES = [
|
||||
("stable_diffusion", "Stable Diffusion"),
|
||||
# Future models can be added here
|
||||
]
|
||||
|
||||
prompt = forms.CharField(
|
||||
label=_("Avatar Description"),
|
||||
max_length=500,
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"rows": 3,
|
||||
"placeholder": _(
|
||||
'Describe the avatar you want to create, e.g., "A friendly robot with blue eyes"'
|
||||
),
|
||||
"id": "id_prompt",
|
||||
"data-token-limit": "77",
|
||||
"data-model": "stable_diffusion",
|
||||
}
|
||||
),
|
||||
help_text=_(
|
||||
"Describe the avatar you want to generate. Be specific about appearance, style, and mood.<br><small class='text-muted'>Stable Diffusion has a 77-token limit. Keep your description concise for best results.</small>"
|
||||
),
|
||||
)
|
||||
|
||||
model = forms.ChoiceField(
|
||||
label=_("AI Model"),
|
||||
choices=MODEL_CHOICES,
|
||||
initial="stable_diffusion",
|
||||
help_text=_("Select the AI model to use for generation."),
|
||||
)
|
||||
|
||||
quality = forms.ChoiceField(
|
||||
label=_("Generation Quality"),
|
||||
choices=[
|
||||
("low", _("Low (faster, lower quality)")),
|
||||
("medium", _("Medium (balanced)")),
|
||||
("high", _("High (slower, better quality)")),
|
||||
],
|
||||
initial="medium",
|
||||
help_text=_("Higher quality takes longer but produces better results."),
|
||||
)
|
||||
|
||||
not_porn = forms.BooleanField(
|
||||
label=_("Suitable for all ages (no offensive content)"),
|
||||
required=True,
|
||||
error_messages={
|
||||
"required": _(
|
||||
'We only host "G-rated" images and so this field must be checked.'
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
can_distribute = forms.BooleanField(
|
||||
label=_("Can be freely copied"),
|
||||
required=True,
|
||||
error_messages={
|
||||
"required": _(
|
||||
"This field must be checked since we need to be able to distribute photos to third parties."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
def clean_prompt(self):
|
||||
"""Validate prompt length against token limits"""
|
||||
prompt = self.cleaned_data.get("prompt", "")
|
||||
model = self.cleaned_data.get("model", "stable_diffusion")
|
||||
|
||||
if prompt:
|
||||
from ivatar.ai_service import validate_avatar_prompt
|
||||
|
||||
validation = validate_avatar_prompt(prompt, model)
|
||||
|
||||
if not validation["valid"]:
|
||||
raise forms.ValidationError(validation["warning"])
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
class DeleteAccountForm(forms.Form):
|
||||
password = forms.CharField(
|
||||
label=_("Password"), required=False, widget=forms.PasswordInput()
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 5.2.1 on 2025-09-17 10:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("ivataraccount", "0020_confirmedopenid_bluesky_handle"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="photo",
|
||||
name="ai_generated",
|
||||
field=models.BooleanField(
|
||||
default=False, help_text="Whether this photo was generated by AI"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="photo",
|
||||
name="ai_model",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="The AI model used for generation",
|
||||
max_length=50,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="photo",
|
||||
name="ai_prompt",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
help_text="The prompt used to generate this image",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="photo",
|
||||
name="ai_quality",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="The quality setting used",
|
||||
max_length=20,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
80
ivatar/ivataraccount/migrations/0022_add_generation_task.py
Normal file
80
ivatar/ivataraccount/migrations/0022_add_generation_task.py
Normal file
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 5.2.1 on 2025-09-17 10:25
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("ivataraccount", "0021_add_ai_generation_fields"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="GenerationTask",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ip_address",
|
||||
models.GenericIPAddressField(null=True, unpack_ipv4=True),
|
||||
),
|
||||
("add_date", models.DateTimeField(default=django.utils.timezone.now)),
|
||||
("prompt", models.TextField()),
|
||||
("model", models.CharField(max_length=50)),
|
||||
("quality", models.CharField(max_length=20)),
|
||||
("allow_nsfw", models.BooleanField(default=False)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("processing", "Processing"),
|
||||
("completed", "Completed"),
|
||||
("failed", "Failed"),
|
||||
("cancelled", "Cancelled"),
|
||||
],
|
||||
default="pending",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("progress", models.IntegerField(default=0)),
|
||||
("queue_position", models.IntegerField(default=0)),
|
||||
("task_id", models.CharField(blank=True, max_length=255, null=True)),
|
||||
("error_message", models.TextField(blank=True, null=True)),
|
||||
(
|
||||
"generated_photo",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="ivataraccount.photo",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Generation Task",
|
||||
"verbose_name_plural": "Generation Tasks",
|
||||
"ordering": ["-add_date"],
|
||||
},
|
||||
),
|
||||
]
|
||||
22
ivatar/ivataraccount/migrations/0023_add_ai_invalid_field.py
Normal file
22
ivatar/ivataraccount/migrations/0023_add_ai_invalid_field.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 5.2.1 on 2025-09-17 10:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("ivataraccount", "0022_add_generation_task"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="photo",
|
||||
name="ai_invalid",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this AI-generated image is invalid (black, etc.)",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -118,6 +118,42 @@ class BaseAccountModel(models.Model):
|
||||
abstract = True
|
||||
|
||||
|
||||
class GenerationTask(BaseAccountModel):
|
||||
"""
|
||||
Model to track avatar generation tasks in the queue
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
("pending", _("Pending")),
|
||||
("processing", _("Processing")),
|
||||
("completed", _("Completed")),
|
||||
("failed", _("Failed")),
|
||||
("cancelled", _("Cancelled")),
|
||||
]
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
prompt = models.TextField()
|
||||
model = models.CharField(max_length=50)
|
||||
quality = models.CharField(max_length=20)
|
||||
allow_nsfw = models.BooleanField(default=False)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending")
|
||||
progress = models.IntegerField(default=0) # 0-100
|
||||
queue_position = models.IntegerField(default=0)
|
||||
task_id = models.CharField(max_length=255, blank=True, null=True) # Celery task ID
|
||||
error_message = models.TextField(blank=True, null=True)
|
||||
generated_photo = models.ForeignKey(
|
||||
"Photo", on_delete=models.SET_NULL, blank=True, null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-add_date"]
|
||||
verbose_name = _("Generation Task")
|
||||
verbose_name_plural = _("Generation Tasks")
|
||||
|
||||
def __str__(self):
|
||||
return f"Task {self.pk}: {self.prompt[:50]}... ({self.status})"
|
||||
|
||||
|
||||
class Photo(BaseAccountModel):
|
||||
"""
|
||||
Model holding the photos and information about them
|
||||
@@ -128,6 +164,27 @@ class Photo(BaseAccountModel):
|
||||
format = models.CharField(max_length=4)
|
||||
access_count = models.BigIntegerField(default=0, editable=False)
|
||||
|
||||
# AI Generation metadata
|
||||
ai_generated = models.BooleanField(
|
||||
default=False, help_text=_("Whether this photo was generated by AI")
|
||||
)
|
||||
ai_prompt = models.TextField(
|
||||
blank=True, null=True, help_text=_("The prompt used to generate this image")
|
||||
)
|
||||
ai_model = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_("The AI model used for generation"),
|
||||
)
|
||||
ai_quality = models.CharField(
|
||||
max_length=20, blank=True, null=True, help_text=_("The quality setting used")
|
||||
)
|
||||
ai_invalid = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_("Whether this AI-generated image is invalid (black, etc.)"),
|
||||
)
|
||||
|
||||
class Meta: # pylint: disable=too-few-public-methods
|
||||
"""
|
||||
Class attributes
|
||||
@@ -136,6 +193,49 @@ class Photo(BaseAccountModel):
|
||||
verbose_name = _("photo")
|
||||
verbose_name_plural = _("photos")
|
||||
|
||||
def is_valid_avatar(self):
|
||||
"""
|
||||
Check if this photo is a valid avatar (not black/invalid)
|
||||
"""
|
||||
if not self.ai_generated:
|
||||
return True # Non-AI photos are assumed valid
|
||||
|
||||
# If we've already marked it as invalid, return False
|
||||
if self.ai_invalid:
|
||||
return False
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
# Load the image data
|
||||
image_data = io.BytesIO(self.data)
|
||||
image = Image.open(image_data)
|
||||
|
||||
# Convert to RGB if needed
|
||||
if image.mode != "RGB":
|
||||
image = image.convert("RGB")
|
||||
|
||||
# Check if image is predominantly black (common NSFW response)
|
||||
pixels = list(image.getdata())
|
||||
black_pixels = sum(1 for r, g, b in pixels if r == 0 and g == 0 and b == 0)
|
||||
total_pixels = len(pixels)
|
||||
|
||||
# If more than 95% black pixels, consider it invalid
|
||||
black_ratio = black_pixels / total_pixels
|
||||
is_valid = black_ratio < 0.95
|
||||
|
||||
# Cache the result
|
||||
if not is_valid:
|
||||
self.ai_invalid = True
|
||||
self.save(update_fields=["ai_invalid"])
|
||||
|
||||
return is_valid
|
||||
|
||||
except Exception:
|
||||
# If we can't analyze the image, assume it's valid
|
||||
return True
|
||||
|
||||
def import_image(self, service_name, email_address):
|
||||
"""
|
||||
Allow to import image from other (eg. Gravatar) service
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
<input type="text" name="bluesky_handle" required value="" placeholder="{% trans 'Bluesky handle' %}" class="form-control" id="id_bluesky_handle">
|
||||
{% endif %}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">{% trans 'Assign Bluesky handle' %}</button>
|
||||
<button type="submit" class="btn btn-primary">{% trans 'Assign Bluesky Handle' %}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
<input type="text" name="bluesky_handle" required value="" placeholder="{% trans 'Bluesky handle' %}" class="form-control" id="id_bluesky_handle">
|
||||
{% endif %}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">{% trans 'Assign Bluesky handle' %}</button>
|
||||
<button type="submit" class="btn btn-primary">{% trans 'Assign Bluesky Handle' %}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
318
ivatar/ivataraccount/templates/avatar_gallery.html
Normal file
318
ivatar/ivataraccount/templates/avatar_gallery.html
Normal file
@@ -0,0 +1,318 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load bootstrap4 %}
|
||||
|
||||
{% block title %}{% trans 'Avatar Gallery' %}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.avatar-gallery-card {
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.avatar-gallery-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 25px rgba(0,0,0,0.15);
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.avatar-image-container {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.gallery-section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.gallery-section h4 {
|
||||
color: #495057;
|
||||
border-bottom: 2px solid #007bff;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Fix button display issues - override base CSS */
|
||||
.btn {
|
||||
display: inline-block !important;
|
||||
padding: 0.375rem 0.75rem !important;
|
||||
margin-bottom: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.25rem;
|
||||
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
min-width: auto !important;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem !important;
|
||||
font-size: 0.875rem;
|
||||
border-radius: 0.2rem;
|
||||
min-width: auto !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
color: #007bff !important;
|
||||
border-color: #007bff !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover {
|
||||
color: #fff !important;
|
||||
background-color: #007bff !important;
|
||||
border-color: #007bff !important;
|
||||
}
|
||||
|
||||
.btn-outline-secondary {
|
||||
color: #6c757d !important;
|
||||
border-color: #6c757d !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.btn-outline-secondary:hover {
|
||||
color: #fff !important;
|
||||
background-color: #6c757d !important;
|
||||
border-color: #6c757d !important;
|
||||
}
|
||||
|
||||
/* Ensure FontAwesome icons display */
|
||||
.fa {
|
||||
font-family: "FontAwesome" !important;
|
||||
font-weight: normal;
|
||||
display: inline-block;
|
||||
font-style: normal;
|
||||
font-variant: normal;
|
||||
text-rendering: auto;
|
||||
line-height: 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Suppress Firefox font warnings */
|
||||
@font-face {
|
||||
font-family: "FontAwesome";
|
||||
src: url("../fonts/fontawesome-webfont.woff2?v=4.7.0") format("woff2"),
|
||||
url("../fonts/fontawesome-webfont.woff?v=4.7.0") format("woff"),
|
||||
url("../fonts/fontawesome-webfont.ttf?v=4.7.0") format("truetype"),
|
||||
url("../fonts/fontawesome-webfont.svg?v=4.7.0#fontawesomeregular") format("svg");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h2>{% trans 'Avatar Gallery' %}</h2>
|
||||
<p class="lead">
|
||||
{% trans 'Browse recently generated avatars for inspiration. Click on any avatar to reuse its prompt.' %}
|
||||
</p>
|
||||
|
||||
<!-- User's Own Avatars -->
|
||||
{% if user_avatars %}
|
||||
<div class="mb-4 gallery-section">
|
||||
<h4><i class="fa fa-user"></i> {% trans 'Your Recent Avatars' %}</h4>
|
||||
<div class="row">
|
||||
{% for avatar in user_avatars %}
|
||||
<div class="col-md-3 col-sm-4 col-6 mb-3">
|
||||
<div class="card h-100 avatar-gallery-card">
|
||||
<div class="card-body p-2">
|
||||
<div class="avatar-image-container text-center d-flex justify-content-center align-items-center">
|
||||
<img src="{% url 'raw_image' avatar.pk %}"
|
||||
alt="Avatar"
|
||||
class="img-fluid rounded avatar-image"
|
||||
style="width: 120px; height: 120px; object-fit: contain;">
|
||||
</div>
|
||||
<div class="card-text">
|
||||
<small class="text-muted">
|
||||
{{ avatar.add_date|date:"M d, Y" }}
|
||||
</small>
|
||||
<p class="small mb-2" style="height: 40px; overflow: hidden;">
|
||||
{{ avatar.ai_prompt|truncatechars:60 }}
|
||||
</p>
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{% url 'reuse_prompt' avatar.pk %}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="fa fa-recycle"></i> {% trans 'Reuse' %}
|
||||
</a>
|
||||
<a href="{% url 'avatar_preview' avatar.pk %}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fa fa-eye"></i> {% trans 'View' %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Community Gallery -->
|
||||
<div class="mb-4 gallery-section">
|
||||
<h4><i class="fa fa-users"></i> {% trans 'Community Gallery' %}</h4>
|
||||
<p class="text-muted">
|
||||
{% trans 'Recently generated avatars from all users (last 30)' %}
|
||||
</p>
|
||||
|
||||
{% if avatars %}
|
||||
<div class="row">
|
||||
{% for avatar in avatars %}
|
||||
<div class="col-md-3 col-sm-4 col-6 mb-3">
|
||||
<div class="card h-100 avatar-gallery-card">
|
||||
<div class="card-body p-2">
|
||||
<div class="avatar-image-container text-center d-flex justify-content-center align-items-center">
|
||||
<img src="{% url 'raw_image' avatar.pk %}"
|
||||
alt="Avatar"
|
||||
class="img-fluid rounded avatar-image"
|
||||
style="width: 120px; height: 120px; object-fit: contain;">
|
||||
</div>
|
||||
<div class="card-text">
|
||||
<small class="text-muted">
|
||||
{{ avatar.add_date|date:"M d, Y" }}
|
||||
{% if avatar.user == user %}
|
||||
<span class="badge badge-info">{% trans 'Yours' %}</span>
|
||||
{% endif %}
|
||||
</small>
|
||||
<p class="small mb-2" style="height: 40px; overflow: hidden;">
|
||||
{{ avatar.ai_prompt|truncatechars:60 }}
|
||||
</p>
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{% url 'reuse_prompt' avatar.pk %}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="fa fa-recycle"></i> {% trans 'Reuse' %}
|
||||
</a>
|
||||
<a href="{% url 'avatar_preview' avatar.pk %}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fa fa-eye"></i> {% trans 'View' %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if is_paginated %}
|
||||
<nav aria-label="Gallery pagination">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page=1">{% trans 'First' %}</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">{% trans 'Previous' %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class="page-item active">
|
||||
<span class="page-link">
|
||||
{% trans 'Page' %} {{ page_obj.number }} {% trans 'of' %} {{ page_obj.paginator.num_pages }}
|
||||
</span>
|
||||
</li>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}">{% trans 'Next' %}</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">{% trans 'Last' %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fa fa-image fa-3x text-muted mb-3"></i>
|
||||
<h5>{% trans 'No Avatars Yet' %}</h5>
|
||||
<p class="text-muted">
|
||||
{% trans 'No AI-generated avatars have been created yet. Be the first to generate one!' %}
|
||||
</p>
|
||||
<a href="{% url 'generate_avatar' %}" class="btn btn-primary">
|
||||
<i class="fa fa-magic"></i> {% trans 'Generate Avatar' %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="text-center mb-4">
|
||||
<a href="{% url 'generate_avatar' %}" class="btn btn-primary btn-lg">
|
||||
<i class="fa fa-magic"></i> {% trans 'Generate New Avatar' %}
|
||||
</a>
|
||||
<a href="{% url 'profile' %}" class="btn btn-outline-secondary btn-lg">
|
||||
<i class="fa fa-user"></i> {% trans 'Back to Profile' %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Tips -->
|
||||
<div class="mt-4">
|
||||
<h5>{% trans 'Gallery Tips' %}</h5>
|
||||
<ul class="list-unstyled">
|
||||
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||
{% trans 'Click "Reuse" to copy a prompt and modify it for your own avatar' %}
|
||||
</li>
|
||||
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||
{% trans 'Click "View" to see the full avatar and assign it to your emails' %}
|
||||
</li>
|
||||
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||
{% trans 'Use the gallery to find inspiration for your own avatar descriptions' %}
|
||||
</li>
|
||||
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||
{% trans 'Your own avatars are shown at the top for quick access' %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add click animation to reuse buttons
|
||||
const reuseButtons = document.querySelectorAll('a[href*="reuse_prompt"]');
|
||||
reuseButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
this.innerHTML = '<i class="fa fa-spinner fa-spin"></i> {% trans "Loading..." %}';
|
||||
this.classList.add('disabled');
|
||||
});
|
||||
});
|
||||
|
||||
// Add click animation to view buttons
|
||||
const viewButtons = document.querySelectorAll('a[href*="avatar_preview"]');
|
||||
viewButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
this.innerHTML = '<i class="fa fa-spinner fa-spin"></i> {% trans "Loading..." %}';
|
||||
this.classList.add('disabled');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
198
ivatar/ivataraccount/templates/avatar_preview.html
Normal file
198
ivatar/ivataraccount/templates/avatar_preview.html
Normal file
@@ -0,0 +1,198 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load bootstrap4 %}
|
||||
|
||||
{% block title %}{% trans 'Avatar Preview' %}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<h2>{% trans 'Avatar Preview' %}</h2>
|
||||
<p class="lead">
|
||||
{% trans 'Here\'s your generated avatar. You can refine it or assign it to your email addresses.' %}
|
||||
</p>
|
||||
|
||||
<!-- Avatar Display -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body text-center">
|
||||
<h4>{% trans 'Generated Avatar' %}</h4>
|
||||
<div class="avatar-preview-container mb-3">
|
||||
<img src="{{ photo_url }}" alt="Generated Avatar" class="img-fluid rounded" style="max-width: 400px; max-height: 400px;">
|
||||
</div>
|
||||
<p class="text-muted">
|
||||
{% trans 'Generated on' %} {{ photo.add_date|date:"F d, Y \a\t H:i" }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Refinement Form -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5><i class="fa fa-magic"></i> {% trans 'Refine Avatar' %}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">
|
||||
{% trans 'Not satisfied with the result? Modify your description and generate a new avatar.' %}
|
||||
</p>
|
||||
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Custom form rendering for better control -->
|
||||
<div class="form-group">
|
||||
<label for="id_prompt">{{ form.prompt.label }}</label>
|
||||
<textarea name="prompt"
|
||||
id="id_prompt"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
maxlength="500"
|
||||
data-token-limit="77"
|
||||
data-model="stable_diffusion"
|
||||
placeholder="{{ form.prompt.field.widget.attrs.placeholder }}"
|
||||
required>{{ form.prompt.value|default:'' }}</textarea>
|
||||
<small class="form-text text-muted">
|
||||
{{ form.prompt.help_text|safe }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_model">{{ form.model.label }}</label>
|
||||
<select name="model" id="id_model" class="form-control">
|
||||
{% for value, label in form.model.field.choices %}
|
||||
<option value="{{ value }}" {% if value == form.model.value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">{{ form.model.help_text }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_quality">{{ form.quality.label }}</label>
|
||||
<select name="quality" id="id_quality" class="form-control">
|
||||
{% for value, label in form.quality.field.choices %}
|
||||
<option value="{{ value }}" {% if value == form.quality.value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">{{ form.quality.help_text }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_not_porn">{{ form.not_porn.label }}</label>
|
||||
<input type="checkbox" name="not_porn" class="form-control" required id="id_not_porn" {% if form.not_porn.value %}checked{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_can_distribute">{{ form.can_distribute.label }}</label>
|
||||
<input type="checkbox" name="can_distribute" class="form-control" required id="id_can_distribute" {% if form.can_distribute.value %}checked{% endif %}>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa fa-refresh"></i> {% trans 'Regenerate Avatar' %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assignment Options -->
|
||||
{% if confirmed_emails %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5><i class="fa fa-envelope"></i> {% trans 'Assign to Email Addresses' %}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">
|
||||
{% trans 'Assign this avatar to one or more of your confirmed email addresses.' %}
|
||||
</p>
|
||||
|
||||
<div class="list-group">
|
||||
{% for email in confirmed_emails %}
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>{{ email.email }}</strong>
|
||||
{% if email.photo_id == photo.pk %}
|
||||
<span class="badge badge-success ml-2">{% trans 'Currently assigned' %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
{% if email.photo_id != photo.pk %}
|
||||
<a href="{% url 'assign_photo_email' email.pk %}?photo_id={{ photo.pk }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="fa fa-link"></i> {% trans 'Assign' %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-success">
|
||||
<i class="fa fa-check"></i> {% trans 'Assigned' %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-body text-center">
|
||||
<h5>{% trans 'No Email Addresses' %}</h5>
|
||||
<p class="text-muted">
|
||||
{% trans 'You need to add and confirm email addresses before you can assign avatars to them.' %}
|
||||
</p>
|
||||
<a href="{% url 'add_email' %}" class="btn btn-primary">
|
||||
<i class="fa fa-plus"></i> {% trans 'Add Email Address' %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<a href="{% url 'generate_avatar' %}" class="btn btn-outline-primary btn-block">
|
||||
<i class="fa fa-plus"></i> {% trans 'Generate New Avatar' %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<a href="{% url 'profile' %}" class="btn btn-outline-secondary btn-block">
|
||||
<i class="fa fa-user"></i> {% trans 'Back to Profile' %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tips -->
|
||||
<div class="mt-4">
|
||||
<h5>{% trans 'Tips for Better Results' %}</h5>
|
||||
<ul class="list-unstyled">
|
||||
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||
{% trans 'Be more specific: "A friendly robot with blue LED eyes and silver metallic body"' %}
|
||||
</li>
|
||||
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||
{% trans 'Add style keywords: "cartoon style", "realistic", "anime", "pixel art"' %}
|
||||
</li>
|
||||
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||
{% trans 'Include mood: "cheerful", "serious", "mysterious", "professional"' %}
|
||||
</li>
|
||||
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||
{% trans 'Specify lighting: "soft lighting", "dramatic shadows", "bright and clear"' %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% endblock %}
|
||||
145
ivatar/ivataraccount/templates/generate_avatar.html
Normal file
145
ivatar/ivataraccount/templates/generate_avatar.html
Normal file
@@ -0,0 +1,145 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load bootstrap4 %}
|
||||
|
||||
{% block title %}{% trans 'Generate AI Avatar' %}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<h2>{% trans 'Generate AI Avatar' %}</h2>
|
||||
<p class="lead">
|
||||
{% trans 'Create a unique avatar using artificial intelligence. Describe what you want and our AI will generate it for you.' %}
|
||||
</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Custom form rendering for better control -->
|
||||
<div class="form-group">
|
||||
<label for="id_prompt">{{ form.prompt.label }}</label>
|
||||
<textarea name="prompt"
|
||||
id="id_prompt"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
maxlength="500"
|
||||
data-token-limit="77"
|
||||
data-model="stable_diffusion"
|
||||
placeholder="{{ form.prompt.field.widget.attrs.placeholder }}"
|
||||
required>{{ form.prompt.value|default:'' }}</textarea>
|
||||
<small class="form-text text-muted">
|
||||
{{ form.prompt.help_text|safe }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_model">{{ form.model.label }}</label>
|
||||
<select name="model" id="id_model" class="form-control">
|
||||
{% for value, label in form.model.field.choices %}
|
||||
<option value="{{ value }}" {% if value == form.model.value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">{{ form.model.help_text }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_quality">{{ form.quality.label }}</label>
|
||||
<select name="quality" id="id_quality" class="form-control">
|
||||
{% for value, label in form.quality.field.choices %}
|
||||
<option value="{{ value }}" {% if value == form.quality.value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">{{ form.quality.help_text }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_not_porn">{{ form.not_porn.label }}</label>
|
||||
<input type="checkbox" name="not_porn" class="form-control" required id="id_not_porn" {% if form.not_porn.value %}checked{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_can_distribute">{{ form.can_distribute.label }}</label>
|
||||
<input type="checkbox" name="can_distribute" class="form-control" required id="id_can_distribute" {% if form.can_distribute.value %}checked{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="fa fa-magic"></i> {% trans 'Generate Avatar' %}
|
||||
</button>
|
||||
<a href="{% url 'profile' %}" class="btn btn-secondary btn-lg">
|
||||
{% trans 'Cancel' %}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h4>{% trans 'Tips for Better Results' %}</h4>
|
||||
<ul class="list-unstyled">
|
||||
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||
{% trans 'Be specific about appearance, style, and mood' %}
|
||||
</li>
|
||||
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||
{% trans 'Include details like hair color, clothing, or facial expressions' %}
|
||||
</li>
|
||||
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||
{% trans 'Try different art styles: "cartoon", "realistic", "anime", "pixel art"' %}
|
||||
</li>
|
||||
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||
{% trans 'Keep descriptions appropriate for all ages' %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h4>{% trans 'Example Prompts' %}</h4>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6>{% trans 'Character Avatar' %}</h6>
|
||||
<p class="text-muted small">
|
||||
"A friendly robot with blue eyes and a silver body, cartoon style"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6>{% trans 'Abstract Avatar' %}</h6>
|
||||
<p class="text-muted small">
|
||||
"Colorful geometric shapes forming a face, modern art style"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 alert alert-info">
|
||||
<h5><i class="fa fa-info-circle"></i> {% trans 'Important Notes' %}</h5>
|
||||
<ul class="mb-0">
|
||||
<li>{% trans 'Avatar generation may take 30-60 seconds depending on server load' %}</li>
|
||||
<li>{% trans 'Generated avatars are automatically saved to your account' %}</li>
|
||||
<li>{% trans 'You can assign the generated avatar to any of your email addresses' %}</li>
|
||||
<li>{% trans 'All generated content must be appropriate for all ages' %}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% endblock %}
|
||||
405
ivatar/ivataraccount/templates/generation_status.html
Normal file
405
ivatar/ivataraccount/templates/generation_status.html
Normal file
@@ -0,0 +1,405 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{% trans 'Avatar Generation Status' %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<h2>{% trans 'Avatar Generation Status' %}</h2>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5><i class="fa fa-magic"></i> {% trans 'Generation Task' %}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p><strong>{% trans 'Prompt:' %}</strong></p>
|
||||
<p class="text-muted">{{ task.prompt }}</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p><strong>{% trans 'Model:' %}</strong> {{ task.model|title }}</p>
|
||||
<p><strong>{% trans 'Quality:' %}</strong> {{ task.quality|title }}</p>
|
||||
<p><strong>{% trans 'Status:' %}</strong>
|
||||
<span class="badge badge-{% if task.status == 'completed' %}success{% elif task.status == 'failed' %}danger{% elif task.status == 'processing' %}warning{% else %}secondary{% endif %} status-badge">
|
||||
{{ task.get_status_display }}
|
||||
{% if task.status == 'processing' %}
|
||||
<i class="fa fa-spinner fa-spin ml-1"></i>
|
||||
{% elif task.status == 'pending' %}
|
||||
<i class="fa fa-clock ml-1"></i>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="live-indicator ml-2">
|
||||
<i class="fa fa-circle text-success"></i>
|
||||
<small class="text-muted">Live</small>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if task.status == 'processing' %}
|
||||
<div class="mt-3">
|
||||
<div class="progress-container">
|
||||
<div class="progress">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
||||
role="progressbar"
|
||||
style="width: {{ task.progress }}%"
|
||||
aria-valuenow="{{ task.progress }}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100">
|
||||
{{ task.progress }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-info mt-2">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">
|
||||
<i class="fa fa-magic"></i> {% trans 'Generating your avatar...' %}
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-md-6 text-right">
|
||||
<small class="text-muted">
|
||||
<i class="fa fa-refresh fa-spin"></i>
|
||||
<span class="last-updated">{% trans 'Updated just now' %}</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% elif task.status == 'pending' %}
|
||||
<div class="mt-3">
|
||||
<div class="alert alert-info">
|
||||
<i class="fa fa-clock"></i>
|
||||
{% trans 'Your avatar is in the queue. Position:' %} <strong>{{ queue_position }}</strong>
|
||||
{% if queue_length > 1 %}
|
||||
{% trans 'out of' %} {{ queue_length }} {% trans 'tasks' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="progress">
|
||||
<div class="progress-bar" role="progressbar" style="width: 0%" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
|
||||
0%
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted">{% trans 'Waiting in queue...' %}</small>
|
||||
</div>
|
||||
{% elif task.status == 'completed' %}
|
||||
<div class="mt-3">
|
||||
<div class="alert alert-success">
|
||||
<i class="fa fa-check-circle"></i>
|
||||
{% trans 'Avatar generated successfully!' %}
|
||||
</div>
|
||||
{% if task.generated_photo %}
|
||||
<div class="text-center">
|
||||
<a href="{% url 'avatar_preview' task.generated_photo.pk %}" class="btn btn-primary btn-lg">
|
||||
<i class="fa fa-eye"></i> {% trans 'View Avatar' %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif task.status == 'failed' %}
|
||||
<div class="mt-3">
|
||||
<div class="alert alert-danger">
|
||||
<i class="fa fa-exclamation-triangle"></i>
|
||||
{% trans 'Avatar generation failed.' %}
|
||||
{% if task.error_message %}
|
||||
<br><small>{{ task.error_message }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<a href="{% url 'generate_avatar' %}" class="btn btn-primary">
|
||||
<i class="fa fa-redo"></i> {% trans 'Try Again' %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Queue Information -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5><i class="fa fa-list"></i> {% trans 'Queue Information' %}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<h3 class="text-primary">{{ processing_count }}</h3>
|
||||
<p class="text-muted">{% trans 'Currently Processing' %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<h3 class="text-warning">{{ queue_length }}</h3>
|
||||
<p class="text-muted">{% trans 'Pending Tasks' %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<h3 class="text-info">2</h3>
|
||||
<p class="text-muted">{% trans 'Max Parallel Jobs' %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Your Recent Tasks -->
|
||||
{% if user_tasks %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5><i class="fa fa-history"></i> {% trans 'Your Recent Tasks' %}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans 'Prompt' %}</th>
|
||||
<th>{% trans 'Status' %}</th>
|
||||
<th>{% trans 'Created' %}</th>
|
||||
<th>{% trans 'Actions' %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user_task in user_tasks %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="text-truncate d-inline-block" style="max-width: 200px;" title="{{ user_task.prompt }}">
|
||||
{{ user_task.prompt }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-{% if user_task.status == 'completed' %}success{% elif user_task.status == 'failed' %}danger{% elif user_task.status == 'processing' %}warning{% else %}secondary{% endif %}">
|
||||
{{ user_task.get_status_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ user_task.add_date|date:"M d, H:i" }}</td>
|
||||
<td>
|
||||
{% if user_task.status == 'completed' and user_task.generated_photo %}
|
||||
<a href="{% url 'avatar_preview' user_task.generated_photo.pk %}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="fa fa-eye"></i>
|
||||
</a>
|
||||
{% elif user_task.status == 'failed' %}
|
||||
<a href="{% url 'generate_avatar' %}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fa fa-redo"></i>
|
||||
</a>
|
||||
{% elif user_task.status == 'pending' or user_task.status == 'processing' %}
|
||||
<a href="{% url 'generation_status' user_task.pk %}" class="btn btn-sm btn-outline-info">
|
||||
<i class="fa fa-info-circle"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<a href="{% url 'generate_avatar' %}" class="btn btn-primary btn-lg">
|
||||
<i class="fa fa-plus-circle"></i> {% trans 'Generate New Avatar' %}
|
||||
</a>
|
||||
<a href="{% url 'profile' %}" class="btn btn-secondary btn-lg">
|
||||
<i class="fa fa-user"></i> {% trans 'Back to Profile' %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const taskId = {{ task.pk }};
|
||||
let refreshInterval;
|
||||
|
||||
function updateStatus() {
|
||||
fetch(`/accounts/api/task_status/${taskId}/`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
console.error('Error fetching status:', data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update status badge with live indicators
|
||||
const statusBadge = document.querySelector('.status-badge');
|
||||
if (statusBadge) {
|
||||
statusBadge.textContent = data.status_display;
|
||||
statusBadge.className = 'badge badge-' +
|
||||
(data.status === 'completed' ? 'success' :
|
||||
data.status === 'failed' ? 'danger' :
|
||||
data.status === 'processing' ? 'warning' : 'secondary') + ' status-badge';
|
||||
|
||||
// Add appropriate icons
|
||||
const existingIcon = statusBadge.querySelector('i');
|
||||
if (existingIcon) existingIcon.remove();
|
||||
|
||||
if (data.status === 'processing') {
|
||||
statusBadge.innerHTML += ' <i class="fa fa-spinner fa-spin ml-1"></i>';
|
||||
} else if (data.status === 'pending') {
|
||||
statusBadge.innerHTML += ' <i class="fa fa-clock ml-1"></i>';
|
||||
}
|
||||
}
|
||||
|
||||
// Update live indicator
|
||||
const liveIndicator = document.querySelector('.live-indicator i');
|
||||
if (liveIndicator) {
|
||||
liveIndicator.classList.remove('text-success', 'text-warning', 'text-danger');
|
||||
if (data.status === 'completed') {
|
||||
liveIndicator.classList.add('text-success');
|
||||
} else if (data.status === 'failed') {
|
||||
liveIndicator.classList.add('text-danger');
|
||||
} else {
|
||||
liveIndicator.classList.add('text-warning');
|
||||
}
|
||||
}
|
||||
|
||||
// Update last updated timestamp
|
||||
const lastUpdated = document.querySelector('.last-updated');
|
||||
if (lastUpdated) {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString();
|
||||
lastUpdated.textContent = `Updated at ${timeString}`;
|
||||
}
|
||||
|
||||
// Update progress bar
|
||||
const progressBar = document.querySelector('.progress-bar');
|
||||
if (progressBar) {
|
||||
progressBar.style.width = data.progress + '%';
|
||||
progressBar.setAttribute('aria-valuenow', data.progress);
|
||||
progressBar.textContent = data.progress + '%';
|
||||
}
|
||||
|
||||
// Update queue information
|
||||
const queueInfo = document.querySelector('.alert-info');
|
||||
if (queueInfo && data.status === 'pending') {
|
||||
queueInfo.innerHTML = `
|
||||
<i class="fa fa-clock"></i>
|
||||
Your avatar is in the queue. Position: <strong>${data.queue_position}</strong>
|
||||
${data.queue_length > 1 ? `out of ${data.queue_length} tasks` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
// Update queue stats
|
||||
const processingCount = document.querySelector('.text-primary');
|
||||
const pendingCount = document.querySelector('.text-warning');
|
||||
if (processingCount) processingCount.textContent = data.processing_count;
|
||||
if (pendingCount) pendingCount.textContent = data.queue_length;
|
||||
|
||||
// Handle completion
|
||||
if (data.status === 'completed') {
|
||||
clearInterval(refreshInterval);
|
||||
if (data.generated_photo_id) {
|
||||
// Redirect to avatar preview
|
||||
setTimeout(() => {
|
||||
window.location.href = `/accounts/avatar_preview/${data.generated_photo_id}/`;
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle failure
|
||||
if (data.status === 'failed') {
|
||||
clearInterval(refreshInterval);
|
||||
const errorDiv = document.querySelector('.alert-danger');
|
||||
if (errorDiv && data.error_message) {
|
||||
errorDiv.innerHTML = `
|
||||
<i class="fa fa-exclamation-triangle"></i>
|
||||
Avatar generation failed.
|
||||
<br><small>${data.error_message}</small>
|
||||
`;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Start auto-refresh if task is pending or processing
|
||||
const taskStatus = '{{ task.status }}';
|
||||
if (taskStatus === 'pending' || taskStatus === 'processing') {
|
||||
refreshInterval = setInterval(updateStatus, 1000); // Update every 1 second for live updates
|
||||
}
|
||||
|
||||
// Add visual feedback for status changes
|
||||
const statusBadge = document.querySelector('.badge');
|
||||
if (statusBadge && taskStatus === 'processing') {
|
||||
statusBadge.classList.add('pulse');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.pulse {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.progress-bar-animated {
|
||||
animation: progress-bar-stripes 1s linear infinite;
|
||||
}
|
||||
|
||||
.live-indicator i {
|
||||
animation: live-pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes live-pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.last-updated {
|
||||
font-weight: 500;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Enhanced progress bar */
|
||||
.progress {
|
||||
height: 25px;
|
||||
background-color: #e9ecef;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background: linear-gradient(45deg, #007bff, #0056b3);
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
line-height: 25px;
|
||||
}
|
||||
|
||||
@keyframes progress-bar-stripes {
|
||||
0% { background-position: 1rem 0; }
|
||||
100% { background-position: 0 0; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -27,6 +27,14 @@ from .views import ResendConfirmationMailView
|
||||
from .views import IvatarLoginView
|
||||
from .views import DeleteAccountView
|
||||
from .views import ExportView
|
||||
from .views import (
|
||||
GenerateAvatarView,
|
||||
AvatarPreviewView,
|
||||
AvatarGalleryView,
|
||||
ReusePromptView,
|
||||
GenerationStatusView,
|
||||
task_status_api,
|
||||
)
|
||||
|
||||
# Define URL patterns, self documenting
|
||||
# To see the fancy, colorful evaluation of these use:
|
||||
@@ -77,6 +85,26 @@ urlpatterns = [ # pylint: disable=invalid-name
|
||||
ProfileView.as_view(),
|
||||
name="profile_with_profile_username",
|
||||
),
|
||||
path("generate_avatar/", GenerateAvatarView.as_view(), name="generate_avatar"),
|
||||
re_path(
|
||||
r"generation_status/(?P<task_id>\d+)/",
|
||||
GenerationStatusView.as_view(),
|
||||
name="generation_status",
|
||||
),
|
||||
re_path(
|
||||
r"api/task_status/(?P<task_id>\d+)/", task_status_api, name="task_status_api"
|
||||
),
|
||||
re_path(
|
||||
r"avatar_preview/(?P<photo_id>\d+)/",
|
||||
AvatarPreviewView.as_view(),
|
||||
name="avatar_preview",
|
||||
),
|
||||
path("avatar_gallery/", AvatarGalleryView.as_view(), name="avatar_gallery"),
|
||||
re_path(
|
||||
r"reuse_prompt/(?P<photo_id>\d+)/",
|
||||
ReusePromptView.as_view(),
|
||||
name="reuse_prompt",
|
||||
),
|
||||
path("add_email/", AddEmailView.as_view(), name="add_email"),
|
||||
path("add_openid/", AddOpenIDView.as_view(), name="add_openid"),
|
||||
path("upload_photo/", UploadPhotoView.as_view(), name="upload_photo"),
|
||||
|
||||
@@ -21,8 +21,9 @@ from django.utils.decorators import method_decorator
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.contrib import messages
|
||||
from django.views.generic.edit import FormView, UpdateView
|
||||
from django.views.generic.base import View, TemplateView
|
||||
from django.views.generic.base import View, TemplateView, RedirectView
|
||||
from django.views.generic.detail import DetailView
|
||||
from django.views.generic.list import ListView
|
||||
from django.contrib.auth import authenticate, login
|
||||
from django.contrib.auth.forms import UserCreationForm, SetPasswordForm
|
||||
from django.contrib.auth.views import LoginView
|
||||
@@ -30,9 +31,11 @@ from django.contrib.auth.views import (
|
||||
PasswordResetView as PasswordResetViewOriginal,
|
||||
)
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.http import HttpResponseRedirect, HttpResponse
|
||||
from django.http import HttpResponseRedirect, HttpResponse, JsonResponse
|
||||
from django.urls import reverse_lazy, reverse
|
||||
from django.shortcuts import render
|
||||
from django.views.decorators.http import require_http_methods
|
||||
import logging
|
||||
from django_openid_auth.models import UserOpenID
|
||||
|
||||
from openid import oidutil
|
||||
@@ -54,12 +57,17 @@ from .gravatar import get_photo as get_gravatar_photo
|
||||
|
||||
from .forms import AddEmailForm, UploadPhotoForm, AddOpenIDForm
|
||||
from .forms import UpdatePreferenceForm, UploadLibravatarExportForm
|
||||
from .forms import DeleteAccountForm
|
||||
from .forms import DeleteAccountForm, GenerateAvatarForm
|
||||
from .models import UnconfirmedEmail, ConfirmedEmail, Photo
|
||||
from .models import UnconfirmedOpenId, ConfirmedOpenId, DjangoOpenIDStore
|
||||
from .models import UserPreference
|
||||
from .models import file_format
|
||||
from .read_libravatar_export import read_gzdata as libravatar_read_gzdata
|
||||
from ivatar.ai_service import generate_avatar_image, AIServiceError
|
||||
from ivatar.tasks import generate_avatar_task, update_queue_positions
|
||||
from ivatar.ivataraccount.models import GenerationTask
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def openid_logging(message, level=0):
|
||||
@@ -1367,3 +1375,358 @@ class ExportView(SuccessMessageMixin, TemplateView):
|
||||
] = f'attachment; filename="libravatar-export_{user.username}.xml.gz"'
|
||||
response.write(bytesobj.read())
|
||||
return response
|
||||
|
||||
|
||||
class GenerateAvatarView(SuccessMessageMixin, FormView):
|
||||
"""
|
||||
View for generating avatars using AI text-to-image
|
||||
"""
|
||||
|
||||
template_name = "generate_avatar.html"
|
||||
form_class = GenerateAvatarForm
|
||||
success_message = _("Avatar generated successfully!")
|
||||
|
||||
@method_decorator(login_required)
|
||||
def dispatch(self, *args, **kwargs):
|
||||
return super().dispatch(*args, **kwargs)
|
||||
|
||||
def get_initial(self):
|
||||
"""Pre-populate form with reused prompt if available"""
|
||||
initial = super().get_initial()
|
||||
|
||||
# Check for reused prompt from gallery
|
||||
reuse_prompt = self.request.session.get("reuse_prompt", "")
|
||||
if reuse_prompt:
|
||||
initial["prompt"] = reuse_prompt
|
||||
# Clear the reused prompt from session
|
||||
del self.request.session["reuse_prompt"]
|
||||
|
||||
return initial
|
||||
|
||||
def form_valid(self, form):
|
||||
"""
|
||||
Handle form submission and queue avatar generation
|
||||
"""
|
||||
try:
|
||||
# Get form data
|
||||
prompt = form.cleaned_data["prompt"]
|
||||
model = form.cleaned_data["model"]
|
||||
quality = form.cleaned_data["quality"]
|
||||
|
||||
# Create generation task
|
||||
task = GenerationTask.objects.create(
|
||||
user=self.request.user,
|
||||
prompt=prompt,
|
||||
model=model,
|
||||
quality=quality,
|
||||
allow_nsfw=False, # Always false - no NSFW override allowed
|
||||
status="pending",
|
||||
)
|
||||
|
||||
# Update queue positions
|
||||
update_queue_positions.delay()
|
||||
|
||||
# Queue the generation task
|
||||
celery_task = generate_avatar_task.delay(
|
||||
task_id=task.pk,
|
||||
user_id=self.request.user.pk,
|
||||
prompt=prompt,
|
||||
model=model,
|
||||
quality=quality,
|
||||
allow_nsfw=False, # Always false - no NSFW override allowed
|
||||
)
|
||||
|
||||
# Store task ID
|
||||
task.task_id = celery_task.id
|
||||
task.save()
|
||||
|
||||
# Store prompt in session for refinement
|
||||
self.request.session["last_avatar_prompt"] = prompt
|
||||
self.request.session["user_consent_given"] = True
|
||||
|
||||
messages.success(
|
||||
self.request,
|
||||
_("Avatar generation queued! You'll be notified when it's ready."),
|
||||
)
|
||||
|
||||
# Redirect to task status page
|
||||
return HttpResponseRedirect(
|
||||
reverse_lazy("generation_status", kwargs={"task_id": task.pk})
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in avatar generation: {e}")
|
||||
messages.error(
|
||||
self.request, _("An unexpected error occurred. Please try again.")
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
|
||||
class GenerationStatusView(TemplateView):
|
||||
"""
|
||||
View for showing avatar generation status and progress
|
||||
"""
|
||||
|
||||
template_name = "generation_status.html"
|
||||
|
||||
@method_decorator(login_required)
|
||||
def dispatch(self, *args, **kwargs):
|
||||
return super().dispatch(*args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
task_id = kwargs.get("task_id")
|
||||
|
||||
try:
|
||||
task = GenerationTask.objects.get(pk=task_id, user=self.request.user)
|
||||
context["task"] = task
|
||||
|
||||
# Get queue information
|
||||
pending_tasks = GenerationTask.objects.filter(status="pending").order_by(
|
||||
"add_date"
|
||||
)
|
||||
processing_tasks = GenerationTask.objects.filter(status="processing")
|
||||
|
||||
context["queue_length"] = pending_tasks.count()
|
||||
context["processing_count"] = processing_tasks.count()
|
||||
context["queue_position"] = task.queue_position
|
||||
|
||||
# Get user's other tasks
|
||||
user_tasks = GenerationTask.objects.filter(user=self.request.user).order_by(
|
||||
"-add_date"
|
||||
)[:5]
|
||||
context["user_tasks"] = user_tasks
|
||||
|
||||
except GenerationTask.DoesNotExist:
|
||||
messages.error(self.request, _("Generation task not found."))
|
||||
return HttpResponseRedirect(reverse_lazy("profile"))
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class AvatarPreviewView(SuccessMessageMixin, FormView):
|
||||
"""
|
||||
View for previewing generated avatars and allowing refinements
|
||||
"""
|
||||
|
||||
template_name = "avatar_preview.html"
|
||||
form_class = GenerateAvatarForm
|
||||
success_message = _("Avatar regenerated successfully!")
|
||||
|
||||
@method_decorator(login_required)
|
||||
def dispatch(self, *args, **kwargs):
|
||||
return super().dispatch(*args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add photo and related data to context"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
try:
|
||||
photo = Photo.objects.get(
|
||||
pk=self.kwargs["photo_id"], user=self.request.user
|
||||
)
|
||||
context["photo"] = photo
|
||||
context["photo_url"] = reverse("raw_image", kwargs={"pk": photo.pk})
|
||||
|
||||
# Get user's confirmed emails for assignment
|
||||
context["confirmed_emails"] = self.request.user.confirmedemail_set.all()
|
||||
|
||||
except Photo.DoesNotExist:
|
||||
messages.error(self.request, _("Avatar not found."))
|
||||
return HttpResponseRedirect(reverse_lazy("profile"))
|
||||
|
||||
return context
|
||||
|
||||
def get_initial(self):
|
||||
"""Pre-populate form with current prompt if available"""
|
||||
initial = super().get_initial()
|
||||
initial["model"] = "stable_diffusion"
|
||||
initial["quality"] = "medium"
|
||||
|
||||
# Try to get the prompt from the session or URL parameters
|
||||
prompt = self.request.session.get("last_avatar_prompt", "")
|
||||
if prompt:
|
||||
initial["prompt"] = prompt
|
||||
|
||||
# Pre-check consent checkboxes since user already gave consent
|
||||
if self.request.session.get("user_consent_given", False):
|
||||
initial["not_porn"] = True
|
||||
initial["can_distribute"] = True
|
||||
|
||||
return initial
|
||||
|
||||
def form_valid(self, form):
|
||||
"""
|
||||
Handle refinement - generate new avatar with modified prompt
|
||||
"""
|
||||
try:
|
||||
# Generate new avatar with refined prompt
|
||||
prompt = form.cleaned_data["prompt"]
|
||||
model = form.cleaned_data["model"]
|
||||
quality = form.cleaned_data["quality"]
|
||||
|
||||
generated_image = generate_avatar_image(
|
||||
prompt=prompt,
|
||||
model=model,
|
||||
size=(512, 512),
|
||||
quality=quality,
|
||||
allow_nsfw=False, # Always false - no NSFW override allowed
|
||||
)
|
||||
|
||||
# Convert PIL image to bytes
|
||||
img_buffer = BytesIO()
|
||||
generated_image.save(img_buffer, format="PNG")
|
||||
img_data = img_buffer.getvalue()
|
||||
|
||||
# Create new Photo object
|
||||
new_photo = Photo()
|
||||
new_photo.user = self.request.user
|
||||
new_photo.ip_address = get_client_ip(self.request)[0]
|
||||
new_photo.data = img_data
|
||||
new_photo.format = "png"
|
||||
|
||||
# Store AI generation metadata
|
||||
new_photo.ai_generated = True
|
||||
new_photo.ai_prompt = prompt
|
||||
new_photo.ai_model = model
|
||||
new_photo.ai_quality = "medium" # Default quality
|
||||
|
||||
new_photo.save()
|
||||
|
||||
# Store the new prompt and preserve consent in session for further refinement
|
||||
self.request.session["last_avatar_prompt"] = prompt
|
||||
self.request.session["user_consent_given"] = True
|
||||
|
||||
messages.success(
|
||||
self.request,
|
||||
_(
|
||||
"Avatar regenerated successfully! You can refine it further or assign it to your email addresses."
|
||||
),
|
||||
)
|
||||
|
||||
# Redirect to preview the new avatar
|
||||
return HttpResponseRedirect(
|
||||
reverse_lazy("avatar_preview", kwargs={"photo_id": new_photo.pk})
|
||||
)
|
||||
|
||||
except Photo.DoesNotExist:
|
||||
messages.error(self.request, _("Original avatar not found."))
|
||||
return HttpResponseRedirect(reverse_lazy("profile"))
|
||||
except AIServiceError as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
_("Failed to regenerate avatar: %(error)s") % {"error": str(e)},
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
_("An unexpected error occurred: %(error)s") % {"error": str(e)},
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
|
||||
class AvatarGalleryView(ListView):
|
||||
"""
|
||||
View for displaying a gallery of recent AI-generated avatars
|
||||
"""
|
||||
|
||||
template_name = "avatar_gallery.html"
|
||||
context_object_name = "avatars"
|
||||
paginate_by = 12 # Show 12 avatars per page
|
||||
|
||||
@method_decorator(login_required)
|
||||
def dispatch(self, *args, **kwargs):
|
||||
return super().dispatch(*args, **kwargs)
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get the last 30 AI-generated avatars from all users, excluding invalid ones"""
|
||||
return (
|
||||
Photo.objects.filter(
|
||||
ai_generated=True,
|
||||
ai_prompt__isnull=False,
|
||||
ai_invalid=False, # Exclude invalid images
|
||||
)
|
||||
.exclude(ai_prompt="")
|
||||
.order_by("-add_date")[:30]
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add additional context data"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
# Add user's own avatars for quick access (filtered for validity)
|
||||
context["user_avatars"] = (
|
||||
Photo.objects.filter(
|
||||
user=self.request.user,
|
||||
ai_generated=True,
|
||||
ai_prompt__isnull=False,
|
||||
ai_invalid=False, # Exclude invalid images
|
||||
)
|
||||
.exclude(ai_prompt="")
|
||||
.order_by("-add_date")[:10]
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class ReusePromptView(RedirectView):
|
||||
"""
|
||||
View to reuse a prompt from the gallery
|
||||
"""
|
||||
|
||||
permanent = False
|
||||
|
||||
def get_redirect_url(self, *args, **kwargs):
|
||||
"""Redirect to generate avatar page with pre-filled prompt"""
|
||||
try:
|
||||
photo = Photo.objects.get(
|
||||
pk=kwargs["photo_id"], ai_generated=True, ai_prompt__isnull=False
|
||||
)
|
||||
|
||||
# Store the prompt in session for the generate form
|
||||
self.request.session["reuse_prompt"] = photo.ai_prompt
|
||||
|
||||
# Redirect to generate avatar page
|
||||
return reverse_lazy("generate_avatar")
|
||||
|
||||
except Photo.DoesNotExist:
|
||||
messages.error(self.request, _("Avatar not found."))
|
||||
return reverse_lazy("avatar_gallery")
|
||||
|
||||
|
||||
@method_decorator(login_required, name="dispatch")
|
||||
@require_http_methods(["GET"])
|
||||
def task_status_api(request, task_id):
|
||||
"""
|
||||
API endpoint to get task status for AJAX requests
|
||||
"""
|
||||
try:
|
||||
task = GenerationTask.objects.get(pk=task_id, user=request.user)
|
||||
|
||||
# Get queue information
|
||||
pending_tasks = GenerationTask.objects.filter(status="pending").order_by(
|
||||
"add_date"
|
||||
)
|
||||
processing_tasks = GenerationTask.objects.filter(status="processing")
|
||||
|
||||
data = {
|
||||
"status": task.status,
|
||||
"progress": task.progress,
|
||||
"queue_position": task.queue_position,
|
||||
"queue_length": pending_tasks.count(),
|
||||
"processing_count": processing_tasks.count(),
|
||||
"error_message": task.error_message,
|
||||
"generated_photo_id": task.generated_photo.pk
|
||||
if task.generated_photo
|
||||
else None,
|
||||
"status_display": task.get_status_display(),
|
||||
}
|
||||
|
||||
return JsonResponse(data)
|
||||
|
||||
except GenerationTask.DoesNotExist:
|
||||
return JsonResponse({"error": "Task not found"}, status=404)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in task status API: {e}")
|
||||
return JsonResponse({"error": "Internal server error"}, status=500)
|
||||
|
||||
@@ -33,6 +33,7 @@ INSTALLED_APPS = [
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"social_django",
|
||||
"django_celery_results",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
||||
182
ivatar/tasks.py
Normal file
182
ivatar/tasks.py
Normal file
@@ -0,0 +1,182 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Celery tasks for avatar generation
|
||||
"""
|
||||
|
||||
import logging
|
||||
from celery import shared_task
|
||||
from django.contrib.auth.models import User
|
||||
from io import BytesIO
|
||||
|
||||
from ivatar.ai_service import generate_avatar_image, AIServiceError
|
||||
from ivatar.ivataraccount.models import GenerationTask, Photo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(bind=True, name="ivatar.tasks.generate_avatar_task")
|
||||
def generate_avatar_task(self, task_id, user_id, prompt, model, quality, allow_nsfw):
|
||||
"""
|
||||
Background task to generate avatar images
|
||||
|
||||
Args:
|
||||
task_id: GenerationTask ID
|
||||
user_id: User ID
|
||||
prompt: Avatar description
|
||||
model: AI model to use
|
||||
quality: Generation quality
|
||||
allow_nsfw: Whether to allow NSFW content
|
||||
|
||||
Returns:
|
||||
dict: Task result with photo_id or error
|
||||
"""
|
||||
try:
|
||||
# Get the task object
|
||||
task = GenerationTask.objects.get(pk=task_id)
|
||||
|
||||
# Update task status
|
||||
task.status = "processing"
|
||||
task.task_id = self.request.id
|
||||
task.progress = 10
|
||||
task.save()
|
||||
|
||||
logger.info(f"Starting avatar generation task {task_id} for user {user_id}")
|
||||
|
||||
# Update progress
|
||||
self.update_state(
|
||||
state="PROGRESS",
|
||||
meta={"progress": 20, "status": "Initializing generation..."},
|
||||
)
|
||||
task.progress = 20
|
||||
task.save()
|
||||
|
||||
# Generate the avatar image
|
||||
self.update_state(
|
||||
state="PROGRESS", meta={"progress": 30, "status": "Generating image..."}
|
||||
)
|
||||
task.progress = 30
|
||||
task.save()
|
||||
|
||||
generated_image = generate_avatar_image(
|
||||
prompt=prompt,
|
||||
model=model,
|
||||
size=(512, 512),
|
||||
quality=quality,
|
||||
allow_nsfw=allow_nsfw,
|
||||
)
|
||||
|
||||
# Update progress
|
||||
self.update_state(
|
||||
state="PROGRESS", meta={"progress": 70, "status": "Processing image..."}
|
||||
)
|
||||
task.progress = 70
|
||||
task.save()
|
||||
|
||||
# Convert PIL image to bytes
|
||||
img_buffer = BytesIO()
|
||||
generated_image.save(img_buffer, format="PNG")
|
||||
img_data = img_buffer.getvalue()
|
||||
|
||||
# Get user
|
||||
user = User.objects.get(pk=user_id)
|
||||
|
||||
# Create Photo object
|
||||
photo = Photo()
|
||||
photo.user = user
|
||||
photo.ip_address = "127.0.0.1" # Default IP for background tasks
|
||||
photo.data = img_data
|
||||
photo.format = "png"
|
||||
|
||||
# Store AI generation metadata
|
||||
photo.ai_generated = True
|
||||
photo.ai_prompt = prompt
|
||||
photo.ai_model = model
|
||||
photo.ai_quality = quality
|
||||
|
||||
photo.save()
|
||||
|
||||
# Update progress
|
||||
self.update_state(
|
||||
state="PROGRESS", meta={"progress": 90, "status": "Saving avatar..."}
|
||||
)
|
||||
task.progress = 90
|
||||
task.save()
|
||||
|
||||
# Update task with completed status
|
||||
task.status = "completed"
|
||||
task.progress = 100
|
||||
task.generated_photo = photo
|
||||
task.save()
|
||||
|
||||
logger.info(
|
||||
f"Completed avatar generation task {task_id}, created photo {photo.pk}"
|
||||
)
|
||||
|
||||
return {"status": "completed", "photo_id": photo.pk, "task_id": task_id}
|
||||
|
||||
except AIServiceError as e:
|
||||
logger.error(f"AI service error in task {task_id}: {e}")
|
||||
task.status = "failed"
|
||||
task.error_message = str(e)
|
||||
task.progress = 0
|
||||
task.save()
|
||||
|
||||
return {"status": "failed", "error": str(e), "task_id": task_id}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in task {task_id}: {e}")
|
||||
task.status = "failed"
|
||||
task.error_message = str(e)
|
||||
task.progress = 0
|
||||
task.save()
|
||||
|
||||
return {
|
||||
"status": "failed",
|
||||
"error": f"Unexpected error: {str(e)}",
|
||||
"task_id": task_id,
|
||||
}
|
||||
|
||||
|
||||
@shared_task(name="ivatar.tasks.update_queue_positions")
|
||||
def update_queue_positions():
|
||||
"""
|
||||
Update queue positions for pending tasks
|
||||
"""
|
||||
try:
|
||||
pending_tasks = GenerationTask.objects.filter(status="pending").order_by(
|
||||
"add_date"
|
||||
)
|
||||
|
||||
for index, task in enumerate(pending_tasks):
|
||||
task.queue_position = index + 1
|
||||
task.save()
|
||||
|
||||
logger.info(f"Updated queue positions for {len(pending_tasks)} pending tasks")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating queue positions: {e}")
|
||||
|
||||
|
||||
@shared_task(name="ivatar.tasks.cleanup_old_tasks")
|
||||
def cleanup_old_tasks():
|
||||
"""
|
||||
Clean up old completed/failed tasks
|
||||
"""
|
||||
try:
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
# Delete tasks older than 7 days
|
||||
cutoff_date = timezone.now() - timedelta(days=7)
|
||||
|
||||
old_tasks = GenerationTask.objects.filter(
|
||||
add_date__lt=cutoff_date, status__in=["completed", "failed", "cancelled"]
|
||||
)
|
||||
|
||||
count = old_tasks.count()
|
||||
old_tasks.delete()
|
||||
|
||||
logger.info(f"Cleaned up {count} old tasks")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up old tasks: {e}")
|
||||
@@ -1,5 +1,6 @@
|
||||
autopep8
|
||||
bcrypt
|
||||
celery
|
||||
defusedxml
|
||||
Django>=4.2.16
|
||||
django-anymail[mailgun]
|
||||
@@ -9,6 +10,7 @@ django-coverage-plugin
|
||||
django-extensions
|
||||
django-ipware
|
||||
django-user-accounts
|
||||
django_celery_results
|
||||
dnspython==2.2.0
|
||||
email-validator
|
||||
fabric
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
<ul class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
|
||||
{% if request.user.is_authenticated %}
|
||||
<li><a href="{% url 'profile' %}"><i class="fa fa-fw fa-image" aria-hidden="true"></i> {% trans 'Profile' %}</a></li>
|
||||
<li><a href="{% url 'generate_avatar' %}"><i class="fa fa-fw fa-magic" aria-hidden="true"></i> {% trans 'Generate AI Avatar' %}</a></li>
|
||||
<li><a href="{% url 'avatar_gallery' %}"><i class="fa fa-fw fa-th" aria-hidden="true"></i> {% trans 'Avatar Gallery' %}</a></li>
|
||||
<li><a href="{% url 'user_preference' %}"><i class="fa fa-fw fa-cog" aria-hidden="true"></i> {% trans 'Preferences' %}</a></li>
|
||||
<li><a href="{% url 'import_photo' %}"><i class="fa fa-fw fa-envelope-square" aria-hidden="true"></i> {% trans 'Import photo via mail address' %}</a></li>
|
||||
<li><a href="{% url 'upload_export' %}"><i class="fa fa-fw fa-file-archive-o" aria-hidden="true"></i> {% trans 'Import libravatar XML export' %}</a></li>
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
</a>
|
||||
<ul class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
|
||||
<li><a href="{% url 'profile' %}"><i class="fa fa-fw fa-image" aria-hidden="true"></i> {% trans 'Profile' %}</a></li>
|
||||
<li><a href="{% url 'generate_avatar' %}"><i class="fa fa-fw fa-magic" aria-hidden="true"></i> {% trans 'Generate AI Avatar' %}</a></li>
|
||||
<li><a href="{% url 'avatar_gallery' %}"><i class="fa fa-fw fa-th" aria-hidden="true"></i> {% trans 'Avatar Gallery' %}</a></li>
|
||||
<li><a href="{% url 'user_preference' %}"><i class="fa fa-fw fa-cog" aria-hidden="true"></i> {% trans 'Preferences' %}</a></li>
|
||||
<li><a href="{% url 'import_photo' %}"><i class="fa fa-fw fa-envelope-square" aria-hidden="true"></i> {% trans 'Import photo via mail address' %}</a></li>
|
||||
<li><a href="{% url 'upload_export' %}"><i class="fa fa-fw fa-file-archive-o" aria-hidden="true"></i> {% trans 'Import libravatar XML export' %}</a></li>
|
||||
|
||||
Reference in New Issue
Block a user