LifeRPG_v2.0/modern/backend/adapters.py
TLimoges33 7fe4ae5365
🧙‍♂️ Transform LifeRPG into The Wizard's Grimoire - Production-Ready Application
 Major Features Added:
- Complete magical theming and rebranding from LifeRPG to The Wizard's Grimoire
- Production-grade React frontend with Tailwind CSS v4 and magical aesthetics
- Comprehensive analytics dashboard with Recharts integration (ScryingPortal)
- Push notifications system with PWA service worker support
- Drag & drop functionality using @dnd-kit for habit reordering
- Social features with friends system and leaderboards
- Performance optimization tools and monitoring
- Mobile app enhancement with PWA installation support

🏗️ Technical Infrastructure:
- Advanced service worker with offline support and background sync
- Zustand state management for scalable application state
- Production-ready UI component system with enhanced Button, Card, Input
- Progressive Web App (PWA) with manifest and app installation
- FastAPI backend with comprehensive API endpoints
- Docker containerization and CI/CD pipeline setup

📱 Progressive Web App Features:
- Offline functionality with intelligent caching
- Push notification support for habit reminders
- App installation on mobile and desktop platforms
- Background sync for offline data management
- Performance monitoring and optimization tools

🎨 User Experience:
- Magical wizard/grimoire theming throughout application
- Responsive design optimized for all device sizes
- Drag & drop habit management with smooth animations
- Interactive analytics with multiple chart types
- Social connectivity with friends and competitive features
- Comprehensive notification and performance settings

🔧 Developer Experience:
- Modern development stack with Vite and React
- Comprehensive testing setup and CI/CD pipelines
- Code quality tools with pre-commit hooks
- Docker development environment
- Detailed documentation and implementation guides

This represents a complete transformation from prototype to production-ready application with enterprise-grade features and magical user experience.
2025-08-30 17:32:42 +00:00

417 lines
16 KiB
Python

