Allow linking a Träwelling account, auto-sync Träwelling→travelynx

travelynx→Träwelling is still work-in-progress

Squashed commit of the following:

commit 97faa6e2e6c8d20fba30f2d0f6e78187ceeb72e6
Author: Daniel Friesel <derf@finalrewind.org>
Date:   Wed Sep 30 18:50:05 2020 +0200

    improve traewelling log and tx handling

commit 487d7dd728b9d45b731bdc7098cf3358ea2e206e
Author: Daniel Friesel <derf@finalrewind.org>
Date:   Wed Sep 30 18:02:41 2020 +0200

    add missing traewelling template

commit 0148da2f48d9a52dcddc0ab81f83d8f8ac3062ab
Author: Daniel Friesel <derf@finalrewind.org>
Date:   Wed Sep 30 18:02:35 2020 +0200

    improve traewelling pull sync

commit 4861a9750f9f2d7621043361d0af6b0a8869a0df
Author: Daniel Friesel <derf@finalrewind.org>
Date:   Tue Sep 29 22:14:24 2020 +0200

    wip checkin from traewelling

commit f6aeb6f06998a2a7a80f63a7b1b688b1a26b66bd
Author: Daniel Friesel <derf@finalrewind.org>
Date:   Tue Sep 29 18:37:53 2020 +0200

    refactor traewelling integration. login and logout are less of a hack now.

    checkin and checkout are not supported at the moment.
This commit is contained in:
Daniel Friesel 2020-09-30 19:12:29 +02:00
parent 952740969c
commit 89e709d8d5
10 changed files with 1213 additions and 41 deletions

View file

