diff --git a/app/controllers/concerns/resource_methods.rb b/app/controllers/concerns/resource_methods.rb index c401ab337..675fffec3 100644 --- a/app/controllers/concerns/resource_methods.rb +++ b/app/controllers/concerns/resource_methods.rb @@ -261,19 +261,8 @@ def collection # Rails.logger.debug "****************************" # Rails.logger.debug "****************************" - # TODO we need the size without the query if paginated - fq = policy_scope(base, policy_scope_class: policy_scope_class) - if default_scope(query: fq) - fq = default_scope(query: fq) - end - @full_collection_total = fq.where(exclude_deleted_clause) - .includes(includes) - .references(references) - .eager_load(eager_load) - .joins(join_tables) - .distinct - .count + @full_collection_total = collection_total instance_variable_set("@#{controller_name}", @full_collection_total) end @@ -284,6 +273,27 @@ def collection end end + def collection_total + base = if belong_to_class && belongs_to_param_id + parent = belong_to_class.find belongs_to_param_id + parent.send(belongs_to_relationship) + else + model_class + end + + fq = policy_scope(base, policy_scope_class: policy_scope_class) + if default_scope(query: fq) + fq = default_scope(query: fq) + end + fq.where(exclude_deleted_clause) + .includes(includes) + .references(references) + .eager_load(eager_load) + .joins(join_tables) + .distinct + .count + end + def query(filters = nil) # Go through the filter and construct the where clause deleted_clause = exclude_deleted_clause diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 8ce21e9be..0fd9f3b60 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -478,7 +478,8 @@ def serializer_includes [ :email_addresses, :convention_roles, - :person_schedule_approvals + :person_schedule_approvals, + :session_limits ] end diff --git a/app/controllers/person_sync_data_controller.rb b/app/controllers/person_sync_data_controller.rb index de35f8ee1..27fad0ecb 100644 --- a/app/controllers/person_sync_data_controller.rb +++ b/app/controllers/person_sync_data_controller.rb @@ -2,9 +2,18 @@ class PersonSyncDataController < ResourceController SERIALIZER_CLASS = 'PersonSyncDatumSerializer'.freeze POLICY_CLASS = 'PersonSyncDatumPolicy'.freeze POLICY_SCOPE_CLASS = 'PersonSyncDatumPolicy::Scope'.freeze - DEFAULT_SORTBY = 'name_sort_by' + DEFAULT_SORTBY = 'people.name_sort_by' DEFAULT_ORDER = 'asc'.freeze + # + def possible_match_count + authorize model_class, policy_class: policy_class + + render status: :ok, + json: { total: collection_total }.to_json, + content_type: 'application/json' + end + # # # @@ -30,7 +39,7 @@ def match # Update the person with the reg data IdentityService.clear_person_reg_info(person: person); IdentityService.update_reg_info(person: person, details: datum.raw_info, reg_match: reg_match) - + render status: :ok, json: { message: "Matched" }.to_json, content_type: 'application/json' @@ -57,6 +66,10 @@ def dismiss_match reg_id: reg_id, person_id: person_id }) + + # We need to refresh the view on match + # this can take a few seconds + MigrationHelpers::PlanoViews.refresh_registration_sync_matches end render status: :ok, @@ -69,10 +82,9 @@ def dismiss_match def default_scope(query: nil) return nil unless query - # People that have a potential mapping and not already mapped - query.joins(:registration_sync_data) - .where('people.reg_id is null') - .where('registration_sync_data.reg_id in (select reg_id from registration_map_counts)') + # TODO: exclude people with no registration_sync_data + + query.where('people.reg_id is null and people.id in (select pid from registration_sync_matches)') end def select_fields @@ -88,14 +100,19 @@ def serializer_includes ] end - def make_distinct? - true + # def make_distinct? + # true + # end + + def references + [ + :primary_email + ] end def includes [ - :email_addresses, - :registration_sync_data + :primary_email ] end end diff --git a/app/controllers/registration_sync_data_controller.rb b/app/controllers/registration_sync_data_controller.rb index a9f16e907..7912a07e4 100644 --- a/app/controllers/registration_sync_data_controller.rb +++ b/app/controllers/registration_sync_data_controller.rb @@ -10,6 +10,7 @@ def sync_statistics status = RegistrationSyncStatus.order('created_at desc').first result = status ? status.result : {} + result[:updated_at] = status&.updated_at; render status: :ok, json: result.to_json, content_type: 'application/json' end diff --git a/app/controllers/reports/people_reports_controller.rb b/app/controllers/reports/people_reports_controller.rb index cb9796cfd..2ef014456 100644 --- a/app/controllers/reports/people_reports_controller.rb +++ b/app/controllers/reports/people_reports_controller.rb @@ -55,8 +55,8 @@ def mis_matched_envs .joins(:person) .where( %q(case - when (person_schedules.environment = 'in_person') then (people.attendance_type != 'in person' and people.attendance_type != 'hybrid') - when (person_schedules.environment = 'hybrid') then (people.attendance_type != 'in person' and people.attendance_type != 'hybrid') + when (person_schedules.environment = 'in_person') then (people.attendance_type != 'in_person' and people.attendance_type != 'hybrid') + when (person_schedules.environment = 'hybrid') then (people.attendance_type != 'in_person' and people.attendance_type != 'hybrid') when (person_schedules.environment = 'virtual') then (people.attendance_type != 'virtual' and people.attendance_type != 'hybrid') end ) diff --git a/app/controllers/reports/program_ops_reports_controller.rb b/app/controllers/reports/program_ops_reports_controller.rb index f6729b520..c3fde2d86 100644 --- a/app/controllers/reports/program_ops_reports_controller.rb +++ b/app/controllers/reports/program_ops_reports_controller.rb @@ -314,7 +314,7 @@ def back_of_badge grouped.each do |assignment| title = assignment.session.short_title || assignment.session.title row.concat [ - title, + assignment.session.title, title.truncate(30), assignment.session.start_time ? FastExcel.date_num(assignment.session.start_time, assignment.session.start_time.in_time_zone.utc_offset) : nil, "#{assignment.session.duration}m", diff --git a/app/controllers/reports/session_reports_controller.rb b/app/controllers/reports/session_reports_controller.rb index dbb73ee96..aad2fb894 100644 --- a/app/controllers/reports/session_reports_controller.rb +++ b/app/controllers/reports/session_reports_controller.rb @@ -334,7 +334,7 @@ def non_accepted_on_schedule people_sessions = SessionService.person_schedule .where("session_assignment_name in ('Moderator', 'Participant', 'Invisible')") - .where("con_state not in ('not_set', 'accepted')") + .where("con_state not in ('accepted')") .where("start_time is not null and room_id is not null") .order('name', 'start_time', 'title') diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index f3e56f1a8..1e0a58325 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -62,7 +62,7 @@ def index ], attendance_type: [ { - value: 'in person', + value: 'in_person', label: "In-person only: I am planning to attend #{convention_name} in-person" }, { value: 'virtual', diff --git a/app/javascript/components/icon_button.vue b/app/javascript/components/icon_button.vue index bb802ec44..8ad9ea037 100644 --- a/app/javascript/components/icon_button.vue +++ b/app/javascript/components/icon_button.vue @@ -15,7 +15,7 @@ v-b-modal="modal" > - + diff --git a/app/javascript/components/magical_reload.vue b/app/javascript/components/magical_reload.vue index 4a652cf94..ba05f185f 100644 --- a/app/javascript/components/magical_reload.vue +++ b/app/javascript/components/magical_reload.vue @@ -20,15 +20,23 @@ export default { label: { type: String, default: "Last reloaded at" - } + }, + reloadAction: { } }, computed: { ...mapState(['reloadedAt']) }, methods: { ...mapMutations({ - reload: MAGICAL_RELOAD - }) + magicalReload: MAGICAL_RELOAD + }), + reload() { + if (this.reloadAction) { + this.reloadAction(); + } else { + this.magicalReload(); + } + } } } diff --git a/app/javascript/components/table_vue.vue b/app/javascript/components/table_vue.vue index dedd78a62..ab19bb2ce 100644 --- a/app/javascript/components/table_vue.vue +++ b/app/javascript/components/table_vue.vue @@ -55,7 +55,7 @@
- + -
+
Search Results: {{totalRows}} {{countCaption}}
-
+
@@ -201,6 +203,13 @@ export default { stateName: { type: String, default: null + }, + stickyHeader: { + default: false + }, + showBottomControls: { + type: Boolean, + default: true } }, data () { diff --git a/app/javascript/constants/strings.js b/app/javascript/constants/strings.js index e28077154..ef70d641d 100644 --- a/app/javascript/constants/strings.js +++ b/app/javascript/constants/strings.js @@ -301,7 +301,7 @@ module.exports = { rejected: "Rejected" }, PERSON_ATTENDANCE_TYPE: { - 'in person': "In Person", + in_person: "In Person", hybrid: "In Person AND Online", virtual: "Online", }, diff --git a/app/javascript/integrations/clyde_settings.vue b/app/javascript/integrations/clyde_settings.vue index 2faad60cb..5787ba8bc 100644 --- a/app/javascript/integrations/clyde_settings.vue +++ b/app/javascript/integrations/clyde_settings.vue @@ -2,11 +2,21 @@
+

Registration Sync Management

+
+ Run Registration Data Sync + +
+
    +
  • Last completed full sync: {{ lastSync }}
  • +
  • Records matched: {{ stats.matched }}
  • +
  • Records updated: {{ stats.updated }}
  • +
  • Records not found: {{ stats.not_found }}
  • +

Configuration

- + - Registration Synchronize @@ -24,8 +34,8 @@
- - This will sync with the Registration system. This will bring the server down for a short time. + + This will sync with the Registration system's data. It will bring the server down for a short time. Please double check that you wish to perform this action.
@@ -36,18 +46,26 @@ import { clydeMixin } from './clyde.mixin' import PlanoModal from '@/components/plano_modal.vue'; import { toastMixin } from '@/mixins'; import { http } from '@/http'; +import { registrationSyncStatsMixin} from '@/store/registration_sync_stats.mixin'; +import RegSyncModal from './reg-sync-modal.vue'; export default { name: "ClydeSettings", - mixins: [clydeMixin, toastMixin], + mixins: [clydeMixin, toastMixin, registrationSyncStatsMixin], components: { - PlanoModal + PlanoModal, + RegSyncModal }, methods: { synchronizeSchedule() { - this.toastPromise(http.get('/registration_sync_data/synchronize'), "Succesfully requested registration sync") + this.toastPromise(http.get('/registration_sync_data/synchronize'), "Succesfully requested registration sync").then(() => { + this.fetchStats(); + }) }, }, + mounted() { + this.fetchStats(); + } } diff --git a/app/javascript/integrations/reg-sync-modal.vue b/app/javascript/integrations/reg-sync-modal.vue new file mode 100644 index 000000000..fe5fd2369 --- /dev/null +++ b/app/javascript/integrations/reg-sync-modal.vue @@ -0,0 +1,174 @@ + + + + + diff --git a/app/javascript/integrations/reg-sync-person-search.vue b/app/javascript/integrations/reg-sync-person-search.vue new file mode 100644 index 000000000..33a8c7ded --- /dev/null +++ b/app/javascript/integrations/reg-sync-person-search.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/app/javascript/registrations/person_sync_table.vue b/app/javascript/registrations/person_sync_table.vue index 9a6d16e35..effdd8f36 100644 --- a/app/javascript/registrations/person_sync_table.vue +++ b/app/javascript/registrations/person_sync_table.vue @@ -6,7 +6,16 @@ ref="person-sync-table" stateName="person-sync-table-search-state" :showControls="false" + stickyHeader="450px" + :showBottomControls="false" > +
@@ -39,9 +50,14 @@ diff --git a/app/javascript/store/app.store.js b/app/javascript/store/app.store.js index cd81f4a4f..6d3973f88 100644 --- a/app/javascript/store/app.store.js +++ b/app/javascript/store/app.store.js @@ -1,6 +1,7 @@ export const MAGICAL_RELOAD = 'MAGICAL RELOAD' export const SET_PER_PAGE = 'SET PER PAGE'; export const SET_SPINNER = 'SET SPINNER'; +export const SET_RELOADED_AT = 'SET RELOADED AT' export const appStore = { state: { @@ -19,6 +20,9 @@ export const appStore = { }, [SET_SPINNER] (state, spinner) { state.wholePageSpinner = spinner; + }, + [SET_RELOADED_AT] (state) { + state.reloadedAt = new Date(); } } } diff --git a/app/javascript/store/model.mixin.js b/app/javascript/store/model.mixin.js index b93c6fc9d..846e56537 100644 --- a/app/javascript/store/model.mixin.js +++ b/app/javascript/store/model.mixin.js @@ -1,4 +1,4 @@ -import { SELECTED, SELECT, UNSELECT, FETCH, FETCH_BY_ID, CLEAR, SEARCH, PATCH_FIELDS, SAVE, DELETE } from "./model.store"; +import { SELECTED, SELECT, UNSELECT, FETCH, FETCH_BY_ID, FETCH_SELECTED, CLEAR, SEARCH, PATCH_FIELDS, SAVE, DELETE, FETCH_NEXT_PAGE, FETCH_PREV_PAGE, SELECT_NEXT, SELECT_PREV, SELECT_FIRST, FULL_TOTAL, SELECTED_INDEX } from "./model.store"; import { mapActions } from 'vuex'; import { toastMixin } from "@/mixins"; import { MODEL_SAVE_ERROR, MODEL_SAVE_SUCCESS, MODEL_DELETE_SUCCESS, MODEL_DELETE_ERROR, SPECIFIC_MODEL_SAVE_ERROR, SPECIFIC_MODEL_SAVE_SUCCESS } from "@/constants/strings"; @@ -13,6 +13,12 @@ export const modelMixinNoProp = { }, collection() { return Object.values(this.$store.getters['jv/get']({_jv: { type: this.model }})) + }, + fullTotal() { + return this.$store.getters[FULL_TOTAL]({model: this.model}) + }, + selectedOrdinal() { + return this.$store.getters[SELECTED_INDEX]({model: this.model}) + 1; } }, methods: { @@ -21,6 +27,15 @@ export const modelMixinNoProp = { select(itemOrId) { this.$store.commit(SELECT, {model: this.model, itemOrId}); }, + selectNext() { + return this.$store.dispatch(SELECT_NEXT, {model: this.model}); + }, + selectPrev() { + return this.$store.dispatch(SELECT_PREV, {model: this.model}); + }, + selectFirst() { + this.$store.commit(SELECT_FIRST, {model: this.model}); + }, unselect() { this.$store.commit(UNSELECT, {model: this.model}); }, @@ -30,6 +45,12 @@ export const modelMixinNoProp = { fetch(params, url = null) { return this.$store.dispatch(FETCH, {model: this.model, url: url, params}); }, + fetchNextPage() { + return this.$store.dispatch(FETCH_NEXT_PAGE, {model: this.model, url: url, params}); + }, + fetchPrevPage() { + return this.$store.dispatch(FETCH_PREV_PAGE, {model: this.model, url: url, params}); + }, fetch_by_id(id) { return this.$store.dispatch(FETCH_BY_ID, {model: this.model, id: id}); }, diff --git a/app/javascript/store/model.store.js b/app/javascript/store/model.store.js index 853cec908..fe30ee44d 100644 --- a/app/javascript/store/model.store.js +++ b/app/javascript/store/model.store.js @@ -3,6 +3,7 @@ import Vuex from 'vuex' import { jsonapiModule, utils } from 'jsonapi-vuex' import { http } from '../http'; import { getId } from '../utils/jsonapi_utils'; +import { of } from 'rxjs'; export const SELECT = 'SELECT'; export const UNSELECT = 'UNSELECT'; @@ -22,6 +23,17 @@ export const UPDATE_ALL = 'UPDATE ALL'; export const PATCH_RELATED = 'PATCH RELATED'; export const PATCH_FIELDS = 'PATCH FIELDS'; +// for paged models +export const SET_MODEL_PAGE_SIZE = 'SET MODEL PAGE SIZE'; +export const SET_MODEL_PAGE_META = 'SET MODEL PAGE META'; +export const SELECT_NEXT = 'SELECT NEXT'; +export const SELECT_PREV = 'SELECT PREV'; +export const SELECT_FIRST = 'SELECT FIRST'; +export const FETCH_NEXT_PAGE = 'FETCH NEXT PAGE'; +export const FETCH_PREV_PAGE = 'FETCH PREV PAGE'; +export const FULL_TOTAL = 'FULL TOTAL'; +export const SELECTED_INDEX = 'SELECTED INDEX'; + // people add-ons import { personStore, personEndpoints } from './person.store'; @@ -172,6 +184,9 @@ export const store = new Vuex.Store({ ...publishedSessionStore.selected, ...publicationDatesStore.selected, }, + page: { + ...personSyncDatumStore.page, + }, ...personSessionStore.state, ...settingsStore.state, ...surveyStore.state, @@ -183,6 +198,8 @@ export const store = new Vuex.Store({ ...appStore.state, ...scheduleWorkflowStore.state, ...integrationStore.state, + ...registrationSyncDatumStore.state, + ...personSyncDatumStore.state, // ...mailingStore.state }, getters: { @@ -201,6 +218,25 @@ export const store = new Vuex.Store({ } } }, + [FULL_TOTAL] (state) { + return ({model}) => { + return state.page[model].fullTotal; + } + }, + [SELECTED_INDEX] (state) { + return ({model}) => { + const {perPage, currentPage, correctOrder } = state.page[model] ?? {}; + const selectedId = state.selected[model]; + if(perPage && currentPage && correctOrder && selectedId) { + // we can calculate which one this is + const previousPageCount = perPage * (currentPage - 1); + const currentIndex = correctOrder.findIndex(id => id === selectedId); + return previousPageCount + currentIndex; + } + // we cannot calculate which one this is, we're missing some data + return -1; + } + }, ...personStore.getters, ...agreementStore.getters, ...roomStore.getters, @@ -238,16 +274,35 @@ export const store = new Vuex.Store({ [UNSELECT] (state, {model}) { state.selected[model] = undefined; }, + [SELECT_FIRST] (state, {model}) { + // this only works if the model is paged + if(state.page[model].usePaged) { + state.selected[model] = state.page[model].correctOrder[0]; + } + }, [CLEAR] (state, {model}) { this.commit('jv/clearRecords', { _jv: { type: model } }) }, + [SET_MODEL_PAGE_META] (state, {model, meta}) { + state.page[model] = { + ...state.page[model], + ...meta + }; + }, + [SET_MODEL_PAGE_SIZE] (state, {model, perPage}) { + state.page[model] ||= {} + state.page[model].perPage = perPage; + state.page[model].currentPage = 1; + }, ...personSessionStore.mutations, ...settingsStore.mutations, ...surveyStore.mutations, ...searchStateStore.mutations, ...roomStore.mutations, ...appStore.mutations, - ...integrationStore.mutations + ...integrationStore.mutations, + ...registrationSyncDatumStore.mutations, + ...personSyncDatumStore.mutations, }, actions: { /** @@ -360,11 +415,39 @@ export const store = new Vuex.Store({ return dispatch('jv/search', [endpoints[model], {params}]) }, // need a way to override the default URL - [FETCH] ({dispatch}, {model, url = null, params}) { + [FETCH] ({dispatch, state, commit}, {model, url = null, params}) { + let isPaged = false; + if (state.page?.[model]?.usePaged) { + isPaged = true; + // modify params to fetch paged if they don't have page info already + let {current_page, perPage} = params ?? {} + if (!current_page) { + current_page = state.page[model]?.currentPage ?? 1; + } + if (!perPage) { + perPage = state.page[model]?.perPage ?? state.perPage ?? 20 + commit(SET_MODEL_PAGE_SIZE, {model, perPage}) + } + params = {...params, perPage, current_page} + } if (url) { return dispatch('jv/get', [url, {params}]) } else { - return dispatch('jv/get', [endpoints[model], {params}]) + // return dispatch('jv/get', [endpoints[model], {params}]) + return new Promise((res, rej) => { + dispatch('jv/get', [endpoints[model], {params}]).then(data => { + if(isPaged) { + const meta = {correctOrder: data._jv.json.data.map(m => m.id)}; + if (typeof data._jv.json.meta !== 'undefined') { + meta.currentPage = data._jv.json.meta.current_page; + meta.total = data._jv.json.meta.total; + meta.fullTotal = data._jv.json.meta.full_total; + } + commit(SET_MODEL_PAGE_META, {model, meta}) + } + res(data); + }).catch(rej); + }); } }, // [CLEAR] ({dispatch}, {model}) { @@ -380,6 +463,109 @@ export const store = new Vuex.Store({ // We do need this - not all fetch by id will be selected models return dispatch('jv/get', `${endpoints[model]}/${id}`) }, + [FETCH_NEXT_PAGE] ({state, dispatch}, {model, url = null, params}) { + //model must be paged + if(state.page[model]?.usePaged) { + let {currentPage, perPage, fullTotal} = state.page[model]; + // model must have a next page + if(perPage * currentPage < fullTotal) { + // now we can fetch the next page + return dispatch(FETCH, {model, url, params: {...params, current_page: currentPage + 1}}) + } else { + console.warn("Attempting to fetch next page when there aren't more pages: ", model) + } + } else { + console.warn("Attempting to fetch next page on an unpaged model: ", model); + } + }, + [FETCH_PREV_PAGE] ({state, dispatch}, {model, url = null, params}) { + //model must be paged + if(state.page[model]?.usePaged) { + let {currentPage} = state.page[model]; + console.log('fetching previous. current page', currentPage) + // model must have a next page + if(currentPage > 1) { + // now we can fetch the next page + return dispatch(FETCH, {model, url, params: {...params, current_page: currentPage - 1}}) + } else { + console.warn("Attempting to fetch prev page when there aren't more pages: ", model) + } + } else { + console.warn("Attempting to fetch prev page on an unpaged model: ", model); + } + }, + // this is an action rather than a mutation because we might need to fetch + [SELECT_NEXT] ({state, dispatch, commit}, {model}) { + // this currently only works with paged models + if(state.page[model]?.usePaged) { + if(state.selected[model]) { + const selected = state.selected[model]; + let { correctOrder, currentPage, fullTotal, perPage } = state.page[model]; + let currentIndex = correctOrder.findIndex((id) => id === selected); + if(currentIndex === correctOrder.length - 1) { + if(perPage * currentPage < fullTotal) { + // need to fetch next + return new Promise((res, rej) => { + // no params here, might need to add later + dispatch(FETCH_NEXT_PAGE, {model}).then((data) => { + const itemOrId = data._jv.json.data[0].id; + commit(SELECT, {model, itemOrId }); + res(itemOrId); + }).catch(rej); + }) + } else { + // don't select anything cause it's on the last one, just return the current id + return of(selected.id); + } + } else { + const itemOrId = correctOrder[currentIndex + 1] + commit(SELECT, {model, itemOrId}); + return of(itemOrId); + } + } else { + console.log("Can't select next when there's nothing selected: ", model) + } + } else { + console.warn("Can't select next from unpaged model: ", model) + } + // todo what should i be returning here + }, + // this is an action rather than a mutation because we might need to fetch + [SELECT_PREV] ({state, dispatch, commit}, {model}) { + // this currently only works with paged models + if(state.page[model]?.usePaged) { + if(state.selected[model]) { + const selected = state.selected[model]; + let { correctOrder, currentPage } = state.page[model]; + let currentIndex = correctOrder.findIndex((id) => id === selected); + if(currentIndex === 0) { + if (currentPage > 1) { + // need to fetch previous + return new Promise((res, rej) => { + // no params here, might need to add later + dispatch(FETCH_PREV_PAGE, {model}).then((data) => { + const itemOrId = data._jv.json.data[data._jv.json.data.length - 1].id; + commit(SELECT, {model, itemOrId }); + res(itemOrId); + }).catch(rej); + }) + } else { + // don't select anything cause it's on the first one, just return the current id + return of(selected.id); + } + } else { + const itemOrId = correctOrder[currentIndex - 1]; + commit(SELECT, {model, itemOrId}); + return of(itemOrId); + } + } else { + console.log("Can't select prev when there's nothing selected: ", model) + } + } else { + console.warn("Can't select prev from unpaged model: ", model) + } + // todo what should i be returning here + }, [PATCH_FIELDS] ({dispatch, commit}, {model, item, fields=[], selected = true}) { // limited field selection let smallItem = { diff --git a/app/javascript/store/person_sync_datum.mixin.js b/app/javascript/store/person_sync_datum.mixin.js index 95ae8f9dc..7164317a4 100644 --- a/app/javascript/store/person_sync_datum.mixin.js +++ b/app/javascript/store/person_sync_datum.mixin.js @@ -1,7 +1,7 @@ import { toastMixin } from "@/mixins"; import { SELECTED } from "./model.store" import { personModel } from "./person.store" -import { MATCH } from "./person_sync_datum.store"; +import { MATCH, DISMISS } from "./person_sync_datum.store"; import { registrationSyncDatumModel } from "./registration_sync_datum.store" import { mapActions} from "vuex"; @@ -20,16 +20,30 @@ export const personSyncDatumMixin = { methods: { ...mapActions({ matchPersonAndReg: MATCH, + dismiss: DISMISS, }), manualMatch(regId, personId) { return this.toastPromise(this.matchPersonAndReg({ regId, personId, - regMatch: 'manual' + regMatch: 'manual', + reload: true }), "Person successfully linked to Registration") }, manualMatchSelected() { return this.manualMatch(this.selectedRegDatum.reg_id, this.selectedPerson.id); + }, + assistedMatch(regId, personId) { + return this.toastPromise(this.matchPersonAndReg({ + regId, + personId, + regMatch: 'assisted' + }), "Person successfully linked to Registration") + }, + dismissMatch(regId, personId) { + return this.toastPromise(this.dismiss({ + regId, personId + }), "Potential match successfully dismissed") } } } diff --git a/app/javascript/store/person_sync_datum.store.js b/app/javascript/store/person_sync_datum.store.js index 3e9a0d4ad..69619024d 100644 --- a/app/javascript/store/person_sync_datum.store.js +++ b/app/javascript/store/person_sync_datum.store.js @@ -5,6 +5,9 @@ import { personModel } from './person.store'; export const personSyncDatumModel = 'person_sync_datum'; export const MATCH = "PERSON SYNC MATCH" +export const DISMISS = "PERSON SYNC DISMISS" +export const FETCH_MATCH_COUNT = "PERSON SYNC POTENTIAL MATCHES COUNT" +export const SET_MATCH_COUNT = "PERSON SYNC POTENTIAL MATCHES COUNT" export const personSyncDatumEndpoints = { [personSyncDatumModel]: 'person_sync_datum' @@ -12,7 +15,7 @@ export const personSyncDatumEndpoints = { export const personSyncDatumStore = { actions: { - [MATCH]({dispatch}, {regId, personId, regMatch}) { + [MATCH]({dispatch}, {regId, personId, regMatch, reload = false}) { console.log('match action', regId, personId, regMatch) return new Promise((res, rej) => { http.post(`${personSyncDatumEndpoints[personSyncDatumModel]}/match`, { @@ -22,17 +25,59 @@ export const personSyncDatumStore = { }).then((data) => { // if it was successful, also then fetch the person. // todo i think we only wnat to do this sometimes? - dispatch(FETCH_SELECTED, {model: personModel}).then(() => { + if (reload) { + dispatch(FETCH_SELECTED, {model: personModel}).then(() => { + res(data); + }) + } else { res(data); - }) + } }).catch(rej); }); + }, + [FETCH_MATCH_COUNT] ({commit}) { + return new Promise((res, rej) => { + http.get(`${personSyncDatumEndpoints[personSyncDatumModel]}/possible_match_count`).then(data => { + commit(SET_MATCH_COUNT, data.data.total); + console.log('match count data:', data) + res(data); + }).catch(rej); + }) + }, + [DISMISS] ({}, {regId, personId}) { + return new Promise((res, rej) => { + http.post(`${personSyncDatumEndpoints[personSyncDatumModel]}/dismiss_match`, { + reg_id: regId, + person_id: personId + }).then((data) => { + console.log(data); + res(data); + }).catch(rej) + }); } }, selected: { [personSyncDatumModel]: undefined }, + page: { + [personSyncDatumModel]: { + usePaged: true, + total: undefined, + fullTotal: undefined, + currentPage: undefined, + perPage: undefined, + correctOrder: [], + } + }, + state: { + possibleMatchCount: undefined, + }, getters: { + }, + mutations: { + [SET_MATCH_COUNT] (state, count) { + state.possibleMatchCount = count; + } } } diff --git a/app/javascript/store/registration_sync_datum.store.js b/app/javascript/store/registration_sync_datum.store.js index 76eac90a2..a65d123dd 100644 --- a/app/javascript/store/registration_sync_datum.store.js +++ b/app/javascript/store/registration_sync_datum.store.js @@ -1,36 +1,59 @@ +import { http } from "@/http"; import { FETCH, SELECT, UNSELECT } from "./model.store"; export const registrationSyncDatumModel = 'registration_sync_datum'; const model = registrationSyncDatumModel; +export const registrationSyncStatisticsModel = 'registration_sync_statistics' export const GET_REG_BY_ID = "GET REG BY ID"; +export const REG_SYNC_STATS = "REG SYNC STATS"; +export const SET_REG_SYNC_STATS = "SET REG SYNC STATS"; +export const FETCH_REG_SYNC_STATS = "FETCH REG SYNC STATS"; export const registrationSyncDatumEndpoints = { - [registrationSyncDatumModel]: 'registration_sync_datum' + [registrationSyncDatumModel]: 'registration_sync_datum', + [registrationSyncStatisticsModel]: 'registration_sync_data/sync_statistics' } export const registrationSyncDatumStore = { selected: { [registrationSyncDatumModel]: undefined }, + state: { + registrationSyncStats: {} + }, getters: { + [REG_SYNC_STATS](state) { + return state.registrationSyncStats; + } + }, + mutations: { + [SET_REG_SYNC_STATS](state, syncStats) { + state.registrationSyncStats = syncStats; + } }, actions: { - [GET_REG_BY_ID] ({commit, dispatch}, {id}) { + [GET_REG_BY_ID]({ commit, dispatch }, { id }) { return new Promise((res, rej) => { - dispatch(FETCH, {model, params: { - // trying without the %23 here in the hope that fetch will serialize correctly - filter: `{"op":"all","queries":[["registration_number","is","${id}"]]}` - }}).then((data) => { + dispatch(FETCH, { + model, params: { + // trying without the %23 here in the hope that fetch will serialize correctly + filter: `{"op":"all","queries":[["registration_number","is","${id}"]]}` + } + }).then((data) => { const keys = Object.keys(data).filter(key => key !== "_jv") - if(keys.length) { - commit(SELECT, {model, itemOrId: keys[0]}) + if (keys.length) { + commit(SELECT, { model, itemOrId: keys[0] }) } else { - commit(UNSELECT, {model}); + commit(UNSELECT, { model }); } res(data); }).catch(rej); }) + }, + [FETCH_REG_SYNC_STATS]({ commit }) { + return http.get(registrationSyncDatumEndpoints[registrationSyncStatisticsModel]).then(({ data }) => + commit(SET_REG_SYNC_STATS, data)) } }, } diff --git a/app/javascript/store/registration_sync_stats.mixin.js b/app/javascript/store/registration_sync_stats.mixin.js new file mode 100644 index 000000000..4b8c9a5e6 --- /dev/null +++ b/app/javascript/store/registration_sync_stats.mixin.js @@ -0,0 +1,19 @@ +import { mapGetters, mapActions } from "vuex"; +import { FETCH_REG_SYNC_STATS, REG_SYNC_STATS } from "./registration_sync_datum.store"; +import { DateTime } from "luxon"; + +export const registrationSyncStatsMixin = { + computed: { + ...mapGetters({ + stats: REG_SYNC_STATS + }), + lastSync() { + return DateTime.fromISO(this.stats?.updated_at).toFormat('D, t ZZZZ'); + } + }, + methods: { + ...mapActions({ + fetchStats: FETCH_REG_SYNC_STATS + }) + } +} diff --git a/app/lib/migration_helpers/plano_views.rb b/app/lib/migration_helpers/plano_views.rb index 5b534fa25..0a59a8186 100644 --- a/app/lib/migration_helpers/plano_views.rb +++ b/app/lib/migration_helpers/plano_views.rb @@ -14,14 +14,26 @@ def self.create_views # view for reg matching self.create_registration_sync_matches + self.create_filtered_registration_sync_matches self.create_registration_map_counts self.create_registration_map_reg_counts self.create_registration_map_people_counts end + def self.create_filtered_registration_sync_matches + query = <<-SQL.squish + CREATE OR REPLACE VIEW filtered_registration_sync_matches AS + select * from registration_sync_matches rsm + where rsm.reg_id not in (select p2.reg_id from people p2 where p2.reg_id is not null) + SQL + + ActiveRecord::Base.connection.execute(query) + end + def self.create_registration_sync_matches query = <<-SQL.squish - CREATE OR REPLACE VIEW registration_sync_matches AS + DROP materialized VIEW IF EXISTS registration_sync_matches; + CREATE MATERIALIZED VIEW registration_sync_matches AS select p.name, null as email, p.id as pid, rsd.reg_id, rsd.id as rid, 'name' as mtype from people p join registration_sync_data rsd @@ -34,16 +46,32 @@ def self.create_registration_sync_matches or rsd."preferred_name" ilike p.pseudonym or rsd."badge_name" ilike p.pseudonym ) + where + concat(p.id, '-', rsd.reg_id) not in + (select concat(drsm.person_id, '-' , drsm.reg_id) from dismissed_reg_sync_matches drsm) union - select null as name, e.email, e.person_id as pid, rsd.reg_id, rsd.id as rid, 'email' as mtype + select null as name, e.email, e.person_id as pid, rsd2.reg_id, rsd2.id as rid, 'email' as mtype from email_addresses e - join registration_sync_data rsd + join registration_sync_data rsd2 on ( - rsd."email" ilike e.email or - rsd."alternative_email" ilike e.email + rsd2."email" ilike e.email or + rsd2."alternative_email" ilike e.email ) where e.isdefault = true + and + concat(e.person_id, '-', rsd2.reg_id) not in + (select concat(drsm.person_id, '-' , drsm.reg_id) from dismissed_reg_sync_matches drsm); + CREATE INDEX matches_reg_id ON registration_sync_matches (reg_id); + CREATE INDEX matches_pid ON registration_sync_matches (pid); + SQL + + ActiveRecord::Base.connection.execute(query) + end + + def self.refresh_registration_sync_matches + query = <<-SQL.squish + REFRESH MATERIALIZED VIEW registration_sync_matches; SQL ActiveRecord::Base.connection.execute(query) @@ -587,6 +615,15 @@ def self.create_session_conflicts ActiveRecord::Base.connection.execute(query) end + def self.test_registration_sync_matches_type + query = <<-SQL.squish + select relkind from pg_catalog.pg_class where relname = 'registration_sync_matches'; + SQL + + res = ActiveRecord::Base.connection.execute(query) + res.first["relkind"] + end + def self.drop_views ActiveRecord::Base.connection.execute <<-SQL DROP VIEW IF EXISTS session_conflicts; @@ -632,8 +669,18 @@ def self.drop_views SQL ActiveRecord::Base.connection.execute <<-SQL - DROP VIEW IF EXISTS registration_sync_matches; + DROP VIEW IF EXISTS filtered_registration_sync_matches; SQL + + if self.test_registration_sync_matches_type == 'm' + ActiveRecord::Base.connection.execute <<-SQL + DROP materialized VIEW IF EXISTS registration_sync_matches; + SQL + else + ActiveRecord::Base.connection.execute <<-SQL + DROP VIEW IF EXISTS registration_sync_matches; + SQL + end end end end diff --git a/app/models/person.rb b/app/models/person.rb index 04210e908..f91839dc2 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -115,8 +115,6 @@ class Person < ApplicationRecord include PasswordArchivable include DirtyAssociations - # acts_as_taggable - acts_as_taggable_on :tags has_paper_trail versions: { class_name: 'Audit::PersonVersion' }, ignore: [:updated_at, :created_at, :lock_version, :integrations], diff --git a/app/models/person_schedule.rb b/app/models/person_schedule.rb index f31767a64..5b31ac41d 100644 --- a/app/models/person_schedule.rb +++ b/app/models/person_schedule.rb @@ -13,11 +13,13 @@ # participant_notes :text # pronouns :string(400) # published_name :string +# recorded :boolean # session_assignment_name :string(100) # session_assignment_role_type :enum # sort_order :integer # start_time :datetime # status :enum +# streamed :boolean # title :string(256) # updated_at :datetime # format_id :uuid diff --git a/app/models/registration/registration_sync_match.rb b/app/models/registration/registration_sync_match.rb index f06891439..d98adca9a 100644 --- a/app/models/registration/registration_sync_match.rb +++ b/app/models/registration/registration_sync_match.rb @@ -1,6 +1,6 @@ # == Schema Information # -# Table name: registration_sync_matches +# Table name: filtered_registration_sync_matches # # email :string # mtype :text primary key @@ -9,8 +9,13 @@ # rid :uuid primary key # reg_id :string # +# Indexes +# +# matches_pid (pid) +# matches_reg_id (reg_id) +# class Registration::RegistrationSyncMatch < ApplicationRecord - self.table_name = :registration_sync_matches + self.table_name = :filtered_registration_sync_matches self.primary_keys = :rid, :mtype belongs_to :person, optional: true, foreign_key: 'pid' diff --git a/app/models/survey/answer.rb b/app/models/survey/answer.rb index 36ec8b346..399b671dd 100644 --- a/app/models/survey/answer.rb +++ b/app/models/survey/answer.rb @@ -57,7 +57,7 @@ def validate_answer raise 'invalid answers for YewNoMaybe question type' unless ['yes', 'no', 'maybe'].include? value end if question.question_type == :attendance_type - raise 'invalid answers for Attendance question type' unless ['in person', 'virtual', 'hybrid'].include? value + raise 'invalid answers for Attendance question type' unless ['in_person', 'virtual', 'hybrid'].include? value end end diff --git a/app/policies/person_sync_datum_policy.rb b/app/policies/person_sync_datum_policy.rb index 445d8fe3e..990df954b 100644 --- a/app/policies/person_sync_datum_policy.rb +++ b/app/policies/person_sync_datum_policy.rb @@ -1,5 +1,9 @@ class PersonSyncDatumPolicy < PlannerPolicy + def possible_match_count? + allowed?(action: :dismiss_match) + end + def dismiss_match? allowed?(action: :dismiss_match) end diff --git a/app/serializers/conclar/participant_serializer.rb b/app/serializers/conclar/participant_serializer.rb index a1eeaeb4f..2f59231d0 100644 --- a/app/serializers/conclar/participant_serializer.rb +++ b/app/serializers/conclar/participant_serializer.rb @@ -53,7 +53,7 @@ class Conclar::ParticipantSerializer < ActiveModel::Serializer res = [] case object.attendance_type - when 'in person' + when 'in_person' t = { value: "person_".concat(object.attendance_type), category: "Attendance", diff --git a/app/serializers/person_schedule_serializer.rb b/app/serializers/person_schedule_serializer.rb index e1856becc..abadc3ba8 100644 --- a/app/serializers/person_schedule_serializer.rb +++ b/app/serializers/person_schedule_serializer.rb @@ -13,11 +13,13 @@ # participant_notes :text # pronouns :string(400) # published_name :string +# recorded :boolean # session_assignment_name :string(100) # session_assignment_role_type :enum # sort_order :integer # start_time :datetime # status :enum +# streamed :boolean # title :string(256) # updated_at :datetime # format_id :uuid diff --git a/app/serializers/person_serializer.rb b/app/serializers/person_serializer.rb index a61d03189..8ca539ecc 100644 --- a/app/serializers/person_serializer.rb +++ b/app/serializers/person_serializer.rb @@ -164,10 +164,6 @@ class PersonSerializer #< ActiveModel::Serializer !person.encrypted_password.blank? end - attribute :tags do |person| - person.base_tags.collect(&:name) - end - attribute :session_count do |person| if person.has_attribute?(:session_count) person.session_count diff --git a/app/serializers/person_sync_datum_serializer.rb b/app/serializers/person_sync_datum_serializer.rb index ee4338805..076736f9f 100644 --- a/app/serializers/person_sync_datum_serializer.rb +++ b/app/serializers/person_sync_datum_serializer.rb @@ -112,17 +112,16 @@ class PersonSyncDatumSerializer :primary_email, :contact_email, :reg_match, :date_reg_synced - has_many :email_addresses, - if: Proc.new { |record, params| AccessControlService.shared_attribute_access?(instance: record, person: params[:current_person]) }, - lazy_load_data: true, serializer: EmailAddressSerializer, - links: { - self: -> (object, params) { - "#{params[:domain]}/person/#{object.id}" - }, - related: -> (object, params) { - "#{params[:domain]}/person/#{object.id}/email_addresses" - } - } + # has_many :email_addresses, + # lazy_load_data: true, serializer: EmailAddressSerializer + # links: { + # self: -> (object, params) { + # "#{params[:domain]}/person/#{object.id}" + # }, + # related: -> (object, params) { + # "#{params[:domain]}/person/#{object.id}/email_addresses" + # } + # } # The reg data that this person could be matched to has_many :registration_sync_data, serializer: RegistrationSyncDatumSerializer diff --git a/app/services/access_control_service.rb b/app/services/access_control_service.rb index aedbe446e..80c9621bd 100644 --- a/app/services/access_control_service.rb +++ b/app/services/access_control_service.rb @@ -24,7 +24,7 @@ def self.attribute_meta_data registered: { sensitive: false, linkable: false, type: :boolean, hidable: false}, registration_type: { sensitive: true, linkable: false, type: :string, hidable: false}, registration_number: { sensitive: true, linkable: false, type: :string, hidable: false}, - attendance_type: { sensitive: false, linkable: true, type: :attendance_type, values: ['in person', 'virtual', 'hybrid'], hidable: false}, + attendance_type: { sensitive: false, linkable: true, type: :attendance_type, values: ['in_person', 'virtual', 'hybrid'], hidable: false}, bio: { sensitive: false, linkable: true, type: :text, hidable: false}, # NOTE: we really do not want individual social media fields to be linked, # this was done as the google form migration. Surveys in Plano should use diff --git a/app/workers/registration_sync_worker.rb b/app/workers/registration_sync_worker.rb index f3b98897a..da9f8f02c 100644 --- a/app/workers/registration_sync_worker.rb +++ b/app/workers/registration_sync_worker.rb @@ -23,6 +23,9 @@ def perform status = RegistrationSyncStatus.order('created_at desc').first status = RegistrationSyncStatus.new if status == nil + # Refresh the materialized view(s) + MigrationHelpers::PlanoViews.refresh_registration_sync_matches + status.result = { updated: number_updated, matched: number_matched, diff --git a/config/routes.rb b/config/routes.rb index 3b071c395..f3c23660a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -215,6 +215,7 @@ get 'people', to: 'registration_sync_data#people' end + get 'person_sync_datum/possible_match_count', to: 'person_sync_data#possible_match_count' post 'person_sync_datum/dismiss_match', to: 'person_sync_data#dismiss_match' post 'person_sync_datum/match', to: 'person_sync_data#match' resources :person_sync_data, path: 'person_sync_datum' diff --git a/db/migrate/20240708121706_fix_in_person_values.rb b/db/migrate/20240708121706_fix_in_person_values.rb new file mode 100644 index 000000000..692b2df9f --- /dev/null +++ b/db/migrate/20240708121706_fix_in_person_values.rb @@ -0,0 +1,7 @@ +class FixInPersonValues < ActiveRecord::Migration[6.1] + def up + # One off change to make site "in person" is "in_person" + # use like just in case of case issues + Person.where("attendance_type ilike 'in person'").update_all(attendance_type: 'in_person') + end +end diff --git a/db/seeds/development/person.seeds.rb b/db/seeds/development/person.seeds.rb index 3cf81a3ce..d1cdf923a 100644 --- a/db/seeds/development/person.seeds.rb +++ b/db/seeds/development/person.seeds.rb @@ -42,7 +42,7 @@ flickr: username, reddit: username, tiktok: username, - attendance_type: ['in person', 'virtual', 'hybrid'].sample + attendance_type: ['in_person', 'virtual', 'hybrid'].sample ) e = name.gsub(' ', '_') + i.to_s + '@test.com' EmailAddress.create( diff --git a/db/structure.sql b/db/structure.sql index 6f1e0e2dc..48ad35aa5 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1332,6 +1332,8 @@ CREATE VIEW public.person_schedules AS sessions.description, sessions.environment, sessions.status, + sessions.streamed, + sessions.recorded, CASE WHEN (sa.updated_at > sessions.updated_at) THEN sa.updated_at ELSE sessions.updated_at @@ -1649,10 +1651,10 @@ CREATE TABLE public.registration_sync_data ( -- --- Name: registration_sync_matches; Type: VIEW; Schema: public; Owner: - +-- Name: registration_sync_matches; Type: MATERIALIZED VIEW; Schema: public; Owner: - -- -CREATE VIEW public.registration_sync_matches AS +CREATE MATERIALIZED VIEW public.registration_sync_matches AS SELECT p.name, NULL::character varying AS email, p.id AS pid, @@ -1670,7 +1672,8 @@ UNION 'email'::text AS mtype FROM (public.email_addresses e JOIN public.registration_sync_data rsd ON ((((rsd.email)::text ~~* (e.email)::text) OR ((rsd.alternative_email)::text ~~* (e.email)::text)))) - WHERE (e.isdefault = true); + WHERE (e.isdefault = true) + WITH NO DATA; -- @@ -2241,6 +2244,15 @@ CREATE TABLE public.tags ( ); +-- +-- Name: tt; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.tt ( + relkind "char" +); + + -- -- Name: venues; Type: TABLE; Schema: public; Owner: - -- @@ -3624,6 +3636,20 @@ CREATE UNIQUE INDEX index_tags_on_name ON public.tags USING btree (name); CREATE INDEX index_versions_on_item_type_and_item_id ON public.versions USING btree (item_type, item_id); +-- +-- Name: matches_pid; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX matches_pid ON public.registration_sync_matches USING btree (pid); + + +-- +-- Name: matches_reg_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX matches_reg_id ON public.registration_sync_matches USING btree (reg_id); + + -- -- Name: par_approle_person_idx; Type: INDEX; Schema: public; Owner: - -- @@ -3953,6 +3979,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20240522190737'), ('20240602172220'), ('20240606115218'), -('20240622165823'); +('20240622165823'), +('20240708121706'); diff --git a/lib/tasks/rbac.rake b/lib/tasks/rbac.rake index 81126b4fe..2cd2594ae 100644 --- a/lib/tasks/rbac.rake +++ b/lib/tasks/rbac.rake @@ -1019,7 +1019,8 @@ namespace :rbac do "index": true, "show": true, "dismiss_match": true, - "match": true + "match": true, + "possible_match_count": true } }) end diff --git a/lib/tasks/submission.rake b/lib/tasks/submission.rake index 9bee9e10f..88d6d6fd4 100644 --- a/lib/tasks/submission.rake +++ b/lib/tasks/submission.rake @@ -142,7 +142,7 @@ namespace :submission do if value.include?('**In-person and virtual:**') ['hybrid'] elsif value.include?('**In-person only:**') - ['in person'] + ['in_person'] elsif value.include?('**Virtual only:**') ['virtual'] end