From 8024dc1bf8c1aac0d29b875624067969fa853668 Mon Sep 17 00:00:00 2001 From: calboo Date: Wed, 11 Feb 2026 14:48:38 +0100 Subject: [PATCH] api support --- app.py | 381 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 381 insertions(+) diff --git a/app.py b/app.py index 38bd592..3ceeb63 100644 --- a/app.py +++ b/app.py @@ -5,6 +5,8 @@ from bson.objectid import ObjectId from datetime import datetime, timedelta import os from dotenv import load_dotenv +import jwt +from functools import wraps load_dotenv() @@ -65,6 +67,23 @@ def get_dashboard_context(user_id): 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 --- @app.route('/') @@ -795,6 +814,368 @@ def delete_goal(goal_id): db.goals.delete_one({'_id': ObjectId(goal_id), 'user_id': get_user_id()}) 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/', 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/', 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/', 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/', 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' host = os.getenv('HOST') port = int(os.getenv('PORT', 80))