diff --git a/taccsite_cms/contrib/__init__.py b/taccsite_cms/contrib/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/taccsite_cms/contrib/_docs/how-to-conditionally-render-child-plugins.md b/taccsite_cms/contrib/_docs/how-to-conditionally-render-child-plugins.md
new file mode 100644
index 000000000..db314ed32
--- /dev/null
+++ b/taccsite_cms/contrib/_docs/how-to-conditionally-render-child-plugins.md
@@ -0,0 +1,11 @@
+# How to Conditionally Render Child Plugins
+
+```handlebars
+ {% for plugin_instance in instance.child_plugin_instances %}
+ {% if plugin_instance.plugin_type == 'LinkPlugin' %}
+ >
+
+
+ {% endif %}
+ {% endfor %}
+```
diff --git a/taccsite_cms/contrib/_docs/how-to-extend-django-cms-plugin.md b/taccsite_cms/contrib/_docs/how-to-extend-django-cms-plugin.md
new file mode 100644
index 000000000..f93e07d4e
--- /dev/null
+++ b/taccsite_cms/contrib/_docs/how-to-extend-django-cms-plugin.md
@@ -0,0 +1,66 @@
+# How To Extend a `djangocms-___` Plugin
+
+These example codes extend the [`djangocms-link` plugin](https://github.com/django-cms/djangocms-link/tree/3.0.0/djangocms_link).
+
+`.../models.py`:
+
+```python
+from djangocms_link.models import AbstractLink
+
+class Taccsite______(AbstractLink):
+ """
+ Components > "Article List" Model
+ https://confluence.tacc.utexas.edu/x/OIAjCQ
+ """
+ # ___ = ___
+
+ class Meta:
+ abstract = False
+```
+
+`.../cms_plugins.py`:
+
+```python
+from djangocms_link.cms_plugins import LinkPlugin
+
+from .models import ______Preview
+
+class ______Plugin(LinkPlugin):
+ module = 'TACC Site'
+ model = Taccsite______
+ name = _('______')
+ render_template = 'static_article_preview.html'
+ def get_render_template(self, context, instance, placeholder):
+ return self.render_template
+
+ fieldsets = [
+ (_('Link'), {
+ 'fields': (
+ ('external_link', 'internal_link'),
+ ('anchor', 'target'),
+ )
+ }),
+ ]
+
+ # Render
+ def render(self, context, instance, placeholder):
+ context = super().render(context, instance, placeholder)
+ request = context['request']
+
+ context.update({
+ 'link_url': instance.get_link(),
+ 'link_text': instance.name,
+ 'link_target': instance.target
+ })
+ return context
+```
+
+`.../templates/______.py`:
+
+```handlebars
+
+
+ {{ link_text }}
+
+```
diff --git a/taccsite_cms/contrib/_docs/how-to-handle-non-nullable-default-value.md b/taccsite_cms/contrib/_docs/how-to-handle-non-nullable-default-value.md
new file mode 100644
index 000000000..fb558ce69
--- /dev/null
+++ b/taccsite_cms/contrib/_docs/how-to-handle-non-nullable-default-value.md
@@ -0,0 +1,62 @@
+# How to Handle "Non-Nullable" "Default Value"
+
+## Sample Error
+
+```text
+You are trying to add a non-nullable field '...'
+to choice without a default; we can't do that
+(the database needs something to populate existing rows).
+Please select a fix:
+ 1) Provide a one-off default now (will be set on all existing rows)
+ 2) Quit, and let me add a default in models.py
+Select an option:
+```
+
+## Explanations
+
+- (blog post) [What do you do when 'makemigrations' is telling you About your Lack of Default Value](https://chrisbartos.com/articles/what-do-you-do-when-makemigrations-is-telling-you-about-your-lack-of-default-value/)
+- (video) [You are trying to add a non-nullable field ' ' to ' ' without a default; we can't do that](https://www.youtube.com/watch?v=NgaTUEijQSQ)
+
+## Solutions
+
+### For `cmsplugin_ptr`
+
+1. ☑ Select option 1), then see:
+ - [Follow-Up Error](#follow-up-error)
+ - [Notes ▸ `cmsplugin_ptr`](#cmsplugin_ptr)
+
+### For Other Fields
+
+1. ⚠ Select option 1) and hope for the best.
+2. ☑ Select option 2) and provide a sensible default (_not_ `None` a.k.a. null).
+3. ⚠ (blog post) (hack) [Add A Migration For A Non-Null Foreignkey Field In Django](https://jaketrent.com/post/add-migration-nonnull-foreignkey-field-django)
+
+## Follow-Up Error
+
+If you allowed Null to be set as default, then you may have this new error:
+
+```text
+django.db.utils.IntegrityError: column "..." contains null values
+```
+
+Solutions:
+
+1. [delete _relevant_ migration files and rebuild migrations](https://stackoverflow.com/a/37244199/11817077)
+2. [delete _all_ migration files and rebuild migrations](https://stackoverflow.com/a/37242930/11817077)
+
+## Notes
+
+### `cmsplugin_ptr`
+
+ If the field is `cmsplugin_ptr` then know that
+
+ - [it is a database relationship field managed automatically by Django](https://github.com/nephila/djangocms-blog/issues/316#issuecomment-242292787),
+ - you may see it in workarounds for other plugins ([source a](https://github.com/django-cms/djangocms-link/blob/3.0.0/djangocms_link/models.py#L125), [source b](https://github.com/django-cms/djangocms-picture/blob/3.0.0/djangocms_picture/models.py#L208)),
+ - you should __not__ add or overwrite it unless you know what you are doing.
+
+ _W. Bomar learned everything in the intitial version of this document after trying to overwrite `cmsplugin_ptr` while extending its model from [source a](https://github.com/django-cms/djangocms-link/blob/3.0.0/djangocms_link/models.py#L125). His solution was [delete _all_ migration files and rebuild migrations](https://stackoverflow.com/a/37242930/11817077)._
+
+## Appendix
+
+- [Django CMS ▸ How to create Plugins ▸ Handling Relations](https://docs.django-cms.org/en/release-3.7.x/how_to/custom_plugins.html#handling-relations)
+- [[BUG] Plugins with models that don't directly inherit from CMSPlugin or an abstract model cannot be copied](https://github.com/django-cms/django-cms/issues/6987)
diff --git a/taccsite_cms/contrib/_docs/how-to-override-validation-error-from-parent-model.md b/taccsite_cms/contrib/_docs/how-to-override-validation-error-from-parent-model.md
new file mode 100644
index 000000000..a9ccbfc9f
--- /dev/null
+++ b/taccsite_cms/contrib/_docs/how-to-override-validation-error-from-parent-model.md
@@ -0,0 +1,67 @@
+# How To Override `ValidationError()` from Parent Model
+
+Intercept Error(s):
+
+```python
+from django.core.exceptions import ValidationError
+
+from djangocms_Xxx.models import AbstractXxx
+
+from taccsite_cms.contrib.helpers import (
+ get_indices_that_start_with
+)
+
+class OurModelThatUsesXxx(AbstractXxx):
+ # Validate
+ def clean(self):
+ # Bypass irrelevant parent validation
+ try:
+ super().clean()
+ except ValidationError as err:
+ # Intercept single-field errors
+ if hasattr(err, 'error_list'):
+ for i in range(len(err.error_list)):
+ # SEE: "Find Error(s)"
+ # ...
+ # Skip error
+ del err.error_list[i]
+ # Replace error
+ # SEE: https://docs.djangoproject.com/en/2.2/ref/forms/validation/#raising-validationerror
+
+ # Intercept multi-field errors
+ if hasattr(err, 'error_dict'):
+ for field, errors in err.message_dict.items():
+ # SEE: "Find Error(s)"
+ # ...
+ # Skip error
+ del err.error_dict[field]
+ # Replace error
+ # SEE: https://docs.djangoproject.com/en/2.2/ref/forms/validation/#raising-validationerror
+
+ # NOTE: The conditional `pass` is only to skip multi-field errors;
+ # single-field error skipping is unaffected by this logic;
+ # so it seems safe to always include this logic block
+ if len(err.messages) == 0:
+ pass
+ else:
+ raise err
+```
+
+Handle Error(s):
+
+```python
+# SEE: "Find Error(s)"
+# ...
+
+ # Catch known static error
+ if 'Known static error string' in error:
+ # ...
+
+ # Catch known dynamic error
+ indices_to_catch = get_indices_that_start_with(
+ 'Known dynamic error string that starts with same text',
+ errors
+ )
+ for i in indices_to_catch:
+ # ...
+```
diff --git a/taccsite_cms/contrib/_docs/taccsite_static_article.md b/taccsite_cms/contrib/_docs/taccsite_static_article.md
new file mode 100644
index 000000000..e69d9e300
--- /dev/null
+++ b/taccsite_cms/contrib/_docs/taccsite_static_article.md
@@ -0,0 +1,66 @@
+# Static Article Plugins
+
+## Intention
+
+Support static addition of news articles that originate from a Core news site.
+
+A [dynamic solution that pulls form the Core news site](https://github.com/TACC/Core-CMS/issues/69) is preferable.
+
+But this is not available due to constrainst of architecture, time, or ability.
+
+## Architecture
+
+### (Currently) Add Image via Child Plugin Instead of Via Fields
+
+Instead, the image fields should be in the plugin, __not__ via a child plugin, but a solution has not yet been implemented.
+
+#### Hope for the Future
+
+The `AbstractLink` model was successfully extended.
+
+See:
+ - [./how-to-extend-django-cms-plugin.md](./how-to-extend-django-cms-plugin.md)
+ - [../taccsite_static_article_preview](../taccsite_static_article_preview)
+ - [../taccsite_static_article_list](../taccsite_static_article_list)
+
+#### Failed Attempt
+
+1. Build model so it extends `AbstractPicture` from `djangocms-picture`.
+2. Tweak model to sweep bugs under the rug.
+3. Quit when he was unable to resolve the error,
+ `TaccsiteStaticNewsArticlePreview has no field named 'cmsplugin_ptr_id'`
+ upon saving a plugin instance.
+4. Learn:
+ - [one should not try to reduce `AbstractPicture`](https://stackoverflow.com/a/3674714/11817077)
+ - [one should not subclass a subclass of `CMSPlugin`](https://github.com/django-cms/django-cms/blob/3.7.4/cms/models/pluginmodel.py#L104)
+
+#### Abandoned Code
+
+```python
+from djangocms_picture.models import AbstractPicture
+
+# To allow user to not set image
+# FAQ: Emptying the clean() method avoids picture validation
+# SEE: https://github.com/django-cms/djangocms-picture/blob/3.0.0/djangocms_picture/models.py#L278
+def skip_image_validation():
+ pass
+
+class TaccsiteStaticNewsArticlePreview(AbstractPicture):
+ #
+ # …
+ #
+
+ # Remove error-prone attribute from parent class
+ # FAQ: Avoid error when running `makemigrations`:
+ # "You are trying to add a non-nullable field 'cmsplugin_ptr' […]"
+ # SEE: https://github.com/django-cms/djangocms-picture/blob/3.0.0/djangocms_picture/models.py#L212
+ # SEE: https://github.com/django-cms/djangocms-picture/blob/3.0.0/djangocms_picture/models.py#L234
+ cmsplugin_ptr = None
+
+ class Meta:
+ abstract = False
+
+ # Validate
+ def clean(self):
+ skip_image_validation()
+```
diff --git a/taccsite_cms/contrib/bootstrap4_djangocms_link/cms_plugins.py b/taccsite_cms/contrib/bootstrap4_djangocms_link/cms_plugins.py
new file mode 100644
index 000000000..98ab52d07
--- /dev/null
+++ b/taccsite_cms/contrib/bootstrap4_djangocms_link/cms_plugins.py
@@ -0,0 +1,24 @@
+# Re-register unregistered LinkPlugin without uninstalling Bootstrap4's
+# FAQ: A Bootstrap link is undesirable but may be used by migrated legacy sites
+# TODO: Drop try/except & load non-standard plugin set for migrated legacy sites
+# FAQ: If we can import both plugins, then re-register LinkPlugin
+# (because Bootstrap4Link unregistered LinkPlugin)
+try:
+ from cms.plugin_pool import plugin_pool
+ from djangocms_link.cms_plugins import LinkPlugin
+ from djangocms_bootstrap4.contrib.bootstrap4_link.cms_plugins import Bootstrap4LinkPlugin
+
+ # Restore original fields
+ # SEE: https://github.com/django-cms/djangocms-bootstrap4/blob/2.0.0/djangocms_bootstrap4/contrib/bootstrap4_link/cms_plugins.py#L26-L42
+ # SEE: https://github.com/django-cms/djangocms-link/blob/3.0.0/djangocms_link/cms_plugins.py#L20-L23
+ LinkPlugin.fieldsets[0][1]['fields'] = (
+ 'name',
+ ('external_link', 'internal_link'),
+ )
+
+ # SEE: https://github.com/django-cms/djangocms-link/issues/163
+ plugin_pool.register_plugin(LinkPlugin)
+# CAVEAT: If import statement fails for reason other than Bootstrap absence,
+# then that failure, and the failure of this plugin, is silent
+except ImportError:
+ pass
diff --git a/taccsite_cms/contrib/bootstrap4_djangocms_picture/cms_plugins.py b/taccsite_cms/contrib/bootstrap4_djangocms_picture/cms_plugins.py
new file mode 100644
index 000000000..31bcae84e
--- /dev/null
+++ b/taccsite_cms/contrib/bootstrap4_djangocms_picture/cms_plugins.py
@@ -0,0 +1,16 @@
+# Re-register unregistered PicturePlugin without uninstalling Bootstrap4's
+# FAQ: A Bootstrap picture has superfluous options that are not always desirable
+# TODO: Drop try/except & load non-standard plugin set for migrated legacy sites
+# FAQ: If we can import both plugins, then re-register PicturePlugin
+# (because Bootstrap4Picture unregistered PicturePlugin)
+try:
+ from cms.plugin_pool import plugin_pool
+ from djangocms_picture.cms_plugins import PicturePlugin
+ from djangocms_bootstrap4.contrib.bootstrap4_picture.cms_plugins import Bootstrap4PicturePlugin
+
+ # SEE: https://github.com/django-cms/djangocms-bootstrap4/blob/master/djangocms_bootstrap4/contrib/bootstrap4_picture/cms_plugins.py#L54
+ plugin_pool.register_plugin(PicturePlugin)
+# CAVEAT: If import statement fails for reason other than Bootstrap presence,
+# then that failure, and the failure of this plugin, is silent
+except ImportError:
+ pass
diff --git a/taccsite_cms/contrib/helpers.py b/taccsite_cms/contrib/helpers.py
new file mode 100644
index 000000000..658938df5
--- /dev/null
+++ b/taccsite_cms/contrib/helpers.py
@@ -0,0 +1,256 @@
+# Get Django `models.CharField` `choices`
+def get_choices(choice_dict):
+ """Get a sequence for a Django model field choices from a dictionary.
+
+ :param Dict[str, Dict[str, str]] dictionary: choice as key for dictionary of classnames and descriptions
+ :return: a sequence for django.db.models.CharField.choices
+ :rtype: List[Tuple[str, str], ...]
+ """
+ choices = []
+
+ for key, data in choice_dict.items():
+ choice = (key, data['description'])
+ choices.append(choice)
+
+ return choices
+
+
+
+# Filter Django `models.CharField` `choices`
+# SEE: get_choices
+def filter_choices_by_prefix(choices, prefix):
+ """Reduce sequence of choices to items whose values begin with given string
+
+ :param List[Tuple[str, str], ...] choices: the sequence to filter
+ :param str prefix: the starting text required of an item value to retain it
+ :returns: a sequence for django.db.models.CharField.choices
+ :rtype: List[Tuple[str, str], ...]
+ """
+ new_choices = []
+
+ for choice in choices:
+ should_keep = choice[0].startswith(prefix)
+ if should_keep:
+ new_choices.append(choice)
+
+ return new_choices
+
+
+
+# Concatenate a list of CSS classes
+# SEE: https://github.com/django-cms/djangocms-bootstrap4/blob/master/djangocms_bootstrap4/helpers.py
+def concat_classnames(classes):
+ """Concatenate a list of classname strings (without failing on None)"""
+ # SEE: https://stackoverflow.com/a/20271297/11817077
+ return ' '.join(_class for _class in classes if _class)
+
+
+
+# Create a list clone that has another list shoved into it
+# SEE: https://newbedev.com/how-to-insert-multiple-elements-into-a-list
+def insert_at_position(position, list, list_to_insert):
+ """Insert list at position within another list
+
+ :returns: New list
+ """
+ return list[:position] + list_to_insert + list[position:]
+
+
+
+# Get the date from a list that is nearest
+# SEE: https://stackoverflow.com/a/32237949/11817077
+def get_nearest(items, pivot):
+ """Get nearest date (or other arithmatic value)
+
+ :returns: The item value nearest the given "pivot" value
+ """
+ return min(items, key=lambda x: abs(x - pivot))
+
+
+
+# Get list of indicies of items that start with text
+# SEE: https://stackoverflow.com/a/67393343/11817077
+def get_indices_that_start_with(text, list):
+ """
+ Get a list of indices of list elements that starts with given text
+
+ :rtype: list
+ """
+ return [i for i in range(len(list)) if list[i].startswith(text)]
+
+
+
+# Populate class attribute of plugin instances
+def add_classname_to_instances(classname, plugin_instances):
+ """Add class names to class attribute of plugin instances"""
+ for instance in plugin_instances:
+ # A plugin must not have any class set
+ if not hasattr(instance.attributes, 'class'):
+ instance.attributes['class'] = ''
+
+ # The class should occur before any CMS or user classes
+ # FAQ: This keeps plugin author classes together
+ instance.attributes['class'] = instance.attributes['class'] + classname
+
+
+
+# Get date nearest today
+
+from datetime import date
+
+# HELP: Can this logic be less verbose?
+# HELP: Is the `preferred_time_period` parameter effectual?
+def which_date_is_nearest_today(date_a, date_b, preferred_time_period):
+ """
+ Returns whether each date is today or nearest today, and whether nearest date is past or today or future.
+
+ Only two dates are supported. You may prefer 'future' or 'past' date(s).
+
+ If both dates are the same date, then both are reported as True.
+
+ :param datetime date_a: a date "A" to compare
+ :param datetime date_b: a date "B" to compare
+ :param str preferred_time_period: whether to prefer 'future' or 'past' dates
+
+ :returns:
+ A tuple of tuples:
+ ((
+ ``boolean`` of whether ``date_a`` is nearest,
+ ``string`` of ``date_a`` time period ``past``/``today``/``future``
+ ),
+ (
+ ``boolean`` of whether ``date_b`` is nearest,
+ ``string`` of ``date_b`` time period ``past``/``today``/``future``
+ )),
+ :rtype: tuple
+ """
+ today = date.today()
+ is_a = False
+ is_b = False
+ a_time_period = 'today'
+ b_time_period = 'today'
+
+ # Match preferred time
+
+ if today == date_a:
+ is_a = True
+ a_time_period = 'today'
+
+ if today == date_b:
+ is_b = True
+ b_time_period = 'today'
+
+ elif preferred_time_period == 'future':
+ is_a = date_a and date_a >= today
+ is_b = date_b and date_b >= today
+ if is_a: a_time_period = 'future'
+ if is_b: b_time_period = 'future'
+ if not is_a and not is_b:
+ is_a = date_a and date_a < today
+ is_b = date_b and date_b < today
+ if is_a: a_time_period = 'past'
+ if is_b: b_time_period = 'past'
+
+ elif preferred_time_period == 'past':
+ is_a = date_a and date_a < today
+ is_b = date_b and date_b < today
+ if is_a: a_time_period = 'past'
+ if is_b: b_time_period = 'past'
+ if not is_a and not is_b:
+ is_a = date_a and date_a >= today
+ is_b = date_b and date_b >= today
+ if is_a: a_time_period = 'future'
+ if is_b: b_time_period = 'future'
+
+ # Show nearest date
+ if is_a and is_b and date_a != date_b:
+ nearest_date = get_nearest((date_a, date_b), today)
+
+ if date_a == nearest_date:
+ is_b = False
+ if date_b == nearest_date:
+ is_a = False
+
+ return ((is_a, a_time_period), (is_b, b_time_period))
+
+
+
+# Allow plugins to set max number of nested children
+
+from django.shortcuts import render
+
+# SEE: https://github.com/django-cms/django-cms/issues/5102#issuecomment-597150141
+class AbstractMaxChildrenPlugin():
+ """
+ Abstract extension of `CMSPluginBase` that allows setting maximum amount of nested/child plugins.
+
+ Usage:
+ 1. Extend this class,
+ after extending `CMSPluginBase` or a class that extends `CMSPluginBase`.
+ 2. Set `max_children` to desired limit.
+ """
+
+ max_children = None
+
+ def add_view(self,request, form_url='', extra_context=None):
+
+ if self.max_children:
+ # FAQ: Placeholders do not have a parent, only plugins do
+ if self._cms_initial_attributes['parent']:
+ num_allowed = len([v for v in self._cms_initial_attributes['parent'].get_children() if v.get_plugin_instance()[0] is not None])
+ else:
+ num_allowed = len([v for v in self.placeholder.get_plugins() if v.get_plugin_instance()[0] is not None and v.get_plugin_name() == self.name])
+
+ if num_allowed >= self.max_children:
+ return render(request , "path/to/your/max_reached_template.html", {
+ 'max_children': self.max_children,
+ })
+ return super(AbstractMaxChildrenPlugin, self).add_view(request, form_url, extra_context)
+
+
+
+# Tweak validation on Django CMS `AbstractLink` for TACC
+
+from cms.models.pluginmodel import CMSPlugin
+
+from django.core.exceptions import ValidationError
+from django.utils.translation import gettext_lazy as _
+
+# SEE: https://github.com/django-cms/djangocms-link/blob/3.0.0/djangocms_link/models.py#L48
+def clean_for_abstract_link(model, self):
+ """
+ Intercept and manipulate validation on `AbstractLink` so that it suits TACC's minimal subclassing of it. (To catch only parent validation errors, not custom ones, run this before any custom validation.)
+
+ Usage:
+ ```
+ from taccsite_cms.contrib.helpers import clean_for_abstract_link
+
+ # Validate
+ def clean(self):
+ clean_for_abstract_link(__class__, self)
+
+ ...
+ ```
+ """
+
+ # Bypass irrelevant parent validation
+ # SEE: ./_docs/how-to-override-validation-error-from-parent-model.md
+ try:
+ super(model, self).clean()
+ except ValidationError as err:
+ # Intercept multi-field errors
+ if hasattr(err, 'error_dict'):
+ for field, errors in err.message_dict.items():
+ # Reduce verbosity of original error message
+ # FAQ: Original error message assumes more fields exist
+ indices = get_indices_that_start_with(
+ 'Only one of ', errors
+ )
+ for i in indices:
+ err.error_dict[field] = ValidationError(
+ _('Only one of External link or Internal link may be given.'), code='invalid')
+
+ if len(err.messages) == 0:
+ pass
+ else:
+ raise err
diff --git a/taccsite_cms/contrib/taccsite_blockquote/__init__.py b/taccsite_cms/contrib/taccsite_blockquote/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/taccsite_cms/contrib/taccsite_blockquote/cms_plugins.py b/taccsite_cms/contrib/taccsite_blockquote/cms_plugins.py
new file mode 100644
index 000000000..f725e2d63
--- /dev/null
+++ b/taccsite_cms/contrib/taccsite_blockquote/cms_plugins.py
@@ -0,0 +1,65 @@
+from cms.plugin_base import CMSPluginBase
+from cms.plugin_pool import plugin_pool
+from django.utils.translation import gettext_lazy as _
+
+from taccsite_cms.contrib.helpers import concat_classnames
+from taccsite_cms.contrib.taccsite_offset.cms_plugins import get_direction_classname
+
+from .models import TaccsiteBlockquote
+
+@plugin_pool.register_plugin
+class TaccsiteBlockquotePlugin(CMSPluginBase):
+ """
+ Components > "Blockquote" Plugin
+ https://confluence.tacc.utexas.edu/x/FIEjCQ
+ """
+ module = 'TACC Site'
+ model = TaccsiteBlockquote
+ name = _('Blockquote')
+ render_template = 'blockquote.html'
+
+ cache = True
+ text_enabled = True
+ allow_children = False
+
+ fieldsets = [
+ (None, {
+ 'fields': (
+ 'text',
+ 'origin',
+ 'use_cite',
+ )
+ }),
+ (_('Citation'), {
+ 'classes': ('collapse',),
+ 'fields': (
+ 'cite_person',
+ ('cite_text', 'cite_url'),
+ )
+ }),
+ (_('Layout'), {
+ 'fields': (
+ 'offset',
+ )
+ }),
+ (_('Advanced settings'), {
+ 'classes': ('collapse',),
+ 'fields': (
+ 'attributes',
+ )
+ }),
+ ]
+
+ # Render
+ def render(self, context, instance, placeholder):
+ context = super().render(context, instance, placeholder)
+ request = context['request']
+
+ classes = concat_classnames([
+ 's-blockquote',
+ get_direction_classname(instance.offset),
+ instance.attributes.get('class'),
+ ])
+ instance.attributes['class'] = classes
+
+ return context
diff --git a/taccsite_cms/contrib/taccsite_blockquote/migrations/0001_initial.py b/taccsite_cms/contrib/taccsite_blockquote/migrations/0001_initial.py
new file mode 100644
index 000000000..8c1fd019b
--- /dev/null
+++ b/taccsite_cms/contrib/taccsite_blockquote/migrations/0001_initial.py
@@ -0,0 +1,35 @@
+# Generated by Django 2.2.16 on 2021-06-22 14:09
+
+from django.db import migrations, models
+import django.db.models.deletion
+import djangocms_attributes_field.fields
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('cms', '0022_auto_20180620_1551'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='TaccsiteBlockquote',
+ fields=[
+ ('cmsplugin_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, related_name='taccsite_blockquote_taccsiteblockquote', serialize=False, to='cms.CMSPlugin')),
+ ('text', models.TextField(default='', null=True, verbose_name='Quote')),
+ ('origin', models.CharField(blank=True, help_text='The origin of the quote (i.e. citation, attribution) (e.g. author, source). This value is ignored if "Advanced origin" fields have data.', max_length=100)),
+ ('use_cite', models.BooleanField(default=False, verbose_name='Use the "Citation" fields')),
+ ('cite_person', models.CharField(blank=True, help_text='The author or speaker of the quote.', max_length=50, verbose_name='Author / Speaker')),
+ ('cite_text', models.CharField(blank=True, help_text='Text for the source of the quote.', max_length=50, verbose_name='Source Text')),
+ ('cite_url', models.CharField(blank=True, help_text='URL for the source of the quote.', max_length=255, verbose_name='Source URL')),
+ ('offset', models.CharField(blank=True, choices=[('left', 'Left'), ('right', 'Right')], max_length=255)),
+ ('attributes', djangocms_attributes_field.fields.AttributesField(default=dict)),
+ ],
+ options={
+ 'abstract': False,
+ },
+ bases=('cms.cmsplugin',),
+ ),
+ ]
diff --git a/taccsite_cms/contrib/taccsite_blockquote/migrations/__init__.py b/taccsite_cms/contrib/taccsite_blockquote/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/taccsite_cms/contrib/taccsite_blockquote/models.py b/taccsite_cms/contrib/taccsite_blockquote/models.py
new file mode 100644
index 000000000..e895d7475
--- /dev/null
+++ b/taccsite_cms/contrib/taccsite_blockquote/models.py
@@ -0,0 +1,59 @@
+from cms.models.pluginmodel import CMSPlugin
+from django.utils.translation import gettext_lazy as _
+
+from django.db import models
+
+from djangocms_attributes_field import fields
+
+from taccsite_cms.contrib.taccsite_offset.models import DIRECTION_CHOICES
+
+class TaccsiteBlockquote(CMSPlugin):
+ """
+ Components > "Blockquote" Model
+ https://confluence.tacc.utexas.edu/x/FIEjCQ
+ """
+ text = models.TextField(
+ verbose_name=_('Quote'),
+ null=True,
+ default='',
+ )
+
+ origin = models.CharField(
+ help_text=_('The origin of the quote (i.e. citation, attribution) (e.g. author, source). This value is ignored if "Advanced origin" fields have data.'),
+ blank=True,
+ max_length=100,
+ )
+
+ use_cite = models.BooleanField(
+ verbose_name=_('Use the "Citation" fields'),
+ default=False,
+ )
+ cite_person = models.CharField(
+ verbose_name=_('Author / Speaker'),
+ help_text='The author or speaker of the quote.',
+ blank=True,
+ max_length=50,
+ )
+ cite_text = models.CharField(
+ verbose_name=_('Source Text'),
+ help_text=_('Text for the source of the quote.'),
+ blank=True,
+ max_length=50,
+ )
+ cite_url = models.CharField(
+ verbose_name=_('Source URL'),
+ help_text=_('URL for the source of the quote.'),
+ blank=True,
+ max_length=255,
+ )
+
+ offset = models.CharField(
+ choices=DIRECTION_CHOICES,
+ blank=True,
+ max_length=255,
+ )
+
+ attributes = fields.AttributesField()
+
+ def get_short_description(self):
+ return self.text
diff --git a/taccsite_cms/contrib/taccsite_blockquote/templates/blockquote.html b/taccsite_cms/contrib/taccsite_blockquote/templates/blockquote.html
new file mode 100644
index 000000000..71fea29cd
--- /dev/null
+++ b/taccsite_cms/contrib/taccsite_blockquote/templates/blockquote.html
@@ -0,0 +1,29 @@
+
+
+
{{ instance.text }}
+
+ {% if instance.use_cite %}
+
+
+ {% if instance.cite_person %}
+ {{ instance.cite_person }}{% if instance.cite_text %},{% endif %}
+ {% endif %}
+
+ {% if instance.cite_text and instance.cite_url %}
+
+ {{ instance.cite_text }}
+
+ {% endif %}
+ {% if instance.cite_text and not instance.cite_url %}
+
+ {{ instance.cite_text }}
+
+ {% endif %}
+
+
+ {% elif instance.origin %}
+
+ {{ instance.origin }}
+
+ {% endif %}
+
diff --git a/taccsite_cms/contrib/taccsite_offset/__init__.py b/taccsite_cms/contrib/taccsite_offset/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/taccsite_cms/contrib/taccsite_offset/cms_plugins.py b/taccsite_cms/contrib/taccsite_offset/cms_plugins.py
new file mode 100644
index 000000000..607cb05d8
--- /dev/null
+++ b/taccsite_cms/contrib/taccsite_offset/cms_plugins.py
@@ -0,0 +1,60 @@
+from cms.plugin_base import CMSPluginBase
+from cms.plugin_pool import plugin_pool
+
+from django.utils.translation import gettext_lazy as _
+
+from taccsite_cms.contrib.helpers import concat_classnames
+
+from .models import TaccsiteOffset, DIRECTION_DICT
+
+# Helpers
+
+# FAQ: This exists to retireve classnames via consistently-named functions
+# SEE: taccsite_cms.contrib.taccsite_static_article_list.cms_plugins
+def get_direction_classname(value):
+ """Get direction class based on value."""
+ return DIRECTION_DICT.get(value, {}).get('classname')
+
+# Plugins
+
+@plugin_pool.register_plugin
+class TaccsiteOffsetPlugin(CMSPluginBase):
+ """
+ Components > "Offset Content" Plugin
+ https://confluence.tacc.utexas.edu/x/FIEjCQ
+ """
+ module = 'TACC Site'
+ model = TaccsiteOffset
+ name = _('Offset Content')
+ render_template = 'offset.html'
+
+ cache = True
+ text_enabled = False
+ allow_children = True
+
+ fieldsets = [
+ (None, {
+ 'fields': (
+ 'direction',
+ )
+ }),
+ (_('Advanced settings'), {
+ 'classes': ('collapse',),
+ 'fields': (
+ 'attributes',
+ )
+ }),
+ ]
+
+ # Render
+ def render(self, context, instance, placeholder):
+ context = super().render(context, instance, placeholder)
+ request = context['request']
+
+ classes = concat_classnames([
+ get_direction_classname(instance.direction),
+ instance.attributes.get('class'),
+ ])
+ instance.attributes['class'] = classes
+
+ return context
diff --git a/taccsite_cms/contrib/taccsite_offset/migrations/0001_initial.py b/taccsite_cms/contrib/taccsite_offset/migrations/0001_initial.py
new file mode 100644
index 000000000..37d4b021b
--- /dev/null
+++ b/taccsite_cms/contrib/taccsite_offset/migrations/0001_initial.py
@@ -0,0 +1,29 @@
+# Generated by Django 2.2.16 on 2021-06-22 14:09
+
+from django.db import migrations, models
+import django.db.models.deletion
+import djangocms_attributes_field.fields
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('cms', '0022_auto_20180620_1551'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='TaccsiteOffset',
+ fields=[
+ ('cmsplugin_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, related_name='taccsite_offset_taccsiteoffset', serialize=False, to='cms.CMSPlugin')),
+ ('direction', models.CharField(blank=True, choices=[('left', 'Left'), ('right', 'Right')], max_length=255)),
+ ('attributes', djangocms_attributes_field.fields.AttributesField(default=dict)),
+ ],
+ options={
+ 'abstract': False,
+ },
+ bases=('cms.cmsplugin',),
+ ),
+ ]
diff --git a/taccsite_cms/contrib/taccsite_offset/migrations/__init__.py b/taccsite_cms/contrib/taccsite_offset/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/taccsite_cms/contrib/taccsite_offset/models.py b/taccsite_cms/contrib/taccsite_offset/models.py
new file mode 100644
index 000000000..ccf4abc6d
--- /dev/null
+++ b/taccsite_cms/contrib/taccsite_offset/models.py
@@ -0,0 +1,45 @@
+from cms.models.pluginmodel import CMSPlugin
+
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+from djangocms_attributes_field import fields
+
+from taccsite_cms.contrib.helpers import get_choices
+
+
+
+# Constants
+
+# TODO: Consider using an Enum (and an Abstract Enum with `get_choices` method)
+DIRECTION_DICT = {
+ 'left': {
+ 'classname': 'o-offset-content--left',
+ 'description': 'Left',
+ },
+ 'right': {
+ 'classname': 'o-offset-content--right',
+ 'description': 'Right',
+ },
+}
+DIRECTION_CHOICES = get_choices(DIRECTION_DICT)
+
+
+
+# Models
+
+class TaccsiteOffset(CMSPlugin):
+ """
+ Components > "Offset Content" Model
+ https://confluence.tacc.utexas.edu/x/GIEjCQ
+ """
+ direction = models.CharField(
+ choices=DIRECTION_CHOICES,
+ blank=True,
+ max_length=255,
+ )
+
+ attributes = fields.AttributesField()
+
+ def get_short_description(self):
+ return self.direction
diff --git a/taccsite_cms/contrib/taccsite_offset/templates/offset.html b/taccsite_cms/contrib/taccsite_offset/templates/offset.html
new file mode 100644
index 000000000..583ca7ad0
--- /dev/null
+++ b/taccsite_cms/contrib/taccsite_offset/templates/offset.html
@@ -0,0 +1,7 @@
+{% load cms_tags %}
+
+
diff --git a/taccsite_cms/contrib/taccsite_sample/cms_plugins.py b/taccsite_cms/contrib/taccsite_sample/cms_plugins.py
index f56b1d5a9..f5de7ae8f 100644
--- a/taccsite_cms/contrib/taccsite_sample/cms_plugins.py
+++ b/taccsite_cms/contrib/taccsite_sample/cms_plugins.py
@@ -1,13 +1,12 @@
from cms.plugin_base import CMSPluginBase
from cms.plugin_pool import plugin_pool
-from cms.models.pluginmodel import CMSPlugin
from django.utils.translation import gettext_lazy as _
from django.utils.encoding import force_text
from .models import TaccsiteSample
from .constants import DEFAULT_USER_NAME as default_name
-from .utils import has_proper_name
+from .helpers import has_proper_name
# SEE: http://docs.django-cms.org/en/release-3.7.x/reference/plugins.html
@plugin_pool.register_plugin
@@ -23,22 +22,28 @@ class TaccsiteSamplePlugin(CMSPluginBase):
cache = False
text_enabled = True
+ allow_children = False
# NOTE: Use case is unclear
# admin_preview = True
# NOTE: To change for all TACC plugins add taccsite_cms/templates/admin/...
# change_form_template = 'templates/plugin_change_form.html'
# NOTE: To change field widget and other attribute beyond `models.…Field`
+ # (Optionally, consider `formfield_overrides`:
+ # https://django.readthedocs.io/en/latest/ref/contrib/admin/index.html#django.contrib.admin.ModelAdmin.formfield_overrides)
# form = TaccsiteSamplePluginForm # TODO: Provide example
# FAQ: Sets tooltip of preview of this plugin within a Text plugin
def icon_alt(self, instance):
super_value = force_text(super().icon_alt(instance))
- return f'Hello, […] ({super_value})'
+ return _('Hello, […] (%(original_string_text)s)') % {
+ 'original_string_text': super_value
+ }
# NOTE: Our previews (see `icon_alt`) are rich and have no icon...
# TODO: Confirm whether these are ever necessary
# def icon_src(self, instance)
# def text_editor_button_icon(...)
+ # Render
def render(self, context, instance, placeholder):
context = super().render(context, instance, placeholder)
request = context['request']
diff --git a/taccsite_cms/contrib/taccsite_sample/utils.py b/taccsite_cms/contrib/taccsite_sample/helpers.py
similarity index 84%
rename from taccsite_cms/contrib/taccsite_sample/utils.py
rename to taccsite_cms/contrib/taccsite_sample/helpers.py
index 0178c6528..39c389f7b 100644
--- a/taccsite_cms/contrib/taccsite_sample/utils.py
+++ b/taccsite_cms/contrib/taccsite_sample/helpers.py
@@ -1,12 +1,7 @@
-"""
-.. module:: taccsite_sample.utils
- :synopsis: Utilities to process user name.
-"""
-
def has_proper_name(user=None):
"""Whether user has enough data with which to form a proper name.
- :param user: Django user object
+ :param django.contrib.auth.models.User: Django user object
:returns: True, False, or None (if unknown)
:rtype: bool | None
@@ -27,7 +22,7 @@ def has_proper_name(user=None):
def get_proper_name(user=None):
"""Get proper name of an authenticated user.
- :param user: Django user object (authenticated)
+ :param django.contrib.auth.models.User: Django user object (authenticated)
:returns: Proper name of user
:rtype: str | None
diff --git a/taccsite_cms/contrib/taccsite_sample/models.py b/taccsite_cms/contrib/taccsite_sample/models.py
index 65ffe024d..156065825 100644
--- a/taccsite_cms/contrib/taccsite_sample/models.py
+++ b/taccsite_cms/contrib/taccsite_sample/models.py
@@ -3,16 +3,9 @@
from django.db import models
from .constants import DEFAULT_USER_NAME as default_name
-from .utils import has_proper_name, get_proper_name
+from .helpers import has_proper_name, get_proper_name
class TaccsiteSample(CMSPlugin):
- # Overwrites
-
- def get_short_description(self):
- return 'Hello, […]'
-
- # Fields
-
"""
Components > "Sample (Greet User)" Model
https://url.to/docs/components/sample/
@@ -20,7 +13,7 @@ def get_short_description(self):
guest_name = models.CharField(
max_length=50,
default=default_name,
- help_text=f'If user is logged in they are greeted by their name. If not logged in, they are greeted as this value. If this value is blank, they are greeted as "{default_name}".',
+ help_text=('If user is logged in they are greeted by their name. If not logged in, they are greeted as this value. If this value is blank, they are greeted as "%(default_name)s".') % {'default_name': default_name},
# To change the widget, a new Form class is required
# FAQ: Wesley B searched for hours to find this important information
# SEE: http://disq.us/p/210zgp2
@@ -29,15 +22,20 @@ def get_short_description(self):
blank=True
)
- # Custom
+ def get_short_description(self):
+ return 'Hello, […]'
+
+
+
+ # Helpers
def get_name(self, user=None):
"""Get name by which to greet the user.
- :param user: Django user object
+ :param django.contrib.auth.models.User: Django user object
- :rtype: str
:returns: Name of authenticated user or the name for any guest
+ :rtype: str
"""
if has_proper_name(user):
name = get_proper_name(user)
diff --git a/taccsite_cms/contrib/taccsite_sample/tests.py b/taccsite_cms/contrib/taccsite_sample/tests.py
index e2887c17e..c4811974a 100644
--- a/taccsite_cms/contrib/taccsite_sample/tests.py
+++ b/taccsite_cms/contrib/taccsite_sample/tests.py
@@ -25,6 +25,7 @@ def setUp(self):
# Helpers
def _create_auth_user(self, username='test', first_name='', last_name=''):
+ """Create authenticated user"""
self.auth_user = User.objects.create_user(
username=username,
first_name=first_name,
@@ -36,11 +37,13 @@ def _create_auth_user(self, username='test', first_name='', last_name=''):
self.context['request'].user = self.auth_user
def _create_anon_user(self, guest_name='Guest'):
+ """Create unauthenticated user"""
self.auth_user = AnonymousUser()
self.context = { 'request': self.factory.get('/test/user') }
self.context['request'].user = self.anon_user
def _populate_plugin_model(self, guest_name=None):
+ """Update plugin model to have necessary data"""
data = {'guest_name': guest_name} if bool(guest_name) else {}
self.plugin = add_plugin(
self.placeholder,
diff --git a/taccsite_cms/contrib/taccsite_static_article_list/README.md b/taccsite_cms/contrib/taccsite_static_article_list/README.md
new file mode 100644
index 000000000..81f610412
--- /dev/null
+++ b/taccsite_cms/contrib/taccsite_static_article_list/README.md
@@ -0,0 +1,3 @@
+# Static Article List
+
+See [./_docs/taccsite_static_article.md](./_docs/taccsite_static_article.md).
diff --git a/taccsite_cms/contrib/taccsite_static_article_list/__init__.py b/taccsite_cms/contrib/taccsite_static_article_list/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/taccsite_cms/contrib/taccsite_static_article_list/cms_plugins.py b/taccsite_cms/contrib/taccsite_static_article_list/cms_plugins.py
new file mode 100644
index 000000000..65e916d9e
--- /dev/null
+++ b/taccsite_cms/contrib/taccsite_static_article_list/cms_plugins.py
@@ -0,0 +1,142 @@
+from cms.plugin_pool import plugin_pool
+from django.utils.translation import gettext_lazy as _
+
+from djangocms_link.cms_plugins import LinkPlugin
+
+from taccsite_cms.contrib.helpers import (
+ concat_classnames,
+ add_classname_to_instances
+)
+
+from .models import TaccsiteArticleList
+from .constants import LAYOUT_DICT, STYLE_DICT
+
+
+
+# Helpers
+
+def get_layout_classname(value):
+ """Get layout class based on value."""
+ return LAYOUT_DICT.get(value, {}).get('classname')
+
+def get_style_classname(value):
+ """Get style class based on value."""
+ return STYLE_DICT.get(value, {}).get('classname')
+
+
+
+# Abstracts
+
+class AbstractArticleListPlugin(LinkPlugin):
+ """
+ Components > "Article List" Plugin
+ https://confluence.tacc.utexas.edu/x/OIAjCQ
+ """
+ module = 'TACC Site'
+ model = TaccsiteArticleList
+ # name = _('______ Article List (Static)') # abstract
+ render_template = 'article_list.html'
+ def get_render_template(self, context, instance, placeholder):
+ return self.render_template
+
+ cache = True
+ text_enabled = False
+ allow_children = True
+
+ fieldsets = [
+ (None, {
+ 'fields': (
+ 'title_text',
+ ('layout_type', 'style_type')
+ )
+ }),
+ (_('Footer link'), {
+ 'classes': ('collapse',),
+ 'description': 'The "See All" link at the bottom of the list. "Display name" is the text.',
+ 'fields': (
+ 'name',
+ ('external_link', 'internal_link'),
+ ('anchor', 'target'),
+ )
+ }),
+ (_('Advanced settings'), {
+ 'classes': ('collapse',),
+ 'fields': (
+ 'attributes',
+ )
+ }),
+ ]
+
+ # Render
+ def render(self, context, instance, placeholder):
+ context = super().render(context, instance, placeholder)
+ request = context['request']
+
+ classes = concat_classnames([
+ 's-article-list c-article-list',
+ get_layout_classname(instance.layout_type),
+ get_style_classname(instance.style_type),
+ instance.attributes.get('class'),
+ ])
+ instance.attributes['class'] = classes
+
+ add_classname_to_instances('c-article-list__item', instance.child_plugin_instances)
+
+ context.update({
+ 'link_url': instance.get_link(),
+ 'link_text': instance.name,
+ 'link_target': instance.target
+ })
+ return context
+
+
+
+# Plugins
+
+@plugin_pool.register_plugin
+class TaccsiteNewsArticleListPlugin(AbstractArticleListPlugin):
+ """
+ Components > "Article List" Plugin
+ https://confluence.tacc.utexas.edu/x/OIAjCQ
+ """
+ name = _('News Article List (Static)')
+
+ child_classes = [
+ 'TaccsiteStaticNewsArticlePreviewPlugin'
+ ]
+
+@plugin_pool.register_plugin
+class TaccsiteAllocsArticleListPlugin(AbstractArticleListPlugin):
+ """
+ Components > "Article List" Plugin
+ https://confluence.tacc.utexas.edu/x/OIAjCQ
+ """
+ name = _('Allocations Article List (Static)')
+
+ child_classes = [
+ 'TaccsiteStaticAllocsArticlePreviewPlugin'
+ ]
+
+@plugin_pool.register_plugin
+class TaccsiteDocsArticleListPlugin(AbstractArticleListPlugin):
+ """
+ Components > "Article List" Plugin
+ https://confluence.tacc.utexas.edu/x/OIAjCQ
+ """
+ name = _('Document Article List (Static)')
+
+ child_classes = [
+ 'TaccsiteStaticDocsArticlePreviewPlugin'
+ ]
+
+@plugin_pool.register_plugin
+class TaccsiteEventsArticleListPlugin(AbstractArticleListPlugin):
+ """
+ Components > "Article List" Plugin
+ https://confluence.tacc.utexas.edu/x/OIAjCQ
+ """
+ name = _('Event Article List (Static)')
+
+ child_classes = [
+ 'TaccsiteStaticEventsArticlePreviewPlugin'
+ ]
diff --git a/taccsite_cms/contrib/taccsite_static_article_list/constants.py b/taccsite_cms/contrib/taccsite_static_article_list/constants.py
new file mode 100644
index 000000000..63505cb50
--- /dev/null
+++ b/taccsite_cms/contrib/taccsite_static_article_list/constants.py
@@ -0,0 +1,35 @@
+# TODO: Consider using an Enum (and an Abstract Enum with `get_choices` method)
+LAYOUT_DICT = {
+ 'cols-widest-2-even_width': {
+ 'classname': 'c-article-list--layout-a',
+ 'description': '2 Equal-Width Columns',
+ },
+ 'cols-widest-2-wide_narrow': {
+ 'classname': 'c-article-list--layout-b',
+ 'description': '2 Columns: 1 Wide, 1 Narrow',
+ },
+ 'cols-widest-2-narrow_wide': {
+ 'classname': 'c-article-list--layout-c',
+ 'description': '2 Columns: 1 Narrow, 1 Wide',
+ },
+ 'cols-widest-3-even_width': {
+ 'classname': 'c-article-list--layout-d',
+ 'description': '3 Equal-Width Columns',
+ },
+ 'rows-always-N-even_height': {
+ 'classname': 'c-article-list--layout-e'
+ ' ' + 'c-article-list--style-gapless',
+ 'description': 'Multiple Rows',
+ },
+}
+
+STYLE_DICT = {
+ 'rows-divided': {
+ 'classname': 'c-article-list--style-divided',
+ 'description': 'Dividers Between Articles',
+ },
+ 'cols-gapless': {
+ 'classname': 'c-article-list--style-gapless',
+ 'description': 'Remove Gaps Between Articles',
+ },
+}
diff --git a/taccsite_cms/contrib/taccsite_static_article_list/migrations/0001_initial.py b/taccsite_cms/contrib/taccsite_static_article_list/migrations/0001_initial.py
new file mode 100644
index 000000000..521bbd070
--- /dev/null
+++ b/taccsite_cms/contrib/taccsite_static_article_list/migrations/0001_initial.py
@@ -0,0 +1,43 @@
+# Generated by Django 2.2.16 on 2021-07-02 19:13
+
+from django.db import migrations, models
+import django.db.models.deletion
+import djangocms_attributes_field.fields
+import djangocms_link.validators
+import filer.fields.file
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('filer', '0012_file_mime_type'),
+ ('cms', '0022_auto_20180620_1551'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='TaccsiteArticleList',
+ fields=[
+ ('template', models.CharField(choices=[('default', 'Default')], default='default', max_length=255, verbose_name='Template')),
+ ('name', models.CharField(blank=True, max_length=255, verbose_name='Display name')),
+ ('external_link', models.CharField(blank=True, help_text='Provide a link to an external source.', max_length=2040, validators=[djangocms_link.validators.IntranetURLValidator(intranet_host_re=None)], verbose_name='External link')),
+ ('anchor', models.CharField(blank=True, help_text='Appends the value only after the internal or external link. Do not include a preceding "#" symbol.', max_length=255, verbose_name='Anchor')),
+ ('mailto', models.EmailField(blank=True, max_length=255, verbose_name='Email address')),
+ ('phone', models.CharField(blank=True, max_length=255, verbose_name='Phone')),
+ ('target', models.CharField(blank=True, choices=[('_blank', 'Open in new window'), ('_self', 'Open in same window'), ('_parent', 'Delegate to parent'), ('_top', 'Delegate to top')], max_length=255, verbose_name='Target')),
+ ('attributes', djangocms_attributes_field.fields.AttributesField(blank=True, default=dict, verbose_name='Attributes')),
+ ('cmsplugin_ptr', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, related_name='taccsite_static_article_list_taccsitearticlelist', serialize=False, to='cms.CMSPlugin')),
+ ('title_text', models.CharField(blank=True, help_text='The title at the top of the list.', max_length=100, verbose_name='Title Text')),
+ ('layout_type', models.CharField(choices=[('Row Layouts', [('rows-always-N-even_height', 'Multiple Rows')]), ('Column Layouts', [('cols-widest-2-even_width', '2 Equal-Width Columns'), ('cols-widest-2-wide_narrow', '2 Columns: 1 Wide, 1 Narrow'), ('cols-widest-2-narrow_wide', '2 Columns: 1 Narrow, 1 Wide'), ('cols-widest-3-even_width', '3 Equal-Width Columns')])], default='Row Layouts', help_text='Layout of the articles within. Notice: All Column Layouts become multiple rows when screen width is narrow.', max_length=255, verbose_name='Layout Option')),
+ ('style_type', models.CharField(blank=True, choices=[('Row Layouts', [('rows-divided', 'Dividers Between Articles')]), ('Column Layouts', [('cols-gapless', 'Remove Gaps Between Articles')])], help_text='Optional styles for the list itself.', max_length=255, verbose_name='Style Option')),
+ ('file_link', filer.fields.file.FilerFileField(blank=True, help_text='If provided links a file from the filer app.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='filer.File', verbose_name='File link')),
+ ('internal_link', models.ForeignKey(blank=True, help_text='If provided, overrides the external link.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='cms.Page', verbose_name='Internal link')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ bases=('cms.cmsplugin',),
+ ),
+ ]
diff --git a/taccsite_cms/contrib/taccsite_static_article_list/migrations/__init__.py b/taccsite_cms/contrib/taccsite_static_article_list/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/taccsite_cms/contrib/taccsite_static_article_list/models.py b/taccsite_cms/contrib/taccsite_static_article_list/models.py
new file mode 100644
index 000000000..0cd92ccf9
--- /dev/null
+++ b/taccsite_cms/contrib/taccsite_static_article_list/models.py
@@ -0,0 +1,115 @@
+from django.core.exceptions import ValidationError
+from django.utils.translation import gettext_lazy as _
+from django.utils.encoding import force_text
+from django.db import models
+
+from djangocms_link.models import AbstractLink
+
+from taccsite_cms.contrib.helpers import (
+ get_choices,
+ filter_choices_by_prefix,
+ clean_for_abstract_link,
+)
+
+from .constants import LAYOUT_DICT, STYLE_DICT
+
+
+
+# Constants
+
+ANY_CHOICES_NAME = _('Any Layouts')
+ROWS_CHOICES_NAME = _('Row Layouts')
+COLS_CHOICES_NAME = _('Column Layouts')
+
+LAYOUT_CHOICES = (
+ ( ROWS_CHOICES_NAME, filter_choices_by_prefix(
+ get_choices(LAYOUT_DICT), 'row'
+ ) ),
+ ( COLS_CHOICES_NAME, filter_choices_by_prefix(
+ get_choices(LAYOUT_DICT), 'cols'
+ ) ),
+)
+STYLE_CHOICES = (
+ ( ROWS_CHOICES_NAME, filter_choices_by_prefix(
+ get_choices(STYLE_DICT), 'rows'
+ ) ),
+ ( COLS_CHOICES_NAME, filter_choices_by_prefix(
+ get_choices(STYLE_DICT), 'cols'
+ ) ),
+)
+
+
+
+# Models
+
+class TaccsiteArticleList(AbstractLink):
+ """
+ Components > "Article List" Model
+ https://confluence.tacc.utexas.edu/x/OIAjCQ
+ """
+ title_text = models.CharField(
+ verbose_name=_('Title Text'),
+ help_text=_('The title at the top of the list.'),
+ blank=True,
+ max_length=100,
+ )
+
+ layout_type = models.CharField(
+ verbose_name=_('Layout Option'),
+ help_text=_('Layout of the articles within. Notice: All %(col_layouts)s become multiple rows when screen width is narrow.') % { 'col_layouts': COLS_CHOICES_NAME },
+ choices=LAYOUT_CHOICES,
+ default=LAYOUT_CHOICES[0][0],
+ blank=False,
+ max_length=255,
+ )
+ style_type = models.CharField(
+ verbose_name=_('Style Option'),
+ help_text=_('Optional styles for the list itself.'),
+ choices=STYLE_CHOICES,
+ blank=True,
+ max_length=255,
+ )
+
+ def get_short_description(self):
+ return self.title_text
+
+
+
+ # Parent
+
+ link_is_optional = True
+
+ class Meta:
+ abstract = False
+
+ # Validate
+ def clean(self):
+ clean_for_abstract_link(__class__, self)
+
+ # If user provided link text, then require link
+ if self.name and not self.get_link():
+ raise ValidationError(
+ _('Please provide a footer link or delete its display name.'), code='invalid'
+ )
+
+ # If user mix-and-matched layout and styles, then explain their mistake
+ layout_name = force_text(
+ self._meta.get_field('layout_type').verbose_name )
+ style_name = force_text(
+ self._meta.get_field('style_type').verbose_name )
+ if 'cols' in self.layout_type and 'rows' in self.style_type:
+ raise ValidationError(
+ _('If you choose a %(layout)s from %(row_layouts)s, then choose a %(style)s from %(row_layouts)s (or no %(style)s).') % {
+ 'style': style_name, 'layout': layout_name,
+ 'row_layouts': ROWS_CHOICES_NAME
+ },
+ code='invalid'
+ )
+ if 'rows' in self.layout_type and 'cols' in self.style_type:
+ raise ValidationError(
+ _('If you choose a %(layout)s from %(col_layouts)s, then choose a %(style)s from %(col_layouts)s (or no %(style)s).') % {
+ 'style': style_name, 'layout': layout_name,
+ 'col_layouts': COLS_CHOICES_NAME
+ },
+ code='invalid'
+ )
diff --git a/taccsite_cms/contrib/taccsite_static_article_list/templates/article_list.html b/taccsite_cms/contrib/taccsite_static_article_list/templates/article_list.html
new file mode 100644
index 000000000..1f0f98ef9
--- /dev/null
+++ b/taccsite_cms/contrib/taccsite_static_article_list/templates/article_list.html
@@ -0,0 +1,29 @@
+{% load cms_tags %}
+
+
+ {# Title #}
+ {% if instance.title_text %}
+
+ {{ instance.title_text }}
+
+ {% endif %}
+
+ {# Articles #}
+ {% for plugin_instance in instance.child_plugin_instances %}
+ {# … #}
+ {% render_plugin plugin_instance %}
+ {% endfor %}
+
+ {# Footer #}
+ {% if link_url %}
+
+ {% endif %}
+
diff --git a/taccsite_cms/contrib/taccsite_static_article_preview/README.md b/taccsite_cms/contrib/taccsite_static_article_preview/README.md
new file mode 100644
index 000000000..943596354
--- /dev/null
+++ b/taccsite_cms/contrib/taccsite_static_article_preview/README.md
@@ -0,0 +1,3 @@
+# Static Article Preview
+
+See [./_docs/taccsite_static_article.md](./_docs/taccsite_static_article.md).
diff --git a/taccsite_cms/contrib/taccsite_static_article_preview/__init__.py b/taccsite_cms/contrib/taccsite_static_article_preview/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/taccsite_cms/contrib/taccsite_static_article_preview/cms_plugins.py b/taccsite_cms/contrib/taccsite_static_article_preview/cms_plugins.py
new file mode 100644
index 000000000..eb6d7f0dd
--- /dev/null
+++ b/taccsite_cms/contrib/taccsite_static_article_preview/cms_plugins.py
@@ -0,0 +1,289 @@
+from django.core.exceptions import ValidationError
+
+from cms.plugin_pool import plugin_pool
+from django.utils.translation import gettext_lazy as _
+
+from djangocms_link.cms_plugins import LinkPlugin
+
+from taccsite_cms.contrib.helpers import (
+ concat_classnames,
+ insert_at_position,
+ which_date_is_nearest_today,
+ AbstractMaxChildrenPlugin,
+)
+
+from .models import (
+ MEDIA_SUPPORT_CHOICES,
+ TaccsiteStaticNewsArticlePreview,
+ TaccsiteStaticAllocsArticlePreview,
+ TaccsiteStaticDocsArticlePreview,
+ TaccsiteStaticEventsArticlePreview,
+)
+
+
+
+# Constants
+
+KIND_DICT = {
+ 'news': 'c-article-preview--news',
+ 'docs': 'c-article-preview--docs',
+ 'allocs': 'c-article-preview--allocs',
+ 'events': 'c-article-preview--events',
+}
+
+
+
+# Helpers
+
+# FAQ: This exists to retireve classnames via consistently-named functions
+# SEE: taccsite_cms.contrib.taccsite_static_article_list.cms_plugins
+def get_kind_classname(value):
+ """Get kind class based on value."""
+ return KIND_DICT[value]
+
+
+
+# Abstracts
+
+class AbstractArticlePreviewPlugin(LinkPlugin, AbstractMaxChildrenPlugin):
+ module = 'TACC Site'
+ # model = TaccsiteStatic___ArticlePreview # abstract
+ # name = _('______ Article Preview (Static)') # abstract
+ render_template = 'static_article_preview.html'
+ def get_render_template(self, context, instance, placeholder):
+ return self.render_template
+
+ cache = True
+ text_enabled = False
+ # NOTE: Should article previews be allowed to exist in isolation?
+ # Consider [hero banner](https://github.com/TACC/Core-CMS/issues/134).
+ # require_parent = True
+
+ fieldsets = [
+ (_('Link'), {
+ 'fields': (
+ ('external_link', 'internal_link'),
+ ('anchor', 'target'),
+ )
+ }),
+ (_('Advanced settings'), {
+ 'classes': ('collapse',),
+ 'fields': (
+ 'attributes',
+ )
+ }),
+ ]
+
+
+
+ # Helpers
+
+ # kind = '______' # abstract
+
+
+
+ # Render
+ def render(self, context, instance, placeholder):
+ context = super().render(context, instance, placeholder)
+ request = context['request']
+
+ classes = concat_classnames([
+ 'c-article-preview',
+ get_kind_classname(self.kind),
+ instance.attributes.get('class'),
+ ])
+ instance.attributes['class'] = classes
+
+ context.update({
+ 'kind': self.kind,
+ 'link_url': instance.get_link(),
+ 'link_text': instance.name,
+ 'link_target': instance.target
+ })
+ return context
+
+class AbstractArticlePreviewWithMediaPlugin(AbstractArticlePreviewPlugin):
+ allow_children = True
+ child_classes = [
+ 'PicturePlugin', # HELP: Why does this not show up in plugin list?
+ 'Bootstrap4PicturePlugin'
+ ]
+
+ fieldsets = insert_at_position(0, AbstractArticlePreviewPlugin.fieldsets, [
+ (_('Image'), {
+ # To enable these fields, see `./README.md`
+ # 'fields': ('picture', 'external_picture')
+ 'fields': ('media_support',)
+ }),
+ ])
+
+ # Set `readonly_fields` that can be populated upon instance creation
+ # SEE: https://stackoverflow.com/a/17614057/11817077
+ # HELP: Instead, how can we disable a field with minimal effort?
+ def get_readonly_fields(self, request, obj=None):
+ if obj: # i.e. user is editing instance
+ return ['media_support'] if len(MEDIA_SUPPORT_CHOICES) == 1 else []
+ else: # i.e. user is creating instance
+ return []
+
+
+
+# Plugins
+
+@plugin_pool.register_plugin
+class TaccsiteStaticNewsArticlePreviewPlugin(AbstractArticlePreviewWithMediaPlugin):
+ """
+ Components > "(Static) News Article Preview" Plugin
+ https://confluence.tacc.utexas.edu/x/OYAjCQ
+ """
+ model = TaccsiteStaticNewsArticlePreview
+ name = _('News Article Preview (Static)')
+
+ parent_classes = [
+ 'TaccsiteNewsArticleListPlugin'
+ ]
+
+ fieldsets = insert_at_position(0, AbstractArticlePreviewWithMediaPlugin.fieldsets, [
+ (None, {
+ # To enable these fields, see `./README.md`
+ # 'fields': (..., 'picture', 'external_picture')
+ 'fields': ('title_text', 'abstract_text')
+ }),
+ ])
+ fieldsets = insert_at_position(len(fieldsets) - 1, fieldsets, [
+ (_('Metadata'), {
+ 'fields': ('publish_date', 'type_text', 'author_text')
+ }),
+ ])
+
+
+
+ # Helpers
+
+ kind = 'news'
+
+@plugin_pool.register_plugin
+class TaccsiteStaticAllocsArticlePreviewPlugin(AbstractArticlePreviewWithMediaPlugin):
+ """
+ Components > "(Static) Allocations Article Preview" Plugin
+ https://confluence.tacc.utexas.edu/x/OYAjCQ
+ """
+ model = TaccsiteStaticAllocsArticlePreview
+ name = _('Allocations Article Preview (Static)')
+
+ parent_classes = [
+ 'TaccsiteAllocsArticleListPlugin'
+ ]
+
+ fieldsets = insert_at_position(0, AbstractArticlePreviewWithMediaPlugin.fieldsets, [
+ (None, {
+ # To enable these fields, see `./README.md`
+ # 'fields': ('picture', 'external_picture')
+ 'fields': ('title_text',)
+ }),
+ ])
+ fieldsets = insert_at_position(len(fieldsets) - 1, fieldsets, [
+ (_('Dates'), {
+ 'description': 'Two dates will show a range. If given one date, the nearest future date is shown. Otherwise, the nearest past date is shown.',
+ 'fields': (('publish_date', 'expiry_date'),)
+ }),
+ ])
+
+
+
+ # Helper
+
+ kind = 'allocs'
+
+
+
+ # Render
+ def render(self, context, instance, placeholder):
+ context = super().render(context, instance, placeholder)
+ request = context['request']
+
+ dates = which_date_is_nearest_today(
+ instance.publish_date,
+ instance.expiry_date,
+ 'future'
+ )
+ (should_show_open_date, open_date_time_period) = dates[0]
+ (should_show_close_date, close_date_time_period) = dates[1]
+
+ context.update({
+ 'open_date': instance.publish_date,
+ 'should_show_open_date': should_show_open_date,
+ 'open_date_time_period': open_date_time_period,
+
+ 'close_date': instance.expiry_date,
+ 'should_show_close_date': should_show_close_date,
+ 'close_date_time_period': close_date_time_period,
+ })
+ return context
+
+@plugin_pool.register_plugin
+class TaccsiteStaticDocsArticlePreviewPlugin(AbstractArticlePreviewPlugin):
+ """
+ Components > "(Static) Document Article Preview" Plugin
+ https://confluence.tacc.utexas.edu/x/OYAjCQ
+ """
+ model = TaccsiteStaticDocsArticlePreview
+ name = _('Document Article Preview (Static)')
+
+ parent_classes = [
+ 'TaccsiteDocsArticleListPlugin'
+ ]
+
+ fieldsets = insert_at_position(0, AbstractArticlePreviewPlugin.fieldsets, [
+ (None, {
+ 'fields': ('title_text', 'abstract_text')
+ }),
+ ])
+
+
+
+ # Helpers
+
+ kind = 'docs'
+
+@plugin_pool.register_plugin
+class TaccsiteStaticEventsArticlePreviewPlugin(AbstractArticlePreviewPlugin):
+ """
+ Components > "(Static) Event Article Preview" Plugin
+ https://confluence.tacc.utexas.edu/x/OYAjCQ
+ """
+ model = TaccsiteStaticEventsArticlePreview
+ name = _('Event Article Preview (Static)')
+
+ parent_classes = [
+ 'TaccsiteEventsArticleListPlugin'
+ ]
+
+ fieldsets = insert_at_position(0, AbstractArticlePreviewPlugin.fieldsets, [
+ (None, {
+ 'fields': (
+ ('publish_date', 'expiry_date'),
+ 'title_text',
+ 'abstract_text'
+ )
+ }),
+ ])
+
+
+
+ # Helpers
+
+ kind = 'events'
+
+
+
+ # Render
+ def render(self, context, instance, placeholder):
+ context = super().render(context, instance, placeholder)
+ request = context['request']
+
+ context.update({
+ 'open_date': instance.publish_date,
+ 'close_date': instance.expiry_date,
+ })
+ return context
diff --git a/taccsite_cms/contrib/taccsite_static_article_preview/migrations/0001_initial.py b/taccsite_cms/contrib/taccsite_static_article_preview/migrations/0001_initial.py
new file mode 100644
index 000000000..6cb252d59
--- /dev/null
+++ b/taccsite_cms/contrib/taccsite_static_article_preview/migrations/0001_initial.py
@@ -0,0 +1,116 @@
+# Generated by Django 2.2.16 on 2021-07-02 19:13
+
+from django.db import migrations, models
+import django.db.models.deletion
+import djangocms_attributes_field.fields
+import djangocms_link.validators
+import filer.fields.file
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('filer', '0012_file_mime_type'),
+ ('cms', '0022_auto_20180620_1551'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='TaccsiteStaticNewsArticlePreview',
+ fields=[
+ ('template', models.CharField(choices=[('default', 'Default')], default='default', max_length=255, verbose_name='Template')),
+ ('name', models.CharField(blank=True, max_length=255, verbose_name='Display name')),
+ ('external_link', models.CharField(blank=True, help_text='Provide a link to an external source.', max_length=2040, validators=[djangocms_link.validators.IntranetURLValidator(intranet_host_re=None)], verbose_name='External link')),
+ ('anchor', models.CharField(blank=True, help_text='Appends the value only after the internal or external link. Do not include a preceding "#" symbol.', max_length=255, verbose_name='Anchor')),
+ ('mailto', models.EmailField(blank=True, max_length=255, verbose_name='Email address')),
+ ('phone', models.CharField(blank=True, max_length=255, verbose_name='Phone')),
+ ('target', models.CharField(blank=True, choices=[('_blank', 'Open in new window'), ('_self', 'Open in same window'), ('_parent', 'Delegate to parent'), ('_top', 'Delegate to top')], max_length=255, verbose_name='Target')),
+ ('attributes', djangocms_attributes_field.fields.AttributesField(blank=True, default=dict, verbose_name='Attributes')),
+ ('cmsplugin_ptr', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, related_name='taccsite_static_article_preview_taccsitestaticnewsarticlepreview', serialize=False, to='cms.CMSPlugin')),
+ ('media_support', models.CharField(choices=[('nested', 'Nest a single Picture / Image plugin inside this plugin.')], default='nested', max_length=255, verbose_name='How to Add an Image')),
+ ('title_text', models.CharField(default='', help_text='The title for the article.', max_length=50, verbose_name='Title')),
+ ('abstract_text', models.TextField(default='', help_text='A summary of the article', verbose_name='Abstract')),
+ ('type_text', models.CharField(blank=True, help_text='The type of the article, ex: "Science News", "Press Release" (manual entry).', max_length=50, verbose_name='Type')),
+ ('author_text', models.CharField(blank=True, help_text='The author of the article (manual entry).', max_length=50, verbose_name='Author')),
+ ('publish_date', models.DateField(blank=True, help_text='The date the article was published (manual entry).', null=True, verbose_name='Date Published')),
+ ('file_link', filer.fields.file.FilerFileField(blank=True, help_text='If provided links a file from the filer app.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='filer.File', verbose_name='File link')),
+ ('internal_link', models.ForeignKey(blank=True, help_text='If provided, overrides the external link.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='cms.Page', verbose_name='Internal link')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ bases=('cms.cmsplugin',),
+ ),
+ migrations.CreateModel(
+ name='TaccsiteStaticEventsArticlePreview',
+ fields=[
+ ('template', models.CharField(choices=[('default', 'Default')], default='default', max_length=255, verbose_name='Template')),
+ ('name', models.CharField(blank=True, max_length=255, verbose_name='Display name')),
+ ('external_link', models.CharField(blank=True, help_text='Provide a link to an external source.', max_length=2040, validators=[djangocms_link.validators.IntranetURLValidator(intranet_host_re=None)], verbose_name='External link')),
+ ('anchor', models.CharField(blank=True, help_text='Appends the value only after the internal or external link. Do not include a preceding "#" symbol.', max_length=255, verbose_name='Anchor')),
+ ('mailto', models.EmailField(blank=True, max_length=255, verbose_name='Email address')),
+ ('phone', models.CharField(blank=True, max_length=255, verbose_name='Phone')),
+ ('target', models.CharField(blank=True, choices=[('_blank', 'Open in new window'), ('_self', 'Open in same window'), ('_parent', 'Delegate to parent'), ('_top', 'Delegate to top')], max_length=255, verbose_name='Target')),
+ ('attributes', djangocms_attributes_field.fields.AttributesField(blank=True, default=dict, verbose_name='Attributes')),
+ ('cmsplugin_ptr', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, related_name='taccsite_static_article_preview_taccsitestaticeventsarticlepreview', serialize=False, to='cms.CMSPlugin')),
+ ('title_text', models.CharField(default='', help_text='The title for the article.', max_length=50, verbose_name='Title')),
+ ('abstract_text', models.TextField(default='', help_text='A summary of the article', verbose_name='Abstract')),
+ ('expiry_date', models.DateField(blank=True, help_text='The date upon which the event starts (manual entry). Format: YYYY-MM-DD', null=True, verbose_name='Event End Date')),
+ ('publish_date', models.DateField(blank=True, help_text='The date after which the event ends (manual entry). Format: YYYY-MM-DD', null=True, verbose_name='Event Start Date')),
+ ('file_link', filer.fields.file.FilerFileField(blank=True, help_text='If provided links a file from the filer app.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='filer.File', verbose_name='File link')),
+ ('internal_link', models.ForeignKey(blank=True, help_text='If provided, overrides the external link.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='cms.Page', verbose_name='Internal link')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ bases=('cms.cmsplugin',),
+ ),
+ migrations.CreateModel(
+ name='TaccsiteStaticDocsArticlePreview',
+ fields=[
+ ('template', models.CharField(choices=[('default', 'Default')], default='default', max_length=255, verbose_name='Template')),
+ ('name', models.CharField(blank=True, max_length=255, verbose_name='Display name')),
+ ('external_link', models.CharField(blank=True, help_text='Provide a link to an external source.', max_length=2040, validators=[djangocms_link.validators.IntranetURLValidator(intranet_host_re=None)], verbose_name='External link')),
+ ('anchor', models.CharField(blank=True, help_text='Appends the value only after the internal or external link. Do not include a preceding "#" symbol.', max_length=255, verbose_name='Anchor')),
+ ('mailto', models.EmailField(blank=True, max_length=255, verbose_name='Email address')),
+ ('phone', models.CharField(blank=True, max_length=255, verbose_name='Phone')),
+ ('target', models.CharField(blank=True, choices=[('_blank', 'Open in new window'), ('_self', 'Open in same window'), ('_parent', 'Delegate to parent'), ('_top', 'Delegate to top')], max_length=255, verbose_name='Target')),
+ ('attributes', djangocms_attributes_field.fields.AttributesField(blank=True, default=dict, verbose_name='Attributes')),
+ ('cmsplugin_ptr', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, related_name='taccsite_static_article_preview_taccsitestaticdocsarticlepreview', serialize=False, to='cms.CMSPlugin')),
+ ('title_text', models.CharField(default='', help_text='The title for the article.', max_length=50, verbose_name='Title')),
+ ('abstract_text', models.TextField(default='', help_text='A summary of the article', verbose_name='Abstract')),
+ ('file_link', filer.fields.file.FilerFileField(blank=True, help_text='If provided links a file from the filer app.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='filer.File', verbose_name='File link')),
+ ('internal_link', models.ForeignKey(blank=True, help_text='If provided, overrides the external link.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='cms.Page', verbose_name='Internal link')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ bases=('cms.cmsplugin',),
+ ),
+ migrations.CreateModel(
+ name='TaccsiteStaticAllocsArticlePreview',
+ fields=[
+ ('template', models.CharField(choices=[('default', 'Default')], default='default', max_length=255, verbose_name='Template')),
+ ('name', models.CharField(blank=True, max_length=255, verbose_name='Display name')),
+ ('external_link', models.CharField(blank=True, help_text='Provide a link to an external source.', max_length=2040, validators=[djangocms_link.validators.IntranetURLValidator(intranet_host_re=None)], verbose_name='External link')),
+ ('anchor', models.CharField(blank=True, help_text='Appends the value only after the internal or external link. Do not include a preceding "#" symbol.', max_length=255, verbose_name='Anchor')),
+ ('mailto', models.EmailField(blank=True, max_length=255, verbose_name='Email address')),
+ ('phone', models.CharField(blank=True, max_length=255, verbose_name='Phone')),
+ ('target', models.CharField(blank=True, choices=[('_blank', 'Open in new window'), ('_self', 'Open in same window'), ('_parent', 'Delegate to parent'), ('_top', 'Delegate to top')], max_length=255, verbose_name='Target')),
+ ('attributes', djangocms_attributes_field.fields.AttributesField(blank=True, default=dict, verbose_name='Attributes')),
+ ('cmsplugin_ptr', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, related_name='taccsite_static_article_preview_taccsitestaticallocsarticlepreview', serialize=False, to='cms.CMSPlugin')),
+ ('media_support', models.CharField(choices=[('nested', 'Nest a single Picture / Image plugin inside this plugin.')], default='nested', max_length=255, verbose_name='How to Add an Image')),
+ ('title_text', models.CharField(default='', help_text='The title for the article.', max_length=50, verbose_name='Title')),
+ ('expiry_date', models.DateField(blank=True, help_text='The date after which submissions are not accepted (manual entry). Format: YYYY-MM-DD', null=True, verbose_name='Submission End Date')),
+ ('publish_date', models.DateField(blank=True, help_text='The date after which submissions are accepted (manual entry). Format: YYYY-MM-DD', null=True, verbose_name='Submission Start Date')),
+ ('file_link', filer.fields.file.FilerFileField(blank=True, help_text='If provided links a file from the filer app.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='filer.File', verbose_name='File link')),
+ ('internal_link', models.ForeignKey(blank=True, help_text='If provided, overrides the external link.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='cms.Page', verbose_name='Internal link')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ bases=('cms.cmsplugin',),
+ ),
+ ]
diff --git a/taccsite_cms/contrib/taccsite_static_article_preview/migrations/__init__.py b/taccsite_cms/contrib/taccsite_static_article_preview/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/taccsite_cms/contrib/taccsite_static_article_preview/models.py b/taccsite_cms/contrib/taccsite_static_article_preview/models.py
new file mode 100644
index 000000000..fb3381a0f
--- /dev/null
+++ b/taccsite_cms/contrib/taccsite_static_article_preview/models.py
@@ -0,0 +1,206 @@
+from cms.models.pluginmodel import CMSPlugin
+
+from django.core.exceptions import ValidationError
+from django.utils.translation import gettext_lazy as _
+from django.utils.encoding import force_text
+from django.db import models
+
+from djangocms_link.models import AbstractLink
+
+from taccsite_cms.contrib.helpers import clean_for_abstract_link
+
+
+
+# Constants
+
+MEDIA_SUPPORT_CHOICES = (
+ ('nested', _('Nest a single Picture / Image plugin inside this plugin.')),
+ # ('direct', _('Choose / Define an image directly within this plugin.')),
+)
+
+
+
+# Helpers
+
+# This field lets us:
+# - (for user) describe how to add media
+# - (for code) identify instances added before media could be directly added
+def create_media_support_field(blank=False):
+ return models.CharField(
+ choices=MEDIA_SUPPORT_CHOICES,
+ verbose_name=_('How to Add an Image'),
+ default=MEDIA_SUPPORT_CHOICES[0][0],
+ blank=blank,
+ max_length=255,
+ )
+
+# Helpers: Field Creation
+# FAQ: Allow fields to be shared between models without creating abstract model
+# NOTE: What every model has could change depending on new page designs…
+
+def create_title_text_field(blank=True):
+ return models.CharField(
+ verbose_name=_('Title'),
+ help_text='The title for the article.',
+ blank=blank,
+ max_length=50,
+ default=''
+ )
+
+def create_abstract_text_field(blank=True):
+ return models.TextField(
+ verbose_name=_('Abstract'),
+ help_text='A summary of the article',
+ blank=blank,
+ default=''
+ )
+
+def create_type_text_field(blank=True):
+ return models.CharField(
+ verbose_name=_('Type'),
+ help_text='The type of the article, ex: "Science News", "Press Release" (manual entry).',
+ blank=blank,
+ max_length=50
+ )
+
+def create_author_text_field(blank=True):
+ return models.CharField(
+ verbose_name=_('Author'),
+ help_text='The author of the article (manual entry).',
+ blank=blank,
+ max_length=50,
+ )
+
+def create_publish_date_field(blank=True, help_text=None, verbose_name=None):
+ return models.DateField(
+ verbose_name=verbose_name
+ if verbose_name
+ else _('Date Published'),
+ # Allocations repurposes this as date when submissions open
+ help_text=help_text + ' Format: YYYY-MM-DD'
+ if help_text
+ else 'The date the article was published (manual entry).',
+ blank=blank,
+ null=True,
+ )
+
+def create_expiry_date_field(blank=True, help_text=None, verbose_name=None):
+ return models.DateField(
+ verbose_name=verbose_name
+ if verbose_name
+ else _('Date to Expire'),
+ # Allocations repurposes this as date when submissions close
+ help_text=help_text + ' Format: YYYY-MM-DD'
+ if help_text
+ else 'The date the article should no longer appear show (manual entry).',
+ blank=blank,
+ null=True,
+ )
+
+
+
+# Models
+
+class TaccsiteStaticNewsArticlePreview(AbstractLink):
+ media_support = create_media_support_field(blank=False)
+ title_text = create_title_text_field(blank=False)
+ abstract_text = create_abstract_text_field(blank=False)
+
+ type_text = create_type_text_field()
+ author_text = create_author_text_field()
+ publish_date = create_publish_date_field()
+
+
+
+ # Parent
+
+ link_is_optional = True
+
+ class Meta:
+ abstract = False
+
+ # Validate
+ def clean(self):
+ clean_for_abstract_link(__class__, self)
+
+class TaccsiteStaticAllocsArticlePreview(AbstractLink):
+ media_support = create_media_support_field(blank=False)
+ title_text = create_title_text_field(blank=False)
+
+ expiry_date = create_expiry_date_field(
+ verbose_name='Submission End Date',
+ help_text='The date after which submissions are not accepted (manual entry).'
+ )
+ publish_date = create_publish_date_field(
+ verbose_name='Submission Start Date',
+ help_text='The date after which submissions are accepted (manual entry).'
+ )
+
+
+
+ # Parent
+
+ link_is_optional = True
+
+ class Meta:
+ abstract = False
+
+ # Validate
+ def clean(self):
+ clean_for_abstract_link(__class__, self)
+
+class TaccsiteStaticDocsArticlePreview(AbstractLink):
+ title_text = create_title_text_field(blank=False)
+ abstract_text = create_abstract_text_field(blank=False)
+
+
+
+ # Parent
+
+ link_is_optional = True
+
+ class Meta:
+ abstract = False
+
+ # Validate
+ def clean(self):
+ clean_for_abstract_link(__class__, self)
+
+class TaccsiteStaticEventsArticlePreview(AbstractLink):
+ title_text = create_title_text_field(blank=False)
+ abstract_text = create_abstract_text_field(blank=False)
+
+ expiry_date = create_expiry_date_field(
+ verbose_name='Event End Date',
+ help_text='The date upon which the event starts (manual entry).'
+ )
+ publish_date = create_publish_date_field(
+ verbose_name='Event Start Date',
+ help_text='The date after which the event ends (manual entry).'
+ )
+
+
+
+ # Parent
+
+ link_is_optional = True
+
+ class Meta:
+ abstract = False
+
+ # Validate
+ def clean(self):
+ clean_for_abstract_link(__class__, self)
+
+ # If user provided link text, then require link
+ if not self.publish_date and not self.expiry_date:
+ end_date_name = force_text(
+ self._meta.get_field('expiry_date').verbose_name )
+ start_date_name = force_text(
+ self._meta.get_field('publish_date').verbose_name )
+ raise ValidationError(
+ _('Provide either a %(start_date)s or an %(end_date)s.') % {
+ 'start_date': start_date_name, 'end_date': end_date_name
+ },
+ code='invalid'
+ )
diff --git a/taccsite_cms/contrib/taccsite_static_article_preview/templates/static_article_preview.html b/taccsite_cms/contrib/taccsite_static_article_preview/templates/static_article_preview.html
new file mode 100644
index 000000000..c0cd6d56c
--- /dev/null
+++ b/taccsite_cms/contrib/taccsite_static_article_preview/templates/static_article_preview.html
@@ -0,0 +1,133 @@
+{% load cms_tags %}
+
+
+ {# Media e.g. image thumbnail #}
+ {% if kind == 'news' or kind == 'allocs' %}
+ {# HACK: Forced to use a wrapper because we cannot control markup #}
+
+ {% for plugin_instance in instance.child_plugin_instances %}
+ {% render_plugin plugin_instance %}
+ {% endfor %}
+
+ {# WARNING: Dev Idea: A single day range may show as just a single date #}
+ {% if open_date or open_date == close_date %}
+
+ {% endif %}
+ {# WARNING: Dev Idea: A lone close date may start with trailing dash #}
+ {% if close_date and close_date != open_date %}
+ —
+
+ {% endif %}
+
+ {% endif %}
+
+
+
+ {# Metadata: Type #}
+ {% if kind == 'news' %}
+