Tag Archives: Apache Wicket

Integrating Google Calendar into a Wicket Application

I recently integrated Google Calendar into a Wicket application I use at home. As some requirements are atypical I want to describe them before going into too much detail:

  • As I use the application only by myself I want to allow anonymous access to my application. If multiple users access your system you have to implement “proper” authentication in your application though.
  • For the sake of simplicity I do not care about encrypting keys stored on my disk. However, if you use the code from this post in a real production system with multiple users please do yourself a favor and ensure any keys are stored as secure as possible.

Technical options

If you want to use a Google API in your application there is a chance that they already provide a client library, which is actually the case for Google Calendar. I decided to go another route for two reasons:

  1. As this is yet another REST API, there is no need for a special library and a new API. If I use Spring already I can achieve the same result by simply using Spring’s RestTemplate.
  2. I just wanted to create, update and delete all-day events with an event title. There is no need for a full blown API that allows all options such as support for recurring events, multiple participants or reminders.

Prior to accessing a Google API an application needs authorization from users to access their data which is handled by OAuth 2. As the application already uses Spring for various aspects, Spring Security OAuth 2 was the obvious choice for me as it integrates nicely into the rest of the application.

However, I faced two challenges: First, I needed to integrate Wicket and Spring Security OAuth 2 and second, Spring Security OAuth 2 needed some tweaks to work together with my setup.

Apart from Spring Security OAuth 2 I use the following libraries/frameworks:

  • Wicket as web framework
  • Spring as dependency injection container
  • Jackson for storing tokens and serializing and deserializing messages to the Google Calendar API
  • Gradle as build tool
  • Jetty as container

I have created a simple demo application which allows you to create an all-day event on any of your calendars. It is available on Github.

OAuth 2 in less than 100 words

There is plenty of information available on the web about OAuth 2, so I just want to describe briefly what OAuth 2 does:

Using OAuth 2 users can grant your application access to their data available through third-party services without providing you the credentials of the accounts you want to access. It involves three parties: Your users, your application (OAuth 2 client) and the authorization provider (OAuth 2 server). Apart from being users of your application they are also known to the authorization provider. When users access your application (step 1), it requests permission to e.g. access their data from a third party service (step 2). The authorization provider redirects to a page where users can grant your application access (step 3).

OAuth2 authorization scenario

OAuth2 authorization scenario (Icons courtesy of Adam Whitcroft)

Two great resources for in-depth information about OAuth 2 are Google’s OAuth 2 documentation and RFC 6749.

OAuth 2 for Web applications

Google’s OAuth 2 documentation describes different scenarios which can be used. I assume that you want to use the Webserver scenario, and specifically that you want to use “online access” (more on that later). In the Webserver scenario the user is redirected to Google’s authorization server before the first call of the application to the Google Calendar API. After the user has granted access, the authorization server redirects the user to your application along with an access token and the application may access the Google Calendar API with this access token.

Getting started

Before we dive into the code, we have to register the application with the OAuth provider. For Google, this is done via the API console. The process is pretty straightforward:

1. Create a new project and provide a descriptive name:

Create a new Google API project

2. Create a new client id. The client id identifies your application against the OAuth provider. Be sure to provide a custom redirect URL in the second screen.

Create a new client id for a Google API project (part 1)

Create a new client id for a Google API project (part 2)

3. After you have clicked “Create” the following overview is presented:

Google API project has been created

The most relevant pieces for your application are the client id and the client secret. As I have already described, the client id uniquely identifies your application with the OAuth 2 provider. The client secret authenticates your application with the OAuth 2 provider. If you want to think of traditional username/password based authentication, the client id loosely corresponds to the username of your application and the client secret corresponds to the password of your application with the OAuth 2 provider. Apparently, these two properties should not be shared.

Copy the values for “client id” into the property “google.calendar.client.id” and “client secret” into the property “google.calendar.client.secret” in the file application.properties if you follow along with the demo application.

4. Next, request access to the Google Calendar API for your application in the “Services” menu

Activate access to Google calendar API

Google Calendar Access using Spring OAuth 2

