Automating Play Store releases in Open Event Android with Fastlane

In Open Event Android we are using fastlane to automate the process of releasing the app to the Play Store, otherwise without this tool we would have to sign the release apk everytime before making a release but with fastlane all we need to do is just merge the development branch with the master branch and the updated app is released in Play Store.

The first thing that we need to do is encrypt the signing keys and upload it to the repository so we will create an encrypted tar file of the signing keys and upload it to the repository.

Now we want to decrypt the tar file everytime we merge our development branch into the master branch. We will create a bash script which does the above task which looks like this.

#!/bin/sh
set -e

export DEPLOY_BRANCH=${DEPLOY_BRANCH:-master}

if [ "$TRAVIS_PULL_REQUEST" != "false" -o "$TRAVIS_REPO_SLUG" != "fossasia/open-event-android" -o "$TRAVIS_BRANCH" != "$DEPLOY_BRANCH" ]; then
 echo "We decrypt key only for pushes to the master branch and not PRs. So, skip."
exit 0
fi

# Decrypt keys
openssl aes-256-cbc -K $encrypted_59a1db41ee4d_key -iv $encrypted_59a1db41ee4d_iv -in ./scripts/secrets.tar.enc -out ./scripts/secrets.tar -d
tar xvf ./scripts/secrets.tar -C scripts/

 

We need to define all these variables written after the $ sign as our Travis environment variables.

Next thing that we need to do is sign the apk so first thing that we check in the update-apk script is if we are on the master branch. We take the unsigned apk and sign it with the following commands.

 cp open-event-master-app-playStore-release-unsigned.apk open-event-master-app-playStore-release-unaligned.apk
jarsigner -verbose -tsa http://timestamp.comodoca.com/rfc3161 -sigalg SHA1withRSA -digestalg SHA1 -keystore ../scripts/key.jks -storepass $STORE_PASS -keypass $KEY_PASS open-event-master-app-playStore-release-unaligned.apk $ALIAS

 

The following command zipaligns the app

 ${ANDROID_HOME}/build-tools/27.0.3/zipalign -v -p 4 open-event-master-app-playStore-release-unaligned.apk open-event-master-app-playStore-release.apk

 

After signing the release apk, the final step is to publish the app to the playstore. The following command will install fastlane.

gem install fastlane

 

This command will take the signed apk and upload it in alpha channel of the playstore.

fastlane supply --apk open-event-master-app-playStore-release.apk --track alpha --json_key ../scripts/fastlane.json --package_name $PACKAGE_NAME

 

That’s it! Now releasing an update to the playstore is as simple as sending a pull request to the master branch. A process that would have otherwise required few minutes has been reduced to just seconds.

Resources

  1. Fastlane Official Site: https://fastlane.tools/
  2. Fastlane Android Documentaion: https://docs.fastlane.tools/getting-started/android/release-deployment/
  3. Fastlane Repository: https://github.com/fastlane/fastlane
Continue ReadingAutomating Play Store releases in Open Event Android with Fastlane

Badges Search Functionality In Admin

Badgeyay is a badge generator service developed by FOSSASIA community for generating badges for conferences and events. Project is separated into frontend that is designed in EmberJS and backend that is created in Flask. Now The Badges tab in admin shows the total badges that are in the system. Along with different meta attributes like Badge creator name, date and other data. The main problem is that it doesn’t provide a way to search the badges from the admin panel. In this blog post i am going to explain about integrating badge search functionality in admin.

Procedure

  1. Integrating form element for searching
<form class=“ui form” {{action ‘findBadges’ on=“submit”}}>
                  

class=“field”>
                      

class=“ui action input”>
                          {{input type=“text” autocomplete=‘off’ value=user placeholder=“Search Badges…”}}
                          
                      


                  </div>
              </form>

 

  1. Main difficulty is to send the data to route components as we can’t pass the props, like we pass in components. The workaround is to send the query as route parameter.
findBadges() {
    var userTerm = this.get(‘user’);
    this.transitionToRoute(‘admin.badges.list’, userTerm);
  }

 

  1. Extracting the query parameter and making request to backend
