Skip to content

Commit fed1ed4

Browse files
AADHIIIIclaude
andcommitted
Fix missing User model and add API endpoint tests
- Add api/models/user.py with User, UserRole, UserStatus, APIKey classes - Add api/models/__init__.py package init - Fix validation middleware to allow multipart/form-data uploads - Remove AI placeholder comments from evaluate.py and auth.py - Add get_user_api_keys() method to AuthService - Clean up unused imports in auth.py - Add comprehensive test suite (24 tests) for API endpoints Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 57233a0 commit fed1ed4

7 files changed

Lines changed: 623 additions & 18 deletions

File tree

api/blueprints/auth.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
"""
22
Authentication API endpoints.
33
"""
4-
from flask import Blueprint, request, jsonify, current_app
4+
from flask import Blueprint, request, jsonify
55
from datetime import datetime
66
import logging
77

88
from ..services.auth_service import AuthService
99
from ..models.user import UserRole
1010
from ..middleware.auth_middleware import require_auth, get_current_user
11-
from utils.exceptions import ValidationError
1211

1312

1413
logger = logging.getLogger(__name__)
@@ -247,13 +246,11 @@ def list_api_keys():
247246
try:
248247
current_user = get_current_user()
249248
auth_service = AuthService()
250-
251-
# Get user's API keys (implementation depends on repository)
252-
# For now, return empty list
249+
250+
api_keys = auth_service.get_user_api_keys(current_user['user_id'])
253251
return jsonify({
254252
'success': True,
255-
'api_keys': [],
256-
'message': 'API key listing not yet implemented'
253+
'api_keys': api_keys
257254
}), 200
258255

259256
except Exception as e:

api/blueprints/evaluate.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@
1111
def evaluate_prompts() -> Dict[str, Any]:
1212
"""
1313
Evaluate prompts across multiple models.
14-
14+
1515
Returns:
1616
JSON response with evaluation results
1717
"""
18-
# Placeholder implementation - will be implemented in later tasks
1918
return jsonify({
20-
'message': 'Evaluation endpoint - to be implemented'
21-
})
19+
'success': False,
20+
'error': 'not_implemented',
21+
'message': 'Evaluation endpoint not yet available'
22+
}), 501

api/middleware/validation_middleware.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,12 @@ def setup_validation_middleware(app: Flask) -> None:
2929
def validate_content_type() -> None:
3030
"""Validate content type for POST/PUT requests."""
3131
if request.method in ['POST', 'PUT']:
32-
if not request.is_json and request.content_length and request.content_length > 0:
32+
content_type = request.content_type or ''
33+
is_multipart = content_type.startswith('multipart/form-data')
34+
if not request.is_json and not is_multipart and request.content_length and request.content_length > 0:
3335
return jsonify({
3436
'error': 'validation_error',
35-
'message': 'Content-Type must be application/json for POST/PUT requests'
37+
'message': 'Content-Type must be application/json or multipart/form-data for POST/PUT requests'
3638
}), 400
3739

3840
@app.before_request