All accesses of the demo application to the Google Calendar API are encapsulated in the class GoogleCalendarRepositoryImpl. It uses an extension of the Spring standard interface RestOperations called OAuth2RestOperations which can handle OAuth 2 authorization in addition. Similar to the standard implementation of RestOperations, RestTemplate, Spring Security OAuth 2 provides a OAuth2RestTemplate. In the demo application, the OAuth2RestTemplate is configured in com/github/gcaldemo/calendar/spring-context.xml. First, let’s have a look at the „oauth:resource“ element there:

<oauth:resource id="google"
                type="authorization_code"
                client-id="${google.calendar.client.id}"
                client-secret="${google.calendar.client.secret}"
                access-token-uri="https://accounts.google.com/o/oauth2/token"
                user-authorization-uri="https://accounts.google.com/o/oauth2/auth"
                scope="https://www.googleapis.com/auth/calendar"
                client-authentication-scheme="form"
/>

This definition describes the resources we want to access. Apart from the client id and the client secret we have already discussed it also contains the URL to which users will be redirected when the application needs their approval to access their data (user-authorization-uri). The “scope” attribute defines the privileges the application wants to acquire. In this case we want read and write access to calendar data as indicated by the URL. The proper URL can be found in the Google Calendar API documentation.

Whenever the application tries to call the Google Calendar API, Spring OAuth 2 will check if the application has a valid access token. This access token is issued by the OAuth 2 provider and provided to the application after the user has granted access to its data. However, the access token is only valid for a limited time period which varies across OAuth providers. Google’s access tokens are currently valid for one hour. After that time period the access token is invalid and users have to grant the application access to their data again.

Fortunately, there are multiple means to prevent nagging users continuously:

  • The OAuth 2 RFC specifies a so called refresh token. The refresh token is sent by the OAuth 2 provider along with the first access token. It can be used to obtain a new access token upon expiration of the old one without user intervention. The validity period of the refresh token may be limited and depends on the OAuth provider. Google’s refresh token is valid until the application explicitly prompts the user explicitly for authorization. Note that Google sends the refresh token only in the „offline“ scenario. Offline means basically that an application can act on behalf of users without the user needing to be present (think batch processes). For more information on the refresh token please refer to the section on refresh tokens in Google’s OAuth 2 documentation.
  • In the online scenario (user is present when your application accesses the Google Calendar API), Google does not send a refresh token but rather stores a cookie on the client’s browser. This cookie will be used instead of a refresh token to prevent repeated explicit approval from the user. As I have hinted earlier, this scenario applies to the demo application.

The demo application provides a simple JSON based token store which is sufficient for a single user. It is implemented in JsonClientTokenServices, which performs the following tasks:

  • It stores the access token in a JSON file
  • It adjusts the expiry_in value: The expiry that is sent from the OAuth 2 provider is denoted in seconds from the point in time when the OAuth 2 provider has issued the access token. So, if the access token is valid for one hour, the initial value will be 3600 (60 seconds per minute * 60 minutes per hour). However, this is obviously not suitable for persistent storage. Therefore, the token store will adjust the expiry accordingly when loading an access token.

The token store is configured along with the access token provider:

<bean id="accessTokenProviderChain" class="org.springframework.security.oauth2.client.token.AccessTokenProviderChain">
<!-- Redefinition of the default access token providers  -->
  <constructor-arg index="0">
    <list>
      <bean class="org.springframework.security.oauth2.client.token.grant.code.AuthorizationCodeAccessTokenProvider"/>
      <bean class="org.springframework.security.oauth2.client.token.grant.implicit.ImplicitAccessTokenProvider"/>
      <bean class="org.springframework.security.oauth2.client.token.grant.password.ResourceOwnerPasswordAccessTokenProvider"/>
      <bean class="org.springframework.security.oauth2.client.token.grant.client.ClientCredentialsAccessTokenProvider"/>
    </list>
  </constructor-arg>
  <property name="clientTokenServices">
    <bean class="com.github.gcaldemo.calendar.repository.impl.token.JsonClientTokenServices"/>
  </property>
</bean>

After defining both the resource to access and the access token provider, the OAuth2RestTemplate can be configured:

<oauth:rest-template id="googleCalendarRestTemplate"
                     resource="google"
                     access-token-provider="accessTokenProviderChain"/>

Integrating Spring OAuth 2 into Wicket

