-
- • {waitingCount} Files(s) Processing
- {`✓ ${importedCount} File(s) ${
- this.withPdf ? 'Generated' : 'Imported'
- }`}
- x {failedCount} File(s) Failed
-
-
-
+
+
+
{waitingCount} Files(s) Processing
+
{`${importedCount} File(s) ${this.withPdf ? 'Generated' : 'Imported'}`}
+
{failedCount} File(s) Failed
+
+
);
}
}
ImportStatus.propTypes = {
- jobs: PropTypes.object,
+ jobs: PropTypes.object.isRequired,
links: PropTypes.array,
- type: PropTypes.string,
- path: PropTypes.string,
+ type: PropTypes.string.isRequired,
+ polling_path: PropTypes.string.isRequired,
+ pollingPath: PropTypes.string.isRequired,
+ pollingInterval: PropTypes.number,
+ polling_interval: PropTypes.number,
with_pdf: PropTypes.bool,
+ withPdf: PropTypes.bool,
};
export default ImportStatus;
diff --git a/app/javascript/components/admin/Initializer.jsx b/app/javascript/components/admin/Initializer.jsx
index f048eb52..23206443 100644
--- a/app/javascript/components/admin/Initializer.jsx
+++ b/app/javascript/components/admin/Initializer.jsx
@@ -1,20 +1,47 @@
-const Initializer = {
- initializeResourcesForm: () => {
- const form = $('form#resource_form');
- if (!form.length) return;
+import $ from 'jquery';
+import CurriculumEditor from './curriculum/CurriculumEditor';
+import ImportStatus from './ImportStatus';
+import MultiSelectedOperation from './MultiSelectedOperation';
+import React from 'react';
+import ReactDOM from 'react-dom';
- const opr_desc = form.find('.resource_opr_description');
- form.find('#resource_curriculum_type').change(ev => {
- const el = $(ev.target);
- if (el.val() === 'unit') {
- opr_desc.slideDown();
- } else {
- opr_desc.slideUp();
- }
+class Initializer {
+ static initialize() {
+ // Mount internal components
+ Initializer.#initializeCurriculumEditor();
+ Initializer.#InitializeImportStatus();
+ Initializer.#initializeMultiSelectedOperation();
+
+ // Initialize simple HTML objects
+ Initializer.#initializeResourcesList();
+ Initializer.#initializeSelectAll();
+ }
+
+ static #initializeCurriculumEditor() {
+ document.querySelectorAll('[id="#lcms-engine-CurriculumEditor"]').forEach(e => {
+ const props = JSON.parse(e.dataset.content);
+ e.removeAttribute('data-content');
+ ReactDOM.render(
, e);
+ });
+ }
+
+ static #InitializeImportStatus() {
+ document.querySelectorAll('[id="#lcms-engine-ImportStatus"]').forEach(e => {
+ const props = JSON.parse(e.dataset.content);
+ e.removeAttribute('data-content');
+ ReactDOM.render(
, e);
+ });
+ }
+
+ static #initializeMultiSelectedOperation() {
+ document.querySelectorAll('[id="#lcms-engine-MultiSelectedOperation"]').forEach(e => {
+ const props = JSON.parse(e.dataset.content);
+ e.removeAttribute('data-content');
+ ReactDOM.render(
, e);
});
- },
+ }
- initializeResourcesList: () => {
+ static #initializeResourcesList() {
const page = $('.o-adm-list.o-adm-documents');
if (!page.length) return;
@@ -22,9 +49,9 @@ const Initializer = {
const value = $(this).prop('checked') ? 1 : 0;
page.find('.c-reimport-doc-form .c-reimport-with-materials__field').val(value);
});
- },
+ }
- initializeSelectAll: () => {
+ static #initializeSelectAll() {
const selector = $('.c-multi-selected--select-all');
if (!selector.length) return;
@@ -33,7 +60,7 @@ const Initializer = {
const checked = el.prop('checked');
$('.table input[type=checkbox][name="selected_ids[]"]').prop('checked', checked);
});
- },
-};
+ }
+}
export default Initializer;
diff --git a/app/javascript/components/admin/MultiSelectedOperation.jsx b/app/javascript/components/admin/MultiSelectedOperation.jsx
index f82a1be7..026fdb06 100644
--- a/app/javascript/components/admin/MultiSelectedOperation.jsx
+++ b/app/javascript/components/admin/MultiSelectedOperation.jsx
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
@@ -13,13 +14,13 @@ class MultiSelectedOperation extends React.Component {
componentDidMount() {
// eslint-disable-next-line react/no-find-dom-node
const $this = $(ReactDOM.findDOMNode(this));
- $this.parent().addClass('c-multi-selected-btn');
+ $this.parent().addClass(`c-multi-selected-btn ${this.props.wrapperClass}`);
}
onSubmit(evt) {
if (this.props.operation === 'delete' && !confirm('Are you sure?')) return; // eslint-disable-line no-restricted-globals
- const entries = $('.o-page .table input[name="selected_ids[]"]');
+ const entries = $('.table input[name="selected_ids[]"]');
const ids = _.filter(entries, e => e.checked).map(e => e.value);
if (ids.length === 0) return evt.preventDefault();
@@ -29,7 +30,7 @@ class MultiSelectedOperation extends React.Component {
}
render() {
- const btnClass = `button ${this.props.btn_style}`;
+ const btnClass = `btn ${this.props.btnStyle}`;
const method = this.props.operation === 'delete' ? 'delete' : 'post';
const csrf_token = $('meta[name=csrf-token]').attr('content');
return (
@@ -42,6 +43,7 @@ class MultiSelectedOperation extends React.Component {
method="post"
className="c-reimport-doc-form"
onSubmit={this.onSubmit}
+ data-turbo={false}
>
@@ -56,7 +58,8 @@ class MultiSelectedOperation extends React.Component {
MultiSelectedOperation.propTypes = {
operation: PropTypes.string,
- btn_style: PropTypes.string,
+ btnStyle: PropTypes.string,
+ wrapperClass: PropTypes.string,
path: PropTypes.string,
text: PropTypes.string,
};
diff --git a/app/javascript/components/admin/association-picker/AssociationPicker.jsx b/app/javascript/components/admin/association-picker/AssociationPicker.jsx
deleted file mode 100644
index 8f7a7851..00000000
--- a/app/javascript/components/admin/association-picker/AssociationPicker.jsx
+++ /dev/null
@@ -1,125 +0,0 @@
-import React from 'react';
-import ReactDOM from 'react-dom';
-import PropTypes from 'prop-types';
-import _ from 'lodash';
-// eslint-disable-next-line no-unused-vars
-import AssociationPickerItem from './AssociationPickerItem';
-import AssociationPickerWindow from './AssociationPickerWindow';
-// eslint-disable-next-line no-unused-vars
-import PickerButton from '../picker/PickerButton';
-import pickerModal from '../picker/pickerModal';
-import pickerWindowWrapper from '../picker/pickerWindowWrapper';
-import { Foundation } from 'foundation-sites';
-import $ from 'jquery';
-
-class AssociationPicker extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = {
- items: this.props.items || [],
- };
- }
-
- get jqmodal() {
- return $(this.modal);
- }
-
- componentDidMount() {
- Foundation.addToJquery($);
- // eslint-disable-next-line no-undef
- pickerModal.call(this);
- }
-
- onClickSelect() {
- // eslint-disable-next-line no-undef
- const pickerComponent = pickerWindowWrapper(AssociationPickerWindow, 'lcms_engine_admin_association_picker_path');
- const picker = React.createElement(
- pickerComponent,
- {
- association: this.props.association,
- allowCreate: this.props.allow_create,
- allowMultiple: this.props.allow_multiple,
- onClickDone: this.closeModal.bind(this),
- onSelectItem: this.selectItem.bind(this),
- selectedItems: this.state.items,
- },
- null
- );
- ReactDOM.render(picker, this.modal);
- this.jqmodal.foundation('open');
- }
-
- selectItem(item, operation) {
- if (!this.props.allow_multiple) {
- this.closeModal();
- }
- operation === 'added' ? this.addItem(item) : this.removeItem(item);
- }
-
- closeModal() {
- this.jqmodal.foundation('close');
- }
-
- addItem(item) {
- const newItems = this.props.allow_multiple ? [...this.state.items, item] : [item];
-
- this.setState({
- ...this.state,
- items: newItems,
- });
- }
-
- removeItem(item) {
- this.setState({
- ...this.state,
- items: _.filter(this.state.items, r => r.id !== item.id),
- });
- }
-
- render() {
- const items = this.state.items.map(item => {
- return (
-
this.removeItem(item)}
- />
- );
- });
-
- const blankInput = this.props.allow_multiple ? (
-
- ) : (
-
- );
-
- return (
- (this.modal = m)}
- />
- );
- }
-}
-
-AssociationPicker.propTypes = {
- item: PropTypes.object,
- items: PropTypes.array,
- association: PropTypes.string,
- create_name: PropTypes.string,
- allow_create: PropTypes.bool,
- allow_multiple: PropTypes.bool,
- name: PropTypes.string,
-};
-
-export default AssociationPicker;
diff --git a/app/javascript/components/admin/association-picker/AssociationPickerItem.jsx b/app/javascript/components/admin/association-picker/AssociationPickerItem.jsx
deleted file mode 100644
index fe6f7a36..00000000
--- a/app/javascript/components/admin/association-picker/AssociationPickerItem.jsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-// eslint-disable-next-line no-unused-vars
-function AssociationPickerItem(props) {
- let input;
-
- if (props.item._create) {
- input = ;
- } else if (props.allowMultiple) {
- input = ;
- } else {
- input = ;
- }
-
- return (
-
- {input}
-
- {props.item.name}
-
- ×
-
-
-
- );
-}
-
-AssociationPickerItem.propTypes = {
- item: PropTypes.object,
- createName: PropTypes.string,
- allowMultiple: PropTypes.bool,
- name: PropTypes.string,
- onClickClose: PropTypes.func,
-};
-
-export default AssociationPickerItem;
diff --git a/app/javascript/components/admin/association-picker/AssociationPickerResults.jsx b/app/javascript/components/admin/association-picker/AssociationPickerResults.jsx
deleted file mode 100644
index 5023d376..00000000
--- a/app/javascript/components/admin/association-picker/AssociationPickerResults.jsx
+++ /dev/null
@@ -1,74 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import _ from 'lodash';
-
-function AssociationPickerResults(props) {
- let items;
-
- const shouldAllowCreate =
- _.isString(props.value) && props.value.length > 0 && props.allowCreate && props.items.length === 0;
-
- const selectedIds = _.map(props.selectedItems, 'id');
- const isSelected = item => {
- return _.includes(selectedIds, item.id);
- };
-
- if (shouldAllowCreate) {
- let newItem = { id: props.value, name: props.value, _create: true };
- items = [
- /* eslint-disable react/jsx-no-bind */
-
- | props.onSelectItem(newItem)}>
- {props.value}
- (Create)
- |
-
,
- /* eslint-enable react/jsx-no-bind */
- ];
- } else {
- items = props.items.map(item => {
- let newItem = {
- id: item.id,
- name: item.name,
- _create: false,
- _selected: isSelected(item),
- };
- return (
- /* eslint-disable react/jsx-no-bind */
-
- | props.onSelectItem(newItem)}>{newItem.name} |
-
- /* eslint-enable react/jsx-no-bind */
- );
- });
- }
-
- return (
-
-
-
- | Name |
-
-
-
- {items.length ? (
- items
- ) : (
-
- | Nothing to select |
-
- )}
-
-
- );
-}
-
-AssociationPickerResults.propTypes = {
- value: PropTypes.string,
- allowCreate: PropTypes.bool,
- items: PropTypes.array,
- selectedItems: PropTypes.array,
- onSelectItem: PropTypes.func,
-};
-
-export default AssociationPickerResults;
diff --git a/app/javascript/components/admin/association-picker/AssociationPickerWindow.jsx b/app/javascript/components/admin/association-picker/AssociationPickerWindow.jsx
deleted file mode 100644
index 1452b3e9..00000000
--- a/app/javascript/components/admin/association-picker/AssociationPickerWindow.jsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import React from 'react';
-import _ from 'lodash';
-import PropTypes from 'prop-types';
-import AssociationPickerResults from './AssociationPickerResults';
-
-class AssociationPickerWindow extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = { ...props, selectedItems: [] };
-
- this.selectItem = this.selectItem.bind(this);
- }
-
- selectItem(item) {
- const operation = this.updateSelectedItems(item);
- if ('onSelectItem' in this.props) {
- this.props.onSelectItem(item, operation);
- }
- }
-
- updateSelectedItems(item) {
- let operation, newItems;
- if (item._selected) {
- newItems = _.filter(this.state.selectedItems, r => r.id !== item.id);
- operation = 'removed';
- } else {
- newItems = [...this.state.selectedItems, item];
- operation = 'added';
- }
- this.setState(...this.state, { selectedItems: newItems });
- return operation;
- }
-
- render() {
- const { q, results } = this.props;
-
- return (
-
-
-
-
Select item
-
-
-
-
-
-
-
-
-
-
- {this.props.pagination()}
-
- {this.props.allowMultiple ? (
-
- ) : null}
-
-
-
- );
- }
-}
-
-AssociationPickerWindow.propTypes = {
- onSelectItem: PropTypes.func,
- q: PropTypes.string,
- results: PropTypes.array,
- onFilterChange: PropTypes.func,
- allowCreate: PropTypes.bool,
- pagination: PropTypes.func,
- allowMultiple: PropTypes.bool,
- onClickDone: PropTypes.func,
-};
-
-export default AssociationPickerWindow;
diff --git a/app/javascript/components/admin/curriculum/CurriculumEditor.jsx b/app/javascript/components/admin/curriculum/CurriculumEditor.jsx
index 8cddce1d..8ce7e6c4 100644
--- a/app/javascript/components/admin/curriculum/CurriculumEditor.jsx
+++ b/app/javascript/components/admin/curriculum/CurriculumEditor.jsx
@@ -126,7 +126,7 @@ class CurriculumEditor extends React.Component {
-
+
(Click on a node with the right button to add/edit/remove)
diff --git a/app/javascript/components/admin/curriculum/DirectoryPicker.jsx b/app/javascript/components/admin/curriculum/DirectoryPicker.jsx
deleted file mode 100644
index 4e5a71fb..00000000
--- a/app/javascript/components/admin/curriculum/DirectoryPicker.jsx
+++ /dev/null
@@ -1,125 +0,0 @@
-import React from 'react';
-import ReactDOM from 'react-dom';
-import PropTypes from 'prop-types';
-import { Foundation } from 'foundation-sites';
-import TagsInput from 'react-tagsinput';
-import $ from 'jquery';
-import '../../../vendor/jstree/jstree.min';
-
-class DirectoryPicker extends React.Component {
- constructor(props) {
- super(props);
- this.state = {
- directory: props.directory,
- parent: props.parent,
- };
-
- this.onClick = this.onClick.bind(this);
- this.handleDirChange = this.handleDirChange.bind(this);
- }
-
- componentDidMount() {
- // eslint-disable-next-line react/no-find-dom-node
- const $this = $(ReactDOM.findDOMNode(this));
- $this.parent().addClass('o-curriculum-tree-picker__container');
-
- const editor = $this.find('#curriculum-tree-picker');
- editor.on('changed.jstree', this.onChanged.bind(this)).jstree({
- core: {
- animation: 0,
- themes: { dots: true },
- check_callback: true,
- data: {
- url: this.props.path,
- data: node => {
- return { id: node.id };
- },
- },
- },
- plugins: ['wholerow', 'changed'],
- });
- this.jsTree = editor.data('jstree');
-
- Foundation.addToJquery($);
- this.jqmodal = $this.find('#curriculum-picker-modal');
- new Foundation.Reveal(this.jqmodal, null);
- }
-
- closeModal() {
- this.jqmodal.foundation('close');
- }
-
- onChanged(_e, data) {
- const dir = this.directory(data.node);
- const parent = {
- id: data.node.id,
- title: data.node.li_attr.title,
- directory: dir,
- };
- this.setState({ directory: dir, parent: parent });
- this.closeModal();
- }
-
- onClick(e) {
- e.preventDefault();
- this.jqmodal.foundation('open');
- }
-
- directory(node) {
- return node.parents
- .map(el => this.jsTree.get_node(el, null).text)
- .reverse()
- .slice(1)
- .concat(node.text);
- }
-
- handleDirChange(tags) {
- this.setState({ directory: tags });
- }
-
- render() {
- const curr = this.state.parent.directory;
- const parent_aside = curr.length > 0 ? `(${curr.join(' | ')}) : ` : '';
- return (
-
-
-
-
- {/* eslint-disable jsx-a11y/anchor-is-valid */}
-
- Select Parent
-
- {/* eslint-enable jsx-a11y/anchor-is-valid */}
-
-
{this.state.parent.title}
-
-
-
-
-
-
-
-
-
Select a Parent Resource
-
-
-
- );
- }
-}
-
-DirectoryPicker.propTypes = {
- tree: PropTypes.array,
- directory: PropTypes.array,
- parent: PropTypes.object,
- path: PropTypes.string,
-};
-
-export default DirectoryPicker;
diff --git a/app/javascript/components/admin/picker/PickerButton.jsx b/app/javascript/components/admin/picker/PickerButton.jsx
deleted file mode 100644
index 22ee71ad..00000000
--- a/app/javascript/components/admin/picker/PickerButton.jsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ResourcePicker from '../resource-picker/ResourcePicker'; // eslint-disable-line no-unused-vars
-
-function PickerButton(props) {
- return (
-
-
-
- {props.hiddenInputs}
- {props.content}
-
-
-
- );
-}
-
-PickerButton.propTypes = {
- onClick: PropTypes.func,
- hiddenInputs: PropTypes.node,
- content: PropTypes.array,
- onRef: PropTypes.func,
-};
-
-export default PickerButton;
diff --git a/app/javascript/components/admin/picker/pickerModal.jsx b/app/javascript/components/admin/picker/pickerModal.jsx
deleted file mode 100644
index a7d07ebb..00000000
--- a/app/javascript/components/admin/picker/pickerModal.jsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import ReactDOM from 'react-dom';
-
-function pickerModal() {
- new Foundation.Reveal(this.jqmodal, null);
- this.jqmodal.on('open.zf.reveal', () => this.jqmodal.css({ top: '15px' }));
- this.jqmodal.on('closed.zf.reveal', () => {
- ReactDOM.unmountComponentAtNode(this.modal);
- });
-}
-
-export default pickerModal;
diff --git a/app/javascript/components/admin/picker/pickerWindowWrapper.jsx b/app/javascript/components/admin/picker/pickerWindowWrapper.jsx
deleted file mode 100644
index 87bfa033..00000000
--- a/app/javascript/components/admin/picker/pickerWindowWrapper.jsx
+++ /dev/null
@@ -1,108 +0,0 @@
-import React from 'react';
-import PaginationBoxView from '../../paginate/PaginationBoxView';
-
-// eslint-disable-next-line no-unused-vars
-function pickerWindowWrapper(WrappedComponent, path) {
- // eslint-disable-next-line react/display-name
- return class extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = {
- results: [],
- pagination: {
- current_page: 1,
- total_pages: 0,
- },
- q: null,
- };
- }
-
- componentDidMount() {
- this.fetch();
- }
-
- fetch() {
- const data = {
- page: this.state.pagination.current_page,
- q: this.state.q,
- };
- const url = Routes[path].call(this, {
- ...data,
- ...this.state,
- ...this.props,
- });
- $.getJSON(url).then(x => this.setState({ ...x }));
- }
-
- onFilterChange(field, event) {
- this.setState({ [field]: event.target.value }, this.fetch);
- }
-
- pageClick(data) {
- const selected = data.selected;
- this.setState(
- {
- ...this.state,
- pagination: {
- ...this.state.pagination,
- current_page: selected + 1,
- },
- },
- this.fetch
- );
- }
-
- pagination() {
- const breakLabel = (
-
- {/*
- eslint-disable jsx-a11y/anchor-is-valid, no-script-url
- */}
- ...
- {/*
- eslint-enable jsx-a11y/anchor-is-valid, no-script-url
- */}
-
- );
- return (
- '}
- breakLabel={breakLabel}
- pageNum={this.state.pagination.total_pages}
- initialSelected={this.state.pagination.current_page - 1}
- forceSelected={this.state.pagination.current_page - 1}
- marginPagesDisplayed={2}
- pageRangeDisplayed={5}
- // eslint-disable-next-line react/jsx-no-bind
- clickCallback={this.pageClick.bind(this)}
- containerClassName={'o-pagination o-page__wrap--row-nest'}
- itemClassName={'o-pagination__item'}
- nextClassName={'o-pagination__item--next'}
- previousClassName={'o-pagination__item--prev'}
- pagesClassName={'o-pagination__item--middle'}
- subContainerClassName={'o-pagination__pages'}
- activeClassName={'o-pagination__page--active'}
- />
- );
- }
-
- render() {
- return (
-
-
-
- );
- }
- };
-}
-
-export default pickerWindowWrapper;
diff --git a/app/javascript/components/admin/resource-picker/ResourcePicker.jsx b/app/javascript/components/admin/resource-picker/ResourcePicker.jsx
deleted file mode 100644
index 0a9ac91f..00000000
--- a/app/javascript/components/admin/resource-picker/ResourcePicker.jsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import React from 'react';
-import ReactDOM from 'react-dom';
-import PropTypes from 'prop-types';
-import _ from 'lodash';
-import ResourcePickerWindow from './ResourcePickerWindow';
-import ResourcePickerResource from './ResourcePickerResource';
-import PickerButton from '../picker/PickerButton';
-import pickerWindowWrapper from '../picker/pickerWindowWrapper';
-import pickerModal from '../picker/pickerModal';
-import { Foundation } from 'foundation-sites';
-import $ from 'jquery';
-
-class ResourcePicker extends React.Component {
- constructor(props) {
- super(props);
-
- const resources = 'resources' in props ? props.resources : [];
-
- this.state = {
- resources: resources,
- };
- }
-
- get jqmodal() {
- return $(this.modal);
- }
-
- get allowMultiple() {
- if (_.isUndefined(this.props.allow_multiple) || this.props.allow_multiple === null) {
- return true;
- }
- return this.props.allow_multiple;
- }
-
- componentDidMount() {
- Foundation.addToJquery($);
- // eslint-disable-next-line no-undef
- pickerModal.call(this);
- }
-
- onClickSelect() {
- // eslint-disable-next-line no-undef
- const pickerComponent = pickerWindowWrapper(ResourcePickerWindow, 'lcms_engine_admin_resource_picker_path');
- const picker = React.createElement(
- pickerComponent,
- {
- onSelectResource: this.selectResource.bind(this),
- },
- null
- );
- ReactDOM.render(picker, this.modal);
- this.jqmodal.foundation('open');
- }
-
- selectResource(resource) {
- this.jqmodal.foundation('close');
-
- const newResources = this.allowMultiple ? [...this.state.resources, resource] : [resource];
-
- this.setState({
- ...this.state,
- resources: newResources,
- });
- }
-
- removeResource(resource) {
- this.setState({
- ...this.state,
- resources: _.filter(this.state.resources, r => r.id !== resource.id),
- });
- }
-
- render() {
- const resources = this.state.resources.map(resource => {
- return (
- this.removeResource(resource)}
- />
- );
- });
-
- const input = ;
-
- return (
- (this.modal = m)}
- />
- );
- }
-}
-
-ResourcePicker.propTypes = {
- name: PropTypes.string,
- resources: PropTypes.array,
- allow_multiple: PropTypes.bool,
-};
-
-export default ResourcePicker;
diff --git a/app/javascript/components/admin/resource-picker/ResourcePickerResource.jsx b/app/javascript/components/admin/resource-picker/ResourcePickerResource.jsx
deleted file mode 100644
index c5a134c0..00000000
--- a/app/javascript/components/admin/resource-picker/ResourcePickerResource.jsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-function ResourcePickerResource(props) {
- return (
-
-
-
- {props.resource.title}
-
- ×
-
-
-
- );
-}
-
-ResourcePickerResource.propTypes = {
- name: PropTypes.string,
- resource: PropTypes.object,
- onClickClose: PropTypes.func,
-};
-
-export default ResourcePickerResource;
diff --git a/app/javascript/components/admin/resource-picker/ResourcePickerWindow.jsx b/app/javascript/components/admin/resource-picker/ResourcePickerWindow.jsx
deleted file mode 100644
index a415948f..00000000
--- a/app/javascript/components/admin/resource-picker/ResourcePickerWindow.jsx
+++ /dev/null
@@ -1,144 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-class ResourcePickerWindow extends React.Component {
- constructor(props) {
- super(props);
-
- this.typeOptions = [
- ['subject', 'subject'],
- ['grade', 'grade'],
- ['module', 'module'],
- ['unit', 'unit'],
- ];
-
- this.subjectOptions = [
- ['ela', 'ELA'],
- ['math', 'Math'],
- ];
-
- this.gradeOptions = [
- ['pk', 'prekindergarten'],
- ['k', 'kindergarten'],
- ['1', 'grade 1'],
- ['2', 'grade 2'],
- ['3', 'grade 3'],
- ['4', 'grade 4'],
- ['5', 'grade 5'],
- ['6', 'grade 6'],
- ['7', 'grade 7'],
- ['8', 'grade 8'],
- ['9', 'grade 9'],
- ['10', 'grade 10'],
- ['11', 'grade 11'],
- ['12', 'grade 12'],
- ];
-
- const initialState = {
- pagination: {
- current_page: 1,
- total_pages: 0,
- },
- results: [],
- type: null,
- subject: null,
- grade: null,
- q: null,
- };
-
- this.state = { ...initialState, ...props };
- }
-
- filterElement(title, value, type, data) {
- return (
-
- );
- }
-
- selectResource(resource) {
- if ('onSelectResource' in this.props) {
- this.props.onSelectResource(resource);
- }
- }
-
- render() {
- const { grade, q, subject, type } = this.props;
-
- /* eslint-disable jsx-a11y/anchor-is-valid */
- return (
-
-
-
-
Select resource
-
- {this.filterElement('Curriculum Type', type, 'type', this.typeOptions)}
- {this.filterElement('Subject', subject, 'subject', this.subjectOptions)}
- {this.filterElement('Grade', grade, 'grade', this.gradeOptions)}
-
-
-
-
-
-
-
-
-
-
-
- | Title |
-
-
-
- {this.props.results.map(resource => (
-
- {/*
- eslint-disable react/jsx-no-bind
- */}
- | {resource.title} |
-
- ))}
-
-
-
- {this.props.pagination()}
-
-
-
- );
- }
-}
-
-ResourcePickerWindow.propTypes = {
- onFilterChange: PropTypes.func,
- onSelectResource: PropTypes.func,
- grade: PropTypes.string,
- q: PropTypes.string,
- subject: PropTypes.string,
- type: PropTypes.string,
- results: PropTypes.array,
- pagination: PropTypes.func,
-};
-
-export default ResourcePickerWindow;
diff --git a/app/javascript/packs/lcms_engine_admin.js b/app/javascript/packs/lcms_engine_admin.js
deleted file mode 100644
index ff64d1fc..00000000
--- a/app/javascript/packs/lcms_engine_admin.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import Initializer from '../components/admin/Initializer';
-
-document.addEventListener('turbolinks:load', () => {
- Initializer.initializeResourcesForm();
- Initializer.initializeResourcesList();
- Initializer.initializeSelectAll();
-});
-
-// Support component names relative to this directory:
-// eslint-disable-next-line no-undef
-const componentRequireContext = require.context('components', true);
-// eslint-disable-next-line no-undef
-const ReactRailsUJS = require('react_ujs');
-// eslint-disable-next-line react-hooks/rules-of-hooks
-ReactRailsUJS.useContext(componentRequireContext);
diff --git a/app/javascript/packs/lcms_engine_application.js b/app/javascript/packs/lcms_engine_application.js
deleted file mode 100644
index 6bbded4e..00000000
--- a/app/javascript/packs/lcms_engine_application.js
+++ /dev/null
@@ -1,25 +0,0 @@
-/* eslint no-console:0 */
-// This file is automatically compiled by Webpack, along with any other files
-// present in this directory. You're encouraged to place your actual application logic in
-// a relevant structure within app/javascript and only use these pack files to reference
-// that code so it'll be compiled.
-//
-// To reference this file, add <%= javascript_pack_tag 'application' %> to the appropriate
-// layout file, like app/views/layouts/application.html.erb
-
-// Uncomment to copy all static images under ../images to the output folder and reference
-// them with the image_pack_tag helper in views (e.g <%= image_pack_tag 'rails.png' %>)
-// or the `imagePath` JavaScript helper below.
-//
-// const images = require.context('../images', true)
-// const imagePath = (name) => images(name, true)
-
-// Support component names relative to this directory:
-// eslint-disable-next-line no-undef
-const componentRequireContext = require.context('components', true);
-// eslint-disable-next-line no-undef
-const ReactRailsUJS = require('react_ujs');
-// eslint-disable-next-line react-hooks/rules-of-hooks
-ReactRailsUJS.useContext(componentRequireContext);
-
-console.debug('Hello World from Webpacker FROM INSIDE `lcms-engine` gem!');
diff --git a/app/javascript/packs/server_rendering.js b/app/javascript/packs/server_rendering.js
deleted file mode 100644
index e4a90f8f..00000000
--- a/app/javascript/packs/server_rendering.js
+++ /dev/null
@@ -1,6 +0,0 @@
-// By default, this pack is loaded for server-side rendering.
-// It must expose react_ujs as `ReactRailsUJS` and prepare a require context.
-const componentRequireContext = require.context('components', true);
-const ReactRailsUJS = require('react_ujs');
-// eslint-disable-next-line react-hooks/rules-of-hooks
-ReactRailsUJS.useContext(componentRequireContext);
diff --git a/vendor/assets/javascripts/html.sortable.min.js b/app/javascript/vendor/html.sortable.min.js
similarity index 100%
rename from vendor/assets/javascripts/html.sortable.min.js
rename to app/javascript/vendor/html.sortable.min.js
diff --git a/app/jobs/concerns/lcms/engine/nested_resque_job.rb b/app/jobs/concerns/lcms/engine/nested_resque_job.rb
index dc65c843..537df961 100644
--- a/app/jobs/concerns/lcms/engine/nested_resque_job.rb
+++ b/app/jobs/concerns/lcms/engine/nested_resque_job.rb
@@ -6,7 +6,7 @@ module NestedResqueJob
extend ActiveSupport::Concern
class_methods do # rubocop:disable Metrics/BlockLength
- def queued_or_running_nested?(job_id, current_job_id = -1)
+ def queued_or_running_nested?(job_id, current_job_id = '-1')
check_child = ->(j) { j['arguments'][1]&.dig('initial_job_id') == job_id && j['job_id'] != current_job_id }
job_klasses = self::NESTED_JOBS + [name]
job_klasses.each do |job_klass|
@@ -25,6 +25,19 @@ def status_nested(jid)
:done
end
+ # Fetches the results of nested jobs in Resque.
+ #
+ # This method iterates over each job class in the `NESTED_JOBS` constant,
+ # constructs a Redis key pattern for the nested job results, and retrieves
+ # the value from Redis. It attempts to parse the value as JSON and adds it
+ # to the result array. If the parsing fails, it adds the original value
+ # to the result array instead.
+ #
+ # @param jid [String] The job ID of the parent job.
+ # @return [Array] An array containing the results of all nested jobs of the parent job.
+ #
+ # @example
+ # fetch_result_nested('1234')
def fetch_result_nested(jid)
[].tap do |result|
self::NESTED_JOBS.each do |job_klass|
diff --git a/app/jobs/lcms/engine/document_generate_gdoc_job.rb b/app/jobs/lcms/engine/document_generate_gdoc_job.rb
index 6321a73d..020d1280 100644
--- a/app/jobs/lcms/engine/document_generate_gdoc_job.rb
+++ b/app/jobs/lcms/engine/document_generate_gdoc_job.rb
@@ -20,7 +20,7 @@ class DocumentGenerateGdocJob < Lcms::Engine::ApplicationJob
def perform(document, options)
content_type = options[:content_type]
- document = DocumentGenerator.document_presenter.new document.reload, content_type: content_type
+ document = DocumentGenerator.document_presenter.new(document.reload, content_type:)
gdoc = GDOC_EXPORTERS[content_type].new(document, options).export
key = options[:excludes].present? ? options[:gdoc_folder] : document.gdoc_key
diff --git a/app/jobs/lcms/engine/document_generate_pdf_job.rb b/app/jobs/lcms/engine/document_generate_pdf_job.rb
index 1c230d0d..c20fa71d 100644
--- a/app/jobs/lcms/engine/document_generate_pdf_job.rb
+++ b/app/jobs/lcms/engine/document_generate_pdf_job.rb
@@ -16,7 +16,7 @@ class DocumentGeneratePdfJob < Lcms::Engine::ApplicationJob
def perform(doc, options)
content_type = options[:content_type]
- document = DocumentGenerator.document_presenter.new doc.reload, content_type: content_type
+ document = DocumentGenerator.document_presenter.new(doc.reload, content_type:)
filename = options[:filename].presence || "#{::DocumentExporter::Pdf::Base.s3_folder}/#{document.pdf_filename}"
pdf = PDF_EXPORTERS[content_type].new(document, options).export
url = S3Service.upload filename, pdf
diff --git a/app/jobs/lcms/engine/document_parse_job.rb b/app/jobs/lcms/engine/document_parse_job.rb
index 935c77a5..10161366 100644
--- a/app/jobs/lcms/engine/document_parse_job.rb
+++ b/app/jobs/lcms/engine/document_parse_job.rb
@@ -10,41 +10,63 @@ class DocumentParseJob < Lcms::Engine::ApplicationJob
queue_as :default
- def perform(entry, options = {})
- if entry.is_a?(Document)
- @document = entry
- reimport_materials if options[:reimport_materials].present?
- reimport_document(@document.file_url) if result.nil?
+ #
+ # Options can have:
+ # - reimport_materials - require materials re-import.
+ # If such option is passed, then at first try to import
+ # connected materials. If all is good - import document itself.
+ # If there were errors with materials - do not import document.
+ #
+ # @param [Integer|String] id_or_url
+ # @param [Hash] options
+ #
+ def perform(id_or_url, options = {})
+ @options = options
- @document.update(reimported: false) unless result[:ok]
+ if id_or_url.is_a?(String)
+ reimport_document(id_or_url)
else
- reimport_document entry
+ @options.merge!(is_reimport: true)
+ reimport_by_id(id_or_url)
+ @document.update(reimported: false) unless result[:ok]
end
- store_result result, options
+ store_result(result, options)
+ rescue StandardError => e
+ res = { ok: false, link: id_or_url, errors: [e.message] }
+ store_result(res, options)
end
private
- attr_reader :document, :result
+ attr_reader :document, :options, :result
+
+ #
+ # @param [Integer] id
+ #
+ def reimport_by_id(id)
+ @document = Lcms::Engine::Document.find(id)
+ reimport_materials if options[:reimport_materials].present?
+ reimport_document(@document.file_url)
+ end
def reimport_document(link)
- form = DocumentForm.new({ link: link }, import_retry: true)
+ form = DocumentForm.new({ link: }, options.merge(import_retry: true))
@result = if form.save
- { ok: true, link: link, model: form.document, warnings: form.service_errors }
+ { ok: true, link:, model: form.document, warnings: form.service_errors }
else
- { ok: false, link: link, errors: form.errors[:link] }
+ { ok: false, link:, errors: form.errors[:link] }
end
end
def reimport_materials
document.materials.each do |material|
link = material.file_url
- form = MaterialForm.new({ link: link, source_type: material.source_type }, import_retry: true)
+ form = MaterialForm.new({ link:, source_type: material.source_type }, import_retry: true)
next if form.save
error_msg = %(Material error (source): #{form.errors[:link]})
- @result = { ok: false, link: link, errors: [error_msg] }
+ @result = { ok: false, link:, errors: [error_msg] }
break
end
end
diff --git a/app/jobs/lcms/engine/integrations/webhook_call_job.rb b/app/jobs/lcms/engine/integrations/webhook_call_job.rb
new file mode 100644
index 00000000..fff434ba
--- /dev/null
+++ b/app/jobs/lcms/engine/integrations/webhook_call_job.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Lcms
+ module Engine
+ module Integrations
+ class WebhookCallJob < Lcms::Engine::ApplicationJob
+ extend ResqueJob
+ include Lcms::Engine::RetryDelayed
+
+ queue_as :low
+
+ def perform(config_id, payload)
+ webhook_configuration = WebhookConfiguration.find(config_id)
+
+ webhook_configuration.execute_call(payload)
+ end
+ end
+ end
+ end
+end
diff --git a/app/jobs/lcms/engine/material_parse_job.rb b/app/jobs/lcms/engine/material_parse_job.rb
index e17beb8c..99cc50e1 100644
--- a/app/jobs/lcms/engine/material_parse_job.rb
+++ b/app/jobs/lcms/engine/material_parse_job.rb
@@ -9,24 +9,27 @@ class MaterialParseJob < Lcms::Engine::ApplicationJob
queue_as :default
- def perform(entry, options = {})
- attrs = attributes_for entry
- form = MaterialForm.new(attrs, import_retry: true)
+ #
+ # @param [Integer|String] id_or_url
+ # @param [Hash] options
+ #
+ def perform(id_or_url, options = {})
+ url =
+ if id_or_url.is_a?(String)
+ id_or_url
+ else
+ Lcms::Engine::Material.find(id_or_url).file_url
+ end
+ form = MaterialForm.new({ link: url }, import_retry: true)
res = if form.save
- { ok: true, link: attrs[:link], model: form.material }
+ { ok: true, link: url, model: form.material }
else
- { ok: false, link: attrs[:link], errors: form.errors[:link] }
+ { ok: false, link: url, errors: form.errors[:link] }
end
- store_result res, options
- end
-
- private
-
- def attributes_for(entry)
- {}.tap do |data|
- data[:link] = entry.is_a?(Material) ? entry.file_url : entry
- data[:source_type] = entry.source_type if entry.is_a?(Material)
- end
+ store_result(res, options)
+ rescue StandardError => e
+ res = { ok: false, link: id_or_url, errors: [e.message] }
+ store_result(res, options)
end
end
end
diff --git a/app/lib/lcms/engine/api/auth_helper.rb b/app/lib/lcms/engine/api/auth_helper.rb
new file mode 100644
index 00000000..b876cdd6
--- /dev/null
+++ b/app/lib/lcms/engine/api/auth_helper.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Lcms
+ module Engine
+ module Api
+ module AuthHelper
+ def self.compute_hmac_signature(timestamp, path, body, secret_key)
+ data = "#{timestamp}#{path}#{body}"
+ OpenSSL::HMAC.hexdigest(
+ OpenSSL::Digest.new('sha256'),
+ secret_key,
+ data
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/app/middleware/remove_session.rb b/app/middleware/remove_session.rb
index c9d88a64..b7409806 100644
--- a/app/middleware/remove_session.rb
+++ b/app/middleware/remove_session.rb
@@ -16,11 +16,10 @@ def call(env)
# Don't delete the session cookie if:
# - We're in the process of logging in (breaks CSRF for sign in form)
# - We're logged in (needed for Devise)
- skip_delete = (
+ skip_delete =
path =~ %r{^/users} ||
user_key.present? ||
headers[SET_COOKIE].blank?
- )
signing_out = path == '/users/sign_out'
diff --git a/app/models/concerns/lcms/engine/filterable.rb b/app/models/concerns/lcms/engine/filterable.rb
new file mode 100644
index 00000000..64828772
--- /dev/null
+++ b/app/models/concerns/lcms/engine/filterable.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Lcms
+ module Engine
+ module Filterable
+ extend ActiveSupport::Concern
+
+ included do
+ scope :where_grade, ->(grades) { where_metadata_in :grade, grades }
+ scope :where_module, ->(modules) { where_metadata_in :module, modules }
+ scope :where_subject, ->(subjects) { where_metadata_in :subject, subjects }
+ end
+
+ class_methods do
+ #
+ # @param [String|Symbol] key
+ # @param [Array|Array] arr
+ # @return [ActiveRecord::QueryMethods::WhereChain]
+ #
+ def where_metadata_in(key, arr)
+ arr = Array.wrap(arr).compact.map(&:downcase)
+ base_table =
+ case name
+ when 'Lcms::Engine::Material'
+ 'materials'
+ when 'Lcms::Engine::Resource'
+ 'resources'
+ when 'Lcms::Engine::Document'
+ 'documents'
+ else
+ raise "Unknown table name: #{name}"
+ end
+ clauses = Array.new(arr.count) { "lower(#{base_table}.metadata->>'#{key}') = ?" }.join(' OR ')
+ where(clauses, *arr)
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/lcms/engine/navigable.rb b/app/models/concerns/lcms/engine/navigable.rb
deleted file mode 100644
index d7261e1c..00000000
--- a/app/models/concerns/lcms/engine/navigable.rb
+++ /dev/null
@@ -1,39 +0,0 @@
-# frozen_string_literal: true
-
-require 'active_support/concern'
-
-module Lcms
- module Engine
- module Navigable
- extend ActiveSupport::Concern
-
- included do
- def parents
- ancestors.reverse
- end
-
- def previous
- @previous ||=
- if level_position.to_i.positive?
- siblings.where(level_position: level_position - 1).first
- else
- # last element of previous node from parent level
- parent.try(:previous).try(:children).try(:last)
- end
- end
-
- def next
- @next ||=
- if level_position.nil?
- nil
- elsif level_position < siblings.size
- siblings.where(level_position: level_position + 1).first
- else
- # first element of next node from parent level
- parent.try(:next).try(:children).try(:first)
- end
- end
- end
- end
- end
-end
diff --git a/app/models/concerns/lcms/engine/searchable.rb b/app/models/concerns/lcms/engine/searchable.rb
deleted file mode 100644
index 3577a0a0..00000000
--- a/app/models/concerns/lcms/engine/searchable.rb
+++ /dev/null
@@ -1,60 +0,0 @@
-# frozen_string_literal: true
-
-require 'active_support/concern'
-
-module Lcms
- module Engine
- module Searchable
- extend ActiveSupport::Concern
-
- included do
- attr_accessor :skip_indexing
-
- after_commit :index_document, on: %i(create update), if: :should_index?
- after_commit :delete_document, on: :destroy, if: :should_index?
-
- def self.search(term, options = {})
- search_model.search term, options.merge!(model_type: name.underscore)
- end
-
- private
-
- def self.search_model
- @search_model ||= Search::Document
- end
-
- def delete_document
- search_repo.delete search_doc
- rescue Faraday::ConnectionFailed, Elasticsearch::Transport::Transport::Errors::NotFound => e
- Rails.logger.warn("index_document failed: #{e.message}")
- end
-
- def search_doc
- self.class.search_model.build_from self
- end
-
- def index_document
- doc = search_doc
- search_repo.save(doc) if doc.present?
- rescue Faraday::ConnectionFailed => e
- Rails.logger.warn("index_document failed: #{e.message}")
- end
-
- def search_repo
- @search_repo ||= Search::Repository.new
- end
-
- def should_index?
- !skip_indexing && search_repo.index_exists?
- end
-
- #
- # Explicitly skip indexing as we do not use it now
- #
- def skip_indexing
- true
- end
- end
- end
- end
-end
diff --git a/app/models/lcms/engine/document.rb b/app/models/lcms/engine/document.rb
index 91b50a6c..be9d433a 100644
--- a/app/models/lcms/engine/document.rb
+++ b/app/models/lcms/engine/document.rb
@@ -3,17 +3,20 @@
module Lcms
module Engine
class Document < ApplicationRecord
+ include Filterable
include Partable
+
GOOGLE_URL_PREFIX = 'https://docs.google.com/document/d'
belongs_to :resource, optional: true
has_many :document_parts, as: :renderer, dependent: :delete_all
has_and_belongs_to_many :materials
+ after_destroy :destroy_connected_resource
+
before_save :clean_curriculum_metadata
before_save :set_resource_from_metadata
- store_accessor :foundational_metadata
serialize :toc, DocTemplate::Objects::TocMetadata
scope :actives, -> { where(active: true) }
@@ -47,7 +50,7 @@ class Document < ApplicationRecord
(documents.metadata ->> 'subject' <> 'ela' AND documents.metadata ->> 'unit' = :mod)
OR (documents.metadata ->> 'subject' = 'ela' AND documents.metadata ->> 'module' = :mod)
SQL
- where(sql, mod: mod)
+ where(sql, mod:)
}
scope :with_broken_materials, lambda {
@@ -64,10 +67,10 @@ class Document < ApplicationRecord
def activate!
self.class.transaction do
- # deactive all other lessons for this resource
- self.class.where(resource_id: resource_id).where.not(id: id).update_all active: false
+ # de-active all other lessons for this resource
+ self.class.where(resource_id:).where.not(id:).update_all(active: false)
# activate this lesson. PS: use a simple sql update, no callbacks
- update_columns active: true
+ update_columns(active: true)
end
end
@@ -85,25 +88,15 @@ def file_url
"#{GOOGLE_URL_PREFIX}/#{file_id}"
end
- def file_fs_url
- return unless foundational_file_id.present?
-
- "#{GOOGLE_URL_PREFIX}/#{foundational_file_id}"
- end
-
- def foundational?
- metadata['type'].to_s.casecmp('fs').zero?
- end
-
def gdoc_material_ids
materials.gdoc.pluck(:id)
end
def materials_anchors
- {}.tap do |materials_with_anchors|
+ {}.tap do |materials_with_anchors| # steep:ignore
toc.collect_children.each do |x|
x.material_ids.each do |m|
- materials_with_anchors[m] ||= { optional: [], anchors: [] }
+ materials_with_anchors[m] ||= { optional: [], anchors: [] } # steep:ignore
materials_with_anchors[m][x.optional ? :optional : :anchors] << x.anchor
end
end
@@ -122,7 +115,7 @@ def tmp_link(key)
url = links[key]
with_lock do
reload.links.delete(key)
- update links: links
+ update links:
end
url
end
@@ -135,16 +128,16 @@ def clean_curriculum_metadata
# downcase subjects
metadata['subject'] = metadata['subject']&.downcase
- /(\d+)/.match(metadata['grade']) do |m|
- metadata['grade'] = "grade #{m[1].to_i}"
- end
-
# store only the lesson number
# or alphanumeric - needed by OPR type, see https://github.com/learningtapestry/unbounded/issues/557
lesson = metadata['lesson']
metadata['lesson'] = lesson.match(/lesson (\w+)/i).try(:[], 1) || lesson if lesson.present?
end
+ def destroy_connected_resource
+ resource&.destroy if active?
+ end
+
def set_resource_from_metadata
return unless metadata.present?
diff --git a/app/models/lcms/engine/document_bundle.rb b/app/models/lcms/engine/document_bundle.rb
index 6f34262c..377b0967 100644
--- a/app/models/lcms/engine/document_bundle.rb
+++ b/app/models/lcms/engine/document_bundle.rb
@@ -20,18 +20,18 @@ def self.update_bundle(resource, category = 'full')
end
def self.update_pdf_bundle(resource, category)
- zip_path = LessonsPdfBundler.new(resource, category).bundle
- return unless File.exist?(zip_path.to_s)
+ zip_path = LessonsPdfBundler.new(resource, category).bundle.to_s
+ return unless File.exist?(zip_path)
begin
- doc_bundle = find_or_create_by(resource: resource, category: category, content_type: 'pdf')
+ doc_bundle = find_or_create_by(resource:, category:, content_type: 'pdf')
File.open(zip_path) do |f|
doc_bundle.file = f
doc_bundle.save!
end
ensure
- FileUtils.rm(zip_path)
+ Bundler::FileUtils.rm_f(zip_path)
end
end
private_class_method :update_pdf_bundle
@@ -42,7 +42,7 @@ def self.update_gdoc_bundle(resource)
bundle_path = LessonsGdocBundler.new(resource).bundle
return unless bundle_path
- doc_bundle = find_or_create_by(resource: resource, category: 'full', content_type: 'gdoc')
+ doc_bundle = find_or_create_by(resource:, category: 'full', content_type: 'gdoc')
doc_bundle.url = bundle_path
doc_bundle.save!
end
diff --git a/app/models/lcms/engine/download.rb b/app/models/lcms/engine/download.rb
deleted file mode 100644
index bf702d59..00000000
--- a/app/models/lcms/engine/download.rb
+++ /dev/null
@@ -1,60 +0,0 @@
-# frozen_string_literal: true
-
-module Lcms
- module Engine
- class Download < ApplicationRecord
- CONTENT_TYPES = {
- zip: 'application/zip',
- pdf: 'application/pdf',
- excel: %w(application/vnd.ms-excel application/vnd.openxmlformats-officedocument.spreadsheetml.sheet),
- powerpoint: %w(application/vnd.ms-powerpoint application/vnd.openxmlformats-officedocument.presentationml.presentation), # rubocop:disable Layout/LineLength
- doc: %w(application/msword application/vnd.openxmlformats-officedocument.wordprocessingml.document)
- }.freeze
- S3_URL = 'http://k12-content.s3-website-us-east-1.amazonaws.com/'
- URL_PUBLIC_PREFIX = 'public://'
-
- mount_uploader :filename, DownloadUploader
- alias_attribute :file, :filename
-
- validates :title, presence: true
- validates :file, presence: true, if: -> { url.nil? }
- validates :url, presence: true, if: -> { file.nil? }
-
- before_save :update_metadata
-
- def attachment_url
- if url.present?
- url.sub(URL_PUBLIC_PREFIX, S3_URL)
- else
- file.url
- end
- end
-
- def s3_filename
- File.basename(attachment_url)
- end
-
- def attachment_content_type
- type = content_type
- CONTENT_TYPES.each do |key, types|
- if Array.wrap(types).include?(content_type)
- type = key
- break
- end
- end
- type
- end
-
- private
-
- def update_metadata
- if file.present?
- self.content_type = file.file.content_type
- self.filesize = file.file.size
- end
-
- true
- end
- end
- end
-end
diff --git a/app/models/lcms/engine/download_category.rb b/app/models/lcms/engine/download_category.rb
deleted file mode 100644
index 2445bc95..00000000
--- a/app/models/lcms/engine/download_category.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-# frozen_string_literal: true
-
-require 'acts_as_list'
-
-module Lcms
- module Engine
- class DownloadCategory < ApplicationRecord
- has_many :resource_downloads
-
- default_scope { order(:position) }
-
- acts_as_list
-
- validates :title, uniqueness: true, presence: true
-
- before_save :unique_bundle
-
- def self.bundle
- where(bundle: true).first
- end
-
- private
-
- def unique_bundle
- # we need the try here, otherwise this will fail when migrating the first time.
- self.class.where.not(id: id).where(bundle: true).update_all(bundle: false) if try(:bundle) && bundle_changed?
- end
- end
- end
-end
diff --git a/app/models/lcms/engine/integrations.rb b/app/models/lcms/engine/integrations.rb
new file mode 100644
index 00000000..ea005c2d
--- /dev/null
+++ b/app/models/lcms/engine/integrations.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Lcms
+ module Engine
+ module Integrations
+ def self.table_name_prefix
+ 'lcms_engine_integrations_'
+ end
+ end
+ end
+end
diff --git a/app/models/lcms/engine/integrations/webhook_configuration.rb b/app/models/lcms/engine/integrations/webhook_configuration.rb
new file mode 100644
index 00000000..42a7be77
--- /dev/null
+++ b/app/models/lcms/engine/integrations/webhook_configuration.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+module Lcms
+ module Engine
+ module Integrations
+ class WebhookConfiguration < ApplicationRecord
+ validates :event_name, :endpoint_url, presence: true
+ validates :endpoint_url, url: true
+ validates :action, inclusion: { in: %w(post put patch delete) }
+ validates :auth_type, inclusion: { in: %w(basic bearer hmac) }, allow_blank: true
+
+ scope :active, -> { where(active: true) }
+
+ CALL_TIMEOUT_SECONDS = 30
+
+ def self.trigger(event_name, payload)
+ active.where(event_name:).each do |config|
+ Integrations::WebhookCallJob.perform_later(config.id, payload)
+ end
+ end
+
+ class WebhookCallError < StandardError; end
+
+ def execute_call(payload)
+ response = HTTParty.send(
+ action.downcase.to_sym,
+ endpoint_url,
+ body: payload.to_json,
+ headers: {
+ 'Content-Type' => 'application/json'
+ }.merge(auth_headers(payload)),
+ timeout: CALL_TIMEOUT_SECONDS
+ )
+
+ if response.success?
+ Rails.logger.info("Webhook call successful: #{event_name} to #{endpoint_url}")
+ else
+ error_message =
+ "Webhook call failed: #{event_name} to #{endpoint_url}. Status: #{response.code}, Body: #{response.body}"
+ Rails.logger.error(error_message)
+
+ raise WebhookCallError, error_message if should_retry?(response.code)
+ end
+
+ response
+ rescue StandardError => e
+ error_message = "Webhook call error: #{event_name} to #{endpoint_url}. Error: #{e.message}"
+ Rails.logger.error(error_message)
+ raise WebhookCallError, error_message
+ end
+
+ private
+
+ def auth_headers(payload = nil)
+ case auth_type
+ when 'basic'
+ {
+ 'Authorization' =>
+ "Basic #{Base64.strict_encode64("#{auth_credentials['username']}:#{auth_credentials['password']}")}"
+ }
+ when 'bearer'
+ { 'Authorization' => "Bearer #{auth_credentials['token']}" }
+ when 'hmac'
+ timestamp = Time.now.to_i.to_s
+ {
+ 'X-HMAC-Signature' => generate_hmac_signature(timestamp, payload),
+ 'X-Timestamp' => timestamp
+ }
+ else
+ {}
+ end
+ end
+
+ def generate_hmac_signature(timestamp, payload)
+ secret_key = auth_credentials['secret_key']
+ path = URI(endpoint_url).path
+ Lcms::Engine::Api::AuthHelper.compute_hmac_signature(timestamp, path, payload.to_json, secret_key)
+ end
+
+ def should_retry?(status_code)
+ # Retry for server errors (5xx) and certain client errors
+ status_code.to_i >= 500 || [408, 429].include?(status_code.to_i)
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/lcms/engine/material.rb b/app/models/lcms/engine/material.rb
index 2e001c1a..706f6b04 100644
--- a/app/models/lcms/engine/material.rb
+++ b/app/models/lcms/engine/material.rb
@@ -5,6 +5,7 @@
module Lcms
module Engine
class Material < ApplicationRecord
+ include Filterable
include PgSearch::Model
include Partable
diff --git a/app/models/lcms/engine/material_part.rb b/app/models/lcms/engine/material_part.rb
deleted file mode 100644
index c9e27bff..00000000
--- a/app/models/lcms/engine/material_part.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-# frozen_string_literal: true
-
-module Lcms
- module Engine
- class MaterialPart < ApplicationRecord
- belongs_to :material
- enum context_type: { default: 0, gdoc: 1 }
-
- default_scope { active }
-
- scope :active, -> { where(active: true) }
- end
- end
-end
diff --git a/app/models/lcms/engine/resource.rb b/app/models/lcms/engine/resource.rb
index ae4d09fd..abe16562 100644
--- a/app/models/lcms/engine/resource.rb
+++ b/app/models/lcms/engine/resource.rb
@@ -3,6 +3,8 @@
module Lcms
module Engine
class Resource < ApplicationRecord
+ include Filterable
+
enum resource_type: {
resource: 1,
podcast: 2,
@@ -15,15 +17,12 @@ class Resource < ApplicationRecord
MEDIA_TYPES = %i(video podcast).map { |t| resource_types[t] }.freeze
GENERIC_TYPES = %i(text_set quick_reference_guide resource_other).map { |t| resource_types[t] }.freeze
- SUBJECTS = %w(ela math lead).freeze
+ SUBJECTS = %w(ela math).freeze
HIERARCHY = %i(subject grade module unit lesson).freeze
- include Searchable
- include Navigable
-
mount_uploader :image_file, ResourceImageUploader
- acts_as_taggable_on :content_sources, :download_types, :resource_types, :tags, :topics
+ acts_as_taggable_on :resource_types, :tags
has_closure_tree order: :level_position, dependent: :destroy, numeric_order: true
belongs_to :parent, class_name: 'Lcms::Engine::Resource', foreign_key: 'parent_id', optional: true
@@ -38,11 +37,6 @@ class Resource < ApplicationRecord
has_many :resource_standards, dependent: :destroy
has_many :standards, through: :resource_standards
- # Downloads.
- has_many :resource_downloads, dependent: :destroy
- has_many :downloads, through: :resource_downloads
- accepts_nested_attributes_for :resource_downloads, allow_destroy: true
-
# Reading assignments.
has_many :resource_reading_assignments, dependent: :destroy
alias_attribute :reading_assignments, :resource_reading_assignments
@@ -63,15 +57,24 @@ class Resource < ApplicationRecord
has_many :document_bundles, dependent: :destroy
validates :title, presence: true
- validates :url, presence: true, url: true, if: %i(video? podcast?)
+ validates :url, presence: true, url: true, if: -> { video? || podcast? }
- scope :where_grade, ->(grades) { where_metadata_in :grade, grades }
- scope :where_module, ->(modules) { where_metadata_in :module, modules }
- scope :where_subject, ->(subjects) { where_metadata_in :subject, subjects }
scope :media, -> { where(resource_type: MEDIA_TYPES) }
scope :generic_resources, -> { where(resource_type: GENERIC_TYPES) }
scope :ordered, -> { order(:hierarchical_position, :slug) }
+ # @param link_path [String] when nested, use a dot to separate the levels, eg "level1.level2"
+ # @param datetime [DateTime, Time]
+ scope :where_link_updated_after, lambda { |link_path, datetime|
+ path_elements = link_path.split('.')
+ jsonb_path = path_elements.map(&:to_s).join(',')
+
+ # Convert datetime to Unix timestamp for comparison
+ timestamp = datetime.to_i
+
+ where("(links #>> '{#{jsonb_path},timestamp}')::bigint >= ?", timestamp)
+ }
+
before_save :update_metadata, :update_slug, :update_position
after_save :update_descendants_meta, :update_descendants_position,
@@ -80,19 +83,13 @@ class Resource < ApplicationRecord
before_destroy :destroy_additional_resources
class << self
- # Define dynamic scopes for hierarchy levels.
- # I,e: `grades`, `units`, etc
- HIERARCHY.map(&:to_s).each do |level|
- define_method(:"#{level.pluralize}") { where(curriculum_type: level) }
- end
-
def metadata_from_dir(dir)
pairs = hierarchy[0...dir.size].zip(dir)
pairs.to_h.compact.stringify_keys
end
def find_by_directory(*dir)
- dir = dir&.flatten&.select(&:present?)
+ dir = dir.flatten.select(&:present?)
return unless dir.present?
type = hierarchy[dir.size - 1]
@@ -100,15 +97,6 @@ def find_by_directory(*dir)
where('metadata @> ?', meta).where(curriculum_type: type).first
end
- def find_podcast_by_url(url)
- podcast.where(url: url).first
- end
-
- def find_video_by_url(url)
- video_id = MediaEmbed.video_id(url)
- video.where("url ~ '#{video_id}(&|$)'").first
- end
-
def hierarchy
Lcms::Engine::Resource::HIERARCHY
end
@@ -123,30 +111,34 @@ def ransackable_scopes(_auth_object = nil)
def tree(name = nil)
if name.present?
joins(:curriculum).where('curriculums.name = ? OR curriculums.slug = ?', name, name)
- elsif (default = Lcms::Engine::Curriculum.default)
- where(curriculum_id: default.id)
else
- where(nil)
+ where(curriculum_id: Lcms::Engine::Curriculum.default&.id)
end
end
-
- def where_metadata_in(key, arr)
- arr = Array.wrap arr
- clauses = Array.new(arr.count) { "metadata->>'#{key}' = ?" }.join(' OR ')
- where(clauses, *arr)
- end
end
# Define predicate methods for subjects.
# I,e: #ela?, #math?, ..
SUBJECTS.each do |subject_name|
- define_method(:"#{subject_name}?") { subject == subject_name.to_s }
+ define_method(:"#{subject_name}?") do
+ # @type self: Lcms::Engine::Resource
+ subject == subject_name.to_s
+ end
end
# Define predicate methods for hierarchy levels.
- # I,e: #subject?, #grade?, #lesson?, ...
+ # Sample: #subject?, #grade?, #lesson?, ...
HIERARCHY.each do |level|
- define_method(:"#{level}?") { curriculum_type.present? && curriculum_type.casecmp(level.to_s).zero? }
+ define_method(:"#{level}?") do
+ # @type self: Lcms::Engine::Resource
+ curriculum_type.present? && curriculum_type.to_s.casecmp(level.to_s).to_i.zero?
+ end
+
+ # Define dynamic scopes for hierarchy levels.
+ # I.e., `grades`, `units`, etc.
+ define_singleton_method(level.to_s.pluralize) do
+ where(curriculum_type: level)
+ end
end
def tree?
@@ -158,20 +150,11 @@ def assessment?
end
def media?
- %w(video podcast).include? resource_type
+ %w(video podcast).include?(resource_type.to_s)
end
def generic?
- %w(text_set quick_reference_guide resource_other).include?(resource_type)
- end
-
- # `Optional prerequisite` - https://github.com/learningtapestry/unbounded/issues/557
- def opr?
- tag_list.include?('opr')
- end
-
- def prerequisite?
- tag_list.include?('prereq')
+ %w(text_set quick_reference_guide resource_other).include?(resource_type.to_s)
end
def directory
@@ -193,7 +176,7 @@ def grades=(gds)
end
def lesson_number
- @lesson_number ||= short_title.match(/(\d+)/)&.[](1).to_i
+ @lesson_number ||= short_title.to_s.match(/(\d+)/)&.[](1).to_i
end
def related_resources
@@ -203,35 +186,11 @@ def related_resources
.map(&:related_resource)
end
- def download_categories
- @download_categories ||=
- resource_downloads.includes(:download_category).includes(:download)
- .sort_by { |rd| rd.download_category&.position.to_i }
- .group_by { |d| d.download_category&.title.to_s }
- .transform_values { |v| v.sort_by { |d| [d.download.main ? 0 : 1, d.download.title] } }
- end
-
- def pdf_downloads?(category = nil)
- if category.present?
- resource_downloads.joins(:download)
- .where(download_category: category)
- .where(downloads: { content_type: 'application/pdf' })
- .exists?
- else
- downloads.where(content_type: 'application/pdf').exists?
- end
- end
-
- alias do_not_skip_indexing? should_index?
- def should_index?
- do_not_skip_indexing? && (tree? || media? || generic?)
- end
-
def named_tags
{
- keywords: (tag_list + topic_list).compact.uniq,
- resource_type: resource_type,
- ell_appropriate: ell_appropriate,
+ keywords: tag_list.compact.uniq,
+ resource_type:,
+ ell_appropriate:,
ccss_standards: tag_standards,
ccss_domain: nil, # resource.standards.map { |std| std.domain.try(:name) }.uniq
ccss_cluster: nil, # resource.standards.map { |std| std.cluster.try(:name) }.uniq
@@ -262,11 +221,37 @@ def document?
document.present?
end
+ def next
+ @next ||=
+ if level_position.nil?
+ nil
+ elsif level_position.to_i < siblings.size
+ siblings.where(level_position: level_position.to_i + 1).first
+ else
+ # first element of next node from parent level
+ parent&.next&.children&.first
+ end
+ end
+
def next_hierarchy_level
- index = Lcms::Engine::Resource.hierarchy.index(curriculum_type.to_sym)
+ index = Lcms::Engine::Resource.hierarchy.index(curriculum_type.to_s.to_sym)
Lcms::Engine::Resource.hierarchy[index + 1]
end
+ def parents
+ ancestors.reverse
+ end
+
+ def previous
+ @previous ||=
+ if level_position.to_i.positive?
+ siblings.where(level_position: level_position.to_i - 1).first
+ else
+ # last element of previous node from parent level
+ parent&.previous&.children&.last
+ end
+ end
+
def unit_bundles?
unit? && document_bundles.any?
end
@@ -280,7 +265,7 @@ def self_and_ancestors_not_persisted
def update_metadata
meta = self_and_ancestors_not_persisted
- .each_with_object({}) do |r, obj|
+ .each_with_object({}) do |r, obj| # steep:ignore
obj[r.curriculum_type] = r.short_title
end.compact
metadata.merge! meta if meta.present?
@@ -300,7 +285,7 @@ def update_descendants_author
# update only if a grade author has changed
return unless grade? && author_id_changed?
- descendants.update_all author_id: author_id
+ descendants.update_all author_id:
end
def update_descendants_meta
diff --git a/app/models/lcms/engine/resource_download.rb b/app/models/lcms/engine/resource_download.rb
deleted file mode 100644
index c53c998b..00000000
--- a/app/models/lcms/engine/resource_download.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-module Lcms
- module Engine
- class ResourceDownload < ApplicationRecord
- DOWNLOAD_PER_CATEGORY_LIMIT = 5
-
- belongs_to :resource
- belongs_to :download
- belongs_to :download_category
-
- validates :download, presence: true
-
- accepts_nested_attributes_for :download
- end
- end
-end
diff --git a/app/models/lcms/engine/search/document.rb b/app/models/lcms/engine/search/document.rb
deleted file mode 100644
index c888b336..00000000
--- a/app/models/lcms/engine/search/document.rb
+++ /dev/null
@@ -1,155 +0,0 @@
-# frozen_string_literal: true
-
-require 'virtus'
-
-module Lcms
- module Engine
- module Search
- class Document < ElasticSearchDocument
- include Virtus.model
-
- METADATA_FIELDS = %w(description teaser title lesson_objective).freeze
-
- attribute :breadcrumbs, String
- attribute :description, String
- attribute :doc_type, String
- attribute :document_metadata, String
- attribute :grade, String
- attribute :id, String
- attribute :model_id, Integer
- attribute :model_type, String
- attribute :permalink, String
- attribute :position, String
- attribute :slug, String
- attribute :subject, String
- attribute :tag_authors, Array[String]
- attribute :tag_keywords, Array[String]
- attribute :tag_standards, Array[String]
- attribute :tag_texts, Array[String]
- attribute :teaser, String
- attribute :title, String
-
- class << self
- def build_from(model)
- case model
- when Lcms::Engine::Resource
- new(**attrs_from_resource(model))
-
- when Lcms::Engine::ExternalPage
- new(**attrs_from_page(model))
-
- else
- raise "Unsupported Type for Search : #{model.class.name}"
- end
- end
-
- def doc_type(model)
- model.resource_type == 'resource' ? model.curriculum_type : model.resource_type
- end
-
- def document_metadata(model)
- return unless model.document?
-
- METADATA_FIELDS.map do |k|
- value = model.document.metadata[k]
- Nokogiri::HTML.fragment(value).text.presence
- end.compact.join(' ')
- end
-
- # Position mask:
- # - Since lessons uses 4 blocks of 2 numbers for (grade, mod, unit, lesson),
- # we use 5 blocks to place them after lessons.
- # - the first position is realted to the resource type (always starting
- # with 9 to be placed after the lessons).
- # - The second most significant is related to the grade
- # - The last position is the number of different grades covered, i.e:
- # a resource with 3 different grades show after one with 2, (more specific
- # at the top, more generic at the bottom)
- def grade_position(model)
- if model.is_a?(Lcms::Engine::Resource) && model.generic?
- rtype = model[:resource_type] || 0
- # for generic resource use the min grade, instead the avg
- grade_pos = model.grades.list.map { |g| Lcms::Engine::Grades.grades.index(g) }.compact.min || 0
- last_pos = model.grades.list.size
- else
- rtype = 0
- grade_pos = model.grades.average_number
- last_pos = 0
- end
- first_pos = 90 + rtype
-
- [first_pos, grade_pos, 0, 0, last_pos].map { |n| n.to_s.rjust(2, '0') }.join(' ')
- end
-
- def resource_position(model)
- if model.media? || model.generic?
- grade_position(model)
- else
- model.hierarchical_position
- end
- end
-
- # Overrides ElasticSearchDocument.search to include standards search
- def search(term, options = {})
- return repository.empty_response unless repository.index_exists?
- return repository.search(repository.all_query(options)) unless term.present?
-
- repository.multisearch(
- [
- repository.standards_query(term, options),
- repository.tags_query(term, [:tag_keywords], options),
- repository.tags_query(term, %i(tag_authors tag_texts), options),
- repository.fts_query(term, options)
- ]
- ).max_by(&:total)
- end
-
- private
-
- def attrs_from_page(model)
- {
- description: model.description,
- doc_type: 'page',
- grade: [],
- id: "page_#{model.slug}",
- model_type: :page,
- permalink: model.permalink,
- slug: model.slug,
- tag_keywords: model.keywords,
- teaser: model.teaser,
- title: model.title
- }
- end
-
- def attrs_from_resource(model)
- tags = model.named_tags
-
- {
- breadcrumbs: Lcms::Engine::Breadcrumbs.new(model).title,
- description: model.description,
- doc_type: doc_type(model),
- document_metadata: document_metadata(model),
- grade: model.grades.list,
- id: "resource_#{model.id}",
- model_id: model.id,
- model_type: 'resource',
- position: resource_position(model),
- slug: model.slug,
- subject: model.subject,
- tag_authors: tags[:authors] || [],
- tag_keywords: tags[:keywords] || [],
- tag_standards: tags[:ccss_standards] || [],
- tag_texts: tags[:texts] || [],
- teaser: model.teaser,
- title: model.title
- }
- end
- end
-
- def grades
- @grades ||= Lcms::Engine::Grades.new(self)
- end
- end
- end
- end
-end
diff --git a/app/models/lcms/engine/search/elastic_search_document.rb b/app/models/lcms/engine/search/elastic_search_document.rb
deleted file mode 100644
index 85bfe88a..00000000
--- a/app/models/lcms/engine/search/elastic_search_document.rb
+++ /dev/null
@@ -1,59 +0,0 @@
-# frozen_string_literal: true
-
-module Lcms
- module Engine
- module Search
- class ElasticSearchDocument
- # return the corresponding repository
- def self.repository
- @repository ||= Lcms::Engine::Search::Repository.new
- end
-
- def repository
- self.class.repository
- end
-
- def index!
- repository.save self
- end
-
- def delete!
- repository.delete self
- end
-
- # Default search
- #
- # term: [String || Nil]
- # - [Nil] : return a match_all query
- # - [String] : perform a `fts_query` on the repository
- #
- # options: [Hash]
- # - per_page : number os results per page
- # - page : results page number
- # - : any doc specific filters, e.g: 'grade', 'subject', etc
- #
- def self.search(term, options = {})
- return [] unless repository.index_exists?
-
- query = if term.present?
- repository.fts_query(term, options)
- else
- repository.all_query(options)
- end
- repository.search query
- end
-
- # this is necessary for the ActiveModel::Serializer::CollectionSerializer#as_json method to work
- # (used on the Pagination#serialize_with_pagination)
- # NOTE: https://github.com/rails-api/active_model_serializers/issues/891
- def read_attribute_for_serialization(key)
- if key.try(:to_sym) == :id
- attributes.fetch(key) { id }
- else
- attributes[key]
- end
- end
- end
- end
- end
-end
diff --git a/app/models/lcms/engine/search/repository.rb b/app/models/lcms/engine/search/repository.rb
deleted file mode 100644
index 7af8fc73..00000000
--- a/app/models/lcms/engine/search/repository.rb
+++ /dev/null
@@ -1,223 +0,0 @@
-# frozen_string_literal: true
-
-require 'elasticsearch/persistence'
-
-module Lcms
- module Engine
- module Search
- def self.ngrams_multi_field
- {
- type: 'string', fields: {
- full: { type: 'string', analyzer: 'full_str' },
- partial: { type: 'string', analyzer: 'partial_str' },
- key: { type: 'string', index: 'not_analyzed' }
- }
- }
- end
-
- def self.index_settings
- {
- analysis: {
- filter: {
- str_ngrams: { type: 'nGram', min_gram: 3, max_gram: 10 },
- stop_en: { type: 'stop', stopwords: '_english_' }
- },
- analyzer: {
- keyword_str: {
- filter: ['lowercase'],
- type: 'custom',
- tokenizer: 'keyword'
- },
- full_str: {
- filter: %w(standard lowercase stop_en asciifolding),
- type: 'custom',
- tokenizer: 'standard'
- },
- partial_str: {
- filter: %w(standard lowercase stop_en asciifolding str_ngrams),
- type: 'custom',
- tokenizer: 'standard'
- }
- }
- }
- }
- end
-
- class Repository
- include Elasticsearch::Persistence::Repository
- include Elasticsearch::Persistence::Repository::DSL
-
- client Elasticsearch::Client.new(host: ENV.fetch('ELASTICSEARCH_ADDRESS', nil))
-
- index_name :"unbounded_documents_#{Rails.env}"
-
- document_type :document
-
- klass Lcms::Engine::Search::Document
-
- settings index: Lcms::Engine::Search.index_settings do
- mappings dynamic: 'false' do
- indexes :breadcrumbs, type: 'string', index: 'not_analyzed'
- indexes :description, **Lcms::Engine::Search.ngrams_multi_field
- indexes :doc_type, type: 'string', index: 'not_analyzed' # module | unit | lesson | video | etc
- indexes :document_metadata, type: 'string'
- indexes :grade, type: 'string', index: 'not_analyzed'
- indexes :model_id, type: 'string', index: 'not_analyzed'
- indexes :model_type, type: 'string', index: 'not_analyzed' # ActiveRecord model => resource only for now
- indexes :position, type: 'string', index: 'not_analyzed'
- indexes :subject, type: 'string', index: 'not_analyzed'
- indexes :tag_authors, type: 'string'
- indexes :tag_keywords, type: 'string'
- indexes :tag_standards, type: 'string', analyzer: 'keyword_str'
- indexes :tag_texts, **Lcms::Engine::Search.ngrams_multi_field
- indexes :teaser, **Lcms::Engine::Search.ngrams_multi_field
- indexes :title, **Lcms::Engine::Search.ngrams_multi_field
- end
- end
-
- SYNONYMS = {
- 'text sets' => 'text set',
- 'expert pack' => 'expert packs'
- }.freeze
-
- def all_query(options)
- limit = options.fetch(:per_page, 20)
- page = options.fetch(:page, 1)
-
- query = {
- query: {
- bool: {
- must: { match_all: {} },
- filter: []
- }
- },
- sort: [
- { subject: 'asc' },
- { position: 'asc' },
- { 'title.key' => 'asc' }
- ],
- size: limit,
- from: (page - 1) * limit
- }
-
- apply_filters(query, options)
- end
-
- def fts_query(term, options)
- return term if term.respond_to?(:to_hash)
-
- limit = options.fetch(:per_page, 20)
- page = options.fetch(:page, 1)
- term = replace_synonyms term.downcase
-
- query = {
- min_score: 6,
- query: {
- bool: {
- should: [
- { match: { 'title.full' => { query: term, boost: 3, type: 'phrase' } } },
- { match: { 'title.partial' => { query: term, boost: 0.5 } } },
-
- { match: { 'teaser.full' => { query: term, boost: 4, type: 'phrase' } } },
-
- { match: { document_metadata: { query: term, boost: 1 } } }
- ],
- filter: []
- }
- },
- size: limit,
- from: (page - 1) * limit
- }
-
- apply_filters(query, options)
- end
-
- def standards_query(term, options)
- return term if term.respond_to?(:to_hash)
-
- limit = options.fetch(:per_page, 20)
- page = options.fetch(:page, 1)
-
- query = {
- query: {
- bool: {
- filter: [],
- should: [
- { term: { tag_standards: term } },
- { match_phrase_prefix: { tag_standards: { query: term } } }
- ],
- minimum_should_match: 1
- }
- },
- size: limit,
- from: (page - 1) * limit
- }
-
- apply_filters query, options
- end
-
- def tags_query(term, tags, options)
- return term if term.respond_to?(:to_hash)
-
- limit = options.fetch(:per_page, 20)
- page = options.fetch(:page, 1)
-
- query = {
- query: {
- bool: {
- filter: [],
- should: tags.map { |t| { match: { t => term } } }.concat(
- [
- { match_phrase: { title: term } },
- { match_phrase: { teaser: term } }
- ]
- ),
- minimum_should_match: 1
- }
- },
- size: limit,
- from: (page - 1) * limit
- }
-
- apply_filters query, options
- end
-
- def accepted_filters
- %i(model_type subject grade doc_type)
- end
-
- def apply_filters(query, options)
- accepted_filters.each do |filter|
- next unless options[filter]
-
- filter_term = if options[filter].is_a? Array
- { terms: { filter => options[filter] } }
- else
- { match: { filter => { query: options[filter] } } }
- end
- query[:query][:bool][:filter] << filter_term
- end
- query
- end
-
- def empty_response
- Elasticsearch::Persistence::Repository::Response::Results.new(
- self,
- hits: { total: 0, max_score: nil, hits: [] }
- )
- end
-
- def multisearch(queries)
- body = queries.map { |query| { search: query } }
- client.msearch(index: index, type: type, body: body)['responses'].map do |r|
- Elasticsearch::Persistence::Repository::Response::Results.new(self, r)
- end
- end
-
- def replace_synonyms(term)
- SYNONYMS[term] || term
- end
- end
- end
- end
-end
diff --git a/app/models/lcms/engine/settings.rb b/app/models/lcms/engine/settings.rb
index a6341801..51852ef2 100644
--- a/app/models/lcms/engine/settings.rb
+++ b/app/models/lcms/engine/settings.rb
@@ -9,7 +9,8 @@ def [](key)
end
def []=(key, value)
- settings.update data: settings.data.merge(key.to_s => value)
+ new_settings = {}.tap { _1[key.to_s] = value } # steep:ignore
+ settings.update data: settings.data.merge(new_settings)
end
private
diff --git a/app/models/lcms/engine/standard.rb b/app/models/lcms/engine/standard.rb
index a7705562..01b8edb1 100644
--- a/app/models/lcms/engine/standard.rb
+++ b/app/models/lcms/engine/standard.rb
@@ -92,7 +92,7 @@ def self.filter_ccss_standards(name, subject)
# NOTE: #954 - to be removed?
def short_name
- alt_names.map { |n| self.class.filter_ccss_standards(n, subject) }.compact.try(:first) || name
+ Array.wrap(alt_names).map { |n| self.class.filter_ccss_standards(n, subject) }.compact.try(:first) || name
end
end
end
diff --git a/app/models/lcms/engine/tag.rb b/app/models/lcms/engine/tag.rb
index 48248193..510e8c0d 100644
--- a/app/models/lcms/engine/tag.rb
+++ b/app/models/lcms/engine/tag.rb
@@ -5,7 +5,7 @@
module Lcms
module Engine
class Tag < ActsAsTaggableOn::Tag
- scope :where_context, ->(context) { joins(:taggings).where(taggings: { context: context }) }
+ scope :where_context, ->(context) { joins(:taggings).where(taggings: { context: }) }
end
end
end
diff --git a/app/models/lcms/engine/user.rb b/app/models/lcms/engine/user.rb
index 0870a001..f33b9dc3 100644
--- a/app/models/lcms/engine/user.rb
+++ b/app/models/lcms/engine/user.rb
@@ -16,31 +16,16 @@ class User < ApplicationRecord
validates_presence_of :email, :role
validate :access_code_valid?, on: :create, unless: :admin?
- def full_name
- [
- survey&.fetch('first_name', nil),
- survey&.fetch('last_name', nil)
- ].reject(&:blank?).join(' ')
- end
-
def generate_password
pwd = Devise.friendly_token.first(20)
self.password = pwd
self.password_confirmation = pwd
end
- def name
- super.presence || full_name
- end
-
- def ready_to_go?
- admin? || survey.present?
- end
-
private
def access_code_valid?
- return if AccessCode.by_code(access_code).exists?
+ return false if AccessCode.by_code(access_code.to_s).exists?
errors.add :access_code, 'not found'
end
diff --git a/app/presenters/lcms/engine/base_presenter.rb b/app/presenters/lcms/engine/base_presenter.rb
index f5a2a7c1..2a4c0d8d 100644
--- a/app/presenters/lcms/engine/base_presenter.rb
+++ b/app/presenters/lcms/engine/base_presenter.rb
@@ -14,9 +14,9 @@ def t(key, options = {})
class_name = self.class.to_s.underscore
options[:raise] = true
if key.starts_with?('.')
- I18n.t("#{class_name}.#{key}", options)
+ I18n.t("#{class_name}.#{key}", **options)
else
- I18n.t(key, options)
+ I18n.t(key, **options)
end
end
end
diff --git a/app/presenters/lcms/engine/content_presenter.rb b/app/presenters/lcms/engine/content_presenter.rb
index 3aa84915..21647109 100644
--- a/app/presenters/lcms/engine/content_presenter.rb
+++ b/app/presenters/lcms/engine/content_presenter.rb
@@ -10,11 +10,11 @@ class ContentPresenter < Lcms::Engine::BasePresenter
THUMB_EXT = '.jpg'
def self.base_config
- @base_config ||= YAML.load_file(CONFIG_PATH).deep_symbolize_keys
+ @base_config ||= YAML.load_file(CONFIG_PATH, aliases: true).deep_symbolize_keys
end
def self.materials_config
- @materials_config ||= YAML.load_file(MATERIALS_CONFIG_PATH).deep_symbolize_keys
+ @materials_config ||= YAML.load_file(MATERIALS_CONFIG_PATH, aliases: true).deep_symbolize_keys
end
def base_filename
diff --git a/app/presenters/lcms/engine/curriculum_presenter.rb b/app/presenters/lcms/engine/curriculum_presenter.rb
index 8d5fbcda..4811145f 100644
--- a/app/presenters/lcms/engine/curriculum_presenter.rb
+++ b/app/presenters/lcms/engine/curriculum_presenter.rb
@@ -31,7 +31,7 @@ def element_text(resource)
case resource.curriculum_type
when 'subject'
resource.title
- when 'unit'
+ when 'module', 'unit'
resource.short_title&.upcase.presence || 'N/A'
when 'grade'
resource.short_title&.capitalize.presence || 'N/A'
diff --git a/app/presenters/lcms/engine/document_presenter.rb b/app/presenters/lcms/engine/document_presenter.rb
index fb3ba9be..e9b433aa 100644
--- a/app/presenters/lcms/engine/document_presenter.rb
+++ b/app/presenters/lcms/engine/document_presenter.rb
@@ -12,11 +12,7 @@ class DocumentPresenter < Lcms::Engine::ContentPresenter
TOPIC_SHORT = { 'ela' => 'U', 'math' => 'T' }.freeze
def cc_attribution
- core_cc = ld_metadata.cc_attribution
- return core_cc if (fs_cc = fs_metadata.cc_attribution).blank?
- return core_cc if core_cc.casecmp(fs_cc).zero?
-
- "#{core_cc} #{fs_cc}"
+ ld_metadata.cc_attribution
end
def color_code
@@ -40,19 +36,7 @@ def description
end
def doc_type
- assessment? ? 'assessment' : 'lesson'
- end
-
- def ela2?
- ela? && grade.to_s == '2'
- end
-
- def ela6?
- ela? && grade.to_s == '6'
- end
-
- def fs_metadata
- @fs_metadata ||= DocTemplate::Objects::BaseMetadata.build_from(foundational_metadata)
+ 'lesson'
end
def full_breadcrumb(unit_level: false)
@@ -60,13 +44,13 @@ def full_breadcrumb(unit_level: false)
end
def full_breadcrumb_from_metadata(unit_level)
- lesson_level = assessment? ? 'Assessment' : "Lesson #{lesson}" unless unit_level
+ lesson_level = "Lesson #{lesson}" unless unit_level
[
SUBJECT_FULL[subject] || subject,
grade.to_i.zero? ? grade : "Grade #{grade}",
ll_strand? ? ld_module : "Module #{ld_module.try(:upcase)}",
topic.present? ? "#{TOPIC_FULL[subject]} #{topic.try(:upcase)}" : nil,
- lesson_level
+ lesson_level.to_s
].compact.join(' / ')
end
@@ -133,16 +117,9 @@ def render_content(context_type, options = {})
Lcms::Engine::ReactMaterialsResolver.resolve(content, self)
end
- # rubocop:disable Metrics/PerceivedComplexity
def short_breadcrumb(join_with: ' / ', with_short_lesson: false, with_subject: true, unit_level: false)
- unless unit_level
- lesson_abbr =
- if assessment?
- with_short_lesson ? 'A' : 'Assessment'
- else
- with_short_lesson ? "L#{lesson}" : "Lesson #{lesson}"
- end
- end
+ lesson_abbr = with_short_lesson ? "L#{lesson}" : "Lesson #{lesson}" \
+ unless unit_level
[
with_subject ? SUBJECT_FULL[subject] || subject : nil,
grade.to_i.zero? ? grade : "G#{grade}",
@@ -151,16 +128,9 @@ def short_breadcrumb(join_with: ' / ', with_short_lesson: false, with_subject: t
lesson_abbr
].compact.join(join_with)
end
- # rubocop:enable Metrics/PerceivedComplexity
def short_title
- assessment? ? doc_type : "Lesson #{lesson}"
- end
-
- def short_url
- @short_url ||= Bitly.client
- .shorten(document_url(self))
- .short_url
+ "Lesson #{lesson}"
end
def standards
@@ -184,8 +154,7 @@ def subject_to_str
end
def title
- title = ld_metadata&.title
- resource&.prerequisite? ? "Prerequisite - #{title}" : title
+ ld_metadata&.title
end
def teacher_materials
diff --git a/app/presenters/lcms/engine/generic_presenter.rb b/app/presenters/lcms/engine/generic_presenter.rb
deleted file mode 100644
index e9f68f1d..00000000
--- a/app/presenters/lcms/engine/generic_presenter.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-module Lcms
- module Engine
- class GenericPresenter < Lcms::Engine::ResourcePresenter
- def generic_title
- "#{subject.try(:upcase)} #{grades.to_str}"
- end
-
- def type_name
- I18n.t("resource_types.#{resource_type}")
- end
-
- def preview?
- downloads.any? { |d| d.main? && d.attachment_content_type == 'pdf' && RestClient.head(d.attachment_url) }
- rescue RestClient::ExceptionWithResponse
- false
- end
-
- def pdf_preview_download
- resource_downloads.find { |d| d.download.main? && d.download.attachment_content_type == 'pdf' }
- end
- end
- end
-end
diff --git a/app/presenters/lcms/engine/material_presenter.rb b/app/presenters/lcms/engine/material_presenter.rb
index e61eb953..646fba44 100644
--- a/app/presenters/lcms/engine/material_presenter.rb
+++ b/app/presenters/lcms/engine/material_presenter.rb
@@ -5,7 +5,7 @@ module Engine
class MaterialPresenter < Lcms::Engine::ContentPresenter
attr_accessor :document
- delegate :short_url, :subject, to: :document
+ delegate :subject, to: :document
DEFAULT_TITLE = 'Material'
@@ -25,8 +25,8 @@ def cc_attribution
metadata['cc_attribution'].presence || document&.cc_attribution
end
- def content_for(context_type)
- render_content(context_type)
+ def content_for(context_type, options = {})
+ render_content(context_type, options)
end
def content_type
@@ -107,7 +107,7 @@ def show_title?
end
def student_material?
- ::Material.where(id: id).gdoc.where_metadata_any_of(materials_config_for(:student)).exists?
+ ::Material.where(id:).gdoc.where_metadata_any_of(materials_config_for(:student)).exists?
end
def subtitle
@@ -115,7 +115,7 @@ def subtitle
end
def teacher_material?
- ::Material.where(id: id).gdoc.where_metadata_any_of(materials_config_for(:teacher)).exists?
+ ::Material.where(id:).gdoc.where_metadata_any_of(materials_config_for(:teacher)).exists?
end
def title
diff --git a/app/presenters/lcms/engine/resource_presenter.rb b/app/presenters/lcms/engine/resource_presenter.rb
index 11c3ff9c..a4013e36 100644
--- a/app/presenters/lcms/engine/resource_presenter.rb
+++ b/app/presenters/lcms/engine/resource_presenter.rb
@@ -12,32 +12,6 @@ def page_title
grade_code = grade_avg.include?('k') ? grade_avg : "G#{grade_avg}"
"#{subject.try(:upcase)} #{grade_code.try(:upcase)}: #{title}"
end
-
- def downloads_indent(opts = {})
- pdf_downloads?(opts[:category]) ? 'u-li-indent' : ''
- end
-
- def categorized_downloads_list
- @categorized_downloads_list ||= begin
- downloads_list = Lcms::Engine::DownloadCategory.all.map do |dc|
- downloads = Array.wrap(download_categories[dc.title])
- downloads.concat(document_bundles) if dc.bundle?
- settings = download_categories_settings[dc.title.parameterize] || {}
-
- next unless settings.values.any? || downloads.any?
-
- data = { category: dc, title: dc.title, downloads: downloads, settings: settings }
- Struct.new(*data.keys, keyword_init: true).new(data)
- end
-
- if (uncategorized = download_categories['']).present?
- data = { downloads: uncategorized, settings: {} }
- downloads_list << Struct.new(*data.keys, keyword_init: true).new(data)
- end
-
- downloads_list.compact
- end
- end
end
end
end
diff --git a/app/queries/lcms/engine/admin_documents_query.rb b/app/queries/lcms/engine/admin_documents_query.rb
index c538f789..e337ce02 100644
--- a/app/queries/lcms/engine/admin_documents_query.rb
+++ b/app/queries/lcms/engine/admin_documents_query.rb
@@ -9,7 +9,7 @@ class AdminDocumentsQuery < BaseQuery
# Returns: ActiveRecord relation
def call
@scope = Document.all # initial scope
- apply_filters
+ @scope = apply_filters
if @pagination.present?
sorted_scope.paginate(page: @pagination[:page])
@@ -20,16 +20,18 @@ def call
private
- def apply_filters # rubocop:disable Metrics/AbcSize
- @scope = @scope.actives unless q.inactive == '1'
- @scope = @scope.failed if q.only_failed == '1'
- @scope = @scope.filter_by_term(q.search_term) if q.search_term.present?
+ def apply_filters # rubocop:todo Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
+ @scope = q.respond_to?(:inactive) && q.inactive == '1' ? @scope.unscoped : @scope.actives
+ @scope = @scope.failed if q.respond_to?(:only_failed) && q.only_failed == '1'
+ @scope = @scope.filter_by_term(q.search_term) if q.respond_to?(:search_term) && q.search_term.present?
@scope = @scope.filter_by_subject(q.subject) if q.subject.present?
- @scope = @scope.filter_by_grade(q.grade) if q.grade.present?
+ @scope = @scope.filter_by_grade(q.grade) if q.respond_to?(:grade) && q.grade.present?
+ @scope = @scope.where_grade(q.grades&.compact) \
+ if q.respond_to?(:grades) && Array.wrap(q.grades).reject(&:blank?).present?
@scope = @scope.filter_by_module(q.module) if q.module.present?
@scope = @scope.filter_by_unit(q.unit) if q.unit.present?
- @scope = @scope.with_broken_materials if q.broken_materials == '1'
- @scope = @scope.with_updated_materials if q.reimport_required == '1'
+ @scope = @scope.with_broken_materials if q.respond_to?(:broken_materials) && q.broken_materials == '1'
+ @scope = @scope.with_updated_materials if q.respond_to?(:reimport_required) && q.reimport_required == '1'
@scope
end
diff --git a/app/queries/lcms/engine/admin_materials_query.rb b/app/queries/lcms/engine/admin_materials_query.rb
index 6fc2a760..d3a1218f 100644
--- a/app/queries/lcms/engine/admin_materials_query.rb
+++ b/app/queries/lcms/engine/admin_materials_query.rb
@@ -5,7 +5,7 @@ module Engine
# Usage:
# @materials = AdminMaterialsQuery.call(query_params, page: params[:page])
class AdminMaterialsQuery < BaseQuery
- STRICT_METADATA = %w(grade subject).freeze
+ STRICT_METADATA = %w(subject).freeze
# Returns: ActiveRecord relation
def call
@@ -26,10 +26,15 @@ def call
def filter_by_metadata
metadata_keys.each do |key|
+ next unless q.respond_to?(key)
next unless q[key].present?
@scope =
- if STRICT_METADATA.include?(key.to_s)
+ if key.to_s == 'grades'
+ grades = Array.wrap(q.grades).reject(&:blank?)
+ values = grades.map { _1[/\d+/].nil? ? _1 : _1[/\d+/] }
+ values.any? ? @scope = @scope.where_grade(values) : @scope
+ elsif STRICT_METADATA.include?(key.to_s)
@scope.where_metadata(key => q[key].to_s.downcase)
else
@scope.where_metadata_like(key, q[key])
@@ -38,22 +43,32 @@ def filter_by_metadata
end
def metadata_keys
- DocTemplate.config.dig('metadata', 'service').constantize.materials_metadata.attribute_set.map(&:name)
+ default_keys = DocTemplate.config.dig('metadata', 'service')
+ .constantize.materials_metadata.attribute_set.map(&:name)
+ # From search form comes `grades` field which can contain multiple values
+ default_keys.delete('grade')
+ default_keys.push('grades')
end
def search_by_identifier
+ return unless q.respond_to?(:search_term) && q.search_term.present?
+
# we need the `with_pg_search_rank` scope for this to work with DISTINCT
# See more on: https://github.com/Casecommons/pg_search/issues/238
- @scope = @scope.search_identifier(q.search_term).with_pg_search_rank if q.search_term.present?
+ @scope = @scope.search_identifier(q.search_term).with_pg_search_rank
end
def search_by_file_name
- @scope = @scope.search_name(q.search_file_name).with_pg_search_rank if q.search_file_name.present?
+ return unless q.respond_to?(:search_file_name) && q.search_file_name.present?
+
+ ActiveSupport::Deprecation
+ .warn('Lcms::Engine::Material.search_name has been removed. Refactor your calls accordingly.')
+ # @scope = @scope.search_name(q.search_file_name).with_pg_search_rank
end
def sorted_scope
- @scope = @scope.reorder(:identifier) if q.sort_by.blank? || q.sort_by == 'identifier'
- @scope = @scope.reorder(updated_at: :desc) if q.sort_by == 'last_update'
+ @scope = @scope.reorder('identifier') if q.sort_by.blank? || q.sort_by == 'identifier'
+ @scope = @scope.reorder('updated_at DESC') if q.sort_by == 'last_update'
@scope.distinct
end
end
diff --git a/app/queries/lcms/engine/base_query.rb b/app/queries/lcms/engine/base_query.rb
index f2127f50..d1d1cb49 100644
--- a/app/queries/lcms/engine/base_query.rb
+++ b/app/queries/lcms/engine/base_query.rb
@@ -10,7 +10,8 @@ def self.call(query, pagination = nil)
# query : query params (Hash or OpenStruct)
# pagination : pagination params, if pagination is nil whe return all results
def initialize(query, pagination = nil)
- @q = OpenStruct.new(query) # rubocop:disable Style/OpenStructUse
+ # query is a type of `Struct` with pre-defined and pre-populated fields
+ @q = query
@pagination = pagination
end
diff --git a/app/serializers/lcms/engine/curriculum_resource_serializer.rb b/app/serializers/lcms/engine/curriculum_resource_serializer.rb
deleted file mode 100644
index 1eeae783..00000000
--- a/app/serializers/lcms/engine/curriculum_resource_serializer.rb
+++ /dev/null
@@ -1,72 +0,0 @@
-# frozen_string_literal: true
-
-module Lcms
- module Engine
- class CurriculumResourceSerializer < ActiveModel::Serializer
- attributes :children, :id, :lesson_count, :module_count, :module_sizes,
- :resource, :type, :unit_count, :unit_sizes
-
- def initialize(object, options = {})
- super(object, options)
- @depth = options[:depth] || 0
- @depth_branch = options[:depth_branch]
- end
-
- def resource
- ResourceDetailsSerializer.new(object).as_json
- end
-
- def type
- object.curriculum_type
- end
-
- def children
- return [] if !module? && @depth.zero?
- return [] if !module? && @depth_branch && !@depth_branch.include?(object.id)
-
- object.children
- .includes(:copyright_attributions)
- .eager_load(:standards)
- .ordered.map do |res|
- CurriculumResourceSerializer.new(
- res,
- depth: @depth - 1,
- depth_branch: @depth_branch
- ).as_json
- end
- end
-
- def lesson_count
- count = descendants.select(&:lesson?).count
- object.assessment? ? count + 1 : count
- end
-
- def unit_count
- descendants.select(&:unit?).count
- end
-
- def module?
- type.casecmp('module').zero?
- end
-
- def module_count
- descendants.select(&:module?).count
- end
-
- def module_sizes
- descendants.select(&:module?).map { |r| r.self_and_descendants.lessons.count }
- end
-
- def unit_sizes
- descendants.select(&:unit?).map do |r|
- count = r.self_and_descendants.lessons.count
- r.assessment? ? count + 1 : count
- end
- end
-
- def descendants
- @descendants ||= object.self_and_descendants.ordered
- end
- end
- end
-end
diff --git a/app/serializers/lcms/engine/resource_details_serializer.rb b/app/serializers/lcms/engine/resource_details_serializer.rb
deleted file mode 100644
index f173f00d..00000000
--- a/app/serializers/lcms/engine/resource_details_serializer.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-# frozen_string_literal: true
-
-module Lcms
- module Engine
- # This is a superset of ResourceSerializer, meant to be used were we need
- # associations and other info. Currently is used on ExploreCurriculums
- # (together with the CurriculumResourceSerializer)
- class ResourceDetailsSerializer < ResourceSerializer
- include ResourceHelper
-
- attributes :breadcrumb_title, :copyright, :downloads, :grade, :has_related, :id,
- :opr_description, :opr_standards, :path, :short_title, :subject, :teaser, :time_to_teach,
- :title, :type
-
- def downloads
- serialize_download = lambda do |download|
- next unless download.is_a?(ResourceDownload)
-
- indent = object.pdf_downloads? download.download_category
- {
- id: download.id,
- icon: h.file_icon(download.download.attachment_content_type),
- title: download.download.title,
- url: download_path(download, slug: object.slug),
- preview_url: preview_download_path(id: download, slug: object.slug),
- indent: indent
- }
- end
- object.download_categories.map { |k, v| [k, v.map(&serialize_download)] }
- end
-
- def copyright
- copyrights_text(object)
- end
-
- def has_related # rubocop:disable Naming/PredicateName
- false
- end
-
- def opr_standards
- return unless object.opr? && object.document.present?
-
- DocumentGenerator.document_presenter.new(object.document).standards
- end
-
- private
-
- def h
- ApplicationController.helpers
- end
- end
- end
-end
diff --git a/app/serializers/lcms/engine/resource_instruction_serializer.rb b/app/serializers/lcms/engine/resource_instruction_serializer.rb
deleted file mode 100644
index 44ff3e0f..00000000
--- a/app/serializers/lcms/engine/resource_instruction_serializer.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-# frozen_string_literal: true
-
-module Lcms
- module Engine
- class ResourceInstructionSerializer < ActiveModel::Serializer
- include ResourceHelper
-
- attributes :id, :title, :subject, :teaser, :path, :img, :instruction_type,
- :grade_avg, :time_to_teach
-
- def title
- return object.title if media?
-
- type_name = I18n.t("resource_types.#{object.resource_type}")
- object.grades.present? ? "#{object.grades.to_str} #{type_name}" : type_name
- end
-
- def subject
- object.subject.try(:downcase) || 'default'
- end
-
- def teaser
- object.title
- end
-
- def path
- media? ? media_path(object.id) : generic_path(object)
- end
-
- def img
- object.try(:image_file).try(:url) || placeholder
- end
-
- def instruction_type
- media? ? object.resource_type : :generic
- end
-
- def grade_avg
- object.grades.average
- end
-
- private
-
- def media?
- object.media?
- end
-
- def placeholder
- ActionController::Base.helpers.image_path('resource_placeholder.jpg')
- end
- end
- end
-end
diff --git a/app/serializers/lcms/engine/resource_picker_serializer.rb b/app/serializers/lcms/engine/resource_picker_serializer.rb
deleted file mode 100644
index c11c6724..00000000
--- a/app/serializers/lcms/engine/resource_picker_serializer.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-
-module Lcms
- module Engine
- class ResourcePickerSerializer < ActiveModel::Serializer
- attributes :id, :title
- end
- end
-end
diff --git a/app/serializers/lcms/engine/resource_serializer.rb b/app/serializers/lcms/engine/resource_serializer.rb
index b60116ea..5b4bce47 100644
--- a/app/serializers/lcms/engine/resource_serializer.rb
+++ b/app/serializers/lcms/engine/resource_serializer.rb
@@ -2,14 +2,10 @@
module Lcms
module Engine
- # This is a subset of the previous ResourceSerializer, meant to be used on listings
- # like find_lessons and search cards. We use this instead the full version (ResourceDetailsSerializer)
- # to avoid expensive queries on data we don't need (like downloads, and related)
class ResourceSerializer < ActiveModel::Serializer
include ResourceHelper
- attributes :breadcrumb_title, :grade, :id, :is_assessment, :is_foundational, :is_opr, :is_prerequisite, :path,
- :short_title, :subject, :teaser, :time_to_teach, :title, :type
+ attributes :breadcrumb_title, :grade, :id, :path, :short_title, :subject, :teaser, :time_to_teach, :title, :type
def breadcrumb_title
Breadcrumbs.new(object).title
@@ -19,22 +15,6 @@ def grade
object.grades.average
end
- def is_assessment # rubocop:disable Naming/PredicateName
- object&.assessment? || short_title&.index('assessment').present?
- end
-
- def is_foundational # rubocop:disable Naming/PredicateName
- object.document&.foundational?
- end
-
- def is_opr # rubocop:disable Naming/PredicateName
- object.opr?
- end
-
- def is_prerequisite # rubocop:disable Naming/PredicateName
- object.prerequisite?
- end
-
def path
return document_path(object.document) if object.document? && !object.assessment?
diff --git a/app/services/lcms/engine/bulk_edit_resources_service.rb b/app/services/lcms/engine/bulk_edit_resources_service.rb
index 1898000a..c028a552 100644
--- a/app/services/lcms/engine/bulk_edit_resources_service.rb
+++ b/app/services/lcms/engine/bulk_edit_resources_service.rb
@@ -27,7 +27,7 @@ def edit!
.destroy_all
(after.standard_ids - before.standard_ids).each do |standard_id|
- resource.resource_standards.find_or_create_by!(standard_id: standard_id)
+ resource.resource_standards.find_or_create_by!(standard_id:)
end
resource.metadata['grade'] = after.metadata['grade']
diff --git a/app/services/lcms/engine/document_build_service.rb b/app/services/lcms/engine/document_build_service.rb
index aa9b5610..11bf0bee 100644
--- a/app/services/lcms/engine/document_build_service.rb
+++ b/app/services/lcms/engine/document_build_service.rb
@@ -16,24 +16,23 @@ def initialize(credentials, opts = {})
end
#
- # @return Document ActiveRecord::Model
+ # @param [String] url
+ # @return [Lcms::Engine::Document]
#
- def build_for(url, expand: false)
+ def build_for(url)
@content = download url
- @expand_document = expand
@template = DocTemplate::Template.parse @content
- @errors = @template.metadata_service.errors
+ @errors = @template.metadata_service.errors + @template.documents.values.flat_map(&:errors)
- create_document
- clear_preview_link
-
- content_key = foundational? ? :foundational_content : :original_content
- @document.update! content_key => content
+ @document = create_document
+ @document.update!(original_content: content)
+ clear_preview_link
build
+
@document.create_parts_for(template)
- ActiveSupport::Notifications.instrument EVENT_BUILT, id: document.id
+ ActiveSupport::Notifications.instrument(EVENT_BUILT, id: document.id)
document.activate!
document
@@ -41,39 +40,20 @@ def build_for(url, expand: false)
private
- attr_reader :credentials, :content, :document, :downloader, :expand_document, :options, :template
+ attr_reader :credentials, :content, :document, :downloader, :options, :template
#
# Building the document. Handles workflow:
# Core-first FS-second and FS-first Core-second.
#
def build
- if expand_document
- combine_layout
- combine_activity_metadata
- document.update! build_params
- else
- document.document_parts.delete_all
- document.update! document_params.merge(toc: template.toc, material_ids: template.toc.collect_material_ids)
- end
+ document.document_parts.delete_all
+ document.update! document_params.merge(toc: template.toc, material_ids: template.toc.collect_material_ids)
end
def build_params
- params =
- if foundational?
- {
- foundational_metadata: template.foundational_metadata,
- fs_name: downloader.file.name
- }
- else
- document_params.merge(
- foundational_metadata: document.foundational_metadata,
- fs_name: document.name,
- name: downloader.file.name
- )
- end
-
- params[:toc] = combine_toc
+ params = document_params.merge(fs_name: document.name, name: downloader.file.name)
+ params[:toc] = document.toc
params[:material_ids] = params[:toc].collect_material_ids
params
end
@@ -84,66 +64,21 @@ def clear_preview_link
document.links = links
end
- def combine_activity_metadata
- old_data = document.activity_metadata
- new_data =
- if foundational?
- old_data.concat template.activity_metadata
- old_data
- else
- template.activity_metadata.concat old_data
- template.activity_metadata
- end
- document.activity_metadata = new_data
- end
-
- def combine_layout
- DocumentPart.context_types.each_key do |context_type|
- existing_layout = document.layout(context_type)
- new_layout = template.remove_part :layout, context_type
- new_layout_content =
- if foundational?
- "#{existing_layout.content}#{new_layout[:content]}"
- else
- "#{new_layout[:content]}#{existing_layout.content}"
- end
- existing_layout.update content: new_layout_content
- end
- end
-
- def combine_toc
- modifier = foundational? ? :append : :prepend
- toc = document.toc
- toc.send modifier, template.toc
- toc
- end
-
+ #
# Initiate or update existing document:
- # - fills in original or fs contents
- # - stores specific file_id for each type of a lesson
+ # - fills in original contents
+ # - stores specific file_id
+ #
def create_document
- # rubocop:disable Lint/AmbiguousOperatorPrecedence
- if template.metadata['subject'].presence &&
- template.metadata['subject'].casecmp('ela').zero? || template.prereq?
- @document = Document.actives.find_or_initialize_by(file_id: downloader.file_id)
- else
- @document = foundational? ? find_core_document : find_fs_document
- id_field = foundational? ? :foundational_file_id : :file_id
-
- @expand_document ||= @document.present?
-
- @document[id_field] = downloader.file_id if @document.present?
- @document ||= Document.actives.find_or_initialize_by(id_field => downloader.file_id)
- end
- # rubocop:enable Lint/AmbiguousOperatorPrecedence
+ doc = find_resource
+ doc[:file_id] = downloader.file_id if doc.present?
+ doc || Document.actives.find_or_initialize_by(file_id: downloader.file_id)
end
def document_params
{
activity_metadata: template.metadata_service.try(:activity_metadata),
- agenda_metadata: template.metadata_service.try(:agenda),
css_styles: template.css_styles,
- foundational_metadata: template.metadata_service.try(:foundational_metadata),
name: downloader.file.name,
last_modified_at: downloader.file.modified_time,
last_author_email: downloader.file.last_modifying_user.try(:email_address),
@@ -160,35 +95,11 @@ def download(url)
@downloader.content
end
- #
- # If there is existing lesson with Core-type - return it. Nil otherwise.
- #
- def find_core_document
- return unless (core_doc = find_resource)
- return if core_doc.foundational?
-
- core_doc
- end
-
- #
- # If there is existing lesson with FS-type - return it. Nil otherwise.
- #
- def find_fs_document
- return unless (fs_doc = find_resource)
- return unless fs_doc.foundational?
-
- fs_doc
- end
-
def find_resource
context = DocTemplate.config.dig('metadata', 'context').constantize
dir = context.new(template.metadata.with_indifferent_access).directory
Resource.find_by_directory(dir)&.document
end
-
- def foundational?
- !!template.metadata_service.try(:foundational?)
- end
end
end
end
diff --git a/app/services/lcms/engine/embed_equations.rb b/app/services/lcms/engine/embed_equations.rb
index d1573356..695bfe81 100644
--- a/app/services/lcms/engine/embed_equations.rb
+++ b/app/services/lcms/engine/embed_equations.rb
@@ -4,7 +4,7 @@ module Lcms
module Engine
class EmbedEquations
REDIS_KEY = 'ub-equation:'
- REDIS_KEY_SVG = "#{REDIS_KEY}svg:"
+ REDIS_KEY_SVG = "#{REDIS_KEY}svg:".freeze
class << self
def call(content)
diff --git a/app/services/lcms/engine/google/drive_service.rb b/app/services/lcms/engine/google/drive_service.rb
index bca75a6c..df500bb7 100644
--- a/app/services/lcms/engine/google/drive_service.rb
+++ b/app/services/lcms/engine/google/drive_service.rb
@@ -14,16 +14,20 @@ def self.build(document, options = {})
new document, options
end
+ def self.escape_double_quotes(file_name)
+ file_name.to_s.gsub('"', '\"')
+ end
+
def copy(file_ids, folder_id = parent)
- super file_ids, folder_id
+ super
end
def create_folder(folder_name, parent_id = FOLDER_ID)
- super folder_name, parent_id
+ super
end
def initialize(document, options)
- super google_credentials
+ super(google_credentials)
@document = document
@options = options
end
@@ -32,8 +36,12 @@ def file_id
@file_id ||= begin
folder = @options[:folder_id] || parent
file_name = document.base_filename
+ # Escape double quotes
+ escaped_name = self.class.escape_double_quotes(file_name)
+
response = service.list_files(
- q: %("#{folder}" in parents and name = "#{file_name}" and mimeType = "#{MIME_FILE}" and trashed = false),
+ q: %("#{folder}" in parents and name = "#{escaped_name}" and mimeType = "#{MIME_FILE}" \
+ and trashed = false),
fields: 'files(id)'
)
files = Array.wrap(response&.files)
diff --git a/app/services/lcms/engine/google/script_service.rb b/app/services/lcms/engine/google/script_service.rb
index 10c388b6..4f7c3e2c 100644
--- a/app/services/lcms/engine/google/script_service.rb
+++ b/app/services/lcms/engine/google/script_service.rb
@@ -48,7 +48,7 @@ def gdoc_template_id
end
def parameters
- [document.cc_attribution, document.full_breadcrumb, document.short_url]
+ [document.cc_attribution, document.full_breadcrumb, document_url(document)]
end
def service
diff --git a/app/services/lcms/engine/html_sanitizer.rb b/app/services/lcms/engine/html_sanitizer.rb
index 62c50bc7..03af5ed9 100644
--- a/app/services/lcms/engine/html_sanitizer.rb
+++ b/app/services/lcms/engine/html_sanitizer.rb
@@ -5,10 +5,10 @@
module Lcms
module Engine
class HtmlSanitizer # rubocop:disable Metrics/ClassLength
- LIST_STYLE_RE = /\.lst-(\S+)[^{}]+>\s*(?:li:before)\s*{\s*content[^{}]+counter\(lst-ctn-\1,([^)]+)\)/.freeze
+ LIST_STYLE_RE = /\.lst-(\S+)[^{}]+>\s*(?:li:before)\s*{\s*content[^{}]+counter\(lst-ctn-\1,([^)]+)\)/
CLEAN_ELEMENTS = %w(a div h1 h2 h3 h4 h5 h6 p table).join(',')
GDOC_REMOVE_EMPTY_SELECTOR = '.o-ld-activity'
- LINK_UNDERLINE_REGEX = /text-decoration\s*:\s*underline/i.freeze
+ LINK_UNDERLINE_REGEX = /text-decoration\s*:\s*underline/i
SKIP_P_CHECK = %w(ul ol table).freeze
STRIP_ELEMENTS = %w(a div h1 h2 h3 h4 h5 h6 p span table).freeze
@@ -35,8 +35,10 @@ def sanitize_css(css)
# Removes all empty nodes before first one filled in
#
def strip_content(nodes)
- nodes.xpath('./*').each do |node|
- break if keep_node?(node)
+ # we need to remove empty nodes unless we meet some node we need to keep
+ # or text (tag placeholder)
+ nodes.xpath('./node()').each do |node|
+ break if node.text? || keep_node?(node)
node.remove
end
@@ -169,7 +171,7 @@ def post_clean_styles(nodes)
].freeze
private_constant :FONT_STYLES_RE
- BORDER_RE = /border-\w+-width:\s*0\w+;?/.freeze
+ BORDER_RE = /border-\w+-width:\s*0\w+;?/
private_constant :BORDER_RE
BORDER_REPLACE_RE = {
@@ -186,7 +188,7 @@ def post_clean_styles(nodes)
].freeze
private_constant :SUB_SUP_RE
- SUB_SUP_STYLE_RE = /vertical-align:\s*(sub|super);?/i.freeze
+ SUB_SUP_STYLE_RE = /vertical-align:\s*(sub|super);?/i
private_constant :SUB_SUP_STYLE_RE
def add_css_class(el, *classes)
@@ -234,7 +236,7 @@ def fix_external_target(node)
def fix_inline_img(node)
# TODO: test if it's working fine with all inline images
- node['src'] = node['src'].gsub!(/%(20|0A)/, '') if node['src'].to_s.start_with?('data:')
+ node['src'] = node['src'].gsub(/%(20|0A)/, '') if node['src'].to_s.start_with?('data:')
end
def fix_googlechart_img(node)
@@ -332,7 +334,7 @@ def post_processing_images_gdoc(nodes)
css = ':not(.u-ld-not-image-wrap) > img:not([src*=googleapis]):not(.o-ld-icon):not(.o-ld-latex)'
nodes.css(css).each do |img|
- img = img.parent.replace(img) if img.parent.name == 'span' || img.parent.name == 'p'
+ img = img.parent.replace(img) if %w(span p).include?(img.parent.name)
img.replace(%(
@@ -380,7 +382,7 @@ def remove_spans_wo_attrs(env)
def remove_empty_paragraphs(env)
node = env[:node]
- node.unlink if node.element? && (node.name == 'p' || node.name == 'span') && node.inner_html.squish.blank?
+ node.unlink if node.element? && %w(p span).include?(node.name) && node.inner_html.squish.blank?
end
# replace inline borders style with width = 0 as they're not processing correct for pdf
diff --git a/app/services/lcms/engine/import_service.rb b/app/services/lcms/engine/import_service.rb
new file mode 100644
index 00000000..7bb93168
--- /dev/null
+++ b/app/services/lcms/engine/import_service.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Lcms
+ module Engine
+ class ImportService
+ #
+ # @param [String] url
+ # @return [String]
+ #
+ def self.call(url)
+ google_credentials = Lt::Google::Api::Auth::Cli.new.credentials
+ downloader = Lt::Lcms::Lesson::Downloader::Base.new google_credentials, url
+ downloader.download(mime_type: 'text/csv').content
+ end
+ end
+ end
+end
diff --git a/app/services/lcms/engine/material_build_service.rb b/app/services/lcms/engine/material_build_service.rb
index a48743f9..30ff9bb0 100644
--- a/app/services/lcms/engine/material_build_service.rb
+++ b/app/services/lcms/engine/material_build_service.rb
@@ -7,7 +7,7 @@ module Lcms
module Engine
class MaterialBuildService
EVENT_BUILT = 'material:built'
- PDF_EXT_RE = /\.pdf$/.freeze
+ PDF_EXT_RE = /\.pdf$/
attr_reader :errors
@@ -34,11 +34,11 @@ def build_from_pdf # rubocop:disable Metrics/AbcSize
title = @downloader.file.name.sub(PDF_EXT_RE, '')
identifier = "#{title.downcase}#{ContentPresenter::PDF_EXT}"
- metadata = DocTemplate::Objects::MaterialMetadata.build_from_pdf(identifier: identifier, title: title).as_json
+ metadata = DocTemplate::Objects::MaterialMetadata.build_from_pdf(identifier:, title:).as_json
material.update!(
material_params.merge(
- identifier: identifier,
- metadata: metadata
+ identifier:,
+ metadata:
)
)
@@ -63,7 +63,7 @@ def build_from_gdoc
create_material
content = @downloader.download.content
template = DocTemplate::Template.parse(content, type: :material)
- @errors = template.metadata_service.errors
+ @errors = template.metadata_service.errors + template.documents.values.flat_map(&:errors)
metadata = template.metadata_service.options_for(:default)[:metadata]
material.update!(
diff --git a/app/services/lcms/engine/material_preview_generator.rb b/app/services/lcms/engine/material_preview_generator.rb
index 345fa1c8..f3984e9e 100644
--- a/app/services/lcms/engine/material_preview_generator.rb
+++ b/app/services/lcms/engine/material_preview_generator.rb
@@ -6,8 +6,8 @@ module Engine
# Generates and uploads PDF/GDoc files for material
#
class MaterialPreviewGenerator
- GDOC_RE = %r{docs.google.com/document/d/([^/]*)}i.freeze
- GDOC_BROKEN_RE = %r{/open\?id=$}i.freeze
+ GDOC_RE = %r{docs.google.com/document/d/([^/]*)}i
+ GDOC_BROKEN_RE = %r{/open\?id=$}i
PDF_S3_FOLDER = 'temp-materials-pdf'
attr_reader :error, :url
@@ -30,7 +30,7 @@ def perform
attr_reader :material, :options
- def assign_document
+ def assign_document # rubocop:disable Naming/PredicateMethod
document = material.documents.last || Document.last
unless document.present?
@error = "Can't generate PDF for preview: no documents exist"
@@ -45,14 +45,14 @@ def generate_gdoc
folder_id = options[:folder_id]
file_id = material.preview_links['gdoc'].to_s.match(GDOC_RE)&.[](1)
@url = DocumentExporter::Gdoc::Material.new(material)
- .export_to(folder_id, file_id: file_id)
+ .export_to(folder_id, file_id:)
.url
return true if @url !~ GDOC_BROKEN_RE
raise 'GDoc generation failed. Please try again later'
end
- def generate_pdf
+ def generate_pdf # rubocop:disable Naming/PredicateMethod
pdf_filename = "#{PDF_S3_FOLDER}/#{material.id}/#{material.base_filename}#{ContentPresenter::PDF_EXT}"
pdf = DocumentExporter::Pdf::Material.new(material).export
@url = S3Service.upload pdf_filename, pdf
diff --git a/app/services/lcms/engine/react_materials_resolver.rb b/app/services/lcms/engine/react_materials_resolver.rb
index 7724a644..c6b114e5 100644
--- a/app/services/lcms/engine/react_materials_resolver.rb
+++ b/app/services/lcms/engine/react_materials_resolver.rb
@@ -23,7 +23,7 @@ def replace_react(node, document) # rubocop:disable Metrics/PerceivedComplexity
else
JSON.parse(data)
end
- node.remove && return if (raw_props['material_ids']).empty?
+ node.remove && return if raw_props['material_ids'].empty?
props = PreviewsMaterialSerializer.new(raw_props, document)
node.remove && return if props.data && props.data.empty?
diff --git a/app/services/lcms/engine/related_instructions_service.rb b/app/services/lcms/engine/related_instructions_service.rb
deleted file mode 100644
index 4d13c597..00000000
--- a/app/services/lcms/engine/related_instructions_service.rb
+++ /dev/null
@@ -1,57 +0,0 @@
-# frozen_string_literal: true
-
-module Lcms
- module Engine
- class RelatedInstructionsService
- attr_reader :resource, :expanded, :has_more, :instructions
-
- def initialize(resource, expanded)
- @resource = resource
- @expanded = expanded
-
- find_related_instructions
- end
-
- private
-
- def find_related_instructions
- instructions = expanded ? expanded_instructions : colapsed_instructions
-
- @has_more = true if videos.size > instructions.size
- @instructions = instructions.map do |inst|
- ResourceInstructionSerializer.new(inst)
- end
- end
-
- def expanded_instructions
- videos
- end
-
- def colapsed_instructions
- # show 4 videos
- videos[0...4]
- end
-
- def videos
- @videos ||= find_related_through_standards(limit: 4) do |standard|
- standard.resources.media.distinct
- end
- end
-
- def find_related_through_standards(limit:, &_block)
- related = resource.standards.flat_map do |standard|
- qset = yield standard
- qset = qset.limit(limit) unless expanded # limit each part
- qset
- end.uniq
-
- if expanded
- related
- else
- @has_more = true if related.count > limit
- related[0...limit] # limit total
- end
- end
- end
- end
-end
diff --git a/app/services/lcms/engine/s3_service.rb b/app/services/lcms/engine/s3_service.rb
index 979a428e..7cfe1165 100644
--- a/app/services/lcms/engine/s3_service.rb
+++ b/app/services/lcms/engine/s3_service.rb
@@ -12,6 +12,43 @@ def self.create_object(key)
.object(key)
end
+ # Reads data from an S3 object specified by the given URI.
+ #
+ # @param uri [URI] The URI of the S3 object. The URI should include the bucket name in the host
+ # and the object key in the path.
+ # @return [String] The content of the S3 object as a string.
+ #
+ # @raise [RuntimeError] If the S3 object is not found or if there is a service error.
+ # @raise [Aws::S3::Errors::NoSuchKey] If the specified key does not exist in the bucket.
+ # @raise [Aws::S3::Errors::ServiceError] If there is an error with the S3 service.
+ # @raise [StandardError] For any other unexpected errors.
+ #
+ def self.read_data_from_s3(uri)
+ # Extract bucket and key from the URL
+ bucket = uri.host.split('.').first
+ key = URI.decode_www_form_component(uri.path[1..]) # Decode URL-encoded characters
+
+ # Initialize the S3 client
+ s3_client = Aws::S3::Client.new(
+ region: ENV.fetch('AWS_REGION', 'us-east-1'),
+ access_key_id: ENV.fetch('AWS_ACCESS_KEY_ID', nil),
+ secret_access_key: ENV.fetch('AWS_SECRET_ACCESS_KEY', nil)
+ )
+
+ # Fetch the object from S3
+ response = s3_client.get_object(bucket:, key:)
+ response.body.read
+ rescue Aws::S3::Errors::NoSuchKey => e
+ Rails.logger.error "S3 Error: Object not found - #{e.message}"
+ raise "S3 object not found: #{key}"
+ rescue Aws::S3::Errors::ServiceError => e
+ Rails.logger.error "S3 Service Error: #{e.message}"
+ raise "S3 service error: #{e.message}"
+ rescue StandardError => e
+ Rails.logger.error "Unexpected error while fetching S3 object: #{e.message}"
+ raise "Unexpected error: #{e.message}"
+ end
+
#
# Upload data to the specified resource by key
#
@@ -24,7 +61,8 @@ def self.create_object(key)
def self.upload(key, data, options = {})
object = create_object key
options = options.merge(
- body: data
+ body: data,
+ cache_control: 'public, max-age=0, must-revalidate'
)
object.put(options)
object.public_url
diff --git a/app/services/lcms/engine/sketch_compiler.rb b/app/services/lcms/engine/sketch_compiler.rb
deleted file mode 100644
index a96ca447..00000000
--- a/app/services/lcms/engine/sketch_compiler.rb
+++ /dev/null
@@ -1,33 +0,0 @@
-# frozen_string_literal: true
-
-require 'base64'
-
-module Lcms
- module Engine
- class SketchCompiler
- def initialize(user_id, user_ip, version)
- @user_id = user_id
- @user_ip = user_ip
- @version = version
- end
-
- #
- # Returns HTTParty::Response object
- #
- def compile(core_url, foundational_url)
- api_url = ENV.fetch('UB_COMPONENTS_API_URL')
- url = [api_url, @version, 'compile'].join('/')
- post_params = {
- body: {
- uid: Base64.encode64("#{@user_id}@#{@user_ip}"),
- url: core_url,
- foundational_url: foundational_url
- },
- headers: { 'Authorization' => %(Token token="#{ENV.fetch 'UB_COMPONENTS_API_TOKEN'}") },
- timeout: 5 * 60
- }
- HTTParty.post url, post_params
- end
- end
- end
-end
diff --git a/app/services/lcms/engine/standards_import_service.rb b/app/services/lcms/engine/standards_import_service.rb
new file mode 100644
index 00000000..acbdf4cc
--- /dev/null
+++ b/app/services/lcms/engine/standards_import_service.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'csv'
+
+module Lcms
+ module Engine
+ class StandardsImportService < ImportService
+ class << self
+ #
+ # @param [String] url
+ #
+ def call(url)
+ ActiveRecord::Base.transaction do
+ Lcms::Engine::Standard.destroy_all
+ CSV.parse(super, headers: true, &method(:create_from_csv_row))
+ end
+ after_reimport_hook
+ end
+
+ def after_reimport_hook; end
+
+ private
+
+ #
+ # @param [Array]
+ #
+ def create_from_csv_row(_row)
+ raise NotImplementedError
+ end
+ end
+ end
+ end
+end
diff --git a/app/uploaders/download_uploader.rb b/app/uploaders/download_uploader.rb
deleted file mode 100644
index 2806fa4a..00000000
--- a/app/uploaders/download_uploader.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-class DownloadUploader < CarrierWave::Uploader::Base
- def store_dir
- "attachments/#{model.id}"
- end
-
- def fog_attributes
- { 'Content-Disposition' => 'attachment' }
- end
-end
diff --git a/app/views/devise/confirmations/new.html.erb b/app/views/devise/confirmations/new.html.erb
index 0dba595d..670d177e 100644
--- a/app/views/devise/confirmations/new.html.erb
+++ b/app/views/devise/confirmations/new.html.erb
@@ -1,4 +1,4 @@
-
+
Resend confirmation instructions
diff --git a/app/views/devise/passwords/edit.html.erb b/app/views/devise/passwords/edit.html.erb
index c9f20fb0..5f6c08d0 100644
--- a/app/views/devise/passwords/edit.html.erb
+++ b/app/views/devise/passwords/edit.html.erb
@@ -1,4 +1,4 @@
-
+
Change your password
diff --git a/app/views/devise/passwords/new.html.erb b/app/views/devise/passwords/new.html.erb
index e466090f..9a2382a1 100644
--- a/app/views/devise/passwords/new.html.erb
+++ b/app/views/devise/passwords/new.html.erb
@@ -1,4 +1,4 @@
-
+
Forgot your password?
diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb
index ba2d2f64..96946b19 100644
--- a/app/views/devise/registrations/edit.html.erb
+++ b/app/views/devise/registrations/edit.html.erb
@@ -37,7 +37,7 @@
Cancel my account
-
Unhappy? <%= button_to "Cancel my account", registration_path(resource_name), data: { confirm: "Are you sure?" }, method: :delete %>
+
Unhappy? <%= button_to 'Cancel my account', registration_path(resource_name), data: { confirm: 'Are you sure?', 'turbo-method': :delete }, method: :delete %>
<%= link_to "Back", :back %>
diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb
index 1dfc3fe7..7ebe6c45 100644
--- a/app/views/devise/registrations/new.html.erb
+++ b/app/views/devise/registrations/new.html.erb
@@ -1,4 +1,4 @@
-
+
Register
diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb
index 5936fbb9..8e6e4d2d 100644
--- a/app/views/devise/sessions/new.html.erb
+++ b/app/views/devise/sessions/new.html.erb
@@ -1,29 +1,28 @@
-<% content_for :above_flash do %>
-
- Welcome to <%= ENV['APP_NAME'] %>!
-
-<% end %>
-
-
-
-
-
Log in
-
+
+
+
Login
- <%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
- <%= f.email_field :email, autofocus: true, class: 'o-input-placehoder--medium-gray', placeholder: 'Enter email address' %>
- <%= f.password_field :password, autocomplete: 'off', class: 'o-input-placehoder--medium-gray', placeholder: 'Enter password' %>
-
- <%= f.submit 'Log in', class: 'button o-btn--xs-full warning o-btn--xs-full u-margin-right--base ' %>
- <%= link_to 'Register', new_registration_path(resource_name), class: 'button o-btn--xs-full o-btn--bordered-yellow u-color--base o-btn--xs-full' %>
+ <%= form_for(resource, as: resource_name, url: session_path(resource_name), html: { 'data-turbo': 'false' }) do |f| %>
+
+ <%= f.email_field :email, autofocus: true, class: 'form-control', placeholder: 'Enter email address', id: 'email-field' %>
+
+
+ <%= f.password_field :password, autocomplete: 'off', class: 'form-control', placeholder: 'Enter password', id: 'password-field' %>
+
+
+
+ <%= f.submit 'Log in', class: 'btn btn-primary btn-block mb-4' %>
+
+
+ <%= link_to 'Register', new_registration_path(resource_name), class: '' %>
- <% end %>
+
-
+
<%= link_to 'Forgot your password?', new_password_path(resource_name) %>
<%= link_to "Didn't receive confirmation instructions?", new_user_confirmation_path %>
-
+ <% end %>
diff --git a/app/views/layouts/lcms/engine/admin.html.erb b/app/views/layouts/lcms/engine/admin.html.erb
index 76537551..54356179 100644
--- a/app/views/layouts/lcms/engine/admin.html.erb
+++ b/app/views/layouts/lcms/engine/admin.html.erb
@@ -4,24 +4,19 @@
<%= "#{t('ui.unbounded')} - #{page_title}" %>
-
- <%= render partial: 'lcms/engine/shared/favicon' %>
- <%= stylesheet_link_tag 'lcms/engine/lcms_engine_admin', media: 'all', 'data-turbolinks-track' => 'reload' %>
<%= csrf_meta_tags %>
- <%# NOTE: JS served via Assets pipeline should be added first %>
- <%= javascript_include_tag 'lcms/engine/admin/lcms_engine_application', 'data-turbolinks-track' => 'reload' %>
- <%= lcms_engine_javascript_pack_tag 'lcms_engine_admin' %>
+
+
+ <%= javascript_include_tag Ckeditor.cdn_url, "data-turbo-track": "reload", defer: true %>
-
- <%= render partial: 'lcms/engine/admin/shared/header' %>
-
- <%= render partial: 'lcms/engine/shared/flash' %>
- <%= yield %>
-
+ <%= render partial: 'lcms/engine/shared/header' %>
+ <%= render partial: 'lcms/engine/shared/flash' %>
+
+ <%= yield %>
- <%= render partial: 'lcms/engine/shared/footer' %>
+ <%#= render partial: 'lcms/engine/shared/footer' %>