if (params.badge_status === ‘all’) {
    filter.state = ‘all’;
  } else if (params.badge_status === ‘created’) {
    filter.state = ‘created’;
  } else if (params.badge_status === ‘deleted’) {
    filter.state = ‘deleted’;
  } else {
    filter.filter = params.badge_status;
  }

 

  1. Creating query in controller to fetch the data for the same.
if ‘filter’ in args.keys():
      badges = base_query.filter(User.username.contains(args[‘filter’]))
      badges = badges.paginate(page, app.config[‘POSTS_PER_PAGE’], False)
      schema = AdminBadgeSchema(many=True)
      return jsonify(schema.dump(badges.items).data)

 

Resources

  • Flask Sqlalchemy documentation for query – Link
  • Pull Request for the same – Link
  • Ember Guide – Link
Continue ReadingBadges Search Functionality In Admin

Implement Table Sorting In Badgeyay

In this blog post I am going to explain about implementation of inplace table sorting in badgeyay. This is not about just adding the sortable class as described in the semantic docs, but the data inside the table has different characteristics and needs to be sorted in a different manner. Not like the traditional way of comparing strings as it will not be suitable for dates. For creating a custom comparison function for sorting, either we can implement a custom comparator using JQuery or we can use the data values for comparison. The latter option is more preferable as it can be extended  to different columns in the table.

Procedure

  1. Adding the sortable class in the table, which needs to be sorted.
<table class=“ui sortable table”>

  . . .

</table>

 

  1. We need to enable a javascript function when DOM completely gets loaded.
<script type=“text/javascript”>
 $(‘table’).tablesort();
</script>

 

  1. After this we need to create a template helper to return us the time stamp from the UTC formatted DateTime string. The value that will be returned by the helper will be used as the data value for the column entries.
import { helper } from ‘@ember/component/helper’;

export function extractTimeStamp(date) {
return Math.floor((new Date(date)).getTime() / 100);
}

export default helper(extractTimeStamp);

 

  1. The value that will be returned by the helper will be used as data value for comparison by table sorter.
<td data-sort-value={{extract-time-stamp user.created_at}}>{{sanitizeDate user.created_at}}</td>

 

  1. Now we need that certain columns do not sort, as there is no need. Such columns are photoURL, actions etc. These columns should be ignored by the sorter for sorting, so we will add a class to avoid sorting of these columns.
<th class=“no-sort”>User Photo</th>

Resources

  • Semantic UI table class – Link
  • Data sorting in the table API – Link
  • Pull Request for the same – Link
  • Template helper guide ember – Link
Continue ReadingImplement Table Sorting In Badgeyay

Building Open Event Android for F-Droid

According to the official website F-Droid is an installable catalogue of FOSS (Free and Open Source Software) applications for the Android platform. Only apps that are open source and use only open source libraries are accepted in F-Droid. Let’s see what steps were taken to publish Open Event Android in F-Droid.

We need to build a different app for F-Droid because it cannot use any proprietary  libraries so instead of making a new app we made two flavors of the same app. One that would be published in playstore and the other for F-Droid. It is really easy to make different flavors of the same app. Just add the following lines in the build.gradle

flavorDimensions "default"

productFlavors {
fdroid {
dimension "default"
}

playStore {
dimension "default"
}
}

 

We specify the flavor dimension in the first line because all product flavors must have a flavor dimension and then we specify the name of our product flavors. That’s all that is required to make various flavors of an app after that we just need to remove the proprietary libraries from the F-Droid build.

When we have finished building the app we can start with the process of submitting the app to F-Droid. We need to send a Merge Request to the F-droid repository. Let’s see what all steps are required before we could submit our Merge Request to include our app in F-Droid

We need to clone the fdroid server directly from the source so that we have all the latest changes and set the path.

git clone https://gitlab.com/fdroid/fdroidserver.git
export PATH="$PATH:$PWD/fdroidserver"

 

Then clone your fork of the fdroiddata repository and open the directory. You need to write your own username here.

git clone https://gitlab.com/nikit19/fdroiddata.git
cd fdroiddata

 