If the application has a valid access token, the OAuth2RestTemplate performs the API call, otherwise a UserRedirectRequiredException is thrown. Typically, the OAuth2ClientContextFilter, which is part of the Spring security chain, should catch this exception and redirect the user to the user-authorization-uri specified earlier. However, by default Wicket catches all exceptions that occur in the Web application and just dumps them in development mode or provides an error page in production mode. Hence, the UserRedirectRequiredException will never reach the Spring security filter chain. Therefore, we have to tweak exception handling by implementing a custom IExceptionMapper:

public class OAuth2ExceptionMapper implements IExceptionMapper {
  private final IExceptionMapper delegateExceptionMapper;

  public OAuth2ExceptionMapper(IExceptionMapper delegateExceptionMapper) {
    this.delegateExceptionMapper = delegateExceptionMapper;
  }

  @Override
  public IRequestHandler map(Exception e) {
    Throwable rootCause = getRootCause(e);
    if (rootCause instanceof UserRedirectRequiredException) {
      //see DefaultExceptionMapper
      Response response = RequestCycle.get().getResponse();
      if (response instanceof WebResponse) {
        // we don't want to cache an exceptional reply in the browser
        ((WebResponse)response).disableCaching();
      }
      throw ((UserRedirectRequiredException) rootCause);
    } else {
      return delegateExceptionMapper.map(e);
    }
  }

  private Throwable getRootCause(Throwable ex) {
    if (ex == null) {
      return null;
    }
    if (ex.getCause() == null) {
      return ex;
    }
    return getRootCause(ex.getCause());
  }
}

The custom exception mapper has to be created in the application by the exception mapper provider:

public class CalendarDemoApplication extends WebApplication {
  private IProvider<IExceptionMapper> exceptionMapperProvider;

  @Override
  protected void init() {
    super.init();
    this.exceptionMapperProvider = new OAuth2ExceptionMapperProvider();
    //details left out - see original class on Github
  }

  @Override
  public IProvider<IExceptionMapper> getExceptionMapperProvider() {
    return exceptionMapperProvider;
  }

  /**
   * Custom Exception Mapper provider that integrates the OAuth2ExceptionMapper into the application.
   */
  private static class OAuth2ExceptionMapperProvider implements IProvider<IExceptionMapper> {
    @Override
    public IExceptionMapper get() {
      return new OAuth2ExceptionMapper(new DefaultExceptionMapper());
    }
  }
}

Now UserRedirectRequiredException will not be handled by Wicket but propagated further up the call stack which allows the OAuth2ClientContextFilter to handle the exception properly. We are almost done but one last piece is still missing.

System-internal Authentication

As I have written in the introduction, I do not want to authenticate against my own application as I am the only user. However, Spring Security OAuth 2 expects user credentials within the application prior to authenticating the application against an OAuth 2 server. If we try to perform a Google Calendar API call as anonymous user we get the following trace:

org.springframework.security.authentication.InsufficientAuthenticationException: Authentication is required to obtain an access token (anonymous not allowed)
at org.springframework.security.oauth2.client.token.AccessTokenProviderChain.obtainAccessToken(AccessTokenProviderChain.java:88)
at org.springframework.security.oauth2.client.OAuth2RestTemplate.acquireAccessToken(OAuth2RestTemplate.java:217)
at org.springframework.security.oauth2.client.OAuth2RestTemplate.getAccessToken(OAuth2RestTemplate.java:169)
at org.springframework.security.oauth2.client.OAuth2RestTemplate.createRequest(OAuth2RestTemplate.java:90)
at org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:479)
at org.springframework.security.oauth2.client.OAuth2RestTemplate.doExecute(OAuth2RestTemplate.java:124)
at org.springframework.web.client.RestTemplate.execute(RestTemplate.java:446)
at org.springframework.web.client.RestTemplate.getForObject(RestTemplate.java:214)
at com.github.gcaldemo.calendar.repository.impl.GoogleCalendarRepositoryImpl.loadCalendars(GoogleCalendarRepositoryImpl.java:45)
[...]

As the exception message tells, anonymous users are not allowed to perform OAuth 2 operations. Therefore, we have to grant access to the application only to authenticated users. This is done by configuring an interceptor in the http security configuration:

<security:intercept-url pattern="/**" access="ROLE_USER" />

Next, we have to trick Spring Security OAuth 2 by implementing a custom authentication processing filter which publishes a system user with proper privileges to the SecurityContext:

