Rajhuggingface4253 commited on
Commit
f50de1f
·
verified ·
1 Parent(s): 138f1ff

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +511 -0
  2. dockerfile +39 -0
  3. requirements.txt +7 -0
app.py ADDED
@@ -0,0 +1,511 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import random
3
+ import string
4
+ from typing import List, Optional, Any
5
+ from datetime import datetime, timedelta
6
+ from fastapi import FastAPI, HTTPException, Depends, Query, Request, status
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from pydantic import BaseModel, Field, field_validator, model_validator, ConfigDict
9
+ from pydantic import HttpUrl, EmailStr
10
+ from sqlalchemy import create_engine, Column, String, Integer, Float, Boolean, Text, DateTime, func
11
+ from sqlalchemy.orm import declarative_base, sessionmaker, Session
12
+ import os
13
+ from fastapi.responses import JSONResponse
14
+
15
+ # =============================================================================
16
+ # DATABASE SETUP
17
+ # =============================================================================
18
+ if os.path.exists("/data"):
19
+ DATABASE_DIR = "/data"
20
+ print("✅ PRODUCTION MODE: Using Persistent Storage at /data")
21
+ else:
22
+ DATABASE_DIR = os.path.join(os.getcwd(), "data")
23
+ os.makedirs(DATABASE_DIR, exist_ok=True)
24
+ print(f"⚠️ LOCAL MODE: Using local storage at {DATABASE_DIR}")
25
+
26
+ # 2. Set the Database URL
27
+ SQLALCHEMY_DATABASE_URL = f"sqlite:///{DATABASE_DIR}/tools.db"
28
+
29
+ # 3. Create Engine
30
+ engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
31
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
32
+ Base = declarative_base()
33
+
34
+ # =============================================================================
35
+ # DATABASE MODELS
36
+ # =============================================================================
37
+ class Tool(Base):
38
+ __tablename__ = "tools"
39
+
40
+ id = Column(Integer, primary_key=True, index=True)
41
+ slug = Column(String, unique=True, index=True)
42
+ name = Column(String, index=True)
43
+ description = Column(String)
44
+ url = Column(String)
45
+ category = Column(String)
46
+ tags = Column(String) # JSON string
47
+ developer_name = Column(String)
48
+ developer_website = Column(String)
49
+ developer_email = Column(String)
50
+ price = Column(Float, nullable=True)
51
+ pricing_model = Column(String, nullable=True)
52
+ notes = Column(Text, nullable=True)
53
+ submission_date = Column(DateTime, default=func.now())
54
+ status = Column(String, default="pending")
55
+ featured = Column(Boolean, default=False)
56
+ rating = Column(Float, default=0.0)
57
+ thumbnail = Column(String, nullable=True)
58
+ updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
59
+ promoted = Column(Boolean, default=False)
60
+
61
+ # Create tables
62
+ Base.metadata.create_all(bind=engine)
63
+
64
+ # =============================================================================
65
+ # PYDANTIC V2 SCHEMAS
66
+ # =============================================================================
67
+ class Developer(BaseModel):
68
+ model_config = ConfigDict(from_attributes=True)
69
+
70
+ name: str = Field(..., min_length=2, max_length=100)
71
+ website: str = Field(..., pattern=r'^https?://') # Simplified URL validation
72
+ email: Optional[str] = Field(None, pattern=r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
73
+
74
+ @field_validator('website')
75
+ @classmethod
76
+ def validate_website(cls, v: str) -> str:
77
+ if not v.startswith(('http://', 'https://')):
78
+ return f'https://{v}'
79
+ return v
80
+
81
+ class ToolCreate(BaseModel):
82
+ model_config = ConfigDict(from_attributes=True)
83
+
84
+ name: str = Field(..., min_length=2, max_length=100)
85
+ description: str = Field(..., min_length=10, max_length=500)
86
+ url: str = Field(..., pattern=r'^https?://')
87
+ category: str = Field(...)
88
+ tags: List[str] = Field(..., min_length=1)
89
+ developer: Developer
90
+ price: Optional[float] = Field(None, ge=0)
91
+ pricingModel: Optional[str] = Field(None)
92
+ notes: Optional[str] = Field(None)
93
+ terms: bool = Field(..., description="Must agree to terms")
94
+
95
+ @field_validator('url')
96
+ @classmethod
97
+ def validate_url(cls, v: str) -> str:
98
+ if not v.startswith(('http://', 'https://')):
99
+ return f'https://{v}'
100
+ return v
101
+
102
+ @field_validator('terms')
103
+ @classmethod
104
+ def validate_terms(cls, v: bool) -> bool:
105
+ if not v:
106
+ raise ValueError('You must agree to the terms')
107
+ return v
108
+
109
+ class ToolUpdate(BaseModel):
110
+ model_config = ConfigDict(from_attributes=True)
111
+
112
+ name: Optional[str] = Field(None, min_length=2, max_length=100)
113
+ description: Optional[str] = Field(None, min_length=10, max_length=500)
114
+ url: Optional[str] = Field(None, pattern=r'^https?://')
115
+ category: Optional[str] = None
116
+ tags: Optional[List[str]] = None
117
+ developer: Optional[Developer] = None
118
+ price: Optional[float] = Field(None, ge=0)
119
+ pricingModel: Optional[str] = None
120
+ notes: Optional[str] = None
121
+ status: Optional[str] = Field(None, pattern=r'^(pending|approved|rejected)$')
122
+ featured: Optional[bool] = None
123
+ rating: Optional[float] = Field(None, ge=0, le=5)
124
+ thumbnail: Optional[str] = None
125
+ promoted: Optional[bool] = None
126
+
127
+ @field_validator('url')
128
+ @classmethod
129
+ def validate_url(cls, v: Optional[str]) -> Optional[str]:
130
+ if v and not v.startswith(('http://', 'https://')):
131
+ return f'https://{v}'
132
+ return v
133
+
134
+ class ToolResponse(BaseModel):
135
+ model_config = ConfigDict(from_attributes=True)
136
+
137
+ id: str # Slug
138
+ name: str
139
+ description: str
140
+ url: str
141
+ category: str
142
+ tags: List[str]
143
+ developer: Developer
144
+ price: Optional[float] = None
145
+ pricingModel: Optional[str] = None
146
+ submissionDate: str
147
+ status: str
148
+ featured: bool
149
+ rating: float
150
+ thumbnail: Optional[str] = None
151
+ updatedAt: Optional[str] = None
152
+ promoted: bool = False
153
+
154
+ # =============================================================================
155
+ # FASTAPI APP SETUP
156
+ # =============================================================================
157
+ app = FastAPI(
158
+ title="LinkForge API",
159
+ description="Backend API for LinkForge Tool Management System",
160
+ version="2.0.0",
161
+ docs_url="/api/docs",
162
+ redoc_url="/api/redoc"
163
+ )
164
+
165
+ # CORS Configuration - Allow all origins for development
166
+ app.add_middleware(
167
+ CORSMiddleware,
168
+ allow_origins=["*"],
169
+ allow_credentials=True,
170
+ allow_methods=["*"],
171
+ allow_headers=["*"],
172
+ )
173
+
174
+ # =============================================================================
175
+ # DEPENDENCIES & UTILITIES
176
+ # =============================================================================
177
+ def get_db():
178
+ db = SessionLocal()
179
+ try:
180
+ yield db
181
+ finally:
182
+ db.close()
183
+
184
+ def generate_slug():
185
+ return 'tool_' + ''.join(random.choices(string.ascii_lowercase + string.digits, k=9))
186
+
187
+ def map_db_to_response(db_tool: Tool) -> ToolResponse:
188
+ """Convert SQLAlchemy model to Pydantic response model"""
189
+ try:
190
+ tags_list = json.loads(db_tool.tags) if db_tool.tags else []
191
+ except:
192
+ tags_list = []
193
+
194
+ return ToolResponse(
195
+ id=db_tool.slug,
196
+ name=db_tool.name,
197
+ description=db_tool.description,
198
+ url=db_tool.url,
199
+ category=db_tool.category,
200
+ tags=tags_list,
201
+ developer=Developer(
202
+ name=db_tool.developer_name,
203
+ website=db_tool.developer_website,
204
+ email=db_tool.developer_email
205
+ ),
206
+ price=db_tool.price,
207
+ pricingModel=db_tool.pricing_model,
208
+ submissionDate=db_tool.submission_date.isoformat(),
209
+ status=db_tool.status,
210
+ featured=db_tool.featured,
211
+ rating=db_tool.rating,
212
+ thumbnail=db_tool.thumbnail,
213
+ updatedAt=db_tool.updated_at.isoformat() if db_tool.updated_at else None
214
+ )
215
+
216
+ # =============================================================================
217
+ # HEALTH CHECK
218
+ # =============================================================================
219
+ @app.get("/", tags=["Health"])
220
+ async def root():
221
+ return {
222
+ "message": "LinkForge API v2.0.0",
223
+ "status": "running",
224
+ "timestamp": datetime.now().isoformat(),
225
+ "endpoints": {
226
+ "GET /api/tools": "List tools",
227
+ "POST /api/tools": "Create tool",
228
+ "GET /api/tools/{slug}": "Get tool",
229
+ "PATCH /api/tools/{slug}": "Update tool",
230
+ "DELETE /api/tools/{slug}": "Delete tool",
231
+ "GET /api/stats": "Get statistics",
232
+ "GET /api/health": "Health check"
233
+ }
234
+ }
235
+
236
+ @app.get("/api/health", tags=["Health"])
237
+ async def health_check():
238
+ return {
239
+ "status": "healthy",
240
+ "timestamp": datetime.now().isoformat(),
241
+ "service": "linkforge-api",
242
+ "version": "2.0.0"
243
+ }
244
+
245
+ # =============================================================================
246
+ # API ENDPOINTS
247
+ # =============================================================================
248
+ @app.post("/api/tools",
249
+ response_model=ToolResponse,
250
+ status_code=status.HTTP_201_CREATED,
251
+ tags=["Tools"])
252
+ def create_tool(tool: ToolCreate, db: Session = Depends(get_db)):
253
+ """
254
+ Create a new tool submission
255
+ """
256
+ # Check for duplicate URL
257
+ existing = db.query(Tool).filter(Tool.url == tool.url).first()
258
+ if existing:
259
+ raise HTTPException(
260
+ status_code=status.HTTP_400_BAD_REQUEST,
261
+ detail="A tool with this URL already exists."
262
+ )
263
+
264
+ # Create database object
265
+ db_tool = Tool(
266
+ slug=generate_slug(),
267
+ name=tool.name,
268
+ description=tool.description,
269
+ url=tool.url,
270
+ category=tool.category,
271
+ tags=json.dumps(tool.tags),
272
+ developer_name=tool.developer.name,
273
+ developer_website=tool.developer.website,
274
+ developer_email=tool.developer.email,
275
+ price=tool.price,
276
+ pricing_model=tool.pricingModel,
277
+ notes=tool.notes,
278
+ status="pending",
279
+ featured=False,
280
+ rating=0.0
281
+ )
282
+
283
+ try:
284
+ db.add(db_tool)
285
+ db.commit()
286
+ db.refresh(db_tool)
287
+ return map_db_to_response(db_tool)
288
+ except Exception as e:
289
+ db.rollback()
290
+ raise HTTPException(
291
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
292
+ detail=f"Database error: {str(e)}"
293
+ )
294
+
295
+ @app.get("/api/tools", response_model=List[ToolResponse], tags=["Tools"])
296
+ def get_tools(
297
+ skip: int = Query(0, ge=0, description="Number of items to skip"),
298
+ limit: int = Query(100, ge=1, le=1000, description="Number of items to return"),
299
+ status: Optional[str] = Query(None, description="Filter by status"),
300
+ category: Optional[str] = Query(None, description="Filter by category"),
301
+ featured: Optional[bool] = Query(None, description="Filter by featured status"),
302
+ search: Optional[str] = Query(None, description="Search in name and description"),
303
+ db: Session = Depends(get_db)
304
+ ):
305
+ """
306
+ Get paginated list of tools with filtering options
307
+ """
308
+ query = db.query(Tool)
309
+
310
+ # Apply filters
311
+ if status:
312
+ query = query.filter(Tool.status == status)
313
+ if category:
314
+ query = query.filter(Tool.category == category)
315
+ if featured is not None:
316
+ query = query.filter(Tool.featured == featured)
317
+ if search:
318
+ search_term = f"%{search}%"
319
+ query = query.filter(
320
+ (Tool.name.ilike(search_term)) |
321
+ (Tool.description.ilike(search_term))
322
+ )
323
+
324
+ # Get results
325
+ tools = query.order_by(Tool.submission_date.desc()).offset(skip).limit(limit).all()
326
+ return [map_db_to_response(t) for t in tools]
327
+
328
+ @app.get("/api/tools/{slug}", response_model=ToolResponse, tags=["Tools"])
329
+ def get_tool(slug: str, db: Session = Depends(get_db)):
330
+ """
331
+ Get a specific tool by slug
332
+ """
333
+ tool = db.query(Tool).filter(Tool.slug == slug).first()
334
+ if not tool:
335
+ raise HTTPException(
336
+ status_code=status.HTTP_404_NOT_FOUND,
337
+ detail="Tool not found"
338
+ )
339
+ return map_db_to_response(tool)
340
+
341
+ @app.patch("/api/tools/{slug}", response_model=ToolResponse, tags=["Tools"])
342
+ def update_tool(slug: str, tool_update: ToolUpdate, db: Session = Depends(get_db)):
343
+ """
344
+ Update a tool
345
+ """
346
+ db_tool = db.query(Tool).filter(Tool.slug == slug).first()
347
+ if not db_tool:
348
+ raise HTTPException(
349
+ status_code=status.HTTP_404_NOT_FOUND,
350
+ detail="Tool not found"
351
+ )
352
+
353
+ # Update fields if provided
354
+ update_data = tool_update.model_dump(exclude_unset=True)
355
+
356
+ if 'pricingModel' in update_data:
357
+ update_data['pricing_model'] = update_data.pop('pricingModel')
358
+
359
+ if 'developer' in update_data:
360
+ developer = update_data.pop('developer')
361
+ db_tool.developer_name = developer['name']
362
+ db_tool.developer_website = developer['website']
363
+ db_tool.developer_email = developer['email']
364
+
365
+ if 'tags' in update_data:
366
+ db_tool.tags = json.dumps(update_data.pop('tags'))
367
+
368
+ # Update remaining fields
369
+ for key, value in update_data.items():
370
+ if hasattr(db_tool, key):
371
+ setattr(db_tool, key, value)
372
+
373
+ try:
374
+ db.commit()
375
+ db.refresh(db_tool)
376
+ return map_db_to_response(db_tool)
377
+ except Exception as e:
378
+ db.rollback()
379
+ raise HTTPException(
380
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
381
+ detail=f"Update failed: {str(e)}"
382
+ )
383
+
384
+
385
+ @app.patch("/api/tools/{tool_id}", response_model=ToolResponse, tags=["Tools"])
386
+ def update_tool(tool_id: str, tool_update: ToolUpdate, db: Session = Depends(get_db)):
387
+ # 1. Find the tool
388
+ db_tool = db.query(Tool).filter(Tool.id == tool_id).first()
389
+ if not db_tool:
390
+ raise HTTPException(status_code=404, detail="Tool not found")
391
+
392
+ # 2. Update only the fields provided in the request
393
+ # This magic line ensures that sending {"promoted": true} ONLY updates that one flag
394
+ update_data = tool_update.dict(exclude_unset=True)
395
+
396
+ for key, value in update_data.items():
397
+ setattr(db_tool, key, value)
398
+
399
+ # 3. Save and refresh
400
+ db.commit()
401
+ db.refresh(db_tool)
402
+
403
+ # 4. Return the updated tool
404
+ return map_db_to_response(db_tool)
405
+
406
+ @app.delete("/api/tools/{slug}", tags=["Tools"])
407
+ def delete_tool(slug: str, db: Session = Depends(get_db)):
408
+ """
409
+ Delete a tool
410
+ """
411
+ tool = db.query(Tool).filter(Tool.slug == slug).first()
412
+ if not tool:
413
+ raise HTTPException(
414
+ status_code=status.HTTP_404_NOT_FOUND,
415
+ detail="Tool not found"
416
+ )
417
+
418
+ try:
419
+ db.delete(tool)
420
+ db.commit()
421
+ return {
422
+ "message": "Tool deleted successfully",
423
+ "slug": slug
424
+ }
425
+ except Exception as e:
426
+ db.rollback()
427
+ raise HTTPException(
428
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
429
+ detail=f"Deletion failed: {str(e)}"
430
+ )
431
+
432
+ @app.get("/api/stats", tags=["Statistics"])
433
+ def get_statistics(db: Session = Depends(get_db)):
434
+ """
435
+ Get system statistics
436
+ """
437
+ try:
438
+ total = db.query(Tool).count()
439
+ pending = db.query(Tool).filter(Tool.status == "pending").count()
440
+ approved = db.query(Tool).filter(Tool.status == "approved").count()
441
+ rejected = db.query(Tool).filter(Tool.status == "rejected").count()
442
+ featured = db.query(Tool).filter(Tool.featured == True).count()
443
+
444
+ # Get category distribution
445
+ categories = db.query(
446
+ Tool.category,
447
+ func.count(Tool.id).label('count')
448
+ ).group_by(Tool.category).all()
449
+
450
+ # Get recent submissions (last 7 days)
451
+ week_ago = datetime.now() - timedelta(days=7)
452
+ recent = db.query(Tool).filter(Tool.submission_date >= week_ago).count()
453
+
454
+ return {
455
+ "total": total,
456
+ "pending": pending,
457
+ "approved": approved,
458
+ "rejected": rejected,
459
+ "featured": featured,
460
+ "recent_submissions": recent,
461
+ "categories": [{"name": c[0], "count": c[1]} for c in categories],
462
+ "updated": datetime.now().isoformat()
463
+ }
464
+ except Exception as e:
465
+ raise HTTPException(
466
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
467
+ detail=f"Failed to fetch statistics: {str(e)}"
468
+ )
469
+
470
+ # =============================================================================
471
+ # ERROR HANDLING
472
+ # =============================================================================
473
+ @app.exception_handler(HTTPException)
474
+ async def http_exception_handler(request: Request, exc: HTTPException):
475
+ return JSONResponse(
476
+ status_code=exc.status_code,
477
+ content={
478
+ "detail": exc.detail,
479
+ "path": request.url.path,
480
+ "method": request.method
481
+ }
482
+ )
483
+
484
+ @app.exception_handler(Exception)
485
+ async def general_exception_handler(request: Request, exc: Exception):
486
+ return JSONResponse(
487
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
488
+ content={
489
+ "detail": "Internal server error",
490
+ "error": str(exc),
491
+ "path": request.url.path
492
+ }
493
+ )
494
+
495
+ # =============================================================================
496
+ # MAIN EXECUTION
497
+ # =============================================================================
498
+ if __name__ == "__main__":
499
+ import uvicorn
500
+ from fastapi.responses import JSONResponse
501
+ from datetime import timedelta
502
+
503
+
504
+
505
+ uvicorn.run(
506
+ "app:app",
507
+ host="0.0.0.0",
508
+ port=7860,
509
+ log_level="info",
510
+ reload=True
511
+ )
dockerfile ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 1. Use Python 3.11 Slim Bookworm (Lightweight & Stable)
2
+ FROM python:3.11-slim-bookworm
3
+
4
+ # 2. Set working directory
5
+ WORKDIR /app
6
+
7
+ # 3. Create a non-root user (Critical for HF Spaces security permissions)
8
+ # We use ID 1000 which matches the standard HF user
9
+ RUN useradd -m -u 1000 user
10
+
11
+ # 4. Install system dependencies if needed (optional, keeping it slim)
12
+ # RUN apt-get update && apt-get install -y --no-install-recommends ...
13
+
14
+ # 5. Copy requirements first (Docker caching optimization)
15
+ COPY requirements.txt .
16
+
17
+ # 6. Install Python dependencies
18
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
19
+
20
+ # 7. Copy the rest of the application
21
+ COPY --chown=user . .
22
+
23
+ # 8. Create a local data directory & set permissions (For fallback/init)
24
+ RUN mkdir -p /app/data && chown -R user:user /app/data
25
+
26
+ # 9. Switch to the non-root user
27
+ USER user
28
+
29
+ # 10. Set environment variables
30
+ ENV PYTHONUNBUFFERED=1 \
31
+ HOME=/home/user \
32
+ PATH=/home/user/.local/bin:$PATH
33
+
34
+ # 11. Expose the standard Hugging Face port
35
+ EXPOSE 7860
36
+
37
+ # 12. Start the application
38
+ # NOTE: We force port 7860 here, overriding the 8000 in your app.py
39
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ sqlalchemy
4
+ pydantic
5
+ pydantic-settings
6
+ python-multipart
7
+ email-validator