Merge branch 'develop' into features/asset-sync

This commit is contained in:
Martin Artnik 2023-12-13 16:05:18 +01:00
commit 9b97e397ed
104 changed files with 1553 additions and 642 deletions

View file

@ -35,6 +35,10 @@
"template": 240,
"tabWidth": 2
}
],
"comma-dangle": [
"error",
"never"
]
},
"globals": {

View file

@ -57,6 +57,7 @@ gem 'jbuilder' # JSON structures via a Builder-style DSL
gem 'logging', '~> 2.0.0'
gem 'nested_form_fields'
gem 'nokogiri', '~> 1.14.3' # HTML/XML parser
gem 'noticed'
gem 'rails_autolink', '~> 1.1', '>= 1.1.6'
gem 'rgl' # Graph framework for project diagram calculations
gem 'roo', '~> 2.10.0' # Spreadsheet parser

View file

@ -50,7 +50,7 @@ GIT
GIT
remote: https://github.com/scinote-eln/yomu
revision: 09b7b4910f59453970aab03d7b3ddb60b41db89a
revision: fb518a5fbab82f692dea4ae1fdf30eae5df62590
branch: master
specs:
yomu (0.2.4)
@ -301,6 +301,8 @@ GEM
discard (1.2.1)
activerecord (>= 4.2, < 8)
docile (1.4.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
doorkeeper (5.6.6)
railties (>= 5)
down (5.4.1)
@ -324,6 +326,9 @@ GEM
faraday-net_http (3.0.2)
fastimage (2.2.7)
ffi (1.15.5)
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
figaro (1.2.0)
thor (>= 0.14.0, < 2)
fugit (1.8.1)
@ -338,6 +343,14 @@ GEM
nokogiri (~> 1.0)
hashdiff (1.0.1)
hashie (5.0.0)
http (5.1.1)
addressable (~> 2.8)
http-cookie (~> 1.0)
http-form_data (~> 2.2)
llhttp-ffi (~> 0.4.0)
http-cookie (1.0.5)
domain_name (~> 0.5)
http-form_data (2.3.0)
httparty (0.21.0)
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
@ -387,6 +400,9 @@ GEM
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
little-plugger (1.1.4)
llhttp-ffi (0.4.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
logging (2.0.0)
little-plugger (~> 1.1)
multi_json (~> 1.10)
@ -434,6 +450,9 @@ GEM
racc (~> 1.4)
nokogiri (1.14.5-x86_64-linux)
racc (~> 1.4)
noticed (1.6.3)
http (>= 4.0.0)
rails (>= 5.2.0)
oauth2 (2.0.9)
faraday (>= 0.17.3, < 3.0)
jwt (>= 1.0, < 3.0)
@ -661,6 +680,9 @@ GEM
uglifier (4.2.0)
execjs (>= 0.3.0, < 3)
underscore-rails (1.8.3)
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode-display_width (2.4.2)
uniform_notifier (1.16.0)
version_gem (1.1.3)
@ -748,6 +770,7 @@ DEPENDENCIES
nested_form_fields
newrelic_rpm
nokogiri (~> 1.14.3)
noticed
omniauth (~> 2.1)
omniauth-azure-activedirectory-v2
omniauth-linkedin-oauth2

View file

@ -1 +1 @@
1.29.4
1.29.5.1

View file

@ -0,0 +1,81 @@
/* global I18n dropdownSelector HelperModule animateSpinner */
/* eslint-disable no-use-before-define */
(function() {
function initAssignedUsersSelector() {
var myModuleUserSelector = '#module-assigned-users-selector';
dropdownSelector.init(myModuleUserSelector, {
closeOnSelect: true,
labelHTML: true,
tagClass: 'my-module-user-tags',
tagLabel: (data) => {
return `<img class="img-responsive block-inline" src="${data.params.avatar_url}" alt="${data.label}"/>
<span class="user-full-name block-inline">${data.label}</span>`;
},
customDropdownIcon: () => {
return '';
},
optionLabel: (data) => {
if (data.params.avatar_url) {
return `<span class="global-avatar-container" style="margin-top: 10px">
<img src="${data.params.avatar_url}" alt="${data.label}"/></span>
<span style="margin-left: 10px">${data.label}</span>`;
}
return data.label;
},
onSelect: function() {
var selectElement = $(myModuleUserSelector);
var lastUser = selectElement.next().find('.ds-tags').last();
var lastUserId = lastUser.find('.tag-label').data('ds-tag-id');
var newUser;
if (lastUserId > 0) {
newUser = {
user_my_module: {
user_id: lastUserId
}
};
} else {
newUser = {
user_my_module: {
user_id: selectElement.val()
}
};
}
$.post(selectElement.data('users-create-url'), newUser, function(result) {
dropdownSelector.removeValue(myModuleUserSelector, 0, '', true);
dropdownSelector.addValue(myModuleUserSelector, {
value: result.user.id,
label: result.user.full_name,
params: {
avatar_url: result.user.avatar_url,
user_module_id: result.user.user_module_id
}
}, true);
}).fail(function() {
dropdownSelector.removeValue(myModuleUserSelector, lastUserId, '', true);
});
},
onUnSelect: (id) => {
var umID = $(myModuleUserSelector).find(`option[value="${id}"]`).data('params').user_module_id;
$.ajax({
url: `${$(myModuleUserSelector).data('update-module-users-url')}/${umID}`,
type: 'DELETE',
success: () => {
dropdownSelector.closeDropdown(myModuleUserSelector);
},
error: (r) => {
if (r.status === 403) {
HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger');
}
}
});
}
}).getContainer(myModuleUserSelector).addClass('my-module-users-container');
}
initAssignedUsersSelector();
}());

View file

@ -1,7 +1,7 @@
/* global HelperModule PerfectScrollbar */
// eslint-disable-next-line no-unused-vars
var ShareModal = (function() {
const ShareModal = (function() {
function init() {
var form = $('.share-repo-modal').find('form');
var sharedCBs = form.find("input[name='share_team_ids[]']");
@ -13,21 +13,18 @@ var ShareModal = (function() {
form.find('.teams-list').find('input.sci-checkbox, .permission-selector')
.toggleClass('hidden', selectAllCheckbox.is(':checked'));
form.find('.all-teams .sci-toggle-checkbox')
.toggleClass('hidden', !selectAllCheckbox.is(':checked'))
.attr('disabled', !selectAllCheckbox.is(':checked'));
.toggleClass('hidden', !selectAllCheckbox.is(':checked'));
selectAllCheckbox.change(function() {
form.find('.teams-list').find('input.sci-checkbox, .permission-selector')
.toggleClass('hidden', this.checked);
form.find('.all-teams .sci-toggle-checkbox').toggleClass('hidden', !this.checked)
.attr('disabled', !this.checked);
form.find('.all-teams .sci-toggle-checkbox').toggleClass('hidden', !this.checked);
});
sharedCBs.change(function() {
var selectedTeams = form.find('.teams-list .sci-checkbox:checked').length;
form.find('#select_all_teams').prop('indeterminate', selectedTeams > 0);
$('#editable_' + this.value).toggleClass('hidden', !this.checked)
.attr('disabled', !this.checked);
$('#editable_' + this.value).toggleClass('hidden', !this.checked);
});
if (form.find('.teams-list').length) new PerfectScrollbar(form.find('.teams-list')[0]);

View file

@ -84,8 +84,6 @@
.sci-navigation--notificaitons-flyout-notification {
border-bottom: $border-tertiary;
display: grid;
grid-template-columns: max-content auto;
padding: 1rem 0;
.sci-navigation--notificaitons-flyout-notification-icon {
@ -94,7 +92,7 @@
border-radius: 50%;
color: $color-white;
display: flex;
grid-row: 1 / 4;
grid-row: 1 / 5;
height: 2rem;
justify-content: center;
margin-right: .75rem;

View file

@ -52,12 +52,6 @@ input[type="checkbox"].sci-toggle-checkbox {
}
}
&:focus + .sci-toggle-checkbox-label {
box-shadow: 0 0 0 4px var(--sn-science-blue-hover);
outline: 2px solid transparent;
outline-offset: 2px;
}
&.hidden + .sci-toggle-checkbox-label {
display: none;
}

View file

@ -98,6 +98,7 @@
.step-elements {
padding-left: 2.5rem;
padding-right: 2.5rem;
.step-timestamp {
position: relative;

View file

@ -71,6 +71,7 @@ module AccessPermissions
log_activity(:assign_user_to_project, { user_target: user_assignment.user.id,
role: user_assignment.user_role.name })
created_count += 1
propagate_job(user_assignment)
end
@ -100,6 +101,7 @@ module AccessPermissions
end
propagate_job(user_assignment, destroy: true)
log_activity(:unassign_user_from_project, { user_target: user_assignment.user.id,
role: user_assignment.user_role.name })

View file

@ -48,6 +48,19 @@ module AssetsActions
asset_name: { id: asset.id, value_for: 'file_name' },
action: action
})
elsif asset.repository_cell.present?
repository = asset.repository_cell.repository_row.repository
Activities::CreateActivityService
.call(activity_type: :edit_image_on_inventory_item,
owner: current_user,
subject: repository,
team: repository.team,
message_items: {
repository: repository.id,
repository_row: asset.repository_cell.repository_row.id,
asset_name: { id: asset.id, value_for: 'file_name' },
action: action
})
end
end
end

View file

@ -48,6 +48,7 @@ module StepsActions
smart_annotation_notification(
old_text: old_text,
new_text: checklist_item.text,
subject: step.protocol,
title: t('notifications.checklist_title',
user: current_user.full_name,
step: step.name),
@ -59,6 +60,7 @@ module StepsActions
smart_annotation_notification(
old_text: old_text,
new_text: step_text.text,
subject: step.protocol,
title: t('notifications.step_text_title',
user: current_user.full_name,
step: step.name),
@ -70,6 +72,7 @@ module StepsActions
smart_annotation_notification(
old_text: old_text,
new_text: checklist.name,
subject: step.protocol,
title: t('notifications.checklist_title',
user: current_user.full_name,
step: step.name),
@ -81,6 +84,7 @@ module StepsActions
smart_annotation_notification(
old_text: old_text,
new_text: step.description,
subject: step.protocol,
title: t('notifications.step_description_title',
user: current_user.full_name,
step: step.name),

View file

@ -602,6 +602,7 @@ class ExperimentsController < ApplicationController
smart_annotation_notification(
old_text: old_text,
new_text: @experiment.description,
subject: @experiment,
title: t('notifications.experiment_annotation_title',
experiment: @experiment.name,
user: current_user.full_name),

View file

@ -264,6 +264,7 @@ class MyModuleRepositoriesController < ApplicationController
smart_annotation_notification(
old_text: nil,
new_text: comment,
subject: module_repository_row.repository_row,
title: t('notifications.my_module_consumption_comment_annotation_title',
repository_item: module_repository_row.repository_row.name,
repository: @repository.name,

View file

@ -67,6 +67,7 @@ class MyModulesController < ApplicationController
subject: @my_module,
message_items: { my_module: @my_module.id }
)
log_user_designation_activity
redirect_to canvas_experiment_path(@experiment) if params[:my_module][:view_mode] == 'canvas'
rescue ActiveRecord::RecordInvalid
render json: @my_module.errors, status: :unprocessable_entity
@ -529,6 +530,14 @@ class MyModulesController < ApplicationController
log_activity(type_of, @my_module, message_items)
end
def log_user_designation_activity
users = User.where.not(id: current_user.id).where(id: params[:my_module][:user_ids])
users.each do |user|
log_activity(:designate_user_to_my_module, @my_module, { user_target: user.id })
end
end
def log_activity(type_of, my_module = nil, message_items = {})
my_module ||= @my_module
message_items = { my_module: my_module.id }.merge(message_items)
@ -552,6 +561,7 @@ class MyModulesController < ApplicationController
smart_annotation_notification(
old_text: old_text,
new_text: @my_module.description,
subject: @my_module,
title: t('notifications.my_module_description_annotation_title',
my_module: @my_module.name,
user: current_user.full_name),
@ -566,6 +576,7 @@ class MyModulesController < ApplicationController
smart_annotation_notification(
old_text: old_text,
new_text: @my_module.protocol.description,
subject: @my_module,
title: t('notifications.my_module_protocol_annotation_title',
my_module: @my_module.name,
user: current_user.full_name),

View file

@ -1079,6 +1079,7 @@ class ProtocolsController < ApplicationController
smart_annotation_notification(
old_text: old_text,
new_text: @protocol.description,
subject: @protocol,
title: t('notifications.protocol_description_annotation_title',
user: current_user.full_name,
protocol: @protocol.name),

View file

@ -357,6 +357,7 @@ class RepositoriesController < ApplicationController
if repositories.present? && current_user.has_available_exports?
current_user.increase_daily_exports_counter!
RepositoriesExportJob.perform_later(repositories.pluck(:id), user_id: current_user.id, team_id: current_team.id)
log_activity(:export_inventories, inventories: repositories.pluck(:name).join(', '))
render json: { message: t('zip_export.export_request_success') }
else
render json: { message: t('zip_export.export_error') }, status: :unprocessable_entity
@ -364,14 +365,18 @@ class RepositoriesController < ApplicationController
end
def export_repository_stock_items
row_ids = @repository.repository_rows.where(id: params[:row_ids]).pluck(:id)
if row_ids.any?
repository_rows = @repository.repository_rows.where(id: params[:row_ids]).pluck(:id, :name)
if repository_rows.any?
RepositoryStockZipExportJob.perform_later(
user_id: current_user.id,
params: {
repository_row_ids: row_ids
repository_row_ids: repository_rows.map { |row| row[0] }
}
)
log_activity(
:export_inventory_stock_consumption,
inventory_items: repository_rows.map { |row| row[1] }.join(', ')
)
render json: { message: t('zip_export.export_request_success') }
else
render json: { message: t('zip_export.export_error') }, status: :unprocessable_entity
@ -532,14 +537,23 @@ class RepositoriesController < ApplicationController
end
def log_activity(type_of, message_items = {})
message_items = { repository: @repository.id }.merge(message_items)
if @repository.present?
message_items = { repository: @repository.id }.merge(message_items)
Activities::CreateActivityService
.call(activity_type: type_of,
owner: current_user,
subject: @repository,
team: @repository.team,
message_items: message_items)
Activities::CreateActivityService
.call(activity_type: type_of,
owner: current_user,
subject: @repository,
team: @repository.team,
message_items: message_items)
else
Activities::CreateActivityService
.call(activity_type: type_of,
owner: current_user,
subject: @current_team,
team: @current_team,
message_items: message_items)
end
end
def set_breadcrumbs_items

View file

@ -452,6 +452,7 @@ class RepositoryRowsController < ApplicationController
smart_annotation_notification(
old_text: old_text,
new_text: cell.value.data,
subject: cell.repository_column.repository,
title: t('notifications.repository_annotation_title',
user: current_user.full_name,
column: cell.repository_column.name,

View file

@ -100,6 +100,7 @@ module ResultElements
smart_annotation_notification(
old_text: (old_text if old_text),
new_text: @result_text.text,
subject: @result,
title: t('notifications.result_annotation_title',
result: @result.name,
user: current_user.full_name),

View file

@ -121,6 +121,7 @@ class ResultTextsController < ApplicationController
smart_annotation_notification(
old_text: (old_text if old_text),
new_text: @result_text.text,
subject: @result,
title: t('notifications.result_annotation_title',
result: @result.name,
user: current_user.full_name),

View file

@ -60,6 +60,7 @@ class TeamRepositoriesController < ApplicationController
def check_sharing_permissions
render_403 unless can_share_repository?(@repository)
render_403 if !@repository.shareable_write? && update_params[:write_permissions].present?
end
def teams_to_share

View file

@ -4,22 +4,17 @@ class UserNotificationsController < ApplicationController
prepend_before_action -> { request.env['devise.skip_trackable'] = true }, only: :unseen_counter
def index
page = (params[:page] || 1).to_i
notifications = load_notifications.page(page).per(Constants::INFINITE_SCROLL_LIMIT).without_count
page = (params.dig(:page, :number) || 1).to_i
notifications = load_notifications.page(page).per(Constants::INFINITE_SCROLL_LIMIT)
render json: {
notifications: notification_serializer(notifications),
next_page: notifications.next_page
}
render json: notifications, each_serializer: NotificationSerializer
UserNotification.where(
notification_id: notifications.except(:select).where.not(type_of: 2).select(:id)
).seen_by_user(current_user)
notifications.mark_as_read!
end
def unseen_counter
render json: {
unseen: load_notifications.where('user_notifications.checked = ?', false).size
unseen: load_notifications.where(read_at: nil).size
}
end
@ -27,21 +22,8 @@ class UserNotificationsController < ApplicationController
def load_notifications
current_user.notifications
.select(:id, :type_of, :title, :message, :created_at, 'user_notifications.checked')
.in_app
.order(created_at: :desc)
end
def notification_serializer(notifications)
notifications.map do |notification|
{
id: notification.id,
type_of: notification.type_of,
title: notification.title,
message: notification.message,
created_at: I18n.l(notification.created_at, format: :full),
today: notification.created_at.today?,
checked: notification.checked
}
end
end
end

View file

@ -4,9 +4,9 @@ module Users
class PreferencesController < ApplicationController
before_action :load_user, only: [
:index,
:update,
:update_togglable_settings
:update
]
before_action :set_breadcrumbs_items, only: %i(index)
layout 'fluid'
def index
@ -20,30 +20,6 @@ module Users
end
end
def update_togglable_settings
read_from_params(:assignments_notification) do |val|
@user.assignments_notification = val
end
read_from_params(:recent_notification) do |val|
@user.recent_notification = val
end
read_from_params(:recent_notification_email) do |val|
@user.recent_email_notification = val
end
read_from_params(:assignments_notification_email) do |val|
@user.assignments_email_notification = val
end
if @user.save
render json: {
status: :ok
}
else
render json: {
status: :unprocessable_entity
}
end
end
private
def load_user
@ -51,12 +27,21 @@ module Users
end
def update_params
params.require(:user).permit(:time_zone, :date_format)
params.require(:user).permit(:time_zone, :date_format, notifications_settings: {})
end
def read_from_params(name)
yield(params.include?(name) ? true : false)
end
def set_breadcrumbs_items
@breadcrumbs_items = [{
label: t('notifications.breadcrumb'),
url: preferences_path
}]
@breadcrumbs_items
end
end
end
end

View file

@ -71,6 +71,7 @@ module ApplicationHelper
message = options.fetch(:message) { :message_must_be_present }
old_text = options[:old_text] || ''
new_text = options[:new_text]
subject = options[:subject]
return if new_text.blank?
sa_user = /\[\@(.*?)~([0-9a-zA-Z]+)\]/
@ -96,17 +97,21 @@ module ApplicationHelper
target_user = User.find_by_id(user_id)
next unless target_user
generate_annotation_notification(target_user, title, message)
generate_annotation_notification(target_user, title, subject)
end
end
def generate_annotation_notification(target_user, title, message)
notification = Notification.create(
type_of: :assignment,
title: sanitize_input(title),
message: sanitize_input(message)
def generate_annotation_notification(target_user, title, subject)
GeneralNotification.send_notifications(
{
type: :smart_annotation_added,
title: sanitize_input(title),
subject_id: subject.id,
subject_class: subject.class.name,
subject_name: subject.respond_to?(:name) && subject.name,
user: target_user
}
)
UserNotification.create(notification: notification, user: target_user) if target_user.assignments_notification
end
def custom_link_open_new_tab(text)

View file

@ -125,6 +125,7 @@ module CommentHelper
smart_annotation_notification(
old_text: old_text,
new_text: comment.message,
subject: result,
title: t('notifications.result_comment_annotation_title',
result: result.name,
user: current_user.full_name),
@ -147,6 +148,7 @@ module CommentHelper
smart_annotation_notification(
old_text: old_text,
new_text: comment.message,
subject: project,
title: t('notifications.project_comment_annotation_title',
project: project.name,
user: current_user.full_name),
@ -160,6 +162,7 @@ module CommentHelper
smart_annotation_notification(
old_text: old_text,
new_text: comment.message,
subject: step.protocol,
title: t('notifications.step_comment_annotation_title',
step: step.name,
user: current_user.full_name),
@ -184,6 +187,7 @@ module CommentHelper
smart_annotation_notification(
old_text: old_text,
new_text: comment.message,
subject: my_module,
title: t('notifications.my_module_comment_annotation_title',
my_module: my_module.name,
user: current_user.full_name),

View file

@ -10,22 +10,21 @@ module NotificationsHelper
unassigned_user: target_user.name,
team: team.name,
unassigned_by_user: user.name)
title = I18n.t('notifications.assign_user_to_team',
assigned_user: target_user.name,
role: role,
team: team.name,
assigned_by_user: user.name) if role
if role
title = I18n.t('notifications.assign_user_to_team',
assigned_user: target_user.name,
role: role,
team: team.name,
assigned_by_user: user.name)
end
message = "#{I18n.t('search.index.team')} #{team.name}"
end
notification = Notification.create(
type_of: :assignment,
title: sanitize_input(title),
message: sanitize_input(message)
)
if target_user.assignments_notification
notification.create_user_notification(target_user)
end
GeneralNotification.send_notifications({
type: role ? :invite_user_to_team : :remove_user_from_team,
title: sanitize_input(title),
message: sanitize_input(message),
user: target_user
})
end
end

View file

@ -236,13 +236,13 @@ module RepositoryDatatableHelper
def linked_repository_default_columns(record)
{
'1': assigned_row(record),
'2': escape_input(record.external_id),
'3': record.code,
'4': escape_input(record.name),
'5': I18n.l(record.created_at, format: :full),
'6': escape_input(record.created_by.full_name),
'7': (record.archived_on ? I18n.l(record.archived_on, format: :full) : ''),
'8': escape_input(record.archived_by&.full_name)
'2': record.code,
'3': escape_input(record.name),
'4': I18n.l(record.created_at, format: :full),
'5': escape_input(record.created_by.full_name),
'6': (record.archived_on ? I18n.l(record.archived_on, format: :full) : ''),
'7': escape_input(record.archived_by&.full_name),
'8': escape_input(record.external_id)
}
end

View file

@ -12,6 +12,8 @@ window.initManageStockValueModalComponent = () => {
app.component('ManageStockValueModal', ManageStockValueModal);
app.use(PerfectScrollbar);
app.config.globalProperties.i18n = window.I18n;
mountWithTurbolinks(app, '#manageStockValueModal');
mountWithTurbolinks(app, '#manageStockValueModal', () => {
window.manageStockModalComponent = null;
});
}
};

View file

@ -0,0 +1,10 @@
import PerfectScrollbar from 'vue3-perfect-scrollbar';
import { createApp } from 'vue/dist/vue.esm-bundler.js';
import UserPreferences from '../../vue/user_preferences/container.vue';
import { mountWithTurbolinks } from './helpers/turbolinks.js';
const app = createApp({});
app.component('UserPreferences', UserPreferences);
app.use(PerfectScrollbar);
app.config.globalProperties.i18n = window.I18n;
mountWithTurbolinks(app, '#user_preferences');

View file

@ -1,15 +1,20 @@
<template>
<div class="sci-navigation--notificaitons-flyout-notification">
<div class="sci-navigation--notificaitons-flyout-notification-icon" :class="notification.type_of">
<i :class="icon"></i>
</div>
<div class="sci-navigation--notificaitons-flyout-notification-date">
{{ notification.created_at }}
{{ notification.attributes.created_at }}
</div>
<div class="sci-navigation--notificaitons-flyout-notification-title"
v-html="notification.title"
:data-seen="notification.checked"></div>
<div v-html="notification.message" class="sci-navigation--notificaitons-flyout-notification-message"></div>
v-html="notification.attributes.title"
:data-seen="notification.attributes.checked"></div>
<div v-html="notification.attributes.message" class="sci-navigation--notificaitons-flyout-notification-message"></div>
<div v-if="notification.attributes.breadcrumbs" class="flex items-center flex-wrap gap-0.5">
<template v-for="(breadcrumb, index) in notification.attributes.breadcrumbs" :key="index">
<div class="flex items-center gap-0.5">
<i v-if="index > 0" class="sn-icon sn-icon-right"></i>
<a :href="breadcrumb.url" :title="breadcrumb.name" class="truncate max-w-[20ch] inline-block">{{ breadcrumb.name }}</a>
</div>
</template>
</div>
</div>
</template>
@ -21,7 +26,7 @@ export default {
},
computed: {
icon() {
switch(this.notification.type_of) {
switch(this.notification.attributes.type_of) {
case 'deliver':
return 'fas fa-truck';
case 'assignment':

View file

@ -26,6 +26,7 @@
<script>
import NotificationItem from './notification_item.vue'
import axios from '../../../packs/custom_axios.js';
export default {
name: 'NotificationsFlyout',
@ -39,12 +40,13 @@ export default {
data() {
return {
notifications: [],
nextPage: 1,
nextPageUrl: null,
scrollBar: null,
loadingPage: false
}
},
created() {
this.nextPageUrl = this.notificationsUrl;
this.loadNotifications();
},
mounted() {
@ -64,23 +66,30 @@ export default {
this.loadNotifications();
},
todayNotifications() {
return this.notifications.filter(n => n.today);
return this.notifications.filter(n => n.attributes.today);
},
olderNotifications() {
return this.notifications.filter(n => !n.today);
return this.notifications.filter(n => !n.attributes.today);
}
},
methods: {
loadNotifications() {
if (this.nextPage == null || this.loadingPage) return;
if (this.nextPageUrl == null || this.loadingPage) return;
this.loadingPage = true;
$.getJSON(this.notificationsUrl, { page: this.nextPage }, (result) => {
this.notifications = this.notifications.concat(result.notifications);
this.nextPage = result.next_page;
this.loadingPage = false;
this.$emit('update:unseenNotificationsCount');
});
axios.get(this.nextPageUrl)
.then(response => {
this.notifications = this.notifications.concat(response.data.data);
this.nextPageUrl = response.data.links.next;
this.loadingPage = false;
this.$emit('update:unseenNotificationsCount');
})
.catch(error => {
this.loadingPage = false;
});
}
}
}

View file

@ -5,7 +5,7 @@
<div class="portocol-header-left-part grow">
<template v-if="headerSticked && moduleName">
<i class="sn-icon sn-icon-navigator sci--layout--navigator-open cursor-pointer p-1.5 border rounded border-sn-light-grey mr-4"></i>
<div @click="scrollTop" class="task-section-title w-[calc(100%_-_4rem)] min-w-[5rem] cursor-pointer">
<div @click="scrollTop" class="task-section-title w-[calc(100%_-_35rem)] min-w-[5rem] cursor-pointer">
<h2 class="truncate leading-6">{{ moduleName }}</h2>
</div>
</template>
@ -25,7 +25,7 @@
</div>
</div>
<div class="actions-block">
<div class="protocol-buttons-group">
<div class="protocol-buttons-group shrink-0">
<a v-if="urls.add_step_url"
class="btn btn-secondary"
:title="i18n.t('protocols.steps.new_step_title')"

View file

@ -40,7 +40,7 @@
@update="updateText"
@delete="removeItem()"
@keypress="keyPressHandler"
@blur="editingText = false"
@blur="onBlurHandler"
/>
<span v-if="!editingText && (!checklistItem.attributes.urls || deleteUrl)" class="absolute right-0 top-0.5 leading-6 tw-hidden group-hover/checklist-item-header:inline-block !text-sn-blue cursor-pointer" @click="showDeleteModal" tabindex="0">
<i class="sn-icon sn-icon-delete"></i>
@ -139,6 +139,11 @@
this.checklistItem.attributes.checked = this.$refs.checkbox.checked;
this.$emit('toggle', this.checklistItem);
},
onBlurHandler() {
this.$nextTick(() => {
this.editingText = false;
});
},
updateText(text, withKey) {
if (text.length === 0) {
this.disableTextEdit();

View file

@ -35,8 +35,8 @@
@keyup.enter="!editingTable && enableTableEdit()">
<div ref="hotTable" class="hot-table-container" @click="!editingTable && enableTableEdit()">
</div>
<div v-if="editingTable" class="text-xs pt-3 pb-2 text-sn-grey">
{{ i18n.t('protocols.steps.table.edit_message') }}
<div class="text-xs pt-3 pb-2 text-sn-grey h-1">
<span v-if="editingTable">{{ i18n.t('protocols.steps.table.edit_message') }}</span>
</div>
</div>
<deleteElementModal v-if="confirmingDelete" @confirm="deleteElement" @cancel="closeDeleteModal"/>
@ -163,8 +163,9 @@
return;
}
const { row = 0, col = 0 } = this.selectedCell || {};
this.editingTable = true;
this.$nextTick(() => this.tableObject.selectCell(0,0));
this.$nextTick(() => this.tableObject.selectCell(row,col));
},
disableTableEdit() {
this.editingTable = false;
@ -268,8 +269,12 @@
preventOverflow: 'horizontal',
readOnly: !this.editingTable,
afterUnlisten: () => {
this.updatingTableData = true;
this.updateTable();
this.editingTable = false;
},
afterSelection: (r, c, r2, c2) => {
if (r === r2 && c === c2) {
this.selectedCell = { row: r, col: c };
}
},
afterChange: () => {
if (this.editingTable == false) return;

View file

@ -14,7 +14,7 @@
v-model="newValue"
@keydown="handleKeypress"
@blur="handleBlur"
@keyup.escape="cancelEdit"
@keyup.escape="cancelEdit && this.atWhoOpened"
@focus="setCaretAtEnd"/>
<textarea v-else
ref="input"
@ -28,7 +28,7 @@
v-model="newValue"
@keydown="handleKeypress"
@blur="handleBlur"
@keyup.escape="cancelEdit"
@keyup.escape="cancelEdit && this.atWhoOpened"
@focus="setCaretAtEnd"/>
</template>
<div
@ -240,6 +240,9 @@
sel.collapse(sel.anchorNode, offset);
},
handleKeypress(e) {
this.atWhoOpened = $('.atwho-view:visible').length > 0
if (this.atWhoOpened) return;
if (e.key == 'Escape') {
this.cancelEdit();
} else if (e.key == 'Enter' && this.saveOnEnter && e.shiftKey == false) {

View file

@ -21,7 +21,6 @@
class="sn-select__options !relative !top-0 !left-[-1px] !shadow-none scroll-container px-2.5 pt-0 block"
:class="{ [optionsClassName]: true }"
>
<div v-if="options.length" class="flex flex-col gap-[1px]">
<div
v-for="option in options"
@ -78,9 +77,6 @@
valueLabel() {
let option = this.options.find((o) => o[0] === this.value);
return option && option[1];
},
focusElement() {
return this.$refs.focusElement || this.$parent.$refs.focusElement;
}
},
mounted() {
@ -96,7 +92,8 @@
if (this.isOpen) {
this.$emit('open');
this.$nextTick(() => {
this.focusElement.focus();
this.$emit('focus');
this.$refs.focusElement?.focus();
});
this.$refs.optionsContainer.scrollTop = 0;
this.updateOptionPosition();

View file

@ -15,6 +15,7 @@
@blur="blur"
@open="open"
@close="close"
@focus="focus"
>
<input ref="focusElement" v-model="query" type="text" class="sn-select__search-input" :placeholder="searchPlaceholder" />
<span class="sn-select__value">{{ valueLabel || (placeholder || i18n.t('general.select')) }}</span>
@ -77,6 +78,9 @@
}
},
methods: {
focus() {
this.$refs.focusElement.focus();
},
blur() {
this.isOpen = false;
this.$emit('blur');

View file

@ -0,0 +1,142 @@
<template>
<div class="content-pane flexible with-grey-background">
<div class="content-header">
<div class="title-row">
<h1 class="mt-0">
{{ i18n.t('users.settings.account.preferences.title') }}
</h1>
</div>
</div>
<div class="p-4 mb-4 bg-sn-white rounded">
<div>
<h2 class="mt-0">{{ i18n.t("users.settings.account.preferences.edit.time_zone_label") }}</h2>
<div class="text-sn-dark-grey mb-4">
<p>{{ i18n.t("users.settings.account.preferences.edit.time_zone_sublabel") }}</p>
</div>
<SelectSearch
class="max-w-[40ch]"
:value="selectedTimeZone"
@change="setTimeZone"
:options="timeZones"
/>
</div>
<div class="sci-divider my-6 inline-block"></div>
<div>
<h2 class="mt-0">{{ i18n.t("users.settings.account.preferences.edit.date_format_label") }}</h2>
<div class="text-sn-dark-grey mb-4">
<p>{{ i18n.t("users.settings.account.preferences.edit.date_format_sublabel") }}</p>
</div>
<SelectSearch
class="max-w-[40ch]"
:value="selectedDateFormat"
@change="setDateFormat"
:options="dateFormats"
/>
</div>
</div>
<div class="p-4 mb-4 bg-sn-white rounded">
<h2 class="mt-0">{{ i18n.t('notifications.title') }}</h2>
<div class="text-sn-dark-grey">
<p>{{ i18n.t('notifications.sub_title') }}</p>
</div>
<table v-if="notificationsSettings">
<template v-for="(_subGroups, group) in notificationsGroups" :key="group">
<div class="contents">
<tr>
<td colspan=3 class="pt-6"><h3>{{ i18n.t(`notifications.groups.${group}`) }}</h3></td>
</tr>
<tr>
<td></td>
<td class="p-2.5 text-base w-32">{{ i18n.t('notifications.in_app') }}</td>
<td class="p-2.5 text-base w-32">{{ i18n.t('notifications.email') }}</td>
</tr>
</div>
<template v-for="(_notifications, subGroup, i) in notificationsGroups[group]" :key="subGroup">
<tr v-if="subGroup !== 'always_on'"
class="text-base border-transparent border-b-sn-super-light-grey border-solid"
:class="{'border-t-sn-super-light-grey': i == 0}"
>
<td class="p-2.5 pr-10">{{ i18n.t(`notifications.sub_groups.${subGroup}`) }}</td>
<td class="p-2.5">
<div class="sci-toggle-checkbox-container">
<input v-model="notificationsSettings[subGroup]['in_app']" type="checkbox" class="sci-toggle-checkbox" @change="setNotificationsSettings"/>
<label class="sci-toggle-checkbox-label"></label>
</div>
</td>
<td class="p-2.5">
<div class="sci-toggle-checkbox-container">
<input v-model="notificationsSettings[subGroup]['email']" type="checkbox" class="sci-toggle-checkbox" @change="setNotificationsSettings"/>
<label class="sci-toggle-checkbox-label"></label>
</div>
</td>
</tr>
</template>
</template>
</table>
</div>
</div>
</template>
<script>
import SelectSearch from "../shared/select_search.vue";
import axios from '../../packs/custom_axios.js';
export default {
name: "UserPreferences",
props: {
userSettings: Object,
timeZones: Array,
dateFormats: Array,
updateUrl: String,
notificationsGroups: Object
},
data: function() {
return {
selectedTimeZone: null,
selectedDateFormat: null,
notificationsSettings: null
};
},
created() {
this.selectedTimeZone = this.userSettings.time_zone;
this.selectedDateFormat = this.userSettings.date_format;
this.notificationsSettings = {...this.emptySettings, ...this.userSettings.notifications_settings};
},
computed: {
emptySettings() {
let settings = {};
for (const group in this.notificationsGroups) {
for (const subGroup in this.notificationsGroups[group]) {
settings[subGroup] = { in_app: false, email: false };
}
}
return settings;
}
},
components: {
SelectSearch,
PerfectScrollbar
},
methods: {
setTimeZone(value) {
this.selectedTimeZone = value;
axios.put(this.updateUrl, {
user: { time_zone: value }
})
},
setDateFormat(value) {
this.selectedDateFormat = value;
axios.put(this.updateUrl, {
user: { date_format: value }
})
},
setNotificationsSettings() {
axios.put(this.updateUrl, {
user: { notifications_settings: this.notificationsSettings }
})
}
},
}
</script>

View file

@ -24,12 +24,14 @@ module FailedDeliveryNotifiableJob
@user = User.find_by(id: arguments.last[:user_id])
return if @user.blank?
notification = Notification.create!(
type_of: :deliver_error,
title: failed_notification_title,
message: failed_notification_message
DeliveryNotification.send_notifications(
{
title: failed_notification_title,
message: failed_notification_message,
error: true,
user: @user
}
)
notification.create_user_notification(@user)
end
def failed_notification_title

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
module MyModules
class DueDateReminderJob < ApplicationJob
def perform
my_modules = MyModule.uncomplete.approaching_due_dates
my_modules.each do |task|
TaskDueDateNotification.send_notifications({ my_module_id: task.id })
end
end
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class NotificationCleanupJob < ApplicationJob
def perform
Notification.where('created_at < ?', 3.months.ago).delete_all
end
end

View file

@ -136,15 +136,17 @@ module Protocols
"href='#{Rails.application.routes.url_helpers.rails_blob_path(@tmp_files.take.file)}'>" \
"#{@tmp_files.take.file.filename}</a>"
notification = Notification.create!(
type_of: :deliver,
title: I18n.t('protocols.import_export.import_protocol_notification.title', link: original_file_download_link),
message: "#{I18n.t('protocols.import_export.import_protocol_notification.message')} " \
"<a data-id='#{@protocol.id}' data-turbolinks='false' " \
"href='#{Rails.application.routes.url_helpers.protocol_path(@protocol)}'>" \
"#{@protocol.name}</a>"
DeliveryNotification.send_notifications(
{
title:
I18n.t('protocols.import_export.import_protocol_notification.title', link: original_file_download_link),
message: "#{I18n.t('protocols.import_export.import_protocol_notification.message')} " \
"<a data-id='#{@protocol.id}' data-turbolinks='false' " \
"href='#{Rails.application.routes.url_helpers.protocol_path(@protocol)}'>" \
"#{@protocol.name}</a>",
user: @user
}
)
notification.create_user_notification(@user)
end
# Overrides method from FailedDeliveryNotifiableJob concern

View file

@ -21,16 +21,22 @@ module Reports
report.docx_ready!
report_path = Rails.application.routes.url_helpers
.reports_path(team: report.team.id, preview_report_id: report.id, preview_type: :docx)
notification = Notification.create(
type_of: :deliver,
title: I18n.t('projects.reports.index.generation.completed_docx_notification_title'),
message: I18n.t('projects.reports.index.generation.completed_notification_message',
report_link: "<a href='#{report_path}'>#{escape_input(report.name)}</a>",
team_name: escape_input(report.team.name))
DeliveryNotification.send_notifications(
{
title: I18n.t('projects.reports.index.generation.completed_docx_notification_title'),
message: I18n.t('projects.reports.index.generation.completed_notification_message',
report_link: "<a href='#{report_path}'>#{escape_input(report.name)}</a>",
team_name: escape_input(report.team.name)),
subject_id: report_id,
subject_class: 'Report',
subject_name: report.name,
report_type: 'docx',
user: user
}
)
Reports::DocxPreviewJob.perform_now(report.id)
notification.create_user_notification(user)
ensure
I18n.backend.date_format = nil
file.close

View file

@ -162,14 +162,19 @@ module Reports
def create_notification_for_user
report_path = Rails.application.routes.url_helpers
.reports_path(team: @report.team.id, preview_report_id: @report.id, preview_type: :pdf)
notification = Notification.create(
type_of: :deliver,
title: I18n.t('projects.reports.index.generation.completed_pdf_notification_title'),
message: I18n.t('projects.reports.index.generation.completed_notification_message',
report_link: "<a href='#{report_path}'>#{escape_input(@report.name)}</a>",
team_name: escape_input(@report.team.name))
DeliveryNotification.send_notifications(
{
title: I18n.t('projects.reports.index.generation.completed_pdf_notification_title'),
message: I18n.t('projects.reports.index.generation.completed_notification_message',
report_link: "<a href='#{report_path}'>#{escape_input(@report.name)}</a>",
team_name: escape_input(@report.team.name)),
subject_id: @report.id,
subject_class: 'Report',
subject_name: @report.name,
report_type: 'pdf',
user: @user
}
)
notification.create_user_notification(@user)
end
def append_result_asset_previews
@ -222,9 +227,9 @@ module Reports
merged_file
end
def prepend_title_page(file, template, report, renderer)
unless File.exist?(Rails.root.join('app', 'views', 'reports', 'templates', template, 'cover.html.erb'))
return file
def prepend_title_page
unless File.exist?(Rails.root.join('app', 'views', 'reports', 'templates', @template, 'cover.html.erb'))
return @file
end
total_pages = 0

View file

@ -83,18 +83,19 @@ class RepositoriesExportJob < ApplicationJob
end
def generate_notification
notification = Notification.create!(
type_of: :deliver,
title: I18n.t('zip_export.notification_title'),
message: "<a data-id='#{@zip_export.id}' " \
"data-turbolinks='false' " \
"href='#{Rails.application
.routes
.url_helpers
.zip_exports_download_export_all_path(@zip_export)}'>" \
"#{@zip_export.zip_file_name}</a>"
DeliveryNotification.send_notifications(
{
title: I18n.t('zip_export.notification_title'),
message: "<a data-id='#{@zip_export.id}' " \
"data-turbolinks='false' " \
"href='#{Rails.application
.routes
.url_helpers
.zip_exports_download_export_all_path(@zip_export)}'>" \
"#{@zip_export.zip_file_name}</a>",
user: @user
}
)
notification.create_user_notification(@user)
end
# Overrides method from FailedDeliveryNotifiableJob concern

View file

@ -0,0 +1,39 @@
# frozen_string_literal: true
class RepositoryItemDateReminderJob < ApplicationJob
queue_as :default
def perform
process_repository_values(RepositoryDateTimeValue, DateTime.current)
process_repository_values(RepositoryDateValue, Date.current)
end
private
def process_repository_values(model, comparison_value)
model
.joins(repository_cell: { repository_column: :repository })
.where(notification_sent: false, repositories: { type: 'Repository' })
.where('repository_date_time_values.updated_at >= ?', 2.days.ago)
.where( # date(time) values that are within the reminder range
"data <= " \
"(?::timestamp + CAST(((repository_columns.metadata->>'reminder_unit')::int * " \
"(repository_columns.metadata->>'reminder_value')::int) || ' seconds' AS Interval))",
comparison_value
).find_each do |value|
repository_row = RepositoryRow.find(value.repository_cell.repository_row_id)
repository_column = RepositoryColumn.find(value.repository_cell.repository_column_id)
RepositoryItemDateNotification
.send_notifications({
"#{value.class.name.underscore}_id": value.id,
repository_row_id: repository_row.id,
repository_row_name: repository_row.name,
repository_column_id: repository_column.id,
repository_column_name: repository_column.name,
reminder_unit: repository_column.metadata['reminder_unit'],
reminder_value: repository_column.metadata['reminder_value']
})
end
end
end

View file

@ -34,17 +34,18 @@ class ZipExportJob < ApplicationJob
end
def generate_notification!
notification = Notification.create!(
type_of: :deliver,
title: I18n.t('zip_export.notification_title'),
message: "<a data-id='#{@zip_export.id}' " \
"data-turbolinks='false' " \
"href='#{Rails.application
.routes
.url_helpers
.zip_exports_download_path(@zip_export)}'>" \
"#{@zip_export.zip_file_name}</a>"
DeliveryNotification.send_notifications(
{
title: I18n.t('zip_export.notification_title'),
message: "<a data-id='#{@zip_export.id}' " \
"data-turbolinks='false' " \
"href='#{Rails.application
.routes
.url_helpers
.zip_exports_download_path(@zip_export)}'>" \
"#{@zip_export.zip_file_name}</a>",
user: @user
}
)
notification.create_user_notification(@user)
end
end

View file

@ -22,4 +22,16 @@ class AppMailer < Devise::Mailer
}.merge(opts)
mail(headers)
end
def general_notification(opts = {})
@user = params[:recipient]
@notification = params[:record].to_notification
mail(
{
to: @user.email,
subject: I18n.t('notifications.email_title')
}.merge(opts)
)
end
end

View file

@ -9,104 +9,14 @@ module GenerateNotificationModel
end
def generate_notification_from_activity
return if notification_recipients.none?
message = generate_activity_content(self, no_links: true, no_custom_links: true)
description = generate_notification_description_elements(subject).reverse.join(' | ')
notification = Notification.create(
type_of: notification_type,
title: sanitize_input(message),
message: sanitize_input(description),
generator_user_id: owner.id
)
notification_recipients.each do |user|
notification.create_user_notification(user)
end
params = { activity_id: id, type: "#{type_of}_activity".to_sym }
ActivityNotification.send_notifications(params, later: true)
end
protected
def notification_recipients
users = []
case subject
when Project
users = subject.users
when Experiment
users = subject.users
when MyModule
users = subject.designated_users
# Also send to the user that was unassigned,
# and is therefore no longer present on the module.
if type_of == 'undesignate_user_from_my_module'
users += User.where(id: values.dig('message_items', 'user_target', 'id'))
end
when Protocol
users = subject.in_repository? ? [] : subject.my_module.designated_users
when Result
users = subject.my_module.designated_users
when Repository
users = subject.team.users
when Team
users = subject.users
when Report
users = subject.team.users
when ProjectFolder
users = subject.team.users
end
users - [owner]
end
# This method returns unsanitized elements. They must be sanitized before saving to DB
def generate_notification_description_elements(object, elements = [])
case object
when Project
path = Rails.application.routes.url_helpers.project_path(object)
elements << "#{I18n.t('search.index.project')} <a href='#{path}'>#{object.name}</a>"
when Experiment
path = Rails.application.routes.url_helpers.my_modules_experiment_path(object)
elements << "#{I18n.t('search.index.experiment')} <a href='#{path}'>#{object.name}</a>"
generate_notification_description_elements(object.project, elements)
when MyModule
path = if object.archived?
Rails.application.routes.url_helpers.my_modules_experiment_path(object.experiment, view_mode: :archived)
else
Rails.application.routes.url_helpers.protocols_my_module_path(object)
end
elements << "#{I18n.t('search.index.module')} <a href='#{path}'>#{object.name}</a>"
generate_notification_description_elements(object.experiment, elements)
when Protocol
if object.in_repository?
path = Rails.application.routes.url_helpers.protocols_path(team: object.team.id)
elements << "#{I18n.t('search.index.protocol')} <a href='#{path}'>#{object.name}</a>"
generate_notification_description_elements(object.team, elements)
else
generate_notification_description_elements(object.my_module, elements)
end
when Result
generate_notification_description_elements(object.my_module, elements)
when Repository
path = Rails.application.routes.url_helpers.repository_path(object, team: object.team.id)
elements << "#{I18n.t('search.index.repository')} <a href='#{path}'>#{object.name}</a>"
generate_notification_description_elements(object.team, elements)
when Team
path = Rails.application.routes.url_helpers.projects_path(team: object.id)
elements << "#{I18n.t('search.index.team')} <a href='#{path}'>#{object.name}</a>"
when Report
path = Rails.application.routes.url_helpers.reports_path(team: object.team.id)
elements << "#{I18n.t('search.index.report')} <a href='#{path}'>#{object.name}</a>"
generate_notification_description_elements(object.team, elements)
when ProjectFolder
generate_notification_description_elements(object.team, elements)
end
elements
end
def notifiable?
type_of.in? ::Extends::NOTIFIABLE_ACTIVITIES
NotificationExtends::NOTIFICATIONS_TYPES.key?("#{type_of}_activity".to_sym)
end
private
@ -114,14 +24,4 @@ module GenerateNotificationModel
def generate_notification
CreateNotificationFromActivityJob.perform_later(self) if notifiable?
end
def notification_type
return :recent_changes unless instance_of?(Activity)
if type_of.in? Activity::ASSIGNMENT_TYPES
:assignment
else
:recent_changes
end
end
end

View file

@ -1,6 +1,12 @@
# frozen_string_literal: true
class LinkedRepository < Repository
enum permission_level: Extends::SHARED_OBJECTS_PERMISSION_LEVELS.except(:shared_write)
def shareable_write?
false
end
def default_table_state
state = Constants::REPOSITORY_TABLE_DEFAULT_STATE.deep_dup
state['order'] = [[3, 'asc']]

View file

@ -20,6 +20,7 @@ class MyModule < ApplicationRecord
before_validation :archiving_and_restoring_extras, on: :update, if: :archived_changed?
before_save -> { report_elements.destroy_all }, if: -> { !new_record? && experiment_id_changed? }
before_save :reset_due_date_notification_sent, if: -> { due_date_changed? }
around_save :exec_status_consequences, if: :my_module_status_id_changed?
before_create :create_blank_protocol
before_create :assign_default_status_flow
@ -139,6 +140,11 @@ class MyModule < ApplicationRecord
joins(experiment: :project).where(experiment: { projects: { team: teams } })
end
def self.approaching_due_dates
where(due_date_notification_sent: false)
.where('due_date > ? AND due_date <= ?', DateTime.current, DateTime.current + 1.day)
end
def parent
experiment
end
@ -529,6 +535,10 @@ class MyModule < ApplicationRecord
end
end
def reset_due_date_notification_sent
self.due_date_notification_sent = false
end
def archiving_and_restoring_extras
if archived?
# Removes connections with other modules

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
class NonExistantRecord
attr_reader :name
def initialize(name)
@name = name
end
end

View file

@ -1,19 +1,13 @@
# frozen_string_literal: true
class Notification < ApplicationRecord
has_many :user_notifications, inverse_of: :notification, dependent: :destroy
has_many :users, through: :user_notifications
belongs_to :generator_user, class_name: 'User', optional: true
include Noticed::Model
enum type_of: Extends::NOTIFICATIONS_TYPES
belongs_to :recipient, polymorphic: true
def create_user_notification(user)
return if user == generator_user
return unless can_send_to_user?(user)
return unless user.enabled_notifications_for?(type_of.to_sym, :web)
user_notifications.create!(user: user)
end
scope :in_app, lambda {
where.not("notifications.params ? 'hide_in_app' AND notifications.params->'hide_in_app' = 'true'")
}
private

View file

@ -8,7 +8,7 @@ class Repository < RepositoryBase
include RepositoryImportParser
include ArchivableModel
enum permission_level: Extends::SHARED_INVENTORIES_PERMISSION_LEVELS
enum permission_level: Extends::SHARED_OBJECTS_PERMISSION_LEVELS
belongs_to :archived_by,
foreign_key: :archived_by_id,
@ -51,8 +51,8 @@ class Repository < RepositoryBase
.where(team: teams)
.or(accessible_repositories.where(team_shared_objects: { team: teams }))
.or(accessible_repositories
.where(permission_level: [Extends::SHARED_INVENTORIES_PERMISSION_LEVELS[:shared_read],
Extends::SHARED_INVENTORIES_PERMISSION_LEVELS[:shared_write]]))
.where(permission_level: [Extends::SHARED_OBJECTS_PERMISSION_LEVELS[:shared_read],
Extends::SHARED_OBJECTS_PERMISSION_LEVELS[:shared_write]]))
accessible_repositories.distinct
}
@ -108,6 +108,10 @@ class Repository < RepositoryBase
teams.blank? ? self : where(team: teams)
end
def shareable_write?
true
end
def permission_parent
team
end
@ -200,7 +204,7 @@ class Repository < RepositoryBase
new_repo = dup
new_repo.created_by = created_by
new_repo.name = name
new_repo.permission_level = Extends::SHARED_INVENTORIES_PERMISSION_LEVELS[:not_shared]
new_repo.permission_level = Extends::SHARED_OBJECTS_PERMISSION_LEVELS[:not_shared]
new_repo.save!
# Clone columns (only if new_repo was saved)

View file

@ -70,6 +70,7 @@ class RepositoryAssetValue < ApplicationRecord
asset.last_modified_by = user
self.last_modified_by = user
asset.save! && save!
asset.post_process_file(repository_cell.repository_column.repository.team)
end
def snapshot!(cell_snapshot)

View file

@ -9,6 +9,7 @@ class RepositoryDateTimeValueBase < ApplicationRecord
inverse_of: :modified_repository_date_time_values
has_one :repository_cell, as: :value, dependent: :destroy
accepts_nested_attributes_for :repository_cell
before_save :reset_notification_sent, if: -> { data_changed? }
validates :repository_cell, :data, :type, presence: true
@ -33,4 +34,10 @@ class RepositoryDateTimeValueBase < ApplicationRecord
)
value_snapshot.save!
end
private
def reset_notification_sent
self.notification_sent = false
end
end

View file

@ -45,7 +45,7 @@ class RepositorySnapshot < RepositoryBase
my_module: my_module,
created_by: created_by,
team: my_module.experiment.project.team,
permission_level: Extends::SHARED_INVENTORIES_PERMISSION_LEVELS[:not_shared])
permission_level: Extends::SHARED_OBJECTS_PERMISSION_LEVELS[:not_shared])
repository_snapshot.provisioning!
repository_snapshot.reload
end

View file

@ -18,6 +18,8 @@ class RepositoryStockValue < ApplicationRecord
validates :low_stock_threshold, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
before_save :update_consumption_stock_units, if: :repository_stock_unit_item_id_changed?
after_save :send_low_stock_notification, if: -> { status == :low }
after_create do
next if is_a?(RepositoryStockConsumptionValue)
@ -199,4 +201,18 @@ class RepositoryStockValue < ApplicationRecord
.my_module_repository_rows
.update_all(repository_stock_unit_item_id: repository_stock_unit_item_id)
end
def send_low_stock_notification
repository_row = repository_cell.repository_row
repository = repository_row.repository
return unless repository.class.name == 'Repository'
LowStockNotification.send_notifications({
repository_row_id: repository_cell.repository_row_id,
repository_row_name: repository_row.name,
repository_id: repository_row.repository_id,
repository_name: repository.name
})
end
end

View file

@ -41,7 +41,7 @@ class User < ApplicationRecord
recent: true,
recent_email: false,
system_message_email: false
}
}.merge(Extends::DEFAULT_USER_NOTIFICATION_SETTINGS)
}.freeze
DEFAULT_OTP_DRIFT_TIME_SECONDS = 10
@ -307,8 +307,7 @@ class User < ApplicationRecord
inverse_of: :created_by,
dependent: :destroy
has_many :user_notifications, inverse_of: :user
has_many :notifications, through: :user_notifications
has_many :notifications, as: :recipient, dependent: :destroy, inverse_of: :recipient
has_many :zip_exports, inverse_of: :user, dependent: :destroy
has_many :view_states, dependent: :destroy
@ -323,7 +322,6 @@ class User < ApplicationRecord
has_many :hidden_repository_cell_reminders, dependent: :destroy
before_validation :downcase_email!
before_destroy :destroy_notifications
def name
full_name
@ -515,40 +513,6 @@ class User < ApplicationRecord
user_identities.exists?(provider: provider)
end
# json friendly attributes
NOTIFICATIONS_TYPES = %w(assignments_notification recent_notification
assignments_email_notification
recent_email_notification)
# declare notifications getters
NOTIFICATIONS_TYPES.each do |name|
define_method(name) do
attr_name = name.gsub('_notification', '')
notifications_settings.fetch(attr_name.to_sym)
end
end
# declare notifications setters
NOTIFICATIONS_TYPES.each do |name|
define_method("#{name}=") do |value|
attr_name = name.gsub('_notification', '').to_sym
notifications_settings[attr_name] = value
end
end
def enabled_notifications_for?(notification_type, channel)
return true if %i(deliver deliver_error).include?(notification_type)
case channel
when :web
notification_type == :recent_changes && recent_notification ||
notification_type == :assignment && assignments_notification
when :email
notification_type == :recent_changes && recent_email_notification ||
notification_type == :assignment && assignments_email_notification
end
end
def increase_daily_exports_counter!
range = Time.now.utc.beginning_of_day.to_i..Time.now.utc.end_of_day.to_i
last_export = export_vars[:last_export_timestamp] || 0
@ -671,25 +635,6 @@ class User < ApplicationRecord
self.email = email.downcase
end
def destroy_notifications
# Find all notifications where user is the only reference
# on the notification, and destroy all such notifications
# (user_notifications are destroyed when notification is
# destroyed). We try to do this efficiently (hence in_groups_of).
nids_all = notifications.pluck(:id)
nids_all.in_groups_of(1000, false) do |nids|
Notification
.where(id: nids)
.joins(:user_notifications)
.group('notifications.id')
.having('count(notification_id) <= 1')
.destroy_all
end
# Now, simply destroy all user notification relations left
user_notifications.destroy_all
end
def clear_view_cache
Rails.cache.delete_matched(%r{^views\/users\/#{id}-})
end

View file

@ -1,22 +0,0 @@
# frozen_string_literal: true
class UserNotification < ApplicationRecord
include NotificationsHelper
belongs_to :user, optional: true
belongs_to :notification, optional: true
after_create :send_email
def self.unseen_notification_count(user)
where('user_id = ? AND checked = false', user.id).count
end
def self.seen_by_user(user)
where(user: user).where(checked: false).update_all(checked: true)
end
def send_email
send_email_notification(user, notification) if user.enabled_notifications_for?(notification.type_of.to_sym, :email)
end
end

View file

@ -0,0 +1,41 @@
# frozen_string_literal: true
class ActivityNotification < BaseNotification
include SearchHelper
include GlobalActivitiesHelper
include InputSanitizeHelper
include ActionView::Helpers::TextHelper
include ApplicationHelper
include ActiveRecord::Sanitization::ClassMethods
include Rails.application.routes.url_helpers
def message
params[:message] if params[:legacy]
end
def title
if params[:legacy]
params[:title]
else
generate_activity_content(activity)
end
end
def subject
activity.subject unless params[:legacy]
end
private
def current_team
@current_team ||= recipient.teams.find_by(id: recipient.current_team_id)
end
def current_user
recipient
end
def activity
@activity ||= Activity.find_by(id: params[:activity_id])
end
end

View file

@ -0,0 +1,55 @@
# frozen_string_literal: true
class BaseNotification < Noticed::Base
deliver_by :database, if: :database_notification?
deliver_by :email, mailer: 'AppMailer', method: :general_notification, if: :email_notification?
def self.send_notifications(params, later: true)
recipients_class =
"Recipients::#{NotificationExtends::NOTIFICATIONS_TYPES[subtype || params[:type]][:recipients_module]}".constantize
recipients_class.new(params).recipients.each do |recipient|
if later
with(params).deliver_later(recipient)
else
with(params).deliver(recipient)
end
end
end
def self.subtype; end
def subtype
self.class.subtype || params[:type]
end
def subject; end
def message
params[:message]
end
def title
params[:title]
end
private
def database_notification?
# always save all notifications,
# but flag if they should display in app or not
params[:hide_in_app] = recipient.notifications_settings.dig(notification_subgroup.to_s, 'in_app') != true
true
end
def email_notification?
recipient.notifications_settings.dig(notification_subgroup.to_s, 'email')
end
def notification_subgroup
NotificationExtends::NOTIFICATIONS_GROUPS.values.reduce({}, :merge).find do |_sg, n|
n.include?(subtype.to_sym)
end[0]
end
end

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
class DeliveryNotification < BaseNotification
def self.subtype
:delivery
end
def message
params[:message]
end
def title
params[:title]
end
def subject
return unless params[:subject_id] && params[:subject_class]
params[:subject_class].constantize.find(params[:subject_id])
rescue ActiveRecord::RecordNotFound
NonExistantRecord.new(params[:subject_name])
end
end

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
class GeneralNotification < BaseNotification
def message
params[:message]
end
def title
params[:title]
end
def subtype
params[:type]
end
def subject
subject_class = params[:subject_class].constantize
subject_class.find(params[:subject_id])
rescue NameError, ActiveRecord::RecordNotFound
NonExistantRecord.new(params[:subject_name])
end
end

View file

@ -0,0 +1,27 @@
# frozen_string_literal: true
class LowStockNotification < BaseNotification
def self.subtype
:item_low_stock_reminder
end
def title
I18n.t(
'notifications.content.item_low_stock_reminder.message_html',
repository_row_name: subject.name,
repository: repository.name
)
end
def subject
RepositoryRow.find(params[:repository_row_id])
rescue ActiveRecord::RecordNotFound
NonExistantRecord.new(params[:repository_row_name])
end
def repository
Repository.find(params[:repository_id])
rescue ActiveRecord::RecordNotFound
NonExistantRecord.new(params[:repository_name])
end
end

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
class Recipients::AssignedGroupRecipients
def initialize(params)
@params = params
end
def recipients
activity = Activity.find(@params[:activity_id])
project = activity.subject
project.team.users.where.not(id: project.user_assignments.where(assigned: 'manually').select(:user_id))
end
end

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
class Recipients::AssignedRecipients
def initialize(params)
@params = params
end
def recipients
activity = Activity.find(@params[:activity_id])
User.where(id: activity.values.dig('message_items', 'user_target', 'id'))
.where.not(id: activity.owner_id)
end
end

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
module Recipients
class DesignateToMyModuleRecipients < MyModuleDesignatedRecipients
private
def activity_recipients
activity = Activity.find(@params[:activity_id])
user = User.find_by(id: activity.values.dig('message_items', 'user_target', 'id'))
return [] if user.id == activity.owner_id
[user]
end
end
end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class Recipients::DirectRecipient
def initialize(params)
@params = params
end
def recipients
[@params[:user]]
end
end

View file

@ -0,0 +1,31 @@
# frozen_string_literal: true
class Recipients::MyModuleDesignatedRecipients
def initialize(params)
@params = params
end
def recipients
if @params[:activity_id]
activity_recipients
else
MyModule.find(@params[:my_module_id]).designated_users
end
end
private
def activity_recipients
activity = Activity.find(@params[:activity_id])
case activity.subject_type
when 'MyModule'
users = activity.subject.designated_users
when 'Protocol', 'Result'
users = activity.subject.my_module.designated_users
when 'Step'
users = activity.subject.protocol.my_module.designated_users
end
users.where.not(id: activity.owner_id)
end
end

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
class Recipients::RepositoryItemRecipients
def initialize(params)
@repository_row_id = params[:repository_row_id]
end
def recipients
repository_row = RepositoryRow.find(@repository_row_id)
repository_row.repository.team.users
end
end

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
class Recipients::UserChangedRecipient
def initialize(params)
@params = params
end
def recipients
activity = Activity.find(@params[:activity_id])
User.where(id: activity.values.dig('message_items', 'user_changed', 'id'))
end
end

View file

@ -0,0 +1,55 @@
# frozen_string_literal: true
class RepositoryItemDateNotification < BaseNotification
def self.subtype
:item_date_reminder
end
def title
unit = human_readable_unit(column.metadata['reminder_unit'], column.metadata['reminder_value'])
I18n.t(
'notifications.content.item_date_reminder.message_html',
repository_row_name: subject.name,
value: column.metadata['reminder_value'],
units: unit
)
end
def subject
RepositoryRow.find(params[:repository_row_id])
rescue ActiveRecord::RecordNotFound
NonExistantRecord.new(params[:repository_row_name])
end
def column
RepositoryColumn.find(params[:repository_column_id])
rescue ActiveRecord::RecordNotFound
NonExistantRecord.new(params[:repository_column_name])
end
after_deliver do
if params[:repository_date_time_value_id]
RepositoryDateTimeValue.find(params[:repository_date_time_value_id]).update(notification_sent: true)
elsif params[:repository_date_value_id]
RepositoryDateValue.find(params[:repository_date_value_id]).update(notification_sent: true)
end
end
private
def human_readable_unit(seconds, value)
return unless seconds
units_hash = {
'2419200' => 'month',
'604800' => 'week',
'86400' => 'day'
}
base_unit = units_hash.fetch(seconds) do
raise ArgumentError, "Unrecognized time unit for seconds value: #{seconds}"
end
value.to_i > 1 ? base_unit.pluralize : base_unit
end
end

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
class TaskDueDateNotification < BaseNotification
def self.subtype
:my_module_due_date_reminder
end
def title
I18n.t(
'notifications.content.my_module_due_date_reminder.message_html',
my_module_name: subject.name
)
end
def subject
MyModule.find(params[:my_module_id])
end
after_deliver do
MyModule.find(params[:my_module_id]).update_column(:due_date_notification_sent, true)
end
end

View file

@ -59,7 +59,7 @@ Canaid::Permissions.register_for(Repository) do
# repository: share
can :share_repository do |user, repository|
can_manage_repository?(user, repository)
!repository.shared_with?(user.current_team) && repository.permission_granted?(user, RepositoryPermissions::SHARE)
end
# repository: make a snapshot with assigned rows

View file

@ -0,0 +1,91 @@
# frozen_string_literal: true
class NotificationSerializer < ActiveModel::Serializer
include Rails.application.routes.url_helpers
attributes :id, :title, :message, :created_at, :read_at, :type, :breadcrumbs, :checked, :today
def title
object.to_notification.title
end
def breadcrumbs
subject = object.to_notification.subject
generate_breadcrumbs(subject, []) if subject
end
def message
object.to_notification.message
end
def created_at
I18n.l(object.created_at, format: :full)
end
def today
object.created_at.today?
end
def checked
object.read_at.present?
end
private
def generate_breadcrumbs(subject, breadcrumbs)
return [] if subject.is_a?(NonExistantRecord)
case subject
when Project
parent = subject.team
url = project_path(subject)
when Experiment
parent = subject.project
url = my_modules_experiment_path(subject)
when MyModule
parent = subject.experiment
url = protocols_my_module_path(subject)
when Protocol
if subject.in_repository?
parent = subject.team
url = protocol_path(subject)
else
parent = subject.my_module
url = protocols_my_module_path(subject.my_module)
end
when Result
parent = subject.my_module
url = my_module_results_path(subject.my_module)
when ProjectFolder
parent = subject.team
url = project_folder_path(subject)
when RepositoryBase
parent = subject.team
url = repository_path(subject)
when RepositoryRow
parent = subject.team
url = repository_path(subject.repository)
when Report
parent = subject.team
url = reports_path(
preview_report_id: subject.id,
preview_type: object.params[:report_type],
team_id: subject.team.id
)
when LabelTemplate
parent = subject.team
url = label_template_path(subject)
when Team
parent = nil
url = projects_path(team: subject.id)
end
breadcrumbs << { name: subject.name, url: url } if subject.name.present?
if parent
generate_breadcrumbs(parent, breadcrumbs)
else
breadcrumbs.reverse
end
end
end

View file

@ -149,6 +149,19 @@ module WopiUtil
asset_name: { id: @asset.id, value_for: 'file_name' },
action: action
})
elsif @assoc.is_a?(RepositoryCell)
repository = @assoc.repository_row.repository
Activities::CreateActivityService
.call(activity_type: :edit_wopi_file_on_inventory_item,
owner: current_user,
subject: repository,
team: repository.team,
message_items: {
repository: repository.id,
repository_row: @assoc.repository_row.id,
asset_name: { id: @asset.id, value_for: 'file_name' },
action: action
})
end
end
end

View file

@ -1,4 +1,4 @@
<div id="taskSecondaryMenu" class="sticky-header-element bg-sn-white border-b border-solid border-0 border-sn-sleepy-grey rounded-t px-4 py-2 top-0 sticky flex items-center flex-wrap z-[106]">
<div id="taskSecondaryMenu" class="sticky-header-element bg-sn-white border-b border-solid border-0 border-sn-sleepy-grey rounded-t px-4 py-2 pb-[16px] top-0 sticky flex items-center flex-wrap z-[106]">
<div class="flex items-center gap-4 mr-auto">
<% if can_read_experiment?(@my_module.experiment) %>
<a class="p-3 border-b-4 border-transparent hover:no-underline uppercase text-bold capitalize <%= is_module_protocols? ? "text-sn-blue" : "text-sn-grey" %>"

View file

@ -37,7 +37,7 @@
<div class="module-tags">
<div class="tags-wrapper">
<span class="sn-icon block-icon sn-icon-users mr-2.5"></span>
<span class="hidden-xs hidden-sm"><%= t('my_modules.details.assigned_users') %></span>
<span class="hidden-xs hidden-sm mr-1"><%= t('my_modules.details.assigned_users') %></span>
<%= render partial: "user_my_modules/index", locals: { my_module: @my_module } %>
</div>
</div>

View file

@ -35,15 +35,15 @@
</div>
</th>
<th id="assigned" data-unmanageable="true"><%= t("repositories.table.assigned") %></th>
<% if @repository.is_a?(LinkedRepository) %>
<th id="row-external-id"><%= t('repositories.table.external_id') %></th>
<% end %>
<th id="row-id"><%= t("repositories.table.id") %></th>
<th id="row-name"><%= t("repositories.table.row_name") %></th>
<th id="added-on" ><%= t("repositories.table.added_on") %></th>
<th id="added-by" ><%= t("repositories.table.added_by") %></th>
<th id="archived-on"><%= t("repositories.table.archived_on") %></th>
<th id="archived-by"><%= t("repositories.table.archived_by") %></th>
<% if @repository.is_a?(LinkedRepository) %>
<th id="row-external-id"><%= t('repositories.table.external_id') %></th>
<% end %>
<% repository.repository_columns.order(:id).each do |column| %>
<th
class="repository-column <%= 'row-stock item-stock' if column.data_type == 'RepositoryStockValue' %>"

View file

@ -18,14 +18,17 @@
<div class="all-teams">
<span class="team-selector" title="<%= t("repositories.index.modal_share.all_teams_tooltip") %>">
<span class="sci-checkbox-container">
<%= check_box_tag 'select_all_teams', 0, @repository.shared_read? || @repository.shared_write?, { class: 'sci-checkbox' } %>
<%= check_box_tag 'select_all_teams', true, @repository.shared_read? || @repository.shared_write?, { class: 'sci-checkbox' } %>
<span class="sci-checkbox-label"></span>
</span>
<%= t("repositories.index.modal_share.all_teams") %>
</span>
<span class="permission-selector">
<span class="sci-toggle-checkbox-container">
<%= check_box_tag 'select_all_write_permission', 0, @repository.shared_write?, { class: 'hidden sci-toggle-checkbox' }%>
<%= check_box_tag 'select_all_write_permission', true, @repository.shared_write?, {
disabled: !@repository.shareable_write?,
class: 'hidden sci-toggle-checkbox'
} %>
<span class="sci-toggle-checkbox-label"></span>
</span>
</span>
@ -35,7 +38,7 @@
<div class="team-container">
<div class="team-selector">
<span class="sci-checkbox-container">
<%= check_box_tag 'share_team_ids[]', t.id, @repository.private_shared_with?(t), {id: "shared_#{t.id}", class: "sci-checkbox"} %>
<%= check_box_tag 'share_team_ids[]', t.id, @repository.private_shared_with?(t), { id: "shared_#{t.id}", class: "sci-checkbox" } %>
<span class="sci-checkbox-label"></span>
</span>
<%= t.name %>
@ -44,6 +47,7 @@
<span class="sci-toggle-checkbox-container">
<%= check_box_tag 'write_permissions[]', t.id, @repository.private_shared_with_write?(t), {
id: "editable_#{t.id}",
disabled: !@repository.shareable_write?,
class: (@repository.private_shared_with?(t) ? 'sci-toggle-checkbox' : 'sci-toggle-checkbox hidden')
}.compact %>
<span class="sci-toggle-checkbox-label"></span>

View file

@ -19,3 +19,5 @@
'data-view-mode': !can_manage_my_module_designated_users?(my_module)
} %>
</div>
<%= javascript_include_tag 'my_modules/assigned_users' %>

View file

@ -0,0 +1,8 @@
<p>Hello <%= @user.name %>,</p>
<p><%= I18n.t("notifications.email_title") %></p>
<p>Type: <%= @notification.title %></p>
<p>
<%= sanitize_input(prepend_server_url_to_links(@notification.message)) %>
</p>

View file

@ -1,106 +1,17 @@
<% provide(:head_title, t("users.settings.account.preferences.head_title")) %>
<% provide(:container_class, "no-second-nav-container") %>
<%= render partial: "users/settings/sidebar" %>
<div class="tab-content user-account-preferences">
<div class="tab-pane content-pane active" role="tabpanel">
<div class="row">
<div class="col-xs-12 col-sm-12">
<h1 class="preferences-title"><%= t('users.settings.account.preferences.title') %></h1>
<div class="time-zone-container">
<%= label_tag t("users.settings.account.preferences.edit.time_zone_label") %>
<div class="time-zone-selector-container">
<%= select_tag "time-zone-input-field",
options_for_select(
ActiveSupport::TimeZone.all.map{ |tz|
[tz.formatted_offset + " " + tz.name, tz.name]
},
@user.settings[:time_zone]
),{
'data-path-to-update': update_preferences_path(format: :json),
class: 'hidden'
}
%>
</div>
<small><%= t("users.settings.account.preferences.edit.time_zone_sublabel") %></small>
</div>
<div class="date-format-container">
<%= label_tag t("users.settings.account.preferences.edit.date_format_label") %>
<div class="date-format-selector-container">
<%= select_tag "date-format-input-field",
options_for_select(
Constants::SUPPORTED_DATE_FORMATS.map { |df|
["#{l(Time.new(2024, 4, 22), format: :full_date, date_format: df)}", df]
},
@user.settings[:date_format]
),{
'data-path-to-update': update_preferences_path(format: :json),
class: 'hidden'
}
%>
</div>
<small><%= t("users.settings.account.preferences.edit.date_format_sublabel") %></small>
</div>
<hr>
<%= form_for(@user,
url: update_togglable_settings_path(format: :json),
html: { method: :post, id: 'togglable-settings-panel' },
remote: true) do |f| %>
<div class="preferences-settings-container">
<h4><%= t('notifications.title') %></h4>
<div class="row">
<div class="col-sm-12">
<strong><%=t 'notifications.form.assignments' %></strong>
<p><%=t 'notifications.form.assignments_description' %></p>
<div class="row">
<div class="col-sm-4">
<%=t 'notifications.form.notification_scinote' %>
</div>
<div class="col-sm-8">
<%= check_box_tag :assignments_notification, @user.assignments_notification %>
</div>
</div>
<div class="row">
<div class="col-sm-4">
<%=t 'notifications.form.notification_email' %>
</div>
<div class="col-sm-8">
<%= check_box_tag :assignments_notification_email, @user.assignments_email_notification %>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<strong><%=t 'notifications.form.recent_notification' %></strong>
<p><%=t 'notifications.form.recent_notification_description' %></p>
<div class="row">
<div class="col-sm-4">
<%=t 'notifications.form.notification_scinote' %>
</div>
<div class="col-sm-8">
<%= check_box_tag :recent_notification, @user.recent_notification %>
</div>
</div>
<div class="row">
<div class="col-sm-4">
<%=t 'notifications.form.notification_email' %>
</div>
<div class="col-sm-8">
<%= check_box_tag :recent_notification_email, @user.recent_email_notification %>
</div>
</div>
</div>
</div>
</div>
<% end %>
<br>
</div>
</div>
</div>
<div class="tab-pane tab-pane-settings" role="tabpanel"></div>
<div id="user_preferences" class="contents">
<user-preferences
:update-url = "'<%= update_preferences_path(format: :json) %>'"
:user-settings = "<%= @user.settings.to_json %>"
:time-zones = "<%= ActiveSupport::TimeZone.all.map{ |tz|
[ tz.name, tz.formatted_offset + " " + tz.name]
}.to_json %>"
:date-formats = "<%= Constants::SUPPORTED_DATE_FORMATS.map { |df|
[df, "#{l(Time.new(2024, 4, 22), format: :full_date, date_format: df)}"]
}.to_json %>"
:notifications-groups = "<%= NotificationExtends::NOTIFICATIONS_GROUPS.to_json %>"
/>
</div>
<%= javascript_include_tag "users/settings/account/preferences/index" %>
<%= javascript_include_tag "vue_user_preferences" %>

View file

@ -31,6 +31,7 @@ Rails.application.config.assets.precompile += %w(my_modules/status_flow.js)
Rails.application.config.assets.precompile += %w(my_modules/protocols/protocol_status_bar.js)
Rails.application.config.assets.precompile += %w(my_modules/results.js)
Rails.application.config.assets.precompile += %w(my_modules/stock.js)
Rails.application.config.assets.precompile += %w(my_modules/assigned_users.js)
Rails.application.config.assets.precompile += %w(my_modules/tags.js)
Rails.application.config.assets.precompile += %w(my_modules/archived.js)
Rails.application.config.assets.precompile += %w(my_modules/pwa_mobile_app.js)

View file

@ -14,13 +14,6 @@ class Extends
# Extends enum types. Should not be freezed, as modules might append to this.
# !!!Check all addons for the correct order!!!
# DEPRECATED 'system_message' in (SCI-2952, kept b/c of integer enums)
NOTIFICATIONS_TYPES = { assignment: 0,
recent_changes: 1,
system_message: 2, # DEPRECATED
deliver: 5,
deliver_error: 7 }
TASKS_STATES = { uncompleted: 0,
completed: 1 }
@ -495,24 +488,28 @@ class Extends
sequence_on_result_deleted: 289,
sequence_on_result_moved: 290,
move_chemical_structure_on_result: 291,
edit_item_field_inventory: 292
edit_item_field_inventory: 292,
export_inventories: 293,
edit_image_on_inventory_item: 294,
edit_wopi_file_on_inventory_item: 295,
export_inventory_stock_consumption: 296
}
ACTIVITY_GROUPS = {
projects: [*0..7, 32, 33, 34, 95, 108, 65, 109, *158..162, 241, 242, 243],
task_results: [23, 26, 25, 42, 24, 40, 41, 99, 110, 122, 116, 128, 169, 172, 178, *257..273, *284..291],
task_results: [23, 26, 25, 42, 24, 40, 41, 99, 110, 122, 116, 128, 169, 172, 178, *246..248, *257..273, *284..291],
task: [8, 58, 9, 59, *10..14, 35, 36, 37, 53, 54, *60..63, 138, 139, 140, 64, 66, 106, 126, 120, 132,
*146..148, 166, 246, 247, 248],
148, 166],
task_protocol: [15, 22, 16, 18, 19, 20, 21, 17, 38, 39, 100, 111, 45, 46, 47, 121, 124, 115, 118, 127, 130, 137,
217, 168, 171, 177, 184, 185, 188, 189, *192..203, 222, 224, 225, 226, 236, *249..252, *274..278],
168, 171, 177, 184, 185, 188, 189, *192..203, 221, 222, 224, 225, 226, 236, *249..252, *274..278],
task_inventory: [55, 56, 146, 147, 183],
experiment: [*27..31, 57, 141, 165],
reports: [48, 50, 49, 163, 164],
inventories: [70, 71, 105, 144, 145, 72, 73, 74, 102, 142, 143, 75, 76, 77,
78, 96, 107, 113, 114, *133..136, 180, 181, 182, 292],
78, 96, 107, 113, 114, *133..136, 180, 181, 182, *292..296],
protocol_repository: [80, 103, 89, 87, 79, 90, 91, 88, 85, 86, 84, 81, 82,
83, 101, 112, 123, 125, 117, 119, 129, 131, 170, 173, 179, 187, 186,
190, 191, *204..215, 220, 221, 223, 227, 228, 229, *230..235,
190, 191, *204..215, 220, 223, 227, 228, 229, *230..235,
*237..240, *253..256, *279..283],
team: [92, 94, 93, 97, 104, 244, 245],
label_templates: [*216..219]
@ -520,12 +517,6 @@ class Extends
TOP_LEVEL_ASSIGNABLES = %w(Project Team Protocol Repository).freeze
SHARED_INVENTORIES_PERMISSION_LEVELS = {
not_shared: 0,
shared_read: 1,
shared_write: 2
}.freeze
SHARED_OBJECTS_PERMISSION_LEVELS = {
not_shared: 0,
shared_read: 1,
@ -624,7 +615,23 @@ class Extends
my_modules/activities
results/index
protocols/show
preferences/index
)
DEFAULT_USER_NOTIFICATION_SETTINGS = {
my_module_designation: {
email: false,
in_app: true
},
other_smart_annotation: {
email: false,
in_app: true
},
always_on: {
email: true,
in_app: true
}
}
end
# rubocop:enable Style/MutableConstant

View file

@ -0,0 +1,162 @@
# frozen_string_literal: true
class NotificationExtends
NOTIFICATIONS_TYPES = {
designate_user_to_my_module_activity: {
code: 13,
recipients_module: :DesignateToMyModuleRecipients
},
undesignate_user_from_my_module_activity: {
code: 14,
recipients_module: :DesignateToMyModuleRecipients
},
my_module_due_date_reminder: {
recipients_module: :MyModuleDesignatedRecipients
},
add_comment_to_module_activity: {
code: 35,
recipients_module: :MyModuleDesignatedRecipients
},
edit_module_comment_activity: {
code: 36,
recipients_module: :MyModuleDesignatedRecipients
},
delete_module_comment_activity: {
code: 37,
recipients_module: :MyModuleDesignatedRecipients
},
add_comment_to_step_activity: {
code: 17,
recipients_module: :MyModuleDesignatedRecipients
},
edit_step_comment_activity: {
code: 38,
recipients_module: :MyModuleDesignatedRecipients
},
delete_step_comment_activity: {
code: 39,
recipients_module: :MyModuleDesignatedRecipients
},
add_comment_to_result_activity: {
code: 24,
recipients_module: :MyModuleDesignatedRecipients
},
edit_result_comment_activity: {
code: 40,
recipients_module: :MyModuleDesignatedRecipients
},
delete_result_comment_activity: {
code: 41,
recipients_module: :MyModuleDesignatedRecipients
},
assign_user_to_project_activity: {
code: 5,
recipients_module: :AssignedRecipients
},
unassign_user_from_project_activity: {
code: 7,
recipients_module: :AssignedRecipients
},
project_grant_access_to_all_team_members_activity: {
code: 242,
recipients_module: :AssignedGroupRecipients
},
project_remove_access_from_all_team_members_activity: {
code: 243,
recipients_module: :AssignedGroupRecipients
},
change_user_role_on_project_activity: {
code: 6,
recipients_module: :AssignedRecipients
},
change_user_role_on_experiment_activity: {
code: 165,
recipients_module: :AssignedRecipients
},
change_user_role_on_my_module_activity: {
code: 166,
recipients_module: :AssignedRecipients
},
item_low_stock_reminder: {
recipients_module: :RepositoryItemRecipients
},
item_date_reminder: {
recipients_module: :RepositoryItemRecipients
},
smart_annotation_added: {
recipients_module: :DirectRecipient
},
invite_user_to_team: {
code: 92,
recipients_module: :DirectRecipient
},
remove_user_from_team: {
code: 93,
recipients_module: :DirectRecipient
},
change_users_role_on_team_activity: {
code: 94,
recipients_module: :UserChangedRecipient
},
delivery: {
recipients_module: :DirectRecipient
}
}
NOTIFICATIONS_GROUPS = {
my_module: {
my_module_designation: %I[
designate_user_to_my_module_activity
undesignate_user_from_my_module_activity
],
my_module_due_date: %I[
my_module_due_date_reminder
],
my_module_comments: %I[
add_comment_to_module_activity
edit_module_comment_activity
delete_module_comment_activity
add_comment_to_step_activity
edit_step_comment_activity
delete_step_comment_activity
add_comment_to_result_activity
edit_result_comment_activity
delete_result_comment_activity
]
},
project_experiment: {
project_experiment_access: %I[
assign_user_to_project_activity
unassign_user_from_project_activity
project_grant_access_to_all_team_members_activity
project_remove_access_from_all_team_members_activity
],
project_experiment_role_change: %I[
change_user_role_on_project_activity
change_user_role_on_experiment_activity
change_user_role_on_my_module_activity
]
},
repository: {
repository_stock: %I[
item_low_stock_reminder
],
repository_date_reminder: %I[
item_date_reminder
]
},
other: {
other_smart_annotation: %I[
smart_annotation_added
],
other_team_invitation: %I[
invite_user_to_team
remove_user_from_team
change_users_role_on_team_activity
],
always_on: %I[
delivery
]
}
}
end

View file

@ -2,22 +2,35 @@
require 'rufus-scheduler'
scheduler = Rufus::Scheduler.singleton
if ENV['WORKER'].present?
scheduler = Rufus::Scheduler.singleton
if ENV['ENABLE_TEMPLATES_SYNC'] == 'true'
# Templates sync periodic task
scheduler.every '12h' do
Rails.logger.info('Templates, syncing all template projects')
updated, total = TemplatesService.new.update_all_templates
Rails.logger.info(
"Templates, total number of updated projects: #{updated} out of #{total}}"
)
Rails.logger.flush
end
end
if ENV['ENABLE_FLUICS_SYNC'] == 'true'
scheduler.every '24h' do
LabelPrinters::Fluics::SyncService.new.sync_templates! if LabelPrinter.fluics.any?
if ENV['ENABLE_TEMPLATES_SYNC'] == 'true'
# Templates sync periodic task
scheduler.every '12h' do
Rails.logger.info('Templates, syncing all template projects')
updated, total = TemplatesService.new.update_all_templates
Rails.logger.info(
"Templates, total number of updated projects: #{updated} out of #{total}}"
)
Rails.logger.flush
end
end
if ENV['ENABLE_FLUICS_SYNC'] == 'true'
scheduler.every '24h' do
LabelPrinters::Fluics::SyncService.new.sync_templates! if LabelPrinter.fluics.any?
end
end
reminder_job_interval = ENV['REMINDER_JOB_INTERVAL'] || '1h'
scheduler.every reminder_job_interval do
MyModules::DueDateReminderJob.perform_now
RepositoryItemDateReminderJob.perform_now
end
scheduler.every '1d' do
NotificationCleanupJob.perform_now
end
end

View file

@ -3429,14 +3429,35 @@ en:
notifications:
title: "Notifications"
email_settings: "E-mail notifications"
form:
assignments: "Assignment"
notification_scinote: 'Show in SciNote'
notification_email: 'Notify me via email'
assignments_description: 'Assignment notifications appear whenever you get assigned to a team, project, task.'
recent_notification: 'Recent changes'
recent_notification_description: 'Recent changes notifications appear whenever there is a change on a task you are assigned to.'
sub_title: "Define which updates to get notified and where"
in_app: "In-app"
email: "Email"
breadcrumb: "Preferences"
groups:
my_module: "Task"
project_experiment: "Project & Experiment"
repository: "Inventories & Items"
other: "Others"
sub_groups:
my_module_designation: "You were assigned to or unassigned from a task"
my_module_due_date: "Due date & time reminder on assigned task (24h before)"
my_module_comments: "A comment was added or edited on a task you are assigned to"
project_experiment_access: "You were added or removed from a Project"
project_experiment_role_change: "Your role on Project, Experiment, or Task was changed"
repository_stock: "Low stock reminder"
repository_date_reminder: "Date reminder"
other_smart_annotation: "You were tagged"
other_team_invitation: "You were invited or removed from the team"
content:
my_module_due_date_reminder:
title_html: "Task due date reminder"
message_html: "Due date for the task %{my_module_name} is coming up."
item_low_stock_reminder:
title_html: "Item stock reminder"
message_html: "Item %{repository_row_name} in inventory %{repository} is running low."
item_date_reminder:
title_html: "Item date reminder"
message_html: "Date reminder for %{repository_row_name} is coming up in %{value} %{units}."
deliver:
download_link: "Download link:"
download_text: "Click the link to download the file."

View file

@ -317,6 +317,10 @@ en:
result_text_moved_html: "%{user} moved text <strong>%{text_name}</strong> from result %{result_original} to result %{result_destination}."
result_table_moved_html: "%{user} moved table <strong>%{table_name}</strong> from result %{result_original} to result %{result_destination}."
move_chemical_structure_on_result_html: "%{user} moved chemical structure <strong>%{file}</strong> from result %{result_original} to result %{result_destination}."
export_inventories_html: "%{user} exported inventory %{inventories}"
edit_image_on_inventory_item_html: "%{user} edited image %{asset_name} on inventory item %{repository_row} in inventory %{repository}: %{action}."
edit_wopi_file_on_inventory_item_html: "%{user} edited Office online file %{asset_name} on inventory item %{repository_row} in inventory %{repository}: %{action}."
export_inventory_stock_consumption_html: "%{user} exported stock consumption for inventory item(s) %{inventory_items} in inventory %{repository}."
activity_name:
create_project: "Project created"
rename_project: "Project renamed"
@ -477,7 +481,7 @@ en:
delete_chemical_structure_on_protocol: "Chemical structure on protocol deleted"
delete_chemical_structure_on_task: "Chemical structure on task deleted"
protocol_description_in_task_edited: "Protocol description in task edited"
protocol_name_in_task_edited: "Protocol name in task edited."
protocol_name_in_task_edited: "Protocol name in task edited"
export_inventory_items_assigned_to_task: "Task-assigned inventory items exported (live version)"
export_inventory_snapshot_items_assigned_to_task: "Task-assigned inventory items exported (snapshot)"
change_status_on_task_flow: "Task status changed"
@ -588,7 +592,10 @@ en:
result_text_moved: "Result text moved"
result_table_moved: "Result table moved"
move_chemical_structure_on_result: "Chemical structure on result moved"
export_inventories: "Inventories exported"
edit_image_on_inventory_item: "Inventory item image edited"
edit_wopi_file_on_inventory_item: "Inventory item wopi file edited"
export_inventory_stock_consumption: "Inventory stock consumptions exported"
activity_group:
projects: "Projects"
task_results: "Task results"

View file

@ -44,6 +44,7 @@ const entryList = {
vue_navigation_breadcrumbs: './app/javascript/packs/vue/navigation/breadcrumbs.js',
vue_protocol_file_import_modal: './app/javascript/packs/vue/protocol_file_import_modal.js',
vue_components_export_stock_consumption_modal: './app/javascript/packs/vue/export_stock_consumption_modal.js',
vue_user_preferences: './app/javascript/packs/vue/user_preferences.js',
vue_components_manage_stock_value_modal: './app/javascript/packs/vue/manage_stock_value_modal.js',
vue_legacy_datetime_picker: './app/javascript/packs/vue/legacy/datetime_picker.js',
}

View file

@ -0,0 +1,58 @@
# frozen_string_literal: true
class MigrateNotificationToNoticed < ActiveRecord::Migration[7.0]
class UserNotification < ApplicationRecord
belongs_to :notification
end
def up
add_column :notifications, :params, :jsonb, default: {}, null: false
add_column :notifications, :type, :string
add_column :notifications, :read_at, :datetime
add_reference :notifications, :recipient, polymorphic: true
Notification.reset_column_information
type_mapping = {
0 => 'ActivityNotification',
1 => 'GeneralNotification',
5 => 'DeliveryNotification',
7 => 'DeliveryNotification'
}
UserNotification.where('created_at > ?', 3.months.ago).includes(:notification).find_each do |user_notification|
notification = user_notification.notification
new_type = type_mapping[notification.type_of]
new_type ||= 'GeneralNotification'
params = {
title: notification.title,
message: notification.message,
legacy: true
}
params[:error] = notification.type_of == 7 if new_type == 'DeliveryNotification'
Notification.create!(
params: params,
type: new_type,
type_of: notification.type_of,
read_at: (user_notification.updated_at if user_notification.checked),
recipient_id: user_notification.user_id,
recipient_type: 'User',
created_at: user_notification.created_at,
updated_at: user_notification.updated_at
)
end
UserNotification.delete_all
Notification.where(type: nil).delete_all
drop_table :user_notifications
change_column_null :notifications, :type, false
remove_column :notifications, :type_of
remove_column :notifications, :title
remove_column :notifications, :message
remove_column :notifications, :generator_user_id
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddTaskDueDateReminderNotification < ActiveRecord::Migration[7.0]
def change
add_column :my_modules, :due_date_notification_sent, :boolean, default: false
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddNotificationSentToRepositoryDateTimeValues < ActiveRecord::Migration[7.0]
def change
add_column :repository_date_time_values, :notification_sent, :boolean, default: false
end
end

View file

@ -0,0 +1,9 @@
class FixNotificationMigration < ActiveRecord::Migration[7.0]
def up
Notification.find_each do |notification|
parsed_params = JSON.parse(notification.params)
parsed_params.delete('_aj_symbol_keys')
notification.update!(params: parsed_params.symbolize_keys)
end
end
end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
class MigrateDefaultNotificationSettings < ActiveRecord::Migration[7.0]
def up
User.find_each do |user|
my_module_designation = user.settings.dig(:notifications_settings, :my_module_designation)
next unless my_module_designation
user.settings[:notifications_settings][:project_experiment_access] =
{
in_app: my_module_designation[:in_app],
email: my_module_designation[:email]
}
user.settings[:notifications_settings][:other_team_invitation] =
{
in_app: my_module_designation[:in_app],
email: my_module_designation[:email]
}
user.save!
end
end
def down; end
end

View file

@ -0,0 +1,24 @@
# frozen_string_literal: true
class MigrateNotificationSettings < ActiveRecord::Migration[7.0]
def up
User.find_each do |user|
user.settings[:notifications_settings] =
user.settings[:notifications_settings].merge(Extends::DEFAULT_USER_NOTIFICATION_SETTINGS)
user.settings[:notifications_settings][:project_experiment_access] = {
in_app: user.settings.dig(:notifications_settings, :assignments),
email: user.settings.dig(:notifications_settings, :assignments_email)
}
user.settings[:notifications_settings][:other_team_invitation] = {
in_app: user.settings.dig(:notifications_settings, :assignments),
email: user.settings.dig(:notifications_settings, :assignments_email)
}
user.save!
end
end
def down; end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_11_07_163821) do
ActiveRecord::Schema[7.0].define(version: 2023_11_28_123835) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gist"
enable_extension "pg_trgm"
@ -376,6 +376,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_07_163821) do
t.bigint "changing_from_my_module_status_id"
t.jsonb "last_transition_error"
t.integer "provisioning_status"
t.boolean "due_date_notification_sent", default: false
t.index "(('TA'::text || id)) gin_trgm_ops", name: "index_my_modules_on_my_module_code", using: :gin
t.index "trim_html_tags((description)::text) gin_trgm_ops", name: "index_my_modules_on_description", using: :gin
t.index "trim_html_tags((name)::text) gin_trgm_ops", name: "index_my_modules_on_name", using: :gin
@ -390,13 +391,15 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_07_163821) do
end
create_table "notifications", force: :cascade do |t|
t.string "title"
t.string "message"
t.integer "type_of", null: false
t.bigint "generator_user_id"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.jsonb "params", default: {}, null: false
t.string "type", null: false
t.datetime "read_at"
t.string "recipient_type"
t.bigint "recipient_id"
t.index ["created_at"], name: "index_notifications_on_created_at"
t.index ["recipient_type", "recipient_id"], name: "index_notifications_on_recipient"
end
create_table "oauth_access_grants", force: :cascade do |t|
@ -737,6 +740,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_07_163821) do
t.bigint "last_modified_by_id", null: false
t.string "type"
t.datetime "data_dup", precision: nil
t.boolean "notification_sent", default: false
t.index "((data)::date)", name: "index_repository_date_time_values_on_data_as_date", where: "((type)::text = 'RepositoryDateValue'::text)"
t.index "((data)::time without time zone)", name: "index_repository_date_time_values_on_data_as_time", where: "((type)::text = 'RepositoryTimeValue'::text)"
t.index ["data"], name: "index_repository_date_time_values_on_data_as_date_time", where: "((type)::text = 'RepositoryDateTimeValue'::text)"
@ -1172,17 +1176,6 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_07_163821) do
t.index ["user_id"], name: "index_user_my_modules_on_user_id"
end
create_table "user_notifications", force: :cascade do |t|
t.bigint "user_id"
t.bigint "notification_id"
t.boolean "checked", default: false
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.index ["checked"], name: "index_user_notifications_on_checked"
t.index ["notification_id"], name: "index_user_notifications_on_notification_id"
t.index ["user_id"], name: "index_user_notifications_on_user_id"
end
create_table "user_projects", force: :cascade do |t|
t.integer "role"
t.bigint "user_id", null: false
@ -1371,7 +1364,6 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_07_163821) do
add_foreign_key "my_modules", "users", column: "created_by_id"
add_foreign_key "my_modules", "users", column: "last_modified_by_id"
add_foreign_key "my_modules", "users", column: "restored_by_id"
add_foreign_key "notifications", "users", column: "generator_user_id"
add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id"
add_foreign_key "oauth_access_grants", "users", column: "resource_owner_id"
add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id"
@ -1495,8 +1487,6 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_07_163821) do
add_foreign_key "user_my_modules", "my_modules"
add_foreign_key "user_my_modules", "users"
add_foreign_key "user_my_modules", "users", column: "assigned_by_id"
add_foreign_key "user_notifications", "notifications"
add_foreign_key "user_notifications", "users"
add_foreign_key "user_projects", "projects"
add_foreign_key "user_projects", "users"
add_foreign_key "user_projects", "users", column: "assigned_by_id"

View file

@ -6,11 +6,12 @@ describe UserMyModulesController, type: :controller do
login_user
include_context 'reference_project_structure'
let(:other_user) { create :user }
describe 'POST create' do
let(:action) { post :create, params: params, format: :json }
let(:params) do
{ my_module_id: my_module.id, user_my_module: { user_id: user.id } }
{ my_module_id: my_module.id, user_my_module: { user_id: other_user.id } }
end
it 'calls create activity for assigning user to task' do

View file

@ -2,11 +2,8 @@
FactoryBot.define do
factory :notification do
title do
'<i>Admin</i> was added as Owner to project ' \
'<strong>Demo project - qPCR</strong> by <i>User</i>.'
end
message { 'Project: <a href=\"/projects/3\"> Demo project - qPCR</a>' }
type_of { 'assignment' }
recipient_type { 'User' }
recipient_id { FactoryBot.create(:user).id }
read_at { Time.now }
end
end

Some files were not shown because too many files have changed in this diff Show more