//some details omitted - see original class on Github
public class SystemAuthenticationProcessingFilter extends AbstractAuthenticationProcessingFilter {
  @Override
  public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
    // Populate an internal system user in the security context with proper access privileges. These is typically not
    // necessary for multi user systems in production as users typically have to authenticate against your
    // application before using it.
    Authentication authentication = new TestingAuthenticationToken("internal_system_user", "internal_null_credentials", "ROLE_USER");
    authentication.setAuthenticated(true);
    return getAuthenticationManager().authenticate(authentication);
  }

  public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
    throws IOException, ServletException {

    if (SecurityContextHolder.getContext().getAuthentication() == null) {
      SecurityContextHolder.getContext().setAuthentication(attemptAuthentication((HttpServletRequest) req, (HttpServletResponse) res));
    }
    chain.doFilter(req, res);
  }
}

Now, Spring Security OAuth 2 can perform authorization requests properly and we are redirected before accessing the Google Calendar API for the first time:

Demo application requests access permission from Google

After the user has granted the application access to its data we can create an event. Note that the calendar drop down is already pre-filled with all Google calendars the user has at least write access to:

Creating a new Google calendar event

New Google calendar event has been created

Summary

Although a client library for the Google Calendar API is available it is sometimes feasible to use libraries and technologies that are already used in a project. With a few tweaks I was able to use the Google Calendar API in a Wicket application using Spring Security OAuth 2. The example application on Github demonstrates the integration but beware of the limitation that was mentioned above: This setup is primarily suited for single-user applications. If you want to reuse the sample code in a production environment you should use a ClientTokenServices implementation backed by a database and use a real implementation of AbstractAuthenticationProcessingFilter such as UsernamePasswordAuthenticationFilter.

I hope that this post described in enough detail how to integrate the Google Calendar API into a Wicket application. Otherwise, feel free to ask questions in the comments section.

Ubercharts live charts with Wicket 6 and Websockets

Today we want you to show how you can provide live tracking charts for Web-based dashboards. This small showcase Application is based on Wicket 6, Wicket Websockets and Ubercharts.

Just imagine you are running a high-frequency Webshop and you released new Features. And you want to see the feedback what have this features bring to you as soon as possible. Normally you have to wait up to 24h to get the the reports generated through your Business Intelligence Tools. To get reports immediately you have to provide dashboards which updates themselves automatically and which are available from everywhere. This requirements we solved with Wicket a Java Webframework for building Websites and Ubercharts. Ubercharts is meant to be a tiny wrapper for apache wicket framework around the highcharts javascript library. The communication between the Wicket and Live Ubercharts will be provided by Wicket Websockets. In this post we show only the frontend part, not the backend part with saving tracking events.

Run the demo:

  1. Clone GitHub Repo: git clone git@github.com:comsysto/Ubercharts.git
  2. Go to the Ubercharts folder : cd Ubercharts
  3. git checkout wicketWebSockets
  4. gradle idea / gradle eclipse
  5. In IntelliJ IDEA or Eclipse open com.comsysto.runner.Start
  6. Start the main class
Uebercharts Bar Diagram

After running Ubercharts sample Application with websockets it’s should look like this

What should you see at the end :

Relevant Java Classes Diagram:

Live Charts Diagram

Java Class Diagram for Uberchart Websockets example application

The Webapplication starts with a DemoPage.java which only creates the DownloadChartModel.java and transfer it to the DownloadChartPanel.java class which handles the Websockets communication between server and the browser.

DownloadChartModel.java creates the charts with the Uberchart library, for initialize and show the chart is the initChart() method enough. As you can see we initialize the Series with emtyArray, because the data will bee update through Websockets in the DownloadChartPanel.java class.

private Highchart initChart() {

        Number[] emptyArray = {};
        ISeries<Number[]> rock = new NumberSeries(
                   MusikGenre.ROCK.getName()).setData(emptyArray);
        ISeries<Number[]> urban = new NumberSeries(
                   MusikGenre.URBAN.getName()).setData(emptyArray).setVisible(false);
        ......

        Highchart highchart = new Highchart(new BarChart(),
                           rock, pop, urban, electronic, bluesJazz);
        return highchart;
    }

