diff --git a/settings/common.py b/settings/common.py index 6b0eafbc..cdbe0834 100644 --- a/settings/common.py +++ b/settings/common.py @@ -177,6 +177,7 @@ INSTALLED_APPS = [ "taiga.domains", "taiga.front", "taiga.users", + "taiga.userstorage", "taiga.projects", "taiga.projects.attachments", "taiga.projects.milestones", diff --git a/taiga/routers.py b/taiga/routers.py index f1ace3e2..0f3b7bd6 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -28,6 +28,12 @@ router.register(r"permissions", PermissionsViewSet, base_name="permissions") router.register(r"auth", AuthViewSet, base_name="auth") +#taiga.userstorage +from taiga.userstorage.api import StorageEntriesViewSet + +router.register(r"user-storage", StorageEntriesViewSet, base_name="user-storage") + + # Resolver & Search from taiga.base.searches.api import SearchViewSet from taiga.base.resolver.api import ResolverViewSet diff --git a/taiga/userstorage/__init__.py b/taiga/userstorage/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/userstorage/api.py b/taiga/userstorage/api.py new file mode 100644 index 00000000..dc994801 --- /dev/null +++ b/taiga/userstorage/api.py @@ -0,0 +1,48 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 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.utils.translation import ugettext_lazy as _ +from django.db import IntegrityError + +from rest_framework.permissions import IsAuthenticated + +from taiga.base.api import ModelCrudViewSet +from taiga.base import exceptions as exc + +from . import models +from . import serializers +from . import permissions + + +class StorageEntriesViewSet(ModelCrudViewSet): + model = models.StorageEntry + serializer_class = serializers.StorageEntrySerializer + permission_classes = (IsAuthenticated, permissions.StorageEntriesPermission) + lookup_field = "key" + + def get_queryset(self): + return self.request.user.storage_entries.all() + + def pre_save(self, obj): + obj.owner = self.request.user + + def create(self, *args, **kwargs): + try: + return super().create(*args, **kwargs) + except IntegrityError: + key = self.request.DATA.get("key", None) + raise exc.IntegrityError(_("Duplicate key value violates unique constraint. " + "Key '{}' already exists.").format(key)) diff --git a/taiga/userstorage/migrations/0001_initial.py b/taiga/userstorage/migrations/0001_initial.py new file mode 100644 index 00000000..87421278 --- /dev/null +++ b/taiga/userstorage/migrations/0001_initial.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'StorageEntry' + db.create_table('userstorage_storageentry', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('owner', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['users.User'], related_name='storage_entries')), + ('created_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('modified_date', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)), + ('key', self.gf('django.db.models.fields.CharField')(max_length=255)), + ('value', self.gf('django_pgjson.fields.JsonField')(default=None)), + )) + db.send_create_signal('userstorage', ['StorageEntry']) + + # Adding unique constraint on 'StorageEntry', fields ['owner', 'key'] + db.create_unique('userstorage_storageentry', ['owner_id', 'key']) + + + def backwards(self, orm): + # Removing unique constraint on 'StorageEntry', fields ['owner', 'key'] + db.delete_unique('userstorage_storageentry', ['owner_id', 'key']) + + # Deleting model 'StorageEntry' + db.delete_table('userstorage_storageentry') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'object_name': 'Permission', 'unique_together': "(('content_type', 'codename'),)", 'ordering': "('content_type__app_label', 'content_type__model', 'codename')"}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'db_table': "'django_content_type'", 'object_name': 'ContentType', 'unique_together': "(('app_label', 'model'),)", 'ordering': "('name',)"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'users.user': { + 'Meta': {'object_name': 'User', 'ordering': "['username']"}, + 'color': ('django.db.models.fields.CharField', [], {'default': "'#2ee685'", 'max_length': '9', 'blank': 'True'}), + 'colorize_tags': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'default_language': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '20', 'blank': 'True'}), + 'default_timezone': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '20', 'blank': 'True'}), + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'related_name': "'user_set'", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'notify_changes_by_me': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'notify_level': ('django.db.models.fields.CharField', [], {'default': "'all_owned_projects'", 'max_length': '32'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'photo': ('django.db.models.fields.files.FileField', [], {'null': 'True', 'max_length': '500', 'blank': 'True'}), + 'token': ('django.db.models.fields.CharField', [], {'default': 'None', 'null': 'True', 'max_length': '200', 'blank': 'True'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'related_name': "'user_set'", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'userstorage.storageentry': { + 'Meta': {'object_name': 'StorageEntry', 'unique_together': "(('owner', 'key'),)", 'ordering': "['owner', 'key']"}, + 'created_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'modified_date': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'owner': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['users.User']", 'related_name': "'storage_entries'"}), + 'value': ('django_pgjson.fields.JsonField', [], {'default': 'None'}) + } + } + + complete_apps = ['userstorage'] \ No newline at end of file diff --git a/taiga/userstorage/migrations/__init__.py b/taiga/userstorage/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/userstorage/models.py b/taiga/userstorage/models.py new file mode 100644 index 00000000..15da5992 --- /dev/null +++ b/taiga/userstorage/models.py @@ -0,0 +1,37 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 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.db import models +from django.conf import settings +from django.utils.translation import ugettext_lazy as _ +from django_pgjson.fields import JsonField + + +class StorageEntry(models.Model): + owner = models.ForeignKey(settings.AUTH_USER_MODEL, blank=False, null=False, + related_name="storage_entries", verbose_name=_("owner")) + created_date = models.DateTimeField(auto_now_add=True, null=False, blank=False, + verbose_name=_("created date")) + modified_date = models.DateTimeField(auto_now=True, null=False, blank=False, + verbose_name=_("modified date")) + key = models.CharField(max_length=255, null=False, blank=False, verbose_name=_("key")) + value = JsonField(blank=True, default=None, null=True, verbose_name=_("value")) + + class Meta: + verbose_name = "storage entry" + verbose_name_plural = "storages entries" + unique_together = ("owner", "key") + ordering = ["owner", "key"] diff --git a/taiga/userstorage/permissions.py b/taiga/userstorage/permissions.py new file mode 100644 index 00000000..933987e8 --- /dev/null +++ b/taiga/userstorage/permissions.py @@ -0,0 +1,22 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 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.permissions import Permission + + +class StorageEntriesPermission(Permission): + def has_object_permission(self, request, view, obj): + return request.user == obj.owner diff --git a/taiga/userstorage/serializers.py b/taiga/userstorage/serializers.py new file mode 100644 index 00000000..041eb59a --- /dev/null +++ b/taiga/userstorage/serializers.py @@ -0,0 +1,29 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 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 rest_framework import serializers +from taiga.base.serializers import JsonField + +from . import models + + +class StorageEntrySerializer(serializers.ModelSerializer): + value = JsonField(label="value") + + class Meta: + model = models.StorageEntry + fields = ("key", "value", "created_date", "modified_date") + read_only_fields = ("created_date", "modified_date") diff --git a/tests/factories.py b/tests/factories.py index 976afd7c..7cfe0841 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import uuid import factory @@ -7,6 +6,7 @@ from django.conf import settings import taiga.domains.models import taiga.projects.models import taiga.users.models +import taiga.userstorage.models class DomainFactory(factory.DjangoModelFactory): @@ -60,3 +60,11 @@ class MembershipFactory(factory.DjangoModelFactory): project = factory.SubFactory("tests.factories.ProjectFactory") role = factory.SubFactory("tests.factories.RoleFactory") user = factory.SubFactory("tests.factories.UserFactory") + + +class StorageEntryFactory(factory.DjangoModelFactory): + FACTORY_FOR = taiga.userstorage.models.StorageEntry + + owner = factory.SubFactory("tests.factories.UserFactory") + key = factory.Sequence(lambda n: "key-{}".format(n)) + value = factory.Sequence(lambda n: "value {}".format(n)) diff --git a/tests/integration/test_userstorage_api.py b/tests/integration/test_userstorage_api.py new file mode 100644 index 00000000..0c9a15be --- /dev/null +++ b/tests/integration/test_userstorage_api.py @@ -0,0 +1,209 @@ +import pytest + +from rest_framework.reverse import reverse + +from .. import factories + +import json + + +pytestmark = pytest.mark.django_db + + +class TestListStorageEntries: + def _load_initial_data(self): + self.user1 = factories.UserFactory() + self.user2 = factories.UserFactory() + self.storage11 = factories.StorageEntryFactory(owner=self.user1) + self.storage12 = factories.StorageEntryFactory(owner=self.user1) + self.storage21 = factories.StorageEntryFactory(owner=self.user2) + + def test_list_by_anonymous_user(self, client): + self._load_initial_data() + response = client.get(reverse("user-storage-list")) + assert response.status_code == 401 + + def test_list_only_user1_entriees(self, client): + self._load_initial_data() + response = client.login(username=self.user1.username, password=self.user1.username) + response = client.get(reverse("user-storage-list")) + assert response.status_code == 200 + entries = response.data + assert len(entries) == 2 + response = client.logout() + + def test_list_only_user2_entriees(self, client): + self._load_initial_data() + response = client.login(username=self.user2.username, password=self.user2.username) + response = client.get(reverse("user-storage-list")) + assert response.status_code == 200 + entries = response.data + assert len(entries) == 1 + response = client.logout() + + +class TestViewStorageEntries: + def _load_initial_data(self): + self.user1 = factories.UserFactory() + self.user2 = factories.UserFactory() + self.storage11 = factories.StorageEntryFactory(owner=self.user1) + + def test_view_an_entry_by_anonymous_user(self, client): + self._load_initial_data() + response = client.get(reverse("user-storage-detail", args=[self.storage11.key])) + assert response.status_code == 401 + + def test_view_an_entry(self, client): + self._load_initial_data() + response = client.login(username=self.user1.username, password=self.user1.username) + response = client.get(reverse("user-storage-detail", args=[self.storage11.key])) + assert response.status_code == 200 + entry = response.data + assert entry["key"] == self.storage11.key + assert entry["value"] == self.storage11.value + response = client.logout() + + def test_view_an_entry_by_incorrect_user(self, client): + self._load_initial_data() + response = client.login(username=self.user2.username, password=self.user2.username) + response = client.get(reverse("user-storage-detail", args=[self.storage11.key])) + assert response.status_code == 404 + response = client.logout() + + def test_view_non_existent_entry(self, client): + self._load_initial_data() + response = client.login(username=self.user1.username, password=self.user1.username) + response = client.get(reverse("user-storage-detail", args=["foo"])) + assert response.status_code == 404 + response = client.logout() + + +class TestCreateStorageEntries: + @classmethod + def setup_class(cls): + cls.form = {"key": "foo", + "value": "bar"} + cls.form_without_key = {"value": "bar"} + cls.form_without_value = {"key": "foo"} + + def _load_initial_data(self): + self.user1 = factories.UserFactory() + self.user2 = factories.UserFactory() + self.storage11 = factories.StorageEntryFactory(owner=self.user1) + + def test_create_entry_by_anonymous_user_with_error(self, client): + self._load_initial_data() + response = client.post(reverse("user-storage-list"), self.form) + assert response.status_code == 401 + + def test_create_entry_successfully(self, client): + self._load_initial_data() + response = client.login(username=self.user1.username, password=self.user1.username) + response = client.post(reverse("user-storage-list"), self.form) + assert response.status_code == 201 + response = client.get(reverse("user-storage-detail", args=[self.form["key"]])) + assert response.status_code == 200 + response = client.logout() + + def test_create_entry_with_incorret_form_error(self, client): + self._load_initial_data() + response = client.login(username=self.user1.username, password=self.user1.username) + response = client.post(reverse("user-storage-list"), self.form_without_key) + assert response.status_code == 400 + response = client.post(reverse("user-storage-list"), self.form_without_value) + assert response.status_code == 400 + response = client.logout() + + def test_create_entry_with_integrity_error(self, client): + self._load_initial_data() + response = client.login(username=self.user1.username, password=self.user1.username) + error_form = {"key": self.storage11.key, + "value": "bar"} + response = client.post(reverse("user-storage-list"), error_form) + assert response.status_code == 400 + response = client.logout() + + +class TestUpdateStorageEntries: + @classmethod + def setup_class(cls): + cls.form = {"value": "bar"} + + def _load_initial_data(self): + self.user1 = factories.UserFactory() + self.user2 = factories.UserFactory() + self.storage11 = factories.StorageEntryFactory(owner=self.user1) + + def test_update_entry_by_anonymous_user(self, client): + self._load_initial_data() + self.form["key"] = self.storage11.key + response = client.put(reverse("user-storage-detail", args=[self.storage11.key]), + json.dumps(self.form), + content_type='application/json') + assert response.status_code == 401 + + def test_update_entry(self, client): + self._load_initial_data() + response = client.login(username=self.user1.username, password=self.user1.username) + self.form["key"] = self.storage11.key + response = client.put(reverse("user-storage-detail", args=[self.storage11.key]), + json.dumps(self.form), + content_type='application/json') + assert response.status_code == 200 + response = client.get(reverse("user-storage-detail", args=[self.storage11.key])) + assert response.status_code == 200 + entry = response.data + assert entry["value"] == self.form["value"] + response = client.logout() + + def test_update_non_existent_entry(self, client): + self._load_initial_data() + response = client.login(username=self.user1.username, password=self.user1.username) + self.form["key"] = "foo" + response = client.get(reverse("user-storage-detail", args=[self.form["key"]])) + assert response.status_code == 404 + response = client.put(reverse("user-storage-detail", args=[self.form["key"]]), + json.dumps(self.form), + content_type='application/json') + assert response.status_code == 201 + response = client.get(reverse("user-storage-detail", args=[self.form["key"]])) + assert response.status_code == 200 + entry = response.data + assert entry["value"] == self.form["value"] + response = client.logout() + + +class TestDeleteStorageEntries: + def _load_initial_data(self): + self.user1 = factories.UserFactory() + self.user2 = factories.UserFactory() + self.storage11 = factories.StorageEntryFactory(owner=self.user1) + + def test_delete_entry_by_anonymous_user(self, client): + self._load_initial_data() + response = client.delete(reverse("user-storage-detail", args=[self.storage11.key])) + assert response.status_code == 401 + + def test_delete_entry(self, client): + self._load_initial_data() + response = client.login(username=self.user1.username, password=self.user1.username) + key = self.storage11.key + response = client.delete(reverse("user-storage-detail", args=[key])) + assert response.status_code == 204 + response = client.get(reverse("user-storage-detail", args=[key])) + assert response.status_code == 404 + response = client.logout() + + def test_delete_entry_by_incorrect_user(self, client): + self._load_initial_data() + response = client.login(username=self.user2.username, password=self.user2.username) + response = client.delete(reverse("user-storage-detail", args=[self.storage11.key])) + assert response.status_code == 404 + response = client.logout() + + def test_delete_non_existent_entry(self, client): + self._load_initial_data() + response = client.login(username=self.user1.username, password=self.user1.username) + response = client.delete(reverse("user-storage-detail", args=["foo"])) + assert response.status_code == 404 + response = client.logout()