# -*- coding: utf-8 -*-
# This file is part of Shuup.
#
# Copyright (c) 2012-2021, Shuup Commerce Inc. All rights reserved.
#
# This source code is licensed under the OSL-3.0 license found in the
# LICENSE file in the root directory of this source tree.
import logging
import threading
import warnings
from contextlib import contextmanager
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from shuup.apps.provides import get_identifier_to_object_map, get_provide_objects
from shuup.core import cache
from shuup.utils.deprecation import RemovedInFutureShuupWarning
from shuup.utils.importing import load
from shuup.xtheme.extenders import MenuExtenderLocation
log = logging.getLogger(__name__)
# keeps the current middleware state here
_xtheme_middleware_state = threading.local()
def get_theme_cache_key(shop=None):
return "shop-{}-current_theme".format(shop.id if shop else "default")
# TODO: Document how to create Xthemes
class Theme(object):
"""
Base class for all the Xtheme themes.
This class does not directly correspond to a database object;
it's used in the rendering, etc. process.
It does, however, act as a container for a `ThemeSettings` object
that contains the actual persisted settings, etc.
"""
# The identifier for this theme. Used for theme selection.
# Must be set in actual themes!
identifier = None
# The printable name for this theme.
name = ""
# The author for this theme, for posterity.
author = ""
# Directory prefix for this theme's template files.
# If `None`, the `identifier` is used instead
template_dir = None
# Directory prefix for this theme's default template files.
# If `None`, the default `shuup.front` templates is used instead.
default_template_dir = None
# List of global placeholder names.
# TODO: (These could be ignored in per-view editing, or something?)
# TODO: Document this
global_placeholders = []
# List of (name, FormField) pairs for theme configuration.
# This might not be used if you override `get_configuration_form`.
# TODO: Document this
fields = []
"""
List of dicts containing stylesheet definitions.
Each dict must contain following values:
* `identifier` internal identifier of the stylesheet
definition. Identifier is used on theme selection.
If your stylesheet doesn't define the `identifier`
the images cannot be shown in theme selector.
* `stylesheet` with a value of the css file path
* `name` with a name of the stylesheet
Following values are not mandatory:
* `images` a list of image paths. Images are shown
when merchant makes decisions on what theme to use.
Example:
stylesheets = [
{
"identifier": "my_style",
"stylesheet": "path/to/style.css",
"name": _("My Style"),
"images: ["path/to/image.png", "path/to/image2.png"]
}
]
Will be Deprecated in the future: List of tuples(path, name) for
stylesheets provided by this theme.
"""
stylesheets = []
# identifier for the stylesheet provided by default
default_style_identifier = None
# List of plugin specs used in this template
plugins = []
# Guide template location
guide_template = None
# Extra configuration themes - it will be included after the configuration form
extra_config_template = None
extra_config_extra_css = None
extra_config_extra_js = None
def __init__(self, theme_settings=None, shop=None):
"""
Initialize this theme, with an optional `ThemeSettings` or `Shop` object. Only one should be passed.
:param theme_settings: A theme settings object for this theme.
:type theme_settings: ThemeSettings|None
:param shop: The shop for this theme.
:type shop: Shop|None
"""
self._shop = None
self._theme_settings = None
if theme_settings:
if theme_settings.theme_identifier != self.identifier:
raise ValueError(_("Theme identifiers must match."))
self._theme_settings = theme_settings
self._shop = shop or theme_settings.shop
elif shop:
from shuup.xtheme.models import ThemeSettings
self._shop = shop
self._theme_settings = ThemeSettings.objects.get_or_create(theme_identifier=self.identifier, shop=shop)[0]
else:
raise ValueError(_("Either theme_settings or shop should be informed."))
@property
def settings_obj(self):
"""
Get a saved settings model for this theme.
:rtype: shuup.xtheme.models.ThemeSettings
"""
return self._theme_settings
[docs] def set_current(self):
"""
Set this theme as the active theme.
"""
if not self.settings_obj.active:
self.settings_obj.activate()
set_current.alters_data = True
[docs] def get_setting(self, key, default=None):
"""
Get a setting value for this theme.
:param key: Setting name
:type key: str
:param default: Default value, if the setting is not set
:type default: object
:return: Setting value
:rtype: object
"""
return self.settings_obj.get_setting(key, default)
[docs] def get_settings(self):
"""
Get all the currently set settings for the theme as a dict.
:return: Dict of settings
:rtype: dict
"""
return self.settings_obj.get_settings()
[docs] def set_settings(self, *args, **kwargs):
"""
Set a number of settings for this theme.
The arguments are exactly the same as those to `dict`.
.. note:: It's better to call this once instead of calling `set_setting`
several times.
"""
self.settings_obj.update_settings(dict(*args, **kwargs))
set_settings.alters_data = True
[docs] def set_setting(self, key, value):
"""
Set a theme setting `key` to the value `value`.
:param key: Setting name.
:type key: str
:param value: Setting value.
:type value: object
"""
self.settings_obj.update_settings({key: value})
set_setting.alters_data = True
[docs] def get_view(self, view_name):
"""
Get an extra view for this theme.
Views may be either normal Django functions or CBVs (or anything that has `.as_view()` really).
Falsy values are considered "not found".
:param view_name: View name.
:type view_name: str
:return: The extra view, if one exists for the given name.
:rtype: dict[str, View|callable]|None
"""
return None
[docs] def get_all_plugin_choices(self, empty_label=None):
"""
Get grouped list of 2-tuples (identifier and name) of all available Xtheme plugins.
Super handy for `<select>` boxes.
:param empty_label: Label for the "empty" choice. If falsy, no empty choice is prepended.
:type empty_label: str|None
:return: List of 2-tuples
:rtype: Iterable[tuple[str, str]]
"""
choices = []
if empty_label:
choices.append(("", empty_label))
choices += [(_("Global plugins"), self.get_global_plugin_choices())]
choices += [(_("Theme plugins"), self.get_theme_plugin_choices())]
return choices
[docs] def get_theme_plugin_choices(self, empty_label=None):
"""
Get a sorted list of 2-tuples (identifier and name) of available theme specific Xtheme plugins.
Handy for `<select>` boxes.
:param empty_label: Label for the "empty" choice. If falsy, no empty choice is prepended.
:type empty_label: str|None
:return: List of 2-tuples
:rtype: Iterable[tuple[str, str]]
"""
choices = []
if empty_label:
choices.append(("", empty_label))
for plugin_spec in self.plugins:
plugin = load(plugin_spec)
choices.append((plugin.identifier, getattr(plugin, "name", None) or plugin.identifier))
choices.sort(key=lambda v: v[1])
return choices
[docs] def get_global_plugin_choices(self, empty_label=None):
"""
Get a sorted list of 2-tuples (identifier and name) of available global Xtheme plugins.
Handy for `<select>` boxes.
:param empty_label: Label for the "empty" choice. If falsy, no empty choice is prepended.
:type empty_label: str|None
:return: List of 2-tuples
:rtype: Iterable[tuple[str, str]]
"""
choices = []
if empty_label:
choices.append(("", empty_label))
for plugin in get_provide_objects("xtheme_plugin"):
if plugin.identifier:
choices.append((plugin.identifier, getattr(plugin, "name", None) or plugin.identifier))
choices.sort(key=lambda v: v[1])
return choices
[docs] def has_stylesheets(self):
return bool(self.stylesheets)
[docs] def has_images(self):
"""
Check if theme has images available.
:return: True or False
:rtype: bool
"""
if not self.has_stylesheets():
return False
if isinstance(self.stylesheets[0], dict):
for sheet in self.stylesheets:
if sheet.get("images", None):
return True
else:
warnings.warn(
"Warning! Using list of tuples in `theme.stylesheets` will deprecate "
"in Shuup 0.5.7. Use list of dictionaries instead.",
RemovedInFutureShuupWarning,
)
return False
[docs] def get_default_style(self):
blank = {"stylesheet": "", "name": self.name}
if not self.has_stylesheets():
return blank
old_style = False if isinstance(self.stylesheets[0], dict) else True
if old_style:
warnings.warn(
"Warning! Using list of tuples in `theme.stylesheets` will deprecate "
"in Shuup 0.5.7. Use list of dictionaries instead.",
RemovedInFutureShuupWarning,
)
# just return this, no identifier available
stylesheet, name = self.stylesheets[0]
return {"stylesheet": stylesheet, "name": name}
if not self.default_style_identifier:
return self.stylesheets[0]
else:
for stylesheet in self.stylesheets:
if stylesheet.identifier == self.default_style_identifier:
return stylesheet
return blank
_not_set = object() # Can't use `None` here.
@contextmanager
def override_current_theme_class(theme_class=_not_set, shop=None):
"""
Context manager for overriding the currently active theme class for testing.
An instance of this class is then returned by `get_current_theme`.
A falsy value means `None` is returned from `get_current_theme`. This is also
useful for testing.
:param theme_class: A theme class object.
:type theme_class: class[Theme]
"""
# Circular import avoidance:
from shuup.xtheme.views.extra import clear_view_cache
old_theme_class = cache.get(get_theme_cache_key(shop))
if theme_class is _not_set or not theme_class:
cache.set(get_theme_cache_key(shop), None)
else:
from shuup.xtheme.models import ThemeSettings
theme_settings = ThemeSettings.objects.get_or_create(shop=shop, theme_identifier=theme_class.identifier)[0]
theme = theme_class(theme_settings)
set_middleware_current_theme(theme)
cache.set(get_theme_cache_key(shop), theme)
clear_view_cache()
yield
cache.set(get_theme_cache_key(shop), old_theme_class)
clear_view_cache()
def get_current_theme(shop):
"""
Get the currently active theme object.
:param shop: The shop to get the active theme.
:type shop: shuup.core.models.Shop
:return: Theme object or None
:rtype: Theme
"""
value = cache.get(get_theme_cache_key(shop))
if value:
set_middleware_current_theme(value)
return value
theme = _get_current_theme(shop)
cache.set(get_theme_cache_key(shop), theme)
# set this theme as the current for this thread
set_middleware_current_theme(theme)
return theme
def set_middleware_current_theme(theme):
""" "
Set the theme as the current for this thread.
"""
_xtheme_middleware_state.theme = theme
def get_middleware_current_theme():
"""
Return the current middleware state theme.
"""
return getattr(_xtheme_middleware_state, "theme", None)
def get_theme_by_identifier(identifier, shop):
"""
Get an instantiated theme by identifier.
:param identifier: Theme identifier.
:type identifier: str
:param shop: Shop to fetch the theme settings.
:type shop: shuup.core.models.Shop
:return: Theme object or None
:rtype: Theme
"""
for theme_cls in get_provide_objects("xtheme"):
if theme_cls.identifier == identifier:
from shuup.xtheme.models import ThemeSettings
theme_settings = ThemeSettings.objects.get_or_create(theme_identifier=identifier, shop=shop)[0]
return theme_cls(theme_settings=theme_settings, shop=shop)
return None # No such thing.
def set_current_theme(identifier, shop):
"""
Activate a theme based on identifier.
:param identifier: Theme identifier.
:type identifier: str
:param shop: Shop to fetch the theme settings.
:type shop: shuup.core.models.Shop
:return: Activated theme
:rtype: Theme
"""
cache.bump_version(get_theme_cache_key(shop))
theme = get_theme_by_identifier(identifier, shop)
if not theme:
raise ValueError("Error! Invalid theme identifier.")
theme.set_current()
cache.set(get_theme_cache_key(shop), theme)
set_middleware_current_theme(theme)
return theme
def _get_current_theme(shop):
theme = None
try:
# Ensure this module can be imported from anywhere by lazily importing the model
from shuup.xtheme.models import ThemeSettings
theme_settings = ThemeSettings.objects.filter(active=True, shop=shop).first()
# no active found, take the first and activate
if not theme_settings:
theme_settings = ThemeSettings.objects.filter(shop=shop).first()
if theme_settings:
theme_settings.activate()
theme_settings.refresh_from_db()
except Exception as exc:
# This is unfortunate and weird, but I don't want other tests to depend
# on Xtheme's state or require the `djangodb` mark for every test.
# So we silence exceptions with pytest-django's "Database access not allowed"
# message here and let everything else pass.
if "Database access not allowed" not in str(exc):
raise
theme_settings = None
if theme_settings:
theme_cls = get_identifier_to_object_map("xtheme").get(theme_settings.theme_identifier)
if theme_cls is not None:
theme = theme_cls(theme_settings=theme_settings, shop=shop)
else:
log.warn("Warning! The active theme %r is currently not installed.", theme_settings.theme_identifier)
return theme