api support
This commit is contained in:
381
app.py
381
app.py
@@ -5,6 +5,8 @@ from bson.objectid import ObjectId
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import os
|
import os
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
import jwt
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
@@ -65,6 +67,23 @@ def get_dashboard_context(user_id):
|
|||||||
|
|
||||||
return current_entry, active_tasks
|
return current_entry, active_tasks
|
||||||
|
|
||||||
|
# --- API Helpers ---
|
||||||
|
def token_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
token = request.headers.get('Authorization')
|
||||||
|
if not token:
|
||||||
|
return jsonify({'message': 'Token is missing!'}), 401
|
||||||
|
try:
|
||||||
|
if token.startswith('Bearer '):
|
||||||
|
token = token.split(" ")[1]
|
||||||
|
data = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"])
|
||||||
|
current_user_id = data['user_id']
|
||||||
|
except:
|
||||||
|
return jsonify({'message': 'Token is invalid!'}), 401
|
||||||
|
return f(current_user_id, *args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
# --- Routes ---
|
# --- Routes ---
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
@@ -795,6 +814,368 @@ def delete_goal(goal_id):
|
|||||||
db.goals.delete_one({'_id': ObjectId(goal_id), 'user_id': get_user_id()})
|
db.goals.delete_one({'_id': ObjectId(goal_id), 'user_id': get_user_id()})
|
||||||
return redirect(url_for('goals'))
|
return redirect(url_for('goals'))
|
||||||
|
|
||||||
|
# --- Auth API ---
|
||||||
|
|
||||||
|
@app.route('/api/login', methods=['POST'])
|
||||||
|
def api_login():
|
||||||
|
auth = request.get_json()
|
||||||
|
if not auth or not auth.get('username') or not auth.get('password'):
|
||||||
|
return jsonify({'message': 'Missing credentials'}), 400
|
||||||
|
|
||||||
|
user = db.users.find_one({'username': auth['username']})
|
||||||
|
if user and bcrypt.check_password_hash(user['password'], auth['password']):
|
||||||
|
token = jwt.encode({
|
||||||
|
'user_id': str(user['_id']),
|
||||||
|
'exp': datetime.utcnow() + timedelta(days=30)
|
||||||
|
}, app.config['SECRET_KEY'], algorithm="HS256")
|
||||||
|
return jsonify({'token': token})
|
||||||
|
|
||||||
|
return jsonify({'message': 'Invalid credentials'}), 401
|
||||||
|
|
||||||
|
@app.route('/api/register', methods=['POST'])
|
||||||
|
def api_register():
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or not data.get('username') or not data.get('password'):
|
||||||
|
return jsonify({'message': 'Missing credentials'}), 400
|
||||||
|
|
||||||
|
if db.users.find_one({'username': data['username']}):
|
||||||
|
return jsonify({'message': 'Username already exists'}), 409
|
||||||
|
|
||||||
|
hashed_password = bcrypt.generate_password_hash(data['password']).decode('utf-8')
|
||||||
|
user_id = db.users.insert_one({'username': data['username'], 'password': hashed_password}).inserted_id
|
||||||
|
|
||||||
|
# Auto login on register
|
||||||
|
token = jwt.encode({
|
||||||
|
'user_id': str(user_id),
|
||||||
|
'exp': datetime.utcnow() + timedelta(days=30)
|
||||||
|
}, app.config['SECRET_KEY'], algorithm="HS256")
|
||||||
|
|
||||||
|
return jsonify({'token': token})
|
||||||
|
|
||||||
|
# --- Dashboard / Status API ---
|
||||||
|
|
||||||
|
@app.route('/api/status', methods=['GET'])
|
||||||
|
@token_required
|
||||||
|
def api_status(user_id):
|
||||||
|
# Get standard dashboard context
|
||||||
|
current_entry, active_tasks = get_dashboard_context(user_id)
|
||||||
|
activities = list(db.activities.find({'user_id': user_id}))
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'is_tracking': current_entry is not None,
|
||||||
|
'entry': {
|
||||||
|
'id': str(current_entry['_id']),
|
||||||
|
'activity_id': str(current_entry['activity_id']),
|
||||||
|
'activity_name': current_entry.get('activity_name'),
|
||||||
|
'activity_color': current_entry.get('activity_color'),
|
||||||
|
'start_time': current_entry['start_time'].isoformat(),
|
||||||
|
'subcategory': current_entry.get('subcategory', ''),
|
||||||
|
'note': current_entry.get('note', '')
|
||||||
|
} if current_entry else None,
|
||||||
|
'active_tasks': [{
|
||||||
|
'id': str(t['_id']),
|
||||||
|
'name': t['name'],
|
||||||
|
'status': t['status'],
|
||||||
|
'completed_at': t['completed_at'].isoformat() if t.get('completed_at') else None
|
||||||
|
} for t in active_tasks],
|
||||||
|
'activities': [{
|
||||||
|
'id': str(a['_id']),
|
||||||
|
'name': a['name'],
|
||||||
|
'color': a.get('color', '#3498db'),
|
||||||
|
'subcategories': a.get('subcategories', [])
|
||||||
|
} for a in activities]
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- Activity Management API ---
|
||||||
|
|
||||||
|
@app.route('/api/activities/create', methods=['POST'])
|
||||||
|
@token_required
|
||||||
|
def api_create_activity(user_id):
|
||||||
|
data = request.get_json()
|
||||||
|
name = data.get('name')
|
||||||
|
if not name: return jsonify({'message': 'Name required'}), 400
|
||||||
|
|
||||||
|
activity_id = db.activities.insert_one({
|
||||||
|
'user_id': user_id,
|
||||||
|
'name': name,
|
||||||
|
'color': data.get('color', '#3498db'),
|
||||||
|
'subcategories': data.get('subcategories', [])
|
||||||
|
}).inserted_id
|
||||||
|
|
||||||
|
# Optional default tasks
|
||||||
|
tasks = data.get('default_tasks', [])
|
||||||
|
for t in tasks:
|
||||||
|
db.tasks.insert_one({
|
||||||
|
'user_id': user_id,
|
||||||
|
'name': t,
|
||||||
|
'activity_id': ObjectId(activity_id),
|
||||||
|
'is_template': True,
|
||||||
|
'status': 'open',
|
||||||
|
'time_entry_id': None,
|
||||||
|
'created_at': datetime.now(),
|
||||||
|
'comments': []
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({'status': 'success', 'id': str(activity_id)})
|
||||||
|
|
||||||
|
@app.route('/api/activities/<activity_id>', methods=['PUT', 'DELETE'])
|
||||||
|
@token_required
|
||||||
|
def api_manage_activity(user_id, activity_id):
|
||||||
|
if request.method == 'DELETE':
|
||||||
|
# Optional: Check for confirmation or cascade delete logic
|
||||||
|
result = db.activities.delete_one({'_id': ObjectId(activity_id), 'user_id': user_id})
|
||||||
|
if result.deleted_count == 0: return jsonify({'message': 'Not found'}), 404
|
||||||
|
return jsonify({'status': 'deleted'})
|
||||||
|
|
||||||
|
if request.method == 'PUT':
|
||||||
|
data = request.get_json()
|
||||||
|
update_fields = {}
|
||||||
|
if 'name' in data: update_fields['name'] = data['name']
|
||||||
|
if 'color' in data: update_fields['color'] = data['color']
|
||||||
|
if 'subcategories' in data: update_fields['subcategories'] = data['subcategories']
|
||||||
|
|
||||||
|
db.activities.update_one(
|
||||||
|
{'_id': ObjectId(activity_id), 'user_id': user_id},
|
||||||
|
{'$set': update_fields}
|
||||||
|
)
|
||||||
|
return jsonify({'status': 'updated'})
|
||||||
|
|
||||||
|
# --- Task API ---
|
||||||
|
|
||||||
|
@app.route('/api/tasks', methods=['GET'])
|
||||||
|
@token_required
|
||||||
|
def api_tasks_list(user_id):
|
||||||
|
# Retrieve filters from query params
|
||||||
|
filter_status = request.args.get('status') # 'open', 'completed'
|
||||||
|
|
||||||
|
query = {'user_id': user_id, 'is_template': False}
|
||||||
|
if filter_status:
|
||||||
|
query['status'] = filter_status
|
||||||
|
|
||||||
|
tasks = list(db.tasks.find(query).sort('due_date', 1))
|
||||||
|
|
||||||
|
return jsonify([{
|
||||||
|
'id': str(t['_id']),
|
||||||
|
'name': t['name'],
|
||||||
|
'status': t['status'],
|
||||||
|
'activity_id': str(t.get('activity_id')) if t.get('activity_id') else None,
|
||||||
|
'subcategory': t.get('subcategory', ''),
|
||||||
|
'due_date': t['due_date'].isoformat() if t.get('due_date') else None,
|
||||||
|
'completed_at': t['completed_at'].isoformat() if t.get('completed_at') else None
|
||||||
|
} for t in tasks])
|
||||||
|
|
||||||
|
@app.route('/api/tasks/create', methods=['POST'])
|
||||||
|
@token_required
|
||||||
|
def api_create_task(user_id):
|
||||||
|
data = request.get_json()
|
||||||
|
if not data.get('name'): return jsonify({'message': 'Name required'}), 400
|
||||||
|
|
||||||
|
activity_id = data.get('activity_id')
|
||||||
|
|
||||||
|
# Check for active context if quick-add flag is present
|
||||||
|
time_entry_id = None
|
||||||
|
if data.get('link_to_active'):
|
||||||
|
current_entry = db.time_entries.find_one({'user_id': user_id, 'end_time': None})
|
||||||
|
if current_entry and (str(current_entry.get('activity_id')) == activity_id or not activity_id):
|
||||||
|
time_entry_id = current_entry['_id']
|
||||||
|
# If no activity specified but linking to active, verify contexts match or inherit?
|
||||||
|
# Keeping it simple: link if requested.
|
||||||
|
|
||||||
|
task_doc = {
|
||||||
|
'user_id': user_id,
|
||||||
|
'name': data['name'],
|
||||||
|
'status': 'open',
|
||||||
|
'is_template': data.get('is_template', False),
|
||||||
|
'source': 'api',
|
||||||
|
'created_at': datetime.now(),
|
||||||
|
'comments': [],
|
||||||
|
'subcategory': data.get('subcategory', ''),
|
||||||
|
'activity_id': ObjectId(activity_id) if activity_id else None,
|
||||||
|
'time_entry_id': time_entry_id
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.get('due_date'):
|
||||||
|
try:
|
||||||
|
task_doc['due_date'] = datetime.fromisoformat(data['due_date'])
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
new_id = db.tasks.insert_one(task_doc).inserted_id
|
||||||
|
return jsonify({'status': 'success', 'id': str(new_id)})
|
||||||
|
|
||||||
|
@app.route('/api/tasks/<task_id>', methods=['GET', 'PUT', 'DELETE'])
|
||||||
|
@token_required
|
||||||
|
def api_task_detail(user_id, task_id):
|
||||||
|
if request.method == 'GET':
|
||||||
|
t = db.tasks.find_one({'_id': ObjectId(task_id), 'user_id': user_id})
|
||||||
|
if not t: return jsonify({'message': 'Not found'}), 404
|
||||||
|
|
||||||
|
# Format comments
|
||||||
|
comments = []
|
||||||
|
for c in t.get('comments', []):
|
||||||
|
comments.append({
|
||||||
|
'text': c['text'],
|
||||||
|
'created_at': c['created_at'].isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'id': str(t['_id']),
|
||||||
|
'name': t['name'],
|
||||||
|
'status': t['status'],
|
||||||
|
'comments': comments
|
||||||
|
# Add other fields as needed
|
||||||
|
})
|
||||||
|
|
||||||
|
if request.method == 'DELETE':
|
||||||
|
db.tasks.delete_one({'_id': ObjectId(task_id), 'user_id': user_id})
|
||||||
|
return jsonify({'status': 'deleted'})
|
||||||
|
|
||||||
|
if request.method == 'PUT':
|
||||||
|
data = request.get_json()
|
||||||
|
# Handle simple updates or comments
|
||||||
|
if 'comment' in data:
|
||||||
|
db.tasks.update_one(
|
||||||
|
{'_id': ObjectId(task_id), 'user_id': user_id},
|
||||||
|
{'$push': {'comments': {
|
||||||
|
'text': data['comment'],
|
||||||
|
'created_at': datetime.now(),
|
||||||
|
'user_id': user_id
|
||||||
|
}}}
|
||||||
|
)
|
||||||
|
return jsonify({'status': 'comment_added'})
|
||||||
|
|
||||||
|
update_fields = {}
|
||||||
|
if 'name' in data: update_fields['name'] = data['name']
|
||||||
|
if 'status' in data:
|
||||||
|
update_fields['status'] = data['status']
|
||||||
|
if data['status'] == 'completed':
|
||||||
|
update_fields['completed_at'] = datetime.now()
|
||||||
|
else:
|
||||||
|
update_fields['completed_at'] = None
|
||||||
|
|
||||||
|
db.tasks.update_one(
|
||||||
|
{'_id': ObjectId(task_id), 'user_id': user_id},
|
||||||
|
{'$set': update_fields}
|
||||||
|
)
|
||||||
|
return jsonify({'status': 'updated'})
|
||||||
|
|
||||||
|
# --- Logbook Detail / Edit API ---
|
||||||
|
|
||||||
|
@app.route('/api/logbook/<entry_id>', methods=['GET', 'PUT', 'DELETE'])
|
||||||
|
@token_required
|
||||||
|
def api_log_entry(user_id, entry_id):
|
||||||
|
if request.method == 'DELETE':
|
||||||
|
db.time_entries.delete_one({'_id': ObjectId(entry_id), 'user_id': user_id})
|
||||||
|
return jsonify({'status': 'deleted'})
|
||||||
|
|
||||||
|
if request.method == 'PUT':
|
||||||
|
data = request.get_json()
|
||||||
|
update_fields = {}
|
||||||
|
|
||||||
|
if 'note' in data: update_fields['note'] = data['note']
|
||||||
|
if 'subcategory' in data: update_fields['subcategory'] = data['subcategory']
|
||||||
|
|
||||||
|
try:
|
||||||
|
if 'start_time' in data:
|
||||||
|
# Expect ISO format
|
||||||
|
update_fields['start_time'] = datetime.fromisoformat(data['start_time'].replace('Z', '+00:00'))
|
||||||
|
if 'end_time' in data:
|
||||||
|
# If explicit null passed, sets to running? Usually we set a time.
|
||||||
|
if data['end_time']:
|
||||||
|
update_fields['end_time'] = datetime.fromisoformat(data['end_time'].replace('Z', '+00:00'))
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({'message': 'Invalid date format'}), 400
|
||||||
|
|
||||||
|
db.time_entries.update_one(
|
||||||
|
{'_id': ObjectId(entry_id), 'user_id': user_id},
|
||||||
|
{'$set': update_fields}
|
||||||
|
)
|
||||||
|
return jsonify({'status': 'updated'})
|
||||||
|
|
||||||
|
# GET detail
|
||||||
|
entry = db.time_entries.find_one({'_id': ObjectId(entry_id), 'user_id': user_id})
|
||||||
|
if not entry: return jsonify({'message': 'Not found'}), 404
|
||||||
|
|
||||||
|
# Get associated tasks
|
||||||
|
tasks = list(db.tasks.find({'time_entry_id': ObjectId(entry_id)}))
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'id': str(entry['_id']),
|
||||||
|
'activity_id': str(entry['activity_id']),
|
||||||
|
'start_time': entry['start_time'].isoformat(),
|
||||||
|
'end_time': entry['end_time'].isoformat() if entry.get('end_time') else None,
|
||||||
|
'note': entry.get('note', ''),
|
||||||
|
'subcategory': entry.get('subcategory', ''),
|
||||||
|
'duration_seconds': (entry['end_time'] - entry['start_time']).total_seconds() if entry.get('end_time') else None,
|
||||||
|
'tasks': [{'id': str(t['_id']), 'name': t['name'], 'status': t['status']} for t in tasks]
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- Goals API ---
|
||||||
|
|
||||||
|
@app.route('/api/goals', methods=['GET', 'POST'])
|
||||||
|
@token_required
|
||||||
|
def api_goals(user_id):
|
||||||
|
if request.method == 'GET':
|
||||||
|
# Re-using heavy calculation logic from web route for API response
|
||||||
|
goals_list = list(db.goals.find({'user_id': user_id}))
|
||||||
|
|
||||||
|
result_data = []
|
||||||
|
today = datetime.now()
|
||||||
|
start_of_today = today.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
ranges = {
|
||||||
|
'daily': start_of_today,
|
||||||
|
'weekly': start_of_today - timedelta(days=today.weekday()),
|
||||||
|
'monthly': start_of_today.replace(day=1),
|
||||||
|
'yearly': start_of_today.replace(month=1, day=1)
|
||||||
|
}
|
||||||
|
|
||||||
|
for goal in goals_list:
|
||||||
|
start_date = ranges.get(goal['frequency'], start_of_today)
|
||||||
|
query = {
|
||||||
|
'user_id': user_id,
|
||||||
|
'start_time': {'$gte': start_date},
|
||||||
|
'end_time': {'$ne': None}
|
||||||
|
}
|
||||||
|
if goal.get('activity_id'): query['activity_id'] = goal['activity_id']
|
||||||
|
if goal.get('subcategory'): query['subcategory'] = goal['subcategory']
|
||||||
|
|
||||||
|
entries = list(db.time_entries.find(query))
|
||||||
|
total_seconds = sum([(e['end_time'] - e['start_time']).total_seconds() for e in entries])
|
||||||
|
current_hours = total_seconds / 3600
|
||||||
|
|
||||||
|
result_data.append({
|
||||||
|
'id': str(goal['_id']),
|
||||||
|
'name': goal['name'],
|
||||||
|
'target_hours': goal['target_hours'],
|
||||||
|
'current_hours': round(current_hours, 2),
|
||||||
|
'percent': min(100, int((current_hours / goal['target_hours']) * 100)),
|
||||||
|
'frequency': goal['frequency'],
|
||||||
|
'activity_id': str(goal.get('activity_id')) if goal.get('activity_id') else None
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify(result_data)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
data = request.get_json()
|
||||||
|
if not data.get('activity_id'): return jsonify({'message': 'Activity required'}), 400
|
||||||
|
|
||||||
|
db.goals.insert_one({
|
||||||
|
'user_id': user_id,
|
||||||
|
'name': data.get('name', 'New Goal'),
|
||||||
|
'target_hours': float(data.get('target_hours', 1)),
|
||||||
|
'frequency': data.get('frequency', 'daily'),
|
||||||
|
'activity_id': ObjectId(data['activity_id']),
|
||||||
|
'subcategory': data.get('subcategory', ''),
|
||||||
|
'created_at': datetime.now()
|
||||||
|
})
|
||||||
|
return jsonify({'status': 'created'})
|
||||||
|
|
||||||
|
@app.route('/api/goals/<goal_id>', methods=['DELETE'])
|
||||||
|
@token_required
|
||||||
|
def api_goal_delete(user_id, goal_id):
|
||||||
|
db.goals.delete_one({'_id': ObjectId(goal_id), 'user_id': user_id})
|
||||||
|
return jsonify({'status': 'deleted'})
|
||||||
|
|
||||||
debug = os.getenv('DEBUG') == 'False'
|
debug = os.getenv('DEBUG') == 'False'
|
||||||
host = os.getenv('HOST')
|
host = os.getenv('HOST')
|
||||||
port = int(os.getenv('PORT', 80))
|
port = int(os.getenv('PORT', 80))
|
||||||
|
|||||||
Reference in New Issue
Block a user