Repository: Nitrate/Nitrate

Issue:
Delete attachment object and associated file from server
As per subject. If the attachment is being linked to another one, removing the attachment itself is enough.

Start after #570 


Discussion:

Resolving patch:
diff --git a/src/tcms/core/files.py b/src/tcms/core/files.py
--- a/src/tcms/core/files.py
+++ b/src/tcms/core/files.py
@@ -46,8 +46,12 @@ def post(self, request):
 
         if to_plan_id is not None:
             redirect_url = reverse('plan-attachment', args=[to_plan_id])
-        if to_case_id is not None:
+            create_rel = TestPlanAttachment.objects.create
+            rel_kwargs = {'plan_id': int(to_plan_id)}
+        else:
             redirect_url = reverse('case-attachment', args=[to_case_id])
+            create_rel = TestCaseAttachment.objects.create
+            rel_kwargs = {'case_id': int(to_case_id)}
 
         upload_file = request.FILES.get('upload_file')
 
@@ -68,50 +72,36 @@ def post(self, request):
         except UnicodeEncodeError:
             return prompt.alert(request, 'Upload File name is not legal.')
 
-        now = datetime.now()  # FIXME: use utcnow()?
-        stored_name = f'{request.user.username}-{now}-{upload_file.name}'
-        stored_file_name = os.path.join(
-            settings.FILE_UPLOAD_DIR, stored_name).replace('\\', '/')
-        stored_file_name = smart_str(stored_file_name)
-
         # Create the upload directory when it's not exist
         if not os.path.exists(settings.FILE_UPLOAD_DIR):
             os.mkdir(settings.FILE_UPLOAD_DIR)
 
-        if os.path.exists(stored_file_name):
-            return prompt.alert(
-                request,
-                f"File named '{upload_file.name}' already exists in upload"
-                f" folder, please rename to another name for solve conflict.",
-            )
-
-        with open(stored_file_name, 'wb+') as f:
-            for chunk in upload_file.chunks():
-                f.write(chunk)
+        now = datetime.now()  # FIXME: use utcnow()?
 
