diff --git a/settings/common.py b/settings/common.py index b10226d0..13e5ae4b 100644 --- a/settings/common.py +++ b/settings/common.py @@ -446,24 +446,38 @@ SOUTH_MIGRATION_MODULES = { 'easy_thumbnails': 'easy_thumbnails.south_migrations', } -DEFAULT_AVATAR_SIZE = 80 # 80x80 pixels -DEFAULT_BIG_AVATAR_SIZE = 300 # 300x300 pixels -DEFAULT_TIMELINE_IMAGE_SIZE = 640 # 640x??? pixels -DEFAUL_CARD_IMAGE_WIDTH = 300 # 300 pixels -DEFAUL_CARD_IMAGE_HEIGHT = 200 # 200 pixels + + + +THN_AVATAR_SIZE = 80 # 80x80 pixels +THN_AVATAR_BIG_SIZE = 300 # 300x300 pixels +THN_LOGO_SMALL_SIZE = 80 # 80x80 pixels +THN_LOGO_BIG_SIZE = 300 # 300x300 pixels +THN_TIMELINE_IMAGE_SIZE = 640 # 640x??? pixels +THN_CARD_IMAGE_WIDTH = 300 # 300 pixels +THN_CARD_IMAGE_HEIGHT = 200 # 200 pixels + +THN_AVATAR_SMALL = "avatar" +THN_AVATAR_BIG = "big-avatar" +THN_LOGO_SMALL = "logo-small" +THN_LOGO_BIG = "logo-big" +THN_ATTACHMENT_TIMELINE = "timeline-image" +THN_ATTACHMENT_CARD = "card-image" THUMBNAIL_ALIASES = { - '': { - 'avatar': {'size': (DEFAULT_AVATAR_SIZE, DEFAULT_AVATAR_SIZE), 'crop': True}, - 'big-avatar': {'size': (DEFAULT_BIG_AVATAR_SIZE, DEFAULT_BIG_AVATAR_SIZE), 'crop': True}, - 'timeline-image': {'size': (DEFAULT_TIMELINE_IMAGE_SIZE, 0), 'crop': True}, - 'card-image': {'size': (DEFAUL_CARD_IMAGE_WIDTH, DEFAUL_CARD_IMAGE_HEIGHT), 'crop': True}, + "": { + THN_AVATAR_SMALL: {"size": (THN_AVATAR_SIZE, THN_AVATAR_SIZE), "crop": True}, + THN_AVATAR_BIG: {"size": (THN_AVATAR_BIG_SIZE, THN_AVATAR_BIG_SIZE), "crop": True}, + THN_LOGO_SMALL: {"size": (THN_LOGO_SMALL_SIZE, THN_LOGO_SMALL_SIZE), "crop": True}, + THN_LOGO_BIG: {"size": (THN_LOGO_BIG_SIZE, THN_LOGO_BIG_SIZE), "crop": True}, + THN_ATTACHMENT_TIMELINE: {"size": (THN_TIMELINE_IMAGE_SIZE, 0), "crop": True}, + THN_ATTACHMENT_CARD: {"size": (THN_CARD_IMAGE_WIDTH, THN_CARD_IMAGE_HEIGHT), "crop": True}, }, } # GRAVATAR_DEFAULT_AVATAR = "img/user-noimage.png" GRAVATAR_DEFAULT_AVATAR = "" -GRAVATAR_AVATAR_SIZE = DEFAULT_AVATAR_SIZE +GRAVATAR_AVATAR_SIZE = THN_AVATAR_SIZE TAGS_PREDEFINED_COLORS = ["#fce94f", "#edd400", "#c4a000", "#8ae234", "#73d216", "#4e9a06", "#d3d7cf", "#fcaf3e", diff --git a/taiga/base/apps.py b/taiga/base/apps.py index db509c52..5a35fa30 100644 --- a/taiga/base/apps.py +++ b/taiga/base/apps.py @@ -17,7 +17,12 @@ from django.apps import AppConfig +from .signals.thumbnails import connect_thumbnail_signals + class BaseAppConfig(AppConfig): name = "taiga.base" verbose_name = "Base App Config" + + def ready(self): + connect_thumbnail_signals() diff --git a/taiga/base/signals/__init__.py b/taiga/base/signals/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/base/signals/thumbnails.py b/taiga/base/signals/thumbnails.py new file mode 100644 index 00000000..334bf970 --- /dev/null +++ b/taiga/base/signals/thumbnails.py @@ -0,0 +1,31 @@ +# Copyright (C) 2014-2015 Andrey Antukh +# Copyright (C) 2014-2015 Jesús Espino +# Copyright (C) 2014-2015 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django_transactional_cleanup.signals import cleanup_post_delete +from easy_thumbnails.files import get_thumbnailer + + +def _delete_thumbnail_files(**kwargs): + thumbnailer = get_thumbnailer(kwargs["file"]) + thumbnailer.delete_thumbnails() + + +def connect_thumbnail_signals(): + cleanup_post_delete.connect(_delete_thumbnail_files) + + +def disconnect_thumbnail_signals(): + cleanup_post_delete.disconnect(_delete_thumbnail_files) diff --git a/taiga/base/utils/thumbnails.py b/taiga/base/utils/thumbnails.py new file mode 100644 index 00000000..ce8697ba --- /dev/null +++ b/taiga/base/utils/thumbnails.py @@ -0,0 +1,30 @@ +# Copyright (C) 2014-2015 Andrey Antukh +# Copyright (C) 2014-2015 Jesús Espino +# Copyright (C) 2014-2015 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from taiga.base.utils.urls import get_absolute_url + +from easy_thumbnails.files import get_thumbnailer +from easy_thumbnails.exceptions import InvalidImageFormatError + + +def get_thumbnail_url(file_obj, thumbnailer_size): + try: + path_url = get_thumbnailer(file_obj)[thumbnailer_size].url + thumb_url = get_absolute_url(path_url) + except InvalidImageFormatError: + thumb_url = None + + return thumb_url diff --git a/taiga/export_import/serializers.py b/taiga/export_import/serializers.py index a6d0d2c1..ceb8263f 100644 --- a/taiga/export_import/serializers.py +++ b/taiga/export_import/serializers.py @@ -50,7 +50,7 @@ from taiga.projects.votes import services as votes_service from taiga.projects.history import services as history_service -class AttachedFileField(serializers.WritableField): +class FileField(serializers.WritableField): read_only = False def to_native(self, obj): @@ -308,7 +308,7 @@ class HistoryExportSerializerMixin(serializers.ModelSerializer): class AttachmentExportSerializer(serializers.ModelSerializer): owner = UserRelatedField(required=False) - attached_file = AttachedFileField() + attached_file = FileField() modified_date = serializers.DateTimeField(required=False) class Meta: @@ -643,7 +643,21 @@ class TimelineExportSerializer(serializers.ModelSerializer): class ProjectExportSerializer(WatcheableObjectModelSerializer): + logo = FileField(required=False) + anon_permissions = PgArrayField(required=False) + public_permissions = PgArrayField(required=False) + modified_date = serializers.DateTimeField(required=False) + roles = RoleExportSerializer(many=True, required=False) owner = UserRelatedField(required=False) + memberships = MembershipExportSerializer(many=True, required=False) + points = PointsExportSerializer(many=True, required=False) + us_statuses = UserStoryStatusExportSerializer(many=True, required=False) + task_statuses = TaskStatusExportSerializer(many=True, required=False) + issue_types = IssueTypeExportSerializer(many=True, required=False) + issue_statuses = IssueStatusExportSerializer(many=True, required=False) + priorities = PriorityExportSerializer(many=True, required=False) + severities = SeverityExportSerializer(many=True, required=False) + tags_colors = JsonField(required=False) default_points = serializers.SlugRelatedField(slug_field="name", required=False) default_us_status = serializers.SlugRelatedField(slug_field="name", required=False) default_task_status = serializers.SlugRelatedField(slug_field="name", required=False) @@ -651,28 +665,15 @@ class ProjectExportSerializer(WatcheableObjectModelSerializer): default_severity = serializers.SlugRelatedField(slug_field="name", required=False) default_issue_status = serializers.SlugRelatedField(slug_field="name", required=False) default_issue_type = serializers.SlugRelatedField(slug_field="name", required=False) - memberships = MembershipExportSerializer(many=True, required=False) - points = PointsExportSerializer(many=True, required=False) - us_statuses = UserStoryStatusExportSerializer(many=True, required=False) - task_statuses = TaskStatusExportSerializer(many=True, required=False) - issue_statuses = IssueStatusExportSerializer(many=True, required=False) - priorities = PriorityExportSerializer(many=True, required=False) - severities = SeverityExportSerializer(many=True, required=False) - issue_types = IssueTypeExportSerializer(many=True, required=False) userstorycustomattributes = UserStoryCustomAttributeExportSerializer(many=True, required=False) taskcustomattributes = TaskCustomAttributeExportSerializer(many=True, required=False) issuecustomattributes = IssueCustomAttributeExportSerializer(many=True, required=False) - roles = RoleExportSerializer(many=True, required=False) - milestones = MilestoneExportSerializer(many=True, required=False) - wiki_pages = WikiPageExportSerializer(many=True, required=False) - wiki_links = WikiLinkExportSerializer(many=True, required=False) user_stories = UserStoryExportSerializer(many=True, required=False) tasks = TaskExportSerializer(many=True, required=False) + milestones = MilestoneExportSerializer(many=True, required=False) issues = IssueExportSerializer(many=True, required=False) - tags_colors = JsonField(required=False) - anon_permissions = PgArrayField(required=False) - public_permissions = PgArrayField(required=False) - modified_date = serializers.DateTimeField(required=False) + wiki_links = WikiLinkExportSerializer(many=True, required=False) + wiki_pages = WikiPageExportSerializer(many=True, required=False) timeline = serializers.SerializerMethodField("get_timeline") class Meta: diff --git a/taiga/export_import/service.py b/taiga/export_import/service.py index c958cf70..fe1fafec 100644 --- a/taiga/export_import/service.py +++ b/taiga/export_import/service.py @@ -120,7 +120,8 @@ def render_project(project, outfile, chunk_size = 8190): b64_data = base64.b64encode(bin_data).decode('utf-8') outfile.write(b64_data) - outfile.write('", \n "name":"{}"}}\n}}'.format(os.path.basename(attachment_file.name))) + outfile.write('", \n "name":"{}"}}\n}}'.format( + os.path.basename(attachment_file.name))) outfile.write(']}') outfile.flush() @@ -324,8 +325,8 @@ def store_task(project, data): custom_attributes_values = data.get("custom_attributes_values", None) if custom_attributes_values: custom_attributes = serialized.object.project.taskcustomattributes.all().values('id', 'name') - custom_attributes_values = _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, - custom_attributes_values) + custom_attributes_values = _use_id_instead_name_as_key_in_custom_attributes_values( + custom_attributes, custom_attributes_values) store_custom_attributes_values(serialized.object, custom_attributes_values, "task", serializers.TaskCustomAttributesValuesExportSerializer) @@ -457,7 +458,8 @@ def store_user_story(project, data): if "status" not in data and project.default_us_status: data["status"] = project.default_us_status.name - us_data = {key: value for key, value in data.items() if key not in ["role_points", "custom_attributes_values"]} + us_data = {key: value for key, value in data.items() if key not in + ["role_points", "custom_attributes_values"]} serialized = serializers.UserStoryExportSerializer(data=us_data, context={"project": project}) if serialized.is_valid(): @@ -495,8 +497,8 @@ def store_user_story(project, data): custom_attributes_values = data.get("custom_attributes_values", None) if custom_attributes_values: custom_attributes = serialized.object.project.userstorycustomattributes.all().values('id', 'name') - custom_attributes_values = _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, - custom_attributes_values) + custom_attributes_values = _use_id_instead_name_as_key_in_custom_attributes_values( + custom_attributes, custom_attributes_values) store_custom_attributes_values(serialized.object, custom_attributes_values, "user_story", serializers.UserStoryCustomAttributesValuesExportSerializer) @@ -553,8 +555,8 @@ def store_issue(project, data): custom_attributes_values = data.get("custom_attributes_values", None) if custom_attributes_values: custom_attributes = serialized.object.project.issuecustomattributes.all().values('id', 'name') - custom_attributes_values = _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, - custom_attributes_values) + custom_attributes_values = _use_id_instead_name_as_key_in_custom_attributes_values( + custom_attributes, custom_attributes_values) store_custom_attributes_values(serialized.object, custom_attributes_values, "issue", serializers.IssueCustomAttributesValuesExportSerializer) diff --git a/taiga/projects/api.py b/taiga/projects/api.py index d80a5509..fbeefb7e 100644 --- a/taiga/projects/api.py +++ b/taiga/projects/api.py @@ -17,6 +17,8 @@ import uuid +from easy_thumbnails.source_generators import pil_image + from django.apps import apps from django.db.models import signals, Prefetch from django.db.models import Value as V @@ -137,6 +139,42 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet) return serializer_class + @detail_route(methods=["POST"]) + def change_logo(self, request, *args, **kwargs): + """ + Change logo to this project. + """ + self.object = get_object_or_404(self.get_queryset(), **kwargs) + self.check_permissions(request, "change_logo", self.object) + + logo = request.FILES.get('logo', None) + if not logo: + raise exc.WrongArguments(_("Incomplete arguments")) + try: + pil_image(logo) + except Exception: + raise exc.WrongArguments(_("Invalid image format")) + + self.object.logo = logo + self.object.save(update_fields=["logo"]) + + serializer = self.get_serializer(self.object) + return response.Ok(serializer.data) + + @detail_route(methods=["POST"]) + def remove_logo(self, request, *args, **kwargs): + """ + Remove the logo of a project. + """ + self.object = get_object_or_404(self.get_queryset(), **kwargs) + self.check_permissions(request, "remove_logo", self.object) + + self.object.logo = None + self.object.save(update_fields=["logo"]) + + serializer = self.get_serializer(self.object) + return response.Ok(serializer.data) + @detail_route(methods=["POST"]) def watch(self, request, pk=None): project = self.get_object() @@ -288,9 +326,7 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet) def pre_save(self, obj): if not obj.id: obj.owner = self.request.user - - # TODO REFACTOR THIS - if not obj.id: + # TODO REFACTOR THIS obj.template = self.request.QUERY_PARAMS.get('template', None) self._set_base_permissions(obj) diff --git a/taiga/projects/apps.py b/taiga/projects/apps.py index a0e3b8b6..841b81d7 100644 --- a/taiga/projects/apps.py +++ b/taiga/projects/apps.py @@ -22,60 +22,78 @@ from django.db.models import signals from . import signals as handlers -def connect_memberships_signals(): - # On membership object is deleted, update role-points relation. - signals.pre_delete.connect(handlers.membership_post_delete, - sender=apps.get_model("projects", "Membership"), - dispatch_uid='membership_pre_delete') - - # On membership object is deleted, update notify policies of all objects relation. - signals.post_save.connect(handlers.create_notify_policy, - sender=apps.get_model("projects", "Membership"), - dispatch_uid='create-notify-policy') - +## Project Signals def connect_projects_signals(): - # On project object is created apply template. - signals.post_save.connect(handlers.project_post_save, - sender=apps.get_model("projects", "Project"), - dispatch_uid='project_post_save') + # On project object is created apply template. + signals.post_save.connect(handlers.project_post_save, + sender=apps.get_model("projects", "Project"), + dispatch_uid='project_post_save') - # Tags - signals.pre_save.connect(handlers.tags_normalization, - sender=apps.get_model("projects", "Project"), - dispatch_uid="tags_normalization_projects") - signals.pre_save.connect(handlers.update_project_tags_when_create_or_edit_taggable_item, - sender=apps.get_model("projects", "Project"), - dispatch_uid="update_project_tags_when_create_or_edit_taggable_item_projects") - - -def connect_us_status_signals(): - signals.post_save.connect(handlers.try_to_close_or_open_user_stories_when_edit_us_status, - sender=apps.get_model("projects", "UserStoryStatus"), - dispatch_uid="try_to_close_or_open_user_stories_when_edit_us_status") - - -def connect_task_status_signals(): - signals.post_save.connect(handlers.try_to_close_or_open_user_stories_when_edit_task_status, - sender=apps.get_model("projects", "TaskStatus"), - dispatch_uid="try_to_close_or_open_user_stories_when_edit_task_status") - - -def disconnect_memberships_signals(): - signals.pre_delete.disconnect(sender=apps.get_model("projects", "Membership"), dispatch_uid='membership_pre_delete') - signals.post_save.disconnect(sender=apps.get_model("projects", "Membership"), dispatch_uid='create-notify-policy') + # Tags normalization after save a project + signals.pre_save.connect(handlers.tags_normalization, + sender=apps.get_model("projects", "Project"), + dispatch_uid="tags_normalization_projects") + signals.pre_save.connect(handlers.update_project_tags_when_create_or_edit_taggable_item, + sender=apps.get_model("projects", "Project"), + dispatch_uid="update_project_tags_when_create_or_edit_taggable_item_projects") def disconnect_projects_signals(): - signals.post_save.disconnect(sender=apps.get_model("projects", "Project"), dispatch_uid='project_post_save') - signals.pre_save.disconnect(sender=apps.get_model("projects", "Project"), dispatch_uid="tags_normalization_projects") - signals.pre_save.disconnect(sender=apps.get_model("projects", "Project"), dispatch_uid="update_project_tags_when_create_or_edit_taggable_item_projects") + signals.post_save.disconnect(sender=apps.get_model("projects", "Project"), + dispatch_uid='project_post_save') + signals.pre_save.disconnect(sender=apps.get_model("projects", "Project"), + dispatch_uid="tags_normalization_projects") + signals.pre_save.disconnect(sender=apps.get_model("projects", "Project"), + dispatch_uid="update_project_tags_when_create_or_edit_taggable_item_projects") + + +## Memberships Signals + +def connect_memberships_signals(): + # On membership object is deleted, update role-points relation. + signals.pre_delete.connect(handlers.membership_post_delete, + sender=apps.get_model("projects", "Membership"), + dispatch_uid='membership_pre_delete') + + # On membership object is deleted, update notify policies of all objects relation. + signals.post_save.connect(handlers.create_notify_policy, + sender=apps.get_model("projects", "Membership"), + dispatch_uid='create-notify-policy') + +def disconnect_memberships_signals(): + signals.pre_delete.disconnect(sender=apps.get_model("projects", "Membership"), + dispatch_uid='membership_pre_delete') + signals.post_save.disconnect(sender=apps.get_model("projects", "Membership"), + dispatch_uid='create-notify-policy') + + +## US Statuses Signals + +def connect_us_status_signals(): + signals.post_save.connect(handlers.try_to_close_or_open_user_stories_when_edit_us_status, + sender=apps.get_model("projects", "UserStoryStatus"), + dispatch_uid="try_to_close_or_open_user_stories_when_edit_us_status") + def disconnect_us_status_signals(): - signals.post_save.disconnect(sender=apps.get_model("projects", "UserStoryStatus"), dispatch_uid="try_to_close_or_open_user_stories_when_edit_us_status") + signals.post_save.disconnect(sender=apps.get_model("projects", "UserStoryStatus"), + dispatch_uid="try_to_close_or_open_user_stories_when_edit_us_status") + + + +## Tasks Statuses Signals + +def connect_task_status_signals(): + signals.post_save.connect(handlers.try_to_close_or_open_user_stories_when_edit_task_status, + sender=apps.get_model("projects", "TaskStatus"), + dispatch_uid="try_to_close_or_open_user_stories_when_edit_task_status") + def disconnect_task_status_signals(): - signals.post_save.disconnect(sender=apps.get_model("projects", "TaskStatus"), dispatch_uid="try_to_close_or_open_user_stories_when_edit_task_status") + signals.post_save.disconnect(sender=apps.get_model("projects", "TaskStatus"), + dispatch_uid="try_to_close_or_open_user_stories_when_edit_task_status") + class ProjectsAppConfig(AppConfig): @@ -83,7 +101,7 @@ class ProjectsAppConfig(AppConfig): verbose_name = "Projects" def ready(self): - connect_memberships_signals() connect_projects_signals() + connect_memberships_signals() connect_us_status_signals() connect_task_status_signals() diff --git a/taiga/projects/attachments/apps.py b/taiga/projects/attachments/apps.py index 4e1f998d..6e1508e1 100644 --- a/taiga/projects/attachments/apps.py +++ b/taiga/projects/attachments/apps.py @@ -15,25 +15,10 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from easy_thumbnails.files import get_thumbnailer - from django.apps import AppConfig from django.apps import apps -from django_transactional_cleanup.signals import cleanup_post_delete - - -def thumbnail_delete(**kwargs): - thumbnailer = get_thumbnailer(kwargs["file"]) - thumbnailer.delete_thumbnails() - - -def connect_attachment_signals(): - cleanup_post_delete.connect(thumbnail_delete) class AttachmentsAppConfig(AppConfig): name = "taiga.projects.attachments" verbose_name = "Attachments" - - def ready(self): - connect_attachment_signals() diff --git a/taiga/projects/attachments/serializers.py b/taiga/projects/attachments/serializers.py index ee1dbd2a..bb15e61e 100644 --- a/taiga/projects/attachments/serializers.py +++ b/taiga/projects/attachments/serializers.py @@ -37,4 +37,4 @@ class AttachmentSerializer(serializers.ModelSerializer): return obj.attached_file.url def get_thumbnail_card_url(self, obj): - return services.get_card_image_thumbnailer_url(obj) + return services.get_card_image_thumbnail_url(obj) diff --git a/taiga/projects/attachments/services.py b/taiga/projects/attachments/services.py index 99c0d57a..ee25c9b5 100644 --- a/taiga/projects/attachments/services.py +++ b/taiga/projects/attachments/services.py @@ -12,25 +12,18 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from taiga.base.utils.urls import get_absolute_url +from django.conf import settings -from easy_thumbnails.files import get_thumbnailer -from easy_thumbnails.exceptions import InvalidImageFormatError +from taiga.base.utils.thumbnails import get_thumbnail_url -def _get_attachment_thumbnailer_url(attachment, thumbnailer_size): - try: - thumb_url = get_thumbnailer(attachment.attached_file)[thumbnailer_size].url - thumb_url = get_absolute_url(thumb_url) - except InvalidImageFormatError: - thumb_url = None - - return thumb_url +def get_timeline_image_thumbnail_url(attachment): + if attachment.attached_file: + return get_thumbnail_url(attachment.attached_file, settings.THN_ATTACHMENT_TIMELINE) + return None -def get_timeline_image_thumbnailer_url(attachment): - return _get_attachment_thumbnailer_url(attachment, "timeline-image") - - -def get_card_image_thumbnailer_url(attachment): - return _get_attachment_thumbnailer_url(attachment, "card-image") +def get_card_image_thumbnail_url(attachment): + if attachment.attached_file: + return get_thumbnail_url(attachment.attached_file, settings.THN_ATTACHMENT_CARD) + return None diff --git a/taiga/projects/history/freeze_impl.py b/taiga/projects/history/freeze_impl.py index b34c3423..ba0c10fe 100644 --- a/taiga/projects/history/freeze_impl.py +++ b/taiga/projects/history/freeze_impl.py @@ -27,7 +27,7 @@ from taiga.base.utils.iterators import as_tuple from taiga.base.utils.iterators import as_dict from taiga.mdrender.service import render as mdrender -from taiga.projects.attachments.services import get_timeline_image_thumbnailer_url +from taiga.projects.attachments.services import get_timeline_image_thumbnail_url import os @@ -178,7 +178,7 @@ def _generic_extract(obj:object, fields:list, default=None) -> dict: @as_tuple def extract_attachments(obj) -> list: for attach in obj.attachments.all(): - thumb_url = get_timeline_image_thumbnailer_url(attach) + thumb_url = get_timeline_image_thumbnail_url(attach) yield {"id": attach.id, "filename": os.path.basename(attach.attached_file.name), diff --git a/taiga/projects/migrations/0031_project_logo.py b/taiga/projects/migrations/0031_project_logo.py new file mode 100644 index 00000000..ebf50a9b --- /dev/null +++ b/taiga/projects/migrations/0031_project_logo.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import taiga.projects.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0030_auto_20151128_0757'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='logo', + field=models.FileField(null=True, blank=True, upload_to=taiga.projects.models.get_user_file_path, verbose_name='logo', max_length=500), + ), + ] diff --git a/taiga/projects/models.py b/taiga/projects/models.py index 0f9a2fa3..592224b2 100644 --- a/taiga/projects/models.py +++ b/taiga/projects/models.py @@ -15,9 +15,13 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import hashlib +import os +import os.path as path import itertools import uuid +from unidecode import unidecode from django.core.exceptions import ValidationError from django.db import models @@ -27,18 +31,22 @@ from django.conf import settings from django.dispatch import receiver from django.contrib.auth import get_user_model from django.utils.translation import ugettext_lazy as _ +from django.template.defaultfilters import slugify +from django.utils.encoding import force_bytes from django.utils import timezone from django_pgjson.fields import JsonField from djorm_pgarray.fields import TextArrayField -from taiga.permissions.permissions import ANON_PERMISSIONS, MEMBERS_PERMISSIONS from taiga.base.tags import TaggedMixin -from taiga.base.utils.slug import slugify_uniquely from taiga.base.utils.dicts import dict_sum +from taiga.base.utils.iterators import split_by_n from taiga.base.utils.sequence import arithmetic_progression +from taiga.base.utils.slug import slugify_uniquely from taiga.base.utils.slug import slugify_uniquely_for_queryset +from taiga.permissions.permissions import ANON_PERMISSIONS, MEMBERS_PERMISSIONS + from taiga.projects.notifications.choices import NotifyLevel from taiga.projects.notifications.services import ( get_notify_policy, @@ -53,6 +61,22 @@ from . import choices from dateutil.relativedelta import relativedelta +def get_user_file_path(instance, filename): + basename = path.basename(filename).lower() + base, ext = path.splitext(basename) + base = slugify(unidecode(base)) + basename = "".join([base, ext]) + + hs = hashlib.sha256() + hs.update(force_bytes(timezone.now().isoformat())) + hs.update(os.urandom(1024)) + + p1, p2, p3, p4, *p5 = split_by_n(hs.hexdigest(), 1) + hash_part = path.join(p1, p2, p3, p4, "".join(p5)) + + return path.join("project", hash_part, basename) + + class Membership(models.Model): # This model stores all project memberships. Also # stores invitations to memberships that does not have @@ -141,6 +165,11 @@ class Project(ProjectDefaults, TaggedMixin, models.Model): verbose_name=_("slug")) description = models.TextField(null=False, blank=False, verbose_name=_("description")) + + logo = models.FileField(upload_to=get_user_file_path, + max_length=500, null=True, blank=True, + verbose_name=_("logo")) + created_date = models.DateTimeField(null=False, blank=False, verbose_name=_("created date"), default=timezone.now) diff --git a/taiga/projects/permissions.py b/taiga/projects/permissions.py index 61bb8afc..c1e017a7 100644 --- a/taiga/projects/permissions.py +++ b/taiga/projects/permissions.py @@ -53,6 +53,8 @@ class ProjectPermission(TaigaResourcePermission): destroy_perms = IsProjectOwner() modules_perms = IsProjectOwner() list_perms = AllowAny() + change_logo_perms = IsProjectOwner() + remove_logo_perms = IsProjectOwner() stats_perms = HasProjectPerm('view_project') member_stats_perms = HasProjectPerm('view_project') issues_stats_perms = HasProjectPerm('view_project') diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py index 0259a6b8..732c7761 100644 --- a/taiga/projects/serializers.py +++ b/taiga/projects/serializers.py @@ -20,7 +20,6 @@ from django.utils.translation import ugettext as _ from django.db.models import Q from taiga.base.api import serializers - from taiga.base.fields import JsonField from taiga.base.fields import PgArrayField from taiga.base.fields import TagsField @@ -36,8 +35,6 @@ from taiga.users.validators import RoleExistsValidator from taiga.permissions.service import get_user_project_permissions from taiga.permissions.service import is_project_owner -from taiga.projects.notifications import models as notify_models - from . import models from . import services from .notifications.mixins import WatchedResourceModelSerializer @@ -47,6 +44,7 @@ from .custom_attributes.serializers import TaskCustomAttributeSerializer from .custom_attributes.serializers import IssueCustomAttributeSerializer from .likes.mixins.serializers import FanResourceSerializerMixin + ###################################################### ## Custom values for selectors ###################################################### @@ -75,7 +73,6 @@ class PointsSerializer(serializers.ModelSerializer): class UserStoryStatusSerializer(serializers.ModelSerializer): - class Meta: model = models.UserStoryStatus i18n_fields = ("name",) @@ -101,7 +98,6 @@ class UserStoryStatusSerializer(serializers.ModelSerializer): class BasicUserStoryStatusSerializer(serializers.ModelSerializer): - class Meta: model = models.UserStoryStatus i18n_fields = ("name",) @@ -175,7 +171,6 @@ class IssueStatusSerializer(serializers.ModelSerializer): class BasicIssueStatusSerializer(serializers.ModelSerializer): - class Meta: model = models.IssueStatus i18n_fields = ("name",) @@ -300,7 +295,8 @@ class ProjectMemberSerializer(serializers.ModelSerializer): class Meta: model = models.Membership - exclude = ("project", "email", "created_at", "token", "invited_by", "invitation_extra_text", "user_order") + exclude = ("project", "email", "created_at", "token", "invited_by", "invitation_extra_text", + "user_order") def get_photo(self, membership): return get_photo_or_gravatar_url(membership.user) @@ -310,21 +306,27 @@ class ProjectMemberSerializer(serializers.ModelSerializer): ## Projects ###################################################### -class ProjectSerializer(FanResourceSerializerMixin, WatchedResourceModelSerializer, serializers.ModelSerializer): - tags = TagsField(default=[], required=False) +class ProjectSerializer(FanResourceSerializerMixin, WatchedResourceModelSerializer, + serializers.ModelSerializer): anon_permissions = PgArrayField(required=False) public_permissions = PgArrayField(required=False) my_permissions = serializers.SerializerMethodField("get_my_permissions") i_am_owner = serializers.SerializerMethodField("get_i_am_owner") + + tags = TagsField(default=[], required=False) tags_colors = TagsColorsField(required=False) + + notify_level = serializers.SerializerMethodField("get_notify_level") total_closed_milestones = serializers.SerializerMethodField("get_total_closed_milestones") - notify_level = serializers.SerializerMethodField("get_notify_level") total_watchers = serializers.SerializerMethodField("get_total_watchers") + logo_small_url = serializers.SerializerMethodField("get_logo_small_url") + logo_big_url = serializers.SerializerMethodField("get_logo_big_url") + class Meta: model = models.Project read_only_fields = ("created_date", "modified_date", "owner", "slug") - exclude = ("last_us_ref", "last_task_ref", "last_issue_ref", + exclude = ("logo", "last_us_ref", "last_task_ref", "last_issue_ref", "issues_csv_uuid", "tasks_csv_uuid", "userstories_csv_uuid") def get_my_permissions(self, obj): @@ -360,6 +362,12 @@ class ProjectSerializer(FanResourceSerializerMixin, WatchedResourceModelSerializ return obj.notify_policies.exclude(notify_level=NotifyLevel.none).count() + def get_logo_small_url(self, obj): + return services.get_logo_small_thumbnail_url(obj) + + def get_logo_big_url(self, obj): + return services.get_logo_big_thumbnail_url(obj) + class ProjectDetailSerializer(ProjectSerializer): us_statuses = UserStoryStatusSerializer(many=True, required=False) # User Stories @@ -395,7 +403,7 @@ class ProjectDetailAdminSerializer(ProjectDetailSerializer): class Meta: model = models.Project read_only_fields = ("created_date", "modified_date", "owner", "slug") - exclude = ("last_us_ref", "last_task_ref", "last_issue_ref") + exclude = ("logo", "last_us_ref", "last_task_ref", "last_issue_ref") ###################################################### diff --git a/taiga/projects/services/__init__.py b/taiga/projects/services/__init__.py index 4b0c2e98..d4940c37 100644 --- a/taiga/projects/services/__init__.py +++ b/taiga/projects/services/__init__.py @@ -29,17 +29,20 @@ from .bulk_update_order import bulk_update_userstory_status_order from .filters import get_all_tags -from .stats import get_stats_for_project_issues -from .stats import get_stats_for_project -from .stats import get_member_stats_for_project +from .invitations import send_invitation +from .invitations import find_invited_user + +from .logo import get_logo_small_thumbnail_url +from .logo import get_logo_big_thumbnail_url from .members import create_members_in_bulk from .members import get_members_from_bulk from .members import remove_user_from_project, project_has_valid_owners, can_user_leave_project -from .invitations import send_invitation -from .invitations import find_invited_user +from .modules_config import get_modules_config + +from .stats import get_stats_for_project_issues +from .stats import get_stats_for_project +from .stats import get_member_stats_for_project from .tags_colors import update_project_tags_colors_handler - -from .modules_config import get_modules_config diff --git a/taiga/projects/services/logo.py b/taiga/projects/services/logo.py new file mode 100644 index 00000000..c1f2bf7e --- /dev/null +++ b/taiga/projects/services/logo.py @@ -0,0 +1,29 @@ +# Copyright (C) 2014-2015 Taiga Agile LLC +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.conf import settings + +from taiga.base.utils.thumbnails import get_thumbnail_url + + +def get_logo_small_thumbnail_url(project): + if project.logo: + return get_thumbnail_url(project.logo, settings.THN_LOGO_SMALL) + return None + + +def get_logo_big_thumbnail_url(project): + if project.logo: + return get_thumbnail_url(project.logo, settings.THN_LOGO_BIG) + return None diff --git a/taiga/projects/signals.py b/taiga/projects/signals.py index 0d330670..afae4b5a 100644 --- a/taiga/projects/signals.py +++ b/taiga/projects/signals.py @@ -22,6 +22,8 @@ from taiga.projects.services.tags_colors import update_project_tags_colors_handl from taiga.projects.notifications.services import create_notify_policy_if_not_exists from taiga.base.utils.db import get_typename_for_model_class +from easy_thumbnails.files import get_thumbnailer + #################################### # Signals over project items @@ -46,11 +48,15 @@ def membership_post_delete(sender, instance, using, **kwargs): instance.project.update_role_points() +## Notify policy + def create_notify_policy(sender, instance, using, **kwargs): if instance.user: create_notify_policy_if_not_exists(instance.project, instance.user) +## Project attributes + def project_post_save(sender, instance, created, **kwargs): """ Populate new project dependen default data @@ -82,6 +88,8 @@ def project_post_save(sender, instance, created, **kwargs): is_owner=True, email=instance.owner.email) +## US statuses + def try_to_close_or_open_user_stories_when_edit_us_status(sender, instance, created, **kwargs): from taiga.projects.userstories import services @@ -92,6 +100,8 @@ def try_to_close_or_open_user_stories_when_edit_us_status(sender, instance, crea services.open_userstory(user_story) +## Task statuses + def try_to_close_or_open_user_stories_when_edit_task_status(sender, instance, created, **kwargs): from taiga.projects.userstories import services diff --git a/taiga/users/api.py b/taiga/users/api.py index eae4a7f2..f5d59bd4 100644 --- a/taiga/users/api.py +++ b/taiga/users/api.py @@ -223,7 +223,6 @@ class UsersViewSet(ModelCrudViewSet): except Exception: raise exc.WrongArguments(_("Invalid image format")) - request.user.delete_photo() request.user.photo = avatar request.user.save(update_fields=["photo"]) user_data = self.admin_serializer_class(request.user).data @@ -236,7 +235,7 @@ class UsersViewSet(ModelCrudViewSet): Remove the avatar of current logged user. """ self.check_permissions(request, "remove_avatar", None) - request.user.delete_photo() + request.user.photo = None request.user.save(update_fields=["photo"]) user_data = self.admin_serializer_class(request.user).data return response.Ok(user_data) diff --git a/taiga/users/models.py b/taiga/users/models.py index 61552de8..52126691 100644 --- a/taiga/users/models.py +++ b/taiga/users/models.py @@ -241,22 +241,10 @@ class User(AbstractBaseUser, PermissionsMixin): self.colorize_tags = True self.token = None self.set_unusable_password() - self.delete_photo() + self.photo = None self.save() self.auth_data.all().delete() - def delete_photo(self): - # Removing thumbnails - thumbnailer = get_thumbnailer(self.photo) - thumbnailer.delete_thumbnails() - - # Removing original photo - if self.photo: - storage, path = self.photo.storage, self.photo.path - storage.delete(path) - - self.photo = None - class Role(models.Model): name = models.CharField(max_length=200, null=False, blank=False, diff --git a/taiga/users/services.py b/taiga/users/services.py index a592698c..86a56869 100644 --- a/taiga/users/services.py +++ b/taiga/users/services.py @@ -34,9 +34,9 @@ from taiga.base.utils.db import to_tsquery from taiga.base.utils.urls import get_absolute_url from taiga.projects.notifications.choices import NotifyLevel from taiga.projects.notifications.services import get_projects_watched + from .gravatar import get_gravatar_url -from django.conf import settings def get_user_by_username_or_email(username_or_email): @@ -55,7 +55,6 @@ def get_user_by_username_or_email(username_or_email): return user - def get_and_validate_user(*, username:str, password:str) -> bool: """ Check if user with username/email exists and specified @@ -75,7 +74,7 @@ def get_and_validate_user(*, username:str, password:str) -> bool: def get_photo_url(photo): """Get a photo absolute url and the photo automatically cropped.""" try: - url = get_thumbnailer(photo)['avatar'].url + url = get_thumbnailer(photo)[settings.THN_AVATAR_SMALL].url return get_absolute_url(url) except InvalidImageFormatError as e: return None @@ -91,7 +90,7 @@ def get_photo_or_gravatar_url(user): def get_big_photo_url(photo): """Get a big photo absolute url and the photo automatically cropped.""" try: - url = get_thumbnailer(photo)['big-avatar'].url + url = get_thumbnailer(photo)[settings.THN_AVATAR_BIG].url return get_absolute_url(url) except InvalidImageFormatError as e: return None @@ -105,13 +104,14 @@ def get_big_photo_or_gravatar_url(user): if user.photo: return get_big_photo_url(user.photo) else: - return get_gravatar_url(user.email, size=settings.DEFAULT_BIG_AVATAR_SIZE) + return get_gravatar_url(user.email, size=settings.THN_AVATAR_BIG_SIZE) def get_visible_project_ids(from_user, by_user): """Calculate the project_ids from one user visible by another""" required_permissions = ["view_project"] - #Or condition for membership filtering, the basic one is the access to projects allowing anonymous visualization + # Or condition for membership filtering, the basic one is the access to projects + # allowing anonymous visualization member_perm_conditions = Q(project__anon_permissions__contains=required_permissions) # Authenticated @@ -138,7 +138,8 @@ def get_stats_for_user(from_user, by_user): total_num_projects = len(project_ids) - roles = [_(r) for r in from_user.memberships.filter(project__id__in=project_ids).values_list("role__name", flat=True)] + roles = [_(r) for r in from_user.memberships.filter(project__id__in=project_ids).values_list( + "role__name", flat=True)] roles = list(set(roles)) User = apps.get_model('users', 'User') diff --git a/tests/integration/test_importer_api.py b/tests/integration/test_importer_api.py index 171dda31..360550fb 100644 --- a/tests/integration/test_importer_api.py +++ b/tests/integration/test_importer_api.py @@ -18,13 +18,10 @@ import pytest import base64 +from django.apps import apps from django.core.urlresolvers import reverse from django.core.files.base import ContentFile -from .. import factories as f - -from django.apps import apps - from taiga.base.utils import json from taiga.projects.models import Project, Membership from taiga.projects.issues.models import Issue @@ -32,6 +29,9 @@ from taiga.projects.userstories.models import UserStory from taiga.projects.tasks.models import Task from taiga.projects.wiki.models import WikiPage +from .. import factories as f +from ..utils import DUMMY_BMP_DATA + pytestmark = pytest.mark.django_db @@ -945,6 +945,36 @@ def test_invalid_dump_import(client): assert response_data["_error_message"] == "Invalid dump format" +def test_valid_dump_import_with_logo(client, settings): + settings.CELERY_ENABLED = False + + user = f.UserFactory.create() + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "valid-project", + "name": "Valid project", + "description": "Valid project desc", + "logo": { + "name": "logo.bmp", + "data": base64.b64encode(DUMMY_BMP_DATA).decode("utf-8") + } + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 201 + response_data = response.data + assert "id" in response_data + assert response_data["name"] == "Valid project" + assert "logo_small_url" in response_data + assert response_data["logo_small_url"] != None + assert "logo_big_url" in response_data + assert response_data["logo_big_url"] != None + + def test_valid_dump_import_with_celery_disabled(client, settings): settings.CELERY_ENABLED = False diff --git a/tests/integration/test_projects.py b/tests/integration/test_projects.py index 2a4f8e5c..99757620 100644 --- a/tests/integration/test_projects.py +++ b/tests/integration/test_projects.py @@ -1,4 +1,7 @@ from django.core.urlresolvers import reverse +from django.conf import settings +from django.core.files import File + from taiga.base.utils import json from taiga.projects.services import stats as stats_services from taiga.projects.history.services import take_snapshot @@ -6,8 +9,14 @@ from taiga.permissions.permissions import ANON_PERMISSIONS from taiga.projects.models import Project from .. import factories as f +from ..utils import DUMMY_BMP_DATA +from tempfile import NamedTemporaryFile +from easy_thumbnails.files import generate_all_aliases, get_thumbnailer + +import os.path import pytest + pytestmark = pytest.mark.django_db @@ -406,3 +415,80 @@ def test_projects_user_order(client): response_content = response.data assert response.status_code == 200 assert(response_content[0]["id"] == project_2.id) + + +@pytest.mark.django_db(transaction=True) +def test_update_project_logo(client): + user = f.UserFactory.create(is_superuser=True) + project = f.create_project() + url = reverse("projects-change-logo", args=(project.id,)) + + with NamedTemporaryFile(delete=False) as logo: + logo.write(DUMMY_BMP_DATA) + logo.seek(0) + project.logo = File(logo) + project.save() + generate_all_aliases(project.logo, include_global=True) + + thumbnailer = get_thumbnailer(project.logo) + original_photo_paths = [project.logo.path] + original_photo_paths += [th.path for th in thumbnailer.get_thumbnails()] + + assert all(list(map(os.path.exists, original_photo_paths))) + + with NamedTemporaryFile(delete=False) as logo: + logo.write(DUMMY_BMP_DATA) + logo.seek(0) + + client.login(user) + post_data = {'logo': logo} + response = client.post(url, post_data) + assert response.status_code == 200 + assert not any(list(map(os.path.exists, original_photo_paths))) + + +@pytest.mark.django_db(transaction=True) +def test_remove_project_logo(client): + user = f.UserFactory.create(is_superuser=True) + project = f.create_project() + url = reverse("projects-remove-logo", args=(project.id,)) + + with NamedTemporaryFile(delete=False) as logo: + logo.write(DUMMY_BMP_DATA) + logo.seek(0) + project.logo = File(logo) + project.save() + generate_all_aliases(project.logo, include_global=True) + + thumbnailer = get_thumbnailer(project.logo) + original_photo_paths = [project.logo.path] + original_photo_paths += [th.path for th in thumbnailer.get_thumbnails()] + + assert all(list(map(os.path.exists, original_photo_paths))) + client.login(user) + response = client.post(url) + assert response.status_code == 200 + assert not any(list(map(os.path.exists, original_photo_paths))) + +@pytest.mark.django_db(transaction=True) +def test_remove_project_with_logo(client): + user = f.UserFactory.create(is_superuser=True) + project = f.create_project() + url = reverse("projects-detail", args=(project.id,)) + + with NamedTemporaryFile(delete=False) as logo: + logo.write(DUMMY_BMP_DATA) + logo.seek(0) + project.logo = File(logo) + project.save() + generate_all_aliases(project.logo, include_global=True) + + thumbnailer = get_thumbnailer(project.logo) + original_photo_paths = [project.logo.path] + original_photo_paths += [th.path for th in thumbnailer.get_thumbnails()] + + assert all(list(map(os.path.exists, original_photo_paths))) + client.login(user) + response = client.delete(url) + assert response.status_code == 204 + assert not any(list(map(os.path.exists, original_photo_paths))) diff --git a/tests/integration/test_users.py b/tests/integration/test_users.py index e1e91d92..9fb87321 100644 --- a/tests/integration/test_users.py +++ b/tests/integration/test_users.py @@ -6,6 +6,7 @@ from django.core.urlresolvers import reverse from django.core.files import File from .. import factories as f +from ..utils import DUMMY_BMP_DATA from taiga.base.utils import json from taiga.users import models @@ -172,35 +173,7 @@ def test_cancel_self_user_with_invalid_token(client): assert response.status_code == 400 -DUMMY_BMP_DATA = b'BM:\x00\x00\x00\x00\x00\x00\x006\x00\x00\x00(\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x18\x00\x00\x00\x00\x00\x04\x00\x00\x00\x13\x0b\x00\x00\x13\x0b\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' - - -def test_change_avatar(client): - url = reverse('users-change-avatar') - - user = f.UserFactory() - client.login(user) - - with NamedTemporaryFile() as avatar: - # Test no avatar send - post_data = {} - response = client.post(url, post_data) - assert response.status_code == 400 - - # Test invalid file send - post_data = { - 'avatar': avatar - } - response = client.post(url, post_data) - assert response.status_code == 400 - - # Test empty valid avatar send - avatar.write(DUMMY_BMP_DATA) - avatar.seek(0) - response = client.post(url, post_data) - assert response.status_code == 200 - - +@pytest.mark.django_db(transaction=True) def test_change_avatar_removes_the_old_one(client): url = reverse('users-change-avatar') user = f.UserFactory() @@ -216,7 +189,7 @@ def test_change_avatar_removes_the_old_one(client): thumbnailer = get_thumbnailer(user.photo) original_photo_paths = [user.photo.path] original_photo_paths += [th.path for th in thumbnailer.get_thumbnails()] - assert list(map(os.path.exists, original_photo_paths)) == [True, True, True, True, True] + assert all(list(map(os.path.exists, original_photo_paths))) client.login(user) avatar.write(DUMMY_BMP_DATA) @@ -225,9 +198,10 @@ def test_change_avatar_removes_the_old_one(client): response = client.post(url, post_data) assert response.status_code == 200 - assert list(map(os.path.exists, original_photo_paths)) == [False, False, False, False, False] + assert not any(list(map(os.path.exists, original_photo_paths))) +@pytest.mark.django_db(transaction=True) def test_remove_avatar(client): url = reverse('users-remove-avatar') user = f.UserFactory() @@ -242,13 +216,13 @@ def test_remove_avatar(client): thumbnailer = get_thumbnailer(user.photo) original_photo_paths = [user.photo.path] original_photo_paths += [th.path for th in thumbnailer.get_thumbnails()] - assert list(map(os.path.exists, original_photo_paths)) == [True, True, True, True, True] + assert all(list(map(os.path.exists, original_photo_paths))) client.login(user) response = client.post(url) assert response.status_code == 200 - assert list(map(os.path.exists, original_photo_paths)) == [False, False, False, False, False] + assert not any(list(map(os.path.exists, original_photo_paths))) def test_list_contacts_private_projects(client): diff --git a/tests/utils.py b/tests/utils.py index f8a2aab1..f16e49fe 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -18,6 +18,8 @@ from django.db.models import signals +DUMMY_BMP_DATA = b'BM:\x00\x00\x00\x00\x00\x00\x006\x00\x00\x00(\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x18\x00\x00\x00\x00\x00\x04\x00\x00\x00\x13\x0b\x00\x00\x13\x0b\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + def signals_switch(): pre_save = signals.pre_save.receivers