Implementation of scanning in F-Droid build variant of Open Event Organizer Android App

Open Event Organizer App (Eventyay Organizer App) is the Android app used by event organizers to create and manage events on the Eventyay platform.

Various features include:

  1. Event creation.
  2. Ticket management.
  3. Attendee list with ticket details.
  4. Scanning of participants etc.

The Play Store build variant of the app uses Google Vision API for scanning attendees. This cannot be used in the F-Droid build variant since F-Droid requires all the libraries used in the project to be open source. Thus, we’ll be using this library: https://github.com/blikoon/QRCodeScanner 

We’ll start by creating separate ScanQRActivity, ScanQRView and activity_scan_qr.xml files for the F-Droid variant. We’ll be using a common ViewModel for the F-Droid and Play Store build variants.

Let’s start with requesting the user for camera permission so that the mobile camera can be used for scanning QR codes.

public void onCameraLoaded() {
    if (hasCameraPermission()) {
        startScan();
    } else {
        requestCameraPermission();
    }
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
    if (requestCode != PERM_REQ_CODE)
            return;

    // If request is cancelled, the result arrays are empty.
    if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
        cameraPermissionGranted(true);
    } else {
        cameraPermissionGranted(false);
    }
}



@Override
public boolean hasCameraPermission() {
    return ContextCompat.checkSelfPermission(this, permission.CAMERA) == PackageManager.PERMISSION_GRANTED;
}

@Override
public void requestCameraPermission() {
    ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.CAMERA}, PERM_REQ_CODE);
}


@Override
public void showPermissionError(String error) {
    Toast.makeText(this, error, Toast.LENGTH_SHORT).show();
}

public void cameraPermissionGranted(boolean granted) {
    if (granted) {
        startScan();
    } else {
        showProgress(false);
        showPermissionError("User denied permission");
    }
}

After the camera permission is granted, or if the camera permission is already granted, then the startScan() method would be called.

@Override
public void startScan() {
    Intent i = new Intent(ScanQRActivity.this, QrCodeActivity.class);
    startActivityForResult(i, REQUEST_CODE_QR_SCAN);
}

QrCodeActivity belongs to the library that we are using.

Now, the processing of barcode would be started after it is scanned. The processBarcode() method in ScanQRViewModel would be called.

public void onActivityResult(int requestCode, int resultCode, Intent intent) {

    if (requestCode == REQUEST_CODE_QR_SCAN) {
        if (intent == null)
            return;

        scanQRViewModel.processBarcode(intent.getStringExtra
            ("com.blikoon.qrcodescanner.got_qr_scan_relult"));

    } else {
        super.onActivityResult(requestCode, resultCode, intent);
    }
}

Let’s move on to the processBarcode() method, which is the same as the Play Store variant.

public void processBarcode(String barcode) {

    Observable.fromIterable(attendees)
        .filter(attendee -> attendee.getOrder() != null)
        .filter(attendee -> (attendee.getOrder().getIdentifier() + "-" + attendee.getId()).equals(barcode))
        .compose(schedule())
        .toList()
        .subscribe(attendees -> {
            if (attendees.size() == 0) {
                message.setValue(R.string.invalid_ticket);
                tint.setValue(false);
            } else {
                checkAttendee(attendees.get(0));
            }
        });
}

The checkAttendee() method:

private void checkAttendee(Attendee attendee) {
    onScannedAttendeeLiveData.setValue(attendee);

    if (toValidate) {
        message.setValue(R.string.ticket_is_valid);
        tint.setValue(true);
        return;
    }

    boolean needsToggle = !(toCheckIn && attendee.isCheckedIn ||
        toCheckOut && !attendee.isCheckedIn);

    attendee.setChecking(true);
    showBarcodePanelLiveData.setValue(true);

    if (toCheckIn) {
        message.setValue(
            attendee.isCheckedIn ? R.string.already_checked_in : R.string.now_checked_in);
        tint.setValue(true);
        attendee.isCheckedIn = true;
    } else if (toCheckOut) {
        message.setValue(
            attendee.isCheckedIn ? R.string.now_checked_out : R.string.already_checked_out);
        tint.setValue(true);
        attendee.isCheckedIn = false;
    }

    if (needsToggle)
        compositeDisposable.add(
            attendeeRepository.scheduleToggle(attendee)
                .subscribe(() -> {
                    // Nothing to do
                }, Logger::logError));
}

