mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-11-13 12:23:44 +01:00
fix(import): enforce object-level authorization on import profiles and runs
ImportProfile and ImportRun had no owner, and neither the web views nor the REST viewsets filtered by the requesting user, so any authenticated user could read, edit and delete every other user's import profiles and runs (GHSA-q668-h56g-mfp4). - ImportProfile is now a SharedObject, with share and take-ownership views like transaction rules; the global unique constraint on name is dropped. - ImportRun is now an OwnedObject owned by the uploader, so runs made with a shared profile stay private to whoever ran them. - Existing runs are backfilled with the owner of their imported transactions; existing profiles stay unowned and usable by everyone.
This commit is contained in:
1 parent
8888a850bd
commit
1e486a608e
9 files changed
+660
-15
No files matched your search
@@ -39,3 +39,8 @@ class ImportFileSerializer(serializers.Serializer):
|
||||
queryset=ImportProfile.objects.all(), source="profile"
|
||||
)
|
||||
file = serializers.FileField()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Reload the queryset to get an updated version with the requesting user
|
||||
self.fields["profile_id"].queryset = ImportProfile.objects.all()
|
||||
@@ -37,6 +37,9 @@ class ImportProfileViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
ordering_fields = '__all__'
|
||||
ordering = ['name']
|
||||
|
||||
def get_queryset(self):
|
||||
return ImportProfile.objects.all()
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
@@ -81,7 +84,7 @@ class ImportRunViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
ordering = ['-id']
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
queryset = ImportRun.objects.all().order_by("-id")
|
||||
profile_id = self.request.query_params.get("profile_id")
|
||||
if profile_id:
|
||||
queryset = queryset.filter(profile_id=profile_id)
|
||||
@@ -132,7 +135,9 @@ class ImportViewSet(viewsets.ViewSet):
|
||||
file_path = fs.path(filename)
|
||||
|
||||
# Create ImportRun record
|
||||
import_run = ImportRun.objects.create(profile=profile, file_name=filename)
|
||||
import_run = ImportRun.objects.create(
|
||||
profile=profile, file_name=filename, owner=request.user
|
||||
)
|
||||
|
||||
# Queue import task
|
||||
process_import.defer(
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Generated by Django 5.2.17 on 2026-10-24 16:17
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def backfill_import_run_owner(apps, schema_editor):
|
||||
"""Assign existing runs to the user who owns the transactions they imported.
|
||||
|
||||
Runs whose transactions belong to several users, or that imported nothing,
|
||||
are left without an owner and stay visible to everyone.
|
||||
"""
|
||||
ImportRun = apps.get_model("import_app", "ImportRun")
|
||||
|
||||
for run in ImportRun.objects.filter(owner__isnull=True):
|
||||
owner_ids = set(
|
||||
run.transactions.exclude(owner__isnull=True).values_list(
|
||||
"owner_id", flat=True
|
||||
)
|
||||
)
|
||||
if len(owner_ids) == 1:
|
||||
run.owner_id = owner_ids.pop()
|
||||
run.save(update_fields=["owner"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('import_app', '0002_alter_importprofile_name_and_more'),
|
||||
('transactions', '0038_transaction_owner'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='importprofile',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='importprofile',
|
||||
name='shared_with',
|
||||
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='importprofile',
|
||||
name='visibility',
|
||||
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='importrun',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='importprofile',
|
||||
name='name',
|
||||
field=models.CharField(max_length=100, verbose_name='Name'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
backfill_import_run_owner, migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
@@ -4,14 +4,20 @@ from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.models import (
|
||||
OwnedObject,
|
||||
OwnedObjectManager,
|
||||
SharedObject,
|
||||
SharedObjectManager,
|
||||
)
|
||||
from apps.import_app.schemas import version_1
|
||||
|
||||
|
||||
class ImportProfile(models.Model):
|
||||
class ImportProfile(SharedObject):
|
||||
class Versions(models.IntegerChoices):
|
||||
VERSION_1 = 1, "Version 1"
|
||||
|
||||
name = models.CharField(max_length=100, verbose_name=_("Name"), unique=True)
|
||||
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
||||
yaml_config = models.TextField(verbose_name=_("YAML Configuration"))
|
||||
version = models.IntegerField(
|
||||
choices=Versions,
|
||||
@@ -19,6 +25,9 @@ class ImportProfile(models.Model):
|
||||
verbose_name=_("Version"),
|
||||
)
|
||||
|
||||
objects = SharedObjectManager()
|
||||
all_objects = models.Manager() # Unfiltered manager
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -40,7 +49,7 @@ class ImportProfile(models.Model):
|
||||
)
|
||||
|
||||
|
||||
class ImportRun(models.Model):
|
||||
class ImportRun(OwnedObject):
|
||||
class Status(models.TextChoices):
|
||||
QUEUED = "QUEUED", _("Queued")
|
||||
PROCESSING = "PROCESSING", _("Processing")
|
||||
@@ -85,3 +94,6 @@ class ImportRun(models.Model):
|
||||
failed_rows = models.IntegerField(default=0)
|
||||
started_at = models.DateTimeField(null=True)
|
||||
finished_at = models.DateTimeField(null=True)
|
||||
|
||||
objects = OwnedObjectManager()
|
||||
all_objects = models.Manager() # Unfiltered manager
|
||||
@@ -0,0 +1,440 @@
|
||||
"""Object-level authorization tests for import profiles and import runs.
|
||||
|
||||
Regression coverage for GHSA-q668-h56g-mfp4: ImportProfile and ImportRun had no
|
||||
owner, and neither the web views nor the REST viewsets filtered by the
|
||||
requesting user, so any authenticated user could read, edit and delete every
|
||||
other user's profiles and runs.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.import_app.models import ImportProfile, ImportRun
|
||||
|
||||
HTMX = {"HTTP_HX_REQUEST": "true"}
|
||||
|
||||
YAML_CONFIG = """
|
||||
settings:
|
||||
file_type: csv
|
||||
importing: transactions
|
||||
trigger_transaction_rules: false
|
||||
mapping:
|
||||
date_field:
|
||||
source: date
|
||||
target: date
|
||||
format: "%Y-%m-%d"
|
||||
amount_field:
|
||||
source: amount
|
||||
target: amount
|
||||
description_field:
|
||||
source: description
|
||||
target: description
|
||||
account_field:
|
||||
source: account
|
||||
target: account
|
||||
type: id
|
||||
"""
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
DEMO=False,
|
||||
)
|
||||
class ImportObjectPermissionTestCase(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.owner = User.objects.create_user(
|
||||
email="owner@test.com", password="testpass123"
|
||||
)
|
||||
self.shared_user = User.objects.create_user(
|
||||
email="shared@test.com", password="testpass123"
|
||||
)
|
||||
self.stranger = User.objects.create_user(
|
||||
email="stranger@test.com", password="testpass123"
|
||||
)
|
||||
|
||||
# Private and unshared: invisible to everyone but the owner.
|
||||
self.private_profile = ImportProfile.all_objects.create(
|
||||
name="Private profile",
|
||||
yaml_config=YAML_CONFIG,
|
||||
owner=self.owner,
|
||||
visibility="private",
|
||||
)
|
||||
# Private but shared with shared_user: usable by them, not by stranger.
|
||||
self.shared_profile = ImportProfile.all_objects.create(
|
||||
name="Shared profile",
|
||||
yaml_config=YAML_CONFIG,
|
||||
owner=self.owner,
|
||||
visibility="private",
|
||||
)
|
||||
self.shared_profile.shared_with.add(self.shared_user)
|
||||
# Legacy profile created before profiles had owners.
|
||||
self.unowned_profile = ImportProfile.all_objects.create(
|
||||
name="Unowned profile", yaml_config=YAML_CONFIG, owner=None
|
||||
)
|
||||
|
||||
self.owner_run = ImportRun.all_objects.create(
|
||||
profile=self.shared_profile,
|
||||
file_name="owner-statement.csv",
|
||||
logs="owner log",
|
||||
owner=self.owner,
|
||||
)
|
||||
self.shared_user_run = ImportRun.all_objects.create(
|
||||
profile=self.shared_profile,
|
||||
file_name="shared-user-statement.csv",
|
||||
logs="shared user log",
|
||||
owner=self.shared_user,
|
||||
)
|
||||
|
||||
def login(self, user):
|
||||
self.client.force_login(user)
|
||||
|
||||
|
||||
class ImportProfileViewPermissionTests(ImportObjectPermissionTestCase):
|
||||
def test_list_only_shows_visible_profiles(self):
|
||||
self.login(self.stranger)
|
||||
response = self.client.get(reverse("import_profiles_list"), **HTMX)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, "Private profile")
|
||||
self.assertNotContains(response, "Shared profile")
|
||||
self.assertContains(response, "Unowned profile")
|
||||
|
||||
def test_list_shows_profiles_shared_with_user(self):
|
||||
self.login(self.shared_user)
|
||||
response = self.client.get(reverse("import_profiles_list"), **HTMX)
|
||||
|
||||
self.assertContains(response, "Shared profile")
|
||||
self.assertNotContains(response, "Private profile")
|
||||
|
||||
def test_stranger_cannot_edit_private_profile(self):
|
||||
self.login(self.stranger)
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"import_profile_edit", kwargs={"profile_id": self.private_profile.id}
|
||||
),
|
||||
{"name": "Hijacked", "version": 1, "yaml_config": YAML_CONFIG},
|
||||
**HTMX,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.private_profile.refresh_from_db()
|
||||
self.assertEqual(self.private_profile.name, "Private profile")
|
||||
|
||||
def test_shared_user_cannot_edit_shared_profile(self):
|
||||
self.login(self.shared_user)
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"import_profile_edit", kwargs={"profile_id": self.shared_profile.id}
|
||||
),
|
||||
{"name": "Hijacked", "version": 1, "yaml_config": YAML_CONFIG},
|
||||
**HTMX,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.shared_profile.refresh_from_db()
|
||||
self.assertEqual(self.shared_profile.name, "Shared profile")
|
||||
|
||||
def test_owner_can_edit_profile(self):
|
||||
self.login(self.owner)
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"import_profile_edit", kwargs={"profile_id": self.private_profile.id}
|
||||
),
|
||||
{
|
||||
"name": "Renamed",
|
||||
"version": 1,
|
||||
"yaml_config": YAML_CONFIG,
|
||||
"submit": "Update",
|
||||
},
|
||||
**HTMX,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 204)
|
||||
self.private_profile.refresh_from_db()
|
||||
self.assertEqual(self.private_profile.name, "Renamed")
|
||||
|
||||
def test_stranger_cannot_delete_private_profile(self):
|
||||
self.login(self.stranger)
|
||||
response = self.client.delete(
|
||||
reverse(
|
||||
"import_profile_delete",
|
||||
kwargs={"profile_id": self.private_profile.id},
|
||||
),
|
||||
**HTMX,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertTrue(
|
||||
ImportProfile.all_objects.filter(id=self.private_profile.id).exists()
|
||||
)
|
||||
|
||||
def test_shared_user_delete_only_removes_their_access(self):
|
||||
self.login(self.shared_user)
|
||||
response = self.client.delete(
|
||||
reverse(
|
||||
"import_profile_delete", kwargs={"profile_id": self.shared_profile.id}
|
||||
),
|
||||
**HTMX,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 204)
|
||||
self.assertTrue(
|
||||
ImportProfile.all_objects.filter(id=self.shared_profile.id).exists()
|
||||
)
|
||||
self.assertFalse(
|
||||
self.shared_profile.shared_with.filter(pk=self.shared_user.pk).exists()
|
||||
)
|
||||
self.assertTrue(
|
||||
ImportRun.all_objects.filter(id=self.owner_run.id).exists()
|
||||
)
|
||||
|
||||
def test_owner_can_delete_profile(self):
|
||||
self.login(self.owner)
|
||||
response = self.client.delete(
|
||||
reverse(
|
||||
"import_profile_delete",
|
||||
kwargs={"profile_id": self.private_profile.id},
|
||||
),
|
||||
**HTMX,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 204)
|
||||
self.assertFalse(
|
||||
ImportProfile.all_objects.filter(id=self.private_profile.id).exists()
|
||||
)
|
||||
|
||||
def test_shared_user_cannot_change_share_settings(self):
|
||||
self.login(self.shared_user)
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"import_profile_share_settings", kwargs={"pk": self.shared_profile.id}
|
||||
),
|
||||
{"owner": self.shared_user.id, "visibility": "public"},
|
||||
**HTMX,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.shared_profile.refresh_from_db()
|
||||
self.assertEqual(self.shared_profile.owner, self.owner)
|
||||
|
||||
def test_take_ownership_of_unowned_profile(self):
|
||||
self.login(self.stranger)
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"import_profile_take_ownership",
|
||||
kwargs={"profile_id": self.unowned_profile.id},
|
||||
),
|
||||
**HTMX,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 204)
|
||||
self.unowned_profile.refresh_from_db()
|
||||
self.assertEqual(self.unowned_profile.owner, self.stranger)
|
||||
|
||||
def test_cannot_take_ownership_of_owned_profile(self):
|
||||
self.login(self.shared_user)
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"import_profile_take_ownership",
|
||||
kwargs={"profile_id": self.shared_profile.id},
|
||||
),
|
||||
**HTMX,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.shared_profile.refresh_from_db()
|
||||
self.assertEqual(self.shared_profile.owner, self.owner)
|
||||
|
||||
|
||||
class ImportRunViewPermissionTests(ImportObjectPermissionTestCase):
|
||||
def test_runs_list_only_shows_own_runs(self):
|
||||
self.login(self.shared_user)
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"import_profile_runs_list",
|
||||
kwargs={"profile_id": self.shared_profile.id},
|
||||
),
|
||||
**HTMX,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "shared-user-statement.csv")
|
||||
self.assertNotContains(response, "owner-statement.csv")
|
||||
|
||||
def test_stranger_cannot_list_runs_of_private_profile(self):
|
||||
self.login(self.stranger)
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"import_profile_runs_list",
|
||||
kwargs={"profile_id": self.shared_profile.id},
|
||||
),
|
||||
**HTMX,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_cannot_read_another_users_run_log(self):
|
||||
self.login(self.shared_user)
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"import_run_log",
|
||||
kwargs={
|
||||
"profile_id": self.shared_profile.id,
|
||||
"run_id": self.owner_run.id,
|
||||
},
|
||||
),
|
||||
**HTMX,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_cannot_delete_another_users_run(self):
|
||||
self.login(self.shared_user)
|
||||
response = self.client.delete(
|
||||
reverse(
|
||||
"import_run_delete",
|
||||
kwargs={
|
||||
"profile_id": self.shared_profile.id,
|
||||
"run_id": self.owner_run.id,
|
||||
},
|
||||
),
|
||||
**HTMX,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertTrue(ImportRun.all_objects.filter(id=self.owner_run.id).exists())
|
||||
|
||||
def test_owner_can_delete_own_run(self):
|
||||
self.login(self.shared_user)
|
||||
response = self.client.delete(
|
||||
reverse(
|
||||
"import_run_delete",
|
||||
kwargs={
|
||||
"profile_id": self.shared_profile.id,
|
||||
"run_id": self.shared_user_run.id,
|
||||
},
|
||||
),
|
||||
**HTMX,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 204)
|
||||
self.assertFalse(
|
||||
ImportRun.all_objects.filter(id=self.shared_user_run.id).exists()
|
||||
)
|
||||
|
||||
def test_stranger_cannot_import_with_private_profile(self):
|
||||
self.login(self.stranger)
|
||||
response = self.client.get(
|
||||
reverse("import_run_add", kwargs={"profile_id": self.private_profile.id}),
|
||||
**HTMX,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@patch("apps.import_app.views.process_import.defer")
|
||||
@patch("apps.import_app.views.FileSystemStorage.save", return_value="file.csv")
|
||||
@patch("apps.import_app.views.FileSystemStorage.path", return_value="/tmp/file.csv")
|
||||
def test_import_with_shared_profile_is_owned_by_uploader(
|
||||
self, mock_path, mock_save, mock_defer
|
||||
):
|
||||
self.login(self.shared_user)
|
||||
response = self.client.post(
|
||||
reverse("import_run_add", kwargs={"profile_id": self.shared_profile.id}),
|
||||
{"file": SimpleUploadedFile("file.csv", b"date,amount\n")},
|
||||
**HTMX,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 204)
|
||||
run = ImportRun.all_objects.latest("id")
|
||||
self.assertEqual(run.owner, self.shared_user)
|
||||
self.assertEqual(run.profile, self.shared_profile)
|
||||
|
||||
|
||||
class ImportAPIPermissionTests(ImportObjectPermissionTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.api = APIClient()
|
||||
|
||||
def test_profiles_list_only_returns_visible_profiles(self):
|
||||
self.api.force_authenticate(user=self.stranger)
|
||||
response = self.api.get("/api/import/profiles/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
names = {p["name"] for p in response.data["results"]}
|
||||
self.assertEqual(names, {"Unowned profile"})
|
||||
|
||||
def test_cannot_retrieve_private_profile(self):
|
||||
self.api.force_authenticate(user=self.stranger)
|
||||
response = self.api.get(f"/api/import/profiles/{self.private_profile.id}/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_runs_list_only_returns_own_runs(self):
|
||||
self.api.force_authenticate(user=self.stranger)
|
||||
response = self.api.get("/api/import/runs/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["count"], 0)
|
||||
|
||||
def test_runs_list_on_shared_profile_hides_other_users_runs(self):
|
||||
self.api.force_authenticate(user=self.shared_user)
|
||||
response = self.api.get(
|
||||
"/api/import/runs/", {"profile_id": self.shared_profile.id}
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
ids = {r["id"] for r in response.data["results"]}
|
||||
self.assertEqual(ids, {self.shared_user_run.id})
|
||||
|
||||
def test_cannot_retrieve_another_users_run(self):
|
||||
self.api.force_authenticate(user=self.stranger)
|
||||
response = self.api.get(f"/api/import/runs/{self.owner_run.id}/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_cannot_import_with_private_profile(self):
|
||||
self.api.force_authenticate(user=self.stranger)
|
||||
response = self.api.post(
|
||||
"/api/import/import/",
|
||||
{
|
||||
"profile_id": self.private_profile.id,
|
||||
"file": SimpleUploadedFile("file.csv", b"date,amount\n"),
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("profile_id", response.data)
|
||||
|
||||
@patch("apps.import_app.tasks.process_import.defer")
|
||||
@patch("django.core.files.storage.FileSystemStorage.save", return_value="file.csv")
|
||||
@patch("django.core.files.storage.FileSystemStorage.path", return_value="/tmp/file.csv")
|
||||
def test_import_with_shared_profile_is_owned_by_uploader(
|
||||
self, mock_path, mock_save, mock_defer
|
||||
):
|
||||
self.api.force_authenticate(user=self.shared_user)
|
||||
response = self.api.post(
|
||||
"/api/import/import/",
|
||||
{
|
||||
"profile_id": self.shared_profile.id,
|
||||
"file": SimpleUploadedFile("file.csv", b"date,amount\n"),
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
run = ImportRun.all_objects.get(id=response.data["import_run_id"])
|
||||
self.assertEqual(run.owner, self.shared_user)
|
||||
@@ -32,6 +32,16 @@ urlpatterns = [
|
||||
views.import_profile_edit,
|
||||
name="import_profile_edit",
|
||||
),
|
||||
path(
|
||||
"import/profiles/<int:profile_id>/take-ownership/",
|
||||
views.import_profile_take_ownership,
|
||||
name="import_profile_take_ownership",
|
||||
),
|
||||
path(
|
||||
"import/profiles/<int:pk>/share/",
|
||||
views.import_profile_share,
|
||||
name="import_profile_share_settings",
|
||||
),
|
||||
path(
|
||||
"import/profiles/<int:profile_id>/runs/list/",
|
||||
views.import_runs_list,
|
||||
|
||||
@@ -2,6 +2,7 @@ import shutil
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.files.storage import FileSystemStorage
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
@@ -9,6 +10,13 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.common.forms import SharedObjectForm
|
||||
from apps.common.functions.permissions import (
|
||||
EDIT,
|
||||
READ,
|
||||
get_shared_object_or_error,
|
||||
)
|
||||
from apps.common.models import SharedObject
|
||||
from apps.import_app.forms import ImportRunFileUploadForm, ImportProfileForm
|
||||
from apps.import_app.models import ImportRun, ImportProfile
|
||||
from apps.import_app.services import PresetService
|
||||
@@ -93,7 +101,9 @@ def import_profile_add(request):
|
||||
@disabled_on_demo
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def import_profile_edit(request, profile_id):
|
||||
profile = get_object_or_404(ImportProfile, id=profile_id)
|
||||
profile = get_shared_object_or_error(
|
||||
ImportProfile, request, id=profile_id, level=EDIT
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
form = ImportProfileForm(request.POST, instance=profile)
|
||||
@@ -123,11 +133,20 @@ def import_profile_edit(request, profile_id):
|
||||
@disabled_on_demo
|
||||
@require_http_methods(["DELETE"])
|
||||
def import_profile_delete(request, profile_id):
|
||||
profile = ImportProfile.objects.get(id=profile_id)
|
||||
profile = get_shared_object_or_error(
|
||||
ImportProfile, request, id=profile_id, level=READ
|
||||
)
|
||||
|
||||
profile.delete()
|
||||
|
||||
messages.success(request, _("Import Profile deleted successfully"))
|
||||
if profile.is_editable_by(request.user):
|
||||
profile.delete()
|
||||
messages.success(request, _("Import Profile deleted successfully"))
|
||||
elif profile.shared_with.filter(pk=request.user.pk).exists():
|
||||
# Someone else's profile shared with us: we can drop our own access to
|
||||
# it, but never delete it.
|
||||
profile.shared_with.remove(request.user)
|
||||
messages.success(request, _("Item no longer shared with you"))
|
||||
else:
|
||||
raise PermissionDenied
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
@@ -142,8 +161,12 @@ def import_profile_delete(request, profile_id):
|
||||
@disabled_on_demo
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def import_runs_list(request, profile_id):
|
||||
profile = ImportProfile.objects.get(id=profile_id)
|
||||
profile = get_shared_object_or_error(
|
||||
ImportProfile, request, id=profile_id, level=READ
|
||||
)
|
||||
|
||||
# ImportRun.objects only returns the requesting user's runs, so a shared
|
||||
# profile does not expose the runs other users made with it.
|
||||
runs = ImportRun.objects.filter(profile=profile).order_by("-id")
|
||||
|
||||
return render(
|
||||
@@ -158,7 +181,7 @@ def import_runs_list(request, profile_id):
|
||||
@disabled_on_demo
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def import_run_log(request, profile_id, run_id):
|
||||
run = ImportRun.objects.get(profile__id=profile_id, id=run_id)
|
||||
run = get_object_or_404(ImportRun, profile__id=profile_id, id=run_id)
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -172,7 +195,9 @@ def import_run_log(request, profile_id, run_id):
|
||||
@disabled_on_demo
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def import_run_add(request, profile_id):
|
||||
profile = ImportProfile.objects.get(id=profile_id)
|
||||
profile = get_shared_object_or_error(
|
||||
ImportProfile, request, id=profile_id, level=READ
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
form = ImportRunFileUploadForm(request.POST, request.FILES)
|
||||
@@ -183,7 +208,9 @@ def import_run_add(request, profile_id):
|
||||
filename = fs.save(uploaded_file.name, uploaded_file)
|
||||
file_path = fs.path(filename)
|
||||
|
||||
import_run = ImportRun.objects.create(profile=profile, file_name=filename)
|
||||
import_run = ImportRun.objects.create(
|
||||
profile=profile, file_name=filename, owner=request.user
|
||||
)
|
||||
|
||||
# Defer the procrastinate task
|
||||
process_import.defer(
|
||||
@@ -215,7 +242,7 @@ def import_run_add(request, profile_id):
|
||||
@disabled_on_demo
|
||||
@require_http_methods(["DELETE"])
|
||||
def import_run_delete(request, profile_id, run_id):
|
||||
run = ImportRun.objects.get(profile__id=profile_id, id=run_id)
|
||||
run = get_object_or_404(ImportRun, profile__id=profile_id, id=run_id)
|
||||
|
||||
run.delete()
|
||||
|
||||
@@ -227,3 +254,56 @@ def import_run_delete(request, profile_id, run_id):
|
||||
"HX-Trigger": "updated",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@disabled_on_demo
|
||||
@require_http_methods(["GET"])
|
||||
def import_profile_take_ownership(request, profile_id):
|
||||
profile = get_shared_object_or_error(
|
||||
ImportProfile, request, id=profile_id, level=EDIT
|
||||
)
|
||||
|
||||
if not profile.owner:
|
||||
profile.owner = request.user
|
||||
profile.visibility = SharedObject.Visibility.private
|
||||
profile.save()
|
||||
|
||||
messages.success(request, _("Ownership taken successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@disabled_on_demo
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def import_profile_share(request, pk):
|
||||
obj = get_shared_object_or_error(ImportProfile, request, id=pk, level=EDIT)
|
||||
|
||||
if request.method == "POST":
|
||||
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Configuration saved successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = SharedObjectForm(instance=obj, user=request.user)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"import_app/fragments/profiles/share.html",
|
||||
{"form": form, "object": obj},
|
||||
)
|
||||
@@ -40,12 +40,30 @@
|
||||
<tr class="profile">
|
||||
<td class="table-col-auto">
|
||||
<div class="join" role="group" aria-label="{% translate 'Actions' %}">
|
||||
{% if not profile.owner or user == profile.owner %}
|
||||
<a class="btn btn-secondary btn-sm join-item"
|
||||
role="button"
|
||||
data-tippy-content="{% translate "Edit" %}"
|
||||
hx-get="{% url 'import_profile_edit' profile_id=profile.id %}"
|
||||
hx-target="#generic-offcanvas">
|
||||
<i class="fa-solid fa-pencil fa-fw"></i></a>
|
||||
{% endif %}
|
||||
{% if not profile.owner %}
|
||||
<a class="btn btn-secondary btn-sm join-item"
|
||||
role="button"
|
||||
data-tippy-content="{% translate "Take ownership" %}"
|
||||
hx-get="{% url 'import_profile_take_ownership' profile_id=profile.id %}">
|
||||
<i class="fa-solid fa-crown fa-fw"></i></a>
|
||||
{% endif %}
|
||||
{% if user == profile.owner %}
|
||||
<a class="btn btn-secondary btn-sm join-item"
|
||||
role="button"
|
||||
hx-target="#generic-offcanvas"
|
||||
hx-swap="innerHTML"
|
||||
data-tippy-content="{% translate "Share" %}"
|
||||
hx-get="{% url 'import_profile_share_settings' pk=profile.id %}">
|
||||
<i class="fa-solid fa-share fa-fw"></i></a>
|
||||
{% endif %}
|
||||
<a class="btn btn-secondary btn-sm join-item"
|
||||
role="button"
|
||||
data-tippy-content="{% translate "Runs" %}"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends 'extends/offcanvas.html' %}
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block title %}{% translate 'Share settings' %}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<form hx-post="{% url "import_profile_share_settings" pk=object.id %}" hx-target="#generic-offcanvas" novalidate>
|
||||
{% crispy form %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user