Merge pull request #69 from taigaio/storage-refactor
Attachments and user photo storage refactor.remotes/origin/enhancement/email-actions
commit
e692cd2cb3
|
@ -58,8 +58,6 @@ SEND_BROKEN_LINK_EMAILS = True
|
||||||
IGNORABLE_404_ENDS = (".php", ".cgi")
|
IGNORABLE_404_ENDS = (".php", ".cgi")
|
||||||
IGNORABLE_404_STARTS = ("/phpmyadmin/",)
|
IGNORABLE_404_STARTS = ("/phpmyadmin/",)
|
||||||
|
|
||||||
|
|
||||||
# Default django tz/i18n config
|
|
||||||
ATOMIC_REQUESTS = True
|
ATOMIC_REQUESTS = True
|
||||||
TIME_ZONE = "UTC"
|
TIME_ZONE = "UTC"
|
||||||
LANGUAGE_CODE = "en"
|
LANGUAGE_CODE = "en"
|
||||||
|
@ -94,13 +92,21 @@ EVENTS_PUSH_BACKEND = "taiga.events.backends.postgresql.EventsPushBackend"
|
||||||
# Message System
|
# Message System
|
||||||
MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage"
|
MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage"
|
||||||
|
|
||||||
# Static configuration.
|
# The absolute url is mandatory because attachments
|
||||||
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
|
# urls depends on it. On production should be set
|
||||||
MEDIA_URL = "/media/"
|
# something like https://media.taiga.io/
|
||||||
STATIC_ROOT = os.path.join(BASE_DIR, "static")
|
MEDIA_URL = "http://localhost:8000/media/"
|
||||||
|
|
||||||
|
# Static url is not widelly used by taiga (only
|
||||||
|
# if admin is activated).
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
ADMIN_MEDIA_PREFIX = "/static/admin/"
|
ADMIN_MEDIA_PREFIX = "/static/admin/"
|
||||||
|
|
||||||
|
# Static configuration.
|
||||||
|
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
|
||||||
|
STATIC_ROOT = os.path.join(BASE_DIR, "static")
|
||||||
|
|
||||||
|
|
||||||
STATICFILES_FINDERS = [
|
STATICFILES_FINDERS = [
|
||||||
"django.contrib.staticfiles.finders.FileSystemFinder",
|
"django.contrib.staticfiles.finders.FileSystemFinder",
|
||||||
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
||||||
|
|
|
@ -16,6 +16,8 @@
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from functools import wraps, partial
|
from functools import wraps, partial
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
|
||||||
|
|
||||||
def as_tuple(function=None, *, remove_nulls=False):
|
def as_tuple(function=None, *, remove_nulls=False):
|
||||||
if function is None:
|
if function is None:
|
||||||
|
@ -33,3 +35,24 @@ def as_dict(function):
|
||||||
def _decorator(*args, **kwargs):
|
def _decorator(*args, **kwargs):
|
||||||
return dict(function(*args, **kwargs))
|
return dict(function(*args, **kwargs))
|
||||||
return _decorator
|
return _decorator
|
||||||
|
|
||||||
|
|
||||||
|
def split_by_n(seq:str, n:int):
|
||||||
|
"""
|
||||||
|
A generator to divide a sequence into chunks of n units.
|
||||||
|
"""
|
||||||
|
while seq:
|
||||||
|
yield seq[:n]
|
||||||
|
seq = seq[n:]
|
||||||
|
|
||||||
|
|
||||||
|
def iter_queryset(queryset, itersize:int=20):
|
||||||
|
"""
|
||||||
|
Util function for iterate in more efficient way
|
||||||
|
all queryset.
|
||||||
|
"""
|
||||||
|
paginator = Paginator(queryset, itersize)
|
||||||
|
for page_num in paginator.page_range:
|
||||||
|
page = paginator.page(page_num)
|
||||||
|
for element in page.object_list:
|
||||||
|
yield element
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import os.path as path
|
||||||
import hashlib
|
import hashlib
|
||||||
import mimetypes
|
import mimetypes
|
||||||
mimetypes.init()
|
mimetypes.init()
|
||||||
|
@ -59,6 +60,8 @@ class BaseAttachmentViewSet(HistoryResourceMixin, WatchedResourceMixin, ModelCru
|
||||||
if not obj.id:
|
if not obj.id:
|
||||||
obj.content_type = self.get_content_type()
|
obj.content_type = self.get_content_type()
|
||||||
obj.owner = self.request.user
|
obj.owner = self.request.user
|
||||||
|
obj.size = obj.attached_file.size
|
||||||
|
obj.name = path.basename(obj.attached_file.name).lower()
|
||||||
|
|
||||||
if obj.project_id != obj.content_object.project_id:
|
if obj.project_id != obj.content_object.project_id:
|
||||||
raise exc.WrongArguments("Project ID not matches between object and project")
|
raise exc.WrongArguments("Project ID not matches between object and project")
|
||||||
|
@ -97,39 +100,3 @@ class WikiAttachmentViewSet(BaseAttachmentViewSet):
|
||||||
permission_classes = (permissions.WikiAttachmentPermission,)
|
permission_classes = (permissions.WikiAttachmentPermission,)
|
||||||
filter_backends = (filters.CanViewWikiAttachmentFilterBackend,)
|
filter_backends = (filters.CanViewWikiAttachmentFilterBackend,)
|
||||||
content_type = "wiki.wikipage"
|
content_type = "wiki.wikipage"
|
||||||
|
|
||||||
|
|
||||||
class RawAttachmentView(generics.RetrieveAPIView):
|
|
||||||
queryset = models.Attachment.objects.all()
|
|
||||||
permission_classes = (permissions.RawAttachmentPermission,)
|
|
||||||
|
|
||||||
def _serve_attachment(self, attachment):
|
|
||||||
if settings.IN_DEVELOPMENT_SERVER:
|
|
||||||
return http.HttpResponseRedirect(attachment.url)
|
|
||||||
|
|
||||||
name = attachment.name
|
|
||||||
response = http.HttpResponse()
|
|
||||||
response['X-Accel-Redirect'] = "/{filepath}".format(filepath=name)
|
|
||||||
response['Content-Disposition'] = 'inline;filename={filename}'.format(
|
|
||||||
filename=os.path.basename(name))
|
|
||||||
response['Content-Type'] = mimetypes.guess_type(name)[0]
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
def check_permissions(self, request, action='retrieve', obj=None):
|
|
||||||
self.object = self.get_object()
|
|
||||||
user_id = self.request.QUERY_PARAMS.get('user', None)
|
|
||||||
token = self.request.QUERY_PARAMS.get('token', None)
|
|
||||||
|
|
||||||
if token and user_id:
|
|
||||||
token_src = "{}-{}-{}".format(settings.ATTACHMENTS_TOKEN_SALT, user_id, self.object.id)
|
|
||||||
if token == hashlib.sha1(token_src.encode("utf-8")).hexdigest():
|
|
||||||
request.user = get_object_or_404(User, pk=user_id)
|
|
||||||
|
|
||||||
return super().check_permissions(request, action, self.object)
|
|
||||||
|
|
||||||
def retrieve(self, request, *args, **kwargs):
|
|
||||||
self.object = self.get_object()
|
|
||||||
|
|
||||||
self.check_permissions(request, 'retrieve', self.object)
|
|
||||||
return self._serve_attachment(self.object.attached_file)
|
|
||||||
|
|
|
@ -0,0 +1,130 @@
|
||||||
|
import re
|
||||||
|
|
||||||
|
from django.apps import apps
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.core.files import File
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from taiga.base.utils.iterators import iter_queryset
|
||||||
|
|
||||||
|
url = """
|
||||||
|
https://api-taiga.kaleidos.net/attachments/446/?user=8&token=9ac0fc593e9c07740975c6282e1e501189578faa
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Parses all objects and try replace old attachments url with one new"
|
||||||
|
|
||||||
|
|
||||||
|
trx = r"((?:https?)://api-taiga\.kaleidos\.net/attachments/(\d+)/[^\s]+)"
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
settings.MEDIA_URL="https://media.taiga.io/"
|
||||||
|
|
||||||
|
self.move_user_photo()
|
||||||
|
self.move_attachments()
|
||||||
|
self.process_userstories()
|
||||||
|
self.process_issues()
|
||||||
|
self.process_wiki()
|
||||||
|
self.process_tasks()
|
||||||
|
self.process_history()
|
||||||
|
|
||||||
|
def move_attachments(self):
|
||||||
|
print("Moving all attachments to new location")
|
||||||
|
|
||||||
|
Attachment = apps.get_model("attachments", "Attachment")
|
||||||
|
qs = Attachment.objects.all()
|
||||||
|
|
||||||
|
for item in iter_queryset(qs):
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
old_file = item.attached_file
|
||||||
|
item.attached_file = File(old_file)
|
||||||
|
item.save()
|
||||||
|
except FileNotFoundError:
|
||||||
|
item.delete()
|
||||||
|
|
||||||
|
def move_user_photo(self):
|
||||||
|
print("Moving all user photos to new location")
|
||||||
|
|
||||||
|
User = apps.get_model("users", "User")
|
||||||
|
qs = User.objects.all()
|
||||||
|
|
||||||
|
for item in iter_queryset(qs):
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
old_file = item.photo
|
||||||
|
item.photo = File(old_file)
|
||||||
|
item.save()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_attachment_real_url(self, pk):
|
||||||
|
if isinstance(pk, str):
|
||||||
|
pk = int(pk)
|
||||||
|
|
||||||
|
Attachment = apps.get_model("attachments", "Attachment")
|
||||||
|
return Attachment.objects.get(pk=pk).attached_file.url
|
||||||
|
|
||||||
|
def replace_matches(self, data):
|
||||||
|
matches = re.findall(self.trx, data)
|
||||||
|
|
||||||
|
original_data = data
|
||||||
|
|
||||||
|
if len(matches) == 0:
|
||||||
|
return data
|
||||||
|
|
||||||
|
for url, attachment_id in matches:
|
||||||
|
new_url = self.get_attachment_real_url(attachment_id)
|
||||||
|
print("Match {} replaced by {}".format(url, new_url))
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = data.replace(url, self.get_attachment_real_url(attachment_id))
|
||||||
|
except Exception as e:
|
||||||
|
print("Exception found but ignoring:", e)
|
||||||
|
|
||||||
|
assert data != original_data
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def process_userstories(self):
|
||||||
|
UserStory = apps.get_model("userstories", "UserStory")
|
||||||
|
qs = UserStory.objects.all()
|
||||||
|
|
||||||
|
for item in iter_queryset(qs):
|
||||||
|
description = self.replace_matches(item.description)
|
||||||
|
UserStory.objects.filter(pk=item.pk).update(description=description)
|
||||||
|
|
||||||
|
def process_tasks(self):
|
||||||
|
Task = apps.get_model("tasks", "Task")
|
||||||
|
qs = Task.objects.all()
|
||||||
|
|
||||||
|
for item in iter_queryset(qs):
|
||||||
|
description = self.replace_matches(item.description)
|
||||||
|
Task.objects.filter(pk=item.pk).update(description=description)
|
||||||
|
|
||||||
|
def process_issues(self):
|
||||||
|
Issue = apps.get_model("issues", "Issue")
|
||||||
|
qs = Issue.objects.all()
|
||||||
|
|
||||||
|
for item in iter_queryset(qs):
|
||||||
|
description = self.replace_matches(item.description)
|
||||||
|
Issue.objects.filter(pk=item.pk).update(description=description)
|
||||||
|
|
||||||
|
def process_wiki(self):
|
||||||
|
WikiPage = apps.get_model("wiki", "WikiPage")
|
||||||
|
qs = WikiPage.objects.all()
|
||||||
|
|
||||||
|
for item in iter_queryset(qs):
|
||||||
|
content = self.replace_matches(item.content)
|
||||||
|
WikiPage.objects.filter(pk=item.pk).update(content=content)
|
||||||
|
|
||||||
|
def process_history(self):
|
||||||
|
HistoryEntry = apps.get_model("history", "HistoryEntry")
|
||||||
|
qs = HistoryEntry.objects.all()
|
||||||
|
|
||||||
|
for item in iter_queryset(qs):
|
||||||
|
comment = self.replace_matches(item.comment)
|
||||||
|
comment_html = self.replace_matches(item.comment_html)
|
||||||
|
HistoryEntry.objects.filter(pk=item.pk).update(comment=comment, comment_html=comment_html)
|
|
@ -0,0 +1,41 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import os.path as path
|
||||||
|
from django.db import models, migrations
|
||||||
|
|
||||||
|
|
||||||
|
def parse_filenames_and_sizes(apps, schema_editor):
|
||||||
|
Attachment = apps.get_model("attachments", "Attachment")
|
||||||
|
|
||||||
|
for item in Attachment.objects.all():
|
||||||
|
try:
|
||||||
|
item.size = item.attached_file.size
|
||||||
|
except Exception as e:
|
||||||
|
item.size = 0
|
||||||
|
|
||||||
|
item.name = path.basename(item.attached_file.name)
|
||||||
|
item.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('attachments', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='attachment',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(default='', blank=True, max_length=500),
|
||||||
|
preserve_default=True,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='attachment',
|
||||||
|
name='size',
|
||||||
|
field=models.IntegerField(editable=False, null=True, blank=True, default=None),
|
||||||
|
preserve_default=True,
|
||||||
|
),
|
||||||
|
migrations.RunPython(parse_filenames_and_sizes)
|
||||||
|
]
|
|
@ -14,25 +14,32 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# 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/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import time
|
import hashlib
|
||||||
|
import os
|
||||||
|
import os.path as path
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.contrib.contenttypes import generic
|
from django.contrib.contenttypes import generic
|
||||||
from django.utils.translation import ugettext_lazy as _
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.encoding import force_bytes
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from taiga.base.utils.iterators import split_by_n
|
||||||
|
|
||||||
|
|
||||||
def get_attachment_file_path(instance, filename):
|
def get_attachment_file_path(instance, filename):
|
||||||
template = "attachment-files/{project}/{model}/{stamp}/{filename}"
|
basename = path.basename(filename).lower()
|
||||||
current_timestamp = int(time.mktime(timezone.now().timetuple()))
|
|
||||||
|
|
||||||
upload_to_path = template.format(stamp=current_timestamp,
|
hs = hashlib.sha256()
|
||||||
project=instance.project.slug,
|
hs.update(force_bytes(timezone.now().isoformat()))
|
||||||
model=instance.content_type.model,
|
hs.update(os.urandom(1024))
|
||||||
filename=filename)
|
|
||||||
return upload_to_path
|
p1, p2, p3, p4, *p5 = split_by_n(hs.hexdigest(), 1)
|
||||||
|
hash_part = path.join(p1, p2, p3, p4, "".join(p5))
|
||||||
|
|
||||||
|
return path.join("attachments", hash_part, basename)
|
||||||
|
|
||||||
|
|
||||||
class Attachment(models.Model):
|
class Attachment(models.Model):
|
||||||
|
@ -51,13 +58,17 @@ class Attachment(models.Model):
|
||||||
default=timezone.now)
|
default=timezone.now)
|
||||||
modified_date = models.DateTimeField(null=False, blank=False,
|
modified_date = models.DateTimeField(null=False, blank=False,
|
||||||
verbose_name=_("modified date"))
|
verbose_name=_("modified date"))
|
||||||
|
name = models.CharField(blank=True, default="", max_length=500)
|
||||||
|
size = models.IntegerField(null=True, blank=True, editable=False, default=None)
|
||||||
attached_file = models.FileField(max_length=500, null=True, blank=True,
|
attached_file = models.FileField(max_length=500, null=True, blank=True,
|
||||||
upload_to=get_attachment_file_path,
|
upload_to=get_attachment_file_path,
|
||||||
verbose_name=_("attached file"))
|
verbose_name=_("attached file"))
|
||||||
|
|
||||||
|
|
||||||
is_deprecated = models.BooleanField(default=False, verbose_name=_("is deprecated"))
|
is_deprecated = models.BooleanField(default=False, verbose_name=_("is deprecated"))
|
||||||
description = models.TextField(null=False, blank=True, verbose_name=_("description"))
|
description = models.TextField(null=False, blank=True, verbose_name=_("description"))
|
||||||
order = models.IntegerField(default=0, null=False, blank=False, verbose_name=_("order"))
|
order = models.IntegerField(default=0, null=False, blank=False, verbose_name=_("order"))
|
||||||
|
|
||||||
_importing = None
|
_importing = None
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
@ -28,10 +28,7 @@ from . import models
|
||||||
|
|
||||||
|
|
||||||
class AttachmentSerializer(serializers.ModelSerializer):
|
class AttachmentSerializer(serializers.ModelSerializer):
|
||||||
name = serializers.SerializerMethodField("get_name")
|
|
||||||
url = serializers.SerializerMethodField("get_url")
|
url = serializers.SerializerMethodField("get_url")
|
||||||
size = serializers.SerializerMethodField("get_size")
|
|
||||||
|
|
||||||
attached_file = serializers.FileField(required=True)
|
attached_file = serializers.FileField(required=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -41,28 +38,5 @@ class AttachmentSerializer(serializers.ModelSerializer):
|
||||||
"object_id", "order")
|
"object_id", "order")
|
||||||
read_only_fields = ("owner", "created_date", "modified_date")
|
read_only_fields = ("owner", "created_date", "modified_date")
|
||||||
|
|
||||||
def get_name(self, obj):
|
|
||||||
if obj.attached_file:
|
|
||||||
return path.basename(obj.attached_file.path)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def get_url(self, obj):
|
def get_url(self, obj):
|
||||||
token = None
|
return obj.attached_file.url
|
||||||
|
|
||||||
url = reverse("attachment-url", kwargs={"pk": obj.pk})
|
|
||||||
if "request" in self.context and self.context["request"].user.is_authenticated():
|
|
||||||
user_id = self.context["request"].user.id
|
|
||||||
token_src = "{}-{}-{}".format(settings.ATTACHMENTS_TOKEN_SALT, user_id, obj.id)
|
|
||||||
token = hashlib.sha1(token_src.encode("utf-8"))
|
|
||||||
|
|
||||||
return "{}?user={}&token={}".format(url, user_id, token.hexdigest())
|
|
||||||
|
|
||||||
return url
|
|
||||||
|
|
||||||
def get_size(self, obj):
|
|
||||||
if obj.attached_file:
|
|
||||||
try:
|
|
||||||
return obj.attached_file.size
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
return 0
|
|
||||||
|
|
|
@ -20,14 +20,8 @@ from django.contrib.staticfiles.urls import staticfiles_urlpatterns
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .routers import router
|
from .routers import router
|
||||||
from .projects.attachments.api import RawAttachmentView
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
admin.autodiscover()
|
|
||||||
|
|
||||||
urlpatterns = patterns('',
|
urlpatterns = patterns('',
|
||||||
url(r'^attachments/(?P<pk>\d+)/$', RawAttachmentView.as_view(), name="attachment-url"),
|
|
||||||
url(r'^api/v1/', include(router.urls)),
|
url(r'^api/v1/', include(router.urls)),
|
||||||
url(r'^api/v1/api-auth/', include('rest_framework.urls', namespace='rest_framework')),
|
url(r'^api/v1/api-auth/', include('rest_framework.urls', namespace='rest_framework')),
|
||||||
url(r'^admin/', include(admin.site.urls)),
|
url(r'^admin/', include(admin.site.urls)),
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import models, migrations
|
||||||
|
import taiga.users.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '0004_auto_20140913_1914'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='user',
|
||||||
|
name='photo',
|
||||||
|
field=models.FileField(upload_to=taiga.users.models.get_user_file_path, blank=True, max_length=500, verbose_name='photo', null=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -14,26 +14,44 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# 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/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import os.path as path
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.contrib.auth.models import UserManager, AbstractBaseUser
|
from django.contrib.auth.models import UserManager, AbstractBaseUser
|
||||||
from django.core import validators
|
from django.core import validators
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.encoding import force_bytes
|
||||||
|
|
||||||
from djorm_pgarray.fields import TextArrayField
|
from djorm_pgarray.fields import TextArrayField
|
||||||
|
|
||||||
from taiga.base.utils.slug import slugify_uniquely
|
from taiga.base.utils.slug import slugify_uniquely
|
||||||
|
from taiga.base.utils.iterators import split_by_n
|
||||||
from taiga.permissions.permissions import MEMBERS_PERMISSIONS
|
from taiga.permissions.permissions import MEMBERS_PERMISSIONS
|
||||||
|
|
||||||
import random
|
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
def generate_random_hex_color():
|
def generate_random_hex_color():
|
||||||
return "#{:06x}".format(random.randint(0,0xFFFFFF))
|
return "#{:06x}".format(random.randint(0,0xFFFFFF))
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_file_path(instance, filename):
|
||||||
|
basename = path.basename(filename).lower()
|
||||||
|
|
||||||
|
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("user", hash_part, basename)
|
||||||
|
|
||||||
|
|
||||||
class PermissionsMixin(models.Model):
|
class PermissionsMixin(models.Model):
|
||||||
"""
|
"""
|
||||||
A mixin class that adds the fields and methods necessary to support
|
A mixin class that adds the fields and methods necessary to support
|
||||||
|
@ -85,7 +103,8 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||||
color = models.CharField(max_length=9, null=False, blank=True, default=generate_random_hex_color,
|
color = models.CharField(max_length=9, null=False, blank=True, default=generate_random_hex_color,
|
||||||
verbose_name=_("color"))
|
verbose_name=_("color"))
|
||||||
bio = models.TextField(null=False, blank=True, default="", verbose_name=_("biography"))
|
bio = models.TextField(null=False, blank=True, default="", verbose_name=_("biography"))
|
||||||
photo = models.FileField(upload_to="users/photo", max_length=500, null=True, blank=True,
|
photo = models.FileField(upload_to=get_user_file_path,
|
||||||
|
max_length=500, null=True, blank=True,
|
||||||
verbose_name=_("photo"))
|
verbose_name=_("photo"))
|
||||||
date_joined = models.DateTimeField(_('date joined'), default=timezone.now)
|
date_joined = models.DateTimeField(_('date joined'), default=timezone.now)
|
||||||
default_language = models.CharField(max_length=20, null=False, blank=True, default="",
|
default_language = models.CharField(max_length=20, null=False, blank=True, default="",
|
||||||
|
|
|
@ -99,6 +99,7 @@ class UserStoryAttachmentFactory(Factory):
|
||||||
project = factory.SubFactory("tests.factories.ProjectFactory")
|
project = factory.SubFactory("tests.factories.ProjectFactory")
|
||||||
owner = factory.SubFactory("tests.factories.UserFactory")
|
owner = factory.SubFactory("tests.factories.UserFactory")
|
||||||
content_object = factory.SubFactory("tests.factories.UserStoryFactory")
|
content_object = factory.SubFactory("tests.factories.UserStoryFactory")
|
||||||
|
attached_file = factory.django.FileField(data=b"File contents")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = "attachments.Attachment"
|
model = "attachments.Attachment"
|
||||||
|
@ -109,6 +110,7 @@ class TaskAttachmentFactory(Factory):
|
||||||
project = factory.SubFactory("tests.factories.ProjectFactory")
|
project = factory.SubFactory("tests.factories.ProjectFactory")
|
||||||
owner = factory.SubFactory("tests.factories.UserFactory")
|
owner = factory.SubFactory("tests.factories.UserFactory")
|
||||||
content_object = factory.SubFactory("tests.factories.TaskFactory")
|
content_object = factory.SubFactory("tests.factories.TaskFactory")
|
||||||
|
attached_file = factory.django.FileField(data=b"File contents")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = "attachments.Attachment"
|
model = "attachments.Attachment"
|
||||||
|
@ -119,15 +121,18 @@ class IssueAttachmentFactory(Factory):
|
||||||
project = factory.SubFactory("tests.factories.ProjectFactory")
|
project = factory.SubFactory("tests.factories.ProjectFactory")
|
||||||
owner = factory.SubFactory("tests.factories.UserFactory")
|
owner = factory.SubFactory("tests.factories.UserFactory")
|
||||||
content_object = factory.SubFactory("tests.factories.IssueFactory")
|
content_object = factory.SubFactory("tests.factories.IssueFactory")
|
||||||
|
attached_file = factory.django.FileField(data=b"File contents")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = "attachments.Attachment"
|
model = "attachments.Attachment"
|
||||||
strategy = factory.CREATE_STRATEGY
|
strategy = factory.CREATE_STRATEGY
|
||||||
|
|
||||||
|
|
||||||
class WikiAttachmentFactory(Factory):
|
class WikiAttachmentFactory(Factory):
|
||||||
project = factory.SubFactory("tests.factories.ProjectFactory")
|
project = factory.SubFactory("tests.factories.ProjectFactory")
|
||||||
owner = factory.SubFactory("tests.factories.UserFactory")
|
owner = factory.SubFactory("tests.factories.UserFactory")
|
||||||
content_object = factory.SubFactory("tests.factories.WikiFactory")
|
content_object = factory.SubFactory("tests.factories.WikiFactory")
|
||||||
|
attached_file = factory.django.FileField(data=b"File contents")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = "attachments.Attachment"
|
model = "attachments.Attachment"
|
||||||
|
|
|
@ -4,73 +4,22 @@ from django.core.urlresolvers import reverse
|
||||||
from django.core.files.base import File
|
from django.core.files.base import File
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
|
||||||
from .. import factories as f
|
|
||||||
from ..utils import set_settings
|
|
||||||
|
|
||||||
from taiga.projects.attachments.serializers import AttachmentSerializer
|
from taiga.projects.attachments.serializers import AttachmentSerializer
|
||||||
|
from .. import factories as f
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
def test_authentication(client):
|
|
||||||
"User can't access an attachment if not authenticated"
|
|
||||||
attachment = f.UserStoryAttachmentFactory.create()
|
|
||||||
url = reverse("attachment-url", kwargs={"pk": attachment.pk})
|
|
||||||
|
|
||||||
response = client.get(url)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
def test_authorization(client):
|
|
||||||
"User can't access an attachment if not authorized"
|
|
||||||
attachment = f.UserStoryAttachmentFactory.create()
|
|
||||||
user = f.UserFactory.create()
|
|
||||||
|
|
||||||
url = reverse("attachment-url", kwargs={"pk": attachment.pk})
|
|
||||||
|
|
||||||
client.login(user)
|
|
||||||
response = client.get(url)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
@set_settings(IN_DEVELOPMENT_SERVER=True)
|
|
||||||
def test_attachment_redirect_in_devserver(client):
|
|
||||||
"When accessing the attachment in devserver redirect to the real attachment url"
|
|
||||||
attachment = f.UserStoryAttachmentFactory.create(attached_file="test")
|
|
||||||
|
|
||||||
url = reverse("attachment-url", kwargs={"pk": attachment.pk})
|
|
||||||
|
|
||||||
client.login(attachment.owner)
|
|
||||||
response = client.get(url)
|
|
||||||
|
|
||||||
assert response.status_code == 302
|
|
||||||
|
|
||||||
|
|
||||||
@set_settings(IN_DEVELOPMENT_SERVER=False)
|
|
||||||
def test_attachment_redirect(client):
|
|
||||||
"When accessing the attachment redirect using X-Accel-Redirect header"
|
|
||||||
attachment = f.UserStoryAttachmentFactory.create()
|
|
||||||
|
|
||||||
url = reverse("attachment-url", kwargs={"pk": attachment.pk})
|
|
||||||
|
|
||||||
client.login(attachment.owner)
|
|
||||||
response = client.get(url)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.has_header('x-accel-redirect')
|
|
||||||
|
|
||||||
|
|
||||||
# Bug test "Don't create attachments without attached_file"
|
|
||||||
def test_create_user_story_attachment_without_file(client):
|
def test_create_user_story_attachment_without_file(client):
|
||||||
|
"""
|
||||||
|
Bug test "Don't create attachments without attached_file"
|
||||||
|
"""
|
||||||
us = f.UserStoryFactory.create()
|
us = f.UserStoryFactory.create()
|
||||||
attachment = f.UserStoryAttachmentFactory(project=us.project, content_object=us)
|
attachment_data = {
|
||||||
|
"description": "test",
|
||||||
attachment_data = AttachmentSerializer(attachment).data
|
"attached_file": None,
|
||||||
attachment_data["id"] = None
|
"project": us.project_id,
|
||||||
attachment_data["description"] = "test"
|
}
|
||||||
attachment_data["attached_file"] = None
|
|
||||||
|
|
||||||
url = reverse('userstory-attachments-list')
|
url = reverse('userstory-attachments-list')
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue