RoyAalekh commited on
Commit
54404f6
·
1 Parent(s): ee85faa

Add Supabase integration for persistent database and file storage

Browse files

- Add supabase and psycopg2-binary dependencies
- Create comprehensive Supabase client with database operations
- Support for private storage buckets with signed URLs
- Database operations: create, read, update, delete trees
- File storage: upload images and audio with persistence
- Configuration via environment variables for HF Spaces

Files changed (2) hide show
  1. requirements.txt +2 -0
  2. supabase_client.py +271 -0
requirements.txt CHANGED
@@ -8,3 +8,5 @@ pandas>=2.3.1
8
  aiofiles>=24.1.0
9
  GitPython>=3.1.40
10
  huggingface-hub>=0.19.0
 
 
 
8
  aiofiles>=24.1.0
9
  GitPython>=3.1.40
10
  huggingface-hub>=0.19.0
11
+ supabase>=2.3.4
12
+ psycopg2-binary>=2.9.9
supabase_client.py ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Supabase client configuration and database operations for TreeTrack
3
+ """
4
+
5
+ import os
6
+ import logging
7
+ from typing import Dict, List, Optional, Any
8
+ from supabase import create_client, Client
9
+ from pathlib import Path
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Supabase configuration
14
+ SUPABASE_URL = os.getenv("SUPABASE_URL", "https://puwgehualigbuxlopnkg.supabase.co")
15
+ SUPABASE_ANON_KEY = os.getenv("SUPABASE_ANON_KEY", "")
16
+ SUPABASE_SERVICE_ROLE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "")
17
+
18
+ # Initialize Supabase client
19
+ supabase: Optional[Client] = None
20
+
21
+ def get_supabase_client() -> Client:
22
+ """Get Supabase client instance"""
23
+ global supabase
24
+
25
+ if supabase is None:
26
+ if not SUPABASE_ANON_KEY:
27
+ raise ValueError("SUPABASE_ANON_KEY environment variable is required")
28
+
29
+ supabase = create_client(SUPABASE_URL, SUPABASE_ANON_KEY)
30
+ logger.info("Supabase client initialized")
31
+
32
+ return supabase
33
+
34
+ def get_service_client() -> Client:
35
+ """Get Supabase client with service role (admin) permissions"""
36
+ if not SUPABASE_SERVICE_ROLE_KEY:
37
+ raise ValueError("SUPABASE_SERVICE_ROLE_KEY environment variable is required")
38
+
39
+ return create_client(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)
40
+
41
+ # Database operations
42
+ class SupabaseTreeDB:
43
+ """Supabase database operations for trees"""
44
+
45
+ def __init__(self):
46
+ self.client = get_supabase_client()
47
+
48
+ def create_tree(self, tree_data: Dict[str, Any]) -> Dict[str, Any]:
49
+ """Create a new tree record"""
50
+ try:
51
+ result = self.client.table('trees').insert(tree_data).execute()
52
+ if result.data:
53
+ logger.info(f"Created tree with ID: {result.data[0].get('id')}")
54
+ return result.data[0]
55
+ else:
56
+ raise Exception("No data returned from insert operation")
57
+ except Exception as e:
58
+ logger.error(f"Error creating tree: {e}")
59
+ raise
60
+
61
+ def get_trees(self, limit: int = 100, offset: int = 0) -> List[Dict[str, Any]]:
62
+ """Get trees with pagination"""
63
+ try:
64
+ result = self.client.table('trees') \
65
+ .select("*") \
66
+ .order('created_at', desc=True) \
67
+ .range(offset, offset + limit - 1) \
68
+ .execute()
69
+
70
+ logger.info(f"Retrieved {len(result.data)} trees")
71
+ return result.data
72
+ except Exception as e:
73
+ logger.error(f"Error retrieving trees: {e}")
74
+ raise
75
+
76
+ def get_tree(self, tree_id: int) -> Optional[Dict[str, Any]]:
77
+ """Get a specific tree by ID"""
78
+ try:
79
+ result = self.client.table('trees') \
80
+ .select("*") \
81
+ .eq('id', tree_id) \
82
+ .execute()
83
+
84
+ if result.data:
85
+ return result.data[0]
86
+ return None
87
+ except Exception as e:
88
+ logger.error(f"Error retrieving tree {tree_id}: {e}")
89
+ raise
90
+
91
+ def update_tree(self, tree_id: int, tree_data: Dict[str, Any]) -> Dict[str, Any]:
92
+ """Update a tree record"""
93
+ try:
94
+ result = self.client.table('trees') \
95
+ .update(tree_data) \
96
+ .eq('id', tree_id) \
97
+ .execute()
98
+
99
+ if result.data:
100
+ logger.info(f"Updated tree with ID: {tree_id}")
101
+ return result.data[0]
102
+ else:
103
+ raise Exception(f"Tree with ID {tree_id} not found")
104
+ except Exception as e:
105
+ logger.error(f"Error updating tree {tree_id}: {e}")
106
+ raise
107
+
108
+ def delete_tree(self, tree_id: int) -> bool:
109
+ """Delete a tree record"""
110
+ try:
111
+ result = self.client.table('trees') \
112
+ .delete() \
113
+ .eq('id', tree_id) \
114
+ .execute()
115
+
116
+ logger.info(f"Deleted tree with ID: {tree_id}")
117
+ return True
118
+ except Exception as e:
119
+ logger.error(f"Error deleting tree {tree_id}: {e}")
120
+ raise
121
+
122
+ def get_tree_count(self) -> int:
123
+ """Get total number of trees"""
124
+ try:
125
+ result = self.client.table('trees') \
126
+ .select("id", count="exact") \
127
+ .execute()
128
+
129
+ return result.count if result.count is not None else 0
130
+ except Exception as e:
131
+ logger.error(f"Error getting tree count: {e}")
132
+ return 0
133
+
134
+ # File storage operations
135
+ class SupabaseStorage:
136
+ """Supabase storage operations for files"""
137
+
138
+ def __init__(self):
139
+ self.client = get_supabase_client()
140
+
141
+ def upload_file(self, bucket_name: str, file_path: str, file_data: bytes) -> str:
142
+ """Upload file to Supabase storage (private buckets)"""
143
+ try:
144
+ result = self.client.storage.from_(bucket_name).upload(file_path, file_data)
145
+
146
+ if result:
147
+ # For private buckets, return the file path (we'll generate signed URLs when needed)
148
+ logger.info(f"File uploaded successfully: {file_path}")
149
+ return file_path
150
+ else:
151
+ raise Exception("Upload failed")
152
+ except Exception as e:
153
+ logger.error(f"Error uploading file {file_path}: {e}")
154
+ raise
155
+
156
+ def delete_file(self, bucket_name: str, file_path: str) -> bool:
157
+ """Delete file from Supabase storage"""
158
+ try:
159
+ result = self.client.storage.from_(bucket_name).remove([file_path])
160
+ logger.info(f"File deleted: {file_path}")
161
+ return True
162
+ except Exception as e:
163
+ logger.error(f"Error deleting file {file_path}: {e}")
164
+ return False
165
+
166
+ def get_public_url(self, bucket_name: str, file_path: str) -> str:
167
+ """Get public URL for a file"""
168
+ return self.client.storage.from_(bucket_name).get_public_url(file_path)
169
+
170
+ def get_signed_url(self, bucket_name: str, file_path: str, expires_in: int = 3600) -> str:
171
+ """Get signed URL for private file (expires in seconds, default 1 hour)"""
172
+ try:
173
+ result = self.client.storage.from_(bucket_name).create_signed_url(file_path, expires_in)
174
+ if result and 'signedURL' in result:
175
+ return result['signedURL']
176
+ else:
177
+ raise Exception("Failed to generate signed URL")
178
+ except Exception as e:
179
+ logger.error(f"Error generating signed URL for {file_path}: {e}")
180
+ raise
181
+
182
+ # Initialize database table (run once)
183
+ def create_trees_table():
184
+ """Create the trees table in Supabase"""
185
+ client = get_service_client()
186
+
187
+ # SQL to create trees table
188
+ sql = """
189
+ CREATE TABLE IF NOT EXISTS trees (
190
+ id BIGSERIAL PRIMARY KEY,
191
+ latitude DECIMAL(10, 8) NOT NULL CHECK (latitude >= -90 AND latitude <= 90),
192
+ longitude DECIMAL(11, 8) NOT NULL CHECK (longitude >= -180 AND longitude <= 180),
193
+ local_name VARCHAR(200),
194
+ scientific_name VARCHAR(200),
195
+ common_name VARCHAR(200),
196
+ tree_code VARCHAR(20),
197
+ height DECIMAL(5, 2) CHECK (height > 0 AND height <= 200),
198
+ width DECIMAL(6, 2) CHECK (width > 0 AND width <= 2000),
199
+ utility JSONB,
200
+ storytelling_text TEXT CHECK (LENGTH(storytelling_text) <= 5000),
201
+ storytelling_audio VARCHAR(500),
202
+ phenology_stages JSONB,
203
+ photographs JSONB,
204
+ notes TEXT CHECK (LENGTH(notes) <= 2000),
205
+ created_at TIMESTAMPTZ DEFAULT NOW(),
206
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
207
+ created_by VARCHAR(100) DEFAULT 'system'
208
+ );
209
+
210
+ -- Create indexes for better performance
211
+ CREATE INDEX IF NOT EXISTS idx_trees_location ON trees(latitude, longitude);
212
+ CREATE INDEX IF NOT EXISTS idx_trees_scientific_name ON trees(scientific_name);
213
+ CREATE INDEX IF NOT EXISTS idx_trees_local_name ON trees(local_name);
214
+ CREATE INDEX IF NOT EXISTS idx_trees_tree_code ON trees(tree_code);
215
+ CREATE INDEX IF NOT EXISTS idx_trees_created_at ON trees(created_at);
216
+
217
+ -- Create updated_at trigger
218
+ CREATE OR REPLACE FUNCTION update_updated_at_column()
219
+ RETURNS TRIGGER AS $$
220
+ BEGIN
221
+ NEW.updated_at = NOW();
222
+ RETURN NEW;
223
+ END;
224
+ $$ language 'plpgsql';
225
+
226
+ DROP TRIGGER IF EXISTS update_trees_updated_at ON trees;
227
+ CREATE TRIGGER update_trees_updated_at
228
+ BEFORE UPDATE ON trees
229
+ FOR EACH ROW
230
+ EXECUTE FUNCTION update_updated_at_column();
231
+ """
232
+
233
+ try:
234
+ client.rpc('exec_sql', {'sql': sql}).execute()
235
+ logger.info("Trees table created successfully")
236
+ return True
237
+ except Exception as e:
238
+ logger.error(f"Error creating trees table: {e}")
239
+ return False
240
+
241
+ # Storage buckets setup
242
+ def create_storage_buckets():
243
+ """Create storage buckets for files"""
244
+ client = get_service_client()
245
+
246
+ buckets = [
247
+ {"name": "tree-images", "public": True},
248
+ {"name": "tree-audio", "public": True}
249
+ ]
250
+
251
+ for bucket in buckets:
252
+ try:
253
+ result = client.storage.create_bucket(bucket["name"], {"public": bucket["public"]})
254
+ logger.info(f"Created storage bucket: {bucket['name']}")
255
+ except Exception as e:
256
+ logger.info(f"Bucket {bucket['name']} might already exist: {e}")
257
+
258
+ return True
259
+
260
+ # Test connection
261
+ def test_supabase_connection() -> bool:
262
+ """Test Supabase connection"""
263
+ try:
264
+ client = get_supabase_client()
265
+ # Try to query the auth users (should work with anon key)
266
+ result = client.table('trees').select("id").limit(1).execute()
267
+ logger.info("Supabase connection successful")
268
+ return True
269
+ except Exception as e:
270
+ logger.error(f"Supabase connection failed: {e}")
271
+ return False