From 9f12763f8bfb6591c0f56dfec88083fde3859054 Mon Sep 17 00:00:00 2001 From: FinnStutzenstein Date: Tue, 23 Apr 2019 16:57:35 +0200 Subject: [PATCH] Split AgendaItem and ListOfSpeakers Server: - ListOfSpeakers (LOS) is now a speprate model, containing of an id, speakers, closed and a content_object. - Moved all speaker related views from ItemViewSet to the new ListOfSpeakersViewSet. - Make Mixins for content objects of items and lists of speakers. - Migrations: Move the lists of speakers from items to the LOS model. Client: - Removed the speaker repo and moved functionality to the new ListOfSpeakersRepositoryService. - Splitted base classes for agenda item content objects to items and LOS. - CurrentAgendaItemService -> CurrentListOfSpeakersSerivce - Cleaned up the list of speakers component. --- client/src/app/core/app-config.ts | 2 +- .../core/core-services/app-load.service.ts | 2 +- .../core/core-services/autoupdate.service.ts | 19 +- .../collection-string-mapper.service.ts | 16 +- .../app/core/core-services/http.service.ts | 7 +- .../core-services/view-model-store.service.ts | 10 +- .../agenda/item-repository.service.ts | 68 ++- ...ist-of-speakers-repository.service.spec.ts | 17 + .../list-of-speakers-repository.service.ts | 227 ++++++++ .../agenda/speaker-repository.service.spec.ts | 17 - .../agenda/speaker-repository.service.ts | 163 ------ .../assignment-repository.service.ts | 34 +- .../base-agenda-content-object-repository.ts | 44 -- .../base-has-content-object-repository.ts | 75 +++ ...t-of-speakers-content-object-repository.ts | 98 ++++ ...s-agenda-item-content-object-repository.ts | 92 ++++ ...t-of-speakers-content-object-repository.ts | 81 +++ .../app/core/repositories/base-repository.ts | 27 +- .../config/config-repository.service.ts | 12 +- .../history/history-repository.service.ts | 15 +- .../mediafile-repository.service.ts | 25 +- .../motions/category-repository.service.ts | 14 +- ...hange-recommendation-repository.service.ts | 16 +- .../motion-block-repository.service.ts | 29 +- ...tion-comment-section-repository.service.ts | 19 +- .../motions/motion-repository.service.ts | 59 ++- .../statute-paragraph-repository.service.ts | 19 +- .../motions/workflow-repository.service.ts | 15 +- .../projector/countdown-repository.service.ts | 14 +- .../projection-default-repository.service.ts | 20 +- .../projector-message-repository.service.ts | 23 +- .../projector/projector-repository.service.ts | 15 +- .../tags/tag-repository.service.ts | 15 +- .../topic-repository.service.spec.ts | 0 .../topic-repository.service.ts | 38 +- .../users/group-repository.service.ts | 15 +- .../users/personal-note-repository.service.ts | 16 +- .../users/user-repository.service.ts | 59 ++- .../ui-services/base-filter-list.service.ts | 4 +- .../app/core/ui-services/search.service.ts | 2 +- .../speaker-button.component.html | 16 + .../speaker-button.component.spec.ts | 25 + .../speaker-button.component.ts | 45 ++ client/src/app/shared/models/agenda/item.ts | 50 +- .../shared/models/agenda/list-of-speakers.ts | 21 + .../src/app/shared/models/agenda/speaker.ts | 53 +- .../shared/models/assignments/assignment.ts | 5 +- ...l-with-agenda-item-and-list-of-speakers.ts | 16 + .../base/base-model-with-agenda-item.ts | 12 + .../base/base-model-with-content-object.ts | 9 + .../base/base-model-with-list-of-speakers.ts | 12 + .../app/shared/models/base/content-object.ts | 12 + .../src/app/shared/models/mediafiles/file.ts | 19 - .../app/shared/models/mediafiles/mediafile.ts | 21 +- .../app/shared/models/motions/motion-block.ts | 5 +- .../src/app/shared/models/motions/motion.ts | 7 +- client/src/app/shared/models/topics/topic.ts | 5 +- client/src/app/shared/shared.module.ts | 7 +- .../app/site/agenda/agenda-import.service.ts | 2 +- .../app/site/agenda/agenda-routing.module.ts | 6 +- client/src/app/site/agenda/agenda.config.ts | 11 +- .../agenda-list/agenda-list.component.html | 9 +- .../agenda-list/agenda-list.component.ts | 32 +- .../list-of-speakers.component.html | 48 +- .../list-of-speakers.component.ts | 179 +++---- .../topic-detail/topic-detail.component.html | 5 +- .../topic-detail/topic-detail.component.ts | 16 +- .../site/agenda/models/view-create-topic.ts | 7 +- .../src/app/site/agenda/models/view-item.ts | 89 +--- .../agenda/models/view-list-of-speakers.ts | 92 ++++ .../app/site/agenda/models/view-speaker.ts | 56 +- .../src/app/site/agenda/models/view-topic.ts | 69 +-- .../assignment-detail.component.html | 13 +- .../assignment-detail.component.ts | 7 - .../models/view-assignment-poll.ts | 4 + .../assignments/models/view-assignment.ts | 54 +- .../src/app/site/base/agenda-information.ts | 27 - .../app/site/base/base-agenda-view-model.ts | 55 -- .../site/base/base-projectable-view-model.ts | 4 +- ...l-with-agenda-item-and-list-of-speakers.ts | 82 +++ .../base/base-view-model-with-agenda-item.ts | 114 ++++ .../base-view-model-with-content-object.ts | 63 +++ .../base-view-model-with-list-of-speakers.ts | 66 +++ client/src/app/site/base/base-view-model.ts | 38 +- client/src/app/site/base/list-view-base.ts | 9 +- client/src/app/site/base/projectable.ts | 8 +- .../src/app/site/config/models/view-config.ts | 33 +- .../app/site/history/models/view-history.ts | 35 +- .../mediafile-list.component.html | 2 + .../site/mediafiles/models/view-mediafile.ts | 36 +- .../app/site/motions/models/view-category.ts | 41 +- .../site/motions/models/view-create-motion.ts | 42 +- .../site/motions/models/view-motion-block.ts | 60 +-- ...s => view-motion-change-recommendation.ts} | 53 +- .../models/view-motion-comment-section.ts | 34 +- .../app/site/motions/models/view-motion.ts | 189 +++---- .../motions/models/view-statute-paragraph.ts | 33 +- .../app/site/motions/models/view-workflow.ts | 30 +- .../motion-block-detail.component.html | 2 +- .../motion-block-detail.component.ts | 11 - ...on-change-recommendation.component.spec.ts | 2 +- .../motion-change-recommendation.component.ts | 2 +- .../motion-detail-diff.component.spec.ts | 2 +- .../motion-detail-diff.component.ts | 2 +- ...iginal-change-recommendations.component.ts | 2 +- .../motion-detail.component.html | 5 +- .../motion-detail/motion-detail.component.ts | 11 +- .../motion-list/motion-list.component.html | 9 +- .../motion-list/motion-list.component.ts | 10 - client/src/app/site/motions/motions.config.ts | 2 +- .../site/projector/models/view-countdown.ts | 35 +- .../models/view-projection-default.ts | 24 +- .../models/view-projector-message.ts | 31 +- .../site/projector/models/view-projector.ts | 29 +- .../services/current-agenda-item.service.ts | 43 +- client/src/app/site/tags/models/view-tag.ts | 30 +- .../site/users/models/view-csv-create-user.ts | 4 - .../src/app/site/users/models/view-group.ts | 30 +- .../site/users/models/view-personal-note.ts | 28 +- client/src/app/site/users/models/view-user.ts | 95 +--- .../common-list-of-speakers-slide-data.ts | 1 - ...common-list-of-speakers-slide.component.ts | 7 +- .../agenda/item-list/item-list-slide-data.ts | 1 - .../item-list/item-list-slide.component.html | 1 - .../item-list/item-list-slide.component.ts | 7 +- .../slides/motions/base/base-motion-slide.ts | 6 +- .../motion-block/motion-block-slide-data.ts | 3 +- openslides/agenda/access_permissions.py | 90 ++-- openslides/agenda/apps.py | 10 +- .../migrations/0007_list_of_speakers_1.py | 79 +++ .../migrations/0007_list_of_speakers_2.py | 56 ++ .../migrations/0007_list_of_speakers_3.py | 23 + openslides/agenda/mixins.py | 113 ++++ openslides/agenda/models.py | 119 ++++- openslides/agenda/projector.py | 91 ++-- openslides/agenda/serializers.py | 31 +- openslides/agenda/signals.py | 86 ++- openslides/agenda/views.py | 490 +++++++++--------- openslides/assignments/apps.py | 1 - openslides/assignments/models.py | 44 +- openslides/assignments/serializers.py | 1 + openslides/core/apps.py | 1 - openslides/mediafiles/apps.py | 1 - openslides/mediafiles/models.py | 20 +- openslides/mediafiles/serializers.py | 1 + openslides/motions/apps.py | 1 - openslides/motions/models.py | 66 +-- openslides/motions/serializers.py | 10 +- openslides/motions/views.py | 12 +- openslides/topics/apps.py | 1 - openslides/topics/models.py | 38 +- openslides/topics/serializers.py | 1 + openslides/users/apps.py | 1 - openslides/users/signals.py | 5 + tests/integration/agenda/test_viewset.py | 260 ++++++---- tests/integration/assignments/test_viewset.py | 3 +- tests/integration/mediafiles/test_viewset.py | 5 +- tests/integration/motions/test_viewset.py | 3 +- tests/integration/topics/test_viewset.py | 3 +- tests/old/agenda/test_list_of_speakers.py | 92 ++-- tests/unit/agenda/test_projector.py | 15 +- tests/unit/agenda/test_views.py | 34 +- 162 files changed, 3353 insertions(+), 2425 deletions(-) create mode 100644 client/src/app/core/repositories/agenda/list-of-speakers-repository.service.spec.ts create mode 100644 client/src/app/core/repositories/agenda/list-of-speakers-repository.service.ts delete mode 100644 client/src/app/core/repositories/agenda/speaker-repository.service.spec.ts delete mode 100644 client/src/app/core/repositories/agenda/speaker-repository.service.ts delete mode 100644 client/src/app/core/repositories/base-agenda-content-object-repository.ts create mode 100644 client/src/app/core/repositories/base-has-content-object-repository.ts create mode 100644 client/src/app/core/repositories/base-is-agenda-item-and-list-of-speakers-content-object-repository.ts create mode 100644 client/src/app/core/repositories/base-is-agenda-item-content-object-repository.ts create mode 100644 client/src/app/core/repositories/base-is-list-of-speakers-content-object-repository.ts rename client/src/app/core/repositories/{agenda => topics}/topic-repository.service.spec.ts (100%) rename client/src/app/core/repositories/{agenda => topics}/topic-repository.service.ts (64%) create mode 100644 client/src/app/shared/components/speaker-button/speaker-button.component.html create mode 100644 client/src/app/shared/components/speaker-button/speaker-button.component.spec.ts create mode 100644 client/src/app/shared/components/speaker-button/speaker-button.component.ts create mode 100644 client/src/app/shared/models/agenda/list-of-speakers.ts create mode 100644 client/src/app/shared/models/base/base-model-with-agenda-item-and-list-of-speakers.ts create mode 100644 client/src/app/shared/models/base/base-model-with-agenda-item.ts create mode 100644 client/src/app/shared/models/base/base-model-with-content-object.ts create mode 100644 client/src/app/shared/models/base/base-model-with-list-of-speakers.ts create mode 100644 client/src/app/shared/models/base/content-object.ts delete mode 100644 client/src/app/shared/models/mediafiles/file.ts create mode 100644 client/src/app/site/agenda/models/view-list-of-speakers.ts delete mode 100644 client/src/app/site/base/agenda-information.ts delete mode 100644 client/src/app/site/base/base-agenda-view-model.ts create mode 100644 client/src/app/site/base/base-view-model-with-agenda-item-and-list-of-speakers.ts create mode 100644 client/src/app/site/base/base-view-model-with-agenda-item.ts create mode 100644 client/src/app/site/base/base-view-model-with-content-object.ts create mode 100644 client/src/app/site/base/base-view-model-with-list-of-speakers.ts rename client/src/app/site/motions/models/{view-change-recommendation.ts => view-motion-change-recommendation.ts} (61%) create mode 100644 openslides/agenda/migrations/0007_list_of_speakers_1.py create mode 100644 openslides/agenda/migrations/0007_list_of_speakers_2.py create mode 100644 openslides/agenda/migrations/0007_list_of_speakers_3.py create mode 100644 openslides/agenda/mixins.py diff --git a/client/src/app/core/app-config.ts b/client/src/app/core/app-config.ts index 76f82807e..b1a9d433f 100644 --- a/client/src/app/core/app-config.ts +++ b/client/src/app/core/app-config.ts @@ -8,7 +8,7 @@ import { BaseViewModel, ViewModelConstructor } from 'app/site/base/base-view-mod interface BaseModelEntry { collectionString: string; - repository: Type>; + repository: Type>; model: ModelConstructor; } diff --git a/client/src/app/core/core-services/app-load.service.ts b/client/src/app/core/core-services/app-load.service.ts index c10f4c169..1a1660013 100644 --- a/client/src/app/core/core-services/app-load.service.ts +++ b/client/src/app/core/core-services/app-load.service.ts @@ -69,7 +69,7 @@ export class AppLoadService { appConfigs.forEach((config: AppConfig) => { if (config.models) { config.models.forEach(entry => { - let repository: BaseRepository = null; + let repository: BaseRepository = null; repository = this.injector.get(entry.repository); repositories.push(repository); this.modelMapper.registerCollectionElement( diff --git a/client/src/app/core/core-services/autoupdate.service.ts b/client/src/app/core/core-services/autoupdate.service.ts index 912304767..12784f2d5 100644 --- a/client/src/app/core/core-services/autoupdate.service.ts +++ b/client/src/app/core/core-services/autoupdate.service.ts @@ -123,11 +123,7 @@ export class AutoupdateService { // Add the objects to the DataStore. for (const collection of Object.keys(autoupdate.changed)) { - if (this.modelMapper.isCollectionRegistered(collection)) { - await this.DS.add(this.mapObjectsToBaseModels(collection, autoupdate.changed[collection])); - } else { - console.error(`Unregistered collection "${collection}". Ignore it.`); - } + await this.DS.add(this.mapObjectsToBaseModels(collection, autoupdate.changed[collection])); } await this.DS.flushToStorage(autoupdate.to_change_id); @@ -140,14 +136,21 @@ export class AutoupdateService { } /** - * Creates baseModels for each plain object + * Creates baseModels for each plain object. If the collection is not registered, + * A console error will be issued and an empty list returned. + * * @param collection The collection all models have to be from. * @param models All models that should be mapped to BaseModels * @returns A list of basemodels constructed from the given models. */ private mapObjectsToBaseModels(collection: string, models: object[]): BaseModel[] { - const targetClass = this.modelMapper.getModelConstructor(collection); - return models.map(model => new targetClass(model)); + if (this.modelMapper.isCollectionRegistered(collection)) { + const targetClass = this.modelMapper.getModelConstructor(collection); + return models.map(model => new targetClass(model)); + } else { + console.error(`Unregistered collection "${collection}". Ignore it.`); + return []; + } } /** diff --git a/client/src/app/core/core-services/collection-string-mapper.service.ts b/client/src/app/core/core-services/collection-string-mapper.service.ts index 2468d5a98..deea97923 100644 --- a/client/src/app/core/core-services/collection-string-mapper.service.ts +++ b/client/src/app/core/core-services/collection-string-mapper.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { ModelConstructor, BaseModel } from '../../shared/models/base/base-model'; import { BaseRepository } from 'app/core/repositories/base-repository'; -import { ViewModelConstructor, BaseViewModel } from 'app/site/base/base-view-model'; +import { ViewModelConstructor, BaseViewModel, TitleInformation } from 'app/site/base/base-view-model'; /** * Unifies the ModelConstructor and ViewModelConstructor. @@ -15,12 +15,12 @@ interface UnifiedConstructors { /** * Every types supported: (View)ModelConstructors, repos and collectionstrings. */ -type TypeIdentifier = UnifiedConstructors | BaseRepository | string; +type TypeIdentifier = UnifiedConstructors | BaseRepository | string; type CollectionStringMappedTypes = [ ModelConstructor, ViewModelConstructor, - BaseRepository + BaseRepository ]; /** @@ -50,7 +50,7 @@ export class CollectionStringMapperService { collectionString: string, model: ModelConstructor, viewModel: ViewModelConstructor, - repository: BaseRepository + repository: BaseRepository ): void { this.collectionStringMapping[collectionString] = [model, viewModel, repository]; } @@ -98,18 +98,18 @@ export class CollectionStringMapperService { * @param obj The object to get the repository from. * @returns the repository */ - public getRepository( + public getRepository( obj: TypeIdentifier - ): BaseRepository | null { + ): BaseRepository | null { if (this.isCollectionRegistered(this.getCollectionString(obj))) { - return this.collectionStringMapping[this.getCollectionString(obj)][2] as BaseRepository; + return this.collectionStringMapping[this.getCollectionString(obj)][2] as BaseRepository; } } /** * @returns all registered repositories. */ - public getAllRepositories(): BaseRepository[] { + public getAllRepositories(): BaseRepository[] { return Object.values(this.collectionStringMapping).map((types: CollectionStringMappedTypes) => types[2]); } } diff --git a/client/src/app/core/core-services/http.service.ts b/client/src/app/core/core-services/http.service.ts index 0866cf0b5..6c124e726 100644 --- a/client/src/app/core/core-services/http.service.ts +++ b/client/src/app/core/core-services/http.service.ts @@ -120,7 +120,12 @@ export class HttpService { return error; } - if (!e.error) { + if (e.status === 405) { + // this should only happen, if the url is wrong -> a bug. + error += this.translate.instant( + 'The requested method is not allowed. Please contact your system administrator.' + ); + } else if (!e.error) { error += this.translate.instant("The server didn't respond."); } else if (typeof e.error === 'object') { if (e.error.detail) { diff --git a/client/src/app/core/core-services/view-model-store.service.ts b/client/src/app/core/core-services/view-model-store.service.ts index 1ceafce56..9dc19be41 100644 --- a/client/src/app/core/core-services/view-model-store.service.ts +++ b/client/src/app/core/core-services/view-model-store.service.ts @@ -20,10 +20,10 @@ export class ViewModelStoreService { * * @param collectionType The collection string or constructor. */ - private getRepository( - collectionType: ViewModelConstructor | string - ): BaseRepository { - return this.mapperService.getRepository(collectionType) as BaseRepository; + private getRepository( + collectionType: ViewModelConstructor | string + ): BaseRepository { + return this.mapperService.getRepository(collectionType) as BaseRepository; } /** @@ -32,7 +32,7 @@ export class ViewModelStoreService { * @param collectionString The collection of the view model * @param id The id of the view model */ - public get(collectionType: ViewModelConstructor | string, id: number): T { + public get(collectionType: ViewModelConstructor | string, id: number): V { return this.getRepository(collectionType).getViewModel(id); } diff --git a/client/src/app/core/repositories/agenda/item-repository.service.ts b/client/src/app/core/repositories/agenda/item-repository.service.ts index f0878f394..2671f7ca9 100644 --- a/client/src/app/core/repositories/agenda/item-repository.service.ts +++ b/client/src/app/core/repositories/agenda/item-repository.service.ts @@ -4,33 +4,41 @@ import { map } from 'rxjs/operators'; import { Observable } from 'rxjs'; import { TranslateService } from '@ngx-translate/core'; -import { BaseRepository } from '../base-repository'; import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; import { ConfigService } from 'app/core/ui-services/config.service'; import { DataSendService } from 'app/core/core-services/data-send.service'; import { DataStoreService } from '../../core-services/data-store.service'; import { HttpService } from 'app/core/core-services/http.service'; import { Item } from 'app/shared/models/agenda/item'; -import { ViewItem } from 'app/site/agenda/models/view-item'; import { TreeIdNode } from 'app/core/ui-services/tree.service'; -import { BaseAgendaViewModel } from 'app/site/base/base-agenda-view-model'; +import { ViewItem, ItemTitleInformation } from 'app/site/agenda/models/view-item'; +import { + BaseViewModelWithAgendaItem, + isBaseViewModelWithAgendaItem +} from 'app/site/base/base-view-model-with-agenda-item'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; import { BaseViewModel } from 'app/site/base/base-view-model'; -import { BaseAgendaContentObjectRepository } from '../base-agenda-content-object-repository'; import { Motion } from 'app/shared/models/motions/motion'; import { MotionBlock } from 'app/shared/models/motions/motion-block'; import { Topic } from 'app/shared/models/topics/topic'; import { Assignment } from 'app/shared/models/assignments/assignment'; +import { BaseIsAgendaItemContentObjectRepository } from '../base-is-agenda-item-content-object-repository'; +import { BaseHasContentObjectRepository } from '../base-has-content-object-repository'; /** - * Repository service for users + * Repository service for items * * Documentation partially provided in {@link BaseRepository} */ @Injectable({ providedIn: 'root' }) -export class ItemRepositoryService extends BaseRepository { +export class ItemRepositoryService extends BaseHasContentObjectRepository< + ViewItem, + Item, + BaseViewModelWithAgendaItem, + ItemTitleInformation +> { /** * Contructor for agenda repository. * @@ -57,21 +65,24 @@ export class ItemRepositoryService extends BaseRepository { MotionBlock ]); - this.setSortFunction((a, b) => { - // TODO: In some occasions weight will be undefined, if the user has not the correct set of permission. - // That should not be the case here. - if (a.weight && b.weight) { - return a.weight - b.weight; - } else { - return -1; - } - }); + this.setSortFunction((a, b) => a.weight - b.weight); } public getVerboseName = (plural: boolean = false) => { return this.translate.instant(plural ? 'Items' : 'Item'); }; + public getTitle = (titleInformation: ItemTitleInformation) => { + if (titleInformation.contentObject) { + return titleInformation.contentObject.getAgendaListTitle(); + } else { + const repo = this.collectionStringMapperService.getRepository( + titleInformation.contentObjectData.collection + ) as BaseIsAgendaItemContentObjectRepository; + return repo.getAgendaListTitle(titleInformation.title_information); + } + }; + /** * Creates the viewItem out of a given item * @@ -80,31 +91,16 @@ export class ItemRepositoryService extends BaseRepository { */ public createViewModel(item: Item): ViewItem { const contentObject = this.getContentObject(item); - const viewItem = new ViewItem(item, contentObject); - viewItem.getVerboseName = this.getVerboseName; - viewItem.getTitle = () => { - const numberPrefix = viewItem.itemNumber ? `${viewItem.itemNumber} · ` : ''; - - if (viewItem.contentObject) { - return numberPrefix + viewItem.contentObject.getAgendaTitleWithType(); - } else { - const repo = this.collectionStringMapperService.getRepository( - viewItem.item.content_object.collection - ) as BaseAgendaContentObjectRepository; - return numberPrefix + repo.getAgendaTitleWithType(viewItem.title_information); - } - }; - viewItem.getListTitle = viewItem.getTitle; - return viewItem; + return new ViewItem(item, contentObject); } /** - * Returns the corresponding content object to a given {@link Item} as an {@link AgendaBaseViewModel} + * Returns the corresponding content object to a given {@link Item} as an {@link BaseAgendaItemViewModel} * * @param agendaItem the target agenda Item * @returns the content object of the given item. Might be null if it was not found. */ - public getContentObject(agendaItem: Item): BaseAgendaViewModel { + public getContentObject(agendaItem: Item): BaseViewModelWithAgendaItem { const contentObject = this.viewModelStoreService.get( agendaItem.content_object.collection, agendaItem.content_object.id @@ -112,13 +108,13 @@ export class ItemRepositoryService extends BaseRepository { if (!contentObject) { return null; } - if (contentObject instanceof BaseAgendaViewModel) { - return contentObject as BaseAgendaViewModel; + if (isBaseViewModelWithAgendaItem(contentObject)) { + return contentObject; } else { throw new Error( `The content object (${agendaItem.content_object.collection}, ${ agendaItem.content_object.id - }) of item ${agendaItem.id} is not a AgendaBaseViewModel.` + }) of item ${agendaItem.id} is not a BaseAgendaItemViewModel.` ); } } diff --git a/client/src/app/core/repositories/agenda/list-of-speakers-repository.service.spec.ts b/client/src/app/core/repositories/agenda/list-of-speakers-repository.service.spec.ts new file mode 100644 index 000000000..2a6616665 --- /dev/null +++ b/client/src/app/core/repositories/agenda/list-of-speakers-repository.service.spec.ts @@ -0,0 +1,17 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; +import { ListOfSpeakersRepositoryService } from './list-of-speakers-repository.service'; + +describe('ListOfSpeakersRepositoryService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [ListOfSpeakersRepositoryService] + }); + }); + + it('should be created', inject([ListOfSpeakersRepositoryService], (service: ListOfSpeakersRepositoryService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/client/src/app/core/repositories/agenda/list-of-speakers-repository.service.ts b/client/src/app/core/repositories/agenda/list-of-speakers-repository.service.ts new file mode 100644 index 000000000..7de503a82 --- /dev/null +++ b/client/src/app/core/repositories/agenda/list-of-speakers-repository.service.ts @@ -0,0 +1,227 @@ +import { Injectable } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; +import { DataSendService } from 'app/core/core-services/data-send.service'; +import { DataStoreService } from '../../core-services/data-store.service'; +import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; +import { ViewListOfSpeakers, ListOfSpeakersTitleInformation } from 'app/site/agenda/models/view-list-of-speakers'; +import { ListOfSpeakers } from 'app/shared/models/agenda/list-of-speakers'; +import { + BaseViewModelWithListOfSpeakers, + isBaseViewModelWithListOfSpeakers +} from 'app/site/base/base-view-model-with-list-of-speakers'; +import { BaseViewModel } from 'app/site/base/base-view-model'; +import { ViewSpeaker } from 'app/site/agenda/models/view-speaker'; +import { ViewUser } from 'app/site/users/models/view-user'; +import { Identifiable } from 'app/shared/models/base/identifiable'; +import { HttpService } from 'app/core/core-services/http.service'; +import { BaseIsListOfSpeakersContentObjectRepository } from '../base-is-list-of-speakers-content-object-repository'; +import { BaseHasContentObjectRepository } from '../base-has-content-object-repository'; +import { Topic } from 'app/shared/models/topics/topic'; +import { Assignment } from 'app/shared/models/assignments/assignment'; +import { Motion } from 'app/shared/models/motions/motion'; +import { MotionBlock } from 'app/shared/models/motions/motion-block'; +import { Mediafile } from 'app/shared/models/mediafiles/mediafile'; +import { ItemRepositoryService } from './item-repository.service'; +import { User } from 'app/shared/models/users/user'; + +/** + * Repository service for lists of speakers + * + * Documentation partially provided in {@link BaseRepository} + */ +@Injectable({ + providedIn: 'root' +}) +export class ListOfSpeakersRepositoryService extends BaseHasContentObjectRepository< + ViewListOfSpeakers, + ListOfSpeakers, + BaseViewModelWithListOfSpeakers, + ListOfSpeakersTitleInformation +> { + /** + * Contructor for agenda repository. + * + * @param DS The DataStore + * @param httpService OpenSlides own HttpService + * @param mapperService OpenSlides mapping service for collection strings + * @param config Read config variables + * @param dataSend send models to the server + * @param treeService sort the data according to weight and parents + */ + public constructor( + DS: DataStoreService, + dataSend: DataSendService, + mapperService: CollectionStringMapperService, + viewModelStoreService: ViewModelStoreService, + translate: TranslateService, + private httpService: HttpService, + private itemRepo: ItemRepositoryService + ) { + super(DS, dataSend, mapperService, viewModelStoreService, translate, ListOfSpeakers, [ + Topic, + Assignment, + Motion, + MotionBlock, + Mediafile, + User + ]); + } + + public getVerboseName = (plural: boolean = false) => { + return this.translate.instant(plural ? 'Lists of speakers' : 'List of speakers'); + }; + + public getTitle = (titleInformation: ListOfSpeakersTitleInformation) => { + if (titleInformation.contentObject) { + return titleInformation.contentObject.getListOfSpeakersTitle(); + } else { + const repo = this.collectionStringMapperService.getRepository( + titleInformation.contentObjectData.collection + ) as BaseIsListOfSpeakersContentObjectRepository; + + // Try to get the agenda item for this to get the item number + // TODO: This can be resolved with #4738 + const item = this.itemRepo.findByContentObject(titleInformation.contentObjectData); + if (item) { + (titleInformation.title_information).agenda_item_number = item.itemNumber; + } + + return repo.getListOfSpeakersTitle(titleInformation.title_information); + } + }; + + /** + * Creates the viewListOfSpeakers out of a given list of speakers + * + * @param listOfSpeakers the list fo speakers that should be converted to view item + * @returns a new view list fo speakers + */ + public createViewModel(listOfSpeakers: ListOfSpeakers): ViewListOfSpeakers { + const contentObject = this.getContentObject(listOfSpeakers); + const speakers = this.getSpeakers(listOfSpeakers); + return new ViewListOfSpeakers(listOfSpeakers, speakers, contentObject); + } + + private getSpeakers(listOfSpeakers: ListOfSpeakers): ViewSpeaker[] { + return listOfSpeakers.speakers.map(speaker => { + const user = this.viewModelStoreService.get(ViewUser, speaker.user_id); + return new ViewSpeaker(speaker, user); + }); + } + + /** + * Returns the corresponding content object to a given {@link ListOfSpeakers} as an {@link BaseListOfSpeakersViewModel} + * + * @param listOfSpeakers the target list fo speakers + * @returns the content object of the given list of sepakers. Might be null if it was not found. + */ + public getContentObject(listOfSpeakers: ListOfSpeakers): BaseViewModelWithListOfSpeakers { + const contentObject = this.viewModelStoreService.get( + listOfSpeakers.content_object.collection, + listOfSpeakers.content_object.id + ); + if (!contentObject) { + return null; + } + if (isBaseViewModelWithListOfSpeakers(contentObject)) { + return contentObject; + } else { + throw new Error( + `The content object (${listOfSpeakers.content_object.collection}, ${ + listOfSpeakers.content_object.id + }) of list of speakers ${listOfSpeakers.id} is not a BaseListOfSpeakersViewModel.` + ); + } + } + + /** + * Add a new speaker to a list of speakers. + * Sends the users id to the server + * + * @param userId {@link User} id of the new speaker + * @param listOfSpeakers the target agenda item + */ + public async createSpeaker(listOfSpeakers: ViewListOfSpeakers, userId: number): Promise { + const restUrl = this.getRestUrl(listOfSpeakers.id, 'manage_speaker'); + return await this.httpService.post(restUrl, { user: userId }); + } + + /** + * Removes the given speaker for the list of speakers + * + * @param listOfSpeakers the target list of speakers + * @param speakerId (otional) the speakers id. If no id is given, the speaker with the + * current operator is removed. + */ + public async delete(listOfSpeakers: ViewListOfSpeakers, speakerId?: number): Promise { + const restUrl = this.getRestUrl(listOfSpeakers.id, 'manage_speaker'); + await this.httpService.delete(restUrl, speakerId ? { speaker: speakerId } : null); + } + + /** + * Deletes all speakers of the given list of speakers. + * + * @param listOfSpeakers the target list of speakers + */ + public async deleteAllSpeakers(listOfSpeakers: ViewListOfSpeakers): Promise { + const restUrl = this.getRestUrl(listOfSpeakers.id, 'manage_speaker'); + await this.httpService.delete(restUrl, { speaker: listOfSpeakers.speakers.map(speaker => speaker.id) }); + } + + /** + * Posts an (manually) sorted speaker list to the server + * + * @param speakerIds array of speaker id numbers + * @param Item the target agenda item + */ + public async sortSpeakers(listOfSpeakers: ViewListOfSpeakers, speakerIds: number[]): Promise { + const restUrl = this.getRestUrl(listOfSpeakers.id, 'sort_speakers'); + await this.httpService.post(restUrl, { speakers: speakerIds }); + } + + /** + * Marks all speakers for a given user + * + * @param userId {@link User} id of the user + * @param marked determine if the user should be marked or not + * @param listOfSpeakers the target list of speakers + */ + public async markSpeaker(listOfSpeakers: ViewListOfSpeakers, speaker: ViewSpeaker, marked: boolean): Promise { + const restUrl = this.getRestUrl(listOfSpeakers.id, 'manage_speaker'); + await this.httpService.patch(restUrl, { user: speaker.user.id, marked: marked }); + } + + /** + * Stops the current speaker + * + * @param listOfSpeakers the target list of speakers + */ + public async stopCurrentSpeaker(listOfSpeakers: ViewListOfSpeakers): Promise { + const restUrl = this.getRestUrl(listOfSpeakers.id, 'speak'); + await this.httpService.delete(restUrl); + } + + /** + * Sets the given speaker id to speak + * + * @param speakerId the speakers id + * @param listOfSpeakers the target list of speakers + */ + public async startSpeaker(listOfSpeakers: ViewListOfSpeakers, speaker: ViewSpeaker): Promise { + const restUrl = this.getRestUrl(listOfSpeakers.id, 'speak'); + await this.httpService.put(restUrl, { speaker: speaker.id }); + } + + /** + * Helper function get the url to the speaker rest address + * + * @param listOfSpeakersId id of the list of speakers + * @param method the desired speaker action + */ + private getRestUrl(listOfSpeakersId: number, method: 'manage_speaker' | 'sort_speakers' | 'speak'): string { + return `/rest/agenda/list-of-speakers/${listOfSpeakersId}/${method}/`; + } +} diff --git a/client/src/app/core/repositories/agenda/speaker-repository.service.spec.ts b/client/src/app/core/repositories/agenda/speaker-repository.service.spec.ts deleted file mode 100644 index d458f1a01..000000000 --- a/client/src/app/core/repositories/agenda/speaker-repository.service.spec.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { SpeakerRepositoryService } from './speaker-repository.service'; -import { E2EImportsModule } from 'e2e-imports.module'; - -describe('SpeakerRepositoryService', () => { - beforeEach(() => - TestBed.configureTestingModule({ - imports: [E2EImportsModule] - }) - ); - - it('should be created', () => { - const service: SpeakerRepositoryService = TestBed.get(SpeakerRepositoryService); - expect(service).toBeTruthy(); - }); -}); diff --git a/client/src/app/core/repositories/agenda/speaker-repository.service.ts b/client/src/app/core/repositories/agenda/speaker-repository.service.ts deleted file mode 100644 index 96ae27a2d..000000000 --- a/client/src/app/core/repositories/agenda/speaker-repository.service.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { Injectable } from '@angular/core'; - -import { TranslateService } from '@ngx-translate/core'; - -import { HttpService } from 'app/core/core-services/http.service'; -import { Identifiable } from 'app/shared/models/base/identifiable'; -import { Item } from 'app/shared/models/agenda/item'; -import { Speaker } from 'app/shared/models/agenda/speaker'; -import { ViewSpeaker } from 'app/site/agenda/models/view-speaker'; -import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; -import { ViewItem } from 'app/site/agenda/models/view-item'; -import { ViewUser } from 'app/site/users/models/view-user'; - -/** - * Define what actions might occur on speaker lists - */ -type SpeakerAction = 'manage_speaker' | 'sort_speakers' | 'speak'; - -/** - * Repository for speakers. - * - * Since speakers are no base models, normal repository methods do not apply here. - */ -@Injectable({ - providedIn: 'root' -}) -export class SpeakerRepositoryService { - /** - * Constructor - * - * @param viewModelStoreService To get the view users - * @param httpService make custom requests - * @param translate translate - */ - public constructor( - private viewModelStoreService: ViewModelStoreService, - private httpService: HttpService, - private translate: TranslateService - ) {} - - /** - * Add a new speaker to an agenda item. - * Sends the users ID to the server - * Might need another repo - * - * @param speakerId {@link User} id of the new speaker - * @param item the target agenda item - */ - public async create(speakerId: number, item: ViewItem): Promise { - const restUrl = this.getRestUrl(item.id, 'manage_speaker'); - await this.httpService.post(restUrl, { user: speakerId }); - } - - /** - * Removes the given speaker for the agenda item - * - * @param item the target agenda item - * @param speakerId (otional) the speakers id. If no id is given, the current operator - * is removed. - */ - public async delete(item: ViewItem, speakerId?: number): Promise { - const restUrl = this.getRestUrl(item.id, 'manage_speaker'); - await this.httpService.delete(restUrl, speakerId ? { speaker: speakerId } : null); - } - - /** - * Creates and returns a new ViewSpeaker out of a speaker model - * - * @param speaker speaker to transform - * @return a new ViewSpeaker - */ - private createViewModel(speaker: Speaker): ViewSpeaker { - const user = this.viewModelStoreService.get(ViewUser, speaker.user_id); - const viewSpeaker = new ViewSpeaker(speaker, user); - viewSpeaker.getVerboseName = (plural: boolean = false) => { - return this.translate.instant(plural ? 'Speakers' : 'Speaker'); - }; - return viewSpeaker; - } - - /** - * Generate viewSpeaker objects from a given agenda Item - * - * @param item agenda Item holding speakers - * @returns the list of view speakers corresponding to the given item - */ - public createSpeakerList(item: Item): ViewSpeaker[] { - let viewSpeakers = []; - const speakers = item.speakers; - if (speakers && speakers.length > 0) { - viewSpeakers = speakers.map(speaker => { - return this.createViewModel(speaker); - }); - } - // sort speakers by their weight - viewSpeakers = viewSpeakers.sort((a, b) => a.weight - b.weight); - return viewSpeakers; - } - - /** - * Deletes all speakers of the given agenda item. - * - * @param item the target agenda item - */ - public async deleteAllSpeakers(item: ViewItem): Promise { - const restUrl = this.getRestUrl(item.id, 'manage_speaker'); - await this.httpService.delete(restUrl, { speaker: item.speakers.map(speaker => speaker.id) }); - } - - /** - * Posts an (manually) sorted speaker list to the server - * - * @param speakerIds array of speaker id numbers - * @param Item the target agenda item - */ - public async sortSpeakers(speakerIds: number[], item: Item): Promise { - const restUrl = this.getRestUrl(item.id, 'sort_speakers'); - await this.httpService.post(restUrl, { speakers: speakerIds }); - } - - /** - * Marks the current speaker - * - * @param speakerId {@link User} id of the new speaker - * @param mark determine if the user was marked or not - * @param item the target agenda item - */ - public async markSpeaker(speakerId: number, mark: boolean, item: ViewItem): Promise { - const restUrl = this.getRestUrl(item.id, 'manage_speaker'); - await this.httpService.patch(restUrl, { user: speakerId, marked: mark }); - } - - /** - * Stops the current speaker - * - * @param item the target agenda item - */ - public async stopCurrentSpeaker(item: ViewItem): Promise { - const restUrl = this.getRestUrl(item.id, 'speak'); - await this.httpService.delete(restUrl); - } - - /** - * Sets the given speaker ID to Speak - * - * @param speakerId the speakers id - * @param item the target agenda item - */ - public async startSpeaker(speakerId: number, item: ViewItem): Promise { - const restUrl = this.getRestUrl(item.id, 'speak'); - await this.httpService.put(restUrl, { speaker: speakerId }); - } - - /** - * Helper function get the url to the speaker rest address - * - * @param itemId id of the agenda item - * @param suffix the desired speaker action - */ - private getRestUrl(itemId: number, suffix: SpeakerAction): string { - return `/rest/agenda/item/${itemId}/${suffix}/`; - } -} diff --git a/client/src/app/core/repositories/assignments/assignment-repository.service.ts b/client/src/app/core/repositories/assignments/assignment-repository.service.ts index c701e93fa..5126d62c5 100644 --- a/client/src/app/core/repositories/assignments/assignment-repository.service.ts +++ b/client/src/app/core/repositories/assignments/assignment-repository.service.ts @@ -4,16 +4,14 @@ import { TranslateService } from '@ngx-translate/core'; import { Assignment } from 'app/shared/models/assignments/assignment'; import { AssignmentRelatedUser } from 'app/shared/models/assignments/assignment-related-user'; -import { BaseAgendaContentObjectRepository } from '../base-agenda-content-object-repository'; import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; import { DataSendService } from 'app/core/core-services/data-send.service'; import { DataStoreService } from '../../core-services/data-store.service'; import { HttpService } from 'app/core/core-services/http.service'; -import { Item } from 'app/shared/models/agenda/item'; import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll'; import { Tag } from 'app/shared/models/core/tag'; import { User } from 'app/shared/models/users/user'; -import { ViewAssignment } from 'app/site/assignments/models/view-assignment'; +import { ViewAssignment, AssignmentTitleInformation } from 'app/site/assignments/models/view-assignment'; import { ViewItem } from 'app/site/agenda/models/view-item'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; import { ViewTag } from 'app/site/tags/models/view-tag'; @@ -23,6 +21,8 @@ import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment- import { ViewAssignmentPollOption } from 'app/site/assignments/models/view-assignment-poll-option'; import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile'; import { Mediafile } from 'app/shared/models/mediafiles/mediafile'; +import { ViewListOfSpeakers } from 'app/site/agenda/models/view-list-of-speakers'; +import { BaseIsAgendaItemAndListOfSpeakersContentObjectRepository } from '../base-is-agenda-item-and-list-of-speakers-content-object-repository'; /** * Repository Service for Assignments. @@ -32,7 +32,11 @@ import { Mediafile } from 'app/shared/models/mediafiles/mediafile'; @Injectable({ providedIn: 'root' }) -export class AssignmentRepositoryService extends BaseAgendaContentObjectRepository { +export class AssignmentRepositoryService extends BaseIsAgendaItemAndListOfSpeakersContentObjectRepository< + ViewAssignment, + Assignment, + AssignmentTitleInformation +> { private readonly restPath = '/rest/assignments/assignment/'; private readonly restPollPath = '/rest/assignments/poll/'; private readonly candidatureOtherPath = '/candidature_other/'; @@ -58,15 +62,11 @@ export class AssignmentRepositoryService extends BaseAgendaContentObjectReposito protected translate: TranslateService, private httpService: HttpService ) { - super(DS, dataSend, mapperService, viewModelStoreService, translate, Assignment, [User, Item, Tag, Mediafile]); + super(DS, dataSend, mapperService, viewModelStoreService, translate, Assignment, [User, Tag, Mediafile]); } - public getAgendaTitle = (assignment: Partial | Partial) => { - return assignment.title; - }; - - public getAgendaTitleWithType = (assignment: Partial | Partial) => { - return assignment.title + ' (' + this.getVerboseName() + ')'; + public getTitle = (titleInformation: AssignmentTitleInformation) => { + return titleInformation.title; }; public getVerboseName = (plural: boolean = false) => { @@ -74,24 +74,22 @@ export class AssignmentRepositoryService extends BaseAgendaContentObjectReposito }; public createViewModel(assignment: Assignment): ViewAssignment { - const agendaItem = this.viewModelStoreService.get(ViewItem, assignment.agenda_item_id); + const item = this.viewModelStoreService.get(ViewItem, assignment.agenda_item_id); + const listOfSpeakers = this.viewModelStoreService.get(ViewListOfSpeakers, assignment.list_of_speakers_id); const tags = this.viewModelStoreService.getMany(ViewTag, assignment.tags_id); const attachments = this.viewModelStoreService.getMany(ViewMediafile, assignment.attachments_id); const assignmentRelatedUsers = this.createViewAssignmentRelatedUsers(assignment.assignment_related_users); const assignmentPolls = this.createViewAssignmentPolls(assignment.polls); - const viewAssignment = new ViewAssignment( + return new ViewAssignment( assignment, assignmentRelatedUsers, assignmentPolls, - agendaItem, + item, + listOfSpeakers, tags, attachments ); - viewAssignment.getVerboseName = this.getVerboseName; - viewAssignment.getAgendaTitle = () => this.getAgendaTitle(viewAssignment); - viewAssignment.getAgendaTitleWithType = () => this.getAgendaTitleWithType(viewAssignment); - return viewAssignment; } private createViewAssignmentRelatedUsers( diff --git a/client/src/app/core/repositories/base-agenda-content-object-repository.ts b/client/src/app/core/repositories/base-agenda-content-object-repository.ts deleted file mode 100644 index dea961274..000000000 --- a/client/src/app/core/repositories/base-agenda-content-object-repository.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { TranslateService } from '@ngx-translate/core'; - -import { BaseViewModel } from '../../site/base/base-view-model'; -import { BaseModel, ModelConstructor } from '../../shared/models/base/base-model'; -import { CollectionStringMapperService } from '../core-services/collection-string-mapper.service'; -import { DataSendService } from '../core-services/data-send.service'; -import { DataStoreService } from '../core-services/data-store.service'; -import { ViewModelStoreService } from '../core-services/view-model-store.service'; -import { BaseRepository } from './base-repository'; - -export function isBaseAgendaContentObjectRepository(obj: any): obj is BaseAgendaContentObjectRepository { - const repo = obj as BaseAgendaContentObjectRepository; - return !!obj && repo.getAgendaTitle !== undefined && repo.getAgendaTitleWithType !== undefined; -} - -export abstract class BaseAgendaContentObjectRepository< - V extends BaseViewModel, - M extends BaseModel -> extends BaseRepository { - public abstract getAgendaTitle: (model: Partial | Partial) => string; - public abstract getAgendaTitleWithType: (model: Partial | Partial) => string; - - /** - */ - public constructor( - DS: DataStoreService, - dataSend: DataSendService, - collectionStringMapperService: CollectionStringMapperService, - viewModelStoreService: ViewModelStoreService, - translate: TranslateService, - baseModelCtor: ModelConstructor, - depsModelCtors?: ModelConstructor[] - ) { - super( - DS, - dataSend, - collectionStringMapperService, - viewModelStoreService, - translate, - baseModelCtor, - depsModelCtors - ); - } -} diff --git a/client/src/app/core/repositories/base-has-content-object-repository.ts b/client/src/app/core/repositories/base-has-content-object-repository.ts new file mode 100644 index 000000000..9e36e6b7d --- /dev/null +++ b/client/src/app/core/repositories/base-has-content-object-repository.ts @@ -0,0 +1,75 @@ +import { BaseRepository } from './base-repository'; +import { BaseModelWithContentObject } from 'app/shared/models/base/base-model-with-content-object'; +import { BaseViewModelWithContentObject } from 'app/site/base/base-view-model-with-content-object'; +import { ContentObject } from 'app/shared/models/base/content-object'; +import { BaseViewModel, TitleInformation } from 'app/site/base/base-view-model'; + +/** + * A base repository for objects that *have* content objects, e.g. items and lists of speakers. + * Ensures that these objects must have a content objects via generics and adds a way of + * efficient querying objects by their content objects: + * If one wants to have the object for "motions/motion:1", call `findByContentObject` with + * these information represented as a {@link ContentObject}. + */ +export abstract class BaseHasContentObjectRepository< + V extends BaseViewModelWithContentObject & T, + M extends BaseModelWithContentObject, + C extends BaseViewModel, + T extends TitleInformation +> extends BaseRepository { + protected contentObjectMapping: { + [collection: string]: { + [id: number]: V; + }; + } = {}; + + /** + * Returns the object with has the given content object as the content object. + * + * @param contentObject The content object to query. + */ + public findByContentObject(contentObject: ContentObject): V | null { + if ( + this.contentObjectMapping[contentObject.collection] && + this.contentObjectMapping[contentObject.collection][contentObject.id] + ) { + return this.contentObjectMapping[contentObject.collection][contentObject.id]; + } + } + + /** + * @override + */ + public changedModels(ids: number[]): void { + ids.forEach(id => { + const v = this.createViewModelWithTitles(this.DS.get(this.collectionString, id)); + this.viewModelStore[id] = v; + + const contentObject = v.contentObjectData; + if (!this.contentObjectMapping[contentObject.collection]) { + this.contentObjectMapping[contentObject.collection] = {}; + } + this.contentObjectMapping[contentObject.collection][contentObject.id] = v; + this.updateViewModelObservable(id); + }); + this.updateViewModelListObservable(); + } + + /** + * @override + */ + public deleteModels(ids: number[]): void { + ids.forEach(id => { + const v = this.viewModelStore[id]; + if (v) { + const contentObject = v.contentObjectData; + if (this.contentObjectMapping[contentObject.collection]) { + delete this.contentObjectMapping[contentObject.collection][contentObject.id]; + } + } + delete this.viewModelStore[id]; + this.updateViewModelObservable(id); + }); + this.updateViewModelListObservable(); + } +} diff --git a/client/src/app/core/repositories/base-is-agenda-item-and-list-of-speakers-content-object-repository.ts b/client/src/app/core/repositories/base-is-agenda-item-and-list-of-speakers-content-object-repository.ts new file mode 100644 index 000000000..a460680d2 --- /dev/null +++ b/client/src/app/core/repositories/base-is-agenda-item-and-list-of-speakers-content-object-repository.ts @@ -0,0 +1,98 @@ +import { TranslateService } from '@ngx-translate/core'; + +import { BaseModel, ModelConstructor } from '../../shared/models/base/base-model'; +import { BaseRepository } from './base-repository'; +import { + isBaseIsAgendaItemContentObjectRepository, + IBaseIsAgendaItemContentObjectRepository +} from './base-is-agenda-item-content-object-repository'; +import { + isBaseIsListOfSpeakersContentObjectRepository, + IBaseIsListOfSpeakersContentObjectRepository +} from './base-is-list-of-speakers-content-object-repository'; +import { DataStoreService } from '../core-services/data-store.service'; +import { DataSendService } from '../core-services/data-send.service'; +import { ViewModelStoreService } from '../core-services/view-model-store.service'; +import { Item } from 'app/shared/models/agenda/item'; +import { ListOfSpeakers } from 'app/shared/models/agenda/list-of-speakers'; +import { CollectionStringMapperService } from '../core-services/collection-string-mapper.service'; +import { + TitleInformationWithAgendaItem, + IBaseViewModelWithAgendaItem +} from 'app/site/base/base-view-model-with-agenda-item'; +import { BaseProjectableViewModel } from 'app/site/base/base-projectable-view-model'; +import { IBaseViewModelWithListOfSpeakers } from 'app/site/base/base-view-model-with-list-of-speakers'; + +export function isBaseIsAgendaItemAndListOfSpeakersContentObjectRepository( + obj: any +): obj is BaseIsAgendaItemAndListOfSpeakersContentObjectRepository { + return ( + !!obj && isBaseIsAgendaItemContentObjectRepository(obj) && isBaseIsListOfSpeakersContentObjectRepository(obj) + ); +} + +/** + * The base repository for objects with an agenda item and a list of speakers. This is some kind of + * multi-inheritance by implementing both inherit classes again... + */ +export abstract class BaseIsAgendaItemAndListOfSpeakersContentObjectRepository< + V extends BaseProjectableViewModel & IBaseViewModelWithAgendaItem & IBaseViewModelWithListOfSpeakers & T, + M extends BaseModel, + T extends TitleInformationWithAgendaItem +> extends BaseRepository + implements + IBaseIsAgendaItemContentObjectRepository, + IBaseIsListOfSpeakersContentObjectRepository { + public constructor( + DS: DataStoreService, + dataSend: DataSendService, + collectionStringMapperService: CollectionStringMapperService, + viewModelStoreService: ViewModelStoreService, + translate: TranslateService, + baseModelCtor: ModelConstructor, + depsModelCtors?: ModelConstructor[] + ) { + super( + DS, + dataSend, + collectionStringMapperService, + viewModelStoreService, + translate, + baseModelCtor, + depsModelCtors + ); + if (!this.depsModelCtors) { + this.depsModelCtors = []; + } + this.depsModelCtors.push(Item); + this.depsModelCtors.push(ListOfSpeakers); + } + + public getAgendaListTitle(titleInformation: T): string { + // Return the agenda title with the model's verbose name appended + const numberPrefix = titleInformation.agenda_item_number ? `${titleInformation.agenda_item_number} · ` : ''; + return numberPrefix + this.getTitle(titleInformation) + ' (' + this.getVerboseName() + ')'; + } + + public getAgendaSlideTitle(titleInformation: T): string { + const numberPrefix = titleInformation.agenda_item_number ? `${titleInformation.agenda_item_number} · ` : ''; + return numberPrefix + this.getTitle(titleInformation); + } + + public getListOfSpeakersTitle = (titleInformation: T) => { + return this.getAgendaListTitle(titleInformation); + }; + + public getListOfSpeakersSlideTitle = (titleInformation: T) => { + return this.getAgendaSlideTitle(titleInformation); + }; + + protected createViewModelWithTitles(model: M): V { + const viewModel = super.createViewModelWithTitles(model); + viewModel.getAgendaListTitle = () => this.getAgendaListTitle(viewModel); + viewModel.getAgendaSlideTitle = () => this.getAgendaSlideTitle(viewModel); + viewModel.getListOfSpeakersTitle = () => this.getListOfSpeakersTitle(viewModel); + viewModel.getListOfSpeakersSlideTitle = () => this.getListOfSpeakersSlideTitle(viewModel); + return viewModel; + } +} diff --git a/client/src/app/core/repositories/base-is-agenda-item-content-object-repository.ts b/client/src/app/core/repositories/base-is-agenda-item-content-object-repository.ts new file mode 100644 index 000000000..b504b6ea3 --- /dev/null +++ b/client/src/app/core/repositories/base-is-agenda-item-content-object-repository.ts @@ -0,0 +1,92 @@ +import { TranslateService } from '@ngx-translate/core'; + +import { BaseModel, ModelConstructor } from '../../shared/models/base/base-model'; +import { CollectionStringMapperService } from '../core-services/collection-string-mapper.service'; +import { DataSendService } from '../core-services/data-send.service'; +import { BaseRepository } from './base-repository'; +import { Item } from 'app/shared/models/agenda/item'; +import { DataStoreService } from '../core-services/data-store.service'; +import { ViewModelStoreService } from '../core-services/view-model-store.service'; +import { + TitleInformationWithAgendaItem, + BaseViewModelWithAgendaItem +} from 'app/site/base/base-view-model-with-agenda-item'; + +export function isBaseIsAgendaItemContentObjectRepository( + obj: any +): obj is BaseIsAgendaItemContentObjectRepository { + const repo = obj as BaseIsAgendaItemContentObjectRepository; + return !!obj && repo.getAgendaSlideTitle !== undefined && repo.getAgendaListTitle !== undefined; +} + +/** + * Describes a base repository which objects do have an assigned agenda item. + */ +export interface IBaseIsAgendaItemContentObjectRepository< + V extends BaseViewModelWithAgendaItem & T, + M extends BaseModel, + T extends TitleInformationWithAgendaItem +> extends BaseRepository { + getAgendaListTitle: (titleInformation: T) => string; + getAgendaSlideTitle: (titleInformation: T) => string; +} + +/** + * The base repository for objects with an agenda item. + */ +export abstract class BaseIsAgendaItemContentObjectRepository< + V extends BaseViewModelWithAgendaItem & T, + M extends BaseModel, + T extends TitleInformationWithAgendaItem +> extends BaseRepository implements IBaseIsAgendaItemContentObjectRepository { + public constructor( + DS: DataStoreService, + dataSend: DataSendService, + collectionStringMapperService: CollectionStringMapperService, + viewModelStoreService: ViewModelStoreService, + translate: TranslateService, + baseModelCtor: ModelConstructor, + depsModelCtors?: ModelConstructor[] + ) { + super( + DS, + dataSend, + collectionStringMapperService, + viewModelStoreService, + translate, + baseModelCtor, + depsModelCtors + ); + if (!this.depsModelCtors) { + this.depsModelCtors = []; + } + this.depsModelCtors.push(Item); + } + + /** + * @returns the agenda title for the agenda item list. Should + * be ` · (<type>)`. E.g. `7 · the is an election (Election)`. + */ + public getAgendaListTitle(titleInformation: T): string { + // Return the agenda title with the model's verbose name appended + const numberPrefix = titleInformation.agenda_item_number ? `${titleInformation.agenda_item_number} · ` : ''; + return numberPrefix + this.getTitle(titleInformation) + ' (' + this.getVerboseName() + ')'; + } + + /** + * @returns the agenda title for the item slides + */ + public getAgendaSlideTitle(titleInformation: T): string { + return this.getTitle(titleInformation); + } + + /** + * Adds the agenda titles to the viewmodel. + */ + protected createViewModelWithTitles(model: M): V { + const viewModel = super.createViewModelWithTitles(model); + viewModel.getAgendaListTitle = () => this.getAgendaListTitle(viewModel); + viewModel.getAgendaSlideTitle = () => this.getAgendaSlideTitle(viewModel); + return viewModel; + } +} diff --git a/client/src/app/core/repositories/base-is-list-of-speakers-content-object-repository.ts b/client/src/app/core/repositories/base-is-list-of-speakers-content-object-repository.ts new file mode 100644 index 000000000..acc19083d --- /dev/null +++ b/client/src/app/core/repositories/base-is-list-of-speakers-content-object-repository.ts @@ -0,0 +1,81 @@ +import { TranslateService } from '@ngx-translate/core'; + +import { TitleInformation } from '../../site/base/base-view-model'; +import { BaseModel, ModelConstructor } from '../../shared/models/base/base-model'; +import { BaseRepository } from './base-repository'; +import { DataStoreService } from '../core-services/data-store.service'; +import { DataSendService } from '../core-services/data-send.service'; +import { CollectionStringMapperService } from '../core-services/collection-string-mapper.service'; +import { ViewModelStoreService } from '../core-services/view-model-store.service'; +import { ListOfSpeakers } from 'app/shared/models/agenda/list-of-speakers'; +import { BaseViewModelWithListOfSpeakers } from 'app/site/base/base-view-model-with-list-of-speakers'; + +export function isBaseIsListOfSpeakersContentObjectRepository( + obj: any +): obj is BaseIsListOfSpeakersContentObjectRepository<any, any, any> { + const repo = obj as BaseIsListOfSpeakersContentObjectRepository<any, any, any>; + return !!obj && repo.getListOfSpeakersTitle !== undefined && repo.getListOfSpeakersSlideTitle !== undefined; +} + +/** + * Describes a base repository which objects have a list of speakers assigned. + */ +export interface IBaseIsListOfSpeakersContentObjectRepository< + V extends BaseViewModelWithListOfSpeakers & T, + M extends BaseModel, + T extends TitleInformation +> extends BaseRepository<V, M, T> { + getListOfSpeakersTitle: (titleInformation: T) => string; + getListOfSpeakersSlideTitle: (titleInformation: T) => string; +} + +/** + * The base repository for objects with a list of speakers. + */ +export abstract class BaseIsListOfSpeakersContentObjectRepository< + V extends BaseViewModelWithListOfSpeakers & T, + M extends BaseModel, + T extends TitleInformation +> extends BaseRepository<V, M, T> implements IBaseIsListOfSpeakersContentObjectRepository<V, M, T> { + public constructor( + DS: DataStoreService, + dataSend: DataSendService, + collectionStringMapperService: CollectionStringMapperService, + viewModelStoreService: ViewModelStoreService, + translate: TranslateService, + baseModelCtor: ModelConstructor<M>, + depsModelCtors?: ModelConstructor<BaseModel>[] + ) { + super( + DS, + dataSend, + collectionStringMapperService, + viewModelStoreService, + translate, + baseModelCtor, + depsModelCtors + ); + if (!this.depsModelCtors) { + this.depsModelCtors = []; + } + this.depsModelCtors.push(ListOfSpeakers); + } + + public getListOfSpeakersTitle(titleInformation: T): string { + return this.getTitle(titleInformation) + ' (' + this.getVerboseName() + ')'; + } + + public getListOfSpeakersSlideTitle(titleInformation: T): string { + return this.getTitle(titleInformation); + } + + /** + * Adds the list of speakers titles to the view model + */ + protected createViewModelWithTitles(model: M): V { + const viewModel = super.createViewModelWithTitles(model); + viewModel.getListOfSpeakersTitle = () => this.getListOfSpeakersTitle(viewModel); + viewModel.getListOfSpeakersSlideTitle = () => this.getListOfSpeakersSlideTitle(viewModel); + return viewModel; + } +} diff --git a/client/src/app/core/repositories/base-repository.ts b/client/src/app/core/repositories/base-repository.ts index bc9133a9b..6624da3a1 100644 --- a/client/src/app/core/repositories/base-repository.ts +++ b/client/src/app/core/repositories/base-repository.ts @@ -1,19 +1,19 @@ import { BehaviorSubject, Observable, Subject } from 'rxjs'; +import { auditTime } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; -import { BaseViewModel } from '../../site/base/base-view-model'; +import { BaseViewModel, TitleInformation } from '../../site/base/base-view-model'; import { BaseModel, ModelConstructor } from '../../shared/models/base/base-model'; import { CollectionStringMapperService } from '../core-services/collection-string-mapper.service'; import { DataSendService } from '../core-services/data-send.service'; import { DataStoreService } from '../core-services/data-store.service'; import { Identifiable } from '../../shared/models/base/identifiable'; -import { auditTime } from 'rxjs/operators'; import { ViewModelStoreService } from '../core-services/view-model-store.service'; import { OnAfterAppsLoaded } from '../onAfterAppsLoaded'; import { Collection } from 'app/shared/models/base/collection'; import { CollectionIds } from '../core-services/data-store-update-manager.service'; -export abstract class BaseRepository<V extends BaseViewModel, M extends BaseModel> +export abstract class BaseRepository<V extends BaseViewModel & T, M extends BaseModel, T extends TitleInformation> implements OnAfterAppsLoaded, Collection { /** * Stores all the viewModel in an object @@ -70,6 +70,7 @@ export abstract class BaseRepository<V extends BaseViewModel, M extends BaseMode } public abstract getVerboseName: (plural?: boolean) => string; + public abstract getTitle: (titleInformation: T) => string; /** * Construction routine for the base repository @@ -114,7 +115,7 @@ export abstract class BaseRepository<V extends BaseViewModel, M extends BaseMode protected loadInitialFromDS(): void { // Populate the local viewModelStore with ViewModel Objects. this.DS.getAll(this.baseModelCtor).forEach((model: M) => { - this.viewModelStore[model.id] = this.createViewModel(model); + this.viewModelStore[model.id] = this.createViewModelWithTitles(model); }); // Update the list and then all models on their own @@ -145,7 +146,7 @@ export abstract class BaseRepository<V extends BaseViewModel, M extends BaseMode */ public changedModels(ids: number[]): void { ids.forEach(id => { - this.viewModelStore[id] = this.createViewModel(this.DS.get(this.collectionString, id)); + this.viewModelStore[id] = this.createViewModelWithTitles(this.DS.get(this.collectionString, id)); this.updateViewModelObservable(id); }); this.updateViewModelListObservable(); @@ -188,6 +189,10 @@ export abstract class BaseRepository<V extends BaseViewModel, M extends BaseMode } } + public getListTitle: (titleInformation: T) => string = (titleInformation: T) => { + return this.getTitle(titleInformation); + }; + /** * Saves the (full) update to an existing model. So called "update"-function * Provides a default procedure, but can be overwritten if required @@ -258,6 +263,18 @@ export abstract class BaseRepository<V extends BaseViewModel, M extends BaseMode */ protected abstract createViewModel(model: M): V; + /** + * After creating a view model, all functions for models form the repo + * are assigned to the new view model. + */ + protected createViewModelWithTitles(model: M): V { + const viewModel = this.createViewModel(model); + viewModel.getTitle = () => this.getTitle(viewModel); + viewModel.getListTitle = () => this.getListTitle(viewModel); + viewModel.getVerboseName = this.getVerboseName; + return viewModel; + } + /** * Clears the repository. */ diff --git a/client/src/app/core/repositories/config/config-repository.service.ts b/client/src/app/core/repositories/config/config-repository.service.ts index 9bf92ddeb..8edce1d52 100644 --- a/client/src/app/core/repositories/config/config-repository.service.ts +++ b/client/src/app/core/repositories/config/config-repository.service.ts @@ -12,7 +12,7 @@ import { HttpService } from 'app/core/core-services/http.service'; import { Identifiable } from 'app/shared/models/base/identifiable'; import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; -import { ViewConfig } from 'app/site/config/models/view-config'; +import { ViewConfig, ConfigTitleInformation } from 'app/site/config/models/view-config'; /** * Holds a single config item. @@ -77,7 +77,7 @@ export interface ConfigGroup { @Injectable({ providedIn: 'root' }) -export class ConfigRepositoryService extends BaseRepository<ViewConfig, Config> { +export class ConfigRepositoryService extends BaseRepository<ViewConfig, Config, ConfigTitleInformation> { /** * Own store for config groups. */ @@ -130,14 +130,16 @@ export class ConfigRepositoryService extends BaseRepository<ViewConfig, Config> return this.translate.instant(plural ? 'Configs' : 'Config'); }; + public getTitle = (titleInformation: ConfigTitleInformation) => { + return titleInformation.key; + }; + /** * Creates a new ViewConfig of a given Config object * @param config */ public createViewModel(config: Config): ViewConfig { - const viewConfig = new ViewConfig(config); - viewConfig.getVerboseName = this.getVerboseName; - return viewConfig; + return new ViewConfig(config); } /** diff --git a/client/src/app/core/repositories/history/history-repository.service.ts b/client/src/app/core/repositories/history/history-repository.service.ts index c552ed9f0..c15835052 100644 --- a/client/src/app/core/repositories/history/history-repository.service.ts +++ b/client/src/app/core/repositories/history/history-repository.service.ts @@ -1,16 +1,17 @@ import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service'; import { DataStoreService } from 'app/core/core-services/data-store.service'; import { BaseRepository } from 'app/core/repositories/base-repository'; import { History } from 'app/shared/models/core/history'; import { Identifiable } from 'app/shared/models/base/identifiable'; import { HttpService } from 'app/core/core-services/http.service'; -import { ViewHistory, ProxyHistory } from 'app/site/history/models/view-history'; +import { ViewHistory, ProxyHistory, HistoryTitleInformation } from 'app/site/history/models/view-history'; import { TimeTravelService } from 'app/core/core-services/time-travel.service'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; import { ViewUser } from 'app/site/users/models/view-user'; -import { TranslateService } from '@ngx-translate/core'; import { DataSendService } from 'app/core/core-services/data-send.service'; /** @@ -21,7 +22,7 @@ import { DataSendService } from 'app/core/core-services/data-send.service'; @Injectable({ providedIn: 'root' }) -export class HistoryRepositoryService extends BaseRepository<ViewHistory, History> { +export class HistoryRepositoryService extends BaseRepository<ViewHistory, History, HistoryTitleInformation> { /** * Constructs the history repository * @@ -46,6 +47,10 @@ export class HistoryRepositoryService extends BaseRepository<ViewHistory, Histor return this.translate.instant(plural ? 'Histories' : 'History'); }; + public getTitle = (titleInformation: HistoryTitleInformation) => { + return titleInformation.element_id; + }; + /** * Creates a new ViewHistory objects out of a historyObject * @@ -53,9 +58,7 @@ export class HistoryRepositoryService extends BaseRepository<ViewHistory, Histor * @return a new ViewHistory object */ public createViewModel(history: History): ViewHistory { - const viewHistory = new ViewHistory(this.createProxyHistory(history)); - viewHistory.getVerboseName = this.getVerboseName; - return viewHistory; + return new ViewHistory(this.createProxyHistory(history)); } /** diff --git a/client/src/app/core/repositories/mediafiles/mediafile-repository.service.ts b/client/src/app/core/repositories/mediafiles/mediafile-repository.service.ts index bdba6265d..3bb734fb0 100644 --- a/client/src/app/core/repositories/mediafiles/mediafile-repository.service.ts +++ b/client/src/app/core/repositories/mediafiles/mediafile-repository.service.ts @@ -1,7 +1,9 @@ import { Injectable } from '@angular/core'; +import { HttpHeaders } from '@angular/common/http'; -import { BaseRepository } from '../base-repository'; -import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile'; +import { TranslateService } from '@ngx-translate/core'; + +import { ViewMediafile, MediafileTitleInformation } from 'app/site/mediafiles/models/view-mediafile'; import { Mediafile } from 'app/shared/models/mediafiles/mediafile'; import { User } from 'app/shared/models/users/user'; import { DataStoreService } from '../../core-services/data-store.service'; @@ -9,10 +11,10 @@ import { Identifiable } from 'app/shared/models/base/identifiable'; import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; import { DataSendService } from 'app/core/core-services/data-send.service'; import { HttpService } from 'app/core/core-services/http.service'; -import { HttpHeaders } from '@angular/common/http'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; import { ViewUser } from 'app/site/users/models/view-user'; -import { TranslateService } from '@ngx-translate/core'; +import { BaseIsListOfSpeakersContentObjectRepository } from '../base-is-list-of-speakers-content-object-repository'; +import { ViewListOfSpeakers } from 'app/site/agenda/models/view-list-of-speakers'; /** * Repository for MediaFiles @@ -20,7 +22,11 @@ import { TranslateService } from '@ngx-translate/core'; @Injectable({ providedIn: 'root' }) -export class MediafileRepositoryService extends BaseRepository<ViewMediafile, Mediafile> { +export class MediafileRepositoryService extends BaseIsListOfSpeakersContentObjectRepository< + ViewMediafile, + Mediafile, + MediafileTitleInformation +> { /** * Constructor for the mediafile repository * @param DS Data store @@ -40,6 +46,10 @@ export class MediafileRepositoryService extends BaseRepository<ViewMediafile, Me this.initSorting(); } + public getTitle = (titleInformation: MediafileTitleInformation) => { + return titleInformation.title; + }; + public getVerboseName = (plural: boolean = false) => { return this.translate.instant(plural ? 'Files' : 'File'); }; @@ -51,10 +61,9 @@ export class MediafileRepositoryService extends BaseRepository<ViewMediafile, Me * @returns a new mediafile ViewModel */ public createViewModel(file: Mediafile): ViewMediafile { + const listOfSpeakers = this.viewModelStoreService.get(ViewListOfSpeakers, file.list_of_speakers_id); const uploader = this.viewModelStoreService.get(ViewUser, file.uploader_id); - const viewMediafile = new ViewMediafile(file, uploader); - viewMediafile.getVerboseName = this.getVerboseName; - return viewMediafile; + return new ViewMediafile(file, listOfSpeakers, uploader); } /** diff --git a/client/src/app/core/repositories/motions/category-repository.service.ts b/client/src/app/core/repositories/motions/category-repository.service.ts index 0bcac6f3e..2e059d37f 100644 --- a/client/src/app/core/repositories/motions/category-repository.service.ts +++ b/client/src/app/core/repositories/motions/category-repository.service.ts @@ -9,7 +9,7 @@ import { ConfigService } from 'app/core/ui-services/config.service'; import { DataSendService } from '../../core-services/data-send.service'; import { DataStoreService } from '../../core-services/data-store.service'; import { HttpService } from '../../core-services/http.service'; -import { ViewCategory } from 'app/site/motions/models/view-category'; +import { ViewCategory, CategoryTitleInformation } from 'app/site/motions/models/view-category'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; type SortProperty = 'prefix' | 'name'; @@ -27,7 +27,7 @@ type SortProperty = 'prefix' | 'name'; @Injectable({ providedIn: 'root' }) -export class CategoryRepositoryService extends BaseRepository<ViewCategory, Category> { +export class CategoryRepositoryService extends BaseRepository<ViewCategory, Category, CategoryTitleInformation> { private sortProperty: SortProperty; /** @@ -60,14 +60,18 @@ export class CategoryRepositoryService extends BaseRepository<ViewCategory, Cate }); } + public getTitle = (titleInformation: CategoryTitleInformation) => { + return titleInformation.prefix + ? titleInformation.prefix + ' - ' + titleInformation.name + : titleInformation.name; + }; + public getVerboseName = (plural: boolean = false) => { return this.translate.instant(plural ? 'Categories' : 'Category'); }; protected createViewModel(category: Category): ViewCategory { - const viewCategory = new ViewCategory(category); - viewCategory.getVerboseName = this.getVerboseName; - return viewCategory; + return new ViewCategory(category); } /** diff --git a/client/src/app/core/repositories/motions/change-recommendation-repository.service.ts b/client/src/app/core/repositories/motions/change-recommendation-repository.service.ts index a7fc5af64..ee1e84bb7 100644 --- a/client/src/app/core/repositories/motions/change-recommendation-repository.service.ts +++ b/client/src/app/core/repositories/motions/change-recommendation-repository.service.ts @@ -11,7 +11,10 @@ import { Workflow } from 'app/shared/models/motions/workflow'; import { BaseRepository } from '../base-repository'; import { DataStoreService } from 'app/core/core-services/data-store.service'; import { MotionChangeRecommendation } from 'app/shared/models/motions/motion-change-reco'; -import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-change-recommendation'; +import { + ViewMotionChangeRecommendation, + MotionChangeRecommendationTitleInformation +} from 'app/site/motions/models/view-motion-change-recommendation'; import { Identifiable } from 'app/shared/models/base/identifiable'; import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; @@ -31,7 +34,8 @@ import { ViewModelStoreService } from 'app/core/core-services/view-model-store.s }) export class ChangeRecommendationRepositoryService extends BaseRepository< ViewMotionChangeRecommendation, - MotionChangeRecommendation + MotionChangeRecommendation, + MotionChangeRecommendationTitleInformation > { /** * Creates a MotionRepository @@ -57,6 +61,10 @@ export class ChangeRecommendationRepositoryService extends BaseRepository< ]); } + public getTitle = (titleInformation: MotionChangeRecommendationTitleInformation) => { + return this.getVerboseName(); + }; + public getVerboseName = (plural: boolean = false) => { return this.translate.instant(plural ? 'Change recommendations' : 'Change recommendation'); }; @@ -67,9 +75,7 @@ export class ChangeRecommendationRepositoryService extends BaseRepository< * @param {MotionChangeRecommendation} model */ protected createViewModel(model: MotionChangeRecommendation): ViewMotionChangeRecommendation { - const viewMotionChangeRecommendation = new ViewMotionChangeRecommendation(model); - viewMotionChangeRecommendation.getVerboseName = this.getVerboseName; - return viewMotionChangeRecommendation; + return new ViewMotionChangeRecommendation(model); } /** diff --git a/client/src/app/core/repositories/motions/motion-block-repository.service.ts b/client/src/app/core/repositories/motions/motion-block-repository.service.ts index f1e475c0e..1fa543d99 100644 --- a/client/src/app/core/repositories/motions/motion-block-repository.service.ts +++ b/client/src/app/core/repositories/motions/motion-block-repository.service.ts @@ -12,11 +12,11 @@ import { Motion } from 'app/shared/models/motions/motion'; import { MotionBlock } from 'app/shared/models/motions/motion-block'; import { MotionRepositoryService } from './motion-repository.service'; import { ViewMotion } from 'app/site/motions/models/view-motion'; -import { ViewMotionBlock } from 'app/site/motions/models/view-motion-block'; +import { ViewMotionBlock, MotionBlockTitleInformation } from 'app/site/motions/models/view-motion-block'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; -import { Item } from 'app/shared/models/agenda/item'; import { ViewItem } from 'app/site/agenda/models/view-item'; -import { BaseAgendaContentObjectRepository } from '../base-agenda-content-object-repository'; +import { ViewListOfSpeakers } from 'app/site/agenda/models/view-list-of-speakers'; +import { BaseIsAgendaItemAndListOfSpeakersContentObjectRepository } from '../base-is-agenda-item-and-list-of-speakers-content-object-repository'; /** * Repository service for motion blocks @@ -24,7 +24,11 @@ import { BaseAgendaContentObjectRepository } from '../base-agenda-content-object @Injectable({ providedIn: 'root' }) -export class MotionBlockRepositoryService extends BaseAgendaContentObjectRepository<ViewMotionBlock, MotionBlock> { +export class MotionBlockRepositoryService extends BaseIsAgendaItemAndListOfSpeakersContentObjectRepository< + ViewMotionBlock, + MotionBlock, + MotionBlockTitleInformation +> { /** * Constructor for the motion block repository * @@ -43,16 +47,12 @@ export class MotionBlockRepositoryService extends BaseAgendaContentObjectReposit private motionRepo: MotionRepositoryService, private httpService: HttpService ) { - super(DS, dataSend, mapperService, viewModelStoreService, translate, MotionBlock, [Item]); + super(DS, dataSend, mapperService, viewModelStoreService, translate, MotionBlock); this.initSorting(); } - public getAgendaTitle = (motionBlock: Partial<MotionBlock> | Partial<ViewMotionBlock>) => { - return motionBlock.title; - }; - - public getAgendaTitleWithType = (motionBlock: Partial<MotionBlock> | Partial<ViewMotionBlock>) => { - return motionBlock.title + ' (' + this.getVerboseName() + ')'; + public getTitle = (titleInformation: MotionBlockTitleInformation) => { + return titleInformation.title; }; public getVerboseName = (plural: boolean = false) => { @@ -67,11 +67,8 @@ export class MotionBlockRepositoryService extends BaseAgendaContentObjectReposit */ protected createViewModel(block: MotionBlock): ViewMotionBlock { const item = this.viewModelStoreService.get(ViewItem, block.agenda_item_id); - const viewMotionBlock = new ViewMotionBlock(block, item); - viewMotionBlock.getVerboseName = this.getVerboseName; - viewMotionBlock.getAgendaTitle = () => this.getAgendaTitle(viewMotionBlock); - viewMotionBlock.getAgendaTitleWithType = () => this.getAgendaTitleWithType(viewMotionBlock); - return viewMotionBlock; + const listOfSpeakers = this.viewModelStoreService.get(ViewListOfSpeakers, block.list_of_speakers_id); + return new ViewMotionBlock(block, item, listOfSpeakers); } /** diff --git a/client/src/app/core/repositories/motions/motion-comment-section-repository.service.ts b/client/src/app/core/repositories/motions/motion-comment-section-repository.service.ts index bf546746a..a0c528341 100644 --- a/client/src/app/core/repositories/motions/motion-comment-section-repository.service.ts +++ b/client/src/app/core/repositories/motions/motion-comment-section-repository.service.ts @@ -1,16 +1,20 @@ import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + import { DataSendService } from '../../core-services/data-send.service'; import { DataStoreService } from '../../core-services/data-store.service'; import { BaseRepository } from '../base-repository'; -import { ViewMotionCommentSection } from 'app/site/motions/models/view-motion-comment-section'; +import { + ViewMotionCommentSection, + MotionCommentSectionTitleInformation +} from 'app/site/motions/models/view-motion-comment-section'; import { MotionCommentSection } from 'app/shared/models/motions/motion-comment-section'; import { Group } from 'app/shared/models/users/group'; import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; import { HttpService } from 'app/core/core-services/http.service'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; import { ViewGroup } from 'app/site/users/models/view-group'; -import { TranslateService } from '@ngx-translate/core'; import { ViewMotion } from 'app/site/motions/models/view-motion'; /** @@ -28,7 +32,8 @@ import { ViewMotion } from 'app/site/motions/models/view-motion'; }) export class MotionCommentSectionRepositoryService extends BaseRepository< ViewMotionCommentSection, - MotionCommentSection + MotionCommentSection, + MotionCommentSectionTitleInformation > { /** * Creates a CategoryRepository @@ -51,6 +56,10 @@ export class MotionCommentSectionRepositoryService extends BaseRepository< super(DS, dataSend, mapperService, viewModelStoreService, translate, MotionCommentSection, [Group]); } + public getTitle = (titleInformation: MotionCommentSectionTitleInformation) => { + return titleInformation.name; + }; + public getVerboseName = (plural: boolean = false) => { return this.translate.instant(plural ? 'Comment sections' : 'Comment section'); }; @@ -64,9 +73,7 @@ export class MotionCommentSectionRepositoryService extends BaseRepository< protected createViewModel(section: MotionCommentSection): ViewMotionCommentSection { const readGroups = this.viewModelStoreService.getMany(ViewGroup, section.read_groups_id); const writeGroups = this.viewModelStoreService.getMany(ViewGroup, section.write_groups_id); - const viewMotionCommentSection = new ViewMotionCommentSection(section, readGroups, writeGroups); - viewMotionCommentSection.getVerboseName = this.getVerboseName; - return viewMotionCommentSection; + return new ViewMotionCommentSection(section, readGroups, writeGroups); } /** diff --git a/client/src/app/core/repositories/motions/motion-repository.service.ts b/client/src/app/core/repositories/motions/motion-repository.service.ts index 21ed15b8c..2761ed191 100644 --- a/client/src/app/core/repositories/motions/motion-repository.service.ts +++ b/client/src/app/core/repositories/motions/motion-repository.service.ts @@ -6,14 +6,13 @@ import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { Category } from 'app/shared/models/motions/category'; -import { ChangeRecoMode, ViewMotion } from 'app/site/motions/models/view-motion'; +import { ChangeRecoMode, ViewMotion, MotionTitleInformation } from 'app/site/motions/models/view-motion'; import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; import { ConfigService } from 'app/core/ui-services/config.service'; import { DataSendService } from '../../core-services/data-send.service'; import { DataStoreService } from '../../core-services/data-store.service'; import { DiffLinesInParagraph, DiffService, LineRange, ModificationType } from '../../ui-services/diff.service'; import { HttpService } from 'app/core/core-services/http.service'; -import { Item } from 'app/shared/models/agenda/item'; import { LinenumberingService, LineNumberRange } from '../../ui-services/linenumbering.service'; import { Mediafile } from 'app/shared/models/mediafiles/mediafile'; import { Motion } from 'app/shared/models/motions/motion'; @@ -22,7 +21,7 @@ import { MotionChangeRecommendation } from 'app/shared/models/motions/motion-cha import { MotionPoll } from 'app/shared/models/motions/motion-poll'; import { TreeIdNode } from 'app/core/ui-services/tree.service'; import { User } from 'app/shared/models/users/user'; -import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-change-recommendation'; +import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-motion-change-recommendation'; import { ViewMotionAmendedParagraph } from 'app/site/motions/models/view-motion-amended-paragraph'; import { ViewUnifiedChange } from 'app/shared/models/motions/view-unified-change'; import { ViewStatuteParagraph } from 'app/site/motions/models/view-statute-paragraph'; @@ -37,11 +36,12 @@ import { ViewItem } from 'app/site/agenda/models/view-item'; import { ViewMotionBlock } from 'app/site/motions/models/view-motion-block'; import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile'; import { ViewTag } from 'app/site/tags/models/view-tag'; -import { BaseAgendaContentObjectRepository } from '../base-agenda-content-object-repository'; -import { PersonalNote, PersonalNoteContent } from 'app/shared/models/users/personal-note'; import { ViewPersonalNote } from 'app/site/users/models/view-personal-note'; import { OperatorService } from 'app/core/core-services/operator.service'; import { CollectionIds } from 'app/core/core-services/data-store-update-manager.service'; +import { PersonalNote, PersonalNoteContent } from 'app/shared/models/users/personal-note'; +import { ViewListOfSpeakers } from 'app/site/agenda/models/view-list-of-speakers'; +import { BaseIsAgendaItemAndListOfSpeakersContentObjectRepository } from '../base-is-agenda-item-and-list-of-speakers-content-object-repository'; type SortProperty = 'weight' | 'identifier'; @@ -88,7 +88,11 @@ export interface ParagraphToChoose { @Injectable({ providedIn: 'root' }) -export class MotionRepositoryService extends BaseAgendaContentObjectRepository<ViewMotion, Motion> { +export class MotionRepositoryService extends BaseIsAgendaItemAndListOfSpeakersContentObjectRepository< + ViewMotion, + Motion, + MotionTitleInformation +> { /** * The property the incoming data is sorted by */ @@ -127,7 +131,6 @@ export class MotionRepositoryService extends BaseAgendaContentObjectRepository<V Category, User, Workflow, - Item, MotionBlock, Mediafile, Tag, @@ -141,37 +144,39 @@ export class MotionRepositoryService extends BaseAgendaContentObjectRepository<V }); } - public getTitle = (motion: Partial<Motion> | Partial<ViewMotion>) => { - if (motion.identifier) { - return motion.identifier + ': ' + motion.title; + public getTitle = (titleInformation: MotionTitleInformation) => { + if (titleInformation.identifier) { + return titleInformation.identifier + ': ' + titleInformation.title; } else { - return motion.title; + return titleInformation.title; } }; - public getIdentifierOrTitle = (motion: Partial<Motion> | Partial<ViewMotion>) => { - if (motion.identifier) { - return motion.identifier; + public getIdentifierOrTitle = (titleInformation: MotionTitleInformation) => { + if (titleInformation.identifier) { + return titleInformation.identifier; } else { - return motion.title; + return titleInformation.title; } }; - public getAgendaTitle = (motion: Partial<Motion> | Partial<ViewMotion>) => { + public getAgendaSlideTitle = (titleInformation: MotionTitleInformation) => { + const numberPrefix = titleInformation.agenda_item_number ? `${titleInformation.agenda_item_number} · ` : ''; // if the identifier is set, the title will be 'Motion <identifier>'. - if (motion.identifier) { - return this.translate.instant('Motion') + ' ' + motion.identifier; + if (titleInformation.identifier) { + return numberPrefix + this.translate.instant('Motion') + ' ' + titleInformation.identifier; } else { - return motion.title; + return numberPrefix + titleInformation.title; } }; - public getAgendaTitleWithType = (motion: Partial<Motion> | Partial<ViewMotion>) => { + public getAgendaListTitle = (titleInformation: MotionTitleInformation) => { + const numberPrefix = titleInformation.agenda_item_number ? `${titleInformation.agenda_item_number} · ` : ''; // Append the verbose name only, if not the special format 'Motion <identifier>' is used. - if (motion.identifier) { - return this.translate.instant('Motion') + ' ' + motion.identifier; + if (titleInformation.identifier) { + return numberPrefix + this.translate.instant('Motion') + ' ' + titleInformation.identifier; } else { - return motion.title + ' (' + this.getVerboseName() + ')'; + return numberPrefix + titleInformation.title + ' (' + this.getVerboseName() + ')'; } }; @@ -193,6 +198,7 @@ export class MotionRepositoryService extends BaseAgendaContentObjectRepository<V const supporters = this.viewModelStoreService.getMany(ViewUser, motion.supporters_id); const workflow = this.viewModelStoreService.get(ViewWorkflow, motion.workflow_id); const item = this.viewModelStoreService.get(ViewItem, motion.agenda_item_id); + const listOfSpeakers = this.viewModelStoreService.get(ViewListOfSpeakers, motion.list_of_speakers_id); const block = this.viewModelStoreService.get(ViewMotionBlock, motion.motion_block_id); const attachments = this.viewModelStoreService.getMany(ViewMediafile, motion.attachments_id); const tags = this.viewModelStoreService.getMany(ViewTag, motion.tags_id); @@ -215,6 +221,7 @@ export class MotionRepositoryService extends BaseAgendaContentObjectRepository<V workflow, state, item, + listOfSpeakers, block, attachments, tags, @@ -224,11 +231,7 @@ export class MotionRepositoryService extends BaseAgendaContentObjectRepository<V personalNote ); viewMotion.getIdentifierOrTitle = () => this.getIdentifierOrTitle(viewMotion); - viewMotion.getTitle = () => this.getTitle(viewMotion); - viewMotion.getVerboseName = this.getVerboseName; - viewMotion.getAgendaTitle = () => this.getAgendaTitle(viewMotion); - viewMotion.getProjectorTitle = viewMotion.getAgendaTitle; - viewMotion.getAgendaTitleWithType = () => this.getAgendaTitleWithType(viewMotion); + viewMotion.getProjectorTitle = () => this.getAgendaSlideTitle(viewMotion); return viewMotion; } diff --git a/client/src/app/core/repositories/motions/statute-paragraph-repository.service.ts b/client/src/app/core/repositories/motions/statute-paragraph-repository.service.ts index 0c95c4579..6d14647f9 100644 --- a/client/src/app/core/repositories/motions/statute-paragraph-repository.service.ts +++ b/client/src/app/core/repositories/motions/statute-paragraph-repository.service.ts @@ -1,13 +1,14 @@ import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + import { DataSendService } from '../../core-services/data-send.service'; import { DataStoreService } from '../../core-services/data-store.service'; import { BaseRepository } from '../base-repository'; -import { ViewStatuteParagraph } from 'app/site/motions/models/view-statute-paragraph'; +import { ViewStatuteParagraph, StatuteParagraphTitleInformation } from 'app/site/motions/models/view-statute-paragraph'; import { StatuteParagraph } from 'app/shared/models/motions/statute-paragraph'; import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; -import { TranslateService } from '@ngx-translate/core'; /** * Repository Services for statute paragraphs @@ -19,7 +20,11 @@ import { TranslateService } from '@ngx-translate/core'; @Injectable({ providedIn: 'root' }) -export class StatuteParagraphRepositoryService extends BaseRepository<ViewStatuteParagraph, StatuteParagraph> { +export class StatuteParagraphRepositoryService extends BaseRepository< + ViewStatuteParagraph, + StatuteParagraph, + StatuteParagraphTitleInformation +> { /** * Creates a StatuteParagraphRepository * Converts existing and incoming statute paragraphs to ViewStatuteParagraphs @@ -39,13 +44,15 @@ export class StatuteParagraphRepositoryService extends BaseRepository<ViewStatut super(DS, dataSend, mapperService, viewModelStoreService, translate, StatuteParagraph); } + public getTitle = (titleInformation: StatuteParagraphTitleInformation) => { + return titleInformation.title; + }; + public getVerboseName = (plural: boolean = false) => { return this.translate.instant(plural ? 'Statute paragraphs' : 'Statute paragraph'); }; protected createViewModel(statuteParagraph: StatuteParagraph): ViewStatuteParagraph { - const viewStatuteParagraph = new ViewStatuteParagraph(statuteParagraph); - viewStatuteParagraph.getVerboseName = this.getVerboseName; - return viewStatuteParagraph; + return new ViewStatuteParagraph(statuteParagraph); } } diff --git a/client/src/app/core/repositories/motions/workflow-repository.service.ts b/client/src/app/core/repositories/motions/workflow-repository.service.ts index de76550d3..435929c99 100644 --- a/client/src/app/core/repositories/motions/workflow-repository.service.ts +++ b/client/src/app/core/repositories/motions/workflow-repository.service.ts @@ -1,7 +1,9 @@ import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + import { Workflow } from 'app/shared/models/motions/workflow'; -import { ViewWorkflow } from 'app/site/motions/models/view-workflow'; +import { ViewWorkflow, WorkflowTitleInformation } from 'app/site/motions/models/view-workflow'; import { DataSendService } from '../../core-services/data-send.service'; import { DataStoreService } from '../../core-services/data-store.service'; import { BaseRepository } from '../base-repository'; @@ -10,7 +12,6 @@ import { WorkflowState } from 'app/shared/models/motions/workflow-state'; import { ViewMotion } from 'app/site/motions/models/view-motion'; import { HttpService } from 'app/core/core-services/http.service'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; -import { TranslateService } from '@ngx-translate/core'; /** * Repository Services for Categories @@ -25,7 +26,7 @@ import { TranslateService } from '@ngx-translate/core'; @Injectable({ providedIn: 'root' }) -export class WorkflowRepositoryService extends BaseRepository<ViewWorkflow, Workflow> { +export class WorkflowRepositoryService extends BaseRepository<ViewWorkflow, Workflow, WorkflowTitleInformation> { /** * The url to state on rest */ @@ -57,6 +58,10 @@ export class WorkflowRepositoryService extends BaseRepository<ViewWorkflow, Work }); } + public getTitle = (titleInformation: WorkflowTitleInformation) => { + return titleInformation.name; + }; + public getVerboseName = (plural: boolean = false) => { return this.translate.instant(plural ? 'Workflows' : 'Workflow'); }; @@ -81,9 +86,7 @@ export class WorkflowRepositoryService extends BaseRepository<ViewWorkflow, Work * @param workflow the Workflow to convert */ protected createViewModel(workflow: Workflow): ViewWorkflow { - const viewWorkflow = new ViewWorkflow(workflow); - viewWorkflow.getVerboseName = this.getVerboseName; - return viewWorkflow; + return new ViewWorkflow(workflow); } /** diff --git a/client/src/app/core/repositories/projector/countdown-repository.service.ts b/client/src/app/core/repositories/projector/countdown-repository.service.ts index 45cb70d3c..0408c538f 100644 --- a/client/src/app/core/repositories/projector/countdown-repository.service.ts +++ b/client/src/app/core/repositories/projector/countdown-repository.service.ts @@ -6,7 +6,7 @@ import { DataSendService } from '../../core-services/data-send.service'; import { DataStoreService } from '../../core-services/data-store.service'; import { BaseRepository } from '../base-repository'; import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; -import { ViewCountdown } from 'app/site/projector/models/view-countdown'; +import { ViewCountdown, CountdownTitleInformation } from 'app/site/projector/models/view-countdown'; import { Countdown } from 'app/shared/models/core/countdown'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; import { ServertimeService } from 'app/core/core-services/servertime.service'; @@ -14,7 +14,7 @@ import { ServertimeService } from 'app/core/core-services/servertime.service'; @Injectable({ providedIn: 'root' }) -export class CountdownRepositoryService extends BaseRepository<ViewCountdown, Countdown> { +export class CountdownRepositoryService extends BaseRepository<ViewCountdown, Countdown, CountdownTitleInformation> { public constructor( DS: DataStoreService, dataSend: DataSendService, @@ -26,14 +26,18 @@ export class CountdownRepositoryService extends BaseRepository<ViewCountdown, Co super(DS, dataSend, mapperService, viewModelStoreService, translate, Countdown); } + public getTitle = (titleInformation: CountdownTitleInformation) => { + return titleInformation.description + ? `${titleInformation.title} (${titleInformation.description})` + : titleInformation.title; + }; + public getVerboseName = (plural: boolean = false) => { return this.translate.instant(plural ? 'Countdowns' : 'Countdown'); }; protected createViewModel(countdown: Countdown): ViewCountdown { - const viewCountdown = new ViewCountdown(countdown); - viewCountdown.getVerboseName = this.getVerboseName; - return viewCountdown; + return new ViewCountdown(countdown); } /** diff --git a/client/src/app/core/repositories/projector/projection-default-repository.service.ts b/client/src/app/core/repositories/projector/projection-default-repository.service.ts index 1a6b2e045..e9f67aa06 100644 --- a/client/src/app/core/repositories/projector/projection-default-repository.service.ts +++ b/client/src/app/core/repositories/projector/projection-default-repository.service.ts @@ -9,7 +9,10 @@ import { DataStoreService } from '../../core-services/data-store.service'; import { Identifiable } from 'app/shared/models/base/identifiable'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; import { ProjectionDefault } from 'app/shared/models/core/projection-default'; -import { ViewProjectionDefault } from 'app/site/projector/models/view-projection-default'; +import { + ViewProjectionDefault, + ProjectionDefaultTitleInformation +} from 'app/site/projector/models/view-projection-default'; /** * Manages all projection default instances. @@ -17,7 +20,11 @@ import { ViewProjectionDefault } from 'app/site/projector/models/view-projection @Injectable({ providedIn: 'root' }) -export class ProjectionDefaultRepositoryService extends BaseRepository<ViewProjectionDefault, ProjectionDefault> { +export class ProjectionDefaultRepositoryService extends BaseRepository< + ViewProjectionDefault, + ProjectionDefault, + ProjectionDefaultTitleInformation +> { /** * Constructor calls the parent constructor * @@ -41,15 +48,12 @@ export class ProjectionDefaultRepositoryService extends BaseRepository<ViewProje return this.translate.instant(plural ? 'Projectiondefaults' : 'Projectiondefault'); }; - public getTitle = (projectionDefault: Partial<ProjectionDefault> | Partial<ViewProjectionDefault>) => { - return this.translate.instant(projectionDefault.display_name); + public getTitle = (titleInformation: ProjectionDefaultTitleInformation) => { + return this.translate.instant(titleInformation.display_name); }; public createViewModel(projectionDefault: ProjectionDefault): ViewProjectionDefault { - const viewProjectionDefault = new ViewProjectionDefault(projectionDefault); - viewProjectionDefault.getVerboseName = this.getVerboseName; - viewProjectionDefault.getTitle = () => this.getTitle(viewProjectionDefault); - return viewProjectionDefault; + return new ViewProjectionDefault(projectionDefault); } /** diff --git a/client/src/app/core/repositories/projector/projector-message-repository.service.ts b/client/src/app/core/repositories/projector/projector-message-repository.service.ts index 6284025ae..048d4b298 100644 --- a/client/src/app/core/repositories/projector/projector-message-repository.service.ts +++ b/client/src/app/core/repositories/projector/projector-message-repository.service.ts @@ -1,17 +1,26 @@ import { Injectable } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + import { DataStoreService } from '../../core-services/data-store.service'; import { BaseRepository } from '../base-repository'; import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; import { ProjectorMessage } from 'app/shared/models/core/projector-message'; -import { ViewProjectorMessage } from 'app/site/projector/models/view-projector-message'; +import { + ViewProjectorMessage, + ProjectorMessageTitleInformation +} from 'app/site/projector/models/view-projector-message'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; -import { TranslateService } from '@ngx-translate/core'; import { DataSendService } from 'app/core/core-services/data-send.service'; @Injectable({ providedIn: 'root' }) -export class ProjectorMessageRepositoryService extends BaseRepository<ViewProjectorMessage, ProjectorMessage> { +export class ProjectorMessageRepositoryService extends BaseRepository< + ViewProjectorMessage, + ProjectorMessage, + ProjectorMessageTitleInformation +> { public constructor( DS: DataStoreService, dataSend: DataSendService, @@ -22,13 +31,15 @@ export class ProjectorMessageRepositoryService extends BaseRepository<ViewProjec super(DS, dataSend, mapperService, viewModelStoreService, translate, ProjectorMessage); } + public getTitle = (titleInformation: ProjectorMessageTitleInformation) => { + return this.getVerboseName(); + }; + public getVerboseName = (plural: boolean = false) => { return this.translate.instant(plural ? 'Messages' : 'Message'); }; protected createViewModel(message: ProjectorMessage): ViewProjectorMessage { - const viewProjectorMessage = new ViewProjectorMessage(message); - viewProjectorMessage.getVerboseName = this.getVerboseName; - return viewProjectorMessage; + return new ViewProjectorMessage(message); } } diff --git a/client/src/app/core/repositories/projector/projector-repository.service.ts b/client/src/app/core/repositories/projector/projector-repository.service.ts index 4e86fb003..52e8789cd 100644 --- a/client/src/app/core/repositories/projector/projector-repository.service.ts +++ b/client/src/app/core/repositories/projector/projector-repository.service.ts @@ -1,15 +1,16 @@ import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + import { BaseRepository } from '../base-repository'; import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; import { DataSendService } from '../../core-services/data-send.service'; import { DataStoreService } from '../../core-services/data-store.service'; import { Identifiable } from 'app/shared/models/base/identifiable'; -import { ViewProjector } from 'app/site/projector/models/view-projector'; +import { ViewProjector, ProjectorTitleInformation } from 'app/site/projector/models/view-projector'; import { Projector } from 'app/shared/models/core/projector'; import { HttpService } from 'app/core/core-services/http.service'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; -import { TranslateService } from '@ngx-translate/core'; /** * Directions for scale and scroll requests. @@ -26,7 +27,7 @@ export enum ScrollScaleDirection { @Injectable({ providedIn: 'root' }) -export class ProjectorRepositoryService extends BaseRepository<ViewProjector, Projector> { +export class ProjectorRepositoryService extends BaseRepository<ViewProjector, Projector, ProjectorTitleInformation> { /** * Constructor calls the parent constructor * @@ -46,14 +47,16 @@ export class ProjectorRepositoryService extends BaseRepository<ViewProjector, Pr super(DS, dataSend, mapperService, viewModelStoreService, translate, Projector, [Projector]); } + public getTitle = (titleInformation: ProjectorTitleInformation) => { + return titleInformation.name; + }; + public getVerboseName = (plural: boolean = false) => { return this.translate.instant(plural ? 'Projectors' : 'Projector'); }; public createViewModel(projector: Projector): ViewProjector { - const viewProjector = new ViewProjector(projector); - viewProjector.getVerboseName = this.getVerboseName; - return viewProjector; + return new ViewProjector(projector); } /** diff --git a/client/src/app/core/repositories/tags/tag-repository.service.ts b/client/src/app/core/repositories/tags/tag-repository.service.ts index ce16d9fa8..3b2598bda 100644 --- a/client/src/app/core/repositories/tags/tag-repository.service.ts +++ b/client/src/app/core/repositories/tags/tag-repository.service.ts @@ -1,13 +1,14 @@ import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + import { Tag } from 'app/shared/models/core/tag'; -import { ViewTag } from 'app/site/tags/models/view-tag'; +import { ViewTag, TagTitleInformation } from 'app/site/tags/models/view-tag'; import { DataSendService } from '../../core-services/data-send.service'; import { DataStoreService } from '../../core-services/data-store.service'; import { BaseRepository } from '../base-repository'; import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; -import { TranslateService } from '@ngx-translate/core'; /** * Repository Services for Tags @@ -22,7 +23,7 @@ import { TranslateService } from '@ngx-translate/core'; @Injectable({ providedIn: 'root' }) -export class TagRepositoryService extends BaseRepository<ViewTag, Tag> { +export class TagRepositoryService extends BaseRepository<ViewTag, Tag, TagTitleInformation> { /** * Creates a TagRepository * Converts existing and incoming Tags to ViewTags @@ -43,14 +44,16 @@ export class TagRepositoryService extends BaseRepository<ViewTag, Tag> { this.initSorting(); } + public getTitle = (titleInformation: TagTitleInformation) => { + return titleInformation.name; + }; + public getVerboseName = (plural: boolean = false) => { return this.translate.instant(plural ? 'Tags' : 'Tag'); }; protected createViewModel(tag: Tag): ViewTag { - const viewTag = new ViewTag(tag); - viewTag.getVerboseName = this.getVerboseName; - return viewTag; + return new ViewTag(tag); } /** diff --git a/client/src/app/core/repositories/agenda/topic-repository.service.spec.ts b/client/src/app/core/repositories/topics/topic-repository.service.spec.ts similarity index 100% rename from client/src/app/core/repositories/agenda/topic-repository.service.spec.ts rename to client/src/app/core/repositories/topics/topic-repository.service.spec.ts diff --git a/client/src/app/core/repositories/agenda/topic-repository.service.ts b/client/src/app/core/repositories/topics/topic-repository.service.ts similarity index 64% rename from client/src/app/core/repositories/agenda/topic-repository.service.ts rename to client/src/app/core/repositories/topics/topic-repository.service.ts index 1dd869c0d..43335abcf 100644 --- a/client/src/app/core/repositories/agenda/topic-repository.service.ts +++ b/client/src/app/core/repositories/topics/topic-repository.service.ts @@ -2,17 +2,17 @@ import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -import { BaseAgendaContentObjectRepository } from '../base-agenda-content-object-repository'; import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service'; import { DataStoreService } from 'app/core/core-services/data-store.service'; import { DataSendService } from 'app/core/core-services/data-send.service'; -import { Item } from 'app/shared/models/agenda/item'; import { Mediafile } from 'app/shared/models/mediafiles/mediafile'; import { Topic } from 'app/shared/models/topics/topic'; -import { ViewTopic } from 'app/site/agenda/models/view-topic'; +import { ViewTopic, TopicTitleInformation } from 'app/site/agenda/models/view-topic'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile'; import { ViewItem } from 'app/site/agenda/models/view-item'; +import { ViewListOfSpeakers } from 'app/site/agenda/models/view-list-of-speakers'; +import { BaseIsAgendaItemAndListOfSpeakersContentObjectRepository } from '../base-is-agenda-item-and-list-of-speakers-content-object-repository'; /** * Repository for topics @@ -20,7 +20,11 @@ import { ViewItem } from 'app/site/agenda/models/view-item'; @Injectable({ providedIn: 'root' }) -export class TopicRepositoryService extends BaseAgendaContentObjectRepository<ViewTopic, Topic> { +export class TopicRepositoryService extends BaseIsAgendaItemAndListOfSpeakersContentObjectRepository< + ViewTopic, + Topic, + TopicTitleInformation +> { /** * Constructor calls the parent constructor * @@ -35,16 +39,25 @@ export class TopicRepositoryService extends BaseAgendaContentObjectRepository<Vi viewModelStoreService: ViewModelStoreService, translate: TranslateService ) { - super(DS, dataSend, mapperService, viewModelStoreService, translate, Topic, [Mediafile, Item]); + super(DS, dataSend, mapperService, viewModelStoreService, translate, Topic, [Mediafile]); } - public getAgendaTitle = (topic: Partial<Topic> | Partial<ViewTopic>) => { - return topic.title; + public getTitle = (titleInformation: TopicTitleInformation) => { + if (titleInformation.agenda_item_number) { + return titleInformation.agenda_item_number + ' · ' + titleInformation.title; + } else { + return titleInformation.title; + } }; - public getAgendaTitleWithType = (topic: Partial<Topic> | Partial<ViewTopic>) => { + public getAgendaListTitle = (titleInformation: TopicTitleInformation) => { // Do not append ' (Topic)' to the title. - return topic.title; + return this.getTitle(titleInformation); + }; + + public getAgendaSlideTitle = (titleInformation: TopicTitleInformation) => { + // Do not append ' (Topic)' to the title. + return this.getTitle(titleInformation); }; public getVerboseName = (plural: boolean = false) => { @@ -60,11 +73,8 @@ export class TopicRepositoryService extends BaseAgendaContentObjectRepository<Vi public createViewModel(topic: Topic): ViewTopic { const attachments = this.viewModelStoreService.getMany(ViewMediafile, topic.attachments_id); const item = this.viewModelStoreService.get(ViewItem, topic.agenda_item_id); - const viewTopic = new ViewTopic(topic, attachments, item); - viewTopic.getVerboseName = this.getVerboseName; - viewTopic.getAgendaTitle = () => this.getAgendaTitle(viewTopic); - viewTopic.getAgendaTitleWithType = () => this.getAgendaTitle(viewTopic); - return viewTopic; + const listOfSpeakers = this.viewModelStoreService.get(ViewListOfSpeakers, topic.list_of_speakers_id); + return new ViewTopic(topic, attachments, item, listOfSpeakers); } /** diff --git a/client/src/app/core/repositories/users/group-repository.service.ts b/client/src/app/core/repositories/users/group-repository.service.ts index 042d1e4f1..8bd4afeea 100644 --- a/client/src/app/core/repositories/users/group-repository.service.ts +++ b/client/src/app/core/repositories/users/group-repository.service.ts @@ -1,14 +1,15 @@ import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + import { BaseRepository } from '../base-repository'; import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; import { ConstantsService } from '../../core-services/constants.service'; import { DataSendService } from '../../core-services/data-send.service'; import { DataStoreService } from '../../core-services/data-store.service'; import { Group } from 'app/shared/models/users/group'; -import { ViewGroup } from 'app/site/users/models/view-group'; +import { ViewGroup, GroupTitleInformation } from 'app/site/users/models/view-group'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; -import { TranslateService } from '@ngx-translate/core'; import { HttpService } from 'app/core/core-services/http.service'; /** @@ -35,7 +36,7 @@ export interface AppPermissions { @Injectable({ providedIn: 'root' }) -export class GroupRepositoryService extends BaseRepository<ViewGroup, Group> { +export class GroupRepositoryService extends BaseRepository<ViewGroup, Group, GroupTitleInformation> { /** * holds sorted permissions per app. */ @@ -61,14 +62,16 @@ export class GroupRepositoryService extends BaseRepository<ViewGroup, Group> { this.sortPermsPerApp(); } + public getTitle = (titleInformation: GroupTitleInformation) => { + return titleInformation.name; + }; + public getVerboseName = (plural: boolean = false) => { return this.translate.instant(plural ? 'Groups' : 'Group'); }; public createViewModel(group: Group): ViewGroup { - const viewGroup = new ViewGroup(group); - viewGroup.getVerboseName = this.getVerboseName; - return viewGroup; + return new ViewGroup(group); } /** diff --git a/client/src/app/core/repositories/users/personal-note-repository.service.ts b/client/src/app/core/repositories/users/personal-note-repository.service.ts index 3e94e8dce..5ab259b7d 100644 --- a/client/src/app/core/repositories/users/personal-note-repository.service.ts +++ b/client/src/app/core/repositories/users/personal-note-repository.service.ts @@ -8,7 +8,7 @@ import { BaseRepository } from '../base-repository'; import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; import { Identifiable } from 'app/shared/models/base/identifiable'; import { PersonalNote } from 'app/shared/models/users/personal-note'; -import { ViewPersonalNote } from 'app/site/users/models/view-personal-note'; +import { ViewPersonalNote, PersonalNoteTitleInformation } from 'app/site/users/models/view-personal-note'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; /** @@ -16,7 +16,11 @@ import { ViewModelStoreService } from 'app/core/core-services/view-model-store.s @Injectable({ providedIn: 'root' }) -export class PersonalNoteRepositoryService extends BaseRepository<ViewPersonalNote, PersonalNote> { +export class PersonalNoteRepositoryService extends BaseRepository< + ViewPersonalNote, + PersonalNote, + PersonalNoteTitleInformation +> { /** * @param DS The DataStore * @param mapperService Maps collection strings to classes @@ -31,14 +35,16 @@ export class PersonalNoteRepositoryService extends BaseRepository<ViewPersonalNo super(DS, dataSend, mapperService, viewModelStoreService, translate, PersonalNote); } + public getTitle = (titleInformation: PersonalNoteTitleInformation) => { + return this.getVerboseName(); + }; + public getVerboseName = (plural: boolean = false) => { return this.translate.instant(plural ? 'Personal notes' : 'Personal note'); }; protected createViewModel(personalNote: PersonalNote): ViewPersonalNote { - const viewPersonalNote = new ViewPersonalNote(personalNote); - viewPersonalNote.getVerboseName = this.getVerboseName; - return viewPersonalNote; + return new ViewPersonalNote(personalNote); } /** diff --git a/client/src/app/core/repositories/users/user-repository.service.ts b/client/src/app/core/repositories/users/user-repository.service.ts index bcea4ed7f..6a6021e20 100644 --- a/client/src/app/core/repositories/users/user-repository.service.ts +++ b/client/src/app/core/repositories/users/user-repository.service.ts @@ -12,7 +12,7 @@ import { Group } from 'app/shared/models/users/group'; import { HttpService } from 'app/core/core-services/http.service'; import { NewEntry } from 'app/core/ui-services/base-import.service'; import { User } from 'app/shared/models/users/user'; -import { ViewUser } from 'app/site/users/models/view-user'; +import { ViewUser, UserTitleInformation } from 'app/site/users/models/view-user'; import { ViewGroup } from 'app/site/users/models/view-group'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; @@ -32,7 +32,7 @@ type SortProperty = 'first_name' | 'last_name' | 'number'; @Injectable({ providedIn: 'root' }) -export class UserRepositoryService extends BaseRepository<ViewUser, User> { +export class UserRepositoryService extends BaseRepository<ViewUser, User, UserTitleInformation> { /** * The property the incoming data is sorted by */ @@ -65,6 +65,55 @@ export class UserRepositoryService extends BaseRepository<ViewUser, User> { }); } + public getTitle = (titleInformation: UserTitleInformation) => { + return this.getFullName(titleInformation); + }; + + /** + * Getter for the short name (Title, given name, surname) + * + * @returns a non-empty string + */ + public getShortName(titleInformation: UserTitleInformation): string { + const title = titleInformation.title ? titleInformation.title.trim() : ''; + const firstName = titleInformation.first_name ? titleInformation.first_name.trim() : ''; + const lastName = titleInformation.last_name ? titleInformation.last_name.trim() : ''; + let shortName = `${firstName} ${lastName}`; + + if (shortName.length <= 1) { + // We have at least one space from the concatination of + // first- and lastname. + shortName = titleInformation.username; + } + + if (title) { + shortName = `${title} ${shortName}`; + } + + return shortName; + } + + public getFullName(titleInformation: UserTitleInformation): string { + let name = this.getShortName(titleInformation); + const additions: string[] = []; + + // addition: add number and structure level + const structure_level = titleInformation.structure_level ? titleInformation.structure_level.trim() : ''; + if (structure_level) { + additions.push(structure_level); + } + + const number = titleInformation.number ? titleInformation.number.trim() : ''; + if (number) { + additions.push(`${this.translate.instant('No.')} ${number}`); + } + + if (additions.length > 0) { + name += ' (' + additions.join(' · ') + ')'; + } + return name.trim(); + } + public getVerboseName = (plural: boolean = false) => { return this.translate.instant(plural ? 'Participants' : 'Participant'); }; @@ -72,10 +121,8 @@ export class UserRepositoryService extends BaseRepository<ViewUser, User> { public createViewModel(user: User): ViewUser { const groups = this.viewModelStoreService.getMany(ViewGroup, user.groups_id); const viewUser = new ViewUser(user, groups); - viewUser.getVerboseName = this.getVerboseName; - viewUser.getNumberForName = (nr: number) => { - return `${this.translate.instant('No.')} ${nr}`; - }; + viewUser.getFullName = () => this.getFullName(viewUser); + viewUser.getShortName = () => this.getShortName(viewUser); return viewUser; } diff --git a/client/src/app/core/ui-services/base-filter-list.service.ts b/client/src/app/core/ui-services/base-filter-list.service.ts index 8efd0700e..d2e1a17e3 100644 --- a/client/src/app/core/ui-services/base-filter-list.service.ts +++ b/client/src/app/core/ui-services/base-filter-list.service.ts @@ -2,7 +2,7 @@ import { BehaviorSubject, Observable, Subscription } from 'rxjs'; import { BaseModel } from 'app/shared/models/base/base-model'; import { BaseRepository } from '../repositories/base-repository'; -import { BaseViewModel } from '../../site/base/base-view-model'; +import { BaseViewModel, TitleInformation } from '../../site/base/base-view-model'; import { StorageService } from '../core-services/storage.service'; /** @@ -208,7 +208,7 @@ export abstract class BaseFilterListService<V extends BaseViewModel> { * @param exexcludeIds Set if certain ID's should be excluded from filtering */ protected updateFilterForRepo( - repo: BaseRepository<BaseViewModel, BaseModel>, + repo: BaseRepository<BaseViewModel, BaseModel, TitleInformation>, filter: OsFilter, noneOptionLabel?: string, excludeIds?: number[] diff --git a/client/src/app/core/ui-services/search.service.ts b/client/src/app/core/ui-services/search.service.ts index 8f1f1035e..c0d29015d 100644 --- a/client/src/app/core/ui-services/search.service.ts +++ b/client/src/app/core/ui-services/search.service.ts @@ -92,7 +92,7 @@ export class SearchService { */ public registerModel( collectionString: string, - repo: BaseRepository<any, any>, + repo: BaseRepository<any, any, any>, displayOrder: number, openInNewTab: boolean = false ): void { diff --git a/client/src/app/shared/components/speaker-button/speaker-button.component.html b/client/src/app/shared/components/speaker-button/speaker-button.component.html new file mode 100644 index 000000000..18764b6ad --- /dev/null +++ b/client/src/app/shared/components/speaker-button/speaker-button.component.html @@ -0,0 +1,16 @@ +<ng-container *osPerms="'agenda.can_see_list_of_speakers'"> + <ng-container *ngIf="listOfSpeakers"> + <button type="button" *ngIf="!menuItem" mat-icon-button [routerLink]="listOfSpeakers.listOfSpeakersUrl" [disabled]="disabled"> + <mat-icon + [matBadge]="listOfSpeakers.waitingSpeakerAmount > 0 ? listOfSpeakers.waitingSpeakerAmount : null" + matBadgeColor="accent" + > + mic + </mat-icon> + </button> + <button type="button" *ngIf="menuItem" mat-menu-item [routerLink]="listOfSpeakers.listOfSpeakersUrl"> + <mat-icon>mic</mat-icon> + <span translate>List of speakers</span> + </button> + </ng-container> +</ng-container> diff --git a/client/src/app/shared/components/speaker-button/speaker-button.component.spec.ts b/client/src/app/shared/components/speaker-button/speaker-button.component.spec.ts new file mode 100644 index 000000000..23c7101a2 --- /dev/null +++ b/client/src/app/shared/components/speaker-button/speaker-button.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; +import { SpeakerButtonComponent } from './speaker-button.component'; + +describe('SpeakerButtonComponent', () => { + let component: SpeakerButtonComponent; + let fixture: ComponentFixture<SpeakerButtonComponent>; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SpeakerButtonComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/components/speaker-button/speaker-button.component.ts b/client/src/app/shared/components/speaker-button/speaker-button.component.ts new file mode 100644 index 000000000..cde5e4fc7 --- /dev/null +++ b/client/src/app/shared/components/speaker-button/speaker-button.component.ts @@ -0,0 +1,45 @@ +import { Component, Input } from '@angular/core'; + +import { ContentObject, isContentObject } from 'app/shared/models/base/content-object'; +import { ViewListOfSpeakers } from 'app/site/agenda/models/view-list-of-speakers'; +import { + BaseViewModelWithListOfSpeakers, + isBaseViewModelWithListOfSpeakers +} from 'app/site/base/base-view-model-with-list-of-speakers'; +import { ListOfSpeakersRepositoryService } from 'app/core/repositories/agenda/list-of-speakers-repository.service'; + +/** + * A generic button to go to the list of speakers. Give the content object with + * [object]=object, which can be a ContentObject or a ViewModelWithListOfSpeakers. + * - Usage as a mini-fab (like in the agenda) with [menuItem]=false (default) + * - Usage in a dropdown (=list) with [menuItem]=true + */ +@Component({ + selector: 'os-speaker-button', + templateUrl: './speaker-button.component.html' +}) +export class SpeakerButtonComponent { + @Input() + public set object(obj: BaseViewModelWithListOfSpeakers | ContentObject | null) { + if (isBaseViewModelWithListOfSpeakers(obj)) { + this.listOfSpeakers = obj.listOfSpeakers; + } else if (isContentObject(obj)) { + this.listOfSpeakers = this.listOfSpeakersRepo.findByContentObject(obj); + } else { + this.listOfSpeakers = null; + } + } + + public listOfSpeakers: ViewListOfSpeakers | null; + + @Input() + public disabled: boolean; + + @Input() + public menuItem = false; + + /** + * The constructor + */ + public constructor(private listOfSpeakersRepo: ListOfSpeakersRepositoryService) {} +} diff --git a/client/src/app/shared/models/agenda/item.ts b/client/src/app/shared/models/agenda/item.ts index 660ea37fc..b4529814f 100644 --- a/client/src/app/shared/models/agenda/item.ts +++ b/client/src/app/shared/models/agenda/item.ts @@ -1,14 +1,5 @@ -import { Speaker } from './speaker'; -import { BaseModel } from '../base/base-model'; - -/** - * The representation of the content object for agenda items. The unique combination - * of the collection and id is given. - */ -interface ContentObject { - id: number; - collection: string; -} +import { ContentObject } from '../base/content-object'; +import { BaseModelWithContentObject } from '../base/base-model-with-content-object'; /** * Determine visibility states for agenda items @@ -24,19 +15,36 @@ export const itemVisibilityChoices = [ * Representations of agenda Item * @ignore */ -export class Item extends BaseModel<Item> { +export class Item extends BaseModelWithContentObject<Item> { public static COLLECTIONSTRING = 'agenda/item'; + // TODO: remove this, if the server can properly include the agenda item number + // in the title information. See issue #4738 + private _itemNumber: string; + private _titleInformation: any; + public id: number; - public item_number: string; - public title_information: object; + public get item_number(): string { + return this._itemNumber; + } + public set item_number(val: string) { + this._itemNumber = val; + if (this._titleInformation) { + this._titleInformation.agenda_item_number = this.item_number; + } + } + public get title_information(): object { + return this._titleInformation; + } + public set title_information(val: object) { + this._titleInformation = val; + this._titleInformation.agenda_item_number = this.item_number; + } public comment: string; public closed: boolean; public type: number; public is_hidden: boolean; public duration: number; // minutes - public speakers: Speaker[]; - public speaker_list_closed: boolean; public content_object: ContentObject; public weight: number; public parent_id: number; @@ -45,14 +53,4 @@ export class Item extends BaseModel<Item> { public constructor(input?: any) { super(Item.COLLECTIONSTRING, input); } - - public deserialize(input: any): void { - Object.assign(this, input); - - if (input.speakers instanceof Array) { - this.speakers = input.speakers.map(speakerData => { - return new Speaker(speakerData); - }); - } - } } diff --git a/client/src/app/shared/models/agenda/list-of-speakers.ts b/client/src/app/shared/models/agenda/list-of-speakers.ts new file mode 100644 index 000000000..b87f6c99d --- /dev/null +++ b/client/src/app/shared/models/agenda/list-of-speakers.ts @@ -0,0 +1,21 @@ +import { Speaker } from './speaker'; +import { ContentObject } from '../base/content-object'; +import { BaseModelWithContentObject } from '../base/base-model-with-content-object'; + +/** + * Representations of agenda Item + * @ignore + */ +export class ListOfSpeakers extends BaseModelWithContentObject<ListOfSpeakers> { + public static COLLECTIONSTRING = 'agenda/list-of-speakers'; + + public id: number; + public title_information: object; + public speakers: Speaker[]; + public closed: boolean; + public content_object: ContentObject; + + public constructor(input?: any) { + super(ListOfSpeakers.COLLECTIONSTRING, input); + } +} diff --git a/client/src/app/shared/models/agenda/speaker.ts b/client/src/app/shared/models/agenda/speaker.ts index 432816805..18d36e394 100644 --- a/client/src/app/shared/models/agenda/speaker.ts +++ b/client/src/app/shared/models/agenda/speaker.ts @@ -1,14 +1,3 @@ -import { Deserializer } from '../base/deserializer'; - -/** - * Determine the state of the speaker - */ -export enum SpeakerState { - WAITING, - CURRENT, - FINISHED -} - /** * Representation of a speaker in an agenda item. * @@ -16,49 +5,23 @@ export enum SpeakerState { * Part of the 'speakers' list. * @ignore */ -export class Speaker extends Deserializer { - public static COLLECTIONSTRING = 'agenda/item/speakers'; - - public id: number; - public user_id: number; +export interface Speaker { + id: number; + user_id: number; /** * ISO datetime string to indicate the begin time of the speech. Empty if * the speaker has not started */ - public begin_time: string; + begin_time: string; /** * ISO datetime string to indicate the end time of the speech. Empty if the * speech has not ended */ - public end_time: string; + end_time: string; - public weight: number; - public marked: boolean; - public item_id: number; - - /** - * Needs to be completely optional because agenda has (yet) the optional parameter 'speaker' - * @param input - */ - public constructor(input?: any) { - super(input); - } - - /** - * @returns - * - waiting if there is no begin nor end time - * - current if there is a begin time and not end time - * - finished if there are both begin and end time - */ - public get state(): SpeakerState { - if (!this.begin_time && !this.end_time) { - return SpeakerState.WAITING; - } else if (this.begin_time && !this.end_time) { - return SpeakerState.CURRENT; - } else { - return SpeakerState.FINISHED; - } - } + weight: number; + marked: boolean; + item_id: number; } diff --git a/client/src/app/shared/models/assignments/assignment.ts b/client/src/app/shared/models/assignments/assignment.ts index f2db1d103..d4f00306c 100644 --- a/client/src/app/shared/models/assignments/assignment.ts +++ b/client/src/app/shared/models/assignments/assignment.ts @@ -1,12 +1,12 @@ -import { BaseModel } from '../base/base-model'; import { AssignmentRelatedUser } from './assignment-related-user'; import { AssignmentPoll } from './assignment-poll'; +import { BaseModelWithAgendaItemAndListOfSpeakers } from '../base/base-model-with-agenda-item-and-list-of-speakers'; /** * Representation of an assignment. * @ignore */ -export class Assignment extends BaseModel<Assignment> { +export class Assignment extends BaseModelWithAgendaItemAndListOfSpeakers<Assignment> { public static COLLECTIONSTRING = 'assignments/assignment'; public id: number; @@ -17,7 +17,6 @@ export class Assignment extends BaseModel<Assignment> { public assignment_related_users: AssignmentRelatedUser[]; public poll_description_default: number; public polls: AssignmentPoll[]; - public agenda_item_id: number; public tags_id: number[]; public attachments_id: number[]; diff --git a/client/src/app/shared/models/base/base-model-with-agenda-item-and-list-of-speakers.ts b/client/src/app/shared/models/base/base-model-with-agenda-item-and-list-of-speakers.ts new file mode 100644 index 000000000..2773cedb9 --- /dev/null +++ b/client/src/app/shared/models/base/base-model-with-agenda-item-and-list-of-speakers.ts @@ -0,0 +1,16 @@ +import { BaseModel } from './base-model'; +import { BaseModelWithAgendaItem, isBaseModelWithAgendaItem } from './base-model-with-agenda-item'; +import { BaseModelWithListOfSpeakers, isBaseModelWithListOfSpeakers } from './base-model-with-list-of-speakers'; + +export function isBaseModelWithAgendaItemAndListOfSpeakers(obj: any): obj is BaseModelWithAgendaItemAndListOfSpeakers { + return !!obj && isBaseModelWithAgendaItem(obj) && isBaseModelWithListOfSpeakers(obj); +} + +/** + * A base model with an agenda item and a list of speakers. + */ +export abstract class BaseModelWithAgendaItemAndListOfSpeakers<T = object> extends BaseModel<T> + implements BaseModelWithAgendaItem<T>, BaseModelWithListOfSpeakers<T> { + public agenda_item_id: number; + public list_of_speakers_id: number; +} diff --git a/client/src/app/shared/models/base/base-model-with-agenda-item.ts b/client/src/app/shared/models/base/base-model-with-agenda-item.ts new file mode 100644 index 000000000..1d6f5976e --- /dev/null +++ b/client/src/app/shared/models/base/base-model-with-agenda-item.ts @@ -0,0 +1,12 @@ +import { BaseModel } from './base-model'; + +export function isBaseModelWithAgendaItem(obj: any): obj is BaseModelWithAgendaItem { + return !!obj && (<BaseModelWithAgendaItem>obj).agenda_item_id !== undefined; +} + +/** + * A base model which has an agenda item. These models have a `agenda_item_id` in any case. + */ +export abstract class BaseModelWithAgendaItem<T = object> extends BaseModel<T> { + public agenda_item_id: number; +} diff --git a/client/src/app/shared/models/base/base-model-with-content-object.ts b/client/src/app/shared/models/base/base-model-with-content-object.ts new file mode 100644 index 000000000..14ee3cee1 --- /dev/null +++ b/client/src/app/shared/models/base/base-model-with-content-object.ts @@ -0,0 +1,9 @@ +import { ContentObject } from './content-object'; +import { BaseModel } from './base-model'; + +/** + * A base model which has a content object, like items of list of speakers. + */ +export abstract class BaseModelWithContentObject<T = object> extends BaseModel<T> { + public abstract content_object: ContentObject; +} diff --git a/client/src/app/shared/models/base/base-model-with-list-of-speakers.ts b/client/src/app/shared/models/base/base-model-with-list-of-speakers.ts new file mode 100644 index 000000000..fc645fd0a --- /dev/null +++ b/client/src/app/shared/models/base/base-model-with-list-of-speakers.ts @@ -0,0 +1,12 @@ +import { BaseModel } from './base-model'; + +export function isBaseModelWithListOfSpeakers(obj: any): obj is BaseModelWithListOfSpeakers { + return !!obj && (<BaseModelWithListOfSpeakers>obj).list_of_speakers_id !== undefined; +} + +/** + * A base model with a list of speakers. The id is always given by the server. + */ +export abstract class BaseModelWithListOfSpeakers<T = object> extends BaseModel<T> { + public list_of_speakers_id: number; +} diff --git a/client/src/app/shared/models/base/content-object.ts b/client/src/app/shared/models/base/content-object.ts new file mode 100644 index 000000000..3bd5d5c78 --- /dev/null +++ b/client/src/app/shared/models/base/content-object.ts @@ -0,0 +1,12 @@ +export function isContentObject(obj: any): obj is ContentObject { + return !!obj && obj.id !== undefined && obj.collection !== undefined; +} + +/** + * The representation of content objects. Holds the unique combination + * of the collection and the id. + */ +export interface ContentObject { + id: number; + collection: string; +} diff --git a/client/src/app/shared/models/mediafiles/file.ts b/client/src/app/shared/models/mediafiles/file.ts deleted file mode 100644 index e9a7f812b..000000000 --- a/client/src/app/shared/models/mediafiles/file.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Deserializer } from '../base/deserializer'; - -/** - * The name and the type of a mediaFile. - * @ignore - */ -export class File extends Deserializer { - public name: string; - public type: string; - - /** - * Needs to be fully optional, because the 'mediafile'-property in the mediaFile class is optional as well - * @param name The name of the file - * @param type The tape (jpg, png, pdf) - */ - public constructor(input?: any) { - super(input); - } -} diff --git a/client/src/app/shared/models/mediafiles/mediafile.ts b/client/src/app/shared/models/mediafiles/mediafile.ts index 58cf99046..1483fd280 100644 --- a/client/src/app/shared/models/mediafiles/mediafile.ts +++ b/client/src/app/shared/models/mediafiles/mediafile.ts @@ -1,15 +1,23 @@ -import { File } from './file'; -import { BaseModel } from '../base/base-model'; +import { BaseModelWithListOfSpeakers } from '../base/base-model-with-list-of-speakers'; + +interface FileMetadata { + name: string; + type: string; + + // Only for PDFs + pages: number; + encrypted?: boolean; +} /** * Representation of MediaFile. Has the nested property "File" * @ignore */ -export class Mediafile extends BaseModel<Mediafile> { +export class Mediafile extends BaseModelWithListOfSpeakers<Mediafile> { public static COLLECTIONSTRING = 'mediafiles/mediafile'; public id: number; public title: string; - public mediafile: File; + public mediafile: FileMetadata; public media_url_prefix: string; public uploader_id: number; public filesize: string; @@ -20,11 +28,6 @@ export class Mediafile extends BaseModel<Mediafile> { super(Mediafile.COLLECTIONSTRING, input); } - public deserialize(input: any): void { - Object.assign(this, input); - this.mediafile = new File(input.mediafile); - } - /** * Determine the downloadURL * diff --git a/client/src/app/shared/models/motions/motion-block.ts b/client/src/app/shared/models/motions/motion-block.ts index c75c990d9..954d89d77 100644 --- a/client/src/app/shared/models/motions/motion-block.ts +++ b/client/src/app/shared/models/motions/motion-block.ts @@ -1,15 +1,14 @@ -import { BaseModel } from '../base/base-model'; +import { BaseModelWithAgendaItemAndListOfSpeakers } from '../base/base-model-with-agenda-item-and-list-of-speakers'; /** * Representation of a motion block. * @ignore */ -export class MotionBlock extends BaseModel { +export class MotionBlock extends BaseModelWithAgendaItemAndListOfSpeakers<MotionBlock> { public static COLLECTIONSTRING = 'motions/motion-block'; public id: number; public title: string; - public agenda_item_id: number; public constructor(input?: any) { super(MotionBlock.COLLECTIONSTRING, input); diff --git a/client/src/app/shared/models/motions/motion.ts b/client/src/app/shared/models/motions/motion.ts index d9ffb3847..e6bdcad7c 100644 --- a/client/src/app/shared/models/motions/motion.ts +++ b/client/src/app/shared/models/motions/motion.ts @@ -1,6 +1,6 @@ import { MotionSubmitter } from './motion-submitter'; import { MotionPoll } from './motion-poll'; -import { BaseModel } from '../base/base-model'; +import { BaseModelWithAgendaItemAndListOfSpeakers } from '../base/base-model-with-agenda-item-and-list-of-speakers'; export interface MotionComment { id: number; @@ -12,11 +12,11 @@ export interface MotionComment { /** * Representation of Motion. * - * Slightly Defined cause heavy maintaince on server side. + * Slightly defined cause heavy maintenance on server side. * * @ignore */ -export class Motion extends BaseModel { +export class Motion extends BaseModelWithAgendaItemAndListOfSpeakers<Motion> { public static COLLECTIONSTRING = 'motions/motion'; public id: number; @@ -44,7 +44,6 @@ export class Motion extends BaseModel { public tags_id: number[]; public attachments_id: number[]; public polls: MotionPoll[]; - public agenda_item_id: number; public weight: number; public sort_parent_id: number; public created: string; diff --git a/client/src/app/shared/models/topics/topic.ts b/client/src/app/shared/models/topics/topic.ts index 8a5aa4d76..695fd4a47 100644 --- a/client/src/app/shared/models/topics/topic.ts +++ b/client/src/app/shared/models/topics/topic.ts @@ -1,17 +1,16 @@ -import { BaseModel } from '../base/base-model'; +import { BaseModelWithAgendaItemAndListOfSpeakers } from '../base/base-model-with-agenda-item-and-list-of-speakers'; /** * Representation of a topic. * @ignore */ -export class Topic extends BaseModel<Topic> { +export class Topic extends BaseModelWithAgendaItemAndListOfSpeakers<Topic> { public static COLLECTIONSTRING = 'topics/topic'; public id: number; public title: string; public text: string; public attachments_id: number[]; - public agenda_item_id: number; public constructor(input?: any) { super(Topic.COLLECTIONSTRING, input); diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 92b8b3008..5e590acec 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -83,6 +83,7 @@ import { SlideContainerComponent } from './components/slide-container/slide-cont import { CountdownTimeComponent } from './components/contdown-time/countdown-time.component'; import { MediaUploadContentComponent } from './components/media-upload-content/media-upload-content.component'; import { PrecisionPipe } from './pipes/precision.pipe'; +import { SpeakerButtonComponent } from './components/speaker-button/speaker-button.component'; /** * Share Module for all "dumb" components and pipes. @@ -206,7 +207,8 @@ import { PrecisionPipe } from './pipes/precision.pipe'; CountdownTimeComponent, MediaUploadContentComponent, PrecisionPipe, - ScrollingModule + ScrollingModule, + SpeakerButtonComponent ], declarations: [ PermsDirective, @@ -234,7 +236,8 @@ import { PrecisionPipe } from './pipes/precision.pipe'; SlideContainerComponent, CountdownTimeComponent, MediaUploadContentComponent, - PrecisionPipe + PrecisionPipe, + SpeakerButtonComponent ], providers: [ { provide: DateAdapter, useClass: OpenSlidesDateAdapter }, diff --git a/client/src/app/site/agenda/agenda-import.service.ts b/client/src/app/site/agenda/agenda-import.service.ts index 93057f439..949b3b53e 100644 --- a/client/src/app/site/agenda/agenda-import.service.ts +++ b/client/src/app/site/agenda/agenda-import.service.ts @@ -7,7 +7,7 @@ import { BaseImportService, NewEntry } from 'app/core/ui-services/base-import.se import { CreateTopic } from './models/create-topic'; import { DurationService } from 'app/core/ui-services/duration.service'; import { itemVisibilityChoices } from 'app/shared/models/agenda/item'; -import { TopicRepositoryService } from '../../core/repositories/agenda/topic-repository.service'; +import { TopicRepositoryService } from '../../core/repositories/topics/topic-repository.service'; import { ViewCreateTopic } from './models/view-create-topic'; @Injectable({ diff --git a/client/src/app/site/agenda/agenda-routing.module.ts b/client/src/app/site/agenda/agenda-routing.module.ts index c43c1823d..2190e1168 100644 --- a/client/src/app/site/agenda/agenda-routing.module.ts +++ b/client/src/app/site/agenda/agenda-routing.module.ts @@ -18,9 +18,9 @@ const routes: Routes = [ canDeactivate: [WatchSortingTreeGuard], data: { basePerm: 'agenda.can_manage' } }, - { path: 'speakers', component: ListOfSpeakersComponent, data: { basePerm: 'agenda.can_see' } }, - { path: 'topics/:id', component: TopicDetailComponent, data: { basePerm: 'agenda.can_see' } }, - { path: ':id/speakers', component: ListOfSpeakersComponent, data: { basePerm: 'agenda.can_see' } } + { path: 'speakers', component: ListOfSpeakersComponent, data: { basePerm: 'agenda.can_see_list_of_speakers' } }, + { path: 'speakers/:id', component: ListOfSpeakersComponent, data: { basePerm: 'agenda.can_see_list_of_speakers' } }, + { path: 'topics/:id', component: TopicDetailComponent, data: { basePerm: 'agenda.can_see' } } ]; @NgModule({ diff --git a/client/src/app/site/agenda/agenda.config.ts b/client/src/app/site/agenda/agenda.config.ts index e65a82046..5d2de4223 100644 --- a/client/src/app/site/agenda/agenda.config.ts +++ b/client/src/app/site/agenda/agenda.config.ts @@ -2,14 +2,23 @@ import { AppConfig } from '../../core/app-config'; import { Item } from '../../shared/models/agenda/item'; import { Topic } from '../../shared/models/topics/topic'; import { ItemRepositoryService } from 'app/core/repositories/agenda/item-repository.service'; -import { TopicRepositoryService } from 'app/core/repositories/agenda/topic-repository.service'; +import { TopicRepositoryService } from 'app/core/repositories/topics/topic-repository.service'; import { ViewTopic } from './models/view-topic'; import { ViewItem } from './models/view-item'; +import { ListOfSpeakers } from 'app/shared/models/agenda/list-of-speakers'; +import { ViewListOfSpeakers } from './models/view-list-of-speakers'; +import { ListOfSpeakersRepositoryService } from 'app/core/repositories/agenda/list-of-speakers-repository.service'; export const AgendaAppConfig: AppConfig = { name: 'agenda', models: [ { collectionString: 'agenda/item', model: Item, viewModel: ViewItem, repository: ItemRepositoryService }, + { + collectionString: 'agenda/list-of-speakers', + model: ListOfSpeakers, + viewModel: ViewListOfSpeakers, + repository: ListOfSpeakersRepositoryService + }, { collectionString: 'topics/topic', model: Topic, diff --git a/client/src/app/site/agenda/components/agenda-list/agenda-list.component.html b/client/src/app/site/agenda/components/agenda-list/agenda-list.component.html index f91861a40..f1b69feb8 100644 --- a/client/src/app/site/agenda/components/agenda-list/agenda-list.component.html +++ b/client/src/app/site/agenda/components/agenda-list/agenda-list.component.html @@ -77,14 +77,7 @@ <ng-container matColumnDef="speakers"> <mat-header-cell *matHeaderCellDef mat-sort-header>Speakers</mat-header-cell> <mat-cell *matCellDef="let item"> - <button mat-icon-button (click)="onSpeakerIcon(item, $event)" [disabled]="isMultiSelect"> - <mat-icon - [matBadge]="item.waitingSpeakerAmount > 0 ? item.waitingSpeakerAmount : null" - matBadgeColor="accent" - > - mic - </mat-icon> - </button> + <os-speaker-button [object]="item.contentObjectData" [disabled]="isMultiSelect"></os-speaker-button> </mat-cell> </ng-container> diff --git a/client/src/app/site/agenda/components/agenda-list/agenda-list.component.ts b/client/src/app/site/agenda/components/agenda-list/agenda-list.component.ts index a5276fc13..43dac913b 100644 --- a/client/src/app/site/agenda/components/agenda-list/agenda-list.component.ts +++ b/client/src/app/site/agenda/components/agenda-list/agenda-list.component.ts @@ -21,6 +21,8 @@ import { ViewItem } from '../../models/view-item'; import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; import { _ } from 'app/core/translate/translation-marker'; import { StorageService } from 'app/core/core-services/storage.service'; +import { ListOfSpeakersRepositoryService } from 'app/core/repositories/agenda/list-of-speakers-repository.service'; +import { ViewListOfSpeakers } from '../../models/view-list-of-speakers'; /** * List view for the agenda. @@ -35,12 +37,12 @@ export class AgendaListComponent extends ListViewBaseComponent<ViewItem, Item, I /** * Determine the display columns in desktop view */ - public displayedColumnsDesktop: string[] = ['title', 'info', 'speakers']; + public displayedColumnsDesktop: string[] = ['title', 'info']; /** * Determine the display columns in mobile view */ - public displayedColumnsMobile: string[] = ['title', 'speakers']; + public displayedColumnsMobile: string[] = ['title']; public isNumberingAllowed: boolean; @@ -105,7 +107,8 @@ export class AgendaListComponent extends ListViewBaseComponent<ViewItem, Item, I private csvExport: AgendaCsvExportService, public filterService: AgendaFilterListService, private agendaPdfService: AgendaPdfService, - private pdfService: PdfDocumentService + private pdfService: PdfDocumentService, + private listOfSpeakersRepo: ListOfSpeakersRepositoryService ) { super(titleService, translate, matSnackBar, repo, route, storage, filterService); @@ -126,6 +129,16 @@ export class AgendaListComponent extends ListViewBaseComponent<ViewItem, Item, I this.setFulltextFilter(); } + /** + * Gets the list of speakers for an agenda item. Might be null, if the items content + * object does not have a list of speakers. + * + * @param item The item to get the list of speakers from + */ + public getListOfSpeakers(item: ViewItem): ViewListOfSpeakers | null { + return this.listOfSpeakersRepo.findByContentObject(item.item.content_object); + } + /** * Links to the content object. * @@ -183,16 +196,6 @@ export class AgendaListComponent extends ListViewBaseComponent<ViewItem, Item, I await this.repo.update({ closed: !item.closed }, item).then(null, this.raiseError); } - /** - * Handler for the speakers button - * - * @param item indicates the row that was clicked on - */ - public onSpeakerIcon(item: ViewItem, event: MouseEvent): void { - event.stopPropagation(); - this.router.navigate([`${item.id}/speakers`], { relativeTo: this.route }); - } - /** * Handler for the plus button. * Comes from the HeadBar Component @@ -258,6 +261,9 @@ export class AgendaListComponent extends ListViewBaseComponent<ViewItem, Item, I */ public getColumnDefinition(): string[] { let columns = this.vp.isMobile ? this.displayedColumnsMobile : this.displayedColumnsDesktop; + if (this.operator.hasPerms('agenda.can_see_list_of_speakers')) { + columns = columns.concat(['speakers']); + } if (this.operator.hasPerms('agenda.can_manage')) { columns = columns.concat(['menu']); } diff --git a/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.html b/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.html index 90b76b116..8d30f7b9d 100644 --- a/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.html +++ b/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.html @@ -2,8 +2,8 @@ <!-- Title --> <div class="title-slot"> <h2> - <span *ngIf="!currentListOfSpeakers" translate>List of speakers</span> - <span *ngIf="currentListOfSpeakers" translate>Current list of speakers</span> + <span *ngIf="!isCurrentListOfSpeakers" translate>List of speakers</span> + <span *ngIf="isCurrentListOfSpeakers" translate>Current list of speakers</span> </h2> </div> <div class="menu-slot" *osPerms="['agenda.can_manage_list_of_speakers', 'core.can_manage_projector']"> @@ -11,9 +11,9 @@ </div> </os-head-bar> -<mat-card class="os-card speaker-card" *ngIf="viewItem"> +<mat-card class="os-card speaker-card" *ngIf="viewListOfSpeakers"> <!-- Title --> - <h1 class="los-title on-transition-fade" *ngIf="viewItem">{{ viewItem.getTitle() }}</h1> + <h1 class="los-title on-transition-fade">{{ viewListOfSpeakers.getTitle() }}</h1> <!-- List of finished speakers --> <mat-expansion-panel *ngIf="finishedSpeakers && finishedSpeakers.length > 0" class="finished-list"> @@ -24,7 +24,7 @@ <mat-list-item *ngFor="let speaker of finishedSpeakers; let number = index"> <div class="finished-speaker-grid"> <div class="number">{{ number + 1 }}.</div> - <div class="name">{{ speaker }}</div> + <div class="name">{{ speaker.getTitle() }}</div> <div class="time"> {{ durationString(speaker) }} ({{ 'Start time' | translate }}: {{ startTimeToString(speaker) }}) </div> @@ -53,7 +53,7 @@ <mat-icon>mic</mat-icon> </span> - <span class="name">{{ activeSpeaker }}</span> + <span class="name">{{ activeSpeaker.getTitle() }}</span> <span class="suffix"> <!-- Stop speaker button --> @@ -77,29 +77,29 @@ [enable]="opCanManage()" (sortEvent)="onSortingChange($event)" > - <!-- implicit item references into the component using ng-template slot --> - <ng-template let-item> + <!-- implicit speaker references into the component using ng-template slot --> + <ng-template let-speaker> <span *osPerms="'agenda.can_manage_list_of_speakers'"> - <span *ngIf="hasSpokenCount(item)" class="red-warning-text speaker-warning"> - {{ hasSpokenCount(item) + 1 }}. <span translate>contribution</span> + <span *ngIf="hasSpokenCount(speaker)" class="red-warning-text speaker-warning"> + {{ hasSpokenCount(speaker) + 1 }}. <span translate>contribution</span> </span> - <span *ngIf="item.gender">({{ item.gender | translate }})</span> + <span *ngIf="speaker.gender">({{ speaker.gender | translate }})</span> </span> <!-- Start, start and delete buttons --> <span *osPerms="'agenda.can_manage_list_of_speakers'"> <!-- start button --> - <button mat-icon-button matTooltip="{{ 'Begin speech' | translate }}" (click)="onStartButton(item)"> + <button mat-icon-button matTooltip="{{ 'Begin speech' | translate }}" (click)="onStartButton(speaker)"> <mat-icon>play_arrow</mat-icon> </button> <!-- star button --> - <button mat-icon-button matTooltip="{{ 'Mark speaker' | translate }}" (click)="onMarkButton(item)"> - <mat-icon>{{ item.marked ? 'star' : 'star_border' }}</mat-icon> + <button mat-icon-button matTooltip="{{ 'Mark speaker' | translate }}" (click)="onMarkButton(speaker)"> + <mat-icon>{{ speaker.marked ? 'star' : 'star_border' }}</mat-icon> </button> <!-- delete button --> - <button mat-icon-button matTooltip="{{ 'Remove' | translate }}" (click)="onDeleteButton(item)"> + <button mat-icon-button matTooltip="{{ 'Remove' | translate }}" (click)="onDeleteButton(speaker)"> <mat-icon>close</mat-icon> </button> </span> @@ -139,39 +139,39 @@ <mat-menu #speakerMenu="matMenu"> <os-projector-button - *ngIf="viewItem && projectors && projectors.length > 1" + *ngIf="viewListOfSpeakers && projectors && projectors.length > 1" [object]="getClosSlide()" [menuItem]="true" text="Current list of speakers (as slide)" ></os-projector-button> <os-projector-button - *ngIf="viewItem" - [object]="viewItem.listOfSpeakersSlide" + *ngIf="viewListOfSpeakers" + [object]="viewListOfSpeakers" [menuItem]="true" text="List of speakers" ></os-projector-button> <os-projector-button - *ngIf="viewItem" - [object]="viewItem.contentObject" + *ngIf="viewListOfSpeakers" + [object]="viewListOfSpeakers.contentObject" [menuItem]="true" [text]="getContentObjectProjectorButtonText()" ></os-projector-button> - <button mat-menu-item *ngIf="closedList" (click)="openSpeakerList()"> + <button mat-menu-item *ngIf="isListOfSpeakersClosed" (click)="openSpeakerList()"> <mat-icon>mic</mat-icon> <span translate>Open list of speakers</span> </button> - <button mat-menu-item *ngIf="!closedList" (click)="closeSpeakerList()"> + <button mat-menu-item *ngIf="!isListOfSpeakersClosed" (click)="closeSpeakerList()"> <mat-icon>mic_off</mat-icon> <span translate>Close list of speakers</span> </button> - <mat-divider *ngIf="!emptyList"></mat-divider> + <mat-divider *ngIf="!isListOfSpeakersEmpty"></mat-divider> - <button mat-menu-item (click)="clearSpeakerList()" *ngIf="!emptyList" class="red-warning-text"> + <button mat-menu-item (click)="clearSpeakerList()" *ngIf="!isListOfSpeakersEmpty" class="red-warning-text"> <mat-icon>delete</mat-icon> <span translate>Remove all speakers</span> </button> diff --git a/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.ts b/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.ts index 82bab8bd7..95a3f771e 100644 --- a/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.ts +++ b/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.ts @@ -11,19 +11,17 @@ import { BaseViewComponent } from 'app/site/base/base-view'; import { OperatorService } from 'app/core/core-services/operator.service'; import { ProjectorRepositoryService } from 'app/core/repositories/projector/projector-repository.service'; import { PromptService } from 'app/core/ui-services/prompt.service'; -import { SpeakerState } from 'app/shared/models/agenda/speaker'; -import { SpeakerRepositoryService } from 'app/core/repositories/agenda/speaker-repository.service'; -import { ViewItem } from '../../models/view-item'; -import { ViewSpeaker } from '../../models/view-speaker'; +import { ViewSpeaker, SpeakerState } from '../../models/view-speaker'; import { ViewProjector } from 'app/site/projector/models/view-projector'; import { ViewUser } from 'app/site/users/models/view-user'; import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service'; import { DurationService } from 'app/core/ui-services/duration.service'; -import { CurrentAgendaItemService } from 'app/site/projector/services/current-agenda-item.service'; -import { ItemRepositoryService } from 'app/core/repositories/agenda/item-repository.service'; import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service'; +import { CurrentListOfSpeakersService } from 'app/site/projector/services/current-agenda-item.service'; import { CurrentListOfSpeakersSlideService } from 'app/site/projector/services/current-list-of-of-speakers-slide.service'; import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; +import { ListOfSpeakersRepositoryService } from 'app/core/repositories/agenda/list-of-speakers-repository.service'; +import { ViewListOfSpeakers } from '../../models/view-list-of-speakers'; /** * The list of speakers for agenda items. @@ -37,12 +35,12 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit /** * Determine if the user is viewing the current list if speakers */ - public currentListOfSpeakers = false; + public isCurrentListOfSpeakers = false; /** * Holds the view item to the given topic */ - public viewItem: ViewItem; + public viewListOfSpeakers: ViewListOfSpeakers; /** * Holds the speakers @@ -80,19 +78,19 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit public addSpeakerForm: FormGroup; /** - * @returns true if the items' speaker list is currently not open + * @returns true if the list of speakers list is currently closed */ - public get closedList(): boolean { - return this.viewItem && this.viewItem.item.speaker_list_closed; + public get isListOfSpeakersClosed(): boolean { + return this.viewListOfSpeakers && this.viewListOfSpeakers.closed; } - public get emptyList(): boolean { + public get isListOfSpeakersEmpty(): boolean { if (this.speakers && this.speakers.length) { return false; } else if (this.finishedSpeakers && this.finishedSpeakers.length) { return false; } - return this.activeSpeaker ? false : true; + return !this.activeSpeaker; } /** @@ -100,11 +98,10 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit */ private closReferenceProjectorId: number | null; - private closItemSubscription: Subscription | null; + private closSubscription: Subscription | null; /** - * Constructor for speaker list component. Generates the forms and subscribes - * to the {@link currentListOfSpeakers} + * Constructor for speaker list component. Generates the forms. * * @param title * @param translate @@ -112,43 +109,29 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit * @param projectorRepo * @param route Angulars ActivatedRoute * @param DS the DataStore - * @param repo Repository for speakers - * @param itemRepo Repository for agendaItems - * @param op the current operator + * @param listOfSpeakersRepo Repository for list of speakers + * @param operator the current operator * @param promptService - * @param currentAgendaItemService + * @param currentListOfSpeakersService * @param durationService helper for speech duration display */ public constructor( title: Title, protected translate: TranslateService, // protected required for ng-translate-extract snackBar: MatSnackBar, - projectorRepo: ProjectorRepositoryService, + private projectorRepo: ProjectorRepositoryService, private route: ActivatedRoute, - private repo: SpeakerRepositoryService, - private itemRepo: ItemRepositoryService, - private op: OperatorService, + private listOfSpeakersRepo: ListOfSpeakersRepositoryService, + private operator: OperatorService, private promptService: PromptService, - private currentAgendaItemService: CurrentAgendaItemService, + private currentListOfSpeakersService: CurrentListOfSpeakersService, private durationService: DurationService, private userRepository: UserRepositoryService, private collectionStringMapper: CollectionStringMapperService, private currentListOfSpeakersSlideService: CurrentListOfSpeakersSlideService ) { super(title, translate, snackBar); - this.isCurrentListOfSpeakers(); this.addSpeakerForm = new FormGroup({ user_id: new FormControl([]) }); - - if (this.currentListOfSpeakers) { - this.projectors = projectorRepo.getSortedViewModelList(); - this.updateClosProjector(); - projectorRepo.getViewModelListObservable().subscribe(newProjectors => { - this.projectors = newProjectors; - this.updateClosProjector(); - }); - } else { - this.getItemByUrl(); - } } /** @@ -158,6 +141,24 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit * React to form changes */ public ngOnInit(): void { + // Check, if we are on the current list of speakers. + this.isCurrentListOfSpeakers = + this.route.snapshot.url.length > 0 + ? this.route.snapshot.url[this.route.snapshot.url.length - 1].path === 'speakers' + : true; + + if (this.isCurrentListOfSpeakers) { + this.projectors = this.projectorRepo.getSortedViewModelList(); + this.updateClosProjector(); + this.projectorRepo.getViewModelListObservable().subscribe(newProjectors => { + this.projectors = newProjectors; + this.updateClosProjector(); + }); + } else { + const id = +this.route.snapshot.url[this.route.snapshot.url.length - 1].path; + this.setListOfSpeakersId(id); + } + // load and observe users this.users = this.userRepository.getViewModelListBehaviorSubject(); @@ -171,16 +172,7 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit } public opCanManage(): boolean { - return this.op.hasPerms('agenda.can_manage_list_of_speakers'); - } - - /** - * Check the URL to determine a current list of Speakers - */ - private isCurrentListOfSpeakers(): void { - if (this.route.snapshot.url[0]) { - this.currentListOfSpeakers = this.route.snapshot.url[0].path === 'speakers'; - } + return this.operator.hasPerms('agenda.can_manage_list_of_speakers'); } /** @@ -198,14 +190,14 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit if (this.projectorSubscription) { this.projectorSubscription.unsubscribe(); - this.viewItem = null; + this.viewListOfSpeakers = null; } - this.projectorSubscription = this.currentAgendaItemService - .getAgendaItemObservable(referenceProjector) - .subscribe(item => { - if (item) { - this.setSpeakerList(item.id); + this.projectorSubscription = this.currentListOfSpeakersService + .getListOfSpeakersObservable(referenceProjector) + .subscribe(listOfSpeakers => { + if (listOfSpeakers) { + this.setListOfSpeakersId(listOfSpeakers.id); } }); } @@ -218,31 +210,20 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit } /** - * Extract the ID from the url - * Determine whether the speaker list belongs to a motion or a topic - */ - private getItemByUrl(): void { - const id = +this.route.snapshot.url[0]; - this.setSpeakerList(id); - } - - /** - * Sets the current item as list of speakers + * Sets the current list of speakers id to show * - * @param item the item to use as List of Speakers + * @param id the list of speakers id */ - private setSpeakerList(id: number): void { - if (this.closItemSubscription) { - this.closItemSubscription.unsubscribe(); + private setListOfSpeakersId(id: number): void { + if (this.closSubscription) { + this.closSubscription.unsubscribe(); } - this.closItemSubscription = this.itemRepo.getViewModelObservable(id).subscribe(newAgendaItem => { - if (newAgendaItem) { - this.viewItem = newAgendaItem; - const allSpeakers = this.repo.createSpeakerList(newAgendaItem.item); + this.closSubscription = this.listOfSpeakersRepo.getViewModelObservable(id).subscribe(listOfSpeakers => { + if (listOfSpeakers) { + this.viewListOfSpeakers = listOfSpeakers; + const allSpeakers = this.viewListOfSpeakers.speakers.sort((a, b) => a.weight - b.weight); this.speakers = allSpeakers.filter(speaker => speaker.state === SpeakerState.WAITING); - // Since the speaker repository is not a normal repository, sorting cannot be handled there - this.speakers.sort((a: ViewSpeaker, b: ViewSpeaker) => a.weight - b.weight); this.finishedSpeakers = allSpeakers.filter(speaker => speaker.state === SpeakerState.FINISHED); // convert begin time to date and sort @@ -259,11 +240,11 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit /** * @returns the verbose name of the model of the content object from viewItem. - * If a motion is the current content object, "Motion" will be the returned value. + * E.g. if a motion is the current content object, "Motion" will be the returned value. */ public getContentObjectProjectorButtonText(): string { const verboseName = this.collectionStringMapper - .getRepository(this.viewItem.item.content_object.collection) + .getRepository(this.viewListOfSpeakers.listOfSpeakers.content_object.collection) .getVerboseName(); return verboseName; } @@ -274,7 +255,9 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit * @param userId the user id to add to the list. No parameter adds the operators user as speaker. */ public addNewSpeaker(userId?: number): void { - this.repo.create(userId, this.viewItem).then(() => this.addSpeakerForm.reset(), this.raiseError); + this.listOfSpeakersRepo + .createSpeaker(this.viewListOfSpeakers, userId) + .then(() => this.addSpeakerForm.reset(), this.raiseError); } /** @@ -285,32 +268,34 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit public onSortingChange(listInNewOrder: ViewSpeaker[]): void { // extract the ids from the ViewSpeaker array const userIds = listInNewOrder.map(speaker => speaker.id); - this.repo.sortSpeakers(userIds, this.viewItem.item).then(null, this.raiseError); + this.listOfSpeakersRepo.sortSpeakers(this.viewListOfSpeakers, userIds).then(null, this.raiseError); } /** * Click on the mic button to mark a speaker as speaking * - * @param item the speaker marked in the list + * @param speaker the speaker marked in the list */ - public onStartButton(item: ViewSpeaker): void { - this.repo.startSpeaker(item.id, this.viewItem).then(null, this.raiseError); + public onStartButton(speaker: ViewSpeaker): void { + this.listOfSpeakersRepo.startSpeaker(this.viewListOfSpeakers, speaker).then(null, this.raiseError); } /** * Click on the mic-cross button */ public onStopButton(): void { - this.repo.stopCurrentSpeaker(this.viewItem).then(null, this.raiseError); + this.listOfSpeakersRepo.stopCurrentSpeaker(this.viewListOfSpeakers).then(null, this.raiseError); } /** - * Click on the star button + * Click on the star button. Toggles the marked attribute. * - * @param item + * @param speaker The speaker clicked on. */ - public onMarkButton(item: ViewSpeaker): void { - this.repo.markSpeaker(item.user.id, !item.marked, this.viewItem).then(null, this.raiseError); + public onMarkButton(speaker: ViewSpeaker): void { + this.listOfSpeakersRepo + .markSpeaker(this.viewListOfSpeakers, speaker, !speaker.marked) + .then(null, this.raiseError); } /** @@ -319,7 +304,9 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit * @param speaker */ public onDeleteButton(speaker?: ViewSpeaker): void { - this.repo.delete(this.viewItem, speaker ? speaker.id : null).then(null, this.raiseError); + this.listOfSpeakersRepo + .delete(this.viewListOfSpeakers, speaker ? speaker.id : null) + .then(null, this.raiseError); } /** @@ -328,7 +315,7 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit * @returns whether or not the current operator is in the list */ public isOpInList(): boolean { - return this.speakers.some(speaker => speaker.user.id === this.op.user.id); + return this.speakers.some(speaker => speaker.userId === this.operator.user.id); } /** @@ -342,20 +329,24 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit } /** - * Closes the current speaker list + * Closes the current list of speakers */ public closeSpeakerList(): Promise<void> { - if (!this.viewItem.item.speaker_list_closed) { - return this.itemRepo.update({ speaker_list_closed: true }, this.viewItem); + if (!this.viewListOfSpeakers.closed) { + return this.listOfSpeakersRepo + .update({ closed: true }, this.viewListOfSpeakers) + .then(null, this.raiseError); } } /** - * Opens the speaker list for the current item + * Opens the list of speaker for the current item */ public openSpeakerList(): Promise<void> { - if (this.viewItem.item.speaker_list_closed) { - return this.itemRepo.update({ speaker_list_closed: false }, this.viewItem); + if (this.viewListOfSpeakers.closed) { + return this.listOfSpeakersRepo + .update({ closed: false }, this.viewListOfSpeakers) + .then(null, this.raiseError); } } @@ -368,7 +359,7 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit 'Are you sure you want to delete all speakers from this list of speakers?' ); if (await this.promptService.open(title, null)) { - this.repo.deleteAllSpeakers(this.viewItem); + this.listOfSpeakersRepo.deleteAllSpeakers(this.viewListOfSpeakers); } } diff --git a/client/src/app/site/agenda/components/topic-detail/topic-detail.component.html b/client/src/app/site/agenda/components/topic-detail/topic-detail.component.html index 9804e9f22..6a1929d3a 100644 --- a/client/src/app/site/agenda/components/topic-detail/topic-detail.component.html +++ b/client/src/app/site/agenda/components/topic-detail/topic-detail.component.html @@ -106,10 +106,7 @@ </mat-card> <mat-menu #topicExtraMenu="matMenu"> - <button mat-menu-item *ngIf="topic" [routerLink]="getSpeakerLink()"> - <mat-icon>mic</mat-icon> - <span translate>List of speakers</span> - </button> + <os-speaker-button [object]="topic" [menuItem]="true"></os-speaker-button> <div *osPerms="'agenda.can_manage'"> <mat-divider></mat-divider> <button mat-menu-item class="red-warning-text" (click)="onDeleteButton()"> diff --git a/client/src/app/site/agenda/components/topic-detail/topic-detail.component.ts b/client/src/app/site/agenda/components/topic-detail/topic-detail.component.ts index 2d91640f9..095e76cf7 100644 --- a/client/src/app/site/agenda/components/topic-detail/topic-detail.component.ts +++ b/client/src/app/site/agenda/components/topic-detail/topic-detail.component.ts @@ -8,7 +8,7 @@ import { TranslateService } from '@ngx-translate/core'; import { BaseViewComponent } from 'app/site/base/base-view'; import { PromptService } from 'app/core/ui-services/prompt.service'; -import { TopicRepositoryService } from 'app/core/repositories/agenda/topic-repository.service'; +import { TopicRepositoryService } from 'app/core/repositories/topics/topic-repository.service'; import { ViewTopic } from '../../models/view-topic'; import { OperatorService } from 'app/core/core-services/operator.service'; import { BehaviorSubject } from 'rxjs'; @@ -195,20 +195,6 @@ export class TopicDetailComponent extends BaseViewComponent { }); } - /** - * Create the absolute path to the corresponding list of speakers - * - * @returns the link to the list of speakers as string - */ - public getSpeakerLink(): string { - if (!this.newTopic && this.topic) { - const item = this.topic.getAgendaItem(); - if (item) { - return `/agenda/${item.id}/speakers`; - } - } - } - /** * Handler for the delete button. Uses the PromptService */ diff --git a/client/src/app/site/agenda/models/view-create-topic.ts b/client/src/app/site/agenda/models/view-create-topic.ts index e30d1f2a6..6ee3a5195 100644 --- a/client/src/app/site/agenda/models/view-create-topic.ts +++ b/client/src/app/site/agenda/models/view-create-topic.ts @@ -1,6 +1,5 @@ import { CreateTopic } from './create-topic'; import { ViewTopic } from './view-topic'; -import { Topic } from 'app/shared/models/topics/topic'; /** * View model for Topic('Agenda item') creation. @@ -8,7 +7,7 @@ import { Topic } from 'app/shared/models/topics/topic'; */ export class ViewCreateTopic extends ViewTopic { public get topic(): CreateTopic { - return this._topic as CreateTopic; + return this._model as CreateTopic; } /** @@ -109,10 +108,6 @@ export class ViewCreateTopic extends ViewTopic { super(topic); } - public getModel(): Topic { - return super.getModel(); - } - public getVerboseName = () => { throw new Error('This should not be used'); }; diff --git a/client/src/app/site/agenda/models/view-item.ts b/client/src/app/site/agenda/models/view-item.ts index 1e512ee1e..9a2f44d76 100644 --- a/client/src/app/site/agenda/models/view-item.ts +++ b/client/src/app/site/agenda/models/view-item.ts @@ -1,25 +1,23 @@ -import { BaseViewModel } from '../../base/base-view-model'; import { Item, itemVisibilityChoices } from 'app/shared/models/agenda/item'; -import { Speaker, SpeakerState } from 'app/shared/models/agenda/speaker'; -import { BaseAgendaViewModel, isAgendaBaseModel } from 'app/site/base/base-agenda-view-model'; -import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; +import { + BaseViewModelWithAgendaItem, + isBaseViewModelWithAgendaItem +} from 'app/site/base/base-view-model-with-agenda-item'; +import { BaseViewModelWithContentObject } from 'app/site/base/base-view-model-with-content-object'; +import { ContentObject } from 'app/shared/models/base/content-object'; -export class ViewItem extends BaseViewModel { +export interface ItemTitleInformation { + contentObject: BaseViewModelWithAgendaItem; + contentObjectData: ContentObject; + title_information: object; +} + +export class ViewItem extends BaseViewModelWithContentObject<Item, BaseViewModelWithAgendaItem> + implements ItemTitleInformation { public static COLLECTIONSTRING = Item.COLLECTIONSTRING; - private _item: Item; - private _contentObject: BaseAgendaViewModel; - public get item(): Item { - return this._item; - } - - public get contentObject(): BaseAgendaViewModel { - return this._contentObject; - } - - public get id(): number { - return this.item.id; + return this._model; } public get itemNumber(): string { @@ -34,13 +32,6 @@ export class ViewItem extends BaseViewModel { return this.item.duration; } - /** - * Gets the amount of waiting speakers - */ - public get waitingSpeakerAmount(): number { - return this.item.speakers.filter(speaker => speaker.state === SpeakerState.WAITING).length; - } - public get type(): number { return this.item.type; } @@ -81,13 +72,6 @@ export class ViewItem extends BaseViewModel { return type ? type.csvName : ''; } - /** - * TODO: make the repository set the ViewSpeakers here. - */ - public get speakers(): Speaker[] { - return this.item.speakers; - } - /** * @returns the weight the server assigns to that item. Mostly useful for sorting within * it's own hierarchy level (items sharing a parent) @@ -103,46 +87,7 @@ export class ViewItem extends BaseViewModel { return this.item.parent_id; } - /** - * This is set by the repository - */ - public getVerboseName: () => string; - public getTitle: () => string; - public getListTitle: () => string; - - public listOfSpeakersSlide: ProjectorElementBuildDeskriptor = { - getBasicProjectorElement: options => ({ - name: 'agenda/list-of-speakers', - id: this.id, - getIdentifiers: () => ['name', 'id'] - }), - slideOptions: [], - projectionDefaultName: 'agenda_list_of_speakers', - getDialogTitle: () => this.getTitle() - }; - - public constructor(item: Item, contentObject: BaseAgendaViewModel) { - super(Item.COLLECTIONSTRING); - this._item = item; - this._contentObject = contentObject; - } - - public getModel(): Item { - return this.item; - } - - public updateDependencies(update: BaseViewModel): boolean { - if ( - update && - update.collectionString === this.item.content_object.collection && - update.id === this.item.content_object.id - ) { - if (!isAgendaBaseModel(update)) { - throw new Error('The item is not an BaseAgendaViewModel:' + update); - } - this._contentObject = update as BaseAgendaViewModel; - return true; - } - return false; + public constructor(item: Item, contentObject?: BaseViewModelWithAgendaItem) { + super(Item.COLLECTIONSTRING, item, isBaseViewModelWithAgendaItem, 'BaseViewModelWithAgendaItem', contentObject); } } diff --git a/client/src/app/site/agenda/models/view-list-of-speakers.ts b/client/src/app/site/agenda/models/view-list-of-speakers.ts new file mode 100644 index 000000000..6f3187a5c --- /dev/null +++ b/client/src/app/site/agenda/models/view-list-of-speakers.ts @@ -0,0 +1,92 @@ +import { BaseViewModel } from '../../base/base-view-model'; +import { Item } from 'app/shared/models/agenda/item'; +import { ProjectorElementBuildDeskriptor, Projectable } from 'app/site/base/projectable'; +import { ListOfSpeakers } from 'app/shared/models/agenda/list-of-speakers'; +import { ViewSpeaker, SpeakerState } from './view-speaker'; +import { + BaseViewModelWithListOfSpeakers, + isBaseViewModelWithListOfSpeakers +} from 'app/site/base/base-view-model-with-list-of-speakers'; +import { ViewUser } from 'app/site/users/models/view-user'; +import { BaseViewModelWithContentObject } from 'app/site/base/base-view-model-with-content-object'; +import { ContentObject } from 'app/shared/models/base/content-object'; + +export interface ListOfSpeakersTitleInformation { + contentObject: BaseViewModelWithListOfSpeakers; + contentObjectData: ContentObject; + title_information: object; +} + +export class ViewListOfSpeakers extends BaseViewModelWithContentObject<ListOfSpeakers, BaseViewModelWithListOfSpeakers> + implements ListOfSpeakersTitleInformation, Projectable { + public static COLLECTIONSTRING = ListOfSpeakers.COLLECTIONSTRING; + + private _speakers?: ViewSpeaker[]; + + public get listOfSpeakers(): ListOfSpeakers { + return this._model; + } + + public get speakers(): ViewSpeaker[] { + return this._speakers; + } + + public get title_information(): object { + return this.listOfSpeakers.title_information; + } + + /** + * Gets the amount of waiting speakers + */ + public get waitingSpeakerAmount(): number { + return this.speakers.filter(speaker => speaker.state === SpeakerState.WAITING).length; + } + + public get closed(): boolean { + return this.listOfSpeakers.closed; + } + + public get listOfSpeakersUrl(): string { + return `/agenda/speakers/${this.id}`; + } + + public constructor( + listOfSpeakers: ListOfSpeakers, + speakers: ViewSpeaker[], + contentObject?: BaseViewModelWithListOfSpeakers + ) { + super( + Item.COLLECTIONSTRING, + listOfSpeakers, + isBaseViewModelWithListOfSpeakers, + 'BaseViewModelWithListOfSpeakers', + contentObject + ); + this._speakers = speakers; + } + + public getProjectorTitle(): string { + return this.getTitle(); + } + + public getSlide(): ProjectorElementBuildDeskriptor { + return { + getBasicProjectorElement: options => ({ + name: 'agenda/list-of-speakers', + id: this.id, + getIdentifiers: () => ['name', 'id'] + }), + slideOptions: [], + projectionDefaultName: 'agenda_list_of_speakers', + getDialogTitle: () => this.getTitle() + }; + } + + public updateDependencies(update: BaseViewModel): boolean { + const updated = super.updateDependencies(update); + if (!updated && update instanceof ViewUser) { + return this.speakers.map(speaker => speaker.updateDependencies(update)).some(x => x); + } + return updated; + } +} diff --git a/client/src/app/site/agenda/models/view-speaker.ts b/client/src/app/site/agenda/models/view-speaker.ts index 73c7f7d2c..f5fda0fa2 100644 --- a/client/src/app/site/agenda/models/view-speaker.ts +++ b/client/src/app/site/agenda/models/view-speaker.ts @@ -1,14 +1,24 @@ import { BaseViewModel } from 'app/site/base/base-view-model'; -import { Speaker, SpeakerState } from 'app/shared/models/agenda/speaker'; +import { Speaker } from 'app/shared/models/agenda/speaker'; import { ViewUser } from 'app/site/users/models/view-user'; -import { User } from 'app/shared/models/users/user'; +import { Updateable } from 'app/site/base/updateable'; +import { Identifiable } from 'app/shared/models/base/identifiable'; + +/** + * Determine the state of the speaker + */ +export enum SpeakerState { + WAITING, + CURRENT, + FINISHED +} /** * Provides "safe" access to a speaker with all it's components */ -export class ViewSpeaker extends BaseViewModel { +export class ViewSpeaker implements Updateable, Identifiable { private _speaker: Speaker; - private _user: ViewUser | null; + private _user?: ViewUser; public get speaker(): Speaker { return this._speaker; @@ -22,6 +32,10 @@ export class ViewSpeaker extends BaseViewModel { return this.speaker.id; } + public get userId(): number { + return this.speaker.user_id; + } + public get weight(): number { return this.speaker.weight; } @@ -44,8 +58,20 @@ export class ViewSpeaker extends BaseViewModel { return this.speaker.end_time; } + /** + * @returns + * - waiting if there is no begin nor end time + * - current if there is a begin time and not end time + * - finished if there are both begin and end time + */ public get state(): SpeakerState { - return this.speaker.state; + if (!this.begin_time && !this.end_time) { + return SpeakerState.WAITING; + } else if (this.begin_time && !this.end_time) { + return SpeakerState.CURRENT; + } else { + return SpeakerState.FINISHED; + } } public get name(): string { @@ -56,13 +82,7 @@ export class ViewSpeaker extends BaseViewModel { return this.user ? this.user.gender : ''; } - /** - * This is set by the repository - */ - public getVerboseName; - public constructor(speaker: Speaker, user?: ViewUser) { - super('TODO'); this._speaker = speaker; this._user = user; } @@ -71,13 +91,11 @@ export class ViewSpeaker extends BaseViewModel { return this.name; }; - public getModel(): User { - return this.user.user; + public updateDependencies(update: BaseViewModel): boolean { + if (update instanceof ViewUser && update.id === this.speaker.user_id) { + this._user = update; + return true; + } + return false; } - - /** - * Speaker is not a base model, - * @param update the incoming update - */ - public updateDependencies(update: BaseViewModel): void {} } diff --git a/client/src/app/site/agenda/models/view-topic.ts b/client/src/app/site/agenda/models/view-topic.ts index 0085d2a77..aa8f6c644 100644 --- a/client/src/app/site/agenda/models/view-topic.ts +++ b/client/src/app/site/agenda/models/view-topic.ts @@ -1,40 +1,33 @@ import { Topic } from 'app/shared/models/topics/topic'; -import { BaseAgendaViewModel } from 'app/site/base/base-agenda-view-model'; import { SearchRepresentation } from 'app/core/ui-services/search.service'; import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile'; import { ViewItem } from './view-item'; import { BaseViewModel } from 'app/site/base/base-view-model'; +import { ViewListOfSpeakers } from './view-list-of-speakers'; +import { BaseViewModelWithAgendaItemAndListOfSpeakers } from 'app/site/base/base-view-model-with-agenda-item-and-list-of-speakers'; +import { TitleInformationWithAgendaItem } from 'app/site/base/base-view-model-with-agenda-item'; + +export interface TopicTitleInformation extends TitleInformationWithAgendaItem { + title: string; + agenda_item_number?: string; +} /** * Provides "safe" access to topic with all it's components * @ignore */ -export class ViewTopic extends BaseAgendaViewModel { +export class ViewTopic extends BaseViewModelWithAgendaItemAndListOfSpeakers implements TopicTitleInformation { public static COLLECTIONSTRING = Topic.COLLECTIONSTRING; - protected _topic: Topic; - private _attachments: ViewMediafile[]; - private _agendaItem: ViewItem; + private _attachments?: ViewMediafile[]; public get topic(): Topic { - return this._topic; + return this._model; } public get attachments(): ViewMediafile[] { - return this._attachments; - } - - public get agendaItem(): ViewItem { - return this._agendaItem; - } - - public get id(): number { - return this.topic.id; - } - - public get agenda_item_id(): number { - return this.topic.agenda_item_id; + return this._attachments || []; } public get attachments_id(): number[] { @@ -49,34 +42,14 @@ export class ViewTopic extends BaseAgendaViewModel { return this.topic.text; } - /** - * This is set by the repository - */ - public getVerboseName; - public getAgendaTitle; - public getAgendaTitleWithType; - - public constructor(topic: Topic, attachments?: ViewMediafile[], item?: ViewItem) { - super(Topic.COLLECTIONSTRING); - this._topic = topic; + public constructor( + topic: Topic, + attachments?: ViewMediafile[], + item?: ViewItem, + listOfSpeakers?: ViewListOfSpeakers + ) { + super(Topic.COLLECTIONSTRING, topic, item, listOfSpeakers); this._attachments = attachments; - this._agendaItem = item; - } - - public getTitle = () => { - if (this.agendaItem && this.agendaItem.itemNumber) { - return this.agendaItem.itemNumber + ' · ' + this.title; - } else { - return this.title; - } - }; - - public getModel(): Topic { - return this.topic; - } - - public getAgendaItem(): ViewItem { - return this.agendaItem; } /** @@ -118,6 +91,7 @@ export class ViewTopic extends BaseAgendaViewModel { } public updateDependencies(update: BaseViewModel): void { + super.updateDependencies(update); if (update instanceof ViewMediafile && this.attachments_id.includes(update.id)) { const attachmentIndex = this.attachments.findIndex(mediafile => mediafile.id === update.id); if (attachmentIndex < 0) { @@ -126,8 +100,5 @@ export class ViewTopic extends BaseAgendaViewModel { this.attachments[attachmentIndex] = update; } } - if (update instanceof ViewItem && this.agenda_item_id === update.id) { - this._agendaItem = update; - } } } diff --git a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html index 27a5a172d..f7b8fbdc5 100644 --- a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html +++ b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html @@ -28,12 +28,7 @@ <span translate>PDF</span> </button> <!-- List of speakers --> - <div *ngIf="assignment.agendaItem"> - <button mat-menu-item [routerLink]="getSpeakerLink()" *osPerms="'agenda.can_see'"> - <mat-icon>mic</mat-icon> - <span translate>List of speakers</span> - </button> - </div> + <os-speaker-button [object]="assignment" [menuItem]="true"></os-speaker-button> </div> <!-- Project --> <os-projector-button [object]="assignment" [menuItem]="true"></os-projector-button> @@ -62,7 +57,9 @@ <ng-template #metaInfoTemplate> <mat-card class="os-card " *ngIf="assignment"> - <h1>{{ assignment.getTitle() }}</h1> + <div *ngIf="!editAssignment && assignment.getTitle"> + <h1>{{ assignment.getTitle() }}</h1> + </div> <div *ngIf="assignment"> <div *ngIf="assignment.assignment.description" [innerHTML]="assignment.assignment.description"></div> </div> @@ -227,7 +224,7 @@ matInput placeholder="{{ 'Title' | translate }}" formControlName="title" - [value]="assignmentCopy.getTitle() || ''" + [value]="assignmentCopy.title || ''" /> </mat-form-field> </div> diff --git a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts index 4d3f327a5..f66d8b465 100644 --- a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts +++ b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts @@ -501,11 +501,4 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn .sortCandidates(listInNewOrder.map(relatedUser => relatedUser.id), this.assignment) .then(null, this.raiseError); } - - /** - * Gets the link to the list of speakers associated with the assignment - */ - public getSpeakerLink(): string { - return `/agenda/${this.assignment.agendaItem.id}/speakers`; - } } diff --git a/client/src/app/site/assignments/models/view-assignment-poll.ts b/client/src/app/site/assignments/models/view-assignment-poll.ts index 7dfe78802..f919ec43a 100644 --- a/client/src/app/site/assignments/models/view-assignment-poll.ts +++ b/client/src/app/site/assignments/models/view-assignment-poll.ts @@ -95,6 +95,10 @@ export class ViewAssignmentPoll implements Identifiable, Updateable, Projectable return this.getTitle(); } + public getProjectorTitle(): string { + return this.getTitle(); + } + /** * Creates a copy with deep-copy on all changing numerical values, * but intact uncopied references to the users diff --git a/client/src/app/site/assignments/models/view-assignment.ts b/client/src/app/site/assignments/models/view-assignment.ts index 0b5516ba0..613c68fe0 100644 --- a/client/src/app/site/assignments/models/view-assignment.ts +++ b/client/src/app/site/assignments/models/view-assignment.ts @@ -1,5 +1,4 @@ import { Assignment } from 'app/shared/models/assignments/assignment'; -import { BaseAgendaViewModel } from 'app/site/base/base-agenda-view-model'; import { SearchRepresentation } from 'app/core/ui-services/search.service'; import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; import { ViewUser } from 'app/site/users/models/view-user'; @@ -9,6 +8,13 @@ import { BaseViewModel } from 'app/site/base/base-view-model'; import { ViewAssignmentRelatedUser } from './view-assignment-related-user'; import { ViewAssignmentPoll } from './view-assignment-poll'; import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile'; +import { BaseViewModelWithAgendaItemAndListOfSpeakers } from 'app/site/base/base-view-model-with-agenda-item-and-list-of-speakers'; +import { ViewListOfSpeakers } from 'app/site/agenda/models/view-list-of-speakers'; +import { TitleInformationWithAgendaItem } from 'app/site/base/base-view-model-with-agenda-item'; + +export interface AssignmentTitleInformation extends TitleInformationWithAgendaItem { + title: string; +} /** * A constant containing all possible assignment phases and their different @@ -33,22 +39,17 @@ export const AssignmentPhases: { name: string; value: number; display_name: stri } ]; -export class ViewAssignment extends BaseAgendaViewModel { +export class ViewAssignment extends BaseViewModelWithAgendaItemAndListOfSpeakers<Assignment> + implements AssignmentTitleInformation { public static COLLECTIONSTRING = Assignment.COLLECTIONSTRING; - private _assignment: Assignment; private _assignmentRelatedUsers: ViewAssignmentRelatedUser[]; private _assignmentPolls: ViewAssignmentPoll[]; - private _agendaItem?: ViewItem; private _tags?: ViewTag[]; private _attachments?: ViewMediafile[]; - public get id(): number { - return this._assignment ? this._assignment.id : null; - } - public get assignment(): Assignment { - return this._assignment; + return this._model; } public get polls(): ViewAssignmentPoll[] { @@ -75,10 +76,6 @@ export class ViewAssignment extends BaseAgendaViewModel { return this._assignmentRelatedUsers; } - public get agendaItem(): ViewItem | null { - return this._agendaItem; - } - public get tags(): ViewTag[] { return this._tags || []; } @@ -123,35 +120,26 @@ export class ViewAssignment extends BaseAgendaViewModel { return this._assignmentRelatedUsers ? this._assignmentRelatedUsers.length : 0; } - /** - * Constructor. Is set by the repository - */ - public getVerboseName; - public getAgendaTitle; - public getAgendaTitleWithType; - public constructor( assignment: Assignment, assignmentRelatedUsers: ViewAssignmentRelatedUser[], assignmentPolls: ViewAssignmentPoll[], - agendaItem?: ViewItem, + item?: ViewItem, + listOfSpeakers?: ViewListOfSpeakers, tags?: ViewTag[], attachments?: ViewMediafile[] ) { - super(Assignment.COLLECTIONSTRING); + super(Assignment.COLLECTIONSTRING, assignment, item, listOfSpeakers); - this._assignment = assignment; this._assignmentRelatedUsers = assignmentRelatedUsers; this._assignmentPolls = assignmentPolls; - this._agendaItem = agendaItem; this._tags = tags; this._attachments = attachments; } public updateDependencies(update: BaseViewModel): void { - if (update instanceof ViewItem && update.id === this.assignment.agenda_item_id) { - this._agendaItem = update; - } else if (update instanceof ViewTag && this.assignment.tags_id.includes(update.id)) { + super.updateDependencies(update); + if (update instanceof ViewTag && this.assignment.tags_id.includes(update.id)) { const tagIndex = this._tags.findIndex(_tag => _tag.id === update.id); if (tagIndex < 0) { this._tags.push(update); @@ -171,18 +159,6 @@ export class ViewAssignment extends BaseAgendaViewModel { } } - public getAgendaItem(): ViewItem { - return this.agendaItem; - } - - public getTitle = () => { - return this.title; - }; - - public getModel(): Assignment { - return this.assignment; - } - public formatForSearch(): SearchRepresentation { return [this.title]; } diff --git a/client/src/app/site/base/agenda-information.ts b/client/src/app/site/base/agenda-information.ts deleted file mode 100644 index 8eec50204..000000000 --- a/client/src/app/site/base/agenda-information.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { DetailNavigable } from '../../shared/models/base/detail-navigable'; -import { ViewItem } from '../agenda/models/view-item'; - -/** - * An Interface for all extra information needed for content objects of items. - */ -export interface AgendaInformation extends DetailNavigable { - /** - * Should return the title for the agenda list view. - */ - getAgendaTitle: () => string; - - /** - * Should return the title for the list of speakers view. - */ - getAgendaTitleWithType: () => string; - - /** - * An (optional) descriptive text to be exported in the CSV. - */ - getCSVExportText(): string; - - /** - * Get access to the agenda item - */ - getAgendaItem(): ViewItem; -} diff --git a/client/src/app/site/base/base-agenda-view-model.ts b/client/src/app/site/base/base-agenda-view-model.ts deleted file mode 100644 index 821680a53..000000000 --- a/client/src/app/site/base/base-agenda-view-model.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { AgendaInformation } from 'app/site/base/agenda-information'; -import { BaseProjectableViewModel } from './base-projectable-view-model'; -import { SearchRepresentation } from 'app/core/ui-services/search.service'; -import { ViewItem } from '../agenda/models/view-item'; - -export function isAgendaBaseModel(obj: object): obj is BaseAgendaViewModel { - const agendaViewModel = <BaseAgendaViewModel>obj; - return ( - agendaViewModel.getAgendaTitle !== undefined && - agendaViewModel.getAgendaTitleWithType !== undefined && - agendaViewModel.getCSVExportText !== undefined && - agendaViewModel.getAgendaItem !== undefined && - agendaViewModel.getDetailStateURL !== undefined - ); -} - -/** - * Base view class for projectable models. - */ -export abstract class BaseAgendaViewModel extends BaseProjectableViewModel implements AgendaInformation { - /** - * @returns the contained agenda item - */ - public abstract getAgendaItem(): ViewItem; - - /** - * @returns the agenda title - */ - public getAgendaTitle = () => { - return this.getTitle(); - }; - - /** - * @return the agenda title with the verbose name of the content object - */ - public getAgendaTitleWithType = () => { - // Return the agenda title with the model's verbose name appended - return this.getAgendaTitle() + ' (' + this.getVerboseName() + ')'; - }; - - /** - * @returns the (optional) descriptive text to be exported in the CSV. - * May be overridden by inheriting classes - */ - public getCSVExportText(): string { - return ''; - } - - public abstract getDetailStateURL(): string; - - /** - * Should return a string representation of the object, so there can be searched for. - */ - public abstract formatForSearch(): SearchRepresentation; -} diff --git a/client/src/app/site/base/base-projectable-view-model.ts b/client/src/app/site/base/base-projectable-view-model.ts index 8d7424b32..2c17da186 100644 --- a/client/src/app/site/base/base-projectable-view-model.ts +++ b/client/src/app/site/base/base-projectable-view-model.ts @@ -1,11 +1,13 @@ import { Projectable, ProjectorElementBuildDeskriptor } from './projectable'; import { BaseViewModel } from './base-view-model'; import { ConfigService } from 'app/core/ui-services/config.service'; +import { BaseModel } from 'app/shared/models/base/base-model'; /** * Base view class for projectable models. */ -export abstract class BaseProjectableViewModel extends BaseViewModel implements Projectable { +export abstract class BaseProjectableViewModel<M extends BaseModel = any> extends BaseViewModel<M> + implements Projectable { public abstract getSlide(configService?: ConfigService): ProjectorElementBuildDeskriptor; /** diff --git a/client/src/app/site/base/base-view-model-with-agenda-item-and-list-of-speakers.ts b/client/src/app/site/base/base-view-model-with-agenda-item-and-list-of-speakers.ts new file mode 100644 index 000000000..059c29ac1 --- /dev/null +++ b/client/src/app/site/base/base-view-model-with-agenda-item-and-list-of-speakers.ts @@ -0,0 +1,82 @@ +import { SearchRepresentation } from 'app/core/ui-services/search.service'; +import { BaseModelWithAgendaItemAndListOfSpeakers } from 'app/shared/models/base/base-model-with-agenda-item-and-list-of-speakers'; +import { ViewItem } from '../agenda/models/view-item'; +import { ViewListOfSpeakers } from '../agenda/models/view-list-of-speakers'; +import { BaseProjectableViewModel } from './base-projectable-view-model'; +import { isBaseViewModelWithAgendaItem, IBaseViewModelWithAgendaItem } from './base-view-model-with-agenda-item'; +import { + isBaseViewModelWithListOfSpeakers, + IBaseViewModelWithListOfSpeakers +} from './base-view-model-with-list-of-speakers'; +import { BaseViewModel } from './base-view-model'; + +export function isBaseViewModelWithAgendaItemAndListOfSpeakers( + obj: any +): obj is BaseViewModelWithAgendaItemAndListOfSpeakers { + return !!obj && isBaseViewModelWithAgendaItem(obj) && isBaseViewModelWithListOfSpeakers(obj); +} + +/** + * Base view class for view models with an agenda item and a list of speakers associated. + */ +export abstract class BaseViewModelWithAgendaItemAndListOfSpeakers< + M extends BaseModelWithAgendaItemAndListOfSpeakers = any +> extends BaseProjectableViewModel implements IBaseViewModelWithAgendaItem<M>, IBaseViewModelWithListOfSpeakers<M> { + protected _item?: ViewItem; + protected _listOfSpeakers?: ViewListOfSpeakers; + + public get agendaItem(): ViewItem | null { + return this._item; + } + + public get agenda_item_id(): number { + return this._model.agenda_item_id; + } + + public get agenda_item_number(): string | null { + return this.agendaItem && this.agendaItem.itemNumber ? this.agendaItem.itemNumber : null; + } + + public get listOfSpeakers(): ViewListOfSpeakers | null { + return this._listOfSpeakers; + } + + public get list_of_speakers_id(): number { + return this._model.list_of_speakers_id; + } + + public getAgendaSlideTitle: () => string; + public getAgendaListTitle: () => string; + public getListOfSpeakersTitle: () => string; + public getListOfSpeakersSlideTitle: () => string; + + public constructor(collectionString: string, model: M, item?: ViewItem, listOfSpeakers?: ViewListOfSpeakers) { + super(collectionString, model); + // Explicit set to null instead of undefined, if not given + this._item = item || null; + this._listOfSpeakers = listOfSpeakers || null; + } + + /** + * @returns the (optional) descriptive text to be exported in the CSV. + * May be overridden by inheriting classes + */ + public getCSVExportText(): string { + return ''; + } + + public abstract getDetailStateURL(): string; + + /** + * Should return a string representation of the object, so there can be searched for. + */ + public abstract formatForSearch(): SearchRepresentation; + + public updateDependencies(update: BaseViewModel): void { + if (update instanceof ViewItem && update.id === this.agenda_item_id) { + this._item = update; + } else if (update instanceof ViewListOfSpeakers && update.id === this.list_of_speakers_id) { + this._listOfSpeakers = update; + } + } +} diff --git a/client/src/app/site/base/base-view-model-with-agenda-item.ts b/client/src/app/site/base/base-view-model-with-agenda-item.ts new file mode 100644 index 000000000..1dfd420bd --- /dev/null +++ b/client/src/app/site/base/base-view-model-with-agenda-item.ts @@ -0,0 +1,114 @@ +import { BaseProjectableViewModel } from './base-projectable-view-model'; +import { SearchRepresentation } from 'app/core/ui-services/search.service'; +import { ViewItem } from '../agenda/models/view-item'; +import { isDetailNavigable, DetailNavigable } from 'app/shared/models/base/detail-navigable'; +import { isSearchable, Searchable } from './searchable'; +import { BaseModelWithAgendaItem } from 'app/shared/models/base/base-model-with-agenda-item'; +import { BaseViewModel, TitleInformation } from './base-view-model'; +import { Item } from 'app/shared/models/agenda/item'; + +export function isBaseViewModelWithAgendaItem(obj: any): obj is BaseViewModelWithAgendaItem { + const model = <BaseViewModelWithAgendaItem>obj; + return ( + !!obj && + isDetailNavigable(model) && + isSearchable(model) && + model.getAgendaSlideTitle !== undefined && + model.getAgendaListTitle !== undefined && + model.getCSVExportText !== undefined && + model.agendaItem !== undefined && + model.agenda_item_id !== undefined + ); +} + +export interface TitleInformationWithAgendaItem extends TitleInformation { + agenda_item_number?: string; +} + +/** + * Describes a base class for view models. + */ +export interface IBaseViewModelWithAgendaItem<M extends BaseModelWithAgendaItem = any> + extends BaseProjectableViewModel<M>, + DetailNavigable, + Searchable { + agendaItem: ViewItem | null; + + agenda_item_id: number; + + agenda_item_number: string | null; + + /** + * @returns the agenda title + */ + getAgendaSlideTitle: () => string; + + /** + * @return the agenda title with the verbose name of the content object + */ + getAgendaListTitle: () => string; + + /** + * @returns the (optional) descriptive text to be exported in the CSV. + * May be overridden by inheriting classes + */ + getCSVExportText(): string; +} + +/** + * Base view model class for view models with an agenda item. + */ +export abstract class BaseViewModelWithAgendaItem<M extends BaseModelWithAgendaItem = any> + extends BaseProjectableViewModel<M> + implements IBaseViewModelWithAgendaItem<M> { + protected _item?: ViewItem; + + public get agendaItem(): ViewItem | null { + return this._item; + } + + public get agenda_item_id(): number { + return this._model.agenda_item_id; + } + + public get agenda_item_number(): string | null { + return this.agendaItem && this.agendaItem.itemNumber ? this.agendaItem.itemNumber : null; + } + + /** + * @returns the agenda title for the item slides + */ + public getAgendaSlideTitle: () => string; + + /** + * @return the agenda title for the list view + */ + public getAgendaListTitle: () => string; + + public constructor(collecitonString: string, model: M, item?: ViewItem) { + super(collecitonString, model); + this._item = item || null; // Explicit set to null instead of undefined, if not given + } + + /** + * @returns the (optional) descriptive text to be exported in the CSV. + * May be overridden by inheriting classes + */ + public getCSVExportText(): string { + return ''; + } + + public abstract getDetailStateURL(): string; + + /** + * Should return a string representation of the object, so there can be searched for. + */ + public abstract formatForSearch(): SearchRepresentation; + + public updateDependencies(update: BaseViewModel): void { + // We cannot check with instanceof, because this gives circular dependency issues... + if (update.collectionString === Item.COLLECTIONSTRING && update.id === this.agenda_item_id) { + this._item = update as ViewItem; + } + } +} diff --git a/client/src/app/site/base/base-view-model-with-content-object.ts b/client/src/app/site/base/base-view-model-with-content-object.ts new file mode 100644 index 000000000..8d463163f --- /dev/null +++ b/client/src/app/site/base/base-view-model-with-content-object.ts @@ -0,0 +1,63 @@ +import { BaseViewModel } from './base-view-model'; +import { BaseModelWithContentObject } from 'app/shared/models/base/base-model-with-content-object'; +import { ContentObject } from 'app/shared/models/base/content-object'; + +/** + * Base class for view models with content objects. Ensures a content object attribute and + * implements the generic logic for `updateDependencies`. + * + * Type M is the contained model + * Type C is the type of every content object. + */ +export abstract class BaseViewModelWithContentObject< + M extends BaseModelWithContentObject = any, + C extends BaseViewModel = any +> extends BaseViewModel<M> { + protected _contentObject?: C; + + public get contentObjectData(): ContentObject { + return this.getModel().content_object; + } + + public get contentObject(): C | null { + return this._contentObject; + } + + /** + * @param collectionString The collection string of this model + * @param model the model this view model captures + * @param isC A function ensuring that an arbitrary object is a valid content object + * @param CVerbose is the verbose name of the base content object class, for debugging purposes + * @param contentObject (optional) The content object, if it is known during creation. + */ + public constructor( + collectionString: string, + model: M, + private isC: (obj: any) => obj is C, + private CVerbose: string, + contentObject?: C + ) { + super(collectionString, model); + this._contentObject = contentObject; + } + + /** + * Check, if the given model mathces the content object definition. If so, the function + * returns true, else false. + */ + public updateDependencies(update: BaseViewModel): boolean { + if ( + update && + update.collectionString === this.contentObjectData.collection && + update.id === this.contentObjectData.id + ) { + if (this.isC(update)) { + this._contentObject = update; + return true; + } else { + throw new Error(`The object is not an ${this.CVerbose}:` + update); + } + } + return false; + } +} diff --git a/client/src/app/site/base/base-view-model-with-list-of-speakers.ts b/client/src/app/site/base/base-view-model-with-list-of-speakers.ts new file mode 100644 index 000000000..37021efb8 --- /dev/null +++ b/client/src/app/site/base/base-view-model-with-list-of-speakers.ts @@ -0,0 +1,66 @@ +import { BaseProjectableViewModel } from './base-projectable-view-model'; +import { isDetailNavigable, DetailNavigable } from 'app/shared/models/base/detail-navigable'; +import { ViewListOfSpeakers } from '../agenda/models/view-list-of-speakers'; +import { BaseModelWithListOfSpeakers } from 'app/shared/models/base/base-model-with-list-of-speakers'; +import { BaseViewModel } from './base-view-model'; +import { ListOfSpeakers } from 'app/shared/models/agenda/list-of-speakers'; + +export function isBaseViewModelWithListOfSpeakers(obj: any): obj is BaseViewModelWithListOfSpeakers { + const model = <BaseViewModelWithListOfSpeakers>obj; + return ( + !!obj && + isDetailNavigable(model) && + model.getListOfSpeakersTitle !== undefined && + model.listOfSpeakers !== undefined && + model.list_of_speakers_id !== undefined + ); +} + +/** + * Describes a base view model with a list of speakers. + */ +export interface IBaseViewModelWithListOfSpeakers<M extends BaseModelWithListOfSpeakers = any> + extends BaseProjectableViewModel<M>, + DetailNavigable { + listOfSpeakers: ViewListOfSpeakers | null; + + list_of_speakers_id: number; + + getListOfSpeakersTitle: () => string; + + getListOfSpeakersSlideTitle: () => string; +} + +/** + * Base view model class for models with a list of speakers. + */ +export abstract class BaseViewModelWithListOfSpeakers<M extends BaseModelWithListOfSpeakers = any> + extends BaseProjectableViewModel<M> + implements IBaseViewModelWithListOfSpeakers<M> { + protected _listOfSpeakers?: ViewListOfSpeakers; + + public get listOfSpeakers(): ViewListOfSpeakers | null { + return this._listOfSpeakers; + } + + public get list_of_speakers_id(): number { + return this._model.list_of_speakers_id; + } + + public getListOfSpeakersTitle: () => string; + public getListOfSpeakersSlideTitle: () => string; + + public constructor(collectionString: string, model: M, listOfSpeakers?: ViewListOfSpeakers) { + super(collectionString, model); + this._listOfSpeakers = listOfSpeakers || null; // Explicit set to null instead of undefined, if not given + } + + public abstract getDetailStateURL(): string; + + public updateDependencies(update: BaseViewModel): void { + // We cannot check with instanceof, becuase this givec circular dependency issues... + if (update.collectionString === ListOfSpeakers.COLLECTIONSTRING && update.id === this.list_of_speakers_id) { + this._listOfSpeakers = update as ViewListOfSpeakers; + } + } +} diff --git a/client/src/app/site/base/base-view-model.ts b/client/src/app/site/base/base-view-model.ts index 54849e52a..aa55b7b18 100644 --- a/client/src/app/site/base/base-view-model.ts +++ b/client/src/app/site/base/base-view-model.ts @@ -4,6 +4,8 @@ import { Collection } from 'app/shared/models/base/collection'; import { BaseModel } from 'app/shared/models/base/base-model'; import { Updateable } from './updateable'; +export type TitleInformation = object; + export interface ViewModelConstructor<T extends BaseViewModel> { COLLECTIONSTRING: string; new (...args: any[]): T; @@ -12,11 +14,13 @@ export interface ViewModelConstructor<T extends BaseViewModel> { /** * Base class for view models. alls view models should have titles. */ -export abstract class BaseViewModel implements Displayable, Identifiable, Collection, Updateable { - /** - * Force children to have an id. - */ - public abstract id: number; +export abstract class BaseViewModel<M extends BaseModel = any> + implements Displayable, Identifiable, Collection, Updateable { + protected _model: M; + + public get id(): number { + return this._model.id; + } /** * force children of BaseModel to have a collectionString. @@ -34,7 +38,8 @@ export abstract class BaseViewModel implements Displayable, Identifiable, Collec return this._collectionString; } - public abstract getTitle: () => string; + public getTitle: () => string; + public getListTitle: () => string; /** * Returns the verbose name. @@ -42,24 +47,23 @@ export abstract class BaseViewModel implements Displayable, Identifiable, Collec * @param plural If the name should be plural * @returns the verbose name of the model */ - public abstract getVerboseName: (plural?: boolean) => string; + public getVerboseName: (plural?: boolean) => string; /** - * TODO: Remove verboseName, this must be overwritten by repos.. - * - * @param verboseName * @param collectionString + * @param model */ - public constructor(collectionString: string) { + public constructor(collectionString: string, model: M) { this._collectionString = collectionString; + this._model = model; } - public getListTitle: () => string = () => { - return this.getTitle(); - }; - - /** return the main model of a view model */ - public abstract getModel(): BaseModel; + /** + * @returns the main underlying model of the view model + */ + public getModel(): M { + return this._model; + } public abstract updateDependencies(update: BaseViewModel): void; diff --git a/client/src/app/site/base/list-view-base.ts b/client/src/app/site/base/list-view-base.ts index 3a2086a10..63dd18312 100644 --- a/client/src/app/site/base/list-view-base.ts +++ b/client/src/app/site/base/list-view-base.ts @@ -1,22 +1,23 @@ import { MatTableDataSource, MatTable, MatSort, MatPaginator, MatSnackBar, PageEvent } from '@angular/material'; import { Title } from '@angular/platform-browser'; -import { TranslateService } from '@ngx-translate/core'; import { ViewChild, Type, OnDestroy } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; + import { BaseViewComponent } from './base-view'; -import { BaseViewModel } from './base-view-model'; +import { BaseViewModel, TitleInformation } from './base-view-model'; import { BaseSortListService } from 'app/core/ui-services/base-sort-list.service'; import { BaseFilterListService } from 'app/core/ui-services/base-filter-list.service'; import { BaseModel } from 'app/shared/models/base/base-model'; import { StorageService } from 'app/core/core-services/storage.service'; import { BaseRepository } from 'app/core/repositories/base-repository'; -import { Observable } from 'rxjs'; export abstract class ListViewBaseComponent< V extends BaseViewModel, M extends BaseModel, - R extends BaseRepository<V, M> + R extends BaseRepository<V, M, TitleInformation> > extends BaseViewComponent implements OnDestroy { /** * The data source for a table. Requires to be initialized with a BaseViewModel diff --git a/client/src/app/site/base/projectable.ts b/client/src/app/site/base/projectable.ts index 63e779a3e..f14057003 100644 --- a/client/src/app/site/base/projectable.ts +++ b/client/src/app/site/base/projectable.ts @@ -25,16 +25,14 @@ export interface ProjectorElementBuildDeskriptor { } export function isProjectable(obj: any): obj is Projectable { - if (obj) { - return (<Projectable>obj).getSlide !== undefined; - } else { - return false; - } + return !!obj && obj.getSlide !== undefined && obj.getProjectorTitle !== undefined; } /** * Interface for every model, that should be projectable. */ export interface Projectable extends Displayable { + getProjectorTitle: () => string; + getSlide(configSerice?: ConfigService): ProjectorElementBuildDeskriptor; } diff --git a/client/src/app/site/config/models/view-config.ts b/client/src/app/site/config/models/view-config.ts index 6a75ef631..321360613 100644 --- a/client/src/app/site/config/models/view-config.ts +++ b/client/src/app/site/config/models/view-config.ts @@ -32,17 +32,16 @@ interface ConfigConstant { choices?: ConfigChoice[]; } +export interface ConfigTitleInformation { + key: string; +} + /** * The view model for configs. */ -export class ViewConfig extends BaseViewModel { +export class ViewConfig extends BaseViewModel<Config> implements ConfigTitleInformation { public static COLLECTIONSTRING = Config.COLLECTIONSTRING; - /** - * The underlying config. - */ - private _config: Config; - /* This private members are set by setConstantsInfo. */ private _helpText: string; private _inputType: ConfigInputType; @@ -60,11 +59,7 @@ export class ViewConfig extends BaseViewModel { } public get config(): Config { - return this._config; - } - - public get id(): number { - return this.config.id; + return this._model; } public get key(): string { @@ -95,26 +90,12 @@ export class ViewConfig extends BaseViewModel { return this._defaultValue; } - /** - * This is set by the repository - */ - public getVerboseName; - public constructor(config: Config) { - super(Config.COLLECTIONSTRING); - this._config = config; + super(Config.COLLECTIONSTRING, config); } - public getTitle = () => { - return this.label; - }; - public updateDependencies(update: BaseViewModel): void {} - public getModel(): Config { - return this.config; - } - /** * Returns the time this config field needs to debounce before sending a request to the server. * A little debounce time for all inputs is given here and is usefull, if inputs sends multiple onChange-events, diff --git a/client/src/app/site/history/models/view-history.ts b/client/src/app/site/history/models/view-history.ts index 7baec5471..e099ac4d3 100644 --- a/client/src/app/site/history/models/view-history.ts +++ b/client/src/app/site/history/models/view-history.ts @@ -4,22 +4,21 @@ import { ViewUser } from 'app/site/users/models/view-user'; export type ProxyHistory = History & { user?: ViewUser }; +export interface HistoryTitleInformation { + element_id: string; +} + /** * View model for history objects */ -export class ViewHistory extends BaseViewModel { +export class ViewHistory extends BaseViewModel<ProxyHistory> implements HistoryTitleInformation { public static COLLECTIONSTRING = History.COLLECTIONSTRING; - /** - * Private BaseModel of the history - */ - private _history: ProxyHistory; - /** * Read the history property */ public get history(): ProxyHistory { - return this._history; + return this._model; } /** @@ -73,11 +72,6 @@ export class ViewHistory extends BaseViewModel { return this.history.now; } - /** - * This is set by the repository - */ - public getVerboseName; - /** * Construction of a ViewHistory * @@ -85,8 +79,7 @@ export class ViewHistory extends BaseViewModel { * @param user the real user BaseModel */ public constructor(history: ProxyHistory) { - super(History.COLLECTIONSTRING); - this._history = history; + super(History.COLLECTIONSTRING, history); } /** @@ -105,19 +98,5 @@ export class ViewHistory extends BaseViewModel { return +this.element_id.split(':')[1]; } - /** - * Get the history objects title - * Required by BaseViewModel - * - * @returns history.getTitle which returns the element_id - */ - public getTitle = () => { - return this.element_id; - }; - - public getModel(): History { - return this.history; - } - public updateDependencies(update: BaseViewModel): void {} } diff --git a/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.html b/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.html index 3c964aa97..168e6c4be 100644 --- a/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.html +++ b/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.html @@ -169,6 +169,8 @@ </div> </div> + <os-speaker-button [object]="file" [menuItem]="true"></os-speaker-button> + <!-- Edit and delete for all images --> <mat-divider></mat-divider> <button mat-menu-item (click)="onEditFile(file)"> diff --git a/client/src/app/site/mediafiles/models/view-mediafile.ts b/client/src/app/site/mediafiles/models/view-mediafile.ts index 19666595f..dbeb91857 100644 --- a/client/src/app/site/mediafiles/models/view-mediafile.ts +++ b/client/src/app/site/mediafiles/models/view-mediafile.ts @@ -3,27 +3,28 @@ import { Mediafile } from 'app/shared/models/mediafiles/mediafile'; import { Searchable } from 'app/site/base/searchable'; import { SearchRepresentation } from 'app/core/ui-services/search.service'; import { ViewUser } from 'app/site/users/models/view-user'; -import { BaseProjectableViewModel } from 'app/site/base/base-projectable-view-model'; import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; +import { BaseViewModelWithListOfSpeakers } from 'app/site/base/base-view-model-with-list-of-speakers'; +import { ViewListOfSpeakers } from 'app/site/agenda/models/view-list-of-speakers'; -export class ViewMediafile extends BaseProjectableViewModel implements Searchable { +export interface MediafileTitleInformation { + title: string; +} + +export class ViewMediafile extends BaseViewModelWithListOfSpeakers<Mediafile> + implements MediafileTitleInformation, Searchable { public static COLLECTIONSTRING = Mediafile.COLLECTIONSTRING; - private _mediafile: Mediafile; private _uploader: ViewUser; public get mediafile(): Mediafile { - return this._mediafile; + return this._model; } public get uploader(): ViewUser { return this._uploader; } - public get id(): number { - return this.mediafile.id; - } - public get uploader_id(): number { return this.mediafile.uploader_id; } @@ -65,25 +66,11 @@ export class ViewMediafile extends BaseProjectableViewModel implements Searchabl return this.mediafile.hidden; } - /** - * This is set by the repository - */ - public getVerboseName; - - public constructor(mediafile: Mediafile, uploader?: ViewUser) { - super(Mediafile.COLLECTIONSTRING); - this._mediafile = mediafile; + public constructor(mediafile: Mediafile, listOfSpeakers?: ViewListOfSpeakers, uploader?: ViewUser) { + super(Mediafile.COLLECTIONSTRING, mediafile, listOfSpeakers); this._uploader = uploader; } - public getTitle = () => { - return this.title; - }; - - public getModel(): Mediafile { - return this.mediafile; - } - public formatForSearch(): SearchRepresentation { const searchValues = [this.title]; if (this.uploader) { @@ -167,6 +154,7 @@ export class ViewMediafile extends BaseProjectableViewModel implements Searchabl } public updateDependencies(update: BaseViewModel): void { + super.updateDependencies(update); if (update instanceof ViewUser && this.uploader_id === update.id) { this._uploader = update; } diff --git a/client/src/app/site/motions/models/view-category.ts b/client/src/app/site/motions/models/view-category.ts index cd28eebfd..e80bf2bc5 100644 --- a/client/src/app/site/motions/models/view-category.ts +++ b/client/src/app/site/motions/models/view-category.ts @@ -3,6 +3,11 @@ import { BaseViewModel } from '../../base/base-view-model'; import { SearchRepresentation } from 'app/core/ui-services/search.service'; import { Searchable } from 'app/site/base/searchable'; +export interface CategoryTitleInformation { + prefix: string; + name: string; +} + /** * Category class for the View * @@ -10,17 +15,11 @@ import { Searchable } from 'app/site/base/searchable'; * Provides "safe" access to variables and functions in {@link Category} * @ignore */ -export class ViewCategory extends BaseViewModel implements Searchable { +export class ViewCategory extends BaseViewModel<Category> implements CategoryTitleInformation, Searchable { public static COLLECTIONSTRING = Category.COLLECTIONSTRING; - private _category: Category; - public get category(): Category { - return this._category; - } - - public get id(): number { - return this.category.id; + return this._model; } public get name(): string { @@ -31,32 +30,28 @@ export class ViewCategory extends BaseViewModel implements Searchable { return this.category.prefix; } + /** + * TODO: Where is this used? Try to avoid this. + */ public set prefix(prefix: string) { - this._category.prefix = prefix; + this._model.prefix = prefix; } + /** + * TODO: Where is this used? Try to avoid this. + */ public set name(name: string) { - this._category.name = name; + this._model.name = name; } public get prefixedName(): string { return this.prefix ? this.prefix + ' - ' + this.name : this.name; } - /** - * This is set by the repository - */ - public getVerboseName; - public constructor(category: Category) { - super(Category.COLLECTIONSTRING); - this._category = category; + super(Category.COLLECTIONSTRING, category); } - public getTitle = () => { - return this.prefixedName; - }; - public formatForSearch(): SearchRepresentation { return [this.name, this.prefix]; } @@ -65,10 +60,6 @@ export class ViewCategory extends BaseViewModel implements Searchable { return '/motions/category'; } - public getModel(): Category { - return this.category; - } - /** * Updates the local objects if required * @param update diff --git a/client/src/app/site/motions/models/view-create-motion.ts b/client/src/app/site/motions/models/view-create-motion.ts index ef17d1eb2..8fefd69c9 100644 --- a/client/src/app/site/motions/models/view-create-motion.ts +++ b/client/src/app/site/motions/models/view-create-motion.ts @@ -6,6 +6,11 @@ import { ViewMotionBlock } from './view-motion-block'; import { ViewItem } from 'app/site/agenda/models/view-item'; import { ViewCategory } from './view-category'; import { ViewWorkflow } from './view-workflow'; +import { ViewListOfSpeakers } from 'app/site/agenda/models/view-list-of-speakers'; +import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile'; +import { ViewTag } from 'app/site/tags/models/view-tag'; +import { ViewMotionChangeRecommendation } from './view-motion-change-recommendation'; +import { PersonalNoteContent } from 'app/shared/models/users/personal-note'; /** * Create motion class for the View. Its different to ViewMotion in fact that the submitter handling is different @@ -14,10 +19,10 @@ import { ViewWorkflow } from './view-workflow'; * @ignore */ export class ViewCreateMotion extends ViewMotion { - protected _motion: CreateMotion; + protected _model: CreateMotion; public get motion(): CreateMotion { - return this._motion; + return this._model; } public get submitters(): ViewUser[] { @@ -30,20 +35,43 @@ export class ViewCreateMotion extends ViewMotion { public set submitters(users: ViewUser[]) { this._submitters = users; - this._motion.submitters_id = users.map(user => user.id); + this._model.submitters_id = users.map(user => user.id); } public constructor( - motion?: CreateMotion, + motion: CreateMotion, category?: ViewCategory, submitters?: ViewUser[], supporters?: ViewUser[], workflow?: ViewWorkflow, state?: WorkflowState, item?: ViewItem, - block?: ViewMotionBlock + listOfSpeakers?: ViewListOfSpeakers, + block?: ViewMotionBlock, + attachments?: ViewMediafile[], + tags?: ViewTag[], + parent?: ViewMotion, + changeRecommendations?: ViewMotionChangeRecommendation[], + amendments?: ViewMotion[], + personalNote?: PersonalNoteContent ) { - super(motion, category, submitters, supporters, workflow, state, item, block, null); + super( + motion, + category, + submitters, + supporters, + workflow, + state, + item, + listOfSpeakers, + block, + attachments, + tags, + parent, + changeRecommendations, + amendments, + personalNote + ); } public getVerboseName = () => { @@ -55,7 +83,7 @@ export class ViewCreateMotion extends ViewMotion { */ public copy(): ViewCreateMotion { return new ViewCreateMotion( - this._motion, + this._model, this._category, this._submitters, this._supporters, diff --git a/client/src/app/site/motions/models/view-motion-block.ts b/client/src/app/site/motions/models/view-motion-block.ts index 58bc3727d..3b343ed68 100644 --- a/client/src/app/site/motions/models/view-motion-block.ts +++ b/client/src/app/site/motions/models/view-motion-block.ts @@ -1,52 +1,34 @@ import { MotionBlock } from 'app/shared/models/motions/motion-block'; -import { BaseAgendaViewModel } from 'app/site/base/base-agenda-view-model'; import { SearchRepresentation } from 'app/core/ui-services/search.service'; import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; import { Searchable } from 'app/site/base/searchable'; import { ViewItem } from 'app/site/agenda/models/view-item'; -import { BaseViewModel } from 'app/site/base/base-view-model'; +import { BaseViewModelWithAgendaItemAndListOfSpeakers } from 'app/site/base/base-view-model-with-agenda-item-and-list-of-speakers'; +import { ViewListOfSpeakers } from 'app/site/agenda/models/view-list-of-speakers'; +import { TitleInformationWithAgendaItem } from 'app/site/base/base-view-model-with-agenda-item'; + +export interface MotionBlockTitleInformation extends TitleInformationWithAgendaItem { + title: string; +} /** * ViewModel for motion blocks. * @ignore */ -export class ViewMotionBlock extends BaseAgendaViewModel implements Searchable { +export class ViewMotionBlock extends BaseViewModelWithAgendaItemAndListOfSpeakers + implements MotionBlockTitleInformation, Searchable { public static COLLECTIONSTRING = MotionBlock.COLLECTIONSTRING; - private _motionBlock: MotionBlock; - private _agendaItem: ViewItem; - public get motionBlock(): MotionBlock { - return this._motionBlock; - } - - public get agendaItem(): ViewItem { - return this._agendaItem; - } - - public get id(): number { - return this.motionBlock.id; + return this._model; } public get title(): string { return this.motionBlock.title; } - public get agenda_item_id(): number { - return this.motionBlock.agenda_item_id; - } - - /** - * This is set by the repository - */ - public getVerboseName; - public getAgendaTitle; - public getAgendaTitleWithType; - - public constructor(motionBlock: MotionBlock, agendaItem?: ViewItem) { - super(MotionBlock.COLLECTIONSTRING); - this._motionBlock = motionBlock; - this._agendaItem = agendaItem; + public constructor(motionBlock: MotionBlock, agendaItem?: ViewItem, listOfSpeakers?: ViewListOfSpeakers) { + super(MotionBlock.COLLECTIONSTRING, motionBlock, agendaItem, listOfSpeakers); } /** @@ -58,10 +40,6 @@ export class ViewMotionBlock extends BaseAgendaViewModel implements Searchable { return [this.title]; } - public getAgendaItem(): ViewItem { - return this.agendaItem; - } - /** * Get the URL to the motion block * @@ -71,20 +49,6 @@ export class ViewMotionBlock extends BaseAgendaViewModel implements Searchable { return `/motions/blocks/${this.id}`; } - public updateDependencies(update: BaseViewModel): void { - if (update instanceof ViewItem && this.agenda_item_id === update.id) { - this._agendaItem = update; - } - } - - public getTitle = () => { - return this.title; - }; - - public getModel(): MotionBlock { - return this.motionBlock; - } - public getSlide(): ProjectorElementBuildDeskriptor { return { getBasicProjectorElement: options => ({ diff --git a/client/src/app/site/motions/models/view-change-recommendation.ts b/client/src/app/site/motions/models/view-motion-change-recommendation.ts similarity index 61% rename from client/src/app/site/motions/models/view-change-recommendation.ts rename to client/src/app/site/motions/models/view-motion-change-recommendation.ts index b6442876a..fde573338 100644 --- a/client/src/app/site/motions/models/view-change-recommendation.ts +++ b/client/src/app/site/motions/models/view-motion-change-recommendation.ts @@ -3,6 +3,8 @@ import { ModificationType } from 'app/core/ui-services/diff.service'; import { MotionChangeRecommendation } from 'app/shared/models/motions/motion-change-reco'; import { ViewUnifiedChange, ViewUnifiedChangeType } from '../../../shared/models/motions/view-unified-change'; +export type MotionChangeRecommendationTitleInformation = object; + /** * Change recommendation class for the View * @@ -10,76 +12,57 @@ import { ViewUnifiedChange, ViewUnifiedChangeType } from '../../../shared/models * Provides "safe" access to variables and functions in {@link MotionChangeRecommendation} * @ignore */ -export class ViewMotionChangeRecommendation extends BaseViewModel implements ViewUnifiedChange { +export class ViewMotionChangeRecommendation extends BaseViewModel<MotionChangeRecommendation> + implements MotionChangeRecommendationTitleInformation, ViewUnifiedChange { public static COLLECTIONSTRING = MotionChangeRecommendation.COLLECTIONSTRING; - private _changeRecommendation: MotionChangeRecommendation; - - public get id(): number { - return this._changeRecommendation.id; - } - public get changeRecommendation(): MotionChangeRecommendation { - return this._changeRecommendation; + return this._model; } - /** - * This is set by the repository - */ - public getVerboseName; - - public constructor(changeReco: MotionChangeRecommendation) { - super(MotionChangeRecommendation.COLLECTIONSTRING); - this._changeRecommendation = changeReco; + public constructor(motionChangeRecommendation: MotionChangeRecommendation) { + super(MotionChangeRecommendation.COLLECTIONSTRING, motionChangeRecommendation); } - public getTitle = () => { - return 'Change recommendation'; - }; - public updateDependencies(update: BaseViewModel): void {} - public getModel(): MotionChangeRecommendation { - return this.changeRecommendation; - } - public updateChangeReco(type: number, text: string, internal: boolean): void { // @TODO HTML sanitazion - this._changeRecommendation.type = type; - this._changeRecommendation.text = text; - this._changeRecommendation.internal = internal; + this.changeRecommendation.type = type; + this.changeRecommendation.text = text; + this.changeRecommendation.internal = internal; } public get rejected(): boolean { - return this._changeRecommendation.rejected; + return this.changeRecommendation.rejected; } public get internal(): boolean { - return this._changeRecommendation.internal; + return this.changeRecommendation.internal; } public get type(): number { - return this._changeRecommendation.type || ModificationType.TYPE_REPLACEMENT; + return this.changeRecommendation.type || ModificationType.TYPE_REPLACEMENT; } public get other_description(): string { - return this._changeRecommendation.other_description; + return this.changeRecommendation.other_description; } public get line_from(): number { - return this._changeRecommendation.line_from; + return this.changeRecommendation.line_from; } public get line_to(): number { - return this._changeRecommendation.line_to; + return this.changeRecommendation.line_to; } public get text(): string { - return this._changeRecommendation.text; + return this.changeRecommendation.text; } public get motion_id(): number { - return this._changeRecommendation.motion_id; + return this.changeRecommendation.motion_id; } public getChangeId(): string { diff --git a/client/src/app/site/motions/models/view-motion-comment-section.ts b/client/src/app/site/motions/models/view-motion-comment-section.ts index 31a723950..d14b2a7f1 100644 --- a/client/src/app/site/motions/models/view-motion-comment-section.ts +++ b/client/src/app/site/motions/models/view-motion-comment-section.ts @@ -2,6 +2,10 @@ import { BaseViewModel } from '../../base/base-view-model'; import { MotionCommentSection } from 'app/shared/models/motions/motion-comment-section'; import { ViewGroup } from 'app/site/users/models/view-group'; +export interface MotionCommentSectionTitleInformation { + name: string; +} + /** * Motion comment section class for the View * @@ -9,16 +13,15 @@ import { ViewGroup } from 'app/site/users/models/view-group'; * Provides "safe" access to variables and functions in {@link MotionCommentSection} * @ignore */ -export class ViewMotionCommentSection extends BaseViewModel { +export class ViewMotionCommentSection extends BaseViewModel<MotionCommentSection> + implements MotionCommentSectionTitleInformation { public static COLLECTIONSTRING = MotionCommentSection.COLLECTIONSTRING; - private _section: MotionCommentSection; - private _readGroups: ViewGroup[]; private _writeGroups: ViewGroup[]; public get section(): MotionCommentSection { - return this._section; + return this._model; } public get id(): number { @@ -45,30 +48,19 @@ export class ViewMotionCommentSection extends BaseViewModel { return this._writeGroups; } + /** + * TODO: Where is this needed? Try to avoid this. + */ public set name(name: string) { - this._section.name = name; + this._model.name = name; } - /** - * This is set by the repository - */ - public getVerboseName; - - public constructor(section: MotionCommentSection, readGroups: ViewGroup[], writeGroups: ViewGroup[]) { - super(MotionCommentSection.COLLECTIONSTRING); - this._section = section; + public constructor(motionCommentSection: MotionCommentSection, readGroups: ViewGroup[], writeGroups: ViewGroup[]) { + super(MotionCommentSection.COLLECTIONSTRING, motionCommentSection); this._readGroups = readGroups; this._writeGroups = writeGroups; } - public getTitle = () => { - return this.name; - }; - - public getModel(): MotionCommentSection { - return this.section; - } - /** * Updates the local objects if required * @param section diff --git a/client/src/app/site/motions/models/view-motion.ts b/client/src/app/site/motions/models/view-motion.ts index d3536b3ce..b7b023f44 100644 --- a/client/src/app/site/motions/models/view-motion.ts +++ b/client/src/app/site/motions/models/view-motion.ts @@ -4,7 +4,6 @@ import { ViewMotionCommentSection } from './view-motion-comment-section'; import { WorkflowState } from 'app/shared/models/motions/workflow-state'; import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; import { SearchRepresentation } from 'app/core/ui-services/search.service'; -import { BaseAgendaViewModel } from 'app/site/base/base-agenda-view-model'; import { Searchable } from 'app/site/base/searchable'; import { ViewUser } from 'app/site/users/models/view-user'; import { ViewTag } from 'app/site/tags/models/view-tag'; @@ -15,9 +14,12 @@ import { ViewCategory } from './view-category'; import { ViewMotionBlock } from './view-motion-block'; import { BaseViewModel } from 'app/site/base/base-view-model'; import { ConfigService } from 'app/core/ui-services/config.service'; -import { ViewMotionChangeRecommendation } from './view-change-recommendation'; import { ViewPersonalNote } from 'app/site/users/models/view-personal-note'; +import { ViewMotionChangeRecommendation } from './view-motion-change-recommendation'; import { _ } from 'app/core/translate/translation-marker'; +import { BaseViewModelWithAgendaItemAndListOfSpeakers } from 'app/site/base/base-view-model-with-agenda-item-and-list-of-speakers'; +import { ViewListOfSpeakers } from 'app/site/agenda/models/view-list-of-speakers'; +import { TitleInformationWithAgendaItem } from 'app/site/base/base-view-model-with-agenda-item'; /** * The line numbering mode for the motion detail view. @@ -40,6 +42,11 @@ export enum ChangeRecoMode { ModifiedFinal = 'modified_final_version' } +export interface MotionTitleInformation extends TitleInformationWithAgendaItem { + title: string; + identifier?: string; +} + /** * Motion class for the View * @@ -47,30 +54,65 @@ export enum ChangeRecoMode { * Provides "safe" access to variables and functions in {@link Motion} * @ignore */ -export class ViewMotion extends BaseAgendaViewModel implements Searchable { +export class ViewMotion extends BaseViewModelWithAgendaItemAndListOfSpeakers<Motion> + implements MotionTitleInformation, Searchable { public static COLLECTIONSTRING = Motion.COLLECTIONSTRING; - protected _motion: Motion; - protected _category: ViewCategory; - protected _submitters: ViewUser[]; - protected _supporters: ViewUser[]; - protected _workflow: ViewWorkflow; - protected _state: WorkflowState; - protected _item: ViewItem; - protected _block: ViewMotionBlock; - protected _attachments: ViewMediafile[]; - protected _tags: ViewTag[]; - protected _parent: ViewMotion; - protected _amendments: ViewMotion[]; - protected _changeRecommendations: ViewMotionChangeRecommendation[]; + protected _category?: ViewCategory; + protected _submitters?: ViewUser[]; + protected _supporters?: ViewUser[]; + protected _workflow?: ViewWorkflow; + protected _state?: WorkflowState; + protected _block?: ViewMotionBlock; + protected _attachments?: ViewMediafile[]; + protected _tags?: ViewTag[]; + protected _parent?: ViewMotion; + protected _amendments?: ViewMotion[]; + protected _changeRecommendations?: ViewMotionChangeRecommendation[]; public personalNote?: PersonalNoteContent; public get motion(): Motion { - return this._motion; + return this._model; } - public get id(): number { - return this.motion.id; + public get category(): ViewCategory | null { + return this._category; + } + + public get submitters(): ViewUser[] { + return this._submitters || []; + } + + public get supporters(): ViewUser[] { + return this._supporters || []; + } + + /** + * TODO: Where is this needed. Try to avoid this.. + */ + public set supporters(users: ViewUser[]) { + this._supporters = users; + this._model.supporters_id = users.map(user => user.id); + } + + public get motion_block(): ViewMotionBlock | null { + return this._block; + } + + public get attachments(): ViewMediafile[] { + return this._attachments || []; + } + + public get tags(): ViewTag[] { + return this._tags || []; + } + + public get parent(): ViewMotion | null { + return this._parent; + } + + public get amendments(): ViewMotion[] { + return this._amendments || []; } public get identifier(): string { @@ -111,43 +153,22 @@ export class ViewMotion extends BaseAgendaViewModel implements Searchable { return this.motion.sort_parent_id; } - public get agenda_item_id(): number { - return this.motion.agenda_item_id; - } - public get category_id(): number { return this.motion.category_id; } - public get category(): ViewCategory { - return this._category; - } - public get category_weight(): number { return this.motion.category_weight; } - public get submitters(): ViewUser[] { - return this._submitters; - } - public get sorted_submitters_id(): number[] { return this.motion.sorted_submitters_id; } - public get supporters(): ViewUser[] { - return this._supporters; - } - public get supporters_id(): number[] { return this.motion.supporters_id; } - public set supporters(users: ViewUser[]) { - this._supporters = users; - this._motion.supporters_id = users.map(user => user.id); - } - public get workflow(): ViewWorkflow { return this._workflow; } @@ -207,24 +228,16 @@ export class ViewMotion extends BaseAgendaViewModel implements Searchable { return this.state && this.workflow ? this.state.getPreviousStates(this.workflow.workflow) : []; } - public get item(): ViewItem { - return this._item; - } - public get agenda_type(): number { - return this.item ? this.item.type : null; + return this.agendaItem ? this.agendaItem.type : null; } public get motion_block_id(): number { return this.motion.motion_block_id; } - public get motion_block(): ViewMotionBlock { - return this._block; - } - - public get agendaSpeakerAmount(): number { - return this.item ? this.item.waitingSpeakerAmount : null; + public get speakerAmount(): number { + return this.listOfSpeakers ? this.listOfSpeakers.waitingSpeakerAmount : null; } public get parent_id(): number { @@ -243,22 +256,6 @@ export class ViewMotion extends BaseAgendaViewModel implements Searchable { return this.motion.attachments_id; } - public get attachments(): ViewMediafile[] { - return this._attachments; - } - - public get tags(): ViewTag[] { - return this._tags; - } - - public get parent(): ViewMotion { - return this._parent; - } - - public get amendments(): ViewMotion[] { - return this._amendments; - } - /** * @returns the creation date as Date object */ @@ -347,31 +344,9 @@ export class ViewMotion extends BaseAgendaViewModel implements Searchable { return StateCssClassMapping[this.state.css_class] || ''; } - /** - * This is set by the repository - */ - public getTitle: () => string; - - /** - * This is set by the repository - */ + // This is set by the repository public getIdentifierOrTitle: () => string; - /** - * This is set by the repository - */ - public getAgendaTitle: () => string; - - /** - * This is set by the repository - */ - public getAgendaTitleWithType: () => string; - - /** - * This is set by the repository - */ - public getVerboseName: () => string; - public constructor( motion: Motion, category?: ViewCategory, @@ -380,6 +355,7 @@ export class ViewMotion extends BaseAgendaViewModel implements Searchable { workflow?: ViewWorkflow, state?: WorkflowState, item?: ViewItem, + listOfSpeakers?: ViewListOfSpeakers, block?: ViewMotionBlock, attachments?: ViewMediafile[], tags?: ViewTag[], @@ -388,14 +364,12 @@ export class ViewMotion extends BaseAgendaViewModel implements Searchable { amendments?: ViewMotion[], personalNote?: PersonalNoteContent ) { - super(Motion.COLLECTIONSTRING); - this._motion = motion; + super(Motion.COLLECTIONSTRING, motion, item, listOfSpeakers); this._category = category; this._submitters = submitters; this._supporters = supporters; this._workflow = workflow; this._state = state; - this._item = item; this._block = block; this._attachments = attachments; this._tags = tags; @@ -405,14 +379,6 @@ export class ViewMotion extends BaseAgendaViewModel implements Searchable { this.personalNote = personalNote; } - public getAgendaItem(): ViewItem { - return this.item; - } - - public getModel(): Motion { - return this.motion; - } - /** * Formats the category for search * @@ -461,12 +427,11 @@ export class ViewMotion extends BaseAgendaViewModel implements Searchable { * @param update */ public updateDependencies(update: BaseViewModel): void { + super.updateDependencies(update); if (update instanceof ViewWorkflow) { this.updateWorkflow(update); } else if (update instanceof ViewCategory) { this.updateCategory(update); - } else if (update instanceof ViewItem) { - this.updateItem(update); } else if (update instanceof ViewMotionBlock) { this.updateMotionBlock(update); } else if (update instanceof ViewUser) { @@ -507,17 +472,6 @@ export class ViewMotion extends BaseAgendaViewModel implements Searchable { } } - /** - * Update routine for the agenda Item - * - * @param item potentially the changed agenda Item. Needs manual verification - */ - private updateItem(item: ViewItem): void { - if (item.id === this.motion.agenda_item_id) { - this._item = item; - } - } - /** * Update routine for the motion block * @@ -532,7 +486,7 @@ export class ViewMotion extends BaseAgendaViewModel implements Searchable { /** * Update routine for supporters and submitters * - * @param update potentially the changed agenda Item. Needs manual verification + * @param update potentially the changed user. Needs manual verification */ private updateUser(update: ViewUser): void { if (this.motion.submitters && this.motion.submitters.find(user => user.user_id === update.id)) { @@ -665,7 +619,7 @@ export class ViewMotion extends BaseAgendaViewModel implements Searchable { }), slideOptions: slideOptions, projectionDefaultName: 'motions', - getDialogTitle: this.getAgendaTitle + getDialogTitle: this.getAgendaSlideTitle }; } @@ -674,13 +628,14 @@ export class ViewMotion extends BaseAgendaViewModel implements Searchable { */ public copy(): ViewMotion { return new ViewMotion( - this._motion, + this._model, this._category, this._submitters, this._supporters, this._workflow, this._state, this._item, + this._listOfSpeakers, this._block, this._attachments, this._tags, diff --git a/client/src/app/site/motions/models/view-statute-paragraph.ts b/client/src/app/site/motions/models/view-statute-paragraph.ts index 034372b62..d6cec7f6b 100644 --- a/client/src/app/site/motions/models/view-statute-paragraph.ts +++ b/client/src/app/site/motions/models/view-statute-paragraph.ts @@ -3,6 +3,10 @@ import { StatuteParagraph } from 'app/shared/models/motions/statute-paragraph'; import { Searchable } from 'app/site/base/searchable'; import { SearchRepresentation } from 'app/core/ui-services/search.service'; +export interface StatuteParagraphTitleInformation { + title: string; +} + /** * State paragrpah class for the View * @@ -10,17 +14,12 @@ import { SearchRepresentation } from 'app/core/ui-services/search.service'; * Provides "safe" access to variables and functions in {@link StatuteParagraph} * @ignore */ -export class ViewStatuteParagraph extends BaseViewModel implements Searchable { +export class ViewStatuteParagraph extends BaseViewModel<StatuteParagraph> + implements StatuteParagraphTitleInformation, Searchable { public static COLLECTIONSTRING = StatuteParagraph.COLLECTIONSTRING; - private _paragraph: StatuteParagraph; - public get statuteParagraph(): StatuteParagraph { - return this._paragraph; - } - - public get id(): number { - return this.statuteParagraph.id; + return this._model; } public get title(): string { @@ -35,22 +34,8 @@ export class ViewStatuteParagraph extends BaseViewModel implements Searchable { return this.statuteParagraph.weight; } - /** - * This is set by the repository - */ - public getVerboseName; - - public constructor(paragraph: StatuteParagraph) { - super(StatuteParagraph.COLLECTIONSTRING); - this._paragraph = paragraph; - } - - public getTitle = () => { - return this.title; - }; - - public getModel(): StatuteParagraph { - return this.statuteParagraph; + public constructor(statuteParagraph: StatuteParagraph) { + super(StatuteParagraph.COLLECTIONSTRING, statuteParagraph); } public formatForSearch(): SearchRepresentation { diff --git a/client/src/app/site/motions/models/view-workflow.ts b/client/src/app/site/motions/models/view-workflow.ts index 8e70db833..d920f358f 100644 --- a/client/src/app/site/motions/models/view-workflow.ts +++ b/client/src/app/site/motions/models/view-workflow.ts @@ -10,21 +10,19 @@ export const StateCssClassMapping = { warning: 'yellow' }; +export interface WorkflowTitleInformation { + name: string; +} + /** * class for the ViewWorkflow. * @ignore */ -export class ViewWorkflow extends BaseViewModel { +export class ViewWorkflow extends BaseViewModel<Workflow> implements WorkflowTitleInformation { public static COLLECTIONSTRING = Workflow.COLLECTIONSTRING; - private _workflow: Workflow; - public get workflow(): Workflow { - return this._workflow; - } - - public get id(): number { - return this.workflow.id; + return this._model; } public get name(): string { @@ -43,28 +41,14 @@ export class ViewWorkflow extends BaseViewModel { return this.getStateById(this.first_state_id); } - /** - * This is set by the repository - */ - public getVerboseName; - public constructor(workflow: Workflow) { - super(Workflow.COLLECTIONSTRING); - this._workflow = workflow; + super(Workflow.COLLECTIONSTRING, workflow); } - public getTitle = () => { - return this.name; - }; - public sortStates(): void { this.workflow.sortStates(); } - public getModel(): Workflow { - return this.workflow; - } - /** * Updates the local objects if required * diff --git a/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.html b/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.html index 0393b3e4c..0e96bb75b 100644 --- a/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.html +++ b/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.html @@ -101,7 +101,7 @@ <!-- The menu content --> <mat-menu #motionBlockMenu="matMenu"> - <button mat-menu-item [routerLink]="getSpeakerLink()"> + <button *ngIf="block" mat-menu-item [routerLink]="block.listOfSpeakersUrl"> <mat-icon>mic</mat-icon> <span translate>List of speakers</span> </button> diff --git a/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.ts b/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.ts index 1dddd3a1e..edc7769e4 100644 --- a/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.ts +++ b/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.ts @@ -103,17 +103,6 @@ export class MotionBlockDetailComponent extends ListViewBaseComponent<ViewMotion ); } - /** - * Get link to the list of speakers of the corresponding agenda item - * - * @returns the link to the list of speakers as string - */ - public getSpeakerLink(): string { - if (this.block) { - return `/agenda/${this.block.agenda_item_id}/speakers`; - } - } - /** * Returns the columns that should be shown in the table * diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-change-recommendation/motion-change-recommendation.component.spec.ts b/client/src/app/site/motions/modules/motion-detail/components/motion-change-recommendation/motion-change-recommendation.component.spec.ts index 27b684b5a..568bc6aa4 100644 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-change-recommendation/motion-change-recommendation.component.spec.ts +++ b/client/src/app/site/motions/modules/motion-detail/components/motion-change-recommendation/motion-change-recommendation.component.spec.ts @@ -8,7 +8,7 @@ import { import { E2EImportsModule } from 'e2e-imports.module'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material'; import { ModificationType } from 'app/core/ui-services/diff.service'; -import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-change-recommendation'; +import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-motion-change-recommendation'; describe('MotionChangeRecommendationComponent', () => { let component: MotionChangeRecommendationComponent; diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-change-recommendation/motion-change-recommendation.component.ts b/client/src/app/site/motions/modules/motion-detail/components/motion-change-recommendation/motion-change-recommendation.component.ts index f5501770e..4c7f6819a 100644 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-change-recommendation/motion-change-recommendation.component.ts +++ b/client/src/app/site/motions/modules/motion-detail/components/motion-change-recommendation/motion-change-recommendation.component.ts @@ -8,7 +8,7 @@ import { TranslateService } from '@ngx-translate/core'; import { BaseViewComponent } from 'app/site/base/base-view'; import { ChangeRecommendationRepositoryService } from 'app/core/repositories/motions/change-recommendation-repository.service'; import { LineRange, ModificationType } from 'app/core/ui-services/diff.service'; -import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-change-recommendation'; +import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-motion-change-recommendation'; /** * Data that needs to be provided to the MotionChangeRecommendationComponent dialog diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-detail-diff/motion-detail-diff.component.spec.ts b/client/src/app/site/motions/modules/motion-detail/components/motion-detail-diff/motion-detail-diff.component.spec.ts index 0f6382dc1..8c6ea7ae4 100644 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-detail-diff/motion-detail-diff.component.spec.ts +++ b/client/src/app/site/motions/modules/motion-detail/components/motion-detail-diff/motion-detail-diff.component.spec.ts @@ -5,7 +5,7 @@ import { MotionDetailDiffComponent } from './motion-detail-diff.component'; import { MotionDetailOriginalChangeRecommendationsComponent } from '../motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component'; import { Motion } from 'app/shared/models/motions/motion'; import { ViewMotion, LineNumberingMode } from 'app/site/motions/models/view-motion'; -import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-change-recommendation'; +import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-motion-change-recommendation'; import { ViewUnifiedChange } from 'app/shared/models/motions/view-unified-change'; import { E2EImportsModule } from 'e2e-imports.module'; diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-detail-diff/motion-detail-diff.component.ts b/client/src/app/site/motions/modules/motion-detail/components/motion-detail-diff/motion-detail-diff.component.ts index 5a2fdc956..8e07ad319 100644 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-detail-diff/motion-detail-diff.component.ts +++ b/client/src/app/site/motions/modules/motion-detail/components/motion-detail-diff/motion-detail-diff.component.ts @@ -16,7 +16,7 @@ import { MotionRepositoryService } from 'app/core/repositories/motions/motion-re import { PromptService } from 'app/core/ui-services/prompt.service'; import { ViewMotion, LineNumberingMode } from 'app/site/motions/models/view-motion'; import { ViewUnifiedChange, ViewUnifiedChangeType } from 'app/shared/models/motions/view-unified-change'; -import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-change-recommendation'; +import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-motion-change-recommendation'; /** * This component displays the original motion text with the change blocks inside. diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.ts b/client/src/app/site/motions/modules/motion-detail/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.ts index 7567a559f..ef0546646 100644 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.ts +++ b/client/src/app/site/motions/modules/motion-detail/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.ts @@ -12,7 +12,7 @@ import { import { LineRange, ModificationType } from 'app/core/ui-services/diff.service'; import { OperatorService } from 'app/core/core-services/operator.service'; -import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-change-recommendation'; +import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-motion-change-recommendation'; /** * This component displays the original motion text with annotated change commendations diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.html b/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.html index 9768bd8a5..d8db3417d 100644 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.html +++ b/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.html @@ -68,10 +68,7 @@ <span translate>PDF</span> </button> <!-- List of speakers --> - <button mat-menu-item [routerLink]="getSpeakerLink()" *osPerms="'agenda.can_see'"> - <mat-icon>mic</mat-icon> - <span translate>List of speakers</span> - </button> + <os-speaker-button [object]="motion" [menuItem]="true"></os-speaker-button> <!-- Project --> <os-projector-button [object]="motion" diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.ts b/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.ts index 88ca0ef9f..3a4bcdf2b 100644 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.ts +++ b/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.ts @@ -40,7 +40,7 @@ import { ViewCreateMotion } from 'app/site/motions/models/view-create-motion'; import { ViewItem } from 'app/site/agenda/models/view-item'; import { ViewportService } from 'app/core/ui-services/viewport.service'; import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile'; -import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-change-recommendation'; +import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-motion-change-recommendation'; import { ViewMotionNotificationEditMotion, TypeOfNotificationViewMotion @@ -1302,15 +1302,6 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit, } } - /** - * Create the absolute path to the corresponding list of speakers - * - * @returns the link to the corresponding list of speakers as string - */ - public getSpeakerLink(): string { - return `/agenda/${this.motion.agenda_item_id}/speakers`; - } - /** * Click handler for the pdf button */ diff --git a/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.html b/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.html index 5f6455688..46ee7adb1 100644 --- a/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.html +++ b/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.html @@ -138,14 +138,7 @@ <ng-container matColumnDef="speakers"> <mat-header-cell *matHeaderCellDef mat-sort-header>Speakers</mat-header-cell> <mat-cell *matCellDef="let motion"> - <button mat-icon-button (click)="onSpeakerIcon(motion, $event)" [disabled]="isMultiSelect"> - <mat-icon - [matBadge]="motion.agendaSpeakerAmount > 0 ? motion.agendaSpeakerAmount : null" - matBadgeColor="accent" - > - mic - </mat-icon> - </button> + <os-speaker-button [object]="motion" [disabled]="isMultiSelect"></os-speaker-button> </mat-cell> </ng-container> diff --git a/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.ts b/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.ts index ff2b852a0..28192759b 100644 --- a/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.ts +++ b/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.ts @@ -228,16 +228,6 @@ export class MotionListComponent extends ListViewBaseComponent<ViewMotion, Motio } } - /** - * Handler for the speakers button - * - * @param motion indicates the row that was clicked on - */ - public onSpeakerIcon(motion: ViewMotion, event: MouseEvent): void { - event.stopPropagation(); - this.router.navigate([`/agenda/${motion.agenda_item_id}/speakers`]); - } - /** * Handler for the plus button */ diff --git a/client/src/app/site/motions/motions.config.ts b/client/src/app/site/motions/motions.config.ts index 920a239e9..f6587c840 100644 --- a/client/src/app/site/motions/motions.config.ts +++ b/client/src/app/site/motions/motions.config.ts @@ -15,7 +15,7 @@ import { StatuteParagraphRepositoryService } from 'app/core/repositories/motions import { ChangeRecommendationRepositoryService } from 'app/core/repositories/motions/change-recommendation-repository.service'; import { ViewCategory } from './models/view-category'; import { ViewMotionCommentSection } from './models/view-motion-comment-section'; -import { ViewMotionChangeRecommendation } from './models/view-change-recommendation'; +import { ViewMotionChangeRecommendation } from './models/view-motion-change-recommendation'; import { ViewMotionBlock } from './models/view-motion-block'; import { ViewStatuteParagraph } from './models/view-statute-paragraph'; import { ViewMotion } from './models/view-motion'; diff --git a/client/src/app/site/projector/models/view-countdown.ts b/client/src/app/site/projector/models/view-countdown.ts index 3e8e4cbf0..5eccbf480 100644 --- a/client/src/app/site/projector/models/view-countdown.ts +++ b/client/src/app/site/projector/models/view-countdown.ts @@ -3,17 +3,16 @@ import { BaseProjectableViewModel } from 'app/site/base/base-projectable-view-mo import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; import { BaseViewModel } from 'app/site/base/base-view-model'; -export class ViewCountdown extends BaseProjectableViewModel { +export interface CountdownTitleInformation { + title: string; + description?: string; +} + +export class ViewCountdown extends BaseProjectableViewModel<Countdown> implements CountdownTitleInformation { public static COLLECTIONSTRING = Countdown.COLLECTIONSTRING; - private _countdown: Countdown; - public get countdown(): Countdown { - return this._countdown; - } - - public get id(): number { - return this.countdown.id; + return this._model; } public get running(): boolean { @@ -36,26 +35,8 @@ export class ViewCountdown extends BaseProjectableViewModel { return this.countdown.title; } - /** - * This is set by the repository - */ - public getVerboseName; - public constructor(countdown: Countdown) { - super(Countdown.COLLECTIONSTRING); - this._countdown = countdown; - } - - /** - * @returns a title for the countdown, consisting of the title and additional - * text info that may be displayed on the projector - */ - public getTitle = () => { - return this.description ? `${this.title} (${this.description})` : this.title; - }; - - public getModel(): Countdown { - return this.countdown; + super(Countdown.COLLECTIONSTRING, countdown); } public updateDependencies(update: BaseViewModel): void {} diff --git a/client/src/app/site/projector/models/view-projection-default.ts b/client/src/app/site/projector/models/view-projection-default.ts index 0c23f9e81..c4fbf4ac4 100644 --- a/client/src/app/site/projector/models/view-projection-default.ts +++ b/client/src/app/site/projector/models/view-projection-default.ts @@ -1,13 +1,16 @@ import { BaseViewModel } from '../../base/base-view-model'; import { ProjectionDefault } from 'app/shared/models/core/projection-default'; -export class ViewProjectionDefault extends BaseViewModel { +export interface ProjectionDefaultTitleInformation { + display_name: string; +} + +export class ViewProjectionDefault extends BaseViewModel<ProjectionDefault> + implements ProjectionDefaultTitleInformation { public static COLLECTIONSTRING = ProjectionDefault.COLLECTIONSTRING; - private _projectionDefault: ProjectionDefault; - public get projectionDefault(): ProjectionDefault { - return this._projectionDefault; + return this._model; } public get id(): number { @@ -22,19 +25,8 @@ export class ViewProjectionDefault extends BaseViewModel { return this.projectionDefault.display_name; } - /** - * This is set by the repository - */ - public getVerboseName: () => string; - public getTitle: () => string; - public constructor(projectionDefault: ProjectionDefault) { - super(ProjectionDefault.COLLECTIONSTRING); - this._projectionDefault = projectionDefault; - } - - public getModel(): ProjectionDefault { - return this.projectionDefault; + super(ProjectionDefault.COLLECTIONSTRING, projectionDefault); } public updateDependencies(update: BaseViewModel): void {} diff --git a/client/src/app/site/projector/models/view-projector-message.ts b/client/src/app/site/projector/models/view-projector-message.ts index ef80564de..0b74fcb4f 100644 --- a/client/src/app/site/projector/models/view-projector-message.ts +++ b/client/src/app/site/projector/models/view-projector-message.ts @@ -4,39 +4,22 @@ import { ProjectorMessage } from 'app/shared/models/core/projector-message'; import { BaseViewModel } from 'app/site/base/base-view-model'; import { stripHtmlTags } from 'app/shared/utils/strip-html-tags'; -export class ViewProjectorMessage extends BaseProjectableViewModel { +export type ProjectorMessageTitleInformation = object; + +export class ViewProjectorMessage extends BaseProjectableViewModel<ProjectorMessage> + implements ProjectorMessageTitleInformation { public static COLLECTIONSTRING = ProjectorMessage.COLLECTIONSTRING; - private _message: ProjectorMessage; - public get projectormessage(): ProjectorMessage { - return this._message; - } - - public get id(): number { - return this.projectormessage.id; + return this._model; } public get message(): string { return this.projectormessage.message; } - /** - * This is set by the repository - */ - public getVerboseName; - - public constructor(message: ProjectorMessage) { - super(ProjectorMessage.COLLECTIONSTRING); - this._message = message; - } - - public getTitle = () => { - return 'Message'; - }; - - public getModel(): ProjectorMessage { - return this.projectormessage; + public constructor(projectorMessage: ProjectorMessage) { + super(ProjectorMessage.COLLECTIONSTRING, projectorMessage); } public updateDependencies(update: BaseViewModel): void {} diff --git a/client/src/app/site/projector/models/view-projector.ts b/client/src/app/site/projector/models/view-projector.ts index 0708e3f27..482ea330b 100644 --- a/client/src/app/site/projector/models/view-projector.ts +++ b/client/src/app/site/projector/models/view-projector.ts @@ -1,14 +1,17 @@ import { BaseViewModel } from '../../base/base-view-model'; import { Projector, ProjectorElements } from 'app/shared/models/core/projector'; -export class ViewProjector extends BaseViewModel { +export interface ProjectorTitleInformation { + name: string; +} + +export class ViewProjector extends BaseViewModel<Projector> { public static COLLECTIONSTRING = Projector.COLLECTIONSTRING; - private _projector: Projector; private _referenceProjector: ViewProjector; public get projector(): Projector { - return this._projector; + return this._model; } public get referenceProjector(): ViewProjector { @@ -19,10 +22,6 @@ export class ViewProjector extends BaseViewModel { } } - public get id(): number { - return this.projector.id; - } - public get name(): string { return this.projector.name; } @@ -103,25 +102,11 @@ export class ViewProjector extends BaseViewModel { return this.projector.show_logo; } - /** - * This is set by the repository - */ - public getVerboseName; - public constructor(projector: Projector, referenceProjector?: ViewProjector) { - super(Projector.COLLECTIONSTRING); - this._projector = projector; + super(Projector.COLLECTIONSTRING, projector); this._referenceProjector = referenceProjector; } - public getTitle = () => { - return this.name; - }; - - public getModel(): Projector { - return this.projector; - } - public updateDependencies(update: BaseViewModel): void { if (update instanceof ViewProjector && this.reference_projector_id === update.id) { this._referenceProjector = update; diff --git a/client/src/app/site/projector/services/current-agenda-item.service.ts b/client/src/app/site/projector/services/current-agenda-item.service.ts index 611dd1faa..76f091946 100644 --- a/client/src/app/site/projector/services/current-agenda-item.service.ts +++ b/client/src/app/site/projector/services/current-agenda-item.service.ts @@ -6,61 +6,64 @@ import { ProjectorService } from 'app/core/core-services/projector.service'; import { ViewProjector } from '../models/view-projector'; import { ProjectorRepositoryService } from 'app/core/repositories/projector/projector-repository.service'; import { SlideManager } from 'app/slides/services/slide-manager.service'; -import { BaseAgendaViewModel } from 'app/site/base/base-agenda-view-model'; -import { ViewItem } from 'app/site/agenda/models/view-item'; +import { ViewListOfSpeakers } from 'app/site/agenda/models/view-list-of-speakers'; +import { BaseViewModelWithListOfSpeakers } from 'app/site/base/base-view-model-with-list-of-speakers'; /** * Observes the projector config for a given projector and returns a observable of the - * current view item displayed at on the projector. + * current view list of speakers displayed on the projector. */ @Injectable({ providedIn: 'root' }) -export class CurrentAgendaItemService { - private currentItemIds: { [projectorId: number]: BehaviorSubject<ViewItem | null> } = {}; +export class CurrentListOfSpeakersService { + private currentListOfSpeakersIds: { [projectorId: number]: BehaviorSubject<ViewListOfSpeakers | null> } = {}; public constructor( private projectorService: ProjectorService, private projectorRepo: ProjectorRepositoryService, private slideManager: SlideManager ) { + // Watch for changes and update the current list of speakers for every projector. this.projectorRepo.getGeneralViewModelObservable().subscribe(projector => { - if (projector && this.currentItemIds[projector.id]) { - const item = this.getCurrentAgendaItemIdForProjector(projector); - this.currentItemIds[projector.id].next(item); + if (projector && this.currentListOfSpeakersIds[projector.id]) { + const listOfSpeakers = this.getCurrentListOfSpeakersForProjector(projector); + this.currentListOfSpeakersIds[projector.id].next(listOfSpeakers); } }); } /** - * Returns an observable for the agenda item id of the currently projected element on the + * Returns an observable for the view list of speakers of the currently projected element on the * given projector. * * @param projector The projector to observe. - * @returns An observalbe for the agenda item id. Null, if no element with an agenda item is shown. + * @returns An observalbe for the list of speakers. Null, if no element with an list of speakers is shown. */ - public getAgendaItemObservable(projector: ViewProjector): Observable<ViewItem | null> { - if (!this.currentItemIds[projector.id]) { - const item = this.getCurrentAgendaItemIdForProjector(projector); - this.currentItemIds[projector.id] = new BehaviorSubject<ViewItem | null>(item); + public getListOfSpeakersObservable(projector: ViewProjector): Observable<ViewListOfSpeakers | null> { + if (!this.currentListOfSpeakersIds[projector.id]) { + const listOfSpeakers = this.getCurrentListOfSpeakersForProjector(projector); + this.currentListOfSpeakersIds[projector.id] = new BehaviorSubject<ViewListOfSpeakers | null>( + listOfSpeakers + ); } - return this.currentItemIds[projector.id].asObservable(); + return this.currentListOfSpeakersIds[projector.id].asObservable(); } /** - * Tries to get the agenda item id for one non stable element on the projector. + * Tries to get the view list of speakers for one non stable element on the projector. * * @param projector The projector - * @returns The agenda item id or null, if there is no such projector element. + * @returns The view list of speakers or null, if there is no such projector element. */ - private getCurrentAgendaItemIdForProjector(projector: ViewProjector): ViewItem | null { + private getCurrentListOfSpeakersForProjector(projector: ViewProjector): ViewListOfSpeakers | null { const nonStableElements = projector.elements.filter(element => !element.stable); if (nonStableElements.length > 0) { const nonStableElement = this.slideManager.getIdentifialbeProjectorElement(nonStableElements[0]); // The normal case is just one non stable slide try { const viewModel = this.projectorService.getViewModelFromProjectorElement(nonStableElement); - if (viewModel instanceof BaseAgendaViewModel) { - return viewModel.getAgendaItem(); + if (viewModel instanceof BaseViewModelWithListOfSpeakers) { + return viewModel.listOfSpeakers; } } catch (e) { // make TypeScript silent. diff --git a/client/src/app/site/tags/models/view-tag.ts b/client/src/app/site/tags/models/view-tag.ts index 608595ef8..8c9add1ea 100644 --- a/client/src/app/site/tags/models/view-tag.ts +++ b/client/src/app/site/tags/models/view-tag.ts @@ -3,6 +3,10 @@ import { BaseViewModel } from '../../base/base-view-model'; import { SearchRepresentation } from 'app/core/ui-services/search.service'; import { Searchable } from 'app/site/base/searchable'; +export interface TagTitleInformation { + name: string; +} + /** * Tag view class * @@ -10,39 +14,19 @@ import { Searchable } from 'app/site/base/searchable'; * Provides "safe" access to variables and functions in {@link Tag} * @ignore */ -export class ViewTag extends BaseViewModel implements Searchable { +export class ViewTag extends BaseViewModel<Tag> implements TagTitleInformation, Searchable { public static COLLECTIONSTRING = Tag.COLLECTIONSTRING; - private _tag: Tag; - public get tag(): Tag { - return this._tag; - } - - public get id(): number { - return this.tag.id; + return this._model; } public get name(): string { return this.tag.name; } - /** - * This is set by the repository - */ - public getVerboseName; - public constructor(tag: Tag) { - super(Tag.COLLECTIONSTRING); - this._tag = tag; - } - - public getTitle = () => { - return this.name; - }; - - public getModel(): Tag { - return this.tag; + super(Tag.COLLECTIONSTRING, tag); } public formatForSearch(): SearchRepresentation { diff --git a/client/src/app/site/users/models/view-csv-create-user.ts b/client/src/app/site/users/models/view-csv-create-user.ts index 785092693..da38c7213 100644 --- a/client/src/app/site/users/models/view-csv-create-user.ts +++ b/client/src/app/site/users/models/view-csv-create-user.ts @@ -41,10 +41,6 @@ export class ViewCsvCreateUser extends ViewUser { super(user); } - public getModel(): User { - return super.getModel(); - } - /** * takes a list of solved group maps to update. Returns the amount of * entries that remain unmatched diff --git a/client/src/app/site/users/models/view-group.ts b/client/src/app/site/users/models/view-group.ts index b482e34d9..9e93a189b 100644 --- a/client/src/app/site/users/models/view-group.ts +++ b/client/src/app/site/users/models/view-group.ts @@ -1,17 +1,15 @@ import { BaseViewModel } from '../../base/base-view-model'; import { Group } from 'app/shared/models/users/group'; -export class ViewGroup extends BaseViewModel { +export interface GroupTitleInformation { + name: string; +} + +export class ViewGroup extends BaseViewModel<Group> implements GroupTitleInformation { public static COLLECTIONSTRING = Group.COLLECTIONSTRING; - private _group: Group; - public get group(): Group { - return this._group; - } - - public get id(): number { - return this.group.id; + return this._model; } public get name(): string { @@ -31,27 +29,13 @@ export class ViewGroup extends BaseViewModel { return this.group.permissions; } - /** - * This is set by the repository - */ - public getVerboseName; - public constructor(group?: Group) { - super(Group.COLLECTIONSTRING); - this._group = group; + super(Group.COLLECTIONSTRING, group); } public hasPermission(perm: string): boolean { return this.permissions.includes(perm); } - public getTitle = () => { - return this.name; - }; - - public getModel(): Group { - return this.group; - } - public updateDependencies(update: BaseViewModel): void {} } diff --git a/client/src/app/site/users/models/view-personal-note.ts b/client/src/app/site/users/models/view-personal-note.ts index f4d391601..798047320 100644 --- a/client/src/app/site/users/models/view-personal-note.ts +++ b/client/src/app/site/users/models/view-personal-note.ts @@ -1,17 +1,13 @@ import { BaseViewModel } from 'app/site/base/base-view-model'; import { PersonalNote, PersonalNotesFormat, PersonalNoteContent } from 'app/shared/models/users/personal-note'; -export class ViewPersonalNote extends BaseViewModel { +export type PersonalNoteTitleInformation = object; + +export class ViewPersonalNote extends BaseViewModel<PersonalNote> implements PersonalNoteTitleInformation { public static COLLECTIONSTRING = PersonalNote.COLLECTIONSTRING; - private _personalNote: PersonalNote; - public get personalNote(): PersonalNote { - return this._personalNote; - } - - public get id(): number { - return this.personalNote.id; + return this._model; } public get userId(): number { @@ -22,14 +18,8 @@ export class ViewPersonalNote extends BaseViewModel { return this.personalNote.notes; } - /** - * This is set by the repository - */ - public getVerboseName; - public constructor(personalNote: PersonalNote) { - super(PersonalNote.COLLECTIONSTRING); - this._personalNote = personalNote; + super(PersonalNote.COLLECTIONSTRING, personalNote); } public getNoteContent(collection: string, id: number): PersonalNoteContent | null { @@ -40,13 +30,5 @@ export class ViewPersonalNote extends BaseViewModel { } } - public getTitle = () => { - return this.personalNote ? this.personalNote.toString() : null; - }; - - public getModel(): PersonalNote { - return this.personalNote; - } - public updateDependencies(update: BaseViewModel): void {} } diff --git a/client/src/app/site/users/models/view-user.ts b/client/src/app/site/users/models/view-user.ts index 338a04166..488a5f248 100644 --- a/client/src/app/site/users/models/view-user.ts +++ b/client/src/app/site/users/models/view-user.ts @@ -6,24 +6,28 @@ import { SearchRepresentation } from 'app/core/ui-services/search.service'; import { ViewGroup } from './view-group'; import { BaseViewModel } from 'app/site/base/base-view-model'; -export class ViewUser extends BaseProjectableViewModel implements Searchable { +export interface UserTitleInformation { + username: string; + title?: string; + first_name?: string; + last_name?: string; + structure_level?: string; + number?: string; +} + +export class ViewUser extends BaseProjectableViewModel<User> implements UserTitleInformation, Searchable { public static COLLECTIONSTRING = User.COLLECTIONSTRING; - private _user: User; private _groups: ViewGroup[]; public get user(): User { - return this._user; + return this._model; } public get groups(): ViewGroup[] { return this._groups; } - public get id(): number { - return this.user.id; - } - public get username(): string { return this.user.username; } @@ -97,81 +101,31 @@ export class ViewUser extends BaseProjectableViewModel implements Searchable { return this.user && !!this.user.last_email_send; } - /** - * Getter for the short name (Title, given name, surname) - * - * @returns a non-empty string - */ public get short_name(): string { - if (!this.user) { + if (this.user && this.getShortName) { + return this.getShortName(); + } else { return ''; } - - const title = this.title ? this.title.trim() : ''; - const firstName = this.first_name ? this.first_name.trim() : ''; - const lastName = this.last_name ? this.last_name.trim() : ''; - let shortName = `${firstName} ${lastName}`; - - if (shortName.length <= 1) { - // We have at least one space from the concatination of - // first- and lastname. - shortName = this.username; - } - - if (title) { - shortName = `${title} ${shortName}`; - } - - return shortName; } public get full_name(): string { - if (!this.user) { + if (this.user && this.getFullName) { + return this.getFullName(); + } else { return ''; } - - let name = this.short_name; - const additions: string[] = []; - - // addition: add number and structure level - const structure_level = this.structure_level ? this.structure_level.trim() : ''; - if (structure_level) { - additions.push(structure_level); - } - - const number = this.number ? this.number.trim() : ''; - if (number) { - if (this.getNumberForName) { - additions.push(this.getNumberForName(number)); - } - } - - if (additions.length > 0) { - name += ' (' + additions.join(' · ') + ')'; - } - return name.trim(); } - /** - * This is set by the repository - */ - public getVerboseName; - - /** - * This is set by the repository. Translates the number string. - */ - public getNumberForName; + // Will be set by the repository + public getFullName: () => string; + public getShortName: () => string; public constructor(user: User, groups?: ViewGroup[]) { - super(User.COLLECTIONSTRING); - this._user = user; + super(User.COLLECTIONSTRING, user); this._groups = groups; } - public getModel(): User { - return this.user; - } - /** * Formats the category for search * @@ -198,13 +152,6 @@ export class ViewUser extends BaseProjectableViewModel implements Searchable { }; } - /** - * required by BaseViewModel. Don't confuse with the users title. - */ - public getTitle = () => { - return this.full_name; - }; - public updateDependencies(update: BaseViewModel): void { if (update instanceof ViewGroup && this.user.groups_id.includes(update.id)) { const groupIndex = this.groups.findIndex(group => group.id === update.id); diff --git a/client/src/app/slides/agenda/common/common-list-of-speakers-slide-data.ts b/client/src/app/slides/agenda/common/common-list-of-speakers-slide-data.ts index d1724a1b8..c328de2ee 100644 --- a/client/src/app/slides/agenda/common/common-list-of-speakers-slide-data.ts +++ b/client/src/app/slides/agenda/common/common-list-of-speakers-slide-data.ts @@ -9,5 +9,4 @@ export interface CommonListOfSpeakersSlideData { finished?: SlideSpeaker[]; title_information?: object; content_object_collection?: string; - item_number?: string; } diff --git a/client/src/app/slides/agenda/common/common-list-of-speakers-slide.component.ts b/client/src/app/slides/agenda/common/common-list-of-speakers-slide.component.ts index 6ba5102b7..435e5f0f6 100644 --- a/client/src/app/slides/agenda/common/common-list-of-speakers-slide.component.ts +++ b/client/src/app/slides/agenda/common/common-list-of-speakers-slide.component.ts @@ -3,7 +3,7 @@ import { Component } from '@angular/core'; import { BaseSlideComponent } from 'app/slides/base-slide-component'; import { CommonListOfSpeakersSlideData } from './common-list-of-speakers-slide-data'; import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service'; -import { isBaseAgendaContentObjectRepository } from 'app/core/repositories/base-agenda-content-object-repository'; +import { isBaseIsAgendaItemContentObjectRepository } from 'app/core/repositories/base-is-agenda-item-content-object-repository'; @Component({ selector: 'os-common-list-of-speakers-slide', @@ -20,11 +20,10 @@ export class CommonListOfSpeakersSlideComponent extends BaseSlideComponent<Commo return ''; } - const numberPrefix = this.data.data.item_number ? `${this.data.data.item_number} · ` : ''; const repo = this.collectionStringMapperService.getRepository(this.data.data.content_object_collection); - if (isBaseAgendaContentObjectRepository(repo)) { - return numberPrefix + repo.getAgendaTitle(this.data.data.title_information); + if (isBaseIsAgendaItemContentObjectRepository(repo)) { + return repo.getAgendaSlideTitle(this.data.data.title_information); } else { throw new Error('The content object has no agenda base repository!'); } diff --git a/client/src/app/slides/agenda/item-list/item-list-slide-data.ts b/client/src/app/slides/agenda/item-list/item-list-slide-data.ts index e90e02fba..65a85da3c 100644 --- a/client/src/app/slides/agenda/item-list/item-list-slide-data.ts +++ b/client/src/app/slides/agenda/item-list/item-list-slide-data.ts @@ -1,5 +1,4 @@ export interface SlideItem { - item_number: string; title_information: object; collection: string; depth: number; diff --git a/client/src/app/slides/agenda/item-list/item-list-slide.component.html b/client/src/app/slides/agenda/item-list/item-list-slide.component.html index e8a725eae..012d79dea 100644 --- a/client/src/app/slides/agenda/item-list/item-list-slide.component.html +++ b/client/src/app/slides/agenda/item-list/item-list-slide.component.html @@ -7,7 +7,6 @@ [ngClass]="item.depth === 0 ? 'mainitem' : 'subitem'" class="item" > - <span *ngIf="item.item_number">{{ item.item_number }} ·</span> {{ getTitle(item) }} </div> </div> diff --git a/client/src/app/slides/agenda/item-list/item-list-slide.component.ts b/client/src/app/slides/agenda/item-list/item-list-slide.component.ts index 732e37132..412c0c28c 100644 --- a/client/src/app/slides/agenda/item-list/item-list-slide.component.ts +++ b/client/src/app/slides/agenda/item-list/item-list-slide.component.ts @@ -1,8 +1,9 @@ import { Component } from '@angular/core'; + import { BaseSlideComponent } from 'app/slides/base-slide-component'; import { ItemListSlideData, SlideItem } from './item-list-slide-data'; import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service'; -import { isBaseAgendaContentObjectRepository } from 'app/core/repositories/base-agenda-content-object-repository'; +import { isBaseIsAgendaItemContentObjectRepository } from 'app/core/repositories/base-is-agenda-item-content-object-repository'; @Component({ selector: 'os-item-list-slide', @@ -16,8 +17,8 @@ export class ItemListSlideComponent extends BaseSlideComponent<ItemListSlideData public getTitle(item: SlideItem): string { const repo = this.collectionStringMapperService.getRepository(item.collection); - if (isBaseAgendaContentObjectRepository(repo)) { - return repo.getAgendaTitle(item.title_information); + if (isBaseIsAgendaItemContentObjectRepository(repo)) { + return repo.getAgendaSlideTitle(item.title_information); } else { throw new Error('The content object has no agenda based repository!'); } diff --git a/client/src/app/slides/motions/base/base-motion-slide.ts b/client/src/app/slides/motions/base/base-motion-slide.ts index 9ad627917..fd808d5c9 100644 --- a/client/src/app/slides/motions/base/base-motion-slide.ts +++ b/client/src/app/slides/motions/base/base-motion-slide.ts @@ -2,11 +2,7 @@ import { TranslateService } from '@ngx-translate/core'; import { BaseSlideComponent } from 'app/slides/base-slide-component'; import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service'; - -export interface MotionTitleInformation { - title: string; - identifier?: string; -} +import { MotionTitleInformation } from 'app/site/motions/models/view-motion'; /** * Format for referenced motions: A mapping of motion ids to their title information. diff --git a/client/src/app/slides/motions/motion-block/motion-block-slide-data.ts b/client/src/app/slides/motions/motion-block/motion-block-slide-data.ts index 382e1b0c9..fae84b7c6 100644 --- a/client/src/app/slides/motions/motion-block/motion-block-slide-data.ts +++ b/client/src/app/slides/motions/motion-block/motion-block-slide-data.ts @@ -1,4 +1,5 @@ -import { ReferencedMotions, MotionTitleInformation } from '../base/base-motion-slide'; +import { MotionTitleInformation } from 'app/site/motions/models/view-motion'; +import { ReferencedMotions } from '../base/base-motion-slide'; export interface MotionBlockSlideMotionRepresentation extends MotionTitleInformation { recommendation?: { diff --git a/openslides/agenda/access_permissions.py b/openslides/agenda/access_permissions.py index 7ffa8f1c3..675db847c 100644 --- a/openslides/agenda/access_permissions.py +++ b/openslides/agenda/access_permissions.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Iterable, List +from typing import Any, Dict, List from ..utils.access_permissions import BaseAccessPermissions from ..utils.auth import async_has_perm @@ -18,12 +18,14 @@ class ItemAccessPermissions(BaseAccessPermissions): ) -> List[Dict[str, Any]]: """ Returns the restricted serialized data for the instance prepared - for the user. + for the user. If the user does not have agenda.can_see, no data will + be retuned. - Hidden items can only be seen by managers with can_manage permission. + Hidden items can only be seen by managers with can_manage permission. If a user + does not have this permission, he is not allowed to see comments. - We remove comments for non admins/managers and a lot of fields of - internal items for users without permission to see internal items. + Internal items can only be seen by users with can_see_internal_items. If a user + does not have this permission, he is not allowed to see the duration. """ def filtered_data(full_data, blocked_keys): @@ -35,64 +37,36 @@ class ItemAccessPermissions(BaseAccessPermissions): # Parse data. if full_data and await async_has_perm(user_id, "agenda.can_see"): - if await async_has_perm( - user_id, "agenda.can_manage" - ) and await async_has_perm(user_id, "agenda.can_see_internal_items"): - # Managers with special permission can see everything. - data = full_data - elif await async_has_perm(user_id, "agenda.can_see_internal_items"): - # Non managers with special permission can see everything but - # comments and hidden items. + # Assume the user has all permissions. Restrict this below. + data = full_data + + blocked_keys: List[str] = [] + + # Restrict data for non managers + if not await async_has_perm(user_id, "agenda.can_manage"): data = [ - full for full in full_data if not full["is_hidden"] + full for full in data if not full["is_hidden"] ] # filter hidden items - blocked_keys = ("comment",) - data = [ - filtered_data(full, blocked_keys) for full in data - ] # remove blocked_keys - else: - # Users without special permission for internal items. + blocked_keys.append("comment") - # In internal and hidden case managers and non managers see only some fields - # so that list of speakers is provided regardless. Hidden items can only be seen by managers. - # We know that full_data has at least one entry which can be used to parse the keys. - blocked_keys_internal_hidden_case = set(full_data[0].keys()) - set( - ( - "id", - "title_information", - "speakers", - "speaker_list_closed", - "content_object", - ) - ) + # Restrict data for users without can_see_internal_items + if not await async_has_perm(user_id, "agenda.can_see_internal_items"): + data = [full for full in data if not full["is_internal"]] + blocked_keys.append("duration") - # In non internal case managers see everything and non managers see - # everything but comments. - if await async_has_perm(user_id, "agenda.can_manage"): - blocked_keys_non_internal_hidden_case: Iterable[str] = [] - can_see_hidden = True - else: - blocked_keys_non_internal_hidden_case = ("comment",) - can_see_hidden = False - - data = [] - for full in full_data: - if full["is_hidden"]: - if can_see_hidden: - # Same filtering for internal and hidden items - data.append( - filtered_data(full, blocked_keys_internal_hidden_case) - ) - # If can_see_hidden is false, the user (which is a non manager) can not see anything. - elif full["is_internal"]: - data.append( - filtered_data(full, blocked_keys_internal_hidden_case) - ) - else: # agenda item - data.append( - filtered_data(full, blocked_keys_non_internal_hidden_case) - ) + if len(blocked_keys) > 0: + data = [filtered_data(full, blocked_keys) for full in data] else: data = [] return data + + +class ListOfSpeakersAccessPermissions(BaseAccessPermissions): + """ + Access permissions container for ListOfSpeakers and ListOfSpeakersViewSet. + No data will be restricted, because everyone can see the list of speakers + at any time. + """ + + base_permission = "agenda.can_see_list_of_speakers" diff --git a/openslides/agenda/apps.py b/openslides/agenda/apps.py index eeae3d2d4..75d532715 100644 --- a/openslides/agenda/apps.py +++ b/openslides/agenda/apps.py @@ -6,7 +6,6 @@ from django.apps import AppConfig class AgendaAppConfig(AppConfig): name = "openslides.agenda" verbose_name = "OpenSlides Agenda" - angular_site_module = True def ready(self): # Import all required stuff. @@ -19,7 +18,7 @@ class AgendaAppConfig(AppConfig): listen_to_related_object_post_delete, listen_to_related_object_post_save, ) - from .views import ItemViewSet + from .views import ItemViewSet, ListOfSpeakersViewSet from . import serializers # noqa from ..utils.access_permissions import required_user @@ -41,10 +40,14 @@ class AgendaAppConfig(AppConfig): # Register viewsets. router.register(self.get_model("Item").get_collection_string(), ItemViewSet) + router.register( + self.get_model("ListOfSpeakers").get_collection_string(), + ListOfSpeakersViewSet, + ) # register required_users required_user.add_collection_string( - self.get_model("Item").get_collection_string(), required_users + self.get_model("ListOfSpeakers").get_collection_string(), required_users ) def get_config_variables(self): @@ -58,6 +61,7 @@ class AgendaAppConfig(AppConfig): connection. """ yield self.get_model("Item") + yield self.get_model("ListOfSpeakers") def required_users(element: Dict[str, Any]) -> Set[int]: diff --git a/openslides/agenda/migrations/0007_list_of_speakers_1.py b/openslides/agenda/migrations/0007_list_of_speakers_1.py new file mode 100644 index 000000000..3bb8ced13 --- /dev/null +++ b/openslides/agenda/migrations/0007_list_of_speakers_1.py @@ -0,0 +1,79 @@ +# Generated by Django 2.1.7 on 2019-04-25 07:00 + +import django.db.models.deletion +from django.db import migrations, models + +import openslides.utils.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("agenda", "0006_auto_20190119_1425"), + ] + + operations = [ + migrations.CreateModel( + name="ListOfSpeakers", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("object_id", models.PositiveIntegerField(blank=True, null=True)), + ("closed", models.BooleanField(default=False)), + ( + "content_type", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="contenttypes.ContentType", + ), + ), + ], + options={ + "permissions": ( + ("can_see_list_of_speakers", "Can see list of speakers"), + ("can_manage_list_of_speakers", "Can manage list of speakers"), + ), + "default_permissions": (), + }, + bases=(openslides.utils.models.RESTModelMixin, models.Model), + ), + migrations.AlterModelOptions( + name="item", + options={ + "default_permissions": (), + "permissions": ( + ("can_see", "Can see agenda"), + ("can_manage", "Can manage agenda"), + ( + "can_see_internal_items", + "Can see internal items and time scheduling of agenda", + ), + ), + }, + ), + migrations.AddField( + model_name="speaker", + name="list_of_speakers", + field=models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="speakers", + to="agenda.ListOfSpeakers", + ), + preserve_default=False, + ), + migrations.AlterUniqueTogether( + name="listofspeakers", unique_together={("content_type", "object_id")} + ), + ] diff --git a/openslides/agenda/migrations/0007_list_of_speakers_2.py b/openslides/agenda/migrations/0007_list_of_speakers_2.py new file mode 100644 index 000000000..b146c5e02 --- /dev/null +++ b/openslides/agenda/migrations/0007_list_of_speakers_2.py @@ -0,0 +1,56 @@ +# Generated by Django 2.1.7 on 2019-04-25 07:00 + +from django.db import migrations + + +def move_speakers_to_own_model(apps, schema_editor): + """ + Create a list of speaker for every item that exists. Move the speakers over to + the new list of speakers. + """ + Item = apps.get_model("agenda", "Item") + Speaker = apps.get_model("agenda", "Speaker") + ListOfSpeakers = apps.get_model("agenda", "ListOfSpeakers") + + for item in Item.objects.all(): + los = ListOfSpeakers( + content_type=item.content_type, + object_id=item.object_id, + closed=item.speaker_list_closed, + ) + los.save(skip_autoupdate=True) + + for speaker in Speaker.objects.all(): + speaker.list_of_speakers = ListOfSpeakers.objects.get( + object_id=speaker.item.object_id, content_type=speaker.item.content_type + ) + speaker.save(skip_autoupdate=True) + + +def add_speakers_to_mediafiles(apps, schema_editor): + """ + Adds lists of speakers for all mediafiles. + """ + ListOfSpeakers = apps.get_model("agenda", "ListOfSpeakers") + ContentType = apps.get_model("contenttypes", "ContentType") + Mediafile = apps.get_model("mediafiles", "Mediafile") + + mediafile_content_type = ContentType.objects.get_for_model(Mediafile) + for mediafile in Mediafile.objects.all(): + los = ListOfSpeakers( + content_type=mediafile_content_type, object_id=mediafile.pk + ) + los.save(skip_autoupdate=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ("agenda", "0007_list_of_speakers_1"), + ("mediafiles", "0003_auto_20190119_1425"), + ] + + operations = [ + migrations.RunPython(move_speakers_to_own_model), + migrations.RunPython(add_speakers_to_mediafiles), + ] diff --git a/openslides/agenda/migrations/0007_list_of_speakers_3.py b/openslides/agenda/migrations/0007_list_of_speakers_3.py new file mode 100644 index 000000000..3d8b08df9 --- /dev/null +++ b/openslides/agenda/migrations/0007_list_of_speakers_3.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1.7 on 2019-04-25 07:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("agenda", "0007_list_of_speakers_2")] + + operations = [ + migrations.RemoveField(model_name="item", name="speaker_list_closed"), + migrations.AlterField( + model_name="speaker", + name="list_of_speakers", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="speakers", + to="agenda.ListOfSpeakers", + ), + ), + migrations.RemoveField(model_name="speaker", name="item"), + ] diff --git a/openslides/agenda/mixins.py b/openslides/agenda/mixins.py new file mode 100644 index 000000000..6317588c2 --- /dev/null +++ b/openslides/agenda/mixins.py @@ -0,0 +1,113 @@ +from typing import Any, Dict + +from django.contrib.contenttypes.fields import GenericRelation +from django.db import models + +from .models import Item, ListOfSpeakers + + +# See https://github.com/python/mypy/issues/3855 +Unsafe: Any = object + + +class AgendaItemMixin(models.Model): + """ + A mixin for every model that should have an agenda item associated. + """ + + # In theory there could be one then more agenda_item. But we support only one. + agenda_items = GenericRelation(Item) + + class Meta(Unsafe): + abstract = True + + """ + Container for runtime information for agenda app (on create or update of this instance). + """ + agenda_item_update_information: Dict[str, Any] = {} + + agenda_item_skip_autoupdate = False + + @property + def agenda_item(self): + """ + Returns the related agenda item. + """ + # We support only one agenda item so just return the first element of + # the queryset. + return self.agenda_items.all()[0] + + @property + def agenda_item_id(self): + """ + Returns the id of the agenda item object related to this object. + """ + return self.agenda_item.pk + + def get_agenda_title_information(self): + raise NotImplementedError( + "An agenda content object has to provide title information" + ) + + +class ListOfSpeakersMixin(models.Model): + """ + A mixin for every model that should have a list of speakers associated. + """ + + # In theory there could be one then more list of speakers. But we support only one. + lists_of_speakers = GenericRelation(ListOfSpeakers) + + class Meta(Unsafe): + abstract = True + + list_of_speakers_skip_autoupdate = False + + @property + def list_of_speakers(self): + """ + Returns the related list of speakers object. + """ + # We support only one list of speakers so just return the first element of + # the queryset. + return self.lists_of_speakers.all()[0] + + @property + def list_of_speakers_id(self): + """ + Returns the id of the list of speakers object related to this object. + """ + return self.list_of_speakers.pk + + def get_list_of_speakers_title_information(self): + raise NotImplementedError( + "An agenda content object has to provide title information" + ) + + +class AgendaItemWithListOfSpeakersMixin(AgendaItemMixin, ListOfSpeakersMixin): + """ + A combined mixin for agenda items and list of speakers. + """ + + class Meta(Unsafe): + abstract = True + + def set_skip_autoupdate_agenda_item_and_list_of_speakers( + self, skip_autoupdate=True + ): + self.agenda_item_skip_autoupdate = skip_autoupdate + self.list_of_speakers_skip_autoupdate = skip_autoupdate + + def get_title_information(self): + raise NotImplementedError( + "An agenda content object has to provide title information" + ) + + def get_agenda_title_information(self): + # TODO: See issue #4738 + return self.get_title_information() + + def get_list_of_speakers_title_information(self): + # TODO: See issue #4738 + return self.get_title_information() diff --git a/openslides/agenda/models.py b/openslides/agenda/models.py index 2e8e1012d..5f6d9cde8 100644 --- a/openslides/agenda/models.py +++ b/openslides/agenda/models.py @@ -16,7 +16,7 @@ from openslides.utils.models import RESTModelMixin from openslides.utils.utils import to_roman from ..utils.models import CASCADE_AND_AUTOUODATE, SET_NULL_AND_AUTOUPDATE -from .access_permissions import ItemAccessPermissions +from .access_permissions import ItemAccessPermissions, ListOfSpeakersAccessPermissions class ItemManager(models.Manager): @@ -28,10 +28,13 @@ class ItemManager(models.Manager): def get_full_queryset(self): """ Returns the normal queryset with all items. In the background all - speakers and related items (topics, motions, assignments) are - prefetched from the database. + related items (topics, motions, assignments) are prefetched from the database. """ - return self.get_queryset().prefetch_related("speakers", "content_object") + # TODO: Fix the django bug: we cannot include "content_object__agenda_items" here, + # because this is some kind of cyclic lookup. The _prefetched_objects_cache of every + # content object will hold wrong values for the agenda item. + # See issue #4738 + return self.get_queryset().prefetch_related("content_object") def get_only_non_public_items(self): """ @@ -212,7 +215,7 @@ class Item(RESTModelMixin, models.Model): comment = models.TextField(null=True, blank=True) """ - Optional comment to the agenda item. Will not be shoun to normal users. + Optional comment to the agenda item. Will not be shown to normal users. """ closed = models.BooleanField(default=False) @@ -265,17 +268,11 @@ class Item(RESTModelMixin, models.Model): Field for generic relation to a related object. General field to the related object. """ - speaker_list_closed = models.BooleanField(default=False) - """ - True, if the list of speakers is closed. - """ - class Meta: default_permissions = () permissions = ( ("can_see", "Can see agenda"), ("can_manage", "Can manage agenda"), - ("can_manage_list_of_speakers", "Can manage list of speakers"), ( "can_see_internal_items", "Can see internal items and time scheduling of agenda", @@ -332,6 +329,71 @@ class Item(RESTModelMixin, models.Model): else: return self.parent.level + 1 + +class ListOfSpeakersManager(models.Manager): + """ + """ + + def get_full_queryset(self): + """ + Returns the normal queryset with all items. In the background all + speakers and related items (topics, motions, assignments) are + prefetched from the database. + """ + return self.get_queryset().prefetch_related("speakers", "content_object") + + +class ListOfSpeakers(RESTModelMixin, models.Model): + """ + """ + + access_permissions = ListOfSpeakersAccessPermissions() + objects = ListOfSpeakersManager() + can_see_permission = "agenda.can_see_list_of_speakers" + + content_type = models.ForeignKey( + ContentType, on_delete=models.SET_NULL, null=True, blank=True + ) + """ + Field for generic relation to a related object. Type of the object. + """ + + object_id = models.PositiveIntegerField(null=True, blank=True) + """ + Field for generic relation to a related object. Id of the object. + """ + + content_object = GenericForeignKey() + """ + Field for generic relation to a related object. General field to the related object. + """ + + closed = models.BooleanField(default=False) + """ + True, if the list of speakers is closed. + """ + + class Meta: + default_permissions = () + permissions = ( + ("can_see_list_of_speakers", "Can see list of speakers"), + ("can_manage_list_of_speakers", "Can manage list of speakers"), + ) + unique_together = ("content_type", "object_id") + + @property + def title_information(self): + """ + Return get_list_of_speakers_title_information() from the content_object. + """ + try: + return self.content_object.get_list_of_speakers_title_information() + except AttributeError: + raise NotImplementedError( + "You have to provide a get_list_of_speakers_title_information " + "method on your related model." + ) + def get_next_speaker(self): """ Returns the speaker object of the speaker who is next. @@ -348,20 +410,27 @@ class SpeakerManager(models.Manager): Manager for Speaker model. Provides a customized add method. """ - def add(self, user, item, skip_autoupdate=False): + def add(self, user, list_of_speakers, skip_autoupdate=False): """ Customized manager method to prevent anonymous users to be on the list of speakers and that someone is twice on one list (off coming speakers). Cares also initial sorting of the coming speakers. """ - if self.filter(user=user, item=item, begin_time=None).exists(): + if self.filter( + user=user, list_of_speakers=list_of_speakers, begin_time=None + ).exists(): raise OpenSlidesError(f"{user} is already on the list of speakers.") if isinstance(user, AnonymousUser): raise OpenSlidesError("An anonymous user can not be on lists of speakers.") weight = ( - self.filter(item=item).aggregate(models.Max("weight"))["weight__max"] or 0 + self.filter(list_of_speakers=list_of_speakers).aggregate( + models.Max("weight") + )["weight__max"] + or 0 + ) + speaker = self.model( + list_of_speakers=list_of_speakers, user=user, weight=weight + 1 ) - speaker = self.model(item=item, user=user, weight=weight + 1) speaker.save(force_insert=True, skip_autoupdate=skip_autoupdate) return speaker @@ -378,9 +447,11 @@ class Speaker(RESTModelMixin, models.Model): ForeinKey to the user who speaks. """ - item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name="speakers") + list_of_speakers = models.ForeignKey( + ListOfSpeakers, on_delete=models.CASCADE, related_name="speakers" + ) """ - ForeinKey to the agenda item to which the user want to speak. + ForeinKey to the list of speakers to which the user want to speak. """ begin_time = models.DateTimeField(null=True) @@ -419,19 +490,21 @@ class Speaker(RESTModelMixin, models.Model): """ try: current_speaker = ( - Speaker.objects.filter(item=self.item, end_time=None) + Speaker.objects.filter( + list_of_speakers=self.list_of_speakers, end_time=None + ) .exclude(begin_time=None) .get() ) except Speaker.DoesNotExist: pass else: - # Do not send an autoupdate for the countdown and the item. This is done - # by saving the item and countdown later. + # Do not send an autoupdate for the countdown and the list_of_speakers. This is done + # by saving the list_of_speakers and countdown later. current_speaker.end_speech(skip_autoupdate=True) self.weight = None self.begin_time = timezone.now() - self.save() # Here, the item is saved and causes an autoupdate. + self.save() # Here, the list_of_speakers is saved and causes an autoupdate. if config["agenda_couple_countdown_and_speakers"]: countdown, created = Countdown.objects.get_or_create( pk=1, @@ -465,6 +538,6 @@ class Speaker(RESTModelMixin, models.Model): def get_root_rest_element(self): """ - Returns the item to this instance which is the root REST element. + Returns the list_of_speakers to this instance which is the root REST element. """ - return self.item + return self.list_of_speakers diff --git a/openslides/agenda/projector.py b/openslides/agenda/projector.py index 70e18a857..e5cd9d10b 100644 --- a/openslides/agenda/projector.py +++ b/openslides/agenda/projector.py @@ -47,15 +47,13 @@ async def get_flat_tree(all_data: AllData, parent_id: int = 0) -> List[Dict[str, async def get_children(item_ids: List[int], depth: int) -> None: for item_id in item_ids: + item = all_data["agenda/item"][item_id] + title_information = item["title_information"] + title_information["agenda_item_number"] = item["item_number"] tree.append( { - "item_number": all_data["agenda/item"][item_id]["item_number"], - "title_information": all_data["agenda/item"][item_id][ - "title_information" - ], - "collection": all_data["agenda/item"][item_id]["content_object"][ - "collection" - ], + "title_information": title_information, + "collection": item["content_object"]["collection"], "depth": depth, } ) @@ -79,10 +77,11 @@ async def item_list_slide( agenda_items = [] for item in await get_sorted_agenda_items(all_data): if item["parent_id"] is None and item["type"] == 1: + title_information = item["title_information"] + title_information["agenda_item_number"] = item["item_number"] agenda_items.append( { - "item_number": item["item_number"], - "title_information": item["title_information"], + "title_information": title_information, "collection": item["content_object"]["collection"], } ) @@ -100,27 +99,39 @@ async def list_of_speakers_slide( Returns all usernames, that are on the list of speaker of a slide. """ - item_id = element.get("id") + list_of_speakers_id = element.get("id") - if item_id is None: + if list_of_speakers_id is None: raise ProjectorElementException("id is required for list of speakers slide") - return await get_list_of_speakers_slide_data(all_data, item_id) + return await get_list_of_speakers_slide_data(all_data, list_of_speakers_id) async def get_list_of_speakers_slide_data( - all_data: AllData, item_id: int + all_data: AllData, list_of_speakers_id: int ) -> Dict[str, Any]: try: - item = all_data["agenda/item"][item_id] + list_of_speakers = all_data["agenda/list-of-speakers"][list_of_speakers_id] except KeyError: - raise ProjectorElementException(f"Item {item_id} does not exist") + raise ProjectorElementException( + f"List of speakers {list_of_speakers_id} does not exist" + ) + + title_information = list_of_speakers["title_information"] + # try to get the agenda item for the content object (which must not exist) + agenda_item_id = all_data[list_of_speakers["content_object"]["collection"]][ + list_of_speakers["content_object"]["id"] + ].get("agenda_item_id") + if agenda_item_id: + title_information["agenda_item_number"] = all_data["agenda/item"][ + agenda_item_id + ]["item_number"] # Partition speaker objects to waiting, current and finished speakers_waiting = [] speakers_finished = [] current_speaker = None - for speaker in item["speakers"]: + for speaker in list_of_speakers["speakers"]: user = await get_user_name(all_data, speaker["user_id"]) formatted_speaker = { "user": user, @@ -152,23 +163,22 @@ async def get_list_of_speakers_slide_data( "waiting": speakers_waiting, "current": current_speaker, "finished": speakers_finished, - "content_object_collection": item["content_object"]["collection"], - "title_information": item["title_information"], - "item_number": item["item_number"], + "content_object_collection": list_of_speakers["content_object"]["collection"], + "title_information": title_information, } -async def get_current_item_id_for_projector( +async def get_current_list_of_speakers_id_for_projector( all_data: AllData, projector: Dict[str, Any] ) -> Union[int, None]: """ - Search for elements, that do have an agenda item: + Search for elements, that do have a list of speakers: Try to get a model by the collection and id in the element. This - model needs to have a 'agenda_item_id'. This item must exist. The first - matching element is taken. + model needs to have a 'list_of_speakers_id'. This list of speakers + must exist. The first matching element is taken. """ elements = projector["elements"] - item_id = None + list_of_speakers_id = None for element in elements: if "id" not in element: continue @@ -178,16 +188,16 @@ async def get_current_item_id_for_projector( continue model = all_data[collection][id] - if "agenda_item_id" not in model: + if "list_of_speakers_id" not in model: continue - if not model["agenda_item_id"] in all_data["agenda/item"]: + if not model["list_of_speakers_id"] in all_data["agenda/list-of-speakers"]: continue - item_id = model["agenda_item_id"] + list_of_speakers_id = model["list_of_speakers_id"] break - return item_id + return list_of_speakers_id async def get_reference_projector( @@ -219,11 +229,13 @@ async def current_list_of_speakers_slide( The current list of speakers slide. Creates the data for the given projector. """ reference_projector = await get_reference_projector(all_data, projector_id) - item_id = await get_current_item_id_for_projector(all_data, reference_projector) - if item_id is None: # no element found + list_of_speakers_id = await get_current_list_of_speakers_id_for_projector( + all_data, reference_projector + ) + if list_of_speakers_id is None: # no element found return {} - return await get_list_of_speakers_slide_data(all_data, item_id) + return await get_list_of_speakers_slide_data(all_data, list_of_speakers_id) async def current_speaker_chyron_slide( @@ -241,21 +253,26 @@ async def current_speaker_chyron_slide( } reference_projector = await get_reference_projector(all_data, projector_id) - item_id = await get_current_item_id_for_projector(all_data, reference_projector) - if item_id is None: # no element found + list_of_speakers_id = await get_current_list_of_speakers_id_for_projector( + all_data, reference_projector + ) + if list_of_speakers_id is None: # no element found return slide_data - # get item + # get list of speakers to search current speaker try: - item = all_data["agenda/item"][item_id] + list_of_speakers = all_data["agenda/list-of-speakers"][list_of_speakers_id] except KeyError: - raise ProjectorElementException(f"Item {item_id} does not exist") + raise ProjectorElementException( + f"List of speakers {list_of_speakers_id} does not exist" + ) # find current speaker current_speaker = None - for speaker in item["speakers"]: + for speaker in list_of_speakers["speakers"]: if speaker["begin_time"] is not None and speaker["end_time"] is None: current_speaker = await get_user_name(all_data, speaker["user_id"]) + break if current_speaker is not None: slide_data["current_speaker"] = current_speaker diff --git a/openslides/agenda/serializers.py b/openslides/agenda/serializers.py index fa26479bb..e6998ca98 100644 --- a/openslides/agenda/serializers.py +++ b/openslides/agenda/serializers.py @@ -1,6 +1,6 @@ from openslides.utils.rest_api import JSONField, ModelSerializer, RelatedField -from .models import Item, Speaker +from .models import Item, ListOfSpeakers, Speaker class SpeakerSerializer(ModelSerializer): @@ -10,15 +10,7 @@ class SpeakerSerializer(ModelSerializer): class Meta: model = Speaker - fields = ( - "id", - "user", - "begin_time", - "end_time", - "weight", - "marked", - "item", # js-data needs the item-id in the nested object to define relations. - ) + fields = ("id", "user", "begin_time", "end_time", "weight", "marked") class RelatedItemRelatedField(RelatedField): @@ -40,7 +32,6 @@ class ItemSerializer(ModelSerializer): """ content_object = RelatedItemRelatedField(read_only=True) - speakers = SpeakerSerializer(many=True, read_only=True) title_information = JSONField(read_only=True) @@ -56,10 +47,24 @@ class ItemSerializer(ModelSerializer): "is_internal", "is_hidden", "duration", - "speakers", - "speaker_list_closed", "content_object", "weight", "parent", "level", ) + + +class ListOfSpeakersSerializer(ModelSerializer): + """ + Serializer for agenda.models.Item objects. + """ + + content_object = RelatedItemRelatedField(read_only=True) + speakers = SpeakerSerializer(many=True, read_only=True) + + title_information = JSONField(read_only=True) + + class Meta: + model = ListOfSpeakers + fields = ("id", "title_information", "speakers", "closed", "content_object") + read_only_fields = ("id", "title_information", "speakers", "content_object") diff --git a/openslides/agenda/signals.py b/openslides/agenda/signals.py index a772b0dd8..84161538f 100644 --- a/openslides/agenda/signals.py +++ b/openslides/agenda/signals.py @@ -1,8 +1,11 @@ +from typing import Iterable, Tuple + from django.apps import apps from django.contrib.contenttypes.models import ContentType +from django.db import models from ..utils.autoupdate import inform_changed_data -from .models import Item +from .models import Item, ListOfSpeakers def listen_to_related_object_post_save(sender, instance, created, **kwargs): @@ -16,7 +19,19 @@ def listen_to_related_object_post_save(sender, instance, created, **kwargs): Do not run caching and autoupdate if the instance has a key skip_autoupdate in the agenda_item_update_information container. """ - if hasattr(instance, "get_agenda_title_information"): + instance_inform_changed_data = ( + False + ) # evaluates, if the related_object has to be send again. + # This is the case, if it was newly created and the autoupdate is not skipped with + # agenda_item_skip_autoupdate or List_of_speakers_skip_autoupdate. If the related_object is + # related to agenda items and list of speakers, the autoupdate is skipped, if one of the given + # values is True. + is_agenda_item_content_object = hasattr(instance, "get_agenda_title_information") + is_list_of_speakers_content_object = hasattr( + instance, "get_list_of_speakers_title_information" + ) + + if is_agenda_item_content_object: if created: attrs = {} for attr in ("type", "parent_id", "comment", "duration", "weight"): @@ -24,41 +39,76 @@ def listen_to_related_object_post_save(sender, instance, created, **kwargs): attrs[attr] = instance.agenda_item_update_information.get(attr) Item.objects.create(content_object=instance, **attrs) - # If the object is created, the related_object has to be sent again. - if not instance.agenda_item_update_information.get("skip_autoupdate"): - inform_changed_data(instance) - elif not instance.agenda_item_update_information.get("skip_autoupdate"): + if not instance.agenda_item_skip_autoupdate: + instance_inform_changed_data = True + + elif not instance.agenda_item_skip_autoupdate: # If the object has changed, then also the agenda item has to be sent. inform_changed_data(instance.agenda_item) + if is_list_of_speakers_content_object: + if created: + ListOfSpeakers.objects.create(content_object=instance) + if not instance.list_of_speakers_skip_autoupdate: + instance_inform_changed_data = True + + elif not instance.list_of_speakers_skip_autoupdate: + # If the object has changed, then also the list of speakers has to be sent. + inform_changed_data(instance.list_of_speakers) + + # If the related_object is related to the angenda and list of speakers, check, if skip_autoupdate + # is False for both, + if created and is_agenda_item_content_object and is_list_of_speakers_content_object: + instance_inform_changed_data = not ( + instance.agenda_item_skip_autoupdate + or instance.list_of_speakers_skip_autoupdate + ) + + if instance_inform_changed_data: + inform_changed_data(instance) + def listen_to_related_object_post_delete(sender, instance, **kwargs): """ Receiver function to delete agenda items. It is connected to the signal django.db.models.signals.post_delete during app loading. """ - if hasattr(instance, "get_agenda_title_information"): - content_type = ContentType.objects.get_for_model(instance) - try: - # Attention: This delete() call is also necessary to remove - # respective active list of speakers projector elements. - Item.objects.get(object_id=instance.pk, content_type=content_type).delete() - except Item.DoesNotExist: - # Item does not exist so we do not have to delete it. - pass + + has_content_object_mapping: Iterable[Tuple[str, models.Model]] = ( + ("get_agenda_title_information", Item), + ("get_list_of_speakers_title_information", ListOfSpeakers), + ) + + for (attr, Model) in has_content_object_mapping: + if hasattr(instance, attr): + content_type = ContentType.objects.get_for_model(instance) + try: + Model.objects.get( + object_id=instance.pk, content_type=content_type + ).delete() + except Model.DoesNotExist: + # Model does not exist so we do not have to delete it. + pass def get_permission_change_data(sender, permissions, **kwargs): """ - Yields all necessary collections if 'agenda.can_see' or - 'agenda.can_see_internal_items' permissions changes. + Yields all necessary collections if 'agenda.can_see', + 'agenda.can_see_list_of_speakers', 'agenda.can_see_internal_items' + or 'agenda.can_manage' permissions changes. """ agenda_app = apps.get_app_config(app_label="agenda") for permission in permissions: # There could be only one 'agenda.can_see' and then we want to return data. if ( permission.content_type.app_label == agenda_app.label - and permission.codename in ("can_see", "can_see_internal_items") + and permission.codename + in ( + "can_see", + "can_see_list_of_speakers", + "can_see_internal_items", + "can_manage", + ) ): yield from agenda_app.get_startup_elements() break diff --git a/openslides/agenda/views.py b/openslides/agenda/views.py index b169c96ad..a525d1a34 100644 --- a/openslides/agenda/views.py +++ b/openslides/agenda/views.py @@ -8,6 +8,7 @@ from openslides.utils.exceptions import OpenSlidesError from openslides.utils.rest_api import ( GenericViewSet, ListModelMixin, + ModelViewSet, Response, RetrieveModelMixin, UpdateModelMixin, @@ -19,15 +20,13 @@ from openslides.utils.views import TreeSortMixin from ..utils.auth import has_perm from .access_permissions import ItemAccessPermissions -from .models import Item, Speaker +from .models import Item, ListOfSpeakers, Speaker # Viewsets for the REST API -class ItemViewSet( - ListModelMixin, RetrieveModelMixin, UpdateModelMixin, TreeSortMixin, GenericViewSet -): +class ItemViewSet(ModelViewSet, TreeSortMixin): """ API endpoint for agenda items. @@ -41,22 +40,14 @@ class ItemViewSet( """ Returns True if the user has required permissions. """ - if self.action in ("list", "retrieve"): + if self.action in ("list", "retrieve", "metadata"): result = self.get_access_permissions().check_permissions(self.request.user) - elif self.action in ("metadata", "manage_speaker", "tree"): - result = has_perm(self.request.user, "agenda.can_see") - # For manage_speaker and tree requests the rest of the check is - # done in the specific method. See below. - elif self.action in ("partial_update", "update", "sort", "assign"): + elif self.action in ("partial_update", "update", "destroy", "sort", "assign"): result = ( has_perm(self.request.user, "agenda.can_see") and has_perm(self.request.user, "agenda.can_see_internal_items") and has_perm(self.request.user, "agenda.can_manage") ) - elif self.action in ("speak", "sort_speakers"): - result = has_perm(self.request.user, "agenda.can_see") and has_perm( - self.request.user, "agenda.can_manage_list_of_speakers" - ) elif self.action in ("numbering",): result = has_perm(self.request.user, "agenda.can_see") and has_perm( self.request.user, "agenda.can_manage" @@ -92,224 +83,6 @@ class ItemViewSet( return response - @detail_route(methods=["POST", "PATCH", "DELETE"]) - def manage_speaker(self, request, pk=None): - """ - Special view endpoint to add users to the list of speakers or remove - them. Send POST {'user': <user_id>} to add a new speaker. Omit - data to add yourself. Send DELETE {'speaker': <speaker_id>} or - DELETE {'speaker': [<speaker_id>, <speaker_id>, ...]} to remove one or - more speakers from the list of speakers. Omit data to remove yourself. - Send PATCH {'user': <user_id>, 'marked': <bool>} to mark the speaker. - - Checks also whether the requesting user can do this. He needs at - least the permissions 'agenda.can_see' (see - self.check_view_permissions()). In case of adding himself the - permission 'agenda.can_be_speaker' is required. In case of adding - someone else the permission 'agenda.can_manage' is required. In - case of removing someone else 'agenda.can_manage' is required. In - case of removing himself no other permission is required. - """ - # Retrieve item. - item = self.get_object() - - if request.method == "POST": - # Retrieve user_id - user_id = request.data.get("user") - - # Check permissions and other conditions. Get user instance. - if user_id is None: - # Add oneself - if not has_perm(self.request.user, "agenda.can_be_speaker"): - self.permission_denied(request) - if item.speaker_list_closed: - raise ValidationError({"detail": "The list of speakers is closed."}) - user = self.request.user - else: - # Add someone else. - if not has_perm( - self.request.user, "agenda.can_manage_list_of_speakers" - ): - self.permission_denied(request) - try: - user = get_user_model().objects.get(pk=int(user_id)) - except (ValueError, get_user_model().DoesNotExist): - raise ValidationError({"detail": "User does not exist."}) - - # Try to add the user. This ensurse that a user is not twice in the - # list of coming speakers. - try: - Speaker.objects.add(user, item) - except OpenSlidesError as e: - raise ValidationError({"detail": str(e)}) - - # Send new speaker via autoupdate because users without permission - # to see users may not have it but can get it now. - inform_changed_data([user]) - - # Toggle 'marked' for the speaker - elif request.method == "PATCH": - # Check permissions - if not has_perm(self.request.user, "agenda.can_manage_list_of_speakers"): - self.permission_denied(request) - - # Retrieve user_id - user_id = request.data.get("user") - try: - user = get_user_model().objects.get(pk=int(user_id)) - except (ValueError, get_user_model().DoesNotExist): - raise ValidationError({"detail": "User does not exist."}) - - marked = request.data.get("marked") - if not isinstance(marked, bool): - raise ValidationError({"detail": "Marked has to be a bool."}) - - queryset = Speaker.objects.filter(item=item, user=user, begin_time=None) - try: - # We assume that there aren't multiple entries for speakers that - # did not yet begin to speak, because this - # is forbidden by the Manager's add method. We assume that - # there is only one speaker instance or none. - speaker = queryset.get() - except Speaker.DoesNotExist: - raise ValidationError( - {"detail": "The user is not in the list of speakers."} - ) - else: - speaker.marked = marked - speaker.save() - - else: - # request.method == 'DELETE' - speaker_ids = request.data.get("speaker") - - # Check permissions and other conditions. Get speaker instance. - if speaker_ids is None: - # Remove oneself - queryset = Speaker.objects.filter( - item=item, user=self.request.user - ).exclude(weight=None) - try: - # We assume that there aren't multiple entries because this - # is forbidden by the Manager's add method. We assume that - # there is only one speaker instance or none. - speaker = queryset.get() - except Speaker.DoesNotExist: - raise ValidationError( - {"detail": "You are not on the list of speakers."} - ) - else: - speaker.delete() - else: - # Remove someone else. - if not has_perm( - self.request.user, "agenda.can_manage_list_of_speakers" - ): - self.permission_denied(request) - if isinstance(speaker_ids, int): - speaker_ids = [speaker_ids] - deleted_speaker_count = 0 - for speaker_id in speaker_ids: - try: - speaker = Speaker.objects.get(pk=int(speaker_id)) - except (ValueError, Speaker.DoesNotExist): - pass - else: - speaker.delete(skip_autoupdate=True) - deleted_speaker_count += 1 - # send autoupdate if speakers are deleted - if deleted_speaker_count: - inform_changed_data(item) - - return Response() - - @detail_route(methods=["PUT", "DELETE"]) - def speak(self, request, pk=None): - """ - Special view endpoint to begin and end speech of speakers. Send PUT - {'speaker': <speaker_id>} to begin speech. Omit data to begin speech of - the next speaker. Send DELETE to end speech of current speaker. - """ - # Retrieve item. - item = self.get_object() - - if request.method == "PUT": - # Retrieve speaker_id - speaker_id = request.data.get("speaker") - if speaker_id is None: - speaker = item.get_next_speaker() - if speaker is None: - raise ValidationError({"detail": "The list of speakers is empty."}) - else: - try: - speaker = Speaker.objects.get(pk=int(speaker_id)) - except (ValueError, Speaker.DoesNotExist): - raise ValidationError({"detail": "Speaker does not exist."}) - speaker.begin_speech() - message = "User is now speaking." - - else: - # request.method == 'DELETE' - try: - # We assume that there aren't multiple entries because this - # is forbidden by the Model's begin_speech method. We assume that - # there is only one speaker instance or none. - current_speaker = ( - Speaker.objects.filter(item=item, end_time=None) - .exclude(begin_time=None) - .get() - ) - except Speaker.DoesNotExist: - raise ValidationError( - { - "detail": f"There is no one speaking at the moment according to {item}." - } - ) - current_speaker.end_speech() - message = "The speech is finished now." - - # Initiate response. - return Response({"detail": message}) - - @detail_route(methods=["POST"]) - def sort_speakers(self, request, pk=None): - """ - Special view endpoint to sort the list of speakers. - - Expects a list of IDs of the speakers. - """ - # Retrieve item. - item = self.get_object() - - # Check data - speaker_ids = request.data.get("speakers") - if not isinstance(speaker_ids, list): - raise ValidationError({"detail": "Invalid data."}) - - # Get all speakers - speakers = {} - for speaker in item.speakers.filter(begin_time=None): - speakers[speaker.pk] = speaker - - # Check and sort speakers - valid_speakers = [] - for speaker_id in speaker_ids: - if not isinstance(speaker_id, int) or speakers.get(speaker_id) is None: - raise ValidationError({"detail": "Invalid data."}) - valid_speakers.append(speakers[speaker_id]) - weight = 0 - with transaction.atomic(): - for speaker in valid_speakers: - speaker.weight = weight - speaker.save(skip_autoupdate=True) - weight += 1 - - # send autoupdate - inform_changed_data(item) - - # Initiate response. - return Response({"detail": "List of speakers successfully sorted."}) - @list_route(methods=["post"]) def numbering(self, request): """ @@ -419,3 +192,256 @@ class ItemViewSet( # Send response. return Response({"detail": f"{len(items)} items successfully assigned."}) + + +class ListOfSpeakersViewSet( + ListModelMixin, RetrieveModelMixin, UpdateModelMixin, TreeSortMixin, GenericViewSet +): + """ + API endpoint for agenda items. + + There are some views, see check_view_permissions. + """ + + access_permissions = ItemAccessPermissions() + queryset = ListOfSpeakers.objects.all() + + def check_view_permissions(self): + """ + Returns True if the user has required permissions. + """ + if self.action in ("list", "retrieve", "metadata"): + result = self.get_access_permissions().check_permissions(self.request.user) + elif self.action in ("manage_speaker",): + result = has_perm(self.request.user, "agenda.can_see_list_of_speakers") + # For manage_speaker requests the rest of the check is + # done in the specific method. See below. + elif self.action in ("update", "partial_update", "speak", "sort_speakers"): + result = has_perm( + self.request.user, "agenda.can_see_list_of_speakers" + ) and has_perm(self.request.user, "agenda.can_manage_list_of_speakers") + else: + result = False + return result + + @detail_route(methods=["POST", "PATCH", "DELETE"]) + def manage_speaker(self, request, pk=None): + """ + Special view endpoint to add users to the list of speakers or remove + them. Send POST {'user': <user_id>} to add a new speaker. Omit + data to add yourself. Send DELETE {'speaker': <speaker_id>} or + DELETE {'speaker': [<speaker_id>, <speaker_id>, ...]} to remove one or + more speakers from the list of speakers. Omit data to remove yourself. + Send PATCH {'user': <user_id>, 'marked': <bool>} to mark the speaker. + + Checks also whether the requesting user can do this. He needs at + least the permissions 'agenda.can_see_list_of_speakers' (see + self.check_view_permissions()). In case of adding himself the + permission 'agenda.can_be_speaker' is required. In case of adding + or removing someone else the permission 'agenda.can_manage_list_of_speakers' + is required. In case of removing himself no other permission is required. + """ + # Retrieve list of speakers. + list_of_speakers = self.get_object() + + if request.method == "POST": # Add new speaker + # Retrieve user_id + user_id = request.data.get("user") + + # Check permissions and other conditions. Get user instance. + if user_id is None: + # Add oneself + if not has_perm(self.request.user, "agenda.can_be_speaker"): + self.permission_denied(request) + if list_of_speakers.closed: + raise ValidationError({"detail": "The list of speakers is closed."}) + user = self.request.user + else: + # Add someone else. + if not has_perm( + self.request.user, "agenda.can_manage_list_of_speakers" + ): + self.permission_denied(request) + try: + user = get_user_model().objects.get(pk=int(user_id)) + except (ValueError, get_user_model().DoesNotExist): + raise ValidationError({"detail": "User does not exist."}) + + # Try to add the user. This ensurse that a user is not twice in the + # list of coming speakers. + try: + Speaker.objects.add(user, list_of_speakers) + except OpenSlidesError as e: + raise ValidationError({"detail": str(e)}) + + # Send new speaker via autoupdate because users without permission + # to see users may not have it but can get it now. + inform_changed_data([user]) + # TODO: inform_changed_data(user) should work. But isinstance(user, Iterable) is true... + + # Toggle 'marked' for the speaker + elif request.method == "PATCH": + # Check permissions + if not has_perm(self.request.user, "agenda.can_manage_list_of_speakers"): + self.permission_denied(request) + + # Retrieve user_id + user_id = request.data.get("user") + try: + user = get_user_model().objects.get(pk=int(user_id)) + except (ValueError, get_user_model().DoesNotExist): + raise ValidationError({"detail": "User does not exist."}) + + marked = request.data.get("marked") + if not isinstance(marked, bool): + raise ValidationError({"detail": "Marked has to be a bool."}) + + queryset = Speaker.objects.filter( + list_of_speakers=list_of_speakers, user=user, begin_time=None + ) + try: + # We assume that there aren't multiple entries for speakers that + # did not yet begin to speak, because this + # is forbidden by the Manager's add method. We assume that + # there is only one speaker instance or none. + speaker = queryset.get() + except Speaker.DoesNotExist: + raise ValidationError( + {"detail": "The user is not in the list of speakers."} + ) + else: + speaker.marked = marked + speaker.save() + + else: + # request.method == 'DELETE' + speaker_ids = request.data.get("speaker") + + # Check permissions and other conditions. Get speaker instance. + if speaker_ids is None: + # Remove oneself + queryset = Speaker.objects.filter( + list_of_speakers=list_of_speakers, user=self.request.user + ).exclude(weight=None) + try: + # We assume that there aren't multiple entries because this + # is forbidden by the Manager's add method. We assume that + # there is only one speaker instance or none. + speaker = queryset.get() + except Speaker.DoesNotExist: + raise ValidationError( + {"detail": "You are not on the list of speakers."} + ) + else: + speaker.delete() + else: + # Remove someone else. + if not has_perm( + self.request.user, "agenda.can_manage_list_of_speakers" + ): + self.permission_denied(request) + if isinstance(speaker_ids, int): + speaker_ids = [speaker_ids] + deleted_speaker_count = 0 + for speaker_id in speaker_ids: + try: + speaker = Speaker.objects.get(pk=int(speaker_id)) + except (ValueError, Speaker.DoesNotExist): + pass + else: + speaker.delete(skip_autoupdate=True) + deleted_speaker_count += 1 + # send autoupdate if speakers are deleted + if deleted_speaker_count > 0: + inform_changed_data(list_of_speakers) + + return Response() + + @detail_route(methods=["PUT", "DELETE"]) + def speak(self, request, pk=None): + """ + Special view endpoint to begin and end speech of speakers. Send PUT + {'speaker': <speaker_id>} to begin speech. Omit data to begin speech of + the next speaker. Send DELETE to end speech of current speaker. + """ + # Retrieve list_of_speakers. + list_of_speakers = self.get_object() + + if request.method == "PUT": + # Retrieve speaker_id + speaker_id = request.data.get("speaker") + if speaker_id is None: + speaker = list_of_speakers.get_next_speaker() + if speaker is None: + raise ValidationError({"detail": "The list of speakers is empty."}) + else: + try: + speaker = Speaker.objects.get(pk=int(speaker_id)) + except (ValueError, Speaker.DoesNotExist): + raise ValidationError({"detail": "Speaker does not exist."}) + speaker.begin_speech() + message = "User is now speaking." + + else: + # request.method == 'DELETE' + try: + # We assume that there aren't multiple entries because this + # is forbidden by the Model's begin_speech method. We assume that + # there is only one speaker instance or none. + current_speaker = ( + Speaker.objects.filter( + list_of_speakers=list_of_speakers, end_time=None + ) + .exclude(begin_time=None) + .get() + ) + except Speaker.DoesNotExist: + raise ValidationError( + { + "detail": f"There is no one speaking at the moment according to {list_of_speakers}." + } + ) + current_speaker.end_speech() + message = "The speech is finished now." + + # Initiate response. + return Response({"detail": message}) + + @detail_route(methods=["POST"]) + def sort_speakers(self, request, pk=None): + """ + Special view endpoint to sort the list of speakers. + + Expects a list of IDs of the speakers. + """ + # Retrieve list_of_speakers. + list_of_speakers = self.get_object() + + # Check data + speaker_ids = request.data.get("speakers") + if not isinstance(speaker_ids, list): + raise ValidationError({"detail": "Invalid data."}) + + # Get all speakers + speakers = {} + for speaker in list_of_speakers.speakers.filter(begin_time=None): + speakers[speaker.pk] = speaker + + # Check and sort speakers + valid_speakers = [] + for speaker_id in speaker_ids: + if not isinstance(speaker_id, int) or speakers.get(speaker_id) is None: + raise ValidationError({"detail": "Invalid data."}) + valid_speakers.append(speakers[speaker_id]) + weight = 0 + with transaction.atomic(): + for speaker in valid_speakers: + speaker.weight = weight + speaker.save(skip_autoupdate=True) + weight += 1 + + # send autoupdate + inform_changed_data(list_of_speakers) + + # Initiate response. + return Response({"detail": "List of speakers successfully sorted."}) diff --git a/openslides/assignments/apps.py b/openslides/assignments/apps.py index 33c16c771..2c4e9b018 100644 --- a/openslides/assignments/apps.py +++ b/openslides/assignments/apps.py @@ -6,7 +6,6 @@ from django.apps import AppConfig class AssignmentsAppConfig(AppConfig): name = "openslides.assignments" verbose_name = "OpenSlides Assignments" - angular_site_module = True def ready(self): # Import all required stuff. diff --git a/openslides/assignments/models.py b/openslides/assignments/models.py index a65f2509f..65735d29b 100644 --- a/openslides/assignments/models.py +++ b/openslides/assignments/models.py @@ -3,11 +3,11 @@ from decimal import Decimal from typing import Any, Dict, List from django.conf import settings -from django.contrib.contenttypes.fields import GenericRelation from django.core.validators import MinValueValidator from django.db import models -from openslides.agenda.models import Item, Speaker +from openslides.agenda.mixins import AgendaItemWithListOfSpeakersMixin +from openslides.agenda.models import Speaker from openslides.core.config import config from openslides.core.models import Tag from openslides.mediafiles.models import Mediafile @@ -79,11 +79,16 @@ class AssignmentManager(models.Manager): polls are prefetched from the database. """ return self.get_queryset().prefetch_related( - "related_users", "agenda_items", "polls", "tags", "attachments" + "related_users", + "agenda_items", + "lists_of_speakers", + "polls", + "tags", + "attachments", ) -class Assignment(RESTModelMixin, models.Model): +class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model): """ Model for assignments. """ @@ -147,10 +152,6 @@ class Assignment(RESTModelMixin, models.Model): Mediafiles as attachments for this assignment. """ - # In theory there could be one then more agenda_item. But we support only - # one. See the property agenda_item. - agenda_items = GenericRelation(Item, related_name="assignments") - class Meta: default_permissions = () permissions = ( @@ -275,13 +276,13 @@ class Assignment(RESTModelMixin, models.Model): for candidate in self.candidates: try: Speaker.objects.add( - candidate, self.agenda_item, skip_autoupdate=True + candidate, self.list_of_speakers, skip_autoupdate=True ) except OpenSlidesError: # The Speaker is already on the list. Do nothing. # TODO: Find a smart way not to catch the error concerning AnonymousUser. pass - inform_changed_data(self.agenda_item) + inform_changed_data(self.list_of_speakers) return poll @@ -319,30 +320,9 @@ class Assignment(RESTModelMixin, models.Model): vote_results_dict[candidate].append(votes) return vote_results_dict - """ - Container for runtime information for agenda app (on create or update of this instance). - """ - agenda_item_update_information: Dict[str, Any] = {} - - def get_agenda_title_information(self): + def get_title_information(self): return {"title": self.title} - @property - def agenda_item(self): - """ - Returns the related agenda item. - """ - # We support only one agenda item so just return the first element of - # the queryset. - return self.agenda_items.all()[0] - - @property - def agenda_item_id(self): - """ - Returns the id of the agenda item object related to this object. - """ - return self.agenda_item.pk - class AssignmentVote(RESTModelMixin, BaseVote): option = models.ForeignKey( diff --git a/openslides/assignments/serializers.py b/openslides/assignments/serializers.py index 75470da9b..57df49f61 100644 --- a/openslides/assignments/serializers.py +++ b/openslides/assignments/serializers.py @@ -203,6 +203,7 @@ class AssignmentFullSerializer(ModelSerializer): "poll_description_default", "polls", "agenda_item_id", + "list_of_speakers_id", "agenda_type", "agenda_parent_id", "tags", diff --git a/openslides/core/apps.py b/openslides/core/apps.py index 8defa7e3a..5b0c35425 100644 --- a/openslides/core/apps.py +++ b/openslides/core/apps.py @@ -13,7 +13,6 @@ from django.db.models.signals import post_migrate, pre_delete class CoreAppConfig(AppConfig): name = "openslides.core" verbose_name = "OpenSlides Core" - angular_site_module = True def ready(self): # Import all required stuff. diff --git a/openslides/mediafiles/apps.py b/openslides/mediafiles/apps.py index 252dc95af..7e9479830 100644 --- a/openslides/mediafiles/apps.py +++ b/openslides/mediafiles/apps.py @@ -6,7 +6,6 @@ from django.apps import AppConfig class MediafilesAppConfig(AppConfig): name = "openslides.mediafiles" verbose_name = "OpenSlides Mediafiles" - angular_site_module = True def ready(self): # Import all required stuff. diff --git a/openslides/mediafiles/models.py b/openslides/mediafiles/models.py index a602e480c..4f8972ffe 100644 --- a/openslides/mediafiles/models.py +++ b/openslides/mediafiles/models.py @@ -1,17 +1,32 @@ from django.conf import settings from django.db import models +from ..agenda.mixins import ListOfSpeakersMixin from ..core.config import config from ..utils.autoupdate import inform_changed_data from ..utils.models import SET_NULL_AND_AUTOUPDATE, RESTModelMixin from .access_permissions import MediafileAccessPermissions -class Mediafile(RESTModelMixin, models.Model): +class MediafileManager(models.Manager): + """ + Customized model manager to support our get_full_queryset method. + """ + + def get_full_queryset(self): + """ + Returns the normal queryset with all mediafiles. In the background + all related list of speakers are prefetched from the database. + """ + return self.get_queryset().prefetch_related("lists_of_speakers") + + +class Mediafile(RESTModelMixin, ListOfSpeakersMixin, models.Model): """ Class for uploaded files which can be delivered under a certain url. """ + objects = MediafileManager() access_permissions = MediafileAccessPermissions() can_see_permission = "mediafiles.can_see" @@ -96,3 +111,6 @@ class Mediafile(RESTModelMixin, models.Model): if config[key]["path"] == self.mediafile.url: return True return False + + def get_list_of_speakers_title_information(self): + return {"title": self.title} diff --git a/openslides/mediafiles/serializers.py b/openslides/mediafiles/serializers.py index 6fbab7340..66c0108ca 100644 --- a/openslides/mediafiles/serializers.py +++ b/openslides/mediafiles/serializers.py @@ -62,6 +62,7 @@ class MediafileSerializer(ModelSerializer): "filesize", "hidden", "timestamp", + "list_of_speakers_id", ) def get_filesize(self, mediafile): diff --git a/openslides/motions/apps.py b/openslides/motions/apps.py index b2c4c2e54..59d9a9d7d 100644 --- a/openslides/motions/apps.py +++ b/openslides/motions/apps.py @@ -7,7 +7,6 @@ from django.db.models.signals import post_migrate class MotionsAppConfig(AppConfig): name = "openslides.motions" verbose_name = "OpenSlides Motion" - angular_site_module = True def ready(self): # Import all required stuff. diff --git a/openslides/motions/models.py b/openslides/motions/models.py index 9c7d25b18..b190e351d 100644 --- a/openslides/motions/models.py +++ b/openslides/motions/models.py @@ -1,14 +1,11 @@ -from typing import Any, Dict - from django.conf import settings from django.contrib.auth.models import AnonymousUser -from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ImproperlyConfigured, ValidationError from django.db import IntegrityError, models, transaction from django.db.models import Max from jsonfield import JSONField -from openslides.agenda.models import Item +from openslides.agenda.mixins import AgendaItemWithListOfSpeakersMixin from openslides.core.config import config from openslides.core.models import Tag from openslides.mediafiles.models import Mediafile @@ -80,6 +77,7 @@ class MotionManager(models.Manager): "comments__section", "comments__section__read_groups", "agenda_items", + "lists_of_speakers", "polls", "attachments", "tags", @@ -90,7 +88,7 @@ class MotionManager(models.Manager): ) -class Motion(RESTModelMixin, models.Model): +class Motion(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model): """ Model for motions. @@ -260,10 +258,6 @@ class Motion(RESTModelMixin, models.Model): Timestamp when motion is modified. """ - # In theory there could be one then more agenda_item. But we support only - # one. See the property agenda_item. - agenda_items = GenericRelation(Item, related_name="motions") - class Meta: default_permissions = () permissions = ( @@ -520,30 +514,9 @@ class Motion(RESTModelMixin, models.Model): ): self.state_extension = self.recommendation_extension - """ - Container for runtime information for agenda app (on create or update of this instance). - """ - agenda_item_update_information: Dict[str, Any] = {} - - def get_agenda_title_information(self): + def get_title_information(self): return {"title": self.title, "identifier": self.identifier} - @property - def agenda_item(self): - """ - Returns the related agenda item. - """ - # We support only one agenda item so just return the first element of - # the queryset. - return self.agenda_items.all()[0] - - @property - def agenda_item_id(self): - """ - Returns the id of the agenda item object related to this object. - """ - return self.agenda_item.pk - def is_amendment(self): """ Returns True if the motion is an amendment. @@ -846,10 +819,10 @@ class MotionBlockManager(models.Manager): Returns the normal queryset with all motion blocks. In the background the related agenda item is prefetched from the database. """ - return self.get_queryset().prefetch_related("agenda_items") + return self.get_queryset().prefetch_related("agenda_items", "lists_of_speakers") -class MotionBlock(RESTModelMixin, models.Model): +class MotionBlock(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model): """ Model for blocks of motions. """ @@ -860,10 +833,6 @@ class MotionBlock(RESTModelMixin, models.Model): title = models.CharField(max_length=255) - # In theory there could be one then more agenda_item. But we support only - # one. See the property agenda_item. - agenda_items = GenericRelation(Item, related_name="topics") - class Meta: verbose_name = "Motion block" default_permissions = () @@ -871,28 +840,7 @@ class MotionBlock(RESTModelMixin, models.Model): def __str__(self): return self.title - """ - Container for runtime information for agenda app (on create or update of this instance). - """ - agenda_item_update_information: Dict[str, Any] = {} - - @property - def agenda_item(self): - """ - Returns the related agenda item. - """ - # We support only one agenda item so just return the first element of - # the queryset. - return self.agenda_items.all()[0] - - @property - def agenda_item_id(self): - """ - Returns the id of the agenda item object related to this object. - """ - return self.agenda_item.pk - - def get_agenda_title_information(self): + def get_title_information(self): return {"title": self.title} diff --git a/openslides/motions/serializers.py b/openslides/motions/serializers.py index f885083c8..a94335f8b 100644 --- a/openslides/motions/serializers.py +++ b/openslides/motions/serializers.py @@ -75,7 +75,14 @@ class MotionBlockSerializer(ModelSerializer): class Meta: model = MotionBlock - fields = ("id", "title", "agenda_item_id", "agenda_type", "agenda_parent_id") + fields = ( + "id", + "title", + "agenda_item_id", + "list_of_speakers_id", + "agenda_type", + "agenda_parent_id", + ) def create(self, validated_data): """ @@ -446,6 +453,7 @@ class MotionSerializer(ModelSerializer): "attachments", "polls", "agenda_item_id", + "list_of_speakers_id", "agenda_type", "agenda_parent_id", "sort_parent", diff --git a/openslides/motions/views.py b/openslides/motions/views.py index aeb166f32..8da74756e 100644 --- a/openslides/motions/views.py +++ b/openslides/motions/views.py @@ -1451,8 +1451,9 @@ class CategoryViewSet(ModelViewSet): # Remove old identifiers for motion in motions: motion.identifier = None - # This line is to skip agenda item autoupdate. See agenda/signals.py. - motion.agenda_item_update_information["skip_autoupdate"] = True + # This line is to skip agenda item and list of speakers autoupdate. + # See agenda/signals.py. + motion.set_skip_autoupdate_agenda_item_and_list_of_speakers() motion.save(skip_autoupdate=True) # Set new identifers and change identifiers of amendments. @@ -1481,10 +1482,9 @@ class CategoryViewSet(ModelViewSet): child.identifier, count=1, ) - # This line is to skip agenda item autoupdate. See agenda/signals.py. - child.agenda_item_update_information[ - "skip_autoupdate" - ] = True + # This line is to skip agenda item and list of speakers autoupdate. + # See agenda/signals.py. + motion.set_skip_autoupdate_agenda_item_and_list_of_speakers() child.save(skip_autoupdate=True) instances.append(child) instances.append(child.agenda_item) diff --git a/openslides/topics/apps.py b/openslides/topics/apps.py index e87a8d2fa..10f0d6790 100644 --- a/openslides/topics/apps.py +++ b/openslides/topics/apps.py @@ -4,7 +4,6 @@ from django.apps import AppConfig class TopicsAppConfig(AppConfig): name = "openslides.topics" verbose_name = "OpenSlides Topics" - angular_site_module = True def ready(self): # Import all required stuff. diff --git a/openslides/topics/models.py b/openslides/topics/models.py index 2c40a32ac..73a511102 100644 --- a/openslides/topics/models.py +++ b/openslides/topics/models.py @@ -1,9 +1,6 @@ -from typing import Any, Dict - -from django.contrib.contenttypes.fields import GenericRelation from django.db import models -from ..agenda.models import Item +from ..agenda.mixins import AgendaItemWithListOfSpeakersMixin from ..mediafiles.models import Mediafile from ..utils.models import RESTModelMixin from .access_permissions import TopicAccessPermissions @@ -20,10 +17,12 @@ class TopicManager(models.Manager): attachments and the related agenda item are prefetched from the database. """ - return self.get_queryset().prefetch_related("attachments", "agenda_items") + return self.get_queryset().prefetch_related( + "attachments", "lists_of_speakers", "agenda_items" + ) -class Topic(RESTModelMixin, models.Model): +class Topic(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model): """ Model for slides with custom content. Used to be called custom slide. """ @@ -36,36 +35,11 @@ class Topic(RESTModelMixin, models.Model): text = models.TextField(blank=True) attachments = models.ManyToManyField(Mediafile, blank=True) - # In theory there could be one then more agenda_item. But we support only - # one. See the property agenda_item. - agenda_items = GenericRelation(Item, related_name="topics") - class Meta: default_permissions = () def __str__(self): return self.title - """ - Container for runtime information for agenda app (on create or update of this instance). - """ - agenda_item_update_information: Dict[str, Any] = {} - - @property - def agenda_item(self): - """ - Returns the related agenda item. - """ - # We support only one agenda item so just return the first element of - # the queryset. - return self.agenda_items.all()[0] - - @property - def agenda_item_id(self): - """ - Returns the id of the agenda item object related to this object. - """ - return self.agenda_item.pk - - def get_agenda_title_information(self): + def get_title_information(self): return {"title": self.title} diff --git a/openslides/topics/serializers.py b/openslides/topics/serializers.py index 5bc2a5433..091cee836 100644 --- a/openslides/topics/serializers.py +++ b/openslides/topics/serializers.py @@ -26,6 +26,7 @@ class TopicSerializer(ModelSerializer): "text", "attachments", "agenda_item_id", + "list_of_speakers_id", "agenda_type", "agenda_parent_id", "agenda_comment", diff --git a/openslides/users/apps.py b/openslides/users/apps.py index 84dc635eb..6b624429b 100644 --- a/openslides/users/apps.py +++ b/openslides/users/apps.py @@ -6,7 +6,6 @@ from django.contrib.auth.signals import user_logged_in class UsersAppConfig(AppConfig): name = "openslides.users" verbose_name = "OpenSlides Users" - angular_site_module = True def ready(self): # Import all required stuff. diff --git a/openslides/users/signals.py b/openslides/users/signals.py index 78e72f651..953af6c0e 100644 --- a/openslides/users/signals.py +++ b/openslides/users/signals.py @@ -38,6 +38,7 @@ def create_builtin_groups_and_admin(**kwargs): "agenda.can_manage_list_of_speakers", "agenda.can_see", "agenda.can_see_internal_items", + "agenda.can_see_list_of_speakers", "assignments.can_manage", "assignments.can_nominate_other", "assignments.can_nominate_self", @@ -85,6 +86,7 @@ def create_builtin_groups_and_admin(**kwargs): base_permissions = ( permission_dict["agenda.can_see"], permission_dict["agenda.can_see_internal_items"], + permission_dict["agenda.can_see_list_of_speakers"], permission_dict["assignments.can_see"], permission_dict["core.can_see_frontpage"], permission_dict["core.can_see_projector"], @@ -106,6 +108,7 @@ def create_builtin_groups_and_admin(**kwargs): permission_dict["agenda.can_see"], permission_dict["agenda.can_see_internal_items"], permission_dict["agenda.can_be_speaker"], + permission_dict["agenda.can_see_list_of_speakers"], permission_dict["assignments.can_see"], permission_dict["assignments.can_nominate_other"], permission_dict["assignments.can_nominate_self"], @@ -129,6 +132,7 @@ def create_builtin_groups_and_admin(**kwargs): permission_dict["agenda.can_see_internal_items"], permission_dict["agenda.can_be_speaker"], permission_dict["agenda.can_manage"], + permission_dict["agenda.can_see_list_of_speakers"], permission_dict["agenda.can_manage_list_of_speakers"], permission_dict["assignments.can_see"], permission_dict["assignments.can_manage"], @@ -162,6 +166,7 @@ def create_builtin_groups_and_admin(**kwargs): committees_permissions = ( permission_dict["agenda.can_see"], permission_dict["agenda.can_see_internal_items"], + permission_dict["agenda.can_see_list_of_speakers"], permission_dict["assignments.can_see"], permission_dict["core.can_see_frontpage"], permission_dict["core.can_see_projector"], diff --git a/tests/integration/agenda/test_viewset.py b/tests/integration/agenda/test_viewset.py index fd7f3a98b..4b4215ea3 100644 --- a/tests/integration/agenda/test_viewset.py +++ b/tests/integration/agenda/test_viewset.py @@ -1,50 +1,67 @@ import pytest from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission +from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient -from openslides.agenda.models import Item, Speaker +from openslides.agenda.models import Item, ListOfSpeakers, Speaker from openslides.assignments.models import Assignment from openslides.core.config import config from openslides.core.models import Countdown +from openslides.mediafiles.models import Mediafile from openslides.motions.models import Motion from openslides.topics.models import Topic from openslides.users.models import Group from openslides.utils.autoupdate import inform_changed_data from openslides.utils.test import TestCase +from ...common_groups import GROUP_DEFAULT_PK from ..helpers import count_queries class ContentObjects(TestCase): """ - Tests content objects with Topic as a content object. - Asserts, that it is recognizes as a content object and tests creation - and deletion of it and the related agenda item. + Tests content objects with Topic as a content object of items and + lists of speakers. Asserts, that it is recognizes as a content + object and tests creation and deletion of it and the related item + and list of speaker. """ def setUp(self): self.client = APIClient() self.client.login(username="admin", password="admin") - def test_topic_is_content_object(self): + def test_topic_is_agenda_item_content_object(self): assert hasattr(Topic(), "get_agenda_title_information") + def test_topic_is_list_of_speakers_content_object(self): + assert hasattr(Topic(), "get_list_of_speakers_title_information") + def test_create_content_object(self): topic = Topic.objects.create(title="test_title_fk3Oc209JDiunw2!wwoH") assert topic.agenda_item is not None + assert topic.list_of_speakers is not None response = self.client.get(reverse("item-detail", args=[topic.agenda_item.pk])) self.assertEqual(response.status_code, status.HTTP_200_OK) + response = self.client.get( + reverse("listofspeakers-detail", args=[topic.list_of_speakers.pk]) + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) def test_delete_content_object(self): topic = Topic.objects.create(title="test_title_lwOCK32jZGFb37DpmoP(") - item_id = topic.agenda_item.id + item_id = topic.agenda_item_id + list_of_speakers_id = topic.list_of_speakers_id topic.delete() response = self.client.get(reverse("item-detail", args=[item_id])) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + response = self.client.get( + reverse("listofspeakers-detail", args=[list_of_speakers_id]) + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) class RetrieveItem(TestCase): @@ -61,8 +78,8 @@ class RetrieveItem(TestCase): def test_normal_by_anonymous_without_perm_to_see_internal_items(self): group = get_user_model().groups.field.related_model.objects.get( - pk=1 - ) # Group with pk 1 is for anonymous users. + pk=GROUP_DEFAULT_PK + ) permission_string = "agenda.can_see_internal_items" app_label, codename = permission_string.split(".") permission = group.permissions.get( @@ -79,7 +96,7 @@ class RetrieveItem(TestCase): self.assertEqual(response.status_code, 404) def test_hidden_by_anonymous_with_manage_perms(self): - group = Group.objects.get(pk=1) # Group with pk 1 is for anonymous users. + group = Group.objects.get(pk=GROUP_DEFAULT_PK) permission_string = "agenda.can_manage" app_label, codename = permission_string.split(".") permission = Permission.objects.get( @@ -91,7 +108,7 @@ class RetrieveItem(TestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) def test_internal_by_anonymous_without_perm_to_see_internal_items(self): - group = Group.objects.get(pk=1) # Group with pk 1 is for anonymous users. + group = Group.objects.get(pk=GROUP_DEFAULT_PK) permission_string = "agenda.can_see_internal_items" app_label, codename = permission_string.split(".") permission = group.permissions.get( @@ -102,33 +119,7 @@ class RetrieveItem(TestCase): self.item.type = Item.INTERNAL_ITEM self.item.save() response = self.client.get(reverse("item-detail", args=[self.item.pk])) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - sorted(response.data.keys()), - sorted( - ( - "id", - "title_information", - "speakers", - "speaker_list_closed", - "content_object", - ) - ), - ) - forbidden_keys = ( - "item_number", - "title_with_type", - "comment", - "closed", - "type", - "is_internal", - "is_hidden", - "duration", - "weight", - "parent", - ) - for key in forbidden_keys: - self.assertFalse(key in response.data.keys()) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) def test_normal_by_anonymous_cant_see_agenda_comments(self): self.item.type = Item.AGENDA_ITEM @@ -139,14 +130,45 @@ class RetrieveItem(TestCase): self.assertTrue(response.data.get("comment") is None) +class RetrieveListOfSpeakers(TestCase): + """ + Tests retrieving list of speakers. + """ + + def setUp(self): + self.client = APIClient() + config["general_system_enable_anonymous"] = True + self.list_of_speakers = Topic.objects.create( + title="test_title_qsjem(ZUNfp7egnzp37n" + ).list_of_speakers + + def test_simple(self): + response = self.client.get( + reverse("listofspeakers-detail", args=[self.list_of_speakers.pk]) + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_without_permission(self): + group = Group.objects.get(pk=GROUP_DEFAULT_PK) + permission_string = "agenda.can_see_list_of_speakers" + app_label, codename = permission_string.split(".") + permission = Permission.objects.get( + content_type__app_label=app_label, codename=codename + ) + group.permissions.remove(permission) + inform_changed_data(group) + response = self.client.get( + reverse("listofspeakers-detail", args=[self.list_of_speakers.pk]) + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + @pytest.mark.django_db(transaction=False) def test_agenda_item_db_queries(): """ Tests that only the following db queries are done: * 1 requests to get the list of all agenda items, - * 1 request to get all speakers, * 3 requests to get the assignments, motions and topics and - * 1 request to get an agenda item (why?) TODO: The last three request are a bug. """ @@ -160,7 +182,31 @@ def test_agenda_item_db_queries(): Motion.objects.create(title="motion2") Assignment.objects.create(title="assignment", open_posts=5) - assert count_queries(Item.get_elements) == 6 + assert count_queries(Item.get_elements) == 5 + + +@pytest.mark.django_db(transaction=False) +def test_list_of_speakers_db_queries(): + """ + Tests that only the following db queries are done: + * 1 requests to get the list of all lists of speakers + * 1 request to get all speakers + * 4 requests to get the assignments, motions, topics and mediafiles and + """ + for index in range(10): + Topic.objects.create(title=f"topic{index}") + parent = Topic.objects.create(title="parent").agenda_item + child = Topic.objects.create(title="child").agenda_item + child.parent = parent + child.save() + Motion.objects.create(title="motion1") + Motion.objects.create(title="motion2") + Assignment.objects.create(title="assignment", open_posts=5) + Mediafile.objects.create( + title=f"mediafile", mediafile=SimpleUploadedFile(f"some_file", b"some content.") + ) + + assert count_queries(ListOfSpeakers.get_elements) == 6 class ManageSpeaker(TestCase): @@ -172,54 +218,69 @@ class ManageSpeaker(TestCase): self.client = APIClient() self.client.login(username="admin", password="admin") - self.item = Topic.objects.create( + self.list_of_speakers = Topic.objects.create( title="test_title_aZaedij4gohn5eeQu8fe" - ).agenda_item + ).list_of_speakers self.user = get_user_model().objects.create_user( username="test_user_jooSaex1bo5ooPhuphae", password="test_password_e6paev4zeeh9n", ) - def test_add_oneself(self): - response = self.client.post(reverse("item-manage-speaker", args=[self.item.pk])) + def test_add_oneself_once(self): + response = self.client.post( + reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]) + ) self.assertEqual(response.status_code, 200) self.assertTrue(Speaker.objects.all().exists()) def test_add_oneself_twice(self): - Speaker.objects.add(get_user_model().objects.get(username="admin"), self.item) - response = self.client.post(reverse("item-manage-speaker", args=[self.item.pk])) + Speaker.objects.add( + get_user_model().objects.get(username="admin"), self.list_of_speakers + ) + response = self.client.post( + reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]) + ) self.assertEqual(response.status_code, 400) def test_add_oneself_when_closed(self): - self.item.speaker_list_closed = True - self.item.save() - response = self.client.post(reverse("item-manage-speaker", args=[self.item.pk])) + self.list_of_speakers.closed = True + self.list_of_speakers.save() + response = self.client.post( + reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]) + ) self.assertEqual(response.status_code, 400) def test_remove_oneself(self): - Speaker.objects.add(get_user_model().objects.get(username="admin"), self.item) + Speaker.objects.add( + get_user_model().objects.get(username="admin"), self.list_of_speakers + ) response = self.client.delete( - reverse("item-manage-speaker", args=[self.item.pk]) + reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]) ) self.assertEqual(response.status_code, 200) self.assertFalse(Speaker.objects.all().exists()) def test_remove_self_not_on_list(self): response = self.client.delete( - reverse("item-manage-speaker", args=[self.item.pk]) + reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]) ) self.assertEqual(response.status_code, 400) def test_add_someone_else(self): response = self.client.post( - reverse("item-manage-speaker", args=[self.item.pk]), {"user": self.user.pk} + reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]), + {"user": self.user.pk}, ) self.assertEqual(response.status_code, 200) - self.assertTrue(Speaker.objects.filter(item=self.item, user=self.user).exists()) + self.assertTrue( + Speaker.objects.filter( + list_of_speakers=self.list_of_speakers, user=self.user + ).exists() + ) def test_invalid_data_string_instead_of_integer(self): response = self.client.post( - reverse("item-manage-speaker", args=[self.item.pk]), + reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]), {"user": "string_instead_of_integer"}, ) @@ -230,15 +291,16 @@ class ManageSpeaker(TestCase): # Be careful: Here we do not test that the user does not exist. inexistent_user_pk = self.user.pk + 1000 response = self.client.post( - reverse("item-manage-speaker", args=[self.item.pk]), + reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]), {"user": inexistent_user_pk}, ) self.assertEqual(response.status_code, 400) def test_add_someone_else_twice(self): - Speaker.objects.add(self.user, self.item) + Speaker.objects.add(self.user, self.list_of_speakers) response = self.client.post( - reverse("item-manage-speaker", args=[self.item.pk]), {"user": self.user.pk} + reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]), + {"user": self.user.pk}, ) self.assertEqual(response.status_code, 400) @@ -251,29 +313,35 @@ class ManageSpeaker(TestCase): inform_changed_data(admin) response = self.client.post( - reverse("item-manage-speaker", args=[self.item.pk]), {"user": self.user.pk} + reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]), + {"user": self.user.pk}, ) self.assertEqual(response.status_code, 403) def test_remove_someone_else(self): - speaker = Speaker.objects.add(self.user, self.item) + speaker = Speaker.objects.add(self.user, self.list_of_speakers) response = self.client.delete( - reverse("item-manage-speaker", args=[self.item.pk]), {"speaker": speaker.pk} + reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]), + {"speaker": speaker.pk}, ) self.assertEqual(response.status_code, 200) self.assertFalse( - Speaker.objects.filter(item=self.item, user=self.user).exists() + Speaker.objects.filter( + list_of_speakers=self.list_of_speakers, user=self.user + ).exists() ) def test_remove_someone_else_not_on_list(self): response = self.client.delete( - reverse("item-manage-speaker", args=[self.item.pk]), {"speaker": "1"} + reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]), + {"speaker": "1"}, ) self.assertEqual(response.status_code, 200) def test_remove_someone_else_invalid_data(self): response = self.client.delete( - reverse("item-manage-speaker", args=[self.item.pk]), {"speaker": "invalid"} + reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]), + {"speaker": "invalid"}, ) self.assertEqual(response.status_code, 200) @@ -284,17 +352,18 @@ class ManageSpeaker(TestCase): admin.groups.add(group_delegates) admin.groups.remove(group_admin) inform_changed_data(admin) - speaker = Speaker.objects.add(self.user, self.item) + speaker = Speaker.objects.add(self.user, self.list_of_speakers) response = self.client.delete( - reverse("item-manage-speaker", args=[self.item.pk]), {"speaker": speaker.pk} + reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]), + {"speaker": speaker.pk}, ) self.assertEqual(response.status_code, 403) def test_mark_speaker(self): - Speaker.objects.add(self.user, self.item) + Speaker.objects.add(self.user, self.list_of_speakers) response = self.client.patch( - reverse("item-manage-speaker", args=[self.item.pk]), + reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]), {"user": self.user.pk, "marked": True}, format="json", ) @@ -309,10 +378,11 @@ class ManageSpeaker(TestCase): admin.groups.add(group_delegates) admin.groups.remove(group_admin) inform_changed_data(admin) - Speaker.objects.add(self.user, self.item) + Speaker.objects.add(self.user, self.list_of_speakers) response = self.client.patch( - reverse("item-manage-speaker", args=[self.item.pk]), {"user": self.user.pk} + reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]), + {"user": self.user.pk}, ) self.assertEqual(response.status_code, 403) @@ -326,70 +396,82 @@ class Speak(TestCase): def setUp(self): self.client = APIClient() self.client.login(username="admin", password="admin") - self.item = Topic.objects.create( + self.list_of_speakers = Topic.objects.create( title="test_title_KooDueco3zaiGhiraiho" - ).agenda_item + ).list_of_speakers self.user = get_user_model().objects.create_user( username="test_user_Aigh4vohb3seecha4aa4", password="test_password_eneupeeVo5deilixoo8j", ) def test_begin_speech(self): - Speaker.objects.add(self.user, self.item) + Speaker.objects.add(self.user, self.list_of_speakers) speaker = Speaker.objects.add( - get_user_model().objects.get(username="admin"), self.item + get_user_model().objects.get(username="admin"), self.list_of_speakers ) self.assertTrue(Speaker.objects.get(pk=speaker.pk).begin_time is None) response = self.client.put( - reverse("item-speak", args=[self.item.pk]), {"speaker": speaker.pk} + reverse("listofspeakers-speak", args=[self.list_of_speakers.pk]), + {"speaker": speaker.pk}, ) self.assertEqual(response.status_code, 200) self.assertFalse(Speaker.objects.get(pk=speaker.pk).begin_time is None) def test_begin_speech_next_speaker(self): - speaker = Speaker.objects.add(self.user, self.item) - Speaker.objects.add(get_user_model().objects.get(username="admin"), self.item) + speaker = Speaker.objects.add(self.user, self.list_of_speakers) + Speaker.objects.add( + get_user_model().objects.get(username="admin"), self.list_of_speakers + ) - response = self.client.put(reverse("item-speak", args=[self.item.pk])) + response = self.client.put( + reverse("listofspeakers-speak", args=[self.list_of_speakers.pk]) + ) self.assertEqual(response.status_code, 200) self.assertFalse(Speaker.objects.get(pk=speaker.pk).begin_time is None) def test_begin_speech_invalid_speaker_id(self): response = self.client.put( - reverse("item-speak", args=[self.item.pk]), {"speaker": "1"} + reverse("listofspeakers-speak", args=[self.list_of_speakers.pk]), + {"speaker": "1"}, ) self.assertEqual(response.status_code, 400) def test_begin_speech_invalid_data(self): response = self.client.put( - reverse("item-speak", args=[self.item.pk]), {"speaker": "invalid"} + reverse("listofspeakers-speak", args=[self.list_of_speakers.pk]), + {"speaker": "invalid"}, ) self.assertEqual(response.status_code, 400) def test_end_speech(self): speaker = Speaker.objects.add( - get_user_model().objects.get(username="admin"), self.item + get_user_model().objects.get(username="admin"), self.list_of_speakers ) speaker.begin_speech() self.assertFalse(Speaker.objects.get(pk=speaker.pk).begin_time is None) self.assertTrue(Speaker.objects.get(pk=speaker.pk).end_time is None) - response = self.client.delete(reverse("item-speak", args=[self.item.pk])) + response = self.client.delete( + reverse("listofspeakers-speak", args=[self.list_of_speakers.pk]) + ) self.assertEqual(response.status_code, 200) self.assertFalse(Speaker.objects.get(pk=speaker.pk).end_time is None) def test_end_speech_no_current_speaker(self): - response = self.client.delete(reverse("item-speak", args=[self.item.pk])) + response = self.client.delete( + reverse("listofspeakers-speak", args=[self.list_of_speakers.pk]) + ) self.assertEqual(response.status_code, 400) def test_begin_speech_with_countdown(self): config["agenda_couple_countdown_and_speakers"] = True - Speaker.objects.add(self.user, self.item) + Speaker.objects.add(self.user, self.list_of_speakers) speaker = Speaker.objects.add( - get_user_model().objects.get(username="admin"), self.item + get_user_model().objects.get(username="admin"), self.list_of_speakers ) self.client.put( - reverse("item-speak", args=[self.item.pk]), {"speaker": speaker.pk} + reverse("listofspeakers-speak", args=[self.list_of_speakers.pk]), + {"speaker": speaker.pk}, ) # Countdown should be created with pk=1 and running self.assertEqual(Countdown.objects.all().count(), 1) @@ -399,10 +481,12 @@ class Speak(TestCase): def test_end_speech_with_countdown(self): config["agenda_couple_countdown_and_speakers"] = True speaker = Speaker.objects.add( - get_user_model().objects.get(username="admin"), self.item + get_user_model().objects.get(username="admin"), self.list_of_speakers ) speaker.begin_speech() - self.client.delete(reverse("item-speak", args=[self.item.pk])) + self.client.delete( + reverse("listofspeakers-speak", args=[self.list_of_speakers.pk]) + ) # Countdown should be created with pk=1 and stopped self.assertEqual(Countdown.objects.all().count(), 1) countdown = Countdown.objects.get(pk=1) diff --git a/tests/integration/assignments/test_viewset.py b/tests/integration/assignments/test_viewset.py index 4031e8817..7862cdb18 100644 --- a/tests/integration/assignments/test_viewset.py +++ b/tests/integration/assignments/test_viewset.py @@ -21,6 +21,7 @@ def test_assignment_db_queries(): * 1 requests to get the list of all assignments, * 1 request to get all related users, * 1 request to get the agenda item, + * 1 request to get the list of speakers, * 1 request to get the polls, * 1 request to get the tags, * 1 request to get the attachments and @@ -32,7 +33,7 @@ def test_assignment_db_queries(): for index in range(10): Assignment.objects.create(title=f"assignment{index}", open_posts=1) - assert count_queries(Assignment.get_elements) == 16 + assert count_queries(Assignment.get_elements) == 17 class CreateAssignment(TestCase): diff --git a/tests/integration/mediafiles/test_viewset.py b/tests/integration/mediafiles/test_viewset.py index 1ed697e4a..902116863 100644 --- a/tests/integration/mediafiles/test_viewset.py +++ b/tests/integration/mediafiles/test_viewset.py @@ -10,7 +10,8 @@ from ..helpers import count_queries def test_mediafiles_db_queries(): """ Tests that only the following db queries are done: - * 1 requests to get the list of all files. + * 1 requests to get the list of all files + * 1 request to get all lists of speakers. """ for index in range(10): Mediafile.objects.create( @@ -18,4 +19,4 @@ def test_mediafiles_db_queries(): mediafile=SimpleUploadedFile(f"some_file{index}", b"some content."), ) - assert count_queries(Mediafile.get_elements) == 1 + assert count_queries(Mediafile.get_elements) == 2 diff --git a/tests/integration/motions/test_viewset.py b/tests/integration/motions/test_viewset.py index 6231c7586..d6c203760 100644 --- a/tests/integration/motions/test_viewset.py +++ b/tests/integration/motions/test_viewset.py @@ -43,6 +43,7 @@ def test_motion_db_queries(): * 1 request for all motion comment sections required for the comments * 1 request for all users required for the read_groups of the sections * 1 request to get the agenda item, + * 1 request to get the list of speakers, * 1 request to get the polls, * 1 request to get the attachments, * 1 request to get the tags, @@ -69,7 +70,7 @@ def test_motion_db_queries(): ) # TODO: Create some polls etc. - assert count_queries(Motion.get_elements) == 12 + assert count_queries(Motion.get_elements) == 13 @pytest.mark.django_db(transaction=False) diff --git a/tests/integration/topics/test_viewset.py b/tests/integration/topics/test_viewset.py index c23fda18b..6b165ff7e 100644 --- a/tests/integration/topics/test_viewset.py +++ b/tests/integration/topics/test_viewset.py @@ -16,11 +16,12 @@ def test_topic_item_db_queries(): * 1 requests to get the list of all topics, * 1 request to get attachments, * 1 request to get the agenda item + * 1 request to get the list of speakers """ for index in range(10): Topic.objects.create(title=f"topic-{index}") - assert count_queries(Topic.get_elements) == 3 + assert count_queries(Topic.get_elements) == 4 class TopicCreate(TestCase): diff --git a/tests/old/agenda/test_list_of_speakers.py b/tests/old/agenda/test_list_of_speakers.py index cd21d5d5a..e0c2c8513 100644 --- a/tests/old/agenda/test_list_of_speakers.py +++ b/tests/old/agenda/test_list_of_speakers.py @@ -1,4 +1,4 @@ -from openslides.agenda.models import Item, Speaker +from openslides.agenda.models import ListOfSpeakers, Speaker from openslides.topics.models import Topic from openslides.users.models import User from openslides.utils.exceptions import OpenSlidesError @@ -7,66 +7,82 @@ from openslides.utils.test import TestCase class ListOfSpeakerModelTests(TestCase): def setUp(self): - self.item1 = Topic.objects.create(title="item1").agenda_item - self.item2 = Topic.objects.create(title="item2").agenda_item + self.list_of_speakers_1 = Topic.objects.create( + title="list_of_speakers_1" + ).list_of_speakers + self.list_of_speakers_2 = Topic.objects.create( + title="list_of_speakers_2" + ).list_of_speakers self.speaker1 = User.objects.create(username="user1") self.speaker2 = User.objects.create(username="user2") def test_append_speaker(self): - # Append speaker1 to the list of item1 - speaker1_item1 = Speaker.objects.add(self.speaker1, self.item1) + # Append speaker1 to the list of list_of_speakers_1 + speaker1_los1 = Speaker.objects.add(self.speaker1, self.list_of_speakers_1) self.assertTrue( - Speaker.objects.filter(user=self.speaker1, item=self.item1).exists() + Speaker.objects.filter( + user=self.speaker1, list_of_speakers=self.list_of_speakers_1 + ).exists() ) - # Append speaker1 to the list of item2 - speaker1_item2 = Speaker.objects.add(self.speaker1, self.item2) + # Append speaker1 to the list of list_of_speakers_2 + speaker1_los2 = Speaker.objects.add(self.speaker1, self.list_of_speakers_2) self.assertTrue( - Speaker.objects.filter(user=self.speaker1, item=self.item2).exists() + Speaker.objects.filter( + user=self.speaker1, list_of_speakers=self.list_of_speakers_2 + ).exists() ) - # Append speaker2 to the list of item1 - speaker2_item1 = Speaker.objects.add(self.speaker2, self.item1) + # Append speaker2 to the list of list_of_speakers_1 + speaker2_los1 = Speaker.objects.add(self.speaker2, self.list_of_speakers_1) self.assertTrue( - Speaker.objects.filter(user=self.speaker2, item=self.item1).exists() + Speaker.objects.filter( + user=self.speaker2, list_of_speakers=self.list_of_speakers_1 + ).exists() ) - # Try to append speaker 1 again to the list of item1 + # Try to append speaker 1 again to the list of list_of_speakers_1 with self.assertRaises(OpenSlidesError): - Speaker.objects.add(self.speaker1, self.item1) + Speaker.objects.add(self.speaker1, self.list_of_speakers_1) # Check time and weight - for object in (speaker1_item1, speaker2_item1, speaker1_item2): + for object in (speaker1_los1, speaker2_los1, speaker1_los2): self.assertIsNone(object.begin_time) self.assertIsNone(object.end_time) - self.assertEqual(speaker1_item1.weight, 1) - self.assertEqual(speaker1_item2.weight, 1) - self.assertEqual(speaker2_item1.weight, 2) + self.assertEqual(speaker1_los1.weight, 1) + self.assertEqual(speaker1_los2.weight, 1) + self.assertEqual(speaker2_los1.weight, 2) def test_open_close_list_of_speaker(self): - self.assertFalse(Item.objects.get(pk=self.item1.pk).speaker_list_closed) - self.item1.speaker_list_closed = True - self.item1.save() - self.assertTrue(Item.objects.get(pk=self.item1.pk).speaker_list_closed) + self.assertFalse( + ListOfSpeakers.objects.get(pk=self.list_of_speakers_1.pk).closed + ) + self.list_of_speakers_1.closed = True + self.list_of_speakers_1.save() + self.assertTrue( + ListOfSpeakers.objects.get(pk=self.list_of_speakers_1.pk).closed + ) def test_speak_and_finish(self): - speaker1_item1 = Speaker.objects.add(self.speaker1, self.item1) - self.assertIsNone(speaker1_item1.begin_time) - self.assertIsNone(speaker1_item1.end_time) - speaker1_item1.begin_speech() - self.assertIsNotNone(Speaker.objects.get(pk=speaker1_item1.pk).begin_time) - self.assertIsNone(Speaker.objects.get(pk=speaker1_item1.pk).weight) - speaker1_item1.end_speech() - self.assertIsNotNone(Speaker.objects.get(pk=speaker1_item1.pk).end_time) + speaker1_los1 = Speaker.objects.add(self.speaker1, self.list_of_speakers_1) + self.assertIsNone(speaker1_los1.begin_time) + self.assertIsNone(speaker1_los1.end_time) + speaker1_los1.begin_speech() + self.assertIsNotNone(Speaker.objects.get(pk=speaker1_los1.pk).begin_time) + self.assertIsNone(Speaker.objects.get(pk=speaker1_los1.pk).weight) + speaker1_los1.end_speech() + self.assertIsNotNone(Speaker.objects.get(pk=speaker1_los1.pk).end_time) def test_finish_when_other_speaker_begins(self): - speaker1_item1 = Speaker.objects.add(self.speaker1, self.item1) - speaker2_item1 = Speaker.objects.add(self.speaker2, self.item1) - speaker1_item1.begin_speech() - self.assertIsNone(speaker1_item1.end_time) - self.assertIsNone(speaker2_item1.begin_time) - speaker2_item1.begin_speech() + speaker1_los1 = Speaker.objects.add(self.speaker1, self.list_of_speakers_1) + speaker2_los1 = Speaker.objects.add(self.speaker2, self.list_of_speakers_1) + speaker1_los1.begin_speech() + self.assertIsNone(speaker1_los1.end_time) + self.assertIsNone(speaker2_los1.begin_time) + speaker2_los1.begin_speech() self.assertIsNotNone( - Speaker.objects.get(user=self.speaker1, item=self.item1).end_time + Speaker.objects.get( + user=self.speaker1, list_of_speakers=self.list_of_speakers_1 + ).end_time ) - self.assertIsNotNone(speaker2_item1.begin_time) + self.assertIsNotNone(speaker2_los1.begin_time) diff --git a/tests/unit/agenda/test_projector.py b/tests/unit/agenda/test_projector.py index 32031ac9c..0e1458292 100644 --- a/tests/unit/agenda/test_projector.py +++ b/tests/unit/agenda/test_projector.py @@ -95,13 +95,11 @@ async def test_main_items(all_data): "items": [ { "collection": "topics/topic", - "item_number": "", - "title_information": {"title": "item1"}, + "title_information": {"title": "item1", "agenda_item_number": ""}, }, { "collection": "topics/topic", - "item_number": "", - "title_information": {"title": "item2"}, + "title_information": {"title": "item2", "agenda_item_number": ""}, }, ] } @@ -118,20 +116,17 @@ async def test_all_items(all_data): { "collection": "topics/topic", "depth": 0, - "item_number": "", - "title_information": {"title": "item1"}, + "title_information": {"title": "item1", "agenda_item_number": ""}, }, { "collection": "topics/topic", "depth": 1, - "item_number": "", - "title_information": {"title": "item4"}, + "title_information": {"title": "item4", "agenda_item_number": ""}, }, { "collection": "topics/topic", "depth": 0, - "item_number": "", - "title_information": {"title": "item2"}, + "title_information": {"title": "item2", "agenda_item_number": ""}, }, ] } diff --git a/tests/unit/agenda/test_views.py b/tests/unit/agenda/test_views.py index f17efc65c..31805f16c 100644 --- a/tests/unit/agenda/test_views.py +++ b/tests/unit/agenda/test_views.py @@ -1,20 +1,20 @@ from unittest import TestCase from unittest.mock import MagicMock, patch -from openslides.agenda.views import ItemViewSet +from openslides.agenda.views import ListOfSpeakersViewSet -class ItemViewSetManageSpeaker(TestCase): +class ListOfSpeakersViewSetManageSpeaker(TestCase): """ - Tests views of ItemViewSet to manage speakers. + Tests views of ListOfSpeakersViewSet to manage speakers. """ def setUp(self): self.request = MagicMock() - self.view_instance = ItemViewSet() + self.view_instance = ListOfSpeakersViewSet() self.view_instance.request = self.request self.view_instance.get_object = get_object_mock = MagicMock() - get_object_mock.return_value = self.mock_item = MagicMock() + get_object_mock.return_value = self.mock_list_of_speakers = MagicMock() @patch("openslides.agenda.views.inform_changed_data") @patch("openslides.agenda.views.has_perm") @@ -24,11 +24,13 @@ class ItemViewSetManageSpeaker(TestCase): self.request.user = 1 mock_has_perm.return_value = True self.request.data = {} - self.mock_item.speaker_list_closed = False + self.mock_list_of_speakers.closed = False self.view_instance.manage_speaker(self.request) - mock_speaker.objects.add.assert_called_with(self.request.user, self.mock_item) + mock_speaker.objects.add.assert_called_with( + self.request.user, self.mock_list_of_speakers + ) @patch("openslides.agenda.views.inform_changed_data") @patch("openslides.agenda.views.has_perm") @@ -49,7 +51,9 @@ class ItemViewSetManageSpeaker(TestCase): self.view_instance.manage_speaker(self.request) MockUser.objects.get.assert_called_with(pk=2) - mock_speaker.objects.add.assert_called_with(mock_user, self.mock_item) + mock_speaker.objects.add.assert_called_with( + mock_user, self.mock_list_of_speakers + ) @patch("openslides.agenda.views.Speaker") def test_remove_oneself(self, mock_speaker): @@ -76,26 +80,28 @@ class ItemViewSetManageSpeaker(TestCase): mock_speaker.objects.get.return_value.delete.assert_called_with( skip_autoupdate=True ) - mock_inform_changed_data.assert_called_with(self.mock_item) + mock_inform_changed_data.assert_called_with(self.mock_list_of_speakers) -class ItemViewSetSpeak(TestCase): +class ListOfSpeakersViewSetSpeak(TestCase): """ - Tests views of ItemViewSet to begin and end speech. + Tests views of ListOfSpeakersViewSet to begin and end speech. """ def setUp(self): self.request = MagicMock() - self.view_instance = ItemViewSet() + self.view_instance = ListOfSpeakersViewSet() self.view_instance.request = self.request self.view_instance.get_object = get_object_mock = MagicMock() - get_object_mock.return_value = self.mock_item = MagicMock() + get_object_mock.return_value = self.mock_list_of_speakers = MagicMock() def test_begin_speech(self): self.request.method = "PUT" self.request.user.has_perm.return_value = True self.request.data = {} - self.mock_item.get_next_speaker.return_value = mock_next_speaker = MagicMock() + self.mock_list_of_speakers.get_next_speaker.return_value = ( + mock_next_speaker + ) = MagicMock() self.view_instance.speak(self.request) mock_next_speaker.begin_speech.assert_called_with()