Merge pull request #4636 from FinnStutzenstein/assignmentAttachments

Add attachments to assignments
This commit is contained in:
Sean 2019-04-29 11:35:39 +02:00 committed by GitHub
commit 3dd659ae36
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 146 additions and 12 deletions

View File

@ -42,7 +42,10 @@ export class ViewModelStoreService {
* @param collectionType The collection of the view model * @param collectionType The collection of the view model
* @param ids All ids to match * @param ids All ids to match
*/ */
public getMany<T extends BaseViewModel>(collectionType: ViewModelConstructor<T> | string, ids: number[]): T[] { public getMany<T extends BaseViewModel>(collectionType: ViewModelConstructor<T> | string, ids?: number[]): T[] {
if (!ids) {
return [];
}
const repository = this.getRepository<T>(collectionType); const repository = this.getRepository<T>(collectionType);
return ids return ids

View File

@ -21,6 +21,8 @@ import { ViewUser } from 'app/site/users/models/view-user';
import { ViewAssignmentRelatedUser } from 'app/site/assignments/models/view-assignment-related-user'; import { ViewAssignmentRelatedUser } from 'app/site/assignments/models/view-assignment-related-user';
import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
import { ViewAssignmentPollOption } from 'app/site/assignments/models/view-assignment-poll-option'; 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';
/** /**
* Repository Service for Assignments. * Repository Service for Assignments.
@ -56,7 +58,7 @@ export class AssignmentRepositoryService extends BaseAgendaContentObjectReposito
protected translate: TranslateService, protected translate: TranslateService,
private httpService: HttpService private httpService: HttpService
) { ) {
super(DS, dataSend, mapperService, viewModelStoreService, translate, Assignment, [User, Item, Tag]); super(DS, dataSend, mapperService, viewModelStoreService, translate, Assignment, [User, Item, Tag, Mediafile]);
} }
public getAgendaTitle = (assignment: Partial<Assignment> | Partial<ViewAssignment>) => { public getAgendaTitle = (assignment: Partial<Assignment> | Partial<ViewAssignment>) => {
@ -74,6 +76,7 @@ export class AssignmentRepositoryService extends BaseAgendaContentObjectReposito
public createViewModel(assignment: Assignment): ViewAssignment { public createViewModel(assignment: Assignment): ViewAssignment {
const agendaItem = this.viewModelStoreService.get(ViewItem, assignment.agenda_item_id); const agendaItem = this.viewModelStoreService.get(ViewItem, assignment.agenda_item_id);
const tags = this.viewModelStoreService.getMany(ViewTag, assignment.tags_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 assignmentRelatedUsers = this.createViewAssignmentRelatedUsers(assignment.assignment_related_users);
const assignmentPolls = this.createViewAssignmentPolls(assignment.polls); const assignmentPolls = this.createViewAssignmentPolls(assignment.polls);
@ -82,7 +85,8 @@ export class AssignmentRepositoryService extends BaseAgendaContentObjectReposito
assignmentRelatedUsers, assignmentRelatedUsers,
assignmentPolls, assignmentPolls,
agendaItem, agendaItem,
tags tags,
attachments
); );
viewAssignment.getVerboseName = this.getVerboseName; viewAssignment.getVerboseName = this.getVerboseName;
viewAssignment.getAgendaTitle = () => this.getAgendaTitle(viewAssignment); viewAssignment.getAgendaTitle = () => this.getAgendaTitle(viewAssignment);

View File

@ -19,6 +19,7 @@ export class Assignment extends BaseModel<Assignment> {
public polls: AssignmentPoll[]; public polls: AssignmentPoll[];
public agenda_item_id: number; public agenda_item_id: number;
public tags_id: number[]; public tags_id: number[];
public attachments_id: number[];
public constructor(input?: any) { public constructor(input?: any) {
super(Assignment.COLLECTIONSTRING, input); super(Assignment.COLLECTIONSTRING, input);

View File

@ -40,10 +40,9 @@
<span translate>Attachments</span>: <span translate>Attachments</span>:
<mat-list dense> <mat-list dense>
<mat-list-item *ngFor="let file of topic.attachments"> <mat-list-item *ngFor="let file of topic.attachments">
<a [routerLink]="file.downloadUrl" target="_blank">{{ file.title }}</a> <a [routerLink]="file.downloadUrl" target="_blank">{{ file.getTitle() }}</a>
</mat-list-item> </mat-list-item>
</mat-list> </mat-list>
<!-- TODO: Mediafiles and attachments are not fully implemented -->
</h3> </h3>
</div> </div>

View File

@ -98,6 +98,14 @@
</button> </button>
</mat-menu> </mat-menu>
</div> </div>
<div *ngIf="assignment.attachments.length">
<span translate>Election documents</span>:
<mat-list dense>
<mat-list-item *ngFor="let file of assignment.attachments">
<a [routerLink]="file.downloadUrl" target="_blank">{{ file.getTitle() }}</a>
</mat-list-item>
</mat-list>
</div>
</mat-card> </mat-card>
</ng-template> </ng-template>
@ -214,6 +222,7 @@
</mat-form-field> </mat-form-field>
</div> </div>
<h4 translate>Description:</h4> <h4 translate>Description:</h4>
<!-- description: HTML Editor --> <!-- description: HTML Editor -->
<editor <editor
formControlName="description" formControlName="description"
@ -221,6 +230,7 @@
*ngIf="assignment && editAssignment" *ngIf="assignment && editAssignment"
required required
></editor> ></editor>
<!-- searchValueSelector: tags --> <!-- searchValueSelector: tags -->
<div class="content-field" *ngIf="tagsAvailable"> <div class="content-field" *ngIf="tagsAvailable">
<os-search-value-selector <os-search-value-selector
@ -234,6 +244,18 @@
></os-search-value-selector> ></os-search-value-selector>
</div> </div>
<!-- Attachments -->
<div class="content-field" *ngIf="mediafilesAvailable">
<os-search-value-selector
ngDefaultControl
[form]="assignmentForm"
[formControl]="assignmentForm.get('attachments_id')"
[multiple]="true"
listname="{{ 'Election documents' | translate }}"
[InputListValues]="mediafilesObserver"
></os-search-value-selector>
</div>
<!-- searchValueSelector: agendaItem --> <!-- searchValueSelector: agendaItem -->
<div class="content-field" *ngIf="parentsAvailable"> <div class="content-field" *ngIf="parentsAvailable">
<os-search-value-selector <os-search-value-selector
@ -258,6 +280,7 @@
/> />
</mat-form-field> </mat-form-field>
</div> </div>
<!-- open posts: number --> <!-- open posts: number -->
<div> <div>
<mat-form-field> <mat-form-field>

View File

@ -25,6 +25,8 @@ import { ViewItem } from 'app/site/agenda/models/view-item';
import { ViewportService } from 'app/core/ui-services/viewport.service'; import { ViewportService } from 'app/core/ui-services/viewport.service';
import { ViewTag } from 'app/site/tags/models/view-tag'; import { ViewTag } from 'app/site/tags/models/view-tag';
import { ViewUser } from 'app/site/users/models/view-user'; import { ViewUser } from 'app/site/users/models/view-user';
import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile';
import { MediafileRepositoryService } from 'app/core/repositories/mediafiles/mediafile-repository.service';
/** /**
* Component for the assignment detail view * Component for the assignment detail view
@ -77,6 +79,11 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
*/ */
public tagsObserver: BehaviorSubject<ViewTag[]>; public tagsObserver: BehaviorSubject<ViewTag[]>;
/**
* Used for the search value selector
*/
public mediafilesObserver: BehaviorSubject<ViewMediafile[]>;
/** /**
* Used in the search Value selector to assign an agenda item * Used in the search Value selector to assign an agenda item
*/ */
@ -133,7 +140,14 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
} }
/** /**
* Checks if there are any tags available * Checks if there are any mediafiles available
*/
public get mediafilesAvailable(): boolean {
return this.mediafilesObserver.getValue().length > 0;
}
/**
* Checks if there are any items available
*/ */
public get parentsAvailable(): boolean { public get parentsAvailable(): boolean {
return this.agendaObserver.getValue().length > 0; return this.agendaObserver.getValue().length > 0;
@ -174,7 +188,8 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
private agendaRepo: ItemRepositoryService, private agendaRepo: ItemRepositoryService,
private tagRepo: TagRepositoryService, private tagRepo: TagRepositoryService,
private promptService: PromptService, private promptService: PromptService,
private pdfService: AssignmentPdfExportService private pdfService: AssignmentPdfExportService,
private mediafileRepo: MediafileRepositoryService
) { ) {
super(title, translate, matSnackBar); super(title, translate, matSnackBar);
this.subscriptions.push( this.subscriptions.push(
@ -187,6 +202,7 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
this.assignmentForm = formBuilder.group({ this.assignmentForm = formBuilder.group({
phase: null, phase: null,
tags_id: [], tags_id: [],
attachments_id: [],
title: '', title: '',
description: '', description: '',
poll_description_default: '', poll_description_default: '',
@ -205,6 +221,7 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
this.getAssignmentByUrl(); this.getAssignmentByUrl();
this.agendaObserver = this.agendaRepo.getViewModelListBehaviorSubject(); this.agendaObserver = this.agendaRepo.getViewModelListBehaviorSubject();
this.tagsObserver = this.tagRepo.getViewModelListBehaviorSubject(); this.tagsObserver = this.tagRepo.getViewModelListBehaviorSubject();
this.mediafilesObserver = this.mediafileRepo.getViewModelListBehaviorSubject();
} }
/** /**
@ -281,6 +298,7 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
this.assignmentForm.patchValue({ this.assignmentForm.patchValue({
title: assignment.title || '', title: assignment.title || '',
tags_id: assignment.assignment.tags_id || [], tags_id: assignment.assignment.tags_id || [],
attachments_id: assignment.assignment.attachments_id || [],
agendaItem: assignment.assignment.agenda_item_id || null, agendaItem: assignment.assignment.agenda_item_id || null,
phase: assignment.phase, // todo default: 0? phase: assignment.phase, // todo default: 0?
description: assignment.assignment.description || '', description: assignment.assignment.description || '',

View File

@ -8,6 +8,7 @@ import { ViewTag } from 'app/site/tags/models/view-tag';
import { BaseViewModel } from 'app/site/base/base-view-model'; import { BaseViewModel } from 'app/site/base/base-view-model';
import { ViewAssignmentRelatedUser } from './view-assignment-related-user'; import { ViewAssignmentRelatedUser } from './view-assignment-related-user';
import { ViewAssignmentPoll } from './view-assignment-poll'; import { ViewAssignmentPoll } from './view-assignment-poll';
import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile';
/** /**
* A constant containing all possible assignment phases and their different * A constant containing all possible assignment phases and their different
@ -40,6 +41,7 @@ export class ViewAssignment extends BaseAgendaViewModel {
private _assignmentPolls: ViewAssignmentPoll[]; private _assignmentPolls: ViewAssignmentPoll[];
private _agendaItem?: ViewItem; private _agendaItem?: ViewItem;
private _tags?: ViewTag[]; private _tags?: ViewTag[];
private _attachments?: ViewMediafile[];
public get id(): number { public get id(): number {
return this._assignment ? this._assignment.id : null; return this._assignment ? this._assignment.id : null;
@ -81,6 +83,10 @@ export class ViewAssignment extends BaseAgendaViewModel {
return this._tags || []; return this._tags || [];
} }
public get attachments(): ViewMediafile[] {
return this._attachments || [];
}
/** /**
* unknown where the identifier to the phase is get * unknown where the identifier to the phase is get
*/ */
@ -129,7 +135,8 @@ export class ViewAssignment extends BaseAgendaViewModel {
assignmentRelatedUsers: ViewAssignmentRelatedUser[], assignmentRelatedUsers: ViewAssignmentRelatedUser[],
assignmentPolls: ViewAssignmentPoll[], assignmentPolls: ViewAssignmentPoll[],
agendaItem?: ViewItem, agendaItem?: ViewItem,
tags?: ViewTag[] tags?: ViewTag[],
attachments?: ViewMediafile[]
) { ) {
super(Assignment.COLLECTIONSTRING); super(Assignment.COLLECTIONSTRING);
@ -138,6 +145,7 @@ export class ViewAssignment extends BaseAgendaViewModel {
this._assignmentPolls = assignmentPolls; this._assignmentPolls = assignmentPolls;
this._agendaItem = agendaItem; this._agendaItem = agendaItem;
this._tags = tags; this._tags = tags;
this._attachments = attachments;
} }
public updateDependencies(update: BaseViewModel): void { public updateDependencies(update: BaseViewModel): void {
@ -153,6 +161,13 @@ export class ViewAssignment extends BaseAgendaViewModel {
} else if (update instanceof ViewUser) { } else if (update instanceof ViewUser) {
this.assignmentRelatedUsers.forEach(aru => aru.updateDependencies(update)); this.assignmentRelatedUsers.forEach(aru => aru.updateDependencies(update));
this.polls.forEach(poll => poll.updateDependencies(update)); this.polls.forEach(poll => poll.updateDependencies(update));
} else if (update instanceof ViewMediafile && this.assignment.attachments_id.includes(update.id)) {
const mediafileIndex = this._attachments.findIndex(_mediafile => _mediafile.id === update.id);
if (mediafileIndex < 0) {
this._attachments.push(update);
} else {
this._attachments[mediafileIndex] = update;
}
} }
} }

View File

@ -0,0 +1,19 @@
# Generated by Django 2.1.7 on 2019-04-26 11:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("mediafiles", "0003_auto_20190119_1425"),
("assignments", "0006_auto_20190119_1425"),
]
operations = [
migrations.AddField(
model_name="assignment",
name="attachments",
field=models.ManyToManyField(blank=True, to="mediafiles.Mediafile"),
)
]

View File

@ -10,6 +10,7 @@ from django.db import models
from openslides.agenda.models import Item, Speaker from openslides.agenda.models import Item, Speaker
from openslides.core.config import config from openslides.core.config import config
from openslides.core.models import Tag from openslides.core.models import Tag
from openslides.mediafiles.models import Mediafile
from openslides.poll.models import ( from openslides.poll.models import (
BaseOption, BaseOption,
BasePoll, BasePoll,
@ -78,7 +79,7 @@ class AssignmentManager(models.Manager):
polls are prefetched from the database. polls are prefetched from the database.
""" """
return self.get_queryset().prefetch_related( return self.get_queryset().prefetch_related(
"related_users", "agenda_items", "polls", "tags" "related_users", "agenda_items", "polls", "tags", "attachments"
) )
@ -141,6 +142,11 @@ class Assignment(RESTModelMixin, models.Model):
Tags for the assignment. Tags for the assignment.
""" """
attachments = models.ManyToManyField(Mediafile, blank=True)
"""
Mediafiles as attachments for this assignment.
"""
# In theory there could be one then more agenda_item. But we support only # In theory there could be one then more agenda_item. But we support only
# one. See the property agenda_item. # one. See the property agenda_item.
agenda_items = GenericRelation(Item, related_name="assignments") agenda_items = GenericRelation(Item, related_name="assignments")

View File

@ -206,6 +206,7 @@ class AssignmentFullSerializer(ModelSerializer):
"agenda_type", "agenda_type",
"agenda_parent_id", "agenda_parent_id",
"tags", "tags",
"attachments",
) )
validators = (posts_validator,) validators = (posts_validator,)
@ -222,10 +223,12 @@ class AssignmentFullSerializer(ModelSerializer):
agenda_type = validated_data.pop("agenda_type", None) agenda_type = validated_data.pop("agenda_type", None)
agenda_parent_id = validated_data.pop("agenda_parent_id", None) agenda_parent_id = validated_data.pop("agenda_parent_id", None)
tags = validated_data.pop("tags", []) tags = validated_data.pop("tags", [])
attachments = validated_data.pop("attachments", [])
assignment = Assignment(**validated_data) assignment = Assignment(**validated_data)
assignment.agenda_item_update_information["type"] = agenda_type assignment.agenda_item_update_information["type"] = agenda_type
assignment.agenda_item_update_information["parent_id"] = agenda_parent_id assignment.agenda_item_update_information["parent_id"] = agenda_parent_id
assignment.save() assignment.save()
assignment.tags.add(*tags) assignment.tags.add(*tags)
assignment.attachments.add(*attachments)
inform_changed_data(assignment) inform_changed_data(assignment)
return assignment return assignment

View File

@ -1,10 +1,13 @@
import pytest import pytest
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse from django.urls import reverse
from rest_framework import status from rest_framework import status
from rest_framework.test import APIClient from rest_framework.test import APIClient
from openslides.assignments.models import Assignment from openslides.assignments.models import Assignment
from openslides.core.models import Tag
from openslides.mediafiles.models import Mediafile
from openslides.utils.autoupdate import inform_changed_data from openslides.utils.autoupdate import inform_changed_data
from openslides.utils.test import TestCase from openslides.utils.test import TestCase
@ -19,16 +22,56 @@ def test_assignment_db_queries():
* 1 request to get all related users, * 1 request to get all related users,
* 1 request to get the agenda item, * 1 request to get the agenda item,
* 1 request to get the polls, * 1 request to get the polls,
* 1 request to get the tags and * 1 request to get the tags,
* 1 request to get the attachments and
* 10 request to fetch each related user again. * 10 request to fetch each related user again.
TODO: The last request are a bug. TODO: The last requests are a bug.
""" """
for index in range(10): for index in range(10):
Assignment.objects.create(title=f"assignment{index}", open_posts=1) Assignment.objects.create(title=f"assignment{index}", open_posts=1)
assert count_queries(Assignment.get_elements) == 15 assert count_queries(Assignment.get_elements) == 16
class CreateAssignment(TestCase):
"""
Tests basic creation of assignments.
"""
def setUp(self):
self.client = APIClient()
self.client.login(username="admin", password="admin")
def test_simple(self):
response = self.client.post(
reverse("assignment-list"),
{"title": "test_title_ef3jpF)M329f30m)f82", "open_posts": 1},
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
assignment = Assignment.objects.get()
self.assertEqual(assignment.title, "test_title_ef3jpF)M329f30m)f82")
def test_with_tags_and_mediafiles(self):
Tag.objects.create(name="test_tag")
Mediafile.objects.create(
title="test_file", mediafile=SimpleUploadedFile("title.txt", b"content")
)
response = self.client.post(
reverse("assignment-list"),
{
"title": "test_title_ef3jpF)M329f30m)f82",
"open_posts": 1,
"tags_id": [1],
"attachments_id": [1],
},
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
assignment = Assignment.objects.get()
self.assertEqual(assignment.title, "test_title_ef3jpF)M329f30m)f82")
self.assertTrue(assignment.tags.exists())
self.assertTrue(assignment.attachments.exists())
class CanidatureSelf(TestCase): class CanidatureSelf(TestCase):