For the Websocket communication we have to set events for the charts. The update events we add through highchart.getChart().getEvents().setLoad(getScript()); method the javascript file DownloadsChartPanel.js. This javascript file subscribes for Websocket messages and updates the chart:

   Wicket.Event.subscribe("/websocket/open", function(jqEvent) {
        // show the initial state of the chart
    });

    Wicket.Event.subscribe("/websocket/message", function(jqEvent, message) {
        // new record is pushed by the server
        var record = jQuery.parseJSON(message);
        if (record) {
            if(record.type == '${messageType}'){
                columnChartUpdate(record.data, record.dataName);
            }
            if(record.type == '${messageSeriesType}'){
                columnChartCategoriesUpdate(record.data, record.dataName);
            }
        }

    });

Websockets handling is initialized in the DownloadChartPanel.java inner class ChartUpdatingBehavior. This inner class creates an UpdateTask.java which handles the WebSocketsConnectionRegistry and the connection handling in a separate thread:

@Override
public void run() {
  IWebSocketConnectionRegistry webSocketConnectionRegistry =
                              new SimpleWebSocketConnectionRegistry();
     while (true) {
       Application application = Application.get(applicationName);
       IWebSocketConnection connection = webSocketConnectionRegistry.
                           getConnection(application, sessionId, pageId);
         if (connection == null || !connection.isOpen()) {
           // stop if the web socket connection is closed
             return;
         }

         try {
           updateFunction(connection);
           // sleep for a while to simulate work
           TimeUnit.SECONDS.sleep(1);
         } catch (Exception x) {
           x.printStackTrace();
           return;
        }
     }
  }

In DownloadChartPanel we have to implement  UpdateTask abstract method updateFunction() in order to update update Chart with new data. UpdateTask sends every second a message with the new values for the chart in the updateFunction() method. The Data is here generated random in the method getDownloads(), there should be a DB or Service call instead for real data. This Websocket Message will bee handled from chart events javascript functions which are loaded in DownloadChartModel.java.

private class ChartUpdatingBehavior extends WebSocketBehavior {
  @Override
  protected void onConnect(ConnectedMessage message) {
     super.onConnect(message);
     // create an asynchronous task that will write the data to the client
     UpdateTask updateTask = new UpdateTask(message.getApplication(),
                message.getSessionId(), message.getPageId()) {
        @Override
        protected void updateFunction(IWebSocketConnection connection)
                                                     throws IOException {
            ObjectMapper objectMapper = new ObjectMapper();
            Message<Number[] > message = new Message<Number[]>(
              MessageType.SERIES_UPDATE,selectedGenreType.name(), getDownloads());
            String json = objectMapper.writeValueAsString(message);
            connection.sendMessage(json);
        }
    };
 Executors.newScheduledThreadPool(1).schedule(updateTask, 1, TimeUnit.SECONDS);
}

So now we have one way communication from the Server to the Client. Now we want to change the Music genre in the webfronted with a click on the Chart Legend. For that we add in the ChartModel the ChartCategorySwitch.js which is called on chart click function. The Script sends Wicket.Websocket message to the Server with the name of the selected genre.

function productSwitchUpdate (event) {
    var selected = this.index;
    var allSeries = this.chart.series;
    $.each(allSeries, function(index, series) {
      if(selected == index ){
        series.show() ;
        var message='{"type":"'+'${messageType}'+'","dataName":"'+series.name+'"}'
        Wicket.WebSocket.send(message);
      }else{
        series.hide();
      }
   });
   return false;
}

The Server hadles the click-message in the DownloadsChartPanel.java getWebSocketBehaviorForClicks() method. This method Listens for Websockets Messages and when a GENRE_UPDATE Message comes in selectedGenreType will be set. When a Websocket message will be pushed back to the chart with the new categories names.

private Behavior getWebSocketBehaviorForClicks() {
  return new WebSocketBehavior() {
    @Override
    protected void onMessage(WebSocketRequestHandler handler,TextMessage message){
      try{
        ObjectMapper objectMapper = new ObjectMapper();
        Message parsedMsg = objectMapper.readValue(message.getText(),
                     new TypeReference() {});
        if (parsedMsg.getType() == MessageType.GENRE_UPDATE){
           selectedGenreType = MusikGenre.valueOf(
                         parsedMsg.getDataName().toUpperCase());
           Message<String[]> msg = new Message<String[]>(
                         MessageType.CATEGORIES_UPDATE,selectedGenreType.name(),
                         musikGenreMap.get(selectedGenreType));
           String json = objectMapper.writeValueAsString(msg);
           handler.push(json);
        }
    } catch (IOException e) {
       e.printStackTrace();
    }
  }
};
}

While developing  we have some problems to get it running in Jetty and Tomcat with the same war file. For fixing it you have to add dependencies in the build.gradle file:

