Adding Custom System Roles in Open Event Server

In this blog, we will talk about how to add different custom system roles concerning a user on Open Event Server. The focus is on its model and Schema updation.

Model Updation

For the User Table, we’ll update our User Model as follows:

Now, let’s try to understand these hybrid properties.

In this feature, we are providing Admin the rights to see whether a user is acting as a Marketer and / or  Sales Admin of any of the event or not. Here, _is__system_role method is used to check whether an user plays a system role like Marketer, Sales Admin or not. This is done by querying the record from UserSystemRole model. If the record is present then the returned value is True otherwise false.

Schema Updation

For the User Model, we’ll update our Schema as follows:

Now, let’s try to understand this Schema.

Since all the properties will return either True or false so these all properties are set to Boolean in Schema.Here dump_only means, we will return this property in the Schema.

So, we saw how User Model and Schema is updated to show custom system roles concerning a user on Open Event Server.

Resources

Continue Reading

Onsite Attendee in Open Event Server

The Open Event Server enables organizers to manage events from concerts to conferences and meetups. It offers features for events with several tracks and venues. The Event organizers may add orders on behalf of others and accept payments onsite. This blog post goes over the implementation of the onsite attendee feature in the Open Event Server.

Route

Normally we expect the payload for a POST request of order to contain already created attendees also. In this case we want to create the attendees internally inside the server. Hence we need some way to differentiate between the two types of orders. The most basic and easy to implement option is to use a query parameter to specify if the attendees are onsite or not. We use ?onsite=true in order to specify that the attendees are onsite and hence should be created internally.

In the POST request, we check if the query parameters contains the onsite param as true or not. If it is true then we create the attendees using a helper function. The helper function will be discussed in detail later in the article.

# Create on site attendees.
if request.args.get('onsite', False):
    create_onsite_attendees_for_order(data)
elif data.get('on_site_tickets'):
    del data['on_site_tickets']
require_relationship(['ticket_holders'], data)

 

OnsiteTicketSchema

In order to create attendees on the server, we need the information about each ticket bought and it’s quantity. This data is expected in the format declared in the OnsiteTicketSchema.

class OnSiteTicketSchema(SoftDeletionSchema):
    class Meta:
        type_ = 'on-site-ticket'
        inflect = dasherize

    id = fields.Str(load_only=True, required=True)
    quantity = fields.Str(load_only=True, required=True)

Creating onsite Attendees

Following are the few points which we need to focus on when creating onsite attendees:

  1. Validate if the ticket’s data is provided or not. We raise an error if the ticket data is not provided.
  2. Verify if the ticket is sold out or not. We raise an error if the ticket is sold out.
  3. In case an error is raised in any step then we delete the already created attendees. This is a very important point to keep in mind.

if not on_site_tickets:
        raise UnprocessableEntity({'pointer': 'data/attributes/on_site_tickets'}, 'on_site_tickets info missing')

ticket_sold_count = get_count(db.session.query(TicketHolder.id).
                                      filter_by(ticket_id=int(ticket.id), deleted_at=None))

        # Check if the ticket is already sold out or not.
        if ticket_sold_count + quantity > ticket.quantity:
            # delete the already created attendees.
            for holder in data['ticket_holders']:
                ticket_holder = db.session.query(TicketHolder).filter(id == int(holder)).one()
                db.session.delete(ticket_holder)
                try:
                    db.session.commit()
                except Exception as e:
                    logging.error('DB Exception! %s' % e)
                    db.session.rollback()

            raise ConflictException(
                {'pointer': '/data/attributes/on_site_tickets'},
                "Ticket with id: {} already sold out. You can buy at most {} tickets".format(ticket_id,
                                                                                             ticket.quantity -
                                                                                             ticket_sold_count)
            )

The complete method can be checked here.

References

 

 

Continue Reading

Adding Panel to Add Event Types in Admin Dashboard of Open Event Frontend

This blog will illustrate how to add a new section to admin dashboard of Open Event Frontend which allows admin to add event types. For this we need modals to display a form by which we can edit or add a new event type and we need to create a new route admin/content/events. To create a new route we use ember CLI command:

ember g route admin/content/events

