@saooti/octopus-sdk 41.10.9 → 41.11.0-beta2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/index.ts +9 -10
- package/package.json +7 -6
- package/src/api/index.ts +9 -0
- package/src/api/mediathequeApi.ts +75 -0
- package/src/components/composable/useRights.ts +316 -144
- package/src/components/display/RightsIndicator.vue +215 -0
- package/src/components/misc/ClassicAlert.vue +15 -10
- package/src/components/misc/ClassicNav.vue +5 -0
- package/src/components/misc/ClassicPopover.vue +34 -65
- package/src/components/misc/ClassicTabs.vue +57 -0
- package/src/locale/de.json +6 -1
- package/src/locale/en.json +6 -1
- package/src/locale/es.json +6 -1
- package/src/locale/fr.json +7 -1
- package/src/locale/it.json +6 -1
- package/src/locale/sl.json +6 -1
- package/src/stores/FilterStore.ts +0 -23
- package/src/stores/class/cartouchier/cartouchier.ts +2 -0
- package/src/stores/class/general/media.ts +2 -0
- package/src/stores/class/radio/mix.ts +2 -0
- package/src/stores/class/radio/playlistMedia.ts +2 -0
- package/tests/components/composable/useRights.spec.ts +328 -1
- package/tests/components/display/RightsIndicator.spec.ts +345 -0
- package/tests/components/misc/ClassicPopover.spec.ts +64 -51
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import { setupPinia, setupAuthStore } from '@tests/utils';
|
|
3
3
|
import { useAuthStore } from '@/stores/AuthStore';
|
|
4
|
-
import { useRights } from '@/components/composable/useRights';
|
|
4
|
+
import { useRights, ActionRight } from '@/components/composable/useRights';
|
|
5
5
|
import type { Organisation } from '@/stores/class/general/organisation';
|
|
6
6
|
import type { Emission } from '@/stores/class/general/emission';
|
|
7
7
|
import type { Podcast } from '@/stores/class/general/podcast';
|
|
8
|
+
import type { Mix } from '@/stores/class/radio/mix';
|
|
9
|
+
import { PlaylistMedia } from '@/stores/class/radio/playlistMedia';
|
|
10
|
+
import { Cartouchier } from '@/stores/class/cartouchier/cartouchier';
|
|
11
|
+
import type { Media } from '@/stores/class/general/media';
|
|
8
12
|
|
|
9
13
|
async function setup(roles: string[], userId = 'test-user-123'): Promise<void> {
|
|
10
14
|
setupPinia();
|
|
@@ -330,6 +334,207 @@ describe('useRights', () => {
|
|
|
330
334
|
});
|
|
331
335
|
});
|
|
332
336
|
|
|
337
|
+
describe('Cartouchier permissions', () => {
|
|
338
|
+
const ownCartouchier = { ownerId: 'test-user-123' } as Cartouchier;
|
|
339
|
+
const otherCartouchier = { ownerId: 'other-user' } as Cartouchier;
|
|
340
|
+
const noOwnerCartouchier = {} as Cartouchier;
|
|
341
|
+
|
|
342
|
+
describe('canCreateCartouchier', () => {
|
|
343
|
+
['ADMIN', 'ORGANISATION', 'PRODUCTION', 'RADIO', 'ANIMATION', 'PODCAST_CRUD', 'RESTRICTED_PRODUCTION', 'RESTRICTED_ANIMATION'].forEach(role => {
|
|
344
|
+
it(`allows ${role}`, async () => {
|
|
345
|
+
await setup([role]);
|
|
346
|
+
expect(useRights().canCreateCartouchier()).toBe(true);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('denies unrelated roles', async () => {
|
|
351
|
+
await setup(['PLAYLISTS']);
|
|
352
|
+
expect(useRights().canCreateCartouchier()).toBe(false);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe('canEditCartouchier', () => {
|
|
357
|
+
['ADMIN', 'ORGANISATION', 'PRODUCTION', 'RADIO', 'ANIMATION'].forEach(role => {
|
|
358
|
+
it(`allows ${role} to edit any element`, async () => {
|
|
359
|
+
await setup([role]);
|
|
360
|
+
expect(useRights().canEditCartouchier(otherCartouchier)).toBe(true);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
['RESTRICTED_PRODUCTION', 'RESTRICTED_ANIMATION', 'PODCAST_CRUD'].forEach(role => {
|
|
365
|
+
it(`${role} can only edit own element`, async () => {
|
|
366
|
+
await setup([role]);
|
|
367
|
+
expect(useRights().canEditCartouchier(ownCartouchier)).toBe(true);
|
|
368
|
+
expect(useRights().canEditCartouchier(otherCartouchier)).toBe(false);
|
|
369
|
+
expect(useRights().canEditCartouchier(noOwnerCartouchier)).toBe(false);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('denies unrelated roles', async () => {
|
|
374
|
+
await setup(['PLAYLISTS']);
|
|
375
|
+
expect(useRights().canEditCartouchier(ownCartouchier)).toBe(false);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
describe('canDeleteCartouchier', () => {
|
|
380
|
+
it('restricted user can delete own element but not others\'', async () => {
|
|
381
|
+
await setup(['RESTRICTED_PRODUCTION']);
|
|
382
|
+
expect(useRights().canDeleteCartouchier(ownCartouchier)).toBe(true);
|
|
383
|
+
expect(useRights().canDeleteCartouchier(otherCartouchier)).toBe(false);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe('Bac/PlaylistMedia permissions', () => {
|
|
389
|
+
const ownBac = { ownerId: 'test-user-123' } as PlaylistMedia;
|
|
390
|
+
const otherBac = { ownerId: 'other-user' } as PlaylistMedia;
|
|
391
|
+
const noOwnerBac = {} as PlaylistMedia;
|
|
392
|
+
|
|
393
|
+
describe('canCreatePlaylistMedia', () => {
|
|
394
|
+
['ADMIN', 'ORGANISATION', 'PRODUCTION', 'RADIO', 'ANIMATION', 'PODCAST_CRUD', 'RESTRICTED_PRODUCTION', 'RESTRICTED_ANIMATION'].forEach(role => {
|
|
395
|
+
it(`allows ${role}`, async () => {
|
|
396
|
+
await setup([role]);
|
|
397
|
+
expect(useRights().canCreatePlaylistMedia()).toBe(true);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('denies unrelated roles', async () => {
|
|
402
|
+
await setup(['PLAYLISTS']);
|
|
403
|
+
expect(useRights().canCreatePlaylistMedia()).toBe(false);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
describe('canEditPlaylistMedia', () => {
|
|
408
|
+
['ADMIN', 'ORGANISATION', 'PRODUCTION', 'RADIO', 'ANIMATION'].forEach(role => {
|
|
409
|
+
it(`allows ${role} to edit any element`, async () => {
|
|
410
|
+
await setup([role]);
|
|
411
|
+
expect(useRights().canEditPlaylistMedia(otherBac)).toBe(true);
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
['RESTRICTED_PRODUCTION', 'RESTRICTED_ANIMATION', 'PODCAST_CRUD'].forEach(role => {
|
|
416
|
+
it(`${role} can only edit own element`, async () => {
|
|
417
|
+
await setup([role]);
|
|
418
|
+
expect(useRights().canEditPlaylistMedia(ownBac)).toBe(true);
|
|
419
|
+
expect(useRights().canEditPlaylistMedia(otherBac)).toBe(false);
|
|
420
|
+
expect(useRights().canEditPlaylistMedia(noOwnerBac)).toBe(false);
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('denies unrelated roles', async () => {
|
|
425
|
+
await setup(['PLAYLISTS']);
|
|
426
|
+
expect(useRights().canEditPlaylistMedia(ownBac)).toBe(false);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
describe('canDeletePlaylistMedia', () => {
|
|
431
|
+
it('restricted user can delete own element but not others\'', async () => {
|
|
432
|
+
await setup(['RESTRICTED_PRODUCTION']);
|
|
433
|
+
expect(useRights().canDeletePlaylistMedia(ownBac)).toBe(true);
|
|
434
|
+
expect(useRights().canDeletePlaylistMedia(otherBac)).toBe(false);
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
describe('Media permissions', () => {
|
|
440
|
+
const ownMedia = { mediaId: 1, ownerId: 'test-user-123' } as Media;
|
|
441
|
+
const otherMedia = { mediaId: 2, ownerId: 'other-user' } as Media;
|
|
442
|
+
const noOwnerMedia = { mediaId: 3 } as Media;
|
|
443
|
+
|
|
444
|
+
describe('canCreateMedia', () => {
|
|
445
|
+
['ADMIN', 'ORGANISATION', 'PRODUCTION', 'RADIO', 'ANIMATION',
|
|
446
|
+
'PODCAST_CRUD', 'RESTRICTED_PRODUCTION', 'RESTRICTED_ANIMATION'].forEach(role => {
|
|
447
|
+
it(`allows ${role}`, async () => {
|
|
448
|
+
await setup([role]);
|
|
449
|
+
expect(useRights().canCreateMedia()).toBe(true);
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('denies unrelated roles', async () => {
|
|
454
|
+
await setup(['PLAYLISTS']);
|
|
455
|
+
expect(useRights().canCreateMedia()).toBe(false);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
describe('canEditMedia', () => {
|
|
460
|
+
['ADMIN', 'ORGANISATION', 'PRODUCTION', 'RADIO', 'ANIMATION'].forEach(role => {
|
|
461
|
+
it(`allows ${role} to edit any element`, async () => {
|
|
462
|
+
await setup([role]);
|
|
463
|
+
expect(useRights().canEditMedia(otherMedia)).toBe(true);
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
['RESTRICTED_PRODUCTION', 'RESTRICTED_ANIMATION', 'PODCAST_CRUD'].forEach(role => {
|
|
468
|
+
it(`${role} can only edit own element`, async () => {
|
|
469
|
+
await setup([role]);
|
|
470
|
+
expect(useRights().canEditMedia(ownMedia)).toBe(true);
|
|
471
|
+
expect(useRights().canEditMedia(otherMedia)).toBe(false);
|
|
472
|
+
expect(useRights().canEditMedia(noOwnerMedia)).toBe(false);
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('denies unrelated roles', async () => {
|
|
477
|
+
await setup(['PLAYLISTS']);
|
|
478
|
+
expect(useRights().canEditMedia(ownMedia)).toBe(false);
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
describe('canDeleteMedia', () => {
|
|
483
|
+
it('delegates to canEditMedia', async () => {
|
|
484
|
+
await setup(['RESTRICTED_PRODUCTION']);
|
|
485
|
+
expect(useRights().canDeleteMedia(ownMedia)).toBe(true);
|
|
486
|
+
expect(useRights().canDeleteMedia(otherMedia)).toBe(false);
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
describe('Mix permissions', () => {
|
|
492
|
+
const ownMix = { ownerId: 'test-user-123' } as Mix;
|
|
493
|
+
const otherMix = { ownerId: 'other-user' } as Mix;
|
|
494
|
+
|
|
495
|
+
describe('canCreateMix', () => {
|
|
496
|
+
['ADMIN', 'RADIO'].forEach(role => {
|
|
497
|
+
it(`allows ${role}`, async () => {
|
|
498
|
+
await setup([role]);
|
|
499
|
+
expect(useRights().canCreateMix()).toBe(true);
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('denies unrelated roles', async () => {
|
|
504
|
+
await setup(['PLAYLISTS']);
|
|
505
|
+
expect(useRights().canCreateMix()).toBe(false);
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
describe('canEditMix', () => {
|
|
510
|
+
['ADMIN', 'RADIO'].forEach(role => {
|
|
511
|
+
it(`allows ${role} to edit any element`, async () => {
|
|
512
|
+
await setup([role]);
|
|
513
|
+
expect(useRights().canEditMix(otherMix)).toBe(true);
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('denies unrelated roles', async () => {
|
|
518
|
+
await setup(['PLAYLISTS']);
|
|
519
|
+
expect(useRights().canEditMix(ownMix)).toBe(false);
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
describe('canDeleteMix', () => {
|
|
524
|
+
['ADMIN', 'RADIO'].forEach(role => {
|
|
525
|
+
it(`allows ${role} to delete any element`, async () => {
|
|
526
|
+
await setup([role]);
|
|
527
|
+
expect(useRights().canDeleteMix(otherMix)).toBe(true);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('denies unrelated roles', async () => {
|
|
532
|
+
await setup(['PLAYLISTS']);
|
|
533
|
+
expect(useRights().canDeleteMix(ownMix)).toBe(false);
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
|
|
333
538
|
describe('Other permissions', () => {
|
|
334
539
|
describe('canEditCodeInsertPlayer', () => {
|
|
335
540
|
['ADMIN', 'ORGANISATION'].forEach(role => {
|
|
@@ -347,4 +552,126 @@ describe('useRights', () => {
|
|
|
347
552
|
});
|
|
348
553
|
});
|
|
349
554
|
});
|
|
555
|
+
|
|
556
|
+
describe('ActionRight detailed reasons', () => {
|
|
557
|
+
describe('getEditEmissionRight', () => {
|
|
558
|
+
it('returns Allowed for PRODUCTION', async () => {
|
|
559
|
+
await setup(['PRODUCTION']);
|
|
560
|
+
expect(useRights().getEditEmissionRight({ emissionId: 1, createdByUserId: 'other' } as Emission)).toBe(ActionRight.Allowed);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('returns Allowed for RESTRICTED_PRODUCTION editing own emission', async () => {
|
|
564
|
+
await setup(['RESTRICTED_PRODUCTION']);
|
|
565
|
+
expect(useRights().getEditEmissionRight({ emissionId: 1, createdByUserId: 'test-user-123' } as Emission)).toBe(ActionRight.Allowed);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it('returns DeniedNotOwner for RESTRICTED_PRODUCTION editing others\' emission', async () => {
|
|
569
|
+
await setup(['RESTRICTED_PRODUCTION']);
|
|
570
|
+
expect(useRights().getEditEmissionRight({ emissionId: 1, createdByUserId: 'other' } as Emission)).toBe(ActionRight.DeniedNotOwner);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('returns DeniedNoRight for role without emission access', async () => {
|
|
574
|
+
await setup(['PLAYLISTS']);
|
|
575
|
+
expect(useRights().getEditEmissionRight({ emissionId: 1, createdByUserId: 'test-user-123' } as Emission)).toBe(ActionRight.DeniedNoRight);
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
describe('getEditPodcastRight', () => {
|
|
580
|
+
it('returns Allowed for PRODUCTION on any podcast', async () => {
|
|
581
|
+
await setup(['PRODUCTION']);
|
|
582
|
+
expect(useRights().getEditPodcastRight({ createdByUserId: 'other', valid: true } as Podcast)).toBe(ActionRight.Allowed);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('returns Allowed for RESTRICTED_PRODUCTION editing own podcast', async () => {
|
|
586
|
+
await setup(['RESTRICTED_PRODUCTION']);
|
|
587
|
+
expect(useRights().getEditPodcastRight({ createdByUserId: 'test-user-123', valid: true } as Podcast)).toBe(ActionRight.Allowed);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it('returns DeniedNotOwner for RESTRICTED_PRODUCTION editing others\' podcast', async () => {
|
|
591
|
+
await setup(['RESTRICTED_PRODUCTION']);
|
|
592
|
+
expect(useRights().getEditPodcastRight({ createdByUserId: 'other', valid: true } as Podcast)).toBe(ActionRight.DeniedNotOwner);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it('returns DeniedNotOwner for PODCAST_CRUD editing others\' podcast', async () => {
|
|
596
|
+
await setup(['PODCAST_CRUD']);
|
|
597
|
+
expect(useRights().getEditPodcastRight({ valid: false, publisher: { userId: 'other' } } as Podcast)).toBe(ActionRight.DeniedNotOwner);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('returns DeniedNoRight for role without podcast access', async () => {
|
|
601
|
+
await setup(['PLAYLISTS']);
|
|
602
|
+
expect(useRights().getEditPodcastRight({ createdByUserId: 'test-user-123', valid: true } as Podcast)).toBe(ActionRight.DeniedNoRight);
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
describe('getEditCartouchierRight', () => {
|
|
607
|
+
it('returns Allowed for ANIMATION on any element', async () => {
|
|
608
|
+
await setup(['ANIMATION']);
|
|
609
|
+
expect(useRights().getEditCartouchierRight({ ownerId: 'other' } as Cartouchier)).toBe(ActionRight.Allowed);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it('returns Allowed for RESTRICTED_PRODUCTION editing own element', async () => {
|
|
613
|
+
await setup(['RESTRICTED_PRODUCTION']);
|
|
614
|
+
expect(useRights().getEditCartouchierRight({ ownerId: 'test-user-123' } as Cartouchier)).toBe(ActionRight.Allowed);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it('returns DeniedNotOwner for RESTRICTED_PRODUCTION editing others\' element', async () => {
|
|
618
|
+
await setup(['RESTRICTED_PRODUCTION']);
|
|
619
|
+
expect(useRights().getEditCartouchierRight({ ownerId: 'other' } as Cartouchier)).toBe(ActionRight.DeniedNotOwner);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it('returns DeniedNoRight for role without cartouchier access', async () => {
|
|
623
|
+
await setup(['PLAYLISTS']);
|
|
624
|
+
expect(useRights().getEditCartouchierRight({ ownerId: 'test-user-123' } as Cartouchier)).toBe(ActionRight.DeniedNoRight);
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
describe('getEditMediaRight', () => {
|
|
629
|
+
it('returns Allowed for ANIMATION on any element', async () => {
|
|
630
|
+
await setup(['ANIMATION']);
|
|
631
|
+
expect(useRights().getEditMediaRight({ mediaId: 1, ownerId: 'other' } as Media)).toBe(ActionRight.Allowed);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it('returns Allowed for RESTRICTED_PRODUCTION editing own element', async () => {
|
|
635
|
+
await setup(['RESTRICTED_PRODUCTION']);
|
|
636
|
+
expect(useRights().getEditMediaRight({ mediaId: 1, ownerId: 'test-user-123' } as Media)).toBe(ActionRight.Allowed);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it('returns DeniedNotOwner for RESTRICTED_PRODUCTION editing others\' element', async () => {
|
|
640
|
+
await setup(['RESTRICTED_PRODUCTION']);
|
|
641
|
+
expect(useRights().getEditMediaRight({ mediaId: 1, ownerId: 'other' } as Media)).toBe(ActionRight.DeniedNotOwner);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('returns DeniedNotOwner when ownerId is absent', async () => {
|
|
645
|
+
await setup(['PODCAST_CRUD']);
|
|
646
|
+
expect(useRights().getEditMediaRight({ mediaId: 1 } as Media)).toBe(ActionRight.DeniedNotOwner);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it('returns DeniedNoRight for role without media access', async () => {
|
|
650
|
+
await setup(['PLAYLISTS']);
|
|
651
|
+
expect(useRights().getEditMediaRight({ mediaId: 1, ownerId: 'test-user-123' } as Media)).toBe(ActionRight.DeniedNoRight);
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
describe('getEditTranscriptRight', () => {
|
|
656
|
+
it('returns Allowed for PRODUCTION on any podcast', async () => {
|
|
657
|
+
await setup(['PRODUCTION']);
|
|
658
|
+
expect(useRights().getEditTranscriptRight({ createdByUserId: 'other' } as Podcast)).toBe(ActionRight.Allowed);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it('returns Allowed for PODCAST_CRUD editing own transcript', async () => {
|
|
662
|
+
await setup(['PODCAST_CRUD']);
|
|
663
|
+
expect(useRights().getEditTranscriptRight({ createdByUserId: 'test-user-123' } as Podcast)).toBe(ActionRight.Allowed);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it('returns DeniedNotOwner for PODCAST_CRUD editing others\' transcript', async () => {
|
|
667
|
+
await setup(['PODCAST_CRUD']);
|
|
668
|
+
expect(useRights().getEditTranscriptRight({ createdByUserId: 'other' } as Podcast)).toBe(ActionRight.DeniedNotOwner);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it('returns DeniedNoRight for role without transcript access', async () => {
|
|
672
|
+
await setup(['RESTRICTED_ANIMATION']);
|
|
673
|
+
expect(useRights().getEditTranscriptRight({ createdByUserId: 'test-user-123' } as Podcast)).toBe(ActionRight.DeniedNoRight);
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
});
|
|
350
677
|
});
|