The following command will make a template for you in the metadata directory, the name of the file is our application id. If you open the metadata folder you will see text files of all the apps that are published in F-Droid. If you want to publish an app in F-droid it is this file that needs to be sent in merge request. There are no other changes that needs to be done, we just have to fill the fields in the meta file correctly.

cp templates/app-minimal metadata/com.eventyay.attendee.txt

 

After filling all the details in the meta file we need to commit those changes. The following command will check if there are any syntax errors in the meta file.

fdroid readmeta

 

Finally we need to build the app. The following command builds the app from the source repository so if we have followed all the steps correctly, the app would build successfully and then we can send the merge request.

fdroid build -v -l com.eventyay.attendee

 

Resources

  1. Quick Start: https://gitlab.com/fdroid/fdroiddata/blob/master/README.md#quickstart
  2. Making merge requests: https://gitlab.com/fdroid/fdroiddata/blob/master/CONTRIBUTING.md#merge-requests

 

Continue ReadingBuilding Open Event Android for F-Droid

Implement Charges Endpoint in the Open Event Android App

In the Open Event Android App we first create an attendee and then the order when a user tries to buy a ticket but the end user will only know that the transaction is successful when money gets deducted from his account. This is where we use the charges endpoint of the Open Event Server to complete the payment. Let’s see how this is being done in the Open Event Android App.

We are sending a POST request from the app to the following endpoint. The order identifier is added in the url to uniquely identify the order for which we are charging the user. The body of the POST request contains the Charge object with the required information.

@POST("orders/{orderIdentifier}/charge")
fun chargeOrder(@Path("orderIdentifier") orderIdentifier: String, @Body charge: Charge): Single<Charge>

 

This is how the model class Charge looks like. We specify the type at the top. Then we can either send a stripe or paypal token to the server which contains all the details of the payment. Message and status fields are returned from the server after the getting a success response. The status is true if the payment has been successfully made otherwise it is false. The message field gives us the feedback about what kind of error we got if the payment was not successful otherwise we get a success response.

@Type("charge")
data class Charge(
@Id(IntegerIdHandler::class)
val id: Int,
val stripe: String? = null,
val paypal: String? = null,
val message: String? = null,
val status: Boolean? = null
)

 

This is how we send the stripe token to the server. If the card details are correct then we receive the stripe token in the onSuccess method and send it to the server using the Charges endpoint.

override fun onSuccess(token: Token) {
//Send this token to server
val charge = Charge(attendeeFragmentViewModel.getId().toInt(), token.id, null)
attendeeFragmentViewModel.completeOrder(charge)

}

 

This is the function that is being used to send the POST request. It takes a charge object as an argument and then we use that in the chargeOrder method. The orderIdentifier variable that we see in the chargeOrder function is a lateinit variable that gets initialized when an order has been returned from the server. When this request is being made in the background thread we show user the progress bar. When we receive a success response from the server, we update the value of the message variable with the value returned from the server and show it to the user. If the value of the status is true that means that the payment has been successfully completed.

fun completeOrder(charge: Charge) {
compositeDisposable.add(orderService.chargeOrder(orderIdentifier.toString(), charge)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnSubscribe {
progress.value = true
}.doFinally {
progress.value = false
}.subscribe({
message.value = it.message
paymentCompleted.value = it.status

if (it.status != null && it.status) {
Timber.d("Successfully charged for the order!")
} else {
Timber.d("Failed charging the user")
}
}, {
message.value = "Payment not completed!"
Timber.d(it, "Failed charging the user")
}))
}

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 ReadingImplement Charges Endpoint in the Open Event Android App

Displaying name of users in Users tab in Admin Panel

In the Users tab in the Admin Panel, we have a lot of user information displayed in a tabular form. This information is fetched from the accounting objects of each user. As the users are now able to also store their name in their respective accounting object, hence we needed to implement a feature to display the name of the users in the Users table in a separate column. This blog post explains how the user names are fetched from the respective accounting objects and are then displayed in the Users table in the Admin Panel.

How is name of user stored on the server?

The name of any user is stored in the user’s accounting object. All the settings of a user are stored in a JSONObject with the key name as ‘settings’. The name of a user is also stored in ‘settings’ JSONObject. This is shown as follows:

