diff --git a/changes/76.feature b/changes/76.feature new file mode 100644 index 0000000..6558064 --- /dev/null +++ b/changes/76.feature @@ -0,0 +1 @@ +Add schema.org support diff --git a/cms_helper.py b/cms_helper.py index 307c128..8bb2dc2 100755 --- a/cms_helper.py +++ b/cms_helper.py @@ -10,6 +10,7 @@ META_USE_OG_PROPERTIES=True, META_USE_TWITTER_PROPERTIES=True, META_USE_SCHEMAORG_PROPERTIES=True, + META_USE_JSON_LD_SCHEMA=True, FILE_UPLOAD_TEMP_DIR=mkdtemp(), TEST_RUNNER="app_helper.pytest_runner.PytestTestRunner", SECRET_KEY="dont-use-me", diff --git a/docs/conf.py b/docs/conf.py index 7717c4b..f42f50e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -276,3 +276,7 @@ # If true, do not generate a @detailmenu in the "Top" node's menu. # texinfo_no_detailmenu = False + +intersphinx_mapping = { + "django": ("https://django.readthedocs.io/en/latest/", None), +} diff --git a/docs/index.rst b/docs/index.rst index df9c48a..95eca00 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -25,6 +25,7 @@ django meta has two different operating mode: upgrading models views + schema settings rendering extra_tags diff --git a/docs/modules.rst b/docs/modules.rst index 5514538..2dc880c 100644 --- a/docs/modules.rst +++ b/docs/modules.rst @@ -1,17 +1,17 @@ Package documentation ===================== -.. automodule:: meta.models +.. automodule:: meta.views :members: :undoc-members: - :show-inheritance: + :private-members: -.. automodule:: meta.views +.. automodule:: meta.models :members: :undoc-members: - :show-inheritance: + :private-members: _metadata,_schema .. automodule:: meta.templatetags.meta :members: :undoc-members: - :show-inheritance: + :private-members: diff --git a/docs/schema.rst b/docs/schema.rst new file mode 100644 index 0000000..53822d5 --- /dev/null +++ b/docs/schema.rst @@ -0,0 +1,113 @@ +.. _schema.org: + +********** +schema.org +********** + +``django-meta`` provides full support for schema.org in JSON-LD format. + +schema.org is supported in both :ref:`models` and :ref:`views` framework. + +Model-level +----------- + +In the same way as basic :py:attr:`~meta.models.ModelMeta._metadata` attribute, +:py:attr:`~meta.models.ModelMeta._schema` exists to resolve and build +the per-object **Schema.org** representation of the current object. + +As per :py:attr:`~meta.models.ModelMeta._metadata`, :py:attr:`~meta.models.ModelMeta._schema` +values can contains the name of a method, property or attribute available on the class: + +.. _schema.model: + +.. code-block:: python + + class Blog(ModelMeta, Model) + ... + _schema = { + 'image': 'get_image_full_url', + 'articleBody': 'text', + 'articleSection': 'get_categories', + 'author': 'get_schema_author', + 'copyrightYear': 'copyright_year', + 'dateCreated': 'get_date', + 'dateModified': 'get_date', + 'datePublished': 'date_published', + 'headline': 'headline', + 'keywords': 'get_keywords', + 'description': 'get_description', + 'name': 'title', + 'url': 'get_full_url', + 'mainEntityOfPage': 'get_full_url', + 'publisher': 'get_site', + } + + + +View-level +---------- + +:py:class:`~meta.views.Meta` and :py:class:`~meta.views.MetadataMixin` provides a few API to work with **schema.org** +properties. + +.. _schema.get_schema: + +MetadataMixin ++++++++++++++ + +The high level interface is :py:meth:`meta.views.MetadataMixin.get_schema` which works in much the same way as +:py:attr:`meta.models.ModelMeta._schema`. + +In :py:meth:`~meta.views.MetadataMixin.get_schema` you must return the whole **schema.org** structure. + +For a single object it can look like this: + +.. code-block:: python + + def get_schema(self, context=None): + return { + 'image': self.object.get_image_full_url(), + 'articleBody': self.object.text, + 'articleSection': self.object.get_categories(), + 'author': self.object.get_schema_author(), + 'copyrightYear': self.object.date_published.year, + 'dateCreated': self.object.get_date(), + 'dateModified': self.object.get_date(), + 'datePublished': self.object.date_published(), + 'headline': self.object.abstract[:50], + 'keywords': self.object.get_keywords(), + 'description': self.object.get_description(), + 'name': self.object.title(), + 'url': self.object.get_full_url(), + 'mainEntityOfPage': self.object.get_full_url(), + 'publisher': self.object.get_site(), + } + + +.. note:: as it's :py:attr:`~meta.views.Meta.schema` responsibility to convert objects to types suitable for json encoding, + you are not required to put only literal values here. Instances of :py:class:`~meta.views.Meta`, dates, iterables + and dictionaries are allowed. + +.. _schema._schema: + +Meta +++++ + +The low level interface is :py:meth:`meta.views.Meta._schema` attribute or (``schema`` argument to :py:class:`~meta.views.Meta` +constructor): + +.. code-block:: python + + class MyView(View): + + def get_context_data(self, **kwargs): + context = super(PostDetailView, self).get_context_data(**kwargs) + context['meta'] = Meta(schema={ + '@type': 'Organization', + 'name': 'My Publisher', + 'logo': Meta(schema={ + '@type': 'ImageObject', + 'url': self.get_image_full_url() + }) + }) + return context diff --git a/meta/models.py b/meta/models.py index d4e4676..5f2916a 100644 --- a/meta/models.py +++ b/meta/models.py @@ -1,8 +1,12 @@ import warnings from copy import copy +from django.db.models import Manager +from django.utils.functional import cached_property + from .settings import get_setting from .utils import get_request, set_request +from .views import FullUrlMixin NEED_REQUEST_OBJECT_ERR_MSG = ( "Meta models needs request objects when initializing if sites framework " @@ -10,12 +14,28 @@ ).strip() -class ModelMeta: +class ModelMeta(FullUrlMixin): """ Meta information mixin. """ _metadata = {} + """ + Metadata configuration dictionary + + `_metadata` dict values can be: + + * name of object method taking the field name as parameter + * name of object method taking no parameters + * name of object attribute + * name of callable taking the field name as parameter + * name of callable taking no parameters + * literal value + + They are checked in the given order: the first that matches is returned. + + Callable must be available in the module (i.e.: imported if not defined in the module itself) + """ _metadata_default = { "title": False, "og_title": False, @@ -49,6 +69,28 @@ class ModelMeta: "locale": False, "custom_namespace": get_setting("OG_NAMESPACES"), } + _schema = {} + """ + schema.org properties dictionary + + `_metadata` dict values can be: + + * name of object method taking the field name as parameter + * name of object method taking no parameters + * name of object attribute + * name of callable taking the field name as parameter + * name of callable taking no parameters + * literal value + + They are checked in the given order: the first that matches is returned. + + Callable must be available in the module (i.e.: imported if not defined in the module itself) + + If the resulting value is a :py:class:`~meta.models.ModelMeta` or :py:class:`~meta.views.Meta` instance + its schema is set in the schema.org dataset. + + See :ref:`a sample implementation `. + """ def get_meta(self, request=None): """ @@ -70,33 +112,41 @@ def _retrieve_data(self, request, metadata): def _get_meta_value(self, field, value): """ - Build the data according to a + Build metadata values from :py:attr:`_metadata` :param field: metadata field name :param value: provided value :return: data """ + + def process_value(item): + if isinstance(item, Manager): + return list(item.all()) + elif callable(item): + try: + return item(field) + except TypeError: + return item() + else: + return item + if value: try: - attr = getattr(self, value) - if callable(attr): - try: - return attr(field) - except TypeError: - return attr() - else: - return attr + return process_value(getattr(self, value)) except (AttributeError, TypeError): return value def as_meta(self, request=None): """ - Method that generates the Meta object (from django-meta) + Populates the :py:class:`~meta.views.Meta` object with values from :py:attr:`_metadata` + + :param request: optional request object. Used to build the correct URI for linked objects + :return: Meta object """ from meta.views import Meta metadata = self.get_meta(request) - meta = Meta(request=request) + meta = Meta(request=request, obj=self) for field, data in self._retrieve_data(request, metadata): setattr(meta, field, data) for field in ("og_title", "twitter_title", "schemaorg_title"): @@ -107,8 +157,23 @@ def as_meta(self, request=None): generaldesc = getattr(meta, "description", False) if not getattr(meta, field, False) and generaldesc: setattr(meta, field, generaldesc) + if self._schema: + meta.schema = self.schema return meta + @cached_property + def schema(self): + """ + Schema.org object description + + :return: dict + """ + schema = {} + for field, value in self._schema.items(): + if value: + schema[field] = self._get_meta_value(field, value) + return schema + def get_request(self): """ Retrieve request from current instance @@ -176,7 +241,7 @@ def get_meta_protocol(self): """ Current http protocol """ - return get_setting("SITE_PROTOCOL") + return self.get_protocol() def build_absolute_uri(self, url): """ @@ -189,16 +254,11 @@ def build_absolute_uri(self, url): if not get_setting("USE_SITES"): raise RuntimeError(NEED_REQUEST_OBJECT_ERR_MSG) - from django.contrib.sites.models import Site - - s = Site.objects.get_current() - meta_protocol = self.get_meta_protocol() - if url.startswith("http"): - return url - if s.domain.find("http") > -1: - return "{}{}".format(s.domain, url) # pragma: no cover - else: - if url.startswith("/"): - return "{}://{}{}".format(meta_protocol, s.domain, url) - else: - return "{}://{}/{}".format(meta_protocol, s.domain, url) + return self._get_full_url(url) + + def mainEntityOfPage(self): + return {"@type": "WebPage", "@id": self.build_absolute_uri(self.get_absolute_url())} + + @property + def _local_key(self): + return "{}:{}:{}".format(self._meta.app_label, self._meta.model_name, self.pk) diff --git a/meta/settings.py b/meta/settings.py index f1dc699..881b9f1 100644 --- a/meta/settings.py +++ b/meta/settings.py @@ -12,10 +12,12 @@ META_USE_TWITTER_PROPERTIES = False META_USE_FACEBOOK_PROPERTIES = False META_USE_SCHEMAORG_PROPERTIES = False +META_USE_JSON_LD_SCHEMA = False META_USE_SITES = False META_USE_TITLE_TAG = False META_OG_NAMESPACES = None + OBJECT_TYPES = ( ("Article", _("Article")), ("Website", _("Website")), diff --git a/meta/templates/meta/meta.html b/meta/templates/meta/meta.html index 4c37ab4..c8c3123 100644 --- a/meta/templates/meta/meta.html +++ b/meta/templates/meta/meta.html @@ -2,6 +2,9 @@ {% spaceless %} {% autoescape off %} {% if meta %} + {% if meta.use_json_ld and meta.schema %} + + {% endif %} {% if meta.description %}{% meta 'description' meta.description %}{% endif %} {% if meta.keywords %}{% meta_list 'keywords' meta.keywords %}{% endif %} {% if meta.extra_props %}{% meta_extras meta.extra_props %}{% endif %} diff --git a/meta/views.py b/meta/views.py index ac419b3..7b665ed 100644 --- a/meta/views.py +++ b/meta/views.py @@ -1,11 +1,92 @@ +import json import warnings +from datetime import date from django.core.exceptions import ImproperlyConfigured from .settings import get_setting +# This global variable is used to cache schemas for visited models to avoid recursion errors when traversing +# when parent and its children has a reference to each other +# When one objects if visited, its schema is put there, in a key generated from its pk +# By adding here visited items, it can be used as a local cache, to stop recursion +visited = {} -class Meta: + +class FullUrlMixin: + """ + Provides a few convenience methods to retrieve the full URL (which includes protocol and domain) of an object. + + If possible, :py:meth:`django.http.request.HttpRequest.build_absolute_uri` is used + """ + + def get_domain(self): + """ + Discover the current website domain + + :py:class:`django.contrib.sites.models.Site` + and :ref:`META_SITE_DOMAIN` + (in this order) are used + + :return: domain URL + """ + from django.contrib.sites.models import Site + + try: + if self.use_sites: + return Site.objects.get_current(self.request).domain + except AttributeError: + if get_setting("USE_SITES"): + try: + return Site.objects.get_current(self.request).domain + except AttributeError: + return Site.objects.get_current().domain + if not get_setting("SITE_DOMAIN"): + raise ImproperlyConfigured("META_SITE_DOMAIN is not set") + return get_setting("SITE_DOMAIN") + + def get_protocol(self): + """ + Discover the current website protocol from :ref:`META_SITE_PROTOCOL` + + :return: http or https depending on :ref:`META_SITE_PROTOCOL` + """ + if not get_setting("SITE_PROTOCOL"): + raise ImproperlyConfigured("META_SITE_PROTOCOL is not set") + return get_setting("SITE_PROTOCOL") + + def _get_full_url(self, url): + """ + Build the full URL (protocol and domain included) for the URL given as argument + + :param url: absolute (domain-less) URL + :return: full url + """ + try: + return self.request.build_absolute_uri(url) + except AttributeError: + pass + if not url: + return None + if url.startswith("http"): + return url + meta_protocol = self.get_protocol() + domain = self.get_domain() + separator = "://" + if url.startswith("//"): + separator = ":" + domain = "" + elif not url.startswith("/"): + url = "/%s" % url + if domain.startswith("http"): + meta_protocol = "" + separator = "" + return "{meta_protocol}{separator}{domain}{url}".format( + meta_protocol=meta_protocol, separator=separator, domain=domain, url=url + ) + + +class Meta(FullUrlMixin): """ Helper for building context meta object """ @@ -14,7 +95,19 @@ class Meta: _url = None _image = None _image_object = None + _schema = {} + """ + Base schema.org types definition. + + It's a dictionary containing all the schema.org properties for the described objects. + + See :ref:`a sample implementation `. + """ request = None + _obj = None + """ + Linked :py:class:`~meta.models.ModelMeta` instance (if Meta is generated from a ModelMeta object) + """ def __init__(self, **kwargs): self.request = kwargs.get("request", None) @@ -46,35 +139,13 @@ def __init__(self, **kwargs): self.use_twitter = kwargs.get("use_twitter", get_setting("USE_TWITTER_PROPERTIES")) self.use_facebook = kwargs.get("use_facebook", get_setting("USE_FACEBOOK_PROPERTIES")) self.use_schemaorg = kwargs.get("use_schemaorg", get_setting("USE_SCHEMAORG_PROPERTIES")) + self.use_json_ld = kwargs.get("use_json_ld", get_setting("USE_JSON_LD_SCHEMA")) self.use_title_tag = kwargs.get("use_title_tag", get_setting("USE_TITLE_TAG")) self.schemaorg_type = kwargs.get("schemaorg_type", get_setting("SCHEMAORG_TYPE")) self.fb_pages = kwargs.get("fb_pages", get_setting("FB_PAGES")) self.og_app_id = kwargs.get("og_app_id", get_setting("FB_APPID")) - - def get_domain(self): - if self.use_sites: - from django.contrib.sites.models import Site - - return Site.objects.get_current(self.request).domain - if not get_setting("SITE_DOMAIN"): - raise ImproperlyConfigured("META_SITE_DOMAIN is not set") - return get_setting("SITE_DOMAIN") - - def get_protocol(self): - if not get_setting("SITE_PROTOCOL"): - raise ImproperlyConfigured("META_SITE_PROTOCOL is not set") - return get_setting("SITE_PROTOCOL") - - def get_full_url(self, url): - if not url: - return None - if url.startswith("http"): - return url - if url.startswith("//"): - return "{}:{}".format(self.get_protocol(), url) - if url.startswith("/"): - return "{}://{}{}".format(self.get_protocol(), self.get_domain(), url) - return "{}://{}/{}".format(self.get_protocol(), self.get_domain(), url) + self._schema = kwargs.get("schema", {}) + self._obj = kwargs.get("obj", {}) @property def keywords(self): @@ -95,13 +166,16 @@ def keywords(self, keywords): seen_add = seen.add self._keywords = [k for k in kws if k not in seen and not seen_add(k)] + def get_full_url(self, url): + return self._get_full_url(url) + @property def url(self): return self._url @url.setter def url(self, url): - self._url = self.get_full_url(url) + self._url = self._get_full_url(url) def _normalize_media_url(self, url): if not url.startswith("http") and not url.startswith("/"): @@ -140,8 +214,71 @@ def image_object(self, image): except KeyError: self._image_object = None - -class MetadataMixin: + @property + def schema(self): + """ + Schema.org object description. + + Items in the schema are converted in a format suitable of json encoding at this stage: + + * instances of :py:class:`~meta.views.Meta` as their schema + * dates as isoformat + * iterables and dicts are processed depth-first to process their items + + If no type is set :py:attr:`~meta.views.Meta.schemaorg_type` is used + + :return: dict + """ + from meta.models import ModelMeta + + def process_item(item): + if isinstance(item, Meta): + return item.schema + if isinstance(item, ModelMeta): + # if not cached, object schema is generated and put into local cache + if item._local_key not in visited: + visited[item._local_key] = item.as_meta(self.request).schema + return visited[item._local_key] + elif isinstance(item, date): + return item.isoformat() + elif isinstance(item, list) or isinstance(item, tuple): + return [process_item(value) for value in item] + elif isinstance(item, dict): + return {itemkey: process_item(itemvalue) for itemkey, itemvalue in item.items()} + else: + return item + + schema = {} + # object is immediately set here to recursion + # if we are visiting parent -> child relation, we don't need the pointer + # back up + if isinstance(self._obj, ModelMeta): + visited[self._obj._local_key] = None + for key, val in self._schema.items(): + schema[key] = process_item(val) + if "@type" not in schema: + schema["@type"] = self.schemaorg_type + # after generating the full schema, we can save it in the local cache for future uses + if isinstance(self._obj, ModelMeta): + visited[self._obj._local_key] = schema + return schema + + @schema.setter + def schema(self, schema): + self._schema = schema + + def as_json_ld(self): + """ + Convert the schema to json-ld + + :return: json + """ + data = self.schema + data["@context"] = "http://schema.org" + return json.dumps(data) + + +class MetadataMixin(FullUrlMixin): """ Django CBV mixin to prepare metadata for the view context """ @@ -173,6 +310,7 @@ class MetadataMixin: use_og = False use_title_tag = False schemaorg_type = None + schema = {} def __init__(self, **kwargs): self.use_sites = get_setting("USE_SITES") @@ -270,6 +408,31 @@ def get_meta_schemaorg_type(self, context=None): def get_meta_locale(self, context=None): return self.locale + def get_schema(self, context=None): + """ + The generic API to retrieve the full schema.org structure for the view. + + By default it returns the :py:attr:`schema`. You can reimplement this method + to build the schema.org structure at runtime. See :ref:`a sample implementation `. + + :param context: view context + :return: dictionary + """ + return self.schema + + def get_schema_property(self, schema_type, property, context=None): + """ + The generic API to retrieve the attribute value for a generic schema type + + This is just a stub that **must** be implemented + + :param schema_type: name of the schema type + :param property: name of the property + :param context: view context + :return: property value + """ + raise NotImplementedError + def get_meta(self, context=None): return self.get_meta_class()( use_og=self.use_og, @@ -296,6 +459,7 @@ def get_meta(self, context=None): locale=self.get_meta_locale(context=context), facebook_app_id=self.get_meta_facebook_app_id(context=context), schemaorg_type=self.get_meta_schemaorg_type(context=context), + schema=self.get_schema(context=context), ) def get_context_data(self, **kwargs): diff --git a/tests/example_app/admin.py b/tests/example_app/admin.py index da7127b..27f447d 100644 --- a/tests/example_app/admin.py +++ b/tests/example_app/admin.py @@ -1,5 +1,7 @@ from django.contrib import admin -from .models import Post +from .models import Comment, Post, Publisher admin.site.register(Post) +admin.site.register(Comment) +admin.site.register(Publisher) diff --git a/tests/example_app/migrations/0002_auto_20180401_2046.py b/tests/example_app/migrations/0002_auto_20180401_2046.py new file mode 100644 index 0000000..3a96c1f --- /dev/null +++ b/tests/example_app/migrations/0002_auto_20180401_2046.py @@ -0,0 +1,65 @@ +# Generated by Django 2.0.3 on 2018-04-01 18:46 + +import django.db.models.deletion +from django.db import migrations, models + +import meta.models + + +class Migration(migrations.Migration): + dependencies = [ + ("example_app", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Comment", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("body", models.CharField(max_length=255, verbose_name="comment")), + ], + options={ + "verbose_name": "comment", + "verbose_name_plural": "comments", + }, + bases=(meta.models.ModelMeta, models.Model), + ), + migrations.CreateModel( + name="Publisher", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=255, verbose_name="name")), + ], + options={ + "verbose_name": "publisher", + "verbose_name_plural": "publishers", + }, + bases=(meta.models.ModelMeta, models.Model), + ), + migrations.AddField( + model_name="post", + name="related_posts", + field=models.ManyToManyField(blank=True, to="example_app.Post", verbose_name="related posts"), + ), + migrations.AddField( + model_name="comment", + name="post", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to="example_app.Post", + verbose_name="post", + ), + ), + migrations.AddField( + model_name="post", + name="publisher", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="posts", + to="example_app.Publisher", + verbose_name="publisher", + ), + ), + ] diff --git a/tests/example_app/models.py b/tests/example_app/models.py index e17407b..5c2363a 100644 --- a/tests/example_app/models.py +++ b/tests/example_app/models.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _ from meta.models import ModelMeta +from meta.views import Meta try: from django.urls import reverse @@ -11,6 +12,43 @@ from django.core.urlresolvers import reverse +class Publisher(ModelMeta, models.Model): + name = models.CharField(_("name"), max_length=255) + + _schema = { + "@type": "Organization", + "name": "name", + "logo": "static_logo", + } + + class Meta: + verbose_name = _("publisher") + verbose_name_plural = _("publishers") + + def __str__(self): + return self.name + + @property + def static_logo(self): + return Meta(schema={"@type": "ImageObject", "url": self.build_absolute_uri("/some/logo.png")}) + + +class Comment(ModelMeta, models.Model): + body = models.CharField(_("comment"), max_length=255) + post = models.ForeignKey( + "example_app.Post", on_delete=models.CASCADE, verbose_name=_("post"), related_name="comments" + ) + + _schema = {"@type": "Comment", "text": "body"} + + class Meta: + verbose_name = _("comment") + verbose_name_plural = _("comments") + + def __str__(self): + return self.body[:10] + + class Post(ModelMeta, models.Model): """ Blog post @@ -33,6 +71,10 @@ class Post(ModelMeta, models.Model): main_image = models.ImageField(verbose_name=_("Main image"), blank=True, upload_to="images", null=True) text = models.TextField(verbose_name=_("Post text"), blank=True, default="") image_url = models.CharField(max_length=200, null=True, blank=True) + publisher = models.ForeignKey( + "example_app.Publisher", on_delete=models.CASCADE, verbose_name=_("publisher"), related_name="posts", null=True + ) + related_posts = models.ManyToManyField("example_app.Post", verbose_name=_("related posts"), blank=True) _metadata_default = ModelMeta._metadata_default.copy() # purely for testing purposes _metadata_default["locale"] = "dummy_locale" @@ -59,7 +101,7 @@ class Post(ModelMeta, models.Model): "twitter_site": "@FooBlag", "twitter_author": "get_author_twitter", "schemaorg_type": "Article", - "published_time": "date_published", + "published_time": "get_date", "modified_time": "get_date", "expiration_time": "get_date", "url": "get_full_url", @@ -70,19 +112,58 @@ class Post(ModelMeta, models.Model): "extra_custom_props": "get_custom_props", } + _schema = { + "image": "get_image_full_url", + "articleBody": "text", + "articleSection": "get_categories", + "author": "get_schema_author", + "copyrightYear": "copyright_year", + "dateCreated": "get_date", + "dateModified": "get_date", + "datePublished": "get_date", + "expires": "get_date", + "headline": "headline", + "keywords": "get_keywords", + "description": "get_description", + "name": "title", + "url": "get_full_url", + "mainEntityOfPage": "mainEntityOfPage", + "publisher": "publisher", + "comment": "comments", + "commentCount": "comments_count", + "citation": "related_posts", + } + class Meta: verbose_name = _("blog article") verbose_name_plural = _("blog articles") ordering = ("-date_published", "-date_created") get_latest_by = "date_published" + def __str__(self): + return self.title + def get_date(self, param): - if param == "published_time": + if param in ("published_time", "datePublished"): return self.date_published - elif param == "modified_time": + elif param in ("modified_time", "dateModified"): return self.date_modified - elif param == "expiration_time": + elif param in ("expiration_time", "expires"): return self.date_published_end + elif param == "dateCreated": + return self.date_created + + @property + def copyright_year(self): + return self.date_published.year + + @property + def headline(self): + return self.abstract[:110] + + @property + def comments_count(self): + return self.comments.count() def get_keywords(self): return self.meta_keywords.strip().split(",") @@ -122,6 +203,7 @@ def get_author(self): author.fb_url = "https://facebook.com/foo.bar" author.twitter_profile = "@FooBar" author.get_full_name = self.author.get_full_name + author.schemaorg_profile = "https://schemaorg-profile.com" return author def __unicode__(self): @@ -136,3 +218,13 @@ def get_false_prop(self): def get_custom_props(self): return [("custom1", "custom_name1", "custom_val1"), ("custom2", "custom_name2", "custom_val2")] + + def get_categories(self): + return ["category 1", "category 2"] + + def get_schema_author(self): + author = self.get_author() + return { + "@type": "Person", + "name": author.get_full_name(), + } diff --git a/tests/example_app/views.py b/tests/example_app/views.py index 4b5c0f8..6121f3f 100644 --- a/tests/example_app/views.py +++ b/tests/example_app/views.py @@ -1,9 +1,18 @@ +from datetime import timedelta + +from django.utils.lorem_ipsum import paragraphs, words +from django.utils.timezone import now from django.views.generic import DetailView, ListView -from meta.views import MetadataMixin +from meta.views import Meta, MetadataMixin from .models import Post +try: + from django.urls import reverse +except ImportError: + from django.core.urlresolvers import reverse + class PostDetailView(DetailView): model = Post @@ -29,6 +38,26 @@ def get_meta_description(self, context): def get_meta_image(self, context): return self.object.image_url + def get_schema(self, context=None): + url = reverse("post-detail-mixinx", kwargs={"slug": self.object.slug}) + return { + "image": self.object.get_image_full_url(), + "articleBody": self.object.text, + "articleSection": self.object.get_categories(), + "author": self.object.get_schema_author(), + "copyrightYear": self.object.date_published.year, + "dateCreated": self.object.get_date("dateCreated"), + "dateModified": self.object.get_date("dateModified"), + "datePublished": self.object.date_published, + "headline": self.object.abstract[:50], + "keywords": self.object.get_keywords(), + "description": self.object.get_description(), + "name": self.object.title, + "url": self.object._get_full_url(url), + "mainEntityOfPage": self.object._get_full_url(url), + "publisher": self.object.get_domain(), + } + class PostMixinImageObjectDetailView(PostMixinDetailView): def get_meta_image_object(self, context=None): @@ -43,3 +72,73 @@ class PostListView(MetadataMixin, ListView): url = "some/page/" extra_props = {"foo": "bar", "key": "value"} extra_custom_props = [("key", "foo", "bar"), ("property", "name", "value")] + + def get_schema(self, context=None): + return { + "@type": "CollectionPage", + "name": self.title, + "url": reverse("post-list"), + "mainEntity": { + "@type": "ItemList", + "itemListElement": [ + { + "@type": "BlogPosting", + "image": self._get_full_url(self.image), + "articleBody": " ".join(paragraphs(count=5, common=False)), + "author": { + "@type": "Person", + "name": "Joe Smith", + }, + "copyrightYear": now().year, + "dateCreated": now() - timedelta(days=1), + "dateModified": now(), + "datePublished": now() - timedelta(days=1), + "headline": words(count=5, common=False), + "keywords": ",".join(words(count=5, common=False).split(" ")), + "description": words(count=5, common=False), + "name": words(count=5, common=False), + "url": reverse("post-list"), + "mainEntityOfPage": reverse("post-detail", kwargs={"slug": words(count=1, common=False)}), + "publisher": { + "@type": "Organization", + "name": "My Publisher", + "logo": Meta( + schema={ + "@type": "ImageObject", + "url": self._get_full_url(self.image), + } + ), + }, + }, + { + "@type": "BlogPosting", + "image": self._get_full_url(self.image), + "articleBody": " ".join(paragraphs(count=5, common=False)), + "author": { + "@type": "Person", + "name": "Joe Smith", + }, + "copyrightYear": now().year, + "dateCreated": now() - timedelta(days=1), + "dateModified": now(), + "datePublished": now() - timedelta(days=1), + "headline": words(count=5, common=False), + "keywords": ",".join(words(count=5, common=False).split(" ")), + "description": words(count=5, common=False), + "name": words(count=5, common=False), + "url": reverse("post-list"), + "mainEntityOfPage": reverse("post-detail", kwargs={"slug": words(count=1, common=False)}), + "publisher": { + "@type": "Organization", + "name": "My Publisher", + "logo": Meta( + schema={ + "@type": "ImageObject", + "url": self._get_full_url(self.image), + } + ), + }, + }, + ], + }, + } diff --git a/tests/test_meta.py b/tests/test_meta.py index 99eb6f9..b23ca15 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -1,3 +1,4 @@ +import json from copy import copy from django.contrib.sites.models import Site @@ -13,6 +14,7 @@ META_USE_OG_PROPERTIES=False, META_USE_TWITTER_PROPERTIES=False, META_USE_SCHEMAORG_PROPERTIES=False, + META_USE_JSON_LD_SCHEMA=False, ) class MetaObjectTestCase(TestCase): def test_defaults(self): @@ -41,9 +43,11 @@ def test_defaults(self): self.assertEqual(m.use_twitter, False) self.assertEqual(m.use_facebook, False) self.assertEqual(m.use_schemaorg, False) + self.assertEqual(m.use_json_ld, False) self.assertEqual(m.fb_pages, "") self.assertEqual(m.og_app_id, "") self.assertEqual(m.use_title_tag, False) + self.assertEqual(m.schema, {"@type": m.schemaorg_type}) def test_set_keywords(self): m = Meta(keywords=["foo", "bar"]) @@ -74,6 +78,11 @@ def test_set_keywords_no_duplicate(self): self.assertEqual(m.keywords[0], "foo") self.assertEqual(len(m.keywords), 1) + @override_settings(META_USE_JSON_LD_SCHEMA=True) + def test_use_json_ld(self): + m = Meta() + self.assertEqual(m.use_json_ld, True) + @override_settings(META_FB_APPID="appid", META_FB_PAGES="fbpages") def test_pages_appid(self): m = Meta() @@ -119,6 +128,14 @@ def test_get_full_url_without_site_id_with_request_will_not_raise(self): m = Meta(request=request) self.assertEqual(m.get_full_url("foo/bar"), "http://testserver/foo/bar") + @override_settings(SITE_ID=None, META_USE_SITES=True, META_SITE_PROTOCOL="http") + def test_get_full_url_with_fdqn_original_url(self): + factory = RequestFactory() + request = factory.get("/") + Site.objects.create(domain=request.get_host()) + m = Meta(request=request) + self.assertEqual(m.get_full_url("https://example.com/foo/bar"), "https://example.com/foo/bar") + def test_get_full_url_without_protocol_without_schema_will_raise(self): m = Meta() with self.assertRaises(ImproperlyConfigured): @@ -135,7 +152,7 @@ def test_get_full_url_with_domain_and_protocol(self): m = Meta() self.assertEqual(m.get_full_url("foo/bar"), "https://foo.com/foo/bar") - @override_settings(META_SITE_PROTOCOL="https") + @override_settings(META_SITE_PROTOCOL="https", META_SITE_DOMAIN="foo.com") def test_get_full_url_without_schema(self): m = Meta() self.assertEqual(m.get_full_url("//foo.com/foo/bar"), "https://foo.com/foo/bar") @@ -145,6 +162,11 @@ def test_get_full_url_with_absolute_path(self): m = Meta() self.assertEqual(m.get_full_url("/foo/bar"), "https://foo.com/foo/bar") + @override_settings(META_SITE_PROTOCOL="http", META_SITE_DOMAIN="http://foo.com") + def test_get_full_url_with_wrong_domain(self): + m = Meta() + self.assertEqual(m.get_full_url("/foo/bar"), "http://foo.com/foo/bar") + @override_settings(META_SITE_PROTOCOL="https", META_SITE_DOMAIN="foo.com") def test_set_url(self): m = Meta(url="foo/bar") @@ -228,3 +250,19 @@ def test_set_image_with_default_image_url(self): def test_set_image_with_defaults(self): m = Meta() self.assertEqual(m.image, "https://foo.com/static/img/image.gif") + + def test_schema_org(self): + m = Meta(schema={"foo": "bar", "list": [{"fuu": "baz", "test": "schema"}]}) + self.assertEqual( + m.schema, + {"foo": "bar", "list": [{"fuu": "baz", "test": "schema"}], "@type": m.schemaorg_type}, + ) + + def test_as_json_ld(self): + m = Meta(schema={"foo": "bar", "list": [{"fuu": "baz", "test": "schema"}]}) + data = m.schema + data["@context"] = "http://schema.org" + self.assertEqual( + m.as_json_ld(), + json.dumps(data), + ) diff --git a/tests/test_metadata_mixin.py b/tests/test_metadata_mixin.py index 0168e09..da67573 100644 --- a/tests/test_metadata_mixin.py +++ b/tests/test_metadata_mixin.py @@ -12,6 +12,7 @@ META_USE_OG_PROPERTIES=False, META_USE_TWITTER_PROPERTIES=False, META_USE_SCHEMAORG_PROPERTIES=False, + META_USE_JSON_LD_SCHEMA=False, ) class MetadataMixinTestCase(TestCase): def test_get_meta_class(self): @@ -120,6 +121,11 @@ def test_get_meta_site_name(self): m.site_name = "Foo" self.assertEqual(m.get_meta_site_name(), "Foo") + @override_settings(META_SITE_PROTOCOL="http", META_SITE_DOMAIN="http://foo.com") + def test_get_full_url_with_wrong_domain(self): + m = MetadataMixin() + self.assertEqual(m._get_full_url("/foo/bar"), "http://foo.com/foo/bar") + @override_settings(META_SITE_NAME="Foo") def test_get_meta_site_name_with_setting(self): m = MetadataMixin() @@ -200,6 +206,10 @@ def test_get_meta_twitter_type(self): self.assertEqual(m.get_meta_twitter_type(), "summary") assert len(w) == 0 + def test_no_schema_org(self): + m = MetadataMixin() + self.assertEqual(m.schema, {}) + def test_get_meta_facebook_app_id(self): m = MetadataMixin() self.assertEqual(m.get_meta_facebook_app_id(), None) diff --git a/tests/test_mixin.py b/tests/test_mixin.py index d44f15e..9d9b8f6 100644 --- a/tests/test_mixin.py +++ b/tests/test_mixin.py @@ -2,6 +2,7 @@ from datetime import timedelta from app_helper.base_test import BaseTestCase +from django.core.exceptions import ImproperlyConfigured from django.test.utils import override_settings from django.utils import timezone @@ -9,7 +10,7 @@ from meta.settings import get_setting from meta.templatetags.meta_extra import generic_prop, googleplus_html_scope -from .example_app.models import Post +from .example_app.models import Comment, Post, Publisher class TestMeta(BaseTestCase): @@ -18,6 +19,7 @@ class TestMeta(BaseTestCase): @classmethod def setUpTestData(cls): super().setUpTestData() + cls.publisher, __ = Publisher.objects.get_or_create(name="publisher name") cls.post, __ = Post.objects.get_or_create( title="a title", og_title="og title", @@ -32,10 +34,34 @@ def setUpTestData(cls): date_published_end=timezone.now() + timedelta(days=2), text="post text", image_url="/path/to/image", + publisher=cls.publisher, ) + cls.related_post, __ = Post.objects.get_or_create( + title="related title", + og_title="related og title", + twitter_title="related twitter title", + schemaorg_title="related schemaorg title", + schemaorg_description="related schemaorg description", + slug="related-title", + abstract="related post abstract", + meta_description="related post meta", + meta_keywords="related post keyword1,related post keyword 2", + author=cls.user, + date_published_end=timezone.now() + timedelta(days=2), + text="related post text", + image_url="/path/to/related-image", + ) + cls.comment, __ = Comment.objects.get_or_create( + body="comment body", + post=cls.post, + ) + cls.post.related_posts.add(cls.related_post) cls.post.main_image, __ = cls.create_django_image() cls.post.save() + cls.related_post.main_image, __ = cls.create_django_image() + cls.related_post.save() cls.image_url = cls.post.main_image.url + cls.related_image_url = cls.related_post.main_image.url cls.image_width = cls.post.main_image.width cls.image_height = cls.post.main_image.height @@ -79,6 +105,64 @@ def test_as_meta(self): ("custom1", "custom_name1", "custom_val1"), ("custom2", "custom_name2", "custom_val2"), ], + "schema": { + "@type": "Article", + "image": "http://example.com{}".format(self.image_url), + "articleBody": "post text", + "articleSection": ["category 1", "category 2"], + "author": { + "@type": "Person", + "name": self.post.author.get_full_name(), + }, + "copyrightYear": self.post.date_published.year, + "dateCreated": self.post.date_created.isoformat(), + "dateModified": self.post.date_modified.isoformat(), + "datePublished": self.post.date_published.isoformat(), + "expires": self.post.date_published_end.isoformat(), + "headline": "post abstract", + "keywords": ["post keyword1", "post keyword 2"], + "description": "post meta", + "name": "a title", + "url": "http://example.com/title/", + "mainEntityOfPage": { + "@type": "WebPage", + "@id": "http://example.com/title/", + }, + "publisher": { + "@type": "Organization", + "logo": { + "@type": "ImageObject", + "url": "http://example.com/some/logo.png", + }, + "name": "publisher name", + }, + "comment": [{"@type": "Comment", "text": "comment body"}], + "commentCount": self.post.comments.count(), + "citation": [ + { + "@type": "Article", + "articleBody": "related post text", + "articleSection": ["category 1", "category 2"], + "author": {"@type": "Person", "name": self.related_post.author.get_full_name()}, + "citation": [], + "comment": [], + "commentCount": self.related_post.comments.count(), + "copyrightYear": self.related_post.date_published.year, + "dateCreated": self.related_post.date_created.isoformat(), + "dateModified": self.related_post.date_modified.isoformat(), + "datePublished": self.related_post.date_published.isoformat(), + "description": "related post meta", + "expires": self.related_post.date_published_end.isoformat(), + "headline": "related post abstract", + "image": "http://example.com{}".format(self.related_image_url), + "keywords": ["related post keyword1", "related post keyword 2"], + "mainEntityOfPage": {"@id": "http://example.com/related-title/", "@type": "WebPage"}, + "name": "related title", + "publisher": None, + "url": "http://example.com/related-title/", + } + ], + }, } with override_settings(META_OG_NAMESPACES=["foo", "bar"], META_FB_PAGES="fbpages", META_FB_APPID="appid"): meta = self.post.as_meta() @@ -139,6 +223,64 @@ def test_as_meta_with_request(self): ("custom1", "custom_name1", "custom_val1"), ("custom2", "custom_name2", "custom_val2"), ], + "schema": { + "@type": "Article", + "image": "http://example.com{}".format(self.image_url), + "articleBody": "post text", + "articleSection": ["category 1", "category 2"], + "author": { + "@type": "Person", + "name": self.post.author.get_full_name(), + }, + "copyrightYear": self.post.date_published.year, + "dateCreated": self.post.date_created.isoformat(), + "dateModified": self.post.date_modified.isoformat(), + "datePublished": self.post.date_published.isoformat(), + "expires": self.post.date_published_end.isoformat(), + "headline": "post abstract", + "keywords": ["post keyword1", "post keyword 2"], + "description": "post meta", + "name": "a title", + "url": "http://example.com/title/", + "mainEntityOfPage": { + "@type": "WebPage", + "@id": "http://example.com/title/", + }, + "publisher": { + "@type": "Organization", + "logo": { + "@type": "ImageObject", + "url": "http://example.com/some/logo.png", + }, + "name": "publisher name", + }, + "comment": [{"@type": "Comment", "text": "comment body"}], + "commentCount": self.post.comments.count(), + "citation": [ + { + "@type": "Article", + "articleBody": "related post text", + "articleSection": ["category 1", "category 2"], + "author": {"@type": "Person", "name": self.related_post.author.get_full_name()}, + "citation": [], + "comment": [], + "commentCount": self.related_post.comments.count(), + "copyrightYear": self.related_post.date_published.year, + "dateCreated": self.related_post.date_created.isoformat(), + "dateModified": self.related_post.date_modified.isoformat(), + "datePublished": self.related_post.date_published.isoformat(), + "description": "related post meta", + "expires": self.related_post.date_published_end.isoformat(), + "headline": "related post abstract", + "image": "http://example.com{}".format(self.related_image_url), + "keywords": ["related post keyword1", "related post keyword 2"], + "mainEntityOfPage": {"@id": "http://example.com/related-title/", "@type": "WebPage"}, + "name": "related title", + "publisher": None, + "url": "http://example.com/related-title/", + } + ], + }, } request = self.get_request(None, "en", path="/title/", secure=True) meta = self.post.as_meta(request) @@ -170,6 +312,7 @@ def test_as_meta_get_request_deprecation(self): META_USE_OG_PROPERTIES=True, META_USE_TWITTER_PROPERTIES=True, META_USE_SCHEMAORG_PROPERTIES=True, + META_USE_JSON_LD_SCHEMA=True, ) def test_templatetag(self): self.post.as_meta() @@ -203,6 +346,10 @@ def test_templatetag(self): response, '{}'.format(self.post.as_meta().as_json_ld()) + ) @override_settings( META_SITE_PROTOCOL="http", @@ -316,11 +463,16 @@ def test_google_plus_scope_works(self): """ self.assertEqual(googleplus_html_scope("bar"), ' itemscope itemtype="https://schema.org/bar" ') - @override_settings(META_SITE_PROTOCOL="https", META_USE_SITES=True) - def test_image_protocol(self): + @override_settings(META_USE_SITES=True, META_SITE_PROTOCOL="https") + def test_image_protocol_https(self): meta = self.post.as_meta() self.assertEqual("https://example.com{}".format(self.image_url), meta.image) + @override_settings(META_USE_SITES=True, META_SITE_PROTOCOL="http") + def test_image_protocol_http(self): + meta = self.post.as_meta() + self.assertEqual("http://example.com{}".format(self.image_url), meta.image) + def test_not_use_sites(self): with override_settings(META_USE_SITES=False): with self.assertRaises(RuntimeError): @@ -328,3 +480,17 @@ def test_not_use_sites(self): with override_settings(META_USE_SITES=True, META_SITE_PROTOCOL="http"): meta = self.post.as_meta() self.assertEqual(meta.url, "http://example.com/title/") + + @override_settings(META_SITE_PROTOCOL=None) + def test_get_meta_protocol_without_site_protocol_will_raise(self): + with self.assertRaises(ImproperlyConfigured): + self.post.get_meta_protocol() + + def test_get_meta_protocol(self): + with override_settings(META_SITE_PROTOCOL="http"): + self.assertEqual(self.post.get_meta_protocol(), "http") + with override_settings(META_SITE_PROTOCOL="https"): + self.assertEqual(self.post.get_meta_protocol(), "https") + + def test_get_author_schemaorg(self): + self.assertEqual(self.post.get_author_schemaorg(), "https://schemaorg-profile.com")