@ -20,7 +20,9 @@ use Travelynx::Helper::DBDB;
use Travelynx::Helper::HAFAS; use Travelynx::Helper::HAFAS;
use Travelynx::Helper::IRIS; use Travelynx::Helper::IRIS;
use Travelynx::Helper::Sendmail; use Travelynx::Helper::Sendmail;
use Travelynx::Helper::Traewelling;
use Travelynx::Model::Journeys; use Travelynx::Model::Journeys;
use Travelynx::Model::Traewelling;
use Travelynx::Model::Users; use Travelynx::Model::Users;
use XML::LibXML; use XML::LibXML;
@ -292,6 +294,26 @@ sub startup {
} }
); );
$self->helper(
traewelling => sub {
my ($self) = @_;
state $trwl = Travelynx::Model::Traewelling->new( pg => $self->pg );
}
);
$self->helper(
traewelling_api => sub {
my ($self) = @_;
state $trwl_api = Travelynx::Helper::Traewelling->new(
log => $self->app->log,
model => $self->traewelling,
root_url => $self->url_for('/')->to_abs,
user_agent => $self->ua,
version => $self->app->config->{version},
);
}
);
$self->helper( $self->helper(
journeys => sub { journeys => sub {
my ($self) = @_; my ($self) = @_;
@ -389,9 +411,12 @@ sub startup {
$self->helper( $self->helper(
'checkin' => sub { 'checkin' => sub {
my ( $self, $station, $train_id, $uid ) = @_; my ( $self, %opt ) = @_;
$uid //= $self->current_user->{id}; my $station = $opt{station};
my $train_id = $opt{train_id};
my $uid = $opt{uid} // $self->current_user->{id};
my $db = $opt{db} // $self->pg->db;
my $status = $self->iris->get_departures( my $status = $self->iris->get_departures(
station => $station, station => $station,
@ -409,7 +434,7 @@ sub startup {
} }
else { else {
my $user = $self->get_user_status($uid); my $user = $self->get_user_status( $uid, $db );
if ( $user->{checked_in} or $user->{cancelled} ) { if ( $user->{checked_in} or $user->{cancelled} ) {
if ( $user->{train_id} eq $train_id if ( $user->{train_id} eq $train_id
@ -420,12 +445,17 @@ sub startup {
} }
# Otherwise, someone forgot to check out first # Otherwise, someone forgot to check out first
$self->checkout( $station, 1, $uid ); $self->checkout(
station => $station,
force => 1,
uid => $uid,
db => $db
);
} }
eval { eval {
my $json = JSON->new; my $json = JSON->new;
$self->pg->db->insert( $db->insert(
'in_transit', 'in_transit',
{ {
user_id => $uid, user_id => $uid,
@ -459,8 +489,12 @@ sub startup {
"Checkin($uid): INSERT failed: $@"); "Checkin($uid): INSERT failed: $@");
return ( undef, 'INSERT failed: ' . $@ ); return ( undef, 'INSERT failed: ' . $@ );
} }
$self->add_route_timestamps( $uid, $train, 1 ); if ( not $opt{in_transaction} ) {
$self->run_hook( $uid, 'checkin' );
# mustn't be called during a transaction
$self->add_route_timestamps( $uid, $train, 1 );
$self->run_hook( $uid, 'checkin' );
}
return ( $train, undef ); return ( $train, undef );
} }
} }
@ -547,16 +581,19 @@ sub startup {
$self->helper( $self->helper(
'checkout' => sub { 'checkout' => sub {
my ( $self, $station, $force, $uid ) = @_; my ( $self, %opt ) = @_;
my $db = $self->pg->db; my $station = $opt{station};
my $status = $self->iris->get_departures( my $force = $opt{force};
my $uid = $opt{uid};
my $db = $opt{db} // $self->pg->db;
my $status = $self->iris->get_departures(
station => $station, station => $station,
lookbehind => 120, lookbehind => 120,
lookahead => 120 lookahead => 120
); );
$uid //= $self->current_user->{id}; $uid //= $self->current_user->{id};
my $user = $self->get_user_status($uid); my $user = $self->get_user_status( $uid, $db );
my $train_id = $user->{train_id}; my $train_id = $user->{train_id};
if ( not $user->{checked_in} and not $user->{cancelled} ) { if ( not $user->{checked_in} and not $user->{cancelled} ) {
@ -671,7 +708,11 @@ sub startup {
} }
} }
if ( not $force ) { if ( not $force ) {
$self->run_hook( $uid, 'update' );
# mustn't be called during a transaction
if ( not $opt{in_transaction} ) {
$self->run_hook( $uid, 'update' );
}
return ( 1, undef ); return ( 1, undef );
} }
} }
@ -680,7 +721,10 @@ sub startup {
eval { eval {
my $tx = $db->begin; my $tx;
if ( not $opt{in_transaction} ) {
$tx = $db->begin;
}
if ( defined $train and not $train->arrival and not $force ) { if ( defined $train and not $train->arrival and not $force ) {
my $train_no = $train->train_no; my $train_no = $train->train_no;
@ -778,7 +822,9 @@ sub startup {
); );
} }
$tx->commit; if ( not $opt{in_transaction} ) {
$tx->commit;
}
}; };
if ($@) { if ($@) {
@ -787,27 +833,33 @@ sub startup {
} }
if ( $has_arrived or $force ) { if ( $has_arrived or $force ) {
$self->run_hook( $uid, 'checkout' ); if ( not $opt{in_transaction} ) {
$self->run_hook( $uid, 'checkout' );
}
return ( 0, undef ); return ( 0, undef );
} }
$self->run_hook( $uid, 'update' ); if ( not $opt{in_transaction} ) {
$self->add_route_timestamps( $uid, $train, 0 ); $self->run_hook( $uid, 'update' );
$self->add_route_timestamps( $uid, $train, 0 );
}
return ( 1, undef ); return ( 1, undef );
} }
); );
$self->helper( $self->helper(
'update_in_transit_comment' => sub { 'update_in_transit_comment' => sub {
my ( $self, $comment, $uid ) = @_; my ( $self, $comment, $uid, $db ) = @_;
$uid //= $self->current_user->{id}; $uid //= $self->current_user->{id};
$db //= $self->pg->db;
my $status = $self->pg->db->select( 'in_transit', ['user_data'], my $status
{ user_id => $uid } )->expand->hash; = $db->select( 'in_transit', ['user_data'], { user_id => $uid } )
->expand->hash;
if ( not $status ) { if ( not $status ) {
return; return;
} }
$status->{user_data}{comment} = $comment; $status->{user_data}{comment} = $comment;
$self->pg->db->update( $db->update(
'in_transit', 'in_transit',
{ user_data => JSON->new->encode( $status->{user_data} ) }, { user_data => JSON->new->encode( $status->{user_data} ) },
{ user_id => $uid } { user_id => $uid }
@ -1872,11 +1924,11 @@ sub startup {
$self->helper( $self->helper(
'get_user_status' => sub { 'get_user_status' => sub {
my ( $self, $uid ) = @_; my ( $self, $uid, $db ) = @_;
$uid //= $self->current_user->{id}; $uid //= $self->current_user->{id};
$db //= $self->pg->db;
my $db = $self->pg->db;
my $now = DateTime->now( time_zone => 'Europe/Berlin' ); my $now = DateTime->now( time_zone => 'Europe/Berlin' );
my $epoch = $now->epoch; my $epoch = $now->epoch;
@ -2315,6 +2367,157 @@ sub startup {
} }
); );
$self->helper(
'traewelling_to_travelynx' => sub {
my ( $self, %opt ) = @_;
my $traewelling = $opt{traewelling};
my $user_data = $opt{user_data};
my $uid = $user_data->{user_id};
if ( not $traewelling->{checkin}
or $self->now->epoch - $traewelling->{checkin}->epoch > 900 )
{
$self->log->debug("... not checked in");
return;
}
if ( $traewelling->{status_id}
and $user_data->{data}{latest_pull_status_id}
and $traewelling->{status_id}
== $user_data->{data}{latest_pull_status_id} )
{
$self->log->debug("... already handled");
return;
}
$self->log->debug("... checked in");
my $user_status = $self->get_user_status($uid);
if ( $user_status->{checked_in} ) {
$self->log->debug(
"... also checked in via travelynx. aborting.");
return;
}
if ( $traewelling->{category}
!~ m{^ (?: nationalExpress | regional | suburban ) $ }x )
{
$self->log->debug("... status is not a train");
$self->traewelling->log(
uid => $uid,
message =>
"$traewelling->{line} nach $traewelling->{arr_name} ist keine Zugfahrt",
status_id => $traewelling->{status_id},
);
$self->traewelling->set_latest_pull_status_id(
uid => $uid,
status_id => $traewelling->{status_id}
);
return;
}
my $dep = $self->iris->get_departures(
station => $traewelling->{dep_eva},
lookbehind => 60,
lookahead => 40
);
if ( $dep->{errstr} ) {
$self->traewelling->log(
uid => $uid,
message =>
"Fehler bei $traewelling->{line} nach $traewelling->{arr_name}: $dep->{errstr}",
status_id => $traewelling->{status_id},
is_error => 1,
);
return;
}
my ( $train_ref, $train_id );
for my $train ( @{ $dep->{results} } ) {
if ( $train->line ne $traewelling->{line} ) {
next;
}
if ( not $train->sched_departure
or $train->sched_departure->epoch
!= $traewelling->{dep_dt}->epoch )
{
next;
}
if (
not List::Util::first { $_ eq $traewelling->{arr_name} }
$train->route_post
)
{
next;
}
$train_id = $train->train_id;
$train_ref = $train;
last;
}
if ($train_id) {
$self->log->debug("... found train: $train_id");
my $db = $self->pg->db;
my $tx = $db->begin;
my ( undef, $err ) = $self->checkin(
station => $traewelling->{dep_eva},
train_id => $train_id,
uid => $uid,
in_transaction => 1,
db => $db
);
if ( not $err ) {
( undef, $err ) = $self->checkout(
station => $traewelling->{arr_eva},
train_id => 0,
uid => $uid,
in_transaction => 1,
db => $db
);
if ( not $err ) {
$self->log->debug("... success!");
if ( $traewelling->{message} ) {
$self->update_in_transit_comment(
$traewelling->{message},
$uid, $db );
}
$self->traewelling->log(
uid => $uid,
db => $db,
message =>
"Eingecheckt in $traewelling->{line} nach $traewelling->{arr_name}",
status_id => $traewelling->{status_id},
);
$self->traewelling->set_latest_pull_status_id(
uid => $uid,
status_id => $traewelling->{status_id},
db => $db
);
$tx->commit;
}
}
if ($err) {
$self->log->debug("... error: $err");
$self->traewelling->log(
uid => $uid,
message =>
"Fehler bei $traewelling->{line} nach $traewelling->{arr_name}: $err",
status_id => $traewelling->{status_id},
is_error => 1
);
}
}
else {
$self->traewelling->log(
uid => $uid,
message =>
"$traewelling->{line} nach $traewelling->{arr_name} nicht gefunden",
status_id => $traewelling->{status_id},
is_error => 1
);
}
}
);
$self->helper( $self->helper(
'journeys_to_map_data' => sub { 'journeys_to_map_data' => sub {
my ( $self, %opt ) = @_; my ( $self, %opt ) = @_;
@ -2647,6 +2850,7 @@ sub startup {
$authed_r->get('/account')->to('account#account'); $authed_r->get('/account')->to('account#account');
$authed_r->get('/account/privacy')->to('account#privacy'); $authed_r->get('/account/privacy')->to('account#privacy');
$authed_r->get('/account/hooks')->to('account#webhook'); $authed_r->get('/account/hooks')->to('account#webhook');
$authed_r->get('/account/traewelling')->to('traewelling#settings');
$authed_r->get('/account/insight')->to('account#insight'); $authed_r->get('/account/insight')->to('account#insight');
$authed_r->get('/ajax/status_card.html')->to('traveling#status_card'); $authed_r->get('/ajax/status_card.html')->to('traveling#status_card');
$authed_r->get('/cancelled')->to('traveling#cancelled'); $authed_r->get('/cancelled')->to('traveling#cancelled');
@ -2668,6 +2872,7 @@ sub startup {
$authed_r->get('/confirm_mail/:token')->to('account#confirm_mail'); $authed_r->get('/confirm_mail/:token')->to('account#confirm_mail');
$authed_r->post('/account/privacy')->to('account#privacy'); $authed_r->post('/account/privacy')->to('account#privacy');
$authed_r->post('/account/hooks')->to('account#webhook'); $authed_r->post('/account/hooks')->to('account#webhook');
$authed_r->post('/account/traewelling')->to('traewelling#settings');
$authed_r->post('/account/insight')->to('account#insight'); $authed_r->post('/account/insight')->to('account#insight');
$authed_r->post('/journey/add')->to('traveling#add_journey_form'); $authed_r->post('/journey/add')->to('traveling#add_journey_form');
$authed_r->post('/journey/comment')->to('traveling#comment_form'); $authed_r->post('/journey/comment')->to('traveling#comment_form');

View file

@ -1012,6 +1012,32 @@ my @migrations = (
} }
); );
}, },
# v21 -> v22
sub {
my ($db) = @_;
$db->query(
qq{
create table traewelling (
user_id integer not null references users (id) primary key,
email varchar(256) not null,
push_sync boolean not null,
pull_sync boolean not null,
errored boolean,
token text,
data jsonb,
latest_run timestamptz
);
comment on table traewelling is 'Token and Status for Traewelling';
create view traewelling_str as select
user_id, email, push_sync, pull_sync, errored, token, data,
extract(epoch from latest_run) as latest_run_ts
from traewelling
;
update schema_version set version = 22;
}
);
},
); );
sub setup_db { sub setup_db {

View file

@ -108,7 +108,11 @@ sub run {
# check out (adds a cancelled journey and resets journey state # check out (adds a cancelled journey and resets journey state
# to checkin # to checkin
$self->app->checkout( $arr, 1, $uid ); $self->app->checkout(
station => $arr,
force => 1,
uid => $uid
);
} }
} }
else { else {
@ -201,7 +205,11 @@ sub run {
{ {
# check out (adds a cancelled journey and resets journey state # check out (adds a cancelled journey and resets journey state
# to destination selection) # to destination selection)
$self->app->checkout( $arr, 0, $uid ); $self->app->checkout(
station => $arr,
force => 0,
uid => $uid
);
} }
} }
else { else {
@ -209,7 +217,11 @@ sub run {
} }
} }
elsif ( $entry->{real_arr_ts} ) { elsif ( $entry->{real_arr_ts} ) {
my ( undef, $error ) = $self->app->checkout( $arr, 1, $uid ); my ( undef, $error ) = $self->app->checkout(
station => $arr,
force => 1,
uid => $uid
);
if ($error) { if ($error) {
die("${error}\n"); die("${error}\n");
} }
@ -222,6 +234,31 @@ sub run {
eval { } eval { }
} }
for my $account_data ( $self->app->traewelling->get_pull_accounts ) {
# $account_data->{user_id} is the travelynx uid
# $account_data->{user_name} is the Träwelling username
$self->app->log->debug(
"Pulling Traewelling status for UID $account_data->{user_id}");
$self->app->traewelling_api->get_status_p(
username => $account_data->{data}{user_name},
token => $account_data->{token}
)->then(
sub {
my ($traewelling) = @_;
$self->app->traewelling_to_travelynx(
traewelling => $traewelling,
user_data => $account_data
);
}
)->catch(
sub {
my ($err) = @_;
$self->app->log->debug("Error $err");
}
)->wait;
}
# Computing yearly stats may take a while, but we've got all time in the # Computing yearly stats may take a while, but we've got all time in the
# world here. This means users won't have to wait when loading their # world here. This means users won't have to wait when loading their
# own by-year journey log. # own by-year journey log.
@ -232,6 +269,8 @@ sub run {
year => $now->year year => $now->year
); );
} }
# TODO wait until all background jobs have terminated
} }
1; 1;

View file

@ -258,14 +258,21 @@ sub travel_v1 {
$train_id = $train->train_id; $train_id = $train->train_id;
} }
my ( $train, $error ) my ( $train, $error ) = $self->checkin(
= $self->checkin( $from_station, $train_id, $uid ); station => $from_station,
train_id => $train_id,
uid => $uid
);
if ( $payload->{comment} and not $error ) { if ( $payload->{comment} and not $error ) {
$self->update_in_transit_comment( $self->update_in_transit_comment(
sanitize( q{}, $payload->{comment} ), $uid ); sanitize( q{}, $payload->{comment} ), $uid );
} }
if ( $to_station and not $error ) { if ( $to_station and not $error ) {
( $train, $error ) = $self->checkout( $to_station, 0, $uid ); ( $train, $error ) = $self->checkout(
station => $to_station,
force => 0,
uid => $uid
);
} }
if ($error) { if ($error) {
$self->render( $self->render(
@ -307,8 +314,11 @@ sub travel_v1 {
sanitize( q{}, $payload->{comment} ), $uid ); sanitize( q{}, $payload->{comment} ), $uid );
} }
my ( $train, $error ) my ( $train, $error ) = $self->checkout(
= $self->checkout( $to_station, $payload->{force} ? 1 : 0, $uid ); station => $to_station,
force => $payload->{force} ? 1 : 0,
uid => $uid
);
if ($error) { if ($error) {
$self->render( $self->render(
json => { json => {

View file

@ -0,0 +1,104 @@
package Travelynx::Controller::Traewelling;
use Mojo::Base 'Mojolicious::Controller';
use Mojo::Promise;
sub settings {
my ($self) = @_;
my $uid = $self->current_user->{id};
if ( $self->param('action')
and $self->validation->csrf_protect->has_error('csrf_token') )
{
$self->render(
'traewelling',
invalid => 'csrf',
);
return;
}
if ( $self->param('action') and $self->param('action') eq 'login' ) {
my $email = $self->param('email');
my $password = $self->param('password');
$self->render_later;
$self->traewelling_api->login_p(
uid => $uid,
email => $email,
password => $password
)->then(
sub {
my $traewelling = $self->traewelling->get($uid);
$self->param( sync_source => 'none' );
$self->render(
'traewelling',
traewelling => $traewelling,
new_traewelling => 1,
);
}
)->catch(
sub {
my ($err) = @_;
$self->render(
'traewelling',
traewelling => {},
new_traewelling => 1,
login_error => $err,
);
}
)->wait;
return;
}
elsif ( $self->param('action') and $self->param('action') eq 'logout' ) {
$self->render_later;
my $traewelling = $self->traewelling->get($uid);
$self->traewelling_api->logout_p(
uid => $uid,
token => $traewelling->{token}
)->then(
sub {
$self->flash( success => 'traewelling' );
$self->redirect_to('account');
}
)->catch(
sub {
my ($err) = @_;
$self->render(
'traewelling',
traewelling => {},
new_traewelling => 1,
logout_error => $err,
);
}
)->wait;
return;
}
elsif ( $self->param('action') and $self->param('action') eq 'config' ) {
$self->traewelling->set_sync(
uid => $uid,
push_sync => $self->param('sync_source') eq 'travelynx' ? 1 : 0,
pull_sync => $self->param('sync_source') eq 'traewelling' ? 1 : 0
);
$self->flash( success => 'traewelling' );
$self->redirect_to('account');
return;
}
my $traewelling = $self->traewelling->get($uid);
if ( $traewelling->{push_sync} ) {
$self->param( sync_source => 'travelynx' );
}
elsif ( $traewelling->{pull_sync} ) {
$self->param( sync_source => 'traewelling' );
}
else {
$self->param( sync_source => 'none' );
}
$self->render(
'traewelling',
traewelling => $traewelling,
);
}
1;

View file

@ -424,8 +424,10 @@ sub log_action {
if ( $params->{action} eq 'checkin' ) { if ( $params->{action} eq 'checkin' ) {
my ( $train, $error ) my ( $train, $error ) = $self->checkin(
= $self->checkin( $params->{station}, $params->{train} ); station => $params->{station},
train_id => $params->{train}
);
my $destination = $params->{dest}; my $destination = $params->{dest};
if ($error) { if ($error) {
@ -447,8 +449,10 @@ sub log_action {
else { else {
# Silently ignore errors -- if they are permanent, the user will see # Silently ignore errors -- if they are permanent, the user will see
# them when selecting the destination manually. # them when selecting the destination manually.
my ( $still_checked_in, undef ) my ( $still_checked_in, undef ) = $self->checkout(
= $self->checkout( $destination, 0 ); station => $destination,
force => 0
);
my $station_link = '/s/' . $destination; my $station_link = '/s/' . $destination;
$self->render( $self->render(
json => { json => {
@ -459,8 +463,10 @@ sub log_action {
} }
} }
elsif ( $params->{action} eq 'checkout' ) { elsif ( $params->{action} eq 'checkout' ) {
my ( $still_checked_in, $error ) my ( $still_checked_in, $error ) = $self->checkout(
= $self->checkout( $params->{station}, $params->{force} ); station => $params->{station},
force => $params->{force}
);
my $station_link = '/s/' . $params->{station}; my $station_link = '/s/' . $params->{station};
if ($error) { if ($error) {
@ -505,8 +511,10 @@ sub log_action {
} }
} }
elsif ( $params->{action} eq 'cancelled_from' ) { elsif ( $params->{action} eq 'cancelled_from' ) {
my ( undef, $error ) my ( undef, $error ) = $self->checkin(
= $self->checkin( $params->{station}, $params->{train} ); station => $params->{station},
train_id => $params->{train}
);
if ($error) { if ($error) {
$self->render( $self->render(
@ -526,8 +534,10 @@ sub log_action {
} }
} }
elsif ( $params->{action} eq 'cancelled_to' ) { elsif ( $params->{action} eq 'cancelled_to' ) {
my ( undef, $error ) my ( undef, $error ) = $self->checkout(
= $self->checkout( $params->{station}, 1 ); station => $params->{station},
force => 1
);
if ($error) { if ($error) {
$self->render( $self->render(

View file

@ -0,0 +1,332 @@
package Travelynx::Helper::Traewelling;
use strict;
use warnings;
use 5.020;
use Mojo::Promise;
sub new {
my ( $class, %opt ) = @_;
my $version = $opt{version};
$opt{header}
= { 'User-Agent' =>
"travelynx/${version} on $opt{root_url} +https://finalrewind.org/projects/travelynx"
};
return bless( \%opt, $class );
}
sub get_status_p {
my ( $self, %opt ) = @_;
my $username = $opt{username};
my $token = $opt{token};
my $promise = Mojo::Promise->new;
my $header = {
'User-Agent' => $self->{header}{'User-Agent'},
'Authorization' => "Bearer $token",
};
$self->{user_agent}->request_timeout(20)
->get_p( "https://traewelling.de/api/v0/user/${username}" => $header )
->then(
sub {
my ($tx) = @_;
if ( my $err = $tx->error ) {
my $err_msg = "HTTP $err->{code} $err->{message}";
$promise->reject($err_msg);
return;
}
else {
if ( my $status = $tx->result->json->{statuses}{data}[0] ) {
my $strp = DateTime::Format::Strptime->new(
pattern => '%Y-%m-%dT%H:%M:%S.000000Z',
time_zone => 'UTC',
);
my $status_id = $status->{id};
my $message = $status->{body};
my $checkin_at
= $strp->parse_datetime( $status->{created_at} );
my $dep_dt = $strp->parse_datetime(
$status->{train_checkin}{departure} );
my $arr_dt = $strp->parse_datetime(
$status->{train_checkin}{arrival} );
my $dep_eva
= $status->{train_checkin}{origin}{ibnr};
my $arr_eva
= $status->{train_checkin}{destination}{ibnr};
my $dep_name
= $status->{train_checkin}{origin}{name};
my $arr_name
= $status->{train_checkin}{destination}{name};
my $category
= $status->{train_checkin}{hafas_trip}{category};
my $trip_id
= $status->{train_checkin}{hafas_trip}{trip_id};
my $linename
= $status->{train_checkin}{hafas_trip}{linename};
my ( $train_type, $train_line ) = split( qr{ }, $linename );
$promise->resolve(
{
status_id => $status_id,
message => $message,
checkin => $checkin_at,
dep_dt => $dep_dt,
dep_eva => $dep_eva,
dep_name => $dep_name,
arr_dt => $arr_dt,
arr_eva => $arr_eva,
arr_name => $arr_name,
trip_id => $trip_id,
train_type => $train_type,
line => $linename,
line_no => $train_line,
category => $category,
}
);
return;
}
else {
$promise->reject("unknown error");
return;
}
}
}
)->catch(
sub {
my ($err) = @_;
$promise->reject($err);
return;
}
)->wait;
return $promise;
}
sub get_user_p {
my ( $self, $uid, $token ) = @_;
my $ua = $self->{user_agent}->request_timeout(20);
my $header = {
'User-Agent' => $self->{header}{'User-Agent'},
'Authorization' => "Bearer $token",
};
my $promise = Mojo::Promise->new;
$ua->get_p( "https://traewelling.de/api/v0/getuser" => $header )->then(
sub {
my ($tx) = @_;
if ( my $err = $tx->error ) {
my $err_msg
= "HTTP $err->{code} $err->{message} bei Abfrage der Nutzerdaten";
$promise->reject($err_msg);
return;
}
else {
my $user_data = $tx->result->json;
$self->{model}->set_user(
uid => $uid,
trwl_id => $user_data->{id},
screen_name => $user_data->{name},
user_name => $user_data->{username},
);
$promise->resolve;
return;
}
}
)->catch(
sub {
my ($err) = @_;
$promise->reject("$err bei Abfrage der Nutzerdaten");
return;
}
)->wait;
return $promise;
}
sub login_p {
my ( $self, %opt ) = @_;
my $uid = $opt{uid};
my $email = $opt{email};
my $password = $opt{password};
my $ua = $self->{user_agent}->request_timeout(20);
my $request = {
email => $email,
password => $password,
};
my $promise = Mojo::Promise->new;
my $token;
$ua->post_p(
"https://traewelling.de/api/v0/auth/login" => $self->{header} =>
json => $request )->then(
sub {
my ($tx) = @_;
if ( my $err = $tx->error ) {
my $err_msg = "HTTP $err->{code} $err->{message} bei Login";
$promise->reject($err_msg);
return;
}
else {
$token = $tx->result->json->{token};
$self->{model}->link(
uid => $uid,
email => $email,
token => $token
);
return $self->get_user_p( $uid, $token );
}
}
)->then(
sub {
$promise->resolve;
return;
}
)->catch(
sub {
my ($err) = @_;
if ($token) {
# We have a token, but couldn't complete the login. For now, we
# solve this by logging out and invalidating the token.
$self->logout_p(
uid => $uid,
token => $token
)->finally(
sub {
$promise->reject($err);
return;
}
);
}
else {
$promise->reject($err);
}
return;
}
)->wait;
return $promise;
}
sub logout_p {
my ( $self, %opt ) = @_;
my $uid = $opt{uid};
my $token = $opt{token};
my $ua = $self->{user_agent}->request_timeout(20);
my $header = {
'User-Agent' => $self->{header}{'User-Agent'},
'Authorization' => "Bearer $token",
};
my $request = {};
$self->{model}->unlink( uid => $uid );
my $promise = Mojo::Promise->new;
$ua->post_p(
"https://traewelling.de/api/v0/auth/logout" => $header => json =>
$request )->then(
sub {
my ($tx) = @_;
if ( my $err = $tx->error ) {
my $err_msg = "HTTP $err->{code} $err->{message}";
$promise->reject($err_msg);
return;
}
else {
$promise->resolve;
return;
}
}
)->catch(
sub {
my ($err) = @_;
$promise->reject($err);
return;
}
)->wait;
return $promise;
}
sub checkin {
my ( $self, $uid ) = @_;
if ( my $token = $self->get_traewelling_push_token($uid) ) {
my $user = $self->get_user_status;
# TODO delete previous traewelling status if the train's destination has been changed
# TODO delete traewelling status when undoing a travelynx checkin
if ( $user->{checked_in} and $user->{extra_data}{trip_id} ) {
my $traewelling = $self->{model}->get($uid);
if ( $traewelling->{data}{trip_id} eq $user->{extra_data}{trip_id} )
{
return;
}
my $header = {
'User-Agent' => 'travelynx/' . $self->{version},
'Authorization' => "Bearer $token",
};
my $request = {
tripID => $user->{extra_data}{trip_id},
start => q{} . $user->{dep_eva},
destination => q{} . $user->{arr_eva},
};
my $trip_req = sprintf(
"tripID=%s&lineName=%s%%20%s&start=%s",
$user->{extra_data}{trip_id}, $user->{train_type},
$user->{train_line} // $user->{train_no}, $user->{dep_eva}
);
$self->{user_agent}->request_timeout(20)
->get_p(
"https://traewelling.de/api/v0/trains/trip?$trip_req" =>
$header )->then(
sub {
return $self->{user_agent}->request_timeout(20)
->post_p(
"https://traewelling.de/api/v0/trains/checkin" =>
$header => json => $request );
}
)->then(
sub {
my ($tx) = @_;
if ( my $err = $tx->error ) {
my $err_msg = "HTTP $err->{code} $err->{message}";
$self->mark_trwl_checkin_error( $uid, $user, $err_msg );
}
else {
# TODO check for traewelling error ("error" key in response)
# TODO store ID of resulting status (request /user/{name} and store status ID)
$self->mark_trwl_checkin_success( $uid, $user );
# mark success: checked into (trip_id, start, destination)
}
}
)->catch(
sub {
my ($err) = @_;
$self->mark_trwl_checkin_error( $uid, $user, $err );
}
)->wait;
}
}
}
1;

View file

@ -0,0 +1,204 @@
package Travelynx::Model::Traewelling;
use strict;
use warnings;
use 5.020;
use DateTime;
sub epoch_to_dt {
my ($epoch) = @_;
# Bugs (and user errors) may lead to undefined timestamps. Set them to
# 1970-01-01 to avoid crashing and show obviously wrong data instead.
$epoch //= 0;
return DateTime->from_epoch(
epoch => $epoch,
time_zone => 'Europe/Berlin',
locale => 'de-DE',
);
}
sub new {
my ( $class, %opt ) = @_;
return bless( \%opt, $class );
}
sub now {
return DateTime->now( time_zone => 'Europe/Berlin' );
}
sub link {
my ( $self, %opt ) = @_;
my $log = [ [ $self->now->epoch, "Erfolgreich angemeldet" ] ];
my $data = {
user_id => $opt{uid},
email => $opt{email},
push_sync => 0,
pull_sync => 0,
token => $opt{token},
data => JSON->new->encode( { log => $log } ),
};
$self->{pg}->db->insert(
'traewelling',
$data,
{
on_conflict => \
'(user_id) do update set email = EXCLUDED.email, token = EXCLUDED.token, push_sync = false, pull_sync = false, data = null, errored = false, latest_run = null'
}
);
return $data;
}
sub set_user {
my ( $self, %opt ) = @_;
my $res_h
= $self->{pg}
->db->select( 'traewelling', 'data', { user_id => $opt{uid} } )
->expand->hash;
$res_h->{data}{user_id} = $opt{trwl_id};
$res_h->{data}{screen_name} = $opt{screen_name};
$res_h->{data}{user_name} = $opt{user_name};
$self->{pg}->db->update(
'traewelling',
{ data => JSON->new->encode( $res_h->{data} ) },
{ user_id => $opt{uid} }
);
}
sub unlink {
my ( $self, %opt ) = @_;
my $uid = $opt{uid};
$self->{pg}->db->delete( 'traewelling', { user_id => $uid } );
}
sub get {
my ( $self, $uid ) = @_;
$uid //= $self->current_user->{id};
my $res_h
= $self->{pg}->db->select( 'traewelling_str', '*', { user_id => $uid } )
->expand->hash;
$res_h->{latest_run} = epoch_to_dt( $res_h->{latest_run_ts} );
for my $log_entry ( @{ $res_h->{data}{log} // [] } ) {
$log_entry->[0] = epoch_to_dt( $log_entry->[0] );
}
return $res_h;
}
sub log {
my ( $self, %opt ) = @_;
my $uid = $opt{uid};
my $message = $opt{message};
my $is_error = $opt{is_error};
my $db = $opt{db} // $self->{pg}->db;
my $res_h
= $db->select( 'traewelling', 'data', { user_id => $uid } )->expand->hash;
splice( @{ $res_h->{data}{log} // [] }, 9 );
unshift(
@{ $res_h->{data}{log} },
[ $self->now->epoch, $message, $opt{status_id} ]
);
if ($is_error) {
$res_h->{data}{error} = $message;
}
$db->update(
'traewelling',
{
errored => $is_error ? 1 : 0,
latest_run => $self->now,
data => JSON->new->encode( $res_h->{data} )
},
{ user_id => $uid }
);
}
sub set_latest_pull_status_id {
my ( $self, %opt ) = @_;
my $uid = $opt{uid};
my $status_id = $opt{status_id};
my $db = $opt{db} // $self->{pg}->db;
my $res_h
= $db->select( 'traewelling', 'data', { user_id => $uid } )->expand->hash;
$res_h->{data}{latest_pull_status_id} = $status_id;
$db->update(
'traewelling',
{ data => JSON->new->encode( $res_h->{data} ) },
{ user_id => $uid }
);
}
sub set_latest_push_status_id {
my ( $self, %opt ) = @_;
my $uid = $opt{uid};
my $status_id = $opt{status_id};
my $db = $opt{db} // $self->{pg}->db;
my $res_h
= $db->select( 'traewelling', 'data', { user_id => $uid } )->expand->hash;
$res_h->{data}{latest_push_status_id} = $status_id;
$db->update(
'traewelling',
{ data => JSON->new->encode( $res_h->{data} ) },
{ user_id => $uid }
);
}
sub set_sync {
my ( $self, %opt ) = @_;
my $uid = $opt{uid};
my $push_sync = $opt{push_sync};
my $pull_sync = $opt{pull_sync};
$self->{pg}->db->update(
'traewelling',
{
push_sync => $push_sync,
pull_sync => $pull_sync
},
{ user_id => $uid }
);
}
sub get_push_accounts {
my ($self) = @_;
my $res = $self->{pg}->db->select(
'traewelling',
[ 'user_id', 'token', 'data' ],
{ push_sync => 1 }
);
return $res->expand->hashes->each;
}
sub get_pull_accounts {
my ($self) = @_;
my $res = $self->{pg}->db->select(
'traewelling',
[ 'user_id', 'token', 'data' ],
{ pull_sync => 1 }
);
return $res->expand->hashes->each;
}
1;

View file

@ -16,6 +16,9 @@
% elsif ($success eq 'privacy') { % elsif ($success eq 'privacy') {
<span class="card-title">Einstellungen zu öffentlichen Account-Daten geändert</span> <span class="card-title">Einstellungen zu öffentlichen Account-Daten geändert</span>
% } % }
% elsif ($success eq 'traewelling') {
<span class="card-title">Traewelling-Verknüpfung aktualisiert</span>
% }
% elsif ($success eq 'use_history') { % elsif ($success eq 'use_history') {
<span class="card-title">Einstellungen zu vorgeschlagenen Verbindungen geändert</span> <span class="card-title">Einstellungen zu vorgeschlagenen Verbindungen geändert</span>
% } % }
@ -31,6 +34,7 @@
<h1>Account</h1> <h1>Account</h1>
% my $acc = current_user(); % my $acc = current_user();
% my $hook = get_webhook(); % my $hook = get_webhook();
% my $traewelling = traewelling->get($acc->{id});
% my $use_history = users->use_history(uid => $acc->{id}); % my $use_history = users->use_history(uid => $acc->{id});
<div class="row"> <div class="row">
<div class="col s12"> <div class="col s12">
@ -110,6 +114,24 @@
% } % }
</td> </td>
</tr> </tr>
<tr>
<th scope="row">Traewelling</th>
<td>
<a href="/account/traewelling"><i class="material-icons">edit</i></a>
% if (not ($traewelling->{token})) {
<span style="color: #999999;">Nicht verknüpft</span>
% }
% elsif ($traewelling->{errored}) {
Fehlerhaft <i class="material-icons">error</i>
% }
% elsif (not ($traewelling->{push_sync} or $traewelling->{pull_sync})) {
<span style="color: #999999;">Verknüpft mit <%= $traewelling->{data}{user_name} // $traewelling->{email} %>, Synchronisierung inaktiv</span>
% }
% else {
Verknüpft mit <%= $traewelling->{data}{user_name} // $traewelling->{email} %>
% }
</td>
</tr>
<tr> <tr>
<th scope="row">Registriert am</th> <th scope="row">Registriert am</th>
<td><%= $acc->{registered_at}->strftime('%d.%m.%Y %H:%M') %></td> <td><%= $acc->{registered_at}->strftime('%d.%m.%Y %H:%M') %></td>

View file

@ -0,0 +1,220 @@
% if (my $invalid = stash('invalid')) {
%= include '_invalid_input', invalid => $invalid
% }
<h1>Träwelling</h1>
<div class="row">
<div class="col s12">
<div class="card purple">
<div class="card-content white-text">
<span class="card-title">Beta-Feature</span>
<p>Die Verbindung von Checkinservices bietet viele Möglichkeiten für interessante Fehlerbilder.
Falls etwas nicht klappt, bitte mit möglichst detaillierten Angaben zum Hergang einen Bug melden.</p>
<p>
Bekannte Probleme: Hooks werden bei einem Checkin via Träwelling nicht ausgelöst.
</p>
</div>
<div class="card-action">
<a href="https://github.com/derf/travelynx/issues" class="waves-effect waves-light btn-flat white-text">
<i class="material-icons left">bug_report</i>Bug melden
</a>
</div>
</div>
</div>
</div>
% if (stash('new_traewelling')) {
<div class="row">
<div class="col s12">
% if ($traewelling->{token}) {
<div class="card success-color">
<div class="card-content white-text">
<span class="card-title">Träwelling verknüpft</span>
% my $user = $traewelling->{data}{user_name} // $traewelling->{email};
<p>Dein travelynx-Account hat nun ein Jahr lang Zugriff auf
den Träwelling-Account <b>@<%= $user %></b>.</p>
</div>
</div>
% }
% elsif (my $login_err = stash('login_error')) {
<div class="card caution-color">
<div class="card-content white-text">
<span class="card-title">Login-Fehler</span>
<p>Der Login bei Träwelling ist fehlgeschlagen: <%= $login_err %></p>
</div>
</div>
% }
% elsif (my $logout_err = stash('logout_error')) {
<div class="card caution-color">
<div class="card-content white-text">
<span class="card-title">Logout-Fehler</span>
<p>Der Logout bei Träwelling ist fehlgeschlagen: <%= $logout_err %>.
Dein Login-Token bei travelynx wurde dennoch gelöscht, so
dass nun kein Zugriff von travelynx auf Träwelling mehr
möglich ist. In den <a
href="https://traewelling.de/settings">Träwelling-Einstellungen</a>
kannst du ihn vollständig löschen.</p>
</div>
</div>
% }
</div>
</div>
% }
% if (not $traewelling->{token}) {
<div class="row">
<div class="col s12">
<p>
Hier hast du die Möglichkeit, deinen travelynx-Account mit einem
Account bei <a href="https://traewelling.de">Träwelling</a> zu
verknüpfen. Dies erlaubt die automatische Übernahme von Checkins
zwischen den beiden Diensten. Träwelling-Checkins in
Nahverkehrsmittel und Züge außerhalb des deutschen Schienennetzes
werden nicht unterstützt und ignoriert.
</p>
<p>
Mit E-Mail und Passwort wird ein Login über die Träwelling-API
durchgeführt. Die E-Mail und das dabei generierte Token werden
von travelynx gespeichert. Das Passwort wird ausschließlich für
den Login verwendet und nicht gespeichert. Der Login kann jederzeit
sowohl auf dieser Seite als auch über die <a
href="https://traewelling.de/settings">Träwelling-Einstellungen</a>
widerrufen werden. Nach einem Jahr läuft er automatisch ab.
</p>
</div>
</div>
<div class="row">
%= form_for '/account/traewelling' => (method => 'POST') => begin
%= csrf_field
<div class="input-field col s12">
<i class="material-icons prefix">account_circle</i>
%= text_field 'email', id => 'email', class => 'validate', required => undef, maxlength => 250
<label for="email">E-Mail</label>
</div>
<div class="input-field col s12">
<i class="material-icons prefix">lock</i>
%= password_field 'password', id => 'password', class => 'validate', required => undef
<label for="password">Passwort</label>
</div>
<div class="col s12 center-align">
<button class="btn waves-effect waves-light" type="submit" name="action" value="login">
Verknüpfen
<i class="material-icons right">send</i>
</button>
</div>
%= end
</div>
% }
% else {
<div class="row">
<div class="col s12">
<p>
Dieser travelynx-Account ist mit dem Träwelling-Account
% if (my $user = $traewelling->{data}{user_name}) {
<a href="https://traewelling.de/profile/<%= $user %>"><%= $user %></a>
% }
% else {
%= $traewelling->{email}
% }
verknüpft.
</p>
</div>
</div>
%= form_for '/account/traewelling' => (method => 'POST') => begin
<div class="row">
%= csrf_field
<div class="input-field col s12">
<div>
<label>
%= radio_button sync_source => 'none'
<span>Keine Synchronisierung</span>
</label>
</div>
</div>
<div class="input-field col s12">
<div>
<label>
%= radio_button sync_source => 'travelynx', disabled => undef
<span>Checkin-Synchronisierung travelynx → Träwelling</span>
</label>
</div>
<p>Die Synchronisierung erfolgt spätestens drei Minuten nach der
Zielwahl. Träwelling-Checkins können von travelynx noch nicht
rückgängig gemacht werden.</p>
</div>
<div class="input-field col s12">
<div>
<label>
%= radio_button sync_source => 'traewelling'
<span>Checkin-Synchronisierung Träwelling → travelynx</span>
</label>
</div>
<p>Alle drei Minuten wird dein Status auf Träwelling abgefragt.
Falls du gerade in einen Zug eingecheckt bist, wird dieser von
travelynx übernommen. Träwelling-Checkins in Nahverkehrsmittel
und Züge außerhalb des deutschen Schienennetzes werden nicht
unterstützt.</p>
</div>
</div>
<div class="row hide-on-small-only">
<div class="col s12 m6 l6 center-align">
<button class="btn waves-effect waves-light red" type="submit" name="action" value="logout">
Abmelden
<i class="material-icons right" aria-hidden="true">sync_disabled</i>
</button>
</div>
<div class="col s12 m6 l6 center-align">
<button class="btn waves-effect waves-light" type="submit" name="action" value="config">
Speichern
<i class="material-icons right" aria-hidden="true">send</i>
</button>
</div>
</div>
<div class="row hide-on-med-and-up">
<div class="col s12 m6 l6 center-align">
<button class="btn waves-effect waves-light" type="submit" name="action" value="config">
Speichern
<i class="material-icons right" aria-hidden="true">send</i>
</button>
</div>
<div class="col s12 m6 l6 center-align" style="margin-top: 1em;">
<button class="btn waves-effect waves-light red" type="submit" name="action" value="logout">
Abmelden
<i class="material-icons right" aria-hidden="true">sync_disabled</i>
</button>
</div>
</div>
%= end
<h2>Status</h2>
<div class="row">
<div class="col s12"">
% if ($traewelling->{latest_run}->epoch) {
Letzter Kontakt mit Träwelling <%= $traewelling->{latest_run}->strftime('am %d.%m.%Y um %H:%M:%S') %><br/>
% if ($traewelling->{errored}) {
<i class="material-icons left">error</i>
Fehler: <%= $traewelling->{data}{error} %>
% }
% }
% else {
Bisher wurde noch keine Synchronisierung durchgeführt.
% }
</div>
</div>
<h2>Log</h2>
<div class="row">
<div class="col s12"">
<ul>
% for my $log_entry (@{$traewelling->{data}{log} // []}) {
<li>
<%= $log_entry->[0]->strftime('%d.%m.%Y %H:%M:%S') %>
% if ($log_entry->[2]) {
Träwelling <a href="https://traewelling.de/status/<%= $log_entry->[2] %>">#<%= $log_entry->[2] %></a>
% }
%= $log_entry->[1]
</li>
% }
</ul>
</div>
</div>
% }