Skip to content

Commit

Permalink
Merge pull request #16 from avixiii-dev/develop
Browse files Browse the repository at this point in the history
Develop
  • Loading branch information
xanaawakens authored Dec 25, 2024
2 parents 4ed2864 + d6d54f3 commit 2ceb505
Show file tree
Hide file tree
Showing 10 changed files with 352 additions and 41 deletions.
2 changes: 1 addition & 1 deletion pytest.ini
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[pytest]
DJANGO_SETTINGS_MODULE = seo_optimizer.settings
DJANGO_SETTINGS_MODULE = tests.settings
python_files = tests.py test_*.py *_tests.py
addopts = --cov=seo_optimizer --cov-report=html --cov-report=term-missing -v
22 changes: 1 addition & 21 deletions seo_optimizer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,11 @@
Django SEO Optimizer - A powerful SEO optimization package
Created by avixiii (https://avixiii.com)
"""
from typing import Type, TypeVar, Optional

from django.apps import apps
from django.conf import settings

from .base import MetadataBase, register_metadata
from .fields import MetadataField, KeywordsField, RobotsField, OpenGraphField
from .models import setup
from .version import VERSION

__version__ = VERSION
__author__ = "avixiii"
__email__ = "[email protected]"
__website__ = "https://avixiii.com"

__all__ = [
"register_metadata",
"MetadataField",
"KeywordsField",
"RobotsField",
"OpenGraphField",
"setup",
]

# Setup the application
if apps.apps_ready and not hasattr(settings, "_SEO_OPTIMIZER_SETUP_DONE"):
setup()
settings._SEO_OPTIMIZER_SETUP_DONE = True
default_app_config = 'seo_optimizer.apps.SEOOptimizerConfig'
11 changes: 10 additions & 1 deletion seo_optimizer/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from django.utils.translation import gettext_lazy as _


class SeoOptimizerConfig(AppConfig):
class SEOOptimizerConfig(AppConfig):
"""Configuration for the SEO Optimizer application."""

name = 'seo_optimizer'
Expand All @@ -15,5 +15,14 @@ class SeoOptimizerConfig(AppConfig):
def ready(self):
"""
Initialize the application when Django starts.
This is called after the app registry is fully populated.
"""
from django.conf import settings
from .models import setup

if not hasattr(settings, "_SEO_OPTIMIZER_SETUP_DONE"):
setup()
settings._SEO_OPTIMIZER_SETUP_DONE = True

# Import signals
from . import signals # noqa
96 changes: 78 additions & 18 deletions seo_optimizer/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@
from django.utils.translation import gettext_lazy as _
from django.template import Template, Context
from django.utils.safestring import mark_safe
from django.contrib.sites.models import Site
from django.apps import apps
from django.utils.encoding import iri_to_uri
from django.conf import settings

from .utils import NotSet, Literal
from .exceptions import MetadataValidationError


T = TypeVar('T', bound='MetadataBase')


Expand All @@ -31,17 +32,30 @@ async def async_process(self) -> Any:
...


@dataclass
class MetadataOptions:
"""Configuration options for metadata"""
"""Options for metadata classes"""
use_cache: bool = True
use_sites: bool = True
use_i18n: bool = True
use_subdomains: bool = False
cache_prefix: str = "seo_optimizer"
cache_timeout: int = getattr(settings, 'SEO_CACHE_TIMEOUT', 3600) # 1 hour
async_enabled: bool = getattr(settings, 'SEO_ASYNC_ENABLED', True)
max_async_workers: int = getattr(settings, 'SEO_MAX_ASYNC_WORKERS', 10)
cache_timeout: int = 3600 # 1 hour
async_enabled: bool = True
max_async_workers: int = 10

def __init__(self, **kwargs):
"""Initialize metadata options"""
for key, value in kwargs.items():
setattr(self, key, value)

# Apply settings
self.async_enabled = getattr(settings, 'SEO_ASYNC_ENABLED', True)
self.cache_timeout = getattr(settings, 'SEO_CACHE_TIMEOUT', 3600)
self.max_async_workers = getattr(settings, 'SEO_MAX_ASYNC_WORKERS', 10)

def __str__(self):
"""String representation of metadata options"""
return f"MetadataOptions({', '.join(f'{k}={v}' for k, v in self.__dict__.items())})"


class FormattedMetadata:
Expand All @@ -53,7 +67,7 @@ def __init__(
metadata: 'MetadataBase',
instances: List[Any],
path: str,
site: Optional[Site] = None,
site: Optional[Any] = None,
language: Optional[str] = None,
subdomain: Optional[str] = None
):
Expand All @@ -72,7 +86,7 @@ def __init__(
def _generate_cache_key(
self,
path: str,
site: Optional[Site],
site: Optional[Any],
language: Optional[str],
subdomain: Optional[str]
) -> str:
Expand Down Expand Up @@ -207,21 +221,39 @@ def _resolve_value(self, name: str) -> Any:


class MetadataBase:
"""
Base class for all metadata definitions with async support
"""
"""Base class for all metadata definitions with async support"""
_meta: MetadataOptions

def __init_subclass__(cls, **kwargs: Any) -> None:

def __init_subclass__(cls, **kwargs: Any):
"""Initialize subclass with metadata options"""
super().__init_subclass__(**kwargs)
cls._meta = MetadataOptions()

# Initialize metadata options from Meta class if available
meta_class = getattr(cls, 'Meta', None)
if meta_class:
meta_attrs = {
key: value for key, value in vars(meta_class).items()
if not key.startswith('_')
}
cls._meta = MetadataOptions(**meta_attrs)
else:
cls._meta = MetadataOptions()

def __init__(self):
"""Initialize metadata instance"""
# Apply settings at instance level
self._meta = MetadataOptions(
async_enabled=getattr(settings, 'SEO_ASYNC_ENABLED', True),
cache_timeout=getattr(settings, 'SEO_CACHE_TIMEOUT', 3600),
max_async_workers=getattr(settings, 'SEO_MAX_ASYNC_WORKERS', 10)
)

@classmethod
async def async_get_metadata(
cls: Type[T],
path: str,
context: Optional[Dict[str, Any]] = None,
site: Optional[Union[Site, str]] = None,
site: Optional[Union[Any, str]] = None,
language: Optional[str] = None,
subdomain: Optional[str] = None
) -> FormattedMetadata:
Expand All @@ -236,7 +268,7 @@ def get_metadata(
cls: Type[T],
path: str,
context: Optional[Dict[str, Any]] = None,
site: Optional[Union[Site, str]] = None,
site: Optional[Union[Any, str]] = None,
language: Optional[str] = None,
subdomain: Optional[str] = None
) -> FormattedMetadata:
Expand All @@ -251,21 +283,49 @@ async def _async_get_instances(
cls: Type[T],
path: str,
context: Optional[Dict[str, Any]] = None,
site: Optional[Union[Site, str]] = None,
site: Optional[Union[Any, str]] = None,
language: Optional[str] = None,
subdomain: Optional[str] = None
) -> List[Any]:
"""Get metadata instances asynchronously"""
Site = apps.get_model('sites', 'Site')

if isinstance(site, str):
site = await Site.objects.aget(domain=site)
elif site is None and cls._meta.use_sites:
site = await Site.objects.aget_current()

raise NotImplementedError("Subclasses must implement _async_get_instances")

@classmethod
def _get_instances(
cls: Type[T],
path: str,
context: Optional[Dict[str, Any]] = None,
site: Optional[Union[Site, str]] = None,
site: Optional[Union[Any, str]] = None,
language: Optional[str] = None,
subdomain: Optional[str] = None
) -> List[Any]:
"""Get metadata instances synchronously"""
Site = apps.get_model('sites', 'Site')

if isinstance(site, str):
site = Site.objects.get(domain=site)
elif site is None and cls._meta.use_sites:
site = Site.objects.get_current()

raise NotImplementedError("Subclasses must implement _get_instances")


def register_metadata(metadata_class: Type[MetadataBase], path_pattern: str) -> None:
"""Register a metadata class for a given path pattern"""
if not issubclass(metadata_class, MetadataBase):
raise TypeError("metadata_class must be a subclass of MetadataBase")

if not hasattr(metadata_class, '_meta'):
metadata_class._meta = MetadataOptions()

# Store the registration for later use
if not hasattr(MetadataBase, '_registry'):
MetadataBase._registry = {}
MetadataBase._registry[path_pattern] = metadata_class
16 changes: 16 additions & 0 deletions seo_optimizer/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""
Custom exceptions for SEO Optimizer
Created by avixiii (https://avixiii.com)
"""

class MetadataValidationError(Exception):
"""Raised when metadata validation fails"""
pass

class MetadataNotFoundError(Exception):
"""Raised when metadata is not found for a given path"""
pass

class InvalidMetadataError(Exception):
"""Raised when metadata is invalid"""
pass
35 changes: 35 additions & 0 deletions seo_optimizer/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""
Models for SEO Optimizer
Created by avixiii (https://avixiii.com)
"""
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.apps import apps

def setup():
"""Setup function called during Django initialization"""
pass # We'll implement this later if needed

class SEOMetadata(models.Model):
"""Base model for SEO metadata"""
path = models.CharField(_('Path'), max_length=255)
site = models.ForeignKey('sites.Site', on_delete=models.CASCADE, related_name='seo_metadata')
title = models.CharField(_('Title'), max_length=255, blank=True)
description = models.TextField(_('Description'), blank=True)
keywords = models.CharField(_('Keywords'), max_length=255, blank=True)
robots = models.CharField(_('Robots'), max_length=255, blank=True)
canonical_url = models.URLField(_('Canonical URL'), blank=True)
og_title = models.CharField(_('Open Graph Title'), max_length=255, blank=True)
og_description = models.TextField(_('Open Graph Description'), blank=True)
og_image = models.URLField(_('Open Graph Image'), blank=True)
created_at = models.DateTimeField(_('Created At'), auto_now_add=True)
updated_at = models.DateTimeField(_('Updated At'), auto_now=True)

class Meta:
verbose_name = _('SEO Metadata')
verbose_name_plural = _('SEO Metadata')
unique_together = ('path', 'site')
ordering = ('-updated_at',)

def __str__(self):
return f"{self.site.domain}{self.path}"
33 changes: 33 additions & 0 deletions seo_optimizer/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""
Signal handlers for SEO Optimizer
Created by avixiii (https://avixiii.com)
"""
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.contrib.sites.models import Site

from .models import SEOMetadata

@receiver(post_save, sender=Site)
def handle_site_save(sender, instance, created, **kwargs):
"""Handle Site model save signal"""
# Add any site-related cleanup or cache invalidation here
pass

@receiver(post_delete, sender=Site)
def handle_site_delete(sender, instance, **kwargs):
"""Handle Site model delete signal"""
# Add any site-related cleanup here
pass

@receiver(post_save, sender=SEOMetadata)
def handle_metadata_save(sender, instance, created, **kwargs):
"""Handle SEOMetadata model save signal"""
# Add any metadata-related cache invalidation here
pass

@receiver(post_delete, sender=SEOMetadata)
def handle_metadata_delete(sender, instance, **kwargs):
"""Handle SEOMetadata model delete signal"""
# Add any metadata-related cleanup here
pass
35 changes: 35 additions & 0 deletions seo_optimizer/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""
Utility functions for SEO Optimizer
Created by avixiii (https://avixiii.com)
"""
from typing import Any, TypeVar, Type, Optional

T = TypeVar('T')

class NotSet:
"""Sentinel class for values that are not set"""
pass

class Literal:
"""Class for literal values that should not be processed"""
def __init__(self, value: Any):
self.value = value

def __str__(self) -> str:
return str(self.value)

def get_current_site(request) -> Optional[Any]:
"""Get current site from request"""
from django.contrib.sites.shortcuts import get_current_site
try:
return get_current_site(request)
except Exception:
return None

def get_site_by_domain(domain: str) -> Optional[Any]:
"""Get site by domain"""
from django.contrib.sites.models import Site
try:
return Site.objects.get(domain=domain)
except Site.DoesNotExist:
return None
Loading

0 comments on commit 2ceb505

Please sign in to comment.