|
| 1 | +# backend/supabase_storage.py |
1 | 2 | import os |
2 | | -import uuid |
3 | | -from urllib.parse import urljoin |
4 | 3 | from django.conf import settings |
5 | 4 | from django.core.files.storage import Storage |
| 5 | +from django.core.files.base import ContentFile |
| 6 | +from django.utils.deconstruct import deconstructible |
| 7 | + |
6 | 8 | from supabase import create_client, Client |
7 | 9 | from storage3.utils import StorageException |
8 | 10 |
|
| 11 | + |
| 12 | +@deconstructible |
9 | 13 | class SupabaseStorage(Storage): |
10 | 14 | """ |
11 | | - Custom storage backend for Supabase Storage |
| 15 | + Custom storage backend for Supabase Storage (déconstructible). |
| 16 | + Ne PAS mettre d'objet client dans __init__. |
12 | 17 | """ |
13 | | - def __init__(self, bucket_name=None): |
14 | | - # Initialize Supabase client |
15 | | - supabase_url = os.environ.get('SUPABASE_URL', '') |
16 | | - supabase_key = os.environ.get('SUPABASE_ANON_KEY', '') |
17 | | - |
18 | | - self.client: Client = create_client(supabase_url, supabase_key) |
| 18 | + def __init__( |
| 19 | + self, |
| 20 | + bucket_name=None, |
| 21 | + url_env="SUPABASE_URL", |
| 22 | + key_env="SUPABASE_ANON_KEY", |
| 23 | + signed_url_expiry=60 * 60 * 24 * 365, # 1 an |
| 24 | + ): |
| 25 | + # ⚠️ Uniquement des types simples ici |
19 | 26 | self.bucket_name = bucket_name |
20 | | - |
| 27 | + self.url_env = url_env |
| 28 | + self.key_env = key_env |
| 29 | + self.signed_url_expiry = signed_url_expiry |
| 30 | + # client non initialisé ici (lazy) |
| 31 | + |
| 32 | + # Client Supabase créé à la volée (lazy) et non sérialisé dans la migration |
| 33 | + @property |
| 34 | + def client(self) -> Client: |
| 35 | + supabase_url = os.environ.get(self.url_env, "") |
| 36 | + supabase_key = os.environ.get(self.key_env, "") |
| 37 | + if not supabase_url or not supabase_key: |
| 38 | + raise RuntimeError("SUPABASE_URL/SUPABASE_ANON_KEY non définis dans l'environnement.") |
| 39 | + return create_client(supabase_url, supabase_key) |
| 40 | + |
21 | 41 | def _get_storage(self): |
| 42 | + if not self.bucket_name: |
| 43 | + raise RuntimeError("bucket_name non défini pour SupabaseStorage") |
22 | 44 | return self.client.storage.from_(self.bucket_name) |
23 | | - |
24 | | - def _open(self, name, mode='rb'): |
25 | | - """ |
26 | | - Retrieve the file from Supabase Storage |
27 | | - """ |
| 45 | + |
| 46 | + # Déconstruction explicite (facultative avec @deconstructible mais plus sûr) |
| 47 | + def deconstruct(self): |
| 48 | + path = "backend.supabase_storage.SupabaseStorage" |
| 49 | + args = [] |
| 50 | + kwargs = { |
| 51 | + "bucket_name": self.bucket_name, |
| 52 | + "url_env": self.url_env, |
| 53 | + "key_env": self.key_env, |
| 54 | + "signed_url_expiry": self.signed_url_expiry, |
| 55 | + } |
| 56 | + return (path, args, kwargs) |
| 57 | + |
| 58 | + def _open(self, name, mode="rb"): |
28 | 59 | try: |
29 | | - # Get the file contents |
30 | 60 | response = self._get_storage().download(name) |
31 | | - |
32 | | - # Create a file-like object |
33 | | - from django.core.files.base import ContentFile |
34 | 61 | return ContentFile(response) |
35 | | - except StorageException as e: |
36 | | - # Handle error, e.g., file not found |
| 62 | + except StorageException: |
37 | 63 | raise FileNotFoundError(f"File {name} not found in bucket {self.bucket_name}") |
38 | 64 |
|
39 | 65 | def _ensure_folder_exists(self, path): |
40 | | - """ |
41 | | - Ensure that a folder exists in the bucket |
42 | | - Supabase requires folders to exist before files can be uploaded to them |
43 | | - """ |
44 | | - if '/' in path: |
45 | | - folder_path = path.rsplit('/', 1)[0] + '/' |
| 66 | + if "/" in path: |
| 67 | + folder_path = path.rsplit("/", 1)[0] + "/" |
46 | 68 | try: |
47 | | - # Check if folder exists by listing with prefix |
48 | | - folders = self._get_storage().list(path=folder_path) |
49 | | - # If we get here, the folder likely exists already |
| 69 | + _ = self._get_storage().list(path=folder_path) |
50 | 70 | except StorageException: |
51 | | - # Try to create the folder with an empty placeholder file |
52 | 71 | try: |
53 | | - self._get_storage().upload(folder_path + '.placeholder', b'') |
| 72 | + self._get_storage().upload(folder_path + ".placeholder", b"") |
54 | 73 | except StorageException as e: |
55 | | - # If folder already exists or we can't create it, just log and continue |
| 74 | + # non bloquant |
56 | 75 | print(f"Note: Could not verify/create folder {folder_path}: {e}") |
57 | | - |
| 76 | + |
58 | 77 | def _save(self, name, content): |
59 | | - """ |
60 | | - Save the file to Supabase Storage in the appropriate folder path |
61 | | - """ |
62 | 78 | try: |
63 | | - # Get the content as bytes |
64 | 79 | file_content = content.read() |
65 | | - |
66 | | - # Ensure the folder exists before uploading (if there's a path) |
67 | | - if '/' in name: |
| 80 | + if "/" in name: |
68 | 81 | self._ensure_folder_exists(name) |
69 | | - |
70 | | - # Upload to Supabase with the full path |
71 | | - result = self._get_storage().upload(name, file_content) |
72 | | - |
73 | | - # Return the file path that was saved |
| 82 | + _ = self._get_storage().upload(name, file_content) |
74 | 83 | return name |
75 | 84 | except StorageException as e: |
76 | | - # Handle upload error |
77 | 85 | raise IOError(f"Error saving file to Supabase Storage: {e}") |
78 | 86 |
|
79 | 87 | def delete(self, name): |
80 | | - """ |
81 | | - Delete the file from Supabase Storage |
82 | | - """ |
83 | 88 | try: |
84 | 89 | self._get_storage().remove([name]) |
85 | 90 | except StorageException: |
86 | | - # File doesn't exist, pass silently |
87 | 91 | pass |
88 | 92 |
|
89 | 93 | def exists(self, name): |
90 | | - """ |
91 | | - Check if a file exists in Supabase Storage |
92 | | - """ |
93 | 94 | try: |
94 | | - # Get folder path and filename |
95 | | - if '/' in name: |
96 | | - folder_path = name.rsplit('/', 1)[0] |
97 | | - filename = name.split('/')[-1] |
98 | | - # List files in the specific folder |
| 95 | + if "/" in name: |
| 96 | + folder_path = name.rsplit("/", 1)[0] |
| 97 | + filename = name.split("/")[-1] |
99 | 98 | files = self._get_storage().list(folder_path) |
100 | 99 | else: |
101 | | - # Files at bucket root |
102 | 100 | files = self._get_storage().list() |
103 | 101 | filename = name |
104 | | - |
105 | | - # Check if file exists in the folder |
106 | | - return any(file['name'] == filename for file in files) |
| 102 | + return any((f.get("name") or f.get("Name")) == filename for f in files) |
107 | 103 | except StorageException: |
108 | 104 | return False |
109 | 105 |
|
110 | 106 | def url(self, name): |
111 | | - """ |
112 | | - Return the public URL for a file |
113 | | - """ |
114 | 107 | try: |
115 | | - # Use the sign endpoint instead of public as it's what Supabase now requires |
116 | | - # The sign endpoint generates a URL with a token that allows access to the file |
117 | | - return self._get_storage().create_signed_url(name, 60*60*24*365) # 1 year expiry |
118 | | - except StorageException as e: |
| 108 | + signed = self._get_storage().create_signed_url(name, self.signed_url_expiry) |
| 109 | + # selon la version, la clé peut être 'signedURL' ou 'signed_url' |
| 110 | + if isinstance(signed, dict): |
| 111 | + return signed.get("signedURL") or signed.get("signed_url") or None |
| 112 | + return signed # fallback si lib renvoie directement une str |
| 113 | + except StorageException: |
| 114 | + # essai fallback naïf (inutile si path correct) |
119 | 115 | try: |
120 | | - # As fallback, try with just the filename |
121 | | - if '/' in name: |
122 | | - filename = name.split('/')[-1] |
123 | | - return self._get_storage().create_signed_url(filename, 60*60*24*365) |
124 | | - else: |
125 | | - # Already tried with the name, so it truly failed |
126 | | - return None |
| 116 | + filename = name.split("/")[-1] |
| 117 | + signed = self._get_storage().create_signed_url(filename, self.signed_url_expiry) |
| 118 | + if isinstance(signed, dict): |
| 119 | + return signed.get("signedURL") or signed.get("signed_url") or None |
| 120 | + return signed |
127 | 121 | except StorageException: |
128 | 122 | return None |
129 | 123 |
|
130 | 124 | def size(self, name): |
131 | | - """ |
132 | | - Return the size of a file |
133 | | - """ |
134 | 125 | try: |
135 | | - # Get folder path and filename |
136 | | - if '/' in name: |
137 | | - folder_path = name.rsplit('/', 1)[0] |
138 | | - filename = name.split('/')[-1] |
139 | | - # List files in the specific folder |
| 126 | + if "/" in name: |
| 127 | + folder_path = name.rsplit("/", 1)[0] |
| 128 | + filename = name.split("/")[-1] |
140 | 129 | files = self._get_storage().list(folder_path) |
141 | 130 | else: |
142 | | - # Files at bucket root |
143 | 131 | files = self._get_storage().list() |
144 | 132 | filename = name |
145 | | - |
146 | | - # Find the file and get its size |
147 | | - for file in files: |
148 | | - if file['name'] == filename: |
149 | | - return file.get('metadata', {}).get('size', 0) |
| 133 | + for f in files: |
| 134 | + if (f.get("name") or f.get("Name")) == filename: |
| 135 | + meta = f.get("metadata") or f.get("Metadata") or {} |
| 136 | + return meta.get("size") or meta.get("Size") or 0 |
150 | 137 | return 0 |
151 | 138 | except StorageException: |
152 | 139 | return 0 |
153 | 140 |
|
154 | 141 | def get_accessed_time(self, name): |
155 | | - return None # Not supported by Supabase Storage |
| 142 | + return None |
156 | 143 |
|
157 | 144 | def get_created_time(self, name): |
158 | | - return None # Not supported by Supabase Storage |
| 145 | + return None |
159 | 146 |
|
160 | 147 | def get_modified_time(self, name): |
161 | | - return None # Not supported by Supabase Storage |
| 148 | + return None |
162 | 149 |
|
163 | 150 |
|
| 151 | +@deconstructible |
164 | 152 | class ImageStorage(SupabaseStorage): |
165 | | - """ |
166 | | - Storage for images using the 'images' bucket |
167 | | - """ |
168 | | - def __init__(self): |
169 | | - super().__init__(bucket_name='images') |
| 153 | + def __init__(self, **kwargs): |
| 154 | + super().__init__(bucket_name="images", **kwargs) |
170 | 155 |
|
| 156 | + def deconstruct(self): |
| 157 | + path = "backend.supabase_storage.ImageStorage" |
| 158 | + return (path, [], {}) # pas d’args/kwargs car bucket fixé |
171 | 159 |
|
| 160 | + |
| 161 | +@deconstructible |
172 | 162 | class VideoStorage(SupabaseStorage): |
173 | | - """ |
174 | | - Storage for videos using the 'videos' bucket |
175 | | - """ |
176 | | - def __init__(self): |
177 | | - super().__init__(bucket_name='videos') |
| 163 | + def __init__(self, **kwargs): |
| 164 | + super().__init__(bucket_name="videos", **kwargs) |
| 165 | + |
| 166 | + def deconstruct(self): |
| 167 | + path = "backend.supabase_storage.VideoStorage" |
| 168 | + return (path, [], {}) |
178 | 169 |
|
179 | 170 |
|
| 171 | +@deconstructible |
180 | 172 | class VoiceStorage(SupabaseStorage): |
181 | | - """ |
182 | | - Storage for audio files using the 'voices' bucket |
183 | | - """ |
184 | | - def __init__(self): |
185 | | - super().__init__(bucket_name='voices') |
| 173 | + def __init__(self, **kwargs): |
| 174 | + super().__init__(bucket_name="voices", **kwargs) |
| 175 | + |
| 176 | + def deconstruct(self): |
| 177 | + path = "backend.supabase_storage.VoiceStorage" |
| 178 | + return (path, [], {}) |
0 commit comments