mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-06 03:30:25 -06:00
Backend: add Black/isort/Flake8 configs and .editorconfig; switch health/readiness to locale-based time. Fix service worker asset list; add smoke test. Admin scopes UI: add read:* and write:* wildcards; add granular scopes for invoices, expenses, payments, mileage, per diem, budget alerts, calendar, comments, recurring invoices. API v1: add endpoints for invoices, expenses, payments, mileage, per diem (+rates), budget alerts, calendar, kanban, saved filters, time entry templates, comments, recurring invoices, credit notes, client notes (paginated), project costs (paginated), currencies, exchange rates, favorites, audit logs, activities, and invoice PDF/templates (admin). Extend /api/v1/info with all resources. No schema changes. Tests: add coverage for new endpoints (CRUD/list/pagination) and service worker route smoke test.
3612 lines
110 KiB
Python
3612 lines
110 KiB
Python
"""REST API v1 - Comprehensive API endpoints with token authentication"""
|
|
from flask import Blueprint, jsonify, request, current_app, g
|
|
from app import db
|
|
from app.models import (
|
|
User,
|
|
Project,
|
|
TimeEntry,
|
|
Task,
|
|
Client,
|
|
Invoice,
|
|
Expense,
|
|
SavedFilter,
|
|
FocusSession,
|
|
RecurringBlock,
|
|
Comment,
|
|
Payment,
|
|
Mileage,
|
|
PerDiem,
|
|
PerDiemRate,
|
|
BudgetAlert,
|
|
CalendarEvent,
|
|
KanbanColumn,
|
|
TimeEntryTemplate,
|
|
CreditNote,
|
|
RecurringInvoice,
|
|
ClientNote,
|
|
ProjectCost,
|
|
TaxRule,
|
|
Currency,
|
|
ExchangeRate,
|
|
UserFavoriteProject,
|
|
Activity,
|
|
AuditLog,
|
|
InvoicePDFTemplate,
|
|
InvoiceTemplate,
|
|
)
|
|
from app.utils.api_auth import require_api_token
|
|
from datetime import datetime, timedelta
|
|
from sqlalchemy import func, or_
|
|
from app.utils.timezone import parse_local_datetime, utc_to_local
|
|
from app.models.time_entry import local_now
|
|
|
|
api_v1_bp = Blueprint('api_v1', __name__, url_prefix='/api/v1')
|
|
|
|
|
|
# ==================== Helper Functions ====================
|
|
|
|
def paginate_query(query, page=None, per_page=None):
|
|
"""Paginate a SQLAlchemy query"""
|
|
page = page or int(request.args.get('page', 1))
|
|
per_page = per_page or int(request.args.get('per_page', 50))
|
|
per_page = min(per_page, 100) # Max 100 items per page
|
|
|
|
paginated = query.paginate(page=page, per_page=per_page, error_out=False)
|
|
|
|
return {
|
|
'items': paginated.items,
|
|
'pagination': {
|
|
'page': paginated.page,
|
|
'per_page': paginated.per_page,
|
|
'total': paginated.total,
|
|
'pages': paginated.pages,
|
|
'has_next': paginated.has_next,
|
|
'has_prev': paginated.has_prev,
|
|
'next_page': paginated.page + 1 if paginated.has_next else None,
|
|
'prev_page': paginated.page - 1 if paginated.has_prev else None
|
|
}
|
|
}
|
|
|
|
|
|
def parse_datetime(dt_str):
|
|
"""Parse datetime string from API request"""
|
|
if not dt_str:
|
|
return None
|
|
try:
|
|
# Handle ISO format with timezone
|
|
ts = dt_str.strip()
|
|
if ts.endswith('Z'):
|
|
ts = ts[:-1] + '+00:00'
|
|
dt = datetime.fromisoformat(ts)
|
|
# Convert to local naive for storage
|
|
if dt.tzinfo is not None:
|
|
dt = utc_to_local(dt).replace(tzinfo=None)
|
|
return dt
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _parse_date(dstr):
|
|
"""Parse a YYYY-MM-DD string to date."""
|
|
if not dstr:
|
|
return None
|
|
try:
|
|
from datetime import date as _date
|
|
return _date.fromisoformat(str(dstr))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
# ==================== API Info & Health ====================
|
|
|
|
@api_v1_bp.route('/info', methods=['GET'])
|
|
def api_info():
|
|
"""Get API information and version
|
|
---
|
|
tags:
|
|
- System
|
|
responses:
|
|
200:
|
|
description: API information
|
|
schema:
|
|
type: object
|
|
properties:
|
|
api_version:
|
|
type: string
|
|
app_version:
|
|
type: string
|
|
endpoints:
|
|
type: array
|
|
documentation_url:
|
|
type: string
|
|
"""
|
|
return jsonify({
|
|
'api_version': 'v1',
|
|
'app_version': current_app.config.get('APP_VERSION', '1.0.0'),
|
|
'documentation_url': '/api/docs',
|
|
'authentication': 'API Token (Bearer or X-API-Key header)',
|
|
'endpoints': {
|
|
'projects': '/api/v1/projects',
|
|
'time_entries': '/api/v1/time-entries',
|
|
'tasks': '/api/v1/tasks',
|
|
'clients': '/api/v1/clients',
|
|
'invoices': '/api/v1/invoices',
|
|
'expenses': '/api/v1/expenses',
|
|
'payments': '/api/v1/payments',
|
|
'mileage': '/api/v1/mileage',
|
|
'per_diems': '/api/v1/per-diems',
|
|
'per_diem_rates': '/api/v1/per-diem-rates',
|
|
'budget_alerts': '/api/v1/budget-alerts',
|
|
'calendar_events': '/api/v1/calendar/events',
|
|
'kanban_columns': '/api/v1/kanban/columns',
|
|
'saved_filters': '/api/v1/saved-filters',
|
|
'time_entry_templates': '/api/v1/time-entry-templates',
|
|
'comments': '/api/v1/comments',
|
|
'recurring_invoices': '/api/v1/recurring-invoices',
|
|
'credit_notes': '/api/v1/credit-notes',
|
|
'client_notes': '/api/v1/clients/<client_id>/notes',
|
|
'project_costs': '/api/v1/projects/<project_id>/costs',
|
|
'tax_rules': '/api/v1/tax-rules',
|
|
'currencies': '/api/v1/currencies',
|
|
'exchange_rates': '/api/v1/exchange-rates',
|
|
'favorites': '/api/v1/users/me/favorites/projects',
|
|
'activities': '/api/v1/activities',
|
|
'audit_logs': '/api/v1/audit-logs',
|
|
'invoice_pdf_templates': '/api/v1/invoice-pdf-templates',
|
|
'invoice_templates': '/api/v1/invoice-templates',
|
|
'users': '/api/v1/users',
|
|
'reports': '/api/v1/reports'
|
|
}
|
|
})
|
|
|
|
|
|
@api_v1_bp.route('/health', methods=['GET'])
|
|
def health_check():
|
|
"""API health check endpoint
|
|
---
|
|
tags:
|
|
- System
|
|
responses:
|
|
200:
|
|
description: API is healthy
|
|
"""
|
|
return jsonify({'status': 'healthy', 'timestamp': local_now().isoformat()})
|
|
|
|
|
|
# ==================== Projects ====================
|
|
|
|
@api_v1_bp.route('/projects', methods=['GET'])
|
|
@require_api_token('read:projects')
|
|
def list_projects():
|
|
"""List all projects
|
|
---
|
|
tags:
|
|
- Projects
|
|
parameters:
|
|
- name: status
|
|
in: query
|
|
type: string
|
|
enum: [active, archived, on_hold]
|
|
- name: client_id
|
|
in: query
|
|
type: integer
|
|
- name: page
|
|
in: query
|
|
type: integer
|
|
- name: per_page
|
|
in: query
|
|
type: integer
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: List of projects
|
|
"""
|
|
query = Project.query
|
|
|
|
# Filter by status
|
|
status = request.args.get('status')
|
|
if status:
|
|
query = query.filter_by(status=status)
|
|
|
|
# Filter by client
|
|
client_id = request.args.get('client_id', type=int)
|
|
if client_id:
|
|
query = query.filter_by(client_id=client_id)
|
|
|
|
# Order by name
|
|
query = query.order_by(Project.name)
|
|
|
|
# Paginate
|
|
result = paginate_query(query)
|
|
|
|
return jsonify({
|
|
'projects': [p.to_dict() for p in result['items']],
|
|
'pagination': result['pagination']
|
|
})
|
|
|
|
|
|
@api_v1_bp.route('/projects/<int:project_id>', methods=['GET'])
|
|
@require_api_token('read:projects')
|
|
def get_project(project_id):
|
|
"""Get a specific project
|
|
---
|
|
tags:
|
|
- Projects
|
|
parameters:
|
|
- name: project_id
|
|
in: path
|
|
type: integer
|
|
required: true
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Project details
|
|
404:
|
|
description: Project not found
|
|
"""
|
|
project = Project.query.get_or_404(project_id)
|
|
return jsonify({'project': project.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/projects', methods=['POST'])
|
|
@require_api_token('write:projects')
|
|
def create_project():
|
|
"""Create a new project
|
|
---
|
|
tags:
|
|
- Projects
|
|
parameters:
|
|
- name: body
|
|
in: body
|
|
required: true
|
|
schema:
|
|
type: object
|
|
required:
|
|
- name
|
|
properties:
|
|
name:
|
|
type: string
|
|
description:
|
|
type: string
|
|
client_id:
|
|
type: integer
|
|
hourly_rate:
|
|
type: number
|
|
estimated_hours:
|
|
type: number
|
|
status:
|
|
type: string
|
|
enum: [active, archived, on_hold]
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
201:
|
|
description: Project created
|
|
400:
|
|
description: Invalid input
|
|
"""
|
|
data = request.get_json() or {}
|
|
|
|
# Validate required fields
|
|
if not data.get('name'):
|
|
return jsonify({'error': 'Project name is required'}), 400
|
|
|
|
# Create project
|
|
project = Project(
|
|
name=data['name'],
|
|
description=data.get('description', ''),
|
|
client_id=data.get('client_id'),
|
|
hourly_rate=data.get('hourly_rate', 0.0),
|
|
estimated_hours=data.get('estimated_hours'),
|
|
status=data.get('status', 'active')
|
|
)
|
|
|
|
db.session.add(project)
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'message': 'Project created successfully',
|
|
'project': project.to_dict()
|
|
}), 201
|
|
|
|
|
|
@api_v1_bp.route('/projects/<int:project_id>', methods=['PUT', 'PATCH'])
|
|
@require_api_token('write:projects')
|
|
def update_project(project_id):
|
|
"""Update a project
|
|
---
|
|
tags:
|
|
- Projects
|
|
parameters:
|
|
- name: project_id
|
|
in: path
|
|
type: integer
|
|
required: true
|
|
- name: body
|
|
in: body
|
|
schema:
|
|
type: object
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Project updated
|
|
404:
|
|
description: Project not found
|
|
"""
|
|
project = Project.query.get_or_404(project_id)
|
|
data = request.get_json() or {}
|
|
|
|
# Update fields
|
|
if 'name' in data:
|
|
project.name = data['name']
|
|
if 'description' in data:
|
|
project.description = data['description']
|
|
if 'client_id' in data:
|
|
project.client_id = data['client_id']
|
|
if 'hourly_rate' in data:
|
|
project.hourly_rate = data['hourly_rate']
|
|
if 'estimated_hours' in data:
|
|
project.estimated_hours = data['estimated_hours']
|
|
if 'status' in data:
|
|
project.status = data['status']
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'message': 'Project updated successfully',
|
|
'project': project.to_dict()
|
|
})
|
|
|
|
|
|
@api_v1_bp.route('/projects/<int:project_id>', methods=['DELETE'])
|
|
@require_api_token('write:projects')
|
|
def delete_project(project_id):
|
|
"""Delete/archive a project
|
|
---
|
|
tags:
|
|
- Projects
|
|
parameters:
|
|
- name: project_id
|
|
in: path
|
|
type: integer
|
|
required: true
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Project archived
|
|
404:
|
|
description: Project not found
|
|
"""
|
|
project = Project.query.get_or_404(project_id)
|
|
|
|
# Archive instead of deleting
|
|
project.status = 'archived'
|
|
db.session.commit()
|
|
|
|
return jsonify({'message': 'Project archived successfully'})
|
|
|
|
|
|
# ==================== Time Entries ====================
|
|
|
|
@api_v1_bp.route('/time-entries', methods=['GET'])
|
|
@require_api_token('read:time_entries')
|
|
def list_time_entries():
|
|
"""List time entries
|
|
---
|
|
tags:
|
|
- Time Entries
|
|
parameters:
|
|
- name: project_id
|
|
in: query
|
|
type: integer
|
|
- name: user_id
|
|
in: query
|
|
type: integer
|
|
- name: start_date
|
|
in: query
|
|
type: string
|
|
format: date
|
|
- name: end_date
|
|
in: query
|
|
type: string
|
|
format: date
|
|
- name: billable
|
|
in: query
|
|
type: boolean
|
|
- name: page
|
|
in: query
|
|
type: integer
|
|
- name: per_page
|
|
in: query
|
|
type: integer
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: List of time entries
|
|
"""
|
|
query = TimeEntry.query
|
|
|
|
# Filter by project
|
|
project_id = request.args.get('project_id', type=int)
|
|
if project_id:
|
|
query = query.filter_by(project_id=project_id)
|
|
|
|
# Filter by user (non-admin can only see their own)
|
|
user_id = request.args.get('user_id', type=int)
|
|
if user_id:
|
|
if g.api_user.is_admin or user_id == g.api_user.id:
|
|
query = query.filter_by(user_id=user_id)
|
|
else:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
else:
|
|
# Default to current user's entries if not admin
|
|
if not g.api_user.is_admin:
|
|
query = query.filter_by(user_id=g.api_user.id)
|
|
|
|
# Filter by date range
|
|
start_date = request.args.get('start_date')
|
|
end_date = request.args.get('end_date')
|
|
if start_date:
|
|
start_dt = parse_datetime(start_date)
|
|
if start_dt:
|
|
query = query.filter(TimeEntry.start_time >= start_dt)
|
|
if end_date:
|
|
end_dt = parse_datetime(end_date)
|
|
if end_dt:
|
|
query = query.filter(TimeEntry.start_time <= end_dt)
|
|
|
|
# Filter by billable
|
|
billable = request.args.get('billable')
|
|
if billable is not None:
|
|
query = query.filter_by(billable=billable.lower() == 'true')
|
|
|
|
# Only completed entries by default
|
|
if request.args.get('include_active') != 'true':
|
|
query = query.filter(TimeEntry.end_time.isnot(None))
|
|
|
|
# Order by start time desc
|
|
query = query.order_by(TimeEntry.start_time.desc())
|
|
|
|
# Paginate
|
|
result = paginate_query(query)
|
|
|
|
return jsonify({
|
|
'time_entries': [e.to_dict() for e in result['items']],
|
|
'pagination': result['pagination']
|
|
})
|
|
|
|
|
|
@api_v1_bp.route('/time-entries/<int:entry_id>', methods=['GET'])
|
|
@require_api_token('read:time_entries')
|
|
def get_time_entry(entry_id):
|
|
"""Get a specific time entry
|
|
---
|
|
tags:
|
|
- Time Entries
|
|
parameters:
|
|
- name: entry_id
|
|
in: path
|
|
type: integer
|
|
required: true
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Time entry details
|
|
404:
|
|
description: Time entry not found
|
|
"""
|
|
entry = TimeEntry.query.get_or_404(entry_id)
|
|
|
|
# Check permissions
|
|
if not g.api_user.is_admin and entry.user_id != g.api_user.id:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
return jsonify({'time_entry': entry.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/time-entries', methods=['POST'])
|
|
@require_api_token('write:time_entries')
|
|
def create_time_entry():
|
|
"""Create a new time entry
|
|
---
|
|
tags:
|
|
- Time Entries
|
|
parameters:
|
|
- name: body
|
|
in: body
|
|
required: true
|
|
schema:
|
|
type: object
|
|
required:
|
|
- project_id
|
|
- start_time
|
|
properties:
|
|
project_id:
|
|
type: integer
|
|
task_id:
|
|
type: integer
|
|
start_time:
|
|
type: string
|
|
format: date-time
|
|
end_time:
|
|
type: string
|
|
format: date-time
|
|
notes:
|
|
type: string
|
|
tags:
|
|
type: string
|
|
billable:
|
|
type: boolean
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
201:
|
|
description: Time entry created
|
|
400:
|
|
description: Invalid input
|
|
"""
|
|
data = request.get_json() or {}
|
|
|
|
# Validate required fields
|
|
if not data.get('project_id'):
|
|
return jsonify({'error': 'project_id is required'}), 400
|
|
if not data.get('start_time'):
|
|
return jsonify({'error': 'start_time is required'}), 400
|
|
|
|
# Validate project
|
|
project = Project.query.filter_by(id=data['project_id'], status='active').first()
|
|
if not project:
|
|
return jsonify({'error': 'Invalid project'}), 400
|
|
|
|
# Parse times
|
|
start_time = parse_datetime(data['start_time'])
|
|
if not start_time:
|
|
return jsonify({'error': 'Invalid start_time format'}), 400
|
|
|
|
end_time = None
|
|
if data.get('end_time'):
|
|
end_time = parse_datetime(data['end_time'])
|
|
if end_time and end_time <= start_time:
|
|
return jsonify({'error': 'end_time must be after start_time'}), 400
|
|
|
|
# Create entry
|
|
entry = TimeEntry(
|
|
user_id=g.api_user.id,
|
|
project_id=data['project_id'],
|
|
task_id=data.get('task_id'),
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
notes=data.get('notes'),
|
|
tags=data.get('tags'),
|
|
billable=data.get('billable', True),
|
|
source='api'
|
|
)
|
|
|
|
db.session.add(entry)
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'message': 'Time entry created successfully',
|
|
'time_entry': entry.to_dict()
|
|
}), 201
|
|
|
|
|
|
@api_v1_bp.route('/time-entries/<int:entry_id>', methods=['PUT', 'PATCH'])
|
|
@require_api_token('write:time_entries')
|
|
def update_time_entry(entry_id):
|
|
"""Update a time entry
|
|
---
|
|
tags:
|
|
- Time Entries
|
|
parameters:
|
|
- name: entry_id
|
|
in: path
|
|
type: integer
|
|
required: true
|
|
- name: body
|
|
in: body
|
|
schema:
|
|
type: object
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Time entry updated
|
|
404:
|
|
description: Time entry not found
|
|
"""
|
|
entry = TimeEntry.query.get_or_404(entry_id)
|
|
|
|
# Check permissions
|
|
if not g.api_user.is_admin and entry.user_id != g.api_user.id:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
data = request.get_json() or {}
|
|
|
|
# Update fields
|
|
if 'project_id' in data:
|
|
entry.project_id = data['project_id']
|
|
if 'task_id' in data:
|
|
entry.task_id = data['task_id']
|
|
if 'start_time' in data:
|
|
start_time = parse_datetime(data['start_time'])
|
|
if start_time:
|
|
entry.start_time = start_time
|
|
if 'end_time' in data:
|
|
if data['end_time'] is None:
|
|
entry.end_time = None
|
|
else:
|
|
end_time = parse_datetime(data['end_time'])
|
|
if end_time:
|
|
entry.end_time = end_time
|
|
if 'notes' in data:
|
|
entry.notes = data['notes']
|
|
if 'tags' in data:
|
|
entry.tags = data['tags']
|
|
if 'billable' in data:
|
|
entry.billable = data['billable']
|
|
|
|
entry.updated_at = local_now()
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'message': 'Time entry updated successfully',
|
|
'time_entry': entry.to_dict()
|
|
})
|
|
|
|
|
|
@api_v1_bp.route('/time-entries/<int:entry_id>', methods=['DELETE'])
|
|
@require_api_token('write:time_entries')
|
|
def delete_time_entry(entry_id):
|
|
"""Delete a time entry
|
|
---
|
|
tags:
|
|
- Time Entries
|
|
parameters:
|
|
- name: entry_id
|
|
in: path
|
|
type: integer
|
|
required: true
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Time entry deleted
|
|
404:
|
|
description: Time entry not found
|
|
"""
|
|
entry = TimeEntry.query.get_or_404(entry_id)
|
|
|
|
# Check permissions
|
|
if not g.api_user.is_admin and entry.user_id != g.api_user.id:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
# Don't allow deletion of active entries
|
|
if entry.is_active:
|
|
return jsonify({'error': 'Cannot delete active time entry'}), 400
|
|
|
|
db.session.delete(entry)
|
|
db.session.commit()
|
|
|
|
return jsonify({'message': 'Time entry deleted successfully'})
|
|
|
|
|
|
# ==================== Timer Control ====================
|
|
|
|
@api_v1_bp.route('/timer/status', methods=['GET'])
|
|
@require_api_token('read:time_entries')
|
|
def timer_status():
|
|
"""Get current timer status for authenticated user
|
|
---
|
|
tags:
|
|
- Timer
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Timer status
|
|
"""
|
|
active_timer = g.api_user.active_timer
|
|
|
|
if not active_timer:
|
|
return jsonify({
|
|
'active': False,
|
|
'timer': None
|
|
})
|
|
|
|
return jsonify({
|
|
'active': True,
|
|
'timer': active_timer.to_dict()
|
|
})
|
|
|
|
|
|
@api_v1_bp.route('/timer/start', methods=['POST'])
|
|
@require_api_token('write:time_entries')
|
|
def start_timer():
|
|
"""Start a new timer
|
|
---
|
|
tags:
|
|
- Timer
|
|
parameters:
|
|
- name: body
|
|
in: body
|
|
required: true
|
|
schema:
|
|
type: object
|
|
required:
|
|
- project_id
|
|
properties:
|
|
project_id:
|
|
type: integer
|
|
task_id:
|
|
type: integer
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
201:
|
|
description: Timer started
|
|
400:
|
|
description: Invalid input or timer already running
|
|
"""
|
|
data = request.get_json() or {}
|
|
|
|
# Check if timer already running
|
|
if g.api_user.active_timer:
|
|
return jsonify({'error': 'Timer already running'}), 400
|
|
|
|
# Validate project
|
|
project_id = data.get('project_id')
|
|
if not project_id:
|
|
return jsonify({'error': 'project_id is required'}), 400
|
|
|
|
project = Project.query.filter_by(id=project_id, status='active').first()
|
|
if not project:
|
|
return jsonify({'error': 'Invalid project'}), 400
|
|
|
|
# Create timer
|
|
timer = TimeEntry(
|
|
user_id=g.api_user.id,
|
|
project_id=project_id,
|
|
task_id=data.get('task_id'),
|
|
start_time=local_now(),
|
|
source='api'
|
|
)
|
|
|
|
db.session.add(timer)
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'message': 'Timer started successfully',
|
|
'timer': timer.to_dict()
|
|
}), 201
|
|
|
|
|
|
@api_v1_bp.route('/timer/stop', methods=['POST'])
|
|
@require_api_token('write:time_entries')
|
|
def stop_timer():
|
|
"""Stop the active timer
|
|
---
|
|
tags:
|
|
- Timer
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Timer stopped
|
|
400:
|
|
description: No active timer
|
|
"""
|
|
active_timer = g.api_user.active_timer
|
|
|
|
if not active_timer:
|
|
return jsonify({'error': 'No active timer'}), 400
|
|
|
|
active_timer.stop_timer()
|
|
|
|
return jsonify({
|
|
'message': 'Timer stopped successfully',
|
|
'time_entry': active_timer.to_dict()
|
|
})
|
|
|
|
|
|
# ==================== Tasks ====================
|
|
|
|
@api_v1_bp.route('/tasks', methods=['GET'])
|
|
@require_api_token('read:tasks')
|
|
def list_tasks():
|
|
"""List tasks
|
|
---
|
|
tags:
|
|
- Tasks
|
|
parameters:
|
|
- name: project_id
|
|
in: query
|
|
type: integer
|
|
- name: status
|
|
in: query
|
|
type: string
|
|
- name: page
|
|
in: query
|
|
type: integer
|
|
- name: per_page
|
|
in: query
|
|
type: integer
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: List of tasks
|
|
"""
|
|
query = Task.query
|
|
|
|
# Filter by project
|
|
project_id = request.args.get('project_id', type=int)
|
|
if project_id:
|
|
query = query.filter_by(project_id=project_id)
|
|
|
|
# Filter by status
|
|
status = request.args.get('status')
|
|
if status:
|
|
query = query.filter_by(status=status)
|
|
|
|
# Order by priority and name
|
|
query = query.order_by(Task.priority.desc(), Task.name)
|
|
|
|
# Paginate
|
|
result = paginate_query(query)
|
|
|
|
return jsonify({
|
|
'tasks': [t.to_dict() for t in result['items']],
|
|
'pagination': result['pagination']
|
|
})
|
|
|
|
|
|
@api_v1_bp.route('/tasks/<int:task_id>', methods=['GET'])
|
|
@require_api_token('read:tasks')
|
|
def get_task(task_id):
|
|
"""Get a specific task
|
|
---
|
|
tags:
|
|
- Tasks
|
|
parameters:
|
|
- name: task_id
|
|
in: path
|
|
type: integer
|
|
required: true
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Task details
|
|
404:
|
|
description: Task not found
|
|
"""
|
|
task = Task.query.get_or_404(task_id)
|
|
return jsonify({'task': task.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/tasks', methods=['POST'])
|
|
@require_api_token('write:tasks')
|
|
def create_task():
|
|
"""Create a new task
|
|
---
|
|
tags:
|
|
- Tasks
|
|
parameters:
|
|
- name: body
|
|
in: body
|
|
required: true
|
|
schema:
|
|
type: object
|
|
required:
|
|
- name
|
|
- project_id
|
|
properties:
|
|
name:
|
|
type: string
|
|
description:
|
|
type: string
|
|
project_id:
|
|
type: integer
|
|
status:
|
|
type: string
|
|
priority:
|
|
type: integer
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
201:
|
|
description: Task created
|
|
400:
|
|
description: Invalid input
|
|
"""
|
|
data = request.get_json() or {}
|
|
|
|
# Validate required fields
|
|
if not data.get('name'):
|
|
return jsonify({'error': 'Task name is required'}), 400
|
|
if not data.get('project_id'):
|
|
return jsonify({'error': 'project_id is required'}), 400
|
|
|
|
# Create task
|
|
task = Task(
|
|
name=data['name'],
|
|
description=data.get('description'),
|
|
project_id=data['project_id'],
|
|
status=data.get('status', 'todo'),
|
|
priority=data.get('priority', 1)
|
|
)
|
|
|
|
db.session.add(task)
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'message': 'Task created successfully',
|
|
'task': task.to_dict()
|
|
}), 201
|
|
|
|
|
|
@api_v1_bp.route('/tasks/<int:task_id>', methods=['PUT', 'PATCH'])
|
|
@require_api_token('write:tasks')
|
|
def update_task(task_id):
|
|
"""Update a task
|
|
---
|
|
tags:
|
|
- Tasks
|
|
parameters:
|
|
- name: task_id
|
|
in: path
|
|
type: integer
|
|
required: true
|
|
- name: body
|
|
in: body
|
|
schema:
|
|
type: object
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Task updated
|
|
404:
|
|
description: Task not found
|
|
"""
|
|
task = Task.query.get_or_404(task_id)
|
|
data = request.get_json() or {}
|
|
|
|
# Update fields
|
|
if 'name' in data:
|
|
task.name = data['name']
|
|
if 'description' in data:
|
|
task.description = data['description']
|
|
if 'status' in data:
|
|
task.status = data['status']
|
|
if 'priority' in data:
|
|
task.priority = data['priority']
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'message': 'Task updated successfully',
|
|
'task': task.to_dict()
|
|
})
|
|
|
|
|
|
@api_v1_bp.route('/tasks/<int:task_id>', methods=['DELETE'])
|
|
@require_api_token('write:tasks')
|
|
def delete_task(task_id):
|
|
"""Delete a task
|
|
---
|
|
tags:
|
|
- Tasks
|
|
parameters:
|
|
- name: task_id
|
|
in: path
|
|
type: integer
|
|
required: true
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Task deleted
|
|
404:
|
|
description: Task not found
|
|
"""
|
|
task = Task.query.get_or_404(task_id)
|
|
|
|
db.session.delete(task)
|
|
db.session.commit()
|
|
|
|
return jsonify({'message': 'Task deleted successfully'})
|
|
|
|
|
|
# ==================== Clients ====================
|
|
|
|
@api_v1_bp.route('/clients', methods=['GET'])
|
|
@require_api_token('read:clients')
|
|
def list_clients():
|
|
"""List all clients
|
|
---
|
|
tags:
|
|
- Clients
|
|
parameters:
|
|
- name: page
|
|
in: query
|
|
type: integer
|
|
- name: per_page
|
|
in: query
|
|
type: integer
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: List of clients
|
|
"""
|
|
query = Client.query.order_by(Client.name)
|
|
|
|
# Paginate
|
|
result = paginate_query(query)
|
|
|
|
return jsonify({
|
|
'clients': [c.to_dict() for c in result['items']],
|
|
'pagination': result['pagination']
|
|
})
|
|
|
|
|
|
@api_v1_bp.route('/clients/<int:client_id>', methods=['GET'])
|
|
@require_api_token('read:clients')
|
|
def get_client(client_id):
|
|
"""Get a specific client
|
|
---
|
|
tags:
|
|
- Clients
|
|
parameters:
|
|
- name: client_id
|
|
in: path
|
|
type: integer
|
|
required: true
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Client details
|
|
404:
|
|
description: Client not found
|
|
"""
|
|
client = Client.query.get_or_404(client_id)
|
|
return jsonify({'client': client.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/clients', methods=['POST'])
|
|
@require_api_token('write:clients')
|
|
def create_client():
|
|
"""Create a new client
|
|
---
|
|
tags:
|
|
- Clients
|
|
parameters:
|
|
- name: body
|
|
in: body
|
|
required: true
|
|
schema:
|
|
type: object
|
|
required:
|
|
- name
|
|
properties:
|
|
name:
|
|
type: string
|
|
email:
|
|
type: string
|
|
company:
|
|
type: string
|
|
phone:
|
|
type: string
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
201:
|
|
description: Client created
|
|
400:
|
|
description: Invalid input
|
|
"""
|
|
data = request.get_json() or {}
|
|
|
|
# Validate required fields
|
|
if not data.get('name'):
|
|
return jsonify({'error': 'Client name is required'}), 400
|
|
|
|
# Create client
|
|
client = Client(
|
|
name=data['name'],
|
|
email=data.get('email'),
|
|
company=data.get('company'),
|
|
phone=data.get('phone')
|
|
)
|
|
|
|
db.session.add(client)
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'message': 'Client created successfully',
|
|
'client': client.to_dict()
|
|
}), 201
|
|
|
|
|
|
# ==================== Invoices ====================
|
|
|
|
@api_v1_bp.route('/invoices', methods=['GET'])
|
|
@require_api_token('read:invoices')
|
|
def list_invoices():
|
|
"""List invoices
|
|
---
|
|
tags:
|
|
- Invoices
|
|
parameters:
|
|
- name: status
|
|
in: query
|
|
type: string
|
|
- name: client_id
|
|
in: query
|
|
type: integer
|
|
- name: project_id
|
|
in: query
|
|
type: integer
|
|
- name: page
|
|
in: query
|
|
type: integer
|
|
- name: per_page
|
|
in: query
|
|
type: integer
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: List of invoices
|
|
"""
|
|
query = Invoice.query
|
|
status = request.args.get('status')
|
|
if status:
|
|
query = query.filter(Invoice.status == status)
|
|
client_id = request.args.get('client_id', type=int)
|
|
if client_id:
|
|
query = query.filter(Invoice.client_id == client_id)
|
|
project_id = request.args.get('project_id', type=int)
|
|
if project_id:
|
|
query = query.filter(Invoice.project_id == project_id)
|
|
query = query.order_by(Invoice.created_at.desc())
|
|
result = paginate_query(query)
|
|
return jsonify({
|
|
'invoices': [inv.to_dict() for inv in result['items']],
|
|
'pagination': result['pagination']
|
|
})
|
|
|
|
|
|
@api_v1_bp.route('/invoices/<int:invoice_id>', methods=['GET'])
|
|
@require_api_token('read:invoices')
|
|
def get_invoice(invoice_id):
|
|
"""Get invoice by id
|
|
---
|
|
tags:
|
|
- Invoices
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Invoice
|
|
404:
|
|
description: Not found
|
|
"""
|
|
invoice = Invoice.query.get_or_404(invoice_id)
|
|
return jsonify({'invoice': invoice.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/invoices', methods=['POST'])
|
|
@require_api_token('write:invoices')
|
|
def create_invoice():
|
|
"""Create a new invoice
|
|
---
|
|
tags:
|
|
- Invoices
|
|
parameters:
|
|
- name: body
|
|
in: body
|
|
required: true
|
|
schema:
|
|
type: object
|
|
required:
|
|
- project_id
|
|
- client_id
|
|
- client_name
|
|
- due_date
|
|
properties:
|
|
invoice_number: { type: string }
|
|
project_id: { type: integer }
|
|
client_id: { type: integer }
|
|
client_name: { type: string }
|
|
client_email: { type: string }
|
|
client_address: { type: string }
|
|
due_date: { type: string, format: date }
|
|
tax_rate: { type: number }
|
|
currency_code: { type: string }
|
|
notes: { type: string }
|
|
terms: { type: string }
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
201:
|
|
description: Invoice created
|
|
400:
|
|
description: Invalid input
|
|
"""
|
|
data = request.get_json() or {}
|
|
# Validate required fields
|
|
required = ['project_id', 'client_id', 'client_name', 'due_date']
|
|
missing = [f for f in required if not data.get(f)]
|
|
if missing:
|
|
return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400
|
|
# Validate foreign keys
|
|
project = Project.query.get(data['project_id'])
|
|
client = Client.query.get(data['client_id'])
|
|
if not project or not client:
|
|
return jsonify({'error': 'Invalid project_id or client_id'}), 400
|
|
due_dt = _parse_date(data.get('due_date'))
|
|
if not due_dt:
|
|
return jsonify({'error': 'Invalid due_date format, expected YYYY-MM-DD'}), 400
|
|
invoice_number = data.get('invoice_number') or Invoice.generate_invoice_number()
|
|
invoice = Invoice(
|
|
invoice_number=invoice_number,
|
|
project_id=project.id,
|
|
client_name=data['client_name'],
|
|
client_id=client.id,
|
|
due_date=due_dt,
|
|
created_by=g.api_user.id,
|
|
client_email=data.get('client_email'),
|
|
client_address=data.get('client_address'),
|
|
notes=data.get('notes'),
|
|
terms=data.get('terms'),
|
|
tax_rate=data.get('tax_rate', 0),
|
|
currency_code=data.get('currency_code', 'EUR'),
|
|
)
|
|
db.session.add(invoice)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Invoice created successfully', 'invoice': invoice.to_dict()}), 201
|
|
|
|
|
|
@api_v1_bp.route('/invoices/<int:invoice_id>', methods=['PUT', 'PATCH'])
|
|
@require_api_token('write:invoices')
|
|
def update_invoice(invoice_id):
|
|
"""Update an invoice
|
|
---
|
|
tags:
|
|
- Invoices
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Invoice updated
|
|
404:
|
|
description: Not found
|
|
"""
|
|
invoice = Invoice.query.get_or_404(invoice_id)
|
|
data = request.get_json() or {}
|
|
# Update basic fields if present
|
|
for field in ('client_name', 'client_email', 'client_address', 'notes', 'terms', 'status', 'currency_code'):
|
|
if field in data:
|
|
setattr(invoice, field, data[field])
|
|
if 'due_date' in data:
|
|
parsed = _parse_date(data['due_date'])
|
|
if parsed:
|
|
invoice.due_date = parsed
|
|
if 'tax_rate' in data:
|
|
try:
|
|
invoice.tax_rate = float(data['tax_rate'])
|
|
except Exception:
|
|
pass
|
|
if 'amount_paid' in data:
|
|
try:
|
|
from decimal import Decimal
|
|
invoice.amount_paid = Decimal(str(data['amount_paid']))
|
|
invoice.update_payment_status()
|
|
except Exception:
|
|
pass
|
|
db.session.commit()
|
|
return jsonify({'message': 'Invoice updated successfully', 'invoice': invoice.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/invoices/<int:invoice_id>', methods=['DELETE'])
|
|
@require_api_token('write:invoices')
|
|
def delete_invoice(invoice_id):
|
|
"""Cancel an invoice (soft-delete)
|
|
---
|
|
tags:
|
|
- Invoices
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Invoice cancelled
|
|
404:
|
|
description: Not found
|
|
"""
|
|
invoice = Invoice.query.get_or_404(invoice_id)
|
|
invoice.status = 'cancelled'
|
|
db.session.commit()
|
|
return jsonify({'message': 'Invoice cancelled successfully'})
|
|
|
|
|
|
# ==================== Expenses ====================
|
|
|
|
@api_v1_bp.route('/expenses', methods=['GET'])
|
|
@require_api_token('read:expenses')
|
|
def list_expenses():
|
|
"""List expenses
|
|
---
|
|
tags:
|
|
- Expenses
|
|
parameters:
|
|
- name: user_id
|
|
in: query
|
|
type: integer
|
|
- name: project_id
|
|
in: query
|
|
type: integer
|
|
- name: client_id
|
|
in: query
|
|
type: integer
|
|
- name: status
|
|
in: query
|
|
type: string
|
|
- name: category
|
|
in: query
|
|
type: string
|
|
- name: start_date
|
|
in: query
|
|
type: string
|
|
format: date
|
|
- name: end_date
|
|
in: query
|
|
type: string
|
|
format: date
|
|
- name: page
|
|
in: query
|
|
type: integer
|
|
- name: per_page
|
|
in: query
|
|
type: integer
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: List of expenses
|
|
"""
|
|
query = Expense.query
|
|
# Restrict by user if not admin
|
|
user_id = request.args.get('user_id', type=int)
|
|
if user_id:
|
|
if g.api_user.is_admin or user_id == g.api_user.id:
|
|
query = query.filter(Expense.user_id == user_id)
|
|
else:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
else:
|
|
if not g.api_user.is_admin:
|
|
query = query.filter(Expense.user_id == g.api_user.id)
|
|
# Other filters
|
|
project_id = request.args.get('project_id', type=int)
|
|
if project_id:
|
|
query = query.filter(Expense.project_id == project_id)
|
|
client_id = request.args.get('client_id', type=int)
|
|
if client_id:
|
|
query = query.filter(Expense.client_id == client_id)
|
|
status = request.args.get('status')
|
|
if status:
|
|
query = query.filter(Expense.status == status)
|
|
category = request.args.get('category')
|
|
if category:
|
|
query = query.filter(Expense.category == category)
|
|
start_date = _parse_date(request.args.get('start_date'))
|
|
end_date = _parse_date(request.args.get('end_date'))
|
|
if start_date:
|
|
query = query.filter(Expense.expense_date >= start_date)
|
|
if end_date:
|
|
query = query.filter(Expense.expense_date <= end_date)
|
|
query = query.order_by(Expense.expense_date.desc(), Expense.created_at.desc())
|
|
result = paginate_query(query)
|
|
return jsonify({
|
|
'expenses': [e.to_dict() for e in result['items']],
|
|
'pagination': result['pagination']
|
|
})
|
|
|
|
|
|
@api_v1_bp.route('/expenses/<int:expense_id>', methods=['GET'])
|
|
@require_api_token('read:expenses')
|
|
def get_expense(expense_id):
|
|
"""Get an expense
|
|
---
|
|
tags:
|
|
- Expenses
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Expense
|
|
404:
|
|
description: Not found
|
|
"""
|
|
expense = Expense.query.get_or_404(expense_id)
|
|
if not g.api_user.is_admin and expense.user_id != g.api_user.id:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
return jsonify({'expense': expense.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/expenses', methods=['POST'])
|
|
@require_api_token('write:expenses')
|
|
def create_expense():
|
|
"""Create a new expense
|
|
---
|
|
tags:
|
|
- Expenses
|
|
parameters:
|
|
- name: body
|
|
in: body
|
|
required: true
|
|
schema:
|
|
type: object
|
|
required:
|
|
- title
|
|
- category
|
|
- amount
|
|
- expense_date
|
|
properties:
|
|
title: { type: string }
|
|
description: { type: string }
|
|
category: { type: string }
|
|
amount: { type: number }
|
|
currency_code: { type: string }
|
|
expense_date: { type: string, format: date }
|
|
project_id: { type: integer }
|
|
client_id: { type: integer }
|
|
billable: { type: boolean }
|
|
reimbursable: { type: boolean }
|
|
payment_method: { type: string }
|
|
payment_date: { type: string, format: date }
|
|
tags: { type: string }
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
201:
|
|
description: Expense created
|
|
400:
|
|
description: Invalid input
|
|
"""
|
|
data = request.get_json() or {}
|
|
required = ['title', 'category', 'amount', 'expense_date']
|
|
missing = [f for f in required if not data.get(f)]
|
|
if missing:
|
|
return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400
|
|
exp_date = _parse_date(data.get('expense_date'))
|
|
if not exp_date:
|
|
return jsonify({'error': 'Invalid expense_date format, expected YYYY-MM-DD'}), 400
|
|
pay_date = _parse_date(data.get('payment_date'))
|
|
from decimal import Decimal
|
|
try:
|
|
amount = Decimal(str(data['amount']))
|
|
except Exception:
|
|
return jsonify({'error': 'Invalid amount'}), 400
|
|
expense = Expense(
|
|
user_id=g.api_user.id,
|
|
title=data['title'],
|
|
category=data['category'],
|
|
amount=amount,
|
|
expense_date=exp_date,
|
|
description=data.get('description'),
|
|
project_id=data.get('project_id'),
|
|
client_id=data.get('client_id'),
|
|
currency_code=data.get('currency_code', 'EUR'),
|
|
tax_amount=data.get('tax_amount', 0),
|
|
tax_rate=data.get('tax_rate', 0),
|
|
payment_method=data.get('payment_method'),
|
|
payment_date=pay_date,
|
|
billable=data.get('billable', False),
|
|
reimbursable=data.get('reimbursable', True),
|
|
tags=data.get('tags'),
|
|
)
|
|
db.session.add(expense)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Expense created successfully', 'expense': expense.to_dict()}), 201
|
|
|
|
|
|
@api_v1_bp.route('/expenses/<int:expense_id>', methods=['PUT', 'PATCH'])
|
|
@require_api_token('write:expenses')
|
|
def update_expense(expense_id):
|
|
"""Update an expense
|
|
---
|
|
tags:
|
|
- Expenses
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Expense updated
|
|
404:
|
|
description: Not found
|
|
"""
|
|
expense = Expense.query.get_or_404(expense_id)
|
|
if not g.api_user.is_admin and expense.user_id != g.api_user.id:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
data = request.get_json() or {}
|
|
for field in ('title', 'description', 'category', 'currency_code', 'payment_method', 'status', 'tags'):
|
|
if field in data:
|
|
setattr(expense, field, data[field])
|
|
if 'amount' in data:
|
|
try:
|
|
from decimal import Decimal
|
|
expense.amount = Decimal(str(data['amount']))
|
|
except Exception:
|
|
pass
|
|
if 'expense_date' in data:
|
|
parsed = _parse_date(data['expense_date'])
|
|
if parsed:
|
|
expense.expense_date = parsed
|
|
if 'payment_date' in data:
|
|
parsed = _parse_date(data['payment_date'])
|
|
expense.payment_date = parsed
|
|
for bfield in ('billable', 'reimbursable', 'reimbursed', 'invoiced'):
|
|
if bfield in data:
|
|
setattr(expense, bfield, bool(data[bfield]))
|
|
db.session.commit()
|
|
return jsonify({'message': 'Expense updated successfully', 'expense': expense.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/expenses/<int:expense_id>', methods=['DELETE'])
|
|
@require_api_token('write:expenses')
|
|
def delete_expense(expense_id):
|
|
"""Reject an expense (soft-delete)
|
|
---
|
|
tags:
|
|
- Expenses
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Expense rejected
|
|
404:
|
|
description: Not found
|
|
"""
|
|
expense = Expense.query.get_or_404(expense_id)
|
|
if not g.api_user.is_admin and expense.user_id != g.api_user.id:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
expense.status = 'rejected'
|
|
db.session.commit()
|
|
return jsonify({'message': 'Expense rejected successfully'})
|
|
|
|
|
|
# ==================== Payments ====================
|
|
|
|
@api_v1_bp.route('/payments', methods=['GET'])
|
|
@require_api_token('read:payments')
|
|
def list_payments():
|
|
"""List payments
|
|
---
|
|
tags:
|
|
- Payments
|
|
parameters:
|
|
- name: invoice_id
|
|
in: query
|
|
type: integer
|
|
- name: page
|
|
in: query
|
|
type: integer
|
|
- name: per_page
|
|
in: query
|
|
type: integer
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: List of payments
|
|
"""
|
|
query = Payment.query
|
|
invoice_id = request.args.get('invoice_id', type=int)
|
|
if invoice_id:
|
|
query = query.filter(Payment.invoice_id == invoice_id)
|
|
query = query.order_by(Payment.created_at.desc())
|
|
result = paginate_query(query)
|
|
return jsonify({'payments': [p.to_dict() for p in result['items']], 'pagination': result['pagination']})
|
|
|
|
|
|
@api_v1_bp.route('/payments/<int:payment_id>', methods=['GET'])
|
|
@require_api_token('read:payments')
|
|
def get_payment(payment_id):
|
|
"""Get a payment
|
|
---
|
|
tags:
|
|
- Payments
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Payment
|
|
"""
|
|
payment = Payment.query.get_or_404(payment_id)
|
|
return jsonify({'payment': payment.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/payments', methods=['POST'])
|
|
@require_api_token('write:payments')
|
|
def create_payment():
|
|
"""Create a payment
|
|
---
|
|
tags:
|
|
- Payments
|
|
parameters:
|
|
- name: body
|
|
in: body
|
|
required: true
|
|
schema:
|
|
type: object
|
|
required: [invoice_id, amount]
|
|
properties:
|
|
invoice_id: { type: integer }
|
|
amount: { type: number }
|
|
currency: { type: string }
|
|
payment_date: { type: string, format: date }
|
|
method: { type: string }
|
|
reference: { type: string }
|
|
notes: { type: string }
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
201:
|
|
description: Payment created
|
|
"""
|
|
data = request.get_json() or {}
|
|
required = ['invoice_id', 'amount']
|
|
missing = [f for f in required if not data.get(f)]
|
|
if missing:
|
|
return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400
|
|
inv = Invoice.query.get(data['invoice_id'])
|
|
if not inv:
|
|
return jsonify({'error': 'Invalid invoice_id'}), 400
|
|
from decimal import Decimal
|
|
try:
|
|
amount = Decimal(str(data['amount']))
|
|
except Exception:
|
|
return jsonify({'error': 'Invalid amount'}), 400
|
|
pay_date = _parse_date(data.get('payment_date'))
|
|
payment = Payment(
|
|
invoice_id=inv.id,
|
|
amount=amount,
|
|
currency=data.get('currency', 'EUR'),
|
|
payment_date=pay_date or None,
|
|
method=data.get('method'),
|
|
reference=data.get('reference'),
|
|
notes=data.get('notes'),
|
|
received_by=getattr(g.api_user, 'id', None),
|
|
)
|
|
payment.calculate_net_amount()
|
|
db.session.add(payment)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Payment created successfully', 'payment': payment.to_dict()}), 201
|
|
|
|
|
|
@api_v1_bp.route('/payments/<int:payment_id>', methods=['PUT', 'PATCH'])
|
|
@require_api_token('write:payments')
|
|
def update_payment(payment_id):
|
|
"""Update a payment
|
|
---
|
|
tags:
|
|
- Payments
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Payment updated
|
|
"""
|
|
payment = Payment.query.get_or_404(payment_id)
|
|
data = request.get_json() or {}
|
|
for field in ('currency', 'method', 'reference', 'notes', 'status'):
|
|
if field in data:
|
|
setattr(payment, field, data[field])
|
|
if 'amount' in data:
|
|
try:
|
|
from decimal import Decimal
|
|
payment.amount = Decimal(str(data['amount']))
|
|
except Exception:
|
|
pass
|
|
if 'payment_date' in data:
|
|
parsed = _parse_date(data['payment_date'])
|
|
payment.payment_date = parsed
|
|
payment.calculate_net_amount()
|
|
db.session.commit()
|
|
return jsonify({'message': 'Payment updated successfully', 'payment': payment.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/payments/<int:payment_id>', methods=['DELETE'])
|
|
@require_api_token('write:payments')
|
|
def delete_payment(payment_id):
|
|
"""Delete a payment
|
|
---
|
|
tags:
|
|
- Payments
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Payment deleted
|
|
"""
|
|
payment = Payment.query.get_or_404(payment_id)
|
|
db.session.delete(payment)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Payment deleted successfully'})
|
|
|
|
|
|
# ==================== Mileage ====================
|
|
|
|
@api_v1_bp.route('/mileage', methods=['GET'])
|
|
@require_api_token('read:mileage')
|
|
def list_mileage():
|
|
"""List mileage entries (non-admin see own only)
|
|
---
|
|
tags:
|
|
- Mileage
|
|
parameters:
|
|
- name: user_id
|
|
in: query
|
|
type: integer
|
|
- name: project_id
|
|
in: query
|
|
type: integer
|
|
- name: start_date
|
|
in: query
|
|
type: string
|
|
format: date
|
|
- name: end_date
|
|
in: query
|
|
type: string
|
|
format: date
|
|
- name: page
|
|
in: query
|
|
type: integer
|
|
- name: per_page
|
|
in: query
|
|
type: integer
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: List of mileage entries
|
|
"""
|
|
query = Mileage.query
|
|
user_id = request.args.get('user_id', type=int)
|
|
if user_id:
|
|
if g.api_user.is_admin or user_id == g.api_user.id:
|
|
query = query.filter(Mileage.user_id == user_id)
|
|
else:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
else:
|
|
if not g.api_user.is_admin:
|
|
query = query.filter(Mileage.user_id == g.api_user.id)
|
|
project_id = request.args.get('project_id', type=int)
|
|
if project_id:
|
|
query = query.filter(Mileage.project_id == project_id)
|
|
start_date = _parse_date(request.args.get('start_date'))
|
|
end_date = _parse_date(request.args.get('end_date'))
|
|
if start_date:
|
|
query = query.filter(Mileage.trip_date >= start_date)
|
|
if end_date:
|
|
query = query.filter(Mileage.trip_date <= end_date)
|
|
query = query.order_by(Mileage.trip_date.desc(), Mileage.created_at.desc())
|
|
result = paginate_query(query)
|
|
return jsonify({'mileage': [m.to_dict() for m in result['items']], 'pagination': result['pagination']})
|
|
|
|
|
|
@api_v1_bp.route('/mileage/<int:entry_id>', methods=['GET'])
|
|
@require_api_token('read:mileage')
|
|
def get_mileage(entry_id):
|
|
"""Get a mileage entry
|
|
---
|
|
tags:
|
|
- Mileage
|
|
security:
|
|
- Bearer: []
|
|
"""
|
|
entry = Mileage.query.get_or_404(entry_id)
|
|
if not g.api_user.is_admin and entry.user_id != g.api_user.id:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
return jsonify({'mileage': entry.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/mileage', methods=['POST'])
|
|
@require_api_token('write:mileage')
|
|
def create_mileage():
|
|
"""Create a mileage entry
|
|
---
|
|
tags:
|
|
- Mileage
|
|
parameters:
|
|
- name: body
|
|
in: body
|
|
required: true
|
|
schema:
|
|
type: object
|
|
required: [trip_date, purpose, start_location, end_location, distance_km, rate_per_km]
|
|
properties:
|
|
trip_date: { type: string, format: date }
|
|
purpose: { type: string }
|
|
start_location: { type: string }
|
|
end_location: { type: string }
|
|
distance_km: { type: number }
|
|
rate_per_km: { type: number }
|
|
project_id: { type: integer }
|
|
client_id: { type: integer }
|
|
is_round_trip: { type: boolean }
|
|
"""
|
|
data = request.get_json() or {}
|
|
required = ['trip_date', 'purpose', 'start_location', 'end_location', 'distance_km', 'rate_per_km']
|
|
missing = [f for f in required if not data.get(f)]
|
|
if missing:
|
|
return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400
|
|
trip_date = _parse_date(data.get('trip_date'))
|
|
if not trip_date:
|
|
return jsonify({'error': 'Invalid trip_date format, expected YYYY-MM-DD'}), 400
|
|
from decimal import Decimal
|
|
try:
|
|
distance_km = Decimal(str(data['distance_km']))
|
|
rate_per_km = Decimal(str(data['rate_per_km']))
|
|
except Exception:
|
|
return jsonify({'error': 'Invalid distance_km or rate_per_km'}), 400
|
|
entry = Mileage(
|
|
user_id=g.api_user.id,
|
|
trip_date=trip_date,
|
|
purpose=data['purpose'],
|
|
start_location=data['start_location'],
|
|
end_location=data['end_location'],
|
|
distance_km=distance_km,
|
|
rate_per_km=rate_per_km,
|
|
project_id=data.get('project_id'),
|
|
client_id=data.get('client_id'),
|
|
is_round_trip=bool(data.get('is_round_trip', False)),
|
|
description=data.get('description'),
|
|
)
|
|
db.session.add(entry)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Mileage entry created successfully', 'mileage': entry.to_dict()}), 201
|
|
|
|
|
|
@api_v1_bp.route('/mileage/<int:entry_id>', methods=['PUT', 'PATCH'])
|
|
@require_api_token('write:mileage')
|
|
def update_mileage(entry_id):
|
|
"""Update a mileage entry
|
|
---
|
|
tags:
|
|
- Mileage
|
|
"""
|
|
entry = Mileage.query.get_or_404(entry_id)
|
|
if not g.api_user.is_admin and entry.user_id != g.api_user.id:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
data = request.get_json() or {}
|
|
for field in ('purpose', 'start_location', 'end_location', 'description', 'vehicle_type', 'vehicle_description', 'license_plate', 'currency_code', 'status', 'notes'):
|
|
if field in data:
|
|
setattr(entry, field, data[field])
|
|
if 'trip_date' in data:
|
|
parsed = _parse_date(data['trip_date'])
|
|
if parsed:
|
|
entry.trip_date = parsed
|
|
for numfield in ('distance_km', 'rate_per_km', 'start_odometer', 'end_odometer'):
|
|
if numfield in data:
|
|
try:
|
|
from decimal import Decimal
|
|
setattr(entry, numfield, Decimal(str(data[numfield])))
|
|
except Exception:
|
|
pass
|
|
if 'is_round_trip' in data:
|
|
entry.is_round_trip = bool(data['is_round_trip'])
|
|
db.session.commit()
|
|
return jsonify({'message': 'Mileage entry updated successfully', 'mileage': entry.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/mileage/<int:entry_id>', methods=['DELETE'])
|
|
@require_api_token('write:mileage')
|
|
def delete_mileage(entry_id):
|
|
"""Reject a mileage entry
|
|
---
|
|
tags:
|
|
- Mileage
|
|
"""
|
|
entry = Mileage.query.get_or_404(entry_id)
|
|
if not g.api_user.is_admin and entry.user_id != g.api_user.id:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
entry.status = 'rejected'
|
|
db.session.commit()
|
|
return jsonify({'message': 'Mileage entry rejected successfully'})
|
|
|
|
|
|
# ==================== Per Diem ====================
|
|
|
|
@api_v1_bp.route('/per-diems', methods=['GET'])
|
|
@require_api_token('read:per_diem')
|
|
def list_per_diems():
|
|
"""List per diem claims (non-admin see own only)
|
|
---
|
|
tags:
|
|
- PerDiem
|
|
"""
|
|
query = PerDiem.query
|
|
if not g.api_user.is_admin:
|
|
query = query.filter(PerDiem.user_id == g.api_user.id)
|
|
result = paginate_query(query.order_by(PerDiem.start_date.desc()))
|
|
return jsonify({'per_diems': [p.to_dict() for p in result['items']], 'pagination': result['pagination']})
|
|
|
|
|
|
@api_v1_bp.route('/per-diems/<int:pd_id>', methods=['GET'])
|
|
@require_api_token('read:per_diem')
|
|
def get_per_diem(pd_id):
|
|
"""Get a per diem claim
|
|
---
|
|
tags:
|
|
- PerDiem
|
|
"""
|
|
pd = PerDiem.query.get_or_404(pd_id)
|
|
if not g.api_user.is_admin and pd.user_id != g.api_user.id:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
return jsonify({'per_diem': pd.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/per-diems', methods=['POST'])
|
|
@require_api_token('write:per_diem')
|
|
def create_per_diem():
|
|
"""Create a per diem claim
|
|
---
|
|
tags:
|
|
- PerDiem
|
|
"""
|
|
data = request.get_json() or {}
|
|
required = ['trip_purpose', 'start_date', 'end_date', 'country', 'full_day_rate', 'half_day_rate']
|
|
missing = [f for f in required if not data.get(f)]
|
|
if missing:
|
|
return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400
|
|
sdate = _parse_date(data.get('start_date'))
|
|
edate = _parse_date(data.get('end_date'))
|
|
if not sdate or not edate or edate < sdate:
|
|
return jsonify({'error': 'Invalid date range'}), 400
|
|
from decimal import Decimal
|
|
try:
|
|
fdr = Decimal(str(data['full_day_rate']))
|
|
hdr = Decimal(str(data['half_day_rate']))
|
|
except Exception:
|
|
return jsonify({'error': 'Invalid rates'}), 400
|
|
pd = PerDiem(
|
|
user_id=g.api_user.id,
|
|
trip_purpose=data['trip_purpose'],
|
|
start_date=sdate,
|
|
end_date=edate,
|
|
country=data['country'],
|
|
full_day_rate=fdr,
|
|
half_day_rate=hdr,
|
|
city=data.get('city'),
|
|
description=data.get('description'),
|
|
currency_code=data.get('currency_code', 'EUR'),
|
|
full_days=data.get('full_days', 0),
|
|
half_days=data.get('half_days', 0),
|
|
breakfast_provided=data.get('breakfast_provided', 0),
|
|
lunch_provided=data.get('lunch_provided', 0),
|
|
dinner_provided=data.get('dinner_provided', 0),
|
|
)
|
|
pd.recalculate_amount()
|
|
db.session.add(pd)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Per diem created successfully', 'per_diem': pd.to_dict()}), 201
|
|
|
|
|
|
@api_v1_bp.route('/per-diems/<int:pd_id>', methods=['PUT', 'PATCH'])
|
|
@require_api_token('write:per_diem')
|
|
def update_per_diem(pd_id):
|
|
"""Update a per diem claim
|
|
---
|
|
tags:
|
|
- PerDiem
|
|
"""
|
|
pd = PerDiem.query.get_or_404(pd_id)
|
|
if not g.api_user.is_admin and pd.user_id != g.api_user.id:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
data = request.get_json() or {}
|
|
for field in ('trip_purpose', 'description', 'country', 'city', 'currency_code', 'status', 'notes'):
|
|
if field in data:
|
|
setattr(pd, field, data[field])
|
|
for numfield in ('full_days', 'half_days', 'breakfast_provided', 'lunch_provided', 'dinner_provided'):
|
|
if numfield in data:
|
|
try:
|
|
setattr(pd, numfield, int(data[numfield]))
|
|
except Exception:
|
|
pass
|
|
for ratefield in ('full_day_rate', 'half_day_rate', 'breakfast_deduction', 'lunch_deduction', 'dinner_deduction'):
|
|
if ratefield in data:
|
|
try:
|
|
from decimal import Decimal
|
|
setattr(pd, ratefield, Decimal(str(data[ratefield])))
|
|
except Exception:
|
|
pass
|
|
if 'start_date' in data:
|
|
parsed = _parse_date(data['start_date'])
|
|
if parsed:
|
|
pd.start_date = parsed
|
|
if 'end_date' in data:
|
|
parsed = _parse_date(data['end_date'])
|
|
if parsed:
|
|
pd.end_date = parsed
|
|
pd.recalculate_amount()
|
|
db.session.commit()
|
|
return jsonify({'message': 'Per diem updated successfully', 'per_diem': pd.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/per-diems/<int:pd_id>', methods=['DELETE'])
|
|
@require_api_token('write:per_diem')
|
|
def delete_per_diem(pd_id):
|
|
"""Reject a per diem claim
|
|
---
|
|
tags:
|
|
- PerDiem
|
|
"""
|
|
pd = PerDiem.query.get_or_404(pd_id)
|
|
if not g.api_user.is_admin and pd.user_id != g.api_user.id:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
pd.status = 'rejected'
|
|
db.session.commit()
|
|
return jsonify({'message': 'Per diem rejected successfully'})
|
|
|
|
|
|
@api_v1_bp.route('/per-diem-rates', methods=['GET'])
|
|
@require_api_token('read:per_diem')
|
|
def list_per_diem_rates():
|
|
"""List per diem rates
|
|
---
|
|
tags:
|
|
- PerDiemRates
|
|
"""
|
|
query = PerDiemRate.query.filter(PerDiemRate.is_active == True)
|
|
result = paginate_query(query.order_by(PerDiemRate.country.asc(), PerDiemRate.city.asc()))
|
|
return jsonify({'rates': [r.to_dict() for r in result['items']], 'pagination': result['pagination']})
|
|
|
|
|
|
@api_v1_bp.route('/per-diem-rates', methods=['POST'])
|
|
@require_api_token('admin:all')
|
|
def create_per_diem_rate():
|
|
"""Create a per diem rate (admin)
|
|
---
|
|
tags:
|
|
- PerDiemRates
|
|
"""
|
|
data = request.get_json() or {}
|
|
required = ['country', 'full_day_rate', 'half_day_rate', 'effective_from']
|
|
missing = [f for f in required if not data.get(f)]
|
|
if missing:
|
|
return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400
|
|
eff_from = _parse_date(data.get('effective_from'))
|
|
eff_to = _parse_date(data.get('effective_to'))
|
|
from decimal import Decimal
|
|
try:
|
|
fdr = Decimal(str(data['full_day_rate']))
|
|
hdr = Decimal(str(data['half_day_rate']))
|
|
except Exception:
|
|
return jsonify({'error': 'Invalid rates'}), 400
|
|
rate = PerDiemRate(
|
|
country=data['country'],
|
|
full_day_rate=fdr,
|
|
half_day_rate=hdr,
|
|
effective_from=eff_from,
|
|
effective_to=eff_to,
|
|
city=data.get('city'),
|
|
currency_code=data.get('currency_code', 'EUR'),
|
|
breakfast_rate=data.get('breakfast_rate'),
|
|
lunch_rate=data.get('lunch_rate'),
|
|
dinner_rate=data.get('dinner_rate'),
|
|
incidental_rate=data.get('incidental_rate'),
|
|
is_active=bool(data.get('is_active', True)),
|
|
notes=data.get('notes'),
|
|
)
|
|
db.session.add(rate)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Per diem rate created successfully', 'rate': rate.to_dict()}), 201
|
|
|
|
|
|
# ==================== Budget Alerts ====================
|
|
|
|
@api_v1_bp.route('/budget-alerts', methods=['GET'])
|
|
@require_api_token('read:budget_alerts')
|
|
def list_budget_alerts():
|
|
"""List budget alerts
|
|
---
|
|
tags:
|
|
- BudgetAlerts
|
|
"""
|
|
query = BudgetAlert.query
|
|
project_id = request.args.get('project_id', type=int)
|
|
if project_id:
|
|
query = query.filter(BudgetAlert.project_id == project_id)
|
|
result = paginate_query(query.order_by(BudgetAlert.created_at.desc()))
|
|
return jsonify({'alerts': [a.to_dict() for a in result['items']], 'pagination': result['pagination']})
|
|
|
|
|
|
@api_v1_bp.route('/budget-alerts', methods=['POST'])
|
|
@require_api_token('admin:all')
|
|
def create_budget_alert():
|
|
"""Create a budget alert (admin)
|
|
---
|
|
tags:
|
|
- BudgetAlerts
|
|
"""
|
|
data = request.get_json() or {}
|
|
required = ['project_id', 'alert_type', 'budget_consumed_percent', 'budget_amount', 'consumed_amount', 'message']
|
|
missing = [f for f in required if not data.get(f)]
|
|
if missing:
|
|
return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400
|
|
alert = BudgetAlert(
|
|
project_id=data['project_id'],
|
|
alert_type=data['alert_type'],
|
|
alert_level=data.get('alert_level', 'info'),
|
|
budget_consumed_percent=data['budget_consumed_percent'],
|
|
budget_amount=data['budget_amount'],
|
|
consumed_amount=data['consumed_amount'],
|
|
message=data['message'],
|
|
)
|
|
db.session.add(alert)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Budget alert created successfully', 'alert': alert.to_dict()}), 201
|
|
|
|
|
|
@api_v1_bp.route('/budget-alerts/<int:alert_id>/ack', methods=['POST'])
|
|
@require_api_token('write:budget_alerts')
|
|
def acknowledge_budget_alert(alert_id):
|
|
"""Acknowledge a budget alert
|
|
---
|
|
tags:
|
|
- BudgetAlerts
|
|
"""
|
|
alert = BudgetAlert.query.get_or_404(alert_id)
|
|
alert.acknowledge(g.api_user.id)
|
|
return jsonify({'message': 'Alert acknowledged'})
|
|
|
|
|
|
# ==================== Calendar Events ====================
|
|
|
|
@api_v1_bp.route('/calendar/events', methods=['GET'])
|
|
@require_api_token('read:calendar')
|
|
def list_calendar_events():
|
|
"""List calendar events for current user
|
|
---
|
|
tags:
|
|
- Calendar
|
|
parameters:
|
|
- name: start
|
|
in: query
|
|
type: string
|
|
- name: end
|
|
in: query
|
|
type: string
|
|
"""
|
|
start = request.args.get('start')
|
|
end = request.args.get('end')
|
|
start_dt = parse_datetime(start) if start else None
|
|
end_dt = parse_datetime(end) if end else None
|
|
query = CalendarEvent.query.filter(CalendarEvent.user_id == g.api_user.id)
|
|
if start_dt:
|
|
query = query.filter(CalendarEvent.start_time >= start_dt)
|
|
if end_dt:
|
|
query = query.filter(CalendarEvent.start_time <= end_dt)
|
|
events = query.order_by(CalendarEvent.start_time.asc()).all()
|
|
return jsonify({'events': [e.to_dict() for e in events]})
|
|
|
|
|
|
@api_v1_bp.route('/calendar/events/<int:event_id>', methods=['GET'])
|
|
@require_api_token('read:calendar')
|
|
def get_calendar_event(event_id):
|
|
"""Get calendar event
|
|
---
|
|
tags:
|
|
- Calendar
|
|
"""
|
|
ev = CalendarEvent.query.get_or_404(event_id)
|
|
if not g.api_user.is_admin and ev.user_id != g.api_user.id:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
return jsonify({'event': ev.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/calendar/events', methods=['POST'])
|
|
@require_api_token('write:calendar')
|
|
def create_calendar_event():
|
|
"""Create calendar event
|
|
---
|
|
tags:
|
|
- Calendar
|
|
"""
|
|
data = request.get_json() or {}
|
|
required = ['title', 'start_time', 'end_time']
|
|
missing = [f for f in required if not data.get(f)]
|
|
if missing:
|
|
return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400
|
|
start_dt = parse_datetime(data['start_time'])
|
|
end_dt = parse_datetime(data['end_time'])
|
|
if not start_dt or not end_dt or end_dt <= start_dt:
|
|
return jsonify({'error': 'Invalid start/end time'}), 400
|
|
ev = CalendarEvent(
|
|
user_id=g.api_user.id,
|
|
title=data['title'],
|
|
start_time=start_dt,
|
|
end_time=end_dt,
|
|
description=data.get('description'),
|
|
all_day=bool(data.get('all_day', False)),
|
|
location=data.get('location'),
|
|
project_id=data.get('project_id'),
|
|
task_id=data.get('task_id'),
|
|
client_id=data.get('client_id'),
|
|
event_type=data.get('event_type', 'event'),
|
|
reminder_minutes=data.get('reminder_minutes'),
|
|
color=data.get('color'),
|
|
is_private=bool(data.get('is_private', False)),
|
|
)
|
|
db.session.add(ev)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Event created successfully', 'event': ev.to_dict()}), 201
|
|
|
|
|
|
@api_v1_bp.route('/calendar/events/<int:event_id>', methods=['PUT', 'PATCH'])
|
|
@require_api_token('write:calendar')
|
|
def update_calendar_event(event_id):
|
|
"""Update calendar event
|
|
---
|
|
tags:
|
|
- Calendar
|
|
"""
|
|
ev = CalendarEvent.query.get_or_404(event_id)
|
|
if not g.api_user.is_admin and ev.user_id != g.api_user.id:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
data = request.get_json() or {}
|
|
for field in ('title', 'description', 'location', 'event_type', 'color', 'is_private', 'reminder_minutes'):
|
|
if field in data:
|
|
setattr(ev, field, data[field])
|
|
if 'start_time' in data:
|
|
parsed = parse_datetime(data['start_time'])
|
|
if parsed:
|
|
ev.start_time = parsed
|
|
if 'end_time' in data:
|
|
parsed = parse_datetime(data['end_time'])
|
|
if parsed:
|
|
ev.end_time = parsed
|
|
db.session.commit()
|
|
return jsonify({'message': 'Event updated successfully', 'event': ev.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/calendar/events/<int:event_id>', methods=['DELETE'])
|
|
@require_api_token('write:calendar')
|
|
def delete_calendar_event(event_id):
|
|
"""Delete calendar event
|
|
---
|
|
tags:
|
|
- Calendar
|
|
"""
|
|
ev = CalendarEvent.query.get_or_404(event_id)
|
|
if not g.api_user.is_admin and ev.user_id != g.api_user.id:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
db.session.delete(ev)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Event deleted successfully'})
|
|
|
|
|
|
# ==================== Kanban Columns ====================
|
|
|
|
@api_v1_bp.route('/kanban/columns', methods=['GET'])
|
|
@require_api_token('read:tasks')
|
|
def list_kanban_columns():
|
|
"""List kanban columns
|
|
---
|
|
tags:
|
|
- Kanban
|
|
parameters:
|
|
- name: project_id
|
|
in: query
|
|
type: integer
|
|
"""
|
|
project_id = request.args.get('project_id', type=int)
|
|
cols = KanbanColumn.get_all_columns(project_id=project_id)
|
|
return jsonify({'columns': [c.to_dict() for c in cols]})
|
|
|
|
|
|
@api_v1_bp.route('/kanban/columns', methods=['POST'])
|
|
@require_api_token('write:tasks')
|
|
def create_kanban_column():
|
|
"""Create kanban column
|
|
---
|
|
tags:
|
|
- Kanban
|
|
"""
|
|
data = request.get_json() or {}
|
|
required = ['key', 'label']
|
|
missing = [f for f in required if not data.get(f)]
|
|
if missing:
|
|
return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400
|
|
col = KanbanColumn(
|
|
key=data['key'],
|
|
label=data['label'],
|
|
icon=data.get('icon', 'fas fa-circle'),
|
|
color=data.get('color', 'secondary'),
|
|
position=data.get('position', 0),
|
|
is_active=bool(data.get('is_active', True)),
|
|
is_system=bool(data.get('is_system', False)),
|
|
is_complete_state=bool(data.get('is_complete_state', False)),
|
|
project_id=data.get('project_id'),
|
|
)
|
|
db.session.add(col)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Column created successfully', 'column': col.to_dict()}), 201
|
|
|
|
|
|
@api_v1_bp.route('/kanban/columns/<int:col_id>', methods=['PUT', 'PATCH'])
|
|
@require_api_token('write:tasks')
|
|
def update_kanban_column(col_id):
|
|
"""Update kanban column
|
|
---
|
|
tags:
|
|
- Kanban
|
|
"""
|
|
col = KanbanColumn.query.get_or_404(col_id)
|
|
data = request.get_json() or {}
|
|
for field in ('key', 'label', 'icon', 'color', 'position', 'is_active', 'is_complete_state'):
|
|
if field in data:
|
|
setattr(col, field, data[field])
|
|
db.session.commit()
|
|
return jsonify({'message': 'Column updated successfully', 'column': col.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/kanban/columns/<int:col_id>', methods=['DELETE'])
|
|
@require_api_token('write:tasks')
|
|
def delete_kanban_column(col_id):
|
|
"""Delete kanban column
|
|
---
|
|
tags:
|
|
- Kanban
|
|
"""
|
|
col = KanbanColumn.query.get_or_404(col_id)
|
|
if col.is_system:
|
|
return jsonify({'error': 'Cannot delete system column'}), 400
|
|
db.session.delete(col)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Column deleted successfully'})
|
|
|
|
|
|
@api_v1_bp.route('/kanban/columns/reorder', methods=['POST'])
|
|
@require_api_token('write:tasks')
|
|
def reorder_kanban_columns():
|
|
"""Reorder kanban columns
|
|
---
|
|
tags:
|
|
- Kanban
|
|
"""
|
|
data = request.get_json() or {}
|
|
ids = data.get('column_ids') or []
|
|
project_id = data.get('project_id')
|
|
if not isinstance(ids, list) or not ids:
|
|
return jsonify({'error': 'column_ids must be a non-empty list'}), 400
|
|
KanbanColumn.reorder_columns(ids, project_id=project_id)
|
|
return jsonify({'message': 'Columns reordered successfully'})
|
|
|
|
|
|
# ==================== Saved Filters ====================
|
|
|
|
@api_v1_bp.route('/saved-filters', methods=['GET'])
|
|
@require_api_token('read:filters')
|
|
def list_saved_filters():
|
|
"""List saved filters for current user
|
|
---
|
|
tags:
|
|
- SavedFilters
|
|
"""
|
|
query = SavedFilter.query.filter(SavedFilter.user_id == g.api_user.id)
|
|
result = paginate_query(query.order_by(SavedFilter.created_at.desc()))
|
|
return jsonify({'filters': [f.to_dict() for f in result['items']], 'pagination': result['pagination']})
|
|
|
|
|
|
@api_v1_bp.route('/saved-filters/<int:filter_id>', methods=['GET'])
|
|
@require_api_token('read:filters')
|
|
def get_saved_filter(filter_id):
|
|
"""Get saved filter
|
|
---
|
|
tags:
|
|
- SavedFilters
|
|
"""
|
|
sf = SavedFilter.query.get_or_404(filter_id)
|
|
if sf.user_id != g.api_user.id and not (sf.is_shared or g.api_user.is_admin):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
return jsonify({'filter': sf.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/saved-filters', methods=['POST'])
|
|
@require_api_token('write:filters')
|
|
def create_saved_filter():
|
|
"""Create saved filter
|
|
---
|
|
tags:
|
|
- SavedFilters
|
|
"""
|
|
data = request.get_json() or {}
|
|
required = ['name', 'scope', 'payload']
|
|
missing = [f for f in required if not data.get(f)]
|
|
if missing:
|
|
return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400
|
|
sf = SavedFilter(
|
|
user_id=g.api_user.id,
|
|
name=data['name'],
|
|
scope=data['scope'],
|
|
payload=data['payload'],
|
|
is_shared=bool(data.get('is_shared', False)),
|
|
)
|
|
db.session.add(sf)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Saved filter created successfully', 'filter': sf.to_dict()}), 201
|
|
|
|
|
|
@api_v1_bp.route('/saved-filters/<int:filter_id>', methods=['PUT', 'PATCH'])
|
|
@require_api_token('write:filters')
|
|
def update_saved_filter(filter_id):
|
|
"""Update saved filter
|
|
---
|
|
tags:
|
|
- SavedFilters
|
|
"""
|
|
sf = SavedFilter.query.get_or_404(filter_id)
|
|
if sf.user_id != g.api_user.id and not g.api_user.is_admin:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
data = request.get_json() or {}
|
|
for field in ('name', 'scope', 'payload', 'is_shared'):
|
|
if field in data:
|
|
setattr(sf, field, data[field])
|
|
db.session.commit()
|
|
return jsonify({'message': 'Saved filter updated successfully', 'filter': sf.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/saved-filters/<int:filter_id>', methods=['DELETE'])
|
|
@require_api_token('write:filters')
|
|
def delete_saved_filter(filter_id):
|
|
"""Delete saved filter
|
|
---
|
|
tags:
|
|
- SavedFilters
|
|
"""
|
|
sf = SavedFilter.query.get_or_404(filter_id)
|
|
if sf.user_id != g.api_user.id and not g.api_user.is_admin:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
db.session.delete(sf)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Saved filter deleted successfully'})
|
|
|
|
|
|
# ==================== Time Entry Templates ====================
|
|
|
|
@api_v1_bp.route('/time-entry-templates', methods=['GET'])
|
|
@require_api_token('read:time_entries')
|
|
def list_time_entry_templates():
|
|
"""List time entry templates for current user
|
|
---
|
|
tags:
|
|
- TimeEntryTemplates
|
|
"""
|
|
query = TimeEntryTemplate.query.filter(TimeEntryTemplate.user_id == g.api_user.id)
|
|
result = paginate_query(query.order_by(TimeEntryTemplate.created_at.desc()))
|
|
return jsonify({'templates': [t.to_dict() for t in result['items']], 'pagination': result['pagination']})
|
|
|
|
|
|
@api_v1_bp.route('/time-entry-templates/<int:tpl_id>', methods=['GET'])
|
|
@require_api_token('read:time_entries')
|
|
def get_time_entry_template(tpl_id):
|
|
"""Get time entry template
|
|
---
|
|
tags:
|
|
- TimeEntryTemplates
|
|
"""
|
|
tpl = TimeEntryTemplate.query.get_or_404(tpl_id)
|
|
if tpl.user_id != g.api_user.id and not g.api_user.is_admin:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
return jsonify({'template': tpl.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/time-entry-templates', methods=['POST'])
|
|
@require_api_token('write:time_entries')
|
|
def create_time_entry_template():
|
|
"""Create time entry template
|
|
---
|
|
tags:
|
|
- TimeEntryTemplates
|
|
"""
|
|
data = request.get_json() or {}
|
|
required = ['name']
|
|
missing = [f for f in required if not data.get(f)]
|
|
if missing:
|
|
return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400
|
|
tpl = TimeEntryTemplate(
|
|
user_id=g.api_user.id,
|
|
name=data['name'],
|
|
description=data.get('description'),
|
|
project_id=data.get('project_id'),
|
|
task_id=data.get('task_id'),
|
|
default_duration_minutes=data.get('default_duration_minutes'),
|
|
default_notes=data.get('default_notes'),
|
|
tags=data.get('tags'),
|
|
billable=bool(data.get('billable', True)),
|
|
)
|
|
db.session.add(tpl)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Template created successfully', 'template': tpl.to_dict()}), 201
|
|
|
|
|
|
@api_v1_bp.route('/time-entry-templates/<int:tpl_id>', methods=['PUT', 'PATCH'])
|
|
@require_api_token('write:time_entries')
|
|
def update_time_entry_template(tpl_id):
|
|
"""Update time entry template
|
|
---
|
|
tags:
|
|
- TimeEntryTemplates
|
|
"""
|
|
tpl = TimeEntryTemplate.query.get_or_404(tpl_id)
|
|
if tpl.user_id != g.api_user.id and not g.api_user.is_admin:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
data = request.get_json() or {}
|
|
for field in ('name', 'description', 'project_id', 'task_id', 'default_duration_minutes', 'default_notes', 'tags', 'billable'):
|
|
if field in data:
|
|
setattr(tpl, field, data[field])
|
|
db.session.commit()
|
|
return jsonify({'message': 'Template updated successfully', 'template': tpl.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/time-entry-templates/<int:tpl_id>', methods=['DELETE'])
|
|
@require_api_token('write:time_entries')
|
|
def delete_time_entry_template(tpl_id):
|
|
"""Delete time entry template
|
|
---
|
|
tags:
|
|
- TimeEntryTemplates
|
|
"""
|
|
tpl = TimeEntryTemplate.query.get_or_404(tpl_id)
|
|
if tpl.user_id != g.api_user.id and not g.api_user.is_admin:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
db.session.delete(tpl)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Template deleted successfully'})
|
|
|
|
|
|
# ==================== Comments ====================
|
|
|
|
@api_v1_bp.route('/comments', methods=['GET'])
|
|
@require_api_token('read:comments')
|
|
def list_comments():
|
|
"""List comments by project or task
|
|
---
|
|
tags:
|
|
- Comments
|
|
parameters:
|
|
- name: project_id
|
|
in: query
|
|
type: integer
|
|
- name: task_id
|
|
in: query
|
|
type: integer
|
|
"""
|
|
project_id = request.args.get('project_id', type=int)
|
|
task_id = request.args.get('task_id', type=int)
|
|
if not project_id and not task_id:
|
|
return jsonify({'error': 'project_id or task_id is required'}), 400
|
|
if project_id:
|
|
comments = Comment.get_project_comments(project_id)
|
|
else:
|
|
comments = Comment.get_task_comments(task_id)
|
|
return jsonify({'comments': [c.to_dict() for c in comments]})
|
|
|
|
|
|
@api_v1_bp.route('/comments', methods=['POST'])
|
|
@require_api_token('write:comments')
|
|
def create_comment():
|
|
"""Create comment
|
|
---
|
|
tags:
|
|
- Comments
|
|
"""
|
|
data = request.get_json() or {}
|
|
content = (data.get('content') or '').strip()
|
|
project_id = data.get('project_id')
|
|
task_id = data.get('task_id')
|
|
if not content:
|
|
return jsonify({'error': 'content is required'}), 400
|
|
if (not project_id and not task_id) or (project_id and task_id):
|
|
return jsonify({'error': 'Provide either project_id or task_id'}), 400
|
|
cmt = Comment(content=content, user_id=g.api_user.id, project_id=project_id, task_id=task_id, parent_id=data.get('parent_id'))
|
|
db.session.add(cmt)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Comment created successfully', 'comment': cmt.to_dict()}), 201
|
|
|
|
|
|
@api_v1_bp.route('/comments/<int:comment_id>', methods=['PUT', 'PATCH'])
|
|
@require_api_token('write:comments')
|
|
def update_comment(comment_id):
|
|
"""Update comment
|
|
---
|
|
tags:
|
|
- Comments
|
|
"""
|
|
cmt = Comment.query.get_or_404(comment_id)
|
|
if cmt.user_id != g.api_user.id and not g.api_user.is_admin:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
data = request.get_json() or {}
|
|
new_content = (data.get('content') or '').strip()
|
|
if not new_content:
|
|
return jsonify({'error': 'content is required'}), 400
|
|
try:
|
|
cmt.edit_content(new_content, g.api_user)
|
|
except PermissionError:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
return jsonify({'message': 'Comment updated successfully', 'comment': cmt.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/comments/<int:comment_id>', methods=['DELETE'])
|
|
@require_api_token('write:comments')
|
|
def delete_comment(comment_id):
|
|
"""Delete comment
|
|
---
|
|
tags:
|
|
- Comments
|
|
"""
|
|
cmt = Comment.query.get_or_404(comment_id)
|
|
try:
|
|
cmt.delete_comment(g.api_user)
|
|
except PermissionError:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
return jsonify({'message': 'Comment deleted successfully'})
|
|
|
|
|
|
# ==================== Client Notes ====================
|
|
|
|
@api_v1_bp.route('/clients/<int:client_id>/notes', methods=['GET'])
|
|
@require_api_token('read:clients')
|
|
def list_client_notes(client_id):
|
|
"""List client notes (paginated, important first)"""
|
|
query = ClientNote.query.filter(ClientNote.client_id == client_id).order_by(
|
|
ClientNote.is_important.desc(), ClientNote.created_at.desc()
|
|
)
|
|
result = paginate_query(query)
|
|
return jsonify({'notes': [n.to_dict() for n in result['items']], 'pagination': result['pagination']})
|
|
|
|
|
|
@api_v1_bp.route('/clients/<int:client_id>/notes', methods=['POST'])
|
|
@require_api_token('write:clients')
|
|
def create_client_note(client_id):
|
|
"""Create client note"""
|
|
data = request.get_json() or {}
|
|
content = (data.get('content') or '').strip()
|
|
if not content:
|
|
return jsonify({'error': 'content is required'}), 400
|
|
note = ClientNote(content=content, user_id=g.api_user.id, client_id=client_id, is_important=bool(data.get('is_important', False)))
|
|
db.session.add(note)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Client note created successfully', 'note': note.to_dict()}), 201
|
|
|
|
|
|
@api_v1_bp.route('/client-notes/<int:note_id>', methods=['GET'])
|
|
@require_api_token('read:clients')
|
|
def get_client_note(note_id):
|
|
note = ClientNote.query.get_or_404(note_id)
|
|
return jsonify({'note': note.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/client-notes/<int:note_id>', methods=['PUT', 'PATCH'])
|
|
@require_api_token('write:clients')
|
|
def update_client_note(note_id):
|
|
note = ClientNote.query.get_or_404(note_id)
|
|
data = request.get_json() or {}
|
|
new_content = (data.get('content') or '').strip()
|
|
if not new_content:
|
|
return jsonify({'error': 'content is required'}), 400
|
|
if not (g.api_user.is_admin or note.user_id == g.api_user.id):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
note.content = new_content
|
|
if 'is_important' in data:
|
|
note.is_important = bool(data['is_important'])
|
|
db.session.commit()
|
|
return jsonify({'message': 'Client note updated successfully', 'note': note.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/client-notes/<int:note_id>', methods=['DELETE'])
|
|
@require_api_token('write:clients')
|
|
def delete_client_note(note_id):
|
|
note = ClientNote.query.get_or_404(note_id)
|
|
if not (g.api_user.is_admin or note.user_id == g.api_user.id):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
db.session.delete(note)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Client note deleted successfully'})
|
|
|
|
|
|
# ==================== Project Costs ====================
|
|
|
|
@api_v1_bp.route('/projects/<int:project_id>/costs', methods=['GET'])
|
|
@require_api_token('read:projects')
|
|
def list_project_costs(project_id):
|
|
"""List project costs (paginated)"""
|
|
start_date = _parse_date(request.args.get('start_date'))
|
|
end_date = _parse_date(request.args.get('end_date'))
|
|
user_id = request.args.get('user_id', type=int)
|
|
billable_only = (request.args.get('billable_only', 'false').lower() == 'true')
|
|
query = ProjectCost.query.filter(ProjectCost.project_id == project_id)
|
|
if start_date:
|
|
query = query.filter(ProjectCost.cost_date >= start_date)
|
|
if end_date:
|
|
query = query.filter(ProjectCost.cost_date <= end_date)
|
|
if user_id:
|
|
query = query.filter(ProjectCost.user_id == user_id)
|
|
if billable_only:
|
|
query = query.filter(ProjectCost.billable == True)
|
|
query = query.order_by(ProjectCost.cost_date.desc(), ProjectCost.created_at.desc())
|
|
result = paginate_query(query)
|
|
return jsonify({'costs': [c.to_dict() for c in result['items']], 'pagination': result['pagination']})
|
|
|
|
|
|
@api_v1_bp.route('/projects/<int:project_id>/costs', methods=['POST'])
|
|
@require_api_token('write:projects')
|
|
def create_project_cost(project_id):
|
|
"""Create project cost"""
|
|
data = request.get_json() or {}
|
|
required = ['description', 'category', 'amount', 'cost_date']
|
|
missing = [f for f in required if not data.get(f)]
|
|
if missing:
|
|
return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400
|
|
from decimal import Decimal
|
|
try:
|
|
amount = Decimal(str(data['amount']))
|
|
except Exception:
|
|
return jsonify({'error': 'Invalid amount'}), 400
|
|
cost_date = _parse_date(data.get('cost_date'))
|
|
if not cost_date:
|
|
return jsonify({'error': 'Invalid cost_date'}), 400
|
|
cost = ProjectCost(
|
|
project_id=project_id,
|
|
user_id=g.api_user.id,
|
|
description=data['description'],
|
|
category=data['category'],
|
|
amount=amount,
|
|
cost_date=cost_date,
|
|
billable=bool(data.get('billable', True)),
|
|
notes=data.get('notes'),
|
|
currency_code=data.get('currency_code', 'EUR'),
|
|
)
|
|
db.session.add(cost)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Project cost created successfully', 'cost': cost.to_dict()}), 201
|
|
|
|
|
|
@api_v1_bp.route('/project-costs/<int:cost_id>', methods=['GET'])
|
|
@require_api_token('read:projects')
|
|
def get_project_cost(cost_id):
|
|
cost = ProjectCost.query.get_or_404(cost_id)
|
|
return jsonify({'cost': cost.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/project-costs/<int:cost_id>', methods=['PUT', 'PATCH'])
|
|
@require_api_token('write:projects')
|
|
def update_project_cost(cost_id):
|
|
cost = ProjectCost.query.get_or_404(cost_id)
|
|
data = request.get_json() or {}
|
|
for field in ('description', 'category', 'currency_code', 'notes', 'billable'):
|
|
if field in data:
|
|
setattr(cost, field, data[field])
|
|
if 'amount' in data:
|
|
try:
|
|
from decimal import Decimal
|
|
cost.amount = Decimal(str(data['amount']))
|
|
except Exception:
|
|
pass
|
|
if 'cost_date' in data:
|
|
parsed = _parse_date(data['cost_date'])
|
|
if parsed:
|
|
cost.cost_date = parsed
|
|
db.session.commit()
|
|
return jsonify({'message': 'Project cost updated successfully', 'cost': cost.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/project-costs/<int:cost_id>', methods=['DELETE'])
|
|
@require_api_token('write:projects')
|
|
def delete_project_cost(cost_id):
|
|
cost = ProjectCost.query.get_or_404(cost_id)
|
|
db.session.delete(cost)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Project cost deleted successfully'})
|
|
|
|
|
|
# ==================== Tax Rules (Admin) ====================
|
|
|
|
@api_v1_bp.route('/tax-rules', methods=['GET'])
|
|
@require_api_token('admin:all')
|
|
def list_tax_rules():
|
|
"""List tax rules (admin)"""
|
|
rules = TaxRule.query.order_by(TaxRule.created_at.desc()).all()
|
|
return jsonify({'tax_rules': [{
|
|
'id': r.id,
|
|
'name': r.name,
|
|
'country': r.country,
|
|
'region': r.region,
|
|
'client_id': r.client_id,
|
|
'project_id': r.project_id,
|
|
'tax_code': r.tax_code,
|
|
'rate_percent': float(r.rate_percent),
|
|
'compound': r.compound,
|
|
'inclusive': r.inclusive,
|
|
'start_date': r.start_date.isoformat() if r.start_date else None,
|
|
'end_date': r.end_date.isoformat() if r.end_date else None,
|
|
'active': r.active,
|
|
'created_at': r.created_at.isoformat() if r.created_at else None,
|
|
} for r in rules]})
|
|
|
|
|
|
@api_v1_bp.route('/tax-rules', methods=['POST'])
|
|
@require_api_token('admin:all')
|
|
def create_tax_rule():
|
|
data = request.get_json() or {}
|
|
required = ['name', 'rate_percent']
|
|
missing = [f for f in required if not data.get(f)]
|
|
if missing:
|
|
return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400
|
|
from decimal import Decimal
|
|
try:
|
|
rate = Decimal(str(data['rate_percent']))
|
|
except Exception:
|
|
return jsonify({'error': 'Invalid rate_percent'}), 400
|
|
rule = TaxRule(
|
|
name=data['name'],
|
|
country=data.get('country'),
|
|
region=data.get('region'),
|
|
client_id=data.get('client_id'),
|
|
project_id=data.get('project_id'),
|
|
tax_code=data.get('tax_code'),
|
|
rate_percent=rate,
|
|
compound=bool(data.get('compound', False)),
|
|
inclusive=bool(data.get('inclusive', False)),
|
|
start_date=_parse_date(data.get('start_date')),
|
|
end_date=_parse_date(data.get('end_date')),
|
|
active=bool(data.get('active', True)),
|
|
)
|
|
db.session.add(rule)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Tax rule created successfully', 'tax_rule': {'id': rule.id}}), 201
|
|
|
|
|
|
@api_v1_bp.route('/tax-rules/<int:rule_id>', methods=['PUT', 'PATCH'])
|
|
@require_api_token('admin:all')
|
|
def update_tax_rule(rule_id):
|
|
rule = TaxRule.query.get_or_404(rule_id)
|
|
data = request.get_json() or {}
|
|
for field in ('name', 'country', 'region', 'client_id', 'project_id', 'tax_code', 'compound', 'inclusive', 'active'):
|
|
if field in data:
|
|
setattr(rule, field, data[field])
|
|
if 'rate_percent' in data:
|
|
try:
|
|
from decimal import Decimal
|
|
rule.rate_percent = Decimal(str(data['rate_percent']))
|
|
except Exception:
|
|
pass
|
|
if 'start_date' in data:
|
|
rule.start_date = _parse_date(data['start_date'])
|
|
if 'end_date' in data:
|
|
rule.end_date = _parse_date(data['end_date'])
|
|
db.session.commit()
|
|
return jsonify({'message': 'Tax rule updated successfully'})
|
|
|
|
|
|
@api_v1_bp.route('/tax-rules/<int:rule_id>', methods=['DELETE'])
|
|
@require_api_token('admin:all')
|
|
def delete_tax_rule(rule_id):
|
|
rule = TaxRule.query.get_or_404(rule_id)
|
|
db.session.delete(rule)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Tax rule deleted successfully'})
|
|
|
|
|
|
# ==================== Currencies & Exchange Rates ====================
|
|
|
|
@api_v1_bp.route('/currencies', methods=['GET'])
|
|
@require_api_token('read:invoices')
|
|
def list_currencies():
|
|
cur_list = Currency.query.order_by(Currency.code.asc()).all()
|
|
return jsonify({'currencies': [{
|
|
'code': c.code, 'name': c.name, 'symbol': c.symbol, 'decimal_places': c.decimal_places, 'is_active': c.is_active
|
|
} for c in cur_list]})
|
|
|
|
|
|
@api_v1_bp.route('/currencies', methods=['POST'])
|
|
@require_api_token('admin:all')
|
|
def create_currency():
|
|
data = request.get_json() or {}
|
|
required = ['code', 'name']
|
|
missing = [f for f in required if not data.get(f)]
|
|
if missing:
|
|
return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400
|
|
code = data['code'].upper().strip()
|
|
if Currency.query.get(code):
|
|
return jsonify({'error': 'Currency already exists'}), 400
|
|
cur = Currency(code=code, name=data['name'], symbol=data.get('symbol'), decimal_places=int(data.get('decimal_places', 2)), is_active=bool(data.get('is_active', True)))
|
|
db.session.add(cur)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Currency created successfully', 'currency': {'code': cur.code}}), 201
|
|
|
|
|
|
@api_v1_bp.route('/currencies/<string:code>', methods=['PUT', 'PATCH'])
|
|
@require_api_token('admin:all')
|
|
def update_currency(code):
|
|
cur = Currency.query.get_or_404(code.upper())
|
|
data = request.get_json() or {}
|
|
for field in ('name', 'symbol', 'decimal_places', 'is_active'):
|
|
if field in data:
|
|
setattr(cur, field, data[field])
|
|
db.session.commit()
|
|
return jsonify({'message': 'Currency updated successfully'})
|
|
|
|
|
|
@api_v1_bp.route('/exchange-rates', methods=['GET'])
|
|
@require_api_token('read:invoices')
|
|
def list_exchange_rates():
|
|
base = request.args.get('base_code')
|
|
quote = request.args.get('quote_code')
|
|
date_str = request.args.get('date')
|
|
q = ExchangeRate.query
|
|
if base:
|
|
q = q.filter(ExchangeRate.base_code == base.upper())
|
|
if quote:
|
|
q = q.filter(ExchangeRate.quote_code == quote.upper())
|
|
if date_str:
|
|
d = _parse_date(date_str)
|
|
if d:
|
|
q = q.filter(ExchangeRate.date == d)
|
|
rates = q.order_by(ExchangeRate.date.desc()).limit(200).all()
|
|
return jsonify({'exchange_rates': [{
|
|
'id': r.id, 'base_code': r.base_code, 'quote_code': r.quote_code, 'rate': float(r.rate), 'date': r.date.isoformat(), 'source': r.source
|
|
} for r in rates]})
|
|
|
|
|
|
@api_v1_bp.route('/exchange-rates', methods=['POST'])
|
|
@require_api_token('admin:all')
|
|
def create_exchange_rate():
|
|
data = request.get_json() or {}
|
|
required = ['base_code', 'quote_code', 'rate', 'date']
|
|
missing = [f for f in required if not data.get(f)]
|
|
if missing:
|
|
return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400
|
|
from decimal import Decimal
|
|
try:
|
|
rate_val = Decimal(str(data['rate']))
|
|
except Exception:
|
|
return jsonify({'error': 'Invalid rate'}), 400
|
|
d = _parse_date(data['date'])
|
|
if not d:
|
|
return jsonify({'error': 'Invalid date'}), 400
|
|
er = ExchangeRate(
|
|
base_code=data['base_code'].upper(),
|
|
quote_code=data['quote_code'].upper(),
|
|
rate=rate_val,
|
|
date=d,
|
|
source=data.get('source'),
|
|
)
|
|
db.session.add(er)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Exchange rate created successfully', 'exchange_rate': {'id': er.id}}), 201
|
|
|
|
|
|
@api_v1_bp.route('/exchange-rates/<int:rate_id>', methods=['PUT', 'PATCH'])
|
|
@require_api_token('admin:all')
|
|
def update_exchange_rate(rate_id):
|
|
er = ExchangeRate.query.get_or_404(rate_id)
|
|
data = request.get_json() or {}
|
|
if 'rate' in data:
|
|
try:
|
|
from decimal import Decimal
|
|
er.rate = Decimal(str(data['rate']))
|
|
except Exception:
|
|
pass
|
|
if 'date' in data:
|
|
d = _parse_date(data['date'])
|
|
if d:
|
|
er.date = d
|
|
if 'source' in data:
|
|
er.source = data['source']
|
|
db.session.commit()
|
|
return jsonify({'message': 'Exchange rate updated successfully'})
|
|
|
|
|
|
# ==================== Favorites ====================
|
|
|
|
@api_v1_bp.route('/users/me/favorites/projects', methods=['GET'])
|
|
@require_api_token('read:projects')
|
|
def list_favorite_projects():
|
|
favs = UserFavoriteProject.query.filter_by(user_id=g.api_user.id).all()
|
|
return jsonify({'favorites': [f.to_dict() for f in favs]})
|
|
|
|
|
|
@api_v1_bp.route('/users/me/favorites/projects', methods=['POST'])
|
|
@require_api_token('write:projects')
|
|
def add_favorite_project():
|
|
data = request.get_json() or {}
|
|
project_id = data.get('project_id')
|
|
if not project_id:
|
|
return jsonify({'error': 'project_id is required'}), 400
|
|
# Prevent duplicates due to unique constraint
|
|
existing = UserFavoriteProject.query.filter_by(user_id=g.api_user.id, project_id=project_id).first()
|
|
if existing:
|
|
return jsonify({'message': 'Already favorited', 'favorite': existing.to_dict()}), 200
|
|
fav = UserFavoriteProject(user_id=g.api_user.id, project_id=project_id)
|
|
db.session.add(fav)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Project favorited successfully', 'favorite': fav.to_dict()}), 201
|
|
|
|
|
|
@api_v1_bp.route('/users/me/favorites/projects/<int:project_id>', methods=['DELETE'])
|
|
@require_api_token('write:projects')
|
|
def remove_favorite_project(project_id):
|
|
fav = UserFavoriteProject.query.filter_by(user_id=g.api_user.id, project_id=project_id).first_or_404()
|
|
db.session.delete(fav)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Favorite removed successfully'})
|
|
|
|
|
|
# ==================== Audit Logs (Admin) ====================
|
|
|
|
@api_v1_bp.route('/audit-logs', methods=['GET'])
|
|
@require_api_token('admin:all')
|
|
def list_audit_logs():
|
|
"""List audit logs (admin)"""
|
|
entity_type = request.args.get('entity_type')
|
|
user_id = request.args.get('user_id', type=int)
|
|
action = request.args.get('action')
|
|
limit = request.args.get('limit', type=int) or 100
|
|
q = AuditLog.query
|
|
if entity_type:
|
|
q = q.filter(AuditLog.entity_type == entity_type)
|
|
if user_id:
|
|
q = q.filter(AuditLog.user_id == user_id)
|
|
if action:
|
|
q = q.filter(AuditLog.action == action)
|
|
logs = q.order_by(AuditLog.created_at.desc()).limit(limit).all()
|
|
return jsonify({'audit_logs': [l.to_dict() for l in logs]})
|
|
|
|
|
|
# ==================== Activities ====================
|
|
|
|
@api_v1_bp.route('/activities', methods=['GET'])
|
|
@require_api_token('read:reports')
|
|
def list_activities():
|
|
"""List activities"""
|
|
user_id = request.args.get('user_id', type=int)
|
|
entity_type = request.args.get('entity_type')
|
|
limit = request.args.get('limit', type=int) or 50
|
|
acts = Activity.get_recent(user_id=user_id, limit=limit, entity_type=entity_type)
|
|
return jsonify({'activities': [a.to_dict() for a in acts]})
|
|
|
|
|
|
# ==================== Invoice PDF Templates (Admin) ====================
|
|
|
|
@api_v1_bp.route('/invoice-pdf-templates', methods=['GET'])
|
|
@require_api_token('admin:all')
|
|
def list_invoice_pdf_templates():
|
|
templates = InvoicePDFTemplate.get_all_templates()
|
|
return jsonify({'templates': [t.to_dict() for t in templates]})
|
|
|
|
|
|
@api_v1_bp.route('/invoice-pdf-templates/<string:page_size>', methods=['GET'])
|
|
@require_api_token('admin:all')
|
|
def get_invoice_pdf_template(page_size):
|
|
tpl = InvoicePDFTemplate.get_template(page_size)
|
|
return jsonify({'template': tpl.to_dict()})
|
|
|
|
|
|
# ==================== Invoice Templates (Admin) ====================
|
|
|
|
@api_v1_bp.route('/invoice-templates', methods=['GET'])
|
|
@require_api_token('admin:all')
|
|
def list_invoice_templates():
|
|
"""List invoice templates (admin)"""
|
|
templates = InvoiceTemplate.query.order_by(InvoiceTemplate.name.asc()).all()
|
|
return jsonify({'templates': [{
|
|
'id': t.id,
|
|
'name': t.name,
|
|
'description': t.description,
|
|
'html': t.html or '',
|
|
'css': t.css or '',
|
|
'is_default': t.is_default,
|
|
'created_at': t.created_at.isoformat() if t.created_at else None,
|
|
'updated_at': t.updated_at.isoformat() if t.updated_at else None,
|
|
} for t in templates]})
|
|
|
|
|
|
@api_v1_bp.route('/invoice-templates/<int:template_id>', methods=['GET'])
|
|
@require_api_token('admin:all')
|
|
def get_invoice_template(template_id):
|
|
t = InvoiceTemplate.query.get_or_404(template_id)
|
|
return jsonify({'template': {
|
|
'id': t.id,
|
|
'name': t.name,
|
|
'description': t.description,
|
|
'html': t.html or '',
|
|
'css': t.css or '',
|
|
'is_default': t.is_default,
|
|
'created_at': t.created_at.isoformat() if t.created_at else None,
|
|
'updated_at': t.updated_at.isoformat() if t.updated_at else None,
|
|
}})
|
|
|
|
|
|
@api_v1_bp.route('/invoice-templates', methods=['POST'])
|
|
@require_api_token('admin:all')
|
|
def create_invoice_template():
|
|
data = request.get_json() or {}
|
|
name = (data.get('name') or '').strip()
|
|
if not name:
|
|
return jsonify({'error': 'name is required'}), 400
|
|
# Enforce unique name
|
|
if InvoiceTemplate.query.filter_by(name=name).first():
|
|
return jsonify({'error': 'Template name already exists'}), 400
|
|
is_default = bool(data.get('is_default', False))
|
|
if is_default:
|
|
InvoiceTemplate.query.update({InvoiceTemplate.is_default: False})
|
|
t = InvoiceTemplate(
|
|
name=name,
|
|
description=(data.get('description') or '').strip() or None,
|
|
html=(data.get('html') or '').strip() or None,
|
|
css=(data.get('css') or '').strip() or None,
|
|
is_default=is_default,
|
|
)
|
|
db.session.add(t)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Invoice template created successfully', 'template': {'id': t.id}}), 201
|
|
|
|
|
|
@api_v1_bp.route('/invoice-templates/<int:template_id>', methods=['PUT', 'PATCH'])
|
|
@require_api_token('admin:all')
|
|
def update_invoice_template(template_id):
|
|
t = InvoiceTemplate.query.get_or_404(template_id)
|
|
data = request.get_json() or {}
|
|
if 'name' in data:
|
|
name = (data.get('name') or '').strip()
|
|
if not name:
|
|
return jsonify({'error': 'name cannot be empty'}), 400
|
|
# Check duplicate name
|
|
existing = InvoiceTemplate.query.filter(InvoiceTemplate.name == name, InvoiceTemplate.id != template_id).first()
|
|
if existing:
|
|
return jsonify({'error': 'Template name already exists'}), 400
|
|
t.name = name
|
|
for field in ('description', 'html', 'css'):
|
|
if field in data:
|
|
setattr(t, field, (data.get(field) or '').strip() or None)
|
|
if 'is_default' in data and bool(data['is_default']):
|
|
# set this as default, unset others
|
|
InvoiceTemplate.query.filter(InvoiceTemplate.id != template_id).update({InvoiceTemplate.is_default: False})
|
|
t.is_default = True
|
|
db.session.commit()
|
|
return jsonify({'message': 'Invoice template updated successfully'})
|
|
|
|
|
|
@api_v1_bp.route('/invoice-templates/<int:template_id>', methods=['DELETE'])
|
|
@require_api_token('admin:all')
|
|
def delete_invoice_template(template_id):
|
|
t = InvoiceTemplate.query.get_or_404(template_id)
|
|
# In a stricter implementation, we could prevent deletion if referenced
|
|
db.session.delete(t)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Invoice template deleted successfully'})
|
|
|
|
# ==================== Recurring Invoices ====================
|
|
|
|
@api_v1_bp.route('/recurring-invoices', methods=['GET'])
|
|
@require_api_token('read:recurring_invoices')
|
|
def list_recurring_invoices():
|
|
"""List recurring invoice templates
|
|
---
|
|
tags:
|
|
- RecurringInvoices
|
|
"""
|
|
query = RecurringInvoice.query
|
|
is_active = request.args.get('is_active')
|
|
if is_active is not None:
|
|
query = query.filter(RecurringInvoice.is_active == (is_active.lower() == 'true'))
|
|
client_id = request.args.get('client_id', type=int)
|
|
if client_id:
|
|
query = query.filter(RecurringInvoice.client_id == client_id)
|
|
project_id = request.args.get('project_id', type=int)
|
|
if project_id:
|
|
query = query.filter(RecurringInvoice.project_id == project_id)
|
|
result = paginate_query(query.order_by(RecurringInvoice.created_at.desc()))
|
|
return jsonify({'recurring_invoices': [ri.to_dict() for ri in result['items']], 'pagination': result['pagination']})
|
|
|
|
|
|
@api_v1_bp.route('/recurring-invoices/<int:ri_id>', methods=['GET'])
|
|
@require_api_token('read:recurring_invoices')
|
|
def get_recurring_invoice(ri_id):
|
|
"""Get a recurring invoice template"""
|
|
ri = RecurringInvoice.query.get_or_404(ri_id)
|
|
return jsonify({'recurring_invoice': ri.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/recurring-invoices', methods=['POST'])
|
|
@require_api_token('write:recurring_invoices')
|
|
def create_recurring_invoice():
|
|
"""Create a recurring invoice template
|
|
---
|
|
tags:
|
|
- RecurringInvoices
|
|
"""
|
|
data = request.get_json() or {}
|
|
required = ['name', 'project_id', 'client_id', 'client_name', 'frequency', 'next_run_date']
|
|
missing = [f for f in required if not data.get(f)]
|
|
if missing:
|
|
return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400
|
|
freq = (data.get('frequency') or '').lower()
|
|
if freq not in ('daily', 'weekly', 'monthly', 'yearly'):
|
|
return jsonify({'error': 'Invalid frequency'}), 400
|
|
next_date = _parse_date(data.get('next_run_date'))
|
|
if not next_date:
|
|
return jsonify({'error': 'Invalid next_run_date (YYYY-MM-DD)'}), 400
|
|
ri = RecurringInvoice(
|
|
name=data['name'],
|
|
project_id=data['project_id'],
|
|
client_id=data['client_id'],
|
|
frequency=freq,
|
|
next_run_date=next_date,
|
|
created_by=g.api_user.id,
|
|
interval=data.get('interval', 1),
|
|
end_date=_parse_date(data.get('end_date')),
|
|
client_name=data['client_name'],
|
|
client_email=data.get('client_email'),
|
|
client_address=data.get('client_address'),
|
|
due_date_days=data.get('due_date_days', 30),
|
|
tax_rate=data.get('tax_rate', 0),
|
|
currency_code=data.get('currency_code', 'EUR'),
|
|
notes=data.get('notes'),
|
|
terms=data.get('terms'),
|
|
template_id=data.get('template_id'),
|
|
auto_send=bool(data.get('auto_send', False)),
|
|
auto_include_time_entries=bool(data.get('auto_include_time_entries', True)),
|
|
is_active=bool(data.get('is_active', True)),
|
|
)
|
|
db.session.add(ri)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Recurring invoice created successfully', 'recurring_invoice': ri.to_dict()}), 201
|
|
|
|
|
|
@api_v1_bp.route('/recurring-invoices/<int:ri_id>', methods=['PUT', 'PATCH'])
|
|
@require_api_token('write:recurring_invoices')
|
|
def update_recurring_invoice(ri_id):
|
|
"""Update a recurring invoice template"""
|
|
ri = RecurringInvoice.query.get_or_404(ri_id)
|
|
data = request.get_json() or {}
|
|
for field in ('name', 'client_name', 'client_email', 'client_address', 'notes', 'terms', 'currency_code'):
|
|
if field in data:
|
|
setattr(ri, field, data[field])
|
|
if 'frequency' in data and data['frequency'] in ('daily', 'weekly', 'monthly', 'yearly'):
|
|
ri.frequency = data['frequency']
|
|
if 'interval' in data:
|
|
try:
|
|
ri.interval = int(data['interval'])
|
|
except Exception:
|
|
pass
|
|
if 'next_run_date' in data:
|
|
parsed = _parse_date(data['next_run_date'])
|
|
if parsed:
|
|
ri.next_run_date = parsed
|
|
if 'end_date' in data:
|
|
ri.end_date = _parse_date(data['end_date'])
|
|
for bfield in ('auto_send', 'auto_include_time_entries', 'is_active'):
|
|
if bfield in data:
|
|
setattr(ri, bfield, bool(data[bfield]))
|
|
if 'due_date_days' in data:
|
|
try:
|
|
ri.due_date_days = int(data['due_date_days'])
|
|
except Exception:
|
|
pass
|
|
if 'tax_rate' in data:
|
|
try:
|
|
from decimal import Decimal
|
|
ri.tax_rate = Decimal(str(data['tax_rate']))
|
|
except Exception:
|
|
pass
|
|
db.session.commit()
|
|
return jsonify({'message': 'Recurring invoice updated successfully', 'recurring_invoice': ri.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/recurring-invoices/<int:ri_id>', methods=['DELETE'])
|
|
@require_api_token('write:recurring_invoices')
|
|
def delete_recurring_invoice(ri_id):
|
|
"""Deactivate a recurring invoice template"""
|
|
ri = RecurringInvoice.query.get_or_404(ri_id)
|
|
ri.is_active = False
|
|
db.session.commit()
|
|
return jsonify({'message': 'Recurring invoice deactivated successfully'})
|
|
|
|
|
|
@api_v1_bp.route('/recurring-invoices/<int:ri_id>/generate', methods=['POST'])
|
|
@require_api_token('write:recurring_invoices')
|
|
def generate_from_recurring_invoice(ri_id):
|
|
"""Generate an invoice from a recurring template"""
|
|
ri = RecurringInvoice.query.get_or_404(ri_id)
|
|
invoice = ri.generate_invoice()
|
|
if not invoice:
|
|
return jsonify({'message': 'No invoice generated (not due yet or inactive)'}), 200
|
|
db.session.commit()
|
|
return jsonify({'message': 'Invoice generated successfully', 'invoice': invoice.to_dict()}), 201
|
|
|
|
|
|
# ==================== Credit Notes ====================
|
|
|
|
@api_v1_bp.route('/credit-notes', methods=['GET'])
|
|
@require_api_token('read:invoices')
|
|
def list_credit_notes():
|
|
"""List credit notes
|
|
---
|
|
tags:
|
|
- CreditNotes
|
|
"""
|
|
query = CreditNote.query
|
|
invoice_id = request.args.get('invoice_id', type=int)
|
|
if invoice_id:
|
|
query = query.filter(CreditNote.invoice_id == invoice_id)
|
|
result = paginate_query(query.order_by(CreditNote.created_at.desc()))
|
|
return jsonify({'credit_notes': [{
|
|
'id': cn.id,
|
|
'invoice_id': cn.invoice_id,
|
|
'credit_number': cn.credit_number,
|
|
'amount': float(cn.amount),
|
|
'reason': cn.reason,
|
|
'created_by': cn.created_by,
|
|
'created_at': cn.created_at.isoformat() if cn.created_at else None
|
|
} for cn in result['items']], 'pagination': result['pagination']})
|
|
|
|
|
|
@api_v1_bp.route('/credit-notes/<int:cn_id>', methods=['GET'])
|
|
@require_api_token('read:invoices')
|
|
def get_credit_note(cn_id):
|
|
"""Get credit note"""
|
|
cn = CreditNote.query.get_or_404(cn_id)
|
|
return jsonify({'credit_note': {
|
|
'id': cn.id,
|
|
'invoice_id': cn.invoice_id,
|
|
'credit_number': cn.credit_number,
|
|
'amount': float(cn.amount),
|
|
'reason': cn.reason,
|
|
'created_by': cn.created_by,
|
|
'created_at': cn.created_at.isoformat() if cn.created_at else None
|
|
}})
|
|
|
|
|
|
@api_v1_bp.route('/credit-notes', methods=['POST'])
|
|
@require_api_token('write:invoices')
|
|
def create_credit_note():
|
|
"""Create credit note"""
|
|
data = request.get_json() or {}
|
|
required = ['invoice_id', 'amount']
|
|
missing = [f for f in required if not data.get(f)]
|
|
if missing:
|
|
return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400
|
|
inv = Invoice.query.get(data['invoice_id'])
|
|
if not inv:
|
|
return jsonify({'error': 'Invalid invoice_id'}), 400
|
|
from decimal import Decimal
|
|
try:
|
|
amt = Decimal(str(data['amount']))
|
|
except Exception:
|
|
return jsonify({'error': 'Invalid amount'}), 400
|
|
# Generate credit number (simple: CN-<invoice_id>-<timestamp>)
|
|
credit_number = f"CN-{inv.id}-{int(datetime.utcnow().timestamp())}"
|
|
cn = CreditNote(
|
|
invoice_id=inv.id,
|
|
credit_number=credit_number,
|
|
amount=amt,
|
|
reason=data.get('reason'),
|
|
created_by=g.api_user.id,
|
|
)
|
|
db.session.add(cn)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Credit note created successfully', 'credit_note': {
|
|
'id': cn.id,
|
|
'invoice_id': cn.invoice_id,
|
|
'credit_number': cn.credit_number,
|
|
'amount': float(cn.amount),
|
|
'reason': cn.reason,
|
|
'created_by': cn.created_by,
|
|
'created_at': cn.created_at.isoformat() if cn.created_at else None
|
|
}}), 201
|
|
|
|
|
|
@api_v1_bp.route('/credit-notes/<int:cn_id>', methods=['PUT', 'PATCH'])
|
|
@require_api_token('write:invoices')
|
|
def update_credit_note(cn_id):
|
|
"""Update credit note"""
|
|
cn = CreditNote.query.get_or_404(cn_id)
|
|
data = request.get_json() or {}
|
|
if 'reason' in data:
|
|
cn.reason = data['reason']
|
|
if 'amount' in data:
|
|
try:
|
|
from decimal import Decimal
|
|
cn.amount = Decimal(str(data['amount']))
|
|
except Exception:
|
|
pass
|
|
db.session.commit()
|
|
return jsonify({'message': 'Credit note updated successfully'})
|
|
|
|
|
|
@api_v1_bp.route('/credit-notes/<int:cn_id>', methods=['DELETE'])
|
|
@require_api_token('write:invoices')
|
|
def delete_credit_note(cn_id):
|
|
"""Delete credit note"""
|
|
cn = CreditNote.query.get_or_404(cn_id)
|
|
db.session.delete(cn)
|
|
db.session.commit()
|
|
return jsonify({'message': 'Credit note deleted successfully'})
|
|
|
|
# ==================== Reports ====================
|
|
|
|
@api_v1_bp.route('/reports/summary', methods=['GET'])
|
|
@require_api_token('read:reports')
|
|
def report_summary():
|
|
"""Get time tracking summary report
|
|
---
|
|
tags:
|
|
- Reports
|
|
parameters:
|
|
- name: start_date
|
|
in: query
|
|
type: string
|
|
format: date
|
|
- name: end_date
|
|
in: query
|
|
type: string
|
|
format: date
|
|
- name: project_id
|
|
in: query
|
|
type: integer
|
|
- name: user_id
|
|
in: query
|
|
type: integer
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Summary report
|
|
"""
|
|
# Date range (default to last 30 days)
|
|
end_date = request.args.get('end_date')
|
|
start_date = request.args.get('start_date')
|
|
|
|
if not end_date:
|
|
end_dt = datetime.utcnow()
|
|
else:
|
|
end_dt = parse_datetime(end_date) or datetime.utcnow()
|
|
|
|
if not start_date:
|
|
start_dt = end_dt - timedelta(days=30)
|
|
else:
|
|
start_dt = parse_datetime(start_date) or (end_dt - timedelta(days=30))
|
|
|
|
# Build query
|
|
query = TimeEntry.query.filter(
|
|
TimeEntry.end_time.isnot(None),
|
|
TimeEntry.start_time >= start_dt,
|
|
TimeEntry.start_time <= end_dt
|
|
)
|
|
|
|
# Filter by user
|
|
user_id = request.args.get('user_id', type=int)
|
|
if user_id:
|
|
if g.api_user.is_admin or user_id == g.api_user.id:
|
|
query = query.filter_by(user_id=user_id)
|
|
else:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
elif not g.api_user.is_admin:
|
|
query = query.filter_by(user_id=g.api_user.id)
|
|
|
|
# Filter by project
|
|
project_id = request.args.get('project_id', type=int)
|
|
if project_id:
|
|
query = query.filter_by(project_id=project_id)
|
|
|
|
entries = query.all()
|
|
|
|
# Calculate summary
|
|
total_hours = sum(e.duration_hours or 0 for e in entries)
|
|
billable_hours = sum(e.duration_hours or 0 for e in entries if e.billable)
|
|
total_entries = len(entries)
|
|
|
|
# Group by project
|
|
by_project = {}
|
|
for entry in entries:
|
|
if entry.project_id:
|
|
if entry.project_id not in by_project:
|
|
by_project[entry.project_id] = {
|
|
'project_id': entry.project_id,
|
|
'project_name': entry.project.name if entry.project else 'Unknown',
|
|
'hours': 0,
|
|
'entries': 0
|
|
}
|
|
by_project[entry.project_id]['hours'] += entry.duration_hours or 0
|
|
by_project[entry.project_id]['entries'] += 1
|
|
|
|
return jsonify({
|
|
'summary': {
|
|
'start_date': start_dt.isoformat(),
|
|
'end_date': end_dt.isoformat(),
|
|
'total_hours': round(total_hours, 2),
|
|
'billable_hours': round(billable_hours, 2),
|
|
'total_entries': total_entries,
|
|
'by_project': list(by_project.values())
|
|
}
|
|
})
|
|
|
|
|
|
# ==================== Users ====================
|
|
|
|
@api_v1_bp.route('/users/me', methods=['GET'])
|
|
@require_api_token('read:users')
|
|
def get_current_user():
|
|
"""Get current authenticated user information
|
|
---
|
|
tags:
|
|
- Users
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Current user information
|
|
"""
|
|
return jsonify({'user': g.api_user.to_dict()})
|
|
|
|
|
|
@api_v1_bp.route('/users', methods=['GET'])
|
|
@require_api_token('admin:all')
|
|
def list_users():
|
|
"""List all users (admin only)
|
|
---
|
|
tags:
|
|
- Users
|
|
parameters:
|
|
- name: page
|
|
in: query
|
|
type: integer
|
|
- name: per_page
|
|
in: query
|
|
type: integer
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: List of users
|
|
"""
|
|
query = User.query.filter_by(is_active=True).order_by(User.username)
|
|
|
|
# Paginate
|
|
result = paginate_query(query)
|
|
|
|
return jsonify({
|
|
'users': [u.to_dict() for u in result['items']],
|
|
'pagination': result['pagination']
|
|
})
|
|
|
|
|
|
# ==================== Error Handlers ====================
|
|
|
|
@api_v1_bp.errorhandler(404)
|
|
def not_found(error):
|
|
"""Handle 404 errors"""
|
|
return jsonify({'error': 'Resource not found'}), 404
|
|
|
|
|
|
@api_v1_bp.errorhandler(500)
|
|
def internal_error(error):
|
|
"""Handle 500 errors"""
|
|
db.session.rollback()
|
|
return jsonify({'error': 'Internal server error'}), 500
|
|
|