Modifying GetUsers.java to return name of users

The endpoint /aaa/getUsers.json is used to return the accounting info of all users. This includes their signup time, last login time, last login IP, etc. We needed to modify it to return the name of users also along with the already returned data. This is implemented as follows:

   if(accounting.getJSON().has("settings")) {
        JSONObject settings = accounting.getJSON().getJSONObject("settings");
        if(settings.has("userName")) {
            json.put("userName", settings.get("userName"));
        }
        else {
            json.put("userName", "");
        }
    } else {
        json.put("userName", "");
    }
    accounting.commit();

 

Fetching names of all users from the server

We need to make an AJAX call to ‘/aaa/getUsers.json’ as soon as we switch to the Users tab in the Admin Panel. We need to extract all the required data from the JSON response object and put them in state variables so that they can further be used as data indexes for different columns of the table. The implementation of the AJAX call is as follows:

   let url =
      `${urls.API_URL}/aaa/getUsers.json?access_token=` +
      cookies.get('loggedIn') +
      '&page=' +
      page;
    $.ajax({
      url: url,
      dataType: 'jsonp',
      jsonp: 'callback',
      crossDomain: true,
      success: function(response) {
        let userList = response.users;
        let users = [];
        userList.map((data, i) => {
          let user = {
            userName: data.userName,
          };
          users.push(user);
          return 1;
        });
        this.setState({
          data: users,
        });
      }.bind(this)
    });

 

Displaying name of users in Users tab in Admin Panel

We needed to add another column titled ‘User Name’ in the Users table in the Admin Panel. The ‘dataIndex’ attribute of the Ant Design table component specifies the data value which is to be used for that particular column. For our purpose, our data value which needs to be displayed in the ‘User Name’ column is ‘userName’. We also specify a width of the column as another attribute. The implementation is as follows:

   this.columns = [
      // other columns
      {
        title: 'User Name',
        dataIndex: 'userName',
        width: '12%',
      }
      // other columns
    ];

 

This is how the names of users are fetched from their accounting object and are then being displayed in the Users tab in Admin Panel.

Resources

Continue ReadingDisplaying name of users in Users tab in Admin Panel

Ticket Details in the Open Event Android App

It is important to show the user the ticket details before he makes the payment so that he is aware how much does each ticket costs. Let’s see how this is being done in the Open Event Android App.

This function will query the database with a list of ids and return the tickets with the matching ids.

@Query("SELECT * from Ticket WHERE id in (:ids)")
fun getTicketsWithIds(ids: List<Int>): Single<List<Ticket>>

 

To use the above function we first need the ids of the tickets that needs to be shown so we loop through the ticketIdAndQty variable and everytime the quantity is greater than 0 we add the id of the ticket to the array list.

val ticketIds = ArrayList<Int>()
ticketIdAndQty?.forEach {
if (it.second > 0) {
ticketIds.add(it.first)
}
}

 

The next step is to call getTicketsWithIds function in a background thread because it is a database operation and we don’t make calls to the database in the main thread. If the call to the database is successful we store the list of tickets in a variable otherwise an error message is shown to the user.

compositeDisposable.add(ticketService.getTicketsWithIds(ticketIds)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
tickets.value = it
}, {
Timber.e(it, "Error Loading tickets!")
}))

 

To display the values to the user we just have to use the ticket variable and use its various fields but subtotal is not present in the variable so we have to calculate it. We just multiply the price of the ticket  with its quantity. So we pass the adapter position in the qty array to get the quantity of the current ticket and multiply it with the price.

val subTotal: Float? = ticket.price?.toFloat()?.times(qty[adapterPosition])

 

Ticket details are only shown if the user clicks on the textview which says view so we adjust the visibility of the ticket details in this simple if else statements and also update the textview value accordingly.

if (rootView.view.text == "(view)") {
rootView.ticketDetails.visibility = View.VISIBLE
rootView.view.text = "(hide)"
} else {
rootView.ticketDetails.visibility = View.GONE
rootView.view.text = "(view)"
}

 