-        # Write the file to database
-        # store_file = open(upload_file_name, 'ro')
-        ta = TestAttachment.objects.create(
+        attachment = TestAttachment(
             submitter_id=request.user.id,
             description=request.POST.get('description', None),
             file_name=upload_file.name,
-            stored_name=stored_name,
+            stored_name=f'{request.user.username}-{now}-{upload_file.name}',
             create_date=now,
             mime_type=upload_file.content_type
         )
 
-        if to_plan_id is not None:
-            TestPlanAttachment.objects.create(
-                plan_id=int(to_plan_id),
-                attachment_id=ta.attachment_id,
+        if attachment.exists:
+            return prompt.alert(
+                request,
+                f"File named '{upload_file.name}' already exists in upload"
+                f" folder, please rename to another name for solve conflict.",
             )
 
-        if to_case_id is not None:
-            TestCaseAttachment.objects.create(
-                case_id=int(to_case_id),
-                attachment_id=ta.attachment_id,
-            )
+        with open(attachment.stored_filename, 'wb+') as f:
+            for chunk in upload_file.chunks():
+                f.write(chunk)
 
+        attachment.save()
+
+        rel_kwargs['attachment'] = attachment
+        create_rel(**rel_kwargs)
         return HttpResponseRedirect(redirect_url)
 
 
@@ -203,8 +193,7 @@ def delete_file(request):
     if 'from_plan' in request.POST:
         plan_id = int(request.POST['from_plan'])
         try:
-            rel = TestPlanAttachment.objects.get(
-                attachment=file_id, plan_id=plan_id)
+            rel = TestPlanAttachment.objects.get(attachment=file_id, plan_id=plan_id)
         except TestPlanAttachment.DoesNotExist:
             return JsonResponse(
                 {'message': f'Attachment {file_id} does not belong to plan {plan_id}.'},
@@ -213,17 +202,18 @@ def delete_file(request):
         else:
             rel.delete()
 
-        return JsonResponse({
-            'message': f'Attachment {rel.attachment.file_name} is removed from plan'
-                       f' {plan_id} successfully.'
-        })
+        attachment = rel.attachment
+        msg = f'Attachment {attachment.file_name} is removed from plan {plan_id} successfully.'
+        attachment.delete()
+        os.unlink(attachment.stored_filename)
+
+        return JsonResponse({'message': msg})
 
     # Delete cases' attachment
     elif 'from_case' in request.POST:
         case_id = int(request.POST['from_case'])
         try:
-            rel = TestCaseAttachment.objects.get(
-                attachment=file_id, case_id=case_id)
+            rel = TestCaseAttachment.objects.get(attachment=file_id, case_id=case_id)
         except TestCaseAttachment.DoesNotExist:
             return JsonResponse(
                 {'message': f'Attachment {file_id} does not belong to case {case_id}.'},
@@ -232,10 +222,12 @@ def delete_file(request):
         else:
             rel.delete()
 
-        return JsonResponse({
-            'message': f'Attachment {rel.attachment.file_name} is removed from case'
-                       f' {case_id} successfully.'
-        })
+        attachment = rel.attachment
+        msg = f'Attachment {attachment.file_name} is removed from case {case_id} successfully.'
+        attachment.delete()
+        os.unlink(attachment.stored_filename)
+
+        return JsonResponse({'message': msg})
 
     else:
         return JsonResponse(
diff --git a/src/tcms/management/models.py b/src/tcms/management/models.py
--- a/src/tcms/management/models.py
+++ b/src/tcms/management/models.py
@@ -1,7 +1,10 @@
 # -*- coding: utf-8 -*-
 
+import os.path
+
 from django.core.cache import cache
 from django.db import models
+from django.conf import settings
 
 from tcms.core.models import TCMSActionModel
 from tcms.core.utils import calc_percent
@@ -417,6 +420,7 @@ class TestAttachment(models.Model):
     stored_name = models.CharField(max_length=128, unique=True, blank=True, null=True)
     create_date = models.DateTimeField(db_column='creation_ts')
     mime_type = models.CharField(max_length=100)
+    # checksum = models.CharField(max_length=128, unique=True)
 
     submitter = models.ForeignKey('auth.User',
                                   blank=True, null=True,
@@ -429,6 +433,18 @@ def __str__(self):
     class Meta:
         db_table = 'test_attachments'
 
+    @property
+    def stored_filename(self):
+        return (
+            os.path
+            .join(settings.FILE_UPLOAD_DIR, self.stored_name)
+            .replace('\\', '/')
+        )
+
+    @property
+    def exists(self):
+        return os.path.exists(self.stored_filename)
+
 
 class TestAttachmentData(models.Model):
     contents = models.BinaryField(blank=True)


Test patch:
diff --git a/src/tests/core/test_files.py b/src/tests/core/test_files.py
--- a/src/tests/core/test_files.py
+++ b/src/tests/core/test_files.py
@@ -1,18 +1,17 @@
 # -*- coding: utf-8 -*-
 
 import os
-import shutil
 import tempfile
 
 from datetime import datetime
 from http import HTTPStatus
+from unittest.mock import patch
 
 from django.db.models import Max
 from django.http import HttpResponse
 from django.test import RequestFactory
 from django.urls import reverse
 from django.conf import settings
-from unittest.mock import patch
 
 from tcms.core.files import able_to_delete_attachment
 from tcms.management.models import TestAttachment
@@ -38,19 +37,6 @@ def setUpTestData(cls):
         user_should_have_perm(cls.user, 'management.add_testattachment')
         user_should_have_perm(cls.user, 'testcases.add_testcaseattachment')
 
-    @classmethod
-    def setUpClass(cls):
-        super().setUpClass()
-
-        cls.file_upload_dir = tempfile.mkdtemp(
-            prefix=f'{cls.__name__}-upload-dir')
-
-    @classmethod
-    def tearDownClass(cls):
-        shutil.rmtree(cls.file_upload_dir)
-
-        super().tearDownClass()
-
     def setUp(self):
         super().setUp()
 
@@ -92,12 +78,13 @@ def test_refuse_if_file_is_too_big(self):
     def test_upload_file_to_plan(self):
         self.client.login(username=self.user.username, password=self.password)
 
-        with patch('tcms.core.files.settings.FILE_UPLOAD_DIR',
-                   new=self.file_upload_dir):
-            with open(self.upload_filename, 'r') as upload_file:
-                response = self.client.post(self.upload_file_url,
-                                            {'to_plan_id': self.plan.pk,
-                                             'upload_file': upload_file})
+        with tempfile.TemporaryDirectory() as tmpdir:
+            with patch('tcms.core.files.settings.FILE_UPLOAD_DIR', new=tmpdir):
+                with open(self.upload_filename, 'r') as upload_file:
+                    response = self.client.post(self.upload_file_url, {
+                        'to_plan_id': self.plan.pk,
+                        'upload_file': upload_file
+                    })
 
         self.assertRedirects(
             response,
@@ -117,12 +104,13 @@ def test_upload_file_to_plan(self):
     def test_upload_file_to_case(self):
         self.client.login(username=self.user.username, password=self.password)
 
-        with patch('tcms.core.files.settings.FILE_UPLOAD_DIR',
-                   new=self.file_upload_dir):
-            with open(self.upload_filename, 'r') as upload_file:
-                response = self.client.post(self.upload_file_url,
-                                            {'to_case_id': self.case_1.pk,
-                                             'upload_file': upload_file})
+        with tempfile.TemporaryDirectory() as tmpdir:
+            with patch('tcms.core.files.settings.FILE_UPLOAD_DIR', new=tmpdir):
+                with open(self.upload_filename, 'r') as upload_file:
+                    response = self.client.post(self.upload_file_url, {
+                        'to_case_id': self.case_1.pk,
+                        'upload_file': upload_file
+                    })
 
         self.assertRedirects(
             response,
@@ -262,35 +250,43 @@ def test_refuse_if_user_cannot_delete_file(self):
 
         self.assertEqual(HTTPStatus.UNAUTHORIZED, response.status_code)
 
-    def test_delete_attachment_from_plan(self):
+    @patch('os.unlink')
+    def test_delete_attachment_from_plan(self, unlink):
         self.client.login(username=self.plan_attachment.submitter.username,
                           password=self.submitter_pwd)
 
+        stored_filename = self.plan_attachment.stored_filename
+
         response = self.client.post(reverse('delete-file'), {
             'file_id': self.plan_attachment.pk,
             'from_plan': self.plan.pk
         })
 
+        unlink.assert_called_once_with(stored_filename)
+
         self.assertEqual(HTTPStatus.OK, response.status_code)
         still_has = self.plan.attachment.filter(pk=self.plan_attachment.pk).exists()
         self.assertFalse(still_has)
-        # TODO: skip because delete_file does not delete a TestAttachment object from database
-        # self.assertFalse(TestAttachment.objects.filter(pk=self.plan_attachment.pk).exists())
+        self.assertFalse(TestAttachment.objects.filter(pk=self.plan_attachment.pk).exists())
 
-    def test_delete_attachment_from_case(self):
+    @patch('os.unlink')
+    def test_delete_attachment_from_case(self, unlink):
         self.client.login(username=self.case_attachment.submitter.username,
                           password=self.submitter_pwd)
 
+        stored_filename = self.case_attachment.stored_filename
+
         response = self.client.post(reverse('delete-file'), {
             'file_id': self.case_attachment.pk,
             'from_case': self.case_1.pk
         })
 
+        unlink.assert_called_once_with(stored_filename)
+
         self.assertEqual(HTTPStatus.OK, response.status_code)
         still_has = self.case_1.attachment.filter(pk=self.case_attachment.pk).exists()
         self.assertFalse(still_has)
-        # TODO: skip because delete_file does not delete a TestAttachment object from database
-        # self.assertFalse(TestAttachment.objects.filter(pk=self.case_attachment.pk).exists())
+        self.assertFalse(TestAttachment.objects.filter(pk=self.case_attachment.pk).exists())
 
     def test_missing_both_plan_and_case_id(self):
         self.client.login(username=self.plan_attachment.submitter.username,
diff --git a/src/tests/factories.py b/src/tests/factories.py
--- a/src/tests/factories.py
+++ b/src/tests/factories.py
@@ -174,6 +174,7 @@ class TestAttachmentFactory(DjangoModelFactory):
     class Meta:
         model = 'management.TestAttachment'
 
+    stored_name = factory.sequence(lambda n: f'attachment-file-{n}')
     file_name = factory.LazyFunction(lambda: '%s.png' % str(datetime.now()))
     submitter = factory.SubFactory(UserFactory)
     create_date = factory.LazyFunction(datetime.now)