22
33from django .db import models
44
5- from botocore .exceptions import ClientError
65from private_storage .fields import PrivateFileField
6+ from storages .backends .s3boto3 import S3Boto3Storage
7+ from storages .utils import clean_name
78
89from .user import User
910
1011
1112class TemporaryUpload (models .Model ):
1213 """
13- Tracks temporary file uploads to S3 until they are consumed by result submission.
14+ Tracks temporary file uploads until they are consumed by result submission.
1415 Records are deleted when the upload is used (copied to permanent location).
1516 """
1617
@@ -22,74 +23,63 @@ class Meta:
2223 ordering = ["-created_at" ]
2324
2425 def __str__ (self ):
25- return f"TemporaryUpload({ self .id } , { self .s3_key } )"
26-
27- @property
28- def _storage (self ):
29- return self .file .storage
30-
31- @property
32- def _client (self ):
33- return self ._storage .connection .meta .client
34-
35- @property
36- def _bucket_name (self ):
37- return self ._storage .bucket_name
38-
39- @property
40- def s3_key (self ):
41- return self .file .name
26+ return f"TemporaryUpload({ self .id } , { self .file .name } )"
4227
4328 @classmethod
4429 def create_for_upload (cls , user : User ) -> "TemporaryUpload" :
45- """Create a TemporaryUpload record with a generated S3 key ."""
30+ """Create a TemporaryUpload record with a storage- generated path ."""
4631 upload = cls (uploaded_by = user )
47- upload .file .name = f"temp-uploads/ { uuid .uuid4 ().hex } "
32+ upload .file .name = cls . _meta . get_field ( "file" ). generate_filename ( upload , f" { uuid .uuid4 ().hex } ")
4833 upload .save ()
4934 return upload
5035
5136 def generate_presigned_put_url (self ) -> str :
52- """Generate a presigned PUT URL for direct S3 upload."""
53- return self ._client .generate_presigned_url (
37+ """Generate a presigned PUT URL for direct S3 upload.
38+
39+ Only meaningful on S3 storage; dev/test environments don't presign.
40+ """
41+ storage = self .file .storage
42+ if not isinstance (storage , S3Boto3Storage ):
43+ raise RuntimeError ("Presigned URLs are only supported on S3 storage" )
44+ # Storage prepends `location` (e.g. "private-media/") to the field name when
45+ # talking to S3; raw boto3 doesn't know that, so apply the same transform.
46+ s3_key = storage ._normalize_name (clean_name (self .file .name ))
47+ return storage .connection .meta .client .generate_presigned_url (
5448 "put_object" ,
55- Params = {
56- "Bucket" : self ._bucket_name ,
57- "Key" : self .s3_key ,
58- },
49+ Params = {"Bucket" : storage .bucket .name , "Key" : s3_key },
5950 ExpiresIn = 3600 ,
6051 )
6152
62- def copy_to (self , dest_key : str ) -> None :
63- """Copy to permanent location. Does not delete the source."""
64- self ._client .copy_object (
65- Bucket = self ._bucket_name ,
66- CopySource = {"Bucket" : self ._bucket_name , "Key" : self .s3_key },
67- Key = dest_key ,
68- )
53+ def exists_in_storage (self ) -> bool :
54+ return self .file .storage .exists (self .file .name )
6955
70- def copy_to_file_field (self , file_field , filename : str ) -> None :
71- """
72- Copy the uploaded file to a model's FileField.
56+ def copy_to_file_field (self , dest_file_field , filename : str ) -> None :
57+ """Copy the uploaded file into a model's FileField via S3-to-S3 copy."""
58+ if not self .exists_in_storage ():
59+ raise ValueError ("Upload not found in storage" )
7360
74- Uses S3-to-S3 copy (no data through the web worker), then sets the field's
75- name to the destination key.
76- """
77- if not self .exists_in_s3 ():
78- raise ValueError ("Upload not found in S3" )
79- dest_key = file_field .field .upload_to (file_field .instance , filename )
80- self .copy_to (dest_key )
81- file_field .name = dest_key
82-
83- def delete_s3_object (self ) -> None :
84- """Delete the S3 object."""
85- self ._client .delete_object (Bucket = self ._bucket_name , Key = self .s3_key )
86-
87- def exists_in_s3 (self ) -> bool :
88- """Check if the file was actually uploaded to S3."""
89- try :
90- self ._client .head_object (Bucket = self ._bucket_name , Key = self .s3_key )
91- return True
92- except ClientError as e :
93- if e .response ["Error" ]["Code" ] == "404" :
94- return False
95- raise
61+ src_storage = self .file .storage
62+ dst_storage = dest_file_field .storage
63+
64+ if not (isinstance (src_storage , S3Boto3Storage ) and isinstance (dst_storage , S3Boto3Storage )):
65+ raise RuntimeError ("copy_to_file_field requires S3 storage on both source and destination" )
66+ if src_storage .bucket .name != dst_storage .bucket .name :
67+ raise RuntimeError ("Cross-bucket copy is not supported" )
68+
69+ # Build the destination name through the field's upload_to and storage,
70+ # so any path transforms (location prefix, name conflict resolution) apply.
71+ dest_name = dest_file_field .field .generate_filename (dest_file_field .instance , filename )
72+
73+ client = src_storage .connection .meta .client
74+ client .copy_object (
75+ Bucket = dst_storage .bucket .name ,
76+ CopySource = {
77+ "Bucket" : src_storage .bucket .name ,
78+ "Key" : src_storage ._normalize_name (clean_name (self .file .name )),
79+ },
80+ Key = dst_storage ._normalize_name (clean_name (dest_name )),
81+ )
82+ dest_file_field .name = dest_name
83+
84+ def delete_from_storage (self ) -> None :
85+ self .file .storage .delete (self .file .name )
0 commit comments