Skip to content

Commit

Permalink
Add schema.org support (#166)
Browse files Browse the repository at this point in the history
* Add schema.org support

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix tests

* Split image_protocol tests in two

* Fix typo and fix tests

* Add schema.org tests

---------

Co-authored-by: Iacopo Spalletti <[email protected]>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored Sep 25, 2023
1 parent 818453f commit f294c81
Show file tree
Hide file tree
Showing 17 changed files with 891 additions and 70 deletions.
1 change: 1 addition & 0 deletions changes/76.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add schema.org support
1 change: 1 addition & 0 deletions cms_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ django meta has two different operating mode:
upgrading
models
views
schema
settings
rendering
extra_tags
Expand Down
10 changes: 5 additions & 5 deletions docs/modules.rst
Original file line number Diff line number Diff line change
@@ -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:
113 changes: 113 additions & 0 deletions docs/schema.rst
Original file line number Diff line number Diff line change
@@ -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
112 changes: 86 additions & 26 deletions meta/models.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,41 @@
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 "
"is not used. See META_USE_SITES setting."
).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,
Expand Down Expand Up @@ -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 <schema.model>`.
"""

def get_meta(self, request=None):
"""
Expand All @@ -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"):
Expand All @@ -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
Expand Down Expand Up @@ -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):
"""
Expand All @@ -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)
2 changes: 2 additions & 0 deletions meta/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down
3 changes: 3 additions & 0 deletions meta/templates/meta/meta.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
{% spaceless %}
{% autoescape off %}
{% if meta %}
{% if meta.use_json_ld and meta.schema %}
<script type="application/ld+json">{{ meta.as_json_ld }}</script>
{% 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 %}
Expand Down
Loading

0 comments on commit f294c81

Please sign in to comment.