api/models/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""
2+
API models package.
3+
"""
4+
from .user import User, UserRole, UserStatus, APIKey
5+
6+
__all__ = ['User', 'UserRole', 'UserStatus', 'APIKey']

api/models/user.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"""
2+
User and authentication models.
3+
"""
4+
import uuid
5+
import hashlib
6+
import secrets
7+
from datetime import datetime
8+
from enum import Enum
9+
from typing import Optional, Dict, Any, List
10+
11+
12+
class UserRole(Enum):
13+
"""User role enumeration."""
14+
ADMIN = "admin"
15+
RESEARCHER = "researcher"
16+
DEVELOPER = "developer"
17+
VIEWER = "viewer"
18+
19+
20+
class UserStatus(Enum):
21+
"""User account status enumeration."""
22+
PENDING = "pending"
23+
ACTIVE = "active"
24+
INACTIVE = "inactive"
25+
SUSPENDED = "suspended"
26+
27+
28+
class User:
29+
"""User model for authentication and authorization."""
30+
31+
ROLE_PERMISSIONS = {
32+
UserRole.ADMIN: [
33+
'user:create', 'user:read', 'user:update', 'user:delete',
34+
'model:create', 'model:read', 'model:update', 'model:delete',
35+
'experiment:create', 'experiment:read', 'experiment:update', 'experiment:delete',
36+
'api_key:create', 'api_key:read', 'api_key:update', 'api_key:delete',
37+
'system:monitor', 'system:configure'
38+
],
39+
UserRole.RESEARCHER: [
40+
'model:create', 'model:read', 'model:update',
41+
'experiment:create', 'experiment:read', 'experiment:update', 'experiment:delete',
42+
'api_key:create', 'api_key:read', 'api_key:update'
43+
],
44+
UserRole.DEVELOPER: [
45+
'model:read', 'experiment:read', 'experiment:create',
46+
'api_key:create', 'api_key:read'
47+
],
48+
UserRole.VIEWER: [
49+
'model:read', 'experiment:read'
50+
]
51+
}
52+
53+
def __init__(
54+
self,
55+
username: str = "",
56+
email: str = "",
57+
role: UserRole = UserRole.VIEWER,
58+
status: UserStatus = UserStatus.PENDING
59+
):
60+
self.id = str(uuid.uuid4())
61+
self.username = username
62+
self.email = email
63+
self.role = role
64+
self.status = status
65+
self.password_hash: Optional[str] = None
66+
self.failed_login_attempts: int = 0
67+
self.locked_until: Optional[datetime] = None
68+
self.last_login: Optional[datetime] = None
69+
self.created_at: datetime = datetime.utcnow()
70+
71+
def set_password(self, password: str) -> None:
72+
"""Hash and store password with salt."""
73+
salt = secrets.token_hex(16)
74+
hash_obj = hashlib.sha256((salt + password).encode())
75+
self.password_hash = f"{salt}:{hash_obj.hexdigest()}"
76+
77+
def verify_password(self, password: str) -> bool:
78+
"""Verify password against stored hash."""
79+
if not self.password_hash:
80+
return False
81+
82+
try:
83+
salt, stored_hash = self.password_hash.split(':')
84+
hash_obj = hashlib.sha256((salt + password).encode())
85+
return hash_obj.hexdigest() == stored_hash
86+
except ValueError:
87+
return False
88+
89+
def is_active(self) -> bool:
90+
"""Check if user account is active and not locked."""
91+
if self.status != UserStatus.ACTIVE:
92+
return False
93+
94+
if self.locked_until and self.locked_until > datetime.utcnow():
95+
return False
96+
97+
return True
98+
99+
def has_permission(self, permission: str) -> bool:
100+
"""Check if user has the specified permission."""
101+
permissions = self.ROLE_PERMISSIONS.get(self.role, [])
102+
return permission in permissions
103+
104+
def to_dict(self, include_sensitive: bool = False) -> Dict[str, Any]:
105+
"""Convert user to dictionary."""
106+
data = {
107+
'id': self.id,
108+
'username': self.username,
109+
'email': self.email,
110+
'role': self.role.value,
111+
'status': self.status.value,
112+
'created_at': self.created_at.isoformat() if self.created_at else None,
113+
'last_login': self.last_login.isoformat() if self.last_login else None
114+
}
115+
116+
if include_sensitive:
117+
data['failed_login_attempts'] = self.failed_login_attempts
118+
data['locked_until'] = self.locked_until.isoformat() if self.locked_until else None
119+
120+
return data
121+
122+
@classmethod
123+
def from_dict(cls, data: Dict[str, Any]) -> 'User':
124+
"""Create user from dictionary."""
125+
role = UserRole(data.get('role', 'viewer'))
126+
status = UserStatus(data.get('status', 'pending'))
127+
128+
user = cls(
129+
username=data.get('username', ''),
130+
email=data.get('email', ''),
131+
role=role,
132+
status=status
133+
)
134+
135+
if 'id' in data:
136+
user.id = data['id']
137+
138+
return user
139+
140+
141+
class APIKey:
142+
"""API Key model for programmatic access."""
143+
144+
def __init__(
145+
self,
146+
user_id: str = "",
147+
name: str = "",
148+
permissions: Optional[List[str]] = None,
149+
expires_at: Optional[datetime] = None,
150+
is_active: bool = True
151+
):
152+
self.id = str(uuid.uuid4())
153+
self.user_id = user_id
154+
self.name = name
155+
self.permissions = permissions or []
156+
self.expires_at = expires_at
157+
self.is_active = is_active
158+
self.key_prefix: str = ""
159+
self.key_hash: str = ""
160+
self.usage_count: int = 0
161+
self.last_used: Optional[datetime] = None
162+
self.created_at: datetime = datetime.utcnow()
163+
164+
@staticmethod
165+
def generate_key() -> str:
166+
"""Generate a new API key string."""
167+
return f"llm_opt_{secrets.token_hex(24)}"
168+
169+
def set_key(self, key_string: str) -> None:
170+
"""Hash and store API key with prefix for lookup."""
171+
self.key_prefix = key_string[:8]
172+
hash_obj = hashlib.sha256(key_string.encode())
173+
self.key_hash = hash_obj.hexdigest()
174+
175+
def verify_key(self, key_string: str) -> bool:
176+
"""Verify API key against stored hash."""
177+
if not self.key_hash:
178+
return False
179+
180+
hash_obj = hashlib.sha256(key_string.encode())
181+
return hash_obj.hexdigest() == self.key_hash
182+
183+
def is_valid(self) -> bool:
184+
"""Check if API key is valid (active and not expired)."""
185+
if not self.is_active:
186+
return False
187+
188+
if self.expires_at and self.expires_at < datetime.utcnow():
189+
return False
190+
191+
return True
192+
193+
def record_usage(self) -> None:
194+
"""Record API key usage."""
195+
self.usage_count += 1
196+
self.last_used = datetime.utcnow()
197+
198+
def to_dict(self) -> Dict[str, Any]:
199+
"""Convert API key to dictionary (without sensitive data)."""
200+
return {
201+
'id': self.id,
202+
'user_id': self.user_id,
203+
'name': self.name,
204+
'key_prefix': self.key_prefix,
205+
'permissions': self.permissions,
206+
'is_active': self.is_active,
207+
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
208+
'usage_count': self.usage_count,
209+
'last_used': self.last_used.isoformat() if self.last_used else None,
210+
'created_at': self.created_at.isoformat() if self.created_at else None
211+
}

api/services/auth_service.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -261,18 +261,17 @@ def refresh_token(self, refresh_token: str) -> Dict[str, Any]:
261261
def get_user_permissions(self, user_id: str) -> List[str]:
262262
"""
263263
Get all permissions for user.
264-
264+
265265
Args:
266266
user_id: User ID
267-
267+
268268
Returns:
269269
List of permission strings
270270
"""
271271
user = self.user_repo.get_by_id(user_id)
272272
if not user:
273273
return []
274-
275-
# Get role-based permissions
274+
276275
role_permissions = {
277276
UserRole.ADMIN: [
278277
'user:create', 'user:read', 'user:update', 'user:delete',
@@ -294,8 +293,21 @@ def get_user_permissions(self, user_id: str) -> List[str]:
294293
'model:read', 'experiment:read'
295294
]
296295
}
297-
296+
298297
return role_permissions.get(user.role, [])
298+
299+
def get_user_api_keys(self, user_id: str) -> List[Dict[str, Any]]:
300+
"""
301+
Get all API keys for a user.
302+
303+
Args:
304+
user_id: User ID
305+
306+
Returns:
307+
List of API key dictionaries
308+
"""
309+
api_keys = self.api_key_repo.get_by_user(user_id)
310+
return [key.to_dict() for key in api_keys]
299311

300312
def _validate_user_input(self, username: str, email: str, password: str) -> Optional[str]:
301313
"""

0 commit comments

Comments
 (0)