From bfb21e709a2fde568d993da43dab3a92389944c3 Mon Sep 17 00:00:00 2001 From: avi Date: Thu, 26 Dec 2024 01:12:23 +0700 Subject: [PATCH 1/5] chore: setup pytest configuration and test settings --- pytest.ini | 2 +- tests/settings.py | 55 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 tests/settings.py diff --git a/pytest.ini b/pytest.ini index 3cfeba1..40d3fe4 100644 --- a/pytest.ini +++ b/pytest.ini @@ -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 diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..8799795 --- /dev/null +++ b/tests/settings.py @@ -0,0 +1,55 @@ +""" +Django settings for testing +""" +import os + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +SECRET_KEY = 'test-key-not-for-production' + +DEBUG = True + +ALLOWED_HOSTS = [] + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.contrib.sites', + 'seo_optimizer', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + } +} + +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_L10N = True +USE_TZ = True + +STATIC_URL = '/static/' + +SITE_ID = 1 + +# SEO Optimizer settings +SEO_ASYNC_ENABLED = True +SEO_CACHE_TIMEOUT = 3600 +SEO_MAX_ASYNC_WORKERS = 10 From 17f9f5626ec3fed46709b2d3cedb7e91a8fa4c30 Mon Sep 17 00:00:00 2001 From: avi Date: Thu, 26 Dec 2024 01:12:34 +0700 Subject: [PATCH 2/5] feat: add core functionality for SEO metadata --- seo_optimizer/exceptions.py | 16 ++++++++++++++++ seo_optimizer/models.py | 35 +++++++++++++++++++++++++++++++++++ seo_optimizer/utils.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 seo_optimizer/exceptions.py create mode 100644 seo_optimizer/models.py create mode 100644 seo_optimizer/utils.py diff --git a/seo_optimizer/exceptions.py b/seo_optimizer/exceptions.py new file mode 100644 index 0000000..9778503 --- /dev/null +++ b/seo_optimizer/exceptions.py @@ -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 diff --git a/seo_optimizer/models.py b/seo_optimizer/models.py new file mode 100644 index 0000000..e3345d1 --- /dev/null +++ b/seo_optimizer/models.py @@ -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}" diff --git a/seo_optimizer/utils.py b/seo_optimizer/utils.py new file mode 100644 index 0000000..9dd8803 --- /dev/null +++ b/seo_optimizer/utils.py @@ -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 From c2d2ef8ae1b41c6f088f5c16e838bf4bc20d47ab Mon Sep 17 00:00:00 2001 From: avi Date: Thu, 26 Dec 2024 01:13:06 +0700 Subject: [PATCH 3/5] feat: implement Django app configuration and signals --- seo_optimizer/apps.py | 11 ++++++++++- seo_optimizer/signals.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 seo_optimizer/signals.py diff --git a/seo_optimizer/apps.py b/seo_optimizer/apps.py index bdafbd3..c50b6c6 100644 --- a/seo_optimizer/apps.py +++ b/seo_optimizer/apps.py @@ -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' @@ -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 diff --git a/seo_optimizer/signals.py b/seo_optimizer/signals.py new file mode 100644 index 0000000..26119ce --- /dev/null +++ b/seo_optimizer/signals.py @@ -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 From 815093f4601808ef8b1697fe4e4d91b42ae395ce Mon Sep 17 00:00:00 2001 From: avi Date: Thu, 26 Dec 2024 01:13:16 +0700 Subject: [PATCH 4/5] feat: implement base metadata class with async support --- seo_optimizer/__init__.py | 22 +-------- seo_optimizer/base.py | 96 +++++++++++++++++++++++++++++++-------- 2 files changed, 79 insertions(+), 39 deletions(-) diff --git a/seo_optimizer/__init__.py b/seo_optimizer/__init__.py index 2c9118d..edb4bfd 100644 --- a/seo_optimizer/__init__.py +++ b/seo_optimizer/__init__.py @@ -2,14 +2,6 @@ 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 @@ -17,16 +9,4 @@ __email__ = "contact@avixiii.com" __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' diff --git a/seo_optimizer/base.py b/seo_optimizer/base.py index 2ca6148..13f53bb 100644 --- a/seo_optimizer/base.py +++ b/seo_optimizer/base.py @@ -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') @@ -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: @@ -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 ): @@ -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: @@ -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: @@ -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: @@ -251,11 +283,18 @@ 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 @@ -263,9 +302,30 @@ 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 From d6d54f3e570b6db5f47c572712172d9d12316cfd Mon Sep 17 00:00:00 2001 From: avi Date: Thu, 26 Dec 2024 01:13:27 +0700 Subject: [PATCH 5/5] test: add test cases for base metadata functionality --- tests/test_base.py | 88 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tests/test_base.py diff --git a/tests/test_base.py b/tests/test_base.py new file mode 100644 index 0000000..9393e66 --- /dev/null +++ b/tests/test_base.py @@ -0,0 +1,88 @@ +""" +Tests for base functionality of SEO Optimizer +Created by avixiii (https://avixiii.com) +""" +import pytest +from django.test import override_settings, TransactionTestCase +from django.contrib.sites.models import Site +from django.apps import apps +from django.core.exceptions import AppRegistryNotReady +from asgiref.sync import sync_to_async + +from seo_optimizer.base import MetadataBase, register_metadata + + +class TestMetadata(MetadataBase): + """Test metadata class for testing purposes""" + class Meta: + use_sites = True + + def _get_instances(self, path, context=None, site=None, language=None, subdomain=None): + return [] + + @classmethod + async def _async_get_instances(cls, path, context=None, site=None, language=None, subdomain=None): + return [] + + +@pytest.mark.django_db(transaction=True) +class TestMetadataBase(TransactionTestCase): + """Test cases for MetadataBase functionality""" + + def setup_method(self, method): + """Set up test data""" + # Delete any existing sites + Site.objects.all().delete() + + def test_lazy_loading_site_model(self): + """Test that Site model is lazily loaded""" + # This should not raise AppRegistryNotReady + metadata = TestMetadata() + assert hasattr(metadata, '_meta') + assert metadata._meta.use_sites is True + + @pytest.mark.asyncio + async def test_async_get_instances(self): + """Test async instance retrieval with site""" + create_site = sync_to_async(Site.objects.create) + site = await create_site(domain='test.com', name='Test Site') + metadata = TestMetadata() + + # Test with explicit site + instances = await metadata._async_get_instances('/', site=site) + assert isinstance(instances, list) + + # Test with site domain string + instances = await metadata._async_get_instances('/', site='test.com') + assert isinstance(instances, list) + + def test_sync_get_instances(self): + """Test sync instance retrieval with site""" + site = Site.objects.create(domain='test.com', name='Test Site') + metadata = TestMetadata() + + # Test with explicit site + instances = metadata._get_instances('/', site=site) + assert isinstance(instances, list) + + # Test with site domain string + instances = metadata._get_instances('/', site='test.com') + assert isinstance(instances, list) + + @override_settings(SEO_ASYNC_ENABLED=True) + def test_async_enabled_setting(self): + """Test async functionality respects settings""" + metadata = TestMetadata() + assert metadata._meta.async_enabled is True + + @override_settings(SEO_ASYNC_ENABLED=False) + def test_async_disabled_setting(self): + """Test async functionality can be disabled""" + metadata = TestMetadata() + assert metadata._meta.async_enabled is False + + def test_register_metadata(self): + """Test metadata registration""" + path_pattern = r'^/test/.*$' + register_metadata(TestMetadata, path_pattern) + # Add assertions to verify registration