from abc import ABC, abstractmethod
from typing import Any, Dict
class AdapterError(Exception):
pass
class TransientError(AdapterError):
"""Errors that may succeed on retry (e.g., 429/5xx)."""
class Adapter(ABC):
name: str
@abstractmethod
def sync(self, *, db, integration_id: int) -> Dict[str, Any]:
"""Perform a sync for an integration and return a summary dict.
Expected return shape: {"ok": bool, "count": int, "details": {...}}
"""
...
class GoogleCalendarAdapter(Adapter):
name = 'google_calendar'
def sync(self, *, db, integration_id: int) -> Dict[str, Any]:
# Placeholder: our Google flow is handled by a dedicated endpoint.
return {"ok": True, "count": 0, "details": {"note": "use /sync_to_habits endpoint"}}
class TodoistAdapter(Adapter):
name = 'todoist'
def sync(self, *, db, integration_id: int) -> Dict[str, Any]:
# Lazy imports to avoid circulars
from . import models
from .crypto import decrypt_text
import requests
token_row = (
db.query(models.OAuthToken)
.filter_by(integration_id=integration_id)
.order_by(models.OAuthToken.id.desc())
.first()
)
if not token_row:
raise AdapterError('no token for todoist integration')
token = decrypt_text(token_row.access_token) if token_row.access_token else None
if not token:
raise AdapterError('unable to decrypt todoist token')
headers = {
'Authorization': f'Bearer {token}',
'Accept': 'application/json',
}
try:
resp = requests.get('https://api.todoist.com/rest/v2/tasks', headers=headers, timeout=10)
except Exception as e:
raise TransientError(str(e))
if resp.status_code in (429, 500, 502, 503, 504):
raise TransientError(f'todoist HTTP {resp.status_code}')
if resp.status_code != 200:
raise AdapterError(f'todoist HTTP {resp.status_code}')
# Load integration config for cursors/flags
integ = db.query(models.Integration).filter_by(id=integration_id).first()
conf = {}
if integ and integ.config:
try:
import json as _json
conf = _json.loads(integ.config)
except Exception:
conf = {}
full_fetch = bool(conf.get('todoist_full_fetch', True))
items = resp.json() or []
created = 0
updated = 0
seen_ext_ids = set()
from .config import settings
def _apply_close_policy(db, habit, should_close: bool, archived: bool):
if not habit:
return False
if settings.INTEGRATION_CLOSE_MODE == 'delete' and should_close:
db.delete(habit)
return True
new_status = 'archived' if archived else ('completed' if should_close else habit.status)
if habit.status != new_status:
habit.status = new_status
return True
return False
for it in items:
ext_id = str(it.get('id'))
title = it.get('content') or 'Todoist Task'
is_completed = bool(it.get('is_completed'))
is_archived = bool(it.get('is_deleted')) or bool(it.get('is_archived')) if isinstance(it.get('is_archived'), bool) else False
due = it.get('due', {}) or {}
due_dt = due.get('datetime') or due.get('date')
labels = it.get('labels') or []
if not ext_id:
continue
seen_ext_ids.add(ext_id)
mapping = (
db.query(models.IntegrationItemMap)
.filter_by(integration_id=integration_id, external_id=ext_id, entity_type='habit')
.first()
)
if mapping:
habit = db.query(models.Habit).filter_by(id=mapping.entity_id).first()
if habit:
changed = False
if habit.title != title:
habit.title = title
changed = True
changed |= _apply_close_policy(db, habit, is_completed, is_archived)
if due_dt:
try:
from datetime import datetime
habit.due_date = datetime.fromisoformat(due_dt.replace('Z', '+00:00'))
changed = True
except Exception:
pass
if labels:
import json as _json
habit.labels = _json.dumps(labels)
changed = True
if changed:
updated += 1
else:
integ2 = integ or db.query(models.Integration).filter_by(id=integration_id).first()
if not integ2:
raise AdapterError('integration missing during upsert')
import json as _json
habit = models.Habit(
user_id=integ2.user_id,
project_id=None,
title=title,
notes='from todoist',
cadence='once',
status='archived' if is_archived else ('completed' if is_completed else 'active'),
labels=_json.dumps(labels) if labels else None,
)
db.add(habit)
db.flush()
try:
from sqlalchemy.dialects.postgresql import insert as pg_insert
stmt = pg_insert(models.IntegrationItemMap.__table__).values(
integration_id=integration_id,
external_id=ext_id,
entity_type='habit',
entity_id=habit.id,
).on_conflict_do_update(
index_elements=['integration_id', 'external_id', 'entity_type'],
set_={'entity_id': habit.id}
)
db.execute(stmt)
except Exception:
db.add(models.IntegrationItemMap(integration_id=integration_id, external_id=ext_id, entity_type='habit', entity_id=habit.id))
created += 1
db.flush()
if full_fetch:
mappings = db.query(models.IntegrationItemMap).filter_by(integration_id=integration_id, entity_type='habit').all()
for m in mappings:
if m.external_id not in seen_ext_ids:
habit = db.query(models.Habit).filter_by(id=m.entity_id).first()
if habit:
try:
if settings.INTEGRATION_CLOSE_MODE == 'delete':
db.delete(habit)
else:
habit.status = 'archived'
except Exception:
habit.status = 'archived'
db.flush()
if integ:
try:
import json as _json
from datetime import datetime, timezone
conf['last_sync_at'] = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
integ.config = _json.dumps(conf)
db.flush()
except Exception:
pass
return {"ok": True, "count": len(items), "created": created, "updated": updated}
class GitHubAdapter(Adapter):
name = 'github'
def sync(self, *, db, integration_id: int) -> Dict[str, Any]:
from . import models
from .crypto import decrypt_text
import requests
token_row = (
db.query(models.OAuthToken)
.filter_by(integration_id=integration_id)
.order_by(models.OAuthToken.id.desc())
.first()
)
if not token_row:
raise AdapterError('no token for github integration')
token = decrypt_text(token_row.access_token) if token_row.access_token else None
if not token:
raise AdapterError('unable to decrypt github token')
headers = {
'Authorization': f'token {token}',
'Accept': 'application/vnd.github+json',
}
url = 'https://api.github.com/issues'
try:
resp = requests.get(url, headers=headers, timeout=10)
except Exception as e:
raise TransientError(str(e))
if resp.status_code in (429, 500, 502, 503, 504):
raise TransientError(f'github HTTP {resp.status_code}')
if resp.status_code != 200:
raise AdapterError(f'github HTTP {resp.status_code}')
integ = db.query(models.Integration).filter_by(id=integration_id).first()
conf = {}
if integ and integ.config:
try:
import json as _json
conf = _json.loads(integ.config)
except Exception:
conf = {}
since = conf.get('github_since')
items = []
page = 1
while True:
params = {'per_page': 100, 'page': page}
if since:
params['since'] = since
r = requests.get(url, headers=headers, params=params, timeout=10)
if r.status_code in (429, 500, 502, 503, 504):
raise TransientError(f'github HTTP {r.status_code}')
if r.status_code != 200:
raise AdapterError(f'github HTTP {r.status_code}')
batch = r.json() or []
items.extend(batch)
link = r.headers.get('Link') or r.headers.get('link')
if link and 'rel="next"' in link:
page += 1
continue
if len(batch) == 100:
page += 1
continue
break
created = 0
updated = 0
seen_ext_ids = set()
from .config import settings
def _apply_close_policy(db, habit, should_close: bool):
if not habit:
return False
if settings.INTEGRATION_CLOSE_MODE == 'delete' and should_close:
db.delete(habit)
return True
new_status = 'completed' if should_close else 'active'
if habit.status != new_status:
habit.status = new_status
return True
return False
for issue in items:
ext_id = str(issue.get('id'))
title = issue.get('title') or 'GitHub Issue'
state = (issue.get('state') or '').lower()
labels = [l.get('name') for l in (issue.get('labels') or []) if isinstance(l, dict)]
milestone = issue.get('milestone', {}) or {}
due_on = milestone.get('due_on')
if not ext_id:
continue
seen_ext_ids.add(ext_id)
mapping = (
db.query(models.IntegrationItemMap)
.filter_by(integration_id=integration_id, external_id=ext_id, entity_type='habit')
.first()
)
if mapping:
habit = db.query(models.Habit).filter_by(id=mapping.entity_id).first()
if habit:
changed = False
if habit.title != title:
habit.title = title
changed = True
changed |= _apply_close_policy(db, habit, state == 'closed')
if due_on:
from datetime import datetime
try:
habit.due_date = datetime.fromisoformat(due_on.replace('Z', '+00:00'))
changed = True
except Exception:
pass
if labels:
import json as _json
habit.labels = _json.dumps(labels)
changed = True
if changed:
updated += 1
else:
integ2 = integ or db.query(models.Integration).filter_by(id=integration_id).first()
if not integ2:
raise AdapterError('integration missing during upsert')
import json as _json
habit = models.Habit(
user_id=integ2.user_id,
project_id=None,
title=title,
notes='from github',
cadence='once',
status='completed' if state == 'closed' else 'active',
labels=_json.dumps(labels) if labels else None,
)
db.add(habit)
db.flush()
try:
from sqlalchemy.dialects.postgresql import insert as pg_insert
stmt = pg_insert(models.IntegrationItemMap.__table__).values(
integration_id=integration_id,
external_id=ext_id,
entity_type='habit',
entity_id=habit.id,
).on_conflict_do_update(
index_elements=['integration_id', 'external_id', 'entity_type'],
set_={'entity_id': habit.id}
)
db.execute(stmt)
except Exception:
db.add(models.IntegrationItemMap(integration_id=integration_id, external_id=ext_id, entity_type='habit', entity_id=habit.id))
created += 1
db.flush()
if not since:
mappings = db.query(models.IntegrationItemMap).filter_by(integration_id=integration_id, entity_type='habit').all()
for m in mappings:
if m.external_id not in seen_ext_ids:
habit = db.query(models.Habit).filter_by(id=m.entity_id).first()
if habit:
if settings.INTEGRATION_CLOSE_MODE == 'delete':
db.delete(habit)
else:
habit.status = 'archived'
db.flush()
if integ:
try:
import json as _json
from datetime import datetime, timezone
conf['github_since'] = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
integ.config = _json.dumps(conf)
db.flush()
except Exception:
pass
return {"ok": True, "count": len(items), "created": created, "updated": updated}
ADAPTERS = {
'google_calendar': GoogleCalendarAdapter(),
'todoist': TodoistAdapter(),
'github': GitHubAdapter(),
}
class SlackAdapter(Adapter):
name = 'slack'
def sync(self, *, db, integration_id: int) -> Dict[str, Any]:
"""Optional: send a simple notification via incoming webhook as a scaffold.
This is a no-op if the webhook is missing. Intended as a placeholder.
"""
from . import models
from .crypto import decrypt_text
import requests
tok = (
db.query(models.OAuthToken)
.filter_by(integration_id=integration_id)
.order_by(models.OAuthToken.id.desc())
.first()
)
if not tok or not tok.access_token:
return {"ok": True, "count": 0, "details": {"note": "no webhook"}}
webhook = decrypt_text(tok.access_token)
if not webhook:
raise AdapterError('unable to decrypt slack webhook')
payload = {"text": "LifeRPG: Slack integration sync triggered."}
try:
r = requests.post(webhook, json=payload, timeout=5)
except Exception as e:
raise TransientError(str(e))
if r.status_code >= 500:
raise TransientError(f'slack HTTP {r.status_code}')
if r.status_code >= 400:
raise AdapterError(f'slack HTTP {r.status_code}')
return {"ok": True, "count": 1}
ADAPTERS['slack'] = SlackAdapter()