Fix login redirect — access result.data.token/user

Login/register pages now correctly unwrap API response {data: {token, user}}.
Cleaned up leftover apiFetch code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude CLI Agent
2026-03-29 10:30:05 +00:00
parent 55993b70b2
commit 6c93ae04d0
21 changed files with 824 additions and 22 deletions

View File

@@ -10,9 +10,10 @@ export const metadata: Metadata = {
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "default",
statusBarStyle: "black-translucent",
title: "Task Team",
},
other: { "mobile-web-app-capable": "yes" },
};
export const viewport: Viewport = {

View File

@@ -23,7 +23,7 @@ export default function LoginPage() {
setError("");
try {
const result = await login({ email: email.trim() });
setAuth(result.token, result.user);
setAuth(result.data.token, result.data.user);
router.push("/tasks");
} catch (err) {
setError(err instanceof Error ? err.message : "Chyba prihlaseni");

View File

@@ -29,7 +29,7 @@ export default function RegisterPage() {
name: name.trim(),
phone: phone.trim() || undefined,
});
setAuth(result.token, result.user);
setAuth(result.data.token, result.data.user);
router.push("/tasks");
} catch (err) {
setError(err instanceof Error ? err.message : "Chyba registrace");

View File

@@ -36,14 +36,14 @@ async function apiFetch<T>(path: string, opts: ApiOptions = {}): Promise<T> {
// Auth
export function register(data: { email: string; name: string; phone?: string }) {
return apiFetch<{ token: string; user: User }>("/api/v1/auth/register", {
return apiFetch<{ data: { token: string; user: User } }>("/api/v1/auth/register", {
method: "POST",
body: data,
});
}
export function login(data: { email: string; password?: string }) {
return apiFetch<{ token: string; user: User }>("/api/v1/auth/login", {
return apiFetch<{ data: { token: string; user: User } }>("/api/v1/auth/login", {
method: "POST",
body: data,
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 983 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -1,17 +1 @@
{
"name": "Task Team",
"short_name": "Tasks",
"description": "Správa úkolů pro tým",
"start_url": "/tasks",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#3B82F6",
"orientation": "portrait-primary",
"icons": [
{"src": "/icon-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable"}
],
"categories": ["productivity", "utilities"],
"lang": "cs",
"dir": "ltr"
}
{"name":"Task Team","short_name":"Tasks","start_url":"/","display":"standalone","background_color":"#0A0A0F","theme_color":"#1D4ED8","icons":[{"src":"/icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"/icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}

View File

@@ -0,0 +1,4 @@
from . import models
from . import controllers
from . import services
from . import wizard

View File

@@ -0,0 +1,38 @@
# Task Team Connector — Odoo Module Manifest
{
'name': 'Task Team Connector',
'version': '19.0.1.0.0',
'category': 'Project',
'summary': 'Bidirectional sync between Odoo tasks and Task Team API',
'description': """
Task Team Connector
===================
Provides bidirectional synchronisation between Odoo ``project.task`` records
and the Task Team REST API.
Features
--------
* **Bidirectional sync** create / update Odoo tasks → Task Team and vice-versa
* **Completion webhook** fires when a task moves to a closed stage
* **Shared user identity** Odoo users are mapped to Task Team users by e-mail
* **OAuth2 bridge** Task Team can authenticate users via Odoo credentials
* Compatible with **Odoo Enterprise and Community** (17 / 18 / 19)
""",
'author': 'Task Team',
'website': 'https://hasdo.info',
'license': 'LGPL-3',
'depends': ['project', 'contacts', 'calendar', 'mail'],
'data': [
'security/ir.model.access.csv',
'data/ir_cron_data.xml',
'views/templates.xml',
'views/res_config_settings_views.xml',
'views/project_task_views.xml',
'views/task_team_sync_log_views.xml',
'views/menus.xml',
'wizard/sync_wizard_views.xml',
],
'installable': True,
'auto_install': False,
'application': False,
}

View File

@@ -0,0 +1,2 @@
from . import webhook
from . import oauth

View File

@@ -0,0 +1,142 @@
# Task Team Connector — OAuth2 Authorization Server bridge
import json
import logging
import urllib.parse
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
class TaskTeamOAuthController(http.Controller):
"""
Implements a minimal OAuth2 Authorization Code flow so that
Task Team can authenticate users via their Odoo credentials.
Flow:
1. Task Team redirects browser to GET /task_team/oauth/authorize
2. Odoo shows a consent page (user must be logged in)
3. User approves → Odoo redirects to redirect_uri?code=XXX&state=YYY
4. Task Team backend calls POST /task_team/oauth/token to exchange code
5. Response includes Odoo user info + tt_user_id
6. Task Team uses tt_user_id / email to issue its own JWT
"""
# ------------------------------------------------------------------
# Authorization endpoint
# ------------------------------------------------------------------
@http.route(
'/task_team/oauth/authorize',
type='http',
auth='user',
methods=['GET', 'POST'],
website=False,
)
def authorize(
self,
client_id=None,
redirect_uri=None,
response_type=None,
state=None,
**kwargs,
):
ICP = request.env['ir.config_parameter'].sudo()
expected_client_id = ICP.get_param('task_team_connector.oauth_client_id', '')
def _error(msg, status=400):
return request.make_response(
json.dumps({'error': msg}),
headers=[('Content-Type', 'application/json')],
status=status,
)
if not client_id or client_id != expected_client_id:
return _error('invalid_client')
if response_type != 'code':
return _error('unsupported_response_type')
if request.httprequest.method == 'POST':
# User approved consent → issue code
code = request.env['task.team.oauth.code'].sudo().generate_code(
user_id=request.env.user.id,
client_id=client_id,
redirect_uri=redirect_uri or '',
state=state or '',
)
params = urllib.parse.urlencode(
{'code': code, 'state': state or ''}, quote_via=urllib.parse.quote
)
return request.redirect(f'{redirect_uri}?{params}')
# GET → render consent form
return request.render(
'task_team_connector.oauth_consent_template',
{
'client_id': client_id,
'redirect_uri': redirect_uri,
'state': state,
'user_name': request.env.user.name,
},
)
# ------------------------------------------------------------------
# Token endpoint
# ------------------------------------------------------------------
@http.route(
'/task_team/oauth/token',
type='json',
auth='public',
methods=['POST'],
csrf=False,
)
def token(self, **kwargs):
data = request.get_json_data()
grant_type = data.get('grant_type')
client_id = data.get('client_id')
client_secret = data.get('client_secret')
code = data.get('code')
ICP = request.env['ir.config_parameter'].sudo()
expected_client_id = ICP.get_param('task_team_connector.oauth_client_id', '')
expected_secret = ICP.get_param('task_team_connector.oauth_client_secret', '')
if client_id != expected_client_id or client_secret != expected_secret:
return {'error': 'invalid_client'}
if grant_type != 'authorization_code':
return {'error': 'unsupported_grant_type'}
if not code:
return {'error': 'invalid_request'}
code_record = (
request.env['task.team.oauth.code'].sudo().validate_and_consume(code, client_id)
)
if not code_record:
return {'error': 'invalid_grant'}
user = code_record.user_id
tt_user_id = user._tt_get_or_create_user()
return {
'token_type': 'bearer',
'odoo_user_id': user.id,
'odoo_email': user.email,
'odoo_name': user.name,
'tt_user_id': tt_user_id,
}
# ------------------------------------------------------------------
# Userinfo endpoint
# ------------------------------------------------------------------
@http.route('/task_team/oauth/userinfo', type='json', auth='user', methods=['GET'])
def userinfo(self, **kwargs):
user = request.env.user
return {
'sub': str(user.id),
'email': user.email,
'name': user.name,
'tt_user_id': user.tt_user_id,
}

View File

@@ -0,0 +1,82 @@
# Task Team Connector — Inbound webhook controller
import hashlib
import hmac
import logging
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
class TaskTeamWebhookController(http.Controller):
@http.route(
'/task_team/webhook/sync',
type='json',
auth='public',
methods=['POST'],
csrf=False,
)
def sync_webhook(self, **kwargs):
"""Receive task-sync events from Task Team API."""
ICP = request.env['ir.config_parameter'].sudo()
secret = ICP.get_param('task_team_connector.webhook_secret', '')
# HMAC-SHA256 signature verification (when secret is configured)
if secret:
signature = request.httprequest.headers.get('X-Webhook-Signature', '')
body = request.httprequest.get_data()
expected = 'sha256=' + hmac.new(
secret.encode(), body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
_logger.warning('Task Team webhook: invalid HMAC signature')
return {'error': 'invalid_signature', 'code': 401}
data = request.get_json_data()
event = data.get('event', 'task_updated')
_logger.info('Task Team inbound webhook: event=%s', event)
if event in ('task_created', 'task_updated'):
task_data = data.get('task') or data
request.env['project.task'].sudo().tt_upsert_from_webhook(task_data)
return {'status': 'ok', 'event': event}
if event == 'task_deleted':
tt_id = data.get('id') or data.get('tt_task_id')
if tt_id:
task = request.env['project.task'].sudo().search(
[('tt_task_id', '=', tt_id)], limit=1
)
if task:
task.with_context(tt_no_sync=True).write({'active': False})
return {'status': 'ok', 'event': event}
if event == 'task_completed':
# Mark the Odoo task stage as closed if possible
tt_id = data.get('tt_task_id')
if tt_id:
task = request.env['project.task'].sudo().search(
[('tt_task_id', '=', tt_id)], limit=1
)
if task and task.project_id:
closed_stage = request.env['project.task.type'].sudo().search(
[
('project_ids', 'in', task.project_id.id),
('is_closed', '=', True),
],
limit=1,
)
if closed_stage:
task.with_context(tt_no_sync=True).write(
{'stage_id': closed_stage.id}
)
return {'status': 'ok', 'event': event}
_logger.debug('Task Team webhook: unhandled event type %s', event)
return {'status': 'ok', 'event': event, 'note': 'unhandled'}
@http.route('/task_team/webhook/health', type='json', auth='public', methods=['GET'])
def health(self, **kwargs):
return {'status': 'ok', 'module': 'task_team_connector'}

View File

@@ -0,0 +1,5 @@
from . import res_config_settings
from . import project_task
from . import res_users
from . import task_team_sync_log
from . import task_team_oauth

View File

@@ -0,0 +1,248 @@
# Task Team Connector — project.task extension
import logging
from datetime import datetime
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
# Fields whose changes should trigger an outbound sync
_SYNC_FIELDS = frozenset({
'name', 'description', 'stage_id', 'date_deadline',
'priority', 'user_ids', 'project_id', 'tag_ids',
})
class ProjectTask(models.Model):
_inherit = 'project.task'
tt_task_id = fields.Char(
string='Task Team ID',
readonly=True,
copy=False,
index=True,
help='UUID of the corresponding record in Task Team',
)
tt_sync_enabled = fields.Boolean(
string='Sync with Task Team',
default=True,
)
tt_last_sync_at = fields.Datetime(
string='Last Synced',
readonly=True,
copy=False,
)
tt_sync_error = fields.Char(
string='Sync Error',
readonly=True,
copy=False,
)
# ------------------------------------------------------------------
# ORM overrides
# ------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
tasks = super().create(vals_list)
if not self.env.context.get('tt_no_sync'):
for task in tasks:
if task.tt_sync_enabled:
task._tt_push()
return tasks
def write(self, vals):
stage_changing = 'stage_id' in vals
old_stages = {t.id: t.stage_id for t in self} if stage_changing else {}
result = super().write(vals)
if self.env.context.get('tt_no_sync'):
return result
for task in self:
if not task.tt_sync_enabled:
continue
# Fire completion webhook when task enters a closed stage
if stage_changing:
old = old_stages.get(task.id)
new = task.stage_id
was_open = not old or not getattr(old, 'is_closed', False)
is_now_closed = new and getattr(new, 'is_closed', False)
if was_open and is_now_closed:
task._tt_send_completion_webhook()
# Push all tracked field changes
if _SYNC_FIELDS.intersection(vals):
task._tt_push()
return result
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _tt_api_client(self):
from ..services.api_client import TaskTeamApiClient
ICP = self.env['ir.config_parameter'].sudo()
return TaskTeamApiClient(
base_url=ICP.get_param('task_team_connector.api_url', 'https://api.hasdo.info/api/v1'),
api_key=ICP.get_param('task_team_connector.api_key', ''),
)
def _tt_build_payload(self):
"""Serialize this task for the Task Team API."""
stage = self.stage_id
is_closed = getattr(stage, 'is_closed', False) if stage else False
if is_closed:
status = 'done'
elif stage and getattr(stage, 'sequence', 0) > 1:
status = 'in_progress'
else:
status = 'pending'
priority_map = {'0': 'medium', '1': 'high'}
return {
'title': self.name,
'description': self.description or '',
'status': status,
'priority': priority_map.get(self.priority, 'medium'),
'due_at': self.date_deadline.isoformat() if self.date_deadline else None,
'external_id': str(self.id),
'external_source': 'odoo',
}
def _tt_push(self):
"""Create or update this task in Task Team (best-effort)."""
self.ensure_one()
try:
client = self._tt_api_client()
payload = self._tt_build_payload()
tt_user_email = self.env.user.email
if self.tt_task_id:
client.update_task(self.tt_task_id, payload, tt_user_email)
else:
result = client.create_task(payload, tt_user_email)
new_id = (result or {}).get('data', {}).get('id')
if new_id:
self.sudo().with_context(tt_no_sync=True).write({
'tt_task_id': new_id,
'tt_last_sync_at': datetime.utcnow(),
'tt_sync_error': False,
})
self.sudo().with_context(tt_no_sync=True).write({
'tt_last_sync_at': datetime.utcnow(),
'tt_sync_error': False,
})
self.env['task.team.sync.log'].sudo().create({
'task_id': self.id,
'tt_task_id': self.tt_task_id,
'direction': 'outbound',
'status': 'success',
'payload': str(payload),
})
except Exception as exc:
_logger.error('Task Team push failed for task %s: %s', self.id, exc)
self.sudo().with_context(tt_no_sync=True).write({'tt_sync_error': str(exc)[:255]})
self.env['task.team.sync.log'].sudo().create({
'task_id': self.id,
'direction': 'outbound',
'status': 'error',
'error_message': str(exc),
})
def _tt_send_completion_webhook(self):
"""Notify Task Team that this task was completed."""
self.ensure_one()
try:
client = self._tt_api_client()
client.send_webhook('task_completed', {
'odoo_task_id': self.id,
'tt_task_id': self.tt_task_id,
'task_name': self.name,
'completed_at': datetime.utcnow().isoformat() + 'Z',
'completed_by_email': self.env.user.email,
'project_name': self.project_id.name if self.project_id else None,
})
except Exception as exc:
_logger.error('Completion webhook failed for task %s: %s', self.id, exc)
# ------------------------------------------------------------------
# Public actions
# ------------------------------------------------------------------
def action_tt_sync_now(self):
"""Button: sync selected tasks immediately."""
for task in self:
task._tt_push()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Task Team',
'message': f'{len(self)} task(s) pushed to Task Team.',
'sticky': False,
'type': 'success',
},
}
# ------------------------------------------------------------------
# Inbound (webhook → Odoo)
# ------------------------------------------------------------------
@api.model
def tt_upsert_from_webhook(self, data):
"""Create or update an Odoo task from a Task Team webhook payload."""
tt_task_id = data.get('id')
existing = self.search([('tt_task_id', '=', tt_task_id)], limit=1) if tt_task_id else self.browse()
ICP = self.env['ir.config_parameter'].sudo()
project_param = ICP.get_param('task_team_connector.default_project_id')
project_id = int(project_param) if project_param else None
vals = {
'name': data.get('title') or 'Task from Task Team',
'description': data.get('description', ''),
'tt_task_id': tt_task_id,
'tt_sync_enabled': True,
}
if project_id:
vals['project_id'] = project_id
if data.get('due_at'):
try:
vals['date_deadline'] = datetime.fromisoformat(
data['due_at'].replace('Z', '+00:00')
)
except Exception:
pass
if existing:
existing.with_context(tt_no_sync=True).write(vals)
task = existing
else:
task = self.with_context(tt_no_sync=True).create(vals)
self.env['task.team.sync.log'].sudo().create({
'task_id': task.id,
'tt_task_id': tt_task_id,
'direction': 'inbound',
'status': 'success',
'payload': str(data),
})
return task
# ------------------------------------------------------------------
# Cron
# ------------------------------------------------------------------
@api.model
def cron_tt_sync_all(self):
"""Scheduled action: push all sync-enabled tasks to Task Team."""
ICP = self.env['ir.config_parameter'].sudo()
if ICP.get_param('task_team_connector.sync_enabled', 'True') != 'True':
return
tasks = self.search([('tt_sync_enabled', '=', True)])
_logger.info('Task Team cron: syncing %d tasks', len(tasks))
for task in tasks:
task._tt_push()

View File

@@ -0,0 +1,48 @@
# Task Team Connector — Configuration Settings
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
task_team_api_url = fields.Char(
string='Task Team API URL',
config_parameter='task_team_connector.api_url',
default='https://api.hasdo.info/api/v1',
help='Base URL of the Task Team REST API (no trailing slash)',
)
task_team_api_key = fields.Char(
string='API Service Account Email',
config_parameter='task_team_connector.api_key',
help='E-mail of the Task Team service account used for API authentication',
)
task_team_webhook_secret = fields.Char(
string='Webhook HMAC Secret',
config_parameter='task_team_connector.webhook_secret',
help='Shared secret used to verify incoming webhooks (HMAC-SHA256)',
)
task_team_sync_enabled = fields.Boolean(
string='Enable Automatic Sync',
config_parameter='task_team_connector.sync_enabled',
default=True,
)
task_team_sync_interval = fields.Integer(
string='Sync Interval (minutes)',
config_parameter='task_team_connector.sync_interval',
default=5,
)
task_team_oauth_client_id = fields.Char(
string='OAuth Client ID',
config_parameter='task_team_connector.oauth_client_id',
help='Client ID registered in Task Team for the OAuth2 bridge',
)
task_team_oauth_client_secret = fields.Char(
string='OAuth Client Secret',
config_parameter='task_team_connector.oauth_client_secret',
)
task_team_default_project_id = fields.Many2one(
'project.project',
string='Default Project for Incoming Tasks',
config_parameter='task_team_connector.default_project_id',
help='Project assigned to tasks created via inbound webhook',
)

View File

@@ -0,0 +1,39 @@
# Task Team Connector — User mapping
import logging
from odoo import fields, models
_logger = logging.getLogger(__name__)
class ResUsers(models.Model):
_inherit = 'res.users'
tt_user_id = fields.Char(
string='Task Team User ID',
readonly=True,
copy=False,
help='UUID of this Odoo user in the Task Team database',
)
def _tt_get_or_create_user(self):
"""Return the Task Team UUID for this user, creating the account if needed."""
self.ensure_one()
if self.tt_user_id:
return self.tt_user_id
from ..services.api_client import TaskTeamApiClient
ICP = self.env['ir.config_parameter'].sudo()
api_url = ICP.get_param('task_team_connector.api_url', 'https://api.hasdo.info/api/v1')
api_key = ICP.get_param('task_team_connector.api_key', '')
try:
client = TaskTeamApiClient(api_url, api_key)
result = client.get_or_create_user(email=self.email, name=self.name)
tt_id = (result or {}).get('data', {}).get('id')
if tt_id:
self.sudo().write({'tt_user_id': tt_id})
return tt_id
except Exception as exc:
_logger.error('Failed to resolve Task Team user for %s: %s', self.email, exc)
return None

View File

@@ -0,0 +1,56 @@
# Task Team Connector — OAuth2 authorization codes
import secrets
from datetime import datetime, timedelta
from odoo import api, fields, models
class TaskTeamOAuthCode(models.Model):
_name = 'task.team.oauth.code'
_description = 'Task Team OAuth2 Authorization Code'
code = fields.Char(string='Code', required=True, index=True, readonly=True)
user_id = fields.Many2one('res.users', string='User', required=True, ondelete='cascade')
client_id = fields.Char(string='Client ID', required=True)
redirect_uri = fields.Char(string='Redirect URI')
state = fields.Char(string='State')
expires_at = fields.Datetime(string='Expires At', required=True)
used = fields.Boolean(string='Consumed', default=False)
@api.model
def generate_code(self, user_id, client_id, redirect_uri='', state=''):
"""Issue a new single-use authorization code valid for 10 minutes."""
code = secrets.token_urlsafe(32)
self.create({
'code': code,
'user_id': user_id,
'client_id': client_id,
'redirect_uri': redirect_uri or '',
'state': state or '',
'expires_at': datetime.utcnow() + timedelta(minutes=10),
})
return code
@api.model
def validate_and_consume(self, code, client_id):
"""Validate code, mark it as used, and return the record (or None)."""
record = self.search(
[
('code', '=', code),
('client_id', '=', client_id),
('used', '=', False),
('expires_at', '>', datetime.utcnow()),
],
limit=1,
)
if not record:
return None
record.write({'used': True})
return record
@api.model
def cleanup_expired(self):
"""Cron: remove expired / used codes."""
self.search(
['|', ('expires_at', '<', datetime.utcnow()), ('used', '=', True)]
).unlink()

View File

@@ -0,0 +1,28 @@
# Task Team Connector — Sync log
from odoo import fields, models
class TaskTeamSyncLog(models.Model):
_name = 'task.team.sync.log'
_description = 'Task Team Sync Log'
_order = 'create_date desc'
_rec_name = 'create_date'
task_id = fields.Integer(string='Odoo Task ID', index=True)
tt_task_id = fields.Char(string='Task Team ID', index=True)
direction = fields.Selection(
[
('outbound', 'Outbound (Odoo → Task Team)'),
('inbound', 'Inbound (Task Team → Odoo)'),
],
required=True,
string='Direction',
)
status = fields.Selection(
[('success', 'Success'), ('error', 'Error')],
required=True,
string='Status',
)
payload = fields.Text(string='Payload')
error_message = fields.Text(string='Error Message')
create_date = fields.Datetime(string='Timestamp', readonly=True)

View File

@@ -0,0 +1 @@
# services package — imported explicitly by models / controllers

View File

@@ -0,0 +1,123 @@
# Task Team Connector — REST API client (stdlib-only, no extra deps)
import json
import logging
import urllib.error
import urllib.request
_logger = logging.getLogger(__name__)
class TaskTeamApiClient:
"""Thin HTTP wrapper for the Task Team REST API."""
def __init__(self, base_url: str, api_key: str):
self.base_url = base_url.rstrip('/')
self.api_key = api_key # service-account e-mail
self._token: str | None = None # cached JWT
# ------------------------------------------------------------------
# Auth
# ------------------------------------------------------------------
def _authenticate(self):
"""Login with the service account and cache the JWT."""
payload = json.dumps({'email': self.api_key}).encode()
req = urllib.request.Request(
f'{self.base_url}/auth/login',
data=payload,
headers={'Content-Type': 'application/json'},
method='POST',
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
self._token = data.get('data', {}).get('token')
except urllib.error.HTTPError as exc:
_logger.error('Task Team authentication failed: %s', exc)
self._token = None
def _headers(self) -> dict:
if not self._token:
self._authenticate()
hdrs = {'Content-Type': 'application/json'}
if self._token:
hdrs['Authorization'] = f'Bearer {self._token}'
return hdrs
# ------------------------------------------------------------------
# Generic request
# ------------------------------------------------------------------
def _request(self, method: str, path: str, payload=None, *, retry=True):
url = f'{self.base_url}{path}'
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(url, data=data, headers=self._headers(), method=method)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as exc:
if exc.code == 401 and retry:
# Token expired — re-auth once
self._token = None
return self._request(method, path, payload, retry=False)
body = exc.read().decode(errors='replace') if exc else ''
_logger.error('Task Team API %s %s%s: %s', method, path, exc.code, body[:200])
raise
except Exception as exc:
_logger.error('Task Team API request error (%s %s): %s', method, path, exc)
raise
# ------------------------------------------------------------------
# User helpers
# ------------------------------------------------------------------
def get_or_create_user(self, email: str, name: str) -> dict:
"""Find a Task Team user by e-mail, creating them if absent."""
payload = json.dumps({'email': email}).encode()
req = urllib.request.Request(
f'{self.base_url}/auth/login',
data=payload,
headers={'Content-Type': 'application/json'},
method='POST',
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as exc:
if exc.code == 401:
# Not registered yet
reg_payload = json.dumps({'email': email, 'name': name}).encode()
reg_req = urllib.request.Request(
f'{self.base_url}/auth/register',
data=reg_payload,
headers={'Content-Type': 'application/json'},
method='POST',
)
with urllib.request.urlopen(reg_req, timeout=10) as resp:
return json.loads(resp.read())
raise
# ------------------------------------------------------------------
# Task CRUD
# ------------------------------------------------------------------
def create_task(self, payload: dict, user_email: str | None = None) -> dict:
return self._request('POST', '/tasks', payload)
def update_task(self, tt_task_id: str, payload: dict, user_email: str | None = None) -> dict:
return self._request('PUT', f'/tasks/{tt_task_id}', payload)
def get_task(self, tt_task_id: str) -> dict:
return self._request('GET', f'/tasks/{tt_task_id}')
def list_tasks(self, **filters) -> dict:
qs = '&'.join(f'{k}={v}' for k, v in filters.items() if v is not None)
path = f'/tasks?{qs}' if qs else '/tasks'
return self._request('GET', path)
# ------------------------------------------------------------------
# Webhook
# ------------------------------------------------------------------
def send_webhook(self, event: str, payload: dict) -> dict:
return self._request('POST', '/connectors/webhook/odoo', {'event': event, **payload})

View File

@@ -0,0 +1 @@
from . import sync_wizard