Resources

  1. Room official documentation :https://developer.android.com/topic/libraries/architecture/room
  2. Vogella RxJava 2 – Tutorial : http://www.vogella.com/tutorials/RxJava/article.html
  3. Androidhive RxJava Tutorial : https://www.androidhive.info/RxJava/
Continue ReadingTicket Details in the Open Event Android App

Forgot Password Service in Badgeyay

Badgeyay is an open source badge generator service for generating badges developed by FOSSASIA community for technical events and conferences. The project is divided into two components mainly frontend and backend. After creating the user registration functionality in application, if the user forgets the credentials for the login, then there must be a way to recreate the credentials using a secure channel. This is only valid for the users signed up through email login as for the case of OAuth they must have access to their ID on respective social platform. The main challenges in resetting password for the user is to provide a secure channel. So the problem can be breakdown into following issues:

  • Creating a token for reset action
  • Sending that token via mail to user
  • Verifying that token on the server and giving access
  • Changing the credentials  of the user

Procedure

  1. Generating token for the request to change credentials for the user. The token will be an expiry token and will be expired in the mentioned duration. So the token is valid for only a limited period of time and will prevent fraudulent requests.
def pwd_reset_token():
  data = request.get_json()[‘data’][‘attributes’]
  if ’email’ not in data.keys():
      print(‘Email not found’)
  email = data[’email’]
  user = User.getUser(email=email)
  if not user:
      return ErrorResponse(UserNotFound().message, 422, {‘Content-Type’: ‘application/json’}).respond()
  expire = datetime.datetime.utcnow() + datetime.timedelta(seconds=900)
  token = jwt.encode({
      ‘id’: user.id,
      ‘exp’: expire
  }, app.config.get(‘SECRET_KEY’))
  resetObj = ResetPasswordToken(user.id, token.decode(‘UTF-8’))
  resetObj.save_to_db()
  return jsonify(TokenSchema().dump(resetObj).data)

Model for ResetPasswordToken

class ResetPasswordToken(db.Model):

  __tablename__ = ‘Reset Password Token’

  id = db.Column(db.String, primary_key=True)
  token = db.Column(db.String, nullable=False)

  def __init__(self, uid, token):
      self.id = uid
      self.token = token

  def save_to_db(self):
      try:
          db.session.add(self)
          db.session.commit()
      except Exception as e:
          db.session.rollback()
          db.session.flush()
          print(e)

 

  1. Sending the password reset link via mail to the user. The link will contain the token (expiry token) that will be used to validate the request. For the case we will be using Firebase Cloud functions as an HTTP Trigger.
exports.sendResetMail = functions.https.onRequest((req, res) => {
let token = req.query[‘token’];
let email = req.query[’email’];
res.setHeader(‘Content-Type’, ‘application/json’);
sendResetMail(token, email)
  .then(() => {
    console.log(‘Reset mail sent to’, email);
    res.json({ data: { attributes: { status: 200 }, id: token, type: ‘reset-mails’ } });
    return 0;
  })
  .catch(err => {
    console.error(err);
    res.json({ data: { attributes: { status: 500 }, id: token, type: ‘reset-mails’ } });
    return -1;
  });
});

function sendResetMail(token, email) {
const mailOptions = {
  from: `${APP_NAME}<noreply@firebase.com>`,
  to: email,
};

mailOptions.subject = `Password reset link`;
mailOptions.html = ‘<p>Hey ‘ + email + ‘! Here is your password reset <a href=\” + PASSWORD_RESET_LINK
  + token + ‘\’>Link</a><p>’;
return mailTransport.sendMail(mailOptions);
}

 

  1. Verifying the token on the server side to validate the user request
def validate_reset_token():
  args = request.args
  if ‘token’ in args.keys():
      token = args.get(‘token’)
  resp = {‘id’: token}
  try:
      jwt.decode(token, app.config[‘SECRET_KEY’])
      resp[‘valid’] = True
      return jsonify(ValidTokenSchema().dump(resp).data)
  except Exception as e:
      resp[‘valid’] = False
      print(e)
      return jsonify(ValidTokenSchema().dump(resp).data)

 

  1. After user has access to change the credentials, then user can send a POST request to backend through a form shown in UI to change its password.
