3 Commits

Author SHA1 Message Date
Oliver Falk
56cceb5724 Add django_celery_results 2025-09-18 20:23:45 +02:00
Oliver Falk
5a005d6845 Add celery 2025-09-18 11:33:28 +02:00
Oliver Falk
493f9405dd Play around with AI avatars - nothing serious yet 2025-09-17 13:55:12 +02:00
22 changed files with 2536 additions and 5 deletions

View File

@@ -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

View File

@@ -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
View 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
View 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}")

View File

@@ -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()

View File

@@ -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,
),
),
]

View 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"],
},
),
]

View 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.)",
),
),
]

View File

@@ -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

View File

@@ -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 %}

View File

@@ -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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View File

@@ -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"),

View File

@@ -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)

View File

@@ -33,6 +33,7 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"social_django",
"django_celery_results",
]
MIDDLEWARE = [

182
ivatar/tasks.py Normal file
View 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}")

View File

@@ -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

View File

@@ -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>

View File

@@ -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>