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 %} +
+ {% endif %} + + {# Title #} +

+ + {{ instance.title_text }} + +

+ + {# Abstract #} + {% if kind != 'allocs' %} +

+ {{ instance.abstract_text }} +

+ {% endif %} + + + + {# Metadata #} + + + + +
diff --git a/taccsite_cms/default_secrets.py b/taccsite_cms/default_secrets.py index ef3fd6353..c3ca92594 100644 --- a/taccsite_cms/default_secrets.py +++ b/taccsite_cms/default_secrets.py @@ -157,6 +157,13 @@ # SEE: https://confluence.tacc.utexas.edu/x/EwDeCg # SEE: https://confluence.tacc.utexas.edu/x/FAA9Cw "blog": False, + + # Search + # - `True` (Portal: allow search, Any CMS: allow index) + # - (falsy) (Portal & CMS: allow search & index, SAD CMS: never index) + # - `False` (Portal: no search bar, Any CMS: never index) + # FP-1099: Search can be disabled as a workaround to suspected index bug + "search": None, } ######################## diff --git a/taccsite_cms/settings.py b/taccsite_cms/settings.py index 530c2d3fd..f9c3eac8e 100644 --- a/taccsite_cms/settings.py +++ b/taccsite_cms/settings.py @@ -232,8 +232,6 @@ def getsecrets(): 'djangocms_bootstrap4.contrib.bootstrap4_picture', 'djangocms_bootstrap4.contrib.bootstrap4_tabs', 'djangocms_bootstrap4.contrib.bootstrap4_utilities', - 'haystack', - 'aldryn_apphooks_config', # For faster testing, disable migrations during database creation # SEE: https://stackoverflow.com/a/37150997 'test_without_migrations', @@ -241,6 +239,13 @@ def getsecrets(): # TODO: Extract TACC CMS UI components into pip-installable plugins # FAQ: The djangocms_bootstrap4 library can serve as an example 'taccsite_cms.contrib.taccsite_sample', + 'taccsite_cms.contrib.taccsite_blockquote', + 'taccsite_cms.contrib.taccsite_offset', + 'taccsite_cms.contrib.taccsite_static_article_list', + 'taccsite_cms.contrib.taccsite_static_article_preview', + # Restore djangocms plugins that bootstrap4 hides + 'taccsite_cms.contrib.bootstrap4_djangocms_link', + 'taccsite_cms.contrib.bootstrap4_djangocms_picture', ] # Convert list of paths to list of dotted module names @@ -377,8 +382,8 @@ def get_subdirs_as_module_names(path): for feature in FEATURES: print(feature + ": ", FEATURES[feature]) -if current_secrets._FEATURES['blog']: - # Install required apps +# Support Blog/News & Social Media Metadata +if FEATURES['blog']: INSTALLED_APPS += [ # Blog/News # 'filer', # Already added @@ -394,7 +399,7 @@ def get_subdirs_as_module_names(path): 'djangocms_page_meta', ] - # Metadata: Configure + # Configure metadata META_SITE_PROTOCOL = 'http' META_USE_SITES = True META_USE_OG_PROPERTIES = True @@ -402,19 +407,46 @@ def get_subdirs_as_module_names(path): META_USE_GOOGLEPLUS_PROPERTIES = True # django-meta 1.x+ # META_USE_SCHEMAORG_PROPERTIES=True # django-meta 2.x+ - # Blog/News: Set custom paths for templates + # Set custom paths for templates BLOG_PLUGIN_TEMPLATE_FOLDERS = ( ('plugins/default', 'Default template'), # i.e. `templates/djangocms_blog/plugins/default/` ('plugins/default-clone', 'Clone of default template'), # i.e. `templates/djangocms_blog/plugins/default-clone/` ) - # Blog/News: Change default values for the auto-setup of one `BlogConfig` + # Change default values for the auto-setup of one `BlogConfig` # SEE: https://github.com/nephila/djangocms-blog/issues/629 BLOG_AUTO_SETUP = True BLOG_AUTO_HOME_TITLE ='Home' BLOG_AUTO_BLOG_TITLE = 'News' BLOG_AUTO_APP_TITLE = 'News' +# Support Portal search or CMS indexing +if 'search' in FEATURES and ( + FEATURES['search'] + # FAQ: Portals can search by default, but may not set `FEATURES['search']` + or (PORTAL and FEATURES['search'] != False) +): + INSTALLED_APPS += [ + 'haystack', + 'aldryn_apphooks_config', + ] + + # Elasticsearch Indexing + HAYSTACK_ROUTERS = ['aldryn_search.router.LanguageRouter',] + HAYSTACK_SIGNAL_PROCESSOR = 'taccsite_cms.signal_processor.RealtimeSignalProcessor' + ALDRYN_SEARCH_DEFAULT_LANGUAGE = 'en' + ALDRYN_SEARCH_REGISTER_APPHOOK = True + HAYSTACK_CONNECTIONS = { + 'default': { + 'ENGINE': 'haystack.backends.elasticsearch_backend.ElasticsearchSearchEngine', + 'URL': current_secrets._ES_HOSTS, + 'INDEX_NAME': current_secrets._ES_INDEX_PREFIX.format('cms'), + 'KWARGS': {'http_auth': current_secrets._ES_AUTH } + } + } + + ES_DOMAIN = current_secrets._ES_DOMAIN + DJANGOCMS_PICTURE_NESTING = True DJANGOCMS_PICTURE_RESPONSIVE_IMAGES = True @@ -465,22 +497,6 @@ def get_subdirs_as_module_names(path): # Use a custom namespace (using default settings.VARIABLE configuration) SETTINGS_EXPORT_VARIABLE_NAME = 'settings' -# Elasticsearch Indexing -HAYSTACK_ROUTERS = ['aldryn_search.router.LanguageRouter',] -HAYSTACK_SIGNAL_PROCESSOR = 'taccsite_cms.signal_processor.RealtimeSignalProcessor' -ALDRYN_SEARCH_DEFAULT_LANGUAGE = 'en' -ALDRYN_SEARCH_REGISTER_APPHOOK = True -HAYSTACK_CONNECTIONS = { - 'default': { - 'ENGINE': 'haystack.backends.elasticsearch_backend.ElasticsearchSearchEngine', - 'URL': current_secrets._ES_HOSTS, - 'INDEX_NAME': current_secrets._ES_INDEX_PREFIX.format('cms'), - 'KWARGS': {'http_auth': current_secrets._ES_AUTH } - } -} - -ES_DOMAIN = current_secrets._ES_DOMAIN - # Exported settings. SETTINGS_EXPORT = [ 'DEBUG', diff --git a/taccsite_cms/static/site_cms/css/src/_imports/components/c-article-list.css b/taccsite_cms/static/site_cms/css/src/_imports/components/c-article-list.css new file mode 100644 index 000000000..13fc0f125 --- /dev/null +++ b/taccsite_cms/static/site_cms/css/src/_imports/components/c-article-list.css @@ -0,0 +1,205 @@ +/* +Article List + +A list of article previews. + +Markup: +
+

+ News +

+
...
+
...
+
...
+ +
+ +Styleguide Components.ArticleList +*/ +@import url("_imports/tools/x-truncate.css"); +@import url("_imports/tools/x-layout.css"); +@import url("_imports/tools/x-article-link.css"); + + + + + +/* Children */ + + + +.c-article-list--layout-e .c-article-list__item { + /* To shrink heading */ + flex-grow: 1; +} + + + +/* Children: Title */ + +.c-article-list--layout-a .c-article-list__title, +.c-article-list--layout-b .c-article-list__title, +.c-article-list--layout-c .c-article-list__title, +.c-article-list--layout-d .c-article-list__title { + /* To span all columns */ + grid-column-start: 1; + grid-column-end: -1; +} + +.c-article-list__title { + margin-top: 0; /* overwrite Bootstrap */ + margin-bottom: 3.0rem; /* overwrite Bootstrap */ + + color: var(--global-color-accent--normal); + + font-size: 1.6rem; + font-weight: var(--bold); + text-transform: uppercase; + + @extend %x-truncate--one-line; +} +/* Add a fake short border above title */ +.c-article-list__title { + position: relative; + padding-top: 1em; +} +.c-article-list__title::before { + content: ''; + display: block; + + position: absolute; + top: 0; + height: 0.5em; + width: 2.5em; + + background-color: var(--global-color-accent--normal); +} + + + +/* Children: "See More" */ + +/* Anchor */ + +.c-article-list--layout-a .c-article-list__footer, +.c-article-list--layout-b .c-article-list__footer, +.c-article-list--layout-c .c-article-list__footer, +.c-article-list--layout-d .c-article-list__footer { + /* To span all columns */ + grid-column-start: 1; + grid-column-end: -1; +} + +.c-article-list__footer { + border-top-width: var(--global-border-width--thick); + border-top-style: solid; + + margin-bottom: -1.0rem; /* to "undo" space added from `padding-bottom` */ + + font-size: 1.2rem; + font-weight: var(--bold); +} +.c-article-list__link { + display: inline-block; + + padding-top: 1.0rem; + padding-bottom: 1.0rem; + padding-right: 1.0rem; + + @extend %x-truncate--one-line; + max-width: 100%; /* SEE: https://stackoverflow.com/a/44521595 */ +} +/* Dark section */ +.o-section--style-dark .c-article-list__footer { + border-color: var(--global-color-primary--xx-light); +} +.o-section--style-dark .c-article-list__link { + color: var(--global-color-primary--xx-light); +} +/* Light section */ +.o-section--style-light .c-article-list__footer { + border-color: var(--global-color-primary--xx-dark); +} +.o-section--style-light .c-article-list__link { + color: var(--global-color-primary--xx-dark); +} + +/* Icon */ + +.c-article-list__link-icon { + margin-right: 0.75em; + + font-size: 1.4rem; + vertical-align: text-bottom; + + /* To hide the `text-decoration: underline` of the anchor */ + /* SEE: https://stackoverflow.com/a/15688237/11817077 */ + display: inline-block; +} + + + + + +/* Modifiers */ + + + +/* Modifiers: Layout */ + +.c-article-list--layout-a { @extend %x-layout--a; } +.c-article-list--layout-b { @extend %x-layout--b; } +.c-article-list--layout-c { @extend %x-layout--c; } +.c-article-list--layout-d { @extend %x-layout--d; } +.c-article-list--layout-e { @extend %x-layout--e; } + +/* Modifiers: Layout: Column-Based */ + +.c-article-list--layout-a, +.c-article-list--layout-b, +.c-article-list--layout-c, +.c-article-list--layout-d { + column-gap: 3.0rem; /* GH-99: Use standard spacing value */ +} + +/* Modifiers: Layout: Row-Based */ + +.c-article-list--layout-e { + row-gap: 3.0rem; /* GH-99: Use standard spacing value */ +} + + + +/* Modifiers: Style */ + +/* Modifiers: Style: Divided */ + +/* Vertical layout */ +.c-article-list--layout-e.c-article-list--style-divided .c-article-list__item { + padding-top: 0.8rem; + padding-bottom: 0.8rem; + + border-width: var(--global-border-width--normal) 0 0 0; + border-style: solid; +} +/* Dark section */ +.o-section--style-dark.c-article-list--style-divided .c-article-list__item, +.o-section--style-dark .c-article-list--style-divided .c-article-list__item { + border-color: var(--global-color-primary--light); +} +/* Light section */ +.o-section--style-light.c-article-list--style-divided .c-article-list__item, +.o-section--style-light .c-article-list--style-divided .c-article-list__item { + border-color: var(--global-color-primary--dark); +} + +/* Modifiers: Style: Gapless */ + +.c-article-list--style-gapless { + gap: 0; /* overwrite `column-gap` or `row-gap` */ +} diff --git a/taccsite_cms/static/site_cms/css/src/_imports/components/c-article-preview.css b/taccsite_cms/static/site_cms/css/src/_imports/components/c-article-preview.css new file mode 100644 index 000000000..7d363319a --- /dev/null +++ b/taccsite_cms/static/site_cms/css/src/_imports/components/c-article-preview.css @@ -0,0 +1,247 @@ +/* +Article Preview + +A preview of an article (to be used in a `c-article-list`). Content __should__ come in the order defined by the example markup. + +Markup: +
+
+ … +
+

+ A Long or Short Title of Article +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. +

+ +
+ +Styleguide Components.ArticlePreview +*/ +@import url("_imports/tools/x-truncate.css"); +@import url("_imports/tools/x-article-link.css"); + + + + + +/* Block */ + +.c-article-preview { + position: relative; /* for absolutely positioned "Children: Link" */ + + display: flex; + flex-direction: column; +} + + + + + +/* Children */ + + + +/* Children: Media */ +/* HACK: Forced to style directly because we do not contorl markup */ + +.c-article-preview__media { + order: 1; + + overflow: hidden; + + margin-bottom: 0.8rem; /* overwrite Bootstrap */ +} +.c-article-preview__media img { + /* To center image within container */ + position: relative; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + /* To ensure super wide or tall image do not have negative space / gaps */ + width: 100%; + object-fit: cover; + height: 100%; +} +/* News */ +.c-article-preview--news .c-article-preview__media { + height: 180px; +} +/* Allocations */ +.c-article-preview--allocs .c-article-preview__media { + height: 10rem; +} +/* Events */ +.c-article-preview--events .c-article-preview__media { + display: none; +} + + +/* Children: Title */ + +.c-article-preview__title { + order: 3; + + margin-top: 0; /* overwrite Bootstrap and browser */ + margin-bottom: 0; /* overwrite Bootstrap and browser */ + + font-weight: var(--bold); + line-height: 2.4rem; +} +.c-article-preview__title a, +.c-article-preview__title a:hover, +.c-article-preview__title a:focus { + color: inherit; +} +/* News */ +.c-article-preview--news .c-article-preview__title { + font-size: 1.8rem; +} +/* Allocations */ +.c-article-preview--allocs .c-article-preview__title { + font-size: 1.6rem; +} +/* Events */ +.c-article-preview--events .c-article-preview__title { + font-size: 1.4rem; + color: var(--global-color-primary--xx-dark); +} +/* Docs */ +.c-article-preview--docs .c-article-preview__title { + font-size: 1.4rem; + color: var(--global-color-primary--xx-dark); +} + + + +/* Children: Abstract */ + +.c-article-preview__abstract { + order: 4; + + margin-bottom: 0; /* overwrite Bootstrap and browser */ + + line-height: 2.4rem; +} +/* News */ +.c-article-preview--news .c-article-preview__abstract { + font-size: 1.6rem; +} +/* Allocations */ +.c-article-preview--allocs .c-article-preview__abstract { + display: none; +} +/* Events */ +.c-article-preview--events .c-article-preview__abstract { + font-size: 1.4rem; + color: var(--global-color-primary--xx-dark); +} +/* Docs */ +.c-article-preview--docs .c-article-preview__abstract { + font-size: 1.4rem; + color: var(--global-color-primary--xx-dark); +} + + + +/* Children: Metadata */ + +.c-article-preview__metadata { + order: 2; + + display: flex; + flex-direction: column; + + list-style: none; + padding-left: 0; /* overwrite `site.css` and browser */ + + margin-bottom: 0; /* overwrite Bootstrap and browser */ +} +/* Allocations */ +.c-article-preview--allocs .c-article-preview__metadata { + order: 5; +} + +/* Children: Metadata: Date */ + +.c-article-preview__date { + order: 2; + + display: flex; + flex-direction: row; + flex-wrap: wrap; + + font-weight: var(--medium); + + white-space: pre; +} +/* News */ +.c-article-preview--news .c-article-preview__date { + margin-bottom: 0.8rem; /* overwrite Bootstrap */ + font-size: 1.0rem; +} +/* Events */ +.c-article-preview--events .c-article-preview__date { + font-size: 1.4rem; + color: var(--global-color-accent--normal); +} +/* Allocations */ +.c-article-preview--allocs .c-article-preview__date { + font-size: 1.6rem; +} + +/* Children: Metadata: Type */ + +.c-article-preview__type { + order: 1; + + font-size: 1.2rem; + font-weight: var(--bold); + text-transform: uppercase; +} +/* Events */ +.c-article-preview--events .c-article-preview__type, +/* Allocations */ +.c-article-preview--allocs .c-article-preview__type { + display: none; +} + +/* Children: Metadata: Author */ + +.c-article-preview__author { + order: 3; +} +/* News */ +.c-article-preview--news .c-article-preview__author, +/* Events */ +.c-article-preview--events .c-article-preview__author, +/* Allocations */ +.c-article-preview--allocs .c-article-preview__author { + display: none; +} + + + +/* Children: Link */ + +/* Expand link to cover its container */ +.c-article-preview__link::before { + content: ''; + z-index: 1; /* ensure Link appears over Media */ + + color: transparent; /* ensure Link _text_ is invisible (allow decoration) */ + + @extend %x-article-link-stretch; +} +/* Give link state (pseudo-class) feedback */ +.c-article-preview__link:hover::before { + @extend %x-article-link-hover; +} diff --git a/taccsite_cms/static/site_cms/css/src/_imports/components/c-date.css b/taccsite_cms/static/site_cms/css/src/_imports/components/c-date.css new file mode 100644 index 000000000..1f8381e51 --- /dev/null +++ b/taccsite_cms/static/site_cms/css/src/_imports/components/c-date.css @@ -0,0 +1,44 @@ +/* +Date + +A date with a label. + +Markup: +
+
Submission Deadline
+
+ +
+
+ +Styleguide Components.Date +*/ +@import url("_imports/tools/x-truncate.css"); + +/* Container */ + +dl.c-date { + margin: 0; /* overwrite Bootstrap's `_reboot.scss` */ +} + +/* Children */ + +.c-date__label { + @extend %x-truncate--one-line; +} +.c-date__label::after { + content: ':'; + display: inline; + padding-right: 0.25em; +} +dt.c-date__label { + font-weight: inherit; /* overwrite Bootstrap's `_reboot.scss` */ +} + +.c-date__value { + white-space: nowrap; +} +dd.c-date__value { + font-weight: inherit; + margin: 0; /* overwrite Bootstrap's `_reboot.scss` */ +} diff --git a/taccsite_cms/static/site_cms/css/src/_imports/elements/html-elements.css b/taccsite_cms/static/site_cms/css/src/_imports/elements/html-elements.css index 221f27618..97729954c 100755 --- a/taccsite_cms/static/site_cms/css/src/_imports/elements/html-elements.css +++ b/taccsite_cms/static/site_cms/css/src/_imports/elements/html-elements.css @@ -89,7 +89,8 @@ ol ol, ol ul, ul ol, ul ul { } /* Add space between `dt` and `dd` */ dt { - margin-bottom: .5rem; /* Mirror's Bootstrap `margin-bottom` */ + margin-bottom: .5rem; /* overwrite Bootstrap */ + font-weight: initial; /* overwrite Bootstrap */ } diff --git a/taccsite_cms/static/site_cms/css/src/_imports/objects/o-offset-content.css b/taccsite_cms/static/site_cms/css/src/_imports/objects/o-offset-content.css new file mode 100644 index 000000000..e976559af --- /dev/null +++ b/taccsite_cms/static/site_cms/css/src/_imports/objects/o-offset-content.css @@ -0,0 +1,40 @@ +/* +Offset Content + +Content that should be offset from the flow of text within which it is placed. + +Styleguide Objects.OffsetContent +*/ +@import url("_imports/tools/media-queries.css"); + +[class*="o-offset-content--"] { + --offset-distance: 8.5vw; /* NOTE: Value is from Texascale.org 2020 */ + --buffer: 30px; /* double Bootstrap `.col` padding */ + --max-width: initial; +} + +@media only screen and (--medium-and-above) { + .o-offset-content--right { + float: right; + margin-left: var(--buffer); + } + .o-offset-content--right + .o-offset-content--right { clear: right; } + + .o-offset-content--left { + float: left; + margin-right: var(--buffer); + } + .o-offset-content--left + .o-offset-content--left { clear: left; } +} +@media only screen and (--medium-and-above) and (--max-wide-and-below) { + [class*="c-offset-content--"] { + --max-width: 50%; + } + /* Apply negative margin only when using offset value */ + .o-offset-content--right { + margin-right: calc( var(--offset-distance) * -1); + } + .o-offset-content--left { + margin-left: calc( var(--offset-distance) * -1); + } +} diff --git a/taccsite_cms/static/site_cms/css/src/_imports/tools/media-queries.css b/taccsite_cms/static/site_cms/css/src/_imports/tools/media-queries.css index 2b2e4df19..1671ae32d 100644 --- a/taccsite_cms/static/site_cms/css/src/_imports/tools/media-queries.css +++ b/taccsite_cms/static/site_cms/css/src/_imports/tools/media-queries.css @@ -11,6 +11,7 @@ Notice: These must be Tools (imported as needed) until native browser support, a Reference: - https://drafts.csswg.org/mediaqueries-5/#at-ruledef-custom-media +- https://confluence.tacc.utexas.edu/x/b4AZCg Styleguide Tools.CustomMediaQueries.Breakpoints */ @@ -40,4 +41,8 @@ Styleguide Tools.CustomMediaQueries.Breakpoints @custom-media --xxx-wide-and-below (width < 1920px); @custom-media --xxx-wide-and-above (width >= 1920px); -/* @custom-media --xxx-wide-to-omg-wide (... <= width < ...); */ +@custom-media --xxx-wide-to-max-wide (1920px <= width < 2400px); + +@custom-media --max-wide-and-below (width < 2400px); +@custom-media --max-wide-and-above (width >= 2400px); +/* @custom-media --max-wide-to-god-wide (... <= width < ...); */ diff --git a/taccsite_cms/static/site_cms/css/src/_imports/tools/x-article-link.css b/taccsite_cms/static/site_cms/css/src/_imports/tools/x-article-link.css index 85a1f2039..581fac007 100644 --- a/taccsite_cms/static/site_cms/css/src/_imports/tools/x-article-link.css +++ b/taccsite_cms/static/site_cms/css/src/_imports/tools/x-article-link.css @@ -4,9 +4,7 @@ Article Link Styles that allow visible link hover for article lists. %x-article-link-stretch - Stretch link to cover container -%x-article-link-stretch--gapless - Make link box fix gapless layout %x-article-link-hover - Give link a hover state -%x-article-link-hover--gapless - Make link hover state fix gapless layout Styleguide Tools.ExtendsAndMixins.ArticleLink */ @@ -14,7 +12,6 @@ Styleguide Tools.ExtendsAndMixins.ArticleLink /* WARNING: A link ancestor must have its `position` set (not to static) */ /* Expand link to cover container */ -.x-article-link-stretch, %x-article-link-stretch { position: absolute; height: 100%; @@ -27,20 +24,8 @@ Styleguide Tools.ExtendsAndMixins.ArticleLink /* SEE: http://johndoesdesign.com/blog/2012/css/firefox-and-its-css-focus-outline-bug/ */ overflow: hidden; } -.x-article-link-stretch--gapless, -%x-article-link-stretch--gapless { - width: calc(100% + 30px); /* GH-99: Use standard spacing value */ - left: -15px; -} /* Give link state (pseudo-class) feedback */ -.x-article-link-hover, %x-article-link-hover { outline: 1px solid var(--global-color-accent--normal); - - outline-offset: 1em; -} -.x-article-link-hover--gapless, -%x-article-link-hover--gapless { - outline-offset: 0; } diff --git a/taccsite_cms/static/site_cms/css/src/_imports/trumps/s-article-list.css b/taccsite_cms/static/site_cms/css/src/_imports/trumps/s-article-list.css index e07f7bdda..f9b18482b 100644 --- a/taccsite_cms/static/site_cms/css/src/_imports/trumps/s-article-list.css +++ b/taccsite_cms/static/site_cms/css/src/_imports/trumps/s-article-list.css @@ -3,239 +3,52 @@ Article List A list of article previews. Content __must__ use the tags defined by the example markup. -Markup: s-article-list.html +Markup: +
+

Articles

+
...
+
...
+
...
+ +
Styleguide Trumps.Scopes.ArticleList */ -@import url("_imports/tools/x-truncate.css"); -@import url("_imports/tools/x-layout.css"); -@import url("_imports/tools/x-article-link.css"); - - - - - -/* Block */ - -[class*="s-article-list--"] { - /* … */ -} - - /* Children */ - - -/* Children: All */ - -/* Not "Title" & Not "See More" */ -.s-article-list--layout-e > :not(h2):not(p:last-child) { - /* To shrink heading */ - flex-grow: 1; -} - - - /* Children: Title */ -.s-article-list--layout-a > h2, -.s-article-list--layout-b > h2, -.s-article-list--layout-c > h2, -.s-article-list--layout-d > h2 { - /* To span all columns */ - grid-column-start: 1; - grid-column-end: -1; +.s-article-list .c-article-preview__title { + /* FAQ: Article preview truncation differs for Hero Banner */ + /* SEE: https://github.com/TACC/Core-CMS-Resources/blob/main/frontera-cms/static/frontera-cms/css/src/_imports/trumps/s-home.css#L158-L161 */ + @extend %x-truncate--one-line; } -[class*="s-article-list--"] > h2 { - margin-top: 0; /* overwrite Bootstrap */ - margin-bottom: 3.0rem; /* overwrite Bootstrap */ - - color: var(--global-color-accent--normal); +/* Children: Abstract */ - font-size: 1.6rem; - font-weight: var(--bold); - text-transform: uppercase; - - @extend .x-truncate--one-line; -} -/* Add a fake short border above title */ -[class*="s-article-list--"] > h2 { - position: relative; - padding-top: 1em; +.s-article-list .c-article-preview__abstract { + /* FAQ: Article previews may not always truncate many lines */ + @extend %x-truncate--many-lines; + --lines: 3; } -[class*="s-article-list--"] > h2::before { - content: ''; - display: block; - position: absolute; - top: 0; - height: 0.5em; - width: 2.5em; +/* Children: Link */ - background-color: var(--global-color-accent--normal); +.s-article-list:not(.c-article-list--style-gapless) + .c-article-preview__link:hover::before { + outline-offset: 1rem; } -/* Children: "See More" */ - -/* Anchor */ - -.s-article-list--layout-a > p:last-child, -.s-article-list--layout-b > p:last-child, -.s-article-list--layout-c > p:last-child, -.s-article-list--layout-d > p:last-child { - /* To span all columns */ - grid-column-start: 1; - grid-column-end: -1; -} - -[class*="s-article-list--"] > p:last-child { - border-top-width: var(--global-border-width--thick); - border-top-style: solid; - - margin-top: 3.0rem; /* GH-99: Use standard spacing value */ - margin-bottom: -1.0rem; /* to "undo" space added from `padding-bottom` */ - - font-size: 1.2rem; - font-weight: var(--bold); -} -[class*="s-article-list--"] > p:last-child a { - display: inline-block; - - padding-top: 1.0rem; - padding-bottom: 1.0rem; - padding-right: 1.0rem; - - @extend .x-truncate--one-line; - max-width: 100%; /* SEE: https://stackoverflow.com/a/44521595 */ -} -/* Dark section */ -.o-section--style-dark[class*="s-article-list--"] > p:last-child, -.o-section--style-dark [class*="s-article-list--"] > p:last-child { - border-color: var(--global-color-primary--xx-light); -} -.o-section--style-dark[class*="s-article-list--"] > p:last-child a, -.o-section--style-dark [class*="s-article-list--"] > p:last-child a { - color: var(--global-color-primary--xx-light); -} -/* Light section */ -.o-section--style-light[class*="s-article-list--"] > p:last-child, -.o-section--style-light [class*="s-article-list--"] > p:last-child { - border-color: var(--global-color-primary--xx-dark); -} -.o-section--style-light[class*="s-article-list--"] > p:last-child a, -.o-section--style-light [class*="s-article-list--"] > p:last-child a { - color: var(--global-color-primary--xx-dark); -} - -/* Icon */ - -[class*="s-article-list--"] > p:last-child a::before { - font-family: "Font Awesome 5 Free"; - content: "\f35a"; - margin-right: 10px; - - font-size: 1.4rem; - vertical-align: middle; - - /* To hide the `text-decoration: underline` of the anchor */ - /* SEE: https://stackoverflow.com/a/15688237/11817077 */ - display: inline-block; -} - - - - - /* Modifiers */ +/* Modifiers: Docs */ - -/* Modifiers: Links */ - -.s-article-list--links { - font-size: 1.4rem; - color: var(--global-color-primary--xx-dark); -} -.s-article-list--links p:not(:last-child) { - margin: 0; /* Overwrite Bootstrap and browser */ -} -.s-article-list--links p:not(:last-child) a { - font-weight: var(--bold); - color: var(--global-color-primary--xx-dark); -} - -/* Expand link to cover its container */ -.s-article-list--links p:not(:last-child) { position: relative; } -.s-article-list--links p:not(:last-child) a::before { - content: ''; - - @extend .x-article-link-stretch; -} -.s-article-list--layout-gapless.s-article-list--links p:not(:last-child) a::before { - @extend .x-article-link-stretch--gapless; -} -/* Give link state (pseudo-class) feedback */ -.s-article-list--links p:not(:last-child) a:hover::before { - @extend .x-article-link-hover; -} -.s-article-list--layout-gapless.s-article-list--links p:not(:last-child) a:hover::before { - @extend .x-article-link-hover--gapless; -} - - - -/* Modifiers: Layout */ - -.s-article-list--layout-a { @extend .x-layout--a; } -.s-article-list--layout-b { @extend .x-layout--b; } -.s-article-list--layout-c { @extend .x-layout--c; } -.s-article-list--layout-d { @extend .x-layout--d; } -.s-article-list--layout-e { @extend .x-layout--e; } - -/* Modifiers: Layout: Column-Based */ - -.s-article-list--layout-a, -.s-article-list--layout-b, -.s-article-list--layout-c, -.s-article-list--layout-d { - column-gap: 3.0rem; /* GH-99: Use standard spacing value */ -} - -/* Modifiers: Layout: Row-Based */ - -.s-article-list--layout-e { - /* … */ -} - -/* Modifiers: Layout: Options */ - -.s-article-list--layout-gapless { - gap: 0; -} - -.s-article-list--layout-compact > p:last-child { - margin-top: 0; -} - -.s-article-list--layout-divided > :not(h2):not(p:last-child) { - padding-top: 0.8rem; - - border-width: var(--global-border-width--normal) 0 0; - border-style: solid; -} -/* Dark section */ -.o-section--style-dark.s-article-list--layout-divided > :not(h2):not(p:last-child), -.o-section--style-dark .s-article-list--layout-divided > :not(h2):not(p:last-child) { - border-color: var(--global-color-primary--light); -} -/* Light section */ -.o-section--style-light.s-article-list--layout-divided > :not(h2):not(p:last-child), -.o-section--style-light .s-article-list--layout-divided > :not(h2):not(p:last-child) { - border-color: var(--global-color-primary--dark); +.s-article-list .c-article-preview--docs:last-of-type { + /* HACK: Force links to be a little closer together (match design) */ + margin-bottom: 3.0rem; } diff --git a/taccsite_cms/static/site_cms/css/src/_imports/trumps/s-article-list.html b/taccsite_cms/static/site_cms/css/src/_imports/trumps/s-article-list.html deleted file mode 100644 index 50608ad90..000000000 --- a/taccsite_cms/static/site_cms/css/src/_imports/trumps/s-article-list.html +++ /dev/null @@ -1,20 +0,0 @@ -
-

- News & Stuff -

-
- Article Placeholder -
-
- - - diff --git a/taccsite_cms/static/site_cms/css/src/_imports/trumps/s-article-preview.css b/taccsite_cms/static/site_cms/css/src/_imports/trumps/s-article-preview.css deleted file mode 100644 index 7fa66a883..000000000 --- a/taccsite_cms/static/site_cms/css/src/_imports/trumps/s-article-preview.css +++ /dev/null @@ -1,253 +0,0 @@ -/* -Article Preview - -A preview of an article (to be used in a `s-article-list`). Content __must__ come in the order and use the tags defined by the example markup. - -Markup: s-article-preview.html - -Styleguide Trumps.Scopes.ArticlePreview -*/ -@import url("_imports/tools/x-truncate.css"); -@import url("_imports/tools/x-article-link.css"); - - - - - -/* Block */ - -.s-article-preview { - position: relative; /* for absolutely positioned "Children: Link" */ - - display: flex; - flex-direction: column; -} - - - - - -/* Children */ - - - -/* Children: Media */ - -.s-article-preview p:first-child { - order: 1; - - overflow: hidden; - - margin-bottom: 0.8rem; /* overwrite Bootstrap */ -} -.s-article-preview p:first-child > img { - /* To center image within container */ - position: relative; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); -} -.s-article-preview p:first-child > img.img-fluid { - /* To ensure super wide or tall image do not have negative space / gaps */ - width: 100%; - object-fit: cover; - height: 100%; /* overwrite `.img-fluid` *//* NOTE: Sould this be standard? */ -} -/* (List) News */ -.s-article-list--news .s-article-preview p:first-child { - height: 180px; -} -/* (List) Allocations */ -.s-article-list--allocations .s-article-preview p:first-child { - height: 10.0rem; -} -/* (List) Events */ -.s-article-list--events .s-article-preview p:first-child { - display: none; -} - - -/* Children: Title */ - -.s-article-preview h3 { - order: 3; - - margin-top: 0; /* overwrite Bootstrap and browser */ - margin-bottom: 0.8rem; /* overwrite Bootstrap and browser */ - - font-size: 1.8rem; - font-weight: var(--bold); - line-height: 2.4rem; -} -/* (List) */ -[class*="s-article-list--"] .s-article-preview h3 { - @extend %x-truncate--one-line; -} -/* (List) Allocations */ -.s-article-list--allocations .s-article-preview h3 { - font-size: 1.6rem; - font-weight: var(--bold); -} -/* (List) Events */ -.s-article-list--events .s-article-preview h3 { - font-size: 1.4rem; - color: var(--global-color-primary--xx-dark); -} - - - -/* Children: Abstract */ - -.s-article-preview p:not(:first-child):not(:last-child) { - order: 4; - - margin-bottom: 0; /* overwrite Bootstrap and browser */ - - font-size: 1.6rem; - line-height: 2.4rem; -} -/* (List) */ -[class*="s-article-list--"] .s-article-preview p:not(:first-child):not(:last-child) { - @extend %x-truncate--many-lines; - --lines: 3; -} -/* (List) Allocations */ -.s-article-list--allocations .s-article-preview p:not(:first-child):not(:last-child) { - display: none; -} -/* (List) Events */ -.s-article-list--events .s-article-preview p:not(:first-child):not(:last-child) { - font-size: 1.4rem; - color: var(--global-color-primary--xx-dark); -} - - - -/* Children: Metadata */ - -.s-article-preview ul { - order: 2; - - display: flex; - flex-direction: column; - - list-style: none; - padding-left: 0; /* overwrite `site.css` and browser */ - - margin-bottom: 0.8rem; /* overwrite Bootstrap */ -} -/* (List) Allocations */ -.s-article-list--allocations .s-article-preview ul { - order: 5; -} - -/* Children: Metadata: Date */ - -.s-article-preview ul > li:nth-child(1) { - order: 2; - - font-weight: var(--medium); - - display: flex; - flex-direction: row; - flex-wrap: wrap; -} -/* (List) News */ -.s-article-list--news .s-article-preview ul > li:nth-child(1) { - margin-bottom: 0.8rem; /* overwrite Bootstrap */ - font-size: 1.0rem; -} -.s-article-list--news .s-article-preview ul > li:nth-child(1)::before { - content: 'Published: '; - white-space: pre; -} -/* (List) Events */ -.s-article-list--events .s-article-preview ul > li:nth-child(1) { - font-size: 1.4rem; - color: var(--global-color-accent--normal); -} -/* (List) Allocations */ -.s-article-list--allocations .s-article-preview ul > li:nth-child(1) { - font-size: 1.6rem; -} -.s-article-list--allocations .s-article-preview ul > li:nth-child(1)::before { - content: 'Submission Deadlines: '; - white-space: pre; -} - -/* Children: Metadata: Type */ - -.s-article-preview ul > li:nth-child(2) { - order: 1; - - font-size: 1.2rem; - font-weight: var(--bold); - text-transform: uppercase; -} -/* (List) Events */ -.s-article-list--events .s-article-preview ul > li:nth-child(2), -/* (List) Allocations */ -.s-article-list--allocations .s-article-preview ul > li:nth-child(2) { - display: none; -} - -/* Children: Metadata: Author */ - -.s-article-preview ul > li:nth-child(3) { - order: 3; -} -/* (List) News */ -.s-article-list--news .s-article-preview ul > li:nth-child(3), -/* (List) Events */ -.s-article-list--events .s-article-preview ul > li:nth-child(3), -/* (List) Allocations */ -.s-article-list--allocations .s-article-preview ul > li:nth-child(3) { - display: none; -} - - - -/* Children: Link */ - -.s-article-preview p:last-child { - margin-bottom: 0; /* overwite Bootstrap and browser */ -} - -/* Expand link to cover its container */ -.s-article-preview p:last-child { - z-index: 1; /* ensure Link appears over Media */ -} -.s-article-preview p:last-child > a { - color: transparent; /* ensure Link _text_ is invisible (allow decoration) */ - - @extend .x-article-link-stretch; -} -.s-article-list--layout-gapless .s-article-preview p:last-child > a { - @extend .x-article-link-stretch--gapless; -} -/* Give link state (pseudo-class) feedback */ -.s-article-preview p:last-child > a:hover { - @extend .x-article-link-hover; -} -.s-article-list--layout-gapless .s-article-preview p:last-child > a:hover { - @extend .x-article-link-hover--gapless; -} - - - - - -/* Modifiers */ - - - -/* Modifiers: (List) News, Allocations, Evetns, etc. */ -/* SEE: All "Children" styles */ - - - -/* Modifiers: (List) Layout: Options */ - -.s-article-list--layout-compact .s-article-preview > * { - margin-bottom: 0; /* overwrite `.s-article-preview > …` */ -} diff --git a/taccsite_cms/static/site_cms/css/src/_imports/trumps/s-article-preview.html b/taccsite_cms/static/site_cms/css/src/_imports/trumps/s-article-preview.html deleted file mode 100644 index dcea7f35f..000000000 --- a/taccsite_cms/static/site_cms/css/src/_imports/trumps/s-article-preview.html +++ /dev/null @@ -1,30 +0,0 @@ -
-

…

-

- A Long or Short Title of Article -

-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. -

- -
- - - diff --git a/taccsite_cms/static/site_cms/css/src/_imports/trumps/s-blockquote.css b/taccsite_cms/static/site_cms/css/src/_imports/trumps/s-blockquote.css new file mode 100644 index 000000000..7c81be140 --- /dev/null +++ b/taccsite_cms/static/site_cms/css/src/_imports/trumps/s-blockquote.css @@ -0,0 +1,45 @@ +/* +Blockquote + +A [`
`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/blockquote). + +Markup: +
+
+

Words can be like X-rays, if you use them properly—they’ll go through anything. You read and you’re pierced.

+
+
Aldous Huxley, Brave New World
+
+ +Styleguide Trumps.Scopes.Blockquote +*/ + +.s-blockquote { + margin-top: 1.25em; + margin-bottom: 1.25em; +} +.s-blockquote blockquote { + margin-bottom: 0; /* overwrite Bootstrap */ + + font-size: 1.125em; + font-style: italic; +} +.s-blockquote blockquote p { + margin: 0; + white-space: pre-wrap; +} +.s-blockquote blockquote p::before { content: '“'; } +.s-blockquote blockquote p::after { content: '”'; } + +.s-blockquote figcaption { + margin-top: 0.3em; + + font-size: 1em; +} +.s-blockquote figcaption::before { + content: "— "; +} + +.s-blockquote cite { + font-style: normal; /* overwrite browser */ +} diff --git a/taccsite_cms/static/site_cms/css/src/site.css b/taccsite_cms/static/site_cms/css/src/site.css index d63d8c511..886cf15e7 100644 --- a/taccsite_cms/static/site_cms/css/src/site.css +++ b/taccsite_cms/static/site_cms/css/src/site.css @@ -21,10 +21,14 @@ /* Load custom element styles within custom element, not here */ /* OBJECTS */ -@import url("_imports/objects/o-site.css"); +@import url("_imports/objects/o-offset-content.css"); @import url("_imports/objects/o-section.css"); +@import url("_imports/objects/o-site.css"); /* COMPONENTS */ +@import url("_imports/components/c-article-list.css"); +@import url("_imports/components/c-article-preview.css"); +@import url("_imports/components/c-date.css"); @import url("_imports/components/c-footer.css"); @import url("_imports/components/django.cms.css"); @import url("_imports/components/django.cms.blog.css"); @@ -32,5 +36,7 @@ @import url("_imports/components/bootstrap.container.css"); /* TRUMPS */ +@import url("_imports/trumps/s-article-list.css"); @import url("_imports/trumps/s-footer.css"); +@import url("_imports/trumps/s-blockquote.css"); @import url("_imports/trumps/u-empty.css"); diff --git a/taccsite_cms/templates/header.html b/taccsite_cms/templates/header.html index d02a41c53..195df8c41 100644 --- a/taccsite_cms/templates/header.html +++ b/taccsite_cms/templates/header.html @@ -23,7 +23,7 @@ {% include "nav_cms.html" with className="navbar-nav" %} {# NOTE: As of 2020-12, search is only available with a Portal #} - {% include "nav_search.html" with className="form-inline ml-auto" only %} + {% if settings.FEATURES.search != False %}{% include "nav_search.html" with className="form-inline ml-auto" only %}{% endif %} {% include "nav_portal.html" with className="navbar-nav" settings=settings only %} {% else %} {# FAQ: If template were included with `only`, then it would NOT render `show_menu` #} diff --git a/taccsite_custom b/taccsite_custom index 940a5a00d..564a2b151 160000 --- a/taccsite_custom +++ b/taccsite_custom @@ -1 +1 @@ -Subproject commit 940a5a00df3c4b4b275a60c7dd6bd35b38825eee +Subproject commit 564a2b15199365a81c98d403ccdc7627084fd0c4