  "org.apache.wicket:wicket-native-websocket-jetty:0.6",
  "org.apache.wicket:wicket-native-websocket-tomcat:0.6",

and also add a filter in web.xml file:

   //ENABLE WHEN JETTY
   <filter-class>org.apache.wicket.protocol.ws.jetty.Jetty7WebSocketFilter</filter-class>
   //ENABLE WHEN TOMCAT
   <filter-class>org.apache.wicket.protocol.ws.tomcat7.Tomcat7WebSocketFilter</filter-class>

Next steps are migrating it to jetty 9 and add some Wrapper for Ubercharts to easier add Updating tasks.

If you have any feedback, please write to luka.leovac@comsysto.com or alen.tolj@comsysto.com

comSysto becomes a Hippo Partner

Since February 2012 comSysto is proud to be one of the direct partners of Hippo, delivering context aware CMS solutions and empowering their audience to engage with content.

Hippo is a Dutch company located in Amsterdam providing CMS based solutions for over 10 years. Their main product Hippo CMS is the first web content management solution to deliver context-aware content for its customers. According to CMS Match - a wiki portal for content management systems – Hippo CMS is one of the most feature-rich CMS solutions including context awareness, multi lingual, multi channel, multi site, SEO, advanced search, reporting and an intuitive interface. For details take a look at Hippo’s key capabilities.

From a technical perspective it is based on open source technologies like the Spring Framework, Apache Wicket, Apache Jackrabbit and open standards like the Content Repository for Java Technology API (JCR, specified in JSR 170 and JSR 283). It provides seamless integration in any web framework like Apache Wicket and Spring MVC and provides its own Hippo Site Toolkit (HST) for building CMS based web applications. The CMS itself is extensible through a plugin-architecture and managed content is accessible in various ways like via the Hippo Repository or REST services. For an overview about the technology behind Hippo take a look at Hippo’s technology overview.

Do you want to know more? Then try the online demo and get in touch with us – we are looking forward to build and deliver the content web solution you need!

hippo-comSysto

Apache Wicket – Best practices

Apache Wicket erfreut sich immer steigender Popularität und findet mehr und mehr Einsatz in Projekten. Dank der Mächtigkeit von Wicket lassen sich viele Features einfach und schnell realisieren. Für die Umsetzung dieser Features gibt es viele Wege. Dieser Artikel bietet einige Kochrezepte zum richtigen, effizienten und nachhaltigen Umgang mit Apache Wicket.

Dieser Artikel richtet sich an Entwickler, die bereits erste Erfahrungen mit Apache Wicket gesammelt haben. Entwickler, die in Wicket-Welt einsteigen tun sich oftmals schwer, weil sie Entwicklungsmethoden aus der JSF- oder Struts-Welt adaptieren. Diese Frameworks setzen vorrangig auf prozedurale Programmierung. Wicket hingegen setzt massiv auf Objektorientierung. Also vergessen Sie die Struts und JSF-Patterns, sonst werden Sie nicht lange Freude an Wicket haben.

Continue reading

Apache Wicket Training von comSysto und JWeekend

Am 22. und 23. Mai 2010 organisiert die comSysto GmbH in Zusammenarbeit mit JWeekend und Wicket London User Group das erste Apache Wicket Training im deutschsprachigen Raum.

Die Vermittlung der Inhalte wird an praktischen Beispielen vollzogen, so dass nach 2 Tagen eine beeindruckende, von den Teilnehmern geminsam entwicklete webbasierte Anwendung entstehen wird.

In der Halbzeitpause des Trainings am Samstag Abend widmen wir uns noch einem sehr wichtigen Thema – Fußball! Im Champions League Finale zwischen Bayern München und Inter Mailand werden wir selbstverständlich die Bayern auf ihrem Weg zum diesjährigen “Triple” lautstark unterstützen.

Mehr Informationen über die Ziele und Ablauf des Trainings finden Sie in unserem Flyer unter:

comSysto Apache Wicket Training

Bei Interesse bitte eine kurze Email an kontakt[at]comsysto.com schreiben, es sind nur noch wenige Plätze frei.