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:
@@ -10,9 +10,10 @@ export const metadata: Metadata = {
|
|||||||
manifest: "/manifest.json",
|
manifest: "/manifest.json",
|
||||||
appleWebApp: {
|
appleWebApp: {
|
||||||
capable: true,
|
capable: true,
|
||||||
statusBarStyle: "default",
|
statusBarStyle: "black-translucent",
|
||||||
title: "Task Team",
|
title: "Task Team",
|
||||||
},
|
},
|
||||||
|
other: { "mobile-web-app-capable": "yes" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default function LoginPage() {
|
|||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const result = await login({ email: email.trim() });
|
const result = await login({ email: email.trim() });
|
||||||
setAuth(result.token, result.user);
|
setAuth(result.data.token, result.data.user);
|
||||||
router.push("/tasks");
|
router.push("/tasks");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Chyba prihlaseni");
|
setError(err instanceof Error ? err.message : "Chyba prihlaseni");
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default function RegisterPage() {
|
|||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
phone: phone.trim() || undefined,
|
phone: phone.trim() || undefined,
|
||||||
});
|
});
|
||||||
setAuth(result.token, result.user);
|
setAuth(result.data.token, result.data.user);
|
||||||
router.push("/tasks");
|
router.push("/tasks");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Chyba registrace");
|
setError(err instanceof Error ? err.message : "Chyba registrace");
|
||||||
|
|||||||
@@ -36,14 +36,14 @@ async function apiFetch<T>(path: string, opts: ApiOptions = {}): Promise<T> {
|
|||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
export function register(data: { email: string; name: string; phone?: string }) {
|
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",
|
method: "POST",
|
||||||
body: data,
|
body: data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function login(data: { email: string; password?: string }) {
|
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",
|
method: "POST",
|
||||||
body: data,
|
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 |
@@ -1,17 +1 @@
|
|||||||
{
|
{"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"}]}
|
||||||
"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"
|
|
||||||
}
|
|
||||||
|
|||||||
4
odoo_modules/task_team_connector/__init__.py
Normal file
4
odoo_modules/task_team_connector/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
|
from . import services
|
||||||
|
from . import wizard
|
||||||
38
odoo_modules/task_team_connector/__manifest__.py
Normal file
38
odoo_modules/task_team_connector/__manifest__.py
Normal 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,
|
||||||
|
}
|
||||||
2
odoo_modules/task_team_connector/controllers/__init__.py
Normal file
2
odoo_modules/task_team_connector/controllers/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from . import webhook
|
||||||
|
from . import oauth
|
||||||
142
odoo_modules/task_team_connector/controllers/oauth.py
Normal file
142
odoo_modules/task_team_connector/controllers/oauth.py
Normal 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,
|
||||||
|
}
|
||||||
82
odoo_modules/task_team_connector/controllers/webhook.py
Normal file
82
odoo_modules/task_team_connector/controllers/webhook.py
Normal 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'}
|
||||||
5
odoo_modules/task_team_connector/models/__init__.py
Normal file
5
odoo_modules/task_team_connector/models/__init__.py
Normal 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
|
||||||
248
odoo_modules/task_team_connector/models/project_task.py
Normal file
248
odoo_modules/task_team_connector/models/project_task.py
Normal 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()
|
||||||
@@ -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',
|
||||||
|
)
|
||||||
39
odoo_modules/task_team_connector/models/res_users.py
Normal file
39
odoo_modules/task_team_connector/models/res_users.py
Normal 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
|
||||||
56
odoo_modules/task_team_connector/models/task_team_oauth.py
Normal file
56
odoo_modules/task_team_connector/models/task_team_oauth.py
Normal 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()
|
||||||
@@ -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)
|
||||||
1
odoo_modules/task_team_connector/services/__init__.py
Normal file
1
odoo_modules/task_team_connector/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# services package — imported explicitly by models / controllers
|
||||||
123
odoo_modules/task_team_connector/services/api_client.py
Normal file
123
odoo_modules/task_team_connector/services/api_client.py
Normal 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})
|
||||||
1
odoo_modules/task_team_connector/wizard/__init__.py
Normal file
1
odoo_modules/task_team_connector/wizard/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import sync_wizard
|
||||||
Reference in New Issue
Block a user