The primary end point of Open Event API with which we are concerned with for creating a new event type or topic is:

GET/POST/DELETE        /v1/event-types

The model concerned with event types is:

 name : attr('string'),
 slug : attr('string'),

 events: hasMany('event')

 

This model is very basic and contains only name and slug and a relationship to event model. Next we want to fetch the existing event types and display them in table. We write queries which fetches data in event-type model in the route file admin/content/events.js.

import Route from '@ember/routing/route';
export default Route.extend({
 titleToken() {
   return this.get('l10n').t('Social Links');
 },
 async model() {
   return {
     'eventTopics': await this.get('store').query('event-topic', {}),
     'eventTypes': await this.get('store').query('event-type', {})
   };
 }
});

 

This will fetch the data in our model. Next we need to display this data in a template for which we define a table that will display each event type.

<button class="ui blue button {{if device.isMobile 'fluid'}}" {{action 'openNewEventTypeModal'}}>{{t 'Add New Event Type'}}</button> 

      <table class="ui celled table">
         <tbody>
           {{#each model.eventTypes as |eventType|}}
             <tr>
               <td>
                 {{eventType.name}}
               </td>
             </tr>
           {{/each}}
         </tbody>
       </table>

 

We have two buttons that are used to edit or delete a event type. Both buttons open up a modal to achieve this functionality. We also have a “Add new Event Type” button at the top. This buttons opens up a modal and sends out a action to its controller when user successfully fills up the name of the event type. Let us take a look at the code of our controller that saves/deletes our event type to server.

addEventType() {
     this.set('isLoading', true);
     this.get('eventType').save()
       .then(() => {
        // Success message
       })
       .catch(()=> {
        //failure message
       })
       .finally(() => {
         this.set('isLoading', false);
       });
   }

 

deleteEventType(eventType) {
     this.set('isLoading', true);
     eventType.destroyRecord()
       .then(() => {
        // Success
       })
       .catch(()=> {
        //failure
       })
       .finally(() => {
         this.set('isLoading', false);
       });
   }

 

In addNewEventType() function we take the data from the form and save the model, which eventually sends POST request to save the new Event Type on server. This returns a JavaScript promise and we handle it via then and catch. It goes to then block if promise resolves and goes to catch is promise rejects/fails.

Similarly in delete function we take the eventType which is passed as model of event-type object and call destroyRecord() function which eventually sends out a DELETE request to server and data gets deleted. Here also we handle the response via resolve and reject depicted with then and catch respectively.

Resources

Continue Reading

Implementing Accepting and Rejecting Proposals in Open Event Frontend

This blog post will illustrate how to add buttons to accept and reject proposal and making them functional. Sessions tab in event dashboard communicates with the following APIs of Open Event Server.

  • GET                    /v1/sessions
  • PATCH              /v1/sessions
What is meant by accepting or rejecting a session in open event?

Sessions are part of event which include one or many speakers. Speakers can propose one or many sessions for a event. Now it is duty of organizer to accept some proposals and reject others. Open event provides two options to accept or reject a proposal i.e. with email or without email.

For this we need to send a key value pair which includes whether we want to send email or not along with other parameters which include current state of session and other important properties. A typical request to alter state of session looks like this.

{
  "data": {
    "attributes": {
      "title": "Micropython Session",
      "level": 1,
      "starts-at": "2017-06-01T10:00:00.500127+00:00",
      "ends-at": "2017-06-01T11:00:00.500127+00:00",
      "created-at": "2017-05-01T01:24:47.500127+00:00",
      "is-mail-sent": false,
      "send-email": true,
    },
    "type": "session",
    "id": "1"
  }
}
Implementing in frontend

We start by providing two buttons for a pending session. One to accept the session and other to reject the session.

On clicking either accept or reject button we get two options to choose i.e. with email and without email. Depending on what organizer chooses a action is fired from the template and sent to controller. Template code for these buttons looks something like this.

class=“ui vertical compact basic buttons”> {{#unless (eq record.state ‘accepted’)}} {{#ui-dropdown class=‘ui icon bottom right pointing dropdown button’}} class=“green checkmark icon”>

class=“menu”>

class=“item” {{action acceptProposal record true}}>{{t ‘With email’}}

 


class=“item” {{action acceptProposal record false}}>{{t ‘Without email’}}

 

      </div>
    {{/ui-dropdown}}
  {{/unless}}
  {{#unless (eq record.state 'rejected')}}
    {{#ui-dropdown class='ui icon bottom right pointing dropdown button'}}
      <i class="red remove icon"></i>

class=“menu”>

class=“item” {{action rejectProposal record true}}>{{t ‘With email’}}

 


class=“item” {{action rejectProposal record false}}>{{t ‘Without email’}}

 

      </div>
    {{/ui-dropdown}}
  {{/unless}}
</div>

We can see that for with email button we trigger accept proposal button with two parameters record and true. Record contains the instance of session and true signifies that we are sending email. Similar is the case with without email button. Controller for these actions looks something like this.

acceptProposal(session, sendEmail) {
      session.set('sendEmail', sendEmail);
      session.set('state', 'accepted');
      session.set('isMailSent', sendEmail);
      this.set('isLoading', true);
      session.save()
        .then(() => {
          sendEmail ? this.notify.success(this.get('l10n').t('Session has been accepted and speaker has been notified via email.'))
            : this.notify.success(this.get('l10n').t('Session has been accepted'));
        })
        .catch(() => {
          this.notify.error(this.get('l10n').t('An unexpected error has occurred.'));
        })
        .finally(() => {
          this.set('isLoading', false);
        });
    },
    rejectProposal(session, sendEmail) {
      session.set('sendEmail', sendEmail);
      session.set('state', 'rejected');
      session.set('isMailSent', sendEmail);
      this.set('isLoading', true);
      session.save()
        .then(() => {
          sendEmail ? this.notify.success(this.get('l10n').t('Session has been rejected and speaker has been notified via email.'))
            : this.notify.success(this.get('l10n').t('Session has been rejected'));
        })
        .catch(() => {
          this.notify.error(this.get('l10n').t('An unexpected error has occurred.'));
        })
        .finally(() => {
          this.set('isLoading', false);
        });
    }

For accepting with email we set sendEmail field to true and send the query to server. Similarly for reject proposal action we follow same procedure.

Conclusion

Implementing buttons like these, and defining proper actions like these we are able to change the state of session with options to send email or not.

Resources

Continue Reading

Adding an Edit Route for Access Codes in Open Event Frontend

This blog post will illustrate how to add edit route to access code to allow organizers editing an access code. Editing access codes deals with the following API on Open Event Server.

PATCH          /v1/access-codes/{id}

First of all we need to add a route to allow editing of access codes. Our route will be /events/{event_id}/tickets/access-codes/edit/{access_code_id}. To generate a new route we need to run the command

ember g route events/view/tickets/access-codes/edit

This will generate new routes files and templates. Also this will add this route in router.js. In router.js we need to specify what we are passing as a parameter to route. For this we specify access_code_id to let ember know that this parameter will be passed with the URL. This is done so as to know which access code to update. Our route should look something like this /events/{event_id}/tickets/access-codes/edit/{access_code_id}. Final router.js file for access-codes part is given below:

//  app/router.js
 
this.route('access-codes', function() {
   this.route('list', { path: '/:access_status' });
   this.route('create');
   this.route('edit', { path: '/edit/:access_code_id' });
});

 

Next we need to pass model (data) to our edit route template. In our model we will be looking for a particular access code with an id and event tickets. After we get our event tickets, we then look for the event tickets which are already present in that access code. This is done so as to check the tickets in the template which are already present in that access code. So for this in afterModel hook we loop over all event tickets and whichever ticket is included in the access code tickets array we mark its isChecked property as true. This helps us to mark those tickets as checked in multiple select checkboxes of template.

// app/routes/events/view/tickets/access-codes/edit.js

model(params) {
   return RSVP.hash({
     accessCode : this.store.findRecord('access-code', params.access_code_id, {}),
     tickets    : this.modelFor('events.view').query('tickets', {})
   });
 },

 async afterModel(model) {
   let tickets = await model.accessCode.tickets;
   let allTickets = model.tickets;
   allTickets.forEach(ticket => {
     if (tickets.includes(ticket)) {
       ticket.set('isChecked', true);
     } else {
       ticket.set('isChecked', false);
     }
   });

 

The information about multiple select checkboxes in frontend has been discussed in this blog post. Now after we are done setting our edit route we need to redirect user to edit page when he/she clicks on the edit button in access code list. For this we define the necessary actions in template which will be triggered when user clicks on the icon. Code for the edit icon in access code list is given below.

// templates/components/ui-table/cell/events/view/tickets/access-codes/cell-actions.hbs

class="ui vertical compact basic buttons"> {{#ui-popup content=(t 'Edit') click=(action editAccessCode record.id) class='ui icon button' position='left center'}} class="edit icon"> {{/ui-popup}}

 

The editAccessCode action looks something like this.

// controller/events/view/tickets/access-codes/edit.js

editAccessCode(id) {
     this.transitionToRoute('events.view.tickets.access-codes.edit', id);
}

 

After clicking on the edit icon user is redirected to edit route where he/she can edit access code choosen. We use the same component that we chose for creating access code. To know more about create access code template component refer to this blog. Finally when saving the edited access code we call save action. This action is defined in the controllers. The action looks something like this.

// controllers/events/view/tickets/access-codes/edit.js

export default Controller.extend({
 actions: {
   save(accessCode) {
     accessCode.save()
       .then(() => {
         this.get('notify').success(this.get('l10n').t('Access code has been successfully updated.'));
         this.transitionToRoute('events.view.tickets.access-codes');
       })
       .catch(() => {
         this.get('notify').error(this.get('l10n').t('An unexpected error has occured. Discount code cannot be created.'));
       });
   }
 }
});

 

After the access code is saved. We redirect user back to access code list.

Resources

Continue Reading

Modifying Tickets API in Open Event Server to Return Hidden Tickets Only for Organizers and Admins

This blog article will illustrate how we can modify the permissions settings for an API to enable different kind of responses to users with different level of permissions. In this article we will discuss these changes with respect to Tickets API.

Initially we had a query where we were returning only those tickets who were set to be visible by the admin. Query for this was:

class TicketList(ResourceList):
   """
   List Tickets based on different params
   """
   def before_get(self, args, view_kwargs):
       """
       before get method to get the resource id for assigning schema
       :param args:
       :param view_kwargs:
       :return:
       """
       if view_kwargs.get('ticket_tag_id') or view_kwargs.get('access_code_id') or         view_kwargs.get('order_identifier'):
           self.schema = TicketSchemaPublic

   def query(self, view_kwargs):
       """
       query method for resource list
       :param view_kwargs:
       :return:
       """


       query_ = self.session.query(Ticket).filter_by(is_hidden=False)

 

Problem with this query was that this returned same response irrespective of who is logged in. Hence even the organizers were not able to modify hidden tickets because they were not returned by server.

Solution to this problem was to provide hidden tickets only to those who are organizer or are admin/super admins. For this we used the JWT token that was being sent from frontend in request headers for each authenticated request that was being made from frontend.

We modified the code to something like this:

class TicketList(ResourceList):
   """
   List Tickets based on different params
   """
   def before_get(self, args, view_kwargs):
       """
       before get method to get the resource id for assigning schema
       :param args:
       :param view_kwargs:
       :return:
       """
       if view_kwargs.get('ticket_tag_id') or view_kwargs.get('access_code_id') or view_kwargs.get('order_identifier'):
           self.schema = TicketSchemaPublic

   def query(self, view_kwargs):
       """
       query method for resource list
       :param view_kwargs:
       :return:
       """

       if 'Authorization' in request.headers:
           _jwt_required(current_app.config['JWT_DEFAULT_REALM'])
           if current_user.is_super_admin or current_user.is_admin:
               query_ = self.session.query(Ticket)
           elif view_kwargs.get('event_id') and has_access('is_organizer', event_id=view_kwargs['event_id']):
               query_ = self.session.query(Ticket)
           else:
               query_ = self.session.query(Ticket).filter_by(is_hidden=False)
       else:
           query_ = self.session.query(Ticket).filter_by(is_hidden=False)

 

Here we added some conditions which were used to check permission level of logged in user. After picking JWT token from request headers we check if the user was admin or super_admin, then we return all the tickets without any condition. Then we also check if the logged in user was organizer of event then also we send all the tickets without any conditions.

However if request comes from unauthenticated users (without valid token) or users with normal privileges, then we returned tickets whose isHidden property was set to False. The functions such as is_organizer and is_super_admin acted as helpers as they were imported from other helper files where they were defined.

Resources

Continue Reading

How We Implement Custom Forms for Sessions and Speaker Form in Open Event Frontend

In this blog we will see the implementation of custom form for session and speakers form. Since every event organiser requires different fields required to be filled by speakers for their sessions, for ex. Some event organiser may need GitHub profile whereas other may need LinkedIn profile so, we implemented custom form for session and speaker form in Open Event Frontend so that every organiser can choose fields he/she requires to be filled by his speakers who are filling their proposal. Custom form allows following features:

  1. Allows organiser to choose whether he wants a particular field or not.
  2. Organiser can make a field compulsory, so that no speaker can submit his proposal without filling that field.

So, to get started we define our getCustomForm() method in mixins and it looks something like this:

import Mixin from '@ember/object/mixin';
import MutableArray from '@ember/array/mutable';

export default Mixin.create(MutableArray, {

 getCustomForm(parent) {
   return [
     this.store.createRecord('custom-form', {
       fieldIdentifier : 'title',
       form            : 'session',
       type            : 'text',
       isRequired      : true,
       isIncluded      : true,
       isFixed         : true,
       event           : parent
     }),
     this.store.createRecord('custom-form', {
       fieldIdentifier : 'subtitle',
       form            : 'session',
       type            : 'text',
       isRequired      : false,
       isIncluded      : false,
       event           : parent
     }),
     this.store.createRecord('custom-form', {
       fieldIdentifier : 'shortAbstract',
       form            : 'session',
       type            : 'text',
       isRequired      : false,
       isIncluded      : true,
       event           : parent
     }),

...

 

Here we define all the possible fields with their properties. Every field has different properties that enables us to identify whether to choose or reject them. So to enable this these fields to be chosen by organiser we need to feed them into model.

In our session-speakers-step.js in components we assign these fields to our model thorugh this:

didInsertElement() {
   if (this.get('data.event.customForms')&&!his.get('data.event.customForms.length')) {
     this.set('data.event.customForms', this.getCustomForm(this.get('data.event')));
   }
}

 

This hook gets called when our component is rendered and custom form fields gets assigned to data.event.customForms in our model.

In our handlebars template we create sliders to enable organiser to choose whether he wants to enable a field or not.

In our template logic we write a loop that renders these sliders for each fields and manipulate them as organiser slides any slider for a field. Let’s take a look at the template code block:

       <tbody>
           {{#each customForm.session 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>

 

Every slider has a action attached to it that manipulates a property of the custom for and on proceeding it saves the custom form model to server.

Later on when rendering for for speaker we fetch details from server about which fields are required or not.

Resources

Continue Reading

How RSVP Handles Promises in Open Event Frontend

This blog post illustrates how to manage multiple promises simultaneously using a library known as RSVP in Open Event frontend.

What are Promises?

Promises are used to manage synchronous calls in javascript. Promises represent a value/object that may not be available yet but will become available in near future. To quote from MDN web docs:

The Promise object represents the eventual completion (or failure) of an asynchronous operation, and its resulting value.

What about RSVP?

Rsvp is a lightweight library used to organize asynchronous code. Rsvp provides several ways to handle promises and their responses. A very simple promise implementation using rsvp looks something like this.

var RSVP = require('rsvp');

var promise = new RSVP.Promise(function(resolve, reject) {
  // succeed
  resolve(value);
  // or reject
  reject(error);
});

promise.then(function(value) {
  // success
}).catch(function(error) {
  // failure
});

 

It’s simple, right? So, what it is doing is after it defines a promise it assumes two possible states of a promise which are resolve or reject and after promise has completed it executes the respective function.

Use in Open Event Frontend?

Almost all calls to open event server APIs are done asynchronously. One of the most significant use of rsvp comes when handling multiple promises in frontend and we want all of them to be evaluated at together. Unlike normal promises where each promises resolved or rejected individually, rsvp provides a promise.all() method which accepts array of promises and evaluates all at once. It then calls resolve or reject based on the status of all promises. A typical example where we use promise.all() is given here.

import Controller from '@ember/controller';
import RSVP from 'rsvp';
import EventWizardMixin from 'open-event-frontend/mixins/event-wizard';

export default Controller.extend(EventWizardMixin, {
 actions: {
   save() {
     this.set('isLoading', true);
     this.get('model.data.event').save()
       .then(data => {
         let promises = [];
         promises.push(this.get('model.data.event.tickets').toArray().map(ticket => ticket.save()));
         promises.push(this.get('model.data.event.socialLinks').toArray().map(link => link.save()));
         if (this.get('model.data.event.copyright.licence')) {
           let copyright = this.setRelationship(this.get('model.data.event.copyright.content'), data);
           promises.push(copyright.save());
         }
         if (this.get('model.data.event.tax.name')) {
           let tax = this.setRelationship(this.get('model.data.event.tax.content'), data);
           if (this.get('model.event.isTaxEnabled')) {
             promises.push(tax.save());
           } else {
             promises.push(tax.destroyRecord());
           }
         }
         RSVP.Promise.all(promises)
           .then(() => {
             this.set('isLoading', false);
             this.get('notify').success(this.get('l10n').t('Your event has been saved'));
             this.transitionToRoute('events.view.index', data.id);
           }, function() {
             this.get('notify').error(this.get('l10n').t('Oops something went wrong. Please try again'));
           });
       })
       .catch(() => {
         this.set('isLoading', false);
         this.get('notify').error(this.get('l10n').t('Oops something went wrong. Please try again'));
       });
   },
}

 

Here we made array of promises and then pushed each of the promises to it. Then at the end used this array of promises in RSVP.promise.all() which then evaluated it based on success or failure of the promises.

Resources:
Continue Reading

Loading Default System Image of Event Topic on Open Event Server

In this blog, we will talk about how to add feature of loading system image of event topic from server to display it on Open Event Server. The focus is on adding a helper function to create system image and loading that local image onto server.

Helper function

In this feature, we are providing feature of addition of loading default system image if user doesn’t provides that.

  1. First we get a suitable filename for a image file using get_file_name() function.
  2. After getting filename, we check if the url provided by user is a valid url or not.
  3. If the url is invalid then we use the default system image as the image of that particular event topic.
  4. After getting the local image then we read that image, if the given image file or the default image is not readable or gives IOError then we send a message to the user that Image url is invalid.
  5. After successful reading of image we upload the image to event_topic directory in static directory of the project.
  6. After uploading of this image we get a local URL which shows where is the image is stored. This path is stored into database and finally we can display this image.

Resources

Continue Reading

Adding Custom System Roles API on Open Event Server

In this blog, we will talk about how to add API for accessing the Custom System Roles on Open Event Server. The focus is on Schema creation and it’s API creation.

Schema Creation

For the CustomSystemRoleSchema, we’ll make our Schema as follows

Now, let’s try to understand this Schema.

In this feature, we are providing Admin the rights to get and create more system roles.

  1. First of all, we are provide the two fields in this Schema, which are id and name.
  2. The very first attribute id should be of type string as it would have the identity which will auto increment when a new system role is created. Here dump_only means that this value can’t be changed after the record is created.
  3. Next attribute name should be of string type and it will contain the name of new custom system role. This attribute is required in a custom_system_roles table.

API Creation

For the Custom System Roles, we’ll make our API as follows

Now, let’s try to understand this Schema.

In this API, we are providing Admin the rights to set Custom System roles.

  1. CustomSystemRoleList inherits ResourceList which will give us list of all the custom system roles in the whole system.
  2. CustomSystemRoleList has a decorators attribute which gives the permission of POST request to only admins of the system.
  3. CustomSystemRoleDetail inherits ResourceDetail which will give the details of a CustomSystemRole object by id.
  4. CustomSystemRoleDetail has a decorators attribute which gives the permission of PATCH and DELETE requests to only admins of the system.

So, we saw how Custom System Role Schema and API is created to allow users to get it’s values and Admin users to update and delete it’s record.

Resources

Continue Reading
Close Menu
%d bloggers like this: