diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e55ed2f9f..d60f404e3 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -34,6 +34,10 @@ def check_up if ScheduleSnapshot.where("status = 'in_progress'").count > 0 redirect_to '/maintenance.html', status: 503 end + # Stop people from making changes if we are publishing + if PublicationStatus.where("status = 'inprogress'").count > 0 + redirect_to '/maintenance.html', status: 503 + end end def prevent_cache diff --git a/app/controllers/mail_histories_controller.rb b/app/controllers/mail_histories_controller.rb index 602e63e42..1c8b450a3 100644 --- a/app/controllers/mail_histories_controller.rb +++ b/app/controllers/mail_histories_controller.rb @@ -1,3 +1,24 @@ class MailHistoriesController < ResourceController -# TBD + MODEL_CLASS = 'MailHistory'.freeze + SERIALIZER_CLASS = 'MailHistorySerializer'.freeze + POLICY_CLASS = 'MailHistoryPolicy'.freeze + POLICY_SCOPE_CLASS = 'MailHistoryPolicy::Scope'.freeze + DEFAULT_SORTBY = 'date_sent'.freeze + DEFAULT_ORDER = 'desc'.freeze + + def belongs_to_param_id + params[:person_id] + end + + def belong_to_class + Person + end + + def belongs_to_relationship + 'mail_histories' + end + + def paginate + false + end end diff --git a/app/controllers/mailings_controller.rb b/app/controllers/mailings_controller.rb index 8cf83995e..0af35e960 100644 --- a/app/controllers/mailings_controller.rb +++ b/app/controllers/mailings_controller.rb @@ -1,7 +1,8 @@ class MailingsController < ResourceController SERIALIZER_CLASS = 'MailingSerializer'.freeze POLICY_CLASS = 'MailingPolicy'.freeze - DEFAULT_SORTBY = 'title'.freeze + DEFAULT_SORTBY = 'updated_at'.freeze + DEFAULT_ORDER = 'desc'.freeze def serializer_includes [ @@ -136,10 +137,12 @@ def preview recipient_address = params[:email] addr = EmailAddress.find_by(email: recipient_address) + participant_schedule_url = SessionService.participant_schedule_url content = MailService.preview_email_content( person: addr.person, - mailing: mailing + mailing: mailing, + participant_schedule_url: participant_schedule_url ) # render_object(content) # TODO: verify ok for content diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 79a9586e7..9b9d059c4 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -16,7 +16,8 @@ def me :email_addresses, :convention_roles, :unsigned_agreements, - :session_limits + :session_limits, + :assigned_surveys ] ) end diff --git a/app/controllers/publications_controller.rb b/app/controllers/publications_controller.rb new file mode 100644 index 000000000..ffae9d920 --- /dev/null +++ b/app/controllers/publications_controller.rb @@ -0,0 +1,45 @@ +class PublicationsController < ApplicationController + around_action :set_timezone + + def schedule + sessions = SessionService.live_sessions + + send_data XmlFormatter.new(sessions).render('schedule', sessions) + .gsub(/\<\?xml version="1\.0"\?\>\n/, '') + .gsub(/\\n /, '') + .gsub(/\\n /, '') + .gsub(/ \(\d+)\<\/id\>\n\/, '<title><id>\1</id> | ') + .gsub(/\n\<start_time\>/, '<start_time>') + .gsub(/\n\<duration\>/, ' - <duration>') + .gsub(/\<\/duration\>/, ' minutes</duration>') + .gsub(/\n\<\/timeduration\>/, '</timeduration>') + .gsub(/\n\<room\>/, '<room>') + .gsub(/\n\<areas\>/, ' - <areas>') + .gsub(/\n\<format\>/, ', <format>') + .gsub(/\n\<\/roomareasformat\>/, '</roomareasformat>') + .gsub(/\<participants\>\n/, '<participants>') + .gsub(/\<person\>\n/, '<person>') + .gsub(/\n\<person\>/, '<person>') + .gsub(/\\n <role\>/, '<role>') + .gsub(/\<role\>Participant\<\/role>/, '') + .gsub(/\<\/person\>\<role\>/, '</person> <role>') + .gsub(/\<\/person\>\<person\>/, '</person>, <person>') + .gsub(/\<\/participants\>\n/, '</participants>') + .gsub(/\<\/name\>\n/, '</name>') + .gsub(/\n\<\/person\>/, '</person>') + .gsub(/\n\<\/participants\>/, '</participants>') + .gsub(/\<role\>Participant\<\/role>/, '') + .gsub(/\n\<\/session\>/, '</session>') + .gsub(/\n\<\/schedule\>\n/, '</schedule>'), + filename: "schedule.xml", + disposition: 'attachment' + end + + def set_timezone(&block) + timezone = ConfigService.value('convention_timezone') + Time.use_zone(timezone, &block) + end +end diff --git a/app/controllers/reports/people_reports_controller.rb b/app/controllers/reports/people_reports_controller.rb new file mode 100644 index 000000000..6cf0460c6 --- /dev/null +++ b/app/controllers/reports/people_reports_controller.rb @@ -0,0 +1,67 @@ +class Reports::PeopleReportsController < ApplicationController + around_action :set_timezone + + def record_stream_permissions + authorize Person, policy_class: ReportPolicy + + # People: moderators, participants.  NO INVIS, NO RESEVER + + active_roles = SessionAssignmentRoleType.where("role_type = 'participant' and (name != 'Invisible' and name != 'Reserve')") + people = Person + .includes({sessions: :room}, :primary_email) + .references(:sessions) + .where("session_assignments.session_assignment_role_type_id not in (select id from session_assignment_role_type where session_assignment_role_type.name = 'Invisible')") + .where("session_assignments.session_assignment_role_type_id not in (select id from session_assignment_role_type where session_assignment_role_type.name = 'Reserve')") + .order('people.published_name asc') + + + # Person published  names, primary email, attendance type, + # participant status, permission to stream, + # exclusions for streaming, permission to record, exclusions to recording, + # their schedule (in one cell with session title time duration room.  If not possible one line per session will have to do) + + workbook = FastExcel.open(constant_memory: true) + worksheet = workbook.add_worksheet("Record and Stream Permissions") + + worksheet.append_row( + [ + 'Name', + 'Published Name', + 'Primary Email', + 'Attendance Type', + 'Participant Status', + 'Permission to Stream', + 'Streaming Exceptions', + 'Permission to Record', + 'Recording Exceptions', + 'Schedule' + ] + ) + + people.each do |person| + worksheet.append_row( + [ + person.name, + person.published_name, + person.primary_email&.email, + person.attendance_type, + person.con_state, + person.can_stream, + person.can_stream_exceptions, + person.can_record, + person.can_record_exceptions, + person.sessions.scheduled.collect{|s| "'#{s.title}' - #{s.start_time.strftime('%Y-%m-%d %H:%M %Z')} - #{s.duration} mins - #{s.room.name}" }.join(";\n") + ] + ) + end + + send_data workbook.read_string, + filename: "PeopleRecordStream-#{Time.now.strftime('%m-%d-%Y')}.xlsx", + disposition: 'attachment' + end + + def set_timezone(&block) + timezone = ConfigService.value('convention_timezone') + Time.use_zone(timezone, &block) + end +end diff --git a/app/controllers/schedule_controller.rb b/app/controllers/schedule_controller.rb index 02d7fd031..4254fc7af 100644 --- a/app/controllers/schedule_controller.rb +++ b/app/controllers/schedule_controller.rb @@ -2,23 +2,37 @@ class ScheduleController < ApplicationController skip_before_action :check_up, :authenticate_person!, only: [:index, :participants] + # 1. If prod always use the published schedule + # 2. If staging or dev use published - if no published then use the live for testing + # 3. cache mechanism (cache can be popultaed as part of the publish) def index - sessions = ReportsService.scheduled_sessions + snapshot = PublicationDate.order('created_at desc').first&.publish_snapshots&.schedules&.first - render json: ActiveModel::Serializer::CollectionSerializer.new( - sessions, - serializer: Conclar::SessionSerializer - ), - content_type: 'application/json' + if snapshot + render json: snapshot, content_type: 'application/json' + else + sessions = SessionService.scheduled_sessions + render json: ActiveModel::Serializer::CollectionSerializer.new( + sessions, + serializer: Conclar::SessionSerializer + ), + content_type: 'application/json' + end end def participants - participants = ReportsService.scheduled_people + snapshot = PublicationDate.order('created_at desc').first&.publish_snapshots&.participants&.first - render json: ActiveModel::Serializer::CollectionSerializer.new( - participants, - serializer: Conclar::ParticipantSerializer - ), - content_type: 'application/json' + if snapshot + render json: snapshot, content_type: 'application/json' + else + participants = SessionService.scheduled_people + + render json: ActiveModel::Serializer::CollectionSerializer.new( + participants, + serializer: Conclar::ParticipantSerializer + ), + content_type: 'application/json' + end end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index c001378b3..9211e11c9 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -4,6 +4,38 @@ class SessionsController < ResourceController POLICY_SCOPE_CLASS = 'SessionPolicy::Scope'.freeze # DEFAULT_SORTBY = 'name_sort_by' + def schedule_publish + authorize current_person, policy_class: policy_class + + PublicationService.start_publish_job + + render status: :ok, json: {}.to_json, content_type: 'application/json' + end + + # Mass update for the sessions (given ids and params) + def update_all + authorize current_person, policy_class: policy_class + ids = params[:ids] + attrs = params.permit(attrs: {})[:attrs].to_h #permit(:attrs) + + Session.transaction do + # Get all the people with given set of ids and update them + people = Session.where(id: ids).update(attrs) + + # return the updated people back to the caller + options = { + include: serializer_includes, + params: { + domain: "#{request.base_url}", + current_person: current_person + } + } + + render json: serializer_class.new(people,options).serializable_hash(), + content_type: 'application/json' + end + end + def express_interest # create a session assignment if there is not already one model_class.transaction do @@ -290,6 +322,8 @@ def allowed_params require_signup age_restriction_id room_notes + recorded + streamed ] # Tags # format diff --git a/app/javascript/components/bulk_edit_modal.vue b/app/javascript/components/bulk_edit_modal.vue new file mode 100644 index 000000000..ecf65d9a7 --- /dev/null +++ b/app/javascript/components/bulk_edit_modal.vue @@ -0,0 +1,77 @@ +<template> + <div> + <edit-modal + v-bind="$attrs" + :id="id" + :title="title" + @cancel="$emit('cancel', $event)" + @close="$emit('close', $event)" + @ok="confirm" + ok-title="Confirm" + no-stacking + > + <slot v-for="(_, name) in modalSlots" :name="name" :slot="name"></slot> + <template v-for="(_, name) in modalScopedSlots" :slot="name" slot-scope="slotData"><slot :name="name" v-bind="slotData"></slot></template> + </edit-modal> + <edit-modal + v-on="$listeners" + :id="confirmId" + @cancel="$emit('cancel', $event)" + @close="$emit('close', $event)" + :title="confirmTitle" + > + <slot v-for="(_, name) in confirmSlots" :name="`confirm-${name}`" :slot="name"></slot> + <template v-for="(_, name) in confirmScopedSlots" :slot="name" slot-scope="slotData"><slot :name="`confirm-${name}`" v-bind="slotData"></slot></template> + </edit-modal> + </div> +</template> + +<script> +import EditModal from './edit_modal'; + +export default { + name: "BulkEditModal", + props: { + id: { + type: String, + default: 'bulk-edit' + }, + title: { + type: String, + default: "Bulk Edit" + } + }, + components: { + EditModal + }, + computed: { + confirmId() { + return `${this.id}-confirm` + }, + confirmTitle() { + return `${this.title} Confirmation` + }, + modalSlots() { + return Object.fromEntries(Object.entries(this.$slots).filter(([name, _]) => !name.startsWith('confirm'))) + }, + confirmSlots() { + return Object.fromEntries(Object.entries(this.$slots).filter(([name, _]) => name.startsWith('confirm')).map(([name, val]) => [name.replace(/confirm-/, ''), val])) + }, + modalScopedSlots() { + return Object.fromEntries(Object.entries(this.$scopedSlots).filter(([name, _]) => !name.startsWith('confirm'))) + }, + confirmScopedSlots() { + return Object.fromEntries(Object.entries(this.$scopedSlots).filter(([name, _]) => name.startsWith('confirm')).map(([name, val]) => [name.replace(/confirm-/,''), val])) + } + }, + methods: { + confirm() { + this.$bvModal.show(this.confirmId) + } + } +} +</script> + +<style> + +</style> diff --git a/app/javascript/components/edit_modal.vue b/app/javascript/components/edit_modal.vue index 483bdb879..582d62197 100644 --- a/app/javascript/components/edit_modal.vue +++ b/app/javascript/components/edit_modal.vue @@ -1,7 +1,7 @@ <template> <plano-modal no-close-on-backdrop - ok-title="Save" + :ok-title="okTitle" v-on="$listeners" v-bind="$attrs" > @@ -15,6 +15,12 @@ import PlanoModal from './plano_modal'; export default { name: "EditModal", + props: { + okTitle: { + type: String, + default: 'Save' + } + }, components: { PlanoModal }, diff --git a/app/javascript/components/person_con_state_selector.vue b/app/javascript/components/person_con_state_selector.vue index 4282d46e8..4c03a453a 100644 --- a/app/javascript/components/person_con_state_selector.vue +++ b/app/javascript/components/person_con_state_selector.vue @@ -1,15 +1,14 @@ <template> <b-form-select - v-model="selectedValue" - @change="onChange" - v-bind:options="currentSettings.enums.Person.con_state" - :disabled='disabled' + :options="options" + v-bind="$attrs" + v-on="$listeners" ></b-form-select> - <!-- :multiple="multiple" --> </template> <script> import settingsMixin from "@/store/settings.mixin"; +import { PERSON_CON_STATE } from '@/constants/strings'; export default { name: 'PersonConStateSelector', @@ -18,22 +17,11 @@ export default { mixins: [ settingsMixin ], - props: { - value: null, - disabled: false - }, - data: () => ({ - selectedValue: null - // options: [] - }), - methods: { - onChange(arg) { - this.$emit('input', arg) + computed: { + options() { + return this.currentSettings.enums.Person.con_state.map(value => ({text: PERSON_CON_STATE[value], value})) } }, - mounted() { - this.selectedValue = this.value - } } </script> diff --git a/app/javascript/components/plano_modal.vue b/app/javascript/components/plano_modal.vue index 8c6413d06..9905feee3 100644 --- a/app/javascript/components/plano_modal.vue +++ b/app/javascript/components/plano_modal.vue @@ -6,7 +6,7 @@ scrollable v-on="$listeners" v-bind="$attrs" - ref="plano-modal" + :id="id" > <slot v-for="(_, name) in $slots" :name="name" :slot="name" /> <template v-for="(_, name) in $scopedSlots" :slot="name" slot-scope="slotData"><slot :name="name" v-bind="slotData" /></template> @@ -16,12 +16,18 @@ <script> export default { name: "PlanoModal", + props: { + id: { + type: String, + default: 'plano-modal' + } + }, methods: { show() { - this.$refs['plano-modal'].show() + this.$bvModal.show(this.id) }, hide() { - this.$refs['plano-modal'].hide() + this.$bvModal.show(this.id) } } } diff --git a/app/javascript/components/table_vue.vue b/app/javascript/components/table_vue.vue index dde689ffc..8833acbc6 100644 --- a/app/javascript/components/table_vue.vue +++ b/app/javascript/components/table_vue.vue @@ -27,12 +27,12 @@ <div class="d-flex justify-content-end"> <div class="d-inline mx-1" title="clone" v-if="showClone"> - <b-button @click="$emit('clone')" variant="primary" title="clone" :disabled='selected_items.length===0' > + <b-button @click="$emit('clone')" variant="primary" title="Duplicate" :disabled='selected_items.length===0' > <b-icon-files></b-icon-files> </b-button> </div> <div class="d-inline mx-1" title="refresh" v-if="showRefresh"> - <b-button @click="onRefresh" variant="primary" title="refresh"> + <b-button @click="onRefresh" variant="primary" title="Refresh"> <b-icon-arrow-repeat></b-icon-arrow-repeat> </b-button> </div> diff --git a/app/javascript/mailings/mailings_table.vue b/app/javascript/mailings/mailings_table.vue index 06f2477d7..e4c5cd79e 100644 --- a/app/javascript/mailings/mailings_table.vue +++ b/app/javascript/mailings/mailings_table.vue @@ -1,7 +1,8 @@ <template> <div> <table-vue - defaultSortBy='title' + defaultSortBy='updated_at' + :defaultSortDesc="true" :model="model" :columns="columns" :defaultFilter="defaultFilter" diff --git a/app/javascript/people/people_table.vue b/app/javascript/people/people_table.vue index 4af0e13fa..35f5835b8 100644 --- a/app/javascript/people/people_table.vue +++ b/app/javascript/people/people_table.vue @@ -1,38 +1,22 @@ <template> <div> - <modal-form - title="Bulk Edit Status" - ref="mass-edit-state" - @save="onConfirmMassEdit" - > - <b-form> - <person-con-state-selector - v-model="selectedConState" - ></person-con-state-selector> - </b-form> - <template #footer="{ ok, cancel }"> - <b-button variant="link" @click="cancel()">Cancel</b-button> - <b-button variant="primary" @click="ok()">Save</b-button> + <bulk-edit-modal title="Bulk Edit Status" id="bulk-edit-status" @ok="onSaveMassEdit"> + <template #default> + <b-form> + <person-con-state-selector + v-model="selectedConState" + ></person-con-state-selector> + </b-form> </template> - </modal-form> - - <modal-form - title="Bulk Edit Status Confirmation" - ref="mass-edit-confirm" - @save="onSaveMassEdit" - > - <p> - Please confirm that you want to change the - status of {{editableIds.length}} {{editableIds.length == 1 ? 'person' : 'people'}} to '{{selectedConState}}' - <span v-if="declinedRejected">and they will be removed from the below sessions.</span> - </p> - <people-session-names :declinedRejected="declinedRejected" :ids="editableIds"></people-session-names> - <template #footer="{ ok, cancel }"> - <b-button variant="link" @click="cancel()">Cancel</b-button> - <b-button variant="primary" @click="ok()">Save</b-button> + <template #confirm-default> + <p> + Please confirm that you want to change the + status of {{editableIds.length}} {{editableIds.length == 1 ? 'person' : 'people'}} to '{{PERSON_CON_STATE[selectedConState]}}' + <span v-if="declinedRejected">and they will be removed from the below sessions.</span> + </p> + <people-session-names :declinedRejected="declinedRejected" :ids="editableIds"></people-session-names> </template> - </modal-form> - + </bulk-edit-modal> <modal-form title="Add Person" @@ -111,9 +95,9 @@ </template> <template #cell(draft_comments)="{ item }"> <div v-if="draftSchedule"> - <tooltip-overflow :title="comments(item.person_schedule_approvals, 'draft')"> + <tooltip-overflow-keep-newlines :title="comments(item.person_schedule_approvals, 'draft')"> {{ comments(item.person_schedule_approvals, 'draft') }} - </tooltip-overflow> + </tooltip-overflow-keep-newlines> </div> <div v-else class="text-muted text-center"> — </div> </template> @@ -125,9 +109,9 @@ </template> <template #cell(firm_comments)="{ item }"> <div v-if="firmSchedule"> - <tooltip-overflow :title="comments(item.person_schedule_approvals, 'firm')"> + <tooltip-overflow-keep-newlines :title="comments(item.person_schedule_approvals, 'firm')"> {{ comments(item.person_schedule_approvals, 'firm') }} - </tooltip-overflow> + </tooltip-overflow-keep-newlines> </div> <div v-else class="text-muted text-center"> — </div> </template> @@ -139,6 +123,7 @@ import TableVue from '../components/table_vue'; import ModalForm from '../components/modal_form'; import TooltipOverflow from '../shared/tooltip-overflow'; +import TooltipOverflowKeepNewlines from "@/shared/tooltip-overflow-keep-newlines"; import PersonAdd from '../people/person_add.vue'; import { people_columns as columns } from './people'; import { personModel as model } from '@/store/person.store' @@ -150,18 +135,21 @@ import searchStateMixin from '../store/search_state.mixin' import { formatPersonScheduleApprovalState } from '@/store/person_schedule_approval'; import { FETCH_WORKFLOWS, scheduleWorkflowMixin } from '@/store/schedule_workflow'; import { mapActions } from 'vuex'; -import { PERSON_NEVER_LOGGED_IN } from '@/constants/strings'; +import { PERSON_NEVER_LOGGED_IN, PERSON_CON_STATE } from '@/constants/strings'; import { DateTime } from 'luxon'; +import BulkEditModal from '@/components/bulk_edit_modal.vue' export default { name: 'PeopleTable', components: { + TooltipOverflowKeepNewlines, TableVue, TooltipOverflow, ModalForm, PersonAdd, PersonConStateSelector, PeopleSessionNames, + BulkEditModal, }, mixins: [ modelUtilsMixin, @@ -175,6 +163,7 @@ export default { selectedConState: null, searchEmails: null, PERSON_NEVER_LOGGED_IN, + PERSON_CON_STATE, DateTime }), computed: { @@ -230,12 +219,9 @@ export default { this.update_all('person', this.editableIds, {con_state: this.selectedConState}) } }, - onConfirmMassEdit() { - this.$refs['mass-edit-confirm'].showModal() - }, onEditStates(ids) { this.editableIds = ids - this.$refs['mass-edit-state'].showModal() + this.$bvModal.show('bulk-edit-status') }, onNew() { this.$refs['add-person-modal'].showModal() @@ -267,7 +253,7 @@ export default { } </script> -<style> +<style lang="scss"> .col-name-field div { width: 8rem; } diff --git a/app/javascript/people/person_tabs.vue b/app/javascript/people/person_tabs.vue index c9c6f6dee..90ac4467b 100644 --- a/app/javascript/people/person_tabs.vue +++ b/app/javascript/people/person_tabs.vue @@ -47,13 +47,14 @@ <b-tab title="Draft Schedule" lazy v-if="displayDraftSchedule" :active="tab === 'draft-schedule'"> <person-draft-schedule></person-draft-schedule> </b-tab> + <b-tab title="Emails" lazy v-if="currentUserIsAdmin || currentUserIsStaff" :active="tab === 'email'"> + <people-email-tab></people-email-tab> + </b-tab> <b-tab title="Admin" lazy v-if="currentUserIsAdmin || currentUserIsStaff" :active="tab === 'admin'"> <people-admin-tab></people-admin-tab> </b-tab> <b-tab title="Surveys" disabled lazy> </b-tab> - <b-tab title="Emails" disabled lazy> - </b-tab> </b-tabs> </model-loading-overlay> </div> @@ -69,6 +70,7 @@ import PersonDemographics from '../profile/person_demographics.vue'; import PersonLiveSchedule from '@/profile/person_live_schedule.vue'; import PersonDraftSchedule from '@/profile/person_draft_schedule.vue'; import PeopleAdminTab from './people_admin_tab.vue'; +import PeopleEmailTab from '@/profile/person_email_tab.vue'; import ModelLoadingOverlay from '@/components/model_loading_overlay.vue'; import { personModel } from '@/store/person.store' @@ -100,6 +102,7 @@ export default { PersonLiveSchedule, PersonDraftSchedule, PeopleAdminTab, + PeopleEmailTab, }, mixins: [ personSessionMixin, @@ -120,13 +123,14 @@ export default { 'availability', 'session-selection', 'session-ranking', - 'admin' ] if (this.displayDraftSchedule) { - baseTabs.splice(5, 0, 'draft_schedule') + baseTabs.splice(5, 0, 'draft-schedule') } if (this.currentUserIsAdmin || this.currentUserIsStaff || this.firmSchedule) { - baseTabs.splice(5, 0, 'schedule') + baseTabs.splice(5, 0, 'schedule'); + baseTabs.push('email'); + baseTabs.push('admin'); } return baseTabs; }, diff --git a/app/javascript/profile/person_email_tab.vue b/app/javascript/profile/person_email_tab.vue new file mode 100644 index 000000000..cc78c5da8 --- /dev/null +++ b/app/javascript/profile/person_email_tab.vue @@ -0,0 +1,59 @@ +<template> + <div class="container-fluid"> + <div class="row"> + <div class="col"> + <div v-for="mail in fetchedMailings" :key="mail.id" class="mb-2"> + <h5> {{DateTime.fromISO(mail.date_sent).toFormat("DDDD, t ZZZZ")}} </h5> + <dl> + <dt class="font-weight-bold">Subject</dt> + <dd class="ml-2">{{mail.subject}}</dd> + <dt class="font-weight-bold">Body</dt> + <dd class="ml-2" v-html="mail.content"></dd> + </dl> + </div> + </div> + </div> + </div> +</template> + +<script> +import { DateTime } from 'luxon'; +import { mapActions } from 'vuex'; +import { personModel as model } from '@/store/person.store'; +import { modelMixinNoProp } from '@/mixins'; + +export default { + name: 'PersonEmailTab', + data: () => ({ + fetchedMailings: [], + DateTime, + model, + }), + mixins: [ + modelMixinNoProp + ], + methods: { + ...mapActions({ + get: 'jv/get' + }), + fetchMailings() { + this.get(`/person/${this.selected.id}/mail_histories`).then((data) => { + let {_jv, ...filteredMailings} = data; + let sortableMailings = Object.values(filteredMailings); + sortableMailings.sort((a, b) => DateTime.fromISO(b.date_sent) - DateTime.fromISO(a.date_sent)); + this.fetchedMailings = sortableMailings + }); + } + }, + mounted() { + if (this.selected) { + this.fetchMailings(); + } + }, + +} +</script> + +<style> + +</style> diff --git a/app/javascript/profile/person_schedule_display.vue b/app/javascript/profile/person_schedule_display.vue index 36749ca91..2fea45ac4 100644 --- a/app/javascript/profile/person_schedule_display.vue +++ b/app/javascript/profile/person_schedule_display.vue @@ -12,7 +12,7 @@ <div class="col-8" :style="heightHelper"> <b-overlay :show="loading" spinner-variant="primary" variant="white" opacity="1"> <schedule-collapse v-for="(session) in orderedSessions" :key="session.id" :id="session.id" v-model="open[session.id]"> - <template #title><span class="larger-text"><strong class="larger-text">{{session.title}}</strong>, {{session.room}}, {{formatStartTime(session)}}</span></template> + <template #title><span class="larger-text"><strong class="larger-text">{{session.title}}</strong>, {{formatStartTime(session)}}, {{session.room}}</span></template> <dl class="indented-dl"> <dt>Title</dt> <dd>{{session.title}}</dd> diff --git a/app/javascript/reports/reports_screen.vue b/app/javascript/reports/reports_screen.vue index 6aa0dd9bd..66dcde80e 100644 --- a/app/javascript/reports/reports_screen.vue +++ b/app/javascript/reports/reports_screen.vue @@ -41,7 +41,7 @@ <strong><em>Description</em></strong>: List of surveys taken, including day/time submitted, one line per person<br /> <strong><em>Fields</em></strong>: Person name, published name, primary email, attendance type, participant status, surveys taken<br /> <strong><em>Person data included</em></strong>: participant status of applied, probable, vetted, invite_pending, invited, accepted - </p> + </p> </li> <li> <span v-if="currentUserIsStaff" class="text-muted font-italic" title="You do not have the right set of permissions to run this report." v-b-tooltip>Participants and Do Not Assign With</span> @@ -51,7 +51,7 @@ <strong><em>Fields</em></strong>: Person name, published name, session title, area(s) of session, names of other people assigned to the session, names of people not to assign to the same session<br /> <strong><em>Session data included</em></strong>: all scheduled sessions<br /> <strong><em>Person data included</em></strong>: moderators, participants, invisible participants who listed information about who not to assign with - </p> + </p> </li> <li> <a href="/report/session_reports/participants_over_session_limits" target="_blank">Participants over Daily Limits</a> @@ -87,7 +87,15 @@ <strong><em>Fields</em></strong>: Person name, published name, participant status, attendance type (in-person, virtual, hybrid), person’s bio<br /> <strong><em>Session data included</em></strong>: all scheduled sessions<br /> <strong><em>Person data included</em></strong>: people with a participant status of accepted, invited, or invite_pending who are assigned to no sessions, or who are assigned as invisible participants or reserved on one or more sessions - </p> + </p> + </li> + <li> + <a href="/report/people_reports/record_stream_permissions" target="_blank">Participant Recording and Streaming Permissions</a> + <p class="ml-2"> + <strong><em>Description</em></strong>: List of participants with their recording and streaming permissions and exclusions.<br /> + <strong><em>Fields</em></strong>: Person published names, primary email, attendance type, participant status, permission to stream, exclusions for streaming, permission to record, exclusions to recording, and their schedule.<br /> + <strong><em>Person data included</em></strong>: Moderators and participants on scheduled sessions. + </p> </li> </ul> @@ -126,7 +134,7 @@ <strong><em>Description</em></strong>: Scheduled sessions with no assigned moderators or participants, one line per session<br /> <strong><em>Fields</em></strong>: Session title, area(s) of session, session start time, room<br /> <strong><em>Session data included</em></strong>: all scheduled sessions - </p> + </p> </li> <li> <a href="/report/session_reports/assigned_sessions_not_scheduled" target="_blank">Sessions with Participants not Scheduled</a> diff --git a/app/javascript/schedule/schedule_settings.vue b/app/javascript/schedule/schedule_settings.vue index 001ca0133..fa8386d89 100644 --- a/app/javascript/schedule/schedule_settings.vue +++ b/app/javascript/schedule/schedule_settings.vue @@ -2,6 +2,7 @@ <div class="container-fluid"> <div class="row"> <div class="col-12"> + <h5>Release schedule to participants</h5> <b-form-group label-cols="auto" class="align-items-center"> <template #label>Release <strong>Draft</strong> Schedule to Participants</template> <b-form-checkbox switch v-model="localDraftSchedule" :disabled="localDraftSchedule" @change="openDraftConfirm" id="draft-schedule-checkbox" aria-describedby="draft-schedule-date"></b-form-checkbox> @@ -14,10 +15,41 @@ <div v-if="currentSettings.env !== 'production'"> <b-button variant="primary" @click="reset()">Reset for Testing</b-button> <span>THIS DELETES THE SNAPSHOT AND YOU CAN'T EVER GET IT BACK</span> - <div class="mt-3">Note: this minecart isn't actually hooked up to any status yet. So while it does actually produce a snapshot, the toggle won't - reflect reality if you reload. There's some TODOs in here. If you try to snapshot and get an error, reset first. - </div> </div> + <hr /> + </div> + </div> + <div class="row"> + <div class="col-12"> + <h5>Publish schedule to public</h5> + <b-table-simple borderless fixed small> + <b-thead> + <b-tr> + <b-td class="text-center"> + <b-button variant="primary" size="sm" :disabled="!canDiff">Show difference</b-button> + </b-td> + <b-td colspan="3"> + <b-button variant="primary" size="sm" @click="publishdSchedule()">Create a publish snapshot</b-button> + </b-td> + </b-tr> + </b-thead> + </b-table-simple> + <b-table-simple bordered fixed small> + <b-thead class="text-center"> + <b-tr> + <b-td class="text-center">Select 2</b-td> + <b-td colspan="3">Timestamp</b-td> + </b-tr> + </b-thead> + <b-tbody> + <b-tr v-for="(snap, i) in pubSnapshots" :key="snap.id"> + <b-td class="text-center"> + <b-form-checkbox name="pubs-diff" v-model="pubsDiff[i]" :disabled="pubsDiffCount >= 2 && !pubsDiff[i]"></b-form-checkbox> + </b-td> + <b-td colspan="3">{{snap.timestamp}}</b-td> + </b-tr> + </b-tbody> + </b-table-simple> </div> </div> <plano-modal id="confirm-draft-modal" @cancel="cancelDraft()" @close="cancelDraft()" no-close-on-backdrop @ok="confirmDraft()"> @@ -42,6 +74,7 @@ import { import { scheduleWorkflowMixin } from '@/store/schedule_workflow'; import settingsMixin from "@/store/settings.mixin"; +import { DateTime } from 'luxon'; export default { name: "ScheduleSettings", @@ -60,9 +93,23 @@ export default { firmScheduleConfirmed: false, SCHEDULE_DRAFT_CONFIRM_MESSAGE, SCHEDULE_FIRM_CONFIRM_MESSAGE, - NODE_ENV + NODE_ENV, + mockSnapshots: [ + // {timestamp: '2022-08-01T09:58:00Z', id: '12345'}, + // {timestamp: '2022-08-04T00:24:00Z', id:'67890'} + ], + pubsDiff: [false, false, false], }), computed: { + pubSnapshots() { + return [{timestamp: "Current state", id: null}, ...this.mockSnapshots.map(snap => ({...snap, timestamp: DateTime.fromISO(snap.timestamp).toFormat("DDDD, t ZZZZ")}))] + }, + pubsDiffCount() { + return this.pubsDiff.filter(pd => pd).length + }, + canDiff() { + return this.pubsDiffCount === 2; + }, draftScheduledAtText() { return this.draftScheduleConfirmed ? this.draftScheduledAt : "Pending"; }, @@ -101,6 +148,9 @@ export default { this.draftSchedule = false; this.firmSchedule = false; this.toastPromise(http.get('/schedule_workflow/reset'), "succesfully reset workflows") + }, + publishdSchedule() { + this.toastPromise(http.get('/session/schedule_publish'), "Succesfully requested publish") } }, watch: { diff --git a/app/javascript/sessions/session.js b/app/javascript/sessions/session.js index 04ecb8d76..1820f88d7 100644 --- a/app/javascript/sessions/session.js +++ b/app/javascript/sessions/session.js @@ -81,7 +81,24 @@ export const session_columns = [ label: 'Copy Edited/Proofed', type: "select", choices: [{label: "Yes", value: true}, {label: "No", value: false}], - formatter: (value) => value ? "Yes" : "No" + formatter: (value) => value ? "Yes" : "No", + sortable: true, + }, + { + key: 'recorded', + label: 'Recorded', + type: "select", + choices: [{label: "Yes", value: true}, {label: "No", value: false}], + formatter: (value) => value ? "Yes" : "No", + sortable: true + }, + { + key: 'streamed', + label: 'Livestreamed', + type: "select", + choices: [{label: "Yes", value: true}, {label: "No", value: false}], + formatter: (value) => value ? "Yes" : "No", + sortable: true }, { key: 'environment', diff --git a/app/javascript/sessions/session_fields.mixin.js b/app/javascript/sessions/session_fields.mixin.js index f188105ec..5d27ed4de 100644 --- a/app/javascript/sessions/session_fields.mixin.js +++ b/app/javascript/sessions/session_fields.mixin.js @@ -1,5 +1,6 @@ import { conventionTimezoneMixin, settingsMixin } from '@/mixins'; import { DateTime } from 'luxon'; +import {SESSION_STATUS} from '@/constants/strings' export const areaMixin = { computed: { @@ -47,3 +48,17 @@ export const startTimeMixin = { }, } } + +export const sessionStatusMixin = { + mixins: [ + settingsMixin + ], + computed: { + sessionStatusOptions() { + return this.currentSettings?.enums?.Session?.status?.map(value => ({text: SESSION_STATUS[value], value})) + }, + sessionStatusOptionsNoDropped() { + return this.sessionStatusOptions.filter(({value}) => value !== 'dropped') + } + } +} diff --git a/app/javascript/sessions/session_sidebar.vue b/app/javascript/sessions/session_sidebar.vue index 0386482c8..76d8c05ee 100644 --- a/app/javascript/sessions/session_sidebar.vue +++ b/app/javascript/sessions/session_sidebar.vue @@ -48,7 +48,7 @@ </div> </b-row> <div class="row"> - <div class="col-12"> + <div class="col-12 col-md-6 col-lg-4"> <b-form-group label="Public Schedule Visibility"> <span class="text-muted ml-2">Not Visible</span> <b-form-checkbox @@ -57,7 +57,27 @@ :checked="selected.visibility === 'is_public'" class="d-inline-block" >Visible</b-form-checkbox> - </b-form-group> + </b-form-group> + </div> + <div class="col-12 col-md-6 col-lg-4"> + <b-form-group> + <b-form-checkbox + id="session-recorded" + switch + v-model="selected.recorded" + disabled + >Will be recorded</b-form-checkbox> + </b-form-group> + </div> + <div class="col-12 col-md-6 col-lg-4"> + <b-form-group> + <b-form-checkbox + id="session-streamed" + switch + v-model="selected.streamed" + disabled + >Will be live-streamed</b-form-checkbox> + </b-form-group> </div> </div> <div class="float-right d-flex justify-content-end"> diff --git a/app/javascript/sessions/session_summary.vue b/app/javascript/sessions/session_summary.vue index 416d614ad..a6e9ec30d 100644 --- a/app/javascript/sessions/session_summary.vue +++ b/app/javascript/sessions/session_summary.vue @@ -79,6 +79,22 @@ class="d-inline-block" >Visible</b-form-checkbox> </b-form-group> + <b-form-group> + <b-form-checkbox + id="session-recorded" + switch + v-model="session.recorded" + @change="saveSession()" + >Will be recorded</b-form-checkbox> + </b-form-group> + <b-form-group> + <b-form-checkbox + id="session-streamed" + switch + v-model="session.streamed" + @change="saveSession()" + >Will be live-streamed</b-form-checkbox> + </b-form-group> <b-form-group label="Status" label-cols="auto"> <b-form-select id="session-status" v-model="session.status" @change="saveSession()"> <b-form-select-option diff --git a/app/javascript/sessions/session_table.vue b/app/javascript/sessions/session_table.vue index 4565e1883..a9b4c53e5 100644 --- a/app/javascript/sessions/session_table.vue +++ b/app/javascript/sessions/session_table.vue @@ -1,13 +1,39 @@ <template> <div> + <bulk-edit-modal id="bulk-edit-status" title="Bulk Edit Status(es)" @ok="onSaveMassEdit"> + <template #default> + <b-form-select :options="sessionStatusOptionsNoDropped" + v-model="selectedSessionState" + ></b-form-select> + </template> + <template #confirm-default> + <p> + Please confirm that you want to change the + status of {{editableIds.length}} {{editableIds.length == 1 ? 'session' : 'sessions'}} to '{{SESSION_STATUS[selectedSessionState]}}' + </p> + </template> + </bulk-edit-modal> + <table-vue @new="openNewModal" defaultSortBy='sessions.title' :model="model" :columns="columns" stateName="session-table-search-state" + selectMode='multi' ref="sessions-table" > + <template v-slot:left-controls="{ editableIds }"> + <div> + <b-button + variant="primary" + @click="onEditStates(editableIds)" + :disabled="editableIds.length == 0" + >Edit Status(es) + </b-button> + </div> + </template> + <template #cell(title)="{ item }"> <tooltip-overflow v-if="item.title" :title="item.title"> <span v-html="item.title"></span> @@ -50,30 +76,37 @@ <script> import TableVue from '../components/table_vue'; -import ModalForm from '../components/modal_form'; import TooltipOverflow from '../shared/tooltip-overflow'; import { session_columns as columns } from './session'; import { NEW_SESSION, sessionModel as model } from '@/store/session.store' import dateTimeMixin from '../components/date_time.mixin' -import { areaMixin } from './session_fields.mixin'; +import { areaMixin, sessionStatusMixin } from './session_fields.mixin'; import PlanoModal from '@/components/plano_modal.vue'; import { mapActions } from 'vuex'; +import { SESSION_STATUS, SESSION_MUST_UNSCHEDULE } from '@/constants/strings'; +import modelUtilsMixin from "@/store/model_utils.mixin"; +import BulkEditModal from '@/components/bulk_edit_modal.vue'; export default { name: 'SessionTable', components: { TableVue, TooltipOverflow, - ModalForm, PlanoModal, + BulkEditModal }, mixins: [ dateTimeMixin, - areaMixin + modelUtilsMixin, + areaMixin, + sessionStatusMixin, ], data: () => ({ + SESSION_STATUS, columns, model, + editableIds: [], + selectedSessionState: null, newSessionTitle: null }), methods: { @@ -82,15 +115,22 @@ export default { }), openNewModal() { this.newSessionTitle = null; - this.$root.$emit('bv::show::modal', 'add-session'); + this.$bvModal.show('add-session'); }, onNew() { this.newSession({title: this.newSessionTitle}).then((data) => { this.$router.push(`/sessions/edit/${data.id}`) }) }, - onSave() { - } + onSaveMassEdit() { + if (this.editableIds.length > 0 && this.selectedSessionState) { + this.update_all('session', this.editableIds, {status: this.selectedSessionState}) + } + }, + onEditStates(ids) { + this.editableIds = ids + this.$bvModal.show('bulk-edit-status') + }, }, mounted() { this.$refs['sessions-table'].fetchPaged() diff --git a/app/javascript/shared/tooltip-overflow-keep-newlines.vue b/app/javascript/shared/tooltip-overflow-keep-newlines.vue new file mode 100755 index 000000000..c2d14ff43 --- /dev/null +++ b/app/javascript/shared/tooltip-overflow-keep-newlines.vue @@ -0,0 +1,37 @@ +<template> + <!-- Use v-b-tooltip.html to handle cases when the test is html --> + <span v-b-tooltip.html="{customClass: 'truncated-tooltip'}" + :title="title" + class="text-truncate truncated-span" + > + <slot>{{title}}</slot> + </span> +</template> + +<script> +export default { + name: "TooltipOverflowKeepNewlines", + props: { + title: { + type: String, + } + }, +} +</script> + +<style lang="scss"> +.truncated-tooltip { + .tooltip-inner { + max-width: 30rem; + white-space: pre-wrap; + } +} +.truncated-span { + max-width: 15rem; + display: inline-block; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; +} +</style> diff --git a/app/javascript/surveys/survey_sidebar.vue b/app/javascript/surveys/survey_sidebar.vue index 7f3e8cd92..3c0356105 100644 --- a/app/javascript/surveys/survey_sidebar.vue +++ b/app/javascript/surveys/survey_sidebar.vue @@ -19,7 +19,7 @@ </b-col> </b-row> <div class="float-right d-flex justify-content-end"> - <icon-button title="Survey Link" :href="surveyLink" target="_blank" icon="link45deg"></icon-button> + <icon-button title="Survey Link" :href="surveyLink" target="_blank" icon="globe2"></icon-button> <icon-button title="Preview Survey" :href="previewLink" target="_blank" icon="eye-fill"></icon-button> <icon-button title="Edit Survey" :to="editLink" :disabled="survey.public" :disabledTooltip="SURVEY_PUBLIC_NO_EDIT" icon="pencil"></icon-button> <icon-button icon="envelope" disabled disabledTooltip="Send Survey"></icon-button> diff --git a/app/lib/migration_helpers/plano_views.rb b/app/lib/migration_helpers/plano_views.rb index f183a054f..868c26572 100644 --- a/app/lib/migration_helpers/plano_views.rb +++ b/app/lib/migration_helpers/plano_views.rb @@ -42,7 +42,12 @@ def self.create_person_schedules sessions.format_id, sessions.participant_notes, sessions.description, - sessions.environment + sessions.environment, + case + when sa.updated_at > sessions.updated_at + then sa.updated_at + else sessions.updated_at + end as updated_at from session_assignments sa join diff --git a/app/models/availability.rb b/app/models/availability.rb index 94316d462..1510b7a54 100644 --- a/app/models/availability.rb +++ b/app/models/availability.rb @@ -4,5 +4,5 @@ class Availability < ApplicationRecord validates :start_time, presence: true validates :end_time, presence: true - has_paper_trail versions: { class_name: 'Audit::PersonVersion' }, ignore: [:updated_at, :created_at] + has_paper_trail versions: { class_name: 'Audit::PersonVersion' }, ignore: [:updated_at, :created_at, :lock_version] end diff --git a/app/models/concerns/xml_formattable.rb b/app/models/concerns/xml_formattable.rb new file mode 100644 index 000000000..8e292060e --- /dev/null +++ b/app/models/concerns/xml_formattable.rb @@ -0,0 +1,13 @@ +module XmlFormattable + def to_xml + formatter.format + end + + def render(file_name, object, options = nil) + formatter.render(file_name, object, options) + end + + def formatter + @formatter ||= XmlFormatter.new(self) + end +end diff --git a/app/models/email_address.rb b/app/models/email_address.rb index 235aa1abb..83fe4dfde 100644 --- a/app/models/email_address.rb +++ b/app/models/email_address.rb @@ -17,7 +17,7 @@ class EmailAddress < ApplicationRecord before_save :strip_spaces after_save :check_default, :check_contact - has_paper_trail versions: { class_name: 'Audit::PersonVersion' }, ignore: [:updated_at, :created_at] + has_paper_trail versions: { class_name: 'Audit::PersonVersion' }, ignore: [:updated_at, :created_at, :lock_version] validates_with SinglePrimaryEmail diff --git a/app/models/person.rb b/app/models/person.rb index cd60b7a05..f05651754 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -13,15 +13,41 @@ class Person < ApplicationRecord # acts_as_taggable acts_as_taggable_on :tags - has_paper_trail versions: { class_name: 'Audit::PersonVersion' }, ignore: [:updated_at, :created_at] + has_paper_trail versions: { class_name: 'Audit::PersonVersion' }, ignore: [:updated_at, :created_at, :lock_version] before_destroy :check_if_assigned before_save :check_primary_email has_many :availabilities - has_many :session_assignments, dependent: :destroy - has_many :sessions, through: :session_assignments + has_many :session_assignments, dependent: :destroy do + def publishable + # get the people with the given role + where("session_assignments.session_assignment_role_type_id not in (select id from session_assignment_role_type where session_assignment_role_type.name = 'Invisible')") + .where("session_assignments.session_assignment_role_type_id not in (select id from session_assignment_role_type where session_assignment_role_type.name = 'Reserve')") + .where("session_assignments.session_assignment_role_type_id is not null AND session_assignments.state != 'rejected'") + end + end + + has_many :sessions, through: :session_assignments do + def scheduled + # get the people with the given role + where("sessions.status != 'dropped'") + .where("sessions.start_time is not null and sessions.room_id is not null") + .where("session_assignments.session_assignment_role_type_id not in (select id from session_assignment_role_type where session_assignment_role_type.name = 'Invisible')") + .where("session_assignments.session_assignment_role_type_id not in (select id from session_assignment_role_type where session_assignment_role_type.name = 'Reserve')") + .where("session_assignments.session_assignment_role_type_id is not null AND session_assignments.state != 'rejected'") + end + + def publishable + # get the people with the given role + where("sessions.status != 'draft' and sessions.status != 'dropped' and sessions.visibility = 'public'") + .where("sessions.start_time is not null and sessions.room_id is not null") + .where("session_assignments.session_assignment_role_type_id not in (select id from session_assignment_role_type where session_assignment_role_type.name = 'Invisible')") + .where("session_assignments.session_assignment_role_type_id not in (select id from session_assignment_role_type where session_assignment_role_type.name = 'Reserve')") + .where("session_assignments.session_assignment_role_type_id is not null AND session_assignments.state != 'rejected'") + end + end has_many :person_exclusions, dependent: :destroy has_many :exclusions, through: :person_exclusions @@ -38,8 +64,8 @@ class Person < ApplicationRecord class_name: 'PersonSchedule' # We let the publish mechanism do the destroy so that the update service knows what is happening - # has_many :published_session_assignments - # has_many :published_sessions, through: :published_session_assignments + has_many :published_session_assignments + has_many :published_sessions, through: :published_session_assignments has_many :person_mailing_assignments has_many :mailings, through: :person_mailing_assignments diff --git a/app/models/person_constraints.rb b/app/models/person_constraints.rb index de7221cfe..21faf4b13 100644 --- a/app/models/person_constraints.rb +++ b/app/models/person_constraints.rb @@ -1,5 +1,5 @@ class PersonConstraints < ApplicationRecord belongs_to :person - has_paper_trail versions: { class_name: 'Audit::PersonVersion' }, ignore: [:updated_at, :created_at] + has_paper_trail versions: { class_name: 'Audit::PersonVersion' }, ignore: [:updated_at, :created_at, :lock_version] end diff --git a/app/models/person_exclusion.rb b/app/models/person_exclusion.rb index e75e8b889..6c8b3395b 100644 --- a/app/models/person_exclusion.rb +++ b/app/models/person_exclusion.rb @@ -5,5 +5,5 @@ class PersonExclusion < ApplicationRecord validates :person_id, presence: true validates :exclusion_id, presence: true - has_paper_trail versions: { class_name: 'Audit::PersonVersion' }, ignore: [:updated_at, :created_at] + has_paper_trail versions: { class_name: 'Audit::PersonVersion' }, ignore: [:updated_at, :created_at, :lock_version] end diff --git a/app/models/publication_date.rb b/app/models/publication_date.rb index f82b4812a..a2398849f 100644 --- a/app/models/publication_date.rb +++ b/app/models/publication_date.rb @@ -1,2 +1,10 @@ class PublicationDate < ApplicationRecord + has_many :publish_snapshots do + def schedules + where("publish_snapshots.label = 'schedule'") + end + def participants + where("publish_snapshots.label = 'participants'") + end + end end diff --git a/app/models/publication_status.rb b/app/models/publication_status.rb index bff813615..3900eff2d 100644 --- a/app/models/publication_status.rb +++ b/app/models/publication_status.rb @@ -1,11 +1,3 @@ class PublicationStatus < ApplicationRecord - validates_inclusion_of :status, in: %i[inprogress completed] - - def status - read_attribute(:status).to_sym - end - - def status=(value) - write_attribute(:status, value.to_s) - end + validates_inclusion_of :status, in: %w[inprogress completed] end diff --git a/app/models/publish_snapshot.rb b/app/models/publish_snapshot.rb new file mode 100644 index 000000000..5b6001131 --- /dev/null +++ b/app/models/publish_snapshot.rb @@ -0,0 +1,5 @@ +class PublishSnapshot < ApplicationRecord + validates_inclusion_of :label, in: %w[schedule participants] + + belongs_to :publication_date +end diff --git a/app/models/published_session.rb b/app/models/published_session.rb index 736cb7ef0..c57017801 100644 --- a/app/models/published_session.rb +++ b/app/models/published_session.rb @@ -4,10 +4,20 @@ class PublishedSession < ApplicationRecord self.primary_key = :session_id - has_paper_trail versions: { class_name: 'Audit::PublishedSessionVersion' }, ignore: [:updated_at, :created_at] + has_paper_trail versions: { class_name: 'Audit::PublishedSessionVersion' }, ignore: [:updated_at, :created_at, :lock_version] belongs_to :format belongs_to :session + belongs_to :room, required: false + + belongs_to :age_restriction, required: false + + enum environment: { + unknown: 'unknown', + in_person: 'in_person', + hybrid: 'hybrid', + virtual: 'virtual' + } has_many :published_session_assignments, dependent: :destroy do # get the people with the given role @@ -24,6 +34,15 @@ def roles(role_ids) end has_many :people, through: :published_session_assignments + has_many :participant_assignments, + -> { + joins("JOIN session_assignment_role_type as sart ON sart.id = published_session_assignments.session_assignment_role_type_id") + .where("published_session_assignments.session_assignment_role_type_id not in (select id from session_assignment_role_type where session_assignment_role_type.name = 'Reserve')") + .order("sart.sort_order") + }, + class_name: 'PublishedSessionAssignment' + has_many :participants, through: :participant_assignments, source: :person, class_name: 'Person' + enum visibility: { is_public: 'public', is_private: 'private' @@ -47,4 +66,19 @@ def public? def private? visibility == 'public' end + + def self.area_list + sessions = PublishedSession.arel_table + session_areas = Arel::Table.new(SessionArea.table_name) #.alias('session') + areas = Arel::Table.new(Area.table_name) + + sessions.project(sessions[:session_id].as('session_id'), area_aggregate_fn( areas[:name] ).as('area_list')) + .join(session_areas, Arel::Nodes::OuterJoin).on(sessions[:session_id].eq(session_areas[:session_id])) + .join(areas, Arel::Nodes::OuterJoin).on(session_areas[:area_id].eq(areas[:id])) + .group('published_sessions.session_id') + end + + def self.area_aggregate_fn(col) + Arel::Nodes::NamedFunction.new('array_remove',[Arel::Nodes::NamedFunction.new('array_agg',[col]), Arel::Nodes::SqlLiteral.new("NULL")]) + end end diff --git a/app/models/published_session_assignment.rb b/app/models/published_session_assignment.rb index d1415f520..f3e3777ac 100644 --- a/app/models/published_session_assignment.rb +++ b/app/models/published_session_assignment.rb @@ -1,17 +1,16 @@ class PublishedSessionAssignment < ApplicationRecord self.primary_key = :session_assignment_id - has_paper_trail versions: { class_name: 'Audit::PublishedSessionVersion' }, ignore: [:updated_at, :created_at] + has_paper_trail versions: { class_name: 'Audit::PublishedSessionVersion' }, ignore: [:updated_at, :created_at, :lock_version, :sort_order] include RankedModel - ranks :sort_order, with_same: [:session_id] + ranks :sort_order, with_same: [:published_session_id] + belongs_to :person - belongs_to :published_session, - foreign_key: 'session_id' + belongs_to :published_session + belongs_to :session_assignment_role_type, required: false belongs_to :session_assignment - has_one :session_assignment_role_type - enum visibility: { is_public: 'public', is_private: 'private' diff --git a/app/models/session.rb b/app/models/session.rb index a749cdc9b..d95597cf0 100644 --- a/app/models/session.rb +++ b/app/models/session.rb @@ -1,4 +1,6 @@ class Session < ApplicationRecord + include XmlFormattable + validates_presence_of :title validates_numericality_of :duration, allow_nil: true validates_numericality_of :minimum_people, allow_nil: true @@ -7,7 +9,7 @@ class Session < ApplicationRecord # NOTE: when we have a config for default duration change to use a lambda attribute :duration, default: 60 - has_paper_trail versions: { class_name: 'Audit::SessionVersion' }, ignore: [:updated_at, :created_at] + has_paper_trail versions: { class_name: 'Audit::SessionVersion' }, ignore: [:updated_at, :created_at, :updated_by, :lock_version, :interest_opened_by, :interest_opened_at] belongs_to :format, required: false has_one :published_session, dependent: :destroy diff --git a/app/models/session_area.rb b/app/models/session_area.rb index d4f6334f7..305ba59d6 100644 --- a/app/models/session_area.rb +++ b/app/models/session_area.rb @@ -7,5 +7,5 @@ class SessionArea < ApplicationRecord accepts_nested_attributes_for :area - has_paper_trail versions: { class_name: 'Audit::SessionVersion' }, ignore: [:updated_at, :created_at] + has_paper_trail versions: { class_name: 'Audit::SessionVersion' }, ignore: [:updated_at, :created_at, :lock_version] end diff --git a/app/models/session_assignment.rb b/app/models/session_assignment.rb index 59ac5bafd..30621ef16 100644 --- a/app/models/session_assignment.rb +++ b/app/models/session_assignment.rb @@ -20,7 +20,7 @@ class SessionAssignment < ApplicationRecord include RankedModel ranks :sort_order, with_same: [:session_id] - has_paper_trail versions: { class_name: 'Audit::SessionVersion' }, ignore: [:updated_at, :created_at] + has_paper_trail versions: { class_name: 'Audit::SessionVersion' }, ignore: [:updated_at, :created_at, :lock_version, :sort_order, :interested, :interest_ranking, :interest_notes, :planner_notes, :interest_role] belongs_to :person belongs_to :session diff --git a/app/models/session_limit.rb b/app/models/session_limit.rb index 410037632..37fc92e73 100644 --- a/app/models/session_limit.rb +++ b/app/models/session_limit.rb @@ -11,7 +11,7 @@ def validate(record) class SessionLimit < ApplicationRecord belongs_to :person - has_paper_trail versions: { class_name: 'Audit::PersonVersion' }, ignore: [:updated_at, :created_at] + has_paper_trail versions: { class_name: 'Audit::PersonVersion' }, ignore: [:updated_at, :created_at, :lock_version] validates :person_id, presence: true # validates :max_sessions, presence: true diff --git a/app/models/survey.rb b/app/models/survey.rb index cc6ed48b0..8e6c0ead6 100644 --- a/app/models/survey.rb +++ b/app/models/survey.rb @@ -6,7 +6,7 @@ class Survey < ApplicationRecord dependent: :destroy accepts_nested_attributes_for :pages, allow_destroy: true - has_paper_trail versions: { class_name: 'Audit::SurveyVersion' }, ignore: [:updated_at, :created_at] + has_paper_trail versions: { class_name: 'Audit::SurveyVersion' }, ignore: [:updated_at, :created_at, :lock_version] has_many :questions, through: :pages, class_name: 'Survey::Question' diff --git a/app/models/survey/answer.rb b/app/models/survey/answer.rb index 93b45f896..8f6cbee74 100644 --- a/app/models/survey/answer.rb +++ b/app/models/survey/answer.rb @@ -2,7 +2,7 @@ class Survey::Answer < ApplicationRecord include RankedModel ranks :sort_order, with_same: :question_id - has_paper_trail versions: { class_name: 'Audit::SurveyVersion' }, ignore: [:updated_at, :created_at] + has_paper_trail versions: { class_name: 'Audit::SurveyVersion' }, ignore: [:updated_at, :created_at, :lock_version, :sort_order] enum next_page_action: { next_page: 'next_page', submit: 'submit' } diff --git a/app/models/survey/page.rb b/app/models/survey/page.rb index d366318a3..db197af7c 100644 --- a/app/models/survey/page.rb +++ b/app/models/survey/page.rb @@ -4,7 +4,7 @@ class Survey::Page < ApplicationRecord default_scope { order(['survey_pages.sort_order asc'])} - has_paper_trail versions: { class_name: 'Audit::SurveyVersion' }, ignore: [:updated_at, :created_at] + has_paper_trail versions: { class_name: 'Audit::SurveyVersion' }, ignore: [:updated_at, :created_at, :lock_version, :sort_order] # scope the questions so we do not include those that were deleted has_many :questions, diff --git a/app/models/survey/question.rb b/app/models/survey/question.rb index cf44043eb..117e10156 100644 --- a/app/models/survey/question.rb +++ b/app/models/survey/question.rb @@ -6,7 +6,7 @@ class Survey::Question < ApplicationRecord scope :not_deleted, -> { where(deleted_at: nil) } scope :deleted, -> { where('survey_questions.deleted_at is not null') } - has_paper_trail versions: { class_name: 'Audit::SurveyVersion' }, ignore: [:updated_at, :created_at] + has_paper_trail versions: { class_name: 'Audit::SurveyVersion' }, ignore: [:updated_at, :created_at, :lock_version, :sort_order] default_scope {includes(:page).order(['survey_pages.sort_order asc', 'survey_questions.sort_order asc'])} diff --git a/app/policies/mail_history_policy.rb b/app/policies/mail_history_policy.rb new file mode 100644 index 000000000..3b69ac35b --- /dev/null +++ b/app/policies/mail_history_policy.rb @@ -0,0 +1,23 @@ +class MailHistoryPolicy < PlannerPolicy + def create? + false + end + + def update? + false + end + + def destroy? + false + end + + class Scope < PlannerPolicy::Scope + def resolve + if allowed?(action: :index) + scope.all + else + scope.where(person_id: @person.id) + end + end + end +end diff --git a/app/policies/report_policy.rb b/app/policies/report_policy.rb index dc241922f..eea450a4c 100644 --- a/app/policies/report_policy.rb +++ b/app/policies/report_policy.rb @@ -1,5 +1,9 @@ class ReportPolicy < BasePolicy + def record_stream_permissions? + allowed?(action: :record_stream_permissions) + end + def schedule_accpetance? allowed?(action: :schedule_accpetance) end diff --git a/app/policies/session_policy.rb b/app/policies/session_policy.rb index 247c46d97..6bb20d2c8 100644 --- a/app/policies/session_policy.rb +++ b/app/policies/session_policy.rb @@ -3,6 +3,14 @@ def delete_snapshot? !Rails.env.production? && allowed?(action: :delete_snapshot) end + def schedule_publish? + allowed?(action: :schedule_publish) + end + + def update_all? + allowed?(action: :update_all) + end + def take_snapshot? allowed?(action: :take_snapshot) end diff --git a/app/serializers/conclar/participant_serializer.rb b/app/serializers/conclar/participant_serializer.rb index 85492d0bb..2fe0eddfa 100644 --- a/app/serializers/conclar/participant_serializer.rb +++ b/app/serializers/conclar/participant_serializer.rb @@ -11,45 +11,40 @@ class Conclar::ParticipantSerializer < ActiveModel::Serializer attribute :prog do res = [] - moderator = SessionAssignmentRoleType.find_by(name: 'Moderator') - participant = SessionAssignmentRoleType.find_by(name: 'Participant') + # TODO: change when we have publish process ??? + if object.published_session_assignments.count > 0 + moderator = SessionAssignmentRoleType.find_by(name: 'Moderator') + participant = SessionAssignmentRoleType.find_by(name: 'Participant') + + object.published_session_assignments.each do |assignment| + next if assignment.session_assignment_role_type_id != moderator.id and assignment.session_assignment_role_type_id != participant.id + + res << assignment.published_session_id + end + else + object.sessions.publishable.each do |session| + res << session.id + end + end + + res + end - object.session_assignments.each do |assignment| - next if assignment.session_assignment_role_type_id != moderator.id and assignment.session_assignment_role_type_id != participant.id + attribute :links do + res = {} - res << assignment.session_id - end + res['twitter'] = "https://twitter.com/#{object.twitter}" unless object.twitter.blank? + res['facebook'] = "https://facebook.com/#{object.facebook}" unless object.facebook.blank? + res['website'] = object.website unless object.website.blank? + res['instagram'] = "https://instagram.com/#{object.instagram}" unless object.instagram.blank? + res['twitch'] = "https://twitch.tv/#{object.twitch}" unless object.twitch.blank? + res['youtube'] = "https://youtube.com/#{object.youtube}" unless object.youtube.blank? + res['tiktok'] = "https://www.tiktok.com/@#{object.tiktok}" unless object.tiktok.blank? + res['linkedin'] = "https://linkedin.com/in/#{object.linkedin}" unless object.linkedin.blank? + res['othersocialmedia'] = object.othersocialmedia unless object.othersocialmedia.blank? res end - # links ???? - # what about social media URLs? # tags - not supported yet end - -# All participants on the schedule ... -# var people = [ -# { -# "id": "4567", -# "name": [ "Friend Andhis Jr." ], -# "sortname" : "Andhis Jr., Friend", -# "tags": [], -# "prog": [ "1234", "614", "801" ], -# "links": [], -# "bio": "Prior art for Adams's satirical point – that humans attach such importance to their automobiles that a visiting extraterrestrial might reasonably mistake them for the planet's dominant life form – can be found in a widely reprinted article from <i>The Rockefeller Institute Review</i> titled <i>Life on Earth (by a Martian)</i> by Paul Weiss. The idea was also expounded by Carl Sagan, though this may have postdated Adams's creation of the character of Ford. The 1967 Oscar-nominated animated film <i>What on Earth!</i> from the National Film Board of Canada is also based on this premise." -# }, -# { -# "id": "1234", -# "name": [ "Galahad", "", "Sir" ], -# "sortname": "Sir Galahad", -# "tags": [ "GoH" ], -# "prog": [ "416" ], -# "links": { -# "img": "/images/galahad.jpg", -# "photo": "/images/galahad.jpg", -# "img_256_url": "/images/galahad.jpg", -# "url": "http://en.wikipedia.org/wiki/Galahad" -# }, -# "bio": "Sir Galahad (/ˈɡæləhæd/; Middle Welsh: Gwalchavad, sometimes referred to as Galeas /ɡəˈliːəs/ or Galath /ˈɡæləθ/), in Arthurian legend, is a knight of King Arthur's Round Table and one of the three achievers of the Holy Grail." -# }, diff --git a/app/serializers/conclar/session_serializer.rb b/app/serializers/conclar/session_serializer.rb index 5189ff8b9..ddd96b135 100644 --- a/app/serializers/conclar/session_serializer.rb +++ b/app/serializers/conclar/session_serializer.rb @@ -1,5 +1,13 @@ class Conclar::SessionSerializer < ActiveModel::Serializer - attributes :id, :title + attributes :title + + attribute :id do + if object.has_attribute?(:id) + object.id + else + object.session_id + end + end attribute :desc do object.description @@ -13,16 +21,19 @@ class Conclar::SessionSerializer < ActiveModel::Serializer attribute :tags do res = [] - # TODO: optimize - res.concat object.areas.collect(&:name) + res.concat object.area_list #.collect(&:name) res.concat [object.age_restriction.name] if object.age_restriction - res.concat [object.environment] if object.environment != 'unknown' + res.concat [object.environment] if object.environment != 'unknown' # virtual hybrid etc if object.minors_participation && object.minors_participation.class == Array res.concat object.minors_participation end + res.concat ['Require Signup'] if object.require_signup + res.concat ['Recorded'] if object.recorded + res.concat ['Streamed'] if object.streamed + res end diff --git a/app/serializers/mail_history_serializer.rb b/app/serializers/mail_history_serializer.rb index 746d0a72e..3f3562566 100644 --- a/app/serializers/mail_history_serializer.rb +++ b/app/serializers/mail_history_serializer.rb @@ -1,13 +1,15 @@ class MailHistorySerializer include JSONAPI::Serializer - # set id? attributes :id, :lock_version, - :email_status, :date_sent, :email, + :email_status, :date_sent, :content, :testrun, :subject, :created_at, :updated_at - # TODO + # This is because the email can be an array of emails + attribute :email do |object| + JSON.parse object.email + end # belongs_to :person_mailing_assignment # belongs_to :person # belongs_to :mailing diff --git a/app/serializers/session_serializer.rb b/app/serializers/session_serializer.rb index 6940511ac..cf3b6d9e3 100644 --- a/app/serializers/session_serializer.rb +++ b/app/serializers/session_serializer.rb @@ -13,7 +13,8 @@ class SessionSerializer :room_id, :proofed, :format_id, :room_set_id, :status, :environment, :tech_notes, :room_notes, - :minors_participation, :age_restriction_id + :minors_participation, :age_restriction_id, + :recorded, :streamed # tag_list attribute :tag_list do |session| diff --git a/app/services/mail_service.rb b/app/services/mail_service.rb index 14929488d..d4d6b2e21 100644 --- a/app/services/mail_service.rb +++ b/app/services/mail_service.rb @@ -2,6 +2,7 @@ module MailService def self.send_mailing( person:, mailing:, + participant_schedule_url:, tester: nil ) survey = mailing.survey @@ -10,6 +11,7 @@ def self.send_mailing( { person: person, survey: survey, + participant_schedule_url: participant_schedule_url, survey_url: self.generate_survey_url(survey: survey, person: person), login_url: self.generate_login_url(person: person) } @@ -20,19 +22,22 @@ def self.send_mailing( subject: mailing.subject, title: mailing.title, content: content, - is_test: tester != nil + is_test: tester != nil, + person: person, + mailing: mailing ) self.post_mail_transition(person: person, mailing: mailing) unless tester self.post_mail_assign_survey(person: person, survey: survey) unless tester end - def self.preview_email_content(person:, mailing:) + def self.preview_email_content(person:, mailing:, participant_schedule_url:) self.generate_email_content( mailing.content, { person: person, survey: mailing.survey, + participant_schedule_url: participant_schedule_url, survey_url: self.generate_survey_url(survey: mailing.survey, person: person), login_url: self.generate_login_url(person: person) } diff --git a/app/services/publication_service.rb b/app/services/publication_service.rb new file mode 100644 index 000000000..71f1d3a7e --- /dev/null +++ b/app/services/publication_service.rb @@ -0,0 +1,211 @@ +module PublicationService + + def self.start_publish_job + pstatus = PublicationStatus.order('created_at desc').first + pstatus = PublicationStatus.new if pstatus == nil + if pstatus.status != 'inprogress' + pstatus.status = 'inprogress' + pstatus.save! + + PublicationWorker.perform_async + end + end + + def self.gen_pub_numbers + Session.transaction(joinable: false, requires_new: true) do + sessions = publishable_sessions + + current_ref = 0 + sessions.each do |session| + current_ref += 1 + session.pub_reference_number = current_ref + session.save! + end + end + end + + # Publish the schedule + # Copy Sessions, and Assignments to Published versions + # only deal with sessions and assignments chances since + def self.publish(since: nil) + res = {} + Session.transaction(joinable: false, requires_new: true) do + session_results = self.publish_sessions(sessions: self.publishable_sessions, since: since) + assignment_results = self.publish_assignments(sessions: self.publishable_sessions, since: since) + + res = session_results.merge(assignment_results) + end + res + end + + # POST published - create a cache for the published schedule + + def self.publish_sessions(sessions:, since:) + candidates = if since + sessions.where("sessions.updated_at >= ?", since) + else + sessions + end + + # updated + updated_sessions = self.publish_updated_sessions(candidates) + # new + new_sessions = self.publish_new_sessions(candidates) + # dropped + dropped_sessions = self.remove_dropped_sessions(sessions) + + { + new_sessions: new_sessions, + updated_sessions: updated_sessions, + dropped_sessions: dropped_sessions + } + end + + def self.publish_assignments(sessions:, since:) + candidates = if since + self.publishable_assignments(sessions: sessions).where("session_assignments.updated_at >= ?", since) + else + self.publishable_assignments(sessions: sessions) + end + + # updated + updated_assignments = self.publish_updated_assignments(candidates) + # new + new_assignments = self.publish_new_assignments(candidates) + # dropped + dropped_assignments = self.remove_dropped_assignments(self.publishable_assignments(sessions: sessions)) + + { + new_assignments: new_assignments, + updated_assignments: updated_assignments, + dropped_assignments: dropped_assignments + } + end + + def self.publish_new_sessions(sessions) + candidates = if PublishedSession.count > 0 + sessions.where("id not in (?)", PublishedSession.all.collect(&:session_id)) + else + sessions + end + count = candidates.count + + candidates.each do |session| + pub_session = self.publish_session(session: session, update: false) + pub_session.save! + end + count + end + + def self.publish_updated_sessions(sessions) + candidates = sessions.where("id in (?)", PublishedSession.all.collect(&:session_id)) + count = candidates.count + + candidates.each do |session| + pub_session = self.publish_session(session: session) + pub_session.save! + end + + count + end + + def self.remove_dropped_sessions(sessions) + candidates = PublishedSession.where("session_id not in (?)", sessions.collect(&:id)) + count = candidates.count + candidates.destroy_all + count + end + + # Published (create or update) the requested session, if the session already has a published + # version then we update that otherwise we create one + def self.publish_session(session:, update: true) + pub_session = PublishedSession.find_by session_id: session.id + pub_session ||= PublishedSession.new unless update + + return unless pub_session + + session.attributes.each do |attr, val| + next if val.nil? # if there is nothing to copy skip + next if !pub_session.attributes.key?(attr) # if the published version does not support the attr skip + next if [:lock_version, :created_at, :updated_at, :id].include?(attr) # skip lock and dates + next if pub_session.attributes[attr] == val # skip if the value will not change + + pub_session.send("#{attr}=", val) # the the attr in the publihsed instance + end + pub_session.session_id = session.id unless pub_session.session_id # point published to source session + + pub_session + end + + # + def self.publish_new_assignments(assignments) + candidates = if PublishedSessionAssignment.count > 0 + assignments.where("id not in (?)", PublishedSessionAssignment.all.collect(&:session_assignment_id)) + else + assignments + end + + count = candidates.count + + candidates.each do |assignment| + pub_assignment = self.publish_assignment(assignment: assignment, update: false) + pub_assignment.save! + end + + count + end + + def self.publish_updated_assignments(assignments) + candidates = assignments.where("id not in (?)", PublishedSessionAssignment.all.collect(&:session_assignment_id)) + count = candidates.count + + candidates.each do |assignment| + pub_assignment = self.publish_assignment(assignment: assignment) + pub_assignment.save! + end + + count + end + + def self.remove_dropped_assignments(assignments) + candidates = PublishedSessionAssignment.where("session_assignment_id not in (?)", assignments.collect(&:id)) + count = candidates.count + candidates.destroy_all + count + end + + # Create published versions of the assignments + def self.publish_assignment(assignment:, update: true) + pub_assignment = PublishedSessionAssignment.find_by session_assignment_id: assignment.id + pub_assignment ||= PublishedSessionAssignment.new unless update + + return unless pub_assignment + + assignment.attributes.each do |attr, val| + next if val.nil? # if there is nothing to copy skip + next if !pub_assignment.attributes.key?(attr) # if the published version does not support the attr skip + next if [:lock_version, :created_at, :updated_at, :id, :session_id].include?(attr) # skip lock and dates + next if pub_assignment.attributes[attr] == val # skip if the value will not change + + pub_assignment.send("#{attr}=", val) # the the attr in the publihsed instance + end + pub_assignment.session_assignment_id = assignment.id unless pub_assignment.session_assignment_id # point published to source + pub_assignment.published_session_id = assignment.session_id unless pub_assignment.published_session_id + + pub_assignment + end + + # Get the session elligible for publication, not draft or dropped and must be public + def self.publishable_sessions + Session + .where("status != 'draft' and status != 'dropped' and visibility = 'public'") + .where("room_id is not null and start_time is not null") + end + + def self.publishable_assignments(sessions:) + SessionAssignment + .where("session_assignments.session_id in (?)", sessions.collect(&:id)) + .where("session_assignments.session_assignment_role_type_id not in (select id from session_assignment_role_type where session_assignment_role_type.name = 'Invisible' or session_assignment_role_type.name = 'Reserve')") + .where("session_assignments.visibility = 'public'") + end +end diff --git a/app/services/reports_service.rb b/app/services/reports_service.rb index 14b2ad268..d1e4d44e3 100644 --- a/app/services/reports_service.rb +++ b/app/services/reports_service.rb @@ -106,35 +106,6 @@ def self.scheduled_session_no_people # .order(:start_time) end - # Get all the schedule sessions - def self.scheduled_sessions - Session.select( - ::Session.arel_table[Arel.star], - 'areas_list.area_list' - ) - .includes(:format, :room, {participant_assignments: :person}) - .joins(self.area_subquery) - .where("start_time is not null and room_id is not null") - .where("status != 'dropped' and status != 'draft'") - .order(:start_time) - end - - def self.scheduled_people - moderator = SessionAssignmentRoleType.find_by(name: 'Moderator') - participant = SessionAssignmentRoleType.find_by(name: 'Participant') - - people = Person.includes( - {session_assignments: [:session, :session_assignment_role_type]} - ).references( - {session_assignments: :session} - ) - .where("session_assignments.session_assignment_role_type_id in (?)", [moderator.id, participant.id]) - .where("sessions.start_time is not null and sessions.room_id is not null") - .where("sessions.status != 'dropped' and sessions.status != 'draft'") - .where("people.con_state not in (?)", ['declined', 'rejected']) #.distinct - .order("people.published_name") - end - def self.sessions_with_no_moderator sched_table = PersonSchedule.arel_table session_and_roles = sched_table.project( diff --git a/app/services/session_service.rb b/app/services/session_service.rb index 897c7b60c..05cd2a54a 100644 --- a/app/services/session_service.rb +++ b/app/services/session_service.rb @@ -1,4 +1,13 @@ module SessionService + def self.participant_schedule_url + workflow = ScheduleWorkflow.order("updated_at desc").first + + return UrlService.url_for(path: "/#/profile/draft-schedule") if workflow && workflow.state == ScheduleWorkflow.states[:draft] + return UrlService.url_for(path: "/#/profile/schedule") if workflow && workflow.state == ScheduleWorkflow.states[:firm] + + return UrlService.url_for(path: '/') + end + # SessionService.draft_schedule_for(person: p) def self.draft_schedule_for(person:, current_person: nil) sched_table = ::PersonSchedule.arel_table @@ -28,4 +37,127 @@ def self.draft_schedule_for(person:, current_person: nil) PersonScheduleSerializer.new(schedule).serializable_hash end + + # Get all the schedule sessions + # ActiveModel::Serializer::CollectionSerializer.new( + # sessions, + # serializer: Conclar::SessionSerializer + # ) + def self.scheduled_sessions + if PublishedSession.count > 0 || Rails.env.production? + self.published_sessions + else + self.live_sessions + end + end + + def self.cache_published_sessions(publication_date:) + sessions = self.published_sessions + + snapshot = ActiveModel::Serializer::CollectionSerializer.new( + sessions, + serializer: Conclar::SessionSerializer + ) #.serializable_hash + + PublishSnapshot.create!( + snapshot: snapshot, + label: 'schedule', + publication_date: publication_date + ) + end + + def self.cache_published_participants(publication_date:) + participants = self.scheduled_people + + snapshot = ActiveModel::Serializer::CollectionSerializer.new( + participants, + serializer: Conclar::ParticipantSerializer + ) #.serializable_hash + + PublishSnapshot.create!( + snapshot: snapshot, + label: 'participants', + publication_date: publication_date + ) + end + + # ActiveModel::Serializer::CollectionSerializer.new( + # participants, + # serializer: Conclar::ParticipantSerializer + # ), + def self.scheduled_people + # TODO: when publish process done change condition + if PublishedSession.count > 0 || Rails.env.production? + self.published_people + else + self.live_people + end + end + + def self.published_sessions + PublishedSession.select( + ::PublishedSession.arel_table[Arel.star], + 'areas_list.area_list' + ) + .includes(:format, :room, {participant_assignments: :person}) + .joins(self.area_subquery(clazz: PublishedSession)) + end + + def self.live_sessions + Session.select( + ::Session.arel_table[Arel.star], + 'areas_list.area_list' + ) + .includes(:format, :room, {participant_assignments: :person}) + .joins(self.area_subquery) + .where("start_time is not null and room_id is not null") + .where("status != 'dropped' and status != 'draft'") + .order(:start_time) + end + + def self.published_people + moderator = SessionAssignmentRoleType.find_by(name: 'Moderator') + participant = SessionAssignmentRoleType.find_by(name: 'Participant') + + people = Person.includes( + {published_session_assignments: [:published_session, :session_assignment_role_type]} + ).references( + {published_session_assignments: :published_session} + ) + .where("published_session_assignments.session_assignment_role_type_id in (?)", [moderator.id, participant.id]) + .where("published_sessions.start_time is not null and published_sessions.room_id is not null") + .where("people.con_state not in (?)", ['declined', 'rejected']) #.distinct + .order("people.published_name") + end + + def self.live_people + moderator = SessionAssignmentRoleType.find_by(name: 'Moderator') + participant = SessionAssignmentRoleType.find_by(name: 'Participant') + + people = Person.includes( + {session_assignments: [:session, :session_assignment_role_type]} + ).references( + {session_assignments: :session} + ) + .where("session_assignments.session_assignment_role_type_id in (?)", [moderator.id, participant.id]) + .where("sessions.start_time is not null and sessions.room_id is not null") + .where("sessions.status != 'dropped' and sessions.status != 'draft'") + .where("people.con_state not in (?)", ['declined', 'rejected']) #.distinct + .order("people.published_name") + end + + def self.area_subquery(clazz: Session) + session_table = clazz.arel_table + areas_list = clazz.area_list.as('areas_list') + id = (clazz == Session) ? :id : :session_id + [ + session_table.create_join( + areas_list, + session_table.create_on( + areas_list[:session_id].eq(session_table[id]) + ), + Arel::Nodes::OuterJoin + ) + ] + end end diff --git a/app/services/xml_formatter.rb b/app/services/xml_formatter.rb new file mode 100644 index 000000000..2429f86a1 --- /dev/null +++ b/app/services/xml_formatter.rb @@ -0,0 +1,29 @@ +class XmlFormatter + attr_reader :formattable + + def initialize(formattable) + @formattable = formattable + # Use Nokogiri to build XML output + @xml = Nokogiri::XML::Builder.new + end + + def format + render file_name, formattable, xml: @xml + end + + def render(file_name, object, options = nil) + file = "#{template_path}/#{file_name}.nokogiri" + scope = Object.new + scope.instance_variable_set :@instance, object + # at the moment we do not have any options ... TODO: check + Tilt.new(file).render(scope) #, options) + end + + def template_path + Rails.root.join("app", "views", "xml_templates") + end + + def file_name + formattable.class.name.downcase + end +end diff --git a/app/views/xml_templates/schedule.nokogiri b/app/views/xml_templates/schedule.nokogiri new file mode 100644 index 000000000..92cb7e0c4 --- /dev/null +++ b/app/views/xml_templates/schedule.nokogiri @@ -0,0 +1,62 @@ +# +# XML template for a collection of sessions +# +xml.schedule { + # We need a <day> header at the start of the document, so + # before_1st_session needs to be at least 86400 seconds (the + # number of seconds in a day) earlier than the 1st session + before_1st_session = @instance.first.start_time - 86401 + day = Date.parse(before_1st_session.strftime("%Y-%m-%d")) + timeslot = Time.parse(before_1st_session.strftime("%Y-%m-%d %l:%M")) + @instance.each do |session| + # Check if this is the 1st session of the day, in which case, + # create a <day> heading. + this_day = Date.parse(session.start_time.strftime("%Y-%m-%d")) + if this_day > day + day = this_day + xml.day(session.start_time.strftime("%A")) + end + # Check if this is the 1st session at this time, in which case, + # create a <timeslot> subheading. + if session.start_time.utc > timeslot.utc + timeslot = session.start_time.utc + xml.timeslot(session.start_time.strftime("%-l:%M")) + end + xml.session { + xml.id(session.pub_reference_number) + xml.title(session.title) + # timeduration, roomareasformat, and participants will each be a + # block-level paragraph with span-level elements. The line + # breaks & spacing will be removed in publications_controller.rb. + # Otherwise InDesign would render the white space in print. + xml.timeduration{ + xml.start_time(session.start_time.strftime("%-l:%M")) + xml.duration(session.duration) + } + xml.roomareasformat{ + xml.room(session.room.name) + xml.areas(session.area_list.join(", ")) + xml.format(session.format.name) + } + xml.description(session.description) + # If there are no visible participants, do not create an empty + # <participants> element, because InDesign would render that + # as an empty space. + non_invisible = session.participant_assignments.map { + |assignment| assignment.session_assignment_role_type.name != 'Invisible' + } + if non_invisible.length > 0 + xml.participants { + session.participant_assignments.each do |assignment| + xml.person { + xml.name(assignment.person.published_name) + if assignment.session_assignment_role_type.name == 'Moderator' + xml.role("(#{assignment.session_assignment_role_type.name})") + end + } + end + } + end + } + end +} diff --git a/app/views/xml_templates/session.nokogiri b/app/views/xml_templates/session.nokogiri new file mode 100644 index 000000000..2bc9d0a4c --- /dev/null +++ b/app/views/xml_templates/session.nokogiri @@ -0,0 +1,15 @@ +# +# This is an XML template for Session +# TODO: tweak for InDesign +# +xml.session { + xml.title @instance.title + xml.description @instance.description + # Start time is in convention Timezone (TODO) + xml.start_time @instance.start_time + xml.duration @instance.duration + xml.areas @instance.areas + xml.format @instance.format.name + xml.room @instance.room.name + xml.participants @instance.participants +} diff --git a/app/workers/mailing_worker.rb b/app/workers/mailing_worker.rb index 0bbfb2664..4b889c52b 100644 --- a/app/workers/mailing_worker.rb +++ b/app/workers/mailing_worker.rb @@ -22,6 +22,8 @@ def send_mailing(mailing:) # TODO - if test run then send to the requestor return unless mailing.mailing_state == Mailing.mailing_states[:submitted] # Check just in case this is a dup + participant_schedule_url = SessionService.participant_schedule_url + last_person_index = mailing.last_person_idx mailing.people.each_with_index do |person, idx| next unless (last_person_index == -1) || (idx > last_person_index) @@ -29,7 +31,8 @@ def send_mailing(mailing:) begin MailService.send_mailing( person: person, - mailing: mailing + mailing: mailing, + participant_schedule_url: participant_schedule_url ) # note the last person processes so we can continue from there if job stopped and restarted @@ -59,10 +62,12 @@ def send_test_mail(mailing:, test_address:, tester_id:) addr = EmailAddress.find_by(email: test_address) if addr tester = Person.find tester_id + participant_schedule_url = SessionService.participant_schedule_url MailService.send_mailing( person: addr.person, mailing: mailing, + participant_schedule_url: participant_schedule_url, tester: tester ) end diff --git a/app/workers/publication_worker.rb b/app/workers/publication_worker.rb new file mode 100644 index 000000000..cd7284978 --- /dev/null +++ b/app/workers/publication_worker.rb @@ -0,0 +1,38 @@ +class PublicationWorker + include Sidekiq::Worker + + def perform + # 0. we need to set the timezone? + # 2. Publish + # 3. Popultaed any caches that we need + pub_date = nil + PublishedSession.transaction do + pub_date = PublicationDate.new + + pub_date.timestamp = DateTime.current + last_pub = PublicationDate.order('timestamp desc').first + last_time = last_pub&.timestamp + + # DO WORK + result = PublicationService.publish(since: last_time) + + pub_date.update(result) + pub_date.save! + + # do we want the counts in this + # if so we need a completed_at time ????? + pstatus = PublicationStatus.order('created_at desc').first + pstatus = PublicationStatus.new if pstatus == nil + pstatus.status = 'completed' + pstatus.save! + end + + # populate basic caches + if pub_date + PublishedSession.transaction do + SessionService.cache_published_sessions(publication_date: pub_date) + SessionService.cache_published_participants(publication_date: pub_date) + end + end + end +end diff --git a/config/routes.rb b/config/routes.rb index b03ae03f4..569d02f45 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -51,6 +51,8 @@ get 'person/:person_id/mailed_surveys', to: 'people#mailed_surveys' get 'person/:person_id/assigned_surveys', to: 'people#assigned_surveys' + get 'publications/schedule', to: 'publications#schedule' + get 'report/participant_selections', to: 'reports#participant_selections' get 'report/session_selections', to: 'reports#session_selections' get 'report/participant_availabilities', to: 'reports#participant_availabilities' @@ -82,6 +84,8 @@ get 'report/conflict_reports/all_conflicts', to: 'reports/conflict_reports#all_conflicts' get 'report/conflict_reports/all_ignored_conflicts', to: 'reports/conflict_reports#all_ignored_conflicts' + get 'report/people_reports/record_stream_permissions', to: 'reports/people_reports#record_stream_permissions' + resources :availabilities, path: 'availability', except: [:index] resources :person_exclusions, path: 'person_exclusion', except: [:index] resources :session_limits, path: 'session_limit', except: [:index] @@ -136,7 +140,9 @@ resources :tags, path: 'tag' get 'session/tags', to: 'sessions#tags' + get 'session/schedule_publish', to: 'sessions#schedule_publish' post 'session/import', to: 'sessions#import' + post 'session/update_all', to: 'sessions#update_all' # get sessions/assigned_id - &include=session_assignments&filter[session_assignments][person_id]=person_id resources :sessions, path: 'session' do get 'session_assignments', to: 'session_assignments#index' diff --git a/db/migrate/20220726130346_add_fields_to_published_session.rb b/db/migrate/20220726130346_add_fields_to_published_session.rb new file mode 100644 index 000000000..b6fc5dd1e --- /dev/null +++ b/db/migrate/20220726130346_add_fields_to_published_session.rb @@ -0,0 +1,6 @@ +class AddFieldsToPublishedSession < ActiveRecord::Migration[6.1] + def change + add_column :published_sessions, :environment, :session_environments_enum, default: 'unknown' + add_column :published_sessions, :minors_participation, :jsonb + end +end diff --git a/db/migrate/20220801152151_adjust_pub_dates.rb b/db/migrate/20220801152151_adjust_pub_dates.rb new file mode 100644 index 000000000..b5d8314ab --- /dev/null +++ b/db/migrate/20220801152151_adjust_pub_dates.rb @@ -0,0 +1,15 @@ +class AdjustPubDates < ActiveRecord::Migration[6.1] + def change + add_column :publication_dates, :new_sessions, :integer, default: 0 + add_column :publication_dates, :updated_sessions, :integer, default: 0 + add_column :publication_dates, :dropped_sessions, :integer, default: 0 + + add_column :publication_dates, :new_assignments, :integer, default: 0 + add_column :publication_dates, :updated_assignments, :integer, default: 0 + add_column :publication_dates, :dropped_assignments, :integer, default: 0 + + remove_column :publication_dates, :newitems, :integer + remove_column :publication_dates, :modifieditems, :integer + remove_column :publication_dates, :removeditems, :integer + end +end diff --git a/db/migrate/20220801173704_create_publish_snapshots.rb b/db/migrate/20220801173704_create_publish_snapshots.rb new file mode 100644 index 000000000..bdf59303d --- /dev/null +++ b/db/migrate/20220801173704_create_publish_snapshots.rb @@ -0,0 +1,11 @@ +class CreatePublishSnapshots < ActiveRecord::Migration[6.1] + def change + create_table :publish_snapshots, id: :uuid do |t| + t.uuid :publication_date_id + t.string :label + t.jsonb :snapshot + + t.timestamps + end + end +end diff --git a/db/migrate/20220801195644_add_recorded_etc_to_sessions.rb b/db/migrate/20220801195644_add_recorded_etc_to_sessions.rb new file mode 100644 index 000000000..fd344586b --- /dev/null +++ b/db/migrate/20220801195644_add_recorded_etc_to_sessions.rb @@ -0,0 +1,9 @@ +class AddRecordedEtcToSessions < ActiveRecord::Migration[6.1] + def change + add_column :sessions, :recorded, :boolean, default: false, null: false + add_column :sessions, :streamed, :boolean, default: false, null: false + + add_column :published_sessions, :recorded, :boolean, default: false, null: false + add_column :published_sessions, :streamed, :boolean, default: false, null: false + end +end diff --git a/db/structure.sql b/db/structure.sql index 2e5efed3c..c6ec3094f 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -697,7 +697,9 @@ CREATE TABLE public.sessions ( age_restriction_id uuid, minors_participation jsonb, room_set_id uuid, - room_notes text + room_notes text, + recorded boolean DEFAULT false NOT NULL, + streamed boolean DEFAULT false NOT NULL ); @@ -1123,7 +1125,7 @@ CREATE VIEW public.person_schedules AS sessions.description, sessions.environment FROM (((public.session_assignments sa - JOIN public.session_assignment_role_type sart ON (((sart.id = sa.session_assignment_role_type_id) AND (sart.role_type = 'participant'::public.assignment_role_enum)))) + JOIN public.session_assignment_role_type sart ON (((sart.id = sa.session_assignment_role_type_id) AND (sart.role_type = 'participant'::public.assignment_role_enum) AND ((sart.name)::text <> 'Reserve'::text)))) JOIN public.people p ON ((p.id = sa.person_id))) LEFT JOIN public.sessions ON ((sessions.id = sa.session_id))) WHERE ((sa.session_assignment_role_type_id IS NOT NULL) AND (sessions.room_id IS NOT NULL) AND (sessions.start_time IS NOT NULL) AND ((sa.state)::text <> 'rejected'::text)); @@ -1340,9 +1342,12 @@ CREATE TABLE public.publication_dates ( "timestamp" timestamp without time zone, created_at timestamp without time zone NOT NULL, updated_at timestamp without time zone NOT NULL, - newitems integer DEFAULT 0, - modifieditems integer DEFAULT 0, - removeditems integer DEFAULT 0 + new_sessions integer DEFAULT 0, + updated_sessions integer DEFAULT 0, + dropped_sessions integer DEFAULT 0, + new_assignments integer DEFAULT 0, + updated_assignments integer DEFAULT 0, + dropped_assignments integer DEFAULT 0 ); @@ -1360,6 +1365,20 @@ CREATE TABLE public.publication_statuses ( ); +-- +-- Name: publish_snapshots; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.publish_snapshots ( + id uuid DEFAULT public.gen_random_uuid() NOT NULL, + publication_date_id uuid, + label character varying, + snapshot jsonb, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + -- -- Name: published_session_assignments; Type: TABLE; Schema: public; Owner: - -- @@ -1400,7 +1419,9 @@ CREATE TABLE public.published_sessions ( require_signup boolean DEFAULT false, waiting_list_size integer DEFAULT 0, environment public.session_environments_enum DEFAULT 'unknown'::public.session_environments_enum, - minors_participation jsonb + minors_participation jsonb, + recorded boolean DEFAULT false NOT NULL, + streamed boolean DEFAULT false NOT NULL ); @@ -2345,6 +2366,14 @@ ALTER TABLE ONLY public.publication_statuses ADD CONSTRAINT publication_statuses_pkey PRIMARY KEY (id); +-- +-- Name: publish_snapshots publish_snapshots_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.publish_snapshots + ADD CONSTRAINT publish_snapshots_pkey PRIMARY KEY (id); + + -- -- Name: published_session_assignments published_programme_assignments_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -3337,6 +3366,9 @@ INSERT INTO "schema_migrations" (version) VALUES ('20220714124706'), ('20220719000644'), ('20220723213605'), -('20220726130346'); +('20220726130346'), +('20220801152151'), +('20220801173704'), +('20220801195644'); diff --git a/lib/tasks/chicon.rake b/lib/tasks/chicon.rake index 760c68991..a4a0cf5c0 100644 --- a/lib/tasks/chicon.rake +++ b/lib/tasks/chicon.rake @@ -206,6 +206,21 @@ namespace :chicon do hyatt_rooms airmeet_rooms + rooms_no_venue + end + + def rooms_no_venue + # as requested by business + candidates = [ + {name: "Offsite", purpose: "Programming", sort_order: 601}, + ] + + candidates.each do |candidate| + room = Room.find_by name: candidate[:name] + next if room + + Room.create!(candidate) + end end def airmeet_rooms @@ -215,8 +230,9 @@ namespace :chicon do {venue_id: airmeet.id, name: "Airmeet 2", floor: "Virtual", purpose: "Programming", sort_order: 502}, {venue_id: airmeet.id, name: "Airmeet 3", floor: "Virtual", purpose: "Programming", sort_order: 503}, {venue_id: airmeet.id, name: "Airmeet 4", floor: "Virtual", purpose: "Programming", sort_order: 504}, - {venue_id: airmeet.id, name: "Airmeet Readings", floor: "Virtual", purpose: "Programming", sort_order: 505}, - {venue_id: airmeet.id, name: "Airmeet Table Talks", floor: "Virtual", purpose: "Programming", sort_order: 506} + {venue_id: airmeet.id, name: "Airmeet 5", floor: "Virtual", purpose: "Programming", sort_order: 505}, + {venue_id: airmeet.id, name: "Airmeet Readings", floor: "Virtual", purpose: "Programming", sort_order: 506}, + {venue_id: airmeet.id, name: "Airmeet Table Talks", floor: "Virtual", purpose: "Programming", sort_order: 507} ] candidates.each do |candidate| diff --git a/lib/tasks/mailing.rake b/lib/tasks/mailing.rake new file mode 100644 index 000000000..523c42c49 --- /dev/null +++ b/lib/tasks/mailing.rake @@ -0,0 +1,19 @@ +desc "Fix missing person from mail history" + +namespace :mail do + task fix_history: :environment do + MailHistory.all.each do |history| + email = JSON.parse(history.email).first + addr = EmailAddress.where("lower(email) = ? and isdefault = true", email.downcase).first + addr = EmailAddress.where("lower(email) = ?", email.downcase).first unless addr + puts "** No person for email: #{email}" unless addr + next unless addr + + if !history.person_id + history.person_id = addr.person_id + history.save!(touch: false) + end + # break + end + end +end diff --git a/lib/tasks/publications.rake b/lib/tasks/publications.rake new file mode 100644 index 000000000..ae07222d1 --- /dev/null +++ b/lib/tasks/publications.rake @@ -0,0 +1,20 @@ +desc "Utils for published data" + +namespace :pubs do + # Utility to reset the published info - use while testing + task reset: :environment do + PublishedSession.destroy_all + PublishSnapshot.delete_all + PublicationDate.delete_all + PublicationStatus.delete_all + + Audit::PublishedSessionVersion.delete_all + end + + task clear_audit: :environment do + Audit::PublishedSessionVersion.delete_all + Audit::SessionVersion.delete_all + Audit::PersonVersion.delete_all + Audit::SurveyVersion.delete_all + end +end diff --git a/lib/tasks/rbac.rake b/lib/tasks/rbac.rake index dd5303575..144397fe2 100644 --- a/lib/tasks/rbac.rake +++ b/lib/tasks/rbac.rake @@ -154,7 +154,9 @@ namespace :rbac do "destroy": false, "index": true, "show": true, - "update": false + "update": false, + "update_all": false, + "schedule_publish": false }, "format": { "create": false, @@ -409,7 +411,9 @@ namespace :rbac do "destroy": true, "index": true, "show": true, - "update": true + "update": true, + "update_all": true, + "schedule_publish": false }, "format": { "create": true, @@ -519,7 +523,8 @@ namespace :rbac do "schedule_by_person": true, "schedule_by_room_then_time": true, "session_selections": true, - "sessions_with_participants": true + "sessions_with_participants": true, + "record_stream_permissions": true }, "session_report": { "panels_with_too_few_people": true, @@ -702,7 +707,9 @@ namespace :rbac do "destroy": true, "index": true, "show": true, - "update": true + "update": true, + "update_all": true, + "schedule_publish": false }, "format": { "create": true, @@ -812,7 +819,8 @@ namespace :rbac do "schedule_by_person": true, "schedule_by_room_then_time": true, "session_selections": true, - "sessions_with_participants": true + "sessions_with_participants": true, + "record_stream_permissions": true }, "session_report": { "panels_with_too_few_people": true, diff --git a/public/ckeditor/plugins/planobuttons/plugin.js b/public/ckeditor/plugins/planobuttons/plugin.js index 5ffc54126..c380261c4 100644 --- a/public/ckeditor/plugins/planobuttons/plugin.js +++ b/public/ckeditor/plugins/planobuttons/plugin.js @@ -136,6 +136,11 @@ CKEDITOR.config.planobuttons = [ html:'<%= person.email %>', title:'Person\'s Primary Email' }, + { + name:'participant_schedule_url', + html:'<%= participant_schedule_url %>', + title:'Participant Schedule URL' + }, { name:'survey_name', html:'<%= survey.name %>', diff --git a/test/factories/publish_snapshots.rb b/test/factories/publish_snapshots.rb new file mode 100644 index 000000000..21281585b --- /dev/null +++ b/test/factories/publish_snapshots.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :publish_snapshot do + + end +end diff --git a/test/models/publish_snapshot_test.rb b/test/models/publish_snapshot_test.rb new file mode 100644 index 000000000..ca9eb1ef0 --- /dev/null +++ b/test/models/publish_snapshot_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class PublishSnapshotTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end