def changePwd():
  try:
      data = request.get_json()[‘data’][‘attributes’]
  except Exception as e:
      print(e)
      return ErrorResponse(PayloadNotFound().message, 422, {‘Content-Type’: ‘application/json’}).respond()

  token = data[‘token’]
  try:
      decoded_res = jwt.decode(token, app.config[‘SECRET_KEY’])
  except Exception as e:
      print(e)
      return ErrorResponse(SignatureExpired().message, 422, {‘Content-Type’: ‘application/json’}).respond()

  user = User.getUser(user_id=decoded_res[‘id’])

  if ‘pwd’ not in data.keys():
      return ErrorResponse(PasswordNotFound().message, 422, {‘Content-Type’: ‘application/json’}).respond()

  pwd = data[‘pwd’]
  oldPwd = user.password
  user.password = generate_password_hash(pwd)
  user.save_to_db()

  resp = {‘id’: token}
  if update_firebase_password(user.id, pwd):
      resp[‘status’] = ‘Changed’
      return jsonify(ResetPasswordOperation().dump(resp).data)
  else:
      print(‘Firebase not uploaded’)
      user.password = oldPwd
      user.save_to_db()
      resp[‘status’] = ‘Not Changed’
      return jsonify(ResetPasswordOperation().dump(resp).data)

 

  1. After this the password of the user will be changed and allowed to login through new credentials.

Link to PRs:

  • PR for forgot password reset form – #1
  • PR for implementing forgot password on firebase side – #2
  • PR for password reset mail functionality – #3

Resources

  • HTTP Trigger Cloud functions – Link
  • Nodemailer message configuration – Link
  • Ember Data Guide – Link

 

Continue ReadingForgot Password Service in Badgeyay

Tickets fragment in the Open Event Android App

Ticketing is one of the most important part of any event management system. In the Open Event Android App you can view all the ticket details of any event. Let’s see how this was accomplished.

This is how our TicketApi class looks. We are sending a GET request to get the list of all tickets associated with an event. Id here is the event id which is used to uniquely identify an event.

interface TicketApi {

@GET("events/{id}/tickets?include=event&fields[event]=id&page[size]=0")
fun getTickets(@Path("id") id: Long): Flowable<List<Ticket>>

}

 

In the ticket details screen we see the event details on top. We are loading these details from the event which is stored in the database, we use the event id to query the database and load these details.

This is how the events are loaded from the database. We are observing the live data variable event and calling the loadEventDetails methods as soon as the value of the variable changes.

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

ticketsViewModel.loadEvent(id)

 

Let’s have a look at the loadEvent function that is there in our ViewModel. It only requires one parameter that is our event id. We first check if the id is correct or not then load the events in a background thread and report the error if there is any.

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)
error.value = "Error fetching event"
}))
}

 

The following function is used to load the event details with the date formatted in the appropriate manner. We first use the function getLocalizedDateTime to get the localized date in string and then format the date according to our needs.

private fun loadEventDetails(event: Event) {
rootView.eventName.text = event.name
rootView.organizerName.text = "by ${event.organizerName.nullToEmpty()}"
val dateString = StringBuilder()
val startsAt = EventUtils.getLocalizedDateTime(event.startsAt)
val endsAt = EventUtils.getLocalizedDateTime(event.endsAt)
rootView.time.text = dateString.append(EventUtils.getFormattedDate(startsAt))
.append(" - ")
.append(EventUtils.getFormattedDate(endsAt))
.append(" • ")
.append(EventUtils.getFormattedTime(startsAt))
}

 

That’s all that is needed to make the tickets screen. This how the fragment looks in the app.

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 ReadingTickets fragment in the Open Event Android App

Implementing System Logs in SUSI.AI Admin Panel

The admin panel of SUSI.AI provides a lot of features required by system maintainers and admins to administer and maintain various activities of SUSI. Therefore system logs have been implemented on the admin panel so that admins can check whether SUSI server is working fine or throwing some errors. In this blog we will discuss about how logs are implemented on server and accounts.

Continue ReadingImplementing System Logs in SUSI.AI Admin Panel