This would toggle the check-in state of the attendee.

Resources:

Library used: QRCodeScanner

Pull Request: feat: Implement scanning in F-Droid build variant

Open Event Organizer App: Project repo, Play Store, F-Droid

Continue ReadingImplementation of scanning in F-Droid build variant of Open Event Organizer Android App

Adding Helper and Adding Action Buttons to Orders List in Open Event Frontend

This blog post will illustrate how to add a helper to orders list and add action buttons to orders list to delete and cancel an order in Open Event Frontend. To cancel or delete an order item we need to communicate to the server. The API endpoints to which we communicate are:

  • PATCH        /v1/orders/{order_identifier}
  • DELETE    /v1/orders/{orders_identifier}

We will define the action buttons in ui-table component of open event frontend. We will use the cell-actions file to define the cell buttons that will be present in cell-actions column. The following handlebars code will render the buttons on website.

//components/ui-table/cell/events/view/tickets/orders/cell-actions.hbs

class="ui vertical compact basic buttons"> {{#if (and (not-eq record.status 'cancelled') (can-modify-order record))}} {{#ui-popup content=(t 'Cancel order') click=(action (confirm (t 'Are you sure you would like to cancel this Order?') (action cancelOrder record))) class='ui icon button' position='left center'}} class="delete icon"> {{/ui-popup}} {{/if}} {{#if (can-modify-order record)}} {{#ui-popup content=(t 'Delete order') click=(action (confirm (t 'Are you sure you would like to delete this Order?') (action deleteOrder record))) class='ui icon button' position='left center'}} class="trash icon"> {{/ui-popup}} {{/if}} {{#ui-popup content=(t 'Resend order confirmation') class='ui icon button' position='left center'}} class="mail outline icon"> {{/ui-popup}}

 

In above code you can see two things. First is can-modify-order which is a helper. Helper is used to simplify conditional logics which cannot be easily placed in handlebars. Second thing is action. There are two actions defined: cancelOrder and deleteOrder. We will see implementation of these later. First let’s see how we define can-modify-order helper.

In can-modify-order helper we want to return true or false in case we want cancel button and delete button to display or not respectively. We write the code of can-modify-order in helpers/can-modify-order.js file. When we want to get result from this helper we call it from handlebars file and pass any parameter that we want to use in helper. Code for can-modify-order helper is given below.

// helpers/can-modify-order.js

import Helper from '@ember/component/helper';

export function canModifyOrder(params) {
 let [order] = params;
 if (order.amount !== null && order.amount > 0) {
   // returns false if order is paid and completed
   return order.status !== 'completed';
 }
 // returns true for free ticket
 return true;
}

export default Helper.helper(canModifyOrder);

 

We extract the parameter and store it in order variable. We see if it satisfies our conditions we return true else false.

Now lets see how we can define actions to perform delete and cancel action on a order. We define these actions in controllers section of app. After performing suitable operation with order we call save to update modified order and destroyRecord() to delete an order. Let see the code implementation for these actions.

actions: {
   deleteOrder(order) {
     this.set('isLoading', true);
     order.destroyRecord()
       .then(() => {
         this.get('model').reload();
         this.notify.success(this.get('l10n').t('Order has been deleted successfully.'));
       })
       .catch(() => {
         this.notify.error(this.get('l10n').t('An unexpected error has occurred.'));
       })
       .finally(() => {
         this.set('isLoading', false);
       });
   },
   cancelOrder(order) {
     this.set('isLoading', true);
     order.set('status', 'cancelled');
     order.save()
       .then(() => {
         this.notify.success(this.get('l10n').t('Order has been cancelled successfully.'));
       })
       .catch(() => {
         this.notify.error(this.get('l10n').t('An unexpected error has occurred.'));
       })
       .finally(() => {
         this.set('isLoading', false);
       });
   }

 
After defining these actions, buttons in the orders list start working. In this way, we can make use of helper to simplify the conditional logic inside templates and define proper actions.

Resources:
Continue ReadingAdding Helper and Adding Action Buttons to Orders List in Open Event Frontend

Integrating Orders API to Allow User Select Tickets for Placing Order in Open Event Frontend

In Open Event Frontend organizer has option to sell tickets for his event. Tickets which are available to public are listed in public page of event for users to buy. In this blog post we will learn how to integrate orders API and manage multiple tickets of varying quantity under an order.

For orders we mainly interact with three API endpoints.

  1. Orders API endpoint
  2. Attendees API endpoint
  3. Tickets API endpoint

Orders and attendees have one to many relationship and similarly orders and tickets also have one to many relationship. Every attendee is related to one ticket. In simple words one attendee has one ticket and one order can contain many tickets of different quantity each meant for different attendee.

// routes/public/index.js

order: this.store.createRecord('order', {
  event     : eventDetails,
  user      : this.get('authManager.currentUser'),
  tickets   : [],
  attendees : []
})

 

We need to create instance of order model to fill in data in that record. We do this in our routes folder of public route. Code snippet is given below.

We create a empty array for tickets and attendees so that we can add their record instances as relationship to order.

As given in screenshot we have a dropdown for each ticket to select quantity of each ticket. To allow user select quantity of a ticket we use #ui-dropdown component of ember-semantic-ui in our template. The code snippet of that is given below.

// templates/components/public/ticket-list.js

{{#ui-dropdown class='compact selection' forceSelection=false onChange=(action 'updateOrder' ticket) as |execute mapper|}}
  {{input type='hidden' id=(concat ticket.id '_quantity') value=ticket.orderQuantity}}
    <i class="dropdown icon"></i>
    
class="default text">0
class="menu">
class="item" data-value="{{map-value mapper 0}}">{{0}}
{{#each (range ticket.minOrder ticket.maxOrder) as |count|}}
class="item" data-value="{{map-value mapper count}}">{{count}}
{{/each}} </div> {{/ui-dropdown}}

 

For every change in quantity of ticket selected we call a action named updateOrder. The code snippet for this action is given below.

// components/public/ticket-list.js

updateOrder(ticket, count) {
      let order = this.get('order');
      ticket.set('orderQuantity', count);
      order.set('amount', this.get('total'));
      if (count > 0) {
        order.tickets.addObject(ticket);
      } else {
        if (order.tickets.includes(ticket)) {
          order.tickets.removeObject(ticket);
        }
      }
    },

 

Here we can see that if quantity of selected ticket is more than 0 we add that ticket to our order otherwise we remove that ticket from the order if it already exists.

Once user selects his tickets for the order he/she can order the tickets. On clicking Order button we call placeOrder action. With help of this we add all the relations and finally send the order information to the server. Code snippet is given below.

// components/public/ticket-list.js
    
placeOrder() {
   let order = this.get('order');
   let event = order.get('event');
   order.tickets.forEach(ticket => {
     let attendee = ticket.orderQuantity;
     let i;
     for (i = 0; i < attendee; i++) {
       order.attendees.addObject(this.store.createRecord('attendee', {
         firstname : 'John',
         lastname  : 'Doe',
         email     : 'johndoe@example.com',
         event,
         ticket
       }));
     }
   });
   this.sendAction('save');
 }

 

Here for each ticket placed under an order we create a dummy attendee related to ticket and event. And then we call save action to save all the models. Code snippet for save is given:

actions: {
    async save() {
      try {
        this.set('isLoading', true);
        let order = this.get('model.order');
        let attendees = order.get('attendees');
        await order.save();
        attendees.forEach(async attendee => {
          await attendee.save();
        });
        await order.save()
          .then(order => {
            this.get('notify').success(this.get('l10n').t('Order created.'));
            this.transitionToRoute('orders.new', order.id);
          });
      } catch (e) {
        this.get('notify').error(this.get('l10n').t('Oops something went wrong. Please try again'));
      }
    }
  }

 

Here we finally save all the models and transition to orders page to enable user fill the attendees details.

Resources:
Continue ReadingIntegrating Orders API to Allow User Select Tickets for Placing Order in Open Event Frontend

Attendee Form Builder in Open Event Frontend

The Open-Event-Frontend allows the event organiser to create tickets for his or her event. Other uses can buy these tickets in order to attend the event. When buying a ticket we ask for certain information from the buyer. Ideally the event organizer should get to choose what information they want to ask from the buyer. This blog post goes over the implementation of the attendee form builder in the Open Event Frontend.

Information to Collect

The event organizer can choose what details to ask from the order buyer. In order to specify the choices, we present a table with the entries of allowed fields that the organizer can ask for. Moreover there is an option to mark the field as required and hence making it compulsory for the order buyer to add that information in order to buy the tickets.

Route

The route is mainly responsible for fetching the required custom forms.

async model() {
    let filterOptions = [{
      name : 'form',
      op   : 'eq',
      val  : 'attendee'
    }];

    let data = {
      event: this.modelFor('events.view')
    };
    data.customForms = await data.event.query('customForms', {
      filter       : filterOptions,
      sort         : 'id',
      'page[size]' : 50
    });

    return data;
  }

If they don’t exist then we create them in afterModel hook. We check if the size of the list of custom forms sent from the server is 3 or not. If it is 3 then we create the additional custom forms for the builder. Upon creating an event, the server automatically creates 3 custom forms for the builder. These 3 forms are firstName, lastName and email.

afterModel(data) {
    /**
     * Create the additional custom forms if only the compulsory forms exist.
     */
    if (data.customForms.length === 3) {
      let customForms = A();
      for (const customForm of data.customForms ? data.customForms.toArray() : []) {
        customForms.pushObject(customForm);
      }

      const createdCustomForms = this.getCustomAttendeeForm(data.event);

      for (const customForm of createdCustomForms ? createdCustomForms : []) {
        customForms.pushObject(customForm);
      }

      data.customForms = customForms;
    }
  }

Complete source code for reference can be found here.

Component Template

The component template for the form builder is supposed to show the forms and other options to the user in a presentable manner. Due to pre-existing components for handling custom forms, the template is extremely simple. We just loop over the list of custom forms and present the event organizer with a table comprising of the forms. Apart from the forms the organizer can specify the order expiry time. Lastly we present a save button in order to save the changes.

<form class="ui form {{if isLoading 'loading'}}"  {{action 'submit' data on='submit'}} autocomplete="off">
  <h3 class="ui dividing header">
    <i class="checkmark box icon"></i>
    <div class="content">
      {{t 'Information to Collect'}}
    </div>
  </h3>
  <div class="ui two column stackable grid">
    <div class="column">
      <table class="ui selectable celled table">
        <thead>
          <tr>
            {{#if device.isMobile}}
              <th class="center aligned">
                {{t 'Options'}}
              </th>
            {{else}}
              <th class="right aligned">
                {{t 'Option'}}
              </th>
              <th class="center aligned">
                {{t 'Include'}}
              </th>
              <th class="center aligned">
                {{t 'Require'}}
              </th>
            {{/if}}
          </tr>
        </thead>
        <tbody>
          {{#each data.customForms as |field|}}
            <tr class="{{if field.isIncluded 'positive'}}">
              <td class="{{if device.isMobile 'center' 'right'}} aligned">
                <label class="{{if field.isFixed 'required'}}">
                  {{field.name}}
                </label>
              </td>
              <td class="center aligned">
                {{ui-checkbox class='slider'
                              checked=field.isIncluded
                              disabled=field.isFixed
                              onChange=(action (mut field.isIncluded))
                              label=(if device.isMobile (t 'Include'))}}
              </td>
              <td class="center aligned">
                {{ui-checkbox class='slider'
                              checked=field.isRequired
                              disabled=field.isFixed
                              onChange=(action (mut field.isRequired))
                              label=(if device.isMobile (t 'Require'))}}
              </td>
            </tr>
          {{/each}}
        </tbody>
      </table>
    </div>
  </div>
  <h3 class="ui dividing header">
    <i class="options box icon"></i>
    <div class="content">
      {{t 'Registration Options'}}
    </div>
  </h3>
  <div class="field">
    <label>{{t 'REGISTRATION TIME LIMIT'}}</label>
    <div class="{{unless device.isMobile 'two wide'}} field">
      {{input type='number' id='orderExpiryTime' value=data.event.orderExpiryTime min="1" max="60" step="1"}}
    </div>
  </div>
  <div class="ui hidden divider"></div>
  <button type="submit" class="ui teal submit button" name="submit">{{t 'Save'}}</button>
</form>

 

Component

The component is responsible for saving the form. It also provides runtime validations to ensure that the entries entered in the fields of the form are valid.

import Component from '@ember/component';
import FormMixin from 'open-event-frontend/mixins/form';

export default Component.extend(FormMixin, {
  getValidationRules() {
    return {
      inline : true,
      delay  : false,
      on     : 'blur',
      fields : {
        orderExpiryTime: {
          identifier : 'orderExpiryTime',
          rules      : [
            {
              type   : 'integer[1..60]',
              prompt : this.get('l10n').t('Please enter a valid registration time limit between 1 to 60 minutes.')
            }
          ]
        }
      }
    };
  },
  actions: {
    submit(data) {
      this.onValid(() => {
        this.save(data);
      });
    }
  }
});

References

 

Continue ReadingAttendee Form Builder in Open Event Frontend

Attendee details in the Open Event Android App

To be able to create an order we first need to create an attendee with whom we can associate an order. Let’s see how in Open Event Android App we are creating an attendee.

We are loading the event details from our local database using the id variable. Since only logged in users can create an attendee, if the user is not logged in then the user is redirected to the login screen. If any errors are encountered while creating an attendee then they are shown in a toast message to the user. When the user clicks on the register button a POST request is sent to the server with the necessary details of the attendee. In the POST request we are passing an attendee object which has the id, first name, last name and email of the attendee. The ticket id and event id is also sent.

attendeeFragmentViewModel.loadEvent(id)

if (!attendeeFragmentViewModel.isLoggedIn()) {
redirectToLogin()
Toast.makeText(context, "You need to log in first!", Toast.LENGTH_LONG).show()
}

 

attendeeFragmentViewModel.message.observe(this, Observer {
Toast.makeText(context, it, Toast.LENGTH_LONG).show()
})

attendeeFragmentViewModel.progress.observe(this, Observer {
it?.let { Utils.showProgressBar(rootView.progressBarAttendee, it) }
})

 

attendeeFragmentViewModel.event.observe(this, Observer {
it?.let { loadEventDetails(it) }
})

rootView.register.setOnClickListener {
val attendee = Attendee(id = attendeeFragmentViewModel.getId(),
firstname = firstName.text.toString(),
lastname = lastName.text.toString(),
email = email.text.toString(),
ticket = ticketId,
event = eventId)

attendeeFragmentViewModel.createAttendee(attendee)

 

We are using a method called loadEvent in the above code which is defined in the View Model let’s have a look. We are throwing an IllegalStateException if the id is equal to -1 because this should never happen. Then we are fetching the event from the database in a background thread. If we face any errors while fetching the event we report it to the user

 

fun loadEvent(id: Long) {
if (id.equals(-1)) {
throw IllegalStateException("ID should never be -1")
}
compositeDisposable.add(eventService.getEvent(id)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
event.value = it
}, {
Timber.e(it, "Error fetching event %d", id)
message.value = "Error fetching event"
}))
}

 

This method is used to create an attendee. We are checking if the user has filled all the fields if any of the fields is empty a toast message is shown. Then we send a POST request to the server in a background thread. The progress bar starts loading as soon as the request is made and then finally when the attendee has been created successfully, the progress bar stops loading and a success message is shown to the user. If we face any errors while creating an attendee, an error message is shown to the user.

fun createAttendee(attendee: Attendee) {
if (attendee.email.isNullOrEmpty() || attendee.firstname.isNullOrEmpty() || attendee.lastname.isNullOrEmpty()) {
message.value = "Please fill all the fields"
return
}

compositeDisposable.add(attendeeService.postAttendee(attendee)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnSubscribe {
progress.value = true
}.doFinally {
progress.value = false
}.subscribe({
message.value = "Attendee created successfully!"
Timber.d("Success!")
}, {
message.value = "Unable to create Attendee!"
Timber.d(it, "Failed")
}))
}

 

This function sends a POST request to the server and stores the attendee details in the local database.

fun postAttendee(attendee: Attendee): Single<Attendee> {
return attendeeApi.postAttendee(attendee)
.map {
attendeeDao.insertAttendee(it)
it
}

 

This is how the attendee details are inserted into the local database. In case of a conflict the attendee object gets replaced.

@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAttendee(attendee: Attendee)

 

Resources

  1. ReactiveX official documentation : http://reactivex.io/
  2. Vogella RxJava 2 – Tutorial : http://www.vogella.com/tutorials/RxJava/article.html
  3. Androidhive RxJava Tutorial : https://www.androidhive.info/RxJava/
Continue ReadingAttendee details in the Open Event Android App

Open Event Server – Export Attendees as CSV File

FOSSASIA‘s Open Event Server is the REST API backend for the event management platform, Open Event. Here, the event organizers can create their events, add tickets for it and manage all aspects from the schedule to the speakers. Also, once he/she makes his event public, others can view it and buy tickets if interested.

The organizer can see all the attendees in a very detailed view in the event management dashboard. He can see the statuses of all the attendees. The possible statuses are completed, placed, pending, expired and canceled, checked in and not checked in. He/she can take actions such as checking in the attendee.

If the organizer wants to download the list of all the attendees as a CSV file, he or she can do it very easily by simply clicking on the Export As and then on CSV.

Let us see how this is done on the server.

Server side – generating the Attendees CSV file

Here we will be using the csv package provided by python for writing the csv file.

import csv
  • We define a method export_attendees_csv which takes the attendees to be exported as a CSV file as the argument.
  • Next, we define the headers of the CSV file. It is the first row of the CSV file.
def export_attendees_csv(attendees):
   headers = ['Order#', 'Order Date', 'Status', 'First Name', 'Last Name', 'Email',
              'Country', 'Payment Type', 'Ticket Name', 'Ticket Price', 'Ticket Type']
  • A list is defined called rows. This contains the rows of the CSV file. As mentioned earlier, headers is the first row.
rows = [headers]
  • We iterate over each attendee in attendees and form a row for that attendee by separating the values of each of the columns by a comma. Here, every row is one attendee.
  • The newly formed row is added to the rows list.
for attendee in attendees:
   column = [str(attendee.order.get_invoice_number()) if attendee.order else '-',
             str(attendee.order.created_at) if attendee.order and attendee.order.created_at else '-',
             str(attendee.order.status) if attendee.order and attendee.order.status else '-',
             str(attendee.firstname) if attendee.firstname else '',
             str(attendee.lastname) if attendee.lastname else '',
             str(attendee.email) if attendee.email else '',
             str(attendee.country) if attendee.country else '',
             str(attendee.order.payment_mode) if attendee.order and attendee.order.payment_mode else '',
             str(attendee.ticket.name) if attendee.ticket and attendee.ticket.name else '',
             str(attendee.ticket.price) if attendee.ticket and attendee.ticket.price else '0',
             str(attendee.ticket.type) if attendee.ticket and attendee.ticket.type else '']

   rows.append(column)
  • rows contains the contents of the CSV file and hence it is returned.
return rows
  • We iterate over each item of rows and write it to the CSV file using the methods provided by the csv package.
writer = csv.writer(temp_file)
from app.api.helpers.csv_jobs_util import export_attendees_csv
content = export_attendees_csv(attendees)
for row in content:
   writer.writerow(row)

Obtaining the Attendees CSV file:

Firstly, we have an API endpoint which starts the task on the server.

GET - /v1/events/{event_identifier}/export/attendees/csv

Here, event_identifier is the unique ID of the event. This endpoint starts a celery task on the server to export the attendees of the event as a CSV file. It returns the URL of the task to get the status of the export task. A sample response is as follows:

{
  "task_url": "/v1/tasks/b7ca7088-876e-4c29-a0ee-b8029a64849a"
}

The user can go to the above-returned URL and check the status of his/her Celery task. If the task completed successfully he/she will get the download URL. The endpoint to check the status of the task is:

and the corresponding response from the server –

{
  "result": {
    "download_url": "/v1/events/1/exports/http://localhost/static/media/exports/1/zip/OGpMM0w2RH/event1.zip"
  },
  "state": "SUCCESS"
}

The file can be downloaded from the above-mentioned URL.

References

Continue ReadingOpen Event Server – Export Attendees as CSV File