Task #3515: Add logo field to project model

remotes/origin/logger
David Barragán Merino 2015-12-01 19:04:15 +01:00
parent 50e00b6d45
commit 0b7fe02a6f
27 changed files with 496 additions and 200 deletions

View File

@ -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",

View File

@ -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()

View File

View File

@ -0,0 +1,31 @@
# Copyright (C) 2014-2015 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014-2015 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2015 David Barragán <bameda@dbarragan.com>
# 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 <http://www.gnu.org/licenses/>.
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)

View File

@ -0,0 +1,30 @@
# Copyright (C) 2014-2015 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014-2015 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2015 David Barragán <bameda@dbarragan.com>
# 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 <http://www.gnu.org/licenses/>.
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

View File

@ -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:

View File

@ -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)

View File

@ -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:
obj.template = self.request.QUERY_PARAMS.get('template', None)
self._set_base_permissions(obj)

View File

@ -22,6 +22,34 @@ from django.db.models import signals
from . import signals as handlers
## 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')
# 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")
## Memberships Signals
def connect_memberships_signals():
# On membership object is deleted, update role-points relation.
signals.pre_delete.connect(handlers.membership_post_delete,
@ -33,21 +61,14 @@ def connect_memberships_signals():
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')
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')
# 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")
## US Statuses Signals
def connect_us_status_signals():
signals.post_save.connect(handlers.try_to_close_or_open_user_stories_when_edit_us_status,
@ -55,27 +76,24 @@ def connect_us_status_signals():
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")
## 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_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')
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")
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")
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()

View File

@ -15,25 +15,10 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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()

View File

@ -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)

View File

@ -12,25 +12,18 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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

View File

@ -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),

View File

@ -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),
),
]

View File

@ -15,9 +15,13 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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)

View File

@ -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')

View File

@ -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)
total_closed_milestones = serializers.SerializerMethodField("get_total_closed_milestones")
notify_level = serializers.SerializerMethodField("get_notify_level")
total_closed_milestones = serializers.SerializerMethodField("get_total_closed_milestones")
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")
######################################################

View File

@ -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

View File

@ -0,0 +1,29 @@
# Copyright (C) 2014-2015 Taiga Agile LLC <taiga@taiga.io>
# 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 <http://www.gnu.org/licenses/>.
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

View File

@ -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

View File

@ -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)

View File

@ -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,

View File

@ -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')

View File

@ -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

View File

@ -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)))

View File

@ -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):

View File

@ -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