<field reporter:label="Original Circ Modifier" name="original_circ_modifier" reporter:datatype="link" />
<field reporter:label="Original Shelving Location" name="original_location" reporter:datatype="link" />
<field reporter:label="Original Callnumber" name="original_callnumber" reporter:datatype="link" />
+ <field reporter:label="Original Circulating Library" name="original_circ_lib" reporter:datatype="link" />
</fields>
<links>
<link field="course" reltype="has_a" key="id" map="" class="acmc" />
<link field="original_status" reltype="has_a" key="id" map="" class="ccs" />
<link field="original_circ_modifier" reltype="has_a" key="code" map="" class="ccm" />
<link field="original_location" reltype="has_a" key="id" map="" class="acpl" />
+ <link field="original_circ_lib" reltype="has_a" key="id" map="" class="aou" />
</links>
<permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
<actions>
<eg-string #materialEditSuccessString i18n-text text="Update of Course Material succeeded"></eg-string>
<eg-string #materialEditFailedString i18n-text text="Update of Course Material failed or was not allowed"></eg-string>
+<eg-alert-dialog #otherLibraryNoPermissionsAlert
+ i18n-dialogTitle i18n-dialogBody
+ dialogTitle="Insufficient permissions"
+ dialogBody="This material has {{itemCircLib}} as its circulation library, where you do not have UPDATE_COPY permissions. You cannot attach this item without UPDATE_COPY permissions.">
+</eg-alert-dialog>
+
<eg-confirm-dialog #confirmOtherLibraryDialog
i18n-dialogTitle i18n-dialogBody
dialogTitle="Material from a different library"
--- /dev/null
+import { PermService } from '@eg/core/perm.service';
+import { waitForAsync } from '@angular/core/testing';
+import { ToastService } from '@eg/share/toast/toast.service';
+import { CourseService } from '@eg/staff/share/course.service';
+import { AuthService } from '@eg/core/auth.service';
+import { NetService } from '@eg/core/net.service';
+import { PcrudService } from '@eg/core/pcrud.service';
+import { CourseAssociateMaterialComponent } from './course-associate-material.component';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import { of } from 'rxjs';
+import { DialogComponent } from '@eg/share/dialog/dialog.component';
+
+
+describe('CourseAssociateMaterialComponent', () => {
+ let component: CourseAssociateMaterialComponent;
+
+ const mockLibrary = {
+ id: () => 5,
+ shortname: () => 'greatLibrary'
+ };
+
+ const mockLibrary2 = {
+ id: () => 22
+ };
+
+ const mockItem = {
+ a: [],
+ classname: 'acp',
+ _isfieldmapper: true,
+ id: () => {},
+ circ_lib: () => mockLibrary
+ };
+
+ const mockCourse = {
+ a: [],
+ classname: 'acmc',
+ _isfieldmapper: true,
+ owning_lib: () => mockLibrary2
+ };
+
+ const authServiceSpy = jasmine.createSpyObj<AuthService>(['token']);
+ const courseServiceSpy = jasmine.createSpyObj<CourseService>(['associateMaterials']);
+ courseServiceSpy.associateMaterials.and.returnValue({item: mockItem, material: new Promise(() => {})});
+ const netServiceSpy = jasmine.createSpyObj<NetService>(['request']);
+ const pcrudServiceSpy = jasmine.createSpyObj<PcrudService>(['retrieveAll', 'search', 'update']);
+ pcrudServiceSpy.search.and.returnValue(of(mockItem));
+ const toastServiceSpy = jasmine.createSpyObj<ToastService>(['success']);
+ const permServiceSpy = jasmine.createSpyObj<PermService>(['hasWorkPermAt']);
+ permServiceSpy.hasWorkPermAt.and.returnValue(new Promise((resolve) => resolve({UPDATE_COPY: [5, 22]})));
+ const modalSpy = jasmine.createSpyObj<NgbModal>(['open']);
+ const dialogComponentSpy = jasmine.createSpyObj<DialogComponent>(['open']);
+ dialogComponentSpy.open.and.returnValue(of(true));
+ const rejectedDialogComponentSpy = jasmine.createSpyObj<DialogComponent>(['open']);
+ rejectedDialogComponentSpy.open.and.returnValue(of(false));
+
+ beforeEach(() => {
+ component = new CourseAssociateMaterialComponent(authServiceSpy, courseServiceSpy,
+ netServiceSpy, pcrudServiceSpy,
+ toastServiceSpy, permServiceSpy, modalSpy);
+ component.confirmOtherLibraryDialog = dialogComponentSpy;
+ component.currentCourse = mockCourse;
+ });
+
+ describe('#associateItem method', () => {
+ afterEach(() => {
+ courseServiceSpy.associateMaterials.calls.reset();
+ });
+
+ describe('item circ_lib is different from course owning lib', () => {
+ it("attempts to change item circ_lib to the course's library", waitForAsync(() => {
+ const paramsWithCircLib = {
+ barcode: '123',
+ relationship: 'required reading',
+ isModifyingLibrary: true,
+ tempLibrary: 22, // the Library that owns the course, rather than the item's circ_lib
+ currentCourse: mockCourse,
+ isModifyingCallNumber: undefined, isModifyingCircMod: undefined, isModifyingLocation: undefined, isModifyingStatus: undefined,
+ tempCircMod: undefined, tempLocation: undefined, tempStatus: undefined
+ };
+ component.associateItem('123', 'required reading');
+
+ setTimeout(() => { // wait for the subscribe() to do its work
+ expect(courseServiceSpy.associateMaterials).toHaveBeenCalledWith(mockItem, paramsWithCircLib);
+ }, 500);
+ }));
+
+ it('asks the user to confirm', (waitForAsync(() => {
+ component.associateItem('123', 'required reading');
+ setTimeout(() => { // wait for the subscribe() to do its work
+ expect(dialogComponentSpy.open).toHaveBeenCalled();
+ }, 500);
+ })));
+
+ it("sets the owning library's shortname in the UI", (waitForAsync(() => {
+ component.associateItem('123', 'required reading');
+ setTimeout(() => { // wait for the subscribe() to do its work
+ expect(component.itemCircLib).toBe('greatLibrary');
+ }, 500);
+ })));
+
+ it('does not proceed if the user says "no" in the different library confirmation dialog', waitForAsync(() => {
+ component.confirmOtherLibraryDialog = rejectedDialogComponentSpy;
+ component.associateItem('123', 'required reading');
+
+ setTimeout(() => { // wait for the subscribe() to do its work
+ expect(rejectedDialogComponentSpy.open).toHaveBeenCalled();
+ expect(courseServiceSpy.associateMaterials).not.toHaveBeenCalled();
+ }, 500);
+ }));
+
+ });
+ });
+});
+import { PermService } from '@eg/core/perm.service';
import {Component, Input, ViewChild, OnInit} from '@angular/core';
-import {Observable, merge, of, EMPTY} from 'rxjs';
-import {switchMap} from 'rxjs/operators';
+import { Observable, merge, of, EMPTY, throwError, from } from 'rxjs';
+import { switchMap, concatMap } from 'rxjs/operators';
import {DialogComponent} from '@eg/share/dialog/dialog.component';
import {AuthService} from '@eg/core/auth.service';
import {NetService} from '@eg/core/net.service';
@ViewChild('materialAddDifferentLibraryString', { static: true })
materialAddDifferentLibraryString: StringComponent;
@ViewChild('confirmOtherLibraryDialog') confirmOtherLibraryDialog: DialogComponent;
+ @ViewChild('otherLibraryNoPermissionsAlert') otherLibraryNoPermissionsAlert: DialogComponent;
materialsDataSource: GridDataSource;
@Input() barcodeInput: string;
@Input() relationshipInput: string;
@Input() isModifyingCircMod: boolean;
@Input() isModifyingCallNumber: boolean;
@Input() isModifyingLocation: boolean;
+ isModifyingLibrary: boolean;
bibId: number;
itemCircLib: string;
private net: NetService,
private pcrud: PcrudService,
private toast: ToastService,
+ private perm: PermService,
private modal: NgbModal
) {
super(modal);
}
associateItem(barcode, relationship) {
- if (barcode) {
+ if (!barcode || barcode.length === 0) { return; }
+ this.barcodeInput = null;
+
+ this.pcrud.search('acp', {barcode: barcode.trim()}, {
+ flesh: 3, flesh_fields: {acp: ['call_number', 'circ_lib']}
+ }).pipe(switchMap(item => {
+ this.isModifyingLibrary = item.circ_lib().id() !== this.currentCourse.owning_lib().id();
+ return this.isModifyingLibrary ? this.handleItemAtDifferentLibrary$(item) : of(item);
+ }))
+ .subscribe((originalItem) => {
const args = {
barcode: barcode.trim(),
relationship: relationship,
isModifyingCircMod: this.isModifyingCircMod,
isModifyingLocation: this.isModifyingLocation,
isModifyingStatus: this.isModifyingStatus,
+ isModifyingLibrary: this.isModifyingLibrary,
tempCircMod: this.tempCircMod,
tempLocation: this.tempLocation,
+ tempLibrary: this.currentCourse.owning_lib().id(),
tempStatus: this.tempStatus,
currentCourse: this.currentCourse
};
- this.barcodeInput = null;
-
- this.pcrud.search('acp', {barcode: args.barcode}, {
- flesh: 3, flesh_fields: {acp: ['call_number', 'circ_lib']}
- }).pipe(switchMap(item => this.handleItemAtDifferentLibrary$(item)))
- .subscribe((originalItem) => {
- const associatedMaterial = this.course.associateMaterials(originalItem, args);
- associatedMaterial.material.then(res => {
- const item = associatedMaterial.item;
- let new_cn = item.call_number().label();
- if (this.tempCallNumber) { new_cn = this.tempCallNumber; }
- this.course.updateItem(item, this.currentCourse.owning_lib(),
- new_cn, args.isModifyingCallNumber
- ).then(resp => {
- this.materialsGrid.reload();
- this.materialAddSuccessString.current()
- .then(str => this.toast.success(str));
- });
- }, err => {
- this.materialAddFailedString.current()
- .then(str => this.toast.danger(str));
+
+ const associatedMaterial = this.course.associateMaterials(originalItem, args);
+
+ associatedMaterial.material.then(res => {
+ const item = associatedMaterial.item;
+ let new_cn = item.call_number().label();
+ if (this.tempCallNumber) { new_cn = this.tempCallNumber; }
+ this.course.updateItem(item, this.currentCourse.owning_lib(),
+ new_cn, args.isModifyingCallNumber
+ ).then(resp => {
+ this.materialsGrid.reload();
+ this.materialAddSuccessString.current()
+ .then(str => this.toast.success(str));
});
+ }, err => {
+ this.materialAddFailedString.current()
+ .then(str => this.toast.danger(str));
});
- }
+ });
}
deleteSelectedMaterials(items) {
});
}
- handleItemAtDifferentLibrary$(item: IdlObject): Observable<any> {
+ private handleItemAtDifferentLibrary$(item: IdlObject): Observable<any> {
this.itemCircLib = item.circ_lib().shortname();
- if (item.circ_lib().id() !== this.currentCourse.owning_lib().id()) {
- return this.confirmOtherLibraryDialog.open()
- .pipe(switchMap(confirmed => {
- if (!confirmed) { return EMPTY; }
- return of(item);
- }));
- }
- return of(item);
+ const promise = this.perm.hasWorkPermAt(['UPDATE_COPY'], true).then(result => {
+ return result.UPDATE_COPY as number[];
+ });
+ return from(promise).pipe(concatMap((editableItemLibs) => {
+ if (editableItemLibs.indexOf(item.circ_lib().id()) !== -1) {
+ return this.confirmOtherLibraryDialog.open()
+ .pipe(switchMap(confirmed => {
+ // If the user clicked "no", return an empty observable,
+ // so the subsequent code has nothing to do.
+ if (!confirmed) { return EMPTY; }
+ return of(item);
+ }));
+ } else {
+ return this.otherLibraryNoPermissionsAlert.open()
+ .pipe(switchMap(() => EMPTY));
+ }
+ }));
}
}
-import {Observable, throwError} from 'rxjs';
-import {switchMap, tap} from 'rxjs/operators';
+import { Observable, merge, throwError } from 'rxjs';
+import { tap, switchMap } from 'rxjs/operators';
import {Injectable} from '@angular/core';
import {AuthService} from '@eg/core/auth.service';
import {EventService} from '@eg/core/event.service';
if (args.isModifyingCallNumber) {
material.original_callnumber(item.call_number());
}
+ if (args.isModifyingLibrary && args.tempLibrary && this.org.canHaveVolumes(args.tempLibrary)) {
+ material.original_circ_lib(item.circ_lib());
+ item.circ_lib(args.tempLibrary);
+ }
const response = {
item: item,
material: this.pcrud.create(material).toPromise()
).pipe(switchMap(res => {
const event = this.evt.parse(res);
if (event) { return throwError(event); }
- return this.net.request(
- 'open-ils.cat', 'open-ils.cat.transfer_copies_to_volume',
- this.auth.token(), res.acn_id, [item.id()]
- );
+ // Not using open-ils.cat.transfer_copies_to_volume,
+ // because we don't necessarily want acp.circ_lib and
+ // acn.owning_lib to match in this scenario
+ item.call_number(res.acn_id)
+ return this.pcrud.update(item);
}));
return updatingVolume ? itemObservable.pipe(switchMap(() => callNumberObservable)).toPromise() :
--- /dev/null
+import { waitForAsync } from '@angular/core/testing';
+import { CourseService } from './course.service';
+import { AuthService } from '@eg/core/auth.service';
+import { EventService } from '@eg/core/event.service';
+import { IdlService, IdlObject } from '@eg/core/idl.service';
+import { NetService } from '@eg/core/net.service';
+import { OrgService } from '@eg/core/org.service';
+import { PcrudService } from '@eg/core/pcrud.service';
+import { of } from 'rxjs';
+
+describe('CourseService', () => {
+ let service: CourseService;
+ let circLib = 5;
+ let originalCircLib: number;
+
+ const mockCallNumber = {
+ label_class: () => 1,
+ prefix: () => 2,
+ suffix: () => null ,
+ owning_lib: () => 5,
+ record: () => 123
+ };
+
+ const materialSpy = jasmine.createSpyObj<IdlObject>(['item', 'record', 'course', 'original_circ_lib']);
+ materialSpy.original_circ_lib.and.callFake((newValue?: number) => {
+ if (newValue) {
+ originalCircLib = newValue;
+ } else {
+ return originalCircLib;
+ }
+ })
+ const itemSpy = jasmine.createSpyObj<IdlObject>(['call_number', 'circ_lib', 'id']);
+ itemSpy.circ_lib.and.callFake((newValue?: number) => { // this will return 5 unless set otherwise
+ if (newValue) {
+ circLib = newValue;
+ } else {
+ return circLib;
+ }
+ });
+ itemSpy.call_number.and.returnValue(mockCallNumber);
+ const authServiceSpy = jasmine.createSpyObj<AuthService>(['token']);
+ authServiceSpy.token.and.returnValue('myToken');
+ const evtServiceSpy = jasmine.createSpyObj<EventService>(['parse']);
+ const idlServiceSpy = jasmine.createSpyObj<IdlService>(['create']);
+ idlServiceSpy.create.and.returnValue(materialSpy);
+ const netServiceSpy = jasmine.createSpyObj<NetService>(['request']);
+ netServiceSpy.request.and.returnValue(of());
+ const orgServiceSpy = jasmine.createSpyObj<OrgService>(['settings', 'canHaveVolumes']);
+ const pcrudServiceSpy = jasmine.createSpyObj<PcrudService>(['retrieveAll', 'search', 'update', 'create']);
+ pcrudServiceSpy.update.and.returnValue(of(1));
+ pcrudServiceSpy.create.and.returnValue(of(materialSpy));
+
+ const mockOrg = {
+ a: [],
+ classname: 'aou',
+ _isfieldmapper: true,
+ id: () => 5
+ };
+
+ const mockConsortium = {
+ id: () => 1
+ };
+
+ const mockCourse = {
+ id: () => 20
+ };
+
+ beforeEach(() => {
+ service = new CourseService(authServiceSpy, evtServiceSpy,
+ idlServiceSpy, netServiceSpy,
+ orgServiceSpy, pcrudServiceSpy);
+ orgServiceSpy.canHaveVolumes.and.returnValue(true);
+ circLib = 5; // set the item's circ lib to 5
+ });
+
+ afterEach(() => {
+ pcrudServiceSpy.update.calls.reset();
+ itemSpy.circ_lib.calls.reset();
+ materialSpy.original_circ_lib.calls.reset();
+ })
+
+ it('updateItem() passes the expected parameters to open-ils.cat', () => {
+ service.updateItem(itemSpy, mockOrg, 'ABC 123', true);
+ expect(netServiceSpy.request).toHaveBeenCalledWith(
+ 'open-ils.cat', 'open-ils.cat.call_number.find_or_create',
+ 'myToken', 'ABC 123', 123, 5, 2, null, 1
+ );
+ });
+
+ it('updateItem() calls pcrud only once when modifying call number', () => {
+ service.updateItem(itemSpy, mockOrg, 'ABC 123', true);
+ expect(pcrudServiceSpy.update).toHaveBeenCalledTimes(1);
+ });
+
+ it('updateItem() calls pcrud only once when not modifying call number', () => {
+ service.updateItem(itemSpy, mockOrg, 'ABC 123', false);
+ expect(pcrudServiceSpy.update).toHaveBeenCalledTimes(1);
+ });
+
+ it('#associateMaterials can temporarily change the item circ_lib', waitForAsync(() => {
+ const results = service.associateMaterials(itemSpy, {tempLibrary: 4, isModifyingLibrary: true, currentCourse: mockCourse});
+ expect(results.item.circ_lib()).toBe(4);
+ results.material.then((material) => {
+ expect(material.original_circ_lib()).toBe(5);
+ });
+ }));
+
+ it("#associateMaterials does not change the item circ_lib if the requested lib can't have items", () => {
+ orgServiceSpy.canHaveVolumes.and.returnValue(false);
+ const results = service.associateMaterials(itemSpy, {tempLibrary: 1, isModifyingLibrary: true, currentCourse: mockCourse});
+ expect(itemSpy.circ_lib).not.toHaveBeenCalled();
+ expect(results.item.circ_lib()).toBe(5);
+ expect(materialSpy.original_circ_lib).not.toHaveBeenCalled();
+ });
+
+});
if ($acmcm->original_location) {
$acp->location($acmcm->original_location);
}
+ if ($acmcm->original_circ_lib) {
+ $acp->circ_lib($acmcm->original_circ_lib);
+ }
$e->update_asset_copy($acp);
if ($acmcm->original_callnumber) {
my $existing_acn = $e->retrieve_asset_call_number($acp->call_number);
#!perl
use strict; use warnings;
-use Test::More tests => 7;
+use Test::More tests => 8;
use OpenILS::Utils::TestUtils;
use OpenILS::Utils::CStoreEditor qw/:funcs/;
use OpenILS::Application::AppUtils;
# 3. Let's attach an existing item record entry to course #1, then detach it
# --------------------------------------------------------------------------
-# Create an item with temporary location, so that we can confirm its fields revert on course material detach
+# Create an item with temporary location and library, so that we can confirm its fields revert on course material detach
my $acp = Fieldmapper::asset::copy->new;
my $item_id = int (rand (1_000_000) );
my $acmcm_id = int (rand (1_000_000) );
$acp->call_number(1);
$acp->creator(1);
$acp->editor(1);
-$acp->circ_lib(5);
+$acp->circ_lib(6); # temporary value
$acp->age_protect(1);
$acp->barcode( $bre->id . '-1' );
$acp->create_date('now');
$acmcm->item($item_id);
$acmcm->original_status(0);
$acmcm->original_location(1);
+$acmcm->original_circ_lib(5);
$acmcm->temporary_record(0);
$e->xact_begin;
$e->create_asset_course_module_course_materials( $acmcm ); # associated this bib record with a course
# Re-load the acp into memory from the db
$acp = $e->retrieve_asset_copy($item_id);
-is($acp->location, 1, "Successfully reverted item's shelving location");
\ No newline at end of file
+is($acp->location, 1, "Successfully reverted item's shelving location");
+is($acp->circ_lib, 5, "Successfully reverted item's circ_lib");
\ No newline at end of file
original_status INT REFERENCES config.copy_status,
original_circ_modifier TEXT, --REFERENCES config.circ_modifier
original_callnumber INT REFERENCES asset.call_number,
+ original_circ_lib INT REFERENCES actor.org_unit (id),
unique (course, item, record)
);
--- /dev/null
+BEGIN;
+
+-- SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version);
+
+ALTER TABLE asset.course_module_course_materials
+ ADD COLUMN original_circ_lib INT REFERENCES actor.org_unit (id);
+
+COMMIT;
\ No newline at end of file