Add main Flask application
This commit is contained in:
445
app.py
Normal file
445
app.py
Normal file
@@ -0,0 +1,445 @@
|
||||
"""
|
||||
Raccoon Timekeeper - Self-hosted Time Tracking Application
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from functools import wraps
|
||||
|
||||
from flask import Flask, render_template, request, jsonify, redirect, url_for
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'raccoon-secret-key-change-me')
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:///timekeeper.db')
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
|
||||
db = SQLAlchemy(app)
|
||||
|
||||
# =============================================================================
|
||||
# DATABASE MODELS
|
||||
# =============================================================================
|
||||
|
||||
class Task(db.Model):
|
||||
__tablename__ = 'tasks'
|
||||
|
||||
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())[:8])
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
entries = db.relationship('TimeEntry', backref='task', lazy=True)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'active': self.active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
|
||||
class TimeEntry(db.Model):
|
||||
__tablename__ = 'time_entries'
|
||||
|
||||
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())[:8])
|
||||
task_id = db.Column(db.String(36), db.ForeignKey('tasks.id'), nullable=False)
|
||||
task_name = db.Column(db.String(255), nullable=False)
|
||||
date = db.Column(db.Date, nullable=False)
|
||||
hours = db.Column(db.Integer, default=0)
|
||||
minutes = db.Column(db.Integer, default=0)
|
||||
total_minutes = db.Column(db.Integer, default=0)
|
||||
notes = db.Column(db.Text, default='')
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'task_id': self.task_id,
|
||||
'task_name': self.task_name,
|
||||
'date': self.date.isoformat() if self.date else None,
|
||||
'hours': self.hours,
|
||||
'minutes': self.minutes,
|
||||
'total_minutes': self.total_minutes,
|
||||
'notes': self.notes,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# UTILITY FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
def parse_time_input(time_str):
|
||||
"""
|
||||
Parse flexible time input formats:
|
||||
- "1:30" or "1:45" (hours:minutes)
|
||||
- "1.5" or "1.75" (decimal hours)
|
||||
- "90m" or "90min" (minutes)
|
||||
- "1h" or "1hr" (hours)
|
||||
- "1h 30m" or "1h30m" (combined)
|
||||
"""
|
||||
if not time_str:
|
||||
return None
|
||||
|
||||
time_str = str(time_str).strip().lower()
|
||||
|
||||
# Format: "1:30" (hours:minutes)
|
||||
match = re.match(r'^(\d+):(\d+)$', time_str)
|
||||
if match:
|
||||
hours = int(match.group(1))
|
||||
minutes = int(match.group(2))
|
||||
return {
|
||||
'hours': hours,
|
||||
'minutes': minutes,
|
||||
'total_minutes': hours * 60 + minutes
|
||||
}
|
||||
|
||||
# Format: "1h 30m" or "1h30m" or "1hr 30min"
|
||||
match = re.match(r'^(\d+)\s*h(?:r|our)?s?\s*(\d+)\s*m(?:in)?(?:ute)?s?$', time_str)
|
||||
if match:
|
||||
hours = int(match.group(1))
|
||||
minutes = int(match.group(2))
|
||||
return {
|
||||
'hours': hours,
|
||||
'minutes': minutes,
|
||||
'total_minutes': hours * 60 + minutes
|
||||
}
|
||||
|
||||
# Format: "90m" or "90min" or "90 minutes"
|
||||
match = re.match(r'^(\d+)\s*m(?:in)?(?:ute)?s?$', time_str)
|
||||
if match:
|
||||
total_minutes = int(match.group(1))
|
||||
return {
|
||||
'hours': total_minutes // 60,
|
||||
'minutes': total_minutes % 60,
|
||||
'total_minutes': total_minutes
|
||||
}
|
||||
|
||||
# Format: "1h" or "1hr" or "1 hour"
|
||||
match = re.match(r'^(\d+)\s*h(?:r|our)?s?$', time_str)
|
||||
if match:
|
||||
hours = int(match.group(1))
|
||||
return {
|
||||
'hours': hours,
|
||||
'minutes': 0,
|
||||
'total_minutes': hours * 60
|
||||
}
|
||||
|
||||
# Format: "1.5" or "1.75" (decimal hours)
|
||||
match = re.match(r'^(\d+(?:\.\d+)?)$', time_str)
|
||||
if match:
|
||||
decimal_hours = float(match.group(1))
|
||||
total_minutes = int(round(decimal_hours * 60))
|
||||
return {
|
||||
'hours': total_minutes // 60,
|
||||
'minutes': total_minutes % 60,
|
||||
'total_minutes': total_minutes
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_monday(date):
|
||||
"""Get the Monday of the week for a given date."""
|
||||
days_since_monday = date.weekday()
|
||||
return date - timedelta(days=days_since_monday)
|
||||
|
||||
|
||||
def format_minutes(total_minutes):
|
||||
"""Format minutes as H:MM string."""
|
||||
hours = total_minutes // 60
|
||||
minutes = total_minutes % 60
|
||||
return f"{hours}:{minutes:02d}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ROUTES - PAGES
|
||||
# =============================================================================
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""Main time logging page."""
|
||||
return render_template('index.html')
|
||||
|
||||
|
||||
@app.route('/settings')
|
||||
def settings():
|
||||
"""Settings and task management page."""
|
||||
return render_template('settings.html')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API ROUTES - TASKS
|
||||
# =============================================================================
|
||||
|
||||
@app.route('/api/tasks', methods=['GET'])
|
||||
def get_tasks():
|
||||
"""Get all tasks."""
|
||||
include_inactive = request.args.get('include_inactive', 'false').lower() == 'true'
|
||||
|
||||
query = Task.query
|
||||
if not include_inactive:
|
||||
query = query.filter_by(active=True)
|
||||
|
||||
tasks = query.order_by(Task.name).all()
|
||||
return jsonify([task.to_dict() for task in tasks])
|
||||
|
||||
|
||||
@app.route('/api/tasks', methods=['POST'])
|
||||
def add_task():
|
||||
"""Add a new task."""
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({'success': False, 'message': 'Task name cannot be empty'}), 400
|
||||
|
||||
# Check for duplicate
|
||||
existing = Task.query.filter(db.func.lower(Task.name) == name.lower()).first()
|
||||
if existing:
|
||||
return jsonify({'success': False, 'message': 'Task already exists'}), 400
|
||||
|
||||
task = Task(name=name)
|
||||
db.session.add(task)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Task added successfully',
|
||||
'task': task.to_dict()
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/tasks/<task_id>', methods=['PUT'])
|
||||
def update_task(task_id):
|
||||
"""Update a task."""
|
||||
task = Task.query.get(task_id)
|
||||
if not task:
|
||||
return jsonify({'success': False, 'message': 'Task not found'}), 404
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
if 'name' in data:
|
||||
task.name = data['name'].strip()
|
||||
if 'active' in data:
|
||||
task.active = data['active']
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Task updated successfully',
|
||||
'task': task.to_dict()
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/tasks/<task_id>', methods=['DELETE'])
|
||||
def delete_task(task_id):
|
||||
"""Delete a task."""
|
||||
task = Task.query.get(task_id)
|
||||
if not task:
|
||||
return jsonify({'success': False, 'message': 'Task not found'}), 404
|
||||
|
||||
db.session.delete(task)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': 'Task deleted successfully'})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API ROUTES - TIME ENTRIES
|
||||
# =============================================================================
|
||||
|
||||
@app.route('/api/entries', methods=['GET'])
|
||||
def get_entries():
|
||||
"""Get time entries with optional date filtering."""
|
||||
entries = TimeEntry.query.order_by(TimeEntry.date.desc(), TimeEntry.created_at.desc()).all()
|
||||
return jsonify([entry.to_dict() for entry in entries])
|
||||
|
||||
|
||||
@app.route('/api/entries', methods=['POST'])
|
||||
def add_entry():
|
||||
"""Add a new time entry."""
|
||||
data = request.get_json()
|
||||
|
||||
task_id = data.get('task_id')
|
||||
task_name = data.get('task_name')
|
||||
date_str = data.get('date')
|
||||
time_str = data.get('time')
|
||||
notes = data.get('notes', '')
|
||||
|
||||
if not task_id or not date_str or not time_str:
|
||||
return jsonify({'success': False, 'message': 'Missing required fields'}), 400
|
||||
|
||||
# Parse time
|
||||
parsed_time = parse_time_input(time_str)
|
||||
if not parsed_time:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Invalid time format. Use formats like 1:30, 1.5, 90m, or 1h 30m'
|
||||
}), 400
|
||||
|
||||
# Parse date
|
||||
try:
|
||||
entry_date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
return jsonify({'success': False, 'message': 'Invalid date format'}), 400
|
||||
|
||||
entry = TimeEntry(
|
||||
task_id=task_id,
|
||||
task_name=task_name,
|
||||
date=entry_date,
|
||||
hours=parsed_time['hours'],
|
||||
minutes=parsed_time['minutes'],
|
||||
total_minutes=parsed_time['total_minutes'],
|
||||
notes=notes
|
||||
)
|
||||
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Time entry added successfully',
|
||||
'entry': entry.to_dict()
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/entries/<entry_id>', methods=['PUT'])
|
||||
def update_entry(entry_id):
|
||||
"""Update a time entry."""
|
||||
entry = TimeEntry.query.get(entry_id)
|
||||
if not entry:
|
||||
return jsonify({'success': False, 'message': 'Entry not found'}), 404
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
if 'task_id' in data:
|
||||
entry.task_id = data['task_id']
|
||||
if 'task_name' in data:
|
||||
entry.task_name = data['task_name']
|
||||
if 'date' in data:
|
||||
entry.date = datetime.strptime(data['date'], '%Y-%m-%d').date()
|
||||
if 'time' in data:
|
||||
parsed_time = parse_time_input(data['time'])
|
||||
if parsed_time:
|
||||
entry.hours = parsed_time['hours']
|
||||
entry.minutes = parsed_time['minutes']
|
||||
entry.total_minutes = parsed_time['total_minutes']
|
||||
if 'notes' in data:
|
||||
entry.notes = data['notes']
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Entry updated successfully',
|
||||
'entry': entry.to_dict()
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/entries/<entry_id>', methods=['DELETE'])
|
||||
def delete_entry(entry_id):
|
||||
"""Delete a time entry."""
|
||||
entry = TimeEntry.query.get(entry_id)
|
||||
if not entry:
|
||||
return jsonify({'success': False, 'message': 'Entry not found'}), 404
|
||||
|
||||
db.session.delete(entry)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': 'Entry deleted successfully'})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API ROUTES - WEEKLY SUMMARY
|
||||
# =============================================================================
|
||||
|
||||
@app.route('/api/weekly-summary')
|
||||
def get_weekly_summary():
|
||||
"""Get weekly summary for a given week."""
|
||||
week_start_str = request.args.get('week_start')
|
||||
|
||||
if week_start_str:
|
||||
try:
|
||||
week_start = datetime.strptime(week_start_str, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
week_start = get_monday(datetime.now().date())
|
||||
else:
|
||||
week_start = get_monday(datetime.now().date())
|
||||
|
||||
# Ensure we start on Monday
|
||||
week_start = get_monday(week_start)
|
||||
week_end = week_start + timedelta(days=6)
|
||||
|
||||
# Get entries for this week
|
||||
entries = TimeEntry.query.filter(
|
||||
TimeEntry.date >= week_start,
|
||||
TimeEntry.date <= week_end
|
||||
).all()
|
||||
|
||||
# Group by task
|
||||
task_summary = {}
|
||||
daily_totals = {i: 0 for i in range(7)} # Mon=0, Sun=6
|
||||
|
||||
for entry in entries:
|
||||
task_name = entry.task_name
|
||||
day_index = entry.date.weekday() # Mon=0, Sun=6
|
||||
|
||||
if task_name not in task_summary:
|
||||
task_summary[task_name] = {
|
||||
'task_id': entry.task_id,
|
||||
'task_name': task_name,
|
||||
'days': {i: 0 for i in range(7)},
|
||||
'total_minutes': 0
|
||||
}
|
||||
|
||||
task_summary[task_name]['days'][day_index] += entry.total_minutes
|
||||
task_summary[task_name]['total_minutes'] += entry.total_minutes
|
||||
daily_totals[day_index] += entry.total_minutes
|
||||
|
||||
tasks = list(task_summary.values())
|
||||
grand_total = sum(daily_totals.values())
|
||||
|
||||
return jsonify({
|
||||
'week_start': week_start.isoformat(),
|
||||
'week_end': week_end.isoformat(),
|
||||
'tasks': tasks,
|
||||
'daily_totals': daily_totals,
|
||||
'grand_total': grand_total,
|
||||
'entries': [e.to_dict() for e in entries]
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DATABASE INITIALIZATION
|
||||
# =============================================================================
|
||||
|
||||
def init_db():
|
||||
"""Initialize the database."""
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
# Add some default tasks if none exist
|
||||
if Task.query.count() == 0:
|
||||
default_tasks = ['General Work', 'Meetings', 'Admin', 'Training']
|
||||
for task_name in default_tasks:
|
||||
task = Task(name=task_name)
|
||||
db.session.add(task)
|
||||
db.session.commit()
|
||||
print("Default tasks created.")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MAIN
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_db()
|
||||
app.run(debug=os.environ.get('DEBUG', 'false').lower() == 'true', host='0.0.0.0', port=5000)
|
||||
